puppeteer 生成pdf 全攻略

6,464 阅读5分钟

本文已参与「新人创作礼」活动,一起开启掘金创作之路

项目背景:

最近公司需要通过生成pdf文件,这就很简单嘛,一边笑着边把需求转给了前端,当产品递给我一份花里胡哨的原型时,傻了眼,发现事情并没有那么简单。

简单来说我们的需求就是:接口导出一份内容花里胡哨且打印后观感较好的pdf;

于是也进行了一些技术对比调研。前端和后端生成pdf的方法网上有很多, 以下列举一些常用的方式作于参考。

方法对比:

前端

html2canvas+jsPDF

原理:将html元素转换为canvas或者图片最终生成pdf文件。

优点:前端此方法教程最多,上手容易,速度较快。

缺点:难以解决元素被截断问题,打印效果差,前端无法直接通过接口调用方式生成pdf文件

后端

itext/poi:html/xml转pdf,如果pdf内容较多,后台需要维护臃肿不堪的结构化数据或者样式,对于编写和维护都很恶心,并且无法达到花里胡哨的效果;仅仅适合精简的pdf导出。

Poi-tl :利用doc模板生成pdf,利用模板文件可以省去大量样式和排版带来的困扰,但由于doc的局限,无法支持如同html那样纷繁的元素,例如图表,字体,表格等高度个性化的样式。个人认为此方法从效率上优于上面的两种。

以上我们对比了几种方法的优缺点,之后将着重介绍 puppeteer的使用和部署

puppeteer:

Puppeteer 是一个 Node 库,它提供了一个高级 API 来通过DevTools 协议控制 Chrome 或 Chromium 。Puppeteer默认无头运行,但可以配置为运行完整(非无头)Chrome 或 Chromium。

puppeteer能做什么?

大多数您可以在浏览器中手动执行的操作都可以使用 Puppeteer 来完成!以下是一些帮助您入门的示例:

  • 生成页面的屏幕截图和 PDF。

  • 抓取 SPA(单页应用程序)并生成预渲染内容(即“SSR”(服务器端渲染))。
  • 自动化表单提交、UI 测试、键盘输入等。
  • 创建最新的自动化测试环境。使用最新的 JavaScript 和浏览器功能,直接在最新版本的 Chrome 中运行您的测试。
  • 捕获站点的时间线跟踪以帮助诊断性能问题。
  • 测试 Chrome 扩展。

puppeteer的功能丰富,我们只关心第一点生成PDF,简单来说:puppeteer 在环境中运行了个Chrome,利用Chrome的API 完成生成 PDF的操作,看似有些复杂,但复杂有复杂的好处,通过puppeteer 生成的PDF可以直接避免文字或者表格被无情截断的问题,canvas或者图片等其他元素解决截断的方法后文有讲,也十分简单。

[jvppeteer](GitHub - fanyong920/jvppeteer: Headless Chrome For Java (Java 爬虫))

Java 版 puppeteer,后端同学可以采用大佬开发的java版本进行PDF的生成等工作,由于个人部署出现Chrome环境依赖问题,暂时无法解决,最终还是采用了Node进行部署。

官方示例

const puppeteer = require('puppeteer');

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

  // Get the "viewport" of the page, as reported by the page.
  const dimensions = await page.evaluate(() => {
    return {
      width: document.documentElement.clientWidth,
      height: document.documentElement.clientHeight,
      deviceScaleFactor: window.devicePixelRatio,
    };
  });

  console.log('Dimensions:', dimensions);

  await browser.close();
})();

puppeteer.launch():浏览器启动方法,但我们不必要每次请求都重新启动浏览器,可以使用puppeteer.connect()重新打开页签的方式,减少消耗。

代码示例

关于puppeteer.launch() 与 page.pdf() 配置信息请参考官网

const puppeteer = require('puppeteer');
const fs = require('fs');
const logger = require("./log4js");

//服务启动时 运行Browser实例
async function launchBrowser() {
    try {
        const browser = await puppeteer.launch({
            args: ['--no-sandbox', '--disable-setuid-sandbox', '--enable-accelerated-2d-canvas', '--enable-aggressive-domstorage-flushing'],
            ignoreHTTPSErrors: true,
            headless: true,
            timeout: 60000,
        });
        const wsAddress = browser.wsEndpoint();
        const w_data = Buffer.from(wsAddress);
        fs.writeFile(__dirname + '/wsa.txt', w_data, {flag: 'w+'}, function (err) {
            if (err) {
                logger.error(err);
            } else {
                logger.info("浏览器启动成功:", wsAddress);
            }
        });
    } catch (e) {
        logger.error(e)
    }
}


//获取新页签 ws方式连接Browser
async function newPage() {
    const getWSAddress = () => new Promise(resolve => {
        fs.readFile(__dirname + '/wsa.txt', {flag: 'r+', encoding: 'utf8'}, function (err, data) {
            if (err) {
                console.error(err);
                return;
            }
            resolve(data);
        });
    });
    const wsa = await getWSAddress();
    const browserConfig = {
        browserWSEndpoint: wsa
    };
    const browser = await puppeteer.connect(browserConfig);
    return browser.newPage()
}

//导出PDF
const options = {
    //纸张尺寸
    format: 'A4',
    //打印背景,默认为false
    printBackground: true,
    //不展示页眉
    displayHeaderFooter: true,
    //页眉与页脚样式,可在此处展示页码等
    headerTemplate,
    footerTemplate,
    margin: {
        top: '2px',
        bottom: '35px'
    },
}

app.post('/PDF', async (req, res) => {
    const printPdf = async () => {
        const page = await newPage()
        try {
            await page.goto(url, {waitUntil: 'networkidle0'})
            return await page.pdf(options)
        } catch (e) {

        } finally {
            await page.close()
        }
    }
    const result = await printPdf()
    //若要导出jpeg 将Content-Type 改为image/jpeg即可
    res.set({'Content-Type': 'application/pdf', 'Content-Length': result.length})
    res.send(200, result)
})

项目部署

​如果使用windows,恭喜你已经避免了诸多环境依赖,字体缺失等问题,但大多数玩家部署在linux,下面将介绍如利用docker进行部署;

部署和中文字体缺失参考这位老哥的方法,再次感谢这位老哥

puppeteer docker 中文 Failed to launch chrome 中文乱码_DEAD_line9527的博客-CSDN博客

截图的诱惑:Docker部署Puppeteer项目 - 简书

# 拉取基础镜像 此镜象已不再维护,安全起见请自制镜像
FROM buildkite/puppeteer:10.0.0

# 设置国内镜像源
RUN sed -i 's/deb.debian.org/mirrors.163.com/g' /etc/apt/sources.list && \
    apt update && \
    apt-get install -y dpkg wget unzip

# 安装中文字体
COPY fonts/fonts-noto-cjk.deb ./tmp/fonts-noto-cjk.deb
COPY fonts/source-sans-pro.zip ./tmp/source-sans-pro.zip

RUN cd /tmp && dpkg -i fonts-noto-cjk.deb   && \
     unzip source-sans-pro.zip && cd source-sans-pro-2.040R-ro-1.090R-it  && mv ./OTF /usr/share/fonts/  && \
     fc-cache -f -v \

RUN apt-get update

RUN apt-get -y install fontconfig xfonts-utils

RUN fc-list :lang=zh

WORKDIR /app

COPY ./package.json /app/

RUN npm config set unsafe-perm true

RUN npm config set registry https://registry.npm.taobao.org

# 安装pm2
#RUN npm i pm2 -g

RUN npm install

COPY . /app/

EXPOSE 8888

CMD [ "yarn", "server" ]

疑难问题

  1. 中文乱码问题

    linux环境需要安装中文字体,否则将会出现中文乱码问题,网络资源不稳定,字体资源已上传。

  2. 图片被截断问题

    page-break-after : auto | always | avoid | left | right page-break-before : auto | always | avoid | left | right page-break-inside : auto | avoid

    需要处理的元素样式添加:

    page-break-inside:avoid

    page-break-before: always

    一般采用向上插入空白占位进行分页以避免元素被分页截断

  3. 缓存问题

    前端修改页面后由于浏览器缓存可能会导致生成的PDF依旧是老版本,此时可采用无痕模式或者page.noCache(true) 进行处理(暂未验证)