diff --git a/backend/app/export/exporters/docx.py b/backend/app/export/exporters/docx.py
index 2f461a1..74c9d9c 100644
--- a/backend/app/export/exporters/docx.py
+++ b/backend/app/export/exporters/docx.py
@@ -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":
diff --git a/backend/app/export/exporters/pdf.py b/backend/app/export/exporters/pdf.py
index 32f2131..24125ce 100644
--- a/backend/app/export/exporters/pdf.py
+++ b/backend/app/export/exporters/pdf.py
@@ -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 = "
".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 + "
".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
diff --git a/backend/tests/test_export.py b/backend/tests/test_export.py
index fa18e45..2e3b353 100644
--- a/backend/tests/test_export.py
+++ b/backend/tests/test_export.py
@@ -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 "" 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
diff --git a/docs/contracts/第二阶段接口契约-开发版.md b/docs/contracts/第二阶段接口契约-开发版.md
index fc6cd38..55ce34e 100644
--- a/docs/contracts/第二阶段接口契约-开发版.md
+++ b/docs/contracts/第二阶段接口契约-开发版.md
@@ -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 中两者保留源码占位 |
---