06 · 导出
1. 统一管线
所有导出共用前半段,只在最后一步分叉:
缓冲区文本
│ remark(语义解析器,见 03)
▼
mdast
│ 转换阶段:解析 [TOC]、内联脚注、解析相对图片路径、应用导出选项
▼
mdast'
│ mdast → hast
▼
hast ──┬──▶ HTML(自包含单文件)
├──▶ PDF(Chromium 打印)
└──▶ 交给 Pandoc ──┬──▶ DOCX
├──▶ ePub
└──▶ LaTeX导出在 utility 进程里跑,不阻塞编辑;长任务显示进度并可取消。
2. HTML
两种模式:
| 模式 | 说明 |
|---|---|
| 自包含单文件(默认) | CSS 内联、图片转 data URI、KaTeX 字体内嵌、图表转内联 SVG。一个文件发出去就能看 |
| 带资源目录 | 输出 foo.html + foo.assets/,适合体积大的文档 |
选项:是否包含目录、是否包含 YAML front matter、代码高亮主题、 是否内联 <style> 还是外链、页面宽度。
安全:导出的 HTML 同样经过消毒,不能因为「导出」就把用户文档里的 <script> 带出去 (除非用户显式勾选「保留原始 HTML」并确认风险)。
3. PDF(✅ 缩范围实现)
3.1 做法:不自己造分页
PDF 当初被搁置的理由是分页:CSS Paged Media 在 Chromium 里支持不全, 页眉页脚、避免表格被切断、目录页码,每一项都要单独跟浏览器的分页算法搏斗。
那说的是自己造分页。§2 的 HTML 导出一落地,就不必造了 —— 把已经自包含的 HTML 交给 Chromium 打印(Electron webContents.printToPDF),分页归它管。 成本降下来的是范围,不是难度。
管线因此只有一段:markdown → 自包含 HTML → 隐藏窗口 → printToPDF → 字节。 渲染必须发生在 main 侧 —— printToPDF 是 webContents 的能力, 而渲染进程碰不到别的 webContents(也不该碰得到)。
几条不那么显然的决定:
- 隐藏窗口按「假定内容有敌意」来配。 装进去的是用户文档转成的 HTML, 虽然已经过消毒(§2),这里仍然
javascript: false—— 根本不给脚本执行的机会, 比依赖上游消毒更硬。外加 sandbox、contextIsolation、不挂任何 preload。 自包含产物里没有外链(连字体都是 data URI),关掉一切网络能力不影响观感。 - 走临时文件 +
loadFile,不用data:URL。 整篇文档内联了主题 CSS、 KaTeX 字体和图片,data:URL 很容易撞上长度上限, 而那个失败是「窗口白屏、没有任何报错」——最难排查的那一类。 - 一律用浅色主题。 深色主题打出来是一整页黑,既费墨也读不了。 这跟应用自己的
@media print是同一条规矩,不是建议而是默认行为。 真要深色 PDF 的用户可以先导出 HTML 再自己打印。 printBackground必须开。 代码块底色、引用块左边线、表格边框都在背景里, 关掉的话导出的 PDF 跟屏幕上完全不是一个东西。
3.2 拿不到的东西
不粉饰,列清楚(这些正是「缩范围」缩掉的部分):
- 页眉页脚(页码、标题、日期);
- 目录自动页码、精确的交叉引用页码;
@page :left/:right的奇偶页差异化;- 精确控制某个块不被切断 ——
break-inside: avoid在 Chromium 的打印路径上 对长表格与长代码块并不可靠,所以没有承诺它; - 正文字体不保证嵌入。内联的 Web 字体(KaTeX 那批 data URI)Chromium 会 嵌进 PDF,而正文用的是具名系统字体(见 §3.3 的
PRINT_FONT_CSS)—— 收件人机器上没有同名字体时会回退成别的,版面因此可能不同。要完全可控就得 把正文字体也内联成 Web 字体,那是另一件事(字体文件的体积与授权都要单独处理)。
3.3 曾经的缺陷:macOS 上产出空白页(已修)
完整留下来,因为排查过程比结论有价值 —— 这一个缺陷在七轮 CI 里让我连着 提出并推翻了六个错误的结论。
症状:产物是一份结构完整的 PDF(页数对、纸张对、页面底色那句 re f 也画了), 但内容流里一条绘制文字的指令(Tj / TJ)都没有。只在 macOS 上, Linux 与 Windows 正常。本地没有 macOS 机器,只能拿 CI 当验证机,一轮六到十分钟。
根因:PingFang SC 画不进 PDF。
macOS 上默认的中文无衬线字体是 PingFang SC,而 Chromium 的 PDF 后端画不出它 的任何字形 —— 连拉丁字母都画不出(把 font-family 直接写成 'PingFang SC', 一篇纯英文文档同样是空白)。
而字体匹配是逐字形的:正文字体栈里那些拉丁字体(system-ui、Helvetica、 Arial、以及泛型 sans-serif)都没有汉字,于是每一个汉字都回退到系统默认的 中文无衬线字体 —— PingFang SC。一篇中文文档因此整页空白。
CI 上量出来的对照(拉丁 / 中文):
| 字体 | 拉丁 | 中文 |
|---|---|---|
system-ui、Helvetica、Arial、sans-serif | ✅ | ❌ |
'PingFang SC' | ❌ | ❌ |
serif、Times、-apple-system | ✅ | ✅ |
'Songti SC'、'Hiragino Sans GB'、'STHeiti' | ✅ | ✅ |
Arial, 'PingFang SC' | ✅ | ❌ |
Arial, 'Songti SC' | ✅ | ✅ |
serif 系没事,是因为它的中文回退是 Songti SC 而不是 PingFang。
修法(PRINT_FONT_CSS):相对主题里那份字体栈,唯一的改动是把 'PingFang SC' 换成 'Hiragino Sans GB', 'Songti SC'。位置很要紧 —— 它必须排在泛型 (sans-serif / monospace)前面,泛型在 macOS 上给出的中文字体正是 PingFang,排在它后面等于没写。拉丁那一半原样保留,PDF 与屏幕的观感不分家。 只作用于 PDF:浏览器显示 PingFang 没有任何问题。
推翻掉的六个结论,一并记下来,因为它们各自都很像对的:
- 「字体没嵌进去」。第一版断言查 PDF 里有没有
FontFile,它在 Linux 上绿、 macOS 上红 —— 判断它「测的是平台不是产品」于是换掉了断言。 这个动作方向就是错的:那条断言当时正指着根因,被我当成噪声删了。 - 「KaTeX 那几 MB 拖垮了它」。改成按需内联 —— macOS 依旧空白。
- 「测试自己把 PDF 流切错了」。当时按
endstream字面量切流,而流里是压缩 后的二进制,完全可能恰好含这几个字节。改成按/Length切 —— 依旧空白。 - 「viewport meta 把布局压成零宽」。听起来最像那么回事,改掉之后 —— 还是空白。
- 「写死的
<html lang="zh-CN">」。这一条差点算数:拿合成文档做二分时, 七个变体里只有带lang的两个画不出字。可产品里那个写死值去掉之后 —— 产品路径还是空白。 - 「
system-ui在 macOS 上拿不到字形轮廓」。这是第五轮二分之后的结论, 方向已经对了(范围确实收在font-family上),但具体指错了人: 换成 Helvetica 之后依旧空白。system-ui的拉丁字形一直画得出来 —— 坏的从来不是它,是它身后那个中文回退。
第 2、3、4、5 条的改动全部保留了:它们各自都是对的(没有公式的文档产物从 几 MB 回到几十 KB;按 endstream 切确实是个真陷阱;PDF 确实没有「设备宽度」 这回事;lang 写死本来就是缺陷)。它们只是都不是根因。第 6 条的改动被改掉了 —— 它把拉丁字体也一起换了,而那部分本来没病。
最后奏效的是两轮「把变量铺开量」,而不是想原因。
第一轮把二分做在真产物上(前面那次「成功」的二分做在我自己拼的 HTML 上, 结论搬到产品路径根本不成立):一轮九个数据点,定位到 font-family。
但那一轮之后我又犯了同一个毛病 —— 拿一个数据点配了个故事(「system-ui 有毒」),换个字体就推。依旧空白。 第二轮才老实下来:把 (15 种字体 × 拉丁/中文)交叉铺开,一轮 30 个数据点,PingFang 当场现形, 连「为什么 serif 没事」都一并解释了。
教训:
- 本地只有一个平台,三个平台的 CI 才是真相;
- 平台差异面前,「想一个像样的原因然后去修」几乎没有产出。五个结论里有四个 是这么来的,全错;
- 在合成用例上复现出来的现象,不等于产品路径上的那个缺陷。 第 5 条就栽在 这里:二分本身没错,错在二分的对象;
- 一条「测的是平台不是产品」的失败断言,可能正指着产品缺陷。 第 1 条是这次 最贵的一步 —— 字体嵌入与文字绘制在 PDF 里本就是同一件事, 我把指向根因的那根手指当成噪声删掉,然后花了六轮把它绕回来;
- 「范围收窄到某个属性」不等于「知道是这个属性的哪个值」。 第 6 条栽在这里: 数据只说明「去掉
font-family就好」,我读成了「system-ui有毒」。 正确的下一步是把那个属性的取值空间铺开量,而不是换一个值试试。
4. Pandoc 集成(可选)
不打包 Pandoc。Pandoc 是 GPL,打进 MIT 应用的发行版会带来许可传染争议。 做法:
- 启动时探测
pandoc是否在 PATH(或用户在设置里指定路径); - 探测到才在导出菜单里显示 DOCX / ePub / LaTeX / RTF 等项;
- 未探测到时,菜单项显示为「需要安装 Pandoc」并给出安装指引链接;
- 调用方式:以子进程运行,stdin 传 Markdown(不是 HTML —— Pandoc 自己解析 Markdown 质量更高),参数里指定
--from=gfm+tex_math_dollars+footnotes等按当前启用的语法拼装; - 支持用户自定义 reference doc(
--reference-doc=my-template.docx)与 LaTeX 模板。
DOCX 兜底路径:没有 Pandoc 时提供一个基于 docx npm 库的基础导出 (标题、段落、列表、表格、图片、加粗斜体、代码块)。明确标注为「基础保真度」, 不承诺复杂排版。这样至少「导出 Word」不是完全不可用。
5. 其他导出
- 复制为 HTML / 富文本:写入剪贴板的
text/html,直接粘进 Word、邮件、飞书。 这是日常使用频率最高的「导出」,优先级要高于 DOCX。 - 图片导出:把选中区域或整篇渲染成 PNG(隐藏窗口截图),适合发社交媒体。
- 导出为纯 Markdown 变体:例如把本地图片改成 base64、把脚注内联,用于发到不支持 附件的平台。
6. 导入
- 从 DOCX / HTML 导入 → Markdown(有 Pandoc 用 Pandoc,否则 HTML 走 rehype-remark)。
- 粘贴富文本自动转 Markdown(见 02 §7),这条路径日常用得最多。
7. 导出配置的持久化
导出选项按「格式 + 工作区」记住上次的选择。支持导出预设: 用户把常用组合存成命名预设(例如「投稿用 PDF」「博客用 HTML」),一键复用。 预设存在工作区 .typo/ 下,可随仓库共享。