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 中两者保留源码占位 | ---