<span class="mw-page-title-main">TeX2unicode</span>
Fabrice P. Lauss𝕪's Web

$\mathrm{\TeX}$2unicode

TeX2unicode is a little desktop utility that turns a TeX fragment into real Unicode text, anywhere in the system. You hit a global shortcut, type, e.g., g^{(2)}(\tau), press Enter, and g⁽²⁾(τ) is pasted straight into whatever text form you were writing in.

It is an offline, one-keystroke replacement for the otherwise great unicodeit.net: no browser tab, no copy-paste round trip, no network.

Written with the help of the brand new Claude Opus 5.0 on 25 July (2026) for KDE Plasma 6 on X11. Version 1.5.

The idea

Unicode already contains most of the symbols physicists actually type in prose: Greek letters, superscripts, subscripts, arrows, ⟨brackets⟩, ℏ, †, ⊗, ∑. What it lacks is a keyboard for them. $\mathrm{\TeX}$ is that keyboard, provided you have direct access to map one (Knuth's code) to the other (Unicode glyphs). So the only thing missing was a transliterator wired to a hotkey.

I achieve this with a parser, a popup, and a global shortcut.

What it looks like

Press the shortcut (Ctrl+Alt+Shift+U) and a small window appears in the upper third of the screen containing an input line, a large live preview that updates on every keystroke, and a hint line. You type your $\mathrm{\TeX}$ code, watch the Unicode form, and press Enter.

You type You get
g^{(2)}(\tau) g⁽²⁾(τ) the canonical case
\hbar\omega_0/2 ℏω₀/2 zero-point energy
a^\dagger{a} a†a number operator
|\psi\rangle = \alpha|0\rangle + \beta|1\rangle |ψ⟩ = α|0⟩ + β|1⟩ a qubit
\langle\sigma^\dagger\sigma\rangle ⟨σ†σ⟩ expectation value
\sum_{n=0}^{N} c_n ∑ₙ₌₀ᴺ cₙ limits on a big operator
\rho_{11} \approx 0.5 \pm 0.02 ρ₁₁ ≈ 0.5 ± 0.02 subscripts and relations
\hat{a}, \bar{x}, \vec{v}, \dot{x}, \ddot{x} â, x̄, v⃗, ẋ, ẍ combining accents
\mathbb{C}^2 \otimes \mathcal{H} ℂ² ⊗ ℋ math alphabets
\frac{1}{2} and \frac{3}{7} ½ and ³⁄₇ fractions
\sqrt{2}, \nabla^2\phi, T_2^* √2, ∇²ϕ, T₂* assorted
\Delta E\,\Delta t \geq \hbar/2 Δ E Δ t ≥ ℏ/2 thin space with \,
{}^{87}\text{Rb} ⁸⁷Rb preceding superscript
\sin^2\theta + \cos^2\theta = 1 sin²θ + cos²θ = 1 function names
--- em dash (and -- gives an en dash)

Keys

Key Does
Ctrl+Alt+Shift+U open the popup (global, from any application)
Enter convert, paste into the window you came from, and leave it on the clipboard
Shift+Enter convert and copy to the clipboard, but do not paste
/ walk back through history
Esc cancel

Syntax it understands

  • Greek, upper and lower, with variants: \alpha \Omega \varphi \vartheta
  • Superscripts and subscripts, single or braced: x^2, c_n, g^{(2)}, \rho_{11}
  • 558 symbols: operators, relations, arrows, set theory, logic, delimiters, card suits — \otimes \approx \rightarrow \forall \langle \hbar \dagger \partial \infty
  • Accents as combining characters: \hat \bar \vec \tilde \dot \ddot \check \acute \grave \breve \underline
  • Math alphabets: \mathbb \mathcal \mathfrak \mathbf \mathit \mathsf \mathtt (and \mathscr, \bm)
  • Fractions: \frac{1}{2} → ½ where a vulgar fraction exists, otherwise \frac{3}{7} → ³⁄₇
  • Spacing: \, \; \quad \qquad
  • Text: \text{…}, \mathrm{…}, function names (\sin, \log…), \left/\right (dropped), $ (dropped)
  • Dashes and quotes: --- → —, -- → –, `` → “, → ”
  • Dirac notation: \ket{\psi} → |ψ⟩, \bra{\psi} → ⟨ψ|, \braket{a}{b} → ⟨a|b⟩
  • Text-mode accents: \"o → ö, \'e → é, \c{c} → ç, \v{s} → š, \H{o} → ő, and the ligatures \ae \oe \o \ss \aa \l, so Schr\"odinger → Schrödinger
  • Negation by composition: \not\in → ∉, \not= → ≠, \not\subset → ⊄

Anything it does not know is passed through verbatim as \foobar and flagged in the hint line, so a typo is visible rather than silent.

Three design decisions

Spacing is WYSIWYG, not TeX

TeX swallows the space after a control word: \alpha beta renders as “αbeta”. Inheriting that rule here was actively wrong, because the output is prose rather than a formula — x \to \infty came out as x →∞ and \approx 10 as ≈10, silently eating spaces the user had deliberately typed.

So this parser keeps your spacing exactly as typed. To join a command to the next letter, use TeX's own idiom, the empty group: \mu{}s → μs.

Partial conversions fall back instead of half-converting

Unicode's superscript and subscript repertoires are incomplete, which is one of the big tragedies of this world! The Unicode had designed it on purpose so that people would not typeset with it. As if they wouldn't! There is no raised ω, no raised capital C, no lowered B. A group is therefore converted only if every character in it has a raised (or lowered) form; otherwise the whole group is left as literal ^/_ text:

Input Output Why
x^{2+n} x²⁺ⁿ every character has a raised form
e^{-i\omega t} e^-iω t no raised ω exists
k_B k_B no lowered B exists

Mixing heights (e⁻ⁱω​ᵗ) looks worse than an honest caret, and quietly dropping the exponent would be worse still.

It pastes without ever touching the keymap

This is the delicate part. The obvious way to paste is xdotool key ctrl+v, and on a multi-layout keyboard that is a trap. When xdotool is asked for a keysym that is not in the active layout it temporarily rewrites the keyboard mapping to make one available. On my fr,es,ru setup that flips the active layout, and a press/release imbalance during the swap can leave a modifier logically stuck down—after which every mouse click registers as modifier+click and the desktop needs a restart to recover. This happened, and it is why the tool paid careful attention here.

xpaste.py avoids the whole class of problem by never writing to the keymap at all:

  • it drives XTEST with explicit hardware keycodes, so XChangeKeyboardMapping is never called — the keymap is read, never written;
  • it resolves the keycodes with XKeysymToKeycode, which searches all layout groups (so v is still found while the Russian group is active), and falls back to the standard evdev positions 37 (Control_L) and 55 (v);
  • it refuses to fire while any modifier is physically held, waiting up to 600 ms for a clear state, so it can never stack a held modifier onto its own synthetic press;
  • it releases every modifier it pressed, in reverse order, in a finally block, so a press can never be left dangling even if something throws in between.

The converted text is also always put on the clipboard, so if a paste ever misses—wrong window refocused, an application that ignores Ctrl+V—the result is still one Ctrl+V away rather than lost. Set TEX2UNI_PASTE=0 to make copy-only the default behaviour.

One keystroke does not fit every application, and Ctrl+V is wrong in more places than you would expect. Emacs binds it to scroll-up-command, so a paste there just scrolls the buffer; the yank is Ctrl+Y, and it works on our clipboard because modern Emacs pulls the X selection into the kill ring through interprogram-paste-function. Every terminal reserves Ctrl+V for quoted-insert and pastes on Ctrl+Shift+V instead. So texin.py reads WM_CLASS off the very window it is about to paste into—the one it already recorded in order to hand focus back—and looks the keystroke up in a table: Emacs gets Ctrl+Y, terminals Ctrl+Shift+V, xterm and urxvt Shift+Insert, and anything unrecognised the Ctrl+V that is right for the large majority. A line of wmclass = combo in ~/.config/tex2uni/pastekeys.conf overrides any of it, which matters because the table assumes stock keybindings: cua-mode makes Ctrl+V correct in Emacs again, and evil-mode makes Ctrl+Y wrong.

Installation

Dependencies

sudo apt install python3-pyqt5 xclip wmctrl x11-utils libxtst6

x11-utils provides xprop, used to remember which window to paste back into. Everything else is in the standard library.

The files

Three files in ~/bin/, all executable (chmod +x ~/bin/*.py).

~/bin/tex2uni.py — the converter

A recursive-descent parser over the TeX fragment, plus the lookup tables. Usable on its own as a library or a filter:

tex2uni.py 'g^{(2)}(\tau)'     # -> g⁽²⁾(τ)
echo '\hbar\omega' | tex2uni.py  # -> ℏω
#!/usr/bin/env python3
"""TeX -> Unicode transliteration.

    g^{(2)}(\\tau)      ->  g⁽²⁾(τ)
    \\hbar\\omega_0/2    ->  ℏω₀/2
    a^\\dagger a        ->  a† a
    ---                ->  —

Used as a library by texin.py (the popup) and as a CLI:

    tex2uni.py 'g^{(2)}(\\tau)'      # convert argument(s)
    echo '\\alpha' | tex2uni.py      # convert stdin
"""

import sys
import unicodedata

__version__ = '1.5'

# ---------------------------------------------------------------- superscripts
SUP = {
    '0': '⁰', '1': '¹', '2': '²', '3': '³', '4': '⁴',
    '5': '⁵', '6': '⁶', '7': '⁷', '8': '⁸', '9': '⁹',
    '+': '⁺', '-': '⁻', '=': '⁼', '(': '⁽', ')': '⁾', ' ': ' ',
    'a': 'ᵃ', 'b': 'ᵇ', 'c': 'ᶜ', 'd': 'ᵈ', 'e': 'ᵉ', 'f': 'ᶠ', 'g': 'ᵍ',
    'h': 'ʰ', 'i': 'ⁱ', 'j': 'ʲ', 'k': 'ᵏ', 'l': 'ˡ', 'm': 'ᵐ', 'n': 'ⁿ',
    'o': 'ᵒ', 'p': 'ᵖ', 'r': 'ʳ', 's': 'ˢ', 't': 'ᵗ', 'u': 'ᵘ', 'v': 'ᵛ',
    'w': 'ʷ', 'x': 'ˣ', 'y': 'ʸ', 'z': 'ᶻ',
    'A': 'ᴬ', 'B': 'ᴮ', 'D': 'ᴰ', 'E': 'ᴱ', 'G': 'ᴳ', 'H': 'ᴴ', 'I': 'ᴵ',
    'J': 'ᴶ', 'K': 'ᴷ', 'L': 'ᴸ', 'M': 'ᴹ', 'N': 'ᴺ', 'O': 'ᴼ', 'P': 'ᴾ',
    'R': 'ᴿ', 'T': 'ᵀ', 'U': 'ᵁ', 'V': 'ⱽ', 'W': 'ᵂ',
    'α': 'ᵅ', 'β': 'ᵝ', 'γ': 'ᵞ', 'δ': 'ᵟ', 'ε': 'ᵋ', 'θ': 'ᶿ',
    'ι': 'ᶥ', 'φ': 'ᵠ', 'χ': 'ᵡ',
    # no raised glyph exists, but these are *written* raised by convention
    '†': '†', '‡': '‡', '′': '′', '″': '″', '‴': '‴', '∗': '∗', '*': '*',
    '↑': '↑', '↓': '↓',
}

SUB = {
    '0': '₀', '1': '₁', '2': '₂', '3': '₃', '4': '₄',
    '5': '₅', '6': '₆', '7': '₇', '8': '₈', '9': '₉',
    '+': '₊', '-': '₋', '=': '₌', '(': '₍', ')': '₎', ' ': ' ',
    'a': 'ₐ', 'e': 'ₑ', 'h': 'ₕ', 'i': 'ᵢ', 'j': 'ⱼ', 'k': 'ₖ', 'l': 'ₗ',
    'm': 'ₘ', 'n': 'ₙ', 'o': 'ₒ', 'p': 'ₚ', 'r': 'ᵣ', 's': 'ₛ', 't': 'ₜ',
    'u': 'ᵤ', 'v': 'ᵥ', 'x': 'ₓ',
    'β': 'ᵦ', 'γ': 'ᵧ', 'ρ': 'ᵨ', 'φ': 'ᵩ', 'χ': 'ᵪ',
    '↑': '↑', '↓': '↓',
}

# ---------------------------------------------------------------------- symbols
SYMBOLS = {
    # greek, lowercase
    'alpha': 'α', 'beta': 'β', 'gamma': 'γ', 'delta': 'δ', 'epsilon': 'ϵ',
    'varepsilon': 'ε', 'zeta': 'ζ', 'eta': 'η', 'theta': 'θ',
    'vartheta': 'ϑ', 'iota': 'ι', 'kappa': 'κ', 'lambda': 'λ', 'mu': 'μ',
    'nu': 'ν', 'xi': 'ξ', 'omicron': 'ο', 'pi': 'π', 'varpi': 'ϖ',
    'rho': 'ρ', 'varrho': 'ϱ', 'sigma': 'σ', 'varsigma': 'ς', 'tau': 'τ',
    'upsilon': 'υ', 'phi': 'ϕ', 'varphi': 'φ', 'chi': 'χ', 'psi': 'ψ',
    'omega': 'ω', 'digamma': 'ϝ',
    # greek, uppercase
    'Gamma': 'Γ', 'Delta': 'Δ', 'Theta': 'Θ', 'Lambda': 'Λ', 'Xi': 'Ξ',
    'Pi': 'Π', 'Sigma': 'Σ', 'Upsilon': 'Υ', 'Phi': 'Φ', 'Psi': 'Ψ',
    'Omega': 'Ω', 'Alpha': 'Α', 'Beta': 'Β', 'Epsilon': 'Ε', 'Zeta': 'Ζ',
    'Eta': 'Η', 'Iota': 'Ι', 'Kappa': 'Κ', 'Mu': 'Μ', 'Nu': 'Ν',
    'Omicron': 'Ο', 'Rho': 'Ρ', 'Tau': 'Τ', 'Chi': 'Χ',
    # quantum optics / physics staples
    'hbar': 'ℏ', 'hslash': 'ℏ', 'dagger': '†', 'dag': '†', 'ddagger': '‡',
    'ddag': '‡', 'prime': '′', 'dprime': '″', 'partial': '∂', 'nabla': '∇',
    'infty': '∞', 'ell': 'ℓ', 'Re': 'ℜ', 'Im': 'ℑ', 'wp': '℘',
    'aleph': 'ℵ', 'beth': 'ℶ', 'imath': 'ı', 'jmath': 'ȷ', 'AA': 'Å',
    'degree': '°', 'deg': '°', 'angstrom': 'Å', 'micro': 'µ', 'perp': '⊥',
    # operators / relations
    'times': '×', 'div': '÷', 'pm': '±', 'mp': '∓', 'cdot': '·',
    'cdots': '⋯', 'ldots': '…', 'dots': '…', 'vdots': '⋮', 'ddots': '⋱',
    'ast': '∗', 'star': '⋆', 'circ': '∘', 'bullet': '∙',
    'oplus': '⊕', 'ominus': '⊖', 'otimes': '⊗', 'oslash': '⊘',
    'odot': '⊙', 'wedge': '∧', 'vee': '∨', 'cap': '∩', 'cup': '∪',
    'setminus': '∖', 'sqcup': '⊔', 'sqcap': '⊓', 'uplus': '⊎',
    'leq': '≤', 'le': '≤', 'geq': '≥', 'ge': '≥', 'neq': '≠', 'ne': '≠',
    'equiv': '≡', 'sim': '∼', 'simeq': '≃', 'approx': '≈', 'cong': '≅',
    'propto': '∝', 'll': '≪', 'gg': '≫', 'lll': '⋘', 'ggg': '⋙',
    'subset': '⊂', 'supset': '⊃', 'subseteq': '⊆', 'supseteq': '⊇',
    'in': '∈', 'ni': '∋', 'notin': '∉', 'doteq': '≐', 'asymp': '≍',
    # big operators
    'sum': '∑', 'prod': '∏', 'coprod': '∐', 'int': '∫', 'iint': '∬',
    'iiint': '∭', 'oint': '∮', 'bigcup': '⋃', 'bigcap': '⋂',
    'bigoplus': '⨁', 'bigotimes': '⨂', 'bigvee': '⋁', 'bigwedge': '⋀',
    'sqrt': '√', 'surd': '√', 'cbrt': '∛',
    # arrows
    'to': '→', 'rightarrow': '→', 'gets': '←', 'leftarrow': '←',
    'leftrightarrow': '↔', 'uparrow': '↑', 'downarrow': '↓',
    'updownarrow': '↕', 'nearrow': '↗', 'searrow': '↘', 'swarrow': '↙',
    'nwarrow': '↖', 'Rightarrow': '⇒', 'Leftarrow': '⇐',
    'Leftrightarrow': '⇔', 'Uparrow': '⇑', 'Downarrow': '⇓',
    'mapsto': '↦', 'longrightarrow': '⟶', 'longleftarrow': '⟵',
    'hookrightarrow': '↪', 'hookleftarrow': '↩', 'rightleftharpoons': '⇌',
    # logic / sets
    'forall': '∀', 'exists': '∃', 'nexists': '∄', 'neg': '¬', 'lnot': '¬',
    'land': '∧', 'lor': '∨', 'therefore': '∴', 'because': '∵',
    'emptyset': '∅', 'varnothing': '∅', 'top': '⊤', 'bot': '⊥',
    'models': '⊨', 'vdash': '⊢', 'dashv': '⊣',
    # delimiters & brackets
    'langle': '⟨', 'rangle': '⟩', 'lvert': '|', 'rvert': '|',
    'lVert': '‖', 'rVert': '‖', 'lceil': '⌈', 'rceil': '⌉',
    'lfloor': '⌊', 'rfloor': '⌋', 'llbracket': '⟦', 'rrbracket': '⟧',
    'lbrace': '{', 'rbrace': '}', 'mid': '|', 'parallel': '∥',
    # misc text
    'ldots': '…', 'textemdash': '—', 'textendash': '–',
    'copyright': '©', 'pounds': '£', 'S': '§', 'P': '¶', 'dag': '†',
    'checkmark': '✓', 'times': '×', 'flat': '♭', 'sharp': '♯',
    'natural': '♮', 'clubsuit': '♣', 'diamondsuit': '♢',
    'heartsuit': '♡', 'spadesuit': '♠',
    # spacing
    'quad': ' ', 'qquad': '  ', 'thinspace': ' ',
    'enspace': ' ', 'space': ' ',
}

# Generated from the pylatexenc macro table (one-shot harvest, not a
# runtime dependency). Curated SYMBOLS entries above override these,
# so e.g. \bigoplus stays the n-ary ⨁ rather than the binary ⊕.
EXTRA_SYMBOLS = {
    'AE': 'Æ', 'Angle': '⦜', 'Bumpeq': '≎', 'Cap': '⋒', 'Colon': '∷', 'Cup': '⋓',
    'DH': 'Ð', 'DJ': 'Đ', 'Digamma': 'Ϝ', 'DownArrowBar': '⤓', 'DownArrowUpArrow': '⇵',
    'DownLeftRightVector': '⥐', 'DownLeftTeeVector': '⥞', 'DownLeftVectorBar': '⥖',
    'DownRightTeeVector': '⥟', 'DownRightVectorBar': '⥗', 'Equal': '⩵', 'IJ': 'IJ',
    'Ident': '𝕀', 'Koppa': 'Ϟ', 'L': 'Ł', 'LeftDownTeeVector': '⥡', 'LeftDownVectorBar': '⥙',
    'LeftRightVector': '⥎', 'LeftTeeVector': '⥚', 'LeftTriangleBar': '⧏',
    'LeftUpDownVector': '⥑', 'LeftUpTeeVector': '⥠', 'LeftUpVectorBar': '⥘',
    'LeftVectorBar': '⥒', 'Lleftarrow': '⇚', 'Longleftarrow': '⟸', 'Longleftrightarrow': '⟺',
    'Longrightarrow': '⟹', 'Lsh': '↰', 'NG': 'Ŋ', 'NestedGreaterGreater': '⪢',
    'NestedLessLess': '⪡', 'O': 'Ø', 'OE': 'Œ', 'ReverseUpEquilibrium': '⥯',
    'RightDownTeeVector': '⥝', 'RightDownVectorBar': '⥕', 'RightTeeVector': '⥛',
    'RightTriangleBar': '⧐', 'RightUpDownVector': '⥏', 'RightUpTeeVector': '⥜',
    'RightUpVectorBar': '⥔', 'RightVectorBar': '⥓', 'RoundImplies': '⥰', 'Rrightarrow': '⇛',
    'Rsh': '↱', 'RuleDelayed': '⧴', 'Sampi': 'Ϡ', 'Stigma': 'Ϛ', 'Subset': '⋐',
    'Supset': '⋑', 'TH': 'Þ', 'UpArrowBar': '⤒', 'UpEquilibrium': '⥮', 'Updownarrow': '⇕',
    'VDash': '⊫', 'Vdash': '⊩', 'Vert': '‖', 'Vvdash': '⊪', 'aa': 'å', 'ae': 'æ',
    'allequal': '≌', 'amalg': '⨿', 'angle': '∠', 'approxeq': '≊', 'approxnotequal': '≆',
    'aquarius': '♒', 'aries': '♈', 'arrowwaveleft': '↜', 'arrowwaveright': '↝',
    'backepsilon': '϶', 'backprime': '‵', 'backsim': '∽', 'backsimeq': '⋍',
    'barwedge': '⌅', 'between': '≬', 'bigcirc': '◯', 'bigtriangledown': '▽',
    'bigtriangleup': '△', 'blacklozenge': '⧫', 'blacksquare': '▪', 'blacktriangle': '▴',
    'blacktriangledown': '▾', 'blacktriangleleft': '◂', 'blacktriangleright': '▸',
    'bowtie': '⋈', 'boxdot': '⊡', 'boxminus': '⊟', 'boxplus': '⊞', 'boxtimes': '⊠',
    'bumpeq': '≏', 'cancer': '♋', 'capricornus': '♑', 'circeq': '≗', 'circlearrowleft': '↺',
    'circlearrowright': '↻', 'circledS': 'Ⓢ', 'circledast': '⊛', 'circledcirc': '⊚',
    'circleddash': '⊝', 'clockoint': '⨏', 'clwintegral': '∱', 'complement': '∁',
    'curlyeqprec': '⋞', 'curlyeqsucc': '⋟', 'curlyvee': '⋎', 'curlywedge': '⋏',
    'curvearrowleft': '↶', 'curvearrowright': '↷', 'daleth': 'ℸ', 'dblarrowupdown': '⇅',
    'ddddot': '⃜', 'dh': 'ð', 'diagup': '╱', 'diamond': '♢', 'divideontimes': '⋇',
    'dj': 'đ', 'doteqdot': '≑', 'dotplus': '∔', 'dotsb': '…', 'dotsc': '…',
    'dotsi': '…', 'dotsm': '…', 'dotso': '…', 'downdownarrows': '⇊', 'downharpoonleft': '⇃',
    'downharpoonright': '⇂', 'downslopeellipsis': '⋱', 'eighthnote': '♪',
    'enskip': ' ', 'eqcirc': '≖', 'eqslantgtr': '⪖', 'eqslantless': '⪕', 'estimates': '≙',
    'eth': 'ƪ', 'fallingdotseq': '≒', 'forcesextra': '⊨', 'frown': '⌢', 'gemini': '♊',
    'geqq': '≧', 'geqslant': '⩾', 'gimel': 'ℷ', 'gnapprox': '⪊', 'gneq': '⪈',
    'gneqq': '≩', 'gnsim': '⋧', 'greaterequivlnt': '≳', 'gtrapprox': '⪆',
    'gtrdot': '⋗', 'gtreqless': '⋛', 'gtreqqless': '⪌', 'gtrless': '≷', 'gtrsim': '≳',
    'guillemotleft': '«', 'guillemotright': '»', 'guilsinglleft': '‹', 'guilsinglright': '›',
    'hermitconjmatrix': '⊹', 'homothetic': '∻', 'i': 'ı', 'id': '𝕀', 'iddots': '⋰',
    'ij': 'ij', 'image': '⊷', 'intercal': '⊺', 'j': 'ȷ', 'jupiter': '♃', 'l': 'ł',
    'lazysinv': '∾', 'leftarrowtail': '↢', 'leftharpoondown': '↽', 'leftharpoonup': '↼',
    'leftleftarrows': '⇇', 'leftrightarrows': '⇆', 'leftrightharpoons': '⇋',
    'leftrightsquigarrow': '↭', 'leftthreetimes': '⋋', 'leo': '♌', 'leqq': '≦',
    'leqslant': '⩽', 'lessapprox': '⪅', 'lessdot': '⋖', 'lesseqgtr': '⋚',
    'lesseqqgtr': '⪋', 'lessequivlnt': '≲', 'lessgtr': '≶', 'lesssim': '≲',
    'libra': '♎', 'llcorner': '⌞', 'lmoustache': '⎰', 'lnapprox': '⪉', 'lneq': '⪇',
    'lneqq': '≨', 'lnsim': '⋦', 'longleftrightarrow': '⟷', 'longmapsto': '⟼',
    'looparrowleft': '↫', 'looparrowright': '↬', 'lozenge': '◊', 'lrcorner': '⌟',
    'ltimes': '⋉', 'male': '♂', 'measuredangle': '∡', 'mercury': '☿', 'mho': '℧',
    'multimap': '⊸', 'nLeftarrow': '⇍', 'nLeftrightarrow': '⇎', 'nRightarrow': '⇏',
    'nVDash': '⊯', 'nVdash': '⊮', 'neptune': '♆', 'ng': 'ŋ', 'ngeq': '≱',
    'ngtr': '≯', 'nleftarrow': '↚', 'nleftrightarrow': '↮', 'nleq': '≰', 'nless': '≮',
    'nmid': '∤', 'nolinebreak': '⁠', 'notgreaterless': '≹', 'notlessgreater': '≸',
    'nparallel': '∦', 'nprec': '⊀', 'nrightarrow': '↛', 'nsubseteq': '⊈',
    'nsucc': '⊁', 'nsupseteq': '⊉', 'ntriangleleft': '⋪', 'ntrianglelefteq': '⋬',
    'ntriangleright': '⋫', 'ntrianglerighteq': '⋭', 'nvDash': '⊭', 'nvdash': '⊬',
    'o': 'ø', 'oe': 'œ', 'openbracketleft': '〚', 'openbracketright': '〛',
    'original': '⊶', 'perspcorrespond': '⩞', 'pisces': '♓', 'pitchfork': '⋔',
    'pluto': '♇', 'prec': '≺', 'precapprox': '⪷', 'preccurlyeq': '≼', 'precedesnotsimilar': '⋨',
    'preceq': '≼', 'precnapprox': '⪹', 'precneqq': '⪵', 'precsim': '≾', 'quarternote': '♩',
    'quotedblbase': '„', 'quotesinglbase': '‚', 'recorder': '⌕', 'rightangle': '∟',
    'rightanglearc': '⊾', 'rightarrowtail': '↣', 'rightharpoondown': '⇁',
    'rightharpoonup': '⇀', 'rightleftarrows': '⇄', 'rightmoon': '☾', 'rightrightarrows': '⇉',
    'rightsquigarrow': '⇝', 'rightthreetimes': '⋌', 'risingdotseq': '≓', 'rmoustache': '⎱',
    'rtimes': '⋊', 'sagittarius': '♐', 'saturn': '♄', 'scorpio': '♏', 'smallsetminus': '∖',
    'smile': '⌣', 'sphericalangle': '∢', 'sqrint': '⨖', 'sqsubset': '⊏', 'sqsubseteq': '⊑',
    'sqsupset': '⊐', 'sqsupseteq': '⊒', 'square': '□', 'ss': 'ß', 'starequal': '≛',
    'subseteqq': '⫅', 'subsetneq': '⊊', 'subsetneqq': '⫋', 'succ': '≻', 'succapprox': '⪸',
    'succcurlyeq': '≽', 'succeq': '≽', 'succnapprox': '⪺', 'succneqq': '⪶',
    'succnsim': '⋩', 'succsim': '≿', 'supseteqq': '⫆', 'supsetneq': '⊋', 'supsetneqq': '⫌',
    'surfintegral': '∯', 'taurus': '♉', 'th': 'þ', 'tildetrpl': '≋', 'triangledown': '▿',
    'triangleleft': '◃', 'trianglelefteq': '⊴', 'triangleq': '≜', 'triangleright': '▹',
    'trianglerighteq': '⊵', 'truestate': '⊧', 'twoheadleftarrow': '↞', 'twoheadrightarrow': '↠',
    'udots': '⋰', 'ulcorner': '⌜', 'upharpoonleft': '↿', 'upharpoonright': '↾',
    'upslopeellipsis': '⋰', 'upuparrows': '⇈', 'uranus': '♅', 'urcorner': '⌝',
    'varkappa': 'ϰ', 'varperspcorrespond': '⌆', 'vartriangle': '▵', 'vartriangleleft': '⊲',
    'vartriangleright': '⊳', 'veebar': '⊻', 'venus': '♀', 'verymuchgreater': '⋙',
    'verymuchless': '⋘', 'virgo': '♍', 'volintegral': '∰', 'wr': '≀',
}

SYMBOLS = {**EXTRA_SYMBOLS, **SYMBOLS}


# escaped single characters:  \, \; \% \_ ...
ESCAPES = {
    ',': ' ', ';': ' ', ':': ' ', '!': '', ' ': ' ',
    '%': '%', '&': '&', '#': '#', '$': '$', '_': '_', '{': '{', '}': '}',
    '\\': '\n', '-': '­',
}

# ------------------------------------------------------------------- accents
ACCENTS = {
    'hat': '̂', 'widehat': '̂', 'bar': '̄',
    'overline': '̄', 'vec': '⃗', 'tilde': '̃',
    'widetilde': '̃', 'dot': '̇', 'ddot': '̈',
    'dddot': '⃛', 'check': '̌', 'acute': '́',
    'grave': '̀', 'breve': '̆', 'mathring': '̊',
    'underline': '̲', 'underbar': '̲', 'not': '̸',
    'cancel': '̸', 'slashed': '̸',
}

# Text-mode accents: LaTeX's \'e, \"o, \c{c} … Same mechanism as the math
# accents above — emit a combining mark after the base letter and let the NFC
# pass in convert() fuse the pair into é, ö, ç where a precomposed glyph exists
# (and leave it as base+mark, still correct, where none does).
#
# The single-letter names below (\c \v \r \d \b \t \u \k \H) are checked before
# SYMBOLS, so they would shadow a same-named symbol; none exists. \i and \j stay
# symbols (dotless ı ȷ), which is right — they are letters, not accents.
TEXT_ACCENTS = {
    "'": '́',   # \'e    -> é    acute
    '`': '̀',   # \`e    -> è    grave
    '^': '̂',   # \^a    -> â    circumflex
    '"': '̈',   # \"o    -> ö    diaeresis
    '~': '̃',   # \~n    -> ñ    tilde
    '=': '̄',   # \=a    -> ā    macron
    '.': '̇',   # \.z    -> ż    dot above
    'u': '̆',   # \u g   -> ğ    breve
    'v': '̌',   # \v s   -> š    caron / háček
    'H': '̋',   # \H o   -> ő    double acute
    'c': '̧',   # \c{c}  -> ç    cedilla
    'k': '̨',   # \k{a}  -> ą    ogonek
    'r': '̊',   # \r a   -> å    ring above
    'd': '̣',   # \d{s}  -> ṣ    dot below
    'b': '̱',   # \b{k}  -> ḵ    macron below
    't': '͡',   # \t{oo} -> o͡o   tie (sits between the two letters)
}

ACCENTS = {**ACCENTS, **TEXT_ACCENTS}

# --------------------------------------------------------------- math alphabets
def _alphabet(up=None, lo=None, dig=None, gup=None, glo=None, exc=None):
    m = {}
    if up:
        for k in range(26):
            m[chr(ord('A') + k)] = chr(up + k)
    if lo:
        for k in range(26):
            m[chr(ord('a') + k)] = chr(lo + k)
    if dig:
        for k in range(10):
            m[chr(ord('0') + k)] = chr(dig + k)
    if gup:
        for k, c in enumerate('ΑΒΓΔΕΖΗΘΙΚΛΜΝΞΟΠΘΡΣΤΥΦΧΨΩ'):
            m[c] = chr(gup + k)
    if glo:
        for k, c in enumerate('αβγδεζηθικλμνξοπρςστυφχψω'):
            m[c] = chr(glo + k)
    if exc:
        m.update(exc)
    return m


ALPHABETS = {
    'mathbb': _alphabet(0x1D538, 0x1D552, 0x1D7D8, exc={
        'C': 'ℂ', 'H': 'ℍ', 'N': 'ℕ', 'P': 'ℙ', 'Q': 'ℚ', 'R': 'ℝ', 'Z': 'ℤ'}),
    'mathcal': _alphabet(0x1D49C, 0x1D4B6, exc={
        'B': 'ℬ', 'E': 'ℰ', 'F': 'ℱ', 'H': 'ℋ', 'I': 'ℐ', 'L': 'ℒ',
        'M': 'ℳ', 'R': 'ℛ', 'e': 'ℯ', 'g': 'ℊ', 'o': 'ℴ'}),
    'mathfrak': _alphabet(0x1D504, 0x1D51E, exc={
        'C': 'ℭ', 'H': 'ℌ', 'I': 'ℑ', 'R': 'ℜ', 'Z': 'ℨ'}),
    'mathbf': _alphabet(0x1D400, 0x1D41A, 0x1D7CE, 0x1D6A8, 0x1D6C2),
    'mathit': _alphabet(0x1D434, 0x1D44E, None, 0x1D6E2, 0x1D6FC,
                        exc={'h': 'ℎ'}),
    'mathsf': _alphabet(0x1D5A0, 0x1D5BA, 0x1D7E2),
    'mathtt': _alphabet(0x1D670, 0x1D68A, 0x1D7F6),
}
ALPHABETS['mathscr'] = ALPHABETS['mathcal']
ALPHABETS['bm'] = ALPHABETS['mathbf']
ALPHABETS['boldsymbol'] = ALPHABETS['mathbf']

VULGAR = {
    ('1', '2'): '½', ('1', '3'): '⅓', ('2', '3'): '⅔', ('1', '4'): '¼',
    ('3', '4'): '¾', ('1', '5'): '⅕', ('2', '5'): '⅖', ('3', '5'): '⅗',
    ('4', '5'): '⅘', ('1', '6'): '⅙', ('5', '6'): '⅚', ('1', '7'): '⅐',
    ('1', '8'): '⅛', ('3', '8'): '⅜', ('5', '8'): '⅝', ('7', '8'): '⅞',
    ('1', '9'): '⅑', ('1', '10'): '⅒',
}

# commands that are just dropped, keeping their argument (or nothing)
TRANSPARENT = {'text', 'textrm', 'mathrm', 'mbox', 'textnormal', 'operatorname',
               'textit', 'textbf', 'mathnormal', 'displaystyle', 'textstyle',
               'limits', 'nolimits', 'left', 'right', 'big', 'Big', 'bigg',
               'Bigg', 'bigl', 'bigr', 'Bigl', 'Bigr', 'ensuremath'}

# Dirac notation:  command -> (opening, separators between args, closing).
# The argument count is len(separators) + 1, so adding e.g.
# 'ketbra': ('|', ['⟩⟨'], '|') is a one-liner.
DIRAC = {
    'ket': ('|', [], '⟩'),           # \ket{\psi}      -> |ψ⟩
    'bra': ('⟨', [], '|'),           # \bra{\psi}      -> ⟨ψ|
    'braket': ('⟨', ['|'], '⟩'),     # \braket{a}{b}   -> ⟨a|b⟩
}

FUNCTIONS = {'sin', 'cos', 'tan', 'cot', 'sec', 'csc', 'arcsin', 'arccos',
             'arctan', 'sinh', 'cosh', 'tanh', 'coth', 'exp', 'log', 'ln',
             'lg', 'det', 'dim', 'ker', 'deg', 'gcd', 'hom', 'inf', 'sup',
             'lim', 'limsup', 'liminf', 'max', 'min', 'arg', 'Tr', 'tr'}


class _Parser:
    def __init__(self, s):
        self.s = s
        self.i = 0

    # -- low-level ---------------------------------------------------------
    def peek(self):
        return self.s[self.i] if self.i < len(self.s) else ''

    def read_command(self):
        """At self.s[self.i] == '\\'; return the command name or escape char."""
        self.i += 1
        if self.i >= len(self.s):
            return ''
        c = self.s[self.i]
        if not c.isalpha():
            self.i += 1
            return c
        j = self.i
        while j < len(self.s) and self.s[j].isalpha():
            j += 1
        name = self.s[self.i:j]
        self.i = j
        # NB: unlike TeX we do *not* swallow the following space. Output here is
        # prose, so spacing should be exactly what the user typed: `\hbar\omega`
        # -> ℏω and `x \to \infty` -> x → ∞ (TeX's rule would give "x →∞").
        return name

    def read_group(self):
        """Read one argument: a {...} group, a \\command, or a single char."""
        while self.peek() == ' ':
            self.i += 1
        c = self.peek()
        if c == '{':
            self.i += 1
            out = self.parse(stop='}')
            if self.peek() == '}':
                self.i += 1
            return out
        if c == '\\':
            return self.command()
        if c == '':
            return ''
        self.i += 1
        return c

    # -- commands ----------------------------------------------------------
    def command(self):
        name = self.read_command()
        if name in ESCAPES and len(name) == 1:
            return ESCAPES[name]
        if name in ACCENTS:
            base = self.read_group()
            if not base:
                return ACCENTS[name]
            return base[0] + ACCENTS[name] + base[1:]
        if name in ALPHABETS:
            arg = self.read_group()
            table = ALPHABETS[name]
            return ''.join(table.get(ch, ch) for ch in arg)
        if name == 'frac' or name == 'dfrac' or name == 'tfrac':
            num, den = self.read_group(), self.read_group()
            if (num, den) in VULGAR:
                return VULGAR[(num, den)]
            up = _raise(num, SUP)
            dn = _raise(den, SUB)
            if up and dn:
                return up + '⁄' + dn
            return num + '/' + den
        if name == 'sqrt':
            arg = self.read_group()
            if arg and len(arg) == 1:
                return '√' + arg
            return '√(' + arg + ')' if arg else '√'
        if name in DIRAC:
            opening, seps, closing = DIRAC[name]
            args = [self.read_group() for _ in range(len(seps) + 1)]
            body = ''.join(a + s for a, s in zip(args, seps + ['']))
            return opening + body + closing
        if name in TRANSPARENT:
            return self.read_group() if self.peek() == '{' else ''
        if name in FUNCTIONS:
            return name
        if name in SYMBOLS:
            return SYMBOLS[name]
        # unknown: keep it visible so the user can see what failed
        return '\\' + name

    # -- main loop ---------------------------------------------------------
    def parse(self, stop=None):
        out = []
        while self.i < len(self.s):
            c = self.s[self.i]
            if stop and c == stop:
                break
            if c == '\\':
                out.append(self.command())
            elif c == '{':
                self.i += 1
                out.append(self.parse(stop='}'))
                if self.peek() == '}':
                    self.i += 1
            elif c == '}':
                self.i += 1                      # stray brace: drop
            elif c in '^_':
                self.i += 1
                table = SUP if c == '^' else SUB
                arg = self.read_group()
                shifted = _raise(arg, table)
                out.append(shifted if shifted else c + arg)
            elif c == '-' and self.s.startswith('---', self.i):
                out.append('—')
                self.i += 3
            elif c == '-' and self.s.startswith('--', self.i):
                out.append('–')
                self.i += 2
            elif c == '`' and self.s.startswith('``', self.i):
                out.append('“')
                self.i += 2
            elif c == "'" and self.s.startswith("''", self.i):
                out.append('”')
                self.i += 2
            elif c == '~':
                out.append(' ')
                self.i += 1
            elif c == '$':
                self.i += 1                      # math delimiters: drop
            else:
                out.append(c)
                self.i += 1
        return ''.join(out)


def _raise(text, table):
    """Map every char through table; return '' if any char has no raised form."""
    if not text:
        return ''
    out = []
    for ch in text:
        if ch not in table:
            return ''
        out.append(table[ch])
    return ''.join(out)


def convert(s):
    """Transliterate a TeX fragment into Unicode.

    The NFC pass composes a base character and a following combining mark into
    the single precomposed glyph where Unicode has one, which is what turns
    `\\not\\in` into ∉ (44 negations compose this way) and `\\hat{a}` into a
    one-codepoint â. It must be NFC and never NFKC: NFKC would "simplify"
    g⁽²⁾ back into g^(2) and undo the entire point of the tool.
    """
    return unicodedata.normalize('NFC', _Parser(s).parse())


if __name__ == '__main__':
    src = ' '.join(sys.argv[1:]) if len(sys.argv) > 1 else sys.stdin.read()
    sys.stdout.write(convert(src.rstrip('\n')))
    if sys.stdout.isatty():
        sys.stdout.write('\n')

~/bin/xpaste.py — the safe paste keystroke

#!/usr/bin/env python3
"""Send a paste keystroke to the focused X11 window, safely.

Which keystroke that is depends on the target: Ctrl+V almost everywhere, but
Ctrl+Y in Emacs and Ctrl+Shift+V in terminals. The caller decides (see
`paste_combo` in texin.py); this module just delivers it.

The rule on this machine (layouts fr,es,ru) is: NEVER let anything rewrite the
keymap. `xdotool key`/`type` does exactly that for any keysym missing from the
active layout, which flips the layout and has left modifiers stuck down.

So this module:
  * uses XTEST with **explicit hardware keycodes** and never calls
    XChangeKeyboardMapping — the keymap is only ever read, never written;
  * looks the keycode up across all layout groups, falling back to the standard
    evdev positions (37 = Control_L, 55 = v, …) if the lookup fails;
  * refuses to fire while any modifier is physically held, so it can't stack a
    modifier onto its own synthetic press;
  * releases in a `finally`, so a press can never be left dangling.
"""

import ctypes
import ctypes.util
import time

__version__ = '1.5'

# keysym + standard evdev keycode fallback, for every key a paste combo may use
KEYS = {
    'ctrl': (0xFFE3, 37),     # Control_L
    'shift': (0xFFE1, 50),    # Shift_L
    'alt': (0xFFE9, 64),      # Alt_L
    'v': (0x076, 55),
    'y': (0x079, 29),
    'insert': (0xFF63, 118),
}

SHIFT_MASK = 1 << 0
CONTROL_MASK = 1 << 2
MOD1_MASK = 1 << 3            # Alt
MOD4_MASK = 1 << 6            # Super
MOD5_MASK = 1 << 7            # AltGr / ISO_Level3_Shift
HELD = SHIFT_MASK | CONTROL_MASK | MOD1_MASK | MOD4_MASK | MOD5_MASK


class _X:
    def __init__(self):
        self.x11 = ctypes.CDLL(ctypes.util.find_library('X11'))
        self.xtst = ctypes.CDLL(ctypes.util.find_library('Xtst'))

        self.x11.XOpenDisplay.restype = ctypes.c_void_p
        self.x11.XOpenDisplay.argtypes = [ctypes.c_char_p]
        self.dpy = self.x11.XOpenDisplay(None)
        if not self.dpy:
            raise RuntimeError('cannot open X display')

        self.x11.XKeysymToKeycode.restype = ctypes.c_ubyte
        self.x11.XKeysymToKeycode.argtypes = [ctypes.c_void_p, ctypes.c_ulong]
        self.x11.XDefaultRootWindow.restype = ctypes.c_ulong
        self.x11.XDefaultRootWindow.argtypes = [ctypes.c_void_p]
        self.x11.XQueryPointer.argtypes = [
            ctypes.c_void_p, ctypes.c_ulong,
            ctypes.POINTER(ctypes.c_ulong), ctypes.POINTER(ctypes.c_ulong),
            ctypes.POINTER(ctypes.c_int), ctypes.POINTER(ctypes.c_int),
            ctypes.POINTER(ctypes.c_int), ctypes.POINTER(ctypes.c_int),
            ctypes.POINTER(ctypes.c_uint)]
        self.x11.XFlush.argtypes = [ctypes.c_void_p]
        self.x11.XSync.argtypes = [ctypes.c_void_p, ctypes.c_int]
        self.x11.XCloseDisplay.argtypes = [ctypes.c_void_p]
        self.xtst.XTestFakeKeyEvent.argtypes = [
            ctypes.c_void_p, ctypes.c_uint, ctypes.c_int, ctypes.c_ulong]

    def keycode(self, keysym, fallback):
        # read-only lookup; searches every layout group, so `v` is still found
        # while the Russian group is active
        kc = self.x11.XKeysymToKeycode(self.dpy, keysym)
        return kc if kc else fallback

    def modifier_state(self):
        root = self.x11.XDefaultRootWindow(self.dpy)
        r, c = ctypes.c_ulong(), ctypes.c_ulong()
        rx, ry, wx, wy = (ctypes.c_int() for _ in range(4))
        mask = ctypes.c_uint()
        self.x11.XQueryPointer(self.dpy, root, ctypes.byref(r), ctypes.byref(c),
                               ctypes.byref(rx), ctypes.byref(ry),
                               ctypes.byref(wx), ctypes.byref(wy),
                               ctypes.byref(mask))
        return mask.value

    def wait_for_clear_modifiers(self, timeout=0.6):
        """Don't type into a held modifier — that is how things got stuck."""
        deadline = time.monotonic() + timeout
        while time.monotonic() < deadline:
            if not self.modifier_state() & HELD:
                return True
            time.sleep(0.02)
        return not self.modifier_state() & HELD

    def key(self, keycode, press):
        self.xtst.XTestFakeKeyEvent(self.dpy, keycode, 1 if press else 0, 0)

    def close(self):
        self.x11.XCloseDisplay(self.dpy)


def paste(combo='ctrl+v'):
    """Synthesise a paste keystroke: 'ctrl+v', 'ctrl+y', 'ctrl+shift+v', …

    The last element is the key, everything before it a modifier held down
    around it. Returns (ok, message).
    """
    parts = [p.strip().lower() for p in combo.split('+') if p.strip()]
    unknown = [p for p in parts if p not in KEYS]
    if not parts or unknown:
        return False, 'bad paste combo %r (unknown: %s)' % (
            combo, ', '.join(unknown) or 'empty')
    mods, final = parts[:-1], parts[-1]

    try:
        x = _X()
    except Exception as exc:
        return False, f'X11 unavailable: {exc}'

    try:
        if not x.wait_for_clear_modifiers():
            return False, 'a modifier is still held — not injecting'

        codes = [x.keycode(*KEYS[p]) for p in mods]
        key = x.keycode(*KEYS[final])
        held = []
        try:
            for c in codes:
                x.key(c, True)
                held.append(c)
            x.key(key, True)
            x.key(key, False)
        finally:
            # lift every modifier we pressed, in reverse, whatever happened
            for c in reversed(held):
                x.key(c, False)
            x.x11.XSync(x.dpy, 0)
        return True, 'pasted with ' + '+'.join(parts)
    finally:
        x.close()


if __name__ == '__main__':
    import sys
    print(paste(sys.argv[1] if len(sys.argv) > 1 else 'ctrl+v')[1])

~/bin/texin.py — the popup

#!/usr/bin/env python3
"""Popup that turns a TeX fragment into Unicode and puts it on the clipboard.

Bound to a global shortcut (Ctrl+Alt+Shift+U). Type TeX, watch the live
preview, press Enter -> the Unicode is pasted straight into the window you came
from (and left on the clipboard too).

    g^{(2)}(\\tau)  ->  g⁽²⁾(τ)

Esc cancels. Up/Down walks the history. Shift+Enter copies without pasting;
TEX2UNI_PASTE=0 makes copy-only the default.
"""

import os
import re
import subprocess
import sys

sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))

# PyQt5 is the fully-installed binding on this box (the PySide6 package here
# ships QtCore only).
from PyQt5.QtCore import Qt, QTimer
from PyQt5.QtGui import QCursor, QFont, QGuiApplication, QKeySequence
from PyQt5.QtWidgets import (QApplication, QFrame, QLabel, QLineEdit,
                             QShortcut, QVBoxLayout)

from tex2uni import convert, __version__
from xpaste import paste

HISTORY = os.path.expanduser('~/.local/share/tex2uni/history')
HISTORY_MAX = 200

# Enter pastes straight into the window you came from. Set TEX2UNI_PASTE=0 to
# fall back to copy-only; Shift+Enter does that for one shot.
AUTOPASTE = os.environ.get('TEX2UNI_PASTE', '1') != '0'
PASTE_DELAY_MS = 130          # let KWin actually hand focus back first


def notify(title, body):
    subprocess.Popen(['notify-send', '-a', 'tex2uni', '-t', '2500',
                      '-i', 'accessories-character-map', title, body],
                     stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)


def active_window():
    """Toplevel id of the window that was focused before we popped up."""
    try:
        out = subprocess.check_output(['xprop', '-root', '_NET_ACTIVE_WINDOW'],
                                      text=True)
        wid = out.rsplit('#', 1)[1].strip()
        return wid if int(wid, 16) else None
    except Exception:
        return None


FOOTER = "\u21b5 paste \u00b7 \u21e7\u21b5 copy \u00b7 \u2191\u2193 history          laussy.org/TeX2unicode"

# Which keystroke actually pastes, per application. Ctrl+V is wrong in more
# places than you would expect: Emacs binds it to scroll-up (yank is Ctrl+Y),
# and terminals reserve it for quoted-insert. Keyed on WM_CLASS, lowercased;
# anything not listed gets Ctrl+V, which is right for the large majority.
PASTE_KEYS = {
    'emacs': 'ctrl+y',
    'konsole': 'ctrl+shift+v',
    'yakuake': 'ctrl+shift+v',
    'gnome-terminal': 'ctrl+shift+v',
    'gnome-terminal-server': 'ctrl+shift+v',
    'xfce4-terminal': 'ctrl+shift+v',
    'terminator': 'ctrl+shift+v',
    'tilix': 'ctrl+shift+v',
    'alacritty': 'ctrl+shift+v',
    'kitty': 'ctrl+shift+v',
    'st': 'ctrl+shift+v',
    'xterm': 'shift+insert',
    'urxvt': 'shift+insert',
    'rxvt': 'shift+insert',
}
DEFAULT_PASTE = 'ctrl+v'
PASTE_CONF = os.path.expanduser('~/.config/tex2uni/pastekeys.conf')


def load_paste_keys():
    """PASTE_KEYS with user overrides layered on top.

    The config is `wmclass = combo` per line, # comments allowed \u2014 so a binding
    that depends on personal configuration (cua-mode makes Ctrl+V correct in
    Emacs again; evil-mode makes Ctrl+Y wrong) can be fixed without editing
    this file.
    """
    table = dict(PASTE_KEYS)
    try:
        with open(PASTE_CONF, encoding='utf-8') as fh:
            for line in fh:
                line = line.split('#', 1)[0].strip()
                if '=' in line:
                    cls, combo = line.split('=', 1)
                    table[cls.strip().lower()] = combo.strip().lower()
    except OSError:
        pass
    return table


def paste_combo(winid):
    """Pick the paste keystroke for the window we are about to paste into."""
    if not winid:
        return DEFAULT_PASTE
    try:
        out = subprocess.check_output(['xprop', '-id', winid, 'WM_CLASS'],
                                      text=True, stderr=subprocess.DEVNULL)
    except Exception:
        return DEFAULT_PASTE
    table = load_paste_keys()
    # WM_CLASS is `"instance", "Class"`; try both, instance first
    for name in re.findall(r'"([^"]*)"', out):
        if name.lower() in table:
            return table[name.lower()]
    return DEFAULT_PASTE


def load_history():
    try:
        with open(HISTORY, encoding='utf-8') as fh:
            return [ln.rstrip('\n') for ln in fh if ln.strip()]
    except OSError:
        return []


def save_history(entry):
    hist = [h for h in load_history() if h != entry]
    hist.append(entry)
    hist = hist[-HISTORY_MAX:]
    os.makedirs(os.path.dirname(HISTORY), exist_ok=True)
    with open(HISTORY, 'w', encoding='utf-8') as fh:
        fh.write('\n'.join(hist) + '\n')


def to_clipboard(text):
    """Hand the text to xclip, which keeps owning the selection after we exit.

    The text always lands here even when we auto-paste, so a paste that misses
    (wrong window refocused, app ignores Ctrl+V) still leaves it recoverable.
    """
    data = text.encode('utf-8')
    for sel in ('clipboard', 'primary'):
        p = subprocess.Popen(['xclip', '-selection', sel],
                             stdin=subprocess.PIPE,
                             stdout=subprocess.DEVNULL,
                             stderr=subprocess.DEVNULL)
        p.stdin.write(data)
        p.stdin.close()


class TexInput(QFrame):
    def __init__(self):
        super().__init__(None, Qt.Dialog)
        self.setWindowTitle(f"F.P. Laussy's TeX → Unicode ({__version__})")
        self.caller = active_window()      # record before we steal focus
        self.history = load_history()
        self.hpos = len(self.history)
        self.stash = ''

        lay = QVBoxLayout(self)
        lay.setContentsMargins(14, 12, 14, 12)
        lay.setSpacing(8)

        self.edit = QLineEdit()
        self.edit.setPlaceholderText(r'TeX, e.g.  g^{(2)}(\tau)')
        f = QFont('monospace')
        f.setPointSize(13)
        self.edit.setFont(f)
        self.edit.textChanged.connect(self.refresh)
        lay.addWidget(self.edit)

        self.preview = QLabel('')
        pf = QFont()
        pf.setPointSize(20)
        self.preview.setFont(pf)
        self.preview.setTextInteractionFlags(Qt.TextSelectableByMouse)
        self.preview.setMinimumHeight(38)
        lay.addWidget(self.preview)

        self.hint = QLabel(FOOTER)
        hf = QFont('monospace')
        hf.setPointSize(8)
        self.hint.setFont(hf)
        self.hint.setStyleSheet('color: palette(mid);')
        lay.addWidget(self.hint)

        QShortcut(QKeySequence('Escape'), self, self.close)
        self.edit.returnPressed.connect(self.on_return)
        self.resize(560, 0)

    def on_return(self):
        # Shift+Enter = copy to clipboard but don't paste
        shift = QApplication.keyboardModifiers() & Qt.ShiftModifier
        self.commit(autopaste=not shift)

    # ------------------------------------------------------------------
    def refresh(self, text):
        out = convert(text)
        self.preview.setText(out)
        # the footer normally carries the doc URL, but yields to a warning
        # about any command we did not recognise
        if '\\' in out:
            bad = [w for w in out.split() if w.startswith('\\')]
            self.hint.setText('unknown: ' + ' '.join(bad) if bad else FOOTER)
        else:
            self.hint.setText(FOOTER)

    def commit(self, autopaste=True):
        src = self.edit.text().strip()
        if not src:
            self.close()
            return
        out = convert(src)
        to_clipboard(out)
        save_history(src)

        if not (autopaste and AUTOPASTE):
            notify('Copied — paste with Ctrl+V', out)
            self.close()
            return

        # Get out of the way, hand focus back to where the user was typing,
        # and only then synthesise the Ctrl+V.
        self.hide()
        QApplication.processEvents()
        if self.caller:
            subprocess.call(['wmctrl', '-i', '-a', self.caller])
        QTimer.singleShot(PASTE_DELAY_MS, lambda: self.finish_paste(out))

    def finish_paste(self, out):
        ok, msg = paste(paste_combo(self.caller))
        if not ok:
            notify('Copied — paste with Ctrl+V', f'{out}\n({msg})')
        QApplication.quit()

    def keyPressEvent(self, ev):
        if ev.key() in (Qt.Key_Up, Qt.Key_Down) and self.history:
            if self.hpos == len(self.history):
                self.stash = self.edit.text()
            step = -1 if ev.key() == Qt.Key_Up else 1
            self.hpos = max(0, min(len(self.history), self.hpos + step))
            self.edit.setText(self.history[self.hpos]
                              if self.hpos < len(self.history) else self.stash)
            return
        super().keyPressEvent(ev)

    def center_on_cursor(self):
        pos = QCursor.pos()
        screen = QGuiApplication.screenAt(pos) or QGuiApplication.primaryScreen()
        g = screen.availableGeometry()
        self.move(g.center().x() - self.width() // 2,
                  g.top() + g.height() // 3)


def main():
    app = QApplication(sys.argv)
    app.setApplicationName('tex2uni')

    # non-interactive use:  texin.py 'g^{(2)}(\tau)'  -> straight to clipboard
    if len(sys.argv) > 1:
        out = convert(' '.join(sys.argv[1:]))
        to_clipboard(out)
        print(out)
        return 0

    w = TexInput()
    w.show()
    w.center_on_cursor()
    w.raise_()
    w.activateWindow()
    # the window arrives after the shortcut's key release; ask for focus again
    QTimer.singleShot(80, w.edit.setFocus)
    return app.exec()


if __name__ == '__main__':
    sys.exit(main())

The global shortcut

Plasma 6 removed khotkeys; a custom command shortcut is now a hidden .desktop file carrying X-KDE-GlobalAccel-CommandShortcut=true, plus an entry in kglobalshortcutsrc. The D-Bus action name is always _launch.

cat > ~/.local/share/applications/net.local.tex2uni.desktop <<'EOF'
[Desktop Entry]
Exec=/home/YOURNAME/bin/texin.py
Name=TeX to Unicode
Comment=Type a TeX fragment, get Unicode on the clipboard
NoDisplay=true
StartupNotify=false
Type=Application
X-KDE-GlobalAccel-CommandShortcut=true
EOF

kwriteconfig6 --file kglobalshortcutsrc \
  --group "services" --group "net.local.tex2uni.desktop" \
  --key "_launch" "Ctrl+Alt+Shift+U"

Exec must be an absolute path — ~ is not expanded there.

The shortcut daemon only reads that file at startup, so it has to be restarted before the binding exists:

kquitapp6 kglobalaccel
sleep 2
pgrep -x kglobalacceld || setsid /usr/lib/x86_64-linux-gnu/libexec/kglobalacceld &

On some systems kquitapp6 does not respawn the daemon, hence the explicit relaunch. Once registered the binding survives logins. To check it took:

qdbus6 org.kde.kglobalaccel /component/net_local_tex2uni_desktop \
       org.kde.kglobalaccel.Component.shortcutNames        # -> _launch

And to fire it without touching the keyboard, which is the useful way to test:

qdbus6 org.kde.kglobalaccel /component/net_local_tex2uni_desktop \
       org.kde.kglobalaccel.Component.invokeShortcut _launch

Limitations

  • X11 only. The paste path is XTEST, and window refocusing uses wmctrl. On Wayland neither is available to an ordinary client; that would need ydotool (via uinput) or a portal, and the refocusing trick does not exist at all.
  • No two-dimensional layout. Unicode is a line of text, so there are no built-up fractions, no real radical signs over an expression, no matrices.

\sqrt{2} gives √2 and \sqrt{a+b} gives √(a+b), which is honest rather than pretty.

  • Incomplete raised/lowered repertoire, as described above.
  • If the active layout is Russian, the synthetic Ctrl+V is delivered at the physical v position, which the layout maps to Cyrillic м. Most Qt and GTK applications accept that as Ctrl+V anyway through their Latin fallback, but an application that binds strictly by keysym may not. The text is on the clipboard regardless.
  • The popup steals focus for as long as it is open, so it is not usable to convert text into a window that is itself modal.

Version history

  • 1.0 — first release. Converter, live-preview popup, history, direct paste via keycode-only XTEST, global shortcut on Ctrl+Alt+Shift+U.
  • 1.1 Dirac notation: \ket, \bra, \braket, held in a small table so the argument count falls out of the data rather than the code.
  • 1.2 Symbol table grown from 222 to 558 by a one-shot harvest of the pylatexenc macro table (a build-time data source, not a runtime dependency: the shipped file still imports only sys and unicodedata). Curated entries win every collision, so \hbar stays ℏ and \bigoplus stays the n-ary ⨁. convert() now ends with an NFC pass, which is what makes \not\in → ∉ fall out of composition rather than needing a table entry. It must be NFC and never NFKC: NFKC would flatten g⁽²⁾ back to g^(2) and undo the point of the tool.
  • 1.3 Text mode: the 16 accent commands and the ligatures. They reuse the math-accent mechanism and lean on the same NFC pass, so \"o composes to a single codepoint ö rather than o+diaeresis.
  • 1.5 The paste keystroke is chosen per application, from WM_CLASS. xpaste.paste() takes a combo instead of hardcoding v, and the keycode-only guarantees extend to it: every key in a combo resolves through the same keysym-with-evdev-fallback table, and the finally lifts all of them in reverse. Fixes pasting into Emacs, and into terminals, where it had been silently inserting a control character all along.