06 · 导出
1. 统一管线
所有导出共用前半段,只在最后一步分叉:
缓冲区文本
│ remark(语义解析器,见 03)
▼
mdast
│ 转换阶段:内联脚注、解析相对图片路径、应用导出选项
▼
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」并确认风险)。
2.1 消毒是白名单,不是删除键
「消毒」听上去像是「把危险的东西删掉」,而删掉就是丢内容, 那正好撞上 §4 的「绝不静默丢内容」。这两条不矛盾,但要写清楚各自管什么:
| 输入 | 产物 | 理由 |
|---|---|---|
| 白名单内、不带属性的行内标签 | 渲染成真元素 | 跟编辑器一致,H<sub>2</sub>O 就该是 H₂O |
| 其余所有原始 HTML | 转义成可见文本 | 用户在编辑器里看得见它,产物里也该看得见 |
javascript: 链接、<iframe> | 删掉 | 它们没有可显示的字面形态 |
中间那一行是 issue #1 的教训。管线里原本根本没有处理 raw 节点的那一步: remarkRehype 把原始 HTML 变成 raw 节点,rehypeStringify 在 allowDangerousHtml: false 下直接丢弃它们。于是:
H<sub>2</sub>O导出成H2O—— 这不是掉了样式,是内容的意思变了;<div>block</div>导出成block,而用户在编辑器里看到的明明是那串字符。
一篇讨论 HTML 本身的技术文章,导出后所有代码示例凭空消失,且不留任何痕迹。
白名单只有一份(@mosu/markdown 的 inline-html.ts),编辑器和导出共用。 各写一份的话「写下的就是看到的」这条承诺就只是句口号 —— 它成立的前提是 两侧对「什么算可渲染」给出同一个答案。
2.2 转义器要认得自己在什么上下文里
escapeText()(& < > " → 实体)只对HTML 文本节点与属性值成立。 <style> 里两条都不成立,而它一度被用在那儿(issue #15):
- 拦不住该拦的:
}{;一个都不在名单里,42em}body{display:none直接闭合规则、注进任意 CSS; - 破坏本来正确的:
<style>是 raw text 元素,浏览器在里面不解码实体, 一个合法的content: "a & b"被转成&之后是字面量进 CSS 解析器。
修法不是「转义得更全」,而是按那个位置真正接受的语法去处理:
contentWidth是一个长度,用长度的文法校验它,认不出来就当没传;css[]是一整段样式表,唯一要处理的是它能不能提前闭合</style>—— 换成 CSS 自己的转义(<\/style),含义不变而标签识别被中断。
第二条不是假想威胁:主题 CSS 可以来自第三方插件包(见 05)。
2.3 片段跟文档不是同一件事
「复制为富文本」产出的是片段,它落在别人的文档里 —— 那边没有我们的 CSS, 而且多数目标(Gmail、飞书)会把 <style> 元素整个丢掉。所以片段这条路要 把关键排版摊成 style 属性,值用字面量而不是 var()。
KaTeX 那一条不是样式问题。 它一次输出三份并行表示(MathML、MathML 里的 TeX 注解、.katex-html 的视觉版本),靠 katex.css 把前两份藏起来才只看得见 一份。片段这条路没有那份 CSS,于是三份叠在一起:
公式:E=mc2E = mc^2E=mc2 完收敛成只留 MathML(并删掉里面的 TeX 注解):支持 MathML 的目标渲染成公式, 不支持的退化成一份字符文本。留 .katex-html 行不通 —— 它的上下标全靠 .vlist 那套绝对定位,摊成 style 属性也复现不出来。
2.4 内联图片:两个曾经把「自包含」变成空话的细节
传给宿主的路径必须是用户写下的那个。 mdast-util-to-hast 会对 image.url 跑 normalizeUri(),所以到内联那一步时 图片.png 已经是 %E5%9B%BE%E7%89%87.png 了。宿主拿着它去 path.resolve,找的是一个 字面量叫那个名字的文件,永远找不到(issue #13)。 不对称是可证的:编辑器实时预览传给同一个 buildAssetUrl 的是原始源码片段, 所以屏幕上图片好好的,只在导出时消失。
data: 和 file: 要在 protocols.src 里。 消毒器默认只放行 http/https,而消毒跑在内联之前 ——  的 src 在钩子看到它之前就被删了,只剩一个没有源的 <img alt>(issue #12)。 比留着原 URL 更糟:连丢了什么的线索都没有。而 data: 图片本来就是自包含的, 正是这个导出器努力想产出的形态。
只放开 src,不放开 href:<a href="data:text/html,…"> 是钓鱼载体, 而 <img src="data:"> 在现代浏览器里连脚本都跑不了。
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 集成(可选,⏸ 未实现)
下面整节是设计,一行都还没写。导出菜单里目前只有 HTML 与 PDF。
不打包 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」—— 这个判断成立, 它在 M4 就做了,而 DOCX 至今没做。 - ⏸ 图片导出:把选中区域或整篇渲染成 PNG(隐藏窗口截图),适合发社交媒体。
- ⏸ 导出为纯 Markdown 变体:例如把本地图片改成 base64、把脚注内联,用于发到 不支持附件的平台。
6. 导入
- ✅ 粘贴富文本自动转 Markdown(见 02 §7 与 03 §8)。当初写的是「这条路径 日常用得最多」,所以它先做了。已知的降级全部列在 03 §8.7。
- ⏸ 从 DOCX / HTML 导入文件 → Markdown(有 Pandoc 用 Pandoc,否则 HTML 走 rehype-remark)。转换器本身已经有了(
@mosu/import),缺的是「打开一个 .docx / .html 文件」这条入口。
7. 导出配置的持久化
现状:导出选项(纸张 / 方向 / 页边距)是全局的,存在 settings.json 的 export.pdf.* 下,不区分工作区(见 05 三 §4)。
⏸ 还没做的是下面这个更细的模型:按「格式 + 工作区」记住上次的选择,并支持 导出预设 —— 用户把常用组合存成命名预设(例如「投稿用 PDF」「博客用 HTML」), 一键复用,预设存在工作区 .mosu/ 下随仓库共享。
现在只有五个选项,够不上「预设」这个概念要解决的问题;等选项多到一次要调四五个 的时候再做。