如何用代码生成PDF文档

2022-06-15大约21分钟

PDF格式是一个很常见的格式,有时候我们需要把一些内容生成PDF文件供用户使用。

如果是现成的Word等文档,我们可以直接用Wor的“另存为”功能来将其转换成PDF,或者使用一些其他类似Adobe Acrobat的工具来转换。这个我们不在这里多说。

如果是用代码来生成PDF文档,有一些办法。但是自己研究PDF格式的思路就不提了,重复造轮子,太慢,我们只考虑能重用开源代码的几种方式。

根据需求,我们先找找有没有质量可靠,功能够用的NPM包。搜了一下,发现了这个页面:https://www.npmtrends.com/html-pdf-vs-jspdf-vs-pdf-vs-pdf-lib-vs-pdfkit-vs-pdfmake

图片

根据星星数量能看出,备选的库有:jspdfpdfmakepdfkitpdf-libhtml-pdf

上面的这些库,可以让我们用两种方式来生成PDF:

方法1. 按照库要求的格式,把数据准备好,然后生成PDF。

jspdf:

import { jsPDF } from "jspdf";

// Default export is a4 paper, portrait, using millimeters for units
const doc = new jsPDF();

doc.text("Hello world!", 10, 10);
doc.save("a4.pdf");

pdfmake:

var fonts = {
	Roboto: {
		normal: 'fonts/Roboto-Regular.ttf',
		bold: 'fonts/Roboto-Medium.ttf',
		italics: 'fonts/Roboto-Italic.ttf',
		bolditalics: 'fonts/Roboto-MediumItalic.ttf'
	}
};

var PdfPrinter = require('../src/printer');
var printer = new PdfPrinter(fonts);
var fs = require('fs');

var docDefinition = {
	content: [
		'First paragraph',
		'Another paragraph, this time a little bit longer to make sure, this line will be divided into at least two lines'
	]
};

var pdfDoc = printer.createPdfKitDocument(docDefinition);
pdfDoc.pipe(fs.createWriteStream('pdfs/basics.pdf'));
pdfDoc.end();

另外pdfkit和pdf-lib均是类似的方式。

优点:

  1. PDF里的文字都是真实的问题,文档清晰。
  2. 就是可以在node.js端直接生成生成PDF文档,不用依赖浏览器等方式,比较直接。

缺点:

  1. 需要熟悉这个库的API接口,上手门槛较高;
  2. 可能对中文字体支持不够友好。看这里
  3. 布局复杂的内容,排版不太容易。

方法2. 把内容生成HTML页面,然后再用库来转换成PDF。

HTML是一种直观、成熟、普遍使用的技术,如果我们先把内容生成HTML,然后用一个库一下子就能转换成PDF,是不是更容易一些呢?这样,不用再学习PDF库的那么多API,只要会HTML,CSS即可。

html-pdf即是这样的一个库,我们先看一下官方的例子:

var fs = require('fs');
var pdf = require('html-pdf');
var html = fs.readFileSync('./test/businesscard.html', 'utf8');
var options = { format: 'Letter' };

pdf.create(html, options).toFile('./businesscard.pdf', function(err, res) {
  if (err) return console.log(err);
  console.log(res); // { filename: '/app/businesscard.pdf' }
});

但是呢,理想很丰满,现实很骨感,这种方式也还是有明显的优缺点。

优点:

  1. 将html一步转成PDF

缺点:

  1. 用了phantomjs这样一个四五年前已经停止继续开发的、用JavaScript来实现的一个无头浏览器库,让人很担心他对最新的HTML标准的支持和遗留的bug能否被修复;
  2. 使用的生成方式是HTML -> Canvas -> 图片 ->PDF,因为PDF的内容是从图片转换过来的,因此PDF内容的清晰度让人不敢恭维。

因为缺点如此明显,我就没有再深入尝试和研究,浅尝辄止了。

从上面的两种方式可以看出,每种方式都存在一些比较明显的缺点,所以需要找一个更好的方式,因此后来又找到下面两种办法。

方法三. 利用浏览器的PDF打印功能。

细心的你一定会发现,浏览器菜单里的"打印"功能,是可以将完整的网页或者网页的一部分打印成pdf的,那么在我们要生成PDF的网页里,加上这么一行,就可以调用浏览器的打印功能来让用户自己选择是否打印出PDF了。

window.print();

优点

  1. 简单。

缺点

  1. 需要用户后续面对一堆打印选项,知道如何打印当前的页面,对于不熟悉浏览器这个功能的用户来说,是很困难的一件事情。

方法四. 把内容生成HTML页面,使用puppeteer来控制浏览器打开HTML并导出PDF。

Puppeteer是谷歌提供的一个操作Chrome 或者 Chromium浏览器的一个Node.js库,可以用来:

  1. 生成页面的屏幕截图和PDF。
  2. 对 SPA(单页应用程序)进行爬取并生成预呈现的内容(即“SSR”(服务器端呈现))。
  3. 自动提交表单、UI 测试、键盘输入等。
  4. 创建最新的自动化测试环境。使用最新的 JavaScript 和浏览器功能,直接在最新版本的 Chrome 中运行测试。

从功能上来看,用puppeteer来生成PDF也是一个很自然的选项了。

优点

  1. 简单;
  2. 可以很好地复用已有的HTML/CSS/JS代码生成的网页来生成PDF;
  3. 因为puppeteer调用的是Chrome无头浏览器,因此不用担心兼容性问题。

缺点

  1. 需要后台启动一个Chrome浏览器进程,启动需要花费额外的几秒钟时间,另外生成PDF前要打开页面也可能会占用较多内存。.

这里给一段示例代码:

const puppeteer = require('puppeteer');
const express = require('express')
const app = express();

let browser;

const start = async () => {
    browser = await puppeteer.launch({
        headless: true,
        defaultViewport: {
            width: 1280,
            height: 800
        },
        args: [
            "--no-sandbox",
            '--ignore-certificate-errors'
        ]
    });

    await app.listen(3000);
}

async function makePdf(url) {
    const page = await browser.newPage();
await page.setCacheEnabled(false);
    console.time('open');
    await page.goto(url);
    console.timeEnd('open');

    console.time('print');
    const data = await page.pdf({
        format: 'A4'
    });
    console.timeEnd('print');

    console.time('close');
    await page.close();
    console.timeEnd('close');
    return data;
}

app.get('/print', async function (req, res) {
    const data = await makePdf('https://www.lema.fun/');
    res.setHeader( "Content-Type", "application/pdf")
    res.send(data);
})


start();

这段代码例子是启了一个Express的服务器,然后同时让puppeteer打开了一个Chrome浏览器供后面使用,这样可以避免每次生成pdf都要反复启动浏览器花费额外的2~3秒时间。

上面的例子是纯粹用来验证方案的,如果要用在生产环境,需要继续完善一下。

考虑到这种方式比较耗费CPU和内存,并且是一个很独立的功能,比较合适作为一个单独的服务启动,而不是和别的业务代码一起在放在一个进程里。另外,同时有多个请求并发生成PDF的时候,要考虑到这些请求打开页面的时候,不要因为共享了一些数据导致意外的后果,比如Cookie等。方法呢,可以尝试一下使用隐身模式,来给每个页面创建一个隔离的环境:

(async () => {
 const browser = await puppeteer.launch();
  // Create a new incognito browser context.
  const context = await browser.createIncognitoBrowserContext();
  // Create a new page in a pristine context.
  const page = await context.newPage();
  // Do stuff
  await page.goto('https://example.com');
})();

如果你有什么更好的办法,或者发现别的问题,欢迎在乐码范留言讨论。