Files
Templated-PPT-Master/scripts/text_block.py
T

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