LatexUnicode

CI codecov Hex.pm Hexdocs License

Render LaTeX math as Unicode text — for terminals, code comments, log output, and anywhere rich formatting is not available. Pure Elixir, no dependencies, no TeX installation, no image backend.

LatexUnicode.render("\\frac{-b \\pm \\sqrt{b^2 - 4ac}}{2a}")
#=> "(-b ± √(b² - 4ac))/(2a)"
LatexUnicode.render("\\frac{-b \\pm \\sqrt{b^2 - 4ac}}{2a}", display: true)
#=> "-b ± √(b² - 4ac)\n────────────────\n 2a"
LatexUnicode.render("\\sum_{i=0}^n x_i", display: true)
#=> " n\n ∑ xᵢ\ni=0"
LatexUnicode.render("\\begin{pmatrix}1&200\\\\3000&4\\end{pmatrix}")
#=> "⎛ 1 │ 200 ⎞\n⎝ 3000 │ 4 ⎠"
LatexUnicode.render("x + \\unknown{y}")
#=> nil # unsupported or malformed: the caller falls back to the source

render/2 returns nil instead of raising when an expression uses syntax outside the supported subset, so a renderer can fall back to the raw source text (that is what the terminal UI this was extracted from does).

Markdown integration

Math spans in markdown can be expanded before the markdown parser runs, then put back into the rendered lines. The pass is parser-agnostic: it works on source text and a list of rendered lines, so it pairs with any line-based renderer (MDEx, Earmark, plain text, a custom TUI).

alias LatexUnicode.Spans
{source, spans} = Spans.extract("the value $x^2$ and\n\n$$\\frac{1}{2}$$")
# pass `source` to your markdown parser, render it to lines, then:
lines = source |> String.split("\n") |> Spans.substitute(spans)
#=> ["the value x² and", "", "1", "─", "2"]

For markdown something else will read — a @doc string, a guide page — the math can be rendered into the text itself, with nothing to keep in step:

@doc LatexUnicode.Spans.render("""
The identity $e^{i\\pi} + 1 = 0$ holds in the complex plane.
""")

Display math arrives in a fenced block labelled text, because markdown keeps the art's indentation and its line breaks only inside one; fence: false is the way back for text no parser runs over, such as a log line. A guide page is a file rather than an expression, so mix latex_unicode.render writes the rendered copy beside it and --check fails when the two drift apart. This repository's own page is Math in docs.

What it renders

Symbols (Greek, relations, arrows, big operators, delimiters, dots), scripts (x^2, x_{i_j}, \sum_{i=0}^n with display limits), fractions, roots, binomials, \text/\mathbf/\mathbb/font switches, accents and wide decorations, \boxed, \operatorname, \overset/\underset, environments (aligned, alignedat, gather, split, cases, array, matrix, pmatrix, bmatrix, Bmatrix, vmatrix, Vmatrix), spacing commands, and display-mode layout that stacks fractions, operator limits, nested scripts, and matrices into multi-line art.

Beyond pi's subset: \left … \right and the size commands (\big … \Bigg) grow their delimiters to the body, \mathcal/\mathfrak/\mathscr take the letterlike letters (\mathbb takes the blackboard ones, as in pi), \substack stacks, \overbrace/\underbrace draw their brace with the label on the side its script asks for, and \hline rules an array or matrix.

Inline math is pi's rendering character for character — a/b for a fraction, x² for a script, e^(x₁) where Unicode has no script form — and display math is the multi-line art. e^{-x} is e⁻ˣ either way; e^{-x^2} is three lines as display math, because a script inside a script has no Unicode form to nest in.

examples/rendering.txt is all of this rendered, ready to cat in a terminal.

Unsupported commands, malformed groups, and unbalanced environments return nil.

Install

def deps do
[{:latex_unicode, "~> 0.1"}]
end

Development

mix ci matches CI's checks, not its matrix:

compile --all-warnings --warnings-as-errors
format --check-formatted
latex_unicode.render --check guides/math-in-docs.md
credo --strict # includes the ExSlop AI-slop checks
deps.unlock --check-unused
hex.audit
xref graph --label compile-connected --fail-above 5
dialyzer
ex_dna # duplicate code
reach.check --dead-code --smells
test --warnings-as-errors

CI's test job runs mix compile, mix format and mix test on Elixir 1.19/OTP 27, 1.19/OTP 28 and 1.20/OTP 29. The quality job runs mix ci, builds the docs with warnings as errors, holds coverage at 93%, and caches Dialyzer's PLT against the lockfile.

mix ci.fast is the inner loop: the list above without deps.unlock --check-unused, hex.audit, xref graph --label compile-connected --fail-above 5, dialyzer, ex_dna and reach.check --dead-code --smells.

examples/compare_with_pi.exs renders and measures the same inputs with pi's own renderLatex and visibleWidth, and reports where the two agree and where this library is meant to differ: a divergence that is not listed as one fails the run. It reads pi's source with gh and runs it with bun, so it is not part of mix ci.

Elixir 1.19 is the floor, and the toolchain sets it rather than the library: reach needs 1.18, and the ex_ast it depends on needs 1.19. Everything is dev/test scoped with runtime: false, so a consumer's dependency tree stays empty — reach is also what provides mix ex_ast.search, mix ex_ast.replace and mix ex_ast.diff.

Attribution

This library is a port of pi-tui's LaTeX renderer and markdown tokenizers (packages/tui/src/latex.ts and components/markdown.ts in pi) by Mario Zechner, copyright 2025, MIT licensed. The symbol and command tables are generated from that source, and the test suite is a port of its tests, so behaviour matches the reference implementation rather than re-inventing it. The display-width math comes from the same project's visibleWidth.

License

MIT — see LICENSE.