commit - eca55a61963c6f7a60742d25a78010e6ca72386e
commit + a31d8fbf9d5ffda70750701ee27ed51bdbac593a
blob - /dev/null
blob + 298690137181edee5643a890fff3089840f1606f (mode 644)
--- /dev/null
+++ internal/latex/expand.go
+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
+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
+// 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
+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
+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
+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
+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
+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
"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 (
images *image.Cache
graphicsOK bool
+ darkBG bool
}
type fileWatchMsg struct{}
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 {
}
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
}
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()
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()
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
})
}
+ 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
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 {