Release PPT workflow 0.7.1 with portable installation and production components
This commit is contained in:
@@ -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())
|
||||
Reference in New Issue
Block a user