Source code for greater_tables.render.html

"""Python HTML renderer: TableDoc → fragment.

Mirrors ``js/src/gt-render.js`` structure and class stamping EXACTLY — the
DOM-transcript conformance goldens (``tests/golden/transcript``) enforce the
agreement, so any change here must be paired with the walker and the goldens
regenerated. Class helpers below are direct ports of the walker's.

Escaping: every ``text`` is HTML-escaped unless the cell carries
``html: true`` and ``allow_html`` is on (the default here — the producer set
that flag deliberately via ``TableSpec.html_cols``; the walker's default is
the opposite because it consumes foreign documents).

Stylesheet policy (dev/design.md): ``css='embed'`` puts the ~2 KB sheet in
every fragment (idempotent — duplicates are no-ops, and a saved notebook
whose first cell was cleared keeps its styles); ``'once'`` embeds only the
first time per process; ``'none'`` for documents that include gt.css
themselves. ``inline_css=True`` flattens the same RULES dict onto style
attributes for HTMX-fragment/email use — no classes required by the host.
"""

from __future__ import annotations

import html as _html
import re

from ..ir import Cell, HeadCell, Row, TableDoc
from ..mathspans import segments
from ..spec import HouseStyle
from . import css as _css
from .policy import rule_slot, vrule_slot

__all__ = ['render_html', 'reset_css_once']

_CELL_FLAG_CLASS = {'neg': 'gt-neg', 'emphasis': 'gt-emph', 'muted': 'gt-muted'}
_ROW_FLAG_CLASS = {'total': 'gt-total', 'subtotal': 'gt-subtotal',
                   'emphasis': 'gt-emph', 'muted': 'gt-muted'}

_css_emitted = False


[docs] def reset_css_once() -> None: """Reset the ``css='once'`` latch (new kernel/document boundary).""" global _css_emitted _css_emitted = False
[docs] def render_html(doc: TableDoc, *, css: str = 'embed', inline_css: bool = False, allow_html: bool = True, style: HouseStyle | None = None) -> str: """Render a table document to an HTML fragment. Parameters ---------- doc The document. css ``'embed'`` (default), ``'once'``, or ``'none'``. inline_css Flatten the stylesheet onto ``style`` attributes (HTMX/email); implies ``css='none'``. allow_html Honor cell ``html: true``. Off ⇒ such cells render as escaped source. style House style. ``max_rule_depth`` and ``max_vrule_depth`` are the only fields this renderer reads — they decide which breaks get a ``gt-break-{slot}`` / ``gt-vbreak-{slot}`` class. Rule *weights* are CSS custom properties (``--gt-rule-{slot}``, ``--gt-vrule-{slot}``), set in a stylesheet, not here. """ global _css_emitted style = style or HouseStyle() math_on = doc.math != 'off' ncols = len(doc.columns) custom = _width_properties(style) sty = _Styler(inline_css, custom) out: list[str] = [] if not inline_css and css != 'none' and not (css == 'once' and _css_emitted): out.append(f'<style>{_css.css_text()}</style>') _css_emitted = True attrs = ['class="gt"'] if doc.hash: attrs.append(f'data-gt-hash="{doc.hash}"') if doc.label: attrs.append(f'id="{_esc(doc.label)}"') out.append(f'<div {" ".join(attrs)}{sty.attr(".gt")}>') out.append(f'<table{sty.attr("table")}>') if doc.caption is not None: cap = doc.caption if isinstance(doc.caption, Cell) else Cell(text=doc.caption) cls, content = _cell_content(cap, ['gt-caption'], allow_html, math_on) out.append(f'<caption class="{" ".join(cls)}"' f'{sty.attr(".gt-caption")}>{content}</caption>') if doc.head: out.append('<thead>') n_head = len(doc.head) for level, row in enumerate(doc.head): leaf = level == n_head - 1 cells = [] col = 0 for hc in row: colspan = hc.colspan cls = _head_classes(doc, col, colspan, leaf, hc, style.max_vrule_depth) cls, content = _cell_content(hc, cls, allow_html, math_on) span = f' colspan="{colspan}"' if colspan > 1 else '' cells.append(f'<th{span} class="{" ".join(cls)}"' f'{sty.th(cls, leaf)}>{content}</th>') col += colspan out.append('<tr>' + ''.join(cells) + '</tr>') out.append('</thead>') out.append('<tbody>') out.extend(_section(doc, doc.body, sty, allow_html, style)) out.append('</tbody>') if doc.foot: out.append('<tfoot>') out.extend(_section(doc, doc.foot, sty, allow_html, style)) out.append('</tfoot>') out.append('</table>') if doc.notes: notes = ''.join(f'<div>{_span_html(n, math_on)}</div>' for n in doc.notes) out.append(f'<div class="gt-notes"{sty.attr(".gt-notes")}>{notes}</div>') out.append('</div>') return '\n'.join(out)
def _section(doc: TableDoc, rows: tuple[Row, ...], sty: _Styler, allow_html: bool, style: HouseStyle) -> list[str]: ncols = len(doc.columns) pending = [0] * ncols out: list[str] = [] for r, row in enumerate(rows): row_cls = _row_classes(doc, row, r == 0, style.max_rule_depth) cls_attr = f' class="{" ".join(row_cls)}"' if row_cls else '' cells = [] col = 0 for entry in row.cells: cell = entry if isinstance(entry, Cell) else Cell(text=entry) while col < ncols and pending[col] > 0: col += 1 if col >= ncols: break cls = _body_classes(doc, col, cell.colspan, cell, style.max_vrule_depth) cls, content = _cell_content(cell, cls, allow_html, doc.math != 'off') span = '' if cell.colspan > 1: span += f' colspan="{cell.colspan}"' if cell.rowspan > 1: span += f' rowspan="{cell.rowspan}"' cells.append(f'<td data-c="{col}"{span} class="{" ".join(cls)}"' f'{sty.td(cls, row_cls)}>{content}</td>') for cc in range(col, min(ncols, col + cell.colspan)): if cell.rowspan > 1: pending[cc] = cell.rowspan col += cell.colspan for cc in range(ncols): if pending[cc] > 0: pending[cc] -= 1 out.append(f'<tr data-r="{r}"{cls_attr}>' + ''.join(cells) + '</tr>') return out def _cell_content(cell: Cell | HeadCell, cls: list[str], allow_html: bool, math: bool = True) -> tuple[list[str], str]: """Return (classes, inner html) applying math/html handling (walker port).""" if cell.math: return cls + ['gt-math'], _esc(f'\\({cell.text}\\)') if cell.html: # user markup: its $s are literal, never segmented return (cls, cell.text) if allow_html else (cls, _esc(cell.text)) return cls, _span_html(cell.text, math) def _span_html(text: str, math: bool) -> str: """Escape prose; with ``math`` on, ``$...$`` spans emit as gt-math spans.""" if not math or '$' not in text: return _esc(text) return ''.join( '<span class="gt-math">' + _esc('\\(' + run + '\\)') + '</span>' if is_math else _esc(run) for is_math, run in segments(text)) def _esc(text: str) -> str: return _html.escape(text, quote=False) # ---- class stamping: direct ports of gt-render.js — keep in lockstep def _column_classes(doc: TableDoc, col: int, max_vrule_depth) -> list[str]: c = doc.columns[col] cls = [f'gt-{c.align}', 'gt-stub' if c.role == 'stub' else 'gt-data', 'gt-wrap' if c.wrap else 'gt-nowrap'] slot = vrule_slot(c.break_depth, max_vrule_depth) if c.role == 'data' else None if slot is not None: cls.append(f'gt-vbreak-{slot}') return cls def _head_classes(doc: TableDoc, col: int, colspan: int, leaf: bool, hc: HeadCell, max_vrule_depth) -> list[str]: cls: list[str] = [] first = doc.columns[col] if col < len(doc.columns) else None n_stub = doc.n_stub_levels if hc.align: cls.append(f'gt-{hc.align}') elif not leaf or colspan > 1: cls.append('gt-c') elif first: cls.append(f'gt-{first.align}') else: cls.append('gt-l') cls.append('gt-stub' if first and col < n_stub else 'gt-data') if not leaf and hc.text: cls.append('gt-spanner') # cmidrule under groups if n_stub > 0 and col + colspan == n_stub: cls.append('gt-stub-end') bd = first.break_depth if first else None slot = vrule_slot(bd, max_vrule_depth) if col >= n_stub else None if slot is not None: cls.append(f'gt-vbreak-{slot}') cls.extend(_CELL_FLAG_CLASS[f] for f in hc.flags if f in _CELL_FLAG_CLASS) return cls def _body_classes(doc: TableDoc, col: int, colspan: int, cell: Cell, max_vrule_depth) -> list[str]: cls = _column_classes(doc, col, max_vrule_depth) if doc.n_stub_levels > 0 and col + colspan == doc.n_stub_levels: cls.append('gt-stub-end') cls.extend(_CELL_FLAG_CLASS[f] for f in cell.flags if f in _CELL_FLAG_CLASS) return cls def _row_classes(doc: TableDoc, row: Row, is_first: bool, max_rule_depth) -> list[str]: cls: list[str] = [] slot = None if is_first else rule_slot(row.break_depth, doc.n_stub_levels, max_rule_depth) if slot is not None: cls.append(f'gt-break-{slot}') cls.extend(_ROW_FLAG_CLASS[f] for f in row.flags if f in _ROW_FLAG_CLASS) return cls # ---- inline styling (fiscus/HTMX/email path) — same RULES dict, flattened def _width_properties(style: HouseStyle) -> dict[str, str]: """Per-table width custom properties, emitted only when they say something the stylesheet does not already say. This is the whole of the HTML width story. ``gt-nowrap`` already pins the columns that must not be narrowed, so a table-level ``max-width`` leaves the browser to narrow exactly the prose ones — measuring the real font rather than estimating it, which is why no solver runs on this path. """ custom: dict[str, str] = {} if style.max_table_width_em is not None: custom['--gt-max-width'] = f'{style.max_table_width_em:g}em' prose = f'{style.max_prose_width_em:g}em' if prose != _css.VARIABLES['--gt-prose-max']: custom['--gt-prose-max'] = prose return custom class _Styler: """Resolve RULES onto style attributes when ``inline_css`` is on. Understands exactly the selector shapes RULES uses; the vocabulary test plus conformance goldens keep this honest. Custom-property references are substituted with their VARIABLES defaults (email clients know no vars), or with this table's overrides where it has any. Per-table custom properties ride on the wrapper in *both* modes — they are this table's values, not the sheet's — so ``attr('.gt')`` returns a style attribute even when ``inline_css`` is off. """ def __init__(self, active: bool, custom: dict[str, str] | None = None): self.active = active self.custom = custom or {} def attr(self, key: str) -> str: props: dict[str, str] = {} if self.active: props = dict({ '.gt': {'line-height': _css.RULES['.gt']['line-height'], 'width': 'fit-content'}, 'table': _css.RULES['.gt.gt table'], '.gt-caption': _css.RULES['.gt.gt .gt-caption'], '.gt-notes': _css.RULES['.gt.gt .gt-notes'], }.get(key, {})) if key == '.gt' and '--gt-max-width' in self.custom: # no custom properties in an email client: state it outright props['max-width'] = self.custom['--gt-max-width'] if key == '.gt': props = {**self.custom, **props} return self._style(props) def td(self, cls: list[str], row_cls: list[str]) -> str: if not self.active: return '' props = dict(_css.RULES['.gt.gt td, .gt.gt th']) for c in cls: props.update(_css.RULES.get(f'.gt.gt .{c}', {})) for rc in row_cls: props.update(_css.RULES.get(f'.gt.gt tr.{rc} > td', {})) return self._style(props) def th(self, cls: list[str], leaf: bool) -> str: if not self.active: return '' props = dict(_css.RULES['.gt.gt td, .gt.gt th']) props.update(_css.RULES['.gt.gt th']) for c in cls: props.update(_css.RULES.get(f'.gt.gt .{c}', {})) if leaf: props.update(_css.RULES['.gt.gt thead tr:last-child th']) return self._style(props) def _style(self, props: dict[str, str]) -> str: # an email client knows no custom properties, so inline mode drops the # declarations and keeps only what they were resolved into items = [(k, v) for k, v in props.items() if not (self.active and k.startswith('--'))] body = ' '.join(f'{k}: {self._resolve(v)};' for k, v in items) return f' style="{body}"' if body else '' def _resolve(self, value: str) -> str: return _resolve_vars(value, self.custom) if self.active else value _VAR_RE = re.compile(r'var\((--[a-z-]+)(?:,\s*([^)]+))?\)') def _resolve_vars(value: str, overrides: dict[str, str] | None = None) -> str: """Substitute var(--gt-x) with this table's value, else the sheet default.""" def sub(m: re.Match) -> str: name = m.group(1) if overrides and name in overrides: return overrides[name] return _css.VARIABLES.get(name, m.group(2) or '') return _VAR_RE.sub(sub, value)