Files
nanobot/tui/src/latex.ts
T

902 lines
23 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const MATH_SYMBOLS: Readonly<Record<string, string>> = {
alpha: "α",
beta: "β",
gamma: "γ",
delta: "δ",
epsilon: "ε",
varepsilon: "ϵ",
zeta: "ζ",
eta: "η",
theta: "θ",
vartheta: "ϑ",
iota: "ι",
kappa: "κ",
lambda: "λ",
mu: "μ",
nu: "ν",
xi: "ξ",
omicron: "ο",
pi: "π",
varpi: "ϖ",
rho: "ρ",
varrho: "ϱ",
sigma: "σ",
varsigma: "ς",
tau: "τ",
upsilon: "υ",
phi: "φ",
varphi: "ϕ",
chi: "χ",
psi: "ψ",
omega: "ω",
Gamma: "Γ",
Delta: "Δ",
Theta: "Θ",
Lambda: "Λ",
Xi: "Ξ",
Pi: "Π",
Sigma: "Σ",
Upsilon: "Υ",
Phi: "Φ",
Psi: "Ψ",
Omega: "Ω",
times: " × ",
div: " ÷ ",
cdot: " · ",
ast: " ",
pm: " ± ",
mp: " ∓ ",
approx: " ≈ ",
sim: " ",
simeq: " ≃ ",
cong: " ≅ ",
equiv: " ≡ ",
ne: " ≠ ",
neq: " ≠ ",
le: " ≤ ",
leq: " ≤ ",
ge: " ≥ ",
geq: " ≥ ",
ll: " ≪ ",
gg: " ≫ ",
propto: " ∝ ",
to: " → ",
gets: " ← ",
rightarrow: " → ",
leftarrow: " ← ",
leftrightarrow: " ↔ ",
Rightarrow: " ⇒ ",
Leftarrow: " ⇐ ",
Leftrightarrow: " ⇔ ",
mapsto: " ↦ ",
in: " ∈ ",
ni: " ∋ ",
notin: " ∉ ",
subset: " ⊂ ",
supset: " ⊃ ",
subseteq: " ⊆ ",
supseteq: " ⊇ ",
cup: " ",
cap: " ∩ ",
setminus: " ",
emptyset: "∅",
varnothing: "∅",
forall: "∀",
exists: "∃",
neg: "¬",
land: " ∧ ",
lor: " ",
wedge: " ∧ ",
vee: " ",
sum: "∑",
prod: "∏",
coprod: "∐",
int: "∫",
iint: "∬",
iiint: "∭",
oint: "∮",
partial: "∂",
nabla: "∇",
infty: "∞",
ell: "",
hbar: "ℏ",
Re: "",
Im: "",
angle: "∠",
degree: "°",
prime: "",
ldots: "…",
cdots: "⋯",
vdots: "⋮",
ddots: "⋱",
langle: "⟨",
rangle: "⟩",
lceil: "⌈",
rceil: "⌉",
lfloor: "⌊",
rfloor: "⌋",
vert: "|",
Vert: "‖",
}
const NAMED_FUNCTIONS = new Set([
"arccos",
"arcsin",
"arctan",
"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",
])
const STYLE_COMMANDS = new Set([
"mathbf",
"mathbb",
"mathcal",
"mathfrak",
"mathit",
"mathrm",
"mathsf",
"mathtt",
"boldsymbol",
"bm",
])
const ACCENTS: Readonly<Record<string, string>> = {
acute: "\u0301",
bar: "\u0304",
breve: "\u0306",
check: "\u030c",
ddot: "\u0308",
dot: "\u0307",
grave: "\u0300",
hat: "\u0302",
overline: "\u0305",
tilde: "\u0303",
vec: "\u20d7",
}
const SUPER: Readonly<Record<string, 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: "ᶻ",
}
const SUB: Readonly<Record<string, 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: "ₓ",
}
const MAX_MATH_LENGTH = 20_000
const MAX_GROUP_DEPTH = 64
/** Converts supported LaTeX math spans without changing the stored Markdown source. */
export function renderLatexAsUnicode(markdown: string): string {
if (!markdown.includes("$") && !markdown.includes("\\(") && !markdown.includes("\\[")) {
return markdown
}
let rendered = ""
let cursor = 0
const missingClosers = new Set<string>()
const inlineCodeEnds = markdown.includes("`") ? markdownInlineCodeEnds(markdown) : null
const linkDestinationEnds = markdown.includes("](")
? markdownLinkDestinationEnds(markdown)
: null
while (cursor < markdown.length) {
const fencedEnd = fencedCodeEnd(markdown, cursor)
if (fencedEnd !== null) {
rendered += markdown.slice(cursor, fencedEnd)
cursor = fencedEnd
continue
}
if (markdown[cursor] === "`") {
const delimiterLength = repeatedCharacterCount(markdown, cursor, "`")
const inlineEnd = inlineCodeEnds?.get(cursor)
if (inlineEnd !== undefined) {
rendered += markdown.slice(cursor, inlineEnd)
cursor = inlineEnd
continue
}
rendered += markdown.slice(cursor, cursor + delimiterLength)
cursor += delimiterLength
continue
}
const destinationEnd = markdownDestinationEnd(markdown, cursor, linkDestinationEnds)
if (destinationEnd !== null) {
rendered += markdown.slice(cursor, destinationEnd)
cursor = destinationEnd
continue
}
const explicitOpening = explicitMathOpeningAt(markdown, cursor)
const math = mathSpanAt(markdown, cursor, explicitOpening, missingClosers)
if (math) {
if (math.literal) {
rendered += markdown.slice(cursor, math.end)
cursor = math.end
continue
}
const converted = convertMath(math.content)
if (converted !== null) {
rendered += escapeMarkdown(converted)
cursor = math.end
continue
}
rendered += markdown.slice(cursor, math.end)
cursor = math.end
continue
}
if (explicitOpening) {
rendered += explicitOpening
cursor += explicitOpening.length
continue
}
rendered += markdown[cursor]
cursor += 1
}
return rendered
}
interface MathSpan {
content: string
end: number
literal?: boolean
}
interface ParseState {
valid: boolean
}
function convertMath(source: string): string | null {
if (!validMathStructure(source)) return null
const state: ParseState = { valid: true }
const converted = normalizeMath(new LatexParser(source, state).parse())
return state.valid && converted ? converted : null
}
function validMathStructure(source: string): boolean {
if (source.length > MAX_MATH_LENGTH) return false
let depth = 0
for (let cursor = 0; cursor < source.length; cursor += 1) {
const value = source[cursor]
if (value !== "{" && value !== "}") continue
if (isEscaped(source, cursor)) continue
if (value === "{") {
depth += 1
if (depth > MAX_GROUP_DEPTH) return false
} else if (source[cursor] === "}") {
depth -= 1
if (depth < 0) return false
}
}
return depth === 0
}
function explicitMathOpeningAt(markdown: string, start: number): string | null {
const opening = markdown.startsWith("\\[", start)
? "\\["
: markdown.startsWith("\\(", start)
? "\\("
: markdown.startsWith("$$", start)
? "$$"
: null
return opening && !isEscaped(markdown, start) ? opening : null
}
function mathSpanAt(
markdown: string,
start: number,
explicitOpening: string | null,
missingClosers: Set<string>,
): MathSpan | null {
if (explicitOpening === "\\[") {
return delimitedMath(markdown, start, "\\[", "\\]", false, missingClosers)
}
if (explicitOpening === "\\(") {
return delimitedMath(markdown, start, "\\(", "\\)", true, missingClosers)
}
if (explicitOpening === "$$") {
return delimitedMath(markdown, start, "$$", "$$", false, missingClosers)
}
if (
markdown[start] !== "$"
|| markdown[start + 1] === "$"
|| isWhitespace(markdown[start + 1])
|| isEscaped(markdown, start)
) {
return null
}
const span = delimitedMath(markdown, start, "$", "$", true, missingClosers)
if (!span || isWhitespace(span.content.at(-1))) return null
const first = span.content[0]
if (first && /[0-9]/u.test(first)) {
const following = markdown[span.end] ?? ""
if (!isWhitespace(following) && /[+\-*/]\s*$/u.test(span.content)) {
const adjacent = delimitedMath(
markdown,
span.end - 1,
"$",
"$",
true,
missingClosers,
)
if (adjacent && !isWhitespace(adjacent.content.at(-1))) {
return { content: "", end: adjacent.end, literal: true }
}
}
if (/[0-9]/u.test(following)) return null
}
if (first && /[0-9]/u.test(first) && !/[\\^_={}|+<>]/u.test(span.content)) return null
if (
/^[A-Z_][A-Z0-9_]*(?:\/|\s.*)$/u.test(span.content)
&& /[A-Z_]/u.test(markdown[span.end] ?? "")
&& !/[\\^_={}|+<>]/u.test(span.content)
) {
return null
}
return span
}
function delimitedMath(
source: string,
start: number,
opening: string,
closing: string,
singleLine: boolean,
missingClosers: Set<string>,
): MathSpan | null {
const contentStart = start + opening.length
const lineEnd = singleLine ? source.indexOf("\n", contentStart) : -1
const boundary = lineEnd < 0 ? source.length : lineEnd
const cacheKey = `${closing}:${boundary}`
if (missingClosers.has(cacheKey)) return null
for (let close = contentStart; close + closing.length <= boundary; close += 1) {
if (!source.startsWith(closing, close) || isEscaped(source, close)) continue
if (closing === "$" && (source[close - 1] === "$" || source[close + 1] === "$")) continue
const content = source.slice(start + opening.length, close)
return content ? { content, end: close + closing.length } : null
}
missingClosers.add(cacheKey)
return null
}
function fencedCodeEnd(source: string, start: number): number | null {
const marker = source[start]
if (marker !== "`" && marker !== "~") return null
const lineStart = source.lastIndexOf("\n", start - 1) + 1
const context = fenceContext(source.slice(lineStart, start))
if (!context) return null
const count = repeatedCharacterCount(source, start, marker)
if (count < 3) return null
const openingLineEnd = source.indexOf("\n", start + count)
if (openingLineEnd < 0) return source.length
let candidateStart = openingLineEnd + 1
while (candidateStart <= source.length) {
const candidateEnd = source.indexOf("\n", candidateStart)
const lineEnd = candidateEnd < 0 ? source.length : candidateEnd
const line = source.slice(candidateStart, lineEnd)
const markerOffset = closingFenceOffset(line, context)
if (markerOffset === null) {
if (candidateEnd < 0) break
candidateStart = candidateEnd + 1
continue
}
const markerStart = candidateStart + markerOffset
const closingCount = repeatedCharacterCount(source, markerStart, marker)
if (closingCount >= count && source.slice(markerStart + closingCount, lineEnd).trim() === "") {
return candidateEnd < 0 ? source.length : candidateEnd + 1
}
if (candidateEnd < 0) break
candidateStart = candidateEnd + 1
}
return source.length
}
interface FenceContext {
quoteDepth: number
maxIndent: number
}
function fenceContext(prefix: string): FenceContext | null {
let rest = prefix
let quoteDepth = 0
while (true) {
const quote = rest.match(/^[ \t]{0,3}>[ \t]?/u)?.[0]
if (!quote) break
quoteDepth += 1
rest = rest.slice(quote.length)
}
const list = rest.match(/^[ \t]*(?:[-+*]|[0-9]{1,9}[.)])[ \t]+/u)?.[0]
if (list) rest = rest.slice(list.length)
if (!/^[ \t]*$/u.test(rest)) return null
return { quoteDepth, maxIndent: Math.max(3, list?.length ?? 0, rest.length) }
}
function closingFenceOffset(line: string, context: FenceContext): number | null {
let offset = 0
for (let depth = 0; depth < context.quoteDepth; depth += 1) {
const quote = line.slice(offset).match(/^[ \t]{0,3}>[ \t]?/u)?.[0]
if (!quote) return null
offset += quote.length
}
const indent = line.slice(offset).match(new RegExp(`^[ \\t]{0,${context.maxIndent}}`, "u"))?.[0]
return indent === undefined ? null : offset + indent.length
}
function markdownInlineCodeEnds(source: string): ReadonlyMap<number, number> {
const ends = new Map<number, number>()
const previousRun = new Map<number, number>()
let cursor = 0
while (cursor < source.length) {
if (source[cursor] !== "`") {
cursor += 1
continue
}
const count = repeatedCharacterCount(source, cursor, "`")
const previous = previousRun.get(count)
if (previous !== undefined) ends.set(previous, cursor + count)
previousRun.set(count, cursor)
cursor += count
}
return ends
}
function markdownLinkDestinationEnds(source: string): ReadonlyMap<number, number> {
const ends = new Map<number, number>()
const openParentheses: number[] = []
let cursor = 0
while (cursor < source.length) {
const value = source[cursor]
if (value === "\\") {
cursor += Math.min(2, source.length - cursor)
continue
}
if (value === "\n") {
openParentheses.length = 0
} else if (value === "(") {
openParentheses.push(cursor)
} else if (value === ")") {
const opening = openParentheses.pop()
if (opening !== undefined && source[opening - 1] === "]" && !isEscaped(source, opening - 1)) {
ends.set(opening, cursor + 1)
}
}
cursor += 1
}
return ends
}
function markdownDestinationEnd(
source: string,
start: number,
linkDestinationEnds: ReadonlyMap<number, number> | null,
): number | null {
if (source[start] === "(" && source[start - 1] === "]" && !isEscaped(source, start - 1)) {
return linkDestinationEnds?.get(start) ?? null
}
if (source[start] === "<") {
const angle = source.slice(start).match(
/^<(?:[A-Za-z][A-Za-z0-9+.-]{1,31}:[^<>\s]*|[^<>\s@]+@[^<>\s@]+|\/?[A-Za-z][^<>\n]*)>/u,
)?.[0]
if (angle) return start + angle.length
}
if (
source.startsWith("http://", start)
|| source.startsWith("https://", start)
|| source.startsWith("mailto:", start)
) {
let end = start
while (end < source.length && !/[\s<>]/u.test(source[end] ?? "")) end += 1
return end
}
return null
}
function repeatedCharacterCount(source: string, start: number, value: string): number {
let end = start
while (source[end] === value) end += 1
return end - start
}
function isEscaped(source: string, index: number): boolean {
let backslashes = 0
for (let cursor = index - 1; cursor >= 0 && source[cursor] === "\\"; cursor -= 1) {
backslashes += 1
}
return backslashes % 2 === 1
}
function isWhitespace(value: string | undefined): boolean {
return value === undefined || /\s/u.test(value)
}
class LatexParser {
private cursor = 0
constructor(
private readonly source: string,
private readonly state: ParseState,
) {}
parse(): string {
let result = ""
while (this.cursor < this.source.length) {
const value = this.source[this.cursor]
if (value === undefined) break
if (/\s/u.test(value)) {
result += " "
this.cursor += 1
} else if (value === "\\") {
result += this.command()
} else if (value === "{") {
const group = this.rawGroup()
result += group === null ? "{" : new LatexParser(group, this.state).parse()
} else if (value === "^" || value === "_") {
this.cursor += 1
const argument = this.argument()
result += script(argument ?? "", value === "^" ? SUPER : SUB, value)
} else if (value === "&" || value === "~") {
result += " "
this.cursor += 1
} else {
result += value
this.cursor += 1
}
}
return result
}
private command(): string {
this.cursor += 1
const first = this.source[this.cursor]
if (first === undefined) return ""
if (!/[A-Za-z]/u.test(first)) {
this.cursor += 1
if (first === "\\") return "\n"
if (",:;! ".includes(first)) return " "
return first
}
const start = this.cursor
while (/[A-Za-z]/u.test(this.source[this.cursor] ?? "")) this.cursor += 1
const name = this.source.slice(start, this.cursor)
if (this.source[this.cursor] === "*") this.cursor += 1
const symbol = MATH_SYMBOLS[name]
if (symbol !== undefined) return symbol
if (NAMED_FUNCTIONS.has(name)) return name
if ([
"left",
"right",
"limits",
"nolimits",
"displaystyle",
"textstyle",
"scriptstyle",
"scriptscriptstyle",
].includes(name)) return ""
if (["quad", "qquad", "enspace", "enskip"].includes(name)) return " "
if (["hspace", "vspace"].includes(name)) {
this.rawGroup()
return " "
}
if (["frac", "dfrac", "tfrac"].includes(name)) {
const numerator = this.argument()
const denominator = this.argument()
if (numerator !== null && denominator !== null) {
return `${fractionSide(numerator)} / ${fractionSide(denominator)}`
}
return "frac"
}
if (name === "sqrt") {
const root = this.optionalArgument()
const radicand = this.argument()
if (radicand === null) return "√"
const index = root ? script(root, SUPER, "^") : ""
return `${index}√(${radicand})`
}
if (["text", "textrm", "textsf", "texttt", "textnormal", "mbox"].includes(name)) {
const text = this.rawGroup()
return text === null ? name : plainText(text, this.state)
}
if (STYLE_COMMANDS.has(name)) return this.argument() ?? ""
if (name === "operatorname") {
const operator = this.rawGroup()
return operator === null ? "" : plainText(operator, this.state)
}
const accent = ACCENTS[name]
if (accent !== undefined) {
const argument = this.argument()
return argument === null ? "" : `${argument}${accent}`
}
if (name === "begin" || name === "end") {
const environment = this.rawGroup()
return name === "begin" && environment === "cases" ? "{\n" : ""
}
if (name === "not") return this.negatedSymbol()
if (name === "mod") return "mod"
if (name === "pmod") {
const value = this.argument()
return value === null ? "mod" : `(mod ${value})`
}
this.state.valid = false
return `\\${name}`
}
private negatedSymbol(): string {
while (/\s/u.test(this.source[this.cursor] ?? "")) this.cursor += 1
if (this.source[this.cursor] === "=") {
this.cursor += 1
return " ≠ "
}
if (this.source[this.cursor] !== "\\") return "¬"
const saved = this.cursor
this.cursor += 1
const start = this.cursor
while (/[A-Za-z]/u.test(this.source[this.cursor] ?? "")) this.cursor += 1
const name = this.source.slice(start, this.cursor)
const negated: Readonly<Record<string, string>> = {
in: " ∉ ",
ni: " ∌ ",
subset: " ⊄ ",
supset: " ⊅ ",
subseteq: " ⊈ ",
supseteq: " ⊉ ",
}
const symbol = negated[name]
if (symbol !== undefined) return symbol
this.cursor = saved
return "¬"
}
private argument(): string | null {
while (/\s/u.test(this.source[this.cursor] ?? "")) this.cursor += 1
if (this.source[this.cursor] === "{") {
const group = this.rawGroup()
return group === null ? null : new LatexParser(group, this.state).parse()
}
if (this.source[this.cursor] === "\\") return this.command()
const value = this.source[this.cursor]
if (value === undefined) {
this.state.valid = false
return null
}
this.cursor += 1
return value
}
private optionalArgument(): string | null {
while (/\s/u.test(this.source[this.cursor] ?? "")) this.cursor += 1
if (this.source[this.cursor] !== "[") return null
const start = this.cursor + 1
let depth = 1
this.cursor += 1
while (this.cursor < this.source.length) {
const value = this.source[this.cursor]
if (value === "[") depth += 1
else if (value === "]") {
depth -= 1
if (depth === 0) {
const content = this.source.slice(start, this.cursor)
this.cursor += 1
return new LatexParser(content, this.state).parse()
}
}
this.cursor += 1
}
this.state.valid = false
return null
}
private rawGroup(): string | null {
while (/\s/u.test(this.source[this.cursor] ?? "")) this.cursor += 1
if (this.source[this.cursor] !== "{") {
this.state.valid = false
return null
}
const start = this.cursor + 1
let depth = 1
this.cursor += 1
while (this.cursor < this.source.length) {
const value = this.source[this.cursor]
if (value === "\\") {
this.cursor += Math.min(2, this.source.length - this.cursor)
continue
}
if (value === "{") depth += 1
else if (value === "}") {
depth -= 1
if (depth === 0) {
const content = this.source.slice(start, this.cursor)
this.cursor += 1
return content
}
}
this.cursor += 1
}
this.state.valid = false
return null
}
}
function plainText(source: string, state: ParseState): string {
let text = ""
let cursor = 0
while (cursor < source.length) {
if (source[cursor] !== "\\") {
text += source[cursor]
cursor += 1
continue
}
const next = source[cursor + 1]
if (next === undefined) break
if (!/[A-Za-z]/u.test(next)) {
text += ",:;! ".includes(next) ? " " : next
cursor += 2
continue
}
let end = cursor + 1
while (/[A-Za-z]/u.test(source[end] ?? "")) end += 1
const name = source.slice(cursor + 1, end)
const symbol = MATH_SYMBOLS[name]
if (symbol !== undefined) text += symbol
else if (NAMED_FUNCTIONS.has(name)) text += name
else {
state.valid = false
text += `\\${name}`
}
cursor = end
}
return text.replace(/\s+/gu, " ").trim()
}
function script(value: string, alphabet: Readonly<Record<string, string>>, marker: string): string {
const normalized = normalizeMath(value).replace(/\s+/gu, "")
const converted = [...normalized].map((character) => alphabet[character])
if (converted.every((character) => character !== undefined)) return converted.join("")
return normalized ? `${marker}(${normalized})` : marker
}
function fractionSide(value: string): string {
const normalized = normalizeMath(value)
const compound = /[=+≈≠≤≥≪≫≃≅≡∝×÷·±∓→←↔⇒⇐⇔↦∈∉∋∌⊂⊄⊆⊈⊃⊅⊇⊉∪∩∧∨]/u.test(normalized)
|| /[-]/u.test(normalized.slice(1))
|| /\s\/\s/u.test(normalized)
return compound ? `(${normalized})` : normalized
}
function normalizeMath(value: string): string {
return value
.split("\n")
.map((line) => line
.replace(/[ \t]+/gu, " ")
.trim()
.replace(/\s*([=≈≠≤≥≪≫≃≅≡∝×÷±∓→←↔⇒⇐⇔↦∈∉∋∌⊂⊄⊆⊈⊃⊅⊇⊉∪∩∧∨])\s*/gu, " $1 ")
.replace(/\s*\/\s*/gu, " / ")
.replace(/\(\s+/gu, "(")
.replace(/\s+\)/gu, ")")
.replace(/\s+([,;:%])/gu, "$1")
.replace(/[ \t]+/gu, " ")
.trim())
.filter(Boolean)
.join("\n")
}
function escapeMarkdown(value: string): string {
return value
.replace(/\\/gu, "\\\\")
.replace(/([`*_{}\[\]<>#|~])/gu, "\\$1")
}