主要思路是通過使用
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è)腳的注意:
- 樣式一般使用 inline css
- 圖片無法使用 url 加載, 但可以使用 base64
<img src="base64" />
- 頁(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>
-
- 前端不能添加頁(yè)頭頁(yè)腳,
-
還沒解決的問題
- echarts 的文字(axisLabel)沒有使用前端項(xiàng)目的字體
前端部分
前端針對(duì)打印唯一穩(wěn)定的控制只有分頁(yè)功能.
@media print {
.page-container
.page-divider {
/* 打印的特有樣式, 沒有媒體查詢也沒關(guān)系 */
break-after: page;
}
}
后端部分
- 瀏覽器打開需要打印的網(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)
})
}
- 打印封面和內(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))
))
}
- 合并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')
}
- 調(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.
- http://puppeteerjs.com/
- https://pdf-lib.js.org/
- 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
}