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
+87
View File
@@ -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"(?<![\$\d])\$(?!\$)(?=\S)([^\$\n]+?)(?<=\S)\$(?!\$|\d)"
)
def _placeholder(tex, display):
node = ElementTree.Element("span", {
"class": "ody-math-pending", "data-display": str(display).lower(),
})
node.text = AtomicString(tex)
return node
class _HtmlContext(HTMLParser):
"""Inline HTML is still raw when math runs, including tag attributes."""
def __init__(self):
super().__init__()
self.protected = []
def handle_starttag(self, tag, attrs):
if tag in {"code", "pre", "script", "style", "textarea"}:
self.protected.append(tag)
def handle_endtag(self, tag):
if tag in self.protected:
del self.protected[self.protected.index(tag):]
class _InlineMath(InlineProcessor):
def handleMatch(self, match, data):
prefix = data[:match.start()]
if "<" in prefix:
context = _HtmlContext()
context.feed(prefix)
if context.protected or re.match(r"<(?:/?[A-Za-z]|[!?])", context.rawdata.lstrip()):
return None, None, None
# Backticks run before this processor. Preserve a backslash-escaped
# dollar too; math must run before Markdown consumes \( and \[.
if prefix.endswith("\\") and (len(prefix) - len(prefix.rstrip("\\"))) % 2:
return None, None, None
tex = next(group for group in match.groups() if group is not None)
display = match.group(1) is not None
return _placeholder(tex, display), match.start(), match.end()
class _DisplayMath(BlockProcessor):
def test(self, parent, block):
return re.match(r"^ {0,3}(\$\$|\\\[)", block) is not None
def run(self, parent, blocks):
opening = re.match(r"^ {0,3}(\$\$|\\\[)", blocks[0])
closing = "$$" if opening.group(1) == "$$" else "\\]"
source = blocks[0][opening.end():]
consumed = 1
while closing not in source:
if consumed == len(blocks):
return False # Leave incomplete input to ordinary Markdown.
source += "\n\n" + blocks[consumed]
consumed += 1
tex, _, tail = source.partition(closing)
del blocks[:consumed]
if tail.strip():
blocks.insert(0, tail)
parent.append(_placeholder(tex, True))
class ReportMathExtension(Extension):
def extendMarkdown(self, md):
# Backticks (190) and indented code (80) retain precedence.
md.inlinePatterns.register(_InlineMath(_ESCAPED_MATH, md), "report_math_escaped", 185)
# Parse links before dollar math so URL destinations stay literal.
md.inlinePatterns.register(_InlineMath(_DOLLAR_MATH, md), "report_math_dollar", 155)
md.parser.blockprocessors.register(_DisplayMath(md.parser), "report_math", 79)
+28 -3
View File
@@ -23,6 +23,7 @@ from typing import Dict, List, Optional, Tuple
from bs4 import BeautifulSoup
from src.research_utils import strip_thinking
from src.report_math import ReportMathExtension
from urllib.parse import urlparse
import markdown
@@ -47,6 +48,7 @@ for _t in ("td", "th"):
_REPORT_ALLOWED_ATTRS.setdefault(_t, set()).add("align")
_REPORT_ALLOWED_ATTRS.setdefault("a", set()).update({"href", "title", "target", "rel"})
_REPORT_ALLOWED_ATTRS.setdefault("img", set()).update({"src", "alt", "title"})
_REPORT_ALLOWED_ATTRS.setdefault("span", set()).add("data-display")
# ---------------------------------------------------------------------------
# Helpers
@@ -79,7 +81,7 @@ def _md_to_html(md_text: str) -> 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) {{
<script>
(function() {{
// Reuse the chat renderer and vendored assets only for reports with math.
if (document.querySelector('.ody-math-pending')) {{
import('/static/js/markdown.js').then(function(markdown) {{
return markdown.renderMath(document);
}}).catch(function(err) {{ console.warn('Report math render error:', err); }});
}}
// ESC closes the report tab. window.close() works when the tab was
// opened via window.open() (which is how the panel launches it). If the
// browser blocks self-close (rare — e.g. report opened by direct URL),
@@ -2835,11 +2845,23 @@ def generate_visual_report(
spare_images = section_pool[_consumed:]
# Build TOC
math_labels = {}
if 'ody-math-pending' in report_html:
label_soup = BeautifulSoup(report_html, "html.parser")
for heading in label_soup.select('h2[id], h3[id]'):
if not heading.select_one('.ody-math-pending'):
continue
for child in list(heading.find_all(True)):
if 'ody-math-pending' in child.get('class', []):
child['data-display'] = 'false'
else:
child.unwrap()
math_labels[heading['id']] = heading.decode_contents()
toc_lines = []
for h in headings:
depth_class = f"depth-{h['level']}"
toc_lines.append(
f'<a href="#{h["slug"]}" class="{depth_class}">{html.escape(h["text"])}</a>'
f'<a href="#{h["slug"]}" class="{depth_class}">{math_labels.get(h["slug"], html.escape(h["text"]))}</a>'
)
toc_html = "\n ".join(toc_lines) if toc_lines else ""
@@ -2848,8 +2870,11 @@ def generate_visual_report(
options = []
for h in headings:
prefix = "\u2014 " if h["level"] == 3 else ""
text = h["text"]
if h["slug"] in math_labels:
text = BeautifulSoup(math_labels[h["slug"]], "html.parser").get_text()
options.append(
f'<option value="{h["slug"]}">{prefix}{html.escape(h["text"])}</option>'
f'<option value="{h["slug"]}">{prefix}{html.escape(text)}</option>'
)
mobile_toc_html = (
'<div class="mobile-section-nav">'