From 23c70954772e67cc0d10d6e039fbf38418503142 Mon Sep 17 00:00:00 2001 From: "Rasmus \"Pez\" Wejlgaard" Date: Sat, 8 Nov 2025 23:22:37 +0000 Subject: [PATCH] fix: latex math support (#6) * fix: latex math support * move latex junk to another file --- internal/ui/latex.go | 380 +++++++++++++++++++++++++++++++++++++++++++ internal/ui/views.go | 49 ++++-- 2 files changed, 418 insertions(+), 11 deletions(-) create mode 100644 internal/ui/latex.go diff --git a/internal/ui/latex.go b/internal/ui/latex.go new file mode 100644 index 0000000..914b4c5 --- /dev/null +++ b/internal/ui/latex.go @@ -0,0 +1,380 @@ +package ui + +import "strings" + +// renderLatexMath converts LaTeX math expressions to Unicode for terminal display +func renderLatexMath(latex string) string { + result := latex + + // Remove LaTeX math delimiters + result = strings.ReplaceAll(result, `\(`, "") + result = strings.ReplaceAll(result, `\)`, "") + result = strings.ReplaceAll(result, `\[`, "") + result = strings.ReplaceAll(result, `\]`, "") + result = strings.ReplaceAll(result, `$$`, "") + + // Remove single $ delimiters (being careful not to remove actual dollar signs in text) + // Simple approach: if line starts/ends with $, remove it + result = strings.TrimSpace(result) + if strings.HasPrefix(result, "$") && strings.HasSuffix(result, "$") && len(result) > 2 { + result = result[1 : len(result)-1] + result = strings.TrimSpace(result) + } + + // Map of common LaTeX commands to Unicode equivalents + replacements := map[string]string{ + // Greek letters (lowercase) + `\alpha`: "α", + `\beta`: "β", + `\gamma`: "γ", + `\delta`: "δ", + `\epsilon`: "ε", + `\zeta`: "ζ", + `\eta`: "η", + `\theta`: "θ", + `\iota`: "ι", + `\kappa`: "κ", + `\lambda`: "λ", + `\mu`: "μ", + `\nu`: "ν", + `\xi`: "ξ", + `\pi`: "π", + `\rho`: "ρ", + `\sigma`: "σ", + `\tau`: "τ", + `\upsilon`: "υ", + `\phi`: "φ", + `\chi`: "χ", + `\psi`: "ψ", + `\omega`: "ω", + + // Greek letters (uppercase) + `\Gamma`: "Γ", + `\Delta`: "Δ", + `\Theta`: "Θ", + `\Lambda`: "Λ", + `\Xi`: "Ξ", + `\Pi`: "Π", + `\Sigma`: "Σ", + `\Upsilon`: "Υ", + `\Phi`: "Φ", + `\Psi`: "Ψ", + `\Omega`: "Ω", + + // Math operators + `\times`: "×", + `\div`: "÷", + `\pm`: "±", + `\mp`: "∓", + `\cdot`: "·", + `\star`: "⋆", + `\ast`: "∗", + `\circ`: "∘", + `\bullet`: "•", + + // Relations + `\le`: "≤", + `\ge`: "≥", + `\leq`: "≤", + `\geq`: "≥", + `\ne`: "≠", + `\neq`: "≠", + `\approx`: "≈", + `\equiv`: "≡", + `\sim`: "∼", + `\simeq`: "≃", + `\propto`: "∝", + + // Arrows + `\to`: "→", + `\rightarrow`: "→", + `\leftarrow`: "←", + `\Rightarrow`: "⇒", + `\Leftarrow`: "⇐", + `\mapsto`: "↦", + + // Set theory + `\in`: "∈", + `\notin`: "∉", + `\subset`: "⊂", + `\supset`: "⊃", + `\subseteq`: "⊆", + `\supseteq`: "⊇", + `\cup`: "∪", + `\cap`: "∩", + `\emptyset`: "∅", + `\forall`: "∀", + `\exists`: "∃", + + // Calculus + `\partial`: "∂", + `\nabla`: "∇", + `\int`: "∫", + `\sum`: "∑", + `\prod`: "∏", + `\infty`: "∞", + + // Logic + `\land`: "∧", + `\lor`: "∨", + `\lnot`: "¬", + `\neg`: "¬", + `\wedge`: "∧", + `\vee`: "∨", + + // Special symbols + `\hbar`: "ℏ", + `\ell`: "ℓ", + `\Re`: "ℜ", + `\Im`: "ℑ", + `\angle`: "∠", + `\triangle`: "△", + `\square`: "□", + `\degree`: "°", + + // Superscripts (common ones) + `^0`: "⁰", + `^1`: "¹", + `^2`: "²", + `^3`: "³", + `^4`: "⁴", + `^5`: "⁵", + `^6`: "⁶", + `^7`: "⁷", + `^8`: "⁸", + `^9`: "⁹", + `^+`: "⁺", + `^-`: "⁻", + `^=`: "⁼", + `^(`: "⁽", + `^)`: "⁾", + + // Subscripts (common ones) + `_0`: "₀", + `_1`: "₁", + `_2`: "₂", + `_3`: "₃", + `_4`: "₄", + `_5`: "₅", + `_6`: "₆", + `_7`: "₇", + `_8`: "₈", + `_9`: "₉", + `_+`: "₊", + `_-`: "₋", + `_=`: "₌", + `_(`: "₍", + `_)`: "₎", + } + + // Apply all replacements + for latexCmd, unicode := range replacements { + result = strings.ReplaceAll(result, latexCmd, unicode) + } + + // Handle simple fractions \frac{a}{b} -> a/b + result = handleFractions(result) + + // Handle square roots \sqrt{x} -> √(x) + result = handleSquareRoots(result) + + // Handle superscripts ^{...} and subscripts _{...} + result = handleSuperscripts(result) + result = handleSubscripts(result) + + return result +} + +// handleFractions converts \frac{numerator}{denominator} to numerator/denominator +func handleFractions(text string) string { + // Simple regex-free approach for basic fractions + result := text + for { + start := strings.Index(result, `\frac{`) + if start == -1 { + break + } + + // Find the numerator + numStart := start + 6 + numEnd, numerator := findBracedContent(result, numStart) + if numEnd == -1 { + break + } + + // Find the denominator + if numEnd >= len(result) || result[numEnd] != '{' { + break + } + denomEnd, denominator := findBracedContent(result, numEnd+1) + if denomEnd == -1 { + break + } + + // Replace \frac{num}{denom} with (num)/(denom) + replacement := "(" + numerator + ")/(" + denominator + ")" + result = result[:start] + replacement + result[denomEnd:] + } + return result +} + +// handleSquareRoots converts \sqrt{x} to √(x) +func handleSquareRoots(text string) string { + result := text + for { + start := strings.Index(result, `\sqrt{`) + if start == -1 { + break + } + + // Find the content + contentStart := start + 6 + contentEnd, content := findBracedContent(result, contentStart) + if contentEnd == -1 { + break + } + + // Replace \sqrt{content} with √(content) + replacement := "√(" + content + ")" + result = result[:start] + replacement + result[contentEnd:] + } + return result +} + +// findBracedContent finds content within braces starting at position i +// Returns the position after the closing brace and the content +func findBracedContent(text string, start int) (int, string) { + if start >= len(text) { + return -1, "" + } + + depth := 1 + i := start + + for i < len(text) && depth > 0 { + if text[i] == '{' { + depth++ + } else if text[i] == '}' { + depth-- + if depth == 0 { + return i + 1, text[start:i] + } + } + i++ + } + + return -1, "" +} + +// handleSuperscripts converts ^{...} to Unicode superscripts where possible +func handleSuperscripts(text string) string { + superscriptMap := map[rune]string{ + '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': "ᶻ", + 'A': "ᴬ", 'B': "ᴮ", 'D': "ᴰ", 'E': "ᴱ", 'G': "ᴳ", + 'H': "ᴴ", 'I': "ᴵ", 'J': "ᴶ", 'K': "ᴷ", 'L': "ᴸ", + 'M': "ᴹ", 'N': "ᴺ", 'O': "ᴼ", 'P': "ᴾ", 'R': "ᴿ", + 'T': "ᵀ", 'U': "ᵁ", 'V': "ⱽ", 'W': "ᵂ", + '+': "⁺", '-': "⁻", '=': "⁼", '(': "⁽", ')': "⁾", + } + + result := text + + // Handle ^{...} format + for { + start := strings.Index(result, "^{") + if start == -1 { + break + } + + contentStart := start + 2 + contentEnd, content := findBracedContent(result, contentStart) + if contentEnd == -1 { + break + } + + // Convert content to superscript + var superscript strings.Builder + for _, ch := range content { + if sup, ok := superscriptMap[ch]; ok { + superscript.WriteString(sup) + } else { + // If no superscript version exists, wrap in parentheses + superscript.WriteRune(ch) + } + } + + result = result[:start] + superscript.String() + result[contentEnd:] + } + + // Handle simple ^x format (single character without braces) + for i := 0; i < len(result)-1; i++ { + if result[i] == '^' && result[i+1] != '{' { + ch := rune(result[i+1]) + if sup, ok := superscriptMap[ch]; ok { + result = result[:i] + sup + result[i+2:] + } + } + } + + return result +} + +// handleSubscripts converts _{...} to Unicode subscripts where possible +func handleSubscripts(text string) string { + subscriptMap := map[rune]string{ + '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': "ₓ", + '+': "₊", '-': "₋", '=': "₌", '(': "₍", ')': "₎", + } + + result := text + + // Handle _{...} format + for { + start := strings.Index(result, "_{") + if start == -1 { + break + } + + contentStart := start + 2 + contentEnd, content := findBracedContent(result, contentStart) + if contentEnd == -1 { + break + } + + // Convert content to subscript + var subscript strings.Builder + for _, ch := range content { + if sub, ok := subscriptMap[ch]; ok { + subscript.WriteString(sub) + } else { + // If no subscript version exists, wrap in parentheses + subscript.WriteRune(ch) + } + } + + result = result[:start] + subscript.String() + result[contentEnd:] + } + + // Handle simple _x format (single character without braces) + for i := 0; i < len(result)-1; i++ { + if result[i] == '_' && result[i+1] != '{' { + ch := rune(result[i+1]) + if sub, ok := subscriptMap[ch]; ok { + result = result[:i] + sub + result[i+2:] + } + } + } + + return result +} diff --git a/internal/ui/views.go b/internal/ui/views.go index 441e0a6..c56834b 100644 --- a/internal/ui/views.go +++ b/internal/ui/views.go @@ -660,11 +660,20 @@ func renderNotesWithHighlighting(notes []string) []string { } else if codeBlockDelimiter == "markdown" { // Ending a markdown code block inCodeBlock = false - // Highlight and add the code + // Highlight and add the code (or render LaTeX) if len(codeLines) > 0 { - highlighted := highlightCode(strings.Join(codeLines, "\n"), codeLanguage) - highlightedLines := strings.Split(highlighted, "\n") - result = append(result, highlightedLines...) + var processedLines []string + if codeLanguage == "latex" { + // Apply LaTeX-to-Unicode conversion + for _, line := range codeLines { + processedLines = append(processedLines, renderLatexMath(line)) + } + } else { + // Apply syntax highlighting + highlighted := highlightCode(strings.Join(codeLines, "\n"), codeLanguage) + processedLines = strings.Split(highlighted, "\n") + } + result = append(result, processedLines...) } result = append(result, note) // Keep the delimiter visible codeLines = []string{} @@ -677,11 +686,20 @@ func renderNotesWithHighlighting(notes []string) []string { // Check for org-mode style code block end if strings.HasPrefix(trimmed, "#+END_SRC") { inCodeBlock = false - // Highlight and add the code + // Highlight and add the code (or render LaTeX) if len(codeLines) > 0 { - highlighted := highlightCode(strings.Join(codeLines, "\n"), codeLanguage) - highlightedLines := strings.Split(highlighted, "\n") - result = append(result, highlightedLines...) + var processedLines []string + if codeLanguage == "latex" { + // Apply LaTeX-to-Unicode conversion + for _, line := range codeLines { + processedLines = append(processedLines, renderLatexMath(line)) + } + } else { + // Apply syntax highlighting + highlighted := highlightCode(strings.Join(codeLines, "\n"), codeLanguage) + processedLines = strings.Split(highlighted, "\n") + } + result = append(result, processedLines...) } result = append(result, note) // Keep the delimiter visible codeLines = []string{} @@ -700,9 +718,18 @@ func renderNotesWithHighlighting(notes []string) []string { // Handle case where code block wasn't closed if inCodeBlock && len(codeLines) > 0 { - highlighted := highlightCode(strings.Join(codeLines, "\n"), codeLanguage) - highlightedLines := strings.Split(highlighted, "\n") - result = append(result, highlightedLines...) + var processedLines []string + if codeLanguage == "latex" { + // Apply LaTeX-to-Unicode conversion + for _, line := range codeLines { + processedLines = append(processedLines, renderLatexMath(line)) + } + } else { + // Apply syntax highlighting + highlighted := highlightCode(strings.Join(codeLines, "\n"), codeLanguage) + processedLines = strings.Split(highlighted, "\n") + } + result = append(result, processedLines...) } return result