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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
# 取消先到:取消尚未完成的 acquire(Semaphore.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)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user