"""HtmlExporter:Document AST → 完整 HTML5 文档(内嵌基础 CSS)。 对无法静态表达的节点(mermaid / function_plot)渲染为占位代码块并记 warning,不静默丢失; 严重内容缺失由 service 层以 EXPORT_UNSUPPORTED_CONTENT 判定,本层只负责逐节点渲染。 """ from __future__ import annotations import html from datetime import datetime from app.contracts import ExportOptions from app.export.document import Document, DocumentNode, ExportResult _MERMAID_WARNING = "mermaid 需前端渲染,已保留为占位代码块" _FUNCTION_PLOT_WARNING = "函数图像渲染将在后续版本提供,已保留为占位代码块" _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; } 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 文档。""" async def export(self, document: Document, options: ExportOptions) -> ExportResult: 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 ) 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_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)}{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:
warnings.append(_FUNCTION_PLOT_WARNING)
return f'{html.escape(node.text)}'
def _render_math_block(self, node: DocumentNode, warnings: list[str]) -> str:
return f'{html.escape(node.text)}"
def _render_image(self, node: DocumentNode, warnings: list[str]) -> str:
src = html.escape(str(node.attributes.get("src") or ""))
alt = html.escape(str(node.attributes.get("alt") or ""))
title = str(node.attributes.get("title") or "")
attrs = [f'src="{src}"', f'alt="{alt}"']
if title:
attrs.append(f'title="{html.escape(title)}"')
return f"