前言
最近工作中碰到了需要在安卓 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 之前我们需要等待页面内容加载完成,不然会出现导出空白内容或者内容不全的情况。
设置 waitUntil 为 networkidle0 表示等待知道所有网络连接结束。
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 参数的情况下我们返回类型为 attachement 的 disposition,这时浏览器会下载文件;在没有 filename 参数的情况下返回类型为 inline 的 disposition,这是浏览器会直接打开文件预览。
return new StreamableFile(buffer, {
type: 'application/pdf',
disposition: filename
? `attachement; filename="${filename}"`
: `inline; filename="print.pdf"`,
})
小结
使用 puppeteer 实现 PDF 导出服务,可以解决多端导出 PDF 的难题,并且导出效果完美,只需要使用 HTML 实现内容页面即可。
导出服务中可以加入缓存功能,避免多次调用重复导出。但是一般使用的时候还要考虑权限问题,所以就将缓存和权限等功能分离到了业务服务中,该服务就只负责导出功能。就像纯函数编程概念一样,只通过用户指定的输入,返回用户期望的输出,不拖泥带水。
在实现内容页的时候遇到了一些分页,图片被分割的问题,这里就暂时不写了。欲知后事如何,且听下回分解。