使用 Puppeteer 导出 PDF(一)

1,624 阅读3分钟

前言

最近工作中碰到了需要在安卓 APP 中导出 PDF 的功能,首先能想到的就是找各种支持导出 PDF 的库。例如安卓官方的 API PdfDocument,这个库需要自己绘制 PDF 的内容,绘制需要自己指定坐标,其复杂性类似于使用 canvas 绘制页面。还有 Javascript 写的 jsPDF,操作起来的流程大概一样,都需要自己绘制内容。

要是导出 PDF 能像打印页面一样简单就好了!Wait!打印?浏览器不是能打印吗?Chrome 浏览器不是能打印输出 PDF 吗?使用 Puppeteer 不就能实现导出页面为 PDF 了吗?

至此,解决方案有了:使用 Puppeteer 导出 PDF!APP 中肯定是用不了 Puppeteer 的,那我们就把 Puppeteer 作为一个服务运行在服务端。Puppeteer 只能导出网页内容,那我们就把要导出的内容单独做一个网页不就行了。虽然需要单独将要导出的内容使用 HTML 再写一遍,总比自己使用 Canvas 绘制内容强多了吧!

实现

先放上服务端代码:github.com/li-yechao/p…

已经构建好的 Docker 镜像:github.com/li-yechao/p…

设置 Puppeteer 时区

puppeteer 默认使用系统的时区,我们可以设置自定义时区。

注意:如果没有设置时区的话,页面中依赖于时区的功能可能会出现与预期不符的情况,比如格式化时间等。

await page.emulateTimezone(timezone)

设置 timeout

puppeteer 默认超时时间为 30 秒,可以根据需要自定义超时时间。

注意:PDF 导出可能需要较长时间(特别是内容较多的时候)。

page.setDefaultTimeout(timeout)

等待内容加载完成

在导出 PDF 之前我们需要等待页面内容加载完成,不然会出现导出空白内容或者内容不全的情况。

设置 waitUntilnetworkidle0 表示等待知道所有网络连接结束。

await page.goto(url, { waitUntil: 'networkidle0' })

导出 PDF

await page.pdf({
  // 隐藏页头页脚
  displayHeaderFooter: false,
  // 输出背景图
  printBackground: true,
  // 输出纸张格式
  format: 'a4',
  // 超时时间,这里的超时时间需要单独设置
  timeout: 180e3,
  // 边距
  margin: { left: margin, top: margin, right: margin, bottom: margin },
})

压缩 PDF

puppeteer 导出的 PDF 原文件是没有压缩过的,其中的图片也是原图,导致 PDF 文件过大,我们需要在不影响 PDF 质量的条件下将其压缩以便于下载使用。压缩 PDF 需要使用到 ghostscript 程序。

async function compressPdf(buffer: Buffer): Promise<Buffer> {
  const tempDir = mkdtempSync(tmpdir())
  try {
    const path = join(tempDir, 'original.pdf')
    const compressedPath = join(tempDir, 'compressed.pdf')

    writeFileSync(path, buffer)

    const res = spawnSync('gs', [
      '-q',
      '-dNOPAUSE',
      '-dBATCH',
      '-dSAFER',
      '-sDEVICE=pdfwrite',
      '-dPDFSETTINGS=/ebook',
      `-sOutputFile=${compressedPath}`,
      path,
    ])

    if (res.status !== 0) {
      throw new Error('Compress pdf failed')
    }

    return readFileSync(compressedPath)
  } finally {
    rmSync(tempDir, { recursive: true, force: true })
  }
}

Dockerfile

FROM node:16-slim AS build

WORKDIR /app

COPY . /app

# 跳过 chromium 的下载,后面使用 apt 安装
ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true

RUN yarn install && \
	yarn build && \
	yarn install --prod && \
	rm -rf /target && \
	mkdir -p /target && \
	cp -r /app/package.json /app/node_modules /app/dist /app/.env /target/

FROM node:16-slim

# 安装依赖
# chromium: puppeteer 依赖
# ghostscript: pdf 压缩工具
RUN apt update && apt install -y chromium ghostscript

ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium

WORKDIR /app/

COPY --from=build /target .

CMD ["yarn", "start"]

返回文件流

由于 nestjs 框架,我们可以返回 StreamableFile 来实现文件下载。

在有 filename 参数的情况下我们返回类型为 attachementdisposition,这时浏览器会下载文件;在没有 filename 参数的情况下返回类型为 inlinedisposition,这是浏览器会直接打开文件预览。

return new StreamableFile(buffer, {
  type: 'application/pdf',
  disposition: filename
    ? `attachement; filename="${filename}"`
    : `inline; filename="print.pdf"`,
})

小结

使用 puppeteer 实现 PDF 导出服务,可以解决多端导出 PDF 的难题,并且导出效果完美,只需要使用 HTML 实现内容页面即可。

导出服务中可以加入缓存功能,避免多次调用重复导出。但是一般使用的时候还要考虑权限问题,所以就将缓存和权限等功能分离到了业务服务中,该服务就只负责导出功能。就像纯函数编程概念一样,只通过用户指定的输入,返回用户期望的输出,不拖泥带水。

在实现内容页的时候遇到了一些分页,图片被分割的问题,这里就暂时不写了。欲知后事如何,且听下回分解。