news 2026/7/5 9:27:28

纯命令行Markdown转PDF:支持中英文混排与自动目录的CLI方案

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
纯命令行Markdown转PDF:支持中英文混排与自动目录的CLI方案

我试过几十种 Markdown 转 PDF 的方案,从早期用pandoc套 LaTeX 模板折腾三天调页眉,到后来用浏览器 headless 渲染再截图,再到最近半年稳定在终端里一条命令搞定——不是为了炫技,而是因为每天要处理 12~17 份技术方案、客户反馈纪要、内部 SOP 文档,它们全都是.md文件,但收件人要么是法务要加批注,要么是客户总监只认 PDF 打印稿,要么是审计团队要求带页码+目录+页眉页脚的正式交付物。关键词里的CliSnippets不是装饰词,是真实工作流里“按回车即交付”的刚需;Tips也不是泛泛而谈,是踩过 37 次字体崩塌、目录乱序、中文断行、页眉偏移、TOC 错位之后,亲手写进 shell 函数里的条件判断和 fallback 逻辑。今天这篇,不讲原理图、不列工具排行榜、不堆砌参数手册,就带你复现一个我在生产环境跑了 14 个月、零人工干预、支持中英文混排、自动生成三级目录、保留代码块高亮、可嵌入公司 logo、导出文件大小稳定在 800KB±15% 的纯命令行流水线。它就一行命令,但背后有 5 层校验、3 种降级策略、2 套字体兜底方案,以及一份我贴在显示器边框上的速查便签——你照着抄,明天就能用。

1. 整体设计思路与底层逻辑拆解

1.1 为什么不用 Pandoc + LaTeX?——不是不能,而是不该

很多人第一反应是pandoc input.md -o output.pdf --pdf-engine=xelatex,这确实能出漂亮 PDF,但我在实际交付中把它淘汰了,原因很实在:编译不可控、环境不可迁、错误不可读

  • 编译不可控:xelatex 遇到中文路径、特殊符号、未声明字体时,会卡在! Package fontspec Error: The font "Noto Serif CJK SC" cannot be found.这类报错上,且不输出具体哪一行 markdown 触发了该错误。我曾为排查一个>符号被误识别为 quote 环境而翻了 4 小时 log。
  • 环境不可迁:LaTeX 模板依赖ctexxeCJKfancyhdr等宏包,不同系统(macOS Homebrew vs Ubuntu apt vs CentOS yum)安装路径、版本、默认编码各不相同。上周给客户同步脚本,对方在 CentOS 7 上跑pandocfontconfig缺失,临时装fontconfig-devel又触发 glibc 版本冲突,最后花了 90 分钟才让 PDF 出来——而客户等的是下午三点前的终版。
  • 错误不可读:LaTeX 编译失败时,终端刷屏几百行Underfull \hboxOverfull \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 bash,30 秒完成部署;客户服务器scp md2pdf user@host:/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-title="Table of Contents" \ --variable lang=zh-CN \ --metadata title="$TITLE" \ --metadata author="$AUTHOR"

逐条解释为什么这么配:

  • --standalone:生成完整 HTML(含<html><head><body>),而非片段。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 lang=zh-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参数告诉 wkhtmltopdf:

wkhtmltopdf \ --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 插入 HTML<head>,确保字体加载完成后再开始渲染。

注意: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/svg+xml;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/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxMDAiIGhlaWdodD0iMzUiPjxwYXRoIGQ9Ik0wIDBoMTAwdjM1SDB6IiBmaWxsPSIjZmZmIi8+PHRleHQgeD0iNSIgeT0iMjUiIGZvbnQtZmFtaWx5PSJOb3RvIFNlcmlmIENKUyBDIiBmb250LXNpemU9IjE0IiBmaWxsPSIjMzMzIj5NeSBMb2dvPC90ZXh0Pjwvc3ZnPg=="); 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 lang=zh-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_*.mdsop_output/

    mkdir -p sop_output for f in sop_*.md; do md2pdf --toc --logo ./sop_logo.svg "$f" "sop_output/${f%.md}.pdf"; done
  • GitLab 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 week
  • CI 集成的关键是:字体必须预装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 步就定位)

    1. 检查 HTML 是否正常
      open "$HTML_TMP"(macOS)或firefox "$HTML_TMP"(Linux),确认浏览器中中文显示正常。如果浏览器已乱码,问题出在 pandoc 或输入文件编码。

    2. 检查 wkhtmltopdf 字体加载日志
      临时去掉--quiet参数,重新运行:

      wkhtmltopdf --verbose "$HTML_TMP" test.pdf 2>&1 | grep -i "font\|noto"

      正常输出应包含:
      Loading page (1/2) ...
      Printing pages (2/2) ...
      Done
      如果出现Failed to load font 'Noto Serif CJK SC',说明字体文件路径错误或文件损坏。

    3. 验证字体文件完整性

      file ./fonts/NotoSerifCJKSC-Regular.otf # 应输出:OTF/TrueType font data, version 0x00010000, 22 tables otfinfo -i ./fonts/NotoSerifCJKSC-Regular.otf | grep -i "name\|version" # 应显示字体名称和版本
    4. 终极验证:用系统字体替代(临时):

      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
    >
    版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
    网站建设 2026/7/5 9:26:37

    为什么选择 openEuler cloudphone_kernel?五大核心优势深度剖析

    为什么选择 openEuler cloudphone_kernel&#xff1f;五大核心优势深度剖析 【免费下载链接】cloudphone_kernel Kernel for Kbox cloudphone scenarios. 项目地址: https://gitcode.com/openeuler/cloudphone_kernel 前往项目官网免费下载&#xff1a;https://ar.opene…

    作者头像 李华
    网站建设 2026/7/5 9:25:03

    混合应用安全加固实战:IPA成品级混淆与资源扰动方案

    1. 项目概述&#xff1a;混合应用加固的“最后一公里”实战最近在负责一个金融类App的安全加固项目&#xff0c;这个App的架构很有意思&#xff0c;是典型的“H5 原生壳”混合模式。核心的交易、风控逻辑在原生层&#xff0c;而大量的营销活动页、用户协议、业务表单则通过Web…

    作者头像 李华
    网站建设 2026/7/5 9:25:05

    嵌入式系统三重降压转换器应用与优化

    1. 为什么需要三重降压转换&#xff1f;在嵌入式系统设计中&#xff0c;电源管理一直是个令人头疼的问题。我最近为一个工业控制器项目选型电源方案时&#xff0c;发现传统的单路或双路降压转换已经无法满足现代MCU的供电需求。以PIC24FJ64GB004为例&#xff0c;这颗微控制器需…

    作者头像 李华
    网站建设 2026/7/5 9:24:51

    JWT安全攻防实战:从签名算法绕过到密钥混淆的5大漏洞解析

    1. 项目概述&#xff1a;从靶场到实战的JWT安全攻防最近在复盘一些渗透测试项目时&#xff0c;发现JWT&#xff08;JSON Web Token&#xff09;相关的安全问题出现频率越来越高。很多开发团队在引入JWT作为认证方案后&#xff0c;往往只关注其带来的便利性&#xff0c;却忽视了…

    作者头像 李华
    网站建设 2026/7/5 9:21:29

    AI开发代理落地第一关:可信执行边界的构建与PR协作机制

    1. 项目概述&#xff1a;这不是AI编程&#xff0c;而是开发者工作流的“压力测试”“Software Development With Devin: Setup And First Pull Request (Part 1)”——这个标题乍看像一篇教程&#xff0c;但实际是一次对现代软件工程协作范式的真实压力测试。我用它作为切入点&…

    作者头像 李华
    网站建设 2026/7/5 9:21:19

    Qwen3.5-27B大模型FP8量化部署实战:显存减半+推理加速

    1. 项目背景与核心价值在当下大模型推理领域&#xff0c;如何平衡计算效率与推理质量一直是开发者面临的痛点。Qwen3.5-27B作为通义千问系列的重要版本&#xff0c;其27B参数量在精度和性能之间提供了较好的平衡点。但传统FP16推理对显存的高需求&#xff08;约54GB&#xff09…

    作者头像 李华

    关于博客

    这是一个专注于编程技术分享的极简博客,旨在为开发者提供高质量的技术文章和教程。

    订阅更新

    输入您的邮箱,获取最新文章更新。

    © 2025 极简编程博客. 保留所有权利.