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

252 lines
16 KiB
Python

#!/usr/bin/env python3
"""Grouped native tables with explicit per-column bars and status marks."""
from __future__ import annotations
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
from component_typography import resolve_role
SVG = 'http://www.w3.org/2000/svg'
def _fields(value, allowed, required, name):
ref.require(isinstance(value, dict), f'{name}: object required')
ref.require(set(value) <= allowed, f'{name}: unknown fields {sorted(set(value)-allowed)}')
ref.require(required <= set(value), f'{name}: missing fields {sorted(required-set(value))}')
def _number(value, name):
ref.require(type(value) in (int, float) and math.isfinite(value), f'{name}: finite number required')
return value
def _text(value, name, empty=False):
ref.require(isinstance(value, str) and (empty or bool(value.strip())), f'{name}: string required')
ref.require(not any(unicodedata.category(c).startswith('C') for c in value), f'{name}: control/format characters unsupported')
return value
def _indices(value, count, name):
ref.require(isinstance(value, list) and all(type(i) is int and 0 <= i < count for i in value), f'{name}: invalid column index')
ref.require(len(set(value)) == len(value), f'{name}: duplicate column index')
return value
def _fit(value, width, size, name):
units = sum(1 if unicodedata.east_asian_width(c) in ('W', 'F') else .6 for c in value)
ref.require(units*size <= width, f'{name}: text exceeds capacity; shorten text or enlarge bounds')
def _node(parent, tag, **attributes):
return ET.SubElement(parent, '{'+SVG+'}'+tag, {key.replace('_', '-'):str(value) for key,value in attributes.items()})
def _label(parent, value, x, y, font, size, color, align, weight='normal'):
result = _node(parent, 'text', x=x, y=y, font_family=font, font_size=size,
font_weight=weight, fill=color, text_anchor='end' if align == 'r' else 'middle' if align == 'ctr' else 'start')
result.text = value
def _formatted(value, column):
kind = column.get('format', 'text')
if kind == 'integer':
ref.require(type(value) in (int,float) and value == int(value), 'integer format requires integral numeric values')
return f'{int(value):,}'
if kind == 'one-decimal':
ref.require(type(value) in (int,float), 'one-decimal format requires numeric values')
return f'{value:,.1f}'
return str(value)
def render_grouped_table(raw: dict, style: dict, theme: dict, bounds: tuple,
component_id: str) -> ET.Element:
"""Return an outer g containing a native-table marker and separate shapes.
Original values are retained; number formats affect display text only. Bars
express independent zero-based column magnitudes, never a ranking or score.
"""
allowed = {'schema_version','kind','variant','data_kind','source','columns','rows','column_widths',
'numeric_columns','column_groups','focus_row','row_header_columns','bars','status',
'focus_meaning','bar_meaning'}
_fields(raw, allowed, {'schema_version','kind','variant','columns','rows','column_widths','column_groups'}, 'grouped table')
ref.require(type(raw['schema_version']) is int and raw['schema_version'] == 1 and raw['kind'] == 'table' and raw['variant'] == 'grouped', 'requires table/grouped schema_version 1')
data = deepcopy(raw)
if 'data_kind' in data:
ref.require(data['data_kind'] in ('synthetic-demo','observed','derived'), 'invalid data_kind')
for key in ('source','focus_meaning','bar_meaning'):
if key in data: _text(data[key], key, empty=True)
ref.identifier(component_id, 'component ID')
ref.require(isinstance(bounds, (tuple,list)) and len(bounds) == 4, 'bounds requires x/y/width/height')
x,y,width,height = [_number(v, 'bounds') for v in bounds]
canvas = style['canvas']
ref.require(x >= 0 and y >= 0 and width > 0 and height > 0 and x+width <= canvas['width'] and y+height <= canvas['height'], 'bounds outside canvas')
columns, rows = data['columns'], data['rows']
ref.require(isinstance(columns,list) and bool(columns), 'columns must be nonempty list')
ids = []
for col in columns:
_fields(col, {'id','label','unit','format'}, {'id','label'}, 'column')
ids.append(ref.identifier(col['id'], 'column ID'))
_text(col['label'], 'column label')
if 'unit' in col: _text(col['unit'], 'column unit', empty=True)
ref.require(col.get('format','text') in ('text','integer','one-decimal'), 'unknown column format')
count = len(columns)
ref.require(len(set(ids)) == count, 'duplicate column ID')
ref.require(isinstance(rows,list) and bool(rows), 'rows must be nonempty list')
for row in rows:
ref.require(isinstance(row,list) and len(row) == count, 'table row length mismatch')
for value in row:
if type(value) in (int,float): _number(value, 'cell')
else: _text(value, 'cell', empty=True)
widths = data['column_widths']
ref.require(isinstance(widths,list) and len(widths) == count, 'column_widths mismatch')
ref.require(all(_number(v,'column width') > 0 for v in widths) and abs(sum(widths)-width) < .01, 'column widths must fill bounds width')
numeric = _indices(data.get('numeric_columns',[]), count, 'numeric_columns')
for row in rows:
for col in numeric: _number(row[col], 'numeric cell')
row_headers = _indices(data.get('row_header_columns',[]), count, 'row_header_columns')
focus = data.get('focus_row')
ref.require(focus is None or type(focus) is int and 0 <= focus < len(rows), 'focus_row invalid')
groups = data['column_groups']
ref.require(isinstance(groups,list) and bool(groups), 'column_groups must be nonempty list')
cursor = 0
for group in groups:
_fields(group, {'label','start','span'}, {'label','start','span'}, 'column group')
_text(group['label'],'column group label')
ref.require(type(group['start']) is int and type(group['span']) is int and group['start'] == cursor and group['span'] > 0, 'column_groups must provide ordered contiguous coverage')
cursor += group['span']
ref.require(cursor == count, 'column_groups must cover all columns')
bar_columns = []
if 'bars' in data:
_fields(data['bars'], {'columns'}, {'columns'}, 'bars')
bar_columns = _indices(data['bars']['columns'], count, 'bars.columns')
ref.require(set(bar_columns) <= set(numeric), 'bars require numeric_columns')
for row in rows:
for col in bar_columns: ref.require(row[col] >= 0, 'bars require nonnegative values')
status = data.get('status')
if status is not None:
_fields(status, {'column','values'}, {'column','values'}, 'status')
_indices([status['column']], count, 'status.column')
ref.require(status['column'] not in numeric and status['column'] not in bar_columns, 'status column must be textual')
ref.require(isinstance(status['values'],dict) and bool(status['values']), 'status.values must be nonempty object')
for value, cfg in status['values'].items():
_text(value, 'status value')
_fields(cfg, {'palette_index','shape'}, {'palette_index','shape'}, 'status value')
_text(cfg['palette_index'], 'status palette_index')
ref.require(cfg['shape'] in ('circle','diamond'), 'status shape must be circle or diamond')
ref.require(all(row[status['column']] in status['values'] for row in rows), 'unknown status value')
cfg = style.get('components',{}).get('table_grouped',{})
_fields(cfg, {'group_height_px','header_height_px','row_height_px','cell_padding_px'}, set(), 'style.components.table_grouped')
label_role = resolve_role(style, style.get('components',{}).get('table',{}).get('text_role','label'))
caption_role = resolve_role(style, 'caption')
font, caption_font = label_role.family, caption_role.family
size, small = label_role.size, caption_role.size
padding = cfg.get('cell_padding_px',[8,16])
ref.require(isinstance(padding,list) and len(padding) == 2 and all(_number(v,'cell padding') >= 0 and v % 8 == 0 for v in padding), 'cell_padding_px requires two nonnegative 8px multiples')
top, side = padding
module = lambda v: math.ceil(v/8)*8
gh = cfg.get('group_height_px',module(small+2*top))
hh = cfg.get('header_height_px',module(size+small+2*top+8))
rh = cfg.get('row_height_px',module(size+2*top+(8 if bar_columns else 0)))
for key,value,minimum in (('group height',gh,small+2*top),('header height',hh,size+small+2*top+8),('row height',rh,size+2*top+(8 if bar_columns else 0))):
ref.require(_number(value,key) >= minimum and value % 8 == 0, f'{key}: insufficient height or not an 8px multiple')
total = gh+hh+len(rows)*rh
ref.require(total <= height, 'table rows exceed bounds height')
palette = theme['palette']
keys = {'accent1','accent2','surface_tint','lt1','lt2','dk1','dk2','line'}
if status:
keys.update(v['palette_index'] for v in status['values'].values())
for key in keys:
ref.require(isinstance(key,str) and key in palette and isinstance(palette[key],str) and re.fullmatch(r'#[0-9a-fA-F]{6}',palette[key]), f'unknown/invalid palette index: {key}')
outer = ET.Element('{'+SVG+'}g', {'id':component_id,'data-component-kind':'table','data-component-variant':'grouped'})
source = _node(outer,'metadata',type='application/json')
marks = []
marker = _node(outer,'g',id=component_id+'-native',data_pptx_replace_with='table')
metadata = _node(marker,'metadata',type='application/json')
marker.set('data-component-source',json.dumps(data,ensure_ascii=False,allow_nan=False))
fallback = _node(marker,'g',id=component_id+'-fallback')
none = lambda: {s:{'style':'none'} for s in ('top','bottom','left','right')}
table_rows = ['']*count
for i,group in enumerate(groups):
start,span = group['start'],group['span']; gx=x+sum(widths[:start]); gw=sum(widths[start:start+span])
fill = palette['accent1' if i%2 == 0 else 'accent2']
_fit(group['label'],gw-2*side,small,'group header')
cell = {'paragraphs':[{'align':'ctr','runs':[{'text':group['label'],'font_size':small,'font_family':caption_font,'bold':caption_role.bold,'color':palette['lt1']}]}],'fill':fill,'color':palette['lt1'],'font_size':small,'bold':caption_role.bold,'align':'ctr','valign':'middle','padding':{'top':0,'bottom':0,'left':side,'right':side},'borders':none()}
if span > 1: cell['col_span']=span
table_rows[start]=cell
_node(fallback,'rect',x=gx,y=y,width=gw,height=gh,fill=fill)
_label(fallback,group['label'],gx+gw/2,y+(gh+small*.72)/2,caption_font,small,palette['lt1'],'ctr',caption_role.svg_weight)
table_rows = [table_rows]
group_ends = {g['start']+g['span']-1 for g in groups[:-1]}
cy = y+gh
for ri,row in enumerate([columns,*rows]):
header = ri == 0; focused = not header and ri-1 == focus; cell_height = hh if header else rh
cx=x; cells=[]
for ci,value in enumerate(row):
fill = palette['surface_tint'] if header or focused else palette['lt2'] if ci in row_headers or ri%2 == 0 else palette['lt1']
color = palette['accent1'] if header or focused else palette['dk1']
if not header and ci not in numeric and ci not in row_headers: color=palette['dk2']
is_status = not header and status is not None and ci == status['column']
if is_status: color=palette[status['values'][value]['palette_index']]
align='r' if ci in numeric else 'l'
pad={'top':top,'bottom':top+8 if not header and ci in bar_columns else top,'left':side+32 if is_status else side,'right':side}
borders=none(); borders['bottom']={'style':'solid','color':palette['accent1'] if focused else palette['line'],'width':1}
if ci in group_ends: borders['right']={'style':'solid','color':palette['line'],'width':1}
if focused:
borders['top']={'style':'solid','color':palette['accent1'],'width':1}
if ci == 0: borders['left']={'style':'solid','color':palette['accent1'],'width':4}
cell={'fill':fill,'color':color,'align':align,'font_family':font,'font_size':size,'bold':label_role.bold,'padding':pad,'valign':'middle','borders':borders}
_node(fallback,'rect',x=cx,y=cy,width=widths[ci],height=cell_height,fill=fill)
tx=cx+widths[ci]-side if align == 'r' else cx+pad['left']
if header:
title,unit=value['label'],value.get('unit','')
_fit(title,widths[ci]-2*side,size,'column header')
cell['paragraphs']=[{'align':align,'runs':[{'text':title,'font_size':size,'font_family':font,'bold':label_role.bold,'color':color}]}]
if unit:
_fit(unit,widths[ci]-2*side,small,'column unit')
cell['paragraphs'].append({'align':align,'runs':[{'text':unit,'font_size':small,'font_family':caption_font,'bold':caption_role.bold,'color':palette['dk2']}]})
baseline=cy+(cell_height-size-small-8)/2+size*.8
_label(fallback,title,tx,baseline,font,size,color,align,label_role.svg_weight)
_label(fallback,unit,tx,baseline+small+8,caption_font,small,palette['dk2'],align,caption_role.svg_weight)
else: _label(fallback,title,tx,cy+(cell_height+size*.72)/2,font,size,color,align,label_role.svg_weight)
else:
displayed=_formatted(value,columns[ci]); cell['text']=displayed
_fit(displayed,widths[ci]-pad['left']-side,size,'table cell')
_label(fallback,displayed,tx,cy+(cell_height+size*.72-(8 if ci in bar_columns else 0))/2,font,size,color,align,label_role.svg_weight)
if ci in bar_columns:
maximum=max(r[ci] for r in rows) or 1; track=widths[ci]-2*side
marks.append({'id':f'{component_id}-bar-r{ri}-c{ci}','kind':'bar','x':cx+side,'y':cy+cell_height-12,'width':track,'length':track*(value/maximum),'value':value,'domain':[0,maximum],'column':ci,'row':ri-1,'focused':focused})
if is_status:
marks.append({'id':f'{component_id}-status-r{ri}-c{ci}','kind':'status','x':cx+side+8,'y':cy+cell_height/2,'palette_index':status['values'][value]['palette_index'],'shape':status['values'][value]['shape'],'value':value,'row':ri-1,'column':ci})
for edge,line in borders.items():
if line['style']=='none': continue
coords={'top':(cx,cy,cx+widths[ci],cy),'bottom':(cx,cy+cell_height,cx+widths[ci],cy+cell_height),'left':(cx,cy,cx,cy+cell_height),'right':(cx+widths[ci],cy,cx+widths[ci],cy+cell_height)}[edge]
_node(fallback,'line',x1=coords[0],y1=coords[1],x2=coords[2],y2=coords[3],stroke=line['color'],stroke_width=line['width'])
cells.append(cell); cx+=widths[ci]
table_rows.append(cells); cy+=cell_height
payload={'name':component_id,'x':x,'y':y,'width':width,'height':total,'rows':table_rows,'header_rows':2,'strict_grid':True,'column_widths':widths,'row_heights':[gh,hh,*[rh]*len(rows)],'style':{'font_family':font,'font_size':size,'header_font_size':size,'band_row':False}}
metadata.text=json.dumps(payload,ensure_ascii=False,allow_nan=False)
source.text=json.dumps({'component_id':component_id,'data':data,'derived_marks':marks},ensure_ascii=False,allow_nan=False)
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))
overlay=_node(outer,'g',id=component_id+'-marks')
for mark in marks:
if mark['kind']=='bar':
_node(overlay,'rect',id=mark['id']+'-track',x=mark['x'],y=mark['y'],width=mark['width'],height=4,fill=palette['line'])
if mark['length'] > 0:
_node(overlay,'rect',id=mark['id'],x=mark['x'],y=mark['y'],width=mark['length'],height=4,fill=palette['accent1'],fill_opacity=.8 if mark['focused'] else .4)
elif mark['shape']=='circle': _node(overlay,'circle',id=mark['id'],cx=mark['x'],cy=mark['y'],r=4,fill=palette[mark['palette_index']])
else:
mx,my=mark['x'],mark['y']
_node(overlay,'path',id=mark['id'],d=f'M {mx} {my-6} L {mx+6} {my} L {mx} {my+6} L {mx-6} {my} Z',fill=palette[mark['palette_index']])
return outer