From 3e9aa37fb424688df34029014018ef371861abc2 Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Wed, 8 Jul 2026 02:58:57 +0000 Subject: [PATCH 1/5] Studio: fix link, currency and indentation edge cases in LaTeX rendering Follow-up to #6914. Three fixes to studio/frontend/src/lib/latex.ts: - Skip reference-link definition URLs ([id]: url) during delimiter conversion, so escaped parens in such URLs are not rewritten as math. - Preserve the opener line's indentation when emitting a display $$ block, so a \[...\] inside a list item stays part of the list. - Stop a currency amount from pairing with a converted span's opening $, which swallowed the price into math (for example $5 + x \(y\)). --- studio/frontend/src/lib/latex.ts | 61 +++++++++++++++++++++++++------- 1 file changed, 49 insertions(+), 12 deletions(-) diff --git a/studio/frontend/src/lib/latex.ts b/studio/frontend/src/lib/latex.ts index 86a96340480..ac1385f767e 100644 --- a/studio/frontend/src/lib/latex.ts +++ b/studio/frontend/src/lib/latex.ts @@ -66,20 +66,39 @@ const LINK_DEST_RE = /!?\[(?:\\.|[^\]\\])*?\]\(((?:\\.|[^()\\]|\([^()]*\))*)\)/gd; /** - * Find the destination spans of inline links/images, so a `\(...\)` written with - * escaped parens inside a URL isn't rewritten as math (which would break the - * link). Only the destination is returned, not the link text, so math in the - * visible text still converts. Sorted, non-overlapping (matches are disjoint). + * Reference-link definition `[label]: DEST` (group 1 = angle `<...>`, group 2 = + * bare run). `d` flag for indices, `m` to anchor at line start. Its URL, like an + * inline destination, must not be rewritten as math. + */ +const REF_DEF_DEST_RE = + /^ {0,3}\[(?:\\.|[^\]\n\\])+\]:[^\S\n]*(?:<([^>\n]*)>|([^\s<][^\s]*))/gmd; + +/** + * Destination spans of inline links/images and reference-link definitions, so a + * `\(...\)` inside a URL isn't rewritten as math. Link text is not returned, so + * math in the visible text still converts. */ function findLinkDestinationRegions(content: string): Array<[number, number]> { - if (!content.includes("](")) return []; + const hasInline = content.includes("]("); + const hasRefDef = content.includes("]:"); + if (!hasInline && !hasRefDef) return []; const regions: Array<[number, number]> = []; let match: RegExpExecArray | null; - LINK_DEST_RE.lastIndex = 0; - while ((match = LINK_DEST_RE.exec(content)) !== null) { - // `indices` is present (the `d` flag); group 1 spans the destination. - regions.push(match.indices![1]); + if (hasInline) { + LINK_DEST_RE.lastIndex = 0; + while ((match = LINK_DEST_RE.exec(content)) !== null) { + regions.push(match.indices![1]); // `d` flag: group 1 is the destination. + } + } + if (hasRefDef) { + REF_DEF_DEST_RE.lastIndex = 0; + while ((match = REF_DEF_DEST_RE.exec(content)) !== null) { + const span = match.indices![1] ?? match.indices![2]; + if (span) regions.push(span); + } } + // Inline and ref-def spans can interleave (never overlap), so sort. + regions.sort((a, b) => a[0] - b[0]); return regions; } @@ -173,7 +192,11 @@ function looksLikeMathBody(body: string): boolean { * (`**$X$**`, `__$X$__`) are always math: LLMs use that for "bold math" * and the heuristic would otherwise reject prose-shaped bodies like "90 - x". */ -function hasInlineMathCloser(content: string, offset: number): boolean { +function hasInlineMathCloser( + content: string, + offset: number, + mathRegions: Array<[number, number]>, +): boolean { const MAX_SPAN = 200; const limit = Math.min(content.length, offset + 1 + MAX_SPAN); for (let i = offset + 1; i < limit; i++) { @@ -181,6 +204,9 @@ function hasInlineMathCloser(content: string, offset: number): boolean { if (c === "\n") return false; if (c !== "$") continue; if (content[i - 1] === "\\") continue; + // A `$` opening a generated span (from `\(...\)`) is not a currency closer; + // pairing with it would swallow the price into math (`$5 + x \(y\)`). + if (isInRegion(i, mathRegions)) return false; if (content[i + 1] === "$") { i++; continue; @@ -294,7 +320,18 @@ function convertLatexDelimiters(content: string): { continue; } append(content.slice(last, match.index)); - const wrapped = isDisplay ? `\n$$\n${body}\n$$\n` : `$${body}$`; + let wrapped: string; + if (isDisplay) { + // Keep the opener's leading indentation so a `$$` block inside a list item + // stays in the container instead of breaking out at column 0. Only when the + // opener is whitespace-prefixed, so inline `text \[x\]` keeps column 0. + const lineStart = content.lastIndexOf("\n", match.index - 1) + 1; + const prefix = content.slice(lineStart, match.index); + const indent = /^\s*$/.test(prefix) ? prefix : ""; + wrapped = `\n${indent}$$\n${indent}${body}\n${indent}$$\n`; + } else { + wrapped = `$${body}$`; + } const start = append(wrapped); mathRegions.push([start, offset]); last = matchEnd; @@ -334,7 +371,7 @@ export function preprocessLaTeX(content: string): string { if (isInRegion(offset, mathRegions)) { return match; } - if (hasInlineMathCloser(text, offset)) { + if (hasInlineMathCloser(text, offset, mathRegions)) { return match; } return "\\" + match; From f8f7ca0730a3c2038712f0c052bb1ee9fe95eb96 Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Wed, 8 Jul 2026 03:12:44 +0000 Subject: [PATCH 2/5] Exclude GFM footnote definitions from the reference-URL skip A footnote definition like [^1]: \(x\) had its body treated as a link destination, so leading math was left literal. Skip [^...] labels. --- studio/frontend/src/lib/latex.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/studio/frontend/src/lib/latex.ts b/studio/frontend/src/lib/latex.ts index ac1385f767e..bd1606c5085 100644 --- a/studio/frontend/src/lib/latex.ts +++ b/studio/frontend/src/lib/latex.ts @@ -67,11 +67,12 @@ const LINK_DEST_RE = /** * Reference-link definition `[label]: DEST` (group 1 = angle `<...>`, group 2 = - * bare run). `d` flag for indices, `m` to anchor at line start. Its URL, like an - * inline destination, must not be rewritten as math. + * bare run). `d` flag for indices, `m` to anchor at line start. `(?!\^)` skips + * GFM footnotes (`[^id]: text`), whose body is prose, not a URL. Its URL, like + * an inline destination, must not be rewritten as math. */ const REF_DEF_DEST_RE = - /^ {0,3}\[(?:\\.|[^\]\n\\])+\]:[^\S\n]*(?:<([^>\n]*)>|([^\s<][^\s]*))/gmd; + /^ {0,3}\[(?!\^)(?:\\.|[^\]\n\\])+\]:[^\S\n]*(?:<([^>\n]*)>|([^\s<][^\s]*))/gmd; /** * Destination spans of inline links/images and reference-link definitions, so a From dc461e1cfa439d16ba0d4b5097661fa7be0252eb Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Wed, 8 Jul 2026 03:24:41 +0000 Subject: [PATCH 3/5] Merge overlapping link destination regions A reference-def token can nest inline-link spans (for example [1]: http://h/[a](b)/foo\(x\)), so the combined spans could overlap and isInRegion's binary search missed the outer one, rewriting the URL. Merge overlapping spans before the search. --- studio/frontend/src/lib/latex.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/studio/frontend/src/lib/latex.ts b/studio/frontend/src/lib/latex.ts index bd1606c5085..d371ea85732 100644 --- a/studio/frontend/src/lib/latex.ts +++ b/studio/frontend/src/lib/latex.ts @@ -98,9 +98,17 @@ function findLinkDestinationRegions(content: string): Array<[number, number]> { if (span) regions.push(span); } } - // Inline and ref-def spans can interleave (never overlap), so sort. + // Sort, then merge overlaps: a reference-def token can nest inline-link spans + // (`[1]: http://h/[a](b)/foo\(x\)`), and isInRegion's binary search needs + // disjoint spans. regions.sort((a, b) => a[0] - b[0]); - return regions; + const merged: Array<[number, number]> = []; + for (const span of regions) { + const last = merged[merged.length - 1]; + if (last && span[0] <= last[1]) last[1] = Math.max(last[1], span[1]); + else merged.push(span); + } + return merged; } /** From 250942d06c95bb0badc406a9c66ac3e2fe8b7610 Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Wed, 8 Jul 2026 03:42:55 +0000 Subject: [PATCH 4/5] Guard lineStart when the display opener is at index 0 Behavior is unchanged (lastIndexOf clamps a negative fromIndex to 0), but the explicit guard avoids relying on that implicit clamp. --- studio/frontend/src/lib/latex.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/studio/frontend/src/lib/latex.ts b/studio/frontend/src/lib/latex.ts index d371ea85732..fb9f37eabe6 100644 --- a/studio/frontend/src/lib/latex.ts +++ b/studio/frontend/src/lib/latex.ts @@ -334,7 +334,8 @@ function convertLatexDelimiters(content: string): { // Keep the opener's leading indentation so a `$$` block inside a list item // stays in the container instead of breaking out at column 0. Only when the // opener is whitespace-prefixed, so inline `text \[x\]` keeps column 0. - const lineStart = content.lastIndexOf("\n", match.index - 1) + 1; + const lineStart = + match.index > 0 ? content.lastIndexOf("\n", match.index - 1) + 1 : 0; const prefix = content.slice(lineStart, match.index); const indent = /^\s*$/.test(prefix) ? prefix : ""; wrapped = `\n${indent}$$\n${indent}${body}\n${indent}$$\n`; From cf59b2ff384b213bda78dd7bf092e9f1c4f1e6d9 Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Wed, 8 Jul 2026 06:19:03 +0000 Subject: [PATCH 5/5] Scope to indentation and currency fixes Drop the reference-link URL protection added earlier. It guards a case models effectively never emit (escaped parens in a reference-style URL), and approximating CommonMark reference definitions with a regex needs open-ended special-casing. Keep the two high-value fixes: preserve display math indentation (including multi-line bodies) inside a list item, and stop a currency amount from pairing with a converted span's opening dollar sign. --- studio/frontend/src/lib/latex.ts | 53 +++++++++----------------------- 1 file changed, 14 insertions(+), 39 deletions(-) diff --git a/studio/frontend/src/lib/latex.ts b/studio/frontend/src/lib/latex.ts index fb9f37eabe6..edf98756020 100644 --- a/studio/frontend/src/lib/latex.ts +++ b/studio/frontend/src/lib/latex.ts @@ -66,49 +66,21 @@ const LINK_DEST_RE = /!?\[(?:\\.|[^\]\\])*?\]\(((?:\\.|[^()\\]|\([^()]*\))*)\)/gd; /** - * Reference-link definition `[label]: DEST` (group 1 = angle `<...>`, group 2 = - * bare run). `d` flag for indices, `m` to anchor at line start. `(?!\^)` skips - * GFM footnotes (`[^id]: text`), whose body is prose, not a URL. Its URL, like - * an inline destination, must not be rewritten as math. - */ -const REF_DEF_DEST_RE = - /^ {0,3}\[(?!\^)(?:\\.|[^\]\n\\])+\]:[^\S\n]*(?:<([^>\n]*)>|([^\s<][^\s]*))/gmd; - -/** - * Destination spans of inline links/images and reference-link definitions, so a - * `\(...\)` inside a URL isn't rewritten as math. Link text is not returned, so - * math in the visible text still converts. + * Find the destination spans of inline links/images, so a `\(...\)` written with + * escaped parens inside a URL isn't rewritten as math (which would break the + * link). Only the destination is returned, not the link text, so math in the + * visible text still converts. Sorted, non-overlapping (matches are disjoint). */ function findLinkDestinationRegions(content: string): Array<[number, number]> { - const hasInline = content.includes("]("); - const hasRefDef = content.includes("]:"); - if (!hasInline && !hasRefDef) return []; + if (!content.includes("](")) return []; const regions: Array<[number, number]> = []; let match: RegExpExecArray | null; - if (hasInline) { - LINK_DEST_RE.lastIndex = 0; - while ((match = LINK_DEST_RE.exec(content)) !== null) { - regions.push(match.indices![1]); // `d` flag: group 1 is the destination. - } + LINK_DEST_RE.lastIndex = 0; + while ((match = LINK_DEST_RE.exec(content)) !== null) { + // `indices` is present (the `d` flag); group 1 spans the destination. + regions.push(match.indices![1]); } - if (hasRefDef) { - REF_DEF_DEST_RE.lastIndex = 0; - while ((match = REF_DEF_DEST_RE.exec(content)) !== null) { - const span = match.indices![1] ?? match.indices![2]; - if (span) regions.push(span); - } - } - // Sort, then merge overlaps: a reference-def token can nest inline-link spans - // (`[1]: http://h/[a](b)/foo\(x\)`), and isInRegion's binary search needs - // disjoint spans. - regions.sort((a, b) => a[0] - b[0]); - const merged: Array<[number, number]> = []; - for (const span of regions) { - const last = merged[merged.length - 1]; - if (last && span[0] <= last[1]) last[1] = Math.max(last[1], span[1]); - else merged.push(span); - } - return merged; + return regions; } /** @@ -338,7 +310,10 @@ function convertLatexDelimiters(content: string): { match.index > 0 ? content.lastIndexOf("\n", match.index - 1) + 1 : 0; const prefix = content.slice(lineStart, match.index); const indent = /^\s*$/.test(prefix) ? prefix : ""; - wrapped = `\n${indent}$$\n${indent}${body}\n${indent}$$\n`; + // Indent every body line, not just the first, so multi-line display math + // (`\[a\nb\]`) stays wholly inside the container. + const inner = indent ? body.replace(/\n/g, `\n${indent}`) : body; + wrapped = `\n${indent}$$\n${indent}${inner}\n${indent}$$\n`; } else { wrapped = `$${body}$`; }