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())
|
||||
@@ -0,0 +1,433 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Render a data-bound SVG component with a native PowerPoint Chart marker."""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from copy import deepcopy
|
||||
import json
|
||||
import math
|
||||
from pathlib import Path
|
||||
import re
|
||||
import sys
|
||||
import unicodedata
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
import template_ref as ref
|
||||
|
||||
SVG = "http://www.w3.org/2000/svg"
|
||||
ET.register_namespace("", SVG)
|
||||
TYPES = ("column", "bar", "line", "scatter", "pie", "doughnut", "column-stacked")
|
||||
ID = re.compile(r"^[A-Za-z][A-Za-z0-9_-]{0,63}$")
|
||||
|
||||
|
||||
def require(condition, message):
|
||||
if not condition:
|
||||
raise ValueError(message)
|
||||
|
||||
|
||||
def number(value, name):
|
||||
require(isinstance(value, (int, float)) and not isinstance(value, bool), f"{name}: number required")
|
||||
require(math.isfinite(value), f"{name}: finite number required")
|
||||
# The SVG and OOXML scalar calculations must not overflow or underflow.
|
||||
require(value == 0 or 1e-9 <= abs(value) <= 1e12, f"{name}: supported nonzero magnitude is 1e-9..1e12")
|
||||
return value
|
||||
|
||||
|
||||
def text(value, name, *, empty=False):
|
||||
require(isinstance(value, str) and (empty or bool(value.strip())), f"{name}: {'string' if empty else 'nonempty string'} required")
|
||||
require(not any(unicodedata.category(c).startswith('C') for c in value), f"{name}: control/format characters unsupported")
|
||||
return value
|
||||
|
||||
|
||||
def fields(value, allowed, required, name):
|
||||
require(isinstance(value, dict), f"{name}: object required")
|
||||
require(set(value) <= allowed, f"{name}: unknown fields {sorted(set(value) - allowed)}")
|
||||
require(required <= set(value), f"{name}: missing fields {sorted(required - set(value))}")
|
||||
|
||||
|
||||
def validate_data(raw):
|
||||
fields(raw, {"schema_version", "data_kind", "type", "title", "unit", "x_unit", "source", "categories", "series"},
|
||||
{"schema_version", "data_kind", "type", "title", "unit", "series"}, "chart")
|
||||
require(type(raw['schema_version']) is int and raw['schema_version'] == 1, "chart schema_version must be 1")
|
||||
require(raw['data_kind'] in ('synthetic-demo', 'observed', 'derived'), "data_kind must be synthetic-demo, observed or derived")
|
||||
require(raw['type'] in TYPES, f"type must be one of {TYPES}")
|
||||
for key in ('title', 'unit'):
|
||||
text(raw[key], key, empty=key == 'unit')
|
||||
for key in ('source', 'x_unit'):
|
||||
if key in raw:
|
||||
text(raw[key], key, empty=True)
|
||||
kind = raw['type']
|
||||
if kind == 'scatter':
|
||||
require('categories' not in raw, "scatter uses series.points, not categories")
|
||||
else:
|
||||
require('x_unit' not in raw, "x_unit is supported for scatter only")
|
||||
require(isinstance(raw.get('categories'), list) and bool(raw['categories']), "categories must be nonempty list")
|
||||
for category in raw['categories']:
|
||||
text(category, 'category')
|
||||
require(len(set(raw['categories'])) == len(raw['categories']), "category labels must be unique")
|
||||
require(isinstance(raw['series'], list) and bool(raw['series']), "series must be nonempty list")
|
||||
series_ids = set()
|
||||
for s in raw['series']:
|
||||
fields(s, {'id', 'name', 'points'} if kind == 'scatter' else {'id', 'name', 'values'},
|
||||
{'id', 'name', 'points'} if kind == 'scatter' else {'id', 'name', 'values'}, 'series')
|
||||
require(isinstance(s['id'], str) and ID.fullmatch(s['id']), "series id must be stable SVG-safe ID")
|
||||
require(s['id'] not in series_ids, 'series IDs must be unique')
|
||||
series_ids.add(s['id'])
|
||||
text(s['name'], 'series.name')
|
||||
values = s.get('points') if kind == 'scatter' else s.get('values')
|
||||
require(isinstance(values, list) and bool(values), 'series data must be nonempty list')
|
||||
if kind == 'scatter':
|
||||
for point in values:
|
||||
require(isinstance(point, list) and len(point) == 2, 'scatter point must be [x, y]')
|
||||
for val in point:
|
||||
number(val, 'scatter coordinate')
|
||||
else:
|
||||
require(len(values) == len(raw['categories']), 'series values must match categories length')
|
||||
for val in values:
|
||||
number(val, 'series value')
|
||||
if kind in ('pie', 'doughnut', 'column-stacked'):
|
||||
require(val >= 0, f'{kind} initial support requires nonnegative values')
|
||||
if kind in ('pie', 'doughnut'):
|
||||
require(len(raw['series']) == 1, 'pie/doughnut requires exactly one series')
|
||||
require(sum(raw['series'][0]['values']) > 0, 'pie/doughnut requires positive total')
|
||||
return deepcopy(raw)
|
||||
|
||||
|
||||
def _n(value):
|
||||
return f'{value:.10g}'
|
||||
|
||||
|
||||
def _node(parent, tag, **attrs):
|
||||
return ET.SubElement(parent, '{' + SVG + '}' + tag, {k.replace('_', '-'): _n(v) if isinstance(v, (float, int)) else str(v) for k, v in attrs.items()})
|
||||
|
||||
|
||||
def _text(parent, x, y, value, font, size, color, anchor='start'):
|
||||
node = _node(parent, 'text', x=x, y=y, fill=color, font_family=font, font_size=size, text_anchor=anchor)
|
||||
node.text = value
|
||||
return node
|
||||
|
||||
|
||||
def _width(value, size):
|
||||
# Conservative capacity check, not a substitute for application rendering.
|
||||
return sum(1 if unicodedata.east_asian_width(c) in ('W', 'F') else .6 for c in value) * size
|
||||
|
||||
|
||||
def _fit(value, size, width, name):
|
||||
require(_width(value, size) <= width, f'{name} exceeds available width; shorten text or enlarge bounds (font is not reduced)')
|
||||
|
||||
|
||||
def _range(values):
|
||||
low, high = min(0, min(values)), max(0, max(values))
|
||||
if low == high:
|
||||
return 0.0, 1.0, .25
|
||||
span = high - low
|
||||
exponent = math.floor(math.log10(span / 4))
|
||||
base = 10.0 ** exponent
|
||||
raw = span / 4 / base
|
||||
step = next(candidate for candidate in (1, 2, 2.5, 5, 10) if candidate >= raw) * base
|
||||
low = math.floor(low / step) * step
|
||||
high = math.ceil(high / step) * step
|
||||
return low, high, step
|
||||
|
||||
|
||||
def _ticks(scale):
|
||||
low, high, step = scale
|
||||
return [low + i * step for i in range(round((high - low) / step) + 1)]
|
||||
|
||||
|
||||
def _label(value):
|
||||
return '0' if abs(value) < 1e-14 else f'{value:.6g}'
|
||||
|
||||
|
||||
def _axis(scale):
|
||||
low, high, step = scale
|
||||
return {'minimum': low, 'maximum': high, 'major_unit': step,
|
||||
'major_gridlines': True, 'label_position': 'low',
|
||||
'number_format': '0.######E+00' if max(abs(low), abs(high)) >= 1e6 or 0 < step < .001 else 'General'}
|
||||
|
||||
|
||||
def _style(style, theme):
|
||||
require(isinstance(style, dict) and isinstance(theme, dict), 'style/theme must be objects')
|
||||
chart = style.get('components', {}).get('charts', {})
|
||||
require(isinstance(chart, dict), 'style.components.charts must be object')
|
||||
typography = style['typography']
|
||||
role = chart.get('text_role', 'caption' if 'caption' in typography['roles'] else 'label')
|
||||
require(role in typography['roles'], 'chart text_role missing from typography.roles')
|
||||
label = typography['roles'][role]
|
||||
family = typography['families']['body']['family']
|
||||
px_per_pt = number(style['canvas']['pt_to_px'], 'canvas.pt_to_px')
|
||||
require(px_per_pt > 0, 'canvas.pt_to_px must be positive')
|
||||
size = number(label['size_pt'], 'chart size_pt') * px_per_pt
|
||||
require(size > 0, 'chart text size must be positive')
|
||||
palette = theme['palette']
|
||||
indices = chart.get('series_palette_indices', [k for k in ('accent1', 'accent3', 'accent4', 'accent5', 'accent6', 'accent2') if k in palette])
|
||||
require(isinstance(indices, list) and bool(indices), 'series_palette_indices must be nonempty list')
|
||||
require(len(set(indices)) == len(indices), 'series_palette_indices must be unique')
|
||||
keys = [*indices, chart.get('text_palette_index', 'dk1'), chart.get('grid_palette_index', 'line' if 'line' in palette else 'dk2'), 'lt1']
|
||||
axis_index = chart.get('axis_palette_index', chart.get('text_palette_index', 'dk1'))
|
||||
require(isinstance(axis_index, str) and axis_index in palette and re.fullmatch(r'#[0-9A-Fa-f]{6}', palette[axis_index]), 'invalid/missing axis_palette_index')
|
||||
for key in keys:
|
||||
require(isinstance(key, str) and key in palette and re.fullmatch(r'#[0-9A-Fa-f]{6}', palette[key]), f'invalid/missing palette color: {key}')
|
||||
return family, size, [palette[k] for k in indices], palette[keys[-3]], palette[keys[-2]], palette['lt1'], palette[axis_index]
|
||||
|
||||
|
||||
def render_chart(data: dict, style: dict, theme: dict,
|
||||
bounds: tuple[float, float, float, float], component_id: str) -> ET.Element:
|
||||
"""Return an isolated SVG g; insert it into the caller's selected page/Layout.
|
||||
|
||||
Metadata and visible fallback share validated input. bar deliberately leaves
|
||||
axes automatic in native Office, because the pinned compiler cannot configure
|
||||
bar axes. Its SVG ticks therefore preview a scale, not Office's eventual ticks.
|
||||
"""
|
||||
data = validate_data(data)
|
||||
require(isinstance(component_id, str) and ID.fullmatch(component_id), 'component_id must be SVG-safe ID')
|
||||
require(isinstance(bounds, (tuple, list)) and len(bounds) == 4, 'bounds must be [x, y, width, height]')
|
||||
x, y, w, h = [number(v, 'bounds') for v in bounds]
|
||||
require(x >= 0 and y >= 0 and w > 0 and h > 0, 'bounds must have nonnegative origin and positive size')
|
||||
canvas = style['canvas']
|
||||
require(x + w <= canvas['width'] and y + h <= canvas['height'], 'component bounds exceed template canvas')
|
||||
font, fs, colors, ink, grid, background, axis_color = _style(style, theme)
|
||||
require(w >= fs * 14 and h >= fs * 14, 'bounds too small for declared chart font and annotations')
|
||||
kind = data['type']
|
||||
value_labels = style.get('components', {}).get('charts', {}).get('value_labels', False)
|
||||
require(type(value_labels) is bool, 'chart value_labels must be boolean')
|
||||
if value_labels:
|
||||
require(kind == 'column', 'optional value_labels supports nonnegative grouped column only; bar already labels values')
|
||||
require(all(value >= 0 for series in data['series'] for value in series['values']),
|
||||
'optional column value_labels requires nonnegative values')
|
||||
pie = kind in ('pie', 'doughnut')
|
||||
count = len(data['categories']) if pie else len(data['series'])
|
||||
require(count <= len(colors), 'insufficient distinct palette indices for series/slices; configure style.components.charts')
|
||||
colors = colors[:count]
|
||||
source = data.get('source', '')
|
||||
source_line = ('示例数据(合成)' if data['data_kind'] == 'synthetic-demo' else '')
|
||||
if source:
|
||||
source_line += (';' if source_line else '') + '来源:' + source
|
||||
if kind == 'scatter':
|
||||
unit_parts = []
|
||||
if data.get('x_unit'):
|
||||
unit_parts.append('X:' + data['x_unit'])
|
||||
if data['unit']:
|
||||
unit_parts.append('Y:' + data['unit'])
|
||||
unit = ';'.join(unit_parts)
|
||||
else:
|
||||
unit = '单位:' + data['unit'] if data['unit'] else ''
|
||||
for value, name in ((data['title'], 'title'), (unit, 'unit'), (source_line, 'source')):
|
||||
_fit(value, fs, w - 16, name)
|
||||
outer = ET.Element('{' + SVG + '}g', {'id': component_id, 'data-component': 'chart', 'data-chart-type': kind})
|
||||
_text(outer, x + 8, y + fs, data['title'], font, fs, ink)
|
||||
top = y + math.ceil((fs + 16) / 8) * 8
|
||||
if unit:
|
||||
_text(outer, x + 8, top + fs, unit, font, fs, ink)
|
||||
top += math.ceil((fs + 8) / 8) * 8
|
||||
bottom = y + h - (math.ceil((fs + 8) / 8) * 8 if source_line else 8)
|
||||
if source_line:
|
||||
_text(outer, x + 8, y + h - 8, source_line, font, fs, ink)
|
||||
|
||||
legends = [f'{c} {_label(v)}' for c, v in zip(data.get('categories', []), data['series'][0].get('values', []))] if pie else [s['name'] for s in data['series']]
|
||||
legend_widths = [32 + _width(label, fs) + 16 for label in legends]
|
||||
require(all(lw <= w - 16 for lw in legend_widths), 'legend label too wide for bounds')
|
||||
rows, row = [], []
|
||||
used = 0
|
||||
for index, width in enumerate(legend_widths):
|
||||
if row and used + width > w - 16:
|
||||
rows.append(row);row = [];used = 0
|
||||
row.append((index, width));used += width
|
||||
if row:
|
||||
rows.append(row)
|
||||
legend_h = len(rows) * math.ceil((fs + 8) / 8) * 8
|
||||
legend_y = bottom - legend_h
|
||||
for r, items in enumerate(rows):
|
||||
lx = x + 8
|
||||
ly = legend_y + r * math.ceil((fs + 8) / 8) * 8 + fs
|
||||
for idx, width in items:
|
||||
_node(outer, 'rect', x=lx, y=ly-fs*.65, width=16, height=16, fill=colors[idx])
|
||||
_text(outer, lx+24, ly, legends[idx], font, fs, ink)
|
||||
lx += width
|
||||
chart_h = legend_y - top - 8
|
||||
require(chart_h >= fs * 7, 'chart/legend/source too tall for bounds; enlarge region or reduce categories/legend text')
|
||||
marker = _node(outer, 'g', id=component_id+'-native', data_pptx_replace_with='chart')
|
||||
payload = {'name': component_id, 'x': x, 'y': top, 'width': w, 'height': chart_h,
|
||||
'type': 'column' if kind == 'column-stacked' else kind,
|
||||
'show_legend': False,
|
||||
'style': {'colors': colors, 'font_family': font, 'font_size': fs, 'axis_font_size': fs,
|
||||
'text_color': ink, 'axis_color': axis_color, 'grid_color': grid,
|
||||
'chart_area_fill': background, 'plot_area_fill': background},
|
||||
'component_data': deepcopy(data)}
|
||||
metadata = _node(marker, 'metadata', type='application/json')
|
||||
if value_labels:
|
||||
payload['data_labels'] = {'position': 'outside_end', 'show_value': True,
|
||||
'font_size': fs, 'color': ink, 'number_format': 'General'}
|
||||
if kind == 'line':
|
||||
payload['markers'] = True
|
||||
fallback = _node(marker, 'g', id=component_id+'-fallback')
|
||||
_node(fallback, 'rect', x=x, y=top, width=w, height=chart_h, fill=background)
|
||||
if kind == 'scatter':
|
||||
payload['series'] = [{'name': s['name'], 'points': s['points']} for s in data['series']]
|
||||
xs = [p[0] for s in data['series'] for p in s['points']]
|
||||
ys = [p[1] for s in data['series'] for p in s['points']]
|
||||
xscale, yscale = _range(xs), _range(ys)
|
||||
payload['axes'] = {'x': _axis(xscale), 'y': _axis(yscale)}
|
||||
else:
|
||||
payload['categories'] = data['categories']
|
||||
payload['series'] = [{'name': s['name'], 'values': s['values']} for s in data['series']]
|
||||
if kind == 'column-stacked':
|
||||
payload['grouping'] = 'stacked'
|
||||
if not pie:
|
||||
values = ([sum(s['values'][i] for s in data['series']) for i in range(len(data['categories']))]
|
||||
if kind == 'column-stacked' else [v for s in data['series'] for v in s['values']])
|
||||
yscale = _range(values)
|
||||
if kind != 'bar':
|
||||
payload['axes'] = {'category': {'label_position': 'low', 'major_gridlines': False}, 'value': _axis(yscale)}
|
||||
else:
|
||||
payload['data_labels'] = {'position': 'outside_end'}
|
||||
payload['show_value_axis_labels'] = True
|
||||
outer.set('data-native-axis-policy', 'automatic-bar-preview-scale-may-differ')
|
||||
if pie:
|
||||
diameter = min(w - 32, chart_h - 16)
|
||||
radius = diameter / 2
|
||||
cx, cy = x+w/2, top+chart_h/2
|
||||
total = sum(data['series'][0]['values'])
|
||||
angle = -math.pi / 2
|
||||
for i, val in enumerate(data['series'][0]['values']):
|
||||
if val == 0:
|
||||
continue
|
||||
end = angle + val / total * math.tau
|
||||
if math.isclose(val, total):
|
||||
_node(fallback, 'circle', cx=cx, cy=cy, r=radius, fill=colors[i])
|
||||
else:
|
||||
start_x, start_y = cx+radius*math.cos(angle), cy+radius*math.sin(angle)
|
||||
end_x, end_y = cx+radius*math.cos(end), cy+radius*math.sin(end)
|
||||
path = f'M {_n(cx)} {_n(cy)} L {_n(start_x)} {_n(start_y)} A {_n(radius)} {_n(radius)} 0 {int(end-angle > math.pi)} 1 {_n(end_x)} {_n(end_y)} Z'
|
||||
_node(fallback, 'path', d=path, fill=colors[i])
|
||||
angle = end
|
||||
if kind == 'doughnut':
|
||||
# The pinned native writer fixes holeSize to 75%.
|
||||
_node(fallback, 'circle', cx=cx, cy=cy, r=radius*.75, fill=background)
|
||||
elif kind == 'bar':
|
||||
ticks = _ticks(yscale)
|
||||
labels_width = max(_width(c, fs) for c in data['categories']) + 16
|
||||
value_width = max(_width(_label(v), fs) for s in data['series'] for v in s['values'])
|
||||
left, right = x + labels_width + value_width + 8, x+w-value_width-16
|
||||
pt, pb = top+8, top+chart_h-fs-16
|
||||
require(right-left >= fs*7, 'bar category labels leave insufficient plot width')
|
||||
low, high, step = yscale
|
||||
xp = lambda val: left+(val-low)/(high-low)*(right-left)
|
||||
for tick in ticks:
|
||||
_node(fallback, 'line', x1=xp(tick), y1=pt, x2=xp(tick), y2=pb, stroke=grid, stroke_width=1)
|
||||
_text(fallback, xp(tick), pb+fs+8, _label(tick), font, fs, ink, 'middle')
|
||||
band = (pb-pt)/len(data['categories'])
|
||||
bw = band/(len(data['series'])+1.5)
|
||||
require(band >= fs+8 and bw >= fs+4, 'too many bar categories/series for fixed labels and bounds')
|
||||
for i, category in enumerate(data['categories']):
|
||||
center = pt+(i+.5)*band
|
||||
_text(fallback, x+labels_width-8, center+fs*.35, category, font, fs, ink, 'end')
|
||||
for j, s in enumerate(data['series']):
|
||||
value = s['values'][i]
|
||||
x0, x1 = xp(0), xp(value)
|
||||
bar_y = center-(len(data['series'])*bw)/2+j*bw
|
||||
_node(fallback, 'rect', x=min(x0,x1), y=bar_y, width=abs(x1-x0), height=bw, fill=colors[j])
|
||||
_text(fallback, x1 + (8 if value >= 0 else -8), bar_y+bw/2+fs*.35, _label(value), font, fs, ink, 'start' if value >= 0 else 'end')
|
||||
else:
|
||||
ticks = _ticks(yscale)
|
||||
tick_width = max(_width(_label(t), fs) for t in ticks)
|
||||
left, right = x+tick_width+16, x+w-16
|
||||
pt, pb = top+8, top+chart_h-fs-16
|
||||
require(right-left >= fs*7 and (pb-pt)/(len(ticks)-1) >= fs+4, 'axis labels leave insufficient plot area')
|
||||
low, high, step = yscale
|
||||
yp = lambda val: pb-(val-low)/(high-low)*(pb-pt)
|
||||
for tick in ticks:
|
||||
_node(fallback, 'line', x1=left, y1=yp(tick), x2=right, y2=yp(tick), stroke=axis_color if abs(tick)<step*1e-6 else grid, stroke_width=1)
|
||||
_text(fallback, left-8, yp(tick)+fs*.35, _label(tick), font, fs, ink, 'end')
|
||||
if kind == 'scatter':
|
||||
xl, xh, _ = xscale
|
||||
xp = lambda val: left+(val-xl)/(xh-xl)*(right-left)
|
||||
xticks = _ticks(xscale)
|
||||
require((right-left)/(len(xticks)-1) >= max(_width(_label(t),fs) for t in xticks)+8, 'scatter X tick labels too crowded')
|
||||
for tick in xticks:
|
||||
_node(fallback, 'line', x1=xp(tick), y1=pt, x2=xp(tick), y2=pb, stroke=grid, stroke_width=1)
|
||||
_text(fallback, xp(tick), pb+fs+8, _label(tick), font, fs, ink, 'middle')
|
||||
for j, s in enumerate(data['series']):
|
||||
for vx, vy in s['points']:
|
||||
_node(fallback, 'circle', cx=xp(vx), cy=yp(vy), r=5, fill=colors[j])
|
||||
else:
|
||||
band = (right-left)/len(data['categories'])
|
||||
for i, category in enumerate(data['categories']):
|
||||
_fit(category, fs, band-8, 'category')
|
||||
center = left+(i+.5)*band
|
||||
_text(fallback, center, pb+fs+8, category, font, fs, ink, 'middle')
|
||||
if kind in ('column', 'column-stacked'):
|
||||
bw = band/(len(data['series'])+1.5) if kind == 'column' else band/2.5
|
||||
require(bw >= 8, 'too many series/categories for plot width')
|
||||
accumulated = 0
|
||||
for j, s in enumerate(data['series']):
|
||||
val = s['values'][i]
|
||||
bottom_value = accumulated if kind == 'column-stacked' else 0
|
||||
top_value = bottom_value+val
|
||||
xpos = center-bw/2 if kind == 'column-stacked' else center-len(data['series'])*bw/2+j*bw
|
||||
_node(fallback, 'rect', x=xpos, y=min(yp(top_value),yp(bottom_value)), width=bw, height=abs(yp(top_value)-yp(bottom_value)), fill=colors[j])
|
||||
if value_labels:
|
||||
_fit(_label(val), fs, bw - 4, 'column value label')
|
||||
_text(fallback, xpos+bw/2, yp(val)-8, _label(val), font, fs, ink, 'middle')
|
||||
accumulated += val
|
||||
if kind == 'line':
|
||||
payload['line_style'] = 'lineMarker'
|
||||
for j, s in enumerate(data['series']):
|
||||
points = [(left+(i+.5)*band, yp(v)) for i,v in enumerate(s['values'])]
|
||||
_node(fallback, 'polyline', points=' '.join(f'{_n(px)},{_n(py)}' for px,py in points), fill='none', stroke=colors[j], stroke_width=2)
|
||||
for px,py in points:
|
||||
_node(fallback, 'circle', cx=px, cy=py, r=4, fill=colors[j])
|
||||
metadata.text = json.dumps(payload, ensure_ascii=False, allow_nan=False, separators=(',', ':'))
|
||||
runtime = Path(__file__).resolve().parent.parent / 'runtime/ppt-master/scripts'
|
||||
if str(runtime) not in sys.path:
|
||||
sys.path.insert(0, str(runtime))
|
||||
from pptx_shapes import NATIVE_FALLBACK_SHA256_ATTR, svg_native_fallback_fingerprint
|
||||
marker.set(NATIVE_FALLBACK_SHA256_ATTR, svg_native_fallback_fingerprint(marker))
|
||||
return outer
|
||||
|
||||
|
||||
def _json(path):
|
||||
def pairs(items):
|
||||
result = {}
|
||||
for key, value in items:
|
||||
require(key not in result, f'duplicate JSON key: {key}')
|
||||
result[key] = value
|
||||
return result
|
||||
return json.loads(path.read_text(encoding='utf-8'), object_pairs_hook=pairs,
|
||||
parse_constant=lambda token: (_ for _ in ()).throw(ValueError(f'nonfinite JSON value: {token}')))
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--manifest', type=Path, required=True)
|
||||
parser.add_argument('--theme', required=True)
|
||||
parser.add_argument('--data', type=Path, required=True)
|
||||
parser.add_argument('--bounds', type=float, nargs=4, required=True)
|
||||
parser.add_argument('--id', required=True)
|
||||
parser.add_argument('--output', type=Path, required=True)
|
||||
args = parser.parse_args()
|
||||
try:
|
||||
require(args.manifest.is_absolute(), '--manifest must be absolute')
|
||||
manifest = ref.load_manifest(args.manifest)
|
||||
args.layout = None;args.mode = None
|
||||
selection = ref.export_style(args.manifest, manifest, args)
|
||||
component = render_chart(_json(args.data), selection['style'], selection['theme_data'], tuple(args.bounds), args.id)
|
||||
require(args.output.suffix.lower() == '.svg', 'output must end in .svg')
|
||||
require(not args.output.exists() and not args.output.is_symlink(), 'output already exists')
|
||||
require(not any(p.is_symlink() for p in args.output.parents), 'output ancestors cannot be symlinks')
|
||||
canvas = selection['style']['canvas']
|
||||
root = ET.Element('{' + SVG + '}svg', {'width': str(canvas['width']), 'height': str(canvas['height']), 'viewBox': canvas['viewbox']})
|
||||
root.append(component)
|
||||
args.output.parent.mkdir(parents=True, exist_ok=True)
|
||||
with args.output.open('xb') as stream:
|
||||
ET.ElementTree(root).write(stream, encoding='utf-8', xml_declaration=True)
|
||||
print(json.dumps({'output': str(args.output), 'type': component.get('data-chart-type'), 'theme': args.theme,
|
||||
'verification_scope': 'validated component SVG and native marker; native application rendering unverified'}, ensure_ascii=False))
|
||||
return 0
|
||||
except (ref.InputError, OSError, ValueError, TypeError, KeyError, ET.ParseError) as error:
|
||||
print(json.dumps({'error': str(error)}, ensure_ascii=False), file=sys.stderr)
|
||||
return 2
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,224 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Install the checked-out root Skill, or verify it, without another Skill."""
|
||||
|
||||
import argparse
|
||||
import copy
|
||||
from datetime import datetime, timezone
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import uuid
|
||||
|
||||
if sys.version_info < (3, 12):
|
||||
raise SystemExit("Use Python 3.12+ to install the complete native workflow.")
|
||||
import tomllib
|
||||
|
||||
|
||||
def sha(path):
|
||||
return hashlib.sha256(path.read_bytes()).hexdigest()
|
||||
|
||||
|
||||
def roster(root):
|
||||
files = {}
|
||||
for path in sorted(root.rglob("*")):
|
||||
relative = path.relative_to(root)
|
||||
if any(part.startswith(".") or part == "__pycache__" for part in relative.parts):
|
||||
continue
|
||||
if path.is_symlink():
|
||||
raise ValueError(f"Source symlink is unsupported: {relative}")
|
||||
if path.is_file() and path.suffix != ".pyc":
|
||||
files[relative.as_posix()] = sha(path)
|
||||
return files
|
||||
|
||||
|
||||
def safe_target(path):
|
||||
path = Path(os.path.abspath(path.expanduser()))
|
||||
if any(item.is_symlink() for item in (path, *path.parents)):
|
||||
raise ValueError(f"Destination symlink is unsupported: {path}")
|
||||
return path
|
||||
|
||||
|
||||
def compiler_integrity(source):
|
||||
provenance = json.loads((source / "runtime/provenance.json").read_text())
|
||||
root = source / "runtime/ppt-master"
|
||||
for item in provenance["files"]:
|
||||
path = root / item["path"]
|
||||
if not path.resolve().is_relative_to(root) or sha(path) != item["sha256"]:
|
||||
raise ValueError(f"Compiler integrity failed: {item['path']}")
|
||||
return len(provenance["files"])
|
||||
|
||||
|
||||
def runtime_check(python, source):
|
||||
code = """
|
||||
import importlib.metadata, json, pathlib, re, sys
|
||||
if sys.version_info < (3, 12):
|
||||
raise RuntimeError('Native runtime needs Python 3.12+')
|
||||
source = pathlib.Path(sys.argv[1])
|
||||
versions = {}
|
||||
for line in (source / 'runtime/requirements.txt').read_text().splitlines():
|
||||
if not line or line.startswith('#'): continue
|
||||
name, expected = line.split('==')
|
||||
actual = importlib.metadata.version(name)
|
||||
if actual != expected: raise RuntimeError(f'{name}: expected {expected}, got {actual}')
|
||||
versions[name] = actual
|
||||
sys.path.insert(0, str(source / 'scripts'))
|
||||
from native_export import load_compiler
|
||||
load_compiler()
|
||||
print(json.dumps({'python': sys.version.split()[0], 'packages': versions, 'compiler_loaded': True}))
|
||||
"""
|
||||
result = subprocess.run([str(python), "-c", code, str(source)],
|
||||
text=True, capture_output=True, check=True)
|
||||
return json.loads(result.stdout)
|
||||
|
||||
|
||||
def config_plan(codex, target):
|
||||
path = safe_target(codex / "config.toml")
|
||||
before = path.read_text(encoding="utf-8") if path.is_file() else ""
|
||||
data = tomllib.loads(before)
|
||||
entries = data.get("skills", {}).get("config", [])
|
||||
root_entry = str(target / "SKILL.md")
|
||||
nested = str(target / "runtime/ppt-master/SKILL.md")
|
||||
if any(item.get("path") == root_entry and item.get("enabled") is False for item in entries):
|
||||
raise ValueError("Root Skill is disabled in Codex; preserve that decision and resolve it explicitly.")
|
||||
matches = [item for item in entries if item.get("path") == nested]
|
||||
if matches:
|
||||
if len(matches) != 1 or matches[0].get("enabled") is not False:
|
||||
raise ValueError("Conflicting nested attribution entry; resolve this exact Codex entry explicitly.")
|
||||
return path, before, before
|
||||
addition = f"\n[[skills.config]]\npath = {json.dumps(nested, ensure_ascii=False)}\nenabled = false\n"
|
||||
after = before + addition
|
||||
parsed = tomllib.loads(after)
|
||||
expected = copy.deepcopy(data)
|
||||
expected.setdefault("skills", {}).setdefault("config", []).append({"path": nested, "enabled": False})
|
||||
if parsed != expected:
|
||||
raise ValueError("Codex configuration change affected unrelated fields.")
|
||||
return path, before, after
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--dest", type=Path, required=True, help="Exact installed Skill directory")
|
||||
parser.add_argument("--codex-home", type=Path, help="Exclude only the nested attribution Skill")
|
||||
parser.add_argument("--venv", type=Path, help="Dedicated Python 3.12+ virtual environment")
|
||||
parser.add_argument("--prepare-runtime", action="store_true", help="Create venv and install pinned packages")
|
||||
parser.add_argument("--replace", action="store_true", help="Back up and replace an existing Skill")
|
||||
parser.add_argument("--backup-root", type=Path, help="Backup root outside Skill discovery directories")
|
||||
parser.add_argument("--check", action="store_true", help="Read-only payload, configuration and runtime check")
|
||||
args = parser.parse_args()
|
||||
source = Path(__file__).resolve().parents[1]
|
||||
target = safe_target(args.dest)
|
||||
if target == source or target.is_relative_to(source) or source.is_relative_to(target):
|
||||
raise ValueError("Source and installed destination must be separate directories.")
|
||||
if target.name != "ppt-template-workflow":
|
||||
raise ValueError("Destination must end in ppt-template-workflow.")
|
||||
version_match = re.search(r'^ version: "([0-9.]+)"$', (source / "SKILL.md").read_text(), re.M)
|
||||
if not version_match:
|
||||
raise ValueError("Missing root Skill version.")
|
||||
files = roster(source)
|
||||
compiler_files = compiler_integrity(source)
|
||||
if args.prepare_runtime and (args.venv is None or args.check):
|
||||
raise ValueError("--prepare-runtime requires --venv and cannot be combined with --check.")
|
||||
codex = safe_target(args.codex_home) if args.codex_home else None
|
||||
config = config_plan(codex, target) if codex else None
|
||||
if args.check:
|
||||
if not target.is_dir() or roster(target) != files:
|
||||
raise ValueError("Installed payload differs from the selected source.")
|
||||
compiler_integrity(target)
|
||||
if config and config[1] != config[2]:
|
||||
raise ValueError("Nested compiler attribution entry has not been disabled.")
|
||||
elif target.exists() and not args.replace:
|
||||
raise FileExistsError("Destination exists; an authorized upgrade needs --replace (with backup).")
|
||||
elif target.exists() and not target.is_dir():
|
||||
raise ValueError("Existing destination is not a Skill directory.")
|
||||
runtime = None
|
||||
if args.venv:
|
||||
venv = safe_target(args.venv)
|
||||
if venv == target or venv.is_relative_to(target) or venv == source or venv.is_relative_to(source):
|
||||
raise ValueError("Runtime must be outside source and installed Skill.")
|
||||
python = safe_target(venv / ("Scripts/python.exe" if os.name == "nt" else "bin/python")) if os.name == "nt" else venv / "bin/python"
|
||||
# POSIX venv interpreters are normally symlinks to their base Python.
|
||||
if args.prepare_runtime:
|
||||
if not python.is_file():
|
||||
subprocess.run([sys.executable, "-m", "venv", str(venv)], check=True)
|
||||
subprocess.run([str(python), "-m", "pip", "install", "--disable-pip-version-check",
|
||||
"-r", str(source / "runtime/requirements.txt")], check=True)
|
||||
runtime = {"executable": str(python), **runtime_check(python, source)}
|
||||
commit = subprocess.run(["git", "-C", str(source), "rev-parse", "HEAD"],
|
||||
capture_output=True, text=True)
|
||||
dirty = subprocess.run(["git", "-C", str(source), "status", "--porcelain"],
|
||||
capture_output=True, text=True) if commit.returncode == 0 else None
|
||||
receipt = {"schema_version": 1, "skill": "ppt-template-workflow",
|
||||
"version": version_match[1], "source_commit": commit.stdout.strip() if commit.returncode == 0 else None,
|
||||
"source_dirty": bool(dirty.stdout) if dirty and dirty.returncode == 0 else None,
|
||||
"installed_path": str(target), "payload": files, "compiler_files": compiler_files,
|
||||
"runtime": runtime, "status": "native-ready" if runtime else "files-only",
|
||||
"codex_attribution_disabled": bool(config), "checked": args.check}
|
||||
if not args.check:
|
||||
stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ") + "-" + uuid.uuid4().hex[:8]
|
||||
backup_root = safe_target(args.backup_root or
|
||||
((codex / "backups/ppt-template-workflow") if codex else target.parent.parent / "skill-backups"))
|
||||
if (backup_root == target or backup_root.is_relative_to(target) or backup_root.is_relative_to(target.parent)
|
||||
or backup_root == source or backup_root.is_relative_to(source)):
|
||||
raise ValueError("Backup must be outside source and Skill discovery directory.")
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
stage = Path(tempfile.mkdtemp(prefix=".ppt-install-", dir=target.parent))
|
||||
backup = backup_root / stamp / "ppt-template-workflow"
|
||||
moved = False
|
||||
config_written = False
|
||||
config_backup = None
|
||||
try:
|
||||
for relative in files:
|
||||
destination = stage / relative
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(source / relative, destination)
|
||||
if roster(stage) != files:
|
||||
raise ValueError("Staged installation hash mismatch.")
|
||||
if target.exists() or (config and config[1] != config[2]):
|
||||
backup.parent.mkdir(parents=True, exist_ok=False, mode=0o700)
|
||||
if target.exists():
|
||||
target.rename(backup)
|
||||
moved = True
|
||||
if config and config[1] != config[2]:
|
||||
path, before, after = config
|
||||
if (path.read_text(encoding="utf-8") if path.is_file() else "") != before:
|
||||
raise ValueError("Codex configuration changed during installation.")
|
||||
config_backup = backup.parent / "config.toml.before"
|
||||
config_backup.write_text(before, encoding="utf-8")
|
||||
config_backup.chmod(0o600)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(after, encoding="utf-8")
|
||||
if not before:
|
||||
path.chmod(0o600)
|
||||
config_written = True
|
||||
receipt.update(backup=str(backup) if moved else None,
|
||||
config_backup=str(config_backup) if config_backup else None,
|
||||
installed_at=datetime.now(timezone.utc).isoformat())
|
||||
(stage / ".installation.json").write_text(json.dumps(receipt, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
||||
stage.rename(target)
|
||||
except Exception:
|
||||
if config_written:
|
||||
config[0].write_text(config[1], encoding="utf-8")
|
||||
if moved and not target.exists():
|
||||
backup.rename(target)
|
||||
raise
|
||||
finally:
|
||||
if stage.exists():
|
||||
shutil.rmtree(stage)
|
||||
summary = {key: value for key, value in receipt.items() if key != "payload"}
|
||||
summary["payload_files"] = len(files)
|
||||
print(json.dumps(summary, ensure_ascii=False, indent=2))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if sys.version_info < (3, 12):
|
||||
raise SystemExit("Use Python 3.12+ to install the complete native workflow.")
|
||||
try:
|
||||
main()
|
||||
except (OSError, ValueError, subprocess.CalledProcessError) as error:
|
||||
raise SystemExit(f"Installation failed: {error}")
|
||||
@@ -0,0 +1,169 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Discover and verify optional template page examples; never write or activate a template."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from copy import deepcopy
|
||||
import json
|
||||
from pathlib import Path
|
||||
import re
|
||||
import sys
|
||||
from urllib.parse import unquote, urlsplit
|
||||
from xml.etree import ElementTree as ET
|
||||
|
||||
from template_ref import (InputError, VERSION, digest, file_path, identifier, input_hashes,
|
||||
load_manifest, read_json, require, string, strings)
|
||||
|
||||
|
||||
def checked_file(root: Path, record: object, label: str) -> Path:
|
||||
require(isinstance(record, dict), f"{label}: expected path/sha256 object")
|
||||
path = file_path(root, record.get("path"), label)
|
||||
checksum = string(record.get("sha256"), f"{label}.sha256")
|
||||
require(bool(re.fullmatch(r"[0-9a-f]{64}", checksum)), f"{label}: invalid SHA-256")
|
||||
require(digest(path) == checksum, f"{label}: SHA-256 mismatch: {record['path']}")
|
||||
return path
|
||||
|
||||
|
||||
def check_source(source: Path, declared: set[Path]) -> None:
|
||||
text = source.read_text(encoding="utf-8")
|
||||
require("<!DOCTYPE" not in text.upper() and "<!ENTITY" not in text.upper(),
|
||||
"library SVG: document entities are unsupported")
|
||||
svg = ET.fromstring(text)
|
||||
require(svg.tag.rsplit("}", 1)[-1] == "svg", "library source must be SVG")
|
||||
for node in svg.iter():
|
||||
for key, href in node.attrib.items():
|
||||
if key.rsplit("}", 1)[-1] != "href" or href.startswith("#"):
|
||||
continue
|
||||
if href.startswith(("data:image/png;base64,", "data:image/jpeg;base64,")):
|
||||
continue
|
||||
uri = urlsplit(href)
|
||||
require(not uri.scheme and not uri.netloc and not uri.query,
|
||||
f"library SVG: external resource {href}")
|
||||
raw = unquote(uri.path)
|
||||
require(raw and not Path(raw).is_absolute() and "\\" not in raw,
|
||||
f"library SVG: invalid resource {href}")
|
||||
resolved = (source.parent / raw).resolve()
|
||||
require(resolved in declared, f"library SVG: undeclared resource {href}")
|
||||
|
||||
|
||||
def load_library(manifest_path: Path) -> tuple[dict, Path | None, dict | None]:
|
||||
manifest = load_manifest(manifest_path)
|
||||
if "page_library" not in manifest:
|
||||
return manifest, None, None
|
||||
root = manifest_path.parent
|
||||
path = file_path(root, manifest["page_library"], "page_library")
|
||||
catalog = read_json(path, (1,))
|
||||
require(catalog.get("template_id") == manifest["template_id"], "library template_id mismatch")
|
||||
entries = catalog.get("entries")
|
||||
require(isinstance(entries, list), "library entries: expected array")
|
||||
seen = set()
|
||||
for entry in entries:
|
||||
require(isinstance(entry, dict), "library entry: expected object")
|
||||
key = identifier(entry.get("id"), "library entry ID")
|
||||
require(key not in seen, f"Duplicate library entry: {key}")
|
||||
seen.add(key)
|
||||
string(entry.get("title"), "entry.title")
|
||||
require(entry.get("status") in ("candidate", "reference", "deprecated"), "entry.status invalid")
|
||||
versions = strings(entry.get("template_versions"), "entry.template_versions", nonempty=True)
|
||||
require(all(VERSION.fullmatch(v) for v in versions), "entry.template_versions: expected exact SemVer values")
|
||||
require(entry.get("layout") in {item["id"] for item in manifest["layouts"]}, "Unknown library layout")
|
||||
strings(entry.get("tags"), "entry.tags")
|
||||
checked_file(root, entry.get("design"), f"{key}.design")
|
||||
string(entry.get("origin"), f"{key}.origin")
|
||||
variants = entry.get("variants")
|
||||
require(isinstance(variants, dict) and bool(variants), f"{key}.variants: expected non-empty object")
|
||||
for theme, variant in variants.items():
|
||||
require(theme in manifest.get("themes", manifest.get("modes", {})), f"Unknown library theme: {theme}")
|
||||
require(isinstance(variant, dict), f"{key}.{theme}: expected object")
|
||||
files = variant.get("files")
|
||||
require(isinstance(files, list) and bool(files), f"{key}.{theme}.files: expected non-empty array")
|
||||
paths = [checked_file(root, item, f"{key}.{theme}.files") for item in files]
|
||||
require(len(paths) == len(set(paths)), f"{key}.{theme}: duplicate files")
|
||||
source = file_path(root, variant.get("source"), "variant.source")
|
||||
preview = file_path(root, variant.get("preview"), "variant.preview")
|
||||
require(source in paths and preview in paths, "source and preview must be declared files")
|
||||
require(source.suffix.lower() == ".svg", "library source must be .svg")
|
||||
require(preview.suffix.lower() in (".png", ".jpg", ".jpeg"), "library preview must be PNG/JPEG")
|
||||
evidence = variant.get("evidence")
|
||||
require(isinstance(evidence, dict), "variant.evidence: expected object")
|
||||
require(evidence.get("level") in ("source-only", "svg-rendered", "powerpoint-rendered", "powerpoint-edited"),
|
||||
"variant.evidence.level invalid")
|
||||
string(evidence.get("source_version"), "evidence.source_version")
|
||||
string(evidence.get("notes"), "evidence.notes")
|
||||
check_source(source, set(paths))
|
||||
return manifest, path, catalog
|
||||
|
||||
|
||||
def inspect_entry(manifest_path: Path, manifest: dict, path: Path, entry: dict,
|
||||
theme: str, for_use: bool) -> dict:
|
||||
compatible = manifest["version"] in entry["template_versions"]
|
||||
eligible = entry["status"] == "reference" and compatible and theme in entry["variants"]
|
||||
if for_use:
|
||||
require(manifest["status"] == "released", "Library reuse requires a released template")
|
||||
require(eligible, "Entry is not a compatible reference for the selected theme")
|
||||
require(theme in entry["variants"], f"Entry has no variant for theme {theme}")
|
||||
root = manifest_path.parent
|
||||
variant = deepcopy(entry["variants"][theme])
|
||||
inputs = {manifest_path, path, file_path(root, entry["design"]["path"], "design")}
|
||||
inputs.update(file_path(root, item["path"], "variant file") for item in variant["files"])
|
||||
return {"template_id": manifest["template_id"], "template_version": manifest["version"],
|
||||
"entry_id": entry["id"], "title": entry["title"], "status": entry["status"],
|
||||
"layout": entry["layout"], "theme": theme, "compatible": compatible, "eligible": eligible,
|
||||
"design": str(file_path(root, entry["design"]["path"], "design")),
|
||||
"source": str(file_path(root, variant["source"], "source")),
|
||||
"preview": str(file_path(root, variant["preview"], "preview")),
|
||||
"variant": variant, "inputs": input_hashes(root, inputs),
|
||||
"verification_scope": "declared metadata, file hashes and SVG href closure; preview provenance is declared, not rerendered"}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
sub = parser.add_subparsers(dest="command", required=True)
|
||||
for name in ("list", "check", "inspect"):
|
||||
item = sub.add_parser(name)
|
||||
item.add_argument("--manifest", type=Path, required=True, action="append")
|
||||
if name == "list":
|
||||
item.add_argument("--theme", action="append")
|
||||
item.add_argument("--layout", action="append")
|
||||
if name == "inspect":
|
||||
item.add_argument("--entry", required=True, action="append")
|
||||
item.add_argument("--theme", required=True, action="append")
|
||||
item.add_argument("--for-use", action="store_true")
|
||||
args = parser.parse_args()
|
||||
try:
|
||||
for key in ("manifest", "theme", "layout", "entry"):
|
||||
values = getattr(args, key, None)
|
||||
if values is not None:
|
||||
require(len(values) == 1, f"Only one --{key} may be active")
|
||||
setattr(args, key, values[0])
|
||||
manifest_path = args.manifest.resolve()
|
||||
manifest, path, catalog = load_library(manifest_path)
|
||||
if args.command == "inspect":
|
||||
require(catalog is not None, "Template has no page library")
|
||||
entry = next((item for item in catalog["entries"] if item["id"] == args.entry), None)
|
||||
require(entry is not None, f"Unknown library entry: {args.entry}")
|
||||
output = inspect_entry(manifest_path, manifest, path, entry, args.theme, args.for_use)
|
||||
else:
|
||||
entries = [] if catalog is None else catalog["entries"]
|
||||
theme, layout = getattr(args, "theme", None), getattr(args, "layout", None)
|
||||
if theme:
|
||||
require(theme in manifest.get("themes", manifest.get("modes", {})), "Unknown theme filter")
|
||||
if layout:
|
||||
require(layout in {item["id"] for item in manifest["layouts"]}, "Unknown layout filter")
|
||||
entries = [{"id": entry["id"], "title": entry["title"], "status": entry["status"],
|
||||
"layout": entry["layout"], "themes": list(entry["variants"]), "tags": entry["tags"],
|
||||
"compatible": manifest["version"] in entry["template_versions"]}
|
||||
for entry in entries if (not theme or theme in entry["variants"])
|
||||
and (not layout or layout == entry["layout"])]
|
||||
output = {"template_id": manifest["template_id"], "available": catalog is not None,
|
||||
"catalog": str(path) if path else None, "entries": entries, "count": len(entries)}
|
||||
print(json.dumps(output, ensure_ascii=False, indent=2))
|
||||
return 0
|
||||
except (InputError, OSError, ValueError, ET.ParseError) as error:
|
||||
print(json.dumps({"error": str(error)}, ensure_ascii=False), file=sys.stderr)
|
||||
return 2
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
+38
-10
@@ -14,18 +14,45 @@ import template_ref as ref
|
||||
from native_export import boundary_file, regular
|
||||
|
||||
|
||||
def paint(manifest_path: Path, theme_id: str, role: str, output: Path):
|
||||
def paint(manifest_path: Path, theme_id: str, role: str | None, output: Path,
|
||||
material_path: Path | None = None):
|
||||
manifest_path = regular(manifest_path, "manifest")
|
||||
manifest = ref.load_manifest(manifest_path)
|
||||
ref.require(manifest["schema_version"] == 2, "Paint application requires manifest schema 2")
|
||||
ref.require(theme_id in manifest["themes"], "Unknown theme")
|
||||
_, theme = ref.load_theme(manifest_path.parent, manifest["themes"][theme_id], theme_id)
|
||||
asset = theme["assets"].get(role)
|
||||
ref.require(asset is not None and "paint_contract" in asset, "Asset needs an explicit SVG paint contract")
|
||||
root_path = manifest_path.parent
|
||||
if material_path is None:
|
||||
asset = theme["assets"].get(role)
|
||||
ref.require(asset is not None and "paint_contract" in asset, "Asset needs an explicit SVG paint contract")
|
||||
root_path = manifest_path.parent
|
||||
bindings = [target for binding in theme["asset_bindings"] if binding["asset"] == role
|
||||
for target in binding["targets"]]
|
||||
else:
|
||||
ref.require(role is None, "Choose a template asset or a local material, not both")
|
||||
material_path = regular(material_path, "material descriptor")
|
||||
asset = ref.read_json(material_path, (1,))
|
||||
ref.require(set(asset) == {"schema_version", "id", "path", "paint_contract", "bindings"},
|
||||
"Material descriptor requires exactly schema_version/id/path/paint_contract/bindings")
|
||||
role = ref.identifier(asset["id"], "material ID")
|
||||
root_path = material_path.parent
|
||||
bindings = asset["bindings"]
|
||||
ref.require(isinstance(bindings, list), "Material bindings must be an array")
|
||||
_, contract = ref.load_paint_contract(root_path, asset, role)
|
||||
source = boundary_file(root_path, asset["path"], "SVG asset")
|
||||
contract_path = boundary_file(root_path, asset["paint_contract"], "paint contract")
|
||||
contract = ref.read_json(contract_path, (1,))
|
||||
boundary_file(root_path, asset["paint_contract"], "paint contract")
|
||||
allowed = {(rule["selector"], rule["channel"]) for rule in contract["rules"] if rule["policy"] == "palette"}
|
||||
targets = {}
|
||||
for target in bindings:
|
||||
ref.require(isinstance(target, dict) and set(target) == {"selector", "channel", "palette_index"},
|
||||
"Binding requires exactly selector/channel/palette_index")
|
||||
key = (ref.string(target["selector"], "selector"), ref.string(target["channel"], "channel"))
|
||||
ref.require(key in allowed and key not in targets, "Unknown, fixed, structural or duplicate paint binding")
|
||||
index = ref.string(target["palette_index"], "palette index")
|
||||
ref.require(index in theme["palette"], "Unknown palette index")
|
||||
targets[key] = index
|
||||
ref.require(set(targets) == allowed, "Palette bindings must cover exactly the permitted channels")
|
||||
if material_path is not None:
|
||||
ref.require(bool(allowed), "A new icon needs at least one adjustable palette channel")
|
||||
text = source.read_text(encoding="utf-8")
|
||||
ref.require(not re.search(r"<!DOCTYPE|<!ENTITY", text, re.I), "SVG declarations/entities forbidden")
|
||||
root = ET.fromstring(text)
|
||||
@@ -36,8 +63,6 @@ def paint(manifest_path: Path, theme_id: str, role: str, output: Path):
|
||||
if node.get("id"):
|
||||
ref.require(node.get("id") not in by_id, "SVG IDs must be unique")
|
||||
by_id[node.get("id")] = node
|
||||
targets = {(target["selector"], target["channel"]): target["palette_index"]
|
||||
for binding in theme["asset_bindings"] if binding["asset"] == role for target in binding["targets"]}
|
||||
changes = []
|
||||
for rule in contract["rules"]:
|
||||
selector, channel = rule["selector"], rule["channel"]
|
||||
@@ -61,6 +86,7 @@ def paint(manifest_path: Path, theme_id: str, role: str, output: Path):
|
||||
with output.open("xb") as stream:
|
||||
ET.ElementTree(root).write(stream, encoding="utf-8", xml_declaration=True)
|
||||
return {"asset": role, "theme": theme_id, "output": str(output), "paint_changes": changes,
|
||||
"material": str(material_path) if material_path else None,
|
||||
"verification_scope": "explicit SVG channel transformation; rendered material appearance unverified"}
|
||||
|
||||
|
||||
@@ -68,11 +94,13 @@ def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--manifest", type=Path, required=True)
|
||||
parser.add_argument("--theme", required=True)
|
||||
parser.add_argument("--asset", required=True)
|
||||
selection = parser.add_mutually_exclusive_group(required=True)
|
||||
selection.add_argument("--asset")
|
||||
selection.add_argument("--material", type=Path)
|
||||
parser.add_argument("--output", type=Path, required=True)
|
||||
args = parser.parse_args()
|
||||
try:
|
||||
print(json.dumps(paint(args.manifest, args.theme, args.asset, args.output), ensure_ascii=False))
|
||||
print(json.dumps(paint(args.manifest, args.theme, args.asset, args.output, args.material), ensure_ascii=False))
|
||||
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)
|
||||
|
||||
@@ -0,0 +1,252 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Grouped native tables with explicit per-column bars and status marks."""
|
||||
from __future__ import annotations
|
||||
|
||||
from copy import deepcopy
|
||||
import json
|
||||
import math
|
||||
from pathlib import Path
|
||||
import re
|
||||
import sys
|
||||
import unicodedata
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
import template_ref as ref
|
||||
|
||||
SVG = 'http://www.w3.org/2000/svg'
|
||||
|
||||
|
||||
def _fields(value, allowed, required, name):
|
||||
ref.require(isinstance(value, dict), f'{name}: object required')
|
||||
ref.require(set(value) <= allowed, f'{name}: unknown fields {sorted(set(value)-allowed)}')
|
||||
ref.require(required <= set(value), f'{name}: missing fields {sorted(required-set(value))}')
|
||||
|
||||
|
||||
def _number(value, name):
|
||||
ref.require(type(value) in (int, float) and math.isfinite(value), f'{name}: finite number required')
|
||||
return value
|
||||
|
||||
|
||||
def _text(value, name, empty=False):
|
||||
ref.require(isinstance(value, str) and (empty or bool(value.strip())), f'{name}: string required')
|
||||
ref.require(not any(unicodedata.category(c).startswith('C') for c in value), f'{name}: control/format characters unsupported')
|
||||
return value
|
||||
|
||||
|
||||
def _indices(value, count, name):
|
||||
ref.require(isinstance(value, list) and all(type(i) is int and 0 <= i < count for i in value), f'{name}: invalid column index')
|
||||
ref.require(len(set(value)) == len(value), f'{name}: duplicate column index')
|
||||
return value
|
||||
|
||||
|
||||
def _fit(value, width, size, name):
|
||||
units = sum(1 if unicodedata.east_asian_width(c) in ('W', 'F') else .6 for c in value)
|
||||
ref.require(units*size <= width, f'{name}: text exceeds capacity; shorten text or enlarge bounds')
|
||||
|
||||
|
||||
def _node(parent, tag, **attributes):
|
||||
return ET.SubElement(parent, '{'+SVG+'}'+tag, {key.replace('_', '-'):str(value) for key,value in attributes.items()})
|
||||
|
||||
|
||||
def _label(parent, value, x, y, font, size, color, align):
|
||||
result = _node(parent, 'text', x=x, y=y, font_family=font, font_size=size,
|
||||
font_weight='normal', fill=color, text_anchor='end' if align == 'r' else 'middle' if align == 'ctr' else 'start')
|
||||
result.text = value
|
||||
|
||||
|
||||
def _formatted(value, column):
|
||||
kind = column.get('format', 'text')
|
||||
if kind == 'integer':
|
||||
ref.require(type(value) in (int,float) and value == int(value), 'integer format requires integral numeric values')
|
||||
return f'{int(value):,}'
|
||||
if kind == 'one-decimal':
|
||||
ref.require(type(value) in (int,float), 'one-decimal format requires numeric values')
|
||||
return f'{value:,.1f}'
|
||||
return str(value)
|
||||
|
||||
|
||||
def render_grouped_table(raw: dict, style: dict, theme: dict, bounds: tuple,
|
||||
component_id: str) -> ET.Element:
|
||||
"""Return an outer g containing a native-table marker and separate shapes.
|
||||
|
||||
Original values are retained; number formats affect display text only. Bars
|
||||
express independent zero-based column magnitudes, never a ranking or score.
|
||||
"""
|
||||
allowed = {'schema_version','kind','variant','data_kind','source','columns','rows','column_widths',
|
||||
'numeric_columns','column_groups','focus_row','row_header_columns','bars','status',
|
||||
'focus_meaning','bar_meaning'}
|
||||
_fields(raw, allowed, {'schema_version','kind','variant','columns','rows','column_widths','column_groups'}, 'grouped table')
|
||||
ref.require(type(raw['schema_version']) is int and raw['schema_version'] == 1 and raw['kind'] == 'table' and raw['variant'] == 'grouped', 'requires table/grouped schema_version 1')
|
||||
data = deepcopy(raw)
|
||||
if 'data_kind' in data:
|
||||
ref.require(data['data_kind'] in ('synthetic-demo','observed','derived'), 'invalid data_kind')
|
||||
for key in ('source','focus_meaning','bar_meaning'):
|
||||
if key in data: _text(data[key], key, empty=True)
|
||||
ref.identifier(component_id, 'component ID')
|
||||
ref.require(isinstance(bounds, (tuple,list)) and len(bounds) == 4, 'bounds requires x/y/width/height')
|
||||
x,y,width,height = [_number(v, 'bounds') for v in bounds]
|
||||
canvas = style['canvas']
|
||||
ref.require(x >= 0 and y >= 0 and width > 0 and height > 0 and x+width <= canvas['width'] and y+height <= canvas['height'], 'bounds outside canvas')
|
||||
columns, rows = data['columns'], data['rows']
|
||||
ref.require(isinstance(columns,list) and bool(columns), 'columns must be nonempty list')
|
||||
ids = []
|
||||
for col in columns:
|
||||
_fields(col, {'id','label','unit','format'}, {'id','label'}, 'column')
|
||||
ids.append(ref.identifier(col['id'], 'column ID'))
|
||||
_text(col['label'], 'column label')
|
||||
if 'unit' in col: _text(col['unit'], 'column unit', empty=True)
|
||||
ref.require(col.get('format','text') in ('text','integer','one-decimal'), 'unknown column format')
|
||||
count = len(columns)
|
||||
ref.require(len(set(ids)) == count, 'duplicate column ID')
|
||||
ref.require(isinstance(rows,list) and bool(rows), 'rows must be nonempty list')
|
||||
for row in rows:
|
||||
ref.require(isinstance(row,list) and len(row) == count, 'table row length mismatch')
|
||||
for value in row:
|
||||
if type(value) in (int,float): _number(value, 'cell')
|
||||
else: _text(value, 'cell', empty=True)
|
||||
widths = data['column_widths']
|
||||
ref.require(isinstance(widths,list) and len(widths) == count, 'column_widths mismatch')
|
||||
ref.require(all(_number(v,'column width') > 0 for v in widths) and abs(sum(widths)-width) < .01, 'column widths must fill bounds width')
|
||||
numeric = _indices(data.get('numeric_columns',[]), count, 'numeric_columns')
|
||||
for row in rows:
|
||||
for col in numeric: _number(row[col], 'numeric cell')
|
||||
row_headers = _indices(data.get('row_header_columns',[]), count, 'row_header_columns')
|
||||
focus = data.get('focus_row')
|
||||
ref.require(focus is None or type(focus) is int and 0 <= focus < len(rows), 'focus_row invalid')
|
||||
groups = data['column_groups']
|
||||
ref.require(isinstance(groups,list) and bool(groups), 'column_groups must be nonempty list')
|
||||
cursor = 0
|
||||
for group in groups:
|
||||
_fields(group, {'label','start','span'}, {'label','start','span'}, 'column group')
|
||||
_text(group['label'],'column group label')
|
||||
ref.require(type(group['start']) is int and type(group['span']) is int and group['start'] == cursor and group['span'] > 0, 'column_groups must provide ordered contiguous coverage')
|
||||
cursor += group['span']
|
||||
ref.require(cursor == count, 'column_groups must cover all columns')
|
||||
bar_columns = []
|
||||
if 'bars' in data:
|
||||
_fields(data['bars'], {'columns'}, {'columns'}, 'bars')
|
||||
bar_columns = _indices(data['bars']['columns'], count, 'bars.columns')
|
||||
ref.require(set(bar_columns) <= set(numeric), 'bars require numeric_columns')
|
||||
for row in rows:
|
||||
for col in bar_columns: ref.require(row[col] >= 0, 'bars require nonnegative values')
|
||||
status = data.get('status')
|
||||
if status is not None:
|
||||
_fields(status, {'column','values'}, {'column','values'}, 'status')
|
||||
_indices([status['column']], count, 'status.column')
|
||||
ref.require(status['column'] not in numeric and status['column'] not in bar_columns, 'status column must be textual')
|
||||
ref.require(isinstance(status['values'],dict) and bool(status['values']), 'status.values must be nonempty object')
|
||||
for value, cfg in status['values'].items():
|
||||
_text(value, 'status value')
|
||||
_fields(cfg, {'palette_index','shape'}, {'palette_index','shape'}, 'status value')
|
||||
_text(cfg['palette_index'], 'status palette_index')
|
||||
ref.require(cfg['shape'] in ('circle','diamond'), 'status shape must be circle or diamond')
|
||||
ref.require(all(row[status['column']] in status['values'] for row in rows), 'unknown status value')
|
||||
cfg = style.get('components',{}).get('table_grouped',{})
|
||||
_fields(cfg, {'group_height_px','header_height_px','row_height_px','cell_padding_px'}, set(), 'style.components.table_grouped')
|
||||
typography = style['typography']; roles = typography['roles']; families = typography['families']
|
||||
font = families[roles['label']['family_role']]['family']
|
||||
caption_font = families[roles['caption']['family_role']]['family']
|
||||
size = _number(roles['label']['size_pt'],'label size')*_number(canvas['pt_to_px'],'pt_to_px')
|
||||
small = _number(roles['caption']['size_pt'],'caption size')*canvas['pt_to_px']
|
||||
ref.require(size > 0 and small > 0, 'font sizes must be positive')
|
||||
padding = cfg.get('cell_padding_px',[8,16])
|
||||
ref.require(isinstance(padding,list) and len(padding) == 2 and all(_number(v,'cell padding') >= 0 and v % 8 == 0 for v in padding), 'cell_padding_px requires two nonnegative 8px multiples')
|
||||
top, side = padding
|
||||
module = lambda v: math.ceil(v/8)*8
|
||||
gh = cfg.get('group_height_px',module(small+2*top))
|
||||
hh = cfg.get('header_height_px',module(size+small+2*top+8))
|
||||
rh = cfg.get('row_height_px',module(size+2*top+(8 if bar_columns else 0)))
|
||||
for key,value,minimum in (('group height',gh,small+2*top),('header height',hh,size+small+2*top+8),('row height',rh,size+2*top+(8 if bar_columns else 0))):
|
||||
ref.require(_number(value,key) >= minimum and value % 8 == 0, f'{key}: insufficient height or not an 8px multiple')
|
||||
total = gh+hh+len(rows)*rh
|
||||
ref.require(total <= height, 'table rows exceed bounds height')
|
||||
palette = theme['palette']
|
||||
keys = {'accent1','accent2','surface_tint','lt1','lt2','dk1','dk2','line'}
|
||||
if status:
|
||||
keys.update(v['palette_index'] for v in status['values'].values())
|
||||
for key in keys:
|
||||
ref.require(isinstance(key,str) and key in palette and isinstance(palette[key],str) and re.fullmatch(r'#[0-9a-fA-F]{6}',palette[key]), f'unknown/invalid palette index: {key}')
|
||||
outer = ET.Element('{'+SVG+'}g', {'id':component_id,'data-component-kind':'table','data-component-variant':'grouped'})
|
||||
source = _node(outer,'metadata',type='application/json')
|
||||
marks = []
|
||||
marker = _node(outer,'g',id=component_id+'-native',data_pptx_replace_with='table')
|
||||
metadata = _node(marker,'metadata',type='application/json')
|
||||
marker.set('data-component-source',json.dumps(data,ensure_ascii=False,allow_nan=False))
|
||||
fallback = _node(marker,'g',id=component_id+'-fallback')
|
||||
none = lambda: {s:{'style':'none'} for s in ('top','bottom','left','right')}
|
||||
table_rows = ['']*count
|
||||
for i,group in enumerate(groups):
|
||||
start,span = group['start'],group['span']; gx=x+sum(widths[:start]); gw=sum(widths[start:start+span])
|
||||
fill = palette['accent1' if i%2 == 0 else 'accent2']
|
||||
_fit(group['label'],gw-2*side,small,'group header')
|
||||
cell = {'paragraphs':[{'align':'ctr','runs':[{'text':group['label'],'font_size':small,'font_family':caption_font,'bold':False,'color':palette['lt1']}]}],'fill':fill,'color':palette['lt1'],'font_size':small,'bold':False,'align':'ctr','valign':'middle','padding':{'top':0,'bottom':0,'left':side,'right':side},'borders':none()}
|
||||
if span > 1: cell['col_span']=span
|
||||
table_rows[start]=cell
|
||||
_node(fallback,'rect',x=gx,y=y,width=gw,height=gh,fill=fill)
|
||||
_label(fallback,group['label'],gx+gw/2,y+(gh+small*.72)/2,caption_font,small,palette['lt1'],'ctr')
|
||||
table_rows = [table_rows]
|
||||
group_ends = {g['start']+g['span']-1 for g in groups[:-1]}
|
||||
cy = y+gh
|
||||
for ri,row in enumerate([columns,*rows]):
|
||||
header = ri == 0; focused = not header and ri-1 == focus; cell_height = hh if header else rh
|
||||
cx=x; cells=[]
|
||||
for ci,value in enumerate(row):
|
||||
fill = palette['surface_tint'] if header or focused else palette['lt2'] if ci in row_headers or ri%2 == 0 else palette['lt1']
|
||||
color = palette['accent1'] if header or focused else palette['dk1']
|
||||
if not header and ci not in numeric and ci not in row_headers: color=palette['dk2']
|
||||
is_status = not header and status is not None and ci == status['column']
|
||||
if is_status: color=palette[status['values'][value]['palette_index']]
|
||||
align='r' if ci in numeric else 'l'
|
||||
pad={'top':top,'bottom':top+8 if not header and ci in bar_columns else top,'left':side+32 if is_status else side,'right':side}
|
||||
borders=none(); borders['bottom']={'style':'solid','color':palette['accent1'] if focused else palette['line'],'width':1}
|
||||
if ci in group_ends: borders['right']={'style':'solid','color':palette['line'],'width':1}
|
||||
if focused:
|
||||
borders['top']={'style':'solid','color':palette['accent1'],'width':1}
|
||||
if ci == 0: borders['left']={'style':'solid','color':palette['accent1'],'width':4}
|
||||
cell={'fill':fill,'color':color,'align':align,'font_size':size,'bold':False,'padding':pad,'valign':'middle','borders':borders}
|
||||
_node(fallback,'rect',x=cx,y=cy,width=widths[ci],height=cell_height,fill=fill)
|
||||
tx=cx+widths[ci]-side if align == 'r' else cx+pad['left']
|
||||
if header:
|
||||
title,unit=value['label'],value.get('unit','')
|
||||
_fit(title,widths[ci]-2*side,size,'column header')
|
||||
cell['paragraphs']=[{'align':align,'runs':[{'text':title,'font_size':size,'font_family':font,'bold':False,'color':color}]}]
|
||||
if unit:
|
||||
_fit(unit,widths[ci]-2*side,small,'column unit')
|
||||
cell['paragraphs'].append({'align':align,'runs':[{'text':unit,'font_size':small,'font_family':caption_font,'bold':False,'color':palette['dk2']}]})
|
||||
baseline=cy+(cell_height-size-small-8)/2+size*.8
|
||||
_label(fallback,title,tx,baseline,font,size,color,align)
|
||||
_label(fallback,unit,tx,baseline+small+8,caption_font,small,palette['dk2'],align)
|
||||
else: _label(fallback,title,tx,cy+(cell_height+size*.72)/2,font,size,color,align)
|
||||
else:
|
||||
displayed=_formatted(value,columns[ci]); cell['text']=displayed
|
||||
_fit(displayed,widths[ci]-pad['left']-side,size,'table cell')
|
||||
_label(fallback,displayed,tx,cy+(cell_height+size*.72-(8 if ci in bar_columns else 0))/2,font,size,color,align)
|
||||
if ci in bar_columns:
|
||||
maximum=max(r[ci] for r in rows) or 1; track=widths[ci]-2*side
|
||||
marks.append({'id':f'{component_id}-bar-r{ri}-c{ci}','kind':'bar','x':cx+side,'y':cy+cell_height-12,'width':track,'length':track*(value/maximum),'value':value,'domain':[0,maximum],'column':ci,'row':ri-1,'focused':focused})
|
||||
if is_status:
|
||||
marks.append({'id':f'{component_id}-status-r{ri}-c{ci}','kind':'status','x':cx+side+8,'y':cy+cell_height/2,'palette_index':status['values'][value]['palette_index'],'shape':status['values'][value]['shape'],'value':value,'row':ri-1,'column':ci})
|
||||
for edge,line in borders.items():
|
||||
if line['style']=='none': continue
|
||||
coords={'top':(cx,cy,cx+widths[ci],cy),'bottom':(cx,cy+cell_height,cx+widths[ci],cy+cell_height),'left':(cx,cy,cx,cy+cell_height),'right':(cx+widths[ci],cy,cx+widths[ci],cy+cell_height)}[edge]
|
||||
_node(fallback,'line',x1=coords[0],y1=coords[1],x2=coords[2],y2=coords[3],stroke=line['color'],stroke_width=line['width'])
|
||||
cells.append(cell); cx+=widths[ci]
|
||||
table_rows.append(cells); cy+=cell_height
|
||||
payload={'name':component_id,'x':x,'y':y,'width':width,'height':total,'rows':table_rows,'header_rows':2,'strict_grid':True,'column_widths':widths,'row_heights':[gh,hh,*[rh]*len(rows)],'style':{'font_family':font,'font_size':size,'header_font_size':size,'band_row':False}}
|
||||
metadata.text=json.dumps(payload,ensure_ascii=False,allow_nan=False)
|
||||
source.text=json.dumps({'component_id':component_id,'data':data,'derived_marks':marks},ensure_ascii=False,allow_nan=False)
|
||||
runtime=Path(__file__).resolve().parent.parent/'runtime/ppt-master/scripts'
|
||||
if str(runtime) not in sys.path: sys.path.insert(0,str(runtime))
|
||||
from pptx_shapes import NATIVE_FALLBACK_SHA256_ATTR, svg_native_fallback_fingerprint
|
||||
marker.set(NATIVE_FALLBACK_SHA256_ATTR,svg_native_fallback_fingerprint(marker))
|
||||
overlay=_node(outer,'g',id=component_id+'-marks')
|
||||
for mark in marks:
|
||||
if mark['kind']=='bar':
|
||||
_node(overlay,'rect',id=mark['id']+'-track',x=mark['x'],y=mark['y'],width=mark['width'],height=4,fill=palette['line'])
|
||||
if mark['length'] > 0:
|
||||
_node(overlay,'rect',id=mark['id'],x=mark['x'],y=mark['y'],width=mark['length'],height=4,fill=palette['accent1'],fill_opacity=.8 if mark['focused'] else .4)
|
||||
elif mark['shape']=='circle': _node(overlay,'circle',id=mark['id'],cx=mark['x'],cy=mark['y'],r=4,fill=palette[mark['palette_index']])
|
||||
else:
|
||||
mx,my=mark['x'],mark['y']
|
||||
_node(overlay,'path',id=mark['id'],d=f'M {mx} {my-6} L {mx+6} {my} L {mx} {my+6} L {mx-6} {my} Z',fill=palette[mark['palette_index']])
|
||||
return outer
|
||||
@@ -194,6 +194,10 @@ def load_manifest(path: Path) -> dict:
|
||||
v2 = value["schema_version"] == 2
|
||||
if v2:
|
||||
read_json(file_path(path.parent, value.get("style"), "style"), None)
|
||||
if "page_library" in value:
|
||||
library = read_json(file_path(path.parent, value["page_library"], "page_library"), (1,))
|
||||
require(library.get("template_id") == value["template_id"],
|
||||
"page_library: template_id mismatch")
|
||||
variants_key = "themes" if v2 else "modes"
|
||||
variants = value.get(variants_key)
|
||||
require(isinstance(variants, dict) and bool(variants), f"{variants_key}: expected non-empty object")
|
||||
@@ -313,6 +317,9 @@ def export_style(path: Path, value: dict, args: argparse.Namespace) -> dict:
|
||||
inputs.update(workspace_inputs(root, workspace))
|
||||
if native is not None and native.is_file():
|
||||
inputs.add(native)
|
||||
library = file_path(root, value["page_library"], "page_library") if "page_library" in value else None
|
||||
if library is not None:
|
||||
inputs.add(library)
|
||||
return {"schema_version": 2, "template_id": value["template_id"], "version": value["version"],
|
||||
"status": value["status"], "production_ready": value["status"] == "released",
|
||||
"manifest": str(path), "manifest_sha256": digest(path), "theme": selected,
|
||||
@@ -321,6 +328,7 @@ def export_style(path: Path, value: dict, args: argparse.Namespace) -> dict:
|
||||
"workspace": str(workspace), "native_template": str(native) if native is not None else None,
|
||||
"style_path": str(style_path), "theme_path": str(theme_path),
|
||||
"style": style, "theme_data": theme, "editable_policy": deepcopy(value["editable_policy"]),
|
||||
"page_library": str(library) if library is not None else None,
|
||||
"inputs": input_hashes(root, inputs),
|
||||
"verification_scope": "style review and alignment only; no PPT creation, selector execution or release acceptance"}
|
||||
|
||||
@@ -362,6 +370,10 @@ def resolve(path: Path, value: dict, args: argparse.Namespace) -> dict:
|
||||
inputs.update(theme_files)
|
||||
result.update(status=value["status"], production_ready=True,
|
||||
style_path=str(style_path), theme_path=str(theme_path))
|
||||
library = file_path(root, value["page_library"], "page_library") if "page_library" in value else None
|
||||
if library is not None:
|
||||
inputs.add(library)
|
||||
result["page_library"] = str(library) if library is not None else None
|
||||
result["inputs"] = input_hashes(root, inputs)
|
||||
return result
|
||||
|
||||
|
||||
Reference in New Issue
Block a user