前端程序员使用 puppeteer 生成 pdf

4,487 阅读6分钟

这是我参与8月更文挑战的第4天,活动详情查看:8月更文挑战

问题

  • 前端如何使用 puppeteer 生成 pdf ?

  • 生成 pdf 时可以设置封面,页眉及页脚,css,img 吗?

  • 如何让 pdf 背景图显示出来呢?

这片博文,带领你解决这些棘手的问题。

前言

学习本篇博文,需要你有基础的 nodeJs 知识哦~

还需要你对阿里的 node 框架 egg 有一些了解~

另外,博客的内容比较多,所以需要你有点耐心,中间不要略过任何的内容。

正文

一开始接到用 node 生成 pdf 的需求,我快速查阅了一些资料,然后决定采用 puppeteer,这个东西有什么好呢?

说白了就是简单,学习曲线平缓,难度不到,你可以先从官方文档入手,这是文档地址

英文不好的童鞋,这里有中文地址

我先放一个简单的demo,这是官方最简单的生成 pdf 的代码

// create.js
const puppeteer = require('puppeteer');

(async () => {
  const browser = await puppeteer.launch();
  const page = await browser.newPage();
  await page.goto('https://example.com');
  await page.pdf({path: 'example.pdf'});
  await browser.close();
})();

然后在 terminal 中运行

node create.js

看起来很简单吧,

然而需求总是繁复的,对接需求并一一实现的时候就需要一点毅力,一点汗水了,下面我就把问题列出来,省的大家以后继续踩坑 : )


项目结构:

|- index.html    // 模板文件
|- create.js     // 生成 pdf 的文件
|- public
    |-------- css
               |-------- style.css   //模版样式
    |-------- img
               |-------- avatar.png  //模版需要的图片
    |-------- pdf.html   // 模版会先生成一个html,然后通过这个html 转成pdf.pdf
    |-------- pdf.pdf    // 最终生成的 pdf

我们按照上面的文件结构来创建文件,然后在对应的文件内填入下面的内容。

  • 模版文件 index.html
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>title</title>
  <link rel="stylesheet" href="css/style.css">
</head>
<body>
  <div id="cover">
    我是封面
    独占一页
    没有页眉页脚
    想要让我独占一整页
    需要设置我的
    width:794px;height:1124px;page-break-after:always;
    <a href="####">
      <img src="img/avatar.png" alt="">
    </a>
    <div>
      <p>{{date}}</p>
      <p>{{author}}</p>
    </div>
  </div>
  <div class="page">
    我是内容
    我可能有多个页面
    我有页眉和页脚
    我的样式
    width: 595px;margin:0 auto;
    <p>这里你可以自己copy一些长长的内容过来</p>
  </div>
</body>
</html>

这个项目采用 egg 框架,这个框架提供了很多开箱即用的 API 和配置,其中有一个 API 方法是renderView。 这个方法会返回渲染好的数据结构,就是一个 html 页面。

我们通过 ctx.renderView() 拿到渲染好数据的页面结构,传递给 puppeteer 就可以生成pdf

  • create.js 代码如下:
const html_vars = {
 title: 'title',
 date: Date.now(),
 author: '晴天同学'
}
const html_template = './index.html'

// pdf_string 是渲染好的 html string
const pdf_string = await ctx.renderView(html_template, html_vars)

// 尝试导出pdf
const browser = await puppeteer.launch({
   args: ['--disable-dev-shm-usage', '--no-sandbox']
});

const page = await browser.newPage();
page.setContent(pdf_string)
await page.pdf({
	format: 'A4',
	path: 'public/pdf.pdf'
})

运行node create.js就可以在 /public 目录下生成一份 pdf.pdf 了。

然后我们打开这个 pdf 文件查看会发现如下问题

问题一:页面里的 link 引入的 css 文件加载不了

解决:

单纯 link 找不到 css 文件,可以使用 page.addStyleTag()解决,能传递 link 路径(url),也能传递 css 内容(content),还能传递 css 路径(path)

下图是对page.addStyleTag()参数的说明:

在这里插入图片描述

问题二:导出的 pdf 没有背景色和背景图

这是因为 puppeteer 是基于 chrome 无头浏览器,而浏览器为了打印的时候节省油墨,默认是不导出背景图及背景色的。

解决:

page.pdf({
    printBackground: true,
    '-webkit-print-color-adjust': 'exact',
})

问题三:图片路径找不到

解决:

将图片放到静态资源服务器上,直接使用绝对地址就没问题了,即便不放到静态资源服务器,只要把图片 server 起来就行,比如<img src="http://localhost:3000/img/a.png />"

问题四:添加页眉页脚

上面三个问题,在 issue 中一搜一大把,虽然解决起来不困难,但是三个问题放一起就比较繁琐,然后我想到,我为什么不先生成 html,然后把 html 给 puppeteer 呢?因为是纯粹的 html 文件,而不是 renderView 返回的 string,所以上面三个问题自然就解决了,顺便添加上页眉和页脚

解决:

将 create.js 内容改成下面的代码

const { promises: { readFile, writeFile } } = require('fs');
const path = require('path')
......
const pdf_string = await ctx.renderView(html_template, html_vars)
const pdf_path = path.join(__dirname,'/public/pdf.html')
// 先生成 html,这样就可以直接引入 img/css等文件
await writeFile(pdf_path, pdf_string, 'utf8');
// 页脚
const footerTemplate = `<div 
        style="width:80%;margin:0 auto;font-size:8px;border-top:1px solid #ddd;padding:10px 0;display: flex; justify-content: space-between; ">
        <span style="">我是页脚</span>
        <div><span class="pageNumber">
        </span> / <span class="totalPages"></span></div>
        </div>`;
// 页眉
const headerTemplate = `<div
        style="width:80%;margin:0 auto;font-size:8px;border-bottom:1px solid #ddd;padding:10px 0;display: flex; justify-content: space-between;">
        <span>我是页眉</span>
        <span>我也是页眉</span>
        </div>`
// 尝试导出为pdf
const browser = await puppeteer.launch({
   args: ['--disable-dev-shm-usage', '--no-sandbox']
});
const page = await browser.newPage();
await page.goto(`file://${process.cwd()}/public/index.html`);
await page.pdf({
	path: 'publick/pdf.pdf',
	...options,
	// 页眉和页脚
	displayHeaderFooter: true,
    headerTemplate,
    footerTemplate,
    margin: {
    	top: 80,
    	bottom: 80
    },
})
await browser.close();

再次运行node create.js,然后查看 pdf.pdf,发现又出现了下面的问题

问题五:封面页眉页脚如何隐藏

这个问题非常关键, issue 上提问的也很多,感兴趣的可以点击地址 去看看,解决办法主要有两个

  • 第一个办法是将封面的 margin 设置为0
await page.addStyleTag({
    content: "@page:first {margin-top: 0;} body {margin-top: 1cm;}"
});

这样做能解决封面不出现页眉页脚,也带来其他的问题,大概意思是除了封面,后面的其他页的margin-bottom 都计算错误,如图:

在这里插入图片描述

我也遇到了这个问题,看 issue 中么有解决,于是果断采用第二种

  • 第二种办法,将封面与其他页分开,分成多次来生成pdf,最后合并成一个,我重点讲这个实现的方法

想要合并 pdf,那就不能直接生成 pdf 了,而是要先生成 pdf 数据,以 buffer 的形式暂存。

那怎么生成封面及内容的 pdf 的 buffer 呢?

page.pdf(options)这个方法,如果 options 中传递了 path 参数,那么就会生成 pdf 文件,如果不传 path,那么就会返回 pdf 的 buffer

// create.js
......
// 封面的buffer
const cover_buffer = await page.pdf({
    ...options,
    pageRanges: '1'  // 只导出第一页,即封面页
})
// 规避封面因为margin:0 导致的后面 margin-bottom 失效
await page.addStyleTag({
    content: "#cover {display:none}"
})
const content_buffer = await page.pdf({
	...options,
	displayHeaderFooter: true,
    headerTemplate,
    footerTemplate,
    margin: {
    	top: 80,
    	bottom: 80
    },
})

好了,现在的问题是如何把两个 buffer 合并为一个 buffer 呢?

这里在合并阶段也是有两种合并方案方案

  1. 合并的方案可以用 easy-pdf-merge来实现,不过这个要依赖系统工具,所以有兴趣的伙伴自己实验一下
  2. 先合并两个 buffer,将两个 buffer 合并以后再生成 pdf,这里有两个库推荐,分别是pdf-lib or node-pdftk,各有优缺点,我们分别来看看怎么使用

下面这段代码是把 cover_buffer and content_buffer 通过 pdf-lib 合并为一个文件来然后生成pdf, 问题是生成的 pdf 文件,点击目录不能跳转, 所以如果不对目录有要求的伙伴可以使用,非常方便

首先需要安装这个包 npm i pdf-lib -S

然后在 create.js 中使用

// create.js
const { PDFDocument } = require('pdf-lib')
......
const pdfDoc = await PDFDocument.create()

const coverDoc = await PDFDocument.load(cover_buffer)
const [coverPage] = await pdfDoc.copyPages(coverDoc, [0])
pdfDoc.addPage(coverPage)

const mainDoc = await PDFDocument.load(content_buffer)
for (let i = 0; i < mainDoc.getPageCount(); i++) {
    const [aMainPage] = await pdfDoc.copyPages(mainDoc, [i])
    pdfDoc.addPage(aMainPage)
}

const pdfBytes = await pdfDoc.save()
const pdf_path = 'public/pdf.pdf'
await writeFile(pdf_path, pdfBytes);
await browser.close();

下面这段代码是把 cover_buffer and content_buffer 通过 node-pdftk 合并为一个文件来生成pdf,问题是 node-pdftk 针对 macos 有一个 issue,大家可以看看

首先需要安装这个包 npm i node-pdftk -S

然后在 create.js 中使用

// create.js
......
const pdftk = require('node-pdftk')
const pdf_buffer = [cover_buffer, content_buffer];
pdftk
    .input(pdf_buffer)
    .output()
    .then(buf => {
        const pdf_path = 'public/pdf.pdf'
		await writeFile(pdf_path, pdfBytes);
		await browser.close();
    });

以上两种合并 buffer 的方案选择一种放进 create.js 里就可以了,然后运行node create.js查看 pdf.pdf,如果还有问题,请重新看这篇博客,或者在评论中联系我

css设置分页

最后,加一点 css 如何操作分页的姿势:

在这里插入图片描述

给看到末尾的小伙伴点个赞 : )