texify
Fabrice P. Lauss𝕪’s ℤenithal Web

texify

texify is a python script that turns hand-typed prose—an email, a note, anything where the maths was typed the way one types it—into text that TeX can parse: the formulas get wrapped in $...$ and their ASCII stand-ins become macros (>> into \gg, <H> into \langle H\rangle), while the prose is not touched at all.

Written with Claude Opus 5 on 22 September (2026). Version 1.6.0.

The idea

One writes physics in emails as if it were LaTeX without the dollars: N>>1, \bar V(00;00) = 2 U_0/\mathcal V, <H> = N E_0. Pasting such a paragraph into a paper or a report means going through it, putting dollars around every formula and turning every >> and <H> into something TeX understands. That is what texify does.

It takes less understanding than it seems, because typed maths is marked in its very characters. Every whitespace-separated token is graded:

  • SEED—certainly maths: it holds a \macro, a ^ or a _, an =, >> or <<, ->, +/-, or an <X> bracket. Only a seed can start a formula.
  • ATOM—maths-compatible but mute: a lone letter (except a, A and I), a number, an operator, a bracketed group, a function name. It joins a formula but never starts one.
  • TEXT—a word: two or more letters with no maths character in it. It ends the formula.

Formulas grow outwards from every seed across the atoms, and stop at the first word or at a token that carries the sentence's own punctuation—a comma, a full stop—which stays outside the dollars. So (N>>1, \bar V(00;00) = 2 U_0/\mathcal V, \mathcal V = L^3 is the volume) comes out as three formulas, while Eq. (4.23) of paper 3 holds no seed and is left alone. A formula may run across a line break, since mailers wrap lines where they please, but not across an email > quote marker, which would otherwise end up inside the dollars and be typeset as a "greater than".

The safety net

Every change texify makes can be undone: remove the dollars, put >> back for \gg, <X> back for \langle X\rangle and so on, and the input must come back character for character. That is checked on every run, before anything is printed. So texify cannot reword a sentence, drop one or invent a symbol; the worst it can do is put a dollar in the wrong place, and that shows with -v or --diff. Should the check ever fail, it prints what differs and stops with exit status 2.

Asking Claude

What the rules cannot settle is what only meaning can: whether <H> is an expectation value or two comparisons, whether a capital V is a volume or the start of a name. With --llm, the rule-based result is sent to claude -p with orders to insert markup and nothing else, and what comes back goes through the same check: if the model has changed a single word, its answer is thrown away, the rules' answer is kept, and a warning says why. --llm-only leaves the rules out.

Unicode maths

Maths pasted from a PDF or a web page, or typed with a compose key, comes as Unicode rather than as ASCII stand-ins, and that is maths as well: a token holding ⟨, ₁, ², −, ≈, a Greek letter and the like starts a formula, and inside it each character becomes its TeX:

laussy@azag:~$ texify uni.txt
the ratio $\langle n_1n_2\rangle / \langle N(N-1)\rangle$ is 1 for a Fock state,
$g^{(2)}(0) = \langle a^{\dagger 2}a^2\rangle/\langle a^\dagger a\rangle^2$ in the $\Lambda$-system, with $\hbar \omega \gg kT$.
Unicode Becomes
⟨X⟩ \langle X\rangle
n₁, N², 10⁻³, g⁽²⁾ n_1, N^2, 10^{-3}, g^{(2)}
a†, a†² a^\dagger, a^{\dagger 2}
− (the minus sign) -
× · ≈ ≤ ≥ ≠ ≡ ∼ → ≫ ≪ ± ∓ ∞ ∝ ∈ ⊗ \times \cdot \approx \leq \geq \neq \equiv \sim \to \gg \ll \pm \mp \infty \propto \in \otimes
∂ ∇ ∑ ∏ ∫ √ ℏ ħ ½ ° \partial \nabla \sum \prod \int \sqrt \hbar \hbar \frac12 ^\circ
α β … ω, Γ Δ Θ Λ Ξ Π Σ Υ Φ Ψ Ω \alpha \beta … \omega, \Gamma \Delta … (ε is \varepsilon, φ is \varphi)

A Greek letter glued to a Latin one gets a space, αx into \alpha x, and a hyphenated word keeps only its symbol in the maths: Λ-system gives $\Lambda$-system. The Unicode in the prose, outside any formula, is left as it is. The check of the safety net reads the Unicode through the same table, so it still guarantees that nothing but markup was added.

Usage

The text in mail.txt:

Unfortunately, she does not compare, but if I use her Eq. (4.23) of paper 3 and rewrite it
(N>>1, \bar V(00;00) = 2 U_0/\mathcal V, \mathcal V = L^3 is the volume) as <H> = N E_0 + N^2
U_0/\mathcal V. This result leads (at T=0) to a gap ~= 2 x 10^3 K, i.e. 90% of the bound.
laussy@azag:~$ texify -v mail.txt
  N>>1                                         ->  N\gg1
  \bar V(00;00) = 2 U_0/\mathcal V             ->  \bar V(00;00) = 2 U_0/\mathcal V
  \mathcal V = L^3                             ->  \mathcal V = L^3
  <H> = N E_0 + N^2 U_0/\mathcal V             ->  \langle H\rangle = N E_0 + N^2 U_0/\mathcal V
  T=0                                          ->  T=0
  ~= 2 x 10^3 K                                ->  \approx 2 \times 10^3 K
Unfortunately, she does not compare, but if I use her Eq. (4.23) of paper 3 and rewrite it
($N\gg1$, $\bar V(00;00) = 2 U_0/\mathcal V$, $\mathcal V = L^3$ is the volume) as $\langle H\rangle = N E_0 + N^2
U_0/\mathcal V$. This result leads (at $T=0$) to a gap $\approx 2 \times 10^3 K$, i.e. 90\% of the bound.
texify mail.txt          # convert a file, the result on stdout
texify                   # paste the text in the terminal, then Ctrl-D
xclip -o | texify        # from anything that pipes
texify -x -c             # the clipboard in, the clipboard out
texify -x -c -n          # the same, with a desktop notification instead of the text
texify --diff mail.txt   # only the lines that changed
texify --llm -x -c       # let Claude settle the ambiguous cases
texify -h                # all the options, with examples
Option What it does
-x, --from-clipboard read the text from the clipboard (wl-paste, xclip or xsel)
-c, --to-clipboard put the result in the clipboard; it is also printed, unless -n
-n, --notify with -c: print nothing, say in a desktop notification how many formulas were wrapped—the form for a keyboard shortcut
-v, --verbose list every formula found and what it became, on stderr
--diff show only what changed, as a diff
--paren \(...\) instead of $...$
--llm after the rules, a pass by Claude (claude -p), checked the same way
--llm-only Claude alone, no rules
--model the model for --llm (default: the CLI's)
--no-escape leave %, & and # bare in the prose
--no-check skip the check that nothing but markup was added
-V, --version the version
-h, --help the options, with examples

What is replaced, inside the maths only:

Typed Becomes
>>, << \gg, \ll
<=, >=, != \leq, \geq, \neq
-> \to
~= \approx
+/- \pm
<X> \langle X\rangle
infinity \infty
3 x 10 3 \times 10 (between digits only)
sin cos tan exp log ln det max min lim… \sin \cos…

and in the prose, %, & and # become \%, \& and \#, which TeX would otherwise take for a comment, an alignment and a parameter.

Limitations

Units go into the maths with their number, and nothing is set upright: ~= 2 x 10^3 K gives $\approx 2 \times 10^3 K$, with an italic K. A formula made only of mute atoms—N alone, or 2N—has no seed and stays bare, whereas T=0 is caught. And a, A and I are always taken for words, so a variable called a is recognised only inside a formula, never on its own.

Version history

  • v°1.0.0–1.4.0 (22 September (2026))—first version and the same day's refinements: formulas across line breaks but not across email quotes, %&# escaped in the prose, the reversibility check, --llm.
  • v°1.5.0 (23 September (2026))—-h works (it crashed on a % in one help line) and lists every option, with examples; -V for the version; a bare texify in a terminal says it is waiting for pasted text.
  • v°1.6.0 (23 September (2026))—Unicode maths: ⟨n₁n₂⟩ / ⟨N(N−1)⟩ gives $\langle n_1n_2\rangle / \langle N(N-1)\rangle$; sub- and superscript digits, Greek letters and the common symbols are recognised and translated.

The file

#!/usr/bin/env python3
#  _            _  __
# | |_ _____  _(_)/ _|_   _
# | __/ _ \ \/ / | |_| | | |
# | ||  __/>  <| |  _| |_| |
#  \__\___/_/\_\_|_|  \__, |
#                     |___/
#
# texify - Version 1.6.0
# Claude & F.P. Laussy
# https://laussy.org/wiki/texify
#
# Turn hand-typed prose (an email, a note) into TeX-parsable text: the
# maths gets wrapped in $...$ and the ASCII stand-ins become macros, the
# prose is not touched at all.
#
#   It makes me feel confident: if I use her Eq. (4.23) and rewrite it
#   (N>>1, \bar V(00;00) = 2 U_0/\mathcal V) as <H> = N E_0 + N^2 U_0/\mathcal V.
#          |
#          v
#   It makes me feel confident: if I use her Eq. (4.23) and rewrite it
#   ($N\gg1$, $\bar V(00;00) = 2 U_0/\mathcal V$) as $\langle H\rangle =
#   N E_0 + N^2 U_0/\mathcal V$.
#
# Usage:
#   texify file.txt           convert a file
#   ... | texify              convert from stdin
#   texify                    paste the text, then Ctrl-D
#   texify -x -c              clipboard in, clipboard out
#   texify -x -c -n           same, with a notification instead of output
#   texify -h                 all the options, with examples
#   texify -v                 also list the spans it wrapped
#   texify --llm              let Claude adjudicate as well (see below)
#   texify --diff             show what changed, nothing else
#   texify --paren            \(...\) instead of $...$
#   texify --no-escape        leave % & # bare (they are TeX-active in prose)
#
# Besides the dollars it substitutes, inside maths only:
#
#   >> \gg    << \ll    <= \leq   >= \geq   != \neq   -> \to
#   ~= \approx   +/- \pm   <X> \langle X\rangle   infinity \infty
#   3 x 10 -> 3 \times 10 (between digits only)   sin cos exp log ... -> \sin ...
#
# and, in the prose only, % & # -> \% \& \# so the text compiles at all.
#
# Unicode maths (as pasted from a PDF, a web page or typed with a compose
# key) is maths too: a token holding one starts a span, and inside it
#
#   ⟨n₁n₂⟩ -> \langle n_1n_2\rangle   N² -> N^2   10⁻³ -> 10^{-3}   − -> -
#   × · ≈ ≤ ≥ ≠ → ≫ ≪ ± ∞ ∝ ∂ ∇ ∑ ∫ √ ℏ † -> \times \cdot \approx ...
#   α β … ω Γ Δ … Ω -> \alpha \beta … \omega \Gamma \Delta … \Omega
#
# A formula may straddle a line break, so a mailer's hard wrapping does not
# split it -- except across an email > quote marker, which would land inside
# the dollars and be typeset as a relation.
#
# How it decides what is maths
# ----------------------------
# The text is cut into whitespace tokens, each one classified as
#
#   SEED  certainly maths: holds a \macro, a ^ or a _, an = sign, >> or
#         <<, or an <X> expectation bracket.  A seed can start a span.
#   ATOM  maths-compatible but mute: a lone letter (a, A and I excepted),
#         a number, an operator, a bracketed group, a function name.  An
#         atom joins a span but never starts one.
#   TEXT  a word: two or more letters, lowercase or Capitalised, with no
#         maths character in it.  A word ends the span.
#
# Spans grow outwards from every seed across atoms and stop at the first
# word, or at a token carrying prose punctuation (a comma, a full stop),
# which is how "N>>1, \bar V(00;00) = 2 U_0/\mathcal V, \mathcal V = L^3
# is the volume" comes out as three separate formulas.  "Eq. (4.23) of
# paper 3" holds no seed, so nothing there is touched.
#
# The safety net
# --------------
# Every rewrite this program performs is reversible: strip the dollars,
# put >> back for \gg and <X> back for \langle X\rangle, and you must get
# the input again, character for character.  That check is run on the way
# out (--no-check to skip it, --llm relies on it).  So texify cannot
# reword you, drop a sentence or invent a symbol: it can only mis-wrap,
# and a mis-wrap is visible with -v or --diff.
#
# --llm
# -----
# The residue is genuinely semantic -- is "<H>" an expectation value or a
# comparison, is "V" a volume or the start of a name -- and that is what a
# model is for.  --llm sends the paragraph to `claude -p` with orders to
# insert markup and nothing else, then runs the same reversibility check
# on what comes back.  If the check fails (it reworded something), the
# model's answer is thrown away and the rule-based output is used instead,
# with a warning on stderr.  --llm-only skips the rules entirely.

import argparse
import difflib
import os
import re
import shutil
import subprocess
import sys

__version__ = '1.6.0'

# ---------------------------------------------------------------- rewrites
# (pattern, replacement) applied INSIDE a maths span only.  Every entry
# must be undone exactly by the matching REVERSE entry: that is what makes
# the output checkable.
FORWARD = [
    (re.compile(r'<='),  r'\\leq'),
    (re.compile(r'>='),  r'\\geq'),
    (re.compile(r'!='),  r'\\neq'),
    (re.compile(r'>>'),  r'\\gg'),
    (re.compile(r'<<'),  r'\\ll'),
    (re.compile(r'->'),  r'\\to'),
    (re.compile(r'~='),  r'\\approx'),
    (re.compile(r'\+/-'), r'\\pm'),
    (re.compile(r'(?<=[0-9] )x(?= *[0-9])'), r'\\times'),
    (re.compile(r'(?<![A-Za-z\\])infinity(?![A-Za-z])'), r'\\infty'),
    (re.compile(r'<([^<>\s]+)>'), r'\\langle \1\\rangle'),
    # spaced form: only when the content cannot be a chain of comparisons,
    # i.e. it carries a macro, a superscript or a subscript of its own
    (re.compile(r'<([^<>=$]*[\\^_][^<>=$]*)>'), r'\\langle \1\\rangle'),
]

REVERSE = [
    (re.compile(r'\\langle\s*(.*?)\s*\\rangle'), r'<\1>'),
    (re.compile(r'\\([%&#])'), r'\1'),
    (re.compile(r'\\leq(?![A-Za-z])'),    '<='),
    (re.compile(r'\\geq(?![A-Za-z])'),    '>='),
    (re.compile(r'\\neq(?![A-Za-z])'),    '!='),
    (re.compile(r'\\gg(?![A-Za-z])'),     '>>'),
    (re.compile(r'\\ll(?![A-Za-z])'),     '<<'),
    (re.compile(r'\\to(?![A-Za-z])'),     '->'),
    (re.compile(r'\\approx(?![A-Za-z])'), '~='),
    (re.compile(r'\\pm(?![A-Za-z])'),     '+/-'),
    (re.compile(r'\\times(?![A-Za-z])'),  'x'),
    (re.compile(r'\\infty(?![A-Za-z])'),  'infinity'),
]

MATHWORDS = ('infinity', 'infty')

FUNCS = ('sin cos tan sinh cosh tanh exp log ln arg det max min sup inf '
         'lim cot sec csc arcsin arccos arctan').split()
FUNC_RE = re.compile(r'(?<![A-Za-z\\])(' + '|'.join(FUNCS) + r')(?![A-Za-z])')
UNFUNC_RE = re.compile(r'\\(' + '|'.join(FUNCS) + r')(?![A-Za-z])')

# ---------------------------------------------------------------- unicode
# Unicode maths -> TeX.  Used by mathify() inside a span AND by canon() on
# both sides of the check, so the check sees them as the same text.
UNI = {
    '⟨': r'\langle ', '⟩': r'\rangle', '〈': r'\langle ', '〉': r'\rangle',
    '−': '-', '×': r'\times', '·': r'\cdot', '⋅': r'\cdot', '≈': r'\approx',
    '≤': r'\leq', '≥': r'\geq', '≠': r'\neq', '→': r'\to', '←': r'\leftarrow',
    '↔': r'\leftrightarrow', '⇒': r'\Rightarrow', '≫': r'\gg', '≪': r'\ll',
    '±': r'\pm', '∓': r'\mp', '∞': r'\infty', '∝': r'\propto',
    '∂': r'\partial', '∇': r'\nabla', '∑': r'\sum', '∏': r'\prod',
    '∫': r'\int', '√': r'\sqrt', 'ℏ': r'\hbar', '∈': r'\in',
    '≡': r'\equiv', '∼': r'\sim', '≃': r'\simeq', '⊗': r'\otimes',
    '⊕': r'\oplus', '†': r'^\dagger', '∗': '^*', '′': "'", '°': r'^\circ',
}
GREEK = ('alpha beta gamma delta epsilon zeta eta theta iota kappa lambda mu '
         'nu xi omicron pi rho varsigma sigma tau upsilon phi chi psi omega').split()
CAPS = 'Gamma Delta Theta Lambda Xi Pi Sigma Upsilon Phi Psi Omega'.split()
for _i, _n in enumerate(GREEK):            # α = U+03B1 ... ω, Α = U+0391 ... Ω
    UNI[chr(0x3b1 + _i)] = '\\' + _n
    if _n.capitalize() in CAPS:            # Α, Β, Ε... are Latin letters in TeX
        UNI[chr(0x391 + _i)] = '\\' + _n.capitalize()
UNI.update({'ε': r'\varepsilon', 'ϵ': r'\epsilon', 'φ': r'\varphi',
            'ϕ': r'\phi', 'ϑ': r'\vartheta', 'ο': 'o',
            'ħ': r'\hbar', '½': r'\frac12'})
SUB = dict(zip('₀₁₂₃₄₅₆₇₈₉₊₋₌₍₎ₐₑₒₓₕₖₗₘₙₚₛₜᵢⱼ', '0123456789+-=()aeoxhklmnpstij'))
SUP = dict(zip('⁰¹²³⁴⁵⁶⁷⁸⁹⁺⁻⁼⁽⁾ⁿⁱ', '0123456789+-=()ni'))
UNI_RE = re.compile('[' + re.escape(''.join(UNI)) + ']')
SUB_RE = re.compile('[' + ''.join(SUB) + ']+')
SUP_RE = re.compile('[' + ''.join(SUP) + ']+')
DAGSUP_RE = re.compile('([†∗])([' + ''.join(SUP) + ']+)')
UNIMATH_RE = re.compile('[' + re.escape(''.join(UNI) + ''.join(SUB) + ''.join(SUP)) + ']')


def uni2tex(s):
    def script(mark, table):
        def f(m):
            t = ''.join(table[c] for c in m.group(0))
            return mark + (t if len(t) == 1 else '{' + t + '}')
        return f
    s = SUB_RE.sub(script('_', SUB), s)
    s = DAGSUP_RE.sub(lambda m: '^{' + {'†': r'\dagger ', '∗': '*'}[m.group(1)]
                      + ''.join(SUP[c] for c in m.group(2)) + '}', s)
    s = SUP_RE.sub(script('^', SUP), s)
    out = []
    for i, c in enumerate(s):
        r = UNI.get(c)
        if r is None:
            out.append(c)
            continue
        # a macro glued to a following letter needs a space: αx -> \alpha x
        if r[-1].isalpha() and r[0] in '\\^' and i + 1 < len(s) and s[i + 1].isalpha():
            r += ' '
        out.append(r)
    return ''.join(out)


# ---------------------------------------------------------------- tokens
OPENERS, CLOSERS = '([{', ')]}'
PROSE_TAIL = '.,;:!?'
QUOTES = '"\u2018\u2019\u201c\u201d\u00ab\u00bb'
WORD_RE = re.compile(r"^[A-Za-z][a-z'\u2019-]+$")   # is, the, Eq, doesn't
SINGLE_STOP = {'a', 'A', 'I'}
MACRO_RE = re.compile(r'\\[A-Za-z]')
BRAKET_RE = re.compile(r'<[^<>\s]+>')
ATOM_CHARS = set("0123456789()[]{}+-*/^_=<>|,.;:'!\\~")

SEED, ATOM, TEXT = 'SEED', 'ATOM', 'TEXT'
COMPOUND_RE = re.compile(r'^(.+?)(-[A-Za-z][a-z]{2,}(?:-[A-Za-z][a-z]+)*)$')

OP_RE = re.compile(r'^[=+\-*/<>|]+$')      # a bare operator token
REL_RE = re.compile(r'^[<>]$')              # a bare comparison
ESCAPE_RE = re.compile(r'(?<!\\)([%&#])')  # TeX-active in prose


def prosify(s, escape=True):
    """Make the NON-maths part of the text TeX-parsable: % & # are active."""
    return ESCAPE_RE.sub(r'\\\1', s) if escape else s


def balance(s):
    """(unclosed openers, unopened closers) in s."""
    opened = unopened = 0
    for c in s:
        if c in OPENERS:
            opened += 1
        elif c in CLOSERS:
            if opened:
                opened -= 1
            else:
                unopened += 1
    return opened, unopened


def core_of(tok):
    """Split a token into (prefix, core, suffix) on prose punctuation only.

    Brackets are moved out only when they are unbalanced within the token:
    '(N>>1,' gives ('(', 'N>>1', ','), while '(00;00)' is left whole.
    """
    pre, s, suf = '', tok, ''
    changed = True
    while changed and s:
        changed = False
        if s[-1] in PROSE_TAIL:
            suf, s, changed = s[-1] + suf, s[:-1], True
        elif s and s[-1] in QUOTES:
            suf, s, changed = s[-1] + suf, s[:-1], True
        elif s and s[-1] in CLOSERS and balance(s)[1]:
            suf, s, changed = s[-1] + suf, s[:-1], True
        elif s and s[0] in QUOTES:
            pre, s, changed = pre + s[0], s[1:], True
        elif s and s[0] in OPENERS and balance(s)[0]:
            pre, s, changed = pre + s[0], s[1:], True
    return pre, s, suf


def classify(core):
    if not core:
        return TEXT
    if core in SINGLE_STOP:
        return TEXT
    if MACRO_RE.search(core) or BRAKET_RE.search(core) or UNIMATH_RE.search(core):
        return SEED
    if re.search(r'\S[\^_]|[\^_]\S', core):
        return SEED
    if '=' in core or '>>' in core or '<<' in core:
        return SEED
    if '->' in core or '<-' in core or '+/-' in core:
        return SEED
    if core.lower() in MATHWORDS:
        return ATOM
    if WORD_RE.match(core) and core.lower() not in FUNCS:
        return TEXT
    if len(core) == 1:
        return ATOM
    if core.lower() in FUNCS:
        return ATOM
    letters = [c for c in core if c.isalpha()]
    if all(c in ATOM_CHARS or c.isalpha() for c in core) and len(letters) <= 2:
        return ATOM
    if not letters:
        return ATOM
    return TEXT


class Tok:
    __slots__ = ('raw', 'pre', 'core', 'suf', 'kind', 'ws')

    def __init__(self, raw, ws):
        self.raw, self.ws = raw, ws
        self.pre, self.core, self.suf = core_of(raw)
        m = COMPOUND_RE.match(self.core)
        if m and classify(m.group(1)) == SEED:    # Λ-system: only Λ is maths
            self.core, self.suf = m.group(1), m.group(2) + self.suf
        self.kind = classify(self.core)


def tokenize(line):
    """[Tok] with the whitespace that FOLLOWS each token kept on it."""
    out = []
    for m in re.finditer(r'(\S+)(\s*)', line):
        out.append(Tok(m.group(1), m.group(2)))
    return out


# ---------------------------------------------------------------- spans
def find_spans(toks):
    """List of (i, j) token ranges, inclusive, that are maths."""
    n = len(toks)
    for k in range(1, n - 1):
        t = toks[k]
        if (t.kind == ATOM and REL_RE.match(t.core) and not toks[k - 1].suf
                and toks[k - 1].kind in (ATOM, SEED)
                and toks[k + 1].kind in (ATOM, SEED)):
            t.kind = SEED
    spans = []
    i = 0
    while i < n:
        if toks[i].kind != SEED:
            i += 1
            continue
        lo = hi = i
        while lo > 0:                       # grow left
            p = toks[lo - 1]
            if p.suf or not p.core:
                break
            if p.kind == TEXT and not (p.core in SINGLE_STOP
                                       and OP_RE.match(toks[lo].core)):
                break
            lo -= 1
        while toks[hi].suf == '' and hi + 1 < n:   # grow right
            nx = toks[hi + 1]
            if not nx.core:
                break
            if nx.kind == TEXT and not (nx.core in SINGLE_STOP
                                        and OP_RE.match(toks[hi].core)):
                break
            hi += 1
        if spans and lo <= spans[-1][1] + 1 and spans[-1][1] >= lo:
            spans[-1] = (spans[-1][0], max(spans[-1][1], hi))
        else:
            spans.append((lo, hi))
        i = hi + 1
    return spans


def render_span(toks, lo, hi):
    """(before, maths, after) for a token range, brackets balanced."""
    head = toks[lo].pre
    tail = toks[hi].suf
    body = toks[lo].core
    for k in range(lo + 1, hi + 1):
        body += toks[k - 1].ws + toks[k].pre + toks[k].core
    # a closer the span DID open belongs inside, even if the token it was
    # glued to looked unbalanced on its own: '(N/\\mathcal V)' is one group
    while tail and tail[0] in CLOSERS and balance(body)[0]:
        body, tail = body + tail[0], tail[1:]
    # a closer the span never opened, or an opener it never closes, is prose
    while body:
        op, un = balance(body)
        if un and body[0] in CLOSERS:
            head, body = head + body[0], body[1:]
        elif op and body[-1] in OPENERS:
            tail, body = body[-1] + tail, body[:-1]
        elif un:
            k = next(i for i, c in enumerate(body) if c in CLOSERS and balance(body[:i])[0] == 0)
            if k == len(body) - 1:
                tail, body = body[k] + tail, body[:k]
            else:
                break
        else:
            break
    if len(body) > 2 and body[0] == '(' and body[-1] == ')':
        depth = 0
        for i, c in enumerate(body):
            depth += (c in OPENERS) - (c in CLOSERS)
            if depth == 0 and i == len(body) - 1:
                head, body, tail = head + '(', body[1:-1], ')' + tail
            elif depth == 0:
                break
    return head, body, tail


def mathify(s):
    s = uni2tex(s)
    for pat, rep in FORWARD:
        s = pat.sub(rep, s)
    s = FUNC_RE.sub(r'\\\1', s)
    return s


# ---------------------------------------------------------------- engine
QUOTE_RE = re.compile(r'^(\s*(?:>\s?)+)')     # email quoting, not maths
PARA_RE = re.compile(r'(\n[ \t]*\n)')


def texify_chunk(chunk, delim, spans_out, escape):
    """One quote-free chunk of text; newlines inside it are plain space, so a
    formula hard-wrapped by the mailer is still seen as one formula."""
    if not chunk.strip():
        return chunk
    lead = chunk[:len(chunk) - len(chunk.lstrip())]
    trail = chunk[len(chunk.rstrip()):]
    toks = tokenize(chunk.strip())
    spans = find_spans(toks)
    out, cur = lead, 0
    for lo, hi in spans:
        for k in range(cur, lo):
            out += prosify(toks[k].raw, escape) + toks[k].ws
        head, maths, tail = render_span(toks, lo, hi)
        if not maths:
            out += prosify(toks[lo].raw, escape) + toks[lo].ws
            cur = hi + 1
            continue
        if spans_out is not None:
            spans_out.append((maths, mathify(maths)))
        out += (prosify(head, escape) + delim[0] + mathify(maths) + delim[1]
                + prosify(tail, escape) + toks[hi].ws)
        cur = hi + 1
    for k in range(cur, len(toks)):
        out += prosify(toks[k].raw, escape) + toks[k].ws
    return out.rstrip() + trail if trail else out


def texify(text, delim=('$', '$'), spans_out=None, escape=True):
    out = []
    for block in PARA_RE.split(text):
        if not block.strip():
            out.append(block)
        elif QUOTE_RE.match(block) or '\n>' in block:
            # quoted mail: a formula must not cross a > marker (it would end up
            # inside the dollars and be typeset as a relation), so: line by line
            lines = []
            for line in block.split('\n'):
                qm = QUOTE_RE.match(line)
                pre = qm.group(1) if qm else ''
                lines.append(pre + texify_chunk(line[len(pre):], delim,
                                                spans_out, escape))
            out.append('\n'.join(lines))
        else:
            out.append(texify_chunk(block, delim, spans_out, escape))
    return ''.join(out)


# ---------------------------------------------------------------- check
def canon(s):
    """Undo everything texify is allowed to do, then normalise space."""
    s = uni2tex(s)
    s = re.sub(r'\$\$?|\\\(|\\\)|\\\[|\\\]', '', s)
    for pat, rep in REVERSE:
        s = pat.sub(rep, s)
    s = UNFUNC_RE.sub(r'\1', s)
    return re.sub(r'\s+', ' ', s).strip()


def check(src, out):
    """None if out is a pure markup insertion over src, else a diff."""
    a, b = canon(src), canon(out)
    if a == b:
        return None
    return '\n'.join(difflib.unified_diff(
        a.split(' '), b.split(' '), 'input', 'output', n=2, lineterm=''))


# ---------------------------------------------------------------- llm
PROMPT = r"""You are the semantic stage of a LaTeX markup tool.

Below is a paragraph of hand-typed physics prose in which the mathematics
was written informally. Return the SAME paragraph with the mathematics
wrapped in $...$ and the ASCII stand-ins replaced by macros.

Absolute rules:
* Do not add, remove, reorder or reword a single word of prose.
* Do not change spelling, punctuation, capitalisation or line breaks.
* Do not touch the mathematics itself: do not expand it, simplify it,
  correct it, or add \left \right, \, spacing or any macro not listed.
* The ONLY edits allowed are: inserting $ signs, and these substitutions
  inside the mathematics:
      >> -> \gg     << -> \ll     <= -> \leq    >= -> \geq
      != -> \neq    -> -> \to     ~= -> \approx  +/- -> \pm
      <X> -> \langle X\rangle   (an expectation value, NOT a comparison)
      sin cos tan exp log ln det tr max min -> \sin \cos ... (only inside maths)
* Judgement calls are yours: an Eq. number, a paper number, a date or a
  section number is NOT mathematics and stays bare; a lone symbol that
  names a physical quantity IS mathematics. Punctuation that belongs to
  the sentence (a comma, a full stop) stays OUTSIDE the dollars.

Output the paragraph and nothing else: no preamble, no code fence, no
commentary.

PARAGRAPH:
"""


def run_llm(text, model=None, timeout=180):
    exe = shutil.which('claude')
    if not exe:
        raise RuntimeError('no `claude` CLI on PATH')
    cmd = [exe, '-p']
    if model:
        cmd += ['--model', model]
    env = dict(os.environ)
    env.pop('CLAUDE_CODE_SSE_PORT', None)
    p = subprocess.run(cmd, input=PROMPT + text, capture_output=True,
                       text=True, timeout=timeout, env=env)
    if p.returncode != 0:
        raise RuntimeError((p.stderr or 'claude failed').strip()[:400])
    out = p.stdout.strip('\n')
    out = re.sub(r'^```[a-z]*\n|\n```$', '', out.strip())
    return out


# ---------------------------------------------------------------- clipboard
def clip_read():
    for cmd in (['wl-paste', '-n'], ['xclip', '-selection', 'clipboard', '-o'],
                ['xsel', '-ob']):
        if shutil.which(cmd[0]):
            p = subprocess.run(cmd, capture_output=True, text=True)
            if p.returncode == 0:
                return p.stdout
    sys.exit('texify: no clipboard tool (wl-clipboard / xclip / xsel)')


def clip_write(s):
    for cmd in (['wl-copy'], ['xclip', '-selection', 'clipboard'], ['xsel', '-ib']):
        if shutil.which(cmd[0]):
            subprocess.run(cmd, input=s, text=True)
            return cmd[0]
    return None


def notify(body):
    if shutil.which('notify-send'):
        subprocess.Popen(['notify-send', '-a', 'texify', '-t', '2500',
                          '-i', 'accessories-text-editor', 'texify', body])


EPILOG = r"""examples:
  texify mail.txt                 convert a file, result on stdout
  texify                          paste the text, then Ctrl-D
  texify -x -c                    clipboard in, clipboard out
  texify -x -c -n                 same, silently, with a notification (for a shortcut)
  texify -v mail.txt              also list each formula found, on stderr
  texify --diff mail.txt          show only what changed
  texify --llm -x -c              let Claude settle the ambiguous cases

  N>>1, \bar V = 2 U_0/V as <H> = N E_0   ->   $N\gg1$, $\bar V = 2 U_0/V$ as $\langle H\rangle = N E_0$

inside the maths:  >> \gg  << \ll  <= \leq  >= \geq  != \neq  -> \to  ~= \approx
                   +/- \pm  <X> \langle X\rangle  infinity \infty  3 x 10 -> 3 \times 10
                   sin cos exp log ... -> \sin \cos \exp \log ...
in the prose:      % & # -> \% \& \#   (--no-escape to leave them)
unicode maths:     ⟨n₁n₂⟩ / ⟨N(N−1)⟩ -> $\langle n_1n_2\rangle / \langle N(N-1)\rangle$
                   α…ω Γ…Ω, ² ⁻³ ₁, − × ≈ ≤ ≫ ± ∞ ∂ ∑ ∫ ℏ † ... likewise

The output is checked to be the input plus markup only, so texify never
rewords; exit status 2 if that check fails.

https://laussy.org/wiki/texify
"""


# ---------------------------------------------------------------- main
def main():
    p = argparse.ArgumentParser(
        prog='texify',
        formatter_class=argparse.RawDescriptionHelpFormatter,
        description='Turn hand-typed prose (an email, a note) into TeX-parsable '
                    'text:\nthe maths is wrapped in $...$, the prose is left alone.',
        epilog=EPILOG)
    p.add_argument('file', nargs='?',
                   help='input file (default: stdin; in a terminal, paste then Ctrl-D)')
    p.add_argument('-x', '--from-clipboard', action='store_true',
                   help='read the text from the clipboard')
    p.add_argument('-c', '--to-clipboard', action='store_true',
                   help='put the result in the clipboard (also printed, unless -n)')
    p.add_argument('-n', '--notify', action='store_true',
                   help='with -c: a desktop notification instead of printing')
    p.add_argument('-v', '--verbose', action='store_true',
                   help='list the spans that were wrapped, on stderr')
    p.add_argument('--diff', action='store_true', help='show the changes only')
    p.add_argument('--paren', action='store_true', help=r'use \(...\) instead of $...$')
    p.add_argument('--llm', action='store_true',
                   help='rules first, then Claude (claude -p) adjudicates')
    p.add_argument('--llm-only', action='store_true', help='Claude alone, no rules')
    p.add_argument('--model', help='model for --llm (default: the CLI default)')
    p.add_argument('--no-escape', action='store_true',
                   help='leave %%, & and # bare in the prose (they are TeX-active)')
    p.add_argument('--no-check', action='store_true', help='skip the reversibility check')
    p.add_argument('-V', '--version', action='version', version='texify ' + __version__)
    o = p.parse_args()

    if o.from_clipboard:
        src = clip_read()
    elif o.file:
        src = open(o.file, encoding='utf-8').read()
    else:
        if sys.stdin.isatty():
            print('texify: paste the text, then Ctrl-D (texify -h for help)',
                  file=sys.stderr)
        src = sys.stdin.read()
    if not src.strip():
        sys.exit('texify: nothing to convert')

    delim = (r'\(', r'\)') if o.paren else ('$', '$')
    spans = []
    out = src if o.llm_only else texify(src, delim, spans, not o.no_escape)

    if not o.no_check and not o.llm_only:
        bad = check(src, out)
        if bad:
            print('texify: INTERNAL CHECK FAILED - the rules changed your prose.',
                  file=sys.stderr)
            print(bad, file=sys.stderr)
            sys.exit(2)

    if o.llm or o.llm_only:
        try:
            cand = run_llm(src if o.llm_only else out, o.model)
            bad = None if o.no_check else check(src, cand)
            if bad:
                print('texify: the model reworded the text - its answer was '
                      'discarded, falling back to the rules.', file=sys.stderr)
                print(bad, file=sys.stderr)
                if o.llm_only:
                    out = texify(src, delim, spans, not o.no_escape)
            else:
                out = cand
        except Exception as e:
            print('texify: --llm failed (%s), rules output kept.' % e, file=sys.stderr)
            if o.llm_only:
                out = texify(src, delim, spans, not o.no_escape)

    if o.verbose:
        for a, b in spans:
            print('  %-44s ->  %s' % (' '.join(a.split()), ' '.join(b.split())),
                  file=sys.stderr)

    if o.diff:
        for l in difflib.unified_diff(src.split('\n'), out.split('\n'),
                                      'before', 'after', n=1, lineterm=''):
            print(l)
        return

    if o.to_clipboard:
        who = clip_write(out)
        msg = 'copied to the clipboard' if who else 'no clipboard tool found'
        if o.notify:
            notify('%d formula%s wrapped, %s'
                   % (len(spans), '' if len(spans) == 1 else 's', msg))
        else:
            print(out)
            print('texify: %s' % msg, file=sys.stderr)
    else:
        sys.stdout.write(out if out.endswith('\n') else out + '\n')


if __name__ == '__main__':
    main()