Puppeteer[node] 將網(wǎng)頁(yè)轉(zhuǎn)為 PDF

主要思路是通過使用 puppeteer 模擬瀏覽器的 ctrl + p 的打印功能來生成pdf. 但是由于瀏覽器對(duì)打印標(biāo)準(zhǔn)支持的不好. 前端很難準(zhǔn)確控制住pdf的樣式展現(xiàn), 所以還是需要后端做大量細(xì)節(jié)處理.

總覽

  • 前端使用媒體查詢 @media print { ... } 來單獨(dú)控制打印樣式

  • 前端的可以使用 break-after: page; css樣式控制分頁(yè)

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

    • puppeteer.launch().newPage().pdf(PdfOption) 進(jìn)行截圖
    • puppeteer 通過設(shè)置 page.pdf.option 來分頁(yè)封面內(nèi)容 添加頁(yè)頭頁(yè)腳
    • pdf-lib 來組合封面內(nèi)容
  • 一些注意事項(xiàng)

    • 前端不能添加頁(yè)頭頁(yè)腳, position: fixed. 這種方式很容易遮擋頁(yè)面內(nèi)容, 而且無法得到頁(yè)數(shù)
    • 前端不能通過 @page { margin: 0 } 來單獨(dú)處理封面的邊距, 這會(huì)影響后續(xù)頁(yè)面的排版. 所以在服務(wù)端分別截取封面和內(nèi)容
    • 服務(wù)器上可能沒有中文字體, 所以有些中文會(huì)變成亂碼, 前端需要把字體文件包含到項(xiàng)目中. css: font-face.但就算包含字體, 由于頁(yè)頭,頁(yè)腳并不包含在文檔流中. 所以任何樣式都不起作用.
    • 修改頁(yè)頭,頁(yè)腳的注意:
      1. 樣式一般使用 inline css
      2. 圖片無法使用 url 加載, 但可以使用 base64 <img src="base64" />
      3. 頁(yè)數(shù)和日期格式非常固定, 如果修改需要服務(wù)端進(jìn)行插值. '<span class="date"></span>'.replace('2021-03-15'). 2021/03/15 -> 2021-03-15
    • puppeteer 可以獲取網(wǎng)頁(yè)上最初的內(nèi)容. 但如果是動(dòng)態(tài)內(nèi)容. 需要確保后端調(diào)用 pdf 前動(dòng)態(tài)內(nèi)容已經(jīng)修改.
      比如使用網(wǎng)頁(yè)的 document.title 作為pdf的文件名
    • 頁(yè)眉頁(yè)腳中的特殊變量由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)沒有使用前端項(xiàng)目的字體

前端部分

前端針對(duì)打印唯一穩(wěn)定的控制只有分頁(yè)功能.

@media print {
  .page-container
  .page-divider {
    /* 打印的特有樣式, 沒有媒體查詢也沒關(guān)系 */
    break-after: page;
  }
}

后端部分

  1. 瀏覽器打開需要打印的網(wǎng)頁(yè)
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()

    // 全部網(wǎng)絡(luò)鏈接完成后
    // 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. 打印封面和內(nèi)容

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. 調(diào)用
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
}
最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
  • 序言:七十年代末膏萧,一起剝皮案震驚了整個(gè)濱河市漓骚,隨后出現(xiàn)的幾起案子,更是在濱河造成了極大的恐慌榛泛,老刑警劉巖蝌蹂,帶你破解...
    沈念sama閱讀 206,378評(píng)論 6 481
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件,死亡現(xiàn)場(chǎng)離奇詭異,居然都是意外死亡给郊,警方通過查閱死者的電腦和手機(jī),發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 88,356評(píng)論 2 382
  • 文/潘曉璐 我一進(jìn)店門焕蹄,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事≈⒓” “怎么了?”我有些...
    開封第一講書人閱讀 152,702評(píng)論 0 342
  • 文/不壞的土叔 我叫張陵陌选,是天一觀的道長(zhǎng)原在。 經(jīng)常有香客問我澳泵,道長(zhǎng)腊敲,這世上最難降的妖魔是什么介时? 我笑而不...
    開封第一講書人閱讀 55,259評(píng)論 1 279
  • 正文 為了忘掉前任,我火速辦了婚禮伐蒋,結(jié)果婚禮上奸鬓,老公的妹妹穿的比我還像新娘抑淫。我一直安慰自己,他們只是感情好函喉,可當(dāng)我...
    茶點(diǎn)故事閱讀 64,263評(píng)論 5 371
  • 文/花漫 我一把揭開白布萌业。 她就那樣靜靜地躺著,像睡著了一般桌粉。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發(fā)上,一...
    開封第一講書人閱讀 49,036評(píng)論 1 285
  • 那天瞬逊,我揣著相機(jī)與錄音范删,去河邊找鬼添忘。 笑死,一個(gè)胖子當(dāng)著我的面吹牛涕侈,可吹牛的內(nèi)容都是我干的舷礼。 我是一名探鬼主播,決...
    沈念sama閱讀 38,349評(píng)論 3 400
  • 文/蒼蘭香墨 我猛地睜開眼绿淋,長(zhǎng)吁一口氣:“原來是場(chǎng)噩夢(mèng)啊……” “哼!你這毒婦竟也來了失尖?” 一聲冷哼從身側(cè)響起,我...
    開封第一講書人閱讀 36,979評(píng)論 0 259
  • 序言:老撾萬(wàn)榮一對(duì)情侶失蹤羞芍,失蹤者是張志新(化名)和其女友劉穎唯咬,沒想到半個(gè)月后将鸵,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體顶掉,經(jīng)...
    沈念sama閱讀 43,469評(píng)論 1 300
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡簿透,尸身上長(zhǎng)有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 35,938評(píng)論 2 323
  • 正文 我和宋清朗相戀三年胶背,在試婚紗的時(shí)候發(fā)現(xiàn)自己被綠了树枫。 大學(xué)時(shí)的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點(diǎn)故事閱讀 38,059評(píng)論 1 333
  • 序言:一個(gè)原本活蹦亂跳的男人離奇死亡派阱,死狀恐怖诬留,靈堂內(nèi)的尸體忽然破棺而出,到底是詐尸還是另有隱情贫母,我是刑警寧澤文兑,帶...
    沈念sama閱讀 33,703評(píng)論 4 323
  • 正文 年R本政府宣布,位于F島的核電站腺劣,受9級(jí)特大地震影響绿贞,放射性物質(zhì)發(fā)生泄漏。R本人自食惡果不足惜橘原,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 39,257評(píng)論 3 307
  • 文/蒙蒙 一籍铁、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧趾断,春花似錦拒名、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 30,262評(píng)論 0 19
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽(yáng)。三九已至隔嫡,卻和暖如春甸怕,著一層夾襖步出監(jiān)牢的瞬間,已是汗流浹背腮恩。 一陣腳步聲響...
    開封第一講書人閱讀 31,485評(píng)論 1 262
  • 我被黑心中介騙來泰國(guó)打工梢杭, 沒想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留,地道東北人秸滴。 一個(gè)月前我還...
    沈念sama閱讀 45,501評(píng)論 2 354
  • 正文 我出身青樓武契,卻偏偏與公主長(zhǎng)得像,于是被迫代替她去往敵國(guó)和親。 傳聞我的和親對(duì)象是個(gè)殘疾皇子咒唆,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 42,792評(píng)論 2 345

推薦閱讀更多精彩內(nèi)容