fix(export): 修复引用块正文丢失、嵌套列表顺序与排队取消

- 引用块直接子节点为块级节点,PDF/DOCX 改为逐个渲染并继承缩进/颜色,
  不再交给行内渲染器导致正文丢失
- PDF 嵌套列表先输出父级正文再输出子列表,修复顺序颠倒
- 等待渲染槽位期间保持 queued 并监听取消,取消即时生效
- DOCX 列表项补处理直接 text 子节点,避免正文被块级渲染器丢弃
- 补充引用块/嵌套列表/排队取消的结构内容回归测试
- 接口契约同步 html/pdf/docx 三格式均已实现,移除 EXPORT_FORMAT_UNSUPPORTED

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
yxx
2026-09-06 17:48:18 +08:00
co-authored by Claude Code
parent dffafce8b9
commit 780e24a399
6 changed files with 319 additions and 90 deletions
+35 -24
View File
@@ -127,16 +127,30 @@ class DocxExporter:
self._render_inline(p, node.children, warnings)
def _block_blockquote(self, node: DocumentNode, warnings: list[str]) -> None:
p = self._doc.add_paragraph()
self._render_inline(p, node.children, warnings)
p.paragraph_format.left_indent = Pt(16)
for run in p.runs:
run.font.color.rgb = RGBColor(0x57, 0x60, 0x6A)
# 引用块的直接子节点是块级节点(paragraph/list 等),不能交给行内渲染器,
# 否则正文会被当作「无法表示的行内节点」丢弃;逐个渲染并继承引用缩进/颜色。
for child in node.children:
if child.type == "paragraph":
p = self._doc.add_paragraph()
self._render_inline(p, child.children, warnings)
p.paragraph_format.left_indent = Pt(16)
for run in p.runs:
run.font.color.rgb = RGBColor(0x57, 0x60, 0x6A)
elif child.type == "list":
self._block_list(child, warnings, level=1, color=RGBColor(0x57, 0x60, 0x6A))
else:
self._render_block(child, warnings)
def _block_list(self, node: DocumentNode, warnings: list[str], level: int = 0) -> None:
def _block_list(
self,
node: DocumentNode,
warnings: list[str],
level: int = 0,
color: RGBColor | None = None,
) -> None:
ordered = bool(node.attributes.get("ordered"))
for index, item in enumerate(node.children, start=1):
self._block_list_item(item, warnings, ordered, index, level)
self._block_list_item(item, warnings, ordered, index, level, color)
def _block_list_item(
self,
@@ -145,6 +159,7 @@ class DocxExporter:
ordered: bool,
index: int,
level: int,
color: RGBColor | None = None,
) -> None:
if item.attributes.get("task"):
marker = "" if item.attributes.get("checked") else ""
@@ -154,26 +169,22 @@ class DocxExporter:
first = True
for child in item.children:
if child.type == "list":
self._block_list(child, warnings, level + 1)
self._block_list(child, warnings, level + 1, color)
continue
if child.type == "paragraph":
p = self._doc.add_paragraph()
p.paragraph_format.left_indent = indent
if first:
self._add_run(p, marker)
first = False
self._render_inline(p, child.children, warnings)
elif child.children:
# 直接行内子节点:拼进一个段落
p = self._doc.add_paragraph()
p.paragraph_format.left_indent = indent
if first:
self._add_run(p, marker)
first = False
p = self._doc.add_paragraph()
p.paragraph_format.left_indent = indent
if first:
self._add_run(p, marker)
first = False
if child.children:
# 段落或行内容器(strong/link 等):渲染其行内子节点
self._render_inline(p, child.children, warnings)
else:
self._render_block(child, warnings)
first = False
# 直接行内叶子节点(text 等):拼进段落,不能交给块级渲染器(会丢弃正文)
self._add_run(p, child.text or "")
if color is not None:
for run in p.runs:
run.font.color.rgb = color
def _block_table(self, node: DocumentNode, warnings: list[str]) -> None:
rows = node.children
+33 -7
View File
@@ -46,6 +46,8 @@ _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]:
@@ -171,12 +173,29 @@ class PdfExporter:
story.append(Paragraph(self._render_inline(node.children, warnings), self._styles["body"]))
def _block_blockquote(self, node: DocumentNode, story: list, warnings: list[str]) -> None:
story.append(Paragraph(self._render_inline(node.children, warnings), self._styles["quote"]))
# 引用块的直接子节点是块级节点(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) -> None:
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)
self._block_list_item(item, story, warnings, ordered, index, indent, color)
def _block_list_item(
self,
@@ -186,23 +205,28 @@ class PdfExporter:
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 = ParagraphStyle(
f"pdf-li-{indent}",
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)
# 先收集父级正文、后处理嵌套列表:保证「父级文字在前、子列表在后」的阅读顺序,
# 而不是在循环里遇到嵌套列表就立刻递归输出(那会把子列表排到父级前面)。
parts: list[str] = []
nested: list[DocumentNode] = []
for child in item.children:
if child.type == "list":
self._block_list(child, story, warnings, indent + 14)
nested.append(child)
elif child.type == "paragraph":
parts.append(self._render_inline(child.children, warnings))
elif child.children:
@@ -210,6 +234,8 @@ class PdfExporter:
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)
def _block_table(self, node: DocumentNode, story: list, warnings: list[str]) -> None:
rows = node.children
+82 -49
View File
@@ -212,6 +212,32 @@ async def create_export(request: ExportRequest) -> ExportJob:
return job
async def _acquire_render_slot(cancel_event: asyncio.Event) -> bool:
"""等待渲染槽位,同时响应取消:拿到槽位返回 True,被取消返回 False。
等待期间任务保持 queued;取消即时生效,不必等前面的渲染完成。
"""
while True:
if cancel_event.is_set():
return False
acquire = asyncio.create_task(_render_slots.acquire())
cancel_wait = asyncio.create_task(cancel_event.wait())
done, pending = await asyncio.wait(
(acquire, cancel_wait), return_when=asyncio.FIRST_COMPLETED
)
if acquire in done:
# 拿到槽位;收掉仍在等待取消标志的任务(不释放刚拿到的槽位)
for task in pending:
task.cancel()
await asyncio.gather(*pending, return_exceptions=True)
return True
# 取消先到:取消尚未完成的 acquireSemaphore.acquire 取消不会递减计数)
acquire.cancel()
cancel_wait.cancel()
await asyncio.gather(acquire, cancel_wait, return_exceptions=True)
return False
async def _execute(
job_id: str,
format: ExportFormat,
@@ -220,61 +246,66 @@ async def _execute(
metadata: dict | None,
options: ExportOptions,
) -> None:
"""后台渲染:解析 → 导出 → 写文件 → 挂载产物元信息。"""
"""后台渲染:排队 → 解析 → 导出 → 写文件 → 挂载产物元信息。"""
cancel_event = _cancel_flags[job_id]
_jobs[job_id] = _jobs[job_id].model_copy(
update={
"status": ExportStatus.running,
"started_at": _now(),
"progress": ExportProgress(phase="rendering", current=0, total=1, percent=0.0),
}
)
acquired = False
try:
# 并发渲染限额:解析/渲染是 CPU 密集的同步工作,用信号量限制同时执行的任务数
# 超出限额的任务在此排队等待,避免大量任务同时占满工作线程与内存
async with _render_slots:
# 让出一次,使「创建后立即取消」的 queued 任务能及时进入 cancelled
await asyncio.sleep(0)
if cancel_event.is_set():
raise ExportCancelled()
# 并发渲染限额:解析/渲染是 CPU 密集的同步工作,用信号量限制同时执行的任务数
# 等待槽位期间保持 queued 并同时监听取消,取消即时生效,不必等前面的渲染完成。
if not await _acquire_render_slot(cancel_event):
raise ExportCancelled()
acquired = True
# 解析与渲染都是 CPU 密集的同步工作,放入线程执行避免阻塞事件循环,
# 使运行中的取消能在渲染边界生效;写文件前再次检查取消。
document = await asyncio.to_thread(parse_document, markdown)
document.attributes["title"] = title
if metadata:
document.attributes["metadata"] = metadata
# 拿到槽位后才进入 running
_jobs[job_id] = _jobs[job_id].model_copy(
update={
"status": ExportStatus.running,
"started_at": _now(),
"progress": ExportProgress(phase="rendering", current=0, total=1, percent=0.0),
}
)
# 让出一次,使「创建后立即取消」的 queued 任务能及时进入 cancelled
await asyncio.sleep(0)
if cancel_event.is_set():
raise ExportCancelled()
result = await asyncio.to_thread(_render_document, document, options, format)
if cancel_event.is_set():
raise ExportCancelled()
if len(result.content) > MAX_EXPORT_BYTES:
raise ExportTooLarge()
# 解析与渲染都是 CPU 密集的同步工作,放入线程执行避免阻塞事件循环,
# 使运行中的取消能在渲染边界生效;写文件前再次检查取消。
document = await asyncio.to_thread(parse_document, markdown)
document.attributes["title"] = title
if metadata:
document.attributes["metadata"] = metadata
ext = _extension_for(format)
out_dir = get_settings().exports_path
out_dir.mkdir(parents=True, exist_ok=True)
path = _export_path(job_id, ext)
path.write_bytes(result.content)
result = await asyncio.to_thread(_render_document, document, options, format)
if cancel_event.is_set():
raise ExportCancelled()
if len(result.content) > MAX_EXPORT_BYTES:
raise ExportTooLarge()
completed_at = _now()
_jobs[job_id] = _jobs[job_id].model_copy(
update={
"status": ExportStatus.completed,
"progress": ExportProgress(
phase="completed", current=1, total=1, percent=1.0
),
"file": ExportFile(
file_name=f"{_safe_download_name(title)}{ext}",
mime_type=result.mime_type,
size=len(result.content),
sha256=hashlib.sha256(result.content).hexdigest(),
expires_at=completed_at + FILE_TTL,
),
"warnings": result.warnings,
"completed_at": completed_at,
}
)
ext = _extension_for(format)
out_dir = get_settings().exports_path
out_dir.mkdir(parents=True, exist_ok=True)
path = _export_path(job_id, ext)
path.write_bytes(result.content)
completed_at = _now()
_jobs[job_id] = _jobs[job_id].model_copy(
update={
"status": ExportStatus.completed,
"progress": ExportProgress(
phase="completed", current=1, total=1, percent=1.0
),
"file": ExportFile(
file_name=f"{_safe_download_name(title)}{ext}",
mime_type=result.mime_type,
size=len(result.content),
sha256=hashlib.sha256(result.content).hexdigest(),
expires_at=completed_at + FILE_TTL,
),
"warnings": result.warnings,
"completed_at": completed_at,
}
)
except ExportCancelled:
_jobs[job_id] = _jobs[job_id].model_copy(
update={
@@ -302,6 +333,8 @@ async def _execute(
}
)
finally:
if acquired:
_render_slots.release()
_cancel_flags.pop(job_id, None)
+161 -1
View File
@@ -8,6 +8,9 @@
from __future__ import annotations
import asyncio
import base64
import re
import zlib
from datetime import datetime, timedelta, timezone
import pytest
@@ -60,10 +63,15 @@ $$
@pytest.fixture(autouse=True)
def _reset_export_state():
"""清空内存注册表,避免跨用例的任务/取消标志互相污染。"""
"""清空内存注册表,避免跨用例的任务/取消标志互相污染。
每个用例经 `asyncio.run()` 使用独立事件循环,模块级 Semaphore 会绑定到首个
循环,跨用例复用会触发「bound to a different event loop」;此处每例重建槽位。
"""
export_service._jobs.clear()
export_service._tasks.clear()
export_service._cancel_flags.clear()
export_service._render_slots = asyncio.Semaphore(export_service.MAX_CONCURRENT_RENDERS)
yield
export_service._jobs.clear()
export_service._tasks.clear()
@@ -347,6 +355,158 @@ def test_docx_exporter_contains_cjk_text() -> None:
assert "进程调度".encode("utf-8") in xml
def _pdf_unescape(raw: bytes) -> bytes:
"""反转义 PDF 字符串字面量(八进制转义与 \n \r \t 等)。"""
out = bytearray()
i = 0
n = len(raw)
while i < n:
b = raw[i]
if b == 0x5C and i + 1 < n: # 反斜杠转义
nxt = raw[i + 1]
if 0x30 <= nxt <= 0x37: # 八进制(如 \000
j = i + 1
digits = bytearray()
while j < n and j < i + 4 and 0x30 <= raw[j] <= 0x37:
digits.append(raw[j])
j += 1
out.append(int(digits.decode(), 8) & 0xFF)
i = j
continue
simple = {0x6E: 0x0A, 0x72: 0x0D, 0x74: 0x09, 0x62: 0x08, 0x66: 0x0C}
out.append(simple.get(nxt, nxt))
i += 2
continue
out.append(b)
i += 1
return bytes(out)
def _extract_pdf_text(content: bytes) -> str:
"""从 PDF 内容流提取文本(仅测试断言用,非完整 PDF 文本提取)。
reportlab 对 CID 字体按 UTF-16BE(高位 0x00)编码,字符串写为 \000 前缀的八进制
转义;这里解码 ASCII85+flate 内容流、反转义字符串并去掉 0x00 还原 ASCII 正文。
"""
chunks: list[str] = []
for m in re.finditer(rb"stream\r?\n(.*?)endstream", content, re.DOTALL):
raw = m.group(1).strip()
if raw.endswith(b"~>"):
raw = raw[:-2]
try:
dec = zlib.decompress(base64.a85decode(raw))
except Exception:
try:
dec = zlib.decompress(raw)
except Exception:
dec = raw
for sm in re.finditer(rb"\(((?:[^()\\]|\\.)*)\)\s*Tj", dec):
text = _pdf_unescape(sm.group(1))
if text.count(0) > len(text) // 4:
text = text.replace(b"\x00", b"")
chunks.append(text.decode("latin-1"))
return "".join(chunks)
# --------------------------------------------------------------------------- #
# 审阅回归:结构内容验证(不只校验魔法字节,还验证产物正文)
# --------------------------------------------------------------------------- #
def test_pdf_blockquote_preserves_content() -> None:
from app.export.exporters.pdf import PdfExporter
# P2:引用块正文不能因「把块级子节点交给行内渲染器」而丢失
result = asyncio.run(
PdfExporter().export(parse_document("> quoted **content**"), ExportOptions())
)
text = _extract_pdf_text(result.content)
assert "quoted" in text
assert "content" in text
assert not any("无法表示" in w for w in result.warnings)
def test_docx_blockquote_preserves_content() -> None:
import zipfile
from io import BytesIO
from app.export.exporters.docx import DocxExporter
result = asyncio.run(
DocxExporter().export(parse_document("> quoted **content**"), ExportOptions())
)
with zipfile.ZipFile(BytesIO(result.content)) as zf:
xml = zf.read("word/document.xml").decode("utf-8")
assert "quoted" in xml
assert "content" in xml
assert not any("无法表示" in w for w in result.warnings)
def test_pdf_nested_list_parent_before_child() -> None:
from app.export.exporters.pdf import PdfExporter
# P2:嵌套列表输出顺序颠倒——父级正文应在子列表之前
result = asyncio.run(
PdfExporter().export(parse_document("- parent\n - child"), ExportOptions())
)
text = _extract_pdf_text(result.content)
assert text.index("parent") < text.index("child")
def test_docx_nested_list_parent_before_child() -> None:
import zipfile
from io import BytesIO
from app.export.exporters.docx import DocxExporter
result = asyncio.run(
DocxExporter().export(parse_document("- parent\n - child"), ExportOptions())
)
with zipfile.ZipFile(BytesIO(result.content)) as zf:
xml = zf.read("word/document.xml").decode("utf-8")
assert xml.index("parent") < xml.index("child")
def test_export_cancel_queued_job_waiting_for_slot(monkeypatch) -> None:
# P2:等待渲染槽位的任务取消后应立即进入 cancelled,不必等前面的渲染完成
import threading
real_render = export_service._render_document
release = threading.Event()
entered = 0
lock = threading.Lock()
def blocking_render(document, options, format):
nonlocal entered
with lock:
entered += 1
release.wait(timeout=5)
return real_render(document, options, format)
monkeypatch.setattr(export_service, "_render_document", blocking_render)
async def _go():
a = await export_service.create_export(_markdown_request("# a"))
b = await export_service.create_export(_markdown_request("# b"))
# 等 a/b 两个任务都拿到槽位并阻塞在渲染里
for _ in range(2000):
if entered >= 2:
break
await asyncio.sleep(0.001)
c = await export_service.create_export(_markdown_request("# c"))
await asyncio.sleep(0.01) # 让 c 进入排队等待槽位
export_service.cancel_export(c.job_id)
finished_c = await export_service.wait_for_export(c.job_id)
release.set() # 放行前面的任务,避免测试挂起
await asyncio.gather(
export_service.wait_for_export(a.job_id),
export_service.wait_for_export(b.job_id),
)
return finished_c
finished = asyncio.run(_go())
assert finished.status == ExportStatus.cancelled
assert finished.file is None
def test_export_unknown_note_404() -> None:
request = ExportRequest(
source=ExportSource(type=ExportSourceType.note, note_id="note_missing"),
@@ -68,11 +68,11 @@
| Benchmark | POST | `/api/benchmarks/agent/runs` | 暂缓 | 创建 Agent Benchmark(依赖 Agent Runtime 完成后交付) |
| Benchmark | GET | `/api/benchmarks/runs` | 已实现 | 分页获取 Benchmark Run |
| Benchmark | GET/POST | `/api/benchmarks/runs/{run_id}/*` | 计划新增 | 查询、订阅、取消和读取报告 |
| Export | POST | `/api/exports` | 已实现(HTML) | 创建导出任务;`pdf`/`docx` 暂缓,返回 `EXPORT_FORMAT_UNSUPPORTED` |
| Export | GET | `/api/exports` | 已实现HTML | 分页获取导出任务 |
| Export | GET | `/api/exports/{job_id}` | 已实现HTML | 查询导出任务 |
| Export | GET | `/api/exports/{job_id}/file` | 已实现HTML | 下载已完成产物 |
| Export | POST | `/api/exports/{job_id}/cancel` | 已实现HTML | 取消导出任务 |
| Export | POST | `/api/exports` | 已实现(HTML/PDF/DOCX | 创建导出任务;`html`/`pdf`/`docx` 三格式均已支持 |
| Export | GET | `/api/exports` | 已实现 | 分页获取导出任务 |
| Export | GET | `/api/exports/{job_id}` | 已实现 | 查询导出任务 |
| Export | GET | `/api/exports/{job_id}/file` | 已实现 | 下载已完成产物 |
| Export | POST | `/api/exports/{job_id}/cancel` | 已实现 | 取消导出任务 |
| Theme | Host Contract | `ThemePackageService` | 计划新增 | 导入、预览、启停和卸载主题包 |
| Renderer | 内部 Contract | `StaticRenderer` | 计划新增 | Mermaid/Function Plot 预览和导出复用 |
@@ -1080,7 +1080,7 @@ VECTOR_INDEX_REBUILD_REQUIRED
## 10. Export Service
> 实现状态:HTML 导出已实现(`backend/app/export/`),`pdf`/`docx` 暂缓——请求这两个格式返回 `EXPORT_FORMAT_UNSUPPORTED`。`function-plot` 已支持静态 SVG 内嵌(`backend/app/plot/`),解析或渲染失败时回退为源码占位并记录 warning;Mermaid 目前仍以占位代码块保留并记 warning。
> 实现状态:HTML / PDF / DOCX 导出已实现(`backend/app/export/`),`format` 支持 `html`/`pdf`/`docx` 三格式。`function-plot` 已支持静态 SVG 内嵌(`backend/app/plot/`),解析或渲染失败时回退为源码占位并记录 warning;Mermaid 目前仍以占位代码块保留并记 warning。PDF/DOCX 为文本优先 v1`function-plot` 与 Mermaid 保留源码占位并记 warning。
### 10.1 创建导出任务
@@ -1103,7 +1103,7 @@ VECTOR_INDEX_REBUILD_REQUIRED
}
```
`source.type` 首批支持 `note``markdown``note` 来源通过 `source.note_id` 引用已建索引笔记;`markdown` 来源用于尚未保存的预览,内容放在 `source.markdown` 字段,大小限制为 200 000 字符、不持久化到 Trace。`format` 可取 `html``pdf``docx`但当前仅 `html` 已实现,`pdf`/`docx` 返回 `EXPORT_FORMAT_UNSUPPORTED`
`source.type` 首批支持 `note``markdown``note` 来源通过 `source.note_id` 引用已建索引笔记;`markdown` 来源用于尚未保存的预览,内容放在 `source.markdown` 字段,大小限制为 200 000 字符、不持久化到 Trace。`format` 可取 `html``pdf``docx`三格式均已实现
响应:
@@ -1222,7 +1222,6 @@ interface StaticRenderResult {
```text
EXPORT_SOURCE_NOT_FOUND
EXPORT_FORMAT_UNSUPPORTED
EXPORT_OPTIONS_INVALID
EXPORT_RENDER_FAILED
EXPORT_UNSUPPORTED_CONTENT
+1 -1
View File
@@ -121,7 +121,7 @@ cd backend
uv run pytest -q
```
`tests/test_export.py` 覆盖 Markdown 解析(标题/行内/列表/代码分流/表格/数学)、HTML 渲染(标签 + 转义 + warning)、Service 端到端(note 源与 markdown 源、PDF/DOCX 魔法字节与 CJK 字体、未知 note、取消、list/get、过期 410)与 `ExportSource` 契约校验。`tests/test_plot.py` 覆盖表达式解析/求值、SVG 渲染与 `StaticRenderer` 契约(函数图像渲染、Mermaid 占位)。
`tests/test_export.py` 覆盖 Markdown 解析(标题/行内/列表/代码分流/表格/数学)、HTML 渲染(标签 + 转义 + warning)、Service 端到端(note 源与 markdown 源、PDF/DOCX 魔法字节与 CJK 字体、引用块正文与嵌套列表顺序等结构内容回归、排队任务取消、未知 note、取消、list/get、过期 410)与 `ExportSource` 契约校验。`tests/test_plot.py` 覆盖表达式解析/求值、SVG 渲染与 `StaticRenderer` 契约(函数图像渲染、Mermaid 占位)。
## 范围外(后续 PR