"""Assemble LaTeX tables from data, so a paper's numbers are generated rather than typed."""
from __future__ import annotations
import re
from decimal import ROUND_HALF_UP, Decimal
from research_helpers.project import current_project, resolve
__all__ = [
'EM_DASH',
'bare',
'dash_range',
'half_up',
'header',
'table',
]
EM_DASH = '—' # for empty cells
# environments taking a width argument before the column spec
SIZED_ENVIRONMENTS = ('tabulary', 'tabularx', 'tabular*')
DEFAULT_ENVIRONMENT = 'tabulary'
DEFAULT_STYLE = '\\sffamily\\footnotesize'
DEFAULT_HEADER_SIZE = 'scriptsize'
FULL_WIDTH = '\\textwidth'
# a float that must not move unless it spans both columns
NARROW_POSITION = 'H'
WIDE_POSITION = 't'
# chktex warning 24 fires on the space LaTeX ignores after \label
CHKTEX_SUPPRESSION = '% chktex 24'
# a header cell shorter than this collapses to its content width
SHORT_HEADER = 5
BARE_ZERO = re.compile(r'(?<![\d.])0\.')
[docs]
def table( # noqa: PLR0913
*,
spec: str,
header_rows: list[list[str]],
body_rows: list[list[str]],
caption: str,
label: str,
wide: bool = False,
prologue: list[str] | None = None,
environment: str = DEFAULT_ENVIRONMENT,
width: str | None = None,
column_width_in: float | None = None,
setup: str | None = None,
style: str = DEFAULT_STYLE,
chktex: bool = True,
) -> str:
r"""Assemble one table as a LaTeX float.
Arguments:
spec: column specification, e.g. 'LRRCC' for tabulary or 'Xllrr' for tabularx.
header_rows: one list of already-formatted cells per header line.
body_rows: the data rows, cells already formatted as strings.
caption: caption text, as LaTeX.
label: the '\\label' value, e.g. 'tab:morph-curve'. Also the table's identity.
wide: use 'table*', spanning both columns.
prologue: raw lines inserted after the first header row, e.g. a '\\cmidrule'.
environment: the tabular environment, e.g. 'tabulary', 'tabularx' or 'tabular'.
width: explicit width for the environments that take one. Defaults to '\\textwidth' or column width.
column_width_in: overrides the project's '\\columnwidth' for this call.
setup: raw commands emitted inside the float, before the tabular begins.
style: font commands applied inside the float.
chktex: emit the chktex suppression comment after the label.
Returns:
The complete float, ending in a newline.
"""
if not label:
msg = 'a table needs a label: it is the name of the file written and what the paper inputs'
raise ValueError(msg)
float_environment = 'table*' if wide else 'table'
position = WIDE_POSITION if wide else NARROW_POSITION
if width is None:
geometry = resolve(current_project().paper, column_width_in=column_width_in)
narrow = not wide and environment != DEFAULT_ENVIRONMENT
width = f'{geometry.column_width_in}in' if narrow else FULL_WIDTH
sizing = f'{{{width}}}' if environment in SIZED_ENVIRONMENTS else ''
lines = [
f'\\begin{{{float_environment}}}[{position}]',
' \\centering',
f' {style}',
*([f' {setup}'] if setup else []),
f' \\begin{{{environment}}}{sizing}{{{spec}}}',
' \\toprule',
]
for index, row in enumerate(header_rows):
lines.append(' ' + ' & '.join(row) + ' \\\\')
if prologue and index == 0:
lines += [' ' + line for line in prologue]
lines.append(' \\midrule')
for row in body_rows:
bare_rule = len(row) == 1 and row[0].strip() in {'\\midrule', '\\bottomrule', '\\toprule'}
lines.append(' ' + ' & '.join(row) + ('' if bare_rule else ' \\\\'))
lines += [
' \\bottomrule',
f' \\end{{{environment}}}',
f' \\caption{{{caption}}}',
f' \\label{{{label}}}' + (f' {CHKTEX_SUPPRESSION}' if chktex else ''),
f'\\end{{{float_environment}}}',
]
return '\n'.join(lines) + '\n'
[docs]
def half_up(value: float, places: int = 2) -> str:
"""Round half away from zero, the way a reader expects and 'format' does not.
Arguments:
value: the number to round.
places: decimal places to keep.
Returns:
The rounded number, as a string with exactly 'places' decimals.
"""
quantum = Decimal(1).scaleb(-places)
return str(Decimal(repr(value)).quantize(quantum, rounding=ROUND_HALF_UP))
[docs]
def bare(text: str) -> str:
"""Drop the leading zero from every number in 'text'.
Arguments:
text: formatted text, e.g. '0.82'.
Returns:
The text with leading zeroes removed, e.g. '.82'.
"""
return BARE_ZERO.sub('.', text)
[docs]
def dash_range(text: str, dash: str = '--') -> str:
"""Typeset a range with an en-dash.
Arguments:
text: a range written with a hyphen, e.g. '1337-1362'.
dash: what to replace the hyphen with, e.g. '--' (an en-dash in LaTeX).
Returns:
The range, typeset.
"""
return text.replace('-', dash)