我们需要将发票以 PDF 形式发送。以下是我们的解决方案。
Source: Dev.to
Source: …
几个月前,我们的财务团队向我们提出了一个非常合理的请求。
他们每周手动在 Google Docs 中创建发票,导出为 PDF,然后通过电子邮件发送给客户。每个星期一大约要花三个小时,他们希望能够实现自动化。
我记得当时的想法是:
“这只是 PDF 生成而已。我之前做过更复杂的东西。”
这种自信只持续了大约五分钟。如果你从未制作过 PDF,那么本文适合你。如果你 已经 制作过 PDF,你已经预感到接下来会怎样——做好心理准备,这会有点疼。😅
当时,这个请求听起来毫无害处。自动化发票。生成 PDF。交付。
我们没有意识到,这个小任务会演变成数周的与浏览器、字体、表格、分页以及我们自己的过度自信的斗争。
尝试 #1:“我们就直接使用 PDF 库吧” 🤡
像大多数开发者一样,我一开始使用了一个可以用 JSON 定义文档的 PDF 库。
const docDefinition = {
content: [
{ text: "INVOICE", style: "header" },
{
table: {
body: [
["Pro Plan", "$99"],
["Extra Users", "$75"],
],
},
},
],
};
pdfMake.createPdf(docDefinition).download("invoice.pdf");
纸面上看起来很合理。实际操作时,却感觉像在电子表格里写 CSS。所有内容都深度嵌套,样式笨拙,而动态表格——发票中最重要的部分——管理起来异常痛苦。
几天后,显而易见这根本无法扩展。

问题立刻显现:
- Tailwind 设计系统毫无用处——每一种样式都必须重新编写
- 简单的布局更改会变成深度嵌套的配置对象
- 动态内容导致 JSON 逻辑难以阅读
- 调试非常痛苦:生成 → 下载 → 打开 → 眯眼查看 → 重复
而且它看起来像是 2009 年设计的。
尝试 #2:“如果我们直接打印 HTML?” 🖨️
接下来出现了显而易见的想法:我们已经有了 HTML,为什么不直接使用 window.print()?
<div class="invoice">
<h2>Invoice</h2>
<p>Pro Plan $99</p>
<button>Generate PDF</button>
</div>
一开始,它感觉像是魔法 ✨
我们可以复用已有的 HTML。我们的 CSS 正常工作。页面看起来 大体上 正确。
然后我们在各浏览器中进行测试:
| 浏览器 | 问题 |
|---|---|
| Chrome | 正常 |
| Safari | 边距异常 |
| Firefox | 表头消失 |
| Windows Chrome | 页脚消失 |
分页完全是一团乱。产品名称出现在一页,价格出现在下一页,甚至还有半个徽标莫名其妙地出现在某页底部。
对于内部文档,这可能还能接受。但对于面向客户的发票,根本不行。
尝试 #3:“好吧,Headless Chrome” 😐
为了解决浏览器不一致的问题,我们转而使用 Puppeteer —— 在服务器上运行的无头 Chrome。
import puppeteer from "puppeteer";
async function generateInvoice(html) {
const browser = await puppeteer.launch({
args: ["--no-sandbox", "--disable-setuid-sandbox"],
});
const page = await browser.newPage();
await page.setContent(html, { waitUntil: "networkidle0" });
await page.pdf({
format: "A4",
printBackground: true,
margin: { top: "20mm", bottom: "20mm" },
});
await browser.close();
}
一致性有所提升,但出现了新问题:
- Linux 服务器上缺少自定义字体
- 图片有时能加载,有时不能
- 微小的 CSS 变动会以出乎意料的方式破坏分页
- 对页面换行几乎没有控制
感觉就像在没有说明书的情况下组装宜家家具。所有部件在技术上可以拼在一起,但整体并不稳固。

真正的问题(没人解释过)
最终,这一模式变得清晰。
HTML 旨在实现滚动,PDF 则旨在分页。
| 网页 | |
|---|---|
| 无限滚动 | 固定页面大小 |
| 灵活布局 | 精确布局 |
| 没有页面规则 | 严格分页 |
浏览器 尝试 弥合这一差距,但大多失败。每个团队最终都以艰难的方式重新发现这一点。
最终奏效的东西 🎉
在数周的挫败感之后,我们发现了 pdfn,它彻底改变了我们生成 PDF 的方式。它让你可以使用 React 组件(支持循环、属性和条件语句)来构建发票、收据或合同,同时自动处理分页、页眉、页脚以及智能分页换行。
React Components
(Invoice, Receipt)
│
▼
@pdfn/react
- Pagination‑aware rendering
- Smart page breaks
- Headers & Footers
│
▼
@pdfn/serve
- Headless Chromium / Puppeteer
│
▼
Perfect PDF Output
import { Document, Page } from "@pdfn/react";
export default function Invoice() {
return (
<Document>
<Page>
{/* Invoice content goes here */}
</Page>
</Document>
);
}
使用 pdfn 我们终于实现了:
- 一致的样式(Tailwind 正常工作)
- 可靠的分页(没有孤立的行或被截断的徽标)
- 服务器端渲染(字体和图片始终存在)
- 易于维护(纯 React 代码,无神秘的 JSON)
现在财务团队只需点击一个按钮,就能在每周一获得格式完美的 PDF,我们也重新获得了数周的开发时间。 🎉
"Inter",
{ family: "Roboto Mono", weights: [400, 700] },
// Local fonts (embedded as base64)
{ family: "CustomFont", src: "./fonts/custom.woff2", weight: 400 },
]}
>
);
}
进入全屏模式
退出全屏模式
这种架构让维护和调试 PDF 模板变得轻松
为什么 pdfn 与众不同
- 你编写 React,而不是 JSON
- 你使用 Tailwind,而不是自定义样式系统
- 分页功能内置,而不是通过 CSS hack 实现
- 页眉页脚默认可用
- 智能分页(不会在行中或段落中间断开)
- 动态页码(Page 1 of 5 自动生效)
- 实时预览和热重载
- 调试覆盖层,可可视化边距、网格和分页
代码易于阅读,输出可预测。
pdfn 是开源的(MIT 许可证),因此不存在供应商锁定或后期意外收费。检查代码,自行托管,并根据需求进行改造。
我们现在的情况
今天,我们的整个 PDF 工作流已经完全自动化。发票、收据、协议和入职表单会自动生成。财务部门不再手动打开 Google 文档、修复间距、下载或通过电子邮件发送 PDF。
TL;DR(给疲惫的开发者)
- PDF 看似简单,实则难以驾驭
- HTML‑to‑PDF 听起来容易(其实并不容易)
- 浏览器打印会让你失望
- 关注页面的工具很重要
如果你正准备构建 PDF 生成功能,请吸取我的教训。
PDF 很难。并不是你工作不好。
为什么我们分享此内容
pdfn 相对较新,团队规模小但响应迅速。维护者回复了我们的 issue。该库仍在积极维护中。它采用 MIT 许可证,所以我们没有被锁定。
需要让更多开发者知道它的存在。
如果这对你有帮助:
- ⭐ 给仓库加星
- 📝 如果你尝试了,分享你的使用案例
- 🐛 报告问题以帮助改进
- 💬 将本文分享给陷入 PDF 地狱的团队
开源只有在我们分享可行方案时才有意义。
