"""Set a float's inline maths as text, in the float's own typeface."""
# avoid ruff complaining about the glyphs
# ruff: noqa: RUF001, RUF002
from __future__ import annotations
import re
from dataclasses import dataclass, field
from typing import NoReturn
__all__ = ['COMMANDS', 'DESIGNED_SCRIPT', 'PACKAGE', 'UnsupportedMathError', 'convert', 'to_text']
# the LaTeX package, without its '.sty'
PACKAGE = 'rhtextmath'
# every command defined by the package, which the output can use
COMMANDS = ('tmbar', 'tmmed', 'tmscripts', 'tmsub', 'tmsubx', 'tmsup', 'tmsupx', 'tmthick', 'tmthin')
# designed superiors and inferiors (OpenType 'sups' and 'subs')
DESIGNED_SCRIPT = frozenset('0123456789−+=()')
NUMBER = re.compile(r'\d+(?:\{,\}\d+)*(?:\.\d*)?|\.\d+')
COMMAND = re.compile(r'[A-Za-z]+')
GREEK_LOWER = {
'alpha': 'α', 'beta': 'β', 'gamma': 'γ', 'delta': 'δ', 'epsilon': 'ϵ', 'varepsilon': 'ε', 'zeta': 'ζ',
'eta': 'η', 'theta': 'θ', 'vartheta': 'ϑ', 'iota': 'ι', 'kappa': 'κ', 'lambda': 'λ', 'mu': 'μ', 'nu': 'ν',
'xi': 'ξ', 'pi': 'π', 'varpi': 'ϖ', 'rho': 'ρ', 'varrho': 'ϱ', 'sigma': 'σ', 'varsigma': 'ς', 'tau': 'τ',
'upsilon': 'υ', 'phi': 'ϕ', 'varphi': 'φ', 'chi': 'χ', 'psi': 'ψ', 'omega': 'ω',
} # fmt: skip
GREEK_UPPER = {
'Gamma': 'Γ', 'Delta': 'Δ', 'Theta': 'Θ', 'Lambda': 'Λ', 'Xi': 'Ξ', 'Pi': 'Π', 'Sigma': 'Σ',
'Upsilon': 'Υ', 'Phi': 'Φ', 'Psi': 'Ψ', 'Omega': 'Ω',
} # fmt: skip
# command -> (class, text). The classes are TeX's: ord, op, bin, rel, open, close, punct.
SYMBOLS: dict[str, tuple[str, str]] = {
**{name: ('rel', glyph) for name, glyph in {
'le': '≤', 'leq': '≤', 'ge': '≥', 'geq': '≥', 'ne': '≠', 'neq': '≠', 'approx': '≈', 'sim': '∼',
'simeq': '≃', 'equiv': '≡', 'in': '∈', 'notin': '∉', 'propto': '∝', 'll': '≪', 'gg': '≫',
'subset': '⊂', 'subseteq': '⊆', 'to': '→', 'rightarrow': '→', 'mid': '∣',
}.items()},
**{name: ('bin', glyph) for name, glyph in {
'times': '×', 'cdot': '·', 'pm': '±', 'mp': '∓', 'div': '÷', 'ast': '∗', 'circ': '∘',
'cup': '∪', 'cap': '∩', 'setminus': '∖',
}.items()},
**{name: ('ord', glyph) for name, glyph in {
'infty': '∞', 'ldots': '…', 'dots': '…', 'cdots': '⋯', 'prime': '′', 'partial': '∂', 'ell': 'ℓ',
'emptyset': '∅', 'vert': '|', 'lvert': '|', 'rvert': '|', 'Vert': '‖', 'lVert': '‖', 'rVert': '‖',
'|': '‖', '%': '\\%', '#': '\\#', '&': '\\&', '$': '\\$', '_': '\\_',
}.items()},
'langle': ('open', '⟨'), 'rangle': ('close', '⟩'), '{': ('open', '\\{'), '}': ('close', '\\}'),
} # fmt: skip
OPERATORS = frozenset({
'arg', 'cos', 'det', 'exp', 'inf', 'lim', 'ln', 'log', 'max', 'min', 'sin', 'sup', 'tan',
}) # fmt: skip
# every command that is one atom: name -> (class, text, italic)
ATOMS: dict[str, tuple[str, str, bool]] = {
**{name: ('ord', glyph, True) for name, glyph in GREEK_LOWER.items()},
**{name: ('ord', glyph, False) for name, glyph in GREEK_UPPER.items()},
**{name: (kind, glyph, False) for name, (kind, glyph) in SYMBOLS.items()},
**{name: ('op', f'\\textup{{{name}}}', False) for name in OPERATORS},
}
# characters written directly: char -> (class, text)
CHARACTERS: dict[str, tuple[str, str]] = {
'+': ('bin', '+'), '-': ('bin', '−'), '*': ('bin', '∗'), '=': ('rel', '='), '<': ('rel', '<'),
'>': ('rel', '>'), ':': ('rel', ':'), '(': ('open', '('), '[': ('open', '['), ')': ('close', ')'),
']': ('close', ']'), '!': ('close', '!'), '?': ('close', '?'), '|': ('ord', '|'), '/': ('ord', '/'),
'.': ('ord', '.'), ',': ('punct', ','), ';': ('punct', ';'), "'": ('ord', '′'),
'−': ('bin', '−'), '×': ('bin', '×'), '·': ('bin', '·'), '±': ('bin', '±'), '≤': ('rel', '≤'),
'≥': ('rel', '≥'), '≠': ('rel', '≠'), '≈': ('rel', '≈'), '∞': ('ord', '∞'), '…': ('ord', '…'),
} # fmt: skip
# explicit spacing commands, written as text-mode spacing
SPACES = {
',': '\\tmthin{}', ':': '\\tmmed{}', '>': '\\tmmed{}', ';': '\\tmthick{}', ' ': '\\ ',
'quad': '\\quad{}', 'qquad': '\\qquad{}',
} # fmt: skip
# commands whose argument is text, set upright, italic, or bold
TEXT_COMMANDS = {
'text': 'textup', 'textrm': 'textup', 'textup': 'textup', 'textnormal': 'textup', 'mathrm': 'textup',
'textit': 'textit', 'mathit': 'textit', 'textbf': 'textbf', 'mathbf': 'textbf',
} # fmt: skip
ACCENTS = {'bar': 'tmbar', 'overline': 'tmbar'}
# sizing commands that mean nothing in text: the delimiter after them is kept
IGNORED = frozenset({'left', 'right', 'big', 'Big', 'bigl', 'bigr', 'Bigl', 'Bigr', 'displaystyle', 'textstyle'})
# the space TeX puts between adjacent atoms: 0 none, 1 thin, 2 medium, 3 thick.
# a bracketed amount is not used
CLASSES = ('ord', 'op', 'bin', 'rel', 'open', 'close', 'punct')
SPACING_TABLE = """
ord op bin rel open close punct
ord 0 1 (2) (3) 0 0 0
op 1 1 0 (3) 0 0 0
bin (2) (2) 0 0 (2) 0 0
rel (3) (3) 0 0 (3) 0 0
open 0 0 0 0 0 0 0
close 0 1 (2) (3) 0 0 0
punct (1) (1) 0 (1) (1) (1) (1)
"""
# ended by '{}' rather than a space, which would also swallow a space written after the maths
SPACE_COMMANDS = {1: '\\tmthin{}', 2: '\\tmmed{}', 3: '\\tmthick{}'}
def _spacing_table() -> dict[tuple[str, str], tuple[int, bool]]:
"""Read 'SPACING_TABLE' into (left, right) -> (amount)."""
rows = [line.split() for line in SPACING_TABLE.strip().splitlines()[1:]]
return {
(row[0], right): (int(entry.strip('()')), not entry.startswith('('))
for row in rows
for right, entry in zip(CLASSES, row[1:], strict=True)
}
SPACING = _spacing_table()
[docs]
class UnsupportedMathError(ValueError):
"""Raised for maths outside the subset that can be set as text."""
@dataclass
class _Atom:
"""One of TeX's atoms: class, text, and scripts attached to it."""
kind: str
text: str
italic: bool = False # letters to be set in one \textit with their italic neighbours
sub: str | None = None
sup: str | None = None
@property
def scripted(self) -> bool:
return self.sub is not None or self.sup is not None
@dataclass
class _Glue:
"""An explicit space, kept where it was written and ignored by the spacing between atoms."""
text: str
Item = _Atom | _Glue
@dataclass
class _Parser:
"""Recursive descent over one expression."""
expression: str
position: int = 0
items: list[Item] = field(default_factory=list)
def fail(self, detail: str) -> NoReturn:
"""Raise the error for this expression."""
msg = f'cannot set {self.expression!r} as text: {detail}'
raise UnsupportedMathError(msg)
def peek(self) -> str:
"""Return the next character, or '' at the end."""
return self.expression[self.position : self.position + 1]
def skip_spaces(self) -> None:
"""Skip whitespace, which TeX ignores in maths."""
while self.peek().isspace():
self.position += 1
def parse(self, *, script: bool, group: bool = False) -> list[Item]:
"""Parse to the end of the expression or the group the parser is in."""
items: list[Item] = []
while True:
self.skip_spaces()
char = self.peek()
if not char:
if group:
self.fail('a group is not closed')
return items
if char == '}':
if not group:
self.fail('a "}" closes no group')
self.position += 1
return items
if char in '_^':
if script:
self.fail('a script inside a script cannot be set as text')
self.position += 1
self.attach(items, char, self.argument(script=True))
continue
items.extend(self.item(script=script))
def item(self, *, script: bool) -> list[Item]:
"""Parse one token, or one group."""
number = NUMBER.match(self.expression, self.position)
if number:
self.position = number.end()
return [_Atom('ord', number.group(0).replace('{,}', ','))]
char = self.peek()
self.position += 1
if char == '{':
return [_Atom('ord', render(self.parse(script=script, group=True), script=script))]
if char == '\\':
return self.command(script=script)
if char == '~':
return [_Glue('~')]
return [self.character(char)]
def character(self, char: str) -> _Atom:
"""Return the atom a character directly produces."""
if (char.isascii() and char.isalpha()) or char in GREEK_LOWER.values():
return _Atom('ord', char, italic=True)
if char in GREEK_UPPER.values():
return _Atom('ord', char)
if char not in CHARACTERS:
self.fail(f'{char!r} is not supported')
return _Atom(*CHARACTERS[char])
def command(self, *, script: bool) -> list[Item]:
"""Parse a command."""
name = self.command_name()
if name in ATOMS:
kind, text, italic = ATOMS[name]
return [_Atom(kind, text, italic=italic)]
if name in SPACES:
return [_Glue(SPACES[name])]
if name in IGNORED:
self.skip_spaces()
if self.peek() == '.': # the null delimiter of \left. or \right.
self.position += 1
return []
return [self.with_argument(name, script=script)]
def with_argument(self, name: str, *, script: bool) -> _Atom:
"""Return the atom produced by a command with an argument."""
if name == 'operatorname':
return _Atom('op', f'\\textup{{{self.raw_argument()}}}')
if name in TEXT_COMMANDS:
return _Atom('ord', f'\\{TEXT_COMMANDS[name]}{{{self.raw_argument()}}}')
if name not in ACCENTS:
self.fail(f'\\{name} is not supported')
return _Atom('ord', f'\\{ACCENTS[name]}{{{render(self.argument(script=script), script=script)}}}')
def command_name(self) -> str:
"""Read a command's name."""
letters = COMMAND.match(self.expression, self.position)
if letters:
self.position = letters.end()
return letters.group(0)
char = self.peek()
if not char:
self.fail('it ends in a backslash')
self.position += 1
return char
def argument(self, *, script: bool) -> list[Item]:
"""Parse one argument as maths."""
self.skip_spaces()
char = self.peek()
if not char:
self.fail('an argument is missing')
if char == '{':
self.position += 1
return self.parse(script=script, group=True)
if char.isdigit(): # TeX takes one token: x_10 is x_1 followed by 0
self.position += 1
return [_Atom('ord', char)]
return self.item(script=script)
def raw_argument(self) -> str:
"""Read one argument as text, without interpreting it."""
self.skip_spaces()
char = self.peek()
if not char:
self.fail('an argument is missing')
if char != '{':
self.position += 1
return char
depth, start = 0, self.position
while self.position < len(self.expression):
depth += {'{': 1, '}': -1}.get(self.expression[self.position], 0)
self.position += 1
if depth == 0:
break
else:
self.fail('a group is not closed')
return self.expression[start + 1 : self.position - 1]
def attach(self, items: list[Item], which: str, script: list[Item]) -> None:
"""Attach a script to the atom before it, or to an empty one if there is none."""
base = items[-1] if items and isinstance(items[-1], _Atom) else None
if base is None:
base = _Atom('ord', '')
items.append(base)
slot = 'sub' if which == '_' else 'sup'
if getattr(base, slot) is not None:
self.fail(f'a double {"subscript" if slot == "sub" else "superscript"}')
setattr(base, slot, _script(render(script, script=True), slot))
def _script(text: str, slot: str) -> str:
"""Return a rendered script in the macro that sets it, using designed glyphs where possible, else scaled."""
designed = bool(text) and set(text) <= DESIGNED_SCRIPT
return f'\\tm{slot}{"" if designed else "x"}{{{text}}}'
def _classify(atoms: list[_Atom]) -> None:
"""Turn a binary operator with nothing to operate on into an ordinary atom."""
for index, atom in enumerate(atoms):
previous = atoms[index - 1] if index else None
if atom.kind == 'bin' and (previous is None or previous.kind in {'bin', 'op', 'rel', 'open', 'punct'}):
atom.kind = 'ord'
if atom.kind in {'rel', 'close', 'punct'} and previous is not None and previous.kind == 'bin':
previous.kind = 'ord'
if atoms and atoms[-1].kind == 'bin':
atoms[-1].kind = 'ord'
def _merge(items: list[Item]) -> list[Item]:
r"""Join runs of italic letters into one atom, so that they share one \textit and kern as a word."""
merged: list[Item] = []
for item in items:
last = merged[-1] if merged else None
if isinstance(item, _Atom) and isinstance(last, _Atom) and item.italic and last.italic and not last.scripted:
last.text += item.text
last.sub, last.sup = item.sub, item.sup
continue
merged.append(item)
return merged
[docs]
def render(items: list[Item], *, script: bool) -> str:
"""Return parsed items as text, with TeX's spacing between atoms.
Arguments:
items: the list of parsed items.
script: whether the items are part of a script, affecting spacing.
Returns:
The text-mode LaTeX.
"""
_classify([item for item in items if isinstance(item, _Atom)])
pieces: list[str] = []
previous: _Atom | None = None
for item in _merge(items):
if isinstance(item, _Glue):
pieces.append(item.text)
continue
if previous is not None:
amount, everywhere = SPACING[previous.kind, item.kind]
if amount and (everywhere or not script):
pieces.append(SPACE_COMMANDS[amount])
pieces.append(_body(item))
previous = item
return ''.join(pieces)
def _body(atom: _Atom) -> str:
"""Return one atom as text, scripts included."""
text = f'\\textit{{{atom.text}}}' if atom.italic else atom.text
if atom.sub is not None and atom.sup is not None:
return f'{text}\\tmscripts{{{atom.sub}}}{{{atom.sup}}}'
return text + (atom.sub or '') + (atom.sup or '')
[docs]
def to_text(expression: str) -> str:
r"""Rewrite one maths expression as text set by ``rhtextmath.sty``.
The supported subset: Latin letters, numbers (with ``{,}`` as a thousands separator), Greek letters, the
common relations (``= < > \le \ge \ne \approx \sim \in \propto``, and a few more), binary operators
(``+ - \times \cdot \pm``), operator names (``\log \max \min \exp``, ``\operatorname{...}``), text
(``\text \mathrm \mathit \mathbf``), one level of sub- and superscript, ``\bar``, absolute-value bars,
brackets, and explicit spacing. ``\left`` and ``\right`` are dropped and their delimiters kept.
Arguments:
expression: the maths, without its delimiters, e.g. ``'Q_{ov}'`` or ``'9.8\times 10^{-4}'``.
Returns:
Text-mode LaTeX, e.g. ``'\textit{Q}\tmsubx{\textit{ov}}'`` or ``'9.8\tmmed{}×\tmmed{}10\tmsup{−4}'``.
Raises:
UnsupportedMathError: if the expression uses anything outside the subset.
"""
return render(_Parser(expression).parse(script=False), script=False)
[docs]
def convert(latex: str) -> str:
r"""Rewrite every inline maths expression in a piece of LaTeX as text.
Both ``\(...\)`` and ``$...$`` are converted. A backslash is read with the character after it, as TeX
reads it, so an escaped ``\$`` or the ``\\`` of a line break before a parenthesis opens no maths. Display
maths is left alone.
Arguments:
latex: a cell, a caption, or a whole float.
Returns:
The same LaTeX, with no inline maths left in it.
Raises:
UnsupportedMathError: if any expression uses anything outside the subset 'to_text' supports, or is not closed.
"""
pieces: list[str] = []
position = 0
while position < len(latex):
pair = latex[position : position + 2]
if pair in {'\\(', '$$'} or latex[position] == '$':
opening = pair if pair in {'\\(', '$$'} else '$'
closing = {'\\(': '\\)', '$$': '$$', '$': '$'}[opening]
start = position + len(opening)
end = _closing(latex, start, closing)
expression = latex[start:end]
pieces.append(latex[position : end + len(closing)] if opening == '$$' else to_text(expression))
position = end + len(closing)
elif latex[position] == '\\':
pieces.append(pair) # a command's backslash, an escaped character, or a line break
position += len(pair)
else:
pieces.append(latex[position])
position += 1
return ''.join(pieces)
def _closing(latex: str, start: int, delimiter: str) -> int:
"""Return the position where the maths expression starting at 'start' closes."""
position = start
while position < len(latex):
if latex.startswith(delimiter, position):
return position
position += 2 if latex[position] == '\\' else 1
msg = f'cannot set {latex[start:]!r} as text: the maths is not closed'
raise UnsupportedMathError(msg)