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)
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user