Files
NotesAgentic/backend/app/export/exporters/html.py
T
yxxandClaude Code 04f36524b1 fix(plot): 修复函数图像审阅问题(1 P1 + 2 P2)
- P1 浮点刻度死循环:_ticks 改为有上限的整数索引推进并校验步长推进
- P2 求值异常:白名单函数校验参数数量;负数底非整数指数按断点处理;采样容错复数
- P2 无效纵轴范围:退化/非有限 range 丢弃并自动采样重算;渲染异常回退占位不阻断导出
- 补 6 个回归测试

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-05 21:14:50 +08:00

274 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""HtmlExporterDocument AST → 完整 HTML5 文档(内嵌基础 CSS)。
mermaid 等无法静态表达的节点渲染为占位代码块并记 warning,不静默丢失;function_plot
解析为静态 SVG 内嵌(解析失败回退占位并转诊断);严重内容缺失由 service 层以
EXPORT_UNSUPPORTED_CONTENT 判定,本层只负责逐节点渲染。
"""
from __future__ import annotations
import html
from datetime import datetime
from urllib.parse import urlparse
from app.contracts import ExportOptions
from app.export.document import Document, DocumentNode, ExportResult
from app.plot.parser import parse_source
from app.plot.render import render_svg
_MERMAID_WARNING = "mermaid 需前端渲染,已保留为占位代码块"
_RAW_HTML_WARNING = "原始 HTML 已按纯文本转义保留"
# 链接/图片地址允许的协议;无 scheme 的相对地址视为安全,其余协议一律降级
_ALLOWED_URL_SCHEMES = frozenset({"http", "https", "mailto"})
def _safe_url(url: str) -> str | None:
"""校验 URL 协议;安全返回原串,不安全返回 None。"""
url = url.strip()
if not url:
return None
scheme = urlparse(url).scheme.lower()
if scheme and scheme not in _ALLOWED_URL_SCHEMES:
return None
return url
_BASE_CSS = """
body { margin: 0; background: #f6f7f9; color: #1f2328; font: 15px/1.7 -apple-system, 'Segoe UI', 'Microsoft YaHei', sans-serif; }
article { max-width: 860px; margin: 0 auto; padding: 40px 48px; background: #fff; }
article.theme-dark { background: #0d1117; color: #c9d1d9; }
h1, h2, h3, h4, h5, h6 { line-height: 1.3; margin: 1.4em 0 0.6em; }
h1.title { margin-top: 0; }
p { margin: 0.6em 0; }
a { color: #0969da; }
code { font-family: 'JetBrains Mono', Consolas, monospace; font-size: 0.9em; background: #f0f1f3; padding: 0.15em 0.35em; border-radius: 3px; }
pre { background: #f6f8fa; padding: 14px 16px; border-radius: 6px; overflow-x: auto; }
pre.code-theme-github-dark { background: #0d1117; color: #c9d1d9; }
pre code { background: none; padding: 0; }
pre.mermaid, pre.function-plot { border: 1px dashed #d0d7de; }
figure.function-plot { margin: 1em 0; text-align: center; }
figure.function-plot svg { max-width: 100%; height: auto; }
blockquote { margin: 0.8em 0; padding: 0.2em 1em; border-left: 4px solid #d0d7de; color: #57606a; }
img { max-width: 100%; }
table { border-collapse: collapse; margin: 0.8em 0; }
th, td { border: 1px solid #d0d7de; padding: 6px 12px; }
th { background: #f6f8fa; }
dl.metadata { font-size: 0.85em; color: #57606a; border-top: 1px solid #eaeef2; border-bottom: 1px solid #eaeef2; padding: 0.6em 0; }
dl.metadata dt { display: inline; font-weight: 600; margin-right: 0.4em; }
dl.metadata dd { display: inline; margin: 0 1.2em 0 0; }
.math, .math-block { overflow-x: auto; padding: 0.4em 0; }
.task-list-item { list-style: none; }
.task-list-item input { margin-right: 0.4em; }
hr { border: none; border-top: 1px solid #d0d7de; margin: 1.4em 0; }
""".strip()
class HtmlExporter:
"""实现 DocumentExporter:递归渲染 Document AST 为完整 HTML5 文档。"""
def render(self, document: Document, options: ExportOptions) -> ExportResult:
"""同步渲染;CPU 密集,调用方应放入线程执行,避免阻塞事件循环。"""
self._options = options
warnings: list[str] = []
body = self._render_children(document.children, warnings)
content = self._assemble(document, options, body, warnings)
return ExportResult(
content=content.encode("utf-8"), mime_type="text/html", warnings=warnings
)
async def export(self, document: Document, options: ExportOptions) -> ExportResult:
"""契约要求的 async 接口;渲染本身同步,直接转发到 render。"""
return self.render(document, options)
def _assemble(
self, document: Document, options: ExportOptions, body: str, warnings: list[str]
) -> str:
title = str(document.attributes.get("title") or "")
parts = [
"<!doctype html>",
'<html lang="zh-CN">',
"<head>",
'<meta charset="utf-8">',
'<meta name="viewport" content="width=device-width, initial-scale=1">',
]
if title:
parts.append(f"<title>{html.escape(title)}</title>")
parts.append(f"<style>{_BASE_CSS}</style>")
parts.append("</head>")
parts.append("<body>")
parts.append(f'<article class="theme-{html.escape(options.theme_id)}">')
if options.include_title and title:
parts.append(f'<h1 class="title">{html.escape(title)}</h1>')
if options.include_metadata:
metadata = document.attributes.get("metadata")
if metadata:
parts.append(self._render_metadata(metadata))
parts.append(body)
parts.append("</article>")
parts.append("</body>")
parts.append("</html>")
return "\n".join(parts) + "\n"
def _render_metadata(self, metadata: dict) -> str:
entries = ["<dl", ' class="metadata">']
for key, value in metadata.items():
entries.append(f"<dt>{html.escape(str(key))}</dt>")
entries.append(f"<dd>{html.escape(self._fmt_meta_value(value))}</dd>")
entries.append("</dl>")
return "".join(entries)
@staticmethod
def _fmt_meta_value(value: object) -> str:
if isinstance(value, datetime):
return value.isoformat()
if isinstance(value, list):
return ", ".join(str(item) for item in value)
return str(value)
def _render_children(self, children: list[DocumentNode], warnings: list[str]) -> str:
return "".join(self._render_node(child, warnings) for child in children)
def _render_node(self, node: DocumentNode, warnings: list[str]) -> str:
handler = getattr(self, f"_render_{node.type}", None)
if handler is not None:
return handler(node, warnings)
warnings.append(f"无法表示的节点类型已跳过:{node.type}")
return ""
# --- 块级 ---
def _render_heading(self, node: DocumentNode, warnings: list[str]) -> str:
level = max(1, min(6, int(node.attributes.get("level", 1))))
return f"<h{level}>{self._render_children(node.children, warnings)}</h{level}>"
def _render_paragraph(self, node: DocumentNode, warnings: list[str]) -> str:
return f"<p>{self._render_children(node.children, warnings)}</p>"
def _render_blockquote(self, node: DocumentNode, warnings: list[str]) -> str:
return f"<blockquote>{self._render_children(node.children, warnings)}</blockquote>"
def _render_list(self, node: DocumentNode, warnings: list[str]) -> str:
tag = "ol" if node.attributes.get("ordered") else "ul"
return f"<{tag}>{self._render_children(node.children, warnings)}</{tag}>"
def _render_list_item(self, node: DocumentNode, warnings: list[str]) -> str:
inner = self._render_children(node.children, warnings)
if node.attributes.get("task"):
checked = " checked" if node.attributes.get("checked") else ""
return (
'<li class="task-list-item">'
f'<input type="checkbox" disabled{checked}>{inner}</li>'
)
return f"<li>{inner}</li>"
def _render_table(self, node: DocumentNode, warnings: list[str]) -> str:
rows = node.children
head_rows = [r for r in rows if r.attributes.get("head")]
body_rows = [r for r in rows if not r.attributes.get("head")]
parts = ["<table>"]
if head_rows:
parts.append("<thead>")
parts.extend(self._render_node(r, warnings) for r in head_rows)
parts.append("</thead>")
if body_rows:
parts.append("<tbody>")
parts.extend(self._render_node(r, warnings) for r in body_rows)
parts.append("</tbody>")
parts.append("</table>")
return "".join(parts)
def _render_table_row(self, node: DocumentNode, warnings: list[str]) -> str:
return f"<tr>{self._render_children(node.children, warnings)}</tr>"
def _render_table_cell(self, node: DocumentNode, warnings: list[str]) -> str:
tag = "th" if node.attributes.get("head") else "td"
return f"<{tag}>{self._render_children(node.children, warnings)}</{tag}>"
def _render_code_block(self, node: DocumentNode, warnings: list[str]) -> str:
lang = str(node.attributes.get("language") or "")
code = html.escape(node.text)
lang_cls = f' class="language-{html.escape(lang)}"' if lang else ""
theme = html.escape(self._options.code_theme)
return f'<pre class="code-theme-{theme}"><code{lang_cls}>{code}</code></pre>'
def _render_thematic_break(self, node: DocumentNode, warnings: list[str]) -> str:
return "<hr>"
def _render_mermaid(self, node: DocumentNode, warnings: list[str]) -> str:
warnings.append(_MERMAID_WARNING)
return f'<pre class="mermaid">{html.escape(node.text)}</pre>'
@staticmethod
def _format_plot_diagnostic(diag) -> str:
loc = f"(第 {diag.line} 行)" if diag.line else ""
return f"函数图像:{diag.message}{loc}"
def _render_function_plot(self, node: DocumentNode, warnings: list[str]) -> str:
# 解析 fenced 源码:有合法 plot 且无 error → 内嵌静态 SVG;否则回退占位并转诊断
parsed = parse_source(node.text)
for diag in parsed.diagnostics:
warnings.append(self._format_plot_diagnostic(diag))
if parsed.plot is None:
return f'<pre class="function-plot">{html.escape(node.text)}</pre>'
try:
rendered = render_svg(parsed.plot)
except Exception as exc: # 渲染异常回退占位,绝不阻断整篇导出
warnings.append(f"函数图像:渲染失败,已回退占位({exc}")
return f'<pre class="function-plot">{html.escape(node.text)}</pre>'
warnings.extend(rendered.warnings)
return f'<figure class="function-plot">{rendered.content}</figure>'
def _render_math_block(self, node: DocumentNode, warnings: list[str]) -> str:
return f'<div class="math-block">$${html.escape(node.text)}$$</div>'
def _render_html_block(self, node: DocumentNode, warnings: list[str]) -> str:
# 原始 HTML 不可信,转义为纯文本展示,保证正文不丢且无注入风险
warnings.append(_RAW_HTML_WARNING)
return f'<div class="raw-html">{html.escape(node.text)}</div>'
# --- 行内 ---
def _render_text(self, node: DocumentNode, warnings: list[str]) -> str:
return html.escape(node.text)
def _render_emphasis(self, node: DocumentNode, warnings: list[str]) -> str:
return f"<em>{self._render_children(node.children, warnings)}</em>"
def _render_strong(self, node: DocumentNode, warnings: list[str]) -> str:
return f"<strong>{self._render_children(node.children, warnings)}</strong>"
def _render_link(self, node: DocumentNode, warnings: list[str]) -> str:
inner = self._render_children(node.children, warnings)
href = str(node.attributes.get("href") or "")
safe_href = _safe_url(href)
if safe_href is None:
# 危险协议(如 javascript:)降级为纯文本,不输出可点击链接
warnings.append(f"链接协议不安全,已降级为纯文本:{href!r}")
return inner
title = str(node.attributes.get("title") or "")
attrs = [f'href="{html.escape(safe_href)}"']
if title:
attrs.append(f'title="{html.escape(title)}"')
return f"<a {' '.join(attrs)}>{inner}</a>"
def _render_codespan(self, node: DocumentNode, warnings: list[str]) -> str:
return f"<code>{html.escape(node.text)}</code>"
def _render_image(self, node: DocumentNode, warnings: list[str]) -> str:
src = str(node.attributes.get("src") or "")
alt = str(node.attributes.get("alt") or "")
safe_src = _safe_url(src)
if safe_src is None:
# 危险协议(如 data:/javascript:)跳过图片,仅输出 alt 文本
warnings.append(f"图片地址不安全,已跳过:{src!r}")
return html.escape(alt) if alt else ""
title = str(node.attributes.get("title") or "")
attrs = [f'src="{html.escape(safe_src)}"', f'alt="{html.escape(alt)}"']
if title:
attrs.append(f'title="{html.escape(title)}"')
return f"<img {' '.join(attrs)}>"
def _render_math_inline(self, node: DocumentNode, warnings: list[str]) -> str:
return f"\\({html.escape(node.text)}\\)"
def _render_linebreak(self, node: DocumentNode, warnings: list[str]) -> str:
return "<br>"