Files
Templated-PPT-Master/scripts/basic_component.py
T

227 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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())