Release PPT workflow 0.7.3 with fixed spacing, paragraphs and native math

This commit is contained in:
2026-10-13 17:16:50 +08:00
parent c4efbed9b0
commit d671e412ae
26 changed files with 1210 additions and 78 deletions
+26 -22
View File
@@ -10,6 +10,7 @@ import sys
import xml.etree.ElementTree as ET
import template_ref as ref
from component_typography import resolve_role
SVG = "http://www.w3.org/2000/svg"
ET.register_namespace("", SVG)
@@ -19,9 +20,9 @@ def node(parent, tag, **attributes):
return ET.SubElement(parent, f"{{{SVG}}}{tag}", {key.replace("_", "-"): str(value) for key, value in attributes.items()})
def label(parent, value, x, y, font, size, color, anchor="start"):
def label(parent, value, x, y, font, size, color, anchor="start", weight="normal"):
result = node(parent, "text", x=x, y=y, font_family=font, font_size=size,
font_weight="normal", fill=color, text_anchor=anchor)
font_weight=weight, fill=color, text_anchor=anchor)
result.text = str(value)
return result
@@ -53,12 +54,13 @@ def render_component(data: dict, style: dict, theme: dict, bounds: tuple,
ref.require(x >= 0 and y >= 0 and w > 0 and h > 0 and x+w <= canvas["width"] and y+h <= canvas["height"], "bounds outside canvas")
kind = data.get("kind")
ref.require(kind in ("table", "metric", "timeline", "flow"), "unsupported basic component")
palette, roles = theme["palette"], style["typography"]["roles"]
families = style["typography"]["families"]
font = families[roles["label"]["family_role"]]["family"]
ratio = canvas["pt_to_px"]
size = roles["label"]["size_pt"] * ratio
small = roles["caption"]["size_pt"] * ratio
if kind == "table" and data.get("variant") == "grouped":
from table_component import render_grouped_table
return render_grouped_table(data, style, theme, bounds, component_id)
palette = theme["palette"]
label_role = resolve_role(style, "label")
caption_role = resolve_role(style, "caption")
font, size, small = label_role.family, label_role.size, caption_role.size
ink, muted, accent = palette["dk1"], palette["dk2"], palette["accent1"]
line, white = palette["line"], palette["lt1"]
group = ET.Element(f"{{{SVG}}}g", {"id": component_id, "data-component-kind": kind})
@@ -67,9 +69,6 @@ def render_component(data: dict, style: dict, theme: dict, bounds: tuple,
if kind == "table":
ref.require(data.get("variant", "basic") in ("basic", "grouped"), "unsupported table variant")
if data.get("variant") == "grouped":
from table_component import render_grouped_table
return render_grouped_table(data, style, theme, bounds, component_id)
columns, rows = data.get("columns"), data.get("rows")
ref.require(isinstance(columns, list) and columns, "table.columns requires labels")
ref.require(isinstance(rows, list) and rows, "table.rows requires data")
@@ -82,8 +81,11 @@ def render_component(data: dict, style: dict, theme: dict, bounds: tuple,
else:
ref.require(not any(ord(char) < 32 for char in value), "cell: control characters unsupported")
cfg = style["components"]["table"]
table_name = cfg.get("text_role", "label")
table_role = label_role if table_name == "label" else resolve_role(style, table_name)
font, size = table_role.family, table_role.size
top, side = cfg["cell_padding_px"]
row_height = math.ceil((size * roles["label"].get("line_height", 1.25) + 2*top) / 8) * 8
row_height = math.ceil((table_role.line_size + 2*top) / 8) * 8
ref.require(row_height*(len(rows)+1) <= h, "table rows exceed height")
widths = data.get("column_widths", [w/len(columns)] * len(columns))
ref.require(isinstance(widths, list) and len(widths) == len(columns), "column_widths mismatch")
@@ -100,7 +102,7 @@ def render_component(data: dict, style: dict, theme: dict, bounds: tuple,
fill = palette[cfg["header_fill_palette_index"]] if i == 0 else white
align = "r" if i and j in numeric else "l"
cell = {"text": str(value), "fill": fill, "color": color, "align": align,
"font_size": size, "bold": False, "padding": {"top": top, "bottom": top, "left": side, "right": side},
"font_family": font, "font_size": size, "bold": table_role.bold, "padding": {"top": top, "bottom": top, "left": side, "right": side},
"border_color": line, "border_width": 1}
cells.append(cell)
fit(value, widths[j]-2*side, size, "table cell")
@@ -108,7 +110,7 @@ def render_component(data: dict, style: dict, theme: dict, bounds: tuple,
fill=fill, stroke=line, stroke_width=1)
tx = offset+widths[j]-side if align == "r" else offset+side
label(group, value, tx, y+i*row_height+(row_height+size*.72)/2, font, size, color,
"end" if align == "r" else "start")
"end" if align == "r" else "start", table_role.svg_weight)
offset += widths[j]
table_rows.append(cells)
group.set("data-pptx-replace-with", "table")
@@ -129,17 +131,19 @@ def render_component(data: dict, style: dict, theme: dict, bounds: tuple,
detail = data.get("detail", "")
ref.require(isinstance(unit, str) and isinstance(detail, str), "metric unit/detail must be strings")
ref.require(not any(ord(char) < 32 for char in unit+detail), "metric: control characters unsupported")
big = roles["number"]["size_pt"] * ratio
number_role = resolve_role(style, "number")
big = number_role.size
ref.require(h >= 160, "metric needs at least 160px height")
fit(title, w, size, "metric label")
fit(str(value)+unit, w, big, "metric value")
fit(detail, w, small, "metric detail")
label(group, title, x, y+size, font, size, muted)
value_node = label(group, value, x, y+104, font, big, accent)
label(group, title, x, y+size, font, size, muted, weight=label_role.svg_weight)
value_node = label(group, value, x, y+104, number_role.family, big, accent, weight=number_role.svg_weight)
if unit:
span = node(value_node, "tspan", dx=16, font_size=size, fill=muted)
span = node(value_node, "tspan", dx=16, font_family=font, font_size=size,
font_weight=label_role.svg_weight, fill=muted)
span.text = unit
if detail: label(group, detail, x, y+152, font, small, ink)
if detail: label(group, detail, x, y+152, caption_role.family, small, ink, weight=caption_role.svg_weight)
return group
items = data.get("items") if kind == "timeline" else data.get("nodes")
@@ -156,8 +160,8 @@ def render_component(data: dict, style: dict, theme: dict, bounds: tuple,
fit(title, slot-24, size, "timeline label")
fit(date, slot-24, small, "timeline date")
node(group, "circle", cx=centers[i], cy=cy, r=8, fill=accent, stroke=white, stroke_width=2)
label(group, date, centers[i], y+56, font, small, muted, "middle")
label(group, title, centers[i], y+152, font, size, ink, "middle")
label(group, date, centers[i], y+56, caption_role.family, small, muted, "middle", caption_role.svg_weight)
label(group, title, centers[i], y+152, font, size, ink, "middle", label_role.svg_weight)
return group
ref.require(h >= 160, "flow needs at least 160px height")
@@ -181,7 +185,7 @@ def render_component(data: dict, style: dict, theme: dict, bounds: tuple,
nx = x+i*(nw+gap)
title = text(item.get("label"), "flow label");fit(title, nw-24, size, "flow label")
node(group, "rect", x=nx, y=cy-40, width=nw, height=80, fill=white, stroke=muted, stroke_width=2)
label(group, title, nx+nw/2, cy+size*.36, font, size, ink, "middle")
label(group, title, nx+nw/2, cy+size*.36, font, size, ink, "middle", label_role.svg_weight)
if i < len(items)-1 and (ids[i], ids[i+1]) in actual:
end = nx+nw+gap
node(group, "line", x1=nx+nw, y1=cy, x2=end-8, y2=cy, stroke=muted, stroke_width=2)
+34 -23
View File
@@ -13,6 +13,7 @@ import unicodedata
import xml.etree.ElementTree as ET
import template_ref as ref
from component_typography import resolve_role
SVG = "http://www.w3.org/2000/svg"
ET.register_namespace("", SVG)
@@ -101,8 +102,10 @@ def _node(parent, tag, **attrs):
return ET.SubElement(parent, '{' + SVG + '}' + tag, {k.replace('_', '-'): _n(v) if isinstance(v, (float, int)) else str(v) for k, v in attrs.items()})
def _text(parent, x, y, value, font, size, color, anchor='start'):
def _text(parent, x, y, value, font, size, color, anchor='start', weight=None):
node = _node(parent, 'text', x=x, y=y, fill=color, font_family=font, font_size=size, text_anchor=anchor)
if weight is not None:
node.set('font-weight', str(weight))
node.text = value
return node
@@ -153,22 +156,20 @@ def _style(style, theme):
typography = style['typography']
role = chart.get('text_role', 'caption' if 'caption' in typography['roles'] else 'label')
require(role in typography['roles'], 'chart text_role missing from typography.roles')
label = typography['roles'][role]
family = typography['families']['body']['family']
px_per_pt = number(style['canvas']['pt_to_px'], 'canvas.pt_to_px')
require(px_per_pt > 0, 'canvas.pt_to_px must be positive')
size = number(label['size_pt'], 'chart size_pt') * px_per_pt
require(size > 0, 'chart text size must be positive')
caption = resolve_role(style, role)
title = resolve_role(style, 'label' if 'label' in typography['roles'] else role)
palette = theme['palette']
indices = chart.get('series_palette_indices', [k for k in ('accent1', 'accent3', 'accent4', 'accent5', 'accent6', 'accent2') if k in palette])
require(isinstance(indices, list) and bool(indices), 'series_palette_indices must be nonempty list')
require(len(set(indices)) == len(indices), 'series_palette_indices must be unique')
keys = [*indices, chart.get('text_palette_index', 'dk1'), chart.get('grid_palette_index', 'line' if 'line' in palette else 'dk2'), 'lt1']
keys = [*indices, chart.get('text_palette_index', 'dk1'), chart.get('grid_palette_index', 'line' if 'line' in palette else 'dk2'), 'lt1', 'accent2', 'dk2']
axis_index = chart.get('axis_palette_index', chart.get('text_palette_index', 'dk1'))
require(isinstance(axis_index, str) and axis_index in palette and re.fullmatch(r'#[0-9A-Fa-f]{6}', palette[axis_index]), 'invalid/missing axis_palette_index')
for key in keys:
require(isinstance(key, str) and key in palette and re.fullmatch(r'#[0-9A-Fa-f]{6}', palette[key]), f'invalid/missing palette color: {key}')
return family, size, [palette[k] for k in indices], palette[keys[-3]], palette[keys[-2]], palette['lt1'], palette[axis_index]
return (title, caption, [palette[k] for k in indices], palette[chart.get('text_palette_index', 'dk1')],
palette[chart.get('grid_palette_index', 'line' if 'line' in palette else 'dk2')],
palette['lt1'], palette[axis_index], palette['accent2'], palette['dk2'])
def render_chart(data: dict, style: dict, theme: dict,
@@ -186,7 +187,8 @@ def render_chart(data: dict, style: dict, theme: dict,
require(x >= 0 and y >= 0 and w > 0 and h > 0, 'bounds must have nonnegative origin and positive size')
canvas = style['canvas']
require(x + w <= canvas['width'] and y + h <= canvas['height'], 'component bounds exceed template canvas')
font, fs, colors, ink, grid, background, axis_color = _style(style, theme)
title_role, caption_role, colors, ink, grid, background, axis_color, title_color, source_color = _style(style, theme)
font, fs = caption_role.family, caption_role.size
require(w >= fs * 14 and h >= fs * 14, 'bounds too small for declared chart font and annotations')
kind = data['type']
value_labels = style.get('components', {}).get('charts', {}).get('value_labels', False)
@@ -196,6 +198,8 @@ def render_chart(data: dict, style: dict, theme: dict,
require(all(value >= 0 for series in data['series'] for value in series['values']),
'optional column value_labels requires nonnegative values')
pie = kind in ('pie', 'doughnut')
require(pie or caption_role.weight == 400,
'native chart axes support Regular (400) weight only; selected chart text role requires an unsupported weight')
count = len(data['categories']) if pie else len(data['series'])
require(count <= len(colors), 'insufficient distinct palette indices for series/slices; configure style.components.charts')
colors = colors[:count]
@@ -212,17 +216,20 @@ def render_chart(data: dict, style: dict, theme: dict,
unit = ';'.join(unit_parts)
else:
unit = '单位:' + data['unit'] if data['unit'] else ''
for value, name in ((data['title'], 'title'), (unit, 'unit'), (source_line, 'source')):
_fit(data['title'], title_role.size, w - 16, 'title')
for value, name in ((unit, 'unit'), (source_line, 'source')):
_fit(value, fs, w - 16, name)
outer = ET.Element('{' + SVG + '}g', {'id': component_id, 'data-component': 'chart', 'data-chart-type': kind})
_text(outer, x + 8, y + fs, data['title'], font, fs, ink)
top = y + math.ceil((fs + 16) / 8) * 8
outer = ET.Element('{' + SVG + '}g', {'id': component_id, 'data-component': 'chart',
'data-chart-type': kind, 'font-weight': caption_role.svg_weight})
_text(outer, x + 8, y + title_role.size, data['title'], title_role.family,
title_role.size, title_color, weight=title_role.svg_weight)
top = y + math.ceil((title_role.line_size + 8) / 8) * 8
if unit:
_text(outer, x + 8, top + fs, unit, font, fs, ink)
top += math.ceil((fs + 8) / 8) * 8
bottom = y + h - (math.ceil((fs + 8) / 8) * 8 if source_line else 8)
top += math.ceil((caption_role.line_size + 8) / 8) * 8
bottom = y + h - (math.ceil((caption_role.line_size + 8) / 8) * 8 if source_line else 8)
if source_line:
_text(outer, x + 8, y + h - 8, source_line, font, fs, ink)
_text(outer, x + 8, y + h - 8, source_line, font, fs, source_color)
legends = [f'{c} {_label(v)}' for c, v in zip(data.get('categories', []), data['series'][0].get('values', []))] if pie else [s['name'] for s in data['series']]
legend_widths = [32 + _width(label, fs) + 16 for label in legends]
@@ -235,11 +242,12 @@ def render_chart(data: dict, style: dict, theme: dict,
row.append((index, width));used += width
if row:
rows.append(row)
legend_h = len(rows) * math.ceil((fs + 8) / 8) * 8
legend_step = math.ceil((caption_role.line_size + 8) / 8) * 8
legend_h = len(rows) * legend_step
legend_y = bottom - legend_h
for r, items in enumerate(rows):
lx = x + 8
ly = legend_y + r * math.ceil((fs + 8) / 8) * 8 + fs
ly = legend_y + r * legend_step + fs
for idx, width in items:
_node(outer, 'rect', x=lx, y=ly-fs*.65, width=16, height=16, fill=colors[idx])
_text(outer, lx+24, ly, legends[idx], font, fs, ink)
@@ -257,7 +265,7 @@ def render_chart(data: dict, style: dict, theme: dict,
metadata = _node(marker, 'metadata', type='application/json')
if value_labels:
payload['data_labels'] = {'position': 'outside_end', 'show_value': True,
'font_size': fs, 'color': ink, 'number_format': 'General'}
'font_size': fs, 'color': ink, 'bold': caption_role.bold, 'number_format': 'General'}
if kind == 'line':
payload['markers'] = True
fallback = _node(marker, 'g', id=component_id+'-fallback')
@@ -280,7 +288,8 @@ def render_chart(data: dict, style: dict, theme: dict,
if kind != 'bar':
payload['axes'] = {'category': {'label_position': 'low', 'major_gridlines': False}, 'value': _axis(yscale)}
else:
payload['data_labels'] = {'position': 'outside_end'}
payload['data_labels'] = {'position': 'outside_end', 'font_size': fs,
'color': ink, 'bold': caption_role.bold}
payload['show_value_axis_labels'] = True
outer.set('data-native-axis-policy', 'automatic-bar-preview-scale-may-differ')
if pie:
@@ -318,7 +327,8 @@ def render_chart(data: dict, style: dict, theme: dict,
_text(fallback, xp(tick), pb+fs+8, _label(tick), font, fs, ink, 'middle')
band = (pb-pt)/len(data['categories'])
bw = band/(len(data['series'])+1.5)
require(band >= fs+8 and bw >= fs+4, 'too many bar categories/series for fixed labels and bounds')
require(band >= max(fs+8, caption_role.line_size) and bw >= max(fs+4, caption_role.line_size),
'too many bar categories/series for fixed labels and bounds')
for i, category in enumerate(data['categories']):
center = pt+(i+.5)*band
_text(fallback, x+labels_width-8, center+fs*.35, category, font, fs, ink, 'end')
@@ -333,7 +343,8 @@ def render_chart(data: dict, style: dict, theme: dict,
tick_width = max(_width(_label(t), fs) for t in ticks)
left, right = x+tick_width+16, x+w-16
pt, pb = top+8, top+chart_h-fs-16
require(right-left >= fs*7 and (pb-pt)/(len(ticks)-1) >= fs+4, 'axis labels leave insufficient plot area')
require(right-left >= fs*7 and (pb-pt)/(len(ticks)-1) >= max(fs+4, caption_role.line_size),
'axis labels leave insufficient plot area')
low, high, step = yscale
yp = lambda val: pb-(val-low)/(high-low)*(pb-pt)
for tick in ticks:
+52
View File
@@ -0,0 +1,52 @@
"""Resolve template text roles once for reuse within a component render."""
from __future__ import annotations
from dataclasses import dataclass
import math
import template_ref as ref
@dataclass(frozen=True)
class TextRole:
family: str
size: float
weight: int
line_height: float
@property
def svg_weight(self):
return "normal" if self.weight == 400 else "bold" if self.weight == 700 else str(self.weight)
@property
def bold(self):
return self.weight >= 600
@property
def line_size(self):
return self.size * self.line_height
def resolve_role(style: dict, name: str) -> TextRole:
typography = style["typography"]
ref.require(name in typography["roles"], f"unknown typography role: {name}")
role = typography["roles"][name]
family = typography["families"][role["family_role"]]
face = family["family"]
ref.require(isinstance(face, str) and bool(face.strip()), f"{name}: font family required")
ratio, points = style["canvas"]["pt_to_px"], role["size_pt"]
line_height = role.get("line_height", 1.25)
for value, label in ((ratio, "pt_to_px"), (points, "size_pt"), (line_height, "line_height")):
ref.require(type(value) in (int, float) and math.isfinite(value) and value > 0,
f"{name}: positive finite {label} required")
# A role can refine its family weight without losing the family's defaults.
raw = role.get("weight_number", role.get("weight", family.get("weight_number", family.get("weight", "Regular"))))
if isinstance(raw, str):
weights = {"thin": 100, "extralight": 200, "light": 300, "regular": 400,
"normal": 400, "medium": 500, "semibold": 600, "bold": 700,
"extrabold": 800, "black": 900}
raw = weights.get(raw.lower().replace(" ", "").replace("-", ""), raw)
if isinstance(raw, str) and raw.isdigit():
raw = int(raw)
ref.require(type(raw) is int and 1 <= raw <= 1000, f"{name}: invalid font weight")
return TextRole(face, points * ratio, raw, line_height)
+159
View File
@@ -0,0 +1,159 @@
"""Resolve and check fixed template spacing, without caller-owned thresholds.
Semantic relations name spacing roles from the frozen style. Padding applies
to the complete available inner region, not occupied text/ink bounds. Legacy
templates read existing fields through fixed paths, without numeric defaults.
"""
from __future__ import annotations
import math
_LEGACY_ROLES = {
"text_padding": ("padding", ("components", "text_group", "padding_px")),
"paragraph": ("gap", ("components", "text_group", "paragraph_gap_px")),
"item": ("gap", ("components", "text_group", "item_gap_px")),
"columns": ("gap", ("grid", "gutter")),
"panel_padding": ("padding", ("components", "panel", "padding_px")),
"icon_text": ("gap", ("icons", "text_gap_px")),
"basic_table_cell_padding": ("padding", ("components", "table", "cell_padding_px")),
"grouped_table_cell_padding": ("padding", ("components", "table_grouped", "cell_padding_px")),
**{name: ("gap", ("components", "spacing", name + "_px"))
for name in ("heading_body", "content_group", "separator", "formula", "connector_clearance")},
}
def _number(value, label):
if type(value) not in (int, float) or not math.isfinite(value) or value < 0:
raise ValueError(f"{label}: expected nonnegative finite number")
return float(value)
def _name(value, label):
if not isinstance(value, str) or not value.strip():
raise ValueError(f"{label}: expected nonempty ID")
return value
def _padding(value, label):
if type(value) in (int, float):
return (_number(value, label),) * 4
if not isinstance(value, (list, tuple)) or len(value) not in (2, 4):
raise ValueError(f"{label}: padding requires scalar, [vertical,horizontal] or [top,right,bottom,left]")
values = tuple(_number(item, label) for item in value)
return (values[0], values[1], values[0], values[1]) if len(values) == 2 else values
def resolve_spacing(style: dict, role: str) -> float | tuple[float, float, float, float]:
"""Read an exact gap/padding from a frozen template's named role.
New declarations require spacing.policy == 'fixed' and
spacing.roles[role] == {kind:'gap'|'padding', path:[field,...]}.
Paths traverse dictionary keys only, never executable JSONPath. Without a
spacing section, known legacy roles read only their existing fixed paths.
"""
if not isinstance(style, dict):
raise ValueError("style must be an object")
_name(role, "spacing role")
if "spacing" in style:
spacing = style["spacing"]
if not isinstance(spacing, dict) or spacing.get("policy") != "fixed":
raise ValueError("spacing.policy must be fixed")
if spacing.get("caller_overrides", False) is not False:
raise ValueError("fixed spacing forbids caller overrides")
roles = spacing.get("roles")
if not isinstance(roles, dict) or role not in roles:
raise ValueError(f"unknown fixed spacing role: {role}")
declaration = roles[role]
if not isinstance(declaration, dict) or set(declaration) != {"kind", "path"}:
raise ValueError(f"{role}: spacing role requires kind/path only")
kind, path = declaration["kind"], declaration["path"]
if kind not in ("gap", "padding"):
raise ValueError(f"{role}: spacing kind must be gap or padding")
if not isinstance(path, list) or not path or any(not isinstance(key, str) or not key for key in path):
raise ValueError(f"{role}: spacing path requires nonempty field-name list")
else:
if role not in _LEGACY_ROLES:
raise ValueError(f"unknown legacy spacing role: {role}")
kind, path = _LEGACY_ROLES[role]
value = style
for key in path:
if not isinstance(value, dict) or key not in value:
raise ValueError(f"{role}: missing spacing field at {'.'.join(path)}")
value = value[key]
return _number(value, role) if kind == "gap" else _padding(value, role)
def check_spacing(boxes: dict, checks: list, *, style: dict) -> list[dict]:
"""Return deviations from exact template-owned directional gaps/padding.
Gap checks use ``{id, kind:'gap', a, b, axis:'x'|'y', role}``, measured
from a's right/bottom edge to b's left/top edge. Padding checks use
``{id, kind:'padding', outer, inner, role}``, where inner is the complete
usable region. Numeric minimum/expected/override fields are rejected.
Negative distances report ``overlap`` (gap) or ``outside`` (padding);
other deviations report ``too_narrow`` or ``too_wide``. Absolute tolerance
1e-7 absorbs arithmetic roundoff only, not extra layout space. Invalid inputs,
missing IDs or self-relations raise ValueError. These are geometry checks,
not font ink measurements or visual acceptance.
"""
if not isinstance(boxes, dict) or not isinstance(checks, list):
raise ValueError("boxes must be a mapping and checks must be a list")
geometry = {}
for key, bounds in boxes.items():
_name(key, "box ID")
if not isinstance(bounds, (list, tuple)) or len(bounds) != 4:
raise ValueError(f"{key}: bounds requires [x,y,width,height]")
geometry[key] = tuple(_number(value, f"{key}.bounds") for value in bounds)
def pair(check, first, second):
a, b = _name(check[first], first), _name(check[second], second)
if a == b:
raise ValueError(f"{check['id']}: relation must use two different boxes")
if a not in geometry or b not in geometry:
raise ValueError(f"{check['id']}: relation references unknown box")
return geometry[a], geometry[b]
result, seen = [], set()
for check in checks:
if not isinstance(check, dict):
raise ValueError("each spacing check must be an object")
key = _name(check.get("id"), "check ID")
if key in seen:
raise ValueError(f"duplicate check ID: {key}")
seen.add(key)
kind = check.get("kind")
if kind == "gap":
if set(check) != {"id", "kind", "a", "b", "axis", "role"}:
raise ValueError(f"{key}: gap requires id/kind/a/b/axis/role only; numeric overrides forbidden")
if check["axis"] not in ("x", "y"):
raise ValueError(f"{key}: axis must be x or y")
a, b = pair(check, "a", "b")
expected = resolve_spacing(style, check["role"])
if not isinstance(expected, float):
raise ValueError(f"{key}: gap relation requires a gap role")
index = 0 if check["axis"] == "x" else 1
actual = b[index] - a[index] - a[index + 2]
# Derived line heights can differ by floating-point roundoff only.
if not math.isclose(actual, expected, rel_tol=0, abs_tol=1e-7):
result.append({**check, "actual": actual, "expected": expected,
"code": "overlap" if actual < 0 else "too_narrow" if actual < expected else "too_wide"})
elif kind == "padding":
if set(check) != {"id", "kind", "outer", "inner", "role"}:
raise ValueError(f"{key}: padding requires id/kind/outer/inner/role only; numeric overrides forbidden")
outer, inner = pair(check, "outer", "inner")
expected = resolve_spacing(style, check["role"])
if not isinstance(expected, tuple):
raise ValueError(f"{key}: padding relation requires a padding role")
ox, oy, ow, oh = outer
ix, iy, iw, ih = inner
actuals = (iy - oy, ox + ow - ix - iw, oy + oh - iy - ih, ix - ox)
for side, actual, target in zip(("top", "right", "bottom", "left"), actuals, expected):
if not math.isclose(actual, target, rel_tol=0, abs_tol=1e-7):
result.append({"id": key, "kind": kind, "outer": check["outer"],
"inner": check["inner"], "role": check["role"], "side": side,
"actual": actual, "expected": target,
"code": "outside" if actual < 0 else "too_narrow" if actual < target else "too_wide"})
else:
raise ValueError(f"{key}: unsupported spacing check kind")
return result
+413
View File
@@ -0,0 +1,413 @@
"""Build explicit mathematical expressions as native PowerPoint Office Math.
Structured expressions are the source of truth. This is not a TeX parser.
The wrapper replaces a temporary compiler token with OMML after compilation;
the preserved upstream compiler is unchanged. A linear text fallback is kept
for applications without Office Math support, not claimed as an equation.
"""
from __future__ import annotations
import copy
import hashlib
import json
import math
from pathlib import Path
import re
import tempfile
import unicodedata
import zipfile
import xml.etree.ElementTree as SVG_ET
from lxml import etree as ET
from component_typography import resolve_role
import template_ref as ref
SVG = "http://www.w3.org/2000/svg"
A = "http://schemas.openxmlformats.org/drawingml/2006/main"
P = "http://schemas.openxmlformats.org/presentationml/2006/main"
M = "http://schemas.openxmlformats.org/officeDocument/2006/math"
A14 = "http://schemas.microsoft.com/office/drawing/2010/main"
MC = "http://schemas.openxmlformats.org/markup-compatibility/2006"
NS = {"a": A, "p": P, "m": M, "a14": A14, "mc": MC}
TOKEN_PREFIX = "NATIVE_MATH_"
def _finite(value, name, *, positive=False):
ref.require(type(value) in (int, float) and math.isfinite(value)
and (value > 0 if positive else value >= 0),
f"{name}: expected {'positive' if positive else 'nonnegative'} finite number")
return float(value)
def _text(value):
ref.require(isinstance(value, str) and value.strip(), "math requires nonempty text")
ref.require(not any(unicodedata.category(c) in ("Cc", "Cf") for c in value),
"math text cannot contain control/format characters or line breaks")
def validate_expression(expression):
"""Reject unknown constructs rather than silently changing mathematics."""
budget = [0, 0]
def visit(expr, depth):
budget[0] += 1
ref.require(depth <= 32 and budget[0] <= 1000, "math expression exceeds nesting/node limit")
if isinstance(expr, str):
_text(expr)
budget[1] += len(expr)
ref.require(budget[1] <= 8192, "math expression text exceeds limit")
return
ref.require(isinstance(expr, dict), "math expression requires a string or structured node")
kind = expr.get("type")
fields = {
"row": ({"type", "items"}, set()),
"text": ({"type", "text"}, {"style"}),
"fraction": ({"type", "numerator", "denominator"}, set()),
"script": ({"type", "base"}, {"sub", "sup"}),
"radical": ({"type", "body"}, {"degree"}),
"delimiter": ({"type", "body", "open", "close"}, set()),
"nary": ({"type", "operator", "body"}, {"lower", "upper"}),
"matrix": ({"type", "rows"}, set()),
}
ref.require(kind in fields, f"unsupported math node: {kind}")
required, optional = fields[kind]
ref.require(required <= set(expr) <= required | optional, f"{kind}: invalid fields")
if kind == "text":
ref.require(expr.get("style", "plain") in ("plain", "italic", "bold", "bold-italic"),
"math text style invalid")
_text(expr["text"])
visit(expr["text"], depth + 1)
elif kind == "row":
ref.require(isinstance(expr["items"], list) and expr["items"], "math row requires items")
for value in expr["items"]:
visit(value, depth + 1)
elif kind == "matrix":
rows = expr["rows"]
ref.require(isinstance(rows, list) and 1 <= len(rows) <= 10
and all(isinstance(row, list) and 1 <= len(row) <= 10 for row in rows),
"math matrix requires 1–10 rows/columns")
ref.require(len({len(row) for row in rows}) == 1, "math matrix rows must have equal length")
for row in rows:
for value in row:
visit(value, depth + 1)
else:
if kind == "script":
ref.require("sub" in expr or "sup" in expr, "math script requires sub or sup")
if kind == "delimiter":
ref.require(expr["open"] in ("(", "[", "{", "|", "‖", "⌈", "⌊", "⟨")
and expr["close"] in (")", "]", "}", "|", "‖", "⌉", "⌋", "⟩"),
"math delimiter unsupported")
if kind == "nary":
ref.require(expr["operator"] in ("∑", "∏", "∫", "⋂", "⋃"), "math n-ary operator unsupported")
children = {"fraction": ("numerator", "denominator"),
"script": ("base", "sub", "sup"),
"radical": ("body", "degree"),
"delimiter": ("body",), "nary": ("lower", "upper", "body")}[kind]
for key in children:
if key in expr:
visit(expr[key], depth + 1)
visit(expression, 0)
def linear_text(expr):
"""An unambiguous linear compatibility view, not a layout approximation."""
if isinstance(expr, str):
return expr
kind = expr["type"]
if kind == "text":
return expr["text"]
if kind == "row":
return "".join(linear_text(value) for value in expr["items"])
if kind == "fraction":
return f"({linear_text(expr['numerator'])})/({linear_text(expr['denominator'])})"
if kind == "script":
result = f"({linear_text(expr['base'])})"
return result + "".join(("_" if key == "sub" else "^") + "(" + linear_text(expr[key]) + ")"
for key in ("sub", "sup") if key in expr)
if kind == "radical":
degree = "[" + linear_text(expr["degree"]) + "]" if "degree" in expr else ""
return "√" + degree + "(" + linear_text(expr["body"]) + ")"
if kind == "delimiter":
return expr["open"] + linear_text(expr["body"]) + expr["close"]
if kind == "nary":
limits = "".join(("_" if key == "lower" else "^") + "(" + linear_text(expr[key]) + ")"
for key in ("lower", "upper") if key in expr)
return expr["operator"] + limits + "(" + linear_text(expr["body"]) + ")"
return "[" + "; ".join(", ".join(linear_text(value) for value in row) for row in expr["rows"]) + "]"
def _validate_spec(spec, canvas=None):
expected = {"schema_version", "id", "expression", "bounds", "font_family", "font_size_pt",
"color", "display", "align"}
ref.require(isinstance(spec, dict) and set(spec) == expected
and type(spec["schema_version"]) is int and spec["schema_version"] == 1,
"native math requires schema_version 1 and declared fields")
ref.identifier(spec["id"], "math ID")
validate_expression(spec["expression"])
bounds = spec["bounds"]
ref.require(isinstance(bounds, (list, tuple)) and len(bounds) == 4, "math bounds requires x/y/width/height")
x, y = [_finite(v, "math position") for v in bounds[:2]]
w, h = [_finite(v, "math size", positive=True) for v in bounds[2:]]
if canvas:
ref.require(x + w <= canvas["width"] and y + h <= canvas["height"], "math outside canvas")
_text(spec["font_family"])
ref.require(_finite(spec["font_size_pt"], "math font size", positive=True) <= 400,
"math font size outside supported range")
ref.require(isinstance(spec["color"], str) and re.fullmatch(r"#[0-9a-fA-F]{6}", spec["color"]),
"math requires an explicit RGB color")
ref.require(spec["display"] in ("block", "inline") and spec["align"] in ("left", "center", "right"),
"math display/alignment invalid")
def render_math(data, style, theme, bounds, component_id, math_style=None):
"""Return a source group; font comes from a template or explicit project input.
math_style may declare font_family/text_role/palette_index externally when
a template has not yet introduced math roles. It is a locked build input,
not a global font change. Inline creates a standalone equation frame; this
API does not yet combine prose and math inside a flowing text paragraph.
"""
ref.require(isinstance(data, dict) and type(data.get("schema_version")) is int and data["schema_version"] == 1
and {"schema_version", "expression"} <= set(data)
<= {"schema_version", "expression", "display", "align"}, "invalid math component data")
display = data.get("display", "block")
math_style = math_style or {}
ref.require(isinstance(math_style, dict) and set(math_style) <= {"font_family", "text_role", "palette_index"},
"invalid math style declaration")
family = math_style.get("font_family") or style["typography"]["families"].get("math", {}).get("family")
ref.require(family, "Declare a math font in template typography.families.math or project math_style")
role_name = math_style.get("text_role", "math_display" if display == "block" else "math_inline")
role = resolve_role(style, role_name)
spec = {"schema_version": 1, "id": component_id, "expression": data["expression"], "bounds": list(bounds),
"font_family": family, "font_size_pt": role.size / style["canvas"]["pt_to_px"],
"color": theme["palette"][math_style.get("palette_index", "dk1")],
"display": display, "align": data.get("align", "left")}
_validate_spec(spec, style["canvas"])
group = SVG_ET.Element("{" + SVG + "}g", {"id": component_id, "data-native-math": "1"})
metadata = SVG_ET.SubElement(group, "{" + SVG + "}metadata", {"type": "application/json"})
metadata.text = json.dumps(spec, ensure_ascii=False)
x, y, w, h = bounds
anchor = {"left": "start", "center": "middle", "right": "end"}[spec["align"]]
tx = x if anchor == "start" else x + w / 2 if anchor == "middle" else x + w
node = SVG_ET.SubElement(group, "{" + SVG + "}text", {"x": str(tx), "y": str(y + h / 2 + role.size * .32),
"font-family": family, "font-size": str(role.size), "fill": spec["color"], "text-anchor": anchor})
node.text = linear_text(data["expression"])
return group
def prepare_math(root, canvas):
"""Replace source math groups with unique temporary compiler tokens."""
specs, ids = [], set()
def walk(element, transformed=False):
transformed = transformed or bool(element.get("transform"))
if element.get("data-native-math") is not None:
ref.require(element.tag == "{" + SVG + "}g" and element.get("data-native-math") == "1",
"native math marker must be a version-1 group")
ref.require(not transformed, "native math markers do not support ancestor/group transforms")
nodes = element.findall("{" + SVG + "}metadata")
ref.require(len(nodes) == 1 and nodes[0].get("type") == "application/json", "math requires one JSON metadata node")
spec = json.loads(nodes[0].text or "")
_validate_spec(spec, canvas)
ref.require(element.get("id") == spec["id"] and spec["id"] not in ids, "math ID mismatch or duplicate")
visible = "".join(n.text or "" for n in element.iter() if n.tag in ("{" + SVG + "}text", "{" + SVG + "}tspan"))
ref.require(visible == linear_text(spec["expression"]), "math fallback differs from structured expression; regenerate")
ids.add(spec["id"])
token = TOKEN_PREFIX + hashlib.sha256(spec["id"].encode()).hexdigest()[:24]
for child in list(element):
element.remove(child)
element.attrib.pop("data-native-math")
x, y, _, _ = spec["bounds"]
placeholder = SVG_ET.SubElement(element, "{" + SVG + "}text", {
"x": str(x), "y": str(y + spec["font_size_pt"] * canvas["pt_to_px"]),
"font-family": spec["font_family"], "font-size": str(spec["font_size_pt"] * canvas["pt_to_px"]),
"fill": spec["color"]})
placeholder.text = token
specs.append((token, spec))
return
for child in element:
walk(child, transformed)
walk(root)
return specs
def _sub(parent, namespace, local, **attrs):
return ET.SubElement(parent, "{" + namespace + "}" + local, **attrs)
def _property(parent, name, value):
return _sub(parent, M, name, **{"{" + M + "}val": str(value)})
def _run_properties(parent, spec):
properties = _sub(parent, A, "rPr", sz=str(round(spec["font_size_pt"] * 100)), b="0", dirty="0")
_sub(_sub(properties, A, "solidFill"), A, "srgbClr", val=spec["color"][1:])
for script in ("latin", "ea", "cs"):
_sub(properties, A, script, typeface=spec["font_family"])
return properties
def _control(parent, spec):
_run_properties(_sub(parent, M, "ctrlPr"), spec)
def _expression(parent, expr, spec):
if isinstance(expr, str) or expr["type"] == "text":
run = _sub(parent, M, "r")
if isinstance(expr, dict):
properties = _sub(run, M, "rPr")
_property(properties, "sty", {"plain": "p", "italic": "i", "bold": "b", "bold-italic": "bi"}[expr.get("style", "plain")])
_run_properties(run, spec)
text = _sub(run, M, "t")
text.set("{http://www.w3.org/XML/1998/namespace}space", "preserve")
text.text = expr if isinstance(expr, str) else expr["text"]
return
kind = expr["type"]
if kind == "row":
for value in expr["items"]:
_expression(parent, value, spec)
return
if kind == "fraction":
node = _sub(parent, M, "f")
_control(_sub(node, M, "fPr"), spec)
_expression(_sub(node, M, "num"), expr["numerator"], spec)
_expression(_sub(node, M, "den"), expr["denominator"], spec)
elif kind == "script":
tag = "sSubSup" if "sub" in expr and "sup" in expr else "sSub" if "sub" in expr else "sSup"
node = _sub(parent, M, tag)
_control(_sub(node, M, tag + "Pr"), spec)
_expression(_sub(node, M, "e"), expr["base"], spec)
for key in ("sub", "sup"):
if key in expr:
_expression(_sub(node, M, key), expr[key], spec)
elif kind == "radical":
node = _sub(parent, M, "rad")
props = _sub(node, M, "radPr")
_property(props, "degHide", "0" if "degree" in expr else "1")
_control(props, spec)
degree = _sub(node, M, "deg")
if "degree" in expr:
_expression(degree, expr["degree"], spec)
_expression(_sub(node, M, "e"), expr["body"], spec)
elif kind == "delimiter":
node = _sub(parent, M, "d")
props = _sub(node, M, "dPr")
_property(props, "begChr", expr["open"])
_property(props, "endChr", expr["close"])
_control(props, spec)
_expression(_sub(node, M, "e"), expr["body"], spec)
elif kind == "nary":
node = _sub(parent, M, "nary")
props = _sub(node, M, "naryPr")
_property(props, "chr", expr["operator"])
_property(props, "limLoc", "undOvr" if spec["display"] == "block" else "subSup")
_property(props, "subHide", "0" if "lower" in expr else "1")
_property(props, "supHide", "0" if "upper" in expr else "1")
_control(props, spec)
for source, tag in (("lower", "sub"), ("upper", "sup")):
child = _sub(node, M, tag)
if source in expr:
_expression(child, expr[source], spec)
_expression(_sub(node, M, "e"), expr["body"], spec)
else:
node = _sub(parent, M, "m")
props = _sub(node, M, "mPr")
columns = _sub(props, M, "mcs")
column = _sub(_sub(columns, M, "mc"), M, "mcPr")
_property(column, "count", len(expr["rows"][0]))
_control(props, spec)
for row in expr["rows"]:
native_row = _sub(node, M, "mr")
for value in row:
_expression(_sub(native_row, M, "e"), value, spec)
def _equation_shape(original, spec, px_to_emu):
shape = copy.deepcopy(original)
identity = shape.find("p:nvSpPr/p:cNvPr", NS)
identity.set("name", spec["id"])
identity.set("descr", linear_text(spec["expression"]))
transform = shape.find("p:spPr/a:xfrm", NS)
ref.require(transform is not None, "math placeholder has no native geometry")
off, extent = transform.find("a:off", NS), transform.find("a:ext", NS)
x, y, w, h = spec["bounds"]
off.set("x", str(round(x * px_to_emu))); off.set("y", str(round(y * px_to_emu)))
extent.set("cx", str(round(w * px_to_emu))); extent.set("cy", str(round(h * px_to_emu)))
old_body = shape.find("p:txBody", NS)
shape.remove(old_body)
body = _sub(shape, P, "txBody")
body_props = _sub(body, A, "bodyPr", wrap="none", anchor="ctr", lIns="0", rIns="0", tIns="0", bIns="0")
_sub(body_props, A, "noAutofit")
_sub(body, A, "lstStyle")
paragraph = _sub(body, A, "p")
_sub(paragraph, A, "pPr", algn={"left": "l", "center": "ctr", "right": "r"}[spec["align"]])
math_node = _sub(paragraph, A14, "m")
if spec["display"] == "block":
container = _sub(math_node, M, "oMathPara")
_property(_sub(container, M, "oMathParaPr"), "jc", spec["align"])
else:
container = math_node
_expression(_sub(container, M, "oMath"), spec["expression"], spec)
return shape
def apply_math(path: Path, pages, canvas):
"""Install explicit Office Math choices into the temporary native package."""
if not any(pages):
return {"native_math_objects": 0}
scale = canvas["ppt_inches"][0] * 914400 / canvas["width"]
result = []
with zipfile.ZipFile(path) as archive:
entries = [(info, archive.read(info.filename)) for info in archive.infolist()]
changes = {}
for index, specs in enumerate(pages, 1):
if not specs:
continue
part = f"ppt/slides/slide{index}.xml"
raw = next(value for info, value in entries if info.filename == part)
tree = ET.fromstring(raw)
for token, spec in specs:
matches = [sp for sp in tree.findall(".//p:sp", NS)
if "".join(sp.itertext()).find(token) >= 0
and "".join(sp.xpath(".//a:t/text()", namespaces=NS)) == token]
ref.require(len(matches) == 1, f"math compiler token missing/ambiguous: {spec['id']}")
original = matches[0]
for ancestor in original.iterancestors("{" + P + "}grpSp"):
transform = ancestor.find("p:grpSpPr/a:xfrm", NS)
ref.require(transform is not None, "math parent group lacks geometry")
ref.require(transform.find("a:off", NS).attrib == transform.find("a:chOff", NS).attrib
and transform.find("a:ext", NS).attrib == transform.find("a:chExt", NS).attrib,
"math parent group coordinate transform is unsupported")
native = _equation_shape(original, spec, scale)
fallback = copy.deepcopy(native)
body = fallback.find("p:txBody", NS)
paragraph = body.find("a:p", NS)
paragraph.remove(paragraph.find("a14:m", NS))
run = _sub(paragraph, A, "r")
_run_properties(run, spec)
_sub(run, A, "t").text = linear_text(spec["expression"])
alternative = ET.Element("{" + MC + "}AlternateContent", nsmap={"mc": MC})
choice = ET.SubElement(alternative, "{" + MC + "}Choice", Requires="a14", nsmap={"a14": A14, "m": M})
choice.append(native)
_sub(alternative, MC, "Fallback").append(fallback)
original.getparent().replace(original, alternative)
result.append({"slide": index, "id": spec["id"], "font_family": spec["font_family"],
"display": spec["display"], "object_type": "Office Math",
"fallback": "linear ordinary text for non-math-capable applications"})
serialized = ET.tostring(tree, xml_declaration=True, encoding="utf-8", standalone=True)
ref.require(TOKEN_PREFIX.encode() not in serialized, "temporary math token remains in output")
changes[part] = serialized
with tempfile.NamedTemporaryFile(prefix="office-math-", suffix=".pptx", dir=path.parent, delete=False) as temporary:
target = Path(temporary.name)
try:
with zipfile.ZipFile(target, "w", zipfile.ZIP_DEFLATED) as archive:
for info, value in entries:
archive.writestr(info, changes.get(info.filename, value))
target.replace(path)
finally:
target.unlink(missing_ok=True)
return {"native_math_objects": len(result), "math_objects": result,
"math_verification_scope": "native Office Math structure; application rendering/editing require separate checks"}
+8 -2
View File
@@ -141,7 +141,8 @@ def export(manifest_path: Path, theme_id: str, sources: list[Path], output: Path
root = prepare_svg(source, target, template_root)
ref.require(root.get("data-pptx-layout") == layout_id, f"Roster/source layout identity mismatch: {layout_id}")
definitions.append(target)
public, expected_names, compiled_notes, indexed_notes = [], [], {}, {}
from math_component import prepare_math, apply_math
public, expected_names, compiled_notes, indexed_notes, math_pages = [], [], {}, {}, []
for index, source in enumerate(sources):
target = temporary / f"page_{index:04d}.svg"
allowed = source_root or (template_root if source.is_relative_to(template_root) else source.parent)
@@ -149,6 +150,10 @@ def export(manifest_path: Path, theme_id: str, sources: list[Path], output: Path
layout_id = root.get("data-pptx-layout")
ref.require(layout_id in layouts, f"Source references an undeclared layout: {layout_id}")
ref.require(root.get("viewBox") == style["canvas"]["viewbox"], "Source viewBox differs from declared template canvas")
equations = prepare_math(root, style["canvas"])
if equations:
ET.ElementTree(root).write(target, encoding="utf-8", xml_declaration=True)
math_pages.append(equations)
public.append(target)
expected_names.append(layouts[layout_id])
if source.stem in notes:
@@ -166,6 +171,7 @@ def export(manifest_path: Path, theme_id: str, sources: list[Path], output: Path
workers=1, primary_language=style.get("language"))
ref.require(success, "Bundled structured compiler failed")
finalize(generated, theme, style)
math_facts = apply_math(generated, math_pages, style["canvas"])
facts = verify(generated, layouts, expected_names, indexed_notes)
# Exclusive publication also protects against a writer racing the initial
# existence check. A failed compile never disturbs the previous output.
@@ -179,7 +185,7 @@ def export(manifest_path: Path, theme_id: str, sources: list[Path], output: Path
if created:
output.unlink(missing_ok=True)
raise
return {**facts, "template_id": manifest["template_id"], "template_version": manifest["version"],
return {**facts, **math_facts, "template_id": manifest["template_id"], "template_version": manifest["version"],
"theme": theme_id, "output": str(output)}
+192
View File
@@ -0,0 +1,192 @@
"""Conservative line breaking for a single ordinary horizontal paragraph.
Supports CJK with basic Latin words, common ASCII number/date/version tokens,
paired straight quotes and common Chinese punctuation. It preserves every input
character, including spaces. This is not a full Unicode line-break/grapheme,
vertical composition, punctuation-compression or hanging-punctuation engine.
Short labels and table values are not paragraphs and should not use this helper.
The caller supplies deterministic, nonnegative finite text advance measurements
in the same units as max_width. No font library is imported. Width monotonicity
is not assumed. A boundary DAG is solved once, in polynomial time; measurements
of repeated substrings are cached within this call with bounded cache storage.
Common punctuation rules follow W3C CLREQ 6.1.1 and 6.1.2. The additional ban on
one-content-character lines applies to EVERY line, beyond CLREQ 7.1.2's usual
last-line orphan treatment, as required by this workflow.
"""
from __future__ import annotations
from functools import lru_cache
import math
import re
from typing import Callable
import unicodedata
_OPEN = frozenset("([{([{〈《「『【〔〖〘〚‘“«‹﹁﹃﹙﹛﹝")
_CLOSE = frozenset(")]})]}〉》」』】〕〗〙〛’”»›﹂﹄﹚﹜﹞")
_PAUSE = frozenset("、。,.,.;:!?;:?!。、·・…—%%‰")
_SENTENCE = frozenset("。.!!??")
_CLAUSE = frozenset(",,;;::、")
# Western words include ordinary contractions, hyphenated identifiers and
# versions; numbers include grouping, decimals, dates/times, exponents and units.
_TOKEN = re.compile(
r"[A-Za-z][A-Za-z0-9_]*(?:['’._/\-][A-Za-z0-9_]+)*"
r"|[+\-±$¥¥€£]?(?:[0-9]{1,3}(?:,[0-9]{3})+|[0-9]+)"
r"(?:\.[0-9]+)*(?:[/:\-][0-9]+)*(?:[eE][+\-]?[0-9]+)?"
r"(?:[%%‰℃℉°₫]|[A-Za-z]+)?"
r"|……|——"
)
def _paragraph_input(text: str) -> None:
if not isinstance(text, str) or not text:
raise ValueError("text must be a nonempty semantic paragraph")
if any(c in "\r\n\u2028\u2029" or unicodedata.category(c) in ("Cc", "Cf") for c in text):
raise ValueError("paragraph must not contain line breaks, control or format characters")
def _content(char: str) -> bool:
return not char.isspace() and unicodedata.category(char)[0] not in "PM"
def _boundary_data(text: str):
"""Cache character classes and legal token boundaries for the whole text."""
count = len(text)
boundaries = [True] * (count + 1)
for match in _TOKEN.finditer(text):
for i in range(match.start() + 1, match.end()):
boundaries[i] = False
opens, closes = [], []
straight_open = {"'": False, '"': False}
content = [0]
for i, char in enumerate(text):
category = unicodedata.category(char)
opening = char in _OPEN or category in ("Ps", "Pi")
closing = char in _CLOSE or char in _PAUSE or category in ("Pe", "Pf")
if char in straight_open:
# An apostrophe within a protected Western word is not a quote.
if char != "'" or boundaries[i] or boundaries[i + 1]:
opening = not straight_open[char]
closing = straight_open[char]
straight_open[char] = not straight_open[char]
opens.append(opening)
closes.append(closing)
content.append(content[-1] + int(_content(char)))
if category.startswith("M"):
boundaries[i] = False
first, last = [count] * (count + 1), [-1] * (count + 1)
for i in range(count - 1, -1, -1):
first[i] = first[i + 1] if text[i].isspace() else i
for end in range(1, count + 1):
last[end] = last[end - 1] if text[end - 1].isspace() else end - 1
return boundaries, opens, closes, content, first, last
def validate_lines(lines: list[str]) -> list[dict]:
"""Return line-numbered rule violations; widths need the caller's measure.
The full concatenated paragraph supplies quote/token context. No content or
whitespace is changed. An empty list reports an empty paragraph; malformed
API arguments raise ValueError rather than returning a misleading success.
"""
if not isinstance(lines, list) or any(not isinstance(line, str) for line in lines):
raise ValueError("lines must be a list of strings")
if not lines:
return [{"line": 1, "code": "empty_paragraph", "message": "paragraph has no lines"}]
text = "".join(lines)
boundaries, opens, closes, content, first, last = _boundary_data(text)
issues, start = [], 0
for number, line in enumerate(lines, 1):
end = start + len(line)
def issue(code, message):
issues.append({"line": number, "code": code, "message": message})
if not line or line.isspace():
issue("empty_line", "paragraph line must contain text")
else:
if any(c in "\r\n\u2028\u2029" or unicodedata.category(c) in ("Cc", "Cf") for c in line):
issue("unsupported_control", "line contains a line break, control or format character")
if closes[first[start]]:
issue("prohibited_line_start", "line starts with closing or pause punctuation")
if opens[last[end]]:
issue("prohibited_line_end", "line ends with opening punctuation")
amount = content[end] - content[start]
if amount == 0:
issue("punctuation_only", "paragraph line contains only punctuation and whitespace")
elif amount == 1:
issue("single_content_character", "paragraph line contains only one content character")
if end < len(text) and not boundaries[end]:
issue("split_token", "line break splits a protected word, number, paired mark or combining sequence")
start = end
return issues
def wrap_paragraph(text: str, max_width: float, measure: Callable[[str], float], *,
max_lines: int | None = None) -> list[str]:
"""Reflow the complete paragraph without changing content or font size.
Among valid layouts choose the fewest lines, then prefer sentence/clause
boundaries, then reduce raggedness. Dynamic programming can move earlier
breaks to avoid an orphan anywhere, rather than patching the final line.
At most O(b²) segment measurements are considered for b legal boundaries;
no exponential recursion and no assumption of additive/monotone font width.
"""
_paragraph_input(text)
if type(max_width) not in (int, float) or not math.isfinite(max_width) or max_width <= 0:
raise ValueError("max_width must be a positive finite number")
if not callable(measure):
raise ValueError("measure must be callable")
if max_lines is not None and (type(max_lines) is not int or max_lines < 1):
raise ValueError("max_lines must be a positive integer or None")
boundaries, opens, closes, content, first, last = _boundary_data(text)
if content[-1] < 2:
raise ValueError("paragraph requires at least two content characters; short labels are outside this helper")
points = [i for i, allowed in enumerate(boundaries) if allowed]
count = len(points)
@lru_cache(maxsize=4096)
def width(value):
result = measure(value)
if type(result) not in (int, float) or not math.isfinite(result) or result < 0:
raise ValueError("measure must return a nonnegative finite number")
return result
# Score line endings once; closing quotation/bracket marks can follow the
# actual sentence punctuation without losing its preferred break status.
preferences = []
for end in points:
index = last[end]
while index >= 0 and (text[index] in _CLOSE or text[index] in "\"'" and closes[index]):
index -= 1
char = text[index] if index >= 0 else ""
preferences.append(0 if char in _SENTENCE else 1 if char in _CLAUSE
else 2 if end and text[end - 1].isspace() else 3)
best, following = [None] * count, [None] * count
best[-1] = (0, 0, 0.0)
for i in range(count - 2, -1, -1):
start = points[i]
if first[start] == len(text) or closes[first[start]]:
continue
for j in range(i + 1, count):
end = points[j]
if best[j] is None or content[end] - content[start] < 2 or opens[last[end]]:
continue
measured = width(text[start:end])
if measured > max_width:
continue
tail = best[j]
score = (tail[0] + 1, tail[1] + (preferences[j] if j < count - 1 else 0),
tail[2] + ((max_width - measured) / max_width) ** 2)
if best[i] is None or score < best[i]:
best[i], following[i] = score, j
if best[0] is None:
raise ValueError("paragraph cannot fit width while preserving punctuation, tokens and at least two content characters per line; enlarge bounds, rephrase or split the paragraph")
if max_lines is not None and best[0][0] > max_lines:
raise ValueError(f"paragraph requires {best[0][0]} valid lines, exceeding max_lines={max_lines}; enlarge bounds, rephrase or split the paragraph")
lines, current = [], 0
while current < count - 1:
next_index = following[current]
lines.append(text[points[current]:points[next_index]])
current = next_index
return lines
+2 -1
View File
@@ -51,7 +51,8 @@ def prepare(root: Path):
staging.mkdir()
shutil.copytree(skill / "runtime", staging / "runtime", ignore=shutil.ignore_patterns("__pycache__", "*.pyc", "*.pyo"))
(staging / "scripts").mkdir()
for name in ("native_export.py", "finalize_theme.py", "template_ref.py"):
for name in ("native_export.py", "finalize_theme.py", "template_ref.py", "math_component.py",
"component_typography.py", "paragraph_layout.py", "text_block.py", "layout_spacing.py"):
shutil.copyfile(skill / "scripts" / name, staging / "scripts" / name)
# The snapshot is an explicit build input; no installed Skill is needed
# after this project is moved to a different directory or machine.
+16 -17
View File
@@ -12,6 +12,7 @@ import unicodedata
import xml.etree.ElementTree as ET
import template_ref as ref
from component_typography import resolve_role
SVG = 'http://www.w3.org/2000/svg'
@@ -48,9 +49,9 @@ def _node(parent, tag, **attributes):
return ET.SubElement(parent, '{'+SVG+'}'+tag, {key.replace('_', '-'):str(value) for key,value in attributes.items()})
def _label(parent, value, x, y, font, size, color, align):
def _label(parent, value, x, y, font, size, color, align, weight='normal'):
result = _node(parent, 'text', x=x, y=y, font_family=font, font_size=size,
font_weight='normal', fill=color, text_anchor='end' if align == 'r' else 'middle' if align == 'ctr' else 'start')
font_weight=weight, fill=color, text_anchor='end' if align == 'r' else 'middle' if align == 'ctr' else 'start')
result.text = value
@@ -143,12 +144,10 @@ def render_grouped_table(raw: dict, style: dict, theme: dict, bounds: tuple,
ref.require(all(row[status['column']] in status['values'] for row in rows), 'unknown status value')
cfg = style.get('components',{}).get('table_grouped',{})
_fields(cfg, {'group_height_px','header_height_px','row_height_px','cell_padding_px'}, set(), 'style.components.table_grouped')
typography = style['typography']; roles = typography['roles']; families = typography['families']
font = families[roles['label']['family_role']]['family']
caption_font = families[roles['caption']['family_role']]['family']
size = _number(roles['label']['size_pt'],'label size')*_number(canvas['pt_to_px'],'pt_to_px')
small = _number(roles['caption']['size_pt'],'caption size')*canvas['pt_to_px']
ref.require(size > 0 and small > 0, 'font sizes must be positive')
label_role = resolve_role(style, style.get('components',{}).get('table',{}).get('text_role','label'))
caption_role = resolve_role(style, 'caption')
font, caption_font = label_role.family, caption_role.family
size, small = label_role.size, caption_role.size
padding = cfg.get('cell_padding_px',[8,16])
ref.require(isinstance(padding,list) and len(padding) == 2 and all(_number(v,'cell padding') >= 0 and v % 8 == 0 for v in padding), 'cell_padding_px requires two nonnegative 8px multiples')
top, side = padding
@@ -179,11 +178,11 @@ def render_grouped_table(raw: dict, style: dict, theme: dict, bounds: tuple,
start,span = group['start'],group['span']; gx=x+sum(widths[:start]); gw=sum(widths[start:start+span])
fill = palette['accent1' if i%2 == 0 else 'accent2']
_fit(group['label'],gw-2*side,small,'group header')
cell = {'paragraphs':[{'align':'ctr','runs':[{'text':group['label'],'font_size':small,'font_family':caption_font,'bold':False,'color':palette['lt1']}]}],'fill':fill,'color':palette['lt1'],'font_size':small,'bold':False,'align':'ctr','valign':'middle','padding':{'top':0,'bottom':0,'left':side,'right':side},'borders':none()}
cell = {'paragraphs':[{'align':'ctr','runs':[{'text':group['label'],'font_size':small,'font_family':caption_font,'bold':caption_role.bold,'color':palette['lt1']}]}],'fill':fill,'color':palette['lt1'],'font_size':small,'bold':caption_role.bold,'align':'ctr','valign':'middle','padding':{'top':0,'bottom':0,'left':side,'right':side},'borders':none()}
if span > 1: cell['col_span']=span
table_rows[start]=cell
_node(fallback,'rect',x=gx,y=y,width=gw,height=gh,fill=fill)
_label(fallback,group['label'],gx+gw/2,y+(gh+small*.72)/2,caption_font,small,palette['lt1'],'ctr')
_label(fallback,group['label'],gx+gw/2,y+(gh+small*.72)/2,caption_font,small,palette['lt1'],'ctr',caption_role.svg_weight)
table_rows = [table_rows]
group_ends = {g['start']+g['span']-1 for g in groups[:-1]}
cy = y+gh
@@ -203,24 +202,24 @@ def render_grouped_table(raw: dict, style: dict, theme: dict, bounds: tuple,
if focused:
borders['top']={'style':'solid','color':palette['accent1'],'width':1}
if ci == 0: borders['left']={'style':'solid','color':palette['accent1'],'width':4}
cell={'fill':fill,'color':color,'align':align,'font_size':size,'bold':False,'padding':pad,'valign':'middle','borders':borders}
cell={'fill':fill,'color':color,'align':align,'font_family':font,'font_size':size,'bold':label_role.bold,'padding':pad,'valign':'middle','borders':borders}
_node(fallback,'rect',x=cx,y=cy,width=widths[ci],height=cell_height,fill=fill)
tx=cx+widths[ci]-side if align == 'r' else cx+pad['left']
if header:
title,unit=value['label'],value.get('unit','')
_fit(title,widths[ci]-2*side,size,'column header')
cell['paragraphs']=[{'align':align,'runs':[{'text':title,'font_size':size,'font_family':font,'bold':False,'color':color}]}]
cell['paragraphs']=[{'align':align,'runs':[{'text':title,'font_size':size,'font_family':font,'bold':label_role.bold,'color':color}]}]
if unit:
_fit(unit,widths[ci]-2*side,small,'column unit')
cell['paragraphs'].append({'align':align,'runs':[{'text':unit,'font_size':small,'font_family':caption_font,'bold':False,'color':palette['dk2']}]})
cell['paragraphs'].append({'align':align,'runs':[{'text':unit,'font_size':small,'font_family':caption_font,'bold':caption_role.bold,'color':palette['dk2']}]})
baseline=cy+(cell_height-size-small-8)/2+size*.8
_label(fallback,title,tx,baseline,font,size,color,align)
_label(fallback,unit,tx,baseline+small+8,caption_font,small,palette['dk2'],align)
else: _label(fallback,title,tx,cy+(cell_height+size*.72)/2,font,size,color,align)
_label(fallback,title,tx,baseline,font,size,color,align,label_role.svg_weight)
_label(fallback,unit,tx,baseline+small+8,caption_font,small,palette['dk2'],align,caption_role.svg_weight)
else: _label(fallback,title,tx,cy+(cell_height+size*.72)/2,font,size,color,align,label_role.svg_weight)
else:
displayed=_formatted(value,columns[ci]); cell['text']=displayed
_fit(displayed,widths[ci]-pad['left']-side,size,'table cell')
_label(fallback,displayed,tx,cy+(cell_height+size*.72-(8 if ci in bar_columns else 0))/2,font,size,color,align)
_label(fallback,displayed,tx,cy+(cell_height+size*.72-(8 if ci in bar_columns else 0))/2,font,size,color,align,label_role.svg_weight)
if ci in bar_columns:
maximum=max(r[ci] for r in rows) or 1; track=widths[ci]-2*side
marks.append({'id':f'{component_id}-bar-r{ri}-c{ci}','kind':'bar','x':cx+side,'y':cy+cell_height-12,'width':track,'length':track*(value/maximum),'value':value,'domain':[0,maximum],'column':ci,'row':ri-1,'focused':focused})
+134
View File
@@ -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