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)
|
self._render_inline(p, node.children, warnings)
|
||||||
|
|
||||||
def _block_blockquote(self, node: DocumentNode, warnings: list[str]) -> None:
|
def _block_blockquote(self, node: DocumentNode, warnings: list[str]) -> None:
|
||||||
|
# 引用块的直接子节点是块级节点(paragraph/list 等),不能交给行内渲染器,
|
||||||
|
# 否则正文会被当作「无法表示的行内节点」丢弃;逐个渲染并继承引用缩进/颜色。
|
||||||
|
for child in node.children:
|
||||||
|
if child.type == "paragraph":
|
||||||
p = self._doc.add_paragraph()
|
p = self._doc.add_paragraph()
|
||||||
self._render_inline(p, node.children, warnings)
|
self._render_inline(p, child.children, warnings)
|
||||||
p.paragraph_format.left_indent = Pt(16)
|
p.paragraph_format.left_indent = Pt(16)
|
||||||
for run in p.runs:
|
for run in p.runs:
|
||||||
run.font.color.rgb = RGBColor(0x57, 0x60, 0x6A)
|
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"))
|
ordered = bool(node.attributes.get("ordered"))
|
||||||
for index, item in enumerate(node.children, start=1):
|
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(
|
def _block_list_item(
|
||||||
self,
|
self,
|
||||||
@@ -145,6 +159,7 @@ class DocxExporter:
|
|||||||
ordered: bool,
|
ordered: bool,
|
||||||
index: int,
|
index: int,
|
||||||
level: int,
|
level: int,
|
||||||
|
color: RGBColor | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
if item.attributes.get("task"):
|
if item.attributes.get("task"):
|
||||||
marker = "☑ " if item.attributes.get("checked") else "☐ "
|
marker = "☑ " if item.attributes.get("checked") else "☐ "
|
||||||
@@ -154,26 +169,22 @@ class DocxExporter:
|
|||||||
first = True
|
first = True
|
||||||
for child in item.children:
|
for child in item.children:
|
||||||
if child.type == "list":
|
if child.type == "list":
|
||||||
self._block_list(child, warnings, level + 1)
|
self._block_list(child, warnings, level + 1, color)
|
||||||
continue
|
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 = self._doc.add_paragraph()
|
||||||
p.paragraph_format.left_indent = indent
|
p.paragraph_format.left_indent = indent
|
||||||
if first:
|
if first:
|
||||||
self._add_run(p, marker)
|
self._add_run(p, marker)
|
||||||
first = False
|
first = False
|
||||||
|
if child.children:
|
||||||
|
# 段落或行内容器(strong/link 等):渲染其行内子节点
|
||||||
self._render_inline(p, child.children, warnings)
|
self._render_inline(p, child.children, warnings)
|
||||||
else:
|
else:
|
||||||
self._render_block(child, warnings)
|
# 直接行内叶子节点(text 等):拼进段落,不能交给块级渲染器(会丢弃正文)
|
||||||
first = False
|
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:
|
def _block_table(self, node: DocumentNode, warnings: list[str]) -> None:
|
||||||
rows = node.children
|
rows = node.children
|
||||||
|
|||||||
@@ -46,6 +46,8 @@ _PAGE_SIZES = {"a4": A4, "letter": letter}
|
|||||||
|
|
||||||
# 标题字号随层级递减;标题不依赖粗体(CID 无粗体字重),靠字号拉开层级
|
# 标题字号随层级递减;标题不依赖粗体(CID 无粗体字重),靠字号拉开层级
|
||||||
_HEADING_SIZES = {1: 20, 2: 16, 3: 14, 4: 12, 5: 11, 6: 10.5}
|
_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]:
|
def _make_styles() -> dict[str, ParagraphStyle]:
|
||||||
@@ -171,12 +173,29 @@ class PdfExporter:
|
|||||||
story.append(Paragraph(self._render_inline(node.children, warnings), self._styles["body"]))
|
story.append(Paragraph(self._render_inline(node.children, warnings), self._styles["body"]))
|
||||||
|
|
||||||
def _block_blockquote(self, node: DocumentNode, story: list, warnings: list[str]) -> None:
|
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"))
|
ordered = bool(node.attributes.get("ordered"))
|
||||||
for index, item in enumerate(node.children, start=1):
|
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(
|
def _block_list_item(
|
||||||
self,
|
self,
|
||||||
@@ -186,23 +205,28 @@ class PdfExporter:
|
|||||||
ordered: bool,
|
ordered: bool,
|
||||||
index: int,
|
index: int,
|
||||||
indent: int,
|
indent: int,
|
||||||
|
color: str | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
if item.attributes.get("task"):
|
if item.attributes.get("task"):
|
||||||
marker = "☑ " if item.attributes.get("checked") else "☐ "
|
marker = "☑ " if item.attributes.get("checked") else "☐ "
|
||||||
else:
|
else:
|
||||||
marker = f"{index}. " if ordered else "• "
|
marker = f"{index}. " if ordered else "• "
|
||||||
style = ParagraphStyle(
|
style_kwargs: dict = dict(
|
||||||
f"pdf-li-{indent}",
|
|
||||||
parent=self._styles["body"],
|
parent=self._styles["body"],
|
||||||
leftIndent=indent,
|
leftIndent=indent,
|
||||||
firstLineIndent=-7,
|
firstLineIndent=-7,
|
||||||
spaceAfter=2,
|
spaceAfter=2,
|
||||||
)
|
)
|
||||||
# 列表项内容通常是单个段落或直接行内节点,嵌套列表单独递归加深缩进
|
if color:
|
||||||
|
style_kwargs["textColor"] = color
|
||||||
|
style = ParagraphStyle(f"pdf-li-{indent}-{color or 'normal'}", **style_kwargs)
|
||||||
|
# 先收集父级正文、后处理嵌套列表:保证「父级文字在前、子列表在后」的阅读顺序,
|
||||||
|
# 而不是在循环里遇到嵌套列表就立刻递归输出(那会把子列表排到父级前面)。
|
||||||
parts: list[str] = []
|
parts: list[str] = []
|
||||||
|
nested: list[DocumentNode] = []
|
||||||
for child in item.children:
|
for child in item.children:
|
||||||
if child.type == "list":
|
if child.type == "list":
|
||||||
self._block_list(child, story, warnings, indent + 14)
|
nested.append(child)
|
||||||
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:
|
elif child.children:
|
||||||
@@ -210,6 +234,8 @@ class PdfExporter:
|
|||||||
else:
|
else:
|
||||||
parts.append(_html.escape(child.text))
|
parts.append(_html.escape(child.text))
|
||||||
story.append(Paragraph(marker + "<br/>".join(parts), style))
|
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:
|
def _block_table(self, node: DocumentNode, story: list, warnings: list[str]) -> None:
|
||||||
rows = node.children
|
rows = node.children
|
||||||
|
|||||||
@@ -212,6 +212,32 @@ async def create_export(request: ExportRequest) -> ExportJob:
|
|||||||
return job
|
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(
|
async def _execute(
|
||||||
job_id: str,
|
job_id: str,
|
||||||
format: ExportFormat,
|
format: ExportFormat,
|
||||||
@@ -220,8 +246,17 @@ async def _execute(
|
|||||||
metadata: dict | None,
|
metadata: dict | None,
|
||||||
options: ExportOptions,
|
options: ExportOptions,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""后台渲染:解析 → 导出 → 写文件 → 挂载产物元信息。"""
|
"""后台渲染:排队 → 解析 → 导出 → 写文件 → 挂载产物元信息。"""
|
||||||
cancel_event = _cancel_flags[job_id]
|
cancel_event = _cancel_flags[job_id]
|
||||||
|
acquired = False
|
||||||
|
try:
|
||||||
|
# 并发渲染限额:解析/渲染是 CPU 密集的同步工作,用信号量限制同时执行的任务数。
|
||||||
|
# 等待槽位期间保持 queued 并同时监听取消,取消即时生效,不必等前面的渲染完成。
|
||||||
|
if not await _acquire_render_slot(cancel_event):
|
||||||
|
raise ExportCancelled()
|
||||||
|
acquired = True
|
||||||
|
|
||||||
|
# 拿到槽位后才进入 running
|
||||||
_jobs[job_id] = _jobs[job_id].model_copy(
|
_jobs[job_id] = _jobs[job_id].model_copy(
|
||||||
update={
|
update={
|
||||||
"status": ExportStatus.running,
|
"status": ExportStatus.running,
|
||||||
@@ -229,10 +264,6 @@ async def _execute(
|
|||||||
"progress": ExportProgress(phase="rendering", current=0, total=1, percent=0.0),
|
"progress": ExportProgress(phase="rendering", current=0, total=1, percent=0.0),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
try:
|
|
||||||
# 并发渲染限额:解析/渲染是 CPU 密集的同步工作,用信号量限制同时执行的任务数,
|
|
||||||
# 超出限额的任务在此排队等待,避免大量任务同时占满工作线程与内存
|
|
||||||
async with _render_slots:
|
|
||||||
# 让出一次,使「创建后立即取消」的 queued 任务能及时进入 cancelled
|
# 让出一次,使「创建后立即取消」的 queued 任务能及时进入 cancelled
|
||||||
await asyncio.sleep(0)
|
await asyncio.sleep(0)
|
||||||
if cancel_event.is_set():
|
if cancel_event.is_set():
|
||||||
@@ -302,6 +333,8 @@ async def _execute(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
finally:
|
finally:
|
||||||
|
if acquired:
|
||||||
|
_render_slots.release()
|
||||||
_cancel_flags.pop(job_id, None)
|
_cancel_flags.pop(job_id, None)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,9 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import base64
|
||||||
|
import re
|
||||||
|
import zlib
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
@@ -60,10 +63,15 @@ $$
|
|||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
@pytest.fixture(autouse=True)
|
||||||
def _reset_export_state():
|
def _reset_export_state():
|
||||||
"""清空内存注册表,避免跨用例的任务/取消标志互相污染。"""
|
"""清空内存注册表,避免跨用例的任务/取消标志互相污染。
|
||||||
|
|
||||||
|
每个用例经 `asyncio.run()` 使用独立事件循环,模块级 Semaphore 会绑定到首个
|
||||||
|
循环,跨用例复用会触发「bound to a different event loop」;此处每例重建槽位。
|
||||||
|
"""
|
||||||
export_service._jobs.clear()
|
export_service._jobs.clear()
|
||||||
export_service._tasks.clear()
|
export_service._tasks.clear()
|
||||||
export_service._cancel_flags.clear()
|
export_service._cancel_flags.clear()
|
||||||
|
export_service._render_slots = asyncio.Semaphore(export_service.MAX_CONCURRENT_RENDERS)
|
||||||
yield
|
yield
|
||||||
export_service._jobs.clear()
|
export_service._jobs.clear()
|
||||||
export_service._tasks.clear()
|
export_service._tasks.clear()
|
||||||
@@ -347,6 +355,158 @@ def test_docx_exporter_contains_cjk_text() -> None:
|
|||||||
assert "进程调度".encode("utf-8") in xml
|
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:
|
def test_export_unknown_note_404() -> None:
|
||||||
request = ExportRequest(
|
request = ExportRequest(
|
||||||
source=ExportSource(type=ExportSourceType.note, note_id="note_missing"),
|
source=ExportSource(type=ExportSourceType.note, note_id="note_missing"),
|
||||||
|
|||||||
@@ -68,11 +68,11 @@
|
|||||||
| Benchmark | POST | `/api/benchmarks/agent/runs` | 暂缓 | 创建 Agent Benchmark(依赖 Agent Runtime 完成后交付) |
|
| Benchmark | POST | `/api/benchmarks/agent/runs` | 暂缓 | 创建 Agent Benchmark(依赖 Agent Runtime 完成后交付) |
|
||||||
| Benchmark | GET | `/api/benchmarks/runs` | 已实现 | 分页获取 Benchmark Run |
|
| Benchmark | GET | `/api/benchmarks/runs` | 已实现 | 分页获取 Benchmark Run |
|
||||||
| Benchmark | GET/POST | `/api/benchmarks/runs/{run_id}/*` | 计划新增 | 查询、订阅、取消和读取报告 |
|
| Benchmark | GET/POST | `/api/benchmarks/runs/{run_id}/*` | 计划新增 | 查询、订阅、取消和读取报告 |
|
||||||
| Export | POST | `/api/exports` | 已实现(HTML) | 创建导出任务;`pdf`/`docx` 暂缓,返回 `EXPORT_FORMAT_UNSUPPORTED` |
|
| Export | POST | `/api/exports` | 已实现(HTML/PDF/DOCX) | 创建导出任务;`html`/`pdf`/`docx` 三格式均已支持 |
|
||||||
| Export | GET | `/api/exports` | 已实现(HTML) | 分页获取导出任务 |
|
| Export | GET | `/api/exports` | 已实现 | 分页获取导出任务 |
|
||||||
| Export | GET | `/api/exports/{job_id}` | 已实现(HTML) | 查询导出任务 |
|
| Export | GET | `/api/exports/{job_id}` | 已实现 | 查询导出任务 |
|
||||||
| Export | GET | `/api/exports/{job_id}/file` | 已实现(HTML) | 下载已完成产物 |
|
| Export | GET | `/api/exports/{job_id}/file` | 已实现 | 下载已完成产物 |
|
||||||
| Export | POST | `/api/exports/{job_id}/cancel` | 已实现(HTML) | 取消导出任务 |
|
| Export | POST | `/api/exports/{job_id}/cancel` | 已实现 | 取消导出任务 |
|
||||||
| Theme | Host Contract | `ThemePackageService` | 计划新增 | 导入、预览、启停和卸载主题包 |
|
| Theme | Host Contract | `ThemePackageService` | 计划新增 | 导入、预览、启停和卸载主题包 |
|
||||||
| Renderer | 内部 Contract | `StaticRenderer` | 计划新增 | Mermaid/Function Plot 预览和导出复用 |
|
| Renderer | 内部 Contract | `StaticRenderer` | 计划新增 | Mermaid/Function Plot 预览和导出复用 |
|
||||||
|
|
||||||
@@ -1080,7 +1080,7 @@ VECTOR_INDEX_REBUILD_REQUIRED
|
|||||||
|
|
||||||
## 10. Export Service
|
## 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 创建导出任务
|
### 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
|
```text
|
||||||
EXPORT_SOURCE_NOT_FOUND
|
EXPORT_SOURCE_NOT_FOUND
|
||||||
EXPORT_FORMAT_UNSUPPORTED
|
|
||||||
EXPORT_OPTIONS_INVALID
|
EXPORT_OPTIONS_INVALID
|
||||||
EXPORT_RENDER_FAILED
|
EXPORT_RENDER_FAILED
|
||||||
EXPORT_UNSUPPORTED_CONTENT
|
EXPORT_UNSUPPORTED_CONTENT
|
||||||
|
|||||||
@@ -121,7 +121,7 @@ cd backend
|
|||||||
uv run pytest -q
|
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)
|
## 范围外(后续 PR)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user