"""Render complete semantic paragraphs with template-owned spacing. Each paragraph produces one SVG text node containing derived tspan lines, so the default native exporter can keep one text frame per paragraph. The caller owns actual-font measurements; content cannot supply per-line or per-paragraph coordinates. Metadata retains the full input and derived layout for inspection. """ from __future__ import annotations import json import math from typing import Callable import xml.etree.ElementTree as ET from component_typography import resolve_role from layout_spacing import check_spacing, resolve_spacing from paragraph_layout import wrap_paragraph import template_ref as ref SVG = "http://www.w3.org/2000/svg" ET.register_namespace("", SVG) def _number(value, label, *, positive=False): if (type(value) not in (int, float) or not math.isfinite(value) or value < 0 or positive and value == 0): raise ValueError(f"{label}: expected {'positive' if positive else 'nonnegative'} finite number") return float(value) def render_text_block(data: dict, style: dict, theme: dict, bounds: tuple, component_id: str, measure: Callable[[str], float]) -> ET.Element: """Render at declared size; reject capacity failures without clipping text. data = {schema_version:1, paragraphs:[complete string,...], text_role?:body, palette_index?:dk1}. ``measure(text)`` returns pixel advance at the resolved role's actual font and size. Fixed text_padding/paragraph roles resolve template spacing, and the typography role supplies line height. A paragraph occupies one line-height slot per line; gap is extra space between slots of successive paragraphs. Bounds and line records describe these layout slots, not exact font ink extents. """ if (not isinstance(data, dict) or type(data.get("schema_version")) is not int or data["schema_version"] != 1): raise ValueError("text block data requires schema_version 1") if set(data) - {"schema_version", "paragraphs", "text_role", "palette_index"}: raise ValueError("text block only accepts complete paragraphs and declared text/palette roles") paragraphs = data.get("paragraphs") if not isinstance(paragraphs, list) or not paragraphs or any(not isinstance(p, str) for p in paragraphs): raise ValueError("paragraphs must be a nonempty list of complete strings") if not callable(measure): raise ValueError("measure must be callable") ref.identifier(component_id, "component ID") if not isinstance(bounds, (list, tuple)) or len(bounds) != 4: raise ValueError("bounds requires [x,y,width,height]") x, y = (_number(value, "bounds position") for value in bounds[:2]) width, height = (_number(value, "bounds size", positive=True) for value in bounds[2:]) canvas = style["canvas"] if x + width > canvas["width"] or y + height > canvas["height"]: raise ValueError("text block bounds outside canvas") cfg = style["components"]["text_group"] padding = resolve_spacing(style, "text_padding") gap = resolve_spacing(style, "paragraph") if not isinstance(padding, tuple) or not isinstance(gap, float): raise ValueError("text_padding must be a padding role and paragraph must be a gap role") top, right, bottom, left = padding if gap <= 0: raise ValueError("paragraph spacing must be positive to distinguish paragraphs") if cfg.get("alignment", "left") != "left": raise ValueError("text block currently supports left alignment only") role_name = data.get("text_role", cfg.get("text_role", "body")) if not isinstance(role_name, str): raise ValueError("text_role requires a declared role name") role = resolve_role(style, role_name) palette_name = data.get("palette_index", "dk1") if not isinstance(palette_name, str) or palette_name not in theme["palette"]: raise ValueError("palette_index requires a declared theme palette entry") inner_width, inner_height = width - left - right, height - top - bottom if inner_width <= 0 or inner_height <= 0: raise ValueError("padding leaves no text capacity") rows = [wrap_paragraph(paragraph, inner_width, measure) for paragraph in paragraphs] used_height = sum(len(lines) * role.line_size for lines in rows) + gap * (len(rows) - 1) if used_height > inner_height: raise ValueError(f"text block requires {used_height:g}px height plus padding, exceeding {inner_height:g}px capacity; enlarge bounds, rephrase or change layout") group = ET.Element(f"{{{SVG}}}g", {"id": component_id, "data-component-kind": "text-block"}) metadata = ET.SubElement(group, f"{{{SVG}}}metadata", {"type": "application/json"}) records, current_y = [], y + top font_style = {"family": role.family, "size_px": role.size, "weight": role.weight, "line_height": role.line_height, "line_step_px": role.line_size, "text_role": role_name, "palette_index": palette_name} for index, (paragraph, lines) in enumerate(zip(paragraphs, rows), 1): block_height = len(lines) * role.line_size # Center nominal font height in each line slot; baseline is shared by # all instances. Actual glyph extents still need renderer verification. baseline = current_y + (role.line_size - role.size) / 2 + role.size * .8 text_id = f"{component_id}-paragraph-{index}" text = ET.SubElement(group, f"{{{SVG}}}text", { "id": text_id, "x": str(x + left), "y": str(baseline), "font-family": role.family, "font-size": str(role.size), "font-weight": role.svg_weight, "fill": theme["palette"][palette_name], "data-logical-block": text_id, "{http://www.w3.org/XML/1998/namespace}space": "preserve"}) line_records = [] for line_index, line in enumerate(lines): ET.SubElement(text, f"{{{SVG}}}tspan", { "x": str(x + left), "dy": "0" if line_index == 0 else str(role.line_size)}).text = line line_records.append({"text": line, "baseline_y": baseline + line_index * role.line_size}) records.append({"id": text_id, "text": paragraph, "lines": line_records, "bounds": [x + left, current_y, inner_width, block_height], "native_text_frame_expected": 1}) current_y += block_height + gap inner_bounds = [x + left, y + top, inner_width, inner_height] inner_id = f"{component_id}-inner" spacing_boxes = {component_id: [x, y, width, height], inner_id: inner_bounds, **{record["id"]: record["bounds"] for record in records}} spacing_checks = [{"id": f"{component_id}-text-padding", "kind": "padding", "outer": component_id, "inner": inner_id, "role": "text_padding"}] spacing_checks.extend({"id": f"{component_id}-paragraph-gap-{index}", "kind": "gap", "a": records[index - 1]["id"], "b": records[index]["id"], "axis": "y", "role": "paragraph"} for index in range(1, len(records))) spacing_issues = check_spacing(spacing_boxes, spacing_checks, style=style) if spacing_issues: raise ValueError(f"derived text block violates fixed spacing: {spacing_issues}") metadata.text = json.dumps({"schema_version": 1, "component_id": component_id, "data": data, "bounds": [x, y, width, height], "content_bounds": [x + left, y + top, inner_width, used_height], "inner_bounds": inner_bounds, "padding_px": [top, right, bottom, left], "paragraph_gap_px": gap, "font_style": font_style, "paragraphs": records, "spacing_contract": {"policy": "fixed", "boxes": spacing_boxes, "checks": spacing_checks, "violations": spacing_issues}}, ensure_ascii=False) return group