#!/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