v1tz@blog~$cat ./posts/2026-07-17-html-to-pdf-wkhtmltopdf-vs-weasyprint.md

PDF选型WeasyPrintPlaywright

HTML 转 PDF 选型:wkhtmltopdf vs WeasyPrint

category=工具/kind=笔记published=2026-07-17updated=2026-08-05

wkhtmltopdf 的内核停在 2016 年并已归档,新项目别再用。静态内容选 WeasyPrint(CSS Paged Media 做页码页眉页脚),要跑 JS 选 Playwright;附中日文字体的坑和 SSRF 提醒。

一句话结论

两者解决的根本不是同一个问题:

  • wkhtmltopdf 是「网页截图器」——用浏览器内核把页面渲染出来,再切成 PDF。所见即所得,能跑 JS。
  • WeasyPrint 是「排版引擎」——从一开始就为印刷设计,理解页码、页眉页脚、分页规则。不跑 JS。

2026 年的现实

wkhtmltopdf 已经死了,不该用于新项目。见下方「项目现状」。 新项目在 WeasyPrint(静态内容)和 Playwright / Puppeteer(需要 JS)之间选。


它们各自是什么

wkhtmltopdf

一个 C++ 编译的独立二进制,内嵌 Qt WebKit 浏览器内核。工作方式跟浏览器的「打印成 PDF」几乎一样:加载 HTML → 执行 JS → 渲染成位图化的页面 → 输出 PDF。

因为是完整浏览器内核,页面长什么样,PDF 就长什么样。代价是它对「印刷」这件事没有概念——分页只是把长图切断。

WeasyPrint

一个 Python 库,不含浏览器内核。它自己实现了 W3C 的渲染与排版规范,重点是 CSS Paged Media(@page、counter(page)、break-before 这些印刷专用的 CSS)。

它把 HTML+CSS 当作排版描述而非网页,所以能做到「第 3 页页脚显示章节名」「表格跨页时重复表头」这类事。代价是它不是浏览器,不执行 JavaScript。


项目现状(2026-08 核实)

wkhtmltopdf WeasyPrint
状态 ⛔ 已归档,只读 ✅ 活跃维护
关键时间 仓库 2023-01-02 归档;GitHub 组织 2024-07-10 归档 v68 发布于 2026-01-19
最后版本 0.12.6(2020-06-10) v68 / v69 系
维护方 无 CourtBouillon

wkhtmltopdf 为什么会死:它依赖的 QtWebKit 早在 Qt 5.6(2016)就被 Qt 官方弃用,改推 QtWebEngine。此后每个 Qt 官方版本都不再附带 QtWebKit——wkhtmltopdf 的地基被抽走了,维护成本高到无法承担。

用 wkhtmltopdf 的实际风险

内核停留在 2016 年前的 WebKit,往后只会越来越糟:

  • 现代 CSS 大量不支持(Grid、Flexbox 的新特性、CSS 变量等)
  • TLS/加密套件过时 —— 抓取 https 资源可能直接失败
  • 安全漏洞不会再修 —— 如果它处理用户提交的 HTML,这是实打实的攻击面

核心对比

特性 wkhtmltopdf WeasyPrint
核心引擎 Qt WebKit 浏览器内核 自实现的 W3C 渲染/排版引擎
语言 / 生态 C++,独立二进制 Python 库,易嵌入 Python 工程
JavaScript ✅ 完整支持 ❌ 基本不支持
CSS Paged Media ❌ 支持很弱 ✅ 原生支持
页眉页脚 靠 --header-html 传独立 HTML 文件 CSS @page 里直接写
页码 需命令行参数配合 counter(page)
分页控制 弱 break-before / break-inside 等
部署 装二进制,容器里体积大 pip 安装,但依赖系统字体库

怎么选

需要执行 JavaScript 才能出内容?
(ECharts / Highcharts 前端渲染、Ajax 拉数据…)
│
├─ 是 ──→ Playwright 或 Puppeteer
│          (不要用 wkhtmltopdf —— 同样是浏览器内核,
│            但它的内核停在 2016 年)
│
└─ 否 ──→ 内容是静态 HTML
           │
           ├─ 需要印刷级排版?──→ WeasyPrint
           │   (发票、合同、报告、书籍:页码、页眉页脚、
           │     精确分页、双面打印边距)
           │
           └─ 只是简单转换 ──→ WeasyPrint 也够用

几乎不存在「必须选 wkhtmltopdf」的场景。它唯一的优势(跑 JS)已经被 Playwright/Puppeteer 完全覆盖,而且后者用的是现代 Chromium。

只有一种情况会留着它:遗留系统已经在用,迁移成本高于风险。这种情况下至少要确保它不处理不可信的 HTML 输入。


实操

WeasyPrint

pip install weasyprint

macOS 上还需要系统库(渲染文字要用到):

brew install pango libffi

命令行:

weasyprint input.html output.pdf

Python:

from weasyprint import HTML, CSS

HTML("invoice.html").write_pdf(
    "invoice.pdf",
    stylesheets=[CSS("print.css")],
)

# 字符串 / URL からも
HTML(string="<h1>見積書</h1>").write_pdf("out.pdf")
HTML(url="https://example.com").write_pdf("out.pdf")

印刷排版的核心是 CSS,不是代码:

@page {
    size: A4;
    margin: 20mm 15mm;

    @top-center   { content: "見積書"; }
    @bottom-right { content: counter(page) " / " counter(pages); }
}

/* 章ごとに改ページ */
h1 { break-before: page; }

/* 表が途中で切れないように */
tr { break-inside: avoid; }

/* 表が跨页したらヘッダを繰り返す */
thead { display: table-header-group; }

wkhtmltopdf(仅供维护遗留系统参考)

wkhtmltopdf --header-html header.html --footer-html footer.html \
            --margin-top 25mm page.html out.pdf

页眉页脚必须是独立的 HTML 文件,页码要靠它注入的 JS 变量([page] / [topage])取——这就是「维护成本高」的具体体现。


⚠️ 中日文字体(这个坑最常见)

两者都不自带字体,靠系统字体渲染。Docker 容器里默认没有 CJK 字体,结果就是中日文全变成豆腐块 □□□。

容器里要装字体:

RUN apt-get update && apt-get install -y \
    fonts-noto-cjk fonts-noto-color-emoji \
    && rm -rf /var/lib/apt/lists/*

CSS 里指定实际装了的字体,别只写 sans-serif:

body {
    font-family: "Noto Sans CJK JP", "Noto Sans CJK SC", sans-serif;
}

WeasyPrint 走 Pango/fontconfig,装好字体后一般就正常。日文排版要注意 font-family 的顺序——CJK JP 和 CJK SC 对同一个汉字的字形取法不同(例如「直」「骨」),混用会出现字形不一致。


现代替代方案

方案 适用 说明
Playwright 需要 JS 现代 Chromium,page.pdf() 直接出 PDF。多语言 SDK(Python/Node/Java/.NET)
Puppeteer 需要 JS,Node 生态 Chrome DevTools Protocol,Node 优先
WeasyPrint 静态内容 + 印刷排版 本文推荐

Playwright 生成 PDF:

from playwright.sync_api import sync_playwright

with sync_playwright() as p:
    b = p.chromium.launch()
    pg = b.new_page()
    pg.goto("https://example.com", wait_until="networkidle")
    pg.pdf(path="out.pdf", format="A4",
           margin={"top": "20mm", "bottom": "20mm"})
    b.close()

注意 wait_until="networkidle" —— 动态图表要等渲染完再截,否则出空白。这是 JS 路线的通病。


🔒 安全提醒

WeasyPrint v68 修复了一个 SSRF 漏洞(服务端请求伪造),与自定义 URL fetcher 和允许的协议限制有关。

如果你的系统满足以下任一条:

  • 使用了自定义 URL fetcher
  • 限制了允许的协议
  • 处理用户提交的 HTML

务必升级到 v68 以上。用户提交的 HTML 里如果带 <img src="http://内网地址/">,旧版可能被利用来探测内网。


参考

 
[0] v1tz@blog · ~try: 'help'···--:--:--