1. 为什么我折腾了三个星期,就为了不碰 Word
1.1 契机:一本 300 页的技术手册,和一场排版噩梦
事情的开头其实很不体面——我接了个私活,要给一个开源社区项目写配套实操手册,内容大概 300 页,里面全是 Markdown 代码块、命令行输出、截图、表格,还夹杂一堆数学公式。当时我的第一个念头是:这活儿太简单了,我平时的笔记全躺在 Markdown 里,复制过去不就行了?
现实很快教做人。内容塞进 Word 之后,代码块的对齐方式全变了,等宽字体变成了默认的宋体,表格宽度直接冲出页面边界,页边距怎么调都不对劲,章节目录的页码永远对不上,图片位置像随机数生成器一样乱跳。最要命的是,我每一版都要改内容,Word 的“更新域”一刷新,整个目录和页码就是一场大型灾难现场。
那阵子我开始认真反思一个本质问题:写内容的人只想用 Markdown 写,但最终交付的是一本“看起来像书”的 PDF。能不能有一条流水线,让 Markdown 进去、书出来,中间的排版、分页、目录、页眉页脚全部自动完成?我花了两周试遍了市面上的现成工具,各有各的别扭,最后实在忍不住,自己动手搭了一条完整的 Markdown 排书流水线,名字就叫 Folio。
1.2 Folio 是什么:Markdown 到书籍的编译流水线
Folio 是一套以 Markdown 为源格式的书籍排版流水线,核心思路是把“写作”和“排版”彻底拆开。上游你只用 Markdown 写内容,加上少量我自定义的扩展标记;下游它自动完成章节分页、目录生成、页码样式、页眉页脚、代码块配色、数学公式渲染、交叉引用和图表编号,最终输出适合打印和电子阅读的 PDF,也能顺手导出 EPUB。
你可以把它理解成一条“内容进、书出”的传送带。它不是一个像 Typora 那样所见即所得的编辑器,而是一组脚本加主题模板,底层基于“Markdown 转 HTML、再把 HTML 用 Paged Media 技术渲染成 PDF”的成熟工作流。Folio 这个名字取自印刷行业的“书页/页码”术语,本身也暗示了这套工具最在意的东西:每一页长什么样,页码怎么走,章节从哪里起头。
1.3 这套方案适合哪些人
如果你满足下面任何一个条件,Folio 的思路就值得参考:
- 长期用 Markdown 做笔记,想把 Obsidian、Typora 里的资料沉淀成一本可打印、可分享的文档;
- 要输出技术文档、开源手册、课程讲义、实验报告,内容里充满了代码块和表格;
- 对排版有“强迫症”,想精细控制每个页面的字体、行距、页眉页脚和代码配色;
- 不想在 Word 里反复手动调整样式,希望每次改完内容之后,一键重新出书,样式永远稳定。
这篇文章我会把 Folio 的完整设计思路、实操流程和踩坑记录都摊开来讲,你可以直接照着搭,也可以只拿其中一部分思路去优化你自己的 Markdown 导出流程。
2. 方案选型:为什么不是 Typora、Pandoc、LaTeX 单独干完
2.1 常见的 Markdown 转 PDF 路线,各有各的死角
在决定自己动手之前,我把主流路线都过了一遍,简单说下每条的优缺点,你在选型的时候也能有个参照。
| 方案 | 优点 | 致命缺陷 |
|---|---|---|
| Typora / Markdown 编辑器直接导出 PDF | 操作简单,所见即所得 | 页面控制能力太弱,页眉页脚、双面打印、章节起始页全都不好弄 |
| Pandoc + LaTeX 模板 | 转换质量高,自动目录、交叉引用齐全 | 必须装 LaTeX 发行版,模板修起来痛苦,中文字体处理麻烦 |
| 直接写 LaTeX | 排版效果天花板 | 写作成本太高,Markdown 生态的优势全丢了 |
| Pandoc + Word 模板 | 迁移成本低 | 分页和图片位置依旧玄学,目录更新依然靠手动 |
| HTML + Headless Chrome 打印 | 样式灵活 | 分页控制不精确,经常会从奇怪的地方断页 |
| HTML + WeasyPrint / PrinceXML | 支持 CSS Paged Media,分页可控 | 需要自己搭中间层,配置工作量大 |
Folio 最终选的是最后一条路线,也就是“Markdown → HTML → CSS Paged Media → PDF”。这个选择不是拍脑袋定的,背后有三个关键考量。
2.2 为什么选 HTML 中间层,而不是直接用 LaTeX
第一,HTML 和 CSS 对大多数写 Markdown 的人更友好。Markdown 本身就是从 HTML 生态里长出来的,中间层用 HTML,意味着我可以直接用一套 CSS 搞定全部样式:字体、行距、页边距、页眉页脚、表格边框、代码块配色、图片缩放。这些东西在 LaTeX 里写起来相当绕,但用 CSS 写几乎是直觉性的。
第二,现代 CSS Paged Media(分页媒体)规范已经足够成熟,支持@page规则、page-break控制、string-set页眉内容、target-counter目录页码引用。这些特性组合起来,已经能实现书籍级排版的大部分需求。WeasyPrint 和 PrinceXML 对这套规范的支持都很好,前者开源免费,后者商业授权但是渲染引擎更强。
第三,生态复用价值高。HTML 体系里有一大堆现成轮子可用:KaTeX 做数学公式、Prism 做代码高亮、Flexbox 做复杂版式,这些东西在 LaTeX 里都有对应方案,但调试周期远没有 HTML 生态这么短。团队协作时,懂 CSS 的人比懂 LaTeX 的人好找得多。
2.3 Folio 的整体架构拆解
Folio 的架构可以分成四个阶段:
源文件阶段:一本书就是一个目录,里面是
chapters/文件夹下的 Markdown 文件,按01.md、02.md这样的命名顺序排列;assets/放图片和附件;book.yaml负责配置书名、作者、主题、输出格式等元信息。预处理阶段:Node.js 脚本读取 YAML 配置,按顺序拼接所有章节 Markdown,做脚注语法扩展、交叉引用语法解析、代码块标记补全,然后统一转成中间 HTML。这一步也负责把章节编号、图编号、表编号的计算做了。
主题渲染阶段:中间 HTML 套上选定的 CSS 主题模板,注入页眉页脚、封面页、版权页、目录页的 HTML 结构,生成一个“完整书稿”级的 HTML 文件。
最终输出阶段:用 WeasyPrint(或 PrinceXML)把完整 HTML 渲染成 PDF;同时用另一个脚本把同一份中间 HTML 打包成 EPUB,方便手机端阅读。
这条流水线最大的好处是:同一份 Markdown 源文件,可以同时输出印刷向 PDF 和电子向 EPUB,样式分别在主题层控制,互不干扰。
3. 核心能力与关键细节:Markdown 如何变成一本“真正的书”
3.1 Markdown 语法支持与扩展标记
Folio 对标准 Markdown 语法做了完整支持,包括 Typora 忠实用户常用的 GFM(GitHub Flavored Markdown)扩展,例如任务列表、删除线、表格、围栏代码块。但真正让它区别于“把 Markdown 渲染成网页再打印”的,是我自定义的几个扩展标记。
第一个是脚注语法扩展。标准 Markdown 编辑器大多只把脚注渲染成网页里的悬停提示,但书籍需要把脚注真正排到当页底部或者章节末尾。Folio 的预处理脚本会扫描[^note]语法,根据配置决定把它排成页脚注还是章尾注,并且自动生成脚注编号和回跳链接。
第二个是交叉引用扩展。我在 Markdown 源文件里用[@img:architecture]这样的语法引用图片,用[@sec:installation]引用章节,用[@tbl:parameters]引用表格。预处理脚本会把这些占位符替换成“图 3-2”“第 5.3 节”“表 2-1”之类的实际编号文字。过去我在 Word 里最头疼的就是这种编号,内容一改,所有交叉引用全部失效,Folio 里这些编号是每次编译时实时计算的,永远对得上。
第三个是“章节级分页标记”。普通 Markdown 没有“从新一页开始”这种概念,我扩展了 YAML front matter,每个章节文件顶部可以写start_page: true,预处理时就会在这一章前面插入强制分页,保证每章英文环境下用纸习惯上从奇数页开始。
3.2 数学公式与代码块的渲染细节
数学公式是技术类书籍逃不开的坎。Folio 的处理思路是:在预处理阶段直接用 KaTeX 的 Node 服务端渲染能力,把 Markdown 里的$...$和$$...$$公式全部转成 HTML 和 CSS,而不是依赖浏览器端的 JavaScript 动态渲染。这个选择很关键,因为 PDF 渲染引擎拿到的是一份纯 HTML,如果用 MathJax 做客户端渲染,WeasyPrint 根本等不到 JavaScript 执行完就会直接打印,公式就全丢了。
代码块方面,Folio 内置了多套配色主题,从浅色的 GitHub 风格到深色的 Dracula 风格都有。渲染时每一行代码都会生成带行号的结构,并使用等宽字体加合理的行距。特别做了“跨页代码块自动断行”的处理:代码块遇到分页时,会在合适的位置断开,并在下一页顶部重复显示文件名标签,这样读者看代码时不会被“断页”打断思路。
中文字体处理是我单独花了半天时间研究的点。WeasyPrint 对中文支持“开箱即可用”,但要想排版好看,必须在主题 CSS 里明确指定字体回退链:“Source Han Serif SC”, “Noto Serif CJK SC”, serif,标题用黑体,正文用宋体,英文字体用 Source Serif 系列搭配。如果不指定,中文默认字体渲染出来会显得松垮,行距也不对。
3.3 图片路径与资源管理
图片是 Markdown 转 PDF 时最容易翻车的环节。Typora 里一张本地图片直接拖进去,路径是相对的,但到了编译阶段,CWD(当前工作目录)一变,图片就全部 404。Folio 在这个问题上做了三层保护。
第一层是路径规范化。预处理脚本会读取book.yaml里的base_path,把所有图片标签里的相对路径转换成以这个基准路径为根的绝对路径,再嵌入到 HTML 里。这样不管你在项目根目录、在chapters/子目录还是通过脚本间接编译,图片都能正确找到。
第二层是资源嵌入开关。对于要分享给别人的 PDF,Folio 可以把所有图片转成 Base64 内嵌到 HTML 里,生成的 HTML 单文件可以直接预览,不依赖任何外部资源。代价是文件体积变大,但如果图片不多,这个模式特别方便。
第三层是图片尺寸自动适配。书籍版心宽度是固定的,很多截图宽度并不匹配。Folio 的主题 CSS 里用max-width和height: auto做了自适应,同时支持手动指定{width=80%}这样的属性语法,来控制单张图片的显示宽度。这个属性解析是在预处理阶段完成的,不是简单地把 HTML 属性塞进去。
3.4 目录、页眉页脚与页码系统的设计
书籍排版里最体现“专业感”的就是目录、页眉页脚和页码系统。Folio 用 CSS Paged Media 的方式来实现这部分。
目录页是一张独立的 HTML 页面,其中每个目录项用<a href="#chapter-3">链接到对应章节的锚点。为了让 PDF 显示页码而不是链接地址,Folio 在 CSS 里用了target-counter这个 Paged Media 属性:a::after { content: target-counter(attr(href), page); }。这句话的意思是,每个目录项的尾部显示“该锚点所在的物理页码”。
页眉的设计也沿用了类似思路。奇数页页眉显示书名,偶数页页眉显示当前章节名,章节名通过string-set属性从标题元素中提取。这个功能让我彻底告别了手动填页眉的噩梦——章节标题一旦变化,页眉自动跟着变。
页码系统支持两种模式:普通数字页码(1、2、3)和书籍常见的“前言罗马数字 + 正文阿拉伯数字”模式。后者需要在配置里指定frontmatter: roman,预处理脚本就会把封面、版权页、目录页划入前置部分,用罗马数字编号,正文再从 1 开始。初始版本花了不少时间调这个,但实现后效果非常接近正式出版物。
3.5 表格与复杂版式的处理技巧
表格在 Markdown 转 PDF 时显示效果差,非常常见,问题是普通 GFM 表格不支持列宽设定,渲染引擎只能按内容自适应宽度,结果就是长表格被挤到页面边缘。
Folio 的解决办法是:预处理阶段解析表格内容,统计每列的最大内容宽度,结合可用总宽度自动计算比例列宽,并把样式写进<col>标签。对于特别宽的表格,支持设置旋转页面模式,让表格所在的页面横向排版,阅读体验比硬生生压缩列宽好得多。
对于 Markdown 本身表达不了的复杂版式,我采取的是“两段式”思路:源文件里允许直接嵌入原始 HTML 块,比如双栏列表、提示框、侧边注释。预处理脚本会原样保留这些 HTML 块,只是统一包一层容器,方便 CSS 做样式控制。这样既保留了 Markdown 的简单性,又给紧急情况留了后门。
4. 实操:从零到一本成品的完整流程
4.1 环境准备与安装
Folio 依赖 Node.js 做预处理脚本运行环境,用 WeasyPrint 做 PDF 渲染,用 Pandoc 辅助处理 EPUB 元数据。安装步骤按操作系统略有不同,我这里以 macOS 环境为例,Linux 基本一致。
# 安装 Node.js(如果还没有) brew install node # 安装 WeasyPrint brew install weasyprint # 克隆 Folio 模板骨架 git clone https://example.com/folio-skeleton.git my-book cd my-book # 安装 Node 依赖 npm install安装完先跑一个自检命令npm run check,脚本会挨个验证 Node 版本、WeasyPrint 版本、中文字体是否齐全。这个自检特别值得做——我后来帮朋友排查问题,十个里有一半是环境问题,自检能省大量时间。
4.2 初始化一本新书
Folio 的目录结构设计得比较克制:
my-book/ ├── book.yaml # 书籍配置:书名、作者、主题、输出格式 ├── chapters/ │ ├── 00-preface.md │ ├── 01-intro.md │ ├── 02-setup.md │ └── ... ├── assets/ │ ├── images/ │ └── fonts/ ├── themes/ │ ├── default.css │ └── cover.html ├── scripts/ │ ├── preprocess.js │ └── build.js └── out/ # 编译输出目录book.yaml是核心配置文件,我通常这样写:
title: "Markdown 实战手册" author: "你的名字" edition: "1.0" language: zh-CN theme: default # 输出格式 outputs: - pdf - epub # 前置部分使用罗马数字页码 frontmatter: roman # 脚注样式:本页底部 footnote: page-bottom # 图片资源基准路径 base_path: "."这里base_path容易被忽略,它的作用是把所有 Markdown 里的图片路径统一改成从项目根目录解析。我的习惯是:所有图片一律放在assets/images/下,Markdown 里引用写成,这样 Typora 预览和 Folio 编译都能正常识别。
4.3 编写章节内容
章节文件本身没有特殊要求,用标准 Markdown 语法写即可。我在实际项目中会用到几个固定约定:
- 每个章节文件开头用
#作为章标题,一级标题只出现一次; - 章内小节用
##、###,预处理脚本会根据层级自动生成目录缩进; - 图片标题写在括号里,命名保持简单,不带空格;
- 代码块标注语言类型,方便高亮。
4.4 编译与迭代
编译指令是整个项目中唯一需要记住的命令:
npm run build脚本会依次执行:读取book.yaml→ 拼接章节 → 预处理扩展语法 → 生成完整 HTML → 调用 WeasyPrint 渲染 PDF → 调用 Pandoc 生成 EPUB。
第一次编译可能等十几秒,因为 WeasyPrint 要把所有图片读取、解码、采样,最终输出到 PDF。之后的增量编译会快很多。如果只想快速预览某几章,可以执行npm run preview -- --chapters 01,02,这只编译指定章节,省去等待整本书的时间。
我建议把out/book.pdf和最新版chapters/一起纳入 git 提交。这样当内容反复修改后,你可以随时确认“哪一版 PDF 对应哪一版源文件”,不至于出现纸质版和电子版内容对不上的尴尬。
4.5 自定义主题
每个主题本质上就是一个 CSS 文件加一个可选的封面模板。想调整字体、颜色、页边距,改 CSS 即可;想换封面,改themes/cover.html。CSS 变量的引入让主题定制变得特别轻松,核心变量集中在文件头部:
:root { --page-width: 170mm; --page-height: 240mm; --page-margin-top: 25mm; --page-margin-bottom: 22mm; --font-body: "Source Han Serif SC", "Noto Serif CJK SC", serif; --font-heading: "Source Han Sans SC", "Noto Sans CJK SC", sans-serif; --font-mono: "JetBrains Mono", "SF Mono", monospace; --font-size-body: 10.5pt; --line-height-body: 1.75; }换主题时,只需要在book.yaml里改theme: xxx,重新编译即可。内容一个字不用动,整套样式自由切换。
5. 我踩过的坑:实际问题与排查方法
5.1 图片路径和相对路径问题
这个问题是我最早遇到的,特征也最明显:Markdown 在 Typora 里预览一切正常,一编译,图片位置全是小叉号。排查思路是打开生成后的中间 HTML,看img标签的src属性实际指向哪里。
原因几乎都是base_path没设置或者 CWD 不对。后来我改成在预处理脚本里统一用path.resolve()处理所有路径,并且编译前打印一份资源文件清单,哪个文件找不到会直接报错。这个改进让我避免了“图挂了还傻傻看不出来”的低级浪费。
5.2 数学公式导出后乱码或缺失
有段时间我的公式在 PDF 里总是显示成原始的 LaTeX 源码,比如$$\int_0^\infty e^{-x^2}dx$$原样躺在纸上。排查后确认是 KaTeX 服务端渲染没有生效。
原因是我在 Markdown 里用了四个美元符号$$...$$,但在预处理脚本里只匹配了两个美元符号的情况。修复方式是先做公式块抽取,再做行内公式匹配,并且统一把$$转成\[ \]表示法再交给 KaTeX。这个顺序很重要:先处理块级公式,再处理行内公式,否则嵌套匹配会出问题。
5.3 换行与段落间距失控
Markdown 的换行规则和书籍排版的需求天然冲突。Markdown 里两个连续换行才代表新段落,单换行在绝大多数实现里不生效。但我拿到的很多原始文档里,作者用单换行来“假装”分段,编译出来的 PDF 段落挤在一起,很丑。
我在预处理脚本里做了个可配置项line-break-on-single-newline,默认关闭。对于确实需要保留单换行含义的文档——比如诗歌、代码说明——可以在章节 front matter 里单独打开这个选项,达到既不影响全书默认行为,又能处理特殊章节的效果。
5.4 表格复制错乱
这个坑发生在从 Excel 或者网页复制表格到 Markdown 时,粘贴出来的表格列数不一致,有的多一列,有的少一列。预处理脚本解析时会直接报错,说是“表格解析失败”。
排查发现多数原因是复制内容里带了隐藏的 HTML 标签或者多余分隔符。后来我在脚本里加了表格列数校验:解析时统计每一行的列分隔符数量,不一致就报错并指出具体行号,同时在编译前对表格内容做一次 HTML 实体转义。这不能解决所有问题,但至少能快速定位源文件的错误位置,而不是等 PDF 出来才发现表格歪了。
5.5 目录页码对不上
另一个让我头痛的问题是目录页码在新增章节后经常对不上,尤其是“前言用罗马数字、正文用阿拉伯数字”的情况下,页码计算特别容易出错。
排查发现,原始写法的问题是:I 设置frontmatter: roman后,前置部分的页码计数器和正文页码计数器互相独立,但 CSS 里target-counter默认取的是“文档全局物理页码”。WeasyPrint 对 Paged Media 规范实现有略差异,在“重新从 1 开始编号”这种场景必须显式设置counter-reset: page 1,否则提取的页码就是文档内的累计页数。
修复之后,我又加了一个校验步骤:生成 PDF 后,脚本会把目录项的文字和实际页码输出一份清单,人工扫一眼就能发现异常。
5.6 常见问题速查表
| 问题 | 可能原因 | 快速处理 |
|---|---|---|
| 图片显示为小叉号 | 相对路径错误或base_path未设置 | 检查中间 HTML 的img标签,修正配置 |
| 数学公式显示原始 LaTeX | KaTeX 渲染未生效 | 确认块级公式先于行内公式匹配 |
| 中文字体显示为方块 | 缺少中文字体 | 安装 Noto CJK 或 Source Han 系列字体 |
| 段间距过大 | Markdown 空行过多或行距设置过大 | 检查源文件的连续空行,调整--line-height-body |
| 表格超过页宽 | 列宽自适应失败 | 手动打开横向页面模式,或简化表格列数 |
| 目录页码对不上 | 页码计数器未正确重置 | 检查frontmatter配置和 CSS 的counter-reset |
| EPUB 里图片丢失 | EPUB 打包时资源路径错误 | 检查打包脚本里的资源收集规则 |
| WeasyPrint 报字体错误 | 权限或字体缓存问题 | 重建字体缓存,或改用 PrinceXML 引擎 |
6. 几轮实操之后,我最想提醒你的三件事
6.1 注意“内容与样式分离”的边界
Folio 整个设计都在追求“写的人不碰样式”,但实际用下来,我发现完全隔离也有问题。有些内容本身就带有结构含义,比如一张需要旋转 90 度才能放下的宽表,如果源文件里完全不管样式,读者看到的 PDF 体验会很差。
我后来在写作规范里定了一条规则:作者可以在 Markdown 源文件里使用少数“语义化标记”,比如{.full-width}、{.landscape},预处理脚本把这些标记转成对应的布局类名,样式细节仍由主题层决定,但内容作者可以表达“这个内容需要特殊布局”的意图。这样既保持了内容层对样式无感知,又给了必要时的弹性空间。
6.2 把编译流程嵌进日常写作
Folio 刚搭好时,我的流程是“写完再编译”,结果常常攒了几万字一次性出书,错误集体爆发。后来我改成写完一个章节就跑一次npm run preview -- --chapters 当前章节号,每个章节的排版问题当天解决。这样做的最大好处是错误边界小,问题定位快。
习惯之后,我甚至把它接进了文件监听模式:源 Markdown 一保存,脚本自动重新编译预览版,浏览器刷新就能看到最新效果。这个体验已经非常接近 Typora 的所见即所得了,只不过输出的是“书级”效果而非“网页级”效果。
6.3 这套流程的下一步
Folio 目前已经稳定用于我的手册项目。后续我考虑扩展两个方向:一是增加多语言文本混排优化,尤其是中英混排的标点压缩和行距微调;二是把主题做成更精细的“版式库”,支持书籍设计里常见的“栏式排版”和“边缘注释”。
如果你也和当年的我一样,被 Word 的排版折磨得头疼,不妨照着这套思路,用一周时间给自己搭一条 Markdown 排书流水线。内容用 Markdown 写,样式交给 CSS,编译交给脚本——写的人和排版的人从此各干各的,谁也不迁就谁。这是我这三周折腾下来最值的一个决定。