From cc14c2c7f305d077732f21f8e63823176d100f1c Mon Sep 17 00:00:00 2001 From: Djordje Lukic Date: Fri, 7 Aug 2026 12:03:56 +0200 Subject: [PATCH 1/3] feat(tui): render LaTeX in markdown Signed-off-by: Djordje Lukic --- pkg/tui/components/latex/render.go | 528 ++++++++++++++++++ pkg/tui/components/latex/render_test.go | 45 ++ .../testdata/blackboard and scripts.golden | 1 + .../latex/testdata/display fraction.golden | 3 + .../components/latex/testdata/fraction.golden | 1 + .../components/latex/testdata/matrix.golden | 2 + .../latex/testdata/operators.golden | 1 + .../latex/testdata/root and relation.golden | 1 + pkg/tui/components/markdown/fast_renderer.go | 148 ++++- pkg/tui/components/markdown/latex_test.go | 85 +++ 10 files changed, 813 insertions(+), 2 deletions(-) create mode 100644 pkg/tui/components/latex/render.go create mode 100644 pkg/tui/components/latex/render_test.go create mode 100644 pkg/tui/components/latex/testdata/blackboard and scripts.golden create mode 100644 pkg/tui/components/latex/testdata/display fraction.golden create mode 100644 pkg/tui/components/latex/testdata/fraction.golden create mode 100644 pkg/tui/components/latex/testdata/matrix.golden create mode 100644 pkg/tui/components/latex/testdata/operators.golden create mode 100644 pkg/tui/components/latex/testdata/root and relation.golden create mode 100644 pkg/tui/components/markdown/latex_test.go diff --git a/pkg/tui/components/latex/render.go b/pkg/tui/components/latex/render.go new file mode 100644 index 0000000000..d2b1af77b6 --- /dev/null +++ b/pkg/tui/components/latex/render.go @@ -0,0 +1,528 @@ +// Package latex renders supported LaTeX math expressions as terminal-friendly Unicode text. +package latex + +import ( + "fmt" + "regexp" + "strings" + "unicode" + "unicode/utf8" + + runewidth "github.com/mattn/go-runewidth" +) + +var symbols = map[string]string{ + "alpha": "α", "beta": "β", "gamma": "γ", "delta": "δ", "epsilon": "ϵ", "varepsilon": "ε", "zeta": "ζ", "eta": "η", "theta": "θ", "vartheta": "ϑ", "iota": "ι", "kappa": "κ", "lambda": "λ", "mu": "μ", "nu": "ν", "xi": "ξ", "pi": "π", "rho": "ρ", "sigma": "σ", "tau": "τ", "upsilon": "υ", "phi": "ϕ", "varphi": "φ", "chi": "χ", "psi": "ψ", "omega": "ω", + "Gamma": "Γ", "Delta": "Δ", "Theta": "Θ", "Lambda": "Λ", "Xi": "Ξ", "Pi": "Π", "Sigma": "Σ", "Phi": "Φ", "Psi": "Ψ", "Omega": "Ω", + "pm": "±", "mp": "∓", "times": "×", "div": "÷", "cdot": "·", "ast": "∗", "star": "⋆", "circ": "∘", "bullet": "•", "oplus": "⊕", "ominus": "⊖", "otimes": "⊗", "oslash": "⊘", "odot": "⊙", + "cap": "∩", "cup": "∪", "bigcap": "⋂", "bigcup": "⋃", "bigwedge": "⋀", "bigvee": "⋁", "setminus": "∖", "in": "∈", "notin": "∉", "ni": "∋", "subset": "⊂", "supset": "⊃", "subseteq": "⊆", "supseteq": "⊇", + "le": "≤", "leq": "≤", "leqslant": "≤", "ge": "≥", "geq": "≥", "geqslant": "≥", "ne": "≠", "neq": "≠", "equiv": "≡", "approx": "≈", "sim": "∼", "simeq": "≃", "cong": "≅", "propto": "∝", "parallel": "∥", "perp": "⊥", "mid": "∣", + "forall": "∀", "exists": "∃", "nexists": "∄", "neg": "¬", "land": "∧", "wedge": "∧", "lor": "∨", "vee": "∨", "to": "→", "rightarrow": "→", "longrightarrow": "→", "leftarrow": "←", "longleftarrow": "←", "gets": "←", "leftrightarrow": "↔", "mapsto": "↦", "Rightarrow": "⇒", "Leftarrow": "⇐", "Leftrightarrow": "⇔", "implies": "⇒", "iff": "⇔", + "partial": "∂", "nabla": "∇", "int": "∫", "iint": "∬", "iiint": "∭", "oint": "∮", "sum": "∑", "prod": "∏", "coprod": "∐", "infty": "∞", "emptyset": "∅", "varnothing": "∅", "angle": "∠", "therefore": "∴", "because": "∵", "aleph": "ℵ", "hbar": "ℏ", "ell": "ℓ", + "ldots": "…", "dots": "…", "cdots": "⋯", "vdots": "⋮", "ddots": "⋱", "langle": "⟨", "rangle": "⟩", "vert": "|", "lvert": "|", "rvert": "|", "Vert": "‖", "lVert": "‖", "rVert": "‖", "lbrace": "{", "rbrace": "}", "lfloor": "⌊", "rfloor": "⌋", "lceil": "⌈", "rceil": "⌉", "prime": "′", +} + +var ( + relations = set("le leq leqslant ge geq geqslant ne neq equiv approx sim simeq cong propto parallel perp mid in notin ni subset supset subseteq supseteq to rightarrow longrightarrow leftarrow longleftarrow gets leftrightarrow mapsto Rightarrow Leftarrow Leftrightarrow implies iff") + operators = set("arccos arcsin arctan arg cos cosh cot coth csc deg det dim exp gcd hom inf ker lg lim liminf limsup ln log max min Pr sec sin sinh sup tan tanh") + wrappers = set("emph mathcal mathbf mathfrak mathit mathrm mathnormal mathscr mathsf mathtt mathup mbox overbrace pmb smash substack text textbf textit textmd textnormal textrm textsc textsf textsl texttt textup underbrace bm boldsymbol") + spacing = set(", : ; space > enspace enskip medspace quad qquad thickspace thinspace") + ignored = set("displaystyle limits nolimits scriptstyle scriptscriptstyle textstyle big Big bigg Bigg bigl Bigl biggl Biggl bigr Bigr biggr Biggr") +) + +var ( + superscripts = map[rune]rune{'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': 'ᶻ'} + 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': 'ₓ'} + blackboard = map[rune]rune{'C': 'ℂ', 'H': 'ℍ', 'N': 'ℕ', 'P': 'ℙ', 'Q': 'ℚ', 'R': 'ℝ', 'Z': 'ℤ'} +) + +func set(values string) map[string]bool { + r := map[string]bool{} + for v := range strings.FieldsSeq(values) { + r[v] = true + } + return r +} + +type parser struct { + source string + pos int + ok bool + display bool +} + +// Render renders source, returning false for unsupported or malformed syntax. +func Render(source string, display bool) (string, bool) { + p := parser{source: source, ok: true, display: display} + result := p.sequence(0) + if !p.ok || p.pos != len(source) { + return "", false + } + return strings.ReplaceAll(normalize(result), "\uf002", " "), true +} + +func (p *parser) sequence(end byte) string { + var out strings.Builder + for p.pos < len(p.source) { + c := p.source[p.pos] + if end != 0 && c == end { + p.pos++ + return out.String() + } + switch c { + case '}': + p.ok = false + return out.String() + case '{': + p.pos++ + out.WriteString(p.sequence('}')) + case '\\': + out.WriteString(p.command()) + case '^', '_': + p.pos++ + value := p.argument() + current := strings.TrimRightFunc(out.String(), unicode.IsSpace) + out.Reset() + out.WriteString(current) + out.WriteString(script(value, c == '_')) + case '=', '<', '>': + out.WriteByte(' ') + out.WriteByte(c) + out.WriteByte(' ') + p.pos++ + case '&': + p.pos++ + case '~': + out.WriteByte(' ') + p.pos++ + default: + if unicode.IsSpace(rune(c)) { + for p.pos < len(p.source) && unicode.IsSpace(rune(p.source[p.pos])) { + p.pos++ + } + out.WriteByte(' ') + } else { + out.WriteByte(c) + p.pos++ + } + } + } + if end != 0 { + p.ok = false + } + return out.String() +} + +func (p *parser) command() string { + p.pos++ + if p.pos >= len(p.source) { + p.ok = false + return "" + } + start := p.pos + if isLetter(p.source[p.pos]) { + for p.pos < len(p.source) && isLetter(p.source[p.pos]) { + p.pos++ + } + } else { + p.pos++ + } + cmd := p.source[start:p.pos] + if cmd == "\\" { + return "\n" + } + if cmd == " " || spacing[cmd] { + return " " + } + if ignored[cmd] { + return "" + } + switch cmd { + case "{", "}", "$", "%", "#", "_", "&": + return cmd + } + if cmd == "!" { + return "\x00" + } + if cmd == "|" { + return "‖" + } + if cmd == "left" || cmd == "middle" || cmd == "right" { + if p.pos < len(p.source) && p.source[p.pos] == '.' { + p.pos++ + } + return "" + } + if symbol, found := symbols[cmd]; found { + if relations[cmd] || cmd == "times" || cmd == "cdot" { + return " " + symbol + " " + } + return symbol + } + if operators[cmd] { + return "\uf004" + cmd + "\uf005" + } + switch cmd { + case "frac", "dfrac", "tfrac": + n, d := normalize(p.argument()), normalize(p.argument()) + if p.display && cmd != "tfrac" { + return stackFraction(n, d) + } + return fraction(n, d) + case "sqrt": + degree, has := p.optional() + value := normalize(p.argument()) + root := "√" + switch { + case has && degree == "3": + root = "∛" + case has && degree == "4": + root = "∜" + case has && degree != "2": + root = script(degree, false) + root + } + if simple(value) { + return root + value + } + return root + "(" + value + ")" + case "boxed", "fbox": + return "[" + strings.TrimSpace(p.argument()) + "]" + case "binom", "dbinom", "tbinom": + return "(" + normalize(p.argument()) + " choose " + normalize(p.argument()) + ")" + case "mathbb": + var b strings.Builder + for _, r := range p.argument() { + if x, ok := blackboard[r]; ok { + b.WriteRune(x) + } else { + b.WriteRune(r) + } + } + return b.String() + case "operatorname": + if p.pos < len(p.source) && p.source[p.pos] == '*' { + p.pos++ + } + return "\uf004" + normalize(p.argument()) + "\uf005" + case "mod", "bmod": + return " mod " + case "pmod", "pod": + return " (mod " + normalize(p.argument()) + ")" + case "overset", "stackrel": + upper := p.argument() + value := strings.TrimSpace(p.argument()) + return value + script(upper, false) + case "underset": + lower := p.argument() + value := strings.TrimSpace(p.argument()) + return value + script(lower, true) + case "begin": + return p.environment() + case "not": + value := strings.TrimSpace(p.argument()) + neg := map[string]string{"=": "≠", "∈": "∉", "≤": "≰", "≥": "≱", "⊂": "⊄", "⊆": "⊈", "→": "↛"} + if n, ok := neg[value]; ok { + return " " + n + " " + } + return value + "̸" + } + if wrappers[cmd] { + return p.argument() + } + accents := map[string]string{"acute": "́", "bar": "̅", "breve": "̆", "check": "̌", "ddot": "̈", "dot": "̇", "grave": "̀", "hat": "̂", "overline": "̅", "overrightarrow": "⃗", "tilde": "̃", "underline": "̲", "vec": "⃗", "widehat": "̂", "widetilde": "̃"} + if accent, ok := accents[cmd]; ok { + value := p.argument() + if utf8.RuneCountInString(value) == 1 { + return value + accent + } + return cmd + "(" + value + ")" + } + p.ok = false + return "\\" + cmd +} + +func (p *parser) argument() string { + for p.pos < len(p.source) && (p.source[p.pos] == ' ' || p.source[p.pos] == '\t') { + p.pos++ + } + if p.pos >= len(p.source) { + p.ok = false + return "" + } + if p.source[p.pos] == '{' { + p.pos++ + return p.sequence('}') + } + if p.source[p.pos] == '\\' { + return p.command() + } + start := p.pos + _, size := utf8.DecodeRuneInString(p.source[p.pos:]) + p.pos += size + return p.source[start:p.pos] +} + +func (p *parser) optional() (string, bool) { + for p.pos < len(p.source) && unicode.IsSpace(rune(p.source[p.pos])) { + p.pos++ + } + if p.pos >= len(p.source) || p.source[p.pos] != '[' { + return "", false + } + end := strings.IndexByte(p.source[p.pos+1:], ']') + if end < 0 { + p.ok = false + return "", false + } + value := p.source[p.pos+1 : p.pos+1+end] + p.pos += end + 2 + return normalize(value), true +} + +func (p *parser) rawGroup() (string, bool) { + for p.pos < len(p.source) && unicode.IsSpace(rune(p.source[p.pos])) { + p.pos++ + } + if p.pos >= len(p.source) || p.source[p.pos] != '{' { + p.ok = false + return "", false + } + start := p.pos + 1 + depth := 1 + p.pos++ + for p.pos < len(p.source) { + if p.source[p.pos] == '\\' { + p.pos += min(2, len(p.source)-p.pos) + continue + } + if p.source[p.pos] == '{' { + depth++ + } + if p.source[p.pos] == '}' { + depth-- + if depth == 0 { + v := p.source[start:p.pos] + p.pos++ + return v, true + } + } + p.pos++ + } + p.ok = false + return "", false +} + +func (p *parser) environment() string { + env, ok := p.rawGroup() + if !ok { + return "" + } + marker := "\\end{" + env + "}" + offset := strings.Index(p.source[p.pos:], marker) + if offset < 0 { + p.ok = false + return "" + } + body := p.source[p.pos : p.pos+offset] + p.pos += offset + len(marker) + if env == "equation" || env == "equation*" || env == "displaymath" { + return p.nested(body) + } + rows := splitRows(body) + alignedEnvironments := set("aligned align align* alignedat alignat alignat* gather gathered multline multline* split") + if alignedEnvironments[env] { + out := make([]string, 0, len(rows)) + for _, row := range rows { + row = strings.ReplaceAll(row, "&", "") + if v := strings.TrimSpace(p.nested(row)); v != "" { + out = append(out, v) + } + } + return strings.Join(out, "\n") + } + if env == "cases" || env == "cases*" { + out := make([]string, 0, len(rows)) + for i, row := range rows { + cells := strings.Split(row, "&") + value := strings.TrimSpace(p.nested(cells[0])) + condition := "" + if len(cells) > 1 { + condition = strings.TrimSpace(p.nested(cells[1])) + } + delim := "⎨" + if i == 0 { + delim = "⎧" + } + if i == len(rows)-1 { + delim = "⎩" + } + if condition != "" && !regexp.MustCompile(`(?i)^(if|when|for|otherwise)\b`).MatchString(condition) { + condition = "if " + condition + } + out = append(out, delim+" "+value+" "+condition) + } + return strings.TrimSpace(strings.Join(out, "\n")) + } + matrixEnvs := set("array matrix smallmatrix pmatrix bmatrix Bmatrix vmatrix Vmatrix") + if matrixEnvs[env] { + return p.matrix(env, body) + } + p.ok = false + return body +} + +func (p *parser) nested(source string) string { + child := parser{source: source, ok: true, display: p.display} + v := child.sequence(0) + if !child.ok || child.pos != len(source) { + p.ok = false + return source + } + return normalize(v) +} + +func (p *parser) matrix(env, body string) string { + rows := splitRows(body) + cells := make([][]string, 0, len(rows)) + widths := []int{} + for _, row := range rows { + parts := strings.Split(row, "&") + for i := range parts { + parts[i] = strings.TrimSpace(p.nested(parts[i])) + if i >= len(widths) { + widths = append(widths, 0) + } + widths[i] = max(widths[i], runewidth.StringWidth(parts[i])) + } + cells = append(cells, parts) + } + lines := make([]string, len(cells)) + for i, row := range cells { + parts := make([]string, len(widths)) + for j := range widths { + v := "" + if j < len(row) { + v = row[j] + } + parts[j] = v + strings.Repeat("\uf002", widths[j]-runewidth.StringWidth(v)) + } + content := strings.Join(parts, " │ ") + left, right := "", "" + switch env { + case "pmatrix": + left, right = matrixDelims(i, len(cells), "⎛⎜⎝", "⎞⎟⎠") + case "bmatrix": + left, right = matrixDelims(i, len(cells), "⎡⎢⎣", "⎤⎥⎦") + case "Bmatrix": + left, right = matrixDelims(i, len(cells), "⎧⎨⎩", "⎫⎬⎭") + case "vmatrix": + left, right = "│", "│" + case "Vmatrix": + left, right = "║", "║" + } + if left != "" { + content = left + " " + content + " " + right + } + lines[i] = strings.TrimRight(content, " ") + } + return strings.Join(lines, "\n") +} + +func matrixDelims(i, n int, left, right string) (string, string) { + idx := 1 + switch i { + case 0: + idx = 0 + case n - 1: + idx = 2 + } + return string([]rune(left)[idx]), string([]rune(right)[idx]) +} + +func splitRows(s string) []string { + re := regexp.MustCompile(`\\\\(?:\[[^\]\n]*\])?`) + return re.Split(s, -1) +} +func isLetter(b byte) bool { return b >= 'A' && b <= 'Z' || b >= 'a' && b <= 'z' } +func simple(s string) bool { + if s == "" { + return false + } + for _, r := range s { + if !unicode.IsLetter(r) && !unicode.IsDigit(r) && r != '.' { + return false + } + } + return true +} + +func fraction(n, d string) string { + nn, dd := n, d + if !simple(n) && utf8.RuneCountInString(n) > 1 { + nn = "(" + n + ")" + } + denominatorIsSimple := regexp.MustCompile(`^[\p{N}.]+$`).MatchString(d) || utf8.RuneCountInString(d) == 1 + if !denominatorIsSimple { + dd = "(" + d + ")" + } + return nn + "/" + dd +} + +func stackFraction(n, d string) string { + w := max(runewidth.StringWidth(n), runewidth.StringWidth(d)) + return center(n, w) + "\n" + strings.Repeat("─", w) + "\n" + center(d, w) +} + +func center(s string, w int) string { + pad := w - runewidth.StringWidth(s) + return strings.Repeat(" ", pad/2) + s + strings.Repeat(" ", pad-pad/2) +} + +func script(value string, sub bool) string { + value = strings.TrimSpace(normalize(value)) + value = regexp.MustCompile(`\s*([=+\-])\s*`).ReplaceAllString(value, "$1") + table := superscripts + prefix := "^" + if sub { + table = subscripts + prefix = "_" + } + var b strings.Builder + for _, r := range value { + mapped, ok := table[r] + if !ok { + if utf8.RuneCountInString(value) == 1 || (sub && allLetters(value)) { + return prefix + value + } + return prefix + "(" + value + ")" + } + b.WriteRune(mapped) + } + return b.String() +} + +func allLetters(s string) bool { + for _, r := range s { + if !unicode.IsLetter(r) { + return false + } + } + return true +} + +func normalize(s string) string { + s = strings.ReplaceAll(s, "\x00", "") + s = strings.ReplaceAll(s, "\uf004", "") + s = strings.ReplaceAll(s, "\uf005", "") + lines := strings.Split(s, "\n") + for i := range lines { + lines[i] = strings.TrimSpace(strings.Join(strings.Fields(lines[i]), " ")) + lines[i] = regexp.MustCompile(`\s*([=<>])\s*`).ReplaceAllString(lines[i], " $1 ") + } + return strings.TrimSpace(strings.Join(lines, "\n")) +} + +func ExampleRender() { + rendered, _ := Render(`\mathbb{C}^3 \to \mathbb{C}^3`, false) + fmt.Println(rendered) // Output: ℂ³ → ℂ³ +} diff --git a/pkg/tui/components/latex/render_test.go b/pkg/tui/components/latex/render_test.go new file mode 100644 index 0000000000..ec7af8be85 --- /dev/null +++ b/pkg/tui/components/latex/render_test.go @@ -0,0 +1,45 @@ +package latex + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gotest.tools/v3/golden" +) + +func TestRender(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + source string + display bool + }{ + {"blackboard and scripts", `\mathbb{C}^3 \to \mathbb{C}^3`, false}, + {"fraction", `F_1 = -\frac{1}{4x^2}`, false}, + {"operators", `\sum_{i=0}^n \alpha_i + \int_0^\infty e^{-x^2}\,dx`, false}, + {"root and relation", `x=\frac{-b\pm\sqrt{b^2-4ac}}{2a}`, false}, + {"matrix", `\begin{pmatrix}1&200\\3000&4\end{pmatrix}`, false}, + {"display fraction", `\frac{0.1\ \text{lux}}{100\ \text{lm/W}}`, true}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + rendered, ok := Render(test.source, test.display) + require.True(t, ok) + golden.Assert(t, rendered, test.name+".golden") + }) + } +} + +func TestRenderRejectsUnsupportedAndMalformed(t *testing.T) { + t.Parallel() + + for _, source := range []string{`x + \unknown{y}`, `\frac{1}`, `{x`} { + _, ok := Render(source, false) + assert.False(t, ok, source) + } +} diff --git a/pkg/tui/components/latex/testdata/blackboard and scripts.golden b/pkg/tui/components/latex/testdata/blackboard and scripts.golden new file mode 100644 index 0000000000..4943992c19 --- /dev/null +++ b/pkg/tui/components/latex/testdata/blackboard and scripts.golden @@ -0,0 +1 @@ +ℂ³ → ℂ³ \ No newline at end of file diff --git a/pkg/tui/components/latex/testdata/display fraction.golden b/pkg/tui/components/latex/testdata/display fraction.golden new file mode 100644 index 0000000000..072e1337a5 --- /dev/null +++ b/pkg/tui/components/latex/testdata/display fraction.golden @@ -0,0 +1,3 @@ +0.1 lux +──────── +100 lm/W \ No newline at end of file diff --git a/pkg/tui/components/latex/testdata/fraction.golden b/pkg/tui/components/latex/testdata/fraction.golden new file mode 100644 index 0000000000..eec604f714 --- /dev/null +++ b/pkg/tui/components/latex/testdata/fraction.golden @@ -0,0 +1 @@ +F₁ = -1/(4x²) \ No newline at end of file diff --git a/pkg/tui/components/latex/testdata/matrix.golden b/pkg/tui/components/latex/testdata/matrix.golden new file mode 100644 index 0000000000..fb0d1ceeaa --- /dev/null +++ b/pkg/tui/components/latex/testdata/matrix.golden @@ -0,0 +1,2 @@ +⎛ 1 │ 200 ⎞ +⎝ 3000 │ 4 ⎠ \ No newline at end of file diff --git a/pkg/tui/components/latex/testdata/operators.golden b/pkg/tui/components/latex/testdata/operators.golden new file mode 100644 index 0000000000..e3fe02e16e --- /dev/null +++ b/pkg/tui/components/latex/testdata/operators.golden @@ -0,0 +1 @@ +∑ᵢ₌₀ⁿ αᵢ + ∫₀^∞ e^(-x²) dx \ No newline at end of file diff --git a/pkg/tui/components/latex/testdata/root and relation.golden b/pkg/tui/components/latex/testdata/root and relation.golden new file mode 100644 index 0000000000..38efec616b --- /dev/null +++ b/pkg/tui/components/latex/testdata/root and relation.golden @@ -0,0 +1 @@ +x = (-b±√(b²-4ac))/(2a) \ No newline at end of file diff --git a/pkg/tui/components/markdown/fast_renderer.go b/pkg/tui/components/markdown/fast_renderer.go index 71f96eafe1..ef140ffff6 100644 --- a/pkg/tui/components/markdown/fast_renderer.go +++ b/pkg/tui/components/markdown/fast_renderer.go @@ -19,6 +19,7 @@ import ( "github.com/docker/docker-agent/pkg/concurrent" "github.com/docker/docker-agent/pkg/lrucache" + "github.com/docker/docker-agent/pkg/tui/components/latex" "github.com/docker/docker-agent/pkg/tui/components/mermaid" "github.com/docker/docker-agent/pkg/tui/styles" ) @@ -364,6 +365,8 @@ func (p *parser) parse() string { switch { case p.tryCodeBlock(line): // handled inside + case p.tryLatexBlock(line): + // handled inside case p.tryHeading(line): // handled inside case p.tryHorizontalRule(line): @@ -417,6 +420,60 @@ func (p *parser) tryCodeBlock(line string) bool { return true } +// tryLatexBlock renders display math delimited by $$ or \[ at the start of a line. +func (p *parser) tryLatexBlock(line string) bool { + trimmed := strings.TrimLeft(line, " \t") + var opening, closing string + switch { + case strings.HasPrefix(trimmed, "$$"): + opening, closing = "$$", "$$" + case strings.HasPrefix(trimmed, `\[`): + opening, closing = `\[`, `\]` + default: + return false + } + + startLine := p.lineIdx + first := strings.TrimSpace(strings.TrimPrefix(trimmed, opening)) + var source strings.Builder + closed := false + for { + if end := strings.Index(first, closing); end >= 0 { + source.WriteString(first[:end]) + closed = true + p.lineIdx++ + break + } + if source.Len() > 0 { + source.WriteByte('\n') + } + source.WriteString(first) + p.lineIdx++ + if p.lineIdx >= len(p.lines) { + break + } + first = p.lines[p.lineIdx] + } + + if closed { + if rendered, ok := latex.Render(strings.TrimSpace(source.String()), true); ok { + for renderedLine := range strings.SplitSeq(rendered, "\n") { + p.styles.ansiText.renderTo(&p.out, renderedLine) + p.out.WriteByte('\n') + } + p.out.WriteByte('\n') + return true + } + } + + for i := startLine; i < p.lineIdx; i++ { + p.styles.ansiText.renderTo(&p.out, p.lines[i]) + p.out.WriteByte('\n') + } + p.out.WriteByte('\n') + return true +} + // headingLevel returns the ATX heading level (1-6) for line, or 0 if line is // not a valid ATX heading. A valid heading has 1-6 '#' characters followed by // a space, tab, or end of line. @@ -1463,6 +1520,8 @@ func isBlockStart(line string) bool { return true case strings.HasPrefix(trimmed, "```") || strings.HasPrefix(trimmed, "~~~"): return true + case strings.HasPrefix(trimmed, "$$") || strings.HasPrefix(trimmed, `\[`): + return true case strings.HasPrefix(trimmed, ">"): return true case isListStart(trimmed): @@ -1600,6 +1659,15 @@ func (p *parser) renderInlineWithStyleTo(out *strings.Builder, text string, rest n := len(text) for i < n { + // LaTeX must be recognized before backslash escape processing so \(...\) + // remains a math delimiter while escaped dollar signs remain literal. + if rendered, consumed, recognized := renderInlineLatex(text[i:]); recognized { + restoreStyle.renderTo(out, rendered) + width += textWidth(rendered) + i += consumed + continue + } + // Check for escaped characters if text[i] == '\\' && i+1 < n { out.WriteByte(text[i+1]) @@ -1874,7 +1942,7 @@ func isWord(b byte) bool { } // inlineMarkdownChars contains all characters that trigger inline markdown processing. -const inlineMarkdownChars = "\\`*_~[" +const inlineMarkdownChars = "\\`*_~[$" // urlStopMarkdownChars is the subset of inline markdown markers that should // terminate auto-linked URL detection. Excludes _ and \\ because they appear @@ -1912,12 +1980,88 @@ func hasInlineMarkdown(text string) bool { func isInlineMarker(b byte) bool { switch b { - case '\\', '`', '*', '_', '~', '[': + case '\\', '`', '*', '_', '~', '[', '$': return true } return false } +func renderInlineLatex(source string) (string, int, bool) { + var opening, closing string + switch { + case strings.HasPrefix(source, `\(`): + opening, closing = `\(`, `\)` + case strings.HasPrefix(source, `\[`): + opening, closing = `\[`, `\]` + case strings.HasPrefix(source, "$$"): + opening, closing = "$$", "$$" + case strings.HasPrefix(source, "$") && (len(source) == 1 || !unicode.IsSpace(rune(source[1]))): + opening, closing = "$", "$" + default: + return "", 0, false + } + + end := findUnescapedDelimiter(source, closing, len(opening)) + if end < 0 { + return source, len(source), looksLikePendingMath(source[len(opening):]) || opening[0] == '\\' + } + inner := source[len(opening):end] + if inner == "" || strings.ContainsRune(inner, '\n') { + return "", 0, false + } + if opening == "$" { + after := source[end+1:] + if unicode.IsSpace(rune(inner[len(inner)-1])) || (after != "" && after[0] >= '0' && after[0] <= '9') || strings.ContainsRune(inner, '`') { + return "", 0, false + } + if shellVariableMath(inner, after) { + return "", 0, false + } + } + + rawLength := end + len(closing) + rendered, ok := latex.Render(inner, false) + if !ok { + return source[:rawLength], rawLength, true + } + return rendered, rawLength, true +} + +func findUnescapedDelimiter(source, delimiter string, start int) int { + for offset := start; offset <= len(source)-len(delimiter); { + relative := strings.Index(source[offset:], delimiter) + if relative < 0 { + return -1 + } + index := offset + relative + backslashes := 0 + for i := index - 1; i >= 0 && source[i] == '\\'; i-- { + backslashes++ + } + if backslashes%2 == 0 { + return index + } + offset = index + len(delimiter) + } + return -1 +} + +func looksLikePendingMath(source string) bool { + return strings.ContainsAny(source, "_^=+*/<>()[|±≤≥≠≈∈→⇒∞∫∑√-") || strings.ContainsRune(source, '\\') +} + +func shellVariableMath(inner, after string) bool { + if inner == "" || (inner[0] < 'A' || inner[0] > 'Z') && inner[0] != '_' { + return false + } + for _, r := range inner { + if !unicode.IsUpper(r) && !unicode.IsDigit(r) && r != '_' { + return false + } + } + return after != "" && (unicode.IsLetter(rune(after[0])) || after[0] == '_') +} + // renderCodeBlock renders a fenced code block with syntax highlighting. func (p *parser) renderCodeBlock(code, lang string) { if code == "" { diff --git a/pkg/tui/components/markdown/latex_test.go b/pkg/tui/components/markdown/latex_test.go new file mode 100644 index 0000000000..ac0a6702ea --- /dev/null +++ b/pkg/tui/components/markdown/latex_test.go @@ -0,0 +1,85 @@ +package markdown + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestLatexInline(t *testing.T) { + t.Parallel() + + input := `A map $\mathbb{C}^3 \to \mathbb{C}^3$, $xy$, $x-y$, $-x$, $\frac{1}{2}$, and \(s \to \infty\).` + result, err := NewFastRenderer(80).Render(input) + require.NoError(t, err) + assert.Equal(t, "A map ℂ³ → ℂ³, xy, x-y, -x, 1/2, and s → ∞.", strings.TrimRight(stripANSI(result), " ")) +} + +func TestLatexDisplayDelimiters(t *testing.T) { + t.Parallel() + + input := `Before + +\[ +E \approx \frac{0.1\ \text{lux}}{100\ \text{lm/W}} +\] + +after` + result, err := NewFastRenderer(80).Render(input) + require.NoError(t, err) + assert.Equal(t, "Before\n\nE ≈ 0.1 lux\n────────\n100 lm/W\n\nafter", trimLinePadding(stripANSI(result))) + + dollars, err := NewFastRenderer(80).Render("$$\\{3x+2y,\\; x \\in \\{0, \\pm 1\\}\\}$$") + require.NoError(t, err) + assert.Equal(t, "{3x+2y, x ∈ {0, ± 1}}", strings.TrimRight(stripANSI(dollars), " ")) +} + +func TestLatexInsideMarkdownStructures(t *testing.T) { + t.Parallel() + + input := "- Formula: $F_1 = u^2$\n\n| Value |\n| --- |\n| $\\mathbb{C}^3$ |" + result, err := NewFastRenderer(80).Render(input) + require.NoError(t, err) + plain := stripANSI(result) + assert.Contains(t, plain, "Formula: F₁ = u²") + assert.Contains(t, plain, "ℂ³") +} + +func TestLatexPreservesNonMathAndUnsupportedInput(t *testing.T) { + t.Parallel() + + cases := []string{ + "Costs $5 and $10 or $8k–$12k; use `$x$`, $HOME, and ${PATH}.", + `Unknown $x + \unknown{y}$ after`, + `Streaming $\mathbb{C}^3`, + `Escaped \$x-y\$.`, + } + for _, input := range cases { + result, err := NewFastRenderer(100).Render(input) + require.NoError(t, err) + expected := strings.ReplaceAll(input, `\$`, `$`) + expected = strings.ReplaceAll(expected, "`", "") + assert.Equal(t, expected, strings.TrimRight(stripANSI(result), " "), input) + } +} + +func trimLinePadding(value string) string { + lines := strings.Split(value, "\n") + for i := range lines { + lines[i] = strings.TrimRight(lines[i], " ") + } + return strings.Join(lines, "\n") +} + +func TestLatexDoesNotRenderInsideCode(t *testing.T) { + t.Parallel() + + input := "`$x^2$`\n\n```text\n$\\mathbb{C}^3$\n```" + result, err := NewFastRenderer(80).HideCopyIcon().Render(input) + require.NoError(t, err) + plain := stripANSI(result) + assert.Contains(t, plain, "$x^2$") + assert.Contains(t, plain, "$\\mathbb{C}^3$") +} From 79a3aad769f26d80804750a643b7ec0a358df208 Mon Sep 17 00:00:00 2001 From: Djordje Lukic Date: Fri, 7 Aug 2026 12:14:16 +0200 Subject: [PATCH 2/3] fix(tui): preserve markdown after unclosed math Signed-off-by: Djordje Lukic --- pkg/tui/components/markdown/fast_renderer.go | 11 ++++++++++- pkg/tui/components/markdown/latex_test.go | 18 ++++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/pkg/tui/components/markdown/fast_renderer.go b/pkg/tui/components/markdown/fast_renderer.go index ef140ffff6..b8a01f5dd4 100644 --- a/pkg/tui/components/markdown/fast_renderer.go +++ b/pkg/tui/components/markdown/fast_renderer.go @@ -2003,7 +2003,11 @@ func renderInlineLatex(source string) (string, int, bool) { end := findUnescapedDelimiter(source, closing, len(opening)) if end < 0 { - return source, len(source), looksLikePendingMath(source[len(opening):]) || opening[0] == '\\' + pending := source[len(opening):] + if looksLikePendingMath(pending) && !containsMarkdownFormatting(pending) { + return source, len(source), true + } + return "", 0, false } inner := source[len(opening):end] if inner == "" || strings.ContainsRune(inner, '\n') { @@ -2046,6 +2050,11 @@ func findUnescapedDelimiter(source, delimiter string, start int) int { return -1 } +func containsMarkdownFormatting(source string) bool { + return strings.Contains(source, "**") || strings.Contains(source, "__") || + strings.Contains(source, "~~") || strings.ContainsRune(source, '`') +} + func looksLikePendingMath(source string) bool { return strings.ContainsAny(source, "_^=+*/<>()[|±≤≥≠≈∈→⇒∞∫∑√-") || strings.ContainsRune(source, '\\') } diff --git a/pkg/tui/components/markdown/latex_test.go b/pkg/tui/components/markdown/latex_test.go index ac0a6702ea..7f32f7eb51 100644 --- a/pkg/tui/components/markdown/latex_test.go +++ b/pkg/tui/components/markdown/latex_test.go @@ -73,6 +73,24 @@ func trimLinePadding(value string) string { return strings.Join(lines, "\n") } +func TestLatexUnclosedDelimitersKeepRenderingMarkdown(t *testing.T) { + t.Parallel() + + tests := []struct { + input string + expected string + }{ + {`See \( x more examples and **note** this`, "See ( x more examples and note this"}, + {`See \[ x more examples and **note** this`, "See [ x more examples and note this"}, + } + for _, test := range tests { + result, err := NewFastRenderer(100).Render(test.input) + require.NoError(t, err) + assert.Contains(t, stripANSI(result), test.expected) + assert.Contains(t, result, "\x1b[1m", "bold Markdown after an unclosed delimiter should still render") + } +} + func TestLatexDoesNotRenderInsideCode(t *testing.T) { t.Parallel() From 1cb8050ed34e3edd8159c02a2def65ed458a74c2 Mon Sep 17 00:00:00 2001 From: Djordje Lukic Date: Fri, 7 Aug 2026 12:55:39 +0200 Subject: [PATCH 3/3] fix(tui): render matrix columns without separators Signed-off-by: Djordje Lukic --- pkg/tui/components/latex/render.go | 2 +- pkg/tui/components/latex/render_test.go | 7 +++++++ pkg/tui/components/latex/testdata/matrix equation.golden | 2 ++ pkg/tui/components/latex/testdata/matrix.golden | 4 ++-- 4 files changed, 12 insertions(+), 3 deletions(-) create mode 100644 pkg/tui/components/latex/testdata/matrix equation.golden diff --git a/pkg/tui/components/latex/render.go b/pkg/tui/components/latex/render.go index d2b1af77b6..6d205559fa 100644 --- a/pkg/tui/components/latex/render.go +++ b/pkg/tui/components/latex/render.go @@ -406,7 +406,7 @@ func (p *parser) matrix(env, body string) string { } parts[j] = v + strings.Repeat("\uf002", widths[j]-runewidth.StringWidth(v)) } - content := strings.Join(parts, " │ ") + content := strings.Join(parts, "\uf002\uf002") left, right := "", "" switch env { case "pmatrix": diff --git a/pkg/tui/components/latex/render_test.go b/pkg/tui/components/latex/render_test.go index ec7af8be85..c7bfb6d09b 100644 --- a/pkg/tui/components/latex/render_test.go +++ b/pkg/tui/components/latex/render_test.go @@ -21,6 +21,13 @@ func TestRender(t *testing.T) { {"operators", `\sum_{i=0}^n \alpha_i + \int_0^\infty e^{-x^2}\,dx`, false}, {"root and relation", `x=\frac{-b\pm\sqrt{b^2-4ac}}{2a}`, false}, {"matrix", `\begin{pmatrix}1&200\\3000&4\end{pmatrix}`, false}, + {"matrix equation", `A = +\begin{pmatrix} +1 & 2 \\ +3 & 4 +\end{pmatrix}, +\qquad +\det(A) = -2`, true}, {"display fraction", `\frac{0.1\ \text{lux}}{100\ \text{lm/W}}`, true}, } diff --git a/pkg/tui/components/latex/testdata/matrix equation.golden b/pkg/tui/components/latex/testdata/matrix equation.golden new file mode 100644 index 0000000000..c47b99fce8 --- /dev/null +++ b/pkg/tui/components/latex/testdata/matrix equation.golden @@ -0,0 +1,2 @@ +A = ⎛ 1 2 ⎞ +⎝ 3 4 ⎠, det(A) = -2 \ No newline at end of file diff --git a/pkg/tui/components/latex/testdata/matrix.golden b/pkg/tui/components/latex/testdata/matrix.golden index fb0d1ceeaa..4c0edbda00 100644 --- a/pkg/tui/components/latex/testdata/matrix.golden +++ b/pkg/tui/components/latex/testdata/matrix.golden @@ -1,2 +1,2 @@ -⎛ 1 │ 200 ⎞ -⎝ 3000 │ 4 ⎠ \ No newline at end of file +⎛ 1 200 ⎞ +⎝ 3000 4 ⎠ \ No newline at end of file