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.
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.
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) |
| 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 |
\alpha \Omega \varphi \varthetax^2, c_n, g^{(2)}, \rho_{11}\otimes \approx \rightarrow \forall \langle \hbar \dagger \partial \infty\hat \bar \vec \tilde \dot \ddot \check \acute \grave \breve \underline\mathbb \mathcal \mathfrak \mathbf \mathit \mathsf \mathtt (and \mathscr, \bm)\frac{1}{2} → ½ where a vulgar fraction exists, otherwise \frac{3}{7} → ³⁄₇\, \; \quad \qquad\text{…}, \mathrm{…}, function names (\sin, \log…), \left/\right (dropped), $ (dropped)--- → —, -- → –, `` → “, → ”\ket{\psi} → |ψ⟩, \bra{\psi} → ⟨ψ|, \braket{a}{b} → ⟨a|b⟩\"o → ö, \'e → é, \c{c} → ç, \v{s} → š, \H{o} → ő, and the ligatures \ae \oe \o \ss \aa \l, so Schr\"odinger → Schrödinger\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.
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.
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.
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:
XChangeKeyboardMapping is never called — the keymap is read, never written;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);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.
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.
Three files in ~/bin/, all executable
(chmod +x ~/bin/*.py).
~/bin/tex2uni.py — the converterA 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())
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
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.\sqrt{2} gives √2 and \sqrt{a+b} gives √(a+b),
which is honest rather than pretty.
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.Ctrl+Alt+Shift+U.\ket, \bra, \braket, held in a small table so the argument count falls out of the data rather than the code.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.\"o composes to a single codepoint ö rather than o+diaeresis.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.