用Markdown自动化排版:打造从内容到PDF的书籍流水线 1. 为什么我折腾了三个星期就为了不碰 Word1.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 忠实用户常用的 GFMGitHub 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/子目录还是通过脚本间接编译图片都能正确找到。第二层是资源嵌入开关。对于要分享给别人的 PDFFolio 可以把所有图片转成 Base64 内嵌到 HTML 里生成的 HTML 单文件可以直接预览不依赖任何外部资源。代价是文件体积变大但如果图片不多这个模式特别方便。第三层是图片尺寸自动适配。书籍版心宽度是固定的很多截图宽度并不匹配。Folio 的主题 CSS 里用max-width和height: auto做了自适应同时支持手动指定![caption](path){width80%}这样的属性语法来控制单张图片的显示宽度。这个属性解析是在预处理阶段完成的不是简单地把 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 里引用写成![架构图](assets/images/arch.png)这样 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标签修正配置数学公式显示原始 LaTeXKaTeX 渲染未生效确认块级公式先于行内公式匹配中文字体显示为方块缺少中文字体安装 Noto CJK 或 Source Han 系列字体段间距过大Markdown 空行过多或行距设置过大检查源文件的连续空行调整--line-height-body表格超过页宽列宽自适应失败手动打开横向页面模式或简化表格列数目录页码对不上页码计数器未正确重置检查frontmatter配置和 CSS 的counter-resetEPUB 里图片丢失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编译交给脚本——写的人和排版的人从此各干各的谁也不迁就谁。这是我这三周折腾下来最值的一个决定。