"""Text renderer — the conformance oracle.
Reads *only* the TableDoc. Anything this renderer cannot draw is data
missing from the IR, which is the point: it keeps the IR honest. Output is
deterministic monospace text with box-drawing rules.
Semantics made visible: break depths draw partial horizontal rules starting
at the changed stub column (which depths qualify is ``render.policy``, shared
with every other renderer); rowspan/colspan cells span; row flags appear in a
right gutter; math cells render as ``$tex$``; emphasis/muted cells as
``*text*``/``~text~``.
Every non-ASCII character the renderer emits lives in :class:`Charset`, so
``HouseStyle.text_ascii_rules`` can swap the whole set at once — for a LaTeX
verbatim block, or a console whose encoding or mono font cannot carry box
drawing. The ASCII set keeps the heavy/light distinction (``=`` against
``-``) rather than collapsing both to one character: which rule is a frame
and which is a group break is information the oracle should not lose.
"""
from __future__ import annotations
from dataclasses import dataclass
from ..ir import Cell, HeadCell, Row, TableDoc
from ..spec import HouseStyle
from .policy import rule_slot
__all__ = ['ASCII_CHARS', 'Charset', 'UNICODE_CHARS', 'render_text']
GAP = 2 # spaces between columns
STUB_GAP = 3 # ' │ ' between stub block and data block
[docs]
@dataclass(frozen=True)
class Charset:
"""The characters the text renderer draws rules and markers with.
``vrule`` must be one character wide and the flag delimiters live in a
right gutter, so swapping sets never moves a column: widths are solved
from cell content, and ``STUB_GAP`` is 3 either way.
"""
heavy: str # frame, header and section rules
light: str # break rules and spanner underlines
vrule: str # stub/body divider
flag_open: str # row-flag gutter delimiters
flag_close: str
UNICODE_CHARS = Charset('━', '─', '│', '«', '»')
ASCII_CHARS = Charset('=', '-', '|', '<<', '>>')
[docs]
def render_text(doc: TableDoc, style: HouseStyle | None = None) -> str:
"""Render a table document to monospace text.
Two ``HouseStyle`` fields reach here. ``max_rule_depth`` sets how deep
breaks are ruled — text has no way to express a rule *weight*, so every
drawn break looks the same. ``text_ascii_rules`` swaps :data:`ASCII_CHARS`
for the default :data:`UNICODE_CHARS`.
"""
style = style or HouseStyle()
cs = ASCII_CHARS if style.text_ascii_rules else UNICODE_CHARS
ncols = len(doc.columns)
lines: list[str] = []
if doc.caption:
cap = doc.caption if isinstance(doc.caption, str) else _decorate(doc.caption)
if doc.label:
cap += f' {{#{doc.label}}}'
lines.append(cap)
lines.append('')
if ncols == 0:
lines.append('(empty table)')
return '\n'.join(lines + [''])
body_grid, body_rows = _place(doc.body, ncols)
foot_grid, foot_rows = _place(doc.foot, ncols)
widths, gaps = _solve_widths(doc, body_grid, foot_grid)
total = sum(widths) + sum(gaps)
lines.append(cs.heavy * total)
for level, row in enumerate(doc.head):
leaf = level == len(doc.head) - 1
lines.append(_head_line(row, doc, widths, gaps, leaf, cs))
if not leaf:
rule = _spanner_line(row, widths, gaps, total, cs)
if rule.strip():
lines.append(rule)
if doc.head:
lines.append(cs.heavy * total)
lines.extend(_section_lines(doc, doc.body, body_grid, widths, gaps,
style.max_rule_depth, cs))
if doc.foot:
lines.append(cs.heavy * total)
lines.extend(_section_lines(doc, doc.foot, foot_grid, widths, gaps,
style.max_rule_depth, cs))
lines.append(cs.heavy * total)
for note in doc.notes:
lines.append(f'Note: {note}')
return '\n'.join(lines + [''])
# ---------------------------------------------------------------- placement
def _place(rows: tuple[Row, ...], ncols: int):
"""Expand rows into an occupancy grid of (cell, is_origin, col_span)."""
n = len(rows)
grid: list[list[tuple[Cell, bool] | None]] = [[None] * ncols for _ in range(n)]
norm_rows: list[list[Cell]] = []
for r, row in enumerate(rows):
cells = [c if isinstance(c, Cell) else Cell(text=c) for c in row.cells]
norm_rows.append(cells)
col = 0
for cell in cells:
while col < ncols and grid[r][col] is not None:
col += 1
if col >= ncols:
break
for rr in range(r, min(n, r + cell.rowspan)):
for cc in range(col, min(ncols, col + cell.colspan)):
grid[rr][cc] = (cell, rr == r and cc == col)
col += cell.colspan
return grid, norm_rows
def _decorate(cell: Cell | HeadCell) -> str:
text = cell.text
if cell.math:
text = f'${text}$'
if 'emphasis' in cell.flags:
text = f'*{text}*'
if 'muted' in cell.flags:
text = f'~{text}~'
return text
# ------------------------------------------------------------------- widths
def _solve_widths(doc: TableDoc, *grids) -> tuple[list[int], list[int]]:
ncols = len(doc.columns)
n_stub = doc.n_stub_levels
widths = [1] * ncols
# singleton cells set natural widths
for row in doc.head:
col = 0
for hc in row:
if hc.colspan == 1:
widths[col] = max(widths[col], len(_decorate(hc)))
col += hc.colspan
for grid in grids:
for r, grow in enumerate(grid):
for c, entry in enumerate(grow):
if entry and entry[1] and entry[0].colspan == 1:
widths[c] = max(widths[c], len(_decorate(entry[0])))
gaps = [0] + [STUB_GAP if 0 < n_stub == i else GAP for i in range(1, ncols)]
# spanning cells may need more room: widen the last spanned column
def ensure(col: int, span: int, text_len: int) -> None:
have = sum(widths[col:col + span]) + sum(gaps[col + 1:col + span])
if text_len > have:
widths[col + span - 1] += text_len - have
for row in doc.head:
col = 0
for hc in row:
if hc.colspan > 1:
ensure(col, hc.colspan, len(_decorate(hc)))
col += hc.colspan
for grid in grids:
for r, grow in enumerate(grid):
for c, entry in enumerate(grow):
if entry and entry[1] and entry[0].colspan > 1:
ensure(c, entry[0].colspan, len(_decorate(entry[0])))
return widths, gaps
def _pad(text: str, width: int, align: str) -> str:
if align == 'r':
return text.rjust(width)
if align == 'c':
return text.center(width)
return text.ljust(width)
# ------------------------------------------------------------------ drawing
def _head_line(row: tuple[HeadCell, ...], doc: TableDoc, widths, gaps,
leaf: bool, cs: Charset) -> str:
parts: list[str] = []
col = 0
for hc in row:
span_w = (sum(widths[col:col + hc.colspan])
+ sum(gaps[col + 1:col + hc.colspan]))
if hc.align:
align = hc.align
elif hc.colspan > 1:
align = 'c'
elif leaf:
align = doc.columns[col].align
else:
align = 'l'
parts.append((_gap_str(gaps[col], cs) if col else '')
+ _pad(_decorate(hc), span_w, align))
col += hc.colspan
return ''.join(parts).rstrip()
def _gap_str(gap: int, cs: Charset) -> str:
return f' {cs.vrule} ' if gap == STUB_GAP else ' ' * gap
def _spanner_line(row: tuple[HeadCell, ...], widths, gaps, total: int,
cs: Charset) -> str:
"""Underline runs beneath labeled group cells (cmidrule analogue)."""
chars = [' '] * total
col = 0
offset = 0
for hc in row:
span_w = (sum(widths[col:col + hc.colspan])
+ sum(gaps[col + 1:col + hc.colspan]))
if hc.text:
for k in range(offset, min(total, offset + span_w)):
chars[k] = cs.light
offset += span_w + (gaps[col + hc.colspan]
if col + hc.colspan < len(widths) else 0)
col += hc.colspan
return ''.join(chars).rstrip()
def _section_lines(doc: TableDoc, rows: tuple[Row, ...], grid, widths,
gaps, max_rule_depth, cs: Charset) -> list[str]:
lines: list[str] = []
total = sum(widths) + sum(gaps)
for r, row in enumerate(rows):
depth = row.break_depth
slot = None if r == 0 else rule_slot(depth, doc.n_stub_levels,
max_rule_depth)
if slot is not None:
# indent to the changed stub column — the depth, not the weight
# slot, drives geometry (they differ once a cap exceeds the slots)
offset = sum(widths[:depth]) + sum(gaps[:depth + 1])
lines.append(' ' * offset + cs.light * (total - offset))
parts: list[str] = []
col = 0
while col < len(doc.columns):
entry = grid[r][col]
if entry is None:
parts.append((_gap_str(gaps[col], cs) if col else '')
+ ' ' * widths[col])
col += 1
continue
cell, origin = entry
span = cell.colspan
span_w = sum(widths[col:col + span]) + sum(gaps[col + 1:col + span])
text = _decorate(cell) if origin else ''
align = 'c' if span > 1 else doc.columns[col].align
parts.append((_gap_str(gaps[col], cs) if col else '')
+ _pad(text, span_w, align))
col += span
line = ''.join(parts)
if row.flags:
line = (line + ' ' + cs.flag_open + ','.join(row.flags)
+ cs.flag_close)
lines.append(line.rstrip())
return lines