使用puppeteer生成自动分页PDF指南

4,710 阅读3分钟

前段时间接到公司一个需求,需要将用户选择的不定数量的文本内容(富文本格式,包含图片),生成自动分页,且每页都有相同页眉、页尾的 PDF 。考虑到如果是用后端语言实现排版,需要实现一套基于富文本格式的动态分页的逻辑,比较麻烦。于是想到利用 web 技术来排版,并通过 puppeteer 生成 PDF 。

实现的过程中遇到了一些坑点,在这里记录一下。

写在前面

puppeteer 是 google 官方维护的 headless Chrome 工具。可以理解为用 JavaScript 去操作 Chrome 完成一些任务(爬虫、截图等)的工具,且可以不打开 Chrome 的图形化界面。有关如何搭建环境、使用puppeteer ,掘金上已有很多文章谈及,这里不展开说。本文只讲如何生成自动分页的 PDF 。

如何实现分页

单纯分页其实很容易实现,在操作 puppeteer 的时候,传入每页的大小尺寸参数, puppeteer 在生成 PDF 截取内容时候就会按照设定的分页尺寸,将内容自动分页。效果类似于使用 Chrome 时按 ctrl+P 另存为 PDF 。
生成每页大小为 A4 的 PDF ,代码如下:

const browser = await puppeteer.launch({
  headless: true,
});
const page = await browser.newPage();
await page.goto(url);
const pdfBuffer = await page.pdf({
  format: 'A4',
  scale: 1,
  margin: {
    top: '0',
    bottom: '0',
    left: '0',
    right: '0',
  },
  landscape: false,
  displayHeaderFooter: false,
});
await browser.close();

对掘金首页执行生成 PDF 的效果如图:

掘金截图1

实现每页固定页头和页尾

1. 使用 fixed 布局

分页很容易实现,难点在于如何让每一页呈现相同的页头和页尾。我首先尝试了用 fixed 属性,确实可以实现每页固定出现页头和页尾的效果。但是由于使用的是 fixed 布局,出现了内容被页头遮挡的情况。还是以掘金首页为例:
掘金截图1

掘金截图2

掘金截图3

如图,红框位置就被 fixed 布局的页头所遮挡。

2. table 布局

通过不断的 Google 和调试,最后发现 table 布局可以比较好地实现需求。实现上就是把页头放在 thead,页尾放在 tfoot 。代码如下:

<table class="table-container">
  <!-- 每页固定头部 -->
  <thead class="table-header">
    <tr class="table-row">
      <th class="table-row-item">
        <div class="page-header-wrapper">
          <header class="page-header">
            <div class="left">
              <div class="logo-wrapper">
                <img class="logo" src="@/assets/images/logo.svg" alt="logo" />
                <div class="user-name">页头</div>
              </div>
            </div>
          </header>
        </div>
      </th>
    </tr>
  </thead>

  <!-- 包裹段落容器 -->
  <tbody class="table-body">
    <tr class="table-row">
      <td class="table-row-item">
        <div class="container">
          <!-- 此处放置页面内容 -->
        </div>
      </td>
    </tr>
  </tbody>

  <!-- 每页固定尾部 -->
  <tfoot class="table-footer">
    <tr class="table-row">
      <td class="table-row-item">
        <div class="page-footer">页尾</div>
      </td>
    </tr>
  </tfoot>
</table>

实现效果如下:

3. 使用 pdf-lib 生成页尾

实现了每页固定的页头和页尾,又出现了新问题。由于内容是动态的,最后一页的内容是不一定到底部的,使用上述的实现方法会出现最后一页样式不一致的问题。如图:

调试了很久也没有办法在 web 端解决这个问题,于是转换思路,每页只留下页尾的空白位置,生成PDF后再用工具画上页尾。 具体是使用 pdf-lib 这个 node.js 的库。代码如下:

const pdfDoc = await PDFDocument.load(pdfBuffer);
pdfDoc.registerFontkit(fontkit);
const customFont = await pdfDoc.embedFont(SimSun);
const pages = pdfDoc.getPages();
const firstPage = pages[0];
const { width: pageWidth } = firstPage.getSize();
pages.forEach((page, index) => {
  const text = motto[this.getRandom(motto.length, 0)];
  page.drawText(text, {
    x: 41,
    y: 23,
    size: 11,
    font: customFont,
    color: rgb(0.302, 0.302, 0.302),
  });
  page.drawText(`第${index + 1}页/共${pages.length}页`, {
    x: pageWidth - 100,
    y: 23,
    size: 11,
    font: customFont,
    color: rgb(0.302, 0.302, 0.302),
  });
  page.drawRectangle({
    x: 41,
    y: 45,
    width: pageWidth - 82,
    height: 0.6,
    borderColor: rgb(0.941, 0.941, 0.941),
    borderWidth: 0.6,
  });
});
const editedPdfBuffer = await pdfDoc.save();

PS:pdf-lib 如果需要使用特定中文字体,会把字体打包到 PDF 文件中,导致文件大小激增。可以先用字体裁剪工具裁剪字体,只裁剪出会用到的少数字符。

实现效果如图:

这样就完美实现需求啦!

其他

1. 防止特定内容分页

有些内容可能不希望被自动分页,可以用css属性 page-break-inside:avoid; 控制。

2. 垂直方向的 margin 属性导致内容

垂直方向 margin 属性有时候可能会导致内容错位,因为 Chrome 自动分页的时候没有办法帮你自动分割 margin 。
于是使用了一点小技巧,使用空的占位 div 来代替 margin 。

<!-- vue组件 --> 
<div class="place-holder">
  <div
    class="place-holder-item"
    style="width: 100%; height: 1px;"
    v-for="(item, index) in Array(height)"
    :key="index"
  ></div>
</div>

<!-- 使用 --> 
<placeholder :height="30" />

代码地址

最后附上 demo 代码地址,对你有帮助的话麻烦star一下。如果文章有说得不对的地方,烦请指正。
Demo GitHub地址