commit a31d8fbf9d5ffda70750701ee27ed51bdbac593a from: ale date: Fri Jul 31 04:53:16 2026 UTC add LaTeX math rendering ($...$ inline, $$...$$ block) Block math ($$...$$) compiles via tectonic + ghostscript to a PNG and reuses internal/image's Kitty transmission/cache/placeholder pipeline unchanged, so it gets a real, persistently-cached image. Inline math ($...$) never becomes an image -- a Kitty placeholder forced to one row would squash anything with vertical extent, so it's approximated as Unicode text instead, which also doubles as the fallback for block math when there's no graphics support or compilation fails. Found and fixed a real tectonic/standalone interaction along the way: the default (non-preview) standalone backend doesn't recognize \[...\] or equation* as document content in this bundle ("Missing $ inserted"), confirmed by hand with tectonic/gs directly before wiring it in -- needs \documentclass[preview,border=2pt]{standalone}. 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 {