From 780e24a399e5193d55f180466e8b7d9cddd25e66 Mon Sep 17 00:00:00 2001 From: yxx <2412119399@qq.com> Date: Sun, 6 Sep 2026 17:48:18 +0800 Subject: [PATCH] =?UTF-8?q?fix(export):=20=E4=BF=AE=E5=A4=8D=E5=BC=95?= =?UTF-8?q?=E7=94=A8=E5=9D=97=E6=AD=A3=E6=96=87=E4=B8=A2=E5=A4=B1=E3=80=81?= =?UTF-8?q?=E5=B5=8C=E5=A5=97=E5=88=97=E8=A1=A8=E9=A1=BA=E5=BA=8F=E4=B8=8E?= =?UTF-8?q?=E6=8E=92=E9=98=9F=E5=8F=96=E6=B6=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 引用块直接子节点为块级节点,PDF/DOCX 改为逐个渲染并继承缩进/颜色, 不再交给行内渲染器导致正文丢失 - PDF 嵌套列表先输出父级正文再输出子列表,修复顺序颠倒 - 等待渲染槽位期间保持 queued 并监听取消,取消即时生效 - DOCX 列表项补处理直接 text 子节点,避免正文被块级渲染器丢弃 - 补充引用块/嵌套列表/排队取消的结构内容回归测试 - 接口契约同步 html/pdf/docx 三格式均已实现,移除 EXPORT_FORMAT_UNSUPPORTED Co-Authored-By: Claude Code --- backend/app/export/exporters/docx.py | 59 ++++---- backend/app/export/exporters/pdf.py | 40 +++++- backend/app/export/service.py | 131 ++++++++++------- backend/tests/test_export.py | 162 +++++++++++++++++++++- docs/contracts/第二阶段接口契约-开发版.md | 15 +- docs/development/Export开发说明.md | 2 +- 6 files changed, 319 insertions(+), 90 deletions(-) diff --git a/backend/app/export/exporters/docx.py b/backend/app/export/exporters/docx.py index eb6e4b1..2f461a1 100644 --- a/backend/app/export/exporters/docx.py +++ b/backend/app/export/exporters/docx.py @@ -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 diff --git a/backend/app/export/exporters/pdf.py b/backend/app/export/exporters/pdf.py index 9f1ce1b..32f2131 100644 --- a/backend/app/export/exporters/pdf.py +++ b/backend/app/export/exporters/pdf.py @@ -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 + "
".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 diff --git a/backend/app/export/service.py b/backend/app/export/service.py index ec3d9d8..10666eb 100644 --- a/backend/app/export/service.py +++ b/backend/app/export/service.py @@ -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) diff --git a/backend/tests/test_export.py b/backend/tests/test_export.py index f252dbb..fa18e45 100644 --- a/backend/tests/test_export.py +++ b/backend/tests/test_export.py @@ -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"), diff --git a/docs/contracts/第二阶段接口契约-开发版.md b/docs/contracts/第二阶段接口契约-开发版.md index 8dc80e4..fc6cd38 100644 --- a/docs/contracts/第二阶段接口契约-开发版.md +++ b/docs/contracts/第二阶段接口契约-开发版.md @@ -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 diff --git a/docs/development/Export开发说明.md b/docs/development/Export开发说明.md index ed3f627..e8711d8 100644 --- a/docs/development/Export开发说明.md +++ b/docs/development/Export开发说明.md @@ -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)