Files
NotesAgentic/backend/app/export/exporters/pdf.py
T
yxxandClaude Code 4fc26a11e1 fix(export): 修复列表项行内语义丢失与混合嵌套顺序重排
- pdf.py `_block_list_item` 改为按 AST 顺序逐段输出:正文暂存为行内标记文本,
  遇嵌套列表先 flush 再递归,之后继续后续正文,保持「父段—子列表—后续段」原始顺序
- pdf.py/docx.py 列表项直接行内节点改走 `_render_inline_node`,保留加粗/链接语义,
  不再只渲染 children 而丢掉格式(PDF 链接以 /URI 注解保留,DOCX 写入 w:hyperlink)
- docx.py `_render_inline_node` 增加 bold/italic 默认值,便于列表项直接调用
- 契约文档 StaticRenderer 状态「计划新增」→「已实现」
- 新增回归测试:混合嵌套顺序、PDF/DOCX 列表项行内语义

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-06 20:54:05 +08:00

340 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""PdfExporterDocument AST → PDFreportlab platypus)。
v1 为文本优先:标题/段落/行内强调与链接/列表/引用/表格/代码块/数学文本均可导出;
function_plot 与 mermaid 保留源码占位并记 warning。中文字体用 reportlab 内置
STSong-Light CID 字体,避免外部字体依赖。CID 字体无独立 bold/italic 字重,
故行内强调退化为普通文本(内容不丢、样式简化),标题靠字号区分层级。
"""
from __future__ import annotations
import html as _html
from io import BytesIO
from reportlab.lib.enums import TA_CENTER
from reportlab.lib.pagesizes import A4, letter
from reportlab.lib.styles import ParagraphStyle
from reportlab.lib.units import mm
from reportlab.pdfbase import pdfmetrics
from reportlab.pdfbase.cidfonts import UnicodeCIDFont
from reportlab.platypus import (
Paragraph,
Preformatted,
SimpleDocTemplate,
Spacer,
Table,
TableStyle,
)
from reportlab.platypus.flowables import HRFlowable
from app.contracts import ExportOptions
from app.export.document import Document, DocumentNode, ExportResult
from app.export.exporters._common import (
MERMAID_WARNING,
PLOT_PLACEHOLDER_WARNING,
RAW_HTML_WARNING,
format_meta_value,
safe_url,
)
_FONT = "STSong-Light"
pdfmetrics.registerFont(UnicodeCIDFont(_FONT))
_MIME = "application/pdf"
_PAGE_SIZES = {"a4": A4, "letter": letter}
# 标题字号随层级递减;标题不依赖粗体(CID 无粗体字重),靠字号拉开层级
_HEADING_SIZES = {1: 20, 2: 16, 3: 14, 4: 12, 5: 11, 6: 10.5}
# 引用块文字颜色,与 HtmlExporter 的引用灰一致
_QUOTE_COLOR = "#57606a"
def _make_styles() -> dict[str, ParagraphStyle]:
body = ParagraphStyle(
"pdf-body",
fontName=_FONT,
fontSize=10.5,
leading=16,
spaceAfter=6,
)
title = ParagraphStyle("pdf-title", parent=body, fontSize=22, leading=28, spaceAfter=12)
quote = ParagraphStyle(
"pdf-quote",
parent=body,
leftIndent=14,
textColor="#57606a",
spaceBefore=4,
spaceAfter=6,
)
code = ParagraphStyle(
"pdf-code",
parent=body,
fontSize=9,
leading=12,
leftIndent=6,
rightIndent=6,
backColor="#f6f8fa",
borderColor="#d0d7de",
borderWidth=0.5,
borderPadding=6,
spaceBefore=4,
spaceAfter=8,
)
math = ParagraphStyle("pdf-math", parent=body, alignment=TA_CENTER, spaceBefore=6)
cell = ParagraphStyle("pdf-cell", parent=body, fontSize=10, leading=14, spaceAfter=0)
cell_head = ParagraphStyle(
"pdf-cell-head", parent=cell, textColor="#1f2328", fontSize=10
)
meta = ParagraphStyle("pdf-meta", parent=body, fontSize=8.5, leading=13, textColor="#57606a")
styles: dict[str, ParagraphStyle] = {
"body": body,
"title": title,
"quote": quote,
"code": code,
"math": math,
"cell": cell,
"cell_head": cell_head,
"meta": meta,
}
for level, size in _HEADING_SIZES.items():
styles[f"h{level}"] = ParagraphStyle(
f"pdf-h{level}",
parent=body,
fontSize=size,
leading=size * 1.4,
spaceBefore=14 if level <= 2 else 10,
spaceAfter=6,
)
return styles
class PdfExporter:
"""实现 DocumentExporter:递归渲染 Document AST 为 PDF 字节流。"""
def render(self, document: Document, options: ExportOptions) -> ExportResult:
"""同步渲染;CPU 密集,调用方应放入线程执行,避免阻塞事件循环。"""
self._styles = _make_styles()
warnings: list[str] = []
page = _PAGE_SIZES.get((options.page_size or "A4").lower(), A4)
buf = BytesIO()
doc = SimpleDocTemplate(
buf,
pagesize=page,
leftMargin=20 * mm,
rightMargin=20 * mm,
topMargin=18 * mm,
bottomMargin=18 * mm,
title=str(document.attributes.get("title") or "") or None,
)
story: list = []
self._render_header(document, options, story)
self._render_children(document.children, story, warnings)
doc.build(story)
return ExportResult(content=buf.getvalue(), mime_type=_MIME, warnings=warnings)
async def export(self, document: Document, options: ExportOptions) -> ExportResult:
"""契约要求的 async 接口;渲染本身同步,直接转发到 render。"""
return self.render(document, options)
# --- 文档头部 ---
def _render_header(self, document: Document, options: ExportOptions, story: list) -> None:
title = str(document.attributes.get("title") or "")
if options.include_title and title:
story.append(Paragraph(_html.escape(title), self._styles["title"]))
if options.include_metadata:
metadata = document.attributes.get("metadata")
if metadata:
for key, value in metadata.items():
text = f"{_html.escape(str(key))}: {_html.escape(format_meta_value(value))}"
story.append(Paragraph(text, self._styles["meta"]))
# --- 块级 ---
def _render_children(self, children: list[DocumentNode], story: list, warnings: list[str]) -> None:
for child in children:
self._render_block(child, story, warnings)
def _render_block(self, node: DocumentNode, story: list, warnings: list[str]) -> None:
handler = getattr(self, f"_block_{node.type}", None)
if handler is not None:
handler(node, story, warnings)
else:
warnings.append(f"无法表示的节点类型已跳过:{node.type}")
def _block_heading(self, node: DocumentNode, story: list, warnings: list[str]) -> None:
level = max(1, min(6, int(node.attributes.get("level", 1))))
inline = self._render_inline(node.children, warnings)
story.append(Paragraph(inline, self._styles[f"h{level}"]))
def _block_paragraph(self, node: DocumentNode, story: list, warnings: list[str]) -> None:
story.append(Paragraph(self._render_inline(node.children, warnings), self._styles["body"]))
def _block_blockquote(self, node: DocumentNode, story: list, warnings: list[str]) -> None:
# 引用块的直接子节点是块级节点(paragraph/list 等),不能交给行内渲染器,
# 否则正文会被当作「无法表示的行内节点」丢弃;逐个渲染并继承引用缩进/颜色。
for child in node.children:
if child.type == "paragraph":
story.append(
Paragraph(self._render_inline(child.children, warnings), self._styles["quote"])
)
elif child.type == "list":
self._block_list(child, story, warnings, indent=14, color=_QUOTE_COLOR)
else:
self._render_block(child, story, warnings)
def _block_list(
self,
node: DocumentNode,
story: list,
warnings: list[str],
indent: int = 14,
color: str | None = None,
) -> None:
ordered = bool(node.attributes.get("ordered"))
for index, item in enumerate(node.children, start=1):
self._block_list_item(item, story, warnings, ordered, index, indent, color)
def _block_list_item(
self,
item: DocumentNode,
story: list,
warnings: list[str],
ordered: bool,
index: int,
indent: int,
color: str | None = None,
) -> None:
if item.attributes.get("task"):
marker = "☑ " if item.attributes.get("checked") else "☐ "
else:
marker = f"{index}. " if ordered else "• "
style_kwargs: dict = dict(
parent=self._styles["body"],
leftIndent=indent,
firstLineIndent=-7,
spaceAfter=2,
)
if color:
style_kwargs["textColor"] = color
style = ParagraphStyle(f"pdf-li-{indent}-{color or 'normal'}", **style_kwargs)
# 按 AST 顺序逐段输出:正文暂存为行内标记文本,遇到嵌套列表先 flush 再递归、
# 之后继续后续正文,保持「父段—子列表—后续段」的原始顺序(而不是把所有正文
# 都挤到子列表之前)。直接行内节点(text/strong/link 等)走 _render_inline_node
# 保留加粗/链接等语义,不能只渲染其 children 而丢掉格式。
parts: list[str] = []
first = True
def flush() -> None:
nonlocal first
text = "<br/>".join(parts)
if first:
text = marker + text
first = False
if text:
story.append(Paragraph(text, style))
parts.clear()
for child in item.children:
if child.type == "list":
flush()
self._block_list(child, story, warnings, indent + 14, color)
elif child.type == "paragraph":
parts.append(self._render_inline(child.children, warnings))
else:
parts.append(self._render_inline_node(child, warnings))
flush()
def _block_table(self, node: DocumentNode, story: list, warnings: list[str]) -> None:
rows = node.children
if not rows:
return
data: list[list[Paragraph]] = []
head_row_count = 0
for row in rows:
head = bool(row.attributes.get("head"))
if head:
head_row_count += 1
cells = [
Paragraph(
self._render_inline(cell.children, warnings),
self._styles["cell_head" if cell.attributes.get("head") else "cell"],
)
for cell in row.children
]
data.append(cells)
table = Table(data, repeatRows=head_row_count)
commands = [
("GRID", (0, 0), (-1, -1), 0.5, "#d0d7de"),
("VALIGN", (0, 0), (-1, -1), "TOP"),
("LEFTPADDING", (0, 0), (-1, -1), 6),
("RIGHTPADDING", (0, 0), (-1, -1), 6),
("TOPPADDING", (0, 0), (-1, -1), 4),
("BOTTOMPADDING", (0, 0), (-1, -1), 4),
]
if head_row_count:
commands.append(("BACKGROUND", (0, 0), (-1, head_row_count - 1), "#f6f8fa"))
table.setStyle(TableStyle(commands))
story.append(table)
def _block_code_block(self, node: DocumentNode, story: list, warnings: list[str]) -> None:
story.append(Preformatted(node.text, self._styles["code"]))
def _block_thematic_break(self, node: DocumentNode, story: list, warnings: list[str]) -> None:
story.append(Spacer(1, 4))
story.append(HRFlowable(width="100%", color="#d0d7de", thickness=0.5))
story.append(Spacer(1, 6))
def _block_mermaid(self, node: DocumentNode, story: list, warnings: list[str]) -> None:
warnings.append(MERMAID_WARNING)
story.append(Preformatted(node.text, self._styles["code"]))
def _block_function_plot(self, node: DocumentNode, story: list, warnings: list[str]) -> None:
warnings.append(PLOT_PLACEHOLDER_WARNING)
story.append(Preformatted(node.text, self._styles["code"]))
def _block_math_block(self, node: DocumentNode, story: list, warnings: list[str]) -> None:
story.append(Paragraph(f"$${_html.escape(node.text)}$$", self._styles["math"]))
def _block_html_block(self, node: DocumentNode, story: list, warnings: list[str]) -> None:
# 原始 HTML 不可信,按纯文本保留正文
warnings.append(RAW_HTML_WARNING)
story.append(Paragraph(_html.escape(node.text), self._styles["body"]))
# --- 行内(产出 reportlab Paragraph 标记文本) ---
def _render_inline(self, children: list[DocumentNode], warnings: list[str]) -> str:
return "".join(self._render_inline_node(child, warnings) for child in children)
def _render_inline_node(self, node: DocumentNode, warnings: list[str]) -> str:
t = node.type
if t == "text":
return _html.escape(node.text)
if t in ("strong", "emphasis"):
return self._render_inline(node.children, warnings)
if t == "codespan":
return f'<font size="9">{_html.escape(node.text)}</font>'
if t == "link":
inner = self._render_inline(node.children, warnings)
href = str(node.attributes.get("href") or "")
safe_href = safe_url(href)
if safe_href is None:
warnings.append(f"链接协议不安全,已降级为纯文本:{href!r}")
return inner
return f'<a href="{_html.escape(safe_href)}">{inner}</a>'
if t == "image":
src = str(node.attributes.get("src") or "")
alt = str(node.attributes.get("alt") or "")
if safe_url(src) is None:
warnings.append(f"图片地址不安全,已跳过:{src!r}")
else:
warnings.append("图片未内嵌到 PDF,已用替代文本表示")
return _html.escape(alt) if alt else ""
if t == "math_inline":
return f"\\({_html.escape(node.text)}\\)"
if t == "linebreak":
return "<br/>"
warnings.append(f"无法表示的行内节点已跳过:{t}")
return ""