纯命令行Markdown转PDF:支持中英文混排与自动目录的CLI方案 我试过几十种 Markdown 转 PDF 的方案从早期用pandoc套 LaTeX 模板折腾三天调页眉到后来用浏览器 headless 渲染再截图再到最近半年稳定在终端里一条命令搞定——不是为了炫技而是因为每天要处理 12~17 份技术方案、客户反馈纪要、内部 SOP 文档它们全都是.md文件但收件人要么是法务要加批注要么是客户总监只认 PDF 打印稿要么是审计团队要求带页码目录页眉页脚的正式交付物。关键词里的Cli和Snippets不是装饰词是真实工作流里“按回车即交付”的刚需Tips也不是泛泛而谈是踩过 37 次字体崩塌、目录乱序、中文断行、页眉偏移、TOC 错位之后亲手写进 shell 函数里的条件判断和 fallback 逻辑。今天这篇不讲原理图、不列工具排行榜、不堆砌参数手册就带你复现一个我在生产环境跑了 14 个月、零人工干预、支持中英文混排、自动生成三级目录、保留代码块高亮、可嵌入公司 logo、导出文件大小稳定在 800KB±15% 的纯命令行流水线。它就一行命令但背后有 5 层校验、3 种降级策略、2 套字体兜底方案以及一份我贴在显示器边框上的速查便签——你照着抄明天就能用。1. 整体设计思路与底层逻辑拆解1.1 为什么不用 Pandoc LaTeX——不是不能而是不该很多人第一反应是pandoc input.md -o output.pdf --pdf-enginexelatex这确实能出漂亮 PDF但我在实际交付中把它淘汰了原因很实在编译不可控、环境不可迁、错误不可读。编译不可控xelatex 遇到中文路径、特殊符号、未声明字体时会卡在! Package fontspec Error: The font Noto Serif CJK SC cannot be found.这类报错上且不输出具体哪一行 markdown 触发了该错误。我曾为排查一个符号被误识别为 quote 环境而翻了 4 小时 log。环境不可迁LaTeX 模板依赖ctex、xeCJK、fancyhdr等宏包不同系统macOS Homebrew vs Ubuntu apt vs CentOS yum安装路径、版本、默认编码各不相同。上周给客户同步脚本对方在 CentOS 7 上跑pandoc报fontconfig缺失临时装fontconfig-devel又触发 glibc 版本冲突最后花了 90 分钟才让 PDF 出来——而客户等的是下午三点前的终版。错误不可读LaTeX 编译失败时终端刷屏几百行Underfull \hbox、Overfull \vbox真正有用的错误信息埋在第 217 行新手根本找不到。我带过的两个实习生第一次用 pandoc 遇到 TOC 页码错位直接放弃改用 Word 手动排版。所以我的设计起点很明确放弃“编译型”路径拥抱“渲染型”路径——把 Markdown 先转成语义清晰、结构完整的 HTML含nav,section,>md2pdf --toc --header Confidential • {page} --logo ./logo.svg --font Noto Serif CJK SC input.md它看起来像一个封装好的 CLI 工具但背后是 4 层 shell 函数 1 个内联 CSS 模板 2 个预编译 HTML 片段。没有 Python/Node.js 依赖纯 Bash 实现which md2pdf输出就是/usr/local/bin/md2pdf—— 一个 327 行的 shell 脚本。这保证了新同事curl -sL https://git.internal/md2pdf | sudo bash30 秒完成部署客户服务器scp md2pdf userhost:/usr/local/bin/立刻可用。2. 核心细节解析与实操要点2.1 Markdown → HTML 的精准控制不只是转换是结构重建很多工具如marked,commonmark把## 标题直接转成h2标题/h2这不够。wkhtmltopdf 的 TOC 依赖id和>pandoc $INPUT \ --standalone \ --to html5 \ --output $HTML_TMP \ --css $CSS_PATH \ --embed-resources \ --highlight-style pygments \ --variable toc-titleTable of Contents \ --variable langzh-CN \ --metadata title$TITLE \ --metadata author$AUTHOR逐条解释为什么这么配--standalone生成完整 HTML含htmlheadbody而非片段。wkhtmltopdf 需要完整 DOM 树来计算页眉页脚位置--to html5强制 HTML5 输出确保section,nav,article等语义标签被正确使用这对后续 CSS 选择器精准控制至关重要--css $CSS_PATH指定自定义 CSS不是为了美化而是为了重置默认样式并注入 TOC 所需的 data 属性。例如h1, h2, h3, h4, h5, h6 { page-break-after: avoid; } h1 { counter-reset: h2; } h2 { counter-reset: h3; } h2::before { content: counters(h1,.) . ; counter-increment: h2; } h3::before { content: counters(h1,.) . counters(h2,.) . ; counter-increment: h3; }这段 CSS 不仅生成编号还通过counter-increment为后续 TOC 提供层级计数依据--embed-resources把 CSS、字体、logo 全部 base64 编码嵌入 HTML避免 wkhtmltopdf 加载外部资源超时尤其在离线环境或防火墙严格的企业内网--highlight-style pygments启用 Pygments 语法高亮比 pandoc 默认的kate更稳定且支持 200 语言代码块不会因未知语言名而崩坏--variable langzh-CN显式声明语言触发浏览器/WebKit 对中文的正确换行line-break: strict、避头尾text-wrap: balance和字距调整font-kerning: normal--metadata注入文档元数据后续可在页眉页脚中引用{title},{author}。最关键的一步是在 pandoc 输出 HTML 后用 sed 注入>sed -E -i s/h1([^]*)/>body { font-family: Noto Serif CJK SC, Source Han Serif SC, Hiragino Mincho ProN, MS Mincho, serif; } code { font-family: JetBrains Mono, SFMono-Regular, monospace; }第二步把NotoSerifCJKSC-Regular.otf字体文件12.4MB放在脚本同目录用--font-dir参数告诉 wkhtmltopdfwkhtmltopdf \ --font-dir $(dirname $0)/fonts \ --replace font-family:.*?; font-family: Noto Serif CJK SC, serif; \ ...第三步最关键的用--no-stop-slow-scripts和--javascript-delay 200配合字体加载检测。因为 wkhtmltopdf 渲染时如果字体文件较大WebKit 可能未加载完就截屏导致中文显示为方框。我加了一个内联 JS 检测script function waitForFont() { const testEl document.createElement(span); testEl.style.fontFamily Noto Serif CJK SC; testEl.textContent 测; document.body.appendChild(testEl); const width testEl.offsetWidth; document.body.removeChild(testEl); return width 0; } if (!waitForFont()) { setTimeout(waitForFont, 100); } /script这段 JS 插入 HTMLhead确保字体加载完成后再开始渲染。注意Noto Serif CJK SC 是 Google 与 Adobe 联合开发的开源字体覆盖中日韩越全部常用字共 65,535 字符且许可证允许嵌入 PDF。我测试过 327 份含古籍引文、化学式、数学符号的文档无一例缺字。别用“思源宋体”它在 wkhtmltopdf 下偶发字距异常也别用“霞鹜文楷”它缺少部分繁体字。2.3 页眉页脚与公司 Logo 的工业级实现原始输入中htmldoc --book --footer .很简陋。现代需求是左页眉显示文档标题右页眉显示页码页脚居中显示保密等级和日期且公司 logo 必须清晰、无锯齿、大小适中。wkhtmltopdf 支持两种页眉页脚模式CSS 控制推荐和命令行参数控制简单但僵硬。我全部用 CSS因为CSS 可以用page规则精确控制左右页边距、页眉高度CSS 可以用content: string(title)动态插入文档标题CSS 可以用background-image: url(data:image/svgxml;base64,...)嵌入 SVG logo缩放不失真。我的页眉 CSS 如下page { size: A4; margin: 2.5cm 2cm; top-center { content: 《 string(title) 》; font-family: Noto Serif CJK SC; font-size: 10pt; color: #333; } top-right { content: Page counter(page) of counter(pages); font-family: Noto Serif CJK SC; font-size: 10pt; color: #666; } bottom-center { content: CONFIDENTIAL • Generated on string(date); font-family: Noto Serif CJK SC; font-size: 8pt; color: #999; } }Logo 不用img标签而是用background-image.header-logo { position: absolute; top: 0.5cm; left: 1.5cm; width: 3.2cm; height: 1.1cm; background-image: url(data:image/svgxml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxMDAiIGhlaWdodD0iMzUiPjxwYXRoIGQ9Ik0wIDBoMTAwdjM1SDB6IiBmaWxsPSIjZmZmIi8PHRleHQgeD0iNSIgeT0iMjUiIGZvbnQtZmFtaWx5PSJOb3RvIFNlcmlmIENKUyBDIiBmb250LXNpemU9IjE0IiBmaWxsPSIjMzMzIj5NeSBMb2dvPC90ZXh0Pjwvc3ZnPg); background-repeat: no-repeat; background-size: contain; }Base64 编码的 SVG 是我用 Inkscape 导出的确保路径精简、无冗余节点。这样无论 PDF 放大多少倍logo 都清晰锐利。提示页眉页脚的page规则必须写在style标签内且放在head中。如果写在外部 CSS 文件里wkhtmltopdf 会忽略。我吃过亏——把页眉 CSS 放在main.css里结果生成的 PDF 页眉空白debug 了 40 分钟才发现是加载顺序问题。3. 实操过程与核心环节实现3.1 完整命令链从输入到输出的 7 步原子操作下面是你真正要执行的md2pdf脚本核心逻辑已简化为可读形式实际脚本含错误处理和日志#!/bin/bash # md2pdf v2.3.1 —— 生产环境稳定版 INPUT$1 OUTPUT${2:-${INPUT%.md}.pdf} TMP_DIR$(mktemp -d) HTML_TMP$TMP_DIR/out.html CSS_PATH$TMP_DIR/style.css LOGO_PATH./logo.svg # Step 1: 生成基础 HTML带语义 pandoc $INPUT \ --standalone \ --to html5 \ --output $HTML_TMP \ --css $CSS_PATH \ --embed-resources \ --highlight-style pygments \ --variable toc-title目录 \ --variable langzh-CN \ --metadata title$(grep ^# $INPUT | head -1 | sed s/^# //) \ --metadata author$(git config user.name 2/dev/null || echo Anonymous) # Step 2: 注入>for f in *.md; do md2pdf --toc $f; done按命名规则分组转换如sop_*.md→sop_output/mkdir -p sop_output for f in sop_*.md; do md2pdf --toc --logo ./sop_logo.svg $f sop_output/${f%.md}.pdf; doneGitLab CI 中自动构建 PR 文档.gitlab-ci.yml片段build-docs: image: ubuntu:22.04 before_script: - apt-get update apt-get install -y pandoc wkhtmltopdf fonts-noto-cjk - curl -sL https://git.internal/md2pdf | bash script: - md2pdf --toc README.md - md2pdf --toc --logo docs/logo.svg docs/*.md artifacts: paths: - *.pdf expire_in: 1 weekCI 集成的关键是字体必须预装。fonts-noto-cjk包含 Noto Sans/Serif CJK 全系列大小约 180MB但比自己托管字体文件更可靠避免权限、路径、编码问题。我在 GitLab Runner 的 Docker 镜像中固化了这个安装步骤每次 CI 启动耗时增加 12 秒换来的是 100% 的构建成功率。提示不要在 CI 中用--font-dir指向相对路径。CI 环境路径不可控应直接依赖系统字体。本地开发用--font-dir调试CI 中删掉该参数改用系统字体。4. 常见问题与排查技巧实录4.1 中文乱码与方框字5 分钟定位法现象PDF 中中文显示为 □□□ 或空格或部分字显示正常、部分字缺失。排查路径按顺序执行通常第 2 步就定位检查 HTML 是否正常open $HTML_TMPmacOS或firefox $HTML_TMPLinux确认浏览器中中文显示正常。如果浏览器已乱码问题出在 pandoc 或输入文件编码。检查 wkhtmltopdf 字体加载日志临时去掉--quiet参数重新运行wkhtmltopdf --verbose $HTML_TMP test.pdf 21 | grep -i font\|noto正常输出应包含Loading page (1/2) ...Printing pages (2/2) ...Done如果出现Failed to load font Noto Serif CJK SC说明字体文件路径错误或文件损坏。验证字体文件完整性file ./fonts/NotoSerifCJKSC-Regular.otf # 应输出OTF/TrueType font data, version 0x00010000, 22 tables otfinfo -i ./fonts/NotoSerifCJKSC-Regular.otf | grep -i name\|version # 应显示字体名称和版本终极验证用系统字体替代临时wkhtmltopdf --font-dir /System/Library/Fonts $HTML_TMP test.pdf # macOS 系统字体路径 wkhtmltopdf --font-dir /usr/share/fonts/opentype/noto $HTML_TMP test.pdf # Ubuntu 系统字体路径我的避坑技巧在md2pdf脚本开头加一行set -euo pipefail确保任何命令失败立即退出并打印line 42: pandoc: command not found。这比看 200 行日志高效 10 倍。4.2 目录TOC页码错位3 类根因与修复现象TOC 中某章节页码显示为1但实际内容在第 5 页或 TOC 条目缺失。根因类型表现诊断命令修复方法HTML 标题无id属性TOC 条目为空白或显示Untitledgrep -o h[1-6][^]*id $HTML_TMP | wc -l应 0在 pandoc 命令中加--section-divs或用pandoc --standalone --to html5确保生成id>