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