Puppeteer[node] 将网页转为 PDF

主要思路是通过使用 puppeteer 模拟浏览器的 ctrl + p 的打印功能来生成pdf. 但是由于浏览器对打印标准支持的不好. 前端很难准确控制住pdf的样式展现, 所以还是需要后端做大量细节处理.

总览

  • 前端使用媒体查询 @media print { ... } 来单独控制打印样式

  • 前端的可以使用 break-after: page; css样式控制分页

  • 后端使用 puppeteer + pdf-lib 处理 pdf:

    • puppeteer.launch().newPage().pdf(PdfOption) 进行截图
    • puppeteer 通过设置 page.pdf.option 来分页封面内容 添加页头页脚
    • pdf-lib 来组合封面内容
  • 一些注意事项

    • 前端不能添加页头页脚, position: fixed. 这种方式很容易遮挡页面内容, 而且无法得到页数
    • 前端不能通过 @page { margin: 0 } 来单独处理封面的边距, 这会影响后续页面的排版. 所以在服务端分别截取封面和内容
    • 服务器上可能没有中文字体, 所以有些中文会变成乱码, 前端需要把字体文件包含到项目中. css: font-face.但就算包含字体, 由于页头,页脚并不包含在文档流中. 所以任何样式都不起作用.
    • 修改页头,页脚的注意:
      1. 样式一般使用 inline css
      2. 图片无法使用 url 加载, 但可以使用 base64 <img src="base64" />
      3. 页数和日期格式非常固定, 如果修改需要服务端进行插值. '<span class="date"></span>'.replace('2021-03-15'). 2021/03/15 -> 2021-03-15
    • puppeteer 可以获取网页上最初的内容. 但如果是动态内容. 需要确保后端调用 pdf 前动态内容已经修改.
      比如使用网页的 document.title 作为pdf的文件名
    • 页眉页脚中的特殊变量由CSS类特殊定义
      • <span class="date"></span> => <span>2021/03/15<span>
      • <span class="pageNumber"></span> => <span>1<span>
      • <span class="totalPages"></span> => <span>10<span>
  • 还没解决的问题

    • echarts 的文字(axisLabel)没有使用前端项目的字体

前端部分

前端针对打印唯一稳定的控制只有分页功能.

@media print {
  .page-container
  .page-divider {
    /* 打印的特有样式, 没有媒体查询也没关系 */
    break-after: page;
  }
}

后端部分

  1. 浏览器打开需要打印的网页
const puppeteer = require('puppeteer')

function loadPage (url, wait = 1000) {

  return new Promise(async (resolve, reject) => {

    const browser = await puppeteer.launch({ 
      // for centOS
      // https://github.com/puppeteer/puppeteer/blob/main/docs/troubleshooting.md
      headless: true,
      args: ["--no-sandbox"], 
    })

    const page = await browser.newPage()

    // 全部网络链接完成后
    // http://puppeteerjs.com/#?product=Puppeteer&version=v8.0.0&show=api-pagegotourl-options
    await page.goto(url, { waitUntil: 'networkidle0' })
      
    setTimeout(() => resolve({ page, browser }), wait)
  })
}

  1. 打印封面和内容

function pageToPdf (page, options = []) {
  // http://puppeteerjs.com/#?product=Puppeteer&version=v8.0.0&show=api-pagepdfoptions
  const defaultOption = {
    format: 'A4',
    printBackground: true,
    displayHeaderFooter: false,
  }

  return Promise.all(options.map(opt => 
    page.pdf(Object.assign({}, defaultOption, opt))
  ))
}

  1. 合并pdf
const { PDFDocument } = require('pdf-lib')

async function combinePdf (pdfs, config = {}) {
  
  const doc = await PDFDocument.create()

  // [pdf, pdf] => 
  // [[page1], [page2, page3 ...]] 
  const pagesCollPromises = pdfs.map(async (pdf) => {
    
    const pdfDoc = await PDFDocument.load(toArrayBuffer(pdf))
    return doc.copyPages(pdfDoc, pdfDoc.getPageIndices())
  })

  const pagesCollections = await Promise.all(pagesPromises)

  // [[page1], [page2, page3 ...]] => 
  // [page1, page2, page3, ...]
  const pages = pagesCollections.reduce((flat, pc) => flat.concat(pc), [])
  
  // [page1, page2, page3, ...] => 
  // doc
  pages.forEach((p) => doc.addPage(p))

  await doc.setTitle(config.title ? config.title : await page.title())

  // doc => arrayBuffer
  const docBytes = await doc.save()

  // arrayBuffer => buffer
  return Buffer.from(docBytes) //.toString('base64')
}

  1. 调用
const { page, browser } = await loadPage('http://demo.com')

const pdfs = await pageToPdf(page, [{
  pageRanges: '1'
}, {
  displayHeaderFooter: true,
  // only for content page 
  footerTemplate,
  headerTemplate,
  // page.2+
  pageRanges: '2-', 
  // content margin
  margin: {
    top: '2cm',
    left: '0.72cm',
    right: '0.72cm',
    bottom: '1cm',
  }
}])

await browser.close()

const pdfBuffer = await combinePdf(pdfs, { title: 'pdf-file-title' })

response.set({ 'Content-Type': 'application/pdf', 'Content-Length': pdfBuffer.length })
response.send(pdfBuffer)

E.X.

  1. http://puppeteerjs.com/
  2. https://pdf-lib.js.org/
  3. Buffer to ArrayBuffer
function toArrayBuffer(buf) {
    var ab = new ArrayBuffer(buf.length)
    var view = new Uint8Array(ab)
    for (var i = 0; i < buf.length; ++i) {
        view[i] = buf[i]
    }   
    return ab
}
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 196,165评论 5 462
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 82,503评论 2 373
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 143,295评论 0 325
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 52,589评论 1 267
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 61,439评论 5 358
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 46,342评论 1 273
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 36,749评论 3 387
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 35,397评论 0 255
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 39,700评论 1 295
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 34,740评论 2 313
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 36,523评论 1 326
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,364评论 3 314
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 37,755评论 3 300
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,024评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,297评论 1 251
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 41,721评论 2 342
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 40,918评论 2 336

推荐阅读更多精彩内容