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
+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())