Skip to content

Commit a71bcfa

Browse files
committed
fix(math): harden inline dollar and display rendering
1 parent 4c643f6 commit a71bcfa

3 files changed

Lines changed: 246 additions & 9 deletions

File tree

src/features/messages/components/Markdown.test.tsx

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -603,6 +603,33 @@ describe("Markdown file-like href behavior", () => {
603603
expect(container.querySelector(".katex-display")).toBeTruthy();
604604
});
605605

606+
it("renders single-line \\[display\\] math after prose labels", () => {
607+
const { container } = render(
608+
<Markdown
609+
value={String.raw`Display: \[\int_0^\infty e^{-x^2}\,dx = \frac{\sqrt{\pi}}{2}\]`}
610+
className="markdown"
611+
enableMathRendering
612+
/>,
613+
);
614+
615+
expect(container.textContent).toContain("Display:");
616+
expect(container.querySelector(".katex-display")).toBeTruthy();
617+
});
618+
619+
it("does not treat currency-like single dollars as inline math", () => {
620+
const { container } = render(
621+
<Markdown
622+
value="This costs $5 and that costs $10. Math still works: $x+1$"
623+
className="markdown"
624+
enableMathRendering
625+
/>,
626+
);
627+
628+
expect(container.querySelectorAll(".katex").length).toBe(1);
629+
expect(container.textContent).toContain("$5");
630+
expect(container.textContent).toContain("$10");
631+
});
632+
606633
it("does not render math inside fenced code blocks", () => {
607634
const { container } = render(
608635
<Markdown

src/features/messages/utils/backslashMathScanner.test.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,21 @@ describe("normalizeBackslashMathDelimiters", () => {
2222
].join("\n"));
2323
});
2424

25+
it("converts single-line backslash display math embedded after prose", () => {
26+
const input =
27+
String.raw`Display: \[\int_0^\infty e^{-x^2}\,dx = \frac{\sqrt{\pi}}{2}\]`;
28+
29+
const normalized = normalizeBackslashMathDelimiters(input);
30+
31+
expect(normalized).toBe([
32+
"Display:",
33+
"",
34+
"$$",
35+
String.raw`\int_0^\infty e^{-x^2}\,dx = \frac{\sqrt{\pi}}{2}`,
36+
"$$",
37+
].join("\n"));
38+
});
39+
2540
it("keeps escaped inline delimiters literal", () => {
2641
const input = "Literal: \\\\(x\\\\), parsed: \\(z^2\\)";
2742
const normalized = normalizeBackslashMathDelimiters(input);
@@ -190,6 +205,37 @@ describe("normalizeBackslashMathDelimiters", () => {
190205
expect(normalized).toContain(String.raw`Escaped: \\[ literal \\]`);
191206
expect(normalized).toContain(["$$", "E=mc^2", "$$"].join("\n"));
192207
});
208+
209+
it("applies Pandoc-style single-dollar guards for currency-like text", () => {
210+
const input = [
211+
String.raw`Inline math: $e^{i\pi}+1=0$ and $\nabla \cdot \mathbf{E}$`,
212+
"Currency: This costs $5 and that costs $10.",
213+
"Digit after closer: $x$10 remains literal.",
214+
String.raw`Escaped: \$5 and valid: $x+1$`,
215+
].join("\n");
216+
217+
const normalized = normalizeBackslashMathDelimiters(input);
218+
219+
expect(normalized).toContain(String.raw`$e^{i\pi}+1=0$`);
220+
expect(normalized).toContain(String.raw`$\nabla \cdot \mathbf{E}$`);
221+
expect(normalized).toContain(String.raw`This costs \$5 and that costs \$10.`);
222+
expect(normalized).toContain(String.raw`\$x\$10 remains literal.`);
223+
expect(normalized).toContain(String.raw`Escaped: \$5 and valid: $x+1$`);
224+
});
225+
226+
it("does not guard single dollars inside double-dollar display math", () => {
227+
const input = [
228+
"$$",
229+
String.raw`\text{Price is $5}`,
230+
"$$",
231+
"Outside $5 and $10",
232+
].join("\n");
233+
234+
const normalized = normalizeBackslashMathDelimiters(input);
235+
236+
expect(normalized).toContain(["$$", String.raw`\text{Price is $5}`, "$$"].join("\n"));
237+
expect(normalized).toContain(String.raw`Outside \$5 and \$10`);
238+
});
193239
});
194240

195241
describe("normalizeBackslashMathDelimiters extraction-shaped coverage", () => {

src/features/messages/utils/backslashMathScanner.ts

Lines changed: 173 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -294,13 +294,7 @@ function maskUrlLiterals(value: string) {
294294
const referenceDefinitionMatch = line.match(/^(\s{0,3}\[[^\]]+\]:\s*)(\S+)(.*)$/);
295295
if (referenceDefinitionMatch) {
296296
const prefix = referenceDefinitionMatch[1] ?? "";
297-
const rawDestination = referenceDefinitionMatch[2] ?? "";
298-
const suffix = referenceDefinitionMatch[3] ?? "";
299-
if (rawDestination.startsWith("<") && rawDestination.endsWith(">")) {
300-
const innerDestination = rawDestination.slice(1, -1);
301-
return `${prefix}<${toPlaceholder(innerDestination)}>${suffix}`;
302-
}
303-
return `${prefix}${toPlaceholder(rawDestination)}${suffix}`;
297+
return `${prefix}${toPlaceholder(line.slice(prefix.length))}`;
304298
}
305299

306300
const withAutolinksMasked = line.replace(
@@ -377,6 +371,174 @@ function replaceInlineBackslashDelimiters(value: string) {
377371
return output;
378372
}
379373

374+
function appendDisplayMathBlock(output: string, body: string) {
375+
let next = output.replace(/[ \t]+$/, "");
376+
if (next.length > 0 && !next.endsWith("\n")) {
377+
next += "\n\n";
378+
}
379+
next += `$$\n${body}\n$$`;
380+
return next;
381+
}
382+
383+
function replaceSingleLineBackslashDisplayDelimiters(value: string) {
384+
let output = "";
385+
let cursor = 0;
386+
let index = 0;
387+
388+
while (index < value.length) {
389+
if (
390+
value[index] === "\\" &&
391+
index + 1 < value.length &&
392+
value[index + 1] === "[" &&
393+
!isEscaped(value, index)
394+
) {
395+
const start = index;
396+
let closeIndex = -1;
397+
let scan = index + 2;
398+
while (scan < value.length) {
399+
if (value[scan] === "\n" || value[scan] === "\r") {
400+
break;
401+
}
402+
if (
403+
value[scan] === "\\" &&
404+
scan + 1 < value.length &&
405+
value[scan + 1] === "]" &&
406+
!isEscaped(value, scan)
407+
) {
408+
closeIndex = scan;
409+
break;
410+
}
411+
scan += 1;
412+
}
413+
414+
if (closeIndex >= 0) {
415+
const body = value.slice(start + 2, closeIndex).trim();
416+
if (body.length > 0) {
417+
output = appendDisplayMathBlock(output + value.slice(cursor, start), body);
418+
const nextIndex = closeIndex + 2;
419+
if (nextIndex < value.length && value[nextIndex] !== "\n" && value[nextIndex] !== "\r") {
420+
output += "\n\n";
421+
}
422+
index = nextIndex;
423+
cursor = nextIndex;
424+
continue;
425+
}
426+
}
427+
}
428+
429+
index += 1;
430+
}
431+
432+
output += value.slice(cursor);
433+
return output;
434+
}
435+
436+
function isSingleDollar(value: string, index: number) {
437+
return (
438+
value[index] === "$" &&
439+
!isEscaped(value, index) &&
440+
value[index - 1] !== "$" &&
441+
value[index + 1] !== "$"
442+
);
443+
}
444+
445+
function isValidSingleDollarOpener(value: string, index: number) {
446+
const next = value[index + 1];
447+
return Boolean(next) && !/\s/.test(next);
448+
}
449+
450+
function isValidSingleDollarCloser(value: string, index: number) {
451+
const previous = value[index - 1];
452+
const next = value[index + 1];
453+
return Boolean(previous) && !/\s/.test(previous) && !/[0-9]/.test(next ?? "");
454+
}
455+
456+
function collectDoubleDollarMathRanges(value: string) {
457+
const ranges: Array<[number, number]> = [];
458+
let index = 0;
459+
460+
while (index < value.length) {
461+
if (
462+
value[index] !== "$" ||
463+
value[index + 1] !== "$" ||
464+
isEscaped(value, index)
465+
) {
466+
index += 1;
467+
continue;
468+
}
469+
470+
const start = index;
471+
let closeIndex = index + 2;
472+
while (closeIndex < value.length) {
473+
if (
474+
value[closeIndex] === "$" &&
475+
value[closeIndex + 1] === "$" &&
476+
!isEscaped(value, closeIndex)
477+
) {
478+
ranges.push([start, closeIndex + 2]);
479+
index = closeIndex + 2;
480+
break;
481+
}
482+
closeIndex += 1;
483+
}
484+
485+
if (closeIndex >= value.length) {
486+
index += 2;
487+
}
488+
}
489+
490+
return ranges;
491+
}
492+
493+
function escapeInvalidSingleDollarDelimiters(value: string) {
494+
const protectedRanges = collectDoubleDollarMathRanges(value);
495+
const isProtected = (position: number) =>
496+
protectedRanges.some(([start, end]) => position >= start && position < end);
497+
const isGuardableSingleDollar = (position: number) =>
498+
!isProtected(position) && isSingleDollar(value, position);
499+
const validDelimiterIndexes = new Set<number>();
500+
let index = 0;
501+
502+
while (index < value.length) {
503+
if (!isGuardableSingleDollar(index) || !isValidSingleDollarOpener(value, index)) {
504+
index += 1;
505+
continue;
506+
}
507+
508+
let closeIndex = index + 1;
509+
let foundClose = false;
510+
while (closeIndex < value.length) {
511+
if (value[closeIndex] === "\n" || value[closeIndex] === "\r") {
512+
break;
513+
}
514+
if (isGuardableSingleDollar(closeIndex)) {
515+
if (isValidSingleDollarCloser(value, closeIndex)) {
516+
validDelimiterIndexes.add(index);
517+
validDelimiterIndexes.add(closeIndex);
518+
index = closeIndex + 1;
519+
foundClose = true;
520+
}
521+
break;
522+
}
523+
closeIndex += 1;
524+
}
525+
526+
if (!foundClose) {
527+
index += 1;
528+
}
529+
}
530+
531+
let output = "";
532+
for (let cursor = 0; cursor < value.length; cursor += 1) {
533+
if (isGuardableSingleDollar(cursor) && !validDelimiterIndexes.has(cursor)) {
534+
output += "\\$";
535+
} else {
536+
output += value[cursor];
537+
}
538+
}
539+
return output;
540+
}
541+
380542
function convertBackslashBlockDelimiters(value: string) {
381543
const lines = value.split(/\r?\n/);
382544
const output: string[] = [];
@@ -460,8 +622,10 @@ function normalizeBackslashMathDelimitersInChunk(value: string) {
460622
const { masked: linkMasked, restore: restoreLinks } = maskMarkdownLinkDestinations(inlineCodeMasked);
461623
const { masked: urlMasked, restore: restoreUrls } = maskUrlLiterals(linkMasked);
462624
const withBlockMath = convertBackslashBlockDelimiters(urlMasked);
463-
const withInlineMath = replaceInlineBackslashDelimiters(withBlockMath);
464-
return restoreInlineCode(restoreLinks(restoreUrls(withInlineMath)));
625+
const withSingleLineDisplayMath = replaceSingleLineBackslashDisplayDelimiters(withBlockMath);
626+
const withInlineMath = replaceInlineBackslashDelimiters(withSingleLineDisplayMath);
627+
const withDollarGuard = escapeInvalidSingleDollarDelimiters(withInlineMath);
628+
return restoreInlineCode(restoreLinks(restoreUrls(withDollarGuard)));
465629
}
466630

467631
export function normalizeBackslashMathDelimiters(value: string) {

0 commit comments

Comments
 (0)