Release PPT workflow 0.7.3 with fixed spacing, paragraphs and native math
This commit is contained in:
+34
-23
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user