Release PPT workflow 0.7.1 with portable installation and production components

This commit is contained in:
2026-10-12 18:45:54 +08:00
parent 4e3271ff62
commit f932b1970d
40 changed files with 2166 additions and 28 deletions
+226
View File
@@ -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())
+433
View File
@@ -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())
+224
View File
@@ -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}")
+169
View File
@@ -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
View File
@@ -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)
+252
View File
@@ -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
+12
View File
@@ -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