本人实话实话,这片文章讲的都是汗水换来的,一天多的时间,啃文档,扒 github 的 issue 以及 stackoverflow 各种检索,所幸最终成果喜人,生成的 pdf 有封面,有页眉及页脚,css,img和背景图都正常显示。

从开始做 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,通过 ctx.renderView() 拿到渲染好数据的页面结构,这个方法不会将结果返回给前端,代码如下:

//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然后查看 pdf.pdf,会发现如下问题

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

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

puppeteer 如何拿到document puppeteer pdf_puppeteer

问题二:导出的 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 都计算错误,如图:

puppeteer 如何拿到document puppeteer pdf_nodebuffermerge_02

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

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

这里有两种方案

  1. 生成封面 pdf 文件,再生成内容 pdf 文件,最后将两个 pdf 文件合并为一个,合并的方案可以用 easy-pdf-merge来实现,不过这个要依赖系统工具,所以有兴趣的伙伴自己实验一下
  2. 生成封面 pdf 的 buffer,再生成内容 pdf 的 buffer,将两个 buffer 合并以后再生成 pdf,这里有两个库推荐,各有优缺点

先说说怎么生成封面及内容的 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 呢?
推荐两个库:pdf-lib or node-pdftk

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

// 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
......
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 的姿势,关于如何操作分页:

puppeteer 如何拿到document puppeteer pdf_node 生成pdf_03