使用 Node.js 和 Puppeteer 从 HTML 生成 PDF
最初于 2019 年 2 月 5 日发布于blog.risingstack.com。
在本文中,我将展示如何使用 Node.js、Puppeteer、无头 Chrome 和 Docker 从样式丰富的 React 页面生成 PDF 文档。
背景:几个月前,RisingStack的一位客户要求我们开发一项功能,允许用户请求 PDF 格式的 React 页面。该页面本质上是一份面向患者的数据可视化报告/结果,包含大量 SVG 图像。此外,客户还提出了一些特殊要求,要求调整布局并对 HTML 元素进行一些重新排列。因此,PDF 页面的样式和新增内容应该与原始 React 页面有所不同。
由于这项任务比简单的 CSS 规则所能解决的要复杂一些,我们首先探索了各种可能的实现方案。最终,我们找到了 3 个主要解决方案。这篇博文将带您了解这些方案以及最终的实现方案。
在我们开始之前,先说一句个人评论:这相当麻烦,所以系好安全带吧!
目录:
- 客户端还是后端?
- 选项 1:从 DOM 截取屏幕截图
- 选项 2:仅使用 PDF 库
- 最终选项 3:Puppeteer,带有 Node.js 的无头 Chrome
- 将 Puppeteer 与 Docker 结合使用
- 选项 3 +1:CSS 打印规则
- 概括
客户端还是服务器端?
客户端和服务器端都可以生成 PDF 文件。但是,让后端处理可能更合理,因为你不想耗尽用户浏览器所能提供的所有资源。
即便如此,我仍然会针对这两种方法分别展示解决方案。
选项 1:从 DOM 截取屏幕截图
乍一看,这个解决方案似乎是最简单的,事实证明确实如此,但它有其自身的局限性。如果您没有特殊需求,例如 PDF 中可选择或可搜索的文本,那么这是一个简单易行的生成方法。
这个方法简单明了:从页面截取屏幕截图,然后将其放入 PDF 文件中。非常简单。我们使用了两个软件包来实现这个方法:
- Html2canvas,从 DOM 截取屏幕截图
- jsPdf,一个生成 PDF 的库
让我们开始编码。
npm install html2canvas jspdf
import html2canvas from 'html2canvas'
import jsPdf from 'jspdf'
function printPDF () {
const domElement = document.getElementById('your-id')
html2canvas(domElement, { onclone: (document) => {
document.getElementById('print-button').style.visibility = 'hidden'
}})
.then((canvas) => {
const img = canvas.toDataURL('image/png')
const pdf = new jsPdf()
pdf.addImage(imgData, 'JPEG', 0, 0, width, height)
pdf.save('your-filename.pdf')
})
就是这样!
请务必查看该html2canvas
onclone
方法。当你需要快速截取快照并在拍照前操作 DOM(例如隐藏打印按钮)时,它会非常方便。我发现这个包有很多用例。可惜我们这里没有,因为我们需要在后端处理 PDF 的创建。
选项 2:仅使用 PDF 库
NPM 上有几个库可以用于此目的,例如 jsPDF(如上所述)或PDFKit。它们的问题是,如果我想使用这些库,就必须重新创建页面结构。这无疑损害了可维护性,因为我需要将所有后续更改同时应用于 PDF 模板和 React 页面。
请看下面的代码。您需要手动创建 PDF 文档。现在,您可以遍历 DOM 并弄清楚如何将每个元素转换为 PDF 元素,但这是一项繁琐的工作。一定有更简单的方法。
doc = new PDFDocument
doc.pipe fs.createWriteStream('output.pdf')
doc.font('fonts/PalatinoBold.ttf')
.fontSize(25)
.text('Some text with an embedded font!', 100, 100)
doc.image('path/to/image.png', {
fit: [250, 300],
align: 'center',
valign: 'center'
});
doc.addPage()
.fontSize(25)
.text('Here is some vector graphics...', 100, 100)
doc.end()
此代码片段来自 PDFKit 文档。但是,如果您的目标是直接转换为 PDF 文件,而不是转换现有的(并且不断变化的)HTML 页面,那么它会很有用。
最终选项 3:Puppeteer,带有 Node.js 的 Headless Chrome
Puppeteer是什么?文档里说:
Puppeteer 是一个 Node 库,它提供了通过 DevTools 协议控制 Chrome 或 Chromium 的高级 API。Puppeteer 默认以无头模式运行,但可以配置为在完整(非无头模式)的 Chrome 或 Chromium 上运行。
它本质上是一个可以通过 Node.js 运行的浏览器。如果你阅读过 Puppeteer 的文档,就会发现它首先提到了它可以生成页面的截图和 PDF 文件。太棒了!这正是我们想要的。
让我们使用 安装 Puppeteernpmi i puppeteer
并实现我们的用例。
const puppeteer = require('puppeteer')
async function printPDF() {
const browser = await puppeteer.launch({ headless: true });
const page = await browser.newPage();
await page.goto('https://blog.risingstack.com', {waitUntil: 'networkidle0'});
const pdf = await page.pdf({ format: 'A4' });
await browser.close();
return pdf
})
这是一个简单的功能,可以导航到 URL 并生成站点的 PDF 文件。首先,我们启动浏览器(仅在无头模式下支持 PDF 生成),然后打开一个新页面,设置视口,并导航到提供的 URL。
设置该waitUntil: ‘networkidle0’
选项意味着,当网络连接至少 500 毫秒时,Puppeteer 会认为导航已完成。(更多信息,请参阅API 文档。)
之后,我们将 PDF 保存到变量中,关闭浏览器并返回 PDF。
注意:该page.pdf
方法接收一个options
对象,您也可以使用“path”选项将文件保存到磁盘。如果没有提供 path,PDF 将不会保存到磁盘,而是会保存到缓冲区。稍后我将讨论如何处理这种情况。
如果您需要先登录才能从受保护的页面生成 PDF,首先您需要导航到登录页面,检查表单元素中的 ID 或名称,填写它们,然后提交表单:
await page.type('#email', process.env.PDF_USER)
await page.type('#password', process.env.PDF_PASSWORD)
await page.click('#submit')
始终将登录凭据存储在环境变量中,不要对其进行硬编码!
风格操控
Puppeteer 也为这种样式操作提供了解决方案。您可以在生成 PDF 之前插入样式标签,Puppeteer 会生成一个包含修改后样式的文件。
await page.addStyleTag({ content: '.nav { display: none} .navbar { border: 0px} #print-button {display: none}' })
发送文件给客户端并保存
好的,现在您已经在后端生成了一个 PDF 文件。接下来该做什么呢?
正如我上面提到的,如果您不将文件保存到磁盘,您将获得一个缓冲区。您只需要将该缓冲区连同正确的内容类型一起发送到前端即可。
printPDF.then(pdf => {
res.set({ 'Content-Type': 'application/pdf', 'Content-Length': pdf.length })
res.send(pdf)
现在您只需向服务器发送请求即可获取生成的 PDF。
function getPDF() {
return axios.get(`${API_URL}/your-pdf-endpoint`, {
responseType: 'arraybuffer',
headers: {
'Accept': 'application/pdf'
}
})
发送请求后,缓冲区应该开始下载。现在最后一步是将缓冲区转换为 PDF 文件。
savePDF = () => {
this.openModal(‘Loading…’) // open modal
return getPDF() // API call
.then((response) => {
const blob = new Blob([response.data], {type: 'application/pdf'})
const link = document.createElement('a')
link.href = window.URL.createObjectURL(blob)
link.download = `your-file-name.pdf`
link.click()
this.closeModal() // close modal
})
.catch(err => /** error handling **/)
}
<button onClick={this.savePDF}>Save as PDF</button>
就是这样!点击“保存”按钮,浏览器就会保存 PDF 文件。
将 Puppeteer 与 Docker 结合使用
我认为这是实现过程中最棘手的部分 - 所以让我为您节省几个小时的谷歌搜索时间。
官方文档指出“在 Docker 中启动和运行无头 Chrome 可能会很棘手”。官方文档有一个故障排除部分,在撰写本文时,您可以在其中找到有关使用 Docker 安装 Puppeteer 的所有必要信息。
如果您在 Alpine 镜像上安装 Puppeteer,请确保向下滚动到页面的这一部分。否则,您可能会忽略无法运行最新版本的 Puppeteer 的事实,并且还需要使用标志禁用 shm 使用:
const browser = await puppeteer.launch({
headless: true,
args: ['--disable-dev-shm-usage']
});
否则,Puppeteer 子进程可能会在正常启动之前就耗尽内存。更多信息,请参阅上面的故障排除链接。
选项 3 + 1:CSS 打印规则
有人可能会认为从开发人员的角度来看,简单地使用 CSS 打印规则很容易。没有 NPM 模块,只有纯 CSS。但是,当涉及到跨浏览器兼容性时,它们的表现如何?
选择 CSS 打印规则时,必须在每个浏览器中测试结果,以确保它提供相同的布局,并且并不是 100% 做到了。
例如,在给定元素后插入一个换行符不能被视为深奥的用例,但您可能会惊讶地发现,您需要使用变通方法才能在 Firefox 中使其正常工作。
除非您是久经沙场的 CSS 魔术师,并且在创建可打印页面方面拥有丰富的经验,否则这可能会很耗时。
如果您可以使打印样式表保持简单,那么打印规则就很棒。
让我们看一个例子。
@media print {
.print-button {
display: none;
}
.content div {
break-after: always;
}
}
上面的 CSS 隐藏了打印按钮,并在每行后面插入一个分页符。div
有content.
一篇很棒的文章总结了打印规则的功能,以及它们在使用过程中遇到的困难,包括浏览器兼容性。
总而言之,如果你想将不太复杂的页面转换成 PDF,CSS 打印规则是一个非常棒且有效的选择。
摘要:使用 Node.js 和 Puppeteer 将 HTML 转换为 PDF
因此,让我们快速浏览一下这里介绍的从 HTML 页面生成 PDF 文件的选项:
- 从 DOM 截取屏幕截图:当您需要从页面创建快照(例如创建缩略图)时,此功能很有用,但当您需要处理大量数据时,此功能就显得不足了。
- 仅使用 PDF 库:如果您需要从头开始以编程方式创建 PDF 文件,这是一个完美的解决方案。否则,您需要维护 HTML 和 PDF 模板,这绝对是行不通的。
- Puppeteer:尽管在 Docker 上运行起来相对困难,但它为我们的用例提供了最佳结果,并且也是最容易编写代码的。
- CSS 打印规则:如果您的用户足够了解打印到文件,并且您的页面相对简单,那么这可能是最省事的解决方案。但正如您在我们案例中所见,事实并非如此。祝您打印愉快!