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:
@@ -176,12 +176,13 @@ class DocxExporter:
|
|||||||
if first:
|
if first:
|
||||||
self._add_run(p, marker)
|
self._add_run(p, marker)
|
||||||
first = False
|
first = False
|
||||||
if child.children:
|
if child.type == "paragraph":
|
||||||
# 段落或行内容器(strong/link 等):渲染其行内子节点
|
# 块级容器:展开其行内子节点
|
||||||
self._render_inline(p, child.children, warnings)
|
self._render_inline(p, child.children, warnings)
|
||||||
else:
|
else:
|
||||||
# 直接行内叶子节点(text 等):拼进段落,不能交给块级渲染器(会丢弃正文)
|
# 直接行内节点(text/strong/emphasis/link/codespan 等):走行内渲染保留
|
||||||
self._add_run(p, child.text or "")
|
# 语义(加粗/斜体/超链接),不能只渲染其 children 而丢掉格式。
|
||||||
|
self._render_inline_node(p, child, warnings)
|
||||||
if color is not None:
|
if color is not None:
|
||||||
for run in p.runs:
|
for run in p.runs:
|
||||||
run.font.color.rgb = color
|
run.font.color.rgb = color
|
||||||
@@ -259,7 +260,12 @@ class DocxExporter:
|
|||||||
self._render_inline_node(paragraph, child, warnings, bold, italic)
|
self._render_inline_node(paragraph, child, warnings, bold, italic)
|
||||||
|
|
||||||
def _render_inline_node(
|
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:
|
) -> None:
|
||||||
t = node.type
|
t = node.type
|
||||||
if t == "text":
|
if t == "text":
|
||||||
|
|||||||
@@ -220,22 +220,32 @@ class PdfExporter:
|
|||||||
if color:
|
if color:
|
||||||
style_kwargs["textColor"] = color
|
style_kwargs["textColor"] = color
|
||||||
style = ParagraphStyle(f"pdf-li-{indent}-{color or 'normal'}", **style_kwargs)
|
style = ParagraphStyle(f"pdf-li-{indent}-{color or 'normal'}", **style_kwargs)
|
||||||
# 先收集父级正文、后处理嵌套列表:保证「父级文字在前、子列表在后」的阅读顺序,
|
# 按 AST 顺序逐段输出:正文暂存为行内标记文本,遇到嵌套列表先 flush 再递归、
|
||||||
# 而不是在循环里遇到嵌套列表就立刻递归输出(那会把子列表排到父级前面)。
|
# 之后继续后续正文,保持「父段—子列表—后续段」的原始顺序(而不是把所有正文
|
||||||
|
# 都挤到子列表之前)。直接行内节点(text/strong/link 等)走 _render_inline_node,
|
||||||
|
# 保留加粗/链接等语义,不能只渲染其 children 而丢掉格式。
|
||||||
parts: list[str] = []
|
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:
|
for child in item.children:
|
||||||
if child.type == "list":
|
if child.type == "list":
|
||||||
nested.append(child)
|
flush()
|
||||||
|
self._block_list(child, story, warnings, indent + 14, color)
|
||||||
elif child.type == "paragraph":
|
elif child.type == "paragraph":
|
||||||
parts.append(self._render_inline(child.children, warnings))
|
parts.append(self._render_inline(child.children, warnings))
|
||||||
elif child.children:
|
|
||||||
parts.append(self._render_inline(child.children, warnings))
|
|
||||||
else:
|
else:
|
||||||
parts.append(_html.escape(child.text))
|
parts.append(self._render_inline_node(child, warnings))
|
||||||
story.append(Paragraph(marker + "<br/>".join(parts), style))
|
flush()
|
||||||
for child_list in nested:
|
|
||||||
self._block_list(child_list, story, warnings, indent + 14, color)
|
|
||||||
|
|
||||||
def _block_table(self, node: DocumentNode, story: list, warnings: list[str]) -> None:
|
def _block_table(self, node: DocumentNode, story: list, warnings: list[str]) -> None:
|
||||||
rows = node.children
|
rows = node.children
|
||||||
|
|||||||
@@ -465,6 +465,54 @@ def test_docx_nested_list_parent_before_child() -> None:
|
|||||||
assert xml.index("parent") < xml.index("child")
|
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:
|
def test_export_cancel_queued_job_waiting_for_slot(monkeypatch) -> None:
|
||||||
# P2:等待渲染槽位的任务取消后应立即进入 cancelled,不必等前面的渲染完成
|
# P2:等待渲染槽位的任务取消后应立即进入 cancelled,不必等前面的渲染完成
|
||||||
import threading
|
import threading
|
||||||
|
|||||||
@@ -74,7 +74,7 @@
|
|||||||
| Export | GET | `/api/exports/{job_id}/file` | 已实现 | 下载已完成产物 |
|
| Export | GET | `/api/exports/{job_id}/file` | 已实现 | 下载已完成产物 |
|
||||||
| Export | POST | `/api/exports/{job_id}/cancel` | 已实现 | 取消导出任务 |
|
| Export | POST | `/api/exports/{job_id}/cancel` | 已实现 | 取消导出任务 |
|
||||||
| Theme | Host Contract | `ThemePackageService` | 计划新增 | 导入、预览、启停和卸载主题包 |
|
| Theme | Host Contract | `ThemePackageService` | 计划新增 | 导入、预览、启停和卸载主题包 |
|
||||||
| Renderer | 内部 Contract | `StaticRenderer` | 计划新增 | Mermaid/Function Plot 预览和导出复用 |
|
| Renderer | 内部 Contract | `StaticRenderer` | 已实现 | Function Plot 后端静态 SVG 渲染;Mermaid 返回占位;PDF/DOCX 中两者保留源码占位 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user