Release PPT workflow 0.7.1 with portable installation and production components
This commit is contained in:
@@ -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())
|
||||
Reference in New Issue
Block a user