Web chat: draw math with the bundled KaTeX, opt-in and offline (#2972, #2974)

Chat settings > Render math (off by default). When it is on, \( \), \[ \], $x$
and $$ $$ in an answer are drawn as formulas by KaTeX 0.19.0, which is vendored
in serve/web/vendor/katex (script, style, 20 woff2 fonts, MIT licence and a
PROVENANCE.json with the npm integrity and the sha256 of every file, so the page
needs no network). Nothing of KaTeX loads until an answer holds a formula.

The shared core (math.js, from #2972) keeps the text of every message exactly as
the model wrote it: formulas are swapped for placeholders before the Markdown
runs and drawn over them afterwards, so history, copying, the Markdown export
and the requests never see the drawing. Code, link targets, prices ($5 and $10)
and a formula still being streamed stay text. A formula that fails, is over
16 KiB or times out stays as its source text.

Safety: KaTeX runs with trust off (no \href, \url, \includegraphics, \htmlClass:
they are drawn as their own name), maxExpand 1000, maxSize 20 and a fresh macro
table per formula; it builds DOM nodes itself, so no HTML string from a model
reaches the page. A formula already drawn is placed again at once while an
answer streams, so it does not flicker back to source.

serve/test_web_math.js runs the real vendored KaTeX against hostile formulas;
serve/test_web_math.py checks the vendored files against PROVENANCE.json and
that nothing outside this server is addressed. serve/web/vendor is -text in
.gitattributes so the hashes hold on a Windows checkout.

Ported by hand from PRs #2972 (core) and #2974 (KaTeX) by CC-David-CC.

Co-authored-by: David <[email protected]>
This commit is contained in:
Niko1221andDavid committed 2026-10-26 19:52:56 +01:00
1 parent d5cca319c9
commit a965c962cf
32 files changed
+542 -4

No files matched your search

+3
View File
@@ -25,3 +25,6 @@ docker/compose/* text eol=lf
# Apple tools (codesign, plutil) read these: LF only, wherever the repository is cloned
*.plist text eol=lf
# vendored KaTeX (serve/web/vendor): the files are hashed byte for byte (PROVENANCE.json), never convert their line endings.
serve/web/vendor/** -text
+170
View File
@@ -0,0 +1,170 @@
// Chat math (serve/web/math.js + math-adapter.js + the vendored KaTeX), run in Node.
// node serve/test_web_math.js (serve/test_web_math.py runs it too and skips when node is missing)
//
// 1. where a formula starts and ends (prices, code, links, half-written formulas);
// 2. the Markdown step: the model's text is escaped, a formula's source only ever reaches the page escaped;
// 3. drawing: a stub adapter and a stub DOM (order, cache, stale answers, errors, size limit);
// 4. safety: the REAL vendored KaTeX with the adapter's options, fed hostile formulas.
"use strict";
const assert = require("node:assert/strict");
const path = require("node:path");
const web = path.join(__dirname, "web");
const math = require(path.join(web, "math.js"));
const {split} = math;
const esc = (s) => String(s).replace(/[&<>"']/g, (c) => ({"&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;"}[c]));
const unesc = (s) => s.replace(/&(amp|lt|gt|quot|#39);/g, (m, k) => ({amp: "&", lt: "<", gt: ">", quot: '"', "#39": "'"}[k]));
const sources = (text) => split(text).filter((p) => p.source !== undefined).map((p) => [p.source, p.display]);
const cases = {
split_delimiters() {
assert.deepEqual(sources("a $x^2$ b"), [["x^2", false]]);
assert.deepEqual(sources("a \\(x+1\\) b"), [["x+1", false]]);
assert.deepEqual(sources("a $$\\int_0^1 f$$ b"), [["\\int_0^1 f", true]]);
assert.deepEqual(sources("a \\[\\sum_i i\\] b"), [["\\sum_i i", true]]);
assert.deepEqual(sources("$$\nx = 1\n$$"), [["\nx = 1\n", true]], "display math may span lines");
assert.deepEqual(sources("$a$ and $b$"), [["a", false], ["b", false]]);
// the text around a formula is kept whole
assert.equal(split("a $x$ b").map((p) => p.raw).join(""), "a $x$ b");
},
split_not_math() {
for (const text of ["It costs $5.", "$5 and $10", "from $5 to $10 each", "$ 5 $", "a $ b", "price: $1,000", "cost $5 USD, $6 EUR",
"no formula here", "\\$x\\$", "a \\( unfinished", "100$"]) {
assert.deepEqual(sources(text), [], text);
}
assert.deepEqual(sources("`$x$` and ```\n$$y$$\n```"), [], "code is code");
assert.deepEqual(sources("```js\nlet a = '$x$'\n```\n$z$"), [["z", false]], "a closed fence ends");
assert.deepEqual(sources("~~~\n\\(a\\)\n~~~"), [], "tilde fences too");
assert.deepEqual(sources("[x](https://example.com/$a$/b) $c$"), [["c", false]], "a link target is not math");
},
split_streaming() {
const parts = split("see $$x^2 + ");
assert.equal(parts.length, 2);
assert.equal(parts[1].incomplete, true, "a formula still being written is plain text");
assert.equal(parts[1].source, undefined);
assert.equal(split("```\n$$never closed").filter((p) => p.source !== undefined).length, 0, "an open fence swallows the rest");
},
markdown_escapes_everything() {
const fmt = (t) => `<p>${esc(t)}</p>`;
const html = math.markdown("hi <b>there</b> $x<y>\"'$ and <script>alert(1)</script>", fmt);
assert.ok(html.includes("&lt;b&gt;there&lt;/b&gt;") && html.includes("&lt;script&gt;"), "the model's own tags are escaped");
assert.ok(!/<script|<b>/.test(html), html);
// the source sits in one attribute, URL-encoded, and as escaped text
const m = /data-math="([^"]*)"/.exec(html);
assert.ok(m, html);
assert.equal(decodeURIComponent(unesc(m[1])), "x<y>\"'", "the attribute (as the browser reads it) holds the source");
assert.ok(!m[1].includes("<") && !m[1].includes('"'));
assert.ok(html.includes("$x&lt;y&gt;&quot;&#39;$"), "the visible fallback is the escaped source");
// an attribute break-out in the source stays in the attribute
const evil = math.markdown('$x" onmouseover="alert(1)$', fmt);
assert.equal((evil.match(/<span/g) || []).length, 1);
const tag = /<span ([^>]*)>/.exec(evil)[1];
assert.deepEqual([...tag.matchAll(/(?:^|\s)([\w-]+)="/g)].map((x) => x[1]), ["class", "data-math", "data-display"], "no attribute of the model's");
},
markdown_keeps_source_text() {
const text = "a $x$ and \\(y\\)";
const seen = [];
math.markdown(text, (t) => { seen.push(t); return t; });
assert.equal(text, "a $x$ and \\(y\\)", "strings are values, but the function must also not need a copy");
assert.ok(!/\$x\$|\\\(y\\\)/.test(seen[0]), "the Markdown step sees placeholders, not formulas");
// a message that contains the placeholder word itself is not confused
const html = math.markdown("STRATAMATHPLACEHOLDER0END and $x$", (t) => t);
assert.ok(html.startsWith("STRATAMATHPLACEHOLDER0END and <span"), html);
},
async hydrate_with_a_stub_adapter() {
const calls = [];
class Slot {
constructor(source, display) { this.dataset = {math: encodeURIComponent(source), display: display ? "1" : "0"}; this.children = []; this.title = ""; }
replaceChildren(c) { this.children = [c]; }
}
const box = (slots) => ({querySelectorAll: () => slots, contains: (s) => slots.includes(s)});
const mk = (tag) => ({tag, cloneNode() { return {tag, cloned: true}; }});
globalThis.StrataMathAdapter = {
async initialize() { calls.push("init"); },
async render(source, {display}) {
calls.push("render " + source);
if (source === "bad") return {ok: false, reason: "invalid"};
return {ok: true, element: mk(source)};
},
};
math.reset();
const a = new Slot("x", false), b = new Slot("bad", false), c = new Slot("x", true);
const container = box([a, b, c]);
math.hydrate(container);
await new Promise((r) => setTimeout(r, 20));
assert.deepEqual(calls, ["init", "render x", "render bad", "render x"], "one at a time, started once");
assert.equal(a.children[0].tag, "x"); assert.equal(c.children[0].tag, "x");
assert.equal(b.dataset.mathError, "invalid"); assert.ok(b.children.length === 0, "a bad formula stays source text");
assert.match(b.title, /invalid/);
// drawn before: placed at once (no flicker while an answer streams), without another render
const again = new Slot("x", false);
math.hydrate(box([again]));
assert.equal(again.children[0].tag, "x", "placed synchronously from the cache");
assert.equal(calls.length, 4);
// an answer that was replaced while a formula was drawn does not get the old drawing
const old = new Slot("slow", false);
const stale = box([old]);
globalThis.StrataMathAdapter.render = async (s) => { await new Promise((r) => setTimeout(r, 15)); return {ok: true, element: mk(s)}; };
math.hydrate(stale);
math.hydrate(box([])); // a different container is a different generation; the same container is bumped below
stale._mathGeneration++;
await new Promise((r) => setTimeout(r, 40));
assert.equal(old.children.length, 0, "stale generation ignored");
// a formula over 16 KiB is not drawn, and no adapter means source text
assert.deepEqual(await math.result("x".repeat(20000), false), {ok: false, reason: "limit"});
math.reset(); delete globalThis.StrataMathAdapter;
assert.deepEqual(await math.result("y", false), {ok: false, reason: "unavailable"});
math.reset(); globalThis.StrataMathAdapter = {initialize: async () => { throw new Error("no engine"); }, render: async () => ({ok: true})};
assert.deepEqual(await math.result("z", false), {ok: false, reason: "unavailable"}, "an engine that cannot start leaves the source");
delete globalThis.StrataMathAdapter;
},
katex_options_are_the_safe_ones() {
const {OPTIONS} = require(path.join(web, "math-adapter.js"));
assert.equal(OPTIONS.trust, false, "no \\href, \\url, \\includegraphics, \\htmlClass ...");
assert.equal(OPTIONS.throwOnError, true, "an error returns the source, not a red error box");
assert.ok(OPTIONS.maxExpand <= 1000 && OPTIONS.maxSize <= 20, "macro loops and huge sizes are cut");
},
katex_hostile_input() {
const katex = require(path.join(web, "vendor", "katex", "dist", "katex.min.js"));
const {OPTIONS} = require(path.join(web, "math-adapter.js"));
const draw = (src, display = false) => katex.renderToString(src, {...OPTIONS, displayMode: display, macros: {}});
// with trust off these commands are not run: KaTeX draws the command's name in red, as text. There is no link, picture,
// style, id, class or data attribute of the model's making in the result.
const mathml = (h) => h.replace(/<annotation[^>]*>[\s\S]*?<\/annotation>/g, "");
for (const [src, name] of [["\\href{javascript:alert(1)}{click}", "\\href"], ["\\url{javascript:alert(1)}", "\\url"],
["\\href{https://example.com}{x}", "\\href"], ["\\includegraphics{https://example.com/p.png}", "\\includegraphics"],
["\\htmlClass{evil}{b}", "\\htmlClass"], ["\\htmlId{evil}{b}", "\\htmlId"],
["\\htmlStyle{color:red}{b}", "\\htmlStyle"], ["\\htmlData{evil=1}{b}", "\\htmlData"]]) {
const html = mathml(draw(src));
assert.ok(html.includes(`<mtext>${name}</mtext>`), src + " is drawn as its own name: " + html.slice(0, 200));
assert.ok(!/<a\b|<img\b|href=|src=|javascript:|evil|id="|data-evil|color:red/i.test(html), src + " -> " + html);
}
for (const src of ["<img src=x onerror=alert(1)>", "\\text{<script>alert(1)</script>}", "a < b > c \\& d \" '",
"\\text{</span><img src=x onerror=alert(1)>}", "\\mathrm{javascript:alert(1)}", "x_{<svg onload=alert(1)>}"]) {
const html = draw(src);
// the output may contain the text, but only as text: no tag the model wrote exists
assert.ok(!/<(img|script|svg|iframe|a)\b/i.test(html.replace(/<annotation[^>]*>[\s\S]*?<\/annotation>/g, "")), src + " -> " + html);
const tags = (html.match(/<[a-zA-Z][^>]*>/g) || []).join(" ");
assert.ok(!/\son\w+\s*=|javascript:|\ssrc\s*=|\shref\s*=/i.test(tags), "no event handler, link or source in any tag: " + src);
}
// the TeX source is kept in an <annotation> as escaped text, never as markup
const kept = draw("<img src=x onerror=alert(1)>");
assert.ok(!kept.includes("<img"), kept);
// limits
assert.throws(() => draw("\\def\\a{\\a\\a}\\a"), /expansion|Too many/i, "a macro loop is cut");
const rule = mathml(draw("\\rule{100em}{100em}"));
assert.ok(rule.includes("20em") && !rule.includes("100em"), "a huge rule is cut to maxSize");
// macros are not shared between formulas
draw("\\gdef\\foo{1}\\foo");
assert.throws(() => draw("\\foo"), /Undefined control sequence/);
// and a good formula still draws
assert.match(draw("\\frac{a}{b} + \\sqrt{x}"), /class="katex"/);
},
};
(async () => {
const name = process.argv[2];
const run = name ? [[name, cases[name]]] : Object.entries(cases);
if (name && !cases[name]) throw new Error("unknown test " + name);
for (const [n, fn] of run) { await fn(); console.log("ok " + n); }
})().catch((e) => { console.error(e); process.exitCode = 1; });
+107
View File
@@ -0,0 +1,107 @@
"""Chat math on the served page (serve/web/math.js, math-adapter.js, vendor/katex): opt-in, offline, no scripts from the model."""
from __future__ import annotations
import hashlib
import json
import re
import shutil
import subprocess
import unittest
import urllib.error
import urllib.request
from pathlib import Path
from serve.frontend import ChatTemplate
from serve.server import ByteTokenizer, MockEngine, Service, serve
WEB = Path(__file__).parent / "web"
KATEX = WEB / "vendor" / "katex"
@unittest.skipUnless(shutil.which("node"), "node is not installed")
class MathInNode(unittest.TestCase):
def test_delimiters_escaping_drawing_and_hostile_formulas(self):
r = subprocess.run([shutil.which("node"), str(Path(__file__).with_name("test_web_math.js"))],
capture_output=True, text=True, timeout=120)
self.assertEqual(r.returncode, 0, r.stdout + r.stderr)
class Vendored(unittest.TestCase):
def test_the_files_are_the_ones_in_provenance(self):
prov = json.loads((KATEX / "PROVENANCE.json").read_text(encoding="utf-8"))
self.assertEqual((prov["package"], prov["version"], prov["license"]), ("katex", "0.19.0", "MIT"))
self.assertTrue(prov["integrity"].startswith("sha512-"))
on_disk = {p.relative_to(KATEX).as_posix() for p in KATEX.rglob("*") if p.is_file() and p.name != "PROVENANCE.json"}
self.assertEqual(on_disk, set(prov["sha256"]), "a file was added or removed without PROVENANCE.json")
for name, digest in prov["sha256"].items():
with self.subTest(name=name):
self.assertEqual(hashlib.sha256((KATEX / name).read_bytes()).hexdigest(), digest)
self.assertTrue((KATEX / "LICENSE").read_text(encoding="utf-8").startswith("The MIT License"))
def test_it_works_offline(self):
css = (KATEX / "dist" / "katex.min.css").read_text(encoding="utf-8")
fonts = set(re.findall(r"url\(fonts/([\w-]+\.woff2)\)", css))
self.assertEqual(len(fonts), 20)
for font in fonts:
self.assertTrue((KATEX / "dist" / "fonts" / font).is_file(), font)
# no address outside this server in anything that loads on the page
for f in (WEB / "math.js", WEB / "math-adapter.js", KATEX / "dist" / "katex.min.css"):
self.assertNotRegex(f.read_text(encoding="utf-8"), r"https?://(?!www\.w3\.org)", f.name)
self.assertNotIn("@import", css)
def test_the_page_loads_nothing_until_math_is_on(self):
html = (WEB / "index.html").read_text(encoding="utf-8")
js = (WEB / "app.js").read_text(encoding="utf-8")
self.assertIn('src="web/math.js"', html)
self.assertIn('src="web/math-adapter.js"', html)
self.assertNotIn("katex", html.lower().replace("math-adapter", ""), "KaTeX is not named in the page: the adapter loads it")
self.assertIn("math: false", js, "off by default")
self.assertIn('id="s-math"', html)
self.assertIn('aria-checked="false"', re.search(r'<button[^>]*id="s-math"[^>]*>', html).group(0))
adapter = re.sub(r"^\s*//.*$", "", (WEB / "math-adapter.js").read_text(encoding="utf-8"), flags=re.M) # the code, not its comments
self.assertIn("trust: false", adapter)
self.assertNotIn("innerHTML", adapter)
self.assertNotIn("renderToString", adapter, "KaTeX builds DOM nodes; no HTML string goes into the page")
class Served(unittest.TestCase):
@classmethod
def setUpClass(cls):
tok = ByteTokenizer()
cls.svc = Service(MockEngine(tok, "ok"), tok, ChatTemplate(Path(__file__).parent / "chat_template.jinja"))
cls.httpd = serve(cls.svc, port=0)
cls.base = f"http://127.0.0.1:{cls.httpd.server_address[1]}"
@classmethod
def tearDownClass(cls):
cls.httpd.shutdown()
cls.httpd.server_close()
def get(self, path):
try:
with urllib.request.urlopen(self.base + path, timeout=10) as r:
return r.status, r.headers.get("Content-Type", ""), r.read()
except urllib.error.HTTPError as e:
return e.code, e.headers.get("Content-Type", ""), e.read()
def test_scripts_style_and_fonts_are_served(self):
for path, want in (("/web/math.js", "javascript"), ("/web/math-adapter.js", "javascript"),
("/web/vendor/katex/dist/katex.min.js", "javascript"),
("/web/vendor/katex/dist/katex.min.css", "text/css"),
("/web/vendor/katex/dist/fonts/KaTeX_Main-Regular.woff2", "font/woff2")):
with self.subTest(path=path):
code, ctype, body = self.get(path)
self.assertEqual(code, 200)
self.assertIn(want, ctype)
self.assertTrue(body)
def test_nothing_else_below_vendor(self):
for path in ("/web/vendor/katex/PROVENANCE.json", "/web/vendor/katex/LICENSE", "/web/vendor/katex/dist/fonts/x.ttf",
"/web/vendor/katex/../../app.js", "/web/vendor/", "/web/vendor/katex/dist/fonts",
"/web/vendor/katex/dist/missing.js"):
with self.subTest(path=path):
self.assertEqual(self.get(path)[0], 404)
if __name__ == "__main__":
unittest.main()
+7
View File
@@ -221,3 +221,10 @@ main { flex: 1; min-height: 0; display: flex; }
.img-results figure{margin:0}
.img-results img{max-width:100%;border-radius:var(--st-r-lg);display:block}
.img-results figcaption{font-size:var(--st-fs-sm, 13px);color:var(--st-ink-2, inherit);margin-top:4px}
/* Math (serve/web/math.js): a formula never widens the chat; a long display formula scrolls inside its own box. */
.chat-math { overflow-wrap: normal; }
.chat-math--display { display: block; text-align: center; overflow-x: auto; max-width: 100%; margin: .6em 0; padding: .25em 0; }
.chat-math--display > * { margin: 0 !important; }
.chat-math[data-math-error] { white-space: pre-wrap; }
.chat-math .katex { font-size: 1.08em; }
+17 -4
View File
@@ -573,7 +573,7 @@ function markdown(text) {
// fields: the server picks the card's set for the request's thinking state (thinking 1.0 / 0.95 / 20, not thinking
// 0.7 / 0.8 / 20 with a presence penalty). Before 0.1.45 the page sent 0.6 itself; a stored 0.6 with the other two
// values untouched counts as "not set" too.
const DEFAULTS = {thinking: "high", temperature: 1.0, top_p: 0.95, top_k: 20, max: "", seed: "", show: true, esp: true, mcp: true};
const DEFAULTS = {thinking: "high", temperature: 1.0, top_p: 0.95, top_k: 20, max: "", seed: "", show: true, esp: true, mcp: true, math: false};
const OLD_DEFAULT_TEMPERATURE = 0.6;
function samplingUntouched(s) {
const t = +s.temperature;
@@ -658,16 +658,20 @@ function toolHtml(t, k) {
`${icon("chevron", "st-icon st-icon--sm st-chev")}</summary><div class="st-collapse__body">${body}</div></details>`;
}
// the answer's text with the tool blocks where the model called them
// math (opt-in, serve/web/math.js): formulas become placeholders before the Markdown runs and are drawn after it
function answerMarkdown(text) {
return settings.math ? StrataMath.markdown(text, markdown) : markdown(text);
}
function answerHtml(m) {
if (!m.tools || !m.tools.length) return markdown(m.text || "");
if (!m.tools || !m.tools.length) return answerMarkdown(m.text || "");
let html = "", pos = 0;
m.tools.forEach((t, k) => {
const at = Math.min(Math.max(t.at || 0, pos), m.text.length);
if (at > pos) html += markdown(m.text.slice(pos, at));
if (at > pos) html += answerMarkdown(m.text.slice(pos, at));
pos = at;
html += toolHtml(t, k);
});
return html + markdown(m.text.slice(pos));
return html + answerMarkdown(m.text.slice(pos));
}
// a tool event from the stream (the `strata_mcp` field of a chunk)
function onTool(m, x) {
@@ -704,6 +708,7 @@ function updateAssistant(el, m, streaming) {
bubble.innerHTML = m.reasoning ? `<span class="muted cursor">Writing</span>` : `<span class="cursor"></span>`;
} else {
bubble.innerHTML = answerHtml(m);
if (settings.math) StrataMath.hydrate(bubble);
if (streaming) bubble.classList.add("cursor"); else bubble.classList.remove("cursor");
}
el.querySelector(".meta-text").textContent = m.meta || (streaming ? "" : m.stopped ? "Stopped" : "");
@@ -1013,6 +1018,7 @@ function loadDrawer(s = settings) {
for (const b of $("s-thinking").children) b.setAttribute("aria-checked", String(b.dataset.v === s.thinking));
$("s-temp").value = s.temperature; $("s-topp").value = s.top_p; $("s-topk").value = s.top_k;
$("s-max").value = s.max; $("s-seed").value = s.seed;
$("s-math").setAttribute("aria-checked", String(!!s.math));
$("s-show").setAttribute("aria-checked", String(!!s.show));
$("s-esp").setAttribute("aria-checked", String(s.esp !== false));
$("esp-row").hidden = !projectionLoaded();
@@ -1064,6 +1070,7 @@ function outputs() {
}
for (const b of $("s-thinking").children) b.onclick = () => { for (const x of $("s-thinking").children) x.setAttribute("aria-checked", String(x === b)); outputs(); };
for (const id of ["s-temp", "s-topp", "s-topk"]) $(id).oninput = outputs;
$("s-math").onclick = () => $("s-math").setAttribute("aria-checked", String($("s-math").getAttribute("aria-checked") !== "true"));
$("s-show").onclick = () => $("s-show").setAttribute("aria-checked", String($("s-show").getAttribute("aria-checked") !== "true"));
$("s-esp").onclick = () => $("s-esp").setAttribute("aria-checked", String($("s-esp").getAttribute("aria-checked") !== "true"));
$("s-mcp").onclick = () => $("s-mcp").setAttribute("aria-checked", String($("s-mcp").getAttribute("aria-checked") !== "true"));
@@ -1073,10 +1080,16 @@ $("s-apply").onclick = async () => {
const sel = [...$("s-thinking").children].find((b) => b.getAttribute("aria-checked") === "true");
settings = {thinking: sel ? sel.dataset.v : "high", temperature: +$("s-temp").value, top_p: +$("s-topp").value,
top_k: +$("s-topk").value, max: $("s-max").value.trim(), seed: $("s-seed").value.trim(),
math: $("s-math").getAttribute("aria-checked") === "true",
show: $("s-show").getAttribute("aria-checked") === "true",
esp: $("s-esp").getAttribute("aria-checked") === "true",
mcp: $("s-mcp").getAttribute("aria-checked") === "true"};
store.set("sampling", settings);
StrataMath.reset(); // math on or off: every answer is drawn again
for (const el of $("chat").querySelectorAll(".st-msg--assistant")) {
const m = messages[+el.dataset.i];
updateAssistant(el, m, !!busy && busy.msg === m); // the element send() holds stays the same while an answer streams
}
const share = $("s-share").getAttribute("aria-checked") === "true";
openDrawer(false);
if (share || sharedOn) {
+5
View File
@@ -248,6 +248,9 @@
<label class="st-label" for="s-seed">Seed <output>empty = random</output></label>
<input class="st-input" id="s-seed" type="number" min="1" step="1" placeholder="Random">
</div>
<div class="toggle-row"><span><span data-i18n="drawer.math">Render math</span><br><span class="muted small"
data-i18n="drawer.mathNote">draw equations in answers as formulas (works offline)</span></span>
<button class="st-toggle" id="s-math" role="switch" aria-checked="false" aria-label="Render math" data-i18n-label="drawer.math"></button></div>
<div class="toggle-row"><span>Show thinking<br><span class="muted small">expanded while it streams</span></span>
<button class="st-toggle" id="s-show" role="switch" aria-checked="true" aria-label="Show thinking"></button></div>
<div class="toggle-row" id="mcp-row" hidden><span>Use tools from MCP servers<br><span class="muted small"
@@ -268,6 +271,8 @@
<div class="scrim" id="scrim" hidden></div>
<div class="st-toasts" id="toasts" aria-live="polite"></div>
<script src="web/i18n.js"></script>
<script src="web/math.js"></script>
<script src="web/math-adapter.js"></script>
<script src="web/app.js"></script>
</body>
</html>
+34
View File
@@ -0,0 +1,34 @@
// serve/web/math-adapter.js - the KaTeX renderer for math.js (PR #2974 by CC-David-CC). KaTeX 0.19.0 is vendored in
// serve/web/vendor/katex (MIT, see PROVENANCE.json there): no network, no CDN. Nothing is loaded until "Render math" is
// switched on and an answer holds a formula.
//
// What a formula may do: nothing but draw. `trust: false` turns off \href, \url, \includegraphics and \htmlClass-style
// commands, so a formula cannot make a link, load a picture or add an attribute; the macros are not shared between
// formulas; \def loops are cut by maxExpand and a huge \rule or \kern by maxSize. KaTeX builds DOM nodes itself (no
// innerHTML), the result is cloned into the page by math.js, and an error returns the source text instead.
"use strict";
(function (root) {
const OPTIONS = {output: "htmlAndMathml", trust: false, throwOnError: true, strict: "ignore", maxExpand: 1000, maxSize: 20};
const adapter = {
OPTIONS,
async initialize() {
if (root.katex && document.querySelector("link[data-katex-ready]")) return;
const css = document.createElement("link"); css.rel = "stylesheet";
css.href = "web/vendor/katex/dist/katex.min.css";
const loaded = new Promise((resolve, reject) => { css.onload = resolve; css.onerror = reject; });
document.head.append(css);
await Promise.all([loaded, root.katex ? Promise.resolve() : root.StrataMath.script("web/vendor/katex/dist/katex.min.js")]);
css.dataset.katexReady = "1";
},
async render(source, {display, signal}) {
if (signal.aborted) return {ok: false, reason: "unavailable"};
const element = document.createElement("span");
try {
root.katex.render(source, element, {...OPTIONS, displayMode: display, macros: {}});
return {ok: true, element: element.firstElementChild};
} catch (_) { return {ok: false, reason: "invalid"}; }
},
};
root.StrataMathAdapter = adapter;
if (typeof module !== "undefined" && module.exports) module.exports = adapter;
})(typeof globalThis !== "undefined" ? globalThis : this);
+142
View File
@@ -0,0 +1,142 @@
// serve/web/math.js - chat math that never changes the source. Engine-independent: a renderer (math-adapter.js) is plugged in.
// Ported from PR #2972 by CC-David-CC. The text of a message stays as the model wrote it (history, copying, the Markdown
// export and the requests all use it); math is only drawn over a placeholder in the page, and a formula that cannot be drawn
// stays as its source text.
"use strict";
(function (root) {
const escape = (s) => String(s).replace(/[&<>"']/g, (c) => ({"&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;"}[c]));
const escaped = (s, i) => { let n = 0; while (i > 0 && s[--i] === "\\") n++; return n % 2 === 1; };
// split(text) -> [{raw}, {source, display, raw}, {raw, incomplete: true}, ...]
// \( \) and $x$ are inline, \[ \] and $$ $$ are display. Code (fences, `spans`), Markdown link targets and prices ($5 and
// $10) are not math. A formula that is still being written (no closing delimiter yet) stays plain text.
function split(text) {
const parts = []; let start = 0, i = 0;
const push = (end, math) => { if (start < i) parts.push({raw: text.slice(start, i)}); parts.push(math); start = i = end; };
while (i < text.length) {
// Code takes precedence, including incomplete fences while streaming.
const fence = (i === 0 || text[i - 1] === "\n") && text.slice(i).match(/^ {0,3}(`{3,}|~{3,})[^\n]*\n/);
if (fence) {
const marker = fence[1][0], count = fence[1].length;
const end = new RegExp("^ {0,3}" + marker + "{" + count + ",}[ \\t]*(?:\\n|$)", "m").exec(text.slice(i + fence[0].length));
i = end ? i + fence[0].length + end.index + end[0].length : text.length; continue;
}
if (text[i] === "`" && !escaped(text, i)) {
const ticks = text.slice(i).match(/^`+/)[0]; let end = text.indexOf(ticks, i + ticks.length);
while (end >= 0 && (text[end - 1] === "`" || text[end + ticks.length] === "`")) end = text.indexOf(ticks, end + ticks.length);
i = end < 0 ? text.length : end + ticks.length; continue;
}
if (escaped(text, i)) { i++; continue; }
// Never inject a math element into a Markdown link's href attribute.
if (text.startsWith("](http", i)) {
const end = text.indexOf(")", i + 2);
i = end < 0 ? text.length : end + 1; continue;
}
let open, close, display;
if (text.startsWith("\\(", i)) { open = "\\("; close = "\\)"; display = false; }
else if (text.startsWith("\\[", i)) { open = "\\["; close = "\\]"; display = true; }
else if (text.startsWith("$$", i)) { open = close = "$$"; display = true; }
else if (text[i] === "$" && !/\s/.test(text[i + 1] || " ") && text[i - 1] !== "$") { open = close = "$"; display = false; }
if (!open) { i++; continue; }
let end = text.indexOf(close, i + open.length);
while (end >= 0 && (escaped(text, end) || (close === "$" && (/\s/.test(text[end - 1]) || /[\d$]/.test(text[end + 1] || ""))))) end = text.indexOf(close, end + close.length);
const source = end >= 0 ? text.slice(i + open.length, end) : text.slice(i + open.length);
// Numeric prices are text, even when separated by a second dollar amount.
if (open === "$" && (/\n/.test(source) || /\$/.test(source) ||
/^\d[\d,.]*(?:\s*[A-Za-z]|\s*$)/.test(source))) { i++; continue; }
if (end < 0) { push(text.length, {raw: text.slice(i), incomplete: true}); break; }
push(end + close.length, {source, display, raw: text.slice(i, end + close.length)});
}
if (start < text.length) parts.push({raw: text.slice(start)});
return parts;
}
// markdown(text, format): format() is the page's Markdown, run on the text with every formula swapped for a placeholder
// word, then each word becomes <span class="chat-math" data-math="..."> holding the escaped source until it is drawn.
function markdown(text, format) {
let prefix = "STRATAMATHPLACEHOLDER"; while (text.includes(prefix)) prefix += "X";
const slots = [];
const protectedText = split(text).map((p) => {
if (p.source === undefined && !p.incomplete) return p.raw;
const key = prefix + slots.length + "END";
slots.push(p); return key;
}).join("");
let html = format(protectedText);
slots.forEach((p, i) => {
const body = p.incomplete ? escape(p.raw) : `<span class="chat-math${p.display ? " chat-math--display" : ""}" data-math="${escape(encodeURIComponent(p.source))}" data-display="${p.display ? "1" : "0"}">${escape(p.raw)}</span>`;
html = html.replaceAll(prefix + i + "END", () => body);
});
return html;
}
// drawing: one formula at a time (an engine may keep state between formulas), 256 results cached, a 10 s deadline for the
// engine to start and 15 s for one formula. A formula over 16 KiB is not drawn. The adapter returns an element that is not
// in the page; the core clones it into the placeholder if that is still on the screen.
const cache = new Map(), done = new Map();
let initialization = null, queue = Promise.resolve(), epoch = 0;
function reset() { cache.clear(); done.clear(); initialization = null; epoch++; }
const keyOf = (source, display) => JSON.stringify([source, display]);
async function result(source, display) {
if (new TextEncoder().encode(source).length > 16384) return {ok: false, reason: "limit"};
const key = keyOf(source, display);
if (cache.has(key)) { const hit = cache.get(key); cache.delete(key); cache.set(key, hit); return hit; }
if (cache.size >= 256) { const old = cache.keys().next().value; cache.delete(old); done.delete(old); }
const current = epoch; let work;
const task = queue.then(async () => {
if (epoch !== current || cache.get(key) !== task) return {ok: false, reason: "unavailable"};
try {
const adapter = root.StrataMathAdapter;
if (!adapter) return {ok: false, reason: "unavailable"};
initialization ||= Promise.race([adapter.initialize(), new Promise((_, reject) =>
setTimeout(() => reject(new Error("Math initialization timed out")), 10000))]);
await initialization;
const signal = AbortSignal.timeout(15000);
let onAbort;
const deadline = new Promise((resolve) => {
onAbort = () => resolve({ok: false, reason: "limit"});
signal.addEventListener("abort", onAbort, {once: true});
});
work = Promise.resolve(adapter.render(source, {display, signal}));
try { return await Promise.race([work, deadline]); }
finally { signal.removeEventListener("abort", onAbort); }
} catch (_) { return {ok: false, reason: "unavailable"}; }
});
// A browser engine may ignore cancellation. Return source at the deadline,
// but do not overlap conversions that mutate the engine's macro state.
queue = task.then(() => work, () => work).then(() => undefined, () => undefined);
cache.set(key, task);
task.then((r) => { if (cache.get(key) === task) done.set(key, r); });
return task;
}
function place(slot, r, tr) {
if (r.ok) slot.replaceChildren(r.element.cloneNode(true));
else { slot.dataset.mathError = r.reason; slot.title = tr ? tr("math.source", "Math source ({reason})", {reason: r.reason}) : "Math source (" + r.reason + ")"; }
}
// hydrate(container): draw the placeholders in a container. A formula drawn before is placed at once, so a streaming
// answer that is rebuilt on every frame does not flicker back to source text.
function hydrate(container) {
const generation = container._mathGeneration = (container._mathGeneration || 0) + 1;
for (const slot of container.querySelectorAll("[data-math]")) {
const source = decodeURIComponent(slot.dataset.math), display = slot.dataset.display === "1";
const known = done.get(keyOf(source, display));
if (known && known.ok) { place(slot, known, root.tr); continue; }
result(source, display).then((r) => {
if (generation !== container._mathGeneration || !container.contains(slot)) return;
place(slot, r, root.tr);
});
}
}
function script(url) {
return new Promise((resolve, reject) => {
const s = document.createElement("script"); s.src = url;
const timer = setTimeout(() => { s.remove(); reject(new Error("Math script timed out")); }, 10000);
s.onload = () => { clearTimeout(timer); resolve(); };
s.onerror = () => { clearTimeout(timer); s.remove(); reject(new Error("Math script unavailable")); };
document.head.append(s);
});
}
const api = {split, markdown, hydrate, reset, script, result};
root.StrataMath = api;
if (typeof module !== "undefined" && module.exports) module.exports = api;
})(typeof globalThis !== "undefined" ? globalThis : this);
+21
View File
@@ -0,0 +1,21 @@
The MIT License (MIT)
Copyright (c) 2013-2020 Khan Academy and other contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+34
View File
@@ -0,0 +1,34 @@
{
"package": "katex",
"version": "0.19.0",
"url": "https://registry.npmjs.org/katex/-/katex-0.19.0.tgz",
"integrity": "sha512-v6Tznz3zJ7u3niRCoDTsumM2+HA2XXcCu+WAacCeHD2z3p9A9Ks987o5FzfTGBN0e8A0vjEgIDvLbICrXpdw/Q==",
"license": "MIT",
"verified": "the tarball's sha512 was checked against the integrity above when these files were copied from package/dist (2026-10-26)",
"changes": "dist/katex.min.css lost the .woff and .ttf fallbacks of its 20 @font-face rules (every browser that runs this page reads woff2); nothing else was edited. katex.min.js and the 20 woff2 fonts are the package's files unchanged.",
"sha256": {
"LICENSE": "766ccc1f306c885aa45542a9846bbd0a505b27a0374f146778171c2254ce18e3",
"dist/fonts/KaTeX_AMS-Regular.woff2": "0cdd387c9590a1a9f9794560022dbb59654a7d86f187aa0c81495ad42d3a7308",
"dist/fonts/KaTeX_Caligraphic-Bold.woff2": "de7701e42cf1f4cf0b766c03fb27977207eee2f4fd5d76fa82188406da43ea4c",
"dist/fonts/KaTeX_Caligraphic-Regular.woff2": "5d53e70ad607c2352162dec9e0923fb54ecdafaccbf604cd8dcf7d00facb989b",
"dist/fonts/KaTeX_Fraktur-Bold.woff2": "74444efd593c005e3f4573b44524704c0af0a937fe911cca9e94068d0d140d3f",
"dist/fonts/KaTeX_Fraktur-Regular.woff2": "51814d270d06ff0255dba0799994fa4d8c84d11f09951d47595f4abb1f3602dc",
"dist/fonts/KaTeX_Main-Bold.woff2": "0f60d1b897938ec918c8ce073092411baf9438f6739465693ff18b0f9d20b021",
"dist/fonts/KaTeX_Main-BoldItalic.woff2": "99cd42a3c072d918f2f44984a807cf7aa16e13545fd0875fc07c6c65f99e715b",
"dist/fonts/KaTeX_Main-Italic.woff2": "97479ca6cce906abc961ecac96faa5f9ca2e61b8e7670d475826bcdee9a7c267",
"dist/fonts/KaTeX_Main-Regular.woff2": "c2342cd8b869e01752a9321dc17213fc40d4d04c79688c1d43f2cf316abd7866",
"dist/fonts/KaTeX_Math-BoldItalic.woff2": "dc47344dbb6cb5b655c8460d561f4df5f501b90c804ad3c6cec65fe322351ab1",
"dist/fonts/KaTeX_Math-Italic.woff2": "7af58c5ec8f132a2ddde9027c6d7814decce4d3b822a11192a42a20e2e973264",
"dist/fonts/KaTeX_SansSerif-Bold.woff2": "e99ae51144bf1232efcc1bfe5add36262c6866b0faab24fa75740e1b98577a62",
"dist/fonts/KaTeX_SansSerif-Italic.woff2": "00b26ac825e2095056396e0553b8ac26d3f8ad158c3826e28b4c45b385c4714a",
"dist/fonts/KaTeX_SansSerif-Regular.woff2": "68e8c73ef42afd3ccec58bf0fba302cce448938e7fc020a5e31f8a952eee1342",
"dist/fonts/KaTeX_Script-Regular.woff2": "036d4e95149b69ff9bcc0cd55771efeb25ffa3947293e69acd78d5ac328c684b",
"dist/fonts/KaTeX_Size1-Regular.woff2": "6b47c40166b6dbe21a5dfca7718413f2147fd2399be1ba605d8ad39cedf25dfe",
"dist/fonts/KaTeX_Size2-Regular.woff2": "d04c54219f9eaec6d4d4fd42dfb28785975a4794d6b2fc71e566b9cd6db842dd",
"dist/fonts/KaTeX_Size3-Regular.woff2": "73d591271b1604960cb10bb90fee021670af7297017e0e98480b332d11f51995",
"dist/fonts/KaTeX_Size4-Regular.woff2": "a4af7d414440a1c1790825cfb700cf9cf43b0f2c4b04f0ebc523011ad9853ec0",
"dist/fonts/KaTeX_Typewriter-Regular.woff2": "71d517d67827787cfabdf186914cc3358eda539e37931941f2b2fd4a21f68c0b",
"dist/katex.min.css": "864f909fc1f3da0f140321d0e277ac0a444d8909e072cdbf174bcea93b95c089",
"dist/katex.min.js": "103a53763cc033bba8d175bf3f0ba597c3505c9b6747dd3f2c7bc2a6bfcc8ae7"
}
}
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+1
View File
@@ -0,0 +1 @@
@font-face{font-display:block;font-family:KaTeX_AMS;font-style:normal;font-weight:400;src:url(fonts/KaTeX_AMS-Regular.woff2) format("woff2")}@font-face{font-display:block;font-family:KaTeX_Caligraphic;font-style:normal;font-weight:700;src:url(fonts/KaTeX_Caligraphic-Bold.woff2) format("woff2")}@font-face{font-display:block;font-family:KaTeX_Caligraphic;font-style:normal;font-weight:400;src:url(fonts/KaTeX_Caligraphic-Regular.woff2) format("woff2")}@font-face{font-display:block;font-family:KaTeX_Fraktur;font-style:normal;font-weight:700;src:url(fonts/KaTeX_Fraktur-Bold.woff2) format("woff2")}@font-face{font-display:block;font-family:KaTeX_Fraktur;font-style:normal;font-weight:400;src:url(fonts/KaTeX_Fraktur-Regular.woff2) format("woff2")}@font-face{font-display:block;font-family:KaTeX_Main;font-style:normal;font-weight:700;src:url(fonts/KaTeX_Main-Bold.woff2) format("woff2")}@font-face{font-display:block;font-family:KaTeX_Main;font-style:italic;font-weight:700;src:url(fonts/KaTeX_Main-BoldItalic.woff2) format("woff2")}@font-face{font-display:block;font-family:KaTeX_Main;font-style:italic;font-weight:400;src:url(fonts/KaTeX_Main-Italic.woff2) format("woff2")}@font-face{font-display:block;font-family:KaTeX_Main;font-style:normal;font-weight:400;src:url(fonts/KaTeX_Main-Regular.woff2) format("woff2")}@font-face{font-display:block;font-family:KaTeX_Math;font-style:italic;font-weight:700;src:url(fonts/KaTeX_Math-BoldItalic.woff2) format("woff2")}@font-face{font-display:block;font-family:KaTeX_Math;font-style:italic;font-weight:400;src:url(fonts/KaTeX_Math-Italic.woff2) format("woff2")}@font-face{font-display:block;font-family:"KaTeX_SansSerif";font-style:normal;font-weight:700;src:url(fonts/KaTeX_SansSerif-Bold.woff2) format("woff2")}@font-face{font-display:block;font-family:"KaTeX_SansSerif";font-style:italic;font-weight:400;src:url(fonts/KaTeX_SansSerif-Italic.woff2) format("woff2")}@font-face{font-display:block;font-family:"KaTeX_SansSerif";font-style:normal;font-weight:400;src:url(fonts/KaTeX_SansSerif-Regular.woff2) format("woff2")}@font-face{font-display:block;font-family:KaTeX_Script;font-style:normal;font-weight:400;src:url(fonts/KaTeX_Script-Regular.woff2) format("woff2")}@font-face{font-display:block;font-family:KaTeX_Size1;font-style:normal;font-weight:400;src:url(fonts/KaTeX_Size1-Regular.woff2) format("woff2")}@font-face{font-display:block;font-family:KaTeX_Size2;font-style:normal;font-weight:400;src:url(fonts/KaTeX_Size2-Regular.woff2) format("woff2")}@font-face{font-display:block;font-family:KaTeX_Size3;font-style:normal;font-weight:400;src:url(fonts/KaTeX_Size3-Regular.woff2) format("woff2")}@font-face{font-display:block;font-family:KaTeX_Size4;font-style:normal;font-weight:400;src:url(fonts/KaTeX_Size4-Regular.woff2) format("woff2")}@font-face{font-display:block;font-family:KaTeX_Typewriter;font-style:normal;font-weight:400;src:url(fonts/KaTeX_Typewriter-Regular.woff2) format("woff2")}.katex{font:normal 1.21em KaTeX_Main,math,Times New Roman,serif;line-height:1.2;position:relative;text-indent:0;text-rendering:auto}.katex *{-ms-high-contrast-adjust:none!important;border-color:currentColor}.katex .katex-version:after{content:"0.19.0"}.katex .katex-mathml{border:0;-webkit-clip-path:inset(50%);clip-path:inset(50%);height:1px;overflow:hidden;padding:0;position:absolute;width:1px}.katex .katex-html>.katex-newline{display:block}.katex .katex-base{position:relative;white-space:nowrap;width:-webkit-min-content;width:-moz-min-content;width:min-content}.katex .katex-base,.katex .katex-strut{display:inline-block}.katex .textbf{font-weight:700}.katex .textit{font-style:italic}.katex .textrm{font-family:KaTeX_Main}.katex .textsf{font-family:KaTeX_SansSerif}.katex .texttt{font-family:KaTeX_Typewriter}.katex .mathnormal{font-family:KaTeX_Math;font-style:italic}.katex .mathit{font-family:KaTeX_Main;font-style:italic}.katex .mathrm{font-style:normal}.katex .mathbf{font-family:KaTeX_Main;font-weight:700}.katex .boldsymbol{font-family:KaTeX_Math;font-style:italic;font-weight:700}.katex .amsrm,.katex .mathbb,.katex .textbb{font-family:KaTeX_AMS}.katex .mathcal{font-family:KaTeX_Caligraphic}.katex .mathfrak,.katex .textfrak{font-family:KaTeX_Fraktur}.katex .mathboldfrak,.katex .textboldfrak{font-family:KaTeX_Fraktur;font-weight:700}.katex .mathtt{font-family:KaTeX_Typewriter}.katex .mathscr,.katex .textscr{font-family:KaTeX_Script}.katex .mathsf,.katex .textsf{font-family:KaTeX_SansSerif}.katex .mathboldsf,.katex .textboldsf{font-family:KaTeX_SansSerif;font-weight:700}.katex .mathitsf,.katex .mathsfit,.katex .textitsf{font-family:KaTeX_SansSerif;font-style:italic}.katex .mainrm{font-family:KaTeX_Main;font-style:normal}.katex .vlist-t{border-collapse:collapse;display:inline-table;table-layout:fixed}.katex .vlist-r{display:table-row}.katex .vlist{display:table-cell;position:relative;vertical-align:bottom}.katex .vlist>span{display:block;height:0;position:relative}.katex .vlist>span>span{display:inline-block}.katex .vliLine truncated
+1
View File
@@ -0,0 +1 @@
!function(e,t){"object"==typeof exports&&"object"==typeof module?module.exports=t():"function"==typeof define&&define.amd?define([],t):"object"==typeof exports?exports.katex=t():e.katex=t()}("undefined"!=typeof self?self:this,function(){return function(){"use strict";var e={d:function(t,r){for(var n in r)e.o(r,n)&&!e.o(t,n)&&Object.defineProperty(t,n,{enumerable:!0,get:r[n]})},o:function(e,t){return Object.prototype.hasOwnProperty.call(e,t)}},t={};e.d(t,{default:function(){return go}});class r extends Error{constructor(e,t){let n,o,s="KaTeX parse error: "+e;const i=t&&t.loc;if(i&&i.start<=i.end){const e=i.lexer.input;n=i.start,o=i.end,n===e.length?s+=" at end of input: ":s+=" at position "+(n+1)+": ";const t=e.slice(n,o).replace(/[^]/g,"$&\u0332");let r,a;r=n>15?"\u2026"+e.slice(n-15,n):e.slice(0,n),a=o+15<e.length?e.slice(o,o+15)+"\u2026":e.slice(o),s+=r+t+a}super(s),this.name="ParseError",this.position=void 0,this.length=void 0,this.rawMessage=void 0,Object.setPrototypeOf(this,r.prototype),this.position=n,null!=n&&null!=o&&(this.length=o-n),this.rawMessage=e}}var n=r;const o=/([A-Z])/g,s=e=>e.replace(o,"-$1").toLowerCase(),i={"&":"&amp;",">":"&gt;","<":"&lt;",'"':"&quot;","'":"&#x27;"},a=/[&><"']/g,l=e=>String(e).replace(a,e=>i[e]),c=e=>"ordgroup"===e.type||"color"===e.type?1===e.body.length?c(e.body[0]):e:"font"===e.type?c(e.body):e,h=new Set(["mathord","textord","atom"]),m=e=>h.has(c(e).type),u={displayMode:{type:"boolean",description:"Render math in display mode, which puts the math in display style (so \\int and \\sum are large, for example), and centers the math on the page on its own line.",cli:"-d, --display-mode"},output:{type:{enum:["htmlAndMathml","html","mathml"]},description:"Determines the markup language of the output.",cli:"-F, --format <type>"},leqno:{type:"boolean",description:"Render display math in leqno style (left-justified tags)."},fleqn:{type:"boolean",description:"Render display math flush left."},throwOnError:{type:"boolean",default:!0,cli:"-t, --no-throw-on-error",cliDescription:"Render errors (in the color given by --error-color) instead of throwing a ParseError exception when encountering an error."},errorColor:{type:"string",default:"#cc0000",cli:"-c, --error-color <color>",cliDescription:"A color string given in the format 'rgb' or 'rrggbb' (no #). This option determines the color of errors rendered by the -t option.",cliProcessor:e=>"#"+e},macros:{type:"object",cli:"-m, --macro <def>",cliDescription:"Define custom macro of the form '\\foo:expansion' (use multiple -m arguments for multiple macros).",cliDefault:[],cliProcessor:(e,t)=>(t.push(e),t)},minRuleThickness:{type:"number",description:"Specifies a minimum thickness, in ems, for fraction lines, `\\sqrt` top lines, `{array}` vertical lines, `\\hline`, `\\hdashline`, `\\underline`, `\\overline`, and the borders of `\\fbox`, `\\boxed`, and `\\fcolorbox`.",processor:e=>Math.max(0,e),cli:"--min-rule-thickness <size>",cliProcessor:parseFloat},colorIsTextColor:{type:"boolean",description:"Makes \\color behave like LaTeX's 2-argument \\textcolor, instead of LaTeX's one-argument \\color mode change.",cli:"-b, --color-is-text-color"},strict:{type:[{enum:["warn","ignore","error"]},"boolean","function"],description:"Turn on strict / LaTeX faithfulness mode, which throws an error if the input uses features that are not supported by LaTeX.",cli:"-S, --strict",cliDefault:!1},trust:{type:["boolean","function"],description:"Trust the input, enabling all HTML features such as \\url.",cli:"-T, --trust"},maxSize:{type:"number",default:1/0,description:"If non-zero, all user-specified sizes, e.g. in \\rule{500em}{500em}, will be capped to maxSize ems. Otherwise, elements and spaces can be arbitrarily large",processor:e=>Math.max(0,e),cli:"-s, --max-size <n>",cliProcessor:parseInt},maxExpand:{type:"number",default:1e3,description:"Limit the number of macro expansions to the specified number, to prevent e.g. infinite macro loops. If set to Infinity, the macro expander will try to fully expand as in LaTeX.",processor:e=>Math.max(0,e),cli:"-e, --max-expand <n>",cliProcessor:e=>"Infinity"===e?1/0:parseInt(e)},globalGroup:{type:"boolean",cli:!1}};function p(e){if(Object.prototype.hasOwnProperty.call(e,"default")&&void 0!==e.default)return e.default;return function(e){if("string"!=typeof e)return e.enum[0];switch(e){case"boolean":return!1;case"string":return"";case"number":return 0;case"object":return{};default:throw new Error("Unexpected schema type; settings must declare an explicit default.")}}(Array.isArray(e.type)?e.type[0]:e.type)}function d(e,t,r,n){const o=Object.prototype.hasOwnProperty.call(r,t)?r[t]:void 0,s=Object.prototype.hasOwnProperty.call(n,"processor")?n.processor:void 0;e[t]=void 0!==o?s?s(o):o:p(n)}class g{constructor(e){void 0===e&&(e={}),this.displayMode=void 0,this.output=void 0,this.leqno=void 0,this.fleqn=void 0,this.throwOnError=void 0,this.errorColor=void 0,this.macros=void 0,this.minRuleThickness=void 0,this.colorIsTextCLine truncated