#!/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())