Release PPT workflow 0.7.3 with fixed spacing, paragraphs and native math

This commit is contained in:
2026-10-13 17:16:50 +08:00
parent c4efbed9b0
commit d671e412ae
26 changed files with 1210 additions and 78 deletions
+34 -23
View File
@@ -13,6 +13,7 @@ import unicodedata
import xml.etree.ElementTree as ET
import template_ref as ref
from component_typography import resolve_role
SVG = "http://www.w3.org/2000/svg"
ET.register_namespace("", SVG)
@@ -101,8 +102,10 @@ 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'):
def _text(parent, x, y, value, font, size, color, anchor='start', weight=None):
node = _node(parent, 'text', x=x, y=y, fill=color, font_family=font, font_size=size, text_anchor=anchor)
if weight is not None:
node.set('font-weight', str(weight))
node.text = value
return node
@@ -153,22 +156,20 @@ def _style(style, theme):
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')
caption = resolve_role(style, role)
title = resolve_role(style, 'label' if 'label' in typography['roles'] else role)
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']
keys = [*indices, chart.get('text_palette_index', 'dk1'), chart.get('grid_palette_index', 'line' if 'line' in palette else 'dk2'), 'lt1', 'accent2', 'dk2']
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]
return (title, caption, [palette[k] for k in indices], palette[chart.get('text_palette_index', 'dk1')],
palette[chart.get('grid_palette_index', 'line' if 'line' in palette else 'dk2')],
palette['lt1'], palette[axis_index], palette['accent2'], palette['dk2'])
def render_chart(data: dict, style: dict, theme: dict,
@@ -186,7 +187,8 @@ def render_chart(data: dict, style: dict, theme: dict,
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)
title_role, caption_role, colors, ink, grid, background, axis_color, title_color, source_color = _style(style, theme)
font, fs = caption_role.family, caption_role.size
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)
@@ -196,6 +198,8 @@ def render_chart(data: dict, style: dict, theme: dict,
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')
require(pie or caption_role.weight == 400,
'native chart axes support Regular (400) weight only; selected chart text role requires an unsupported weight')
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]
@@ -212,17 +216,20 @@ def render_chart(data: dict, style: dict, theme: dict,
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(data['title'], title_role.size, w - 16, 'title')
for value, name in ((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
outer = ET.Element('{' + SVG + '}g', {'id': component_id, 'data-component': 'chart',
'data-chart-type': kind, 'font-weight': caption_role.svg_weight})
_text(outer, x + 8, y + title_role.size, data['title'], title_role.family,
title_role.size, title_color, weight=title_role.svg_weight)
top = y + math.ceil((title_role.line_size + 8) / 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)
top += math.ceil((caption_role.line_size + 8) / 8) * 8
bottom = y + h - (math.ceil((caption_role.line_size + 8) / 8) * 8 if source_line else 8)
if source_line:
_text(outer, x + 8, y + h - 8, source_line, font, fs, ink)
_text(outer, x + 8, y + h - 8, source_line, font, fs, source_color)
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]
@@ -235,11 +242,12 @@ def render_chart(data: dict, style: dict, theme: dict,
row.append((index, width));used += width
if row:
rows.append(row)
legend_h = len(rows) * math.ceil((fs + 8) / 8) * 8
legend_step = math.ceil((caption_role.line_size + 8) / 8) * 8
legend_h = len(rows) * legend_step
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
ly = legend_y + r * legend_step + 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)
@@ -257,7 +265,7 @@ def render_chart(data: dict, style: dict, theme: dict,
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'}
'font_size': fs, 'color': ink, 'bold': caption_role.bold, 'number_format': 'General'}
if kind == 'line':
payload['markers'] = True
fallback = _node(marker, 'g', id=component_id+'-fallback')
@@ -280,7 +288,8 @@ def render_chart(data: dict, style: dict, theme: dict,
if kind != 'bar':
payload['axes'] = {'category': {'label_position': 'low', 'major_gridlines': False}, 'value': _axis(yscale)}
else:
payload['data_labels'] = {'position': 'outside_end'}
payload['data_labels'] = {'position': 'outside_end', 'font_size': fs,
'color': ink, 'bold': caption_role.bold}
payload['show_value_axis_labels'] = True
outer.set('data-native-axis-policy', 'automatic-bar-preview-scale-may-differ')
if pie:
@@ -318,7 +327,8 @@ def render_chart(data: dict, style: dict, theme: dict,
_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')
require(band >= max(fs+8, caption_role.line_size) and bw >= max(fs+4, caption_role.line_size),
'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')
@@ -333,7 +343,8 @@ def render_chart(data: dict, style: dict, theme: dict,
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')
require(right-left >= fs*7 and (pb-pt)/(len(ticks)-1) >= max(fs+4, caption_role.line_size),
'axis labels leave insufficient plot area')
low, high, step = yscale
yp = lambda val: pb-(val-low)/(high-low)*(pb-pt)
for tick in ticks: