135 lines
8.0 KiB
Python
135 lines
8.0 KiB
Python
"""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
|