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>
This commit is contained in:
yxx
2026-09-06 20:54:05 +08:00
co-authored by Claude Code
parent 6c14047899
commit 4fc26a11e1
4 changed files with 80 additions and 16 deletions
+11 -5
View File
@@ -176,12 +176,13 @@ class DocxExporter:
if first:
self._add_run(p, marker)
first = False
if child.children:
# 段落或行内容器(strong/link 等):渲染其行内子节点
if child.type == "paragraph":
# 块级容器:展开其行内子节点
self._render_inline(p, child.children, warnings)
else:
# 直接行内叶子节点(text 等):拼进段落,不能交给块级渲染器(会丢弃正文)
self._add_run(p, child.text or "")
# 直接行内节点(text/strong/emphasis/link/codespan 等):走行内渲染保留
# 语义(加粗/斜体/超链接),不能只渲染其 children 而丢掉格式。
self._render_inline_node(p, child, warnings)
if color is not None:
for run in p.runs:
run.font.color.rgb = color
@@ -259,7 +260,12 @@ class DocxExporter:
self._render_inline_node(paragraph, child, warnings, bold, italic)
def _render_inline_node(
self, paragraph, node: DocumentNode, warnings: list[str], bold: bool, italic: bool
self,
paragraph,
node: DocumentNode,
warnings: list[str],
bold: bool = False,
italic: bool = False,
) -> None:
t = node.type
if t == "text":
+20 -10
View File
@@ -220,22 +220,32 @@ class PdfExporter:
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] = []
nested: list[DocumentNode] = []
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":
nested.append(child)
flush()
self._block_list(child, story, warnings, indent + 14, color)
elif child.type == "paragraph":
parts.append(self._render_inline(child.children, warnings))
elif child.children:
parts.append(self._render_inline(child.children, warnings))
else:
parts.append(_html.escape(child.text))
story.append(Paragraph(marker + "<br/>".join(parts), style))
for child_list in nested:
self._block_list(child_list, story, warnings, indent + 14, color)
parts.append(self._render_inline_node(child, warnings))
flush()
def _block_table(self, node: DocumentNode, story: list, warnings: list[str]) -> None:
rows = node.children
+48
View File
@@ -465,6 +465,54 @@ def test_docx_nested_list_parent_before_child() -> None:
assert xml.index("parent") < xml.index("child")
def test_pdf_nested_list_mixed_order_preserves_sequence() -> None:
from app.export.exporters.pdf import PdfExporter
# P2:混合列表项(父段—子列表—后续段)应保持原始顺序,不能把所有正文挤到子列表之前
result = asyncio.run(
PdfExporter().export(parse_document("- parent\n\n - child\n\n after"), ExportOptions())
)
text = _extract_pdf_text(result.content)
assert text.index("parent") < text.index("child") < text.index("after")
def test_pdf_list_item_preserves_inline_semantics() -> None:
from app.export.exporters.pdf import PdfExporter
# P2:列表项内的加粗与链接语义不能被「只渲染 children」而静默丢失
result = asyncio.run(
PdfExporter().export(parse_document("- **bold** [link](https://example.com)"), ExportOptions())
)
text = _extract_pdf_text(result.content)
assert "bold" in text
assert "link" in text
# 链接以 PDF 链接注解(/URI)保留,而非降级为纯文本
assert b"/URI" in result.content
assert b"example.com" in result.content
assert not any("链接协议不安全" in w for w in result.warnings)
assert not any("无法表示" in w for w in result.warnings)
def test_docx_list_item_preserves_inline_semantics() -> None:
import zipfile
from io import BytesIO
from app.export.exporters.docx import DocxExporter
# P2:列表项内的加粗与链接语义应保留(w:b 加粗、w:hyperlink 可点击链接)
result = asyncio.run(
DocxExporter().export(parse_document("- **bold** [link](https://example.com)"), ExportOptions())
)
with zipfile.ZipFile(BytesIO(result.content)) as zf:
xml = zf.read("word/document.xml").decode("utf-8")
rels = zf.read("word/_rels/document.xml.rels").decode("utf-8")
assert "<w:b/>" in xml
assert "w:hyperlink" in xml
assert "example.com" in rels
assert not any("链接协议不安全" in w for w in result.warnings)
assert not any("无法表示" in w for w in result.warnings)
def test_export_cancel_queued_job_waiting_for_slot(monkeypatch) -> None:
# P2:等待渲染槽位的任务取消后应立即进入 cancelled,不必等前面的渲染完成
import threading
@@ -74,7 +74,7 @@
| Export | GET | `/api/exports/{job_id}/file` | 已实现 | 下载已完成产物 |
| Export | POST | `/api/exports/{job_id}/cancel` | 已实现 | 取消导出任务 |
| Theme | Host Contract | `ThemePackageService` | 计划新增 | 导入、预览、启停和卸载主题包 |
| Renderer | 内部 Contract | `StaticRenderer` | 计划新增 | Mermaid/Function Plot 预览和导出复用 |
| Renderer | 内部 Contract | `StaticRenderer` | 已实现 | Function Plot 后端静态 SVG 渲染;Mermaid 返回占位;PDF/DOCX 中两者保留源码占位 |
---