我们需要将发票以 PDF 形式发送。以下是我们的解决方案。

发布: (2026年1月15日 GMT+8 23:57)
9 min read
原文: Dev.to

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 则旨在分页。

网页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 地狱的团队

开源只有在我们分享可行方案时才有意义。

Community

Back to Blog

相关文章

阅读更多 »

依赖过山车:驾驭 NPM 主题公园

“啊哈!”的瞬间,一切由此开始 我在实现一个新功能,感觉自己像个代码巫师 🧙‍♂️。我提交了 PR,随后我的 TL 留下了一条评论……

通关教程

请提供您希望翻译的文章摘录或摘要文本,我才能为您进行简体中文翻译。

Go的秘密生活:测试

第13章:真理表 周三的雨在档案室的窗户上敲出稳定的节奏,把曼哈顿的天际线模糊成灰色的斑块和条纹……