"""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.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"}) # 单篇文档允许的函数图像数量上限,超出部分回退占位,防止多图块并发采样耗尽内存/线程 _MAX_FUNCTION_PLOTS = 16 # 单篇文档允许的函数图像累计 AST 节点预算,超出部分回退占位,防止组合复杂度(多图块 # × 多表达式 × 深表达式)在采样求值时长时间占满 CPU _MAX_TOTAL_PLOT_NODES = 8000 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 self._plot_count = 0 self._plot_nodes = 0 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 = [ "", '', "", '', '', ] if title: parts.append(f"{html.escape(title)}") parts.append(f"") parts.append("") parts.append("") parts.append(f'
') if options.include_title and title: parts.append(f'

{html.escape(title)}

') if options.include_metadata: metadata = document.attributes.get("metadata") if metadata: parts.append(self._render_metadata(metadata)) parts.append(body) parts.append("
") parts.append("") parts.append("") return "\n".join(parts) + "\n" def _render_metadata(self, metadata: dict) -> str: entries = ["'] for key, value in metadata.items(): entries.append(f"
{html.escape(str(key))}
") entries.append(f"
{html.escape(self._fmt_meta_value(value))}
") entries.append("") 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"{self._render_children(node.children, warnings)}" def _render_paragraph(self, node: DocumentNode, warnings: list[str]) -> str: return f"

{self._render_children(node.children, warnings)}

" def _render_blockquote(self, node: DocumentNode, warnings: list[str]) -> str: 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)}" 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 ( '
  • ' f'{inner}
  • ' ) return f"
  • {inner}
  • " 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 = [""] if head_rows: parts.append("") parts.extend(self._render_node(r, warnings) for r in head_rows) parts.append("") if body_rows: parts.append("") parts.extend(self._render_node(r, warnings) for r in body_rows) parts.append("") parts.append("
    ") return "".join(parts) def _render_table_row(self, node: DocumentNode, warnings: list[str]) -> str: return f"{self._render_children(node.children, warnings)}" 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)}" 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'
    {code}
    ' def _render_thematic_break(self, node: DocumentNode, warnings: list[str]) -> str: return "
    " def _render_mermaid(self, node: DocumentNode, warnings: list[str]) -> str: warnings.append(_MERMAID_WARNING) return f'
    {html.escape(node.text)}
    ' @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: # 文档级数量上限:超出部分直接回退占位,不解析不采样,防止海量图像耗尽资源 self._plot_count += 1 if self._plot_count > _MAX_FUNCTION_PLOTS: warnings.append( f"函数图像:文档内函数图像数量超过上限 {_MAX_FUNCTION_PLOTS},已回退为源码占位" ) return f'
    {html.escape(node.text)}
    ' # 解析与渲染共同纳入局部异常回退:单个图像失败只回退占位 + warning, # 绝不阻断整篇导出(含复杂表达式触发的 RecursionError 等异常)。 try: parsed = parse_source(node.text) for diag in parsed.diagnostics: warnings.append(self._format_plot_diagnostic(diag)) if parsed.plot is None: return f'
    {html.escape(node.text)}
    ' # 文档级累计复杂度预算:超出后回退占位,不再采样求值 if self._plot_nodes + parsed.plot.node_count > _MAX_TOTAL_PLOT_NODES: warnings.append( f"函数图像:文档内函数图像累计复杂度超过上限 {_MAX_TOTAL_PLOT_NODES} 节点,已回退为源码占位" ) return f'
    {html.escape(node.text)}
    ' self._plot_nodes += parsed.plot.node_count rendered = render_svg(parsed.plot) except Exception as exc: warnings.append(f"函数图像:解析或渲染失败,已回退占位({exc})") return f'
    {html.escape(node.text)}
    ' warnings.extend(rendered.warnings) return f'
    {rendered.content}
    ' def _render_math_block(self, node: DocumentNode, warnings: list[str]) -> str: return f'
    $${html.escape(node.text)}$$
    ' def _render_html_block(self, node: DocumentNode, warnings: list[str]) -> str: # 原始 HTML 不可信,转义为纯文本展示,保证正文不丢且无注入风险 warnings.append(_RAW_HTML_WARNING) return f'
    {html.escape(node.text)}
    ' # --- 行内 --- 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"{self._render_children(node.children, warnings)}" def _render_strong(self, node: DocumentNode, warnings: list[str]) -> str: return f"{self._render_children(node.children, warnings)}" 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"{inner}" def _render_codespan(self, node: DocumentNode, warnings: list[str]) -> str: 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"" 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 "
    "