"""Render a LaTeX table for reading, rather than typesetting it."""
from __future__ import annotations
import html
import re
from dataclasses import dataclass, field
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from collections.abc import Iterable, Mapping
__all__ = ['SYMBOLS', 'Row', 'Table', 'parse', 'show']
# environments that take a width argument before the column specification
SIZED = re.compile(
r'\\begin\{(tabulary|tabularx|tabular\*)\}\{[^}]*\}\{([^}]*)\}(.*?)\\end\{\1\}',
re.S,
)
# and those that do not
PLAIN = re.compile(
r'\\begin\{(tabular|longtable)\}(?:\[[^\]]*\])?\{([^}]*)\}(.*?)\\end\{\1\}',
re.S,
)
CAPTION = re.compile(r'\\caption\{(.*)\}\s*\\label', re.S)
LABEL = re.compile(r'\\label\{([^}]*)\}')
MULTICOLUMN = re.compile(r'\\multicolumn\{(\d+)\}\{([^}]*)\}\{(.*)\}', re.S)
# '&' separates columns, but '\&' is a literal ampersand and must not split the row
CELL_SEPARATOR = re.compile(r'(?<!\\)&')
# rules and colour commands
RULES = re.compile(r'\\(?:top|mid|bottom)rule|\\cmidrule(?:\([lr]+\))?\{[\d-]+\}|\\rowcolor\{[^}]*\}')
BREAKING = re.compile(r'\\midrule|\\bottomrule')
# size and font commands, dropped along with their effect
SIZES = re.compile(r'\\(?:scriptsize|footnotesize|normalsize|small|sffamily|rmfamily|centering)')
# wrappers whose braced argument is kept
WRAPPERS = re.compile(r'\\(?:textbf|emph|textit|texttt|normalsize|small)\{([^{}]*)\}')
SUBSCRIPT = re.compile(r'\$s_\{(\d+)\}\$')
SETLENGTH = re.compile(r'\\setlength\{[^}]*\}\{[^}]*\}')
REF = re.compile(r'\\ref\{(?:tab|sec|fig|eq):([^}]*)\}')
MATH = re.compile(r'\$([^$]*)\$')
# how many times to unwrap nested \textbf{\emph{...}} before giving up
UNWRAP_PASSES = 3
SYMBOLS: Mapping[str, str] = {
r'$\le$': '\u2264',
r'$\ge$': '\u2265',
r'$\pm$': '\u00b1',
r'$\times$': '\u00d7',
r'$\Delta$': '\u0394',
r'$R^2$': 'R\u00b2',
r'\%': '%',
r'\&': '&',
r'\_': '_',
r'\$': '$',
'---': '\u2014',
'--': '\u2013',
'~': ' ',
}
STYLE = """<style>
.paper-table{border-collapse:collapse;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",
sans-serif;font-size:13px;margin:0.5em 0;line-height:1.5}
.paper-table th,.paper-table td{padding:3px 10px;border:none}
.paper-table thead tr:first-child th{border-top:1.5px solid currentColor}
.paper-table thead tr:last-child th{border-bottom:1px solid currentColor}
.paper-table tbody tr:last-child td{border-bottom:1.5px solid currentColor}
.paper-table tr.rule td{border-top:1px solid currentColor}
.paper-table tr.shaded{background:rgba(128,128,128,0.15)}
.paper-table th{font-weight:600;text-align:center}
.paper-table .l{text-align:left}.paper-table .r{text-align:right}.paper-table .c{text-align:center}
.paper-caption{font-size:12px;opacity:0.75;max-width:46em;margin:0.4em 0 1.2em;line-height:1.5}
.paper-caption b{opacity:0.9}
</style>"""
ALIGNMENTS = {'L': 'l', 'X': 'l', 'l': 'l', 'R': 'r', 'r': 'r', 'C': 'c', 'c': 'c'}
def _clean(cell: str, symbols: Mapping[str, str], strip: Iterable[re.Pattern[str]]) -> str:
"""Strip the typesetting from a cell and return the text."""
text = RULES.sub('', SETLENGTH.sub('', cell))
for pattern in strip:
text = pattern.sub('', text)
text = SUBSCRIPT.sub(r's\1', text)
for _ in range(UNWRAP_PASSES):
replaced = WRAPPERS.sub(r'\1', text)
if replaced == text:
break
text = replaced
text = SIZES.sub('', text)
text = REF.sub(lambda match: f'“{match.group(1).replace("-", " ")}”', text)
for source, target in symbols.items():
text = text.replace(source, target)
# anything left in maths mode is plain enough to read unwrapped
text = MATH.sub(r'\1', text)
return re.sub(r'\s+', ' ', text.replace('\\\\', '')).strip()
def _alignments(spec: str) -> list[str]:
"""Map a tabular column spec onto CSS classes."""
return [ALIGNMENTS[char] for char in spec if char in ALIGNMENTS]
[docs]
@dataclass
class Row:
"""A rendered row."""
cells: list[tuple[str, int]] # each cell as (text, colspan)
rule: bool = False # a horizontal rule sits above this row
shaded: bool = False # the row carried \rowcolor, i.e. the paper highlights it
[docs]
@dataclass(repr=False)
class Table:
"""A parsed table, rendered as HTML in Jupyter and as text elsewhere."""
header: list[Row]
body: list[Row]
align: list[str]
caption: str = ''
label: str = ''
latex: str = field(default='', repr=False)
@property
def columns(self) -> int:
"""Return the column count of the widest row."""
return max((sum(span for _, span in row.cells) for row in self.header + self.body), default=0)
def _class(self, index: int) -> str:
return self.align[index] if index < len(self.align) else 'l'
def _repr_html_(self) -> str:
"""Render for Jupyter."""
parts = [STYLE, '<table class="paper-table">']
if self.header:
parts.append('<thead>')
for row in self.header:
parts.append('<tr>')
column = 0
for text, span in row.cells:
span_attr = f' colspan="{span}"' if span > 1 else ''
parts.append(f'<th{span_attr} class="{self._class(column)}">{html.escape(text)}</th>')
column += span
parts.append('</tr>')
parts.append('</thead>')
parts.append('<tbody>')
for row in self.body:
classes = ' '.join(c for c in ('rule' if row.rule else '', 'shaded' if row.shaded else '') if c)
parts.append(f'<tr class="{classes}">' if classes else '<tr>')
column = 0
for text, span in row.cells:
span_attr = f' colspan="{span}"' if span > 1 else ''
parts.append(f'<td{span_attr} class="{self._class(column)}">{html.escape(text)}</td>')
column += span
parts.append('</tr>')
parts += ['</tbody>', '</table>']
if self.caption:
number = self.label.removeprefix('tab:').replace('-', ' ')
parts.append(f'<div class="paper-caption"><b>{html.escape(number)}.</b> {html.escape(self.caption)}</div>')
return ''.join(parts)
def _grid(self) -> list[list[str]]:
"""Expand every row to one entry per column."""
width = self.columns
grid = []
for row in self.header + self.body:
line: list[str] = []
for text, span in row.cells:
line += [text, *([''] * (span - 1))]
grid.append((line + [''] * width)[:width])
return grid
def __str__(self) -> str:
"""Render for a terminal, as aligned plain text."""
grid = self._grid()
if not grid:
return ''
widths = [max(len(line[column]) for line in grid) for column in range(self.columns)]
lines = []
for position, (row, line) in enumerate(zip(self.header + self.body, grid, strict=True)):
cells = [
text.rjust(widths[column]) if self._class(column) == 'r' else text.ljust(widths[column])
for column, text in enumerate(line)
]
if position and (row.rule or position == len(self.header)):
lines.append('-' * (sum(widths) + 2 * (len(widths) - 1)))
lines.append(' '.join(cells).rstrip())
return '\n'.join(lines)
def __repr__(self) -> str:
"""Show the table."""
return self.__str__()
[docs]
def parse(
latex: str,
*,
symbols: Mapping[str, str] | None = None,
strip: Iterable[str] | None = None,
) -> Table:
"""Parse a LaTeX table into rows.
Arguments:
latex: a float or bare tabular environment ('tabulary', 'tabularx', 'tabular*', 'tabular', or 'longtable').
symbols: replacements merged over 'SYMBOLS', for a project's own macros.
strip: extra regular expressions removed from every cell before anything else.
Returns:
The parsed table.
Raises:
ValueError: if no recognised tabular environment is present.
"""
found = SIZED.search(latex) or PLAIN.search(latex)
if not found:
msg = 'no tabulary, tabularx, tabular*, tabular, or longtable environment found.'
raise ValueError(msg)
spec, body = found.group(2), found.group(3)
replacements = {**SYMBOLS, **(symbols or {})}
patterns = [re.compile(pattern) for pattern in (strip or ())]
header: list[Row] = []
rows: list[Row] = []
in_header = True
pending_rule = False
for chunk in body.split('\\\\'):
if not chunk.strip():
continue
# a bare rule is written without a row terminator
stripped = RULES.sub('', SETLENGTH.sub('', chunk)).strip()
breaks = bool(BREAKING.search(chunk))
if not stripped:
pending_rule |= breaks
continue
if in_header and breaks:
# the rule closing the header is a separator
in_header = False
breaks = False
row = Row(cells=[], rule=pending_rule or breaks, shaded='\\rowcolor' in chunk)
pending_rule = False
for cell in CELL_SEPARATOR.split(chunk):
span = MULTICOLUMN.search(cell)
row.cells.append(
(_clean(span.group(3), replacements, patterns), int(span.group(1)))
if span
else (_clean(cell, replacements, patterns), 1),
)
(header if in_header else rows).append(row)
caption = CAPTION.search(latex)
label = LABEL.search(latex)
return Table(
header=header,
body=rows,
align=_alignments(spec),
caption=_clean(caption.group(1), replacements, patterns) if caption else '',
label=label.group(1) if label else '',
latex=latex,
)
[docs]
def show(
latex: str,
*,
symbols: Mapping[str, str] | None = None,
strip: Iterable[str] | None = None,
) -> Table:
"""Return a table in a form Jupyter renders and 'print' formats."""
return parse(latex, symbols=symbols, strip=strip)