258 lines
11 KiB
Python
258 lines
11 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 mistune.plugins.table import table_in_list, table_in_quote
|
||
import re
|
||
from copy import deepcopy
|
||
from app.export.themes import CALLOUTS, ALIASES
|
||
|
||
from app.export.document import Document, DocumentNode
|
||
|
||
_PLUGINS = ["table", "math", "url", "task_lists"]
|
||
|
||
# fenced code 语言分流:命中则转为专用节点,其余按普通代码块
|
||
_MERMAID_LANG = "mermaid"
|
||
_FUNCTION_PLOT_LANGS = {"function-plot", "function_plot", "functionplot"}
|
||
|
||
|
||
def parse_document(markdown: str) -> Document:
|
||
"""把 Markdown 文本解析为 Document AST 根节点。"""
|
||
renderer = mistune.create_markdown(renderer="ast", plugins=_PLUGINS)
|
||
table_in_quote(renderer)
|
||
table_in_list(renderer)
|
||
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":
|
||
children = deepcopy(token.get('children', []))
|
||
first = children[0] if children else {}
|
||
inline = first.get('children', [])
|
||
if first.get('type') == 'paragraph' and inline and inline[0].get('type') == 'text':
|
||
match = re.match(r'^\[!([\w-]+)\]([+-]?)[ \t]*', inline[0].get('raw', ''))
|
||
if match:
|
||
name = match[1].lower()
|
||
name = ALIASES.get(name, name)
|
||
if name not in CALLOUTS:
|
||
name = 'note'
|
||
inline[0]['raw'] = inline[0]['raw'][match.end():]
|
||
split = next((i for i,t in enumerate(inline) if t['type'] in ('softbreak','linebreak')),len(inline))
|
||
title = inline[:split]
|
||
if not any(t.get('raw') or t.get('children') for t in title):
|
||
title = [{'type':'text','raw':match[1].lower().capitalize()}]
|
||
first['children'] = inline[split+1:]
|
||
if not first['children']:
|
||
children.pop(0)
|
||
heading = DocumentNode(type='paragraph',node_id=self.next_id(),children=self.map_inline(title))
|
||
return DocumentNode(type='callout',node_id=self.next_id(),
|
||
attributes={'kind':name,'fold':match[2]},
|
||
children=[heading,*self.map_blocks(children)])
|
||
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
|
||
if kind == "block_html":
|
||
# 原始 HTML 块降级为纯文本节点,由 HtmlExporter 转义并记 warning,避免静默丢失正文
|
||
return DocumentNode(
|
||
type="html_block", node_id=self.next_id(), text=token.get("raw", "")
|
||
)
|
||
# 未知块级 token 保守保留原文;映射为带 text 子节点的 paragraph,避免被渲染层丢弃
|
||
raw = token.get("raw", "")
|
||
if raw:
|
||
return DocumentNode(
|
||
type="paragraph",
|
||
node_id=self.next_id(),
|
||
children=[DocumentNode(type="text", 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":
|
||
# mistune 图片 token:src 在 attrs.url,alt 来自 children 的文本,title 在 attrs.title
|
||
attrs = token.get("attrs", {})
|
||
alt = "".join(
|
||
child.get("raw", "")
|
||
for child in token.get("children", [])
|
||
if child.get("type") == "text"
|
||
)
|
||
attributes = {"src": attrs.get("url", "")}
|
||
if alt:
|
||
attributes["alt"] = 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
|
||
)
|