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)