diff --git a/pkg/tui/components/latex/render.go b/pkg/tui/components/latex/render.go new file mode 100644 index 0000000000..6d205559fa --- /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, "\uf002\uf002") + 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..c7bfb6d09b --- /dev/null +++ b/pkg/tui/components/latex/render_test.go @@ -0,0 +1,52 @@ +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}, + {"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}, + } + + 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 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 new file mode 100644 index 0000000000..4c0edbda00 --- /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..b8a01f5dd4 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,97 @@ 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 { + 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') { + 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 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, '\\') +} + +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..7f32f7eb51 --- /dev/null +++ b/pkg/tui/components/markdown/latex_test.go @@ -0,0 +1,103 @@ +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 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() + + 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$") +}