Commit Diff


commit - eca55a61963c6f7a60742d25a78010e6ca72386e
commit + a31d8fbf9d5ffda70750701ee27ed51bdbac593a
blob - /dev/null
blob + 298690137181edee5643a890fff3089840f1606f (mode 644)
--- /dev/null
+++ internal/latex/expand.go
@@ -0,0 +1,72 @@
+package latex
+
+import (
+	"fmt"
+	"regexp"
+	"strconv"
+	"strings"
+
+	"github.com/maaslalani/slides/internal/image"
+)
+
+// Own Private Use Area marker (TEX instead of image's IMG), so sentinel
+// numbering never needs to be coordinated with internal/image's.
+const sentinelOpen = "TEX"
+const sentinelClose = ""
+
+var sentinelRe = regexp.MustCompile(sentinelOpen + `(\d+)` + sentinelClose)
+
+func sentinel(i int) string {
+	return fmt.Sprintf("%s%d%s", sentinelOpen, i, sentinelClose)
+}
+
+// Preprocess finds display ($$...$$) and inline ($...$) math in markdown.
+// Display blocks become a real image (sentinel + placement) when
+// graphicsOK and lookup resolves their cache entry; otherwise -- and
+// always for inline math, which never renders as an image -- they're
+// replaced with a Unicode approximation so they still read as text.
+func Preprocess(markdown string, dark, graphicsOK bool, lookup func(path string) (image.Entry, bool)) (string, []image.Placement) {
+	display, masked := ParseDisplay(markdown)
+	inline := ParseInline(masked)
+
+	var placements []image.Placement
+	for _, b := range display {
+		if graphicsOK {
+			if entry, ok := lookup(CachePath(b.Source, dark)); ok {
+				markdown = strings.Replace(markdown, b.Raw, sentinel(len(placements)), 1)
+				placements = append(placements, image.Placement{Entry: entry})
+				continue
+			}
+		}
+		markdown = strings.Replace(markdown, b.Raw, ToUnicode(b.Source), 1)
+	}
+	for _, b := range inline {
+		markdown = strings.Replace(markdown, b.Raw, ToUnicode(b.Source), 1)
+	}
+	return markdown, placements
+}
+
+// Expand mirrors image.Expand's line-splicing logic, matched against
+// latex's own sentinel marker.
+func Expand(rendered string, placements []image.Placement) string {
+	if len(placements) == 0 {
+		return rendered
+	}
+	lines := strings.Split(rendered, "\n")
+	out := make([]string, 0, len(lines))
+	for _, line := range lines {
+		m := sentinelRe.FindStringSubmatch(line)
+		if m == nil {
+			out = append(out, line)
+			continue
+		}
+		idx, err := strconv.Atoi(m[1])
+		if err != nil || idx < 0 || idx >= len(placements) {
+			out = append(out, line)
+			continue
+		}
+		e := placements[idx].Entry
+		out = append(out, strings.Split(image.PlaceholderBlock(e.ID, e.Cols, e.Rows), "\n")...)
+	}
+	return strings.Join(out, "\n")
+}
blob - /dev/null
blob + 6d55e08e561435a80f77086f357ca39ffde6269e (mode 644)
--- /dev/null
+++ internal/latex/expand_test.go
@@ -0,0 +1,61 @@
+package latex_test
+
+import (
+	"strings"
+	"testing"
+
+	"github.com/charmbracelet/x/ansi/kitty"
+	"github.com/maaslalani/slides/internal/image"
+	"github.com/maaslalani/slides/internal/latex"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+func TestPreprocess_DisplayBecomesImage(t *testing.T) {
+	md := "before\n\n$$ x^2 $$\n\nafter " + `$\alpha$` + " end"
+
+	lookup := func(path string) (image.Entry, bool) {
+		return image.Entry{ID: 3, Cols: 5, Rows: 2}, true
+	}
+
+	pre, placements := latex.Preprocess(md, true, true, lookup)
+	require.Len(t, placements, 1)
+	assert.Equal(t, image.Entry{ID: 3, Cols: 5, Rows: 2}, placements[0].Entry)
+	assert.NotContains(t, pre, "$$")
+	assert.Contains(t, pre, "α")
+}
+
+func TestPreprocess_FallsBackToUnicodeWhenNotReady(t *testing.T) {
+	md := "$$ x^2 $$"
+	pre, placements := latex.Preprocess(md, true, true, func(string) (image.Entry, bool) {
+		return image.Entry{}, false
+	})
+	assert.Empty(t, placements)
+	assert.Contains(t, pre, "x²")
+}
+
+func TestPreprocess_FallsBackToUnicodeWithoutGraphics(t *testing.T) {
+	md := "$$ x^2 $$"
+	pre, placements := latex.Preprocess(md, true, false, func(string) (image.Entry, bool) {
+		t.Fatal("lookup should not be called when graphicsOK is false")
+		return image.Entry{}, false
+	})
+	assert.Empty(t, placements)
+	assert.Contains(t, pre, "x²")
+}
+
+func TestExpand(t *testing.T) {
+	md := "$$ x^2 $$"
+	pre, placements := latex.Preprocess(md, true, true, func(string) (image.Entry, bool) {
+		return image.Entry{ID: 1, Cols: 4, Rows: 3}, true
+	})
+
+	expanded := latex.Expand(pre, placements)
+	placeholderLines := 0
+	for _, l := range strings.Split(expanded, "\n") {
+		if strings.Contains(l, string(kitty.Placeholder)) {
+			placeholderLines++
+		}
+	}
+	assert.Equal(t, 3, placeholderLines)
+}
blob - /dev/null
blob + 57a6831899566d1713ca22506a7e33fe58bede74 (mode 644)
--- /dev/null
+++ internal/latex/parse.go
@@ -0,0 +1,42 @@
+// Package latex renders LaTeX math ($...$ inline, $$...$$ block) found in
+// slide markdown. Block math becomes a real image via internal/image and
+// the Kitty Graphics Protocol; inline math is approximated as Unicode text
+// so it can sit on the same line as surrounding prose.
+package latex
+
+import (
+	"regexp"
+	"strings"
+)
+
+// Block is a single math region found in a slide.
+type Block struct {
+	Raw    string
+	Source string
+}
+
+var displayRe = regexp.MustCompile(`(?s)\$\$(.+?)\$\$`)
+var inlineRe = regexp.MustCompile(`\$([^$\n]+?)\$`)
+
+// ParseDisplay extracts $$...$$ blocks, in order of appearance. It also
+// returns markdown with every display span blanked out to spaces (masked),
+// for ParseInline to run over -- otherwise inline's regex would treat a
+// display block's interior as its own match.
+func ParseDisplay(markdown string) (blocks []Block, masked string) {
+	masked = markdown
+	for _, m := range displayRe.FindAllStringSubmatch(markdown, -1) {
+		blocks = append(blocks, Block{Raw: m[0], Source: strings.TrimSpace(m[1])})
+		masked = strings.Replace(masked, m[0], strings.Repeat(" ", len(m[0])), 1)
+	}
+	return blocks, masked
+}
+
+// ParseInline extracts $...$ spans from already-masked markdown (see
+// ParseDisplay).
+func ParseInline(masked string) []Block {
+	var blocks []Block
+	for _, m := range inlineRe.FindAllStringSubmatch(masked, -1) {
+		blocks = append(blocks, Block{Raw: m[0], Source: strings.TrimSpace(m[1])})
+	}
+	return blocks
+}
blob - /dev/null
blob + 0be987266e11d58d9457f29b40202e0472c981b1 (mode 644)
--- /dev/null
+++ internal/latex/parse_test.go
@@ -0,0 +1,75 @@
+package latex_test
+
+import (
+	"testing"
+
+	"github.com/maaslalani/slides/internal/latex"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+func TestParseDisplay(t *testing.T) {
+	tests := []struct {
+		name     string
+		markdown string
+		want     []latex.Block
+	}{
+		{
+			name:     "no math",
+			markdown: "just text",
+			want:     nil,
+		},
+		{
+			name:     "single block",
+			markdown: "before\n\n$$ x^2 $$\n\nafter",
+			want: []latex.Block{
+				{Raw: "$$ x^2 $$", Source: "x^2"},
+			},
+		},
+		{
+			name:     "two blocks",
+			markdown: "$$a$$ and $$b$$",
+			want: []latex.Block{
+				{Raw: "$$a$$", Source: "a"},
+				{Raw: "$$b$$", Source: "b"},
+			},
+		},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			blocks, _ := latex.ParseDisplay(tt.markdown)
+			assert.Equal(t, tt.want, blocks)
+		})
+	}
+}
+
+func TestParseDisplay_Masking(t *testing.T) {
+	_, masked := latex.ParseDisplay("$$ x^2 $$")
+	assert.NotContains(t, masked, "$")
+}
+
+func TestParseInline(t *testing.T) {
+	tests := []struct {
+		name     string
+		markdown string
+		want     []latex.Block
+	}{
+		{"no math", "just text", nil},
+		{"single inline", "the value $x^2$ here", []latex.Block{{Raw: "$x^2$", Source: "x^2"}}},
+		{"two inline", "$a$ and $b$", []latex.Block{{Raw: "$a$", Source: "a"}, {Raw: "$b$", Source: "b"}}},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			assert.Equal(t, tt.want, latex.ParseInline(tt.markdown))
+		})
+	}
+}
+
+func TestParseInline_IgnoresDisplayBlocks(t *testing.T) {
+	md := "before $$ x^2 $$ after $y$ end"
+	display, masked := latex.ParseDisplay(md)
+	require.Len(t, display, 1)
+
+	inline := latex.ParseInline(masked)
+	assert.Equal(t, []latex.Block{{Raw: "$y$", Source: "y"}}, inline)
+}
blob - /dev/null
blob + d77eeaf7b476355bf3d411bf0535d539e7be97b4 (mode 644)
--- /dev/null
+++ internal/latex/render.go
@@ -0,0 +1,109 @@
+package latex
+
+import (
+	"crypto/sha256"
+	"encoding/hex"
+	"fmt"
+	"os"
+	"os/exec"
+	"path/filepath"
+	"strings"
+)
+
+// dpi is a fixed rendering resolution; there's no cheap way to query the
+// terminal's real cell pixel size without an extra async round-trip (same
+// simplification internal/image makes for its own sizing).
+const dpi = 300
+
+// preview, not the default box-register backend, is required: standalone's
+// default backend fails to detect an equation* (or \[...\]) as the
+// document's content and errors with "Missing $ inserted" -- confirmed by
+// hand against this tectonic bundle before wiring this in.
+const texTemplate = `\documentclass[preview,border=2pt]{standalone}
+\usepackage{amsmath,amssymb}
+\usepackage{xcolor}
+\begin{document}
+\color{%s}
+\[
+%s
+\]
+\end{document}
+`
+
+func cacheDir() string {
+	base, err := os.UserCacheDir()
+	if err != nil {
+		base = os.TempDir()
+	}
+	return filepath.Join(base, "slides", "latex")
+}
+
+// CachePath returns the deterministic on-disk location for source's
+// rendered PNG. It does no I/O, so it's safe to call from View().
+func CachePath(source string, dark bool) string {
+	h := sha256.New()
+	h.Write([]byte(source))
+	if dark {
+		h.Write([]byte{1})
+	} else {
+		h.Write([]byte{0})
+	}
+	return filepath.Join(cacheDir(), hex.EncodeToString(h.Sum(nil))+".png")
+}
+
+// Render compiles source (a LaTeX math expression) to a tightly-cropped
+// transparent PNG and returns its path, reusing a previous render if
+// CachePath already exists on disk. It shells out to tectonic and
+// ghostscript, so it must only run inside a tea.Cmd, never from View().
+func Render(source string, dark bool) (string, error) {
+	path := CachePath(source, dark)
+	if _, err := os.Stat(path); err == nil {
+		return path, nil
+	}
+	if err := os.MkdirAll(cacheDir(), 0o755); err != nil {
+		return "", err
+	}
+
+	tmp, err := os.MkdirTemp("", "slides-latex-*")
+	if err != nil {
+		return "", err
+	}
+	defer os.RemoveAll(tmp)
+
+	color := "black"
+	if dark {
+		color = "white"
+	}
+	tex := fmt.Sprintf(texTemplate, color, source)
+
+	tectonic := exec.Command("tectonic", "--outdir", tmp, "--outfmt", "pdf", "--untrusted", "-")
+	tectonic.Stdin = strings.NewReader(tex)
+	if out, err := tectonic.CombinedOutput(); err != nil {
+		return "", fmt.Errorf("tectonic: %w: %s", err, out)
+	}
+
+	// standalone crops the PDF page to the content bounding box already,
+	// so rasterizing gives an already-tight PNG -- no trim step needed.
+	pdf := filepath.Join(tmp, "texput.pdf")
+	png := filepath.Join(tmp, "out.png")
+	gs := exec.Command("gs",
+		"-dSAFER", "-dBATCH", "-dNOPAUSE", "-dQUIET",
+		"-sDEVICE=pngalpha", fmt.Sprintf("-r%d", dpi),
+		"-sOutputFile="+png, pdf,
+	)
+	if out, err := gs.CombinedOutput(); err != nil {
+		return "", fmt.Errorf("gs: %w: %s", err, out)
+	}
+
+	if err := os.Rename(png, path); err != nil {
+		data, rerr := os.ReadFile(png)
+		if rerr != nil {
+			return "", rerr
+		}
+		if werr := os.WriteFile(path, data, 0o644); werr != nil {
+			return "", werr
+		}
+	}
+
+	return path, nil
+}
blob - /dev/null
blob + 6f2b03f801e7465c29b4ee2276fe6fb06c309ce0 (mode 644)
--- /dev/null
+++ internal/latex/render_test.go
@@ -0,0 +1,55 @@
+package latex_test
+
+import (
+	"image"
+	_ "image/png"
+	"os"
+	"testing"
+
+	"github.com/maaslalani/slides/internal/latex"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+func TestRender(t *testing.T) {
+	if testing.Short() {
+		t.Skip("shells out to tectonic and gs")
+	}
+
+	path, err := latex.Render(`\int_0^\infty e^{-x^2}\,dx = \frac{\sqrt{\pi}}{2}`, true)
+	require.NoError(t, err)
+
+	f, err := os.Open(path)
+	require.NoError(t, err)
+	defer f.Close()
+
+	img, format, err := image.Decode(f)
+	require.NoError(t, err)
+	assert.Equal(t, "png", format)
+	assert.Greater(t, img.Bounds().Dx(), 0)
+	assert.Greater(t, img.Bounds().Dy(), 0)
+}
+
+func TestRender_CachesOnDisk(t *testing.T) {
+	if testing.Short() {
+		t.Skip("shells out to tectonic and gs")
+	}
+
+	source := `y = mx + b`
+	path1, err := latex.Render(source, false)
+	require.NoError(t, err)
+
+	path2, err := latex.Render(source, false)
+	require.NoError(t, err)
+
+	assert.Equal(t, path1, path2)
+}
+
+func TestCachePath_DeterministicAndColorSensitive(t *testing.T) {
+	p1 := latex.CachePath("x^2", true)
+	p2 := latex.CachePath("x^2", true)
+	p3 := latex.CachePath("x^2", false)
+
+	assert.Equal(t, p1, p2)
+	assert.NotEqual(t, p1, p3)
+}
blob - /dev/null
blob + 7f296215e268d85bbfa1d18d93cb0636ae62fb0c (mode 644)
--- /dev/null
+++ internal/latex/unicode.go
@@ -0,0 +1,202 @@
+package latex
+
+import "strings"
+
+// symbols maps no-argument macros to their Unicode glyph. Not exhaustive --
+// best-effort coverage of the common Greek letters, operators, relations,
+// set/logic notation, and arrows used in typical slide math.
+var symbols = map[string]rune{
+	"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": 'ω',
+
+	"Gamma": 'Γ', "Delta": 'Δ', "Theta": 'Θ', "Lambda": 'Λ',
+	"Xi": 'Ξ', "Pi": 'Π', "Sigma": 'Σ', "Upsilon": 'Υ',
+	"Phi": 'Φ', "Psi": 'Ψ', "Omega": 'Ω',
+
+	"times": '×', "div": '÷', "pm": '±', "mp": '∓', "cdot": '·',
+	"ast": '∗', "star": '⋆', "circ": '∘', "bullet": '•',
+	"oplus": '⊕', "ominus": '⊖', "otimes": '⊗', "oslash": '⊘', "odot": '⊙',
+
+	"leq": '≤', "geq": '≥', "neq": '≠', "approx": '≈', "equiv": '≡',
+	"sim": '∼', "simeq": '≃', "cong": '≅', "propto": '∝',
+	"ll": '≪', "gg": '≫',
+
+	"in": '∈', "notin": '∉', "ni": '∋', "subset": '⊂', "supset": '⊃',
+	"subseteq": '⊆', "supseteq": '⊇', "cup": '∪', "cap": '∩',
+	"setminus": '∖', "emptyset": '∅', "varnothing": '∅',
+
+	"forall": '∀', "exists": '∃', "nexists": '∄', "neg": '¬',
+	"land": '∧', "lor": '∨', "wedge": '∧', "vee": '∨',
+
+	"infty": '∞', "partial": '∂', "nabla": '∇', "hbar": 'ℏ', "ell": 'ℓ',
+	"sum": '∑', "prod": '∏', "coprod": '∐',
+	"int": '∫', "oint": '∮', "iint": '∬', "iiint": '∭',
+
+	"to": '→', "rightarrow": '→', "leftarrow": '←',
+	"leftrightarrow": '↔', "Rightarrow": '⇒', "Leftarrow": '⇐',
+	"Leftrightarrow": '⇔', "mapsto": '↦', "uparrow": '↑', "downarrow": '↓',
+
+	"therefore": '∴', "because": '∵', "perp": '⊥', "parallel": '∥',
+	"angle": '∠', "triangle": '△', "aleph": 'ℵ',
+	"dots": '…', "ldots": '…', "cdots": '⋯', "vdots": '⋮', "ddots": '⋱',
+}
+
+// spacingCommands produce no visible output of their own.
+var spacingCommands = map[string]bool{
+	"left": true, "right": true, ",": true, "!": true, ";": true,
+	"quad": true, "qquad": true,
+}
+
+var superscripts = map[rune]rune{
+	'0': '⁰', '1': '¹', '2': '²', '3': '³', '4': '⁴', '5': '⁵',
+	'6': '⁶', '7': '⁷', '8': '⁸', '9': '⁹', '+': '⁺', '-': '⁻',
+	'=': '⁼', '(': '⁽', ')': '⁾', 'n': 'ⁿ', 'i': 'ⁱ',
+	'a': 'ᵃ', 'b': 'ᵇ', 'c': 'ᶜ', 'd': 'ᵈ', 'e': 'ᵉ', 'f': 'ᶠ',
+	'g': 'ᵍ', 'h': 'ʰ', 'j': 'ʲ', 'k': 'ᵏ', 'l': 'ˡ', 'm': 'ᵐ',
+	'o': 'ᵒ', 'p': 'ᵖ', 'r': 'ʳ', 's': 'ˢ', 't': 'ᵗ', 'u': 'ᵘ',
+	'v': 'ᵛ', 'w': 'ʷ', 'x': 'ˣ', 'y': 'ʸ', 'z': 'ᶻ',
+}
+
+var subscripts = map[rune]rune{
+	'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': 'ₓ',
+}
+
+// ToUnicode approximates LaTeX math as plain Unicode text: symbol macros
+// become their glyph, ^ and _ become super/subscripts where a mapping
+// exists, \frac and \sqrt get a plain-text form. This is best-effort, not
+// a full TeX parser -- anything it doesn't recognize is left as-is so
+// nothing silently disappears.
+func ToUnicode(source string) string {
+	var out strings.Builder
+	runes := []rune(source)
+	i := 0
+	for i < len(runes) {
+		switch runes[i] {
+		case '\\':
+			i = writeMacro(&out, runes, i)
+		case '^':
+			i = writeScript(&out, runes, i+1, superscripts)
+		case '_':
+			i = writeScript(&out, runes, i+1, subscripts)
+		case '{', '}':
+			i++ // grouping braces carry no text of their own
+		default:
+			out.WriteRune(runes[i])
+			i++
+		}
+	}
+	return out.String()
+}
+
+// writeMacro handles a \command starting at i (runes[i] == '\\'), writing
+// its Unicode form (or the raw macro if unrecognized) and returning the
+// index just past it.
+func writeMacro(out *strings.Builder, runes []rune, i int) int {
+	start := i
+	i++ // skip the backslash
+	if i >= len(runes) {
+		out.WriteRune('\\')
+		return i
+	}
+	if !isLetter(runes[i]) {
+		name := string(runes[i])
+		i++
+		if !spacingCommands[name] {
+			out.WriteRune('\\')
+			out.WriteRune(runes[start+1])
+		}
+		return i
+	}
+
+	j := i
+	for j < len(runes) && isLetter(runes[j]) {
+		j++
+	}
+	name := string(runes[i:j])
+
+	switch name {
+	case "frac":
+		a, next := readGroupOrChar(runes, skipSpace(runes, j))
+		b, next2 := readGroupOrChar(runes, skipSpace(runes, next))
+		out.WriteString("(" + ToUnicode(a) + ")/(" + ToUnicode(b) + ")")
+		return next2
+	case "sqrt":
+		a, next := readGroupOrChar(runes, skipSpace(runes, j))
+		out.WriteString("√(" + ToUnicode(a) + ")")
+		return next
+	}
+
+	if spacingCommands[name] {
+		return j
+	}
+	if r, ok := symbols[name]; ok {
+		out.WriteRune(r)
+		return j
+	}
+	out.WriteString(string(runes[start:j]))
+	return j
+}
+
+// writeScript writes the super/subscript form of the token starting at i
+// (a single character or a {...} group), using table for the mapping.
+// Runes without a mapping are written unchanged rather than dropped.
+func writeScript(out *strings.Builder, runes []rune, i int, table map[rune]rune) int {
+	token, next := readGroupOrChar(runes, i)
+	for _, r := range token {
+		if mapped, ok := table[r]; ok {
+			out.WriteRune(mapped)
+		} else {
+			out.WriteRune(r)
+		}
+	}
+	return next
+}
+
+// readGroupOrChar reads a {...} balanced group (returning its inner
+// content) or, if the next rune isn't '{', a single character.
+func readGroupOrChar(runes []rune, i int) (string, int) {
+	if i >= len(runes) {
+		return "", i
+	}
+	if runes[i] != '{' {
+		return string(runes[i]), i + 1
+	}
+	depth := 1
+	start := i + 1
+	j := start
+	for j < len(runes) && depth > 0 {
+		switch runes[j] {
+		case '{':
+			depth++
+		case '}':
+			depth--
+		}
+		j++
+	}
+	end := j - 1
+	if end < start {
+		end = start
+	}
+	return string(runes[start:end]), j
+}
+
+func skipSpace(runes []rune, i int) int {
+	for i < len(runes) && runes[i] == ' ' {
+		i++
+	}
+	return i
+}
+
+func isLetter(r rune) bool {
+	return (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z')
+}
blob - /dev/null
blob + 5c29f9bbedac1fc09bf4a7604fa1ca86629f8317 (mode 644)
--- /dev/null
+++ internal/latex/unicode_test.go
@@ -0,0 +1,43 @@
+package latex_test
+
+import (
+	"testing"
+
+	"github.com/maaslalani/slides/internal/latex"
+	"github.com/stretchr/testify/assert"
+)
+
+func TestToUnicode(t *testing.T) {
+	tests := []struct {
+		name   string
+		source string
+		want   string
+	}{
+		{"plain text", "x + y", "x + y"},
+		{"greek letter", `\alpha`, "α"},
+		{"superscript digit", "x^2", "x²"},
+		{"superscript group", "x^{23}", "x²³"},
+		{"subscript digit", "x_1", "x₁"},
+		{"subscript group", "a_{ij}", "aᵢⱼ"},
+		{"unmapped superscript falls back to raw char", "x^q", "xq"},
+		{"frac simple", `\frac{a}{b}`, "(a)/(b)"},
+		{"frac nested macro", `\frac{\alpha}{2}`, "(α)/(2)"},
+		{"sqrt simple", `\sqrt{x}`, "√(x)"},
+		{"sqrt nested", `\sqrt{x^2}`, "√(x²)"},
+		{"unrecognized macro left as-is", `\foobar`, `\foobar`},
+		{"spacing commands dropped", `a\,b\;c\!d`, "abcd"},
+		{"left right dropped", `\left(x\right)`, "(x)"},
+		{"braces alone are stripped", "{x}", "x"},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			assert.Equal(t, tt.want, latex.ToUnicode(tt.source))
+		})
+	}
+}
+
+func TestToUnicode_GreekInExpression(t *testing.T) {
+	got := latex.ToUnicode(`\alpha^2 + \beta \leq \gamma`)
+	assert.Equal(t, "α² + β ≤ γ", got)
+}
blob - 5bebe5d79a354e68c5d4ae787959ea99cafe7a04
blob + 9c974a382e2d7dee4a0d50c1952a20defb65d494
--- internal/model/model.go
+++ internal/model/model.go
@@ -23,8 +23,10 @@ import (
 	"github.com/charmbracelet/glamour"
 	"github.com/maaslalani/slides/internal/code"
 	"github.com/maaslalani/slides/internal/image"
+	"github.com/maaslalani/slides/internal/latex"
 	"github.com/maaslalani/slides/internal/meta"
 	"github.com/maaslalani/slides/styles"
+	"github.com/muesli/termenv"
 )
 
 var (
@@ -56,6 +58,7 @@ type Model struct {
 
 	images     *image.Cache
 	graphicsOK bool
+	darkBG     bool
 }
 
 type fileWatchMsg struct{}
@@ -87,12 +90,14 @@ func (m *Model) imageBaseDir() string {
 	return filepath.Dir(m.FileName)
 }
 
-// loadImagesCmd decodes and encodes any not-yet-cached images referenced
-// by slide, off the render path. It does file I/O, so it must only run
+// loadImagesCmd decodes and encodes any not-yet-cached images or display
+// math referenced by slide, off the render path. It does file I/O (and,
+// for display math, shells out to tectonic/gs), so it must only run
 // inside a tea.Cmd.
-func loadImagesCmd(images *image.Cache, baseDir, slide string, maxCols int) tea.Cmd {
+func loadImagesCmd(images *image.Cache, baseDir, slide string, maxCols int, darkBG bool) tea.Cmd {
 	refs := image.Parse(slide)
-	if images == nil || len(refs) == 0 {
+	display, _ := latex.ParseDisplay(slide)
+	if images == nil || (len(refs) == 0 && len(display) == 0) {
 		return nil
 	}
 	return func() tea.Msg {
@@ -108,6 +113,21 @@ func loadImagesCmd(images *image.Cache, baseDir, slide
 			}
 			seqs = append(seqs, seq)
 		}
+		for _, b := range display {
+			cachePath := latex.CachePath(b.Source, darkBG)
+			if _, ok := images.Get(cachePath, maxCols); ok {
+				continue
+			}
+			renderedPath, err := latex.Render(b.Source, darkBG)
+			if err != nil {
+				continue
+			}
+			_, seq, err := images.Load(renderedPath, maxCols)
+			if err != nil || seq == "" {
+				continue
+			}
+			seqs = append(seqs, seq)
+		}
 		if len(seqs) == 0 {
 			return nil
 		}
@@ -154,6 +174,11 @@ func (m *Model) Load() error {
 	m.Paging = metaData.Paging
 	if m.Theme == nil {
 		m.Theme = styles.SelectTheme(metaData.Theme)
+		// Computed here, once, before the tea.Program takes over the
+		// terminal: HasDarkBackground does its own OSC 11 query, which
+		// would race with Bubble Tea's input reader if called later from
+		// a tea.Cmd while the program is running.
+		m.darkBG = termenv.HasDarkBackground()
 	}
 	if m.images == nil {
 		m.images = image.NewCache()
@@ -171,7 +196,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd
 		if !m.graphicsOK || len(m.Slides) == 0 {
 			return m, nil
 		}
-		return m, loadImagesCmd(m.images, m.imageBaseDir(), m.Slides[m.Page], m.viewport.Width())
+		return m, loadImagesCmd(m.images, m.imageBaseDir(), m.Slides[m.Page], m.viewport.Width(), m.darkBG)
 
 	case tea.KeyPressMsg:
 		keyPress := msg.String()
@@ -244,13 +269,13 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd
 			if !m.graphicsOK {
 				return m, nil
 			}
-			return m, loadImagesCmd(m.images, m.imageBaseDir(), m.Slides[m.Page], m.viewport.Width())
+			return m, loadImagesCmd(m.images, m.imageBaseDir(), m.Slides[m.Page], m.viewport.Width(), m.darkBG)
 		}
 
 	case uv.KittyGraphicsEvent:
 		if msg.Options.ID == image.ProbeID && string(msg.Payload) == "OK" {
 			m.graphicsOK = true
-			return m, loadImagesCmd(m.images, m.imageBaseDir(), m.Slides[m.Page], m.viewport.Width())
+			return m, loadImagesCmd(m.images, m.imageBaseDir(), m.Slides[m.Page], m.viewport.Width(), m.darkBG)
 		}
 		return m, nil
 
@@ -286,6 +311,14 @@ func (m Model) View() tea.View {
 		})
 	}
 
+	var latexPlacements []image.Placement
+	if m.images != nil {
+		maxCols := m.viewport.Width()
+		slide, latexPlacements = latex.Preprocess(slide, m.darkBG, m.graphicsOK, func(path string) (image.Entry, bool) {
+			return m.images.Get(path, maxCols)
+		})
+	}
+
 	slide, err := r.Render(slide)
 	slide = strings.ReplaceAll(slide, "\t", tabSpaces)
 	slide += m.VirtualText
@@ -296,6 +329,9 @@ func (m Model) View() tea.View {
 	if len(placements) > 0 {
 		slide = image.Expand(slide, placements)
 	}
+	if len(latexPlacements) > 0 {
+		slide = latex.Expand(slide, latexPlacements)
+	}
 
 	var left string
 	if m.Search.Active {