Skip to content

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 节点,rehypeStringifyallowDangerousHtml: false 下直接丢弃它们。于是:

  • H<sub>2</sub>O 导出成 H2O —— 这不是掉了样式,是内容的意思变了
  • <div>block</div> 导出成 block,而用户在编辑器里看到的明明是那串字符。

一篇讨论 HTML 本身的技术文章,导出后所有代码示例凭空消失,且不留任何痕迹。

白名单只有一份@mosu/markdowninline-html.ts),编辑器和导出共用。 各写一份的话「写下的就是看到的」这条承诺就只是句口号 —— 它成立的前提是 两侧对「什么算可渲染」给出同一个答案。

2.2 转义器要认得自己在什么上下文里

escapeText()& < > " → 实体)只对HTML 文本节点与属性值成立。 <style> 里两条都不成立,而它一度被用在那儿(issue #15):

  • 拦不住该拦的} { ; 一个都不在名单里,42em}body{display:none 直接闭合规则、注进任意 CSS;
  • 破坏本来正确的<style> 是 raw text 元素,浏览器在里面不解码实体, 一个合法的 content: "a & b" 被转成 &amp; 之后是字面量进 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.urlnormalizeUri(),所以到内联那一步时 图片.png 已经是 %E5%9B%BE%E7%89%87.png 了。宿主拿着它去 path.resolve,找的是一个 字面量叫那个名字的文件,永远找不到(issue #13)。 不对称是可证的:编辑器实时预览传给同一个 buildAssetUrl 的是原始源码片段, 所以屏幕上图片好好的,只在导出时消失。

data:file: 要在 protocols.src 里。 消毒器默认只放行 http/https,而消毒跑在内联之前 —— ![](data:image/png;base64,…)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 侧 —— printToPDFwebContents 的能力, 而渲染进程碰不到别的 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-uiHelveticaArial、以及泛型 sans-serif)都没有汉字,于是每一个汉字都回退到系统默认的 中文无衬线字体 —— PingFang SC。一篇中文文档因此整页空白。

CI 上量出来的对照(拉丁 / 中文):

字体拉丁中文
system-uiHelveticaArialsans-serif
'PingFang SC'
serifTimes-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 没有任何问题。

推翻掉的六个结论,一并记下来,因为它们各自都很像对的:

  1. 「字体没嵌进去」。第一版断言查 PDF 里有没有 FontFile,它在 Linux 上绿、 macOS 上红 —— 判断它「测的是平台不是产品」于是换掉了断言。 这个动作方向就是错的:那条断言当时正指着根因,被我当成噪声删了。
  2. 「KaTeX 那几 MB 拖垮了它」。改成按需内联 —— macOS 依旧空白
  3. 「测试自己把 PDF 流切错了」。当时按 endstream 字面量切流,而流里是压缩 后的二进制,完全可能恰好含这几个字节。改成按 /Length 切 —— 依旧空白
  4. 「viewport meta 把布局压成零宽」。听起来最像那么回事,改掉之后 —— 还是空白
  5. 「写死的 <html lang="zh-CN">。这一条差点算数:拿合成文档做二分时, 七个变体里只有带 lang 的两个画不出字。可产品里那个写死值去掉之后 —— 产品路径还是空白
  6. 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.jsonexport.pdf.* 下,不区分工作区(见 05 三 §4)。

⏸ 还没做的是下面这个更细的模型:按「格式 + 工作区」记住上次的选择,并支持 导出预设 —— 用户把常用组合存成命名预设(例如「投稿用 PDF」「博客用 HTML」), 一键复用,预设存在工作区 .mosu/ 下随仓库共享。

现在只有五个选项,够不上「预设」这个概念要解决的问题;等选项多到一次要调四五个 的时候再做。

MIT 许可发布。与 Typora 无关联,是一个独立实现。