Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
528 changes: 528 additions & 0 deletions pkg/tui/components/latex/render.go

Large diffs are not rendered by default.

52 changes: 52 additions & 0 deletions pkg/tui/components/latex/render_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
ℂ³ → ℂ³
3 changes: 3 additions & 0 deletions pkg/tui/components/latex/testdata/display fraction.golden
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
0.1 lux
────────
100 lm/W
1 change: 1 addition & 0 deletions pkg/tui/components/latex/testdata/fraction.golden
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
F₁ = -1/(4x²)
2 changes: 2 additions & 0 deletions pkg/tui/components/latex/testdata/matrix equation.golden
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
A = ⎛ 1 2 ⎞
⎝ 3 4 ⎠, det(A) = -2
2 changes: 2 additions & 0 deletions pkg/tui/components/latex/testdata/matrix.golden
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
⎛ 1 200 ⎞
⎝ 3000 4 ⎠
1 change: 1 addition & 0 deletions pkg/tui/components/latex/testdata/operators.golden
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
∑ᵢ₌₀ⁿ αᵢ + ∫₀^∞ e^(-x²) dx
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
x = (-b±√(b²-4ac))/(2a)
157 changes: 155 additions & 2 deletions pkg/tui/components/markdown/fast_renderer.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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])
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 == "" {
Expand Down
103 changes: 103 additions & 0 deletions pkg/tui/components/markdown/latex_test.go
Original file line number Diff line number Diff line change
@@ -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$")
}
Loading