Release PPT workflow 0.7.3 with fixed spacing, paragraphs and native math
This commit is contained in:
@@ -0,0 +1,134 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user