Release PPT workflow 0.7.1 with portable installation and production components

This commit is contained in:
2026-10-12 18:45:54 +08:00
parent 4e3271ff62
commit f932b1970d
40 changed files with 2166 additions and 28 deletions
+226
View File
@@ -0,0 +1,226 @@
#!/usr/bin/env python3
"""Render data-bound table, metric, timeline or linear-flow SVG components."""
from __future__ import annotations
import argparse
import json
import math
from pathlib import Path
import sys
import xml.etree.ElementTree as ET
import template_ref as ref
SVG = "http://www.w3.org/2000/svg"
ET.register_namespace("", SVG)
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"):
result = node(parent, "text", x=x, y=y, font_family=font, font_size=size,
font_weight="normal", fill=color, text_anchor=anchor)
result.text = str(value)
return result
def finite(value, name):
ref.require(type(value) in (int, float) and math.isfinite(value), f"{name}: expected finite number")
return float(value)
def text(value, name):
value = ref.string(value, name)
ref.require(not any(ord(char) < 32 for char in value), f"{name}: control characters unsupported")
return value
def fit(value, width, size, name):
# Conservative capacity only. Actual font/render checks remain necessary.
units = sum(1 if ord(c) > 255 else 0.6 for c in str(value))
ref.require(units * size <= width, f"{name}: text exceeds component capacity; rephrase or enlarge bounds")
def render_component(data: dict, style: dict, theme: dict, bounds: tuple,
component_id: str) -> ET.Element:
ref.require(isinstance(data, dict) and data.get("schema_version") == 1, "component data requires schema_version 1")
ref.identifier(component_id, "component ID")
ref.require(len(bounds) == 4, "bounds requires x/y/width/height")
x, y, w, h = [finite(v, "bounds") for v in bounds]
canvas = style["canvas"]
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
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})
metadata = node(group, "metadata", type="application/json")
metadata.text = json.dumps({"component_id": component_id, "data": data}, ensure_ascii=False)
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")
columns = [text(item, "column") for item in columns]
for row in rows:
ref.require(isinstance(row, list) and len(row) == len(columns), "table row length mismatch")
for value in row:
ref.require(type(value) in (str, int, float), "table cells require strings or numbers")
if type(value) in (int, float): finite(value, "cell")
else:
ref.require(not any(ord(char) < 32 for char in value), "cell: control characters unsupported")
cfg = style["components"]["table"]
top, side = cfg["cell_padding_px"]
row_height = math.ceil((size * roles["label"].get("line_height", 1.25) + 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")
widths = [finite(value, "column width") for value in widths]
ref.require(all(value > 0 for value in widths) and abs(sum(widths)-w) < 0.01, "column widths must fill bounds width")
numeric = data.get("numeric_columns", [])
ref.require(isinstance(numeric, list) and all(type(i) is int and 0 <= i < len(columns) for i in numeric), "numeric_columns invalid")
table_rows = []
for i, row in enumerate([columns, *rows]):
offset = x
cells = []
for j, value in enumerate(row):
color = palette[cfg["header_text_palette_index"]] if i == 0 else ink
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},
"border_color": line, "border_width": 1}
cells.append(cell)
fit(value, widths[j]-2*side, size, "table cell")
node(group, "rect", x=offset, y=y+i*row_height, width=widths[j], height=row_height,
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")
offset += widths[j]
table_rows.append(cells)
group.set("data-pptx-replace-with", "table")
metadata.text = json.dumps({"name": component_id, "x": x, "y": y, "width": w,
"height": row_height*(len(rows)+1), "rows": table_rows, "header_rows": 1, "strict_grid": True,
"column_widths": widths, "row_heights": [row_height]*(len(rows)+1),
"style": {"font_family": font, "font_size": size, "band_row": False}}, ensure_ascii=False)
group.set("data-component-source", json.dumps(data, ensure_ascii=False))
return group
if kind == "metric":
title = text(data.get("label"), "metric.label")
value = data.get("value")
ref.require(type(value) in (str, int, float), "metric.value requires string or number")
if type(value) in (int, float): finite(value, "metric.value")
else: text(value, "metric.value")
unit = data.get("unit", "")
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
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)
if unit:
span = node(value_node, "tspan", dx=16, font_size=size, fill=muted)
span.text = unit
if detail: label(group, detail, x, y+152, font, small, ink)
return group
items = data.get("items") if kind == "timeline" else data.get("nodes")
ref.require(isinstance(items, list) and 2 <= len(items) <= 6, "timeline/flow requires 2–6 items")
ref.require(all(isinstance(item, dict) for item in items), "items must be objects")
if kind == "timeline":
ref.require(h >= 208, "timeline needs at least 208px height")
slot = w/len(items)
centers = [x+slot*(i+.5) for i in range(len(items))]
cy = y+96
node(group, "line", x1=centers[0], y1=cy, x2=centers[-1], y2=cy, stroke=line, stroke_width=2)
for i, item in enumerate(items):
title, date = text(item.get("label"), "timeline label"), text(item.get("date"), "timeline date")
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")
return group
ref.require(h >= 160, "flow needs at least 160px height")
ids = [ref.identifier(item.get("id"), "flow node ID") for item in items]
ref.require(len(ids) == len(set(ids)), "duplicate flow node ID")
edges = data.get("edges")
ref.require(isinstance(edges, list), "flow requires explicit edges")
expected = {(ids[i], ids[i+1]) for i in range(len(ids)-1)}
actual = []
for edge in edges:
ref.require(isinstance(edge, list) and len(edge) == 2, "flow edge requires [from,to]")
ref.require(all(isinstance(value, str) for value in edge), "edge node IDs must be strings")
actual.append(tuple(edge))
ref.require(len(actual) == len(set(actual)) and set(actual) <= expected,
"linear flow supports only distinct forward adjacent edges; compose other diagrams manually")
gap = 32
nw = (w-gap*(len(items)-1))/len(items)
ref.require(nw >= 96, "flow too narrow")
cy = y+80
for i, item in enumerate(items):
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")
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)
node(group, "path", d=f"M{end-8},{cy-4} L{end},{cy} L{end-8},{cy+4} Z", fill=muted)
return group
def main():
parser = argparse.ArgumentParser(description=__doc__)
for flag in ("manifest", "theme", "data", "id", "output"):
parser.add_argument(f"--{flag}", required=True, action="append")
parser.add_argument("--bounds", nargs=4, type=float, required=True, action="append")
args = parser.parse_args()
try:
ref.require(len(args.bounds) == 1, "Only one --bounds allowed")
args.bounds = args.bounds[0]
for flag in ("manifest", "theme", "data", "id", "output"):
values = getattr(args, flag);ref.require(len(values) == 1, f"Only one --{flag} allowed")
setattr(args, flag, values[0])
path = Path(args.manifest).resolve();manifest = ref.load_manifest(path)
ref.require(manifest["schema_version"] == 2 and args.theme in manifest["themes"], "requires one v2 template/theme")
_, style, _, theme, _ = ref.style_inputs(path.parent, manifest, manifest["themes"][args.theme], args.theme)
data = ref.read_json(Path(args.data), (1,))
component = render_component(data, style, theme, tuple(args.bounds), args.id)
canvas = style["canvas"]
root = ET.Element(f"{{{SVG}}}svg", {"width": str(canvas["width"]), "height": str(canvas["height"]),
"viewBox": canvas["viewbox"]})
root.append(component)
output = Path(args.output)
ref.require(output.suffix == ".svg" and not output.exists() and not output.is_symlink(), "requires new .svg output")
ref.require(not any(p.is_symlink() for p in output.parents), "output ancestors cannot be symlinks")
output.parent.mkdir(parents=True, exist_ok=True)
with output.open("xb") as stream: ET.ElementTree(root).write(stream, encoding="utf-8", xml_declaration=True)
print(json.dumps({"kind": data["kind"], "output": str(output), "scope": "component SVG source, no page UI or render acceptance"}))
return 0
except (ref.InputError, OSError, ValueError, KeyError, TypeError, ET.ParseError) as error:
print(json.dumps({"error": str(error)}, ensure_ascii=False), file=sys.stderr)
return 2
if __name__ == "__main__":
raise SystemExit(main())