diff --git a/src/report_math.py b/src/report_math.py
new file mode 100644
index 000000000..fe2d76aab
--- /dev/null
+++ b/src/report_math.py
@@ -0,0 +1,87 @@
+"""Preserve report TeX for markdown.js's existing deferred KaTeX renderer."""
+import re
+from html.parser import HTMLParser
+from xml.etree import ElementTree
+
+from markdown.blockprocessors import BlockProcessor
+from markdown.extensions import Extension
+from markdown.inlinepatterns import InlineProcessor
+from markdown.util import AtomicString
+
+
+# Keep delimiter and currency rules aligned with mathBlocks in markdown.js.
+_ESCAPED_MATH = r"\\\[([\s\S]*?)\\\]|\\\(([^\n]*?)\\\)"
+_DOLLAR_MATH = (
+ r"\$\$([\s\S]*?)\$\$|"
+ r"(? str:
md_text = _autolink_urls(md_text)
result = markdown.markdown(
md_text,
- extensions=["extra", "codehilite", "toc", "tables", "sane_lists", "md_in_html"],
+ extensions=["extra", "codehilite", "toc", "tables", "sane_lists", "md_in_html", ReportMathExtension()],
extension_configs={
"codehilite": {"css_class": "code", "guess_lang": False},
"toc": {"marker": "", "toc_depth": "2-3"},
@@ -1021,6 +1023,8 @@ body[class^="standard-report-"] .report-section:nth-of-type(even) {{
.content code {{ font-family: var(--font-mono); font-size: 0.86em; background: var(--bg-surface-alt); padding: 0.15em 0.4em; border-radius: 4px; }}
.content pre {{ background: var(--bg-surface-alt); border: 1px solid var(--border); border-radius: var(--radius); padding: 1.25rem 1.5rem; overflow-x: auto; margin: 1.25rem 0; font-size: 0.86rem; line-height: 1.6; }}
.content pre code {{ background: none; padding: 0; }}
+/* Match the chat renderer's handling of wide display equations. */
+.content .katex-display {{ overflow-x: auto; overflow-y: hidden; }}
.content table {{ width: 100%; border-collapse: collapse; margin: 1.25rem 0; font-size: 0.9rem; border-radius: var(--radius); overflow: hidden; box-shadow: var(--shadow-sm); }}
.content th {{ text-align: left; padding: 0.7rem 1rem; background: var(--accent-bg); font-weight: 600; border-bottom: 2px solid var(--border-strong); }}
.content td {{ padding: 0.6rem 1rem; border-bottom: 1px solid var(--border); vertical-align: top; }}
@@ -1434,6 +1438,12 @@ body[class^="standard-report-"] .report-section:nth-of-type(even) {{
link'
+ ), "html.parser")
+ assert soup.select_one(".ody-math-pending").get_text() == r'\text{
}'
+ assert soup.find("img") is None
+ assert soup.find("script") is None
+ assert not soup.select('a[href^="javascript:"]')
+
+
+def test_heading_math_reaches_body_and_toc_without_changing_anchor_contract():
+ soup = BeautifulSoup(generate_visual_report(
+ "Formula report", "# Formula report\n\n## Formula $x_i$\n\nValue $x_i$.",
+ session_id="rp-math",
+ ), "html.parser")
+ link = soup.select_one(".toc-sidebar nav a")
+ heading = soup.find(id=link["href"][1:])
+ assert heading["id"] == "formula-xi"
+ for node in (link, heading):
+ assert node.select_one(".ody-math-pending").get_text() == "x_i"
+ assert soup.select_one("#mobile-section-select option").get_text() == "Formula x_i"
+
+
+def test_unclosed_display_math_stays_readable():
+ soup = BeautifulSoup(_md_to_html("$$unfinished\n\nA later paragraph."), "html.parser")
+ assert not soup.select(".ody-math-pending")
+ assert "unfinished" in soup.get_text()
+ assert "A later paragraph." in soup.get_text()
diff --git a/tests/test_visual_report_math_browser.py b/tests/test_visual_report_math_browser.py
new file mode 100644
index 000000000..99d3c0601
--- /dev/null
+++ b/tests/test_visual_report_math_browser.py
@@ -0,0 +1,84 @@
+"""Exercise generated report HTML with the real vendored KaTeX in Chromium."""
+import json
+import os
+from pathlib import Path
+import shutil
+import subprocess
+
+import pytest
+
+from src.visual_report import generate_visual_report
+
+
+@pytest.mark.parametrize("width", [1440, 390])
+@pytest.mark.parametrize("mode", ["math", "plain", "blocked"])
+def test_report_math_in_browser(tmp_path, width, mode):
+ if not shutil.which("node"):
+ pytest.skip("node not installed")
+ markdown = (
+ "# Math report\n\n" + r"## Formula $x_i$" + "\n\n"
+ + r"Inline \(x_i^2\) and $a < b$." + "\n\n"
+ + r"\[\sum_{i=1}^{3} x_i\]" + "\n\n"
+ + r"Invalid: $\frac{1}$. Unsafe: $\href{javascript:alert(1)}{bad}$."
+ + "\n\n`$literal$` costs $5 to $10."
+ + "\n\n$$" + "+".join(["x_i"] * 30) + "$$"
+ ) if mode != "plain" else "## Findings\n\nNo math here."
+ document = tmp_path / "report.html"
+ document.write_text(generate_visual_report("Math report", markdown), encoding="utf-8")
+ script = r"""
+const fs = require('node:fs'), assert = require('node:assert/strict');
+let chromium;
+try { ({chromium} = require('playwright')); }
+catch (error) { if (error.code === 'MODULE_NOT_FOUND') process.exit(77); throw error; }
+(async () => {
+ const browser = await chromium.launch({headless:true});
+ try {
+ const page = await browser.newPage({viewport:{width:Number(process.argv[2]),height:900}});
+ const origin = process.env.ODYSSEUS_TEST_STATIC_ORIGIN;
+ const errors = [], requests = [], failures = [];
+ page.on('pageerror', e => errors.push(e.message));
+ page.on('request', r => requests.push(r.url()));
+ page.on('requestfailed', r => failures.push(r.url()));
+ page.on('dialog', async d => { errors.push('Unexpected dialog'); await d.dismiss(); });
+ const mode = process.argv[3];
+ if (mode === 'blocked') await page.route('**/static/lib/katex/katex.min.js', r => r.abort());
+ await page.route(origin + '/report-math-test', r => r.fulfill({
+ contentType:'text/html', body:fs.readFileSync(process.argv[1], 'utf8')
+ }));
+ await page.goto(origin + '/report-math-test');
+ if (mode === 'math') {
+ await page.locator('.content .katex-display').first().waitFor({state:'attached'});
+ await page.waitForFunction(() => !document.querySelector('.ody-math-pending'));
+ assert.equal(await page.locator('.toc-sidebar .katex').count(), 1);
+ assert.equal(await page.locator('.content .katex-error').count(), 1);
+ assert.equal(await page.locator('.content code').innerText(), '$literal$');
+ assert((await page.locator('.content').innerText()).includes('$5 to $10'));
+ assert.equal(await page.locator('a[href^="javascript:"]').count(), 0);
+ assert.equal(requests.filter(u => u.endsWith('/static/lib/katex/katex.min.js')).length, 1);
+ assert.equal(requests.filter(u => u.endsWith('/static/lib/katex/katex.min.css')).length, 1);
+ assert.equal(await page.evaluate(() => document.documentElement.scrollWidth > innerWidth), false);
+ assert.deepEqual(failures, []);
+ } else if (mode === 'blocked') {
+ await page.waitForFunction(() => performance.getEntriesByType('resource').some(e => e.name.includes('katex.min.js')));
+ assert((await page.locator('.content .ody-math-pending').first().innerText()).includes('x_i'));
+ assert.equal(await page.locator('.katex').count(), 0);
+ } else {
+ await page.waitForLoadState('networkidle');
+ assert.equal(requests.some(u => u.includes('/static/lib/katex/')), false);
+ assert.equal(requests.some(u => u.includes('/static/js/markdown.js')), false);
+ }
+ assert.deepEqual(errors, []);
+ assert(requests.every(u => u.startsWith(origin + '/')), JSON.stringify(requests));
+ console.log(JSON.stringify({mode, width:process.argv[2], requests:requests.length}));
+ } finally { await browser.close(); }
+})().catch(e => { console.error(e); process.exitCode=1; });
+"""
+ result = subprocess.run(
+ ["node", "-e", script, str(document), str(width), mode],
+ cwd=Path(__file__).resolve().parents[1], env=os.environ.copy(),
+ capture_output=True, text=True, timeout=60,
+ )
+ if result.returncode == 77:
+ pytest.skip("playwright not installed")
+ assert result.returncode == 0, result.stdout + result.stderr
+ assert json.loads(result.stdout)["mode"] == mode