Skip to content

Commit b6801e1

Browse files
andrewboldiclaude
andcommitted
fix: handle \[...\] display math delimiters and [asy] blocks in KaTeX
- Support \[...\] and \(...\) delimiters in addition to $...$ and $$...$$ - Replace [asy]...[/asy] Asymptote source blocks with [Diagram] placeholder - Use display block element for display-mode math - Fix Svelte 5 runes mode: use $effect with queueMicrotask instead of afterUpdate Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent d87d9b2 commit b6801e1

3 files changed

Lines changed: 73 additions & 26 deletions

File tree

src/lib/components/ProblemDisplay.svelte

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
<script lang="ts">
2-
import { afterUpdate } from 'svelte';
32
import { renderLatexInContainer } from '$lib/utils/katex';
43
54
interface Props {
@@ -10,11 +9,13 @@
109
}
1110
1211
let { problemId, problemHtml, zenMode, textInvertFilter }: Props = $props();
13-
let container: HTMLElement;
12+
let container = $state<HTMLElement>(undefined!);
1413
15-
afterUpdate(() => {
16-
if (container) {
17-
renderLatexInContainer(container);
14+
$effect(() => {
15+
// Read problemHtml to establish dependency
16+
if (problemHtml && container) {
17+
// Use a microtask to ensure {@html} has flushed to DOM
18+
queueMicrotask(() => renderLatexInContainer(container));
1819
}
1920
});
2021
</script>

src/lib/components/SolutionDisplay.svelte

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
<script lang="ts">
2-
import { afterUpdate } from 'svelte';
32
import { renderLatexInContainer } from '$lib/utils/katex';
43
54
interface Props {
@@ -8,11 +7,11 @@
87
}
98
109
let { solutionHtml, textInvertFilter }: Props = $props();
11-
let container: HTMLElement;
10+
let container = $state<HTMLElement>(undefined!);
1211
13-
afterUpdate(() => {
14-
if (container) {
15-
renderLatexInContainer(container);
12+
$effect(() => {
13+
if (solutionHtml && container) {
14+
queueMicrotask(() => renderLatexInContainer(container));
1615
}
1716
});
1817
</script>

src/lib/utils/katex.ts

Lines changed: 63 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -3,41 +3,88 @@ import katex from 'katex';
33
/**
44
* Process a DOM container to replace <img class="latex"> elements
55
* with KaTeX-rendered math. Leaves non-latex images (diagrams, figures) untouched.
6+
* Also hides [asy] Asymptote source blocks that can't be rendered client-side.
67
*
78
* The AoPS-scraped HTML stores LaTeX source in the `alt` attribute
8-
* of images with class="latex", e.g.:
9-
* <img class="latex" alt="$x^2 + 1$" src="...latex.artofproblemsolving.com/..." />
9+
* of images with class="latex". Supported delimiters:
10+
* $...$ inline math
11+
* $$...$$ display math
12+
* \[...\] display math
13+
* \(...\) inline math
14+
* bare text treated as inline math
1015
*/
16+
17+
function stripDelimiters(alt: string): { latex: string; displayMode: boolean } {
18+
let latex = alt.trim();
19+
let displayMode = false;
20+
21+
if (latex.startsWith('$$') && latex.endsWith('$$')) {
22+
latex = latex.slice(2, -2);
23+
displayMode = true;
24+
} else if (latex.startsWith('\\[') && latex.endsWith('\\]')) {
25+
latex = latex.slice(2, -2);
26+
displayMode = true;
27+
} else if (latex.startsWith('\\(') && latex.endsWith('\\)')) {
28+
latex = latex.slice(2, -2);
29+
} else if (latex.startsWith('$') && latex.endsWith('$')) {
30+
latex = latex.slice(1, -1);
31+
}
32+
33+
return { latex, displayMode };
34+
}
35+
1136
export function renderLatexInContainer(container: HTMLElement): void {
37+
// Replace <img class="latex"> with KaTeX
1238
const images = container.querySelectorAll<HTMLImageElement>('img.latex');
1339

1440
for (const img of images) {
1541
const alt = img.getAttribute('alt');
1642
if (!alt) continue;
1743

18-
// Strip surrounding $ or $$ delimiters
19-
let latex = alt;
20-
let displayMode = false;
21-
22-
if (latex.startsWith('$$') && latex.endsWith('$$')) {
23-
latex = latex.slice(2, -2);
24-
displayMode = true;
25-
} else if (latex.startsWith('$') && latex.endsWith('$')) {
26-
latex = latex.slice(1, -1);
27-
}
44+
const { latex, displayMode } = stripDelimiters(alt);
2845

2946
try {
3047
const rendered = katex.renderToString(latex, {
3148
throwOnError: false,
3249
displayMode,
3350
output: 'html',
3451
});
35-
const span = document.createElement('span');
36-
span.innerHTML = rendered;
37-
span.className = 'katex-rendered';
38-
img.replaceWith(span);
52+
53+
if (displayMode) {
54+
const div = document.createElement('div');
55+
div.innerHTML = rendered;
56+
div.className = 'katex-rendered katex-display-block';
57+
img.replaceWith(div);
58+
} else {
59+
const span = document.createElement('span');
60+
span.innerHTML = rendered;
61+
span.className = 'katex-rendered';
62+
img.replaceWith(span);
63+
}
3964
} catch {
4065
// If KaTeX fails, leave the original image in place
4166
}
4267
}
68+
69+
// Hide [asy] Asymptote source code blocks (can't render client-side)
70+
// These appear as raw text in <p> elements
71+
const walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT);
72+
const asyNodes: Text[] = [];
73+
let node: Text | null;
74+
while ((node = walker.nextNode() as Text | null)) {
75+
if (node.textContent?.includes('[asy]')) {
76+
asyNodes.push(node);
77+
}
78+
}
79+
80+
for (const textNode of asyNodes) {
81+
const parent = textNode.parentElement;
82+
if (!parent) continue;
83+
const html = parent.innerHTML;
84+
// Remove everything between [asy] and [/asy]
85+
const cleaned = html.replace(/\[asy\][\s\S]*?\[\/asy\]/g, '<em style="color: #999;">[Diagram]</em>');
86+
if (cleaned !== html) {
87+
parent.innerHTML = cleaned;
88+
}
89+
}
4390
}

0 commit comments

Comments
 (0)