| 1 | package cli |
| 2 | |
| 3 | import ( |
| 4 | "strings" |
| 5 | "unicode/utf8" |
| 6 | ) |
| 7 | |
| 8 | // latexToUnicode renders a LaTeX math expression as a best-effort Unicode |
| 9 | // approximation suitable for a terminal: Greek letters, operators/relations, |
| 10 | // super/subscripts, \frac, \sqrt and accents map to real glyphs; anything it |
| 11 | // can't represent degrades to a readable plain-text form. There is no Go |
| 12 | // library for this, so the symbol table below is maintained by hand. |
| 13 | func latexToUnicode(expr string) string { |
| 14 | return convertMath([]rune(expr)) |
| 15 | } |
| 16 | |
| 17 | func convertMath(rs []rune) string { |
| 18 | var b strings.Builder |
| 19 | b.Grow(len(rs)) |
| 20 | for i := 0; i < len(rs); { |
| 21 | switch r := rs[i]; r { |
| 22 | case '\\': |
| 23 | i = convertCommand(&b, rs, i) |
| 24 | case '^': |
| 25 | arg, ni := readAtom(rs, i+1, false) |
| 26 | b.WriteString(superscript(convertMath([]rune(arg)))) |
| 27 | i = ni |
| 28 | case '_': |
| 29 | arg, ni := readAtom(rs, i+1, false) |
| 30 | b.WriteString(subscript(convertMath([]rune(arg)))) |
| 31 | i = ni |
| 32 | case '{', '}': |
| 33 | i++ |
| 34 | case '&', '~': |
| 35 | b.WriteByte(' ') |
| 36 | i++ |
| 37 | case '$': |
| 38 | i++ |
| 39 | default: |
| 40 | b.WriteRune(r) |
| 41 | i++ |
| 42 | } |
| 43 | } |
| 44 | return b.String() |
| 45 | } |
| 46 | |
| 47 | // convertCommand consumes the backslash command starting at rs[i] and writes |
| 48 | // its rendering; it returns the index just past everything it consumed. |
| 49 | func convertCommand(b *strings.Builder, rs []rune, i int) int { |
| 50 | j := i + 1 |
| 51 | if j >= len(rs) { |
| 52 | return j |
| 53 | } |
| 54 | if !isASCIILetter(rs[j]) { |
| 55 | switch ch := rs[j]; ch { |
| 56 | case '\\': |
| 57 | b.WriteString(" ") |
| 58 | case ',', ';', ':', '!', ' ': |
| 59 | b.WriteByte(' ') |
| 60 | default: |
| 61 | b.WriteRune(ch) |
| 62 | } |
| 63 | return j + 1 |
| 64 | } |
| 65 | |
| 66 | k := j |
| 67 | for k < len(rs) && isASCIILetter(rs[k]) { |
| 68 | k++ |
| 69 | } |
| 70 | cmd := string(rs[j:k]) |
| 71 | |
| 72 | switch cmd { |
| 73 | case "frac", "tfrac", "dfrac": |
| 74 | num, k2 := readAtom(rs, k, true) |
| 75 | den, k3 := readAtom(rs, k2, true) |
| 76 | b.WriteString(renderFrac(convertMath([]rune(num)), convertMath([]rune(den)))) |
| 77 | return k3 |
| 78 | case "sqrt": |
| 79 | idx := "" |
| 80 | if k < len(rs) && rs[k] == '[' { |
| 81 | idx, k = readBracket(rs, k) |
| 82 | } |
| 83 | arg, k2 := readAtom(rs, k, true) |
| 84 | b.WriteString(renderSqrt(idx, convertMath([]rune(arg)))) |
| 85 | return k2 |
| 86 | case "text", "textrm", "textbf", "textit", "mathrm", "mathsf", "mathtt", "mathit", "mathbf", "mathcal", "operatorname": |
| 87 | arg, k2 := readAtom(rs, k, true) |
| 88 | b.WriteString(arg) |
| 89 | return k2 |
| 90 | case "mathbb": |
| 91 | arg, k2 := readAtom(rs, k, true) |
| 92 | b.WriteString(blackboard(arg)) |
| 93 | return k2 |
| 94 | case "boxed": |
| 95 | // A terminal can't draw a box, so render only the inner content. |
| 96 | // Without this, \boxed{x} printed the literal word "boxedx". |
| 97 | arg, k2 := readAtom(rs, k, true) |
| 98 | b.WriteString(convertMath([]rune(arg))) |
| 99 | return k2 |
| 100 | case "left", "right": |
| 101 | // \left. / \right. are invisible delimiters — drop the dot; any other |
| 102 | // delimiter follows as ordinary text and renders its glyph. |
| 103 | if k < len(rs) && rs[k] == '.' { |
| 104 | return k + 1 |
| 105 | } |
| 106 | return k |
| 107 | case "big", "Big", "bigg", "Bigg", "bigl", "bigr", "Bigl", "Bigr", "displaystyle", "textstyle", "limits", "nolimits": |
| 108 | return k |
| 109 | case "begin", "end": |
| 110 | _, k2 := readAtom(rs, k, true) |
| 111 | return k2 |
| 112 | } |
| 113 | |
| 114 | if combining, ok := accents[cmd]; ok { |
| 115 | arg, k2 := readAtom(rs, k, true) |
| 116 | b.WriteString(applyCombining(convertMath([]rune(arg)), combining)) |
| 117 | return k2 |
| 118 | } |
| 119 | if sym, ok := symbols[cmd]; ok { |
| 120 | b.WriteString(sym) |
| 121 | return k |
| 122 | } |
| 123 | b.WriteString(cmd) |
| 124 | return k |
| 125 | } |
| 126 | |
| 127 | // readAtom reads the argument of a command or script: a {balanced group}, a |
| 128 | // \command, or a single rune. Returns the inner text (no surrounding braces) |
| 129 | // and the index just past it. |
| 130 | func readAtom(rs []rune, i int, skipSpaces bool) (string, int) { |
| 131 | if skipSpaces { |
| 132 | for i < len(rs) && rs[i] == ' ' { |
| 133 | i++ |
| 134 | } |
| 135 | } |
| 136 | if i >= len(rs) { |
| 137 | return "", i |
| 138 | } |
| 139 | switch rs[i] { |
| 140 | case '{': |
| 141 | depth := 0 |
| 142 | start := i + 1 |
| 143 | for j := i; j < len(rs); j++ { |
| 144 | switch rs[j] { |
| 145 | case '{': |
| 146 | depth++ |
| 147 | case '}': |
| 148 | depth-- |
| 149 | if depth == 0 { |
| 150 | return string(rs[start:j]), j + 1 |
| 151 | } |
| 152 | } |
| 153 | } |
| 154 | return string(rs[start:]), len(rs) |
| 155 | case '\\': |
| 156 | k := i + 1 |
| 157 | if k < len(rs) && !isASCIILetter(rs[k]) { |
| 158 | return string(rs[i : k+1]), k + 1 |
| 159 | } |
| 160 | for k < len(rs) && isASCIILetter(rs[k]) { |
| 161 | k++ |
| 162 | } |
| 163 | return string(rs[i:k]), k |
| 164 | default: |
| 165 | return string(rs[i]), i + 1 |
| 166 | } |
| 167 | } |
| 168 | |
| 169 | func readBracket(rs []rune, i int) (string, int) { |
| 170 | start := i + 1 |
| 171 | for j := start; j < len(rs); j++ { |
| 172 | if rs[j] == ']' { |
| 173 | return string(rs[start:j]), j + 1 |
| 174 | } |
| 175 | } |
| 176 | return "", len(rs) |
| 177 | } |
| 178 | |
| 179 | func renderFrac(num, den string) string { |
| 180 | return wrapIfCompound(num) + "/" + wrapIfCompound(den) |
| 181 | } |
| 182 | |
| 183 | func renderSqrt(idx, arg string) string { |
| 184 | if utf8.RuneCountInString(arg) > 1 { |
| 185 | arg = "(" + arg + ")" |
| 186 | } |
| 187 | switch idx { |
| 188 | case "", "2": |
| 189 | return "√" + arg |
| 190 | case "3": |
| 191 | return "∛" + arg |
| 192 | case "4": |
| 193 | return "∜" + arg |
| 194 | } |
| 195 | return superscript(idx) + "√" + arg |
| 196 | } |
| 197 | |
| 198 | func wrapIfCompound(s string) string { |
| 199 | if utf8.RuneCountInString(s) > 1 { |
| 200 | return "(" + s + ")" |
| 201 | } |
| 202 | return s |
| 203 | } |
| 204 | |
| 205 | func applyCombining(s string, mark rune) string { |
| 206 | rs := []rune(s) |
| 207 | if len(rs) == 0 { |
| 208 | return string(mark) |
| 209 | } |
| 210 | return string(rs[0]) + string(mark) + string(rs[1:]) |
| 211 | } |
| 212 | |
| 213 | func blackboard(s string) string { |
| 214 | var b strings.Builder |
| 215 | for _, r := range s { |
| 216 | if bb, ok := blackboardCaps[r]; ok { |
| 217 | b.WriteRune(bb) |
| 218 | } else { |
| 219 | b.WriteRune(r) |
| 220 | } |
| 221 | } |
| 222 | return b.String() |
| 223 | } |
| 224 | |
| 225 | func superscript(s string) string { |
| 226 | if t, ok := mapAll(s, superMap); ok { |
| 227 | return t |
| 228 | } |
| 229 | if utf8.RuneCountInString(s) == 1 { |
| 230 | return "^" + s |
| 231 | } |
| 232 | return "^(" + s + ")" |
| 233 | } |
| 234 | |
| 235 | func subscript(s string) string { |
| 236 | if t, ok := mapAll(s, subMap); ok { |
| 237 | return t |
| 238 | } |
| 239 | if utf8.RuneCountInString(s) == 1 { |
| 240 | return "_" + s |
| 241 | } |
| 242 | return "_(" + s + ")" |
| 243 | } |
| 244 | |
| 245 | func mapAll(s string, m map[rune]rune) (string, bool) { |
| 246 | if s == "" { |
| 247 | return "", true |
| 248 | } |
| 249 | var b strings.Builder |
| 250 | for _, r := range s { |
| 251 | c, ok := m[r] |
| 252 | if !ok { |
| 253 | return "", false |
| 254 | } |
| 255 | b.WriteRune(c) |
| 256 | } |
| 257 | return b.String(), true |
| 258 | } |
| 259 | |
| 260 | func isASCIILetter(r rune) bool { |
| 261 | return (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') |
| 262 | } |
| 263 | |
| 264 | // normalizeMath rewrites the alternate math delimiters \(..\) and \[..\] to |
| 265 | // $..$ / $$..$$ and collapses newlines inside a $$ display block onto one line |
| 266 | // so the inline math parser sees a single contiguous run. It tracks fenced and |
| 267 | // inline code so literal delimiters inside code are never rewritten. |
| 268 | func normalizeMath(s string) string { |
| 269 | rs := []rune(s) |
| 270 | n := len(rs) |
| 271 | var b strings.Builder |
| 272 | b.Grow(len(s)) |
| 273 | |
| 274 | inFenced, inCode, inDisplay := false, false, false |
| 275 | |
| 276 | for i := 0; i < n; { |
| 277 | r := rs[i] |
| 278 | |
| 279 | if r == '`' && i+2 < n && rs[i+1] == '`' && rs[i+2] == '`' { |
| 280 | inFenced = !inFenced |
| 281 | b.WriteString("```") |
| 282 | i += 3 |
| 283 | continue |
| 284 | } |
| 285 | if r == '`' && !inFenced { |
| 286 | inCode = !inCode |
| 287 | b.WriteRune(r) |
| 288 | i++ |
| 289 | continue |
| 290 | } |
| 291 | if inFenced || inCode { |
| 292 | b.WriteRune(r) |
| 293 | i++ |
| 294 | continue |
| 295 | } |
| 296 | |
| 297 | if r == '\\' && i+1 < n { |
| 298 | switch rs[i+1] { |
| 299 | case '\\': |
| 300 | b.WriteString("\\\\") |
| 301 | i += 2 |
| 302 | continue |
| 303 | case '[': |
| 304 | b.WriteString("$$") |
| 305 | inDisplay = true |
| 306 | i += 2 |
| 307 | continue |
| 308 | case ']': |
| 309 | b.WriteString("$$") |
| 310 | inDisplay = false |
| 311 | i += 2 |
| 312 | continue |
| 313 | case '(': |
| 314 | b.WriteString("$") |
| 315 | i += 2 |
| 316 | continue |
| 317 | case ')': |
| 318 | b.WriteString("$") |
| 319 | i += 2 |
| 320 | continue |
| 321 | } |
| 322 | } |
| 323 | if r == '$' && i+1 < n && rs[i+1] == '$' { |
| 324 | b.WriteString("$$") |
| 325 | inDisplay = !inDisplay |
| 326 | i += 2 |
| 327 | continue |
| 328 | } |
| 329 | if r == '\n' && inDisplay { |
| 330 | b.WriteByte(' ') |
| 331 | i++ |
| 332 | continue |
| 333 | } |
| 334 | |
| 335 | b.WriteRune(r) |
| 336 | i++ |
| 337 | } |
| 338 | return b.String() |
| 339 | } |
| 340 | |
| 341 | var symbols = map[string]string{ |
| 342 | "alpha": "α", "beta": "β", "gamma": "γ", "delta": "δ", "epsilon": "ε", |
| 343 | "varepsilon": "ε", "zeta": "ζ", "eta": "η", "theta": "θ", "vartheta": "ϑ", |
| 344 | "iota": "ι", "kappa": "κ", "lambda": "λ", "mu": "μ", "nu": "ν", "xi": "ξ", |
| 345 | "omicron": "ο", "pi": "π", "varpi": "ϖ", "rho": "ρ", "varrho": "ϱ", |
| 346 | "sigma": "σ", "varsigma": "ς", "tau": "τ", "upsilon": "υ", "phi": "φ", |
| 347 | "varphi": "ϕ", "chi": "χ", "psi": "ψ", "omega": "ω", |
| 348 | "Gamma": "Γ", "Delta": "Δ", "Theta": "Θ", "Lambda": "Λ", "Xi": "Ξ", |
| 349 | "Pi": "Π", "Sigma": "Σ", "Upsilon": "Υ", "Phi": "Φ", "Psi": "Ψ", "Omega": "Ω", |
| 350 | |
| 351 | "times": "×", "div": "÷", "cdot": "·", "ast": "∗", "star": "⋆", |
| 352 | "pm": "±", "mp": "∓", "oplus": "⊕", "ominus": "⊖", "otimes": "⊗", |
| 353 | "oslash": "⊘", "odot": "⊙", "circ": "∘", "bullet": "•", "setminus": "∖", |
| 354 | |
| 355 | "leq": "≤", "le": "≤", "geq": "≥", "ge": "≥", "neq": "≠", "ne": "≠", |
| 356 | "equiv": "≡", "approx": "≈", "cong": "≅", "sim": "∼", "simeq": "≃", |
| 357 | "propto": "∝", "ll": "≪", "gg": "≫", "doteq": "≐", "asymp": "≍", |
| 358 | |
| 359 | "leftarrow": "←", "rightarrow": "→", "to": "→", "gets": "←", |
| 360 | "leftrightarrow": "↔", "Leftarrow": "⇐", "Rightarrow": "⇒", |
| 361 | "Leftrightarrow": "⇔", "implies": "⇒", "iff": "⇔", "mapsto": "↦", |
| 362 | "uparrow": "↑", "downarrow": "↓", "longrightarrow": "⟶", "longleftarrow": "⟵", |
| 363 | |
| 364 | "sum": "∑", "prod": "∏", "coprod": "∐", "int": "∫", "iint": "∬", |
| 365 | "iiint": "∭", "oint": "∮", "nabla": "∇", "partial": "∂", |
| 366 | "infty": "∞", "sqrt": "√", "surd": "√", |
| 367 | |
| 368 | "in": "∈", "notin": "∉", "ni": "∋", "subset": "⊂", "supset": "⊃", |
| 369 | "subseteq": "⊆", "supseteq": "⊇", "cup": "∪", "cap": "∩", |
| 370 | "emptyset": "∅", "varnothing": "∅", "forall": "∀", "exists": "∃", |
| 371 | "nexists": "∄", "neg": "¬", "lnot": "¬", "land": "∧", "wedge": "∧", |
| 372 | "lor": "∨", "vee": "∨", |
| 373 | |
| 374 | "angle": "∠", "perp": "⊥", "parallel": "∥", "mid": "∣", "nmid": "∤", |
| 375 | "vert": "∣", "lvert": "∣", "rvert": "∣", |
| 376 | "Vert": "‖", "lVert": "‖", "rVert": "‖", |
| 377 | "triangle": "△", "square": "□", "diamond": "◇", "top": "⊤", "bot": "⊥", |
| 378 | "vdash": "⊢", "models": "⊨", "therefore": "∴", "because": "∵", |
| 379 | |
| 380 | "ldots": "…", "dots": "…", "cdots": "⋯", "vdots": "⋮", "ddots": "⋱", |
| 381 | "prime": "′", "degree": "°", "deg": "°", "hbar": "ℏ", "ell": "ℓ", |
| 382 | "Re": "ℜ", "Im": "ℑ", "aleph": "ℵ", "wp": "℘", |
| 383 | "langle": "⟨", "rangle": "⟩", "lceil": "⌈", "rceil": "⌉", |
| 384 | "lfloor": "⌊", "rfloor": "⌋", "backslash": "\\", |
| 385 | |
| 386 | "quad": " ", "qquad": " ", "space": " ", "thinspace": " ", |
| 387 | "lim": "lim", "sin": "sin", "cos": "cos", "tan": "tan", "log": "log", |
| 388 | "ln": "ln", "exp": "exp", "min": "min", "max": "max", "det": "det", |
| 389 | "gcd": "gcd", "dim": "dim", "ker": "ker", |
| 390 | } |
| 391 | |
| 392 | var accents = map[string]rune{ |
| 393 | "hat": '̂', "widehat": '̂', "bar": '̄', "overline": '̄', |
| 394 | "vec": '⃗', "dot": '̇', "ddot": '̈', "tilde": '̃', |
| 395 | "widetilde": '̃', "acute": '́', "grave": '̀', "check": '̌', |
| 396 | } |
| 397 | |
| 398 | var superMap = map[rune]rune{ |
| 399 | '0': '⁰', '1': '¹', '2': '²', '3': '³', '4': '⁴', '5': '⁵', '6': '⁶', |
| 400 | '7': '⁷', '8': '⁸', '9': '⁹', '+': '⁺', '-': '⁻', '=': '⁼', '(': '⁽', |
| 401 | ')': '⁾', 'a': 'ᵃ', 'b': 'ᵇ', 'c': 'ᶜ', 'd': 'ᵈ', 'e': 'ᵉ', 'f': 'ᶠ', |
| 402 | 'g': 'ᵍ', 'h': 'ʰ', 'i': 'ⁱ', 'j': 'ʲ', 'k': 'ᵏ', 'l': 'ˡ', 'm': 'ᵐ', |
| 403 | 'n': 'ⁿ', 'o': 'ᵒ', 'p': 'ᵖ', 'r': 'ʳ', 's': 'ˢ', 't': 'ᵗ', 'u': 'ᵘ', |
| 404 | 'v': 'ᵛ', 'w': 'ʷ', 'x': 'ˣ', 'y': 'ʸ', 'z': 'ᶻ', |
| 405 | } |
| 406 | |
| 407 | var subMap = map[rune]rune{ |
| 408 | '0': '₀', '1': '₁', '2': '₂', '3': '₃', '4': '₄', '5': '₅', '6': '₆', |
| 409 | '7': '₇', '8': '₈', '9': '₉', '+': '₊', '-': '₋', '=': '₌', '(': '₍', |
| 410 | ')': '₎', 'a': 'ₐ', 'e': 'ₑ', 'h': 'ₕ', 'i': 'ᵢ', 'j': 'ⱼ', 'k': 'ₖ', |
| 411 | 'l': 'ₗ', 'm': 'ₘ', 'n': 'ₙ', 'o': 'ₒ', 'p': 'ₚ', 'r': 'ᵣ', 's': 'ₛ', |
| 412 | 't': 'ₜ', 'u': 'ᵤ', 'v': 'ᵥ', 'x': 'ₓ', |
| 413 | } |
| 414 | |
| 415 | var blackboardCaps = map[rune]rune{ |
| 416 | 'A': '𝔸', 'B': '𝔹', 'C': 'ℂ', 'D': '𝔻', 'E': '𝔼', 'F': '𝔽', 'G': '𝔾', |
| 417 | 'H': 'ℍ', 'I': '𝕀', 'J': '𝕁', 'K': '𝕂', 'L': '𝕃', 'M': '𝕄', 'N': 'ℕ', |
| 418 | 'O': '𝕆', 'P': 'ℙ', 'Q': 'ℚ', 'R': 'ℝ', 'S': '𝕊', 'T': '𝕋', 'U': '𝕌', |
| 419 | 'V': '𝕍', 'W': '𝕎', 'X': '𝕏', 'Y': '𝕐', 'Z': 'ℤ', |
| 420 | } |
| 421 |