fix(tui): bound Markdown math preprocessing

This commit is contained in:
chengyongru
2026-08-22 01:46:12 +08:00
committed by chengyongru
parent cbe4316e4b
commit dd993b4f70
2 changed files with 99 additions and 27 deletions
+15
View File
@@ -38,12 +38,16 @@ describe("terminal LaTeX rendering", () => {
const source = [
"Costs are $24 today or $10-20 later; variables $x$ and $2^n$ are math.",
"Shipping costs $5+$10 and paths use $HOME/$USER.",
"Mixed currency and variables like $5+$x$ remain literal.",
"Commands and groups stay literal: $5+$\\alpha$, $5+${x}$, and $5+$(x)$.",
"Matrix $A$ maps $V$ to $W$.",
].join("\n")
expect(renderLatexAsUnicode(source)).toBe([
"Costs are $24 today or $10-20 later; variables x and 2ⁿ are math.",
"Shipping costs $5+$10 and paths use $HOME/$USER.",
"Mixed currency and variables like $5+$x$ remain literal.",
"Commands and groups stay literal: $5+$\\alpha$, $5+${x}$, and $5+$(x)$.",
"Matrix A maps V to W.",
].join("\n"))
})
@@ -70,6 +74,7 @@ describe("terminal LaTeX rendering", () => {
"\\[\\frac{a}{b}\\]",
"```",
].join("\n"))
expect(renderLatexAsUnicode("`$x$``")).toBe("`x``")
})
test("leaves fenced code inside block quotes and lists unchanged", () => {
@@ -101,9 +106,19 @@ describe("terminal LaTeX rendering", () => {
test("handles many unmatched openers without changing them", () => {
const openers = "\\(".repeat(20_000)
const backslashes = "\\".repeat(20_000)
const backticks = `text ${"`".repeat(20_000)} then $x$`
const descendingBackticks = "text " + Array.from(
{ length: 500 },
(_, index) => "`".repeat(500 - index),
).join("x") + " $x$"
const linkDestinations = `${"](".repeat(20_000)} then $x$`
expect(renderLatexAsUnicode(openers)).toBe(openers)
expect(renderLatexAsUnicode(backslashes)).toBe(backslashes)
expect(renderLatexAsUnicode(backticks)).toBe(backticks.replace("$x$", "x"))
expect(renderLatexAsUnicode(descendingBackticks))
.toBe(descendingBackticks.replace("$x$", "x"))
expect(renderLatexAsUnicode(linkDestinations)).toBe(linkDestinations.replace("$x$", "x"))
})
test("leaves unsupported, malformed, and deeply nested math unchanged", () => {
+84 -27
View File
@@ -263,9 +263,16 @@ 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)
@@ -276,15 +283,19 @@ export function renderLatexAsUnicode(markdown: string): string {
}
if (markdown[cursor] === "`") {
const inlineEnd = inlineCodeEnd(markdown, cursor)
if (inlineEnd !== null) {
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)
const destinationEnd = markdownDestinationEnd(markdown, cursor, linkDestinationEnds)
if (destinationEnd !== null) {
rendered += markdown.slice(cursor, destinationEnd)
cursor = destinationEnd
@@ -294,6 +305,11 @@ export function renderLatexAsUnicode(markdown: string): string {
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)
@@ -320,6 +336,7 @@ export function renderLatexAsUnicode(markdown: string): string {
interface MathSpan {
content: string
end: number
literal?: boolean
}
interface ParseState {
@@ -389,7 +406,23 @@ function mathSpanAt(
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) && /[0-9]/u.test(markdown[span.end] ?? "")) return null
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)
@@ -491,32 +524,56 @@ function closingFenceOffset(line: string, context: FenceContext): number | null
return indent === undefined ? null : offset + indent.length
}
function inlineCodeEnd(source: string, start: number): number | null {
const count = repeatedCharacterCount(source, start, "`")
const delimiter = "`".repeat(count)
const close = source.indexOf(delimiter, start + count)
return close < 0 ? null : close + count
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 markdownDestinationEnd(source: string, start: number): number | null {
if (source[start] === "(" && source[start - 1] === "]" && !isEscaped(source, start - 1)) {
let depth = 1
let cursor = start + 1
while (cursor < source.length) {
const value = source[cursor]
if (value === "\n") return null
if (value === "\\") {
cursor += 2
continue
}
if (value === "(") depth += 1
else if (value === ")") {
depth -= 1
if (depth === 0) return cursor + 1
}
cursor += 1
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
}
return null
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] === "<") {