feat(export): 交付 Markdown → HTML 导出服务
实现 Export Service 完整生命周期:mistune AST → Document AST → HtmlExporter 渲染完整 HTML5,异步任务注册表 + 取消 + 24h 产物过期。新增 5 个 /api/exports 端点与 15 项测试;pdf/docx 与函数图像静态渲染留待后续 PR。
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
"""Export Service:多格式文档导出(首批 HTML)。
|
||||
|
||||
模块划分:
|
||||
- document.py Document AST 内部协议 + DocumentExporter Protocol + ExportResult
|
||||
- markdown.py mistune → Document AST 解析
|
||||
- exporters/html.py HtmlExporter(Document AST → HTML5)
|
||||
- service.py 导出任务注册表、后台执行、取消与文件生命周期
|
||||
"""
|
||||
@@ -0,0 +1,46 @@
|
||||
"""Document AST:导出器的内部中间表示(Internal Protocol,不放入 contracts.py)。
|
||||
|
||||
契约 §10.3 规定节点用稳定判别字段 node_id / type / attributes / children / text,
|
||||
类型专有信息统一放 attributes(如 heading 的 level、link 的 href、image 的 src)。
|
||||
导出器据此递归渲染,对无法表示的节点记 warning,不静默丢弃。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Protocol
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from app.contracts import ExportOptions
|
||||
|
||||
|
||||
class DocumentNode(BaseModel):
|
||||
"""递归文档节点;type 取契约 §10.3 首批 node type 之一。"""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
type: str
|
||||
node_id: str
|
||||
attributes: dict[str, Any] = Field(default_factory=dict)
|
||||
children: list["DocumentNode"] = Field(default_factory=list)
|
||||
text: str = ""
|
||||
|
||||
|
||||
class Document(DocumentNode):
|
||||
"""根节点,type 固定为 document。"""
|
||||
|
||||
type: str = "document"
|
||||
|
||||
|
||||
class DocumentExporter(Protocol):
|
||||
"""导出器协议(契约 §10.3):把 Document AST 渲染为指定格式的产物。"""
|
||||
|
||||
async def export(self, document: Document, options: ExportOptions) -> "ExportResult": ...
|
||||
|
||||
|
||||
class ExportResult(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
content: bytes
|
||||
mime_type: str
|
||||
warnings: list[str] = Field(default_factory=list)
|
||||
@@ -0,0 +1 @@
|
||||
"""Export 渲染器:Document AST → 具体格式产物。"""
|
||||
@@ -0,0 +1,216 @@
|
||||
"""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 = [
|
||||
"<!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>'
|
||||
|
||||
def _render_function_plot(self, node: DocumentNode, warnings: list[str]) -> str:
|
||||
warnings.append(_FUNCTION_PLOT_WARNING)
|
||||
return f'<pre class="function-plot">{html.escape(node.text)}</pre>'
|
||||
|
||||
def _render_math_block(self, node: DocumentNode, warnings: list[str]) -> str:
|
||||
return f'<div class="math-block">$${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:
|
||||
href = html.escape(str(node.attributes.get("href") or ""))
|
||||
title = str(node.attributes.get("title") or "")
|
||||
attrs = [f'href="{href}"']
|
||||
if title:
|
||||
attrs.append(f'title="{html.escape(title)}"')
|
||||
return f"<a {' '.join(attrs)}>{self._render_children(node.children, warnings)}</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 = 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"<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>"
|
||||
@@ -0,0 +1,214 @@
|
||||
"""Markdown → Document AST:用 mistune 的 ast renderer 产出通用 token,再映射为内部节点。
|
||||
|
||||
选用 mistune 内置 'ast' renderer 而非自写 BaseRenderer,是因为 mistune 的行内渲染按
|
||||
字符串拼接、无法承载结构化子节点;ast renderer 直接给出带 children/attrs/raw 的 token
|
||||
树,映射层只做 token → DocumentNode 的搬运,不掺入任何 HTML。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import mistune
|
||||
|
||||
from app.export.document import Document, DocumentNode
|
||||
|
||||
_PLUGINS = ["table", "math", "url", "task_lists"]
|
||||
|
||||
# fenced code 语言分流:命中则转为专用节点,其余按普通代码块
|
||||
_MERMAID_LANG = "mermaid"
|
||||
_FUNCTION_PLOT_LANGS = {"function_plot", "functionplot"}
|
||||
|
||||
|
||||
def parse_document(markdown: str) -> Document:
|
||||
"""把 Markdown 文本解析为 Document AST 根节点。"""
|
||||
renderer = mistune.create_markdown(renderer="ast", plugins=_PLUGINS)
|
||||
tokens = renderer(markdown)
|
||||
mapper = _AstMapper()
|
||||
return Document(node_id=mapper.next_id(), children=mapper.map_blocks(tokens))
|
||||
|
||||
|
||||
class _AstMapper:
|
||||
"""token 树 → DocumentNode 树的映射器;node_id 按遍历顺序递增,无需跨请求稳定。"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._seq = 0
|
||||
|
||||
def next_id(self) -> str:
|
||||
self._seq += 1
|
||||
return f"node_{self._seq:03d}"
|
||||
|
||||
def map_blocks(self, tokens: list[dict]) -> list[DocumentNode]:
|
||||
nodes: list[DocumentNode] = []
|
||||
for token in tokens:
|
||||
node = self.map_block(token)
|
||||
if node is not None:
|
||||
nodes.append(node)
|
||||
return nodes
|
||||
|
||||
def map_block(self, token: dict) -> DocumentNode | None:
|
||||
kind = token["type"]
|
||||
if kind == "heading":
|
||||
return DocumentNode(
|
||||
type="heading",
|
||||
node_id=self.next_id(),
|
||||
attributes={"level": token["attrs"]["level"]},
|
||||
children=self.map_inline(token.get("children", [])),
|
||||
)
|
||||
if kind in ("paragraph", "block_text"):
|
||||
# block_text 是列表项内的段落块,仍按 paragraph 表达,由 list_item 包裹
|
||||
return DocumentNode(
|
||||
type="paragraph",
|
||||
node_id=self.next_id(),
|
||||
children=self.map_inline(token.get("children", [])),
|
||||
)
|
||||
if kind == "list":
|
||||
return DocumentNode(
|
||||
type="list",
|
||||
node_id=self.next_id(),
|
||||
attributes={"ordered": bool(token.get("attrs", {}).get("ordered"))},
|
||||
children=[self.map_list_item(child) for child in token.get("children", [])],
|
||||
)
|
||||
if kind == "block_code":
|
||||
return self._map_code(token)
|
||||
if kind == "block_quote":
|
||||
return DocumentNode(
|
||||
type="blockquote",
|
||||
node_id=self.next_id(),
|
||||
children=self.map_blocks(token.get("children", [])),
|
||||
)
|
||||
if kind == "table":
|
||||
return self._map_table(token)
|
||||
if kind == "block_math":
|
||||
return DocumentNode(
|
||||
type="math_block", node_id=self.next_id(), text=token.get("raw", "")
|
||||
)
|
||||
if kind == "thematic_break":
|
||||
return DocumentNode(type="thematic_break", node_id=self.next_id())
|
||||
if kind == "blank_line":
|
||||
return None
|
||||
# 未知块级 token(如 block_html)保守保留原文,避免静默丢失
|
||||
raw = token.get("raw", "")
|
||||
if raw:
|
||||
return DocumentNode(type="paragraph", node_id=self.next_id(), text=raw)
|
||||
return None
|
||||
|
||||
def map_list_item(self, token: dict) -> DocumentNode:
|
||||
"""列表项:block_text 展平为行内子节点,嵌套 list 保留为子节点。"""
|
||||
attributes: dict = {}
|
||||
if token["type"] == "task_list_item":
|
||||
attributes = {"task": True, "checked": bool(token.get("attrs", {}).get("checked"))}
|
||||
children: list[DocumentNode] = []
|
||||
for child in token.get("children", []):
|
||||
if child["type"] == "block_text":
|
||||
children.extend(self.map_inline(child.get("children", [])))
|
||||
elif child["type"] == "list":
|
||||
children.append(self.map_block(child))
|
||||
else:
|
||||
node = self.map_block(child)
|
||||
if node is not None:
|
||||
children.append(node)
|
||||
return DocumentNode(
|
||||
type="list_item", node_id=self.next_id(), attributes=attributes, children=children
|
||||
)
|
||||
|
||||
def map_inline(self, tokens: list[dict]) -> list[DocumentNode]:
|
||||
nodes: list[DocumentNode] = []
|
||||
for token in tokens:
|
||||
node = self.map_inline_token(token)
|
||||
if node is not None:
|
||||
nodes.append(node)
|
||||
return nodes
|
||||
|
||||
def map_inline_token(self, token: dict) -> DocumentNode | None:
|
||||
kind = token["type"]
|
||||
if kind == "text":
|
||||
return DocumentNode(type="text", node_id=self.next_id(), text=token.get("raw", ""))
|
||||
if kind == "strong":
|
||||
return DocumentNode(
|
||||
type="strong", node_id=self.next_id(),
|
||||
children=self.map_inline(token.get("children", [])),
|
||||
)
|
||||
if kind == "emphasis":
|
||||
return DocumentNode(
|
||||
type="emphasis", node_id=self.next_id(),
|
||||
children=self.map_inline(token.get("children", [])),
|
||||
)
|
||||
if kind == "link":
|
||||
attrs = token.get("attrs", {})
|
||||
attributes = {"href": attrs.get("url", "")}
|
||||
if attrs.get("title"):
|
||||
attributes["title"] = attrs["title"]
|
||||
return DocumentNode(
|
||||
type="link", node_id=self.next_id(), attributes=attributes,
|
||||
children=self.map_inline(token.get("children", [])),
|
||||
)
|
||||
if kind == "codespan":
|
||||
return DocumentNode(type="codespan", node_id=self.next_id(), text=token.get("raw", ""))
|
||||
if kind == "image":
|
||||
attrs = token.get("attrs", {})
|
||||
attributes = {"src": attrs.get("src", "")}
|
||||
if attrs.get("alt"):
|
||||
attributes["alt"] = attrs["alt"]
|
||||
if attrs.get("title"):
|
||||
attributes["title"] = attrs["title"]
|
||||
return DocumentNode(type="image", node_id=self.next_id(), attributes=attributes)
|
||||
if kind == "inline_math":
|
||||
return DocumentNode(
|
||||
type="math_inline", node_id=self.next_id(), text=token.get("raw", "")
|
||||
)
|
||||
if kind == "softbreak":
|
||||
# HTML 中换行会折叠为空白,软换行按空格表达
|
||||
return DocumentNode(type="text", node_id=self.next_id(), text=" ")
|
||||
if kind == "linebreak":
|
||||
return DocumentNode(type="linebreak", node_id=self.next_id())
|
||||
# 未知行内 token 保守保留原文
|
||||
raw = token.get("raw", "")
|
||||
if raw:
|
||||
return DocumentNode(type="text", node_id=self.next_id(), text=raw)
|
||||
return None
|
||||
|
||||
def _map_code(self, token: dict) -> DocumentNode:
|
||||
info = (token.get("attrs", {}).get("info") or "").strip()
|
||||
lang = info.split()[0].lower() if info else ""
|
||||
code = token.get("raw", "").rstrip("\n")
|
||||
if lang == _MERMAID_LANG:
|
||||
return DocumentNode(type="mermaid", node_id=self.next_id(), text=code)
|
||||
if lang in _FUNCTION_PLOT_LANGS:
|
||||
return DocumentNode(type="function_plot", node_id=self.next_id(), text=code)
|
||||
attributes = {"language": lang} if lang else {}
|
||||
return DocumentNode(
|
||||
type="code_block", node_id=self.next_id(), attributes=attributes, text=code
|
||||
)
|
||||
|
||||
def _map_table(self, token: dict) -> DocumentNode:
|
||||
rows: list[DocumentNode] = []
|
||||
for child in token.get("children", []):
|
||||
if child["type"] == "table_head":
|
||||
rows.append(self._map_table_row(child, head=True))
|
||||
elif child["type"] == "table_body":
|
||||
for row in child.get("children", []):
|
||||
if row["type"] == "table_row":
|
||||
rows.append(self._map_table_row(row, head=False))
|
||||
elif child["type"] == "table_row":
|
||||
rows.append(self._map_table_row(child, head=False))
|
||||
return DocumentNode(type="table", node_id=self.next_id(), children=rows)
|
||||
|
||||
def _map_table_row(self, token: dict, *, head: bool) -> DocumentNode:
|
||||
cells: list[DocumentNode] = []
|
||||
for cell in token.get("children", []):
|
||||
if cell["type"] != "table_cell":
|
||||
continue
|
||||
attrs = cell.get("attrs", {})
|
||||
cell_attributes = {"head": bool(attrs.get("head", head))}
|
||||
if attrs.get("align"):
|
||||
cell_attributes["align"] = attrs["align"]
|
||||
cells.append(
|
||||
DocumentNode(
|
||||
type="table_cell",
|
||||
node_id=self.next_id(),
|
||||
attributes=cell_attributes,
|
||||
children=self.map_inline(cell.get("children", [])),
|
||||
)
|
||||
)
|
||||
return DocumentNode(
|
||||
type="table_row", node_id=self.next_id(), attributes={"head": head}, children=cells
|
||||
)
|
||||
@@ -0,0 +1,272 @@
|
||||
"""Export 服务:任务注册表、后台渲染、取消与产物生命周期。
|
||||
|
||||
与 Benchmark 一致采用「创建即返回 queued、后台 Task 异步执行」的内存模型:任务与产物
|
||||
暂存内存与 exports 目录,不持久化到 SQLite。导出是单阶段渲染,无 SSE 事件流,取消主要
|
||||
在渲染前/后让出执行权的边界生效;产物带 24h 过期时间,过期后不可下载。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import logging
|
||||
import re
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from uuid import uuid4
|
||||
|
||||
from app.config import get_settings
|
||||
from app.contracts import (
|
||||
ExportFile,
|
||||
ExportFormat,
|
||||
ExportJob,
|
||||
ExportOptions,
|
||||
ExportProgress,
|
||||
ExportRequest,
|
||||
ExportSource,
|
||||
ExportSourceType,
|
||||
ExportStatus,
|
||||
)
|
||||
from app.errors import ApiError
|
||||
from app.export.document import Document
|
||||
from app.export.exporters.html import HtmlExporter
|
||||
from app.export.markdown import parse_document
|
||||
from app.services import note_service
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_jobs: dict[str, ExportJob] = {}
|
||||
_tasks: dict[str, asyncio.Task] = {}
|
||||
_cancel_flags: dict[str, asyncio.Event] = {}
|
||||
MAX_JOBS = 100
|
||||
# markdown 源大小上限,防止未保存预览塞爆内存/产物
|
||||
MAX_MARKDOWN_CHARS = 200_000
|
||||
# 产物有效期
|
||||
FILE_TTL = timedelta(hours=24)
|
||||
|
||||
_INVALID_FILE_CHARS = re.compile(r'[\\/:*?"<>|]')
|
||||
|
||||
|
||||
class ExportCancelled(Exception):
|
||||
"""导出在渲染前被取消时抛出,用于标记 cancelled。"""
|
||||
|
||||
|
||||
def _now() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
def _safe_download_name(title: str) -> str:
|
||||
"""清洗标题得到安全的下载文件名;空标题回退到 export。"""
|
||||
name = _INVALID_FILE_CHARS.sub("_", title).strip() or "export"
|
||||
return name[:80]
|
||||
|
||||
|
||||
def _forget(job_id: str) -> None:
|
||||
_jobs.pop(job_id, None)
|
||||
_tasks.pop(job_id, None)
|
||||
_cancel_flags.pop(job_id, None)
|
||||
|
||||
|
||||
def _evict_terminal() -> bool:
|
||||
"""超过容量时淘汰最旧的终态任务;全为活动任务无法淘汰时返回 False。"""
|
||||
terminal = (ExportStatus.completed, ExportStatus.failed, ExportStatus.cancelled)
|
||||
while len(_jobs) >= MAX_JOBS:
|
||||
victim = next((jid for jid, job in _jobs.items() if job.status in terminal), None)
|
||||
if victim is None:
|
||||
return False
|
||||
_forget(victim)
|
||||
return True
|
||||
|
||||
|
||||
async def _resolve_source(source: ExportSource) -> tuple[str, str, dict | None]:
|
||||
"""把导出源解析为 (markdown, title, metadata);metadata 仅 note 源提供。"""
|
||||
if source.type == ExportSourceType.note:
|
||||
note = await note_service.get_note(source.note_id)
|
||||
if note is None:
|
||||
raise ApiError(
|
||||
404,
|
||||
"EXPORT_SOURCE_NOT_FOUND",
|
||||
"note not found",
|
||||
{"note_id": source.note_id},
|
||||
)
|
||||
metadata = {
|
||||
"file_path": note.file_path,
|
||||
"tags": note.tags,
|
||||
"created_at": note.created_at,
|
||||
"updated_at": note.updated_at,
|
||||
}
|
||||
return note.markdown, note.title, metadata
|
||||
|
||||
markdown = source.markdown or ""
|
||||
if not markdown.strip():
|
||||
raise ApiError(400, "EXPORT_OPTIONS_INVALID", "markdown source must not be empty")
|
||||
if len(markdown) > MAX_MARKDOWN_CHARS:
|
||||
raise ApiError(
|
||||
400,
|
||||
"EXPORT_OPTIONS_INVALID",
|
||||
f"markdown source exceeds {MAX_MARKDOWN_CHARS} characters",
|
||||
{"size": len(markdown), "limit": MAX_MARKDOWN_CHARS},
|
||||
)
|
||||
return markdown, "", None
|
||||
|
||||
|
||||
async def create_export(request: ExportRequest) -> ExportJob:
|
||||
"""创建导出任务,立即返回 queued 的 ExportJob,由后台 Task 渲染。"""
|
||||
if request.format != ExportFormat.html:
|
||||
raise ApiError(
|
||||
400,
|
||||
"EXPORT_FORMAT_UNSUPPORTED",
|
||||
"PDF/DOCX 暂未实现,当前仅支持 HTML",
|
||||
{"format": request.format.value},
|
||||
)
|
||||
markdown, title, metadata = await _resolve_source(request.source)
|
||||
|
||||
if not _evict_terminal():
|
||||
raise ApiError(
|
||||
429,
|
||||
"EXPORT_CAPACITY_EXCEEDED",
|
||||
"Export capacity exceeded; wait for active jobs to finish.",
|
||||
{},
|
||||
)
|
||||
|
||||
job_id = "export_" + uuid4().hex[:12]
|
||||
job = ExportJob(
|
||||
job_id=job_id,
|
||||
status=ExportStatus.queued,
|
||||
format=request.format,
|
||||
created_at=_now(),
|
||||
)
|
||||
_jobs[job_id] = job
|
||||
_cancel_flags[job_id] = asyncio.Event()
|
||||
_tasks[job_id] = asyncio.create_task(
|
||||
_execute(job_id, markdown, title, metadata, request.options)
|
||||
)
|
||||
return job
|
||||
|
||||
|
||||
async def _execute(
|
||||
job_id: str,
|
||||
markdown: str,
|
||||
title: str,
|
||||
metadata: dict | None,
|
||||
options: ExportOptions,
|
||||
) -> None:
|
||||
"""后台渲染:解析 → 导出 → 写文件 → 挂载产物元信息。"""
|
||||
cancel_event = _cancel_flags[job_id]
|
||||
_jobs[job_id] = _jobs[job_id].model_copy(
|
||||
update={
|
||||
"status": ExportStatus.running,
|
||||
"started_at": _now(),
|
||||
"progress": ExportProgress(phase="rendering", current=0, total=1, percent=0.0),
|
||||
}
|
||||
)
|
||||
try:
|
||||
# 让出一次,使「创建后立即取消」的 queued 任务能及时进入 cancelled
|
||||
await asyncio.sleep(0)
|
||||
if cancel_event.is_set():
|
||||
raise ExportCancelled()
|
||||
|
||||
document = parse_document(markdown)
|
||||
document.attributes["title"] = title
|
||||
if metadata:
|
||||
document.attributes["metadata"] = metadata
|
||||
|
||||
exporter = HtmlExporter()
|
||||
result = await exporter.export(document, options)
|
||||
if cancel_event.is_set():
|
||||
raise ExportCancelled()
|
||||
|
||||
out_dir = get_settings().exports_path
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
path = out_dir / f"{job_id}.html"
|
||||
path.write_bytes(result.content)
|
||||
|
||||
completed_at = _now()
|
||||
_jobs[job_id] = _jobs[job_id].model_copy(
|
||||
update={
|
||||
"status": ExportStatus.completed,
|
||||
"progress": ExportProgress(
|
||||
phase="completed", current=1, total=1, percent=1.0
|
||||
),
|
||||
"file": ExportFile(
|
||||
file_name=f"{_safe_download_name(title)}.html",
|
||||
mime_type=result.mime_type,
|
||||
size=len(result.content),
|
||||
sha256=hashlib.sha256(result.content).hexdigest(),
|
||||
expires_at=completed_at + FILE_TTL,
|
||||
),
|
||||
"warnings": result.warnings,
|
||||
"completed_at": completed_at,
|
||||
}
|
||||
)
|
||||
except ExportCancelled:
|
||||
_jobs[job_id] = _jobs[job_id].model_copy(
|
||||
update={
|
||||
"status": ExportStatus.cancelled,
|
||||
"completed_at": _now(),
|
||||
}
|
||||
)
|
||||
except Exception as exc: # 渲染失败不拖垮服务,只记日志与项目错误码
|
||||
logger.exception("Export failed: job_id=%s", job_id)
|
||||
_jobs[job_id] = _jobs[job_id].model_copy(
|
||||
update={
|
||||
"status": ExportStatus.failed,
|
||||
"error": "Export render failed.",
|
||||
"error_code": "EXPORT_RENDER_FAILED",
|
||||
"completed_at": _now(),
|
||||
}
|
||||
)
|
||||
finally:
|
||||
_cancel_flags.pop(job_id, None)
|
||||
|
||||
|
||||
def list_exports(
|
||||
status: ExportStatus | None = None,
|
||||
format: ExportFormat | None = None,
|
||||
limit: int = 50,
|
||||
offset: int = 0,
|
||||
) -> tuple[list[ExportJob], int]:
|
||||
jobs = list(_jobs.values())
|
||||
if status is not None:
|
||||
jobs = [j for j in jobs if j.status == status]
|
||||
if format is not None:
|
||||
jobs = [j for j in jobs if j.format == format]
|
||||
jobs.sort(key=lambda j: j.created_at, reverse=True)
|
||||
total = len(jobs)
|
||||
return jobs[offset : offset + limit], total
|
||||
|
||||
|
||||
def get_export(job_id: str) -> ExportJob | None:
|
||||
return _jobs.get(job_id)
|
||||
|
||||
|
||||
def cancel_export(job_id: str) -> ExportJob | None:
|
||||
"""取消导出:仅 queued/running 可取消,后台 Task 在让出边界标记 cancelled。"""
|
||||
job = _jobs.get(job_id)
|
||||
if job is None:
|
||||
return None
|
||||
if job.status in (ExportStatus.queued, ExportStatus.running):
|
||||
_cancel_flags[job_id].set()
|
||||
return job
|
||||
|
||||
|
||||
def get_export_file(job_id: str) -> Path:
|
||||
"""返回可下载产物的存储路径;未完成返回 404、过期返回 410。"""
|
||||
job = _jobs.get(job_id)
|
||||
if job is None:
|
||||
raise ApiError(404, "EXPORT_JOB_NOT_FOUND", "export job not found", {"job_id": job_id})
|
||||
if job.status != ExportStatus.completed or job.file is None:
|
||||
raise ApiError(
|
||||
404, "EXPORT_JOB_NOT_FOUND", "export file not ready", {"job_id": job_id}
|
||||
)
|
||||
if job.file.expires_at <= _now():
|
||||
raise ApiError(410, "EXPORT_FILE_EXPIRED", "export file has expired", {"job_id": job_id})
|
||||
return get_settings().exports_path / f"{job_id}.html"
|
||||
|
||||
|
||||
async def wait_for_export(job_id: str) -> ExportJob | None:
|
||||
"""等待后台任务结束(测试/轮询用);无任务时直接返回当前状态。"""
|
||||
task = _tasks.get(job_id)
|
||||
if task is not None:
|
||||
await task
|
||||
return _jobs.get(job_id)
|
||||
Reference in New Issue
Block a user