【踩坑】從前端到後端,常見的列印與下載 pdf 檔案方法
8月 28, 2023
相信身為前端工程師,一定多少會遇到下載頁面的需求。在這一年多的開發經驗裡就遇到了形形色色至少五次類似的需求,逼人把這個技能刻在心底呀~ 當然在實作之前先了解 spec 到底需要的是什麼,是要開啟列印視窗、直接下載檔案,需不需要先預覽等等,以下分享幾個曾實作過的方法與區別。
🖨 利用「列印」來實踐,下載要自己另存 以開發來說,只需要前端就能實踐,最簡單快速的方法就是利用 window.print 原生的列印功能去實作,但這個方法會跳出列印的視窗,且需要自己另存新檔才能真正的下載起來,也許使用上不是那麼便利,接著介紹兩種截然不同的方法來做。
1. window.print() + window.document.write 利用 window.print() 可以將 html 寫進頁面當中,但要讓畫面長的美麗會很~辛~苦!
window.print()
跳出列印功能的視窗
window.open()
可開啟連結至一個新的指定頁面
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 const handlePrintPage = ( ) => { const divElem = document .getElementById ('printArea' ).innerHTML ; const printWindow = window .open ('' , '' , 'height=400,width=800' ); printWindow.document .write ( '<html><head><title></title><link rel="stylesheet" type="text/css" href="/style/style.css"></head><body>' ); printWindow.document .write ('<antd/dist/antd.css>' ); printWindow.document .write ('</head><body >' ); printWindow.document .write (divElem); printWindow.document .write ('</body></html>' ); setTimeout (function ( ) { printWindow.print (); }, 100 ); window .onfocus = function ( ) { window .close (); }; };
2. React to print 👉 react-to-print
當然如果你的專案是 React 的話,有許多套件可以直接使用,其中 react-to-print 就非常方便,因為它可以將網頁上已經寫好的 component 直接列印出來,讓前端只需要再依據 A4 格式去調整列印的排版,當需求是先預覽後列印的話這個方法超級省事。
以下是起手式的範例參考:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 import { useRef } from 'react' ;import { useReactToPrint } from 'react-to-print' ;const ReactToPrintSample = ( ) => { const ref = useRef (null ); const handlePrint = useReactToPrint ({ content : () => ref.current }); return ( <div > <button onClick ={handlePrint} > 列印 PDF</button > <PrintArea ref ={ref} > 要列印的內容</PrintArea > </div > ); }; export default ReactToPrintSample ;
上述的方法都會呼叫出瀏覽器的列印功能視窗,那有沒有方法可以直接「下載起來」,但又「不透過 Server 拿取檔案」呢?這個需求當初卡了我好多天,後來找到了兩個套件搭配的方式來實踐,但是這個方法坑超級多😉
🖼 利用「截圖」來實踐檔案下載 1. html2canvas + jsPDF 👉 html2canvas 👉 jsPDF
html2canvas 會將網頁裡的 Components 以類似「截圖」為一個個 canvas 元素的方式加入畫面。讓我們可以把網頁內容轉換為「圖片」,例如 PNG 格式,這樣可以進行保存、下載、分享。很適合用在局部截圖處理或是圖檔下載 。而 jsPDF 則是負責「生成 pdf」的這個動作,整個流程就是透過 html2canvas 把畫面截圖並一張張加入 jsPDF 生成的 pdf 頁面。聽起來沒有很難但實際寫起來卻很複雜,當初也是參考了很多教學,踩了很多坑才成功產生我們要的畫面。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 const generatePDF = async ( ) => { const input = printRef.current !; try { const canvas = await html2canvas (input, { allowTaint : true , useCORS : true , backgroundColor : '#FFFFFF' , }); let leftHeight = canvas.height ; const a4Width = 595.28 ; const a4Height = 841.89 ; const a4HeightRef = Math .floor ((canvas.width / a4Width) * a4Height); let position = 0 ; const pdf = new jsPDF ('p' , 'pt' , 'a4' ); const createImpl = async (canvas ) => { if (position !== 0 ) { pdf.addPage (); } const pageCount = pdf.getNumberOfPages (); for (let i = 1 ; i <= pageCount; i++) { pdf.setPage (i); pdf.setFontSize (10 ); const pageWidth = pdf.internal .pageSize .width ; const pageHeight = pdf.internal .pageSize .height ; pdf.text (i.toString (), pageWidth - 40 , pageHeight - 20 ); } pdf.addImage ( canvasDraw.toDataURL ('image/jpeg' , 1.0 ), 'JPEG' , 0 , 10 , a4Width, (a4Width / canvasDraw.width ) * height ); leftHeight -= height; position += height; if (leftHeight > 0 ) { setTimeout (() => createImpl (canvas), 500 ); } else { pdf.save ('report.pdf' ); } }; if (leftHeight < a4HeightRef) { pdf.addImage (canvas.toDataURL ('image/jpeg' , 1.0 ), 'JPEG' , 0 , 0 , a4Width, (a4Width / canvas.width ) * leftHeight); pdf.save ('report.pdf' ); } else { await createImpl (canvas); } } catch (err) { console .error ("Error generating PDF:" , err); } };
不過故事不是只到這裡就結束,這個方法在電腦裝置上能夠很順的運行,雖然 canvas 耗時,十頁有圖文、表格的 pdf 大概會跑 10 秒左右,但裝置換到了手機上就 GG,特別是使用 iphone 只要頁面超過 7-8 頁,下載起來的 pdf 就會是全黑的,至今仍找不到原因,只能推測可能是記憶體和性能問題。
📁 還是後端產 pdf 最香! 為了徹底解決這個問題,誕生了下面這個方法,也是我們專案中最後採用的方法:透過後端去實踐。改成後端來產生 pdf,說香不是因為我就可以甩鍋,畢竟我們主管還是大膽的讓我繼續寫下去 😀
1. PdfMake(Node js 環境) 👉 PdfMake
在後端 Node js 環境以 Pdfmake 完成 pdf 的實踐方法不難,主要是在排版會比較花時間,畢竟沒有直覺的畫面可以參考,需要透過打測試 API 的方式來預覽排版結果,一開始不太習慣,但完成之後前端就可以輕鬆的在任何情境透過 API 取得 pdf 檔案,這個速度實在是比 html2canvas 快太多了。
以下是後端程式碼範例
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 const fonts = { Roboto : { normal : 'fonts/Roboto-Regular.ttf' , bold : 'fonts/Roboto-Medium.ttf' , italics : 'fonts/Roboto-Italic.ttf' , bolditalics : 'fonts/Roboto-MediumItalic.ttf' } }; const docDefinition = { content : [ '這是一個範例 PDF 文件。' , { table : { headerRows : 1 , body : [ ['欄位1' , '欄位2' , '欄位3' ], ['資料1' , '資料2' , '資料3' ], ['資料1' , '資料2' , '資料3' ] ] } } ] }; const buffer = await new Promise ((resolve ) => { const pdfDoc = pdfSetting.createPdfKitDocument (docDefinition); const chunks = []; pdfDoc.on ('data' , (chunk ) => { chunks.push (chunk); }); pdfDoc.on ('end' , () => { const bufferTest = Buffer .concat (chunks); resolve (bufferTest); }); pdfDoc.end (); }); return { buffer }; const result = await pdfService.generatePDF (your data from db); const fileName = `report_${data.name} .pdf` ; ctx.set ('Content-Disposition' , `attachment; filename=${fileName} ` ); ctx.set ('Content-Type' , 'application/pdf' ); return finalize (ctx, 200 , result.buffer );
以下是前端串接以取回檔案範例
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 const downloadPDFandSetOverlay = (programCode) { getReportDataFile (programCode) .then (response => { const { data, headers } = response; const blob = new Blob ([data], { type : 'application/pdf' }); const url = window .URL .createObjectURL (blob); const fileName = extractFileName (headers['content-disposition' ]) || 'file.pdf' ; triggerDownload (url, fileName); setOverlayContent ({ text : '下載完成' }); }) .catch (error => { setOverlayContent ({ text : '發生了一點問題,請重試' }); }); } const triggerDownload = (url, fileName ) => { const downloadLink = document .createElement ('a' ); downloadLink.href = url; downloadLink.download = fileName; document .body .appendChild (downloadLink); downloadLink.click (); document .body .removeChild (downloadLink); } const extractFileName = (contentDisposition ) => { if (contentDisposition) { const matches = contentDisposition.match (/filename\s*=\s*([^;\n]+)/i ); if (matches && matches.length > 1 ) { return matches[1 ].replace (/["']/g , '' ); } } return null ; }
💬 參考資料:
使用 html2canvas+jsPDF 实现纯前端 html 导出 pdf