17 KiB
Export 开发说明
所属模块:Export Service(后端,负责人 yxx)。交付「多格式文档导出」:Markdown → HTML / PDF / DOCX 的完整生命周期与 function-plot 静态 SVG 渲染。契约对应 第二阶段接口契约 §10。
定位
Export Service 把笔记或未保存的 Markdown 文本渲染为可下载的 HTML 文件。采用与 Benchmark 一致的「创建即返回 queued、后台 asyncio.Task 执行」的内存模型,产物带 24h 过期时间,过期后不可下载。导出是轮询式(无 SSE 事件流),客户端通过 GET /api/exports/{job_id} 轮询状态,完成后走 GET /api/exports/{job_id}/file 下载。
桌面端的 core_request 保持浏览器 Fetch 语义:JSON 响应以 UTF-8 文本传递,HTML、PDF、DOCX 等下载响应以 Base64 穿过 Tauri IPC,并在前端还原为带正确 Content-Type 的 Response。Host 将单次响应限制为 64 MiB,防止异常产物耗尽 WebView 内存。
生产构建将 PDF 打印所需的 WOFF2 字体内联到 CSS,并在快照中去除同字体的 WOFF/TTF 回退源,使 Tauri 资源协议不参与字体读取。导出弹窗只保留本次打开期间创建或接回的活动任务;关闭后再次打开会清除已完成、失败和取消的历史显示,仍在后台运行的任务会继续显示直到结束。
模块布局
backend/app/export/
├── __init__.py 包说明
├── document.py Document AST 内部协议 + DocumentExporter Protocol + ExportResult
├── markdown.py mistune 'ast' renderer → Document AST
├── exporters/
│ ├── __init__.py
│ ├── _common.py 共享工具(URL 协议校验 + 函数图像预算 + 占位 warning 文案 + 元数据格式化)
│ ├── html.py HtmlExporter(Document AST → 完整 HTML5)
│ ├── pdf.py PdfExporter(Document AST → PDF,reportlab)
│ └── docx.py DocxExporter(Document AST → DOCX,python-docx)
└── service.py ExportService(注册表 + 后台渲染 + 取消 + 产物生命周期)
backend/app/plot/
├── parser.py 函数图像表达式解析(白名单 AST)
├── render.py FunctionPlot → 共享几何(compute_geometry)+ 静态 SVG
├── render_reportlab.py FunctionPlot → reportlab 矢量 Drawing(PDF 内嵌)
└── renderer.py StaticRenderer 内部契约(§10.4)
HTTP DTO(ExportStatus / ExportFormat / ExportSource / ExportOptions / ExportJob 等)放在 app/contracts.py,与 Benchmark DTO 同层;DocumentNode / ExportResult 属导出器内部协议,放在 export/document.py,不进入 HTTP 契约。
接口
| 方法 | 路径 | 用途 |
|---|---|---|
| POST | /api/exports |
创建导出任务(202) |
| GET | /api/exports?status=&format=&limit=&offset= |
分页获取任务 |
| GET | /api/exports/{job_id} |
查询任务状态 |
| GET | /api/exports/{job_id}/file |
下载已完成产物 |
| POST | /api/exports/{job_id}/cancel |
取消任务 |
source.type 支持 note(引用已建索引笔记)与 markdown(未保存预览,字段为 source.markdown,上限 200 000 字符)。format 支持 html / pdf / docx 三种,经 service._EXPORTERS 注册表按格式分发到对应导出器。
Markdown → Document AST
解析用 mistune 的内置 renderer="ast"(非自写 BaseRenderer),因为 mistune 的行内渲染按字符串拼接、无法承载结构化子节点;ast renderer 直接给出带 children/attrs/raw 的 token 树,_AstMapper 只做 token → DocumentNode 的搬运,不掺入任何 HTML。插件启用 table、math、url、task_lists。
fenced code 按语言分流:mermaid → mermaid 节点、function_plot/functionplot → function_plot 节点,其余 → code_block(attributes.language)。node_id 按遍历顺序 node_{seq:03d} 生成,仅渲染内部使用,无需跨请求稳定。
HtmlExporter
递归渲染 Document AST 为完整 HTML5 文档(<!doctype html> + <head> 内嵌基础 CSS + <body>),标题/正文/元信息文本一律 html.escape。function_plot 经 FunctionPlotStaticRenderer 解析为静态 SVG 内嵌(解析/渲染失败或超限时回退 <pre class="function-plot"> 占位并记 warning),mermaid 无法静态表达,渲染为占位 <pre class="mermaid"> 并记 warning,均不静默丢失;code_theme 仅作为代码容器 class,不引入 JS 高亮库。无法表示的节点统一 warnings.append(...) 跳过。
StaticRenderer 内部契约(§10.4)
函数图像与 Mermaid 的静态渲染统一收敛到 app/plot/renderer.py:
StaticRenderRequest(kind/source/source_hash/theme/width/height)是统一的渲染请求载体,source_hash供缓存/去重,theme供主题化渲染。StaticRendererProtocol 定义render(request) -> StaticRenderResult,导出器只面向协议,不直接调用render_svg。FunctionPlotStaticRenderer委托parse_source解析 +render_svg输出内嵌 SVG;parse与render_plot拆开,供导出器在渲染前先拿node_count做文档级累计复杂度预算。MermaidStaticRenderer后端无 Mermaid 渲染能力,返回空占位结果并记 warning,交由前端渲染。
PDF / DOCX 导出器(v1 文本优先)
PdfExporter(reportlab platypus)与 DocxExporter(python-docx)实现与 HtmlExporter 一致的同步 render(document, options) -> ExportResult + 异步 export。v1 为文本优先,覆盖标题/段落/行内强调与链接/列表/引用/表格/代码块/数学文本;mermaid 保留源码占位并记 warning。function_plot 在 PDF 中已内嵌为矢量图,在 DOCX 中仍保留源码占位并记 warning(DOCX 内嵌需栅格化,本轮范围外)。
- PDF 中文字体用 reportlab 内置
STSong-LightCID 字体,无外部字体依赖;CID 字体无独立 bold/italic 字重,行内强调退化为普通文本(内容不丢、样式简化),标题靠字号区分层级。 - PDF 的
function_plot经render_reportlab消费compute_geometry的共享几何,产出矢量Drawing(网格/坐标轴Line、曲线PolyLine、刻度/标签String,ylabel 用Group旋转),再按页面内容宽缩放追加到 story,与 HTML 的 SVG 视觉一致;解析/渲染失败或超预算时回退源码占位并记 warning,单图失败不阻断整篇。 - DOCX 通过 Normal 样式挂载
w:eastAsia=宋体保证中文显示,bold/italic 由 Word 原生渲染;链接写入可点击的w:hyperlinkrun。 - 扩展名/MIME:html→
.html/text/html,pdf→.pdf/application/pdf,docx→.docx/application/vnd.openxmlformats-officedocument.wordprocessingml.document;路由FileResponse按mime_type+file_name通用化,无需改路由。
运行生命周期
queued → running → completed | failed | cancelled。
- 创建时校验:
note源不存在 →EXPORT_SOURCE_NOT_FOUND(404);markdown源为空或超上限 →EXPORT_OPTIONS_INVALID。 - 内存注册表上限
MAX_JOBS=100,超限只淘汰终态任务;满容量且全为活动任务时返回EXPORT_CAPACITY_EXCEEDED(429)。 - 后台渲染在解析前后各让出一次执行权,使「创建后立即取消」的 queued 任务能及时进入 cancelled。
- 失败只向公开响应暴露项目错误码与安全消息,详细异常进入日志。
产物生命周期
产物写入 settings.exports_path(默认 backend/data/exports/,可通过 APP_EXPORTS_PATH 覆盖,已加入 .gitignore),文件名为 {job_id}{ext}(ext 由格式决定),下载 Content-Disposition 用 _safe_download_name 清洗标题得到。ExportFile 记录 sha256、size 与 expires_at(completed_at + 24h),过期返回 EXPORT_FILE_EXPIRED(410)。
资源上限
为防止超大输入或海量函数图像耗尽内存/线程,导出链路内置以下上限:
- 输入源(
note与markdown)统一限制MAX_MARKDOWN_CHARS = 200_000字符,超限返回EXPORT_OPTIONS_INVALID。 - 单个
function-plot图块最多 16 条表达式,超限整块回退占位并记结构化诊断FUNCTION_PLOT_TOO_MANY_EXPRESSIONS。 - 单篇文档最多 16 个函数图像,超出部分回退占位并记 warning。
- 单篇文档累计函数图像 AST 节点预算
_MAX_TOTAL_PLOT_NODES = 8000,超出部分回退占位并记 warning,防止多图块 × 多表达式 × 深表达式组合在采样求值时长时间占满 CPU。 - 并发渲染上限
MAX_CONCURRENT_RENDERS = 2,解析/渲染是 CPU 密集工作,超出限额的任务在内存中排队等待渲染槽位,避免大量任务同时占满工作线程与内存。 - 最终产物大小上限
MAX_EXPORT_BYTES = 20 MB,超限任务标记 failed 并返回EXPORT_OUTPUT_TOO_LARGE。
错误码
错误分两类:同步错误在创建/查询请求的 HTTP 响应里直接返回对应状态码;异步任务错误在创建时已返回 202,后续轮询 GET /api/exports/{job_id} 仍返回 200,错误通过任务状态与 error_code 字段暴露,不映射 HTTP 状态码。
同步错误:
EXPORT_SOURCE_NOT_FOUND 404
EXPORT_OPTIONS_INVALID 400
EXPORT_UNSUPPORTED_CONTENT 422(预留)
EXPORT_JOB_NOT_FOUND 404
EXPORT_FILE_EXPIRED 410
EXPORT_CAPACITY_EXCEEDED 429
异步任务错误(轮询返回 200,字段形如 {"status": "failed", "error_code": "..."}):
EXPORT_RENDER_FAILED
EXPORT_OUTPUT_TOO_LARGE
测试
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 渲染、共享几何 compute_geometry、render_reportlab 矢量 Drawing(Line/PolyLine/String/Group、CJK 字体、y 翻转、缩放)与 StaticRenderer 契约(函数图像渲染、Mermaid 占位)。
范围外(后续 PR)
- Mermaid 静态渲染(后端无渲染能力,HTML/PDF/DOCX 均保留源码占位)。
- DOCX 内嵌函数图像(需栅格化为 PNG,本轮范围外,仅 PDF 内嵌矢量图)。
- 函数图像交互预览与缩放(前端 JS Renderer 负责,后端仅提供静态 SVG)。
- 代码语法高亮(当前仅 CSS class 占位)。
PR #41:陡峭连续曲线与渐近线区分(2026-09-07)
每个相邻有限采样区间都会检查中点,不再要求端点分别位于 range 上下两侧,也不因找到一个可见中点就连接整个区间。共享几何层检查中点与弦的偏差:有可见点且误差不超过四分之一像素时保留子段,否则继续细分左右两侧。每个区间最多额外求值 256 次、深度最多 24 层;同一表达式全部区间共享 8192 次额外求值预算,避免全区间检查导致无界增长。达到限制或无法继续推进浮点坐标时,以显式断点隔开未验证子段。遇到非有限中点仍检查它的两侧,保留有效分支,但不跨过非有限点连接。整条曲线耗尽预算时返回 warning,提示缩小 domain 后重试。
采样三点全在同一不可见侧的子段直接舍弃。细分点与普通点一样检查映射后坐标是否有限,再统一裁剪。SVG 与 PDF 使用相同结果。这是有界数值采样,不是任意函数连续性的数学证明;高频或极窄特征仍受采样与精度限制。
回归覆盖陡峭正负直线、百万斜率、可见中点混合极点、极小纵轴范围、两端均在可见范围内的极点、极点恰好位于中点、常见连续函数及 log/sqrt 定义域边界;验证区间与整条曲线共享求值预算,耗尽后保留断点和 warning,SVG/PDF 曲线坐标不得包含 NaN/Infinity。
补充检测:36 组不同系数和极点位置的几何检查通过。一次本机测量中,百万斜率直线和普通倒数曲线约 3 ms,高频 sin(1000000000*x) 达到预算并返回 warning,约 45 ms;该数据用于验证有界退出,不作为性能承诺。
PR #41:主题与警告框导出(2026-09-07)
HTML 支持 light、dark、sepia、paper-moments、midnight-purple 五套固定导出配色,覆盖正文、代码、表格、链接、引用和函数图像坐标文字。代码块独立设置前景与背景;不加载任意主题 CSS,也不复刻编辑器装饰。未知主题回退 light 并返回 warning。
PDF、DOCX 保持浅色打印样式;选择其他主题时返回明确 warning,需要主题配色请导出 HTML。警告框保留类型、富文本标题、正文与嵌套块;HTML 使用 details 支持默认展开和折叠,PDF、DOCX 始终输出完整内容,以彩色标题区区分类型。
验证:test_export.py 覆盖五套配色、未知主题安全回退、所有内置警告框类型与别名、折叠状态、嵌套正文和打印回退提示。浏览器检查深色导出的代码、表格及警告框对比度。
列表内的警告框和其他已支持块级节点使用块级渲染,PDF 保留列表缩进及可用宽度,DOCX 累加段落和表格缩进。回归测试检查有序、无序、任务列表中的警告框标题、正文、多层嵌套及后续段落,直接验证 PDF 文本和 DOCX 段落的内容顺序。
警告框识别与工作区一致:标记与标题之间可不留空格,类型允许数字、下划线和连字符;自定义类型回退 note 配色并保留自定义标题,省略标题时使用类型名称首字母大写。
警告框、普通引用、列表及交叉嵌套中的 Markdown 表格均启用容器内部解析,HTML 输出 table、PDF 输出 Table、DOCX 输出原生表格。测试逐一检查单元格内容和产物结构。每个 HTML 警告框独立初始化颜色变量,避免 NOTE 等类型继承外层 WARNING 的颜色;已在五套内置导出主题中检查嵌套配色及表格显示。
PDF 主题与资源策略更新(2026-09-07)
当前 PDF 行为以此节为准,覆盖上文早期 PR 的浅色打印和资源预算说明。
PDF 使用导出按钮点击时的主题配色快照,支持六种内置/社区主题与自定义主题的七项颜色。页背景、正文、引用、代码块、表格、链接、语义提示块、Mermaid、公式与函数图均参与主题适配;公式使用透明底再合成主题表面色,深色曲线使用较亮的默认色。标题随下一块分页,代码保留背景与边框。PDF 不执行 CSS 装饰或主题脚本。
PDF 取消导出源/产物大小限制、Mermaid 数量和像素预算、请求资源数量和字节预算、Vault 图片大小/累计预算、MathText 长度/深度预算、函数图数量/表达式数量及 AST 预算。HTML/DOCX、在线预览仍使用原有限制;有效图片、Vault 路径、表达式语法校验和数值求解退出条件仍生效。无限制指移除应用导出配额,实际文件规模仍受浏览器、解析器和可用内存约束。
关闭窗口仅结束 UI 生命周期,导出流程继续;主动取消仍取消提交中的后台任务。回归测试见 ExportDialog.spec.ts、exportService.spec.ts、test_pdf_theme_resources.py。
PDF 实际主题样式修正(2026-09-07)
七色调色板加 ReportLab 固定排版不能复现笔记主题。应用界面的 PDF 导出现在提交 print_html:复用 Markdown 渲染器、KaTeX、Shiki、编辑器 DOM 容器、主题 CSS、CSS 变量、标题偏好和字体资源,由 Chromium 打印为可选择文本的 PDF。保留背景纹理、伪元素、边框、阴影和语义样式;打印规则只处理页面尺寸、滚动容器、分页、图表缩放和交互按钮。提示块采用编辑器的 blockquote 结构以匹配主题选择器。KaTeX 内部 SVG 不应用图表缩放规则。
POST /api/exports/preview-resources 根据同一 Markdown 快照准备 Vault 图片和函数图 SVG,不恢复 PDF 的旧资源配额。图片保留透明通道;不读取 Vault 外文件。客户端将字体和主题图片内嵌为 data URI。浏览器进程关闭文档 JavaScript、网络与文件资源加载,等待字体就绪后打印;不会为静态 CSS 加载用户脚本。
后端依赖 Playwright(已写入 pyproject/uv.lock)。Windows 优先使用本机 Edge/Chrome,可用 APP_PDF_BROWSER 指定可执行文件;没有系统浏览器时,在 backend 目录运行 uv run playwright install chromium。打印在独立子进程中执行,避免 Windows Uvicorn 事件循环冲突。
不传 print_html 的旧 API 客户端保留 ReportLab 兼容路径,其固定排版不能代表应用主题的实际样式。HTML/DOCX 导出行为不变。主题字体/图片需可从应用资源读取并内嵌,读取失败会报错,避免输出缺失资源却宣称样式一致。