Skip to content
Merged
30 changes: 30 additions & 0 deletions apps/web/src/components/chat/MathTypeset.logic.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import katex from "katex";
import { describe, expect, it } from "vite-plus/test";

import { escapeLiteralPercents } from "./MathTypeset.logic";

describe("escapeLiteralPercents", () => {
it.each([
["\\text{3.18% yield}", "\\text{3.18\\% yield}"],
["\\text{100%% yield}", "\\text{100\\%\\% yield}"],
["6\\% + 2\\%", "6\\% + 2\\%"],
["x\\\\%", "x\\\\\\%"],
["x % note\n+ 1", "x % note\n+ 1"],
["% note\n\\text{2%}", "% note\n\\text{2\\%}"],
["x &= 1 % +9%\n\\text{2%}", "x &= 1 % +9%\n\\text{2\\%}"],
["\\verb|50%|+\\text{2%}", "\\verb|50%|+\\text{2\\%}"],
["\\verb*%a%b%+c%", "\\verb*%a%b\\%+c\\%"],
["\\verb|50%+\\text{2%}", "\\verb|50%+\\text{2%}"],
])("repairs %j", (tex, expected) => {
expect(escapeLiteralPercents(tex)).toBe(expected);
});

it("lets the reported multiline equation typeset", () => {
const tex =
"R_e=\\underbrace{\\frac{1.0176}{32}}_{\\text{3.18% dividend yield}}\n+\\underbrace{6\\%}_{\\text{growth}}\n=\\boxed{9.18\\%}";
expect(() => katex.renderToString(tex, { throwOnError: true })).toThrow();
expect(() =>
katex.renderToString(escapeLiteralPercents(tex), { throwOnError: true }),
).not.toThrow();
});
});
39 changes: 39 additions & 0 deletions apps/web/src/components/chat/MathTypeset.logic.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
/**
* Escapes each `%` written right after other text, as in `\text{3.18% yield}`.
* A `%` at a line start or after whitespace stays a comment. Skips escapes and
* `\verb` like KaTeX's lexer, in one linear pass.
*/
export function escapeLiteralPercents(tex: string): string {
let repaired = "";
let copied = 0;
let index = 0;
while (index < tex.length) {
const char = tex[index];
if (char === "\\") {
if (tex.startsWith("\\verb", index)) {
const starred = tex[index + 5] === "*";
const delimiter = tex[index + (starred ? 6 : 5)];
if (delimiter !== undefined && (starred || !/[*a-zA-Z]/.test(delimiter))) {
const bodyStart = index + (starred ? 7 : 6);
let close = bodyStart;
while (close < tex.length && ![delimiter, "\n", "\r"].includes(tex[close]!)) close++;
// KaTeX rejects a \verb that does not close on its own line.
if (tex[close] !== delimiter) return tex;
index = close + 1;
continue;
}
}
// A control symbol such as `\%` or `\\` is two characters.
index += 2;
} else if (char === "%" && index > 0 && !/\s/.test(tex[index - 1]!)) {
repaired += `${tex.slice(copied, index)}\\%`;
copied = ++index;
} else if (char === "%") {
const newline = tex.indexOf("\n", index);
index = newline === -1 ? tex.length : newline + 1;
} else {
index++;
}
}
return copied === 0 ? tex : repaired + tex.slice(copied);
}
31 changes: 20 additions & 11 deletions apps/web/src/components/chat/MathTypeset.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import "katex/dist/katex.min.css";
import type { ReactNode } from "react";

import { LRUCache } from "../../lib/lruCache";
import { escapeLiteralPercents } from "./MathTypeset.logic";

// Virtualized threads remount messages while scrolling; cached markup keeps
// that from typesetting the same formulas again. "" records a parse error.
Expand All @@ -12,20 +13,28 @@ function typeset(tex: string, display: boolean): string {
const key = `${display ? "display" : "inline"}:${tex}`;
const cached = typesetCache.get(key);
if (cached !== null) return cached;
const options = {
displayMode: display,
output: "htmlAndMathml" as const,
throwOnError: true,
// No \href, \url, \htmlClass, or other commands that emit active HTML.
trust: false,
strict: "ignore" as const,
maxSize: 20,
maxExpand: 1000,
};
let html = "";
try {
html = katex.renderToString(tex, {
displayMode: display,
output: "htmlAndMathml",
throwOnError: true,
// No \href, \url, \htmlClass, or other commands that emit active HTML.
trust: false,
strict: "ignore",
maxSize: 20,
maxExpand: 1000,
});
html = katex.renderToString(tex, options);
} catch {
// Unfinished or unsupported TeX stays readable as its source.
const repaired = escapeLiteralPercents(tex);
if (repaired !== tex) {
try {
html = katex.renderToString(repaired, options);
} catch {
// Unfinished or unsupported TeX stays readable as its source.
}
}
}
typesetCache.set(key, html, (key.length + html.length) * 2);
return html;
Expand Down
Loading