fix(research): typeset math in visual reports with the shared renderer (#6929)

This commit is contained in:
red person
2026-11-03 00:33:43 +00:00
committed by GitHub
parent 1e34f938cc
commit 06c1e71fa0
4 changed files with 275 additions and 3 deletions
+76
View File
@@ -0,0 +1,76 @@
"""Research reports preserve TeX for the shared, vendored math renderer."""
from bs4 import BeautifulSoup
import pytest
from src.visual_report import _md_to_html, generate_visual_report
@pytest.mark.parametrize("source,tex,display", [
(r"Value $x_i^2$.", "x_i^2", "false"),
(r"Value \(x_i^2\).", "x_i^2", "false"),
(r"Value $$\sum_i x_i$$.", r"\sum_i x_i", "true"),
(r"Value \[\sum_i x_i\].", r"\sum_i x_i", "true"),
("$$a_i\n\nb_i$$", "a_i\n\nb_i", "true"),
("\\[a_i\n\nb_i\\]", "a_i\n\nb_i", "true"),
])
def test_math_source_survives_markdown(source, tex, display):
soup = BeautifulSoup(_md_to_html(source), "html.parser")
pending = soup.select(".ody-math-pending")
assert len(pending) == 1
assert pending[0].get_text() == tex
assert pending[0]["data-display"] == display
assert pending[0].find("em") is None
@pytest.mark.parametrize("source", [
"`$x_i$`", "```text\n$x_i$\n```", "~~~text\n$x_i$\n~~~",
" $x_i$", "<code>$x_i$</code>", "<pre>$x_i$</pre>",
"Prices range from $5 to $10.", "An escaped \\$5 costs less than $10.",
])
def test_code_and_currency_are_not_math(source):
soup = BeautifulSoup(_md_to_html(source), "html.parser")
assert not soup.select(".ody-math-pending")
assert "$" in soup.get_text()
def test_html_attributes_are_not_rewritten_as_math():
soup = BeautifulSoup(_md_to_html('<a title="$literal$">Value $x_i$</a>'), "html.parser")
assert soup.a["title"] == "$literal$"
assert soup.select_one("a .ody-math-pending").get_text() == "x_i"
def test_link_destinations_stay_literal_while_link_labels_can_contain_math():
soup = BeautifulSoup(_md_to_html('[$x_i$](https://example.com/$literal$)'), "html.parser")
assert soup.a["href"] == "https://example.com/$literal$"
assert soup.select_one("a .ody-math-pending").get_text() == "x_i"
def test_math_source_is_inert_and_report_sanitizer_stays_active():
soup = BeautifulSoup(_md_to_html(
r'$\text{<img src=x onerror="alert(1)">}$'
'\n\n<script>alert(2)</script><a href="javascript:alert(3)">link</a>'
), "html.parser")
assert soup.select_one(".ody-math-pending").get_text() == r'\text{<img src=x onerror="alert(1)">}'
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()
+84
View File
@@ -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