| Server IP : 3.147.158.171 / Your IP : 216.73.216.216 Web Server : Apache/2.4.67 (Amazon Linux) OpenSSL/3.5.5 System : Linux ip-172-31-2-178.us-east-2.compute.internal 6.1.172-216.329.amzn2023.x86_64 #1 SMP PREEMPT_DYNAMIC Wed May 20 06:31:34 UTC 2026 x86_64 User : ec2-user ( 1000) PHP Version : 8.4.21 Disable Function : NONE MySQL : OFF | cURL : ON | WGET : ON | Perl : ON | Python : OFF | Sudo : ON | Pkexec : OFF Directory : /tsai/www/html/api.turmansolutions.ai/app/utilities/ |
Upload File : |
"""Inline LaTeX-math normalization for article HTML.
AI-generated article bodies occasionally wrap short formulas in TeX math
delimiters, e.g. ``$$\\nabla_\\text{rad} > \\nabla_\\text{ad}$$``. The shared
``MarkdownIt`` parser (commonmark) has no math plugin and the WordPress themes
load no MathJax/KaTeX, so the raw TeX survives rendering and leaks onto the
published page as literal ``$$...$$`` text.
`convert_math` runs on the *final* HTML (after markdown rendering, like
`normalize_citations`) and rewrites the small subset of TeX these articles use
into plain Unicode + ``<sub>``/``<sup>`` markup that renders everywhere with no
front-end dependency:
$$\\nabla_\\text{rad} > \\nabla_\\text{ad}$$
-> <span class="tsai-math">∇<sub>rad</sub> > ∇<sub>ad</sub></span>
It is a best-effort transform: constructs it does not recognize degrade to
cleaned-up text (delimiters and backslashes stripped) rather than raw TeX. It
is idempotent — once the ``$``-delimiters are gone there is nothing left to
match — so it is safe to re-run on its own output.
"""
from __future__ import annotations
import re
# --- TeX token tables ---------------------------------------------------------
# Font/roman wrappers: keep the braced group, drop the command so the grouping
# survives for subscript/superscript handling (``\text{rad}`` -> ``{rad}``).
_FONT_CMDS = re.compile(r'\\(?:text|mathrm|mathbf|mathit|mathsf|mathcal|operatorname)\s*\{')
# Whitespace / kerning commands that carry no glyph.
_SPACING = re.compile(r'\\(?:,|;|:|!|quad|qquad|thinspace|;|\s)')
# Symbol and Greek-letter names -> Unicode. Longest names must be tried first so
# ``\nabla`` isn't shadowed by a shorter prefix.
_SYMBOLS = {
# operators / relations
'times': '×', 'cdot': '·', 'div': '÷', 'pm': '±', 'mp': '∓',
'leq': '≤', 'le': '≤', 'geq': '≥', 'ge': '≥', 'neq': '≠', 'ne': '≠',
'approx': '≈', 'equiv': '≡', 'sim': '∼', 'simeq': '≃', 'propto': '∝',
'll': '≪', 'gg': '≫', 'll ': '≪', 'gg ': '≫',
'rightarrow': '→', 'to': '→', 'leftarrow': '←', 'leftrightarrow': '↔',
'Rightarrow': '⇒', 'Leftarrow': '⇐', 'mapsto': '↦',
'infty': '∞', 'partial': '∂', 'nabla': '∇', 'sqrt': '√',
'sum': '∑', 'prod': '∏', 'int': '∫', 'oint': '∮',
'odot': '⊙', 'oplus': '⊕', 'otimes': '⊗', 'star': '⋆', 'ast': '∗',
'prime': '′', 'circ': '∘', 'deg': '°', 'angle': '∠',
'in': '∈', 'notin': '∉', 'subset': '⊂', 'supset': '⊃',
'cup': '∪', 'cap': '∩', 'forall': '∀', 'exists': '∃',
'langle': '⟨', 'rangle': '⟩', 'ldots': '…', 'cdots': '⋯',
# lowercase Greek
'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': 'ω',
# uppercase Greek
'Gamma': 'Γ', 'Delta': 'Δ', 'Theta': 'Θ', 'Lambda': 'Λ', 'Xi': 'Ξ',
'Pi': 'Π', 'Sigma': 'Σ', 'Upsilon': 'Υ', 'Phi': 'Φ', 'Psi': 'Ψ',
'Omega': 'Ω',
}
_SYMBOL_RE = re.compile(
r'\\(' + '|'.join(sorted(_SYMBOLS, key=len, reverse=True)) + r')(?![A-Za-z])'
)
# Display ($$...$$) and inline ($...$) math spans. Non-greedy, single line.
_DISPLAY = re.compile(r'\$\$(.+?)\$\$', re.S)
_INLINE = re.compile(r'\$(?!\$)([^$\n]+?)\$')
# Subscript / superscript: braced group first, then a single character.
_SUB_BRACE = re.compile(r'_\{([^{}]*)\}')
_SUP_BRACE = re.compile(r'\^\{([^{}]*)\}')
_SUB_CHAR = re.compile(r'_([^\s_^{}<&])')
_SUP_CHAR = re.compile(r'\^([^\s_^{}<&])')
# ``\frac{a}{b}`` (non-nested) -> ``a/b``.
_FRAC = re.compile(r'\\frac\s*\{([^{}]*)\}\s*\{([^{}]*)\}')
# Leftover unknown command: strip the backslash, keep the name as text.
_LEFTOVER_CMD = re.compile(r'\\([A-Za-z]+)')
def _convert_tex(tex: str) -> str:
"""Translate a single TeX math fragment (delimiters already removed)."""
s = tex
s = _SPACING.sub(' ', s)
s = _FRAC.sub(r'\1/\2', s)
s = _FONT_CMDS.sub('{', s) # \text{rad} -> {rad}
s = _SYMBOL_RE.sub(lambda m: _SYMBOLS[m.group(1)], s)
s = _SUB_BRACE.sub(r'<sub>\1</sub>', s)
s = _SUP_BRACE.sub(r'<sup>\1</sup>', s)
s = _SUB_CHAR.sub(r'<sub>\1</sub>', s)
s = _SUP_CHAR.sub(r'<sup>\1</sup>', s)
s = _LEFTOVER_CMD.sub(r'\1', s) # unknown \cmd -> cmd (readable fallback)
s = s.replace('{', '').replace('}', '')
s = re.sub(r'[ \t]{2,}', ' ', s).strip()
return s
def convert_math(html: str) -> str:
"""Rewrite TeX math spans in ``html`` to Unicode + ``<sub>``/``<sup>`` markup.
Handles display ``$$...$$`` and inline ``$...$`` delimiters. Inline spans are
only converted when they actually look like math (contain a backslash, brace,
or sub/superscript) so ordinary prose with currency amounts is left alone.
Idempotent and safe to run on HTML that contains no math.
"""
if not html or '$' not in html:
return html
def _display_repl(m: "re.Match[str]") -> str:
return f'<span class="tsai-math">{_convert_tex(m.group(1))}</span>'
def _inline_repl(m: "re.Match[str]") -> str:
body = m.group(1)
if not re.search(r'[\\{}_^]', body):
return m.group(0) # not math-like (e.g. "$5 to $10") — leave intact
return f'<span class="tsai-math">{_convert_tex(body)}</span>'
html = _DISPLAY.sub(_display_repl, html)
html = _INLINE.sub(_inline_repl, html)
return html