"""HtmlExporter:Document 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.themes import html_theme, CALLOUTS from app.export.document import Document, DocumentNode, ExportResult from app.export.exporters._common import FunctionPlotBudget, format_plot_diagnostic from app.plot.renderer import FunctionPlotStaticRenderer, StaticRenderRequest _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: var(--page); color: var(--text); font: 15px/1.7 -apple-system, 'Segoe UI', 'Microsoft YaHei', sans-serif; } article { max-width: 860px; margin: 0 auto; padding: 40px 48px; background: var(--surface); } 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: var(--accent); } code { font-family: 'JetBrains Mono', Consolas, monospace; font-size: 0.9em; background: var(--code); padding: 0.15em 0.35em; border-radius: 3px; } pre { background: var(--code); padding: 14px 16px; border-radius: 6px; overflow-x: auto; } pre.code-theme-github-light { background: #f6f8fa; color: #1f2328; } pre.code-theme-github-dark { background: #0d1117; color: #c9d1d9; } pre code { background: none; padding: 0; } pre.mermaid, pre.function-plot { border: 1px dashed var(--border); } 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 var(--border); color: var(--muted); } img { max-width: 100%; } table { border-collapse: collapse; margin: 0.8em 0; } th, td { border: 1px solid var(--border); padding: 6px 12px; } th { background: var(--code); } dl.metadata { font-size: 0.85em; color: var(--muted); border-top: 1px solid var(--border); border-bottom: 1px solid var(--border); 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 var(--border); margin: 1.4em 0; } .callout { --callout:var(--accent); border:1px solid var(--border); border-left:4px solid var(--callout,var(--accent)); border-radius:6px; margin:1em 0; padding:.8em 1em; } .callout-title { display:block; font-weight:bold; color:var(--callout,var(--accent)); } .callout-content { color:var(--text); } .callout[data-kind="warning"], .callout[data-kind="question"] { --callout:#805400; } .callout[data-kind="danger"], .callout[data-kind="failure"], .callout[data-kind="bug"] { --callout:#b42318; } .callout[data-kind="tip"], .callout[data-kind="success"] { --callout:#176f41; } .callout[data-kind="example"], .callout[data-kind="abstract"], .callout[data-kind="important"] { --callout:#7041a0; } .theme-dark .callout, .theme-midnight-purple .callout { --callout:#a5d6ff; } .theme-dark .callout[data-kind="warning"], .theme-midnight-purple .callout[data-kind="warning"], .theme-dark .callout[data-kind="question"], .theme-midnight-purple .callout[data-kind="question"] { --callout:#f2cc60; } .theme-dark .callout[data-kind="danger"], .theme-midnight-purple .callout[data-kind="danger"], .theme-dark .callout[data-kind="failure"], .theme-midnight-purple .callout[data-kind="failure"], .theme-dark .callout[data-kind="bug"], .theme-midnight-purple .callout[data-kind="bug"] { --callout:#ffa198; } .theme-dark .callout[data-kind="tip"], .theme-midnight-purple .callout[data-kind="tip"], .theme-dark .callout[data-kind="success"], .theme-midnight-purple .callout[data-kind="success"] { --callout:#7ee787; } .theme-dark .callout[data-kind="important"], .theme-midnight-purple .callout[data-kind="important"], .theme-dark .callout[data-kind="abstract"], .theme-midnight-purple .callout[data-kind="abstract"], .theme-dark .callout[data-kind="example"], .theme-midnight-purple .callout[data-kind="example"] { --callout:#d2a8ff; } figure.function-plot svg text { fill:var(--muted); } figure.function-plot svg line { stroke:var(--border); } figure.function-plot svg line[stroke="#57606a"] { stroke:var(--muted); } summary.callout-title { cursor:pointer; display:list-item; } .callout { overflow-wrap:anywhere; } """.strip() class HtmlExporter: """实现 DocumentExporter:递归渲染 Document AST 为完整 HTML5 文档。""" def render(self, document: Document, options: ExportOptions) -> ExportResult: """同步渲染;CPU 密集,调用方应放入线程执行,避免阻塞事件循环。""" self._options = options self._plot_budget = FunctionPlotBudget() self._plot_renderer = FunctionPlotStaticRenderer() warnings: list[str] = [] self._theme_id, self._theme_css = html_theme(options.theme_id, warnings) 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 = [ "", '', "
", '', '', ] if title: parts.append(f"{self._render_children(node.children, warnings)}
" def _render_callout(self, node, warnings): kind = node.attributes['kind'] title = self._render_children(node.children[0].children,warnings) icon = html.escape(CALLOUTS[kind][0]) body = self._render_children(node.children[1:],warnings) heading = f' {title}' if node.attributes.get('fold'): opened = ' open' if node.attributes['fold'] == '+' else '' return f'{self._render_children(node.children, warnings)}" 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 ( '
{code}'
def _render_thematic_break(self, node: DocumentNode, warnings: list[str]) -> str:
return "{html.escape(node.text)}'
def _render_function_plot(self, node: DocumentNode, warnings: list[str]) -> str:
# 文档级数量上限:超出部分直接回退占位,不解析不采样,防止海量图像耗尽资源
over = self._plot_budget.check_count()
if over is not None:
warnings.append(over)
return f'{html.escape(node.text)}'
# 解析与渲染共同纳入局部异常回退:单个图像失败只回退占位 + warning,
# 绝不阻断整篇导出(含复杂表达式触发的 RecursionError 等异常)。
try:
request = StaticRenderRequest(
kind="function_plot", source=node.text, theme=self._options.theme_id
)
parsed = self._plot_renderer.parse(request)
for diag in parsed.diagnostics:
warnings.append(format_plot_diagnostic(diag))
if parsed.plot is None:
return f'{html.escape(node.text)}'
# 文档级累计复杂度预算:超出后回退占位,不再采样求值
over = self._plot_budget.check_nodes(parsed.plot.node_count)
if over is not None:
warnings.append(over)
return f'{html.escape(node.text)}'
rendered = self._plot_renderer.render_plot(parsed.plot)
except Exception as exc:
warnings.append(f"函数图像:解析或渲染失败,已回退占位({exc})")
return f'{html.escape(node.text)}'
warnings.extend(rendered.warnings)
return f'{html.escape(node.text)}"
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"