html生成pdf 的实现方案小结

634 阅读6分钟

一. puppeteer 生成 pdf

什么是 puppeteer,作用是什么?

Puppeteer 是一个 Node 库,它提供了一个高级 API 来通过 DevTools 协议控制 Chromium 或 Chrome。Puppeteer 默认以 headless 模式运行,但是可以通过修改配置文件运行“有头”模式。

作用是:

  • 生成页面 PDF。
  • 抓取 SPA(单页应用)并生成预渲染内容(即“SSR”(服务器端渲染))。
  • 自动提交表单,进行 UI 测试,键盘输入等。
  • 创建一个时时更新的自动化测试环境。
  • 捕获网站的 timeline trace,用来帮助分析性能问题。
  • 测试浏览器扩展。

开始使用:

  1. 安装 npm i puppter 或者 yarn add puppeteer
puppterpuppter-core
完整版,包体积大,会下载Chromium轻量版,体积小,不下载Chromium
  1. 使用截图功能 (见 src/screenshot.js)
const puppeteer = require('puppeteer');

(async () => {
  const browser = await puppeteer.launch({ headless: true });
  const page = await browser.newPage();
  await page.goto('https://www.baidu.com');
  // 默认 800*600 单位 px
  await page.setViewport({
    width: 1000,
    height: 800
  })
  await page.screenshot({ path: './public/baidu.png' });
  await browser.close();
})();
  1. 创建一个 pdf (见 src/hn.js)
const puppeteer = require('puppeteer');

(async () => {
  const browser = await puppeteer.launch();
  const page = await browser.newPage();
  await page.goto('https://www.baidu.com', { waitUntil: 'networkidle2' });
  await page.pdf({ path: './public/hn.pdf', format: 'A4' });
  await browser.close();
})();

很容易就能看到可以正常生成 pdf, 是不是炒鸡简单。。。
既然是生成PDF,那就先上一盘文档

page.pdf([options])

  • options <[Object]> Options object which might have the following properties:
    • path <[string]> The file path to save the PDF to. If path is a relative path, then it is resolved relative to current working directory. If no path is provided, the PDF won't be saved to the disk.
    • scale <[number]> Scale of the webpage rendering. Defaults to 1. Scale amount must be between 0.1 and 2.
    • displayHeaderFooter <[boolean]> Display header and footer. Defaults to false.
    • headerTemplate <[string]> HTML template for the print header. Should be valid HTML markup with following classes used to inject printing values into them:
    • date formatted print date
    • title document title
    • url document location
    • pageNumber current page number
    • totalPages total pages in the document
    • footerTemplate <[string]> HTML template for the print footer. Should use the same format as the headerTemplate.
    • printBackground <[boolean]> Print background graphics. Defaults to false.
    • landscape <[boolean]> Paper orientation. Defaults to false.
    • pageRanges <[string]> Paper ranges to print, e.g., '1-5, 8, 11-13'. Defaults to the empty string, which means print all pages.
    • format <[string]> Paper format. If set, takes priority over width or height options. Defaults to 'Letter'.
    • width <[string]|[number]> Paper width, accepts values labeled with units.
    • height <[string]|[number]> Paper height, accepts values labeled with units.
    • margin <[Object]> Paper margins, defaults to none.
    • top <[string]|[number]> Top margin, accepts values labeled with units.
    • right <[string]|[number]> Right margin, accepts values labeled with units.
    • bottom <[string]|[number]> Bottom margin, accepts values labeled with units.
    • left <[string]|[number]> Left margin, accepts values labeled with units.
    • preferCSSPageSize <[boolean]> Give any CSS @page size declared in the page priority over what is declared in width and height or format options. Defaults to false, which will scale the content to fit the paper size.
  • returns: <[Promise]<[Buffer]>> Promise which resolves with PDF buffer.

NOTE Generating a pdf is currently only supported in Chrome headless.

page.pdf() generates a pdf of the page with print css media. To generate a pdf with screen media, call page.emulateMediaType('screen') before calling page.pdf():

NOTE By default, page.pdf() generates a pdf with modified colors for printing. Use the -webkit-print-color-adjust property to force rendering of exact colors.

// Generates a PDF with 'screen' media type.
await page.emulateMediaType('screen');
await page.pdf({path: 'page.pdf'});

NOTE headerTemplate and footerTemplate markup have the following limitations:

  1. Script tags inside templates are not evaluated.
  2. Page styles are not visible inside templates.
 `width/height/margin` 可接受的单位是 `px/in/cm/mm`
 examples:
    page.pdf({width: 100}) - prints with width set to 100 pixels
    page.pdf({width: '100px'}) - prints with width set to 100 pixels
    page.pdf({width: '10cm'}) - prints with width set to 10 centimeters.
 `foramt` 参数: `Letter / Legal / Tabloid / Ledger / A0 / A1 / A2 / A3 / A4 / A5 / A6 /`
  1. 然后去实现需求
const { resolve } = require('path');
const puppeteer = require('puppeteer');

(async () => {
  // 生成浏览器
  const browser = await puppeteer.launch()
  // 打开新窗口
  const page = await browser.newPage()

  await page.goto(`http://127.0.0.1:5501/template_test.html`, { // ${baseURL}/article/${id}
    waitUntil: 'networkidle2', // networkidle2 会一直等待,直到页面加载后同时没有存在 2 个以上的资源请求,这个种状态持续至少 500 ms
  })
  // 去掉页眉, 但是影响了后边页的 margin-bottom (不建议用)
  // await page.addStyleTag({
  //   content: "@page:first {margin-top: 20px;} body { margin-top: 80px; }"
  // });
  await page.pdf({
    path: resolve(__dirname, '../public', 'zp.pdf'),  // 输出pdf 文件的路径
    format: 'A4',
    printBackground: true, // 默认黑白, 可以打印出背景色
    displayHeaderFooter: true,  // 显示页眉页脚
    margin: {
      top: 80,
      bottom: 80
    },
  })
  await browser.close()
})();
生成文件见 `public/zp.pdf`
  1. 修改页面页脚
4 的代码基础上新增 :
// 页眉
const headerTemplate = `<div
        style="width:80%;margin:0 auto;font-size:8px;border-bottom:1px solid #ddd;padding:10px 0;display: flex; justify-content: space-between;">
        <span class ='title'>左页眉</span>
        <span class='date'>右页眉</span>
        </div>`
// 页脚
const footerTemplate = `<div style="width:80%;margin:0 auto;font-size:8px;border-top:1px solid #ddd;padding:10px 0;display: flex; justify-content: space-between; ">
          <span class='url'>我是页脚</span>
          <div><span class="pageNumber"></span> / <span class="totalPages"></span></div>
        </div>`;
        
 // 在 page.pdf() 方法里,新增 headerTemplate, footerTemplate,这两个参数
 await page.pdf({
    path: resolve(__dirname, '../public', 'zp.1.pdf'),  // 输出pdf 文件的路径
    format: 'A4',
    printBackground: true, // 默认黑白, 可以打印出背景色
    displayHeaderFooter: true,  // 显示页眉页脚
    headerTemplate,
    footerTemplate,
    margin: {
      top: 60,
      bottom: 60
    },
  })
  // 然后 
  `node  src/index.js`
  生成文件见 `public/zp.1.pdf`
  1. 新增添加一个封面的需求

    i. 先说说怎么生成封面 及 内容的 pdf 的 buffer 吧。

    page.pdf(options)这个方法,如果 options 中传递了 path 参数,那么就会生成 pdf 文件,如果不传 path,那么就会返回 pdf 的 buffer

    ii. 把生成的两个 buffer 合并为一个 buffer

    推荐两个包 pdf-libnode-pdftk

    pdf-lib: 创建和修改在任何 JavaScript环境 PDF文档

    node-pdftk: 把文件 buffer => pdf 文档

    iii. 读取 html 的内容有两种方式

     // 方法一: 通过读取 html 文件,然后 setContent
    
      // const coverHtml = fs.readFileSync(join(__dirname, '../','cover.html'), 'utf8');
      // const contentHtml = fs.readFileSync(join(__dirname, '../', 'content.html'), 'utf8')
      // // console.log('contentHtml: ', coverHtml, contentHtml);
      // pageCover.setContent(coverHtml);
      // pageContent.setContent(contentHtml);
    
      // 方法二:  直接通过 goto 方法去 读取文件内容;
      await pageCover.goto(`file://${process.cwd()}/cover.html`, {
        timeout: 30 * 1000,
        waitUntil: [
          'load',              //等待 “load” 事件触发
          'domcontentloaded',  //等待 “domcontentloaded” 事件触发
          'networkidle0',      //500ms 内没有任何网络连接
          'networkidle2'       //在 500ms 内网络连接个数不超过 2 个
        ]
      });
      await pageContent.goto(`file://${process.cwd()}/content.html`);
    

    iv. 然后把读取后文件通过 page.pdf() 的方式分别生成 buffer,合并后通过 pdftk 输出成pdf 文件

const puppeteer = require('puppeteer');
const fs = require('fs');
const { join } = require('path');
const { promises: { writeFile } } = require('fs');

const { PDFDocument } = require('pdf-lib')
const pdftk = require('node-pdftk');

// 页脚
const footerTemplate =
  `<div style="width:80%;margin:0 auto;font-size:8px;border-top:1px solid #ddd;padding:10px 0;display: flex; justify-content: space-between; ">
        <span style="">页脚</span>
        <div>
          <span class="pageNumber"></span> / <span class="totalPages"></span>
        </div>
      </div>`;
// 页眉
const headerTemplate =
  `<div style="width:80%;margin:0 auto;font-size:8px;border-bottom:1px solid #ddd;padding:10px 0;display: flex; justify-content: space-between;">
      <span class='title'>左页眉</span>
      <span class='date'></span>
    </div>`;

(async () => {
  // 生成浏览器
  const browser = await puppeteer.launch({
    headless: true, // 默认,可不加
    args: ['--no-sandbox', '--font-render-hinting=medium']
  })

  // 打开新窗口
  const pageCover = await browser.newPage()
  const pageContent = await browser.newPage()
  const pdfDoc = await PDFDocument.create()

  // 方法一: 通过读取 html 文件,然后 setContent

  // const coverHtml = fs.readFileSync(join(__dirname, '../','cover.html'), 'utf8');
  // const contentHtml = fs.readFileSync(join(__dirname, '../', 'content.html'), 'utf8')
  // // console.log('contentHtml: ', coverHtml, contentHtml);
  // pageCover.setContent(coverHtml);
  // pageContent.setContent(contentHtml);

  // 方法二:  直接通过 goto 方法去 读取文档内容;
  await pageCover.goto(`file://${process.cwd()}/cover.html`, {
    timeout: 30 * 1000,
    waitUntil: [
      'load',              //等待 “load” 事件触发
      'domcontentloaded',  //等待 “domcontentloaded” 事件触发
      'networkidle0',      //在 500ms 内没有任何网络连接
      'networkidle2'       //在 500ms 内网络连接个数不超过 2 个
    ]
  });
  await pageContent.goto(`file://${process.cwd()}/content.html`);
  // 共同的 options
  const options = {
    format: 'a4',
    printBackground: true,
    '-webkit-print-color-adjust': 'exact',
    displayHeaderFooter: true,
  }

  // 生成封面的 buffer
  const cover_buffer = await pageCover.pdf({
    ...options,
    landscape: false, // 横向还是纵向
    // path: 'public/cover.pdf',
    pageRanges: '1', // 只导出封面页
  })

  // 生成内容区的 buffer
  const content_buffer = await pageContent.pdf({
    ...options,
    // path: 'public/index.pdf',
    displayHeaderFooter: true,
    headerTemplate,
    footerTemplate,
    margin: {
      top: 80,
      bottom: 80
    },
  })


  // 生成封面文档
  const coverDoc = await PDFDocument.load(cover_buffer)
  const [coverPage] = await pdfDoc.copyPages(coverDoc, [0])
  pdfDoc.addPage(coverPage)
  // 生成内容区文档
  const mainDoc = await PDFDocument.load(content_buffer)

  for (let i = 0; i < mainDoc.getPageCount(); i++) {
    const [aMainPage] = await pdfDoc.copyPages(mainDoc, [i])
    pdfDoc.addPage(aMainPage)
  }
  
  // 合并两个文件 buffer
  const pdf_buffer = [cover_buffer, content_buffer];
  // 生成pdf 文件并保存输出
  const pdfBytes = await pdfDoc.save()
  const pdf_path = 'public/pdf.pdf'
  
  pdftk
    .input(pdf_buffer)
    .output()
    .then(async (buf) => {
      await writeFile(pdf_path, pdfBytes);
      await browser.close();
      console.log('pdf:', '写入完成');
    });
})();

运行 `node src/cover.js`
生成文件见 `public/pdf.pdf`;
(当然你也可以在 `page.pdf()`方法里配置 path, 分别生成两个文件对应的 pdf,
只不过生成写入的动作是异步的,pdf文件会先生成,写入的内容需要等会儿才能看到。。。。有好的解法欢迎讨论👏🏻)

坑: 在使用 pdftk 的包,如果你用的是 Macos, 需要再安装一个包,详见 issue

  1. 封装接口,实现前端页面点击下载的功能

关于 css 文字打印的问题

css

问题列表:

  1. 对渐变色的支持不够友好,设置 '-webkit-print-color-adjust': 'exact' 不生效;
  2. 对文字被截断问题的处理还是没找到很好的解决办法
  3. 生成水印的需求还没研究

参考文章:

  1. puppeter 中文文档
  2. puppeteer 生成pdf
  3. pdf-lib文档
  4. demo