Source code for greater_tables.render.tikz

"""TikZ renderers: TableDoc → a tikzpicture in a table environment.

Two emitters share one layout pass (:mod:`.tikz_layout`) and are chosen by
``HouseStyle.tikz_engine``:

- ``'tabular'`` (default, :mod:`.tikz_tabular`) — one node holding one real
  ``tabular``, horizontal rules from booktabs and vertical rules drawn off the
  node's known geometry. Roughly 30x faster than the matrix, and the same
  table to the eye.
- ``'matrix'`` — a ``matrix of nodes`` with a zero-height spacer row that fixes
  column widths, header rows in bold (stub header cells bold-italic), and a
  trailing phantom column used as a stable anchor rail. Rules are ``\\path``
  segments anchored on node corners, shifted by half the row/column separation
  into the inter-cell gap; colspans sit as empty matrix cells whose label is
  overlaid centered on the span via ``calc`` midpoints. Roughly 290 ms of TeX
  per table — the cost is materializing one pgf node per cell and nothing
  else — but those nodes are addressable, which ``tikz_post_process`` TeX may
  rely on.

Cells flagged ``math`` re-wrap as ``$...$``; other text is TeX-escaped with
inline ``$...$`` spans passed through as math (``mathspans``, unless the
document says ``math='off'``).
The matrix emitter's output expects the ``\\I`` strut macro
(``TIKZ_PREAMBLE``) — Etcher's standalone template defines it, and a Quarto
document must include it once in the preamble. The tabular emitter carries its
own ``\\providecommand`` and needs nothing from the host document.
"""

from __future__ import annotations

import logging

from ..ir import Cell, TableDoc
from ..spec import HouseStyle
from .policy import rule_slot, vrule_slot
from .tikz_layout import (
    MATRIX_GEOMETRY, W2TIKZ, Layout, cell_tex, layout, style_for, tex_escape,
)
from .tikz_tabular import render_tikz_tabular

__all__ = ['TIKZ_PREAMBLE', 'W2TIKZ', 'render_tikz', 'render_tikz_matrix',
           'render_tikz_tabular', 'table_width']

logger = logging.getLogger(__name__)

TIKZ_PREAMBLE = r'\newcommand{\I}{\vphantom{lp}}'

_EM = 1000


[docs] def table_width(widths, scale: float, ncols: int, csep_milli: int) -> int: """Emitted width of a *matrix* table, in milli-ems. Kept at module scope with its original signature; the general form lives on :class:`~.tikz_layout.Geometry`. """ return MATRIX_GEOMETRY.table_width(widths, scale, ncols, csep_milli)
[docs] def render_tikz(doc: TableDoc, style: HouseStyle | None = None) -> str: """Render a table document to a TikZ table (LaTeX source). Dispatches on ``style.tikz_engine``; see the module docstring for what the two engines trade against each other. """ style = style or HouseStyle() if style.tikz_engine == 'tabular': return render_tikz_tabular(doc, style) return render_tikz_matrix(doc, style)
[docs] def render_tikz_matrix(doc: TableDoc, style: HouseStyle | None = None) -> str: """Render a table document as a ``matrix of nodes`` tikzpicture.""" style = style or HouseStyle() if len(doc.columns) == 0: return '% greater_tables: empty table\n' lay = layout(doc, style, MATRIX_GEOMETRY) return _emit(doc, style, lay)
def _emit(doc: TableDoc, style: HouseStyle, lay: Layout) -> str: name, ncols = lay.name, lay.ncols n_head, n_stub = lay.n_head, lay.n_stub grid, widths, aligns = lay.grid, lay.widths, lay.aligns csep, rsep = style.tikz_column_sep, style.tikz_row_sep gap, ygap = csep / 2, rsep / 2 n_rows = len(grid) body_start, body_rows = lay.body_start, lay.body_rows colw = [max((len(grid[r][j]) for r in range(n_rows)), default=1) for j in range(ncols)] pad = {'l': '<', 'r': '>', 'c': '^'} # the *flush* variants: TikZ's plain 'left'/'right'/'center' are balanced # ragged, whose limited \rightskip stretch makes TeX break a line that # fits its column exactly — measured, a cell then needs ~0.1em of slack # per interword space. Flush is unlimited raggedness, so natural width # means natural width, which is what every width estimate here assumes # (and what the table-level 'text badly ragged' already asks for). align_word = {'l': 'flush left', 'r': 'flush right', 'c': 'flush center'} out: list[str] = [] w = out.append # ---- container, caption, matrix styles latex = f'[{style.tikz_latex}]' if style.tikz_latex else '' w(f'\\begin{{{style.tikz_container_env}}}{latex}') w('\\centering') if doc.caption is not None: cap = doc.caption if isinstance(doc.caption, Cell) else Cell(text=doc.caption) label = f'\\label{{{doc.label}}}' if doc.label else '' w(f'\\caption{{{cell_tex(cap, math=doc.math != "off")}}}{label}') w('\\begin{tikzpicture}[') w(' auto, transform shape,') w(' nosep/.style={inner sep=0},') w(' table/.style={') w(' matrix of nodes,') w(f' row sep={rsep}em,') w(f' column sep={csep}em,') w(' nodes in empty cells,') w(f' nodes={{rectangle, scale={lay.scale}, text badly ragged}},') w('\trow 1/.style={nodes={text=black, anchor=north, inner ysep=0, ' 'text height=0, text depth=0}},') for i in range(2, n_head + 2): w(f'\trow {i}/.style={{nodes={{text=black, anchor=south, ' 'inner ysep=.2em, minimum height=1.3em, font=\\bfseries, ' 'align=flush center}},') for j in range(1, 1 + n_stub): w(f'\trow {i} column {j}/.style=' '{nodes={font=\\bfseries\\itshape, align=flush left}},') for j, (wd, al) in enumerate(zip(widths, aligns), start=1): extra = (f'inner xsep={csep}em, inner ysep=0, ' if j == 1 else 'nosep, ') w(f'\tcolumn {j:>2d}/.style={{nodes={{align={align_word[al]:<12s}}}, ' f'{extra}text width={wd / _EM:.2f}em}},') w(f'\tcolumn {ncols + 1:>2d}/.style={{text height=0.9em, ' 'text depth=0.2em, nosep, text width=0em}') w('\t}]') # ---- the matrix w(f'\\matrix ({name}) [table, ampersand replacement=\\&]{{') for row in grid: line = ' '.join( f'{"" if j == 0 else chr(92) + "&"} ' f'{row[j]:{pad[aligns[j]]}{colw[j]}s}' for j in range(ncols)) w(line + ' \\& \\\\') w('};') # ---- overlaid spanners (real colspans) for tikz_row, c0, span, tex in lay.overlays: a = f'{name}-{tikz_row}-{c0 + 1}.west' b = f'{name}-{tikz_row}-{c0 + span}.east' opts = 'anchor=center' + (', font=\\bfseries' if tikz_row <= 1 + n_head else '') w(f'\\node[{opts}] at ($({a})!0.5!({b})$) {{{tex}}};') # ---- horizontal rules def hpath(style_name: str, tikz_row: int, start_col: int, shift: float): ref = f'{name}-{tikz_row}-{ncols + 1}' start = f'{name}-{tikz_row}-{start_col}' w(f'\\path[draw, {style_name}] ' f'([yshift={shift}em]{ref}.north -| {start}.west) -- ' f'([yshift={shift}em]{ref}.north -| {ref}.east);') top = style_for(style.frame_rule) if top: w(f'\\path[draw, {top}] ({name}-1-1.north west) -- ' f'({name}-1-{ncols + 1}.north east);') base = style_for(style.head_rule) if base and body_rows: hpath(base, body_start, 1, ygap) n_body = lay.n_body for r, row in enumerate(body_rows): if r == 0 or r == n_body: # first row of body / of foot continue rules = [] d = row.break_depth slot = rule_slot(d, n_stub, style.max_rule_depth) if slot is not None: # weight from the slot, start column from the raw depth rules.append((style_for(style.hrule_widths[slot]), d + 1)) if 'total' in row.flags or 'subtotal' in row.flags: rules.append((style_for(style.total_rule), 1)) for style_name, start_col in rules: if style_name: hpath(style_name, body_start + r, start_col, ygap) bottom = style_for(style.frame_rule) if bottom: w(f'\\path[draw, {bottom}] ' f'([yshift={-ygap}em]{name}-1-1.south west |- {name}.south) -- ' f'([yshift={-ygap}em]{name}-1-{ncols + 1}.south east |- ' f'{name}.south);') # ---- spanner rules: full-width underline per group label, extended by # the half-gap each side so adjacent groups meet at the group vertical sp = style_for(style.spanner_rule) if sp: for level, c0, span in lay.spanners: row = 2 + level a = f'{name}-{row}-{c0 + 1}.south west' b = f'{name}-{row}-{c0 + span}.south east' w(f'\\path[draw, {sp}] ([xshift={-gap}em, yshift={-ygap}em]{a}) ' f'-- ([xshift={gap}em, yshift={-ygap}em]{b});') # ---- vertical rules stub = style_for(style.stub_rule) if stub and n_stub > 0: w(f'\\path[draw, {stub}] ' f'([xshift={gap}em]{name}-1-{n_stub}.south east) -- ' f'([xshift={gap}em]{name}-1-{n_stub}.south east |- {name}.south);') for j, column in enumerate(doc.columns): slot = (vrule_slot(column.break_depth, style.max_vrule_depth) if column.role == 'data' else None) if slot is None: continue vstyle = style_for(style.vrule_widths[slot]) if not vstyle: continue # full height, table top to bottom — matching the HTML gt-vbreak # border which runs through the header rows anchor = f'{name}-1-{j + 1}.north west' w(f'\\path[draw, {vstyle}] ([xshift={-gap}em]{anchor}) -- ' f'([xshift={-gap}em]{anchor} |- {name}.south);') if style.tikz_post_process: w(style.tikz_post_process) w('\\end{tikzpicture}') for note in doc.notes: w(f'\\par{{\\footnotesize {tex_escape(note, math=doc.math != "off")}}}') w(f'\\end{{{style.tikz_container_env}}}') return '\n'.join(out) + '\n'