"""Character-based display-width estimation (greater_tables TextLength salvage).
Lives in the TikZ renderer's orbit only — TeX genuinely needs geometry ahead
of rendering; the HTML paths let the browser measure. Widths are em
estimates from a serif glyph table; ``$...$`` spans are token-counted TeX.
**The table is Times metrics**, which is what ``Etcher`` compiles against
(``newtxtext``): measured there, the estimate is exact — median actual /
estimate 1.000 over the fixture corpus, p99 1.007. A document set in another
face is a different measurement: Latin Modern, the LaTeX default that a
plain Quarto PDF uses, runs about 11% wider for text (digits only 1.6%), so
columns come out slightly tight and a long heading may hyphenate. Either
load a Times-metric serif alongside the tables, or accept that. Re-measure
any font with ``dev/tools/measure_glyphs.py``.
Glyph widths are held as **integer milli-ems** and accumulated as integers,
converted to ems only at the end. Summing the float ems directly is not
reproducible across interpreters: CPython 3.12 gave ``sum()`` Neumaier
compensated summation, so 'apples' totalled 2.5549999999999997 on 3.11 and
2.555 on 3.13 — one ULP apart, but on opposite sides of the 2 dp rounding
the TikZ renderer applies, which moved a committed column width by 0.01em.
Integer accumulation is exact on every version, so the TeX goldens are
interpreter-independent. Callers doing width *arithmetic* (summing columns,
allocating a budget) should therefore work in milli-ems throughout —
:func:`display_milli` and :func:`token_milli` — and divide only to print.
Two measures, because a column has two kinds of floor: :func:`display_milli`
is the width the text wants on one line, :func:`token_milli` the width of its
widest unbreakable token. TeX will not hyphenate inside a word, so a wrapping
column narrower than its longest token gives an overfull hbox.
"""
from __future__ import annotations
import html
import re
from ..mathspans import segments
__all__ = ['display_milli', 'math_milli', 'text_display_len',
'token_display_len', 'token_milli']
TEX_SIMPLE_GLYPHS = {
'alpha', 'beta', 'gamma', 'delta', 'epsilon', 'zeta', 'eta', 'theta',
'iota', 'kappa', 'lambda', 'mu', 'nu', 'xi', 'omicron', 'pi', 'rho',
'sigma', 'tau', 'upsilon', 'phi', 'chi', 'psi', 'omega', 'infty', 'sum',
'prod', 'int', 'cup', 'cap', 'vee', 'wedge', 'forall', 'exists', 'neg',
'leq', 'geq', 'neq', 'approx', 'to', 'leftarrow', 'rightarrow',
}
TEX_WIDE = {'frac', 'sqrt', 'sum', 'int', 'prod'}
TEX_SPACING = {'quad', 'qquad', ',', ';', ' ', '!'}
_EM = 1000 # milli-ems per em
# milli-em glyph widths (0.444em → 444), serif metrics
_WIDTH_TABLE = {
'a': 444, 'b': 500, 'c': 444, 'd': 500, 'e': 444, 'f': 333,
'g': 500, 'h': 500, 'i': 278, 'j': 278, 'k': 500, 'l': 278,
'm': 778, 'n': 500, 'o': 500, 'p': 500, 'q': 500, 'r': 333,
's': 389, 't': 278, 'u': 500, 'v': 500, 'w': 722, 'x': 500,
'y': 500, 'z': 444,
'A': 722, 'B': 667, 'C': 667, 'D': 722, 'E': 611, 'F': 556,
'G': 722, 'H': 722, 'I': 333, 'J': 389, 'K': 722, 'L': 611,
'M': 889, 'N': 722, 'O': 722, 'P': 556, 'Q': 722, 'R': 667,
'S': 556, 'T': 611, 'U': 722, 'V': 722, 'W': 944, 'X': 722,
'Y': 722, 'Z': 611,
'0': 500, '1': 500, '2': 500, '3': 500, '4': 500, '5': 500, '6': 500,
'7': 500, '8': 500, '9': 500,
'.': 250, ',': 250, ':': 278, ';': 278, '(': 333, ')': 333,
'[': 333, ']': 333, '’': 333, '"': 444, '!': 333, '?': 444,
' ': 250, '\xa0': 250, '|': 200, '‘': 333, '{': 480, '}': 480,
'-': 333,
# measured against newtx (dev/tools/measure_glyphs.py). These were all
# missing before 6.0.0a5 and took the 600 default: '—' is the missing-value
# marker, '%' rides every ratio column and 'µ' every SI-suffixed one, so
# the three that matter most to a real table were also the most wrong.
'#': 500, '%': 833, '&': 778, '*': 500, '+': 564, '/': 278,
'<': 564, '=': 564, '>': 564, '@': 921, '_': 500,
'£': 500, '€': 500, '§': 500, '¶': 453, '†': 500, '‡': 500,
'°': 400, '±': 564, 'µ': 500, '×': 564, '÷': 564, '−': 564,
'ª': 276, 'º': 310, '•': 350, '–': 500, '—': 1000,
'…': 1000, '‰': 1000,
}
_DEFAULT_WIDTH = 600
_TAG_RE = re.compile(r'<[^>]*>')
_TOKEN_RE = re.compile(r'\\[a-zA-Z]+|[a-zA-Z0-9]|.')
def _char(c: str) -> int:
return _WIDTH_TABLE.get(c, _DEFAULT_WIDTH)
[docs]
def text_display_len(s: str) -> float:
"""Estimate display width in ems: plain text, ``$math$``, HTML entities."""
return display_milli(s) / _EM
[docs]
def token_display_len(s: str) -> float:
"""Em width of the widest unbreakable token — a wrap column's floor."""
return token_milli(s) / _EM
def estimate_math_width(tex: str) -> float:
"""Token-count a TeX math fragment into an em estimate."""
return math_milli(tex) / _EM
[docs]
def display_milli(s: str, math: bool = True) -> int:
"""Display width in milli-ems — the exact, integer accumulation.
``math`` gates ``$``-span interpretation (mathspans, the same rule the
renderers emit by, so what is measured is what is set); pass False for a
``TableDoc.math='off'`` document or html-flag text, where every ``$``
is a glyph.
"""
if not isinstance(s, str):
s = str(s)
if '$' not in s and '<' not in s and '&' not in s:
return sum(_char(c) for c in s)
if not math:
return _milli_prose(s)
return sum(math_milli(run) if is_math else _milli_prose(run)
for is_math, run in segments(s))
[docs]
def token_milli(s: str, math: bool = True) -> int:
"""Widest whitespace-delimited token in milli-ems.
A ``$...$`` span counts as one token: math is set as a box and does not
break at spaces the way surrounding prose does.
"""
if not isinstance(s, str):
s = str(s)
if '$' not in s and '<' not in s and '&' not in s:
return max((sum(_char(c) for c in tok) for tok in s.split()), default=0)
widest = 0
for is_math, run in (segments(s) if math else [(False, s)]):
if is_math:
widest = max(widest, math_milli(run))
else:
decoded = html.unescape(_TAG_RE.sub('', run))
for tok in decoded.split():
widest = max(widest, sum(_char(c) for c in tok))
return widest
def _milli_prose(run: str) -> int:
"""Prose width: tags stripped, entities decoded, glyphs accumulated."""
decoded = html.unescape(_TAG_RE.sub('', run))
return sum(_char(c) for c in decoded)
[docs]
def math_milli(tex: str) -> int:
"""TeX math fragment width in milli-ems."""
width = 0
for tok in _TOKEN_RE.findall(tex):
if tok.startswith('\\'):
name = tok[1:]
if name in TEX_SIMPLE_GLYPHS:
width += 600
elif name in TEX_WIDE:
width += 1500
elif name in TEX_SPACING:
width += 400
else:
width += 1000
elif tok in '{}':
continue
elif tok in '^_':
width += 300
else:
width += _char(tok)
return width