Files
Templated-PPT-Master/scripts/chart_component.py
T

434 lines
23 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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())