本人实话实话,这片文章讲的都是汗水换来的,一天多的时间,啃文档,扒 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),
问题二:导出的 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 文件合并为一个,合并的方案可以用
easy-pdf-merge
来实现,不过这个要依赖系统工具,所以有兴趣的伙伴自己实验一下 - 生成封面 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 的姿势,关于如何操作分页: