HTML页面导出为PDF完整指南(实现篇)

1,698 阅读8分钟

项目背景

笔者所在团队开发了一个智能报告系统,其提供客户服务概况、智能问答(借助ChatGPT)等能力。其中存在导出报告的场景,需要重新实现。通过前期调研,最终确定了以无头浏览器模拟访问报告页面,并调用打印能力,将HTML输出为PDF。

技术方案

HTML To PDF,社区还是有很多成熟方案的。

纯客户端方案

Google or 百度一下html2pdf,有大把方案。但是这些纯客户端方案存在一个致命不足:无法打印iframe里的内容。尤其当iframe里的页面是跨域页面时,更是无能为力。

无头浏览器方案

作为纯客户端方案的替换,使用无头浏览器在服务层(node、php、python、go)模拟页面访问并打印页面,可以有效解决iframe空白问题。

这时候会有同学有疑问,用无头浏览器打印页面也会有iframe空白问题。如何解决呢?

如果有iframe,那就需要先将HTML转换为image,然后把image转换为PDF。(image怎么来呢?截图大法了解一下,不要用render方式进行转换,只要涉及render的,都无法处理iframe问题,采用物理截图才是正解)。

方案实现

作为一名刚入门的前端工程师,自然而然就会想到用nodejs+puppeteer实现无头浏览器服务。考虑到后续的其他页面控制场景(比如页面性能检测、报告质检、报告内容提前爬取等),决定搭一个express服务提供http API。

编码实现

首先,需要明确能力边界,期望的导出PDF文档应该具备以下特征:

  1. 基本还原页面样式
  2. 能够完整导出页面
  3. 体积足够小,提高用户下载速度

对于第一点,最终的实现其实是有取舍的。tcbi服务报告支持自适应终端,也就是说可以实现同一份报告在PC端和移动端都能正常展示,并且保证样式正常。

考虑到PDF文档页面一般为A4,更加匹配PC端的样式,因此跟产品同学讨论后决定所有报告都有PC端样式导出,而不用去做移动端的样式兼容(其实在开发阶段做了兼容,结果就是导出的PDF页面两边留白,反而不美观)。

关于第三点,因为采用的是puppeteer原生的Page.pdf API,其体积还是很小的,包含大量echarts图表的5页报告只有不到700kb。

pdf-体积.png

所以,主要的技术难点在于如何完整地导出页面。

前置准备

考虑到页面的一次性(访问一次就会close),不需要进行优化,而express服务中的Browser实例完全可以多次复用,以减少开启、关闭Browser的开销。决定采用单例模式提供一个Browser工具函数,用来创建唯一的browser实例,并且基于该函数再提供一个创建Page实例的方法:

    const puppeteer = require('puppeteer');

    let instance = null;
    const getBrowserInstance = async function (options) {
      if (!instance) {
        instance = await puppeteer.launch(options);
      }
      return instance;
    };

    // 创建一个puppeteer的Page对象
    const newPuppeteerPage = async () => {
      const launchOptions = {
        headless: 'new',
        args: ['--no-sandbox', '--disable-setuid-sandbox'],// 在Docker环境下需关闭沙箱模式
      };
      const browser = await getBrowserInstance(launchOptions);
      if (!browser) {
        return {
          error: 'browser初始化失败',
        };
      }
      const page = await browser.newPage();
      return { page };
    };

完整导出技术实现

上面的工具函数提供了创建Page实例的工具函数,可以方便地创建单个Page。并且所有Page都是在同一个Browser实例上的,提高了性能。

Q:直接调用Page.pdf()不能完整导出页面吗?

A:是的,在现在的页面结构下,document.body的高度往往小于真实内容区高度,而Page.pdf()打印范围默认是视窗的高度。这就会出现打印区小于可视区的情况。导致导出的PDF不全。

所以完整导出的关键点有三点:

  1. 确保document.body的高度等于内容区的高度。这里的逻辑就跟需要导出的页面强相关。
  2. 确保Page视窗高度等于内容区的高度。
  3. 确保内容完全加载(对于拥有丰富图表的页面尤其重要)。

配置页面高度

为了实现1,2点笔者采用的算法是:

  1. 找到实际内容区的容器$container,获取其scrollHeightscrollWidth
  2. 自底向上遍历$containerparentNode,分别设置其widthheight为上面获取的scrollWidthscrollHeight
  3. 步骤2遍历到html节点为止(设置到body标签即可);
  4. 同时,设置puppeteer的Page视窗宽度和高度为scrollWidthscrollHeight,确保一次导出。 编码如下:
<!---->

    /**
     * 1.获取页面内容的实际高度(内容高度有可能比视窗高度大,也有可能比视窗高度小)
     * 2.如果有滚动则滚动到页面底部
     */
    const { pageScrollHeight, pageScrollWidth, errorMsg } = await page.evaluate(
      async () => {
        const $container = document.querySelector("[图表容器selector]");
        if (!$container) {
          return {
            errorMsg:
              "未获取到 [图表容器selector] ,无法抓取内容",
          };
        }
        const pageScrollHeight = $container?.scrollHeight + 61;
        const pageScrollWidth = $container?.scrollWidth;
        const viewportHeight = document.body.clientHeight;

        // 设置父容器高度为内容高度,确保导出完整的PDF页面
        if (pageScrollHeight > viewportHeight) {
          let $scrollParent = $container.parentNode;
          while ($scrollParent.nodeName !== "HTML") {
            $scrollParent.style.height = `${pageScrollHeight}px`;
            $scrollParent.style["overflow-y"] = "hidden";
            $scrollParent = $scrollParent.parentNode;
          }
        }

        return {
          pageScrollHeight,
          pageScrollWidth,
        };
      }
    );

    if (errorMsg) {
      return res.error(errorMsg);
    }

    // 将页面的高度设置为浏览器窗口的高度
    await page.setViewport({
      width: pageScrollWidth,
      height: pageScrollHeight,
    });

上面的代码主要借助了Page.evaluate方法,用在找到页面的真实内容区高度,并依次设置容器的祖先容器的高度。最后返回内容区的宽、高,以该宽高设置页面的可视区高度。

页面加载完成检测

为了实现第三点,需要确保页面已经完全加载。这里的加载不仅仅资源加载,而是页面完全渲染呈现到用户面前。这里puppeteer其实提供了等待页面加载的配置:Page.goto

    class Page {
      goto(
        url: string,
        options?: WaitForOptions & {
          referer?: string;
          referrerPolicy?: string;
        }
      ): Promise<HTTPResponse | null>;
    }
    // WaitForOptions可选值
    export type PuppeteerLifeCycleEvent =
      | 'load'
      | 'domcontentloaded'
      | 'networkidle0'
      | 'networkidle2';

网上大部分资料都是设置WaitForOptions的值为networkidle2 以确保页面加载完成:

networkidle2 是 Puppeteer 中一个用于等待网络空闲的选项。它的作用是等待网络活动停止,并且在一段时间内没有新的网络连接被触发时,认为页面已经加载完成。这个选项通常用于等待动态网页的加载完成,以便于进行截图或其他操作。 具体来说, networkidle2 选项会等待以下两个条件之一满足后,认为页面已经加载完成:

  1. 在一段时间内没有新的网络连接被触发。
  2. 在一段时间内网络连接的数量保持在一个非常低的水平。

这两个条件中的任意一个满足后,Puppeteer 将认为页面已经加载完成,可以进行下一步操作。 在使用 page.goto() 方法加载页面时,你可以将 networkidle2 选项传递给 waitUntil 参数,以等待页面加载完成,示例:

await page.goto('https://www.example.com', { waitUntil: 'networkidle2' });

——From ChatGPT

然而实际情况是,设置networkidle2 并不能确保页面完全渲染(笔者实践中就发现导出的PDF一些图卡为空)。因此需要自定义一种算法来弥补puppeteer自检测的不足。考虑到图卡加载缓慢的根源就是数据接口请求缓慢,那可不可以监听页面的所有请求,并且直到所有请求都响应,才认为页面加载完毕呢?编码如下:

    // 【start】监听页面启动
    let request = 0;
    let requestfinished = 0;
    page.on("request", () => {
      request += 1;
    });
    page.on("requestfinished", () => {
      requestfinished += 1;
    });

    await page.goto(url, {
      waitUntil: "networkidle2",
    });

    // 【end】等待页面请求全部完成,最多等待10秒
    await new Promise((resolve) => {
      let maxIntervalCount = 10;
      const waitTimer = setInterval(() => {
        maxIntervalCount -= 1;
        // 判断是否存在未完成的请求
        if (request !== requestfinished) {
          noop();
        } else {
          clearInterval(waitTimer);
          resolve();
        }
        if (maxIntervalCount <= 0) {
          clearInterval(waitTimer);
          resolve();
        }
      }, 1000);
    });

上面的代码通过Page.on配置了两个监听器,用来监听requestrequestfinished事件,当两者的值相等时确定为图卡数据获取完毕。为了保证对比的准确性,这里利用了requestrequestfinished事件的时间差,因为是在await page.goto(url, {waitUntil:"networkidle2",})之后才进行两者对比,所以request计数一定跑在requestfinished的计数前面。

边界内容截断问题

看一下效果图:

image.png

发现边界的文字存在被截断问题,这里的处理有两种方式:

  • 一种是在开发报告时对报告边界足够留白,边界区域没有内容,自然也就不怕被截断了
  • 在导出页面是设置scale,缩小一点页面,确保内容区远离边界,防止被截断 笔者用了第二种方式,毕竟第一种不太可控:
// FIXME 设置scale为0.94,确保内容边界不被截断
await page.pdf({
  format: "A4",
  scale: 0.94,
  landscape: false,
  displayHeaderFooter: false,
  path: 'export/xxx.pdf',
  width: pageScrollWidth,
  height: pageScrollHeight,
  printBackground: true,// 打印背景
  margin: { left: 24, top: 24, right: 24, bottom: 24 },
});

其中printBackground:true是产品同学特别要求的,目的是保证图卡间的分隔正常。

当然,也可以通过page.emulateMediaType('screen') 将页面的媒体类型设置为 screen,使得 puppeteer 生成的截图或 PDF 文件更接近屏幕显示时的效果。

至此,基本完成了报告导出为PDF的本地开发,接下来将是部署阶段,这又会遇到什么问题呢?

敬请期待下篇文章^_^

----20230806更新---- HTML页面导出为PDF完整指南(部署篇)

总结

  1. 基于puppeteer无头浏览器的页面导出方案可以有效解决iframe空白问题;
  2. 获取页面实际内容高度可以通过Page.evaluate()方法,其可以执行任意脚本,比如导出的时候给页面添加水印;
  3. 使用单例模式创建Browser实例可以有效提高性能;
  4. goto方法的waitUntil参数不一定可靠,可以搭配自定义算法实现页面完全加载检测;
  5. 对于页面边界内容,导出时存在被截断风险,可以配置scale < 1,将页面内容完全打印到PDF里。

参考