Release PPT workflow 0.7.3 with fixed spacing, paragraphs and native math
This commit is contained in:
+26
-22
@@ -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
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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"}
|
||||
@@ -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)}
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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})
|
||||
|
||||
@@ -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