实现 Export Service 完整生命周期:mistune AST → Document AST → HtmlExporter 渲染完整 HTML5,异步任务注册表 + 取消 + 24h 产物过期。新增 5 个 /api/exports 端点与 15 项测试;pdf/docx 与函数图像静态渲染留待后续 PR。
215 lines
9.0 KiB
Python
215 lines
9.0 KiB
Python
"""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
|
|
)
|