231 lines
13 KiB
Python
231 lines
13 KiB
Python
#!/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
|
||
from component_typography import resolve_role
|
||
|
||
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", weight="normal"):
|
||
result = node(parent, "text", x=x, y=y, font_family=font, font_size=size,
|
||
font_weight=weight, 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")
|
||
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})
|
||
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")
|
||
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"]
|
||
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((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")
|
||
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_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")
|
||
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", table_role.svg_weight)
|
||
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")
|
||
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, 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_family=font, font_size=size,
|
||
font_weight=label_role.svg_weight, fill=muted)
|
||
span.text = unit
|
||
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")
|
||
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, 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")
|
||
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", 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)
|
||
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())
|