覽指南:pdf.js渲染、文本層與打印實(shí)現(xiàn)詳解)
簡介PDF.js Demo是一套基于Mozilla開源PDF.js庫的純JavaScript示例工程面向前端開發(fā)者和需要為網(wǎng)站嵌入文檔預(yù)覽功能的技術(shù)人員解決瀏覽器中無需插件即可實(shí)現(xiàn)PDF在線預(yù)覽、頁面縮放、本地文件加載與打印調(diào)用等核心問題適合OA系統(tǒng)、電子合同預(yù)覽、在線課堂資料展示等場景。資源壓縮包約2.49MB共376個文件核心包括字形映射、屬性配置、圖片圖標(biāo)、腳本文件與HTML示例頁面另附PDF參考文檔和工程配置文件便于對照查閱與二次開發(fā)。已有9926人學(xué)習(xí)下載。示例除了展現(xiàn)基礎(chǔ)渲染流程外還實(shí)現(xiàn)了動態(tài)縮放、通過File API讀取本地PDF以及與瀏覽器打印接口結(jié)合的實(shí)用邏輯同時涵蓋緩存頁面、分頁加載、使用Web Worker后臺解析等性能優(yōu)化要點(diǎn)并配有目錄結(jié)構(gòu)說明和代碼注釋適合作為在線文檔方案的技術(shù)藍(lán)本。1. 純JS PDF在線預(yù)覽指北為何選擇pdf.js以及它到底能解決什么如果你接手過“要在網(wǎng)頁里打開PDF但不想加載插件、不想裝控件”的需求大概率會繞到pdf.js這條路上來。它是純前端解析PDF并渲染到canvas的開源方案只要瀏覽器支持ES5以上和canvas就能把PDF文件變成可交互的頁面同時支持打印、文本選中、搜索這些原生體驗(yàn)。最關(guān)鍵的是它不依賴后端轉(zhuǎn)換直接喂它文件路徑或二進(jìn)制流就能工作。如果你是做內(nèi)部系統(tǒng)、文件管理平臺或者教育類工具的開發(fā)這套方案能省掉一整套在線預(yù)覽服務(wù)配合打印接口還能把“預(yù)覽”和“打印”統(tǒng)一交給前端處理。下面我以一份可運(yùn)行的demo為基準(zhǔn)從零拆解它怎么跑起來以及在真實(shí)項(xiàng)目中哪些參數(shù)值得調(diào)、哪些坑必須繞開。2. 快速搭一個預(yù)覽Demo引入、初始化和第一屏渲染2.1 引入方式從CDN拉全量版還是自托管文件pdf.js的官方分發(fā)包含三個入口pdf.js主文件、pdf.worker.js工作線程文件以及可選的cmaps字符映射目錄。在demo里最省事的方式是直接引用CDN上的固定版本比如在html里這樣寫script srchttps://unpkg.com/pdfjs-dist2.16.105/build/pdf.min.js/script但需要注意pdf.js內(nèi)部會嘗試從與主文件相同的目錄加載worker文件。如果你只引入主文件它會在運(yùn)行時發(fā)一個動態(tài)請求去拉pdf.worker.js。我在實(shí)際項(xiàng)目里習(xí)慣把兩個文件都下載到本地靜態(tài)目錄然后手動指定worker路徑這樣可以避免CDN版本更新導(dǎo)致行為變化。指定方式如下pdfjsLib.GlobalWorkerOptions.workerSrc /assets/pdfjs/pdf.worker.min.js;這里的邏輯是pdf.js在解析PDF時有一部分計算密集的任務(wù)放在Worker線程里執(zhí)行workerSrc就是告訴主線程“去哪里拿這個線程的代碼”。如果不設(shè)置它會默認(rèn)從主文件所在目錄找一旦你把主文件和worker文件放在不同地方初始化就會失敗。設(shè)置路徑時建議用絕對路徑或相對站點(diǎn)根的路徑避免嵌套路由下路徑解析出錯。2.2 初始化PDF文檔getDocument的輸入格式與Promise鏈拿到文件路徑或File對象之后調(diào)用pdfjsLib.getDocument()創(chuàng)建加載任務(wù)。這個函數(shù)返回的是PDFDocumentProxy的Promise后續(xù)所有操作都必須掛在它后面const loadingTask pdfjsLib.getDocument({ url: /uploads/sample.pdf, cMapUrl: /assets/pdfjs/cmaps/, cMapPacked: true, }); loadingTask.promise.then(pdf { console.log(頁數(shù):, pdf.numPages); return pdf.getPage(1); }).then(page { // 拿到page對象后進(jìn)行渲染 }).catch(err { console.error(加載失敗:, err.message); });這里面有兩點(diǎn)要說明第一getDocument返回的是PDFDocumentLoadingTask別直接拿這個對象去調(diào)用getPage必須先通過.promise屬性獲取到proxy。第二cMapUrl和cMapPacked是給包含特定字體的PDF用的比如某些CJK中日韓字體。如果你不設(shè)置這兩個參數(shù)遇到這類文檔時字形會顯示成亂碼或者方框。我一般會在demo里直接帶上cmaps目錄雖然體積多了幾MB但換來的是渲染正確率這筆賬劃算。2.3 渲染第一頁從page到canvas的完整鏈路獲取到PDFPageProxy對象后渲染的核心是把PDF頁面繪制到canvas上。這一步靠page.render()方法完成它接收一個RenderParameters對象。下面是最基礎(chǔ)的第一頁渲染代碼const canvas document.getElementById(pdf-canvas); const ctx canvas.getContext(2d); const viewport page.getViewport({ scale: 1.5 }); canvas.width viewport.width; canvas.height viewport.height; const renderTask page.render({ canvasContext: ctx, viewport: viewport, }); renderTask.promise.then(() { console.log(第一頁渲染完成); });這里的關(guān)鍵在viewport。PDF是一種矢量格式頁面尺寸是用point1/72英寸定義的而canvas需要像素尺寸。getViewport({ scale })的作用就是定義一個縮放比例把PDF坐標(biāo)換算成canvas像素。scale1.5表示把頁面放大50%顯示。你需要先算好寬高再設(shè)置canvas的width和height否則畫布尺寸和viewport不匹配畫出來的內(nèi)容會變形或截斷。另外render()返回的renderTask是一個帶promise的對象渲染過程中也可以調(diào)用cancel()取消這在翻頁特別快的場景下很有用能避免舊頁面渲染阻塞新頁面。3. 多頁、縮放、旋轉(zhuǎn)與頁碼控制把Demo擴(kuò)展成可用的預(yù)覽器3.1 多頁渲染連續(xù)滾動與單頁切換兩種布局demo如果只渲染第一頁那還談不上“預(yù)覽器”。真實(shí)系統(tǒng)里至少要讓用戶能翻頁或者滾動瀏覽。這里有兩種常見做法一種是類似閱讀器的單頁模式底部放“上一頁/下一頁”按鈕另一種是類似PDF閱讀軟件的連續(xù)滾動模式把所有頁先后渲染到同一個滾動容器里。單頁模式的好處是每次只渲染一頁內(nèi)存開銷小實(shí)現(xiàn)也簡單let currentPage 1; const totalPages pdf.numPages; function renderPage(pageNum) { return pdf.getPage(pageNum).then(page { const scale 1.2; const viewport page.getViewport({ scale }); canvas.width viewport.width; canvas.height viewport.height; return page.render({ canvasContext: ctx, viewport, }).promise; }); } document.getElementById(next).addEventListener(click, () { if (currentPage totalPages) { currentPage; renderPage(currentPage); } });這段代碼里要注意的是renderPage每次都會重新設(shè)置canvas尺寸。如果你在getPage和render之間沒有把canvas尺寸重置渲染結(jié)果會被拉伸。我一般會在切換頁面時先清除畫布ctx.clearRect再把新的viewport尺寸賦給canvas防止上一次的殘留影像干擾視覺。連續(xù)滾動模式則更復(fù)雜一點(diǎn)。你需要動態(tài)判斷“哪一頁進(jìn)入視口”然后懶加載渲染。常見做法是監(jiān)聽滾動事件計算當(dāng)前滾動偏移量對應(yīng)的頁數(shù)再調(diào)用getPage去渲染可視區(qū)域周圍的頁面超出視口的頁面移除canvas節(jié)點(diǎn)釋放內(nèi)存。這個方案對長文檔友好但增量渲染和DOM回收邏輯需要仔細(xì)寫否則滾動快時頁面會閃白。3.2 縮放與旋轉(zhuǎn)viewport參數(shù)附近的細(xì)節(jié)viewport不只是scale一個屬性它還包含旋轉(zhuǎn)角度。PDF內(nèi)部可以通過page.rotate屬性指定頁面本身的旋轉(zhuǎn)角度getViewport默認(rèn)會尊重這個角度。如果你想讓用戶手動旋轉(zhuǎn)需要傳第二個參數(shù)rotationconst viewport page.getViewport({ scale: currentScale, rotation: currentRotation, // 90的整數(shù)倍 });注意到一個容易出錯的地方rotation是在原始旋轉(zhuǎn)基礎(chǔ)上的增量不是“重置”。如果PDF本身已經(jīng)是90度方向你傳rotation90會變成180度。所以做旋轉(zhuǎn)功能時要先把page.rotate讀到并保存然后在此基礎(chǔ)上累加。另外縮放時不要只改scale然后就重新渲染必須同時更新canvas的寬高否則畫布內(nèi)部用了ctx.scale的話會出現(xiàn)“畫布CSS尺寸不變但內(nèi)部像素變化”的模糊現(xiàn)象。我現(xiàn)在實(shí)踐中的做法是維護(hù)一份當(dāng)前狀態(tài)對象const state { scale: 1.0, rotation: 0, pageNum: 1, }; function applyViewport(page) { const baseRotation page.rotate || 0; const viewport page.getViewport({ scale: state.scale, rotation: (baseRotation state.rotation) % 360, }); canvas.width viewport.width; canvas.height viewport.height; return viewport; }這么做的好處是每次渲染時都從原始頁面狀態(tài)計算避免上一次操作污染下一次結(jié)果。3.3 文本層讓預(yù)覽支持復(fù)制、搜索與無障礙閱讀純canvas渲染出的PDF只是一張圖片用戶無法選中文字瀏覽器搜索自然也找不到內(nèi)容。這在文檔管理系統(tǒng)里幾乎是不可接受的缺點(diǎn)。解決方案是加一層“文本層”text layer。pdf.js的API暴露了page.getTextContent()方法可以把頁面里的文字塊抽出來再通過TextLayerBuilder這個工具類把它們渲染成一個覆蓋在canvas上的透明層。import { TextLayerBuilder } from pdfjs-dist/lib/web/text_layer_builder; const textLayerDiv document.getElementById(text-layer); const textContent await page.getTextContent(); const textLayer new TextLayerBuilder({ textLayerDiv, pageIndex: page.pageNumber - 1, viewport: viewport, }); textLayer.setTextContent(textContent); textLayer.setViewport(viewport); textLayer.render();注意text layer構(gòu)建器的pageIndex從0開始而page.pageNumber從1開始不減去1的話會導(dǎo)致文本層錯位。這個mapper類在官方示例里有但很多人下載的demo里沒有包含它復(fù)制代碼時容易漏。裝了文本層后你要保證它的z-index高于canvas但低于頁面上的其他按鈕。另外縮放時文本層要重新計算所有文字的位置例如我一般會在縮放結(jié)束后調(diào)用textLayer.setViewport(viewport)再做一次渲染。4. 打印實(shí)現(xiàn)用pdf.js的print模塊還是自己拼iframe4.1 內(nèi)置print方法的邊界它其實(shí)是在“另起爐灶”pdf.js在官方web/viewer.html里自帶打印功能如果你直接把整個viewer嵌入站點(diǎn)可以參考它的實(shí)現(xiàn)。它內(nèi)部的邏輯大致是重新創(chuàng)建所有頁的canvas計算一套適合打印的viewport然后調(diào)用document.execCommand(print)或者h(yuǎn)ostContainer.print()。但在你自己的demo里如果只引入核心庫是沒有這個能力的。你需要自己寫打印入口。我見過不少開發(fā)者試圖從pdfjsLib里找出.print()方法但在核心包里確實(shí)沒有。核心庫提供的只是解析、渲染和文本提取打印是viewer那層的事。所以如果你在自定義預(yù)覽界面做打印需要走下面的路線。4.2 自建iframe打印從canvas到打印文檔的轉(zhuǎn)換流程最穩(wěn)妥的做法是構(gòu)造一個隱藏的iframe把要打印的PDF頁面依次渲染成canvas然后把canvas轉(zhuǎn)成圖片塞進(jìn)iframe的document.body里最后調(diào)用iframe的contentWindow.print()。下面是精簡版代碼async function printPdf(pdf) { // 1. 創(chuàng)建隱藏iframe const iframe document.createElement(iframe); iframe.style.display none; document.body.appendChild(iframe); const win iframe.contentWindow; const doc win.document; doc.write(htmlheadtitleprint/title/headbody/body/html); doc.close(); // 2. 逐頁渲染為canvas轉(zhuǎn)圖片 for (let i 1; i pdf.numPages; i) { const page await pdf.getPage(i); // 打印用的scale建議用1或1.5太高會導(dǎo)致圖片體積巨大 const viewport page.getViewport({ scale: 1.5 }); const canvas document.createElement(canvas); canvas.width viewport.width; canvas.height viewport.height; const ctx canvas.getContext(2d); await page.render({ canvasContext: ctx, viewport }).promise; const img doc.createElement(img); img.src canvas.toDataURL(image/jpeg, 0.9); doc.body.appendChild(img); } // 3. 延遲打印等待圖片加載完成 setTimeout(() { win.print(); document.body.removeChild(iframe); }, 500); }這段代碼有三個實(shí)際問題第一canvas.toDataURL()對大頁面會產(chǎn)生很長的base64字符串內(nèi)存壓力大時可能卡頓。建議改成canvas.toBlob()然后用URL.createObjectURL()賦給img.src。第二打印時iframe內(nèi)的樣式要額外寫一點(diǎn)比如page { margin: 0 }能去掉默認(rèn)邊距img { width: 100%; page-break-after: always; }能保證每張圖片獨(dú)占一頁。第三不要立刻調(diào)用print()要給瀏覽器一點(diǎn)時間加載圖片否則可能出現(xiàn)空白頁。延遲500ms是經(jīng)驗(yàn)值圖片多時我會用img.onload計數(shù)來替代固定延時。4.3 打印參數(shù)控制窗口、頁面方向和縮放規(guī)則到了打印對話框這一步頁面方向、頁邊距這些其實(shí)是用戶自己控制的。你可以通過CSSpage規(guī)則做少量干預(yù)例如強(qiáng)制A4縱向page { size: A4 portrait; margin: 10mm; }但要注意page的size在Chrome和Firefox的渲染支持不一致。Firefox對size: A4有明顯支持Chrome在打印預(yù)覽里也會讀取這個值。如果你的用戶群體里裝了老版本瀏覽器這些規(guī)則可能被忽略所以不要完全依賴CSS最好在iframe的圖片里直接用width: 100%做自適應(yīng)。另外一個細(xì)節(jié)是打印范圍全部頁還是當(dāng)前頁需要通過win.print()的前置邏輯控制比如只把要打印的頁面渲染到iframe而不是打印全部。我一般會在函數(shù)參數(shù)里加一個range數(shù)組比如[1,3,5]代表只打印這幾頁這樣既控制范圍也避免打印太多頁面導(dǎo)致內(nèi)存溢出。5. 常見問題與避坑指南我從三個項(xiàng)目里挖出來的坑5.1 跨域Worker加載失敗不是所有報錯都叫CORS現(xiàn)象控制臺報錯信息類似Failed to fetch dynamically imported module或者worker file not found頁面一直在加載中canvas始終空白。原因pdf.js在加載worker時采用動態(tài)import機(jī)制如果你的站點(diǎn)是http://a.com而worker路徑配置成了http://b.com/assets/pdf.worker.min.js瀏覽器出于跨域限制拒絕拉取。還有一種情況是本地開發(fā)用file://協(xié)議打開頁面動態(tài)import受CORS策略限制同樣失敗。解決把GlobalWorkerOptions.workerSrc改成和頁面同域的相對路徑例如從/assets/開始。同時確認(rèn)你的服務(wù)器對.js文件返回的Content-Type是application/javascript不是text/plain。排查時先打開Network面板看worker請求是不是401或CORS error。另外如果你把pdf.js構(gòu)建成分離的獨(dú)立包也要檢查打包后worker路徑是否被混淆器改動了。5.2 渲染大文件時內(nèi)存一直漲canvas和Promise順序都沒管好現(xiàn)象打開一份幾十MB的PDF翻頁二三十次后頁面開始卡頓甚至瀏覽器標(biāo)簽頁崩潰。原因每一頁渲染都會創(chuàng)建一個canvas元素如果你切換頁時只把舊的canvas隱藏掉而不移除它的像素數(shù)據(jù)仍然占用內(nèi)存。而且fetch和getPage返回的Promise如果沒有及時處理會保持文檔對象不釋放。核心庫本身會緩存頁面數(shù)據(jù)但你調(diào)用pdf.destroy()之前這些緩存一直存在。解決每次渲染完成后把不再可見的canvas的width和height置為0或者直接從DOM上移除。對于連續(xù)滾動模式要在滾動過程中動態(tài)回收離屏頁面。另外在單頁應(yīng)用里路由離開預(yù)覽頁面時務(wù)必調(diào)用pdf.destroy()和loadingTask.destroy()把文檔引用清掉。如果你同時監(jiān)聽了progress事件記得移除監(jiān)聽器不然也會形成閉包引用。5.3 文本層錯位特別是混合字體和跨頁文字現(xiàn)象安裝了文本層后復(fù)制文字時看到的文字位置和canvas里對應(yīng)位置對不上或者選中某段文字時高亮區(qū)域偏移了幾個像素。原因多數(shù)情況下是因?yàn)関iewport不一致。渲染canvas時用了一套scale而文本層渲染時用了另一套scale或者你在window.resize后沒有重置兩個viewport。另外某些字體本身帶glyph偏移pdf.js的文本層對這部分字體的還原偏差比較大。解決把canvas和文本層放進(jìn)同一個父容器用兩套完全相同的viewport參數(shù)分別調(diào)用setViewport。關(guān)鍵點(diǎn)是文本層的定位基于viewport的width/height和transform你可以在渲染文本層前先textLayerDiv.style.width viewport.width px、height同理。我在demo里會在縮放按鈕的回調(diào)里同時更新canvas和文本層并強(qiáng)制加一行textLayer.reset()再重建避免編輯器復(fù)用舊狀態(tài)。5.4 打印出來的圖片發(fā)虛或者被拉伸現(xiàn)象iframe里看到的圖片是清晰的但打印出來模糊或者一張圖被切成了兩頁。原因打印分辨率取決于canvas的像素大小。如果你用scale1即72dpi渲染打印到紙上會明顯偏虛因?yàn)槠聊环直媛屎痛蛴C(jī)分辨率不對等。另一方面如果img的CSS設(shè)了width:100%而原圖本身寬度就不足打印機(jī)會以物理尺寸輸出就會發(fā)虛。解決打印渲染的canvas建議用scale1.5或2對應(yīng)150dpi到300dpi。另外在iframe的CSS里設(shè)置page { size: auto; }同時給img加image-rendering: pixelated的逆操作——實(shí)際上應(yīng)該去掉這個屬性保持默認(rèn)的平滑縮放。每次打印前我用一張白底測試頁先試打確認(rèn)邊距和縮放正常后再進(jìn)入批量打印邏輯。6. 進(jìn)階性能優(yōu)化與移動端適配的幾個實(shí)用技巧如果你準(zhǔn)備把demo擴(kuò)展到生產(chǎn)環(huán)境有幾件小事值得在代碼里提前布局。第一件是虛擬滾動。連續(xù)滾動模式下渲染全部頁面是災(zāi)難比如300頁的文檔。我一般會在滾動容器內(nèi)只保留約3個canvas節(jié)點(diǎn)當(dāng)前可視頁、前一頁和后一頁其他區(qū)域用占位div撐起高度。核心思想是維護(hù)一個pages[pageNum]緩存里面存“是否已渲染”標(biāo)志滾動時對比新頁碼和當(dāng)前渲染集合差的就卸載。占位div的高度計算公式為viewport.height / scale乘上縮放系數(shù)這樣滾動條的位置始終正確。這么做的好處是內(nèi)存占用從“所有頁”降到“3頁”壞處是滾動條跳躍感需要微調(diào)比如預(yù)先渲染未來兩頁能緩解白閃。第二件是移動端適配。觸摸設(shè)備的縮放習(xí)慣和桌面完全不同雙指縮放事件會間接觸發(fā)多次resize。我會在觸摸開始和結(jié)束之間停掉所有渲染任務(wù)用一個requestAnimationFrame來合并縮放請求。另外移動端的canvas不要設(shè)devicePixelRatio之外的額外放大倍數(shù)我在iPhone上用2倍像素渲染會直接卡死后來限制最高scale為2給用戶一個“高清模式”開關(guān)默認(rèn)走1.5。第三件是文本搜索和鏈接跳轉(zhuǎn)。pdf.js的getTextContent返回的對象里有items數(shù)組每個item帶str和transform矩陣。我利用這些數(shù)據(jù)實(shí)現(xiàn)了簡單的定位跳轉(zhuǎn)用戶點(diǎn)搜索結(jié)果時找到那條item的transform[4]和transform[5]x和y坐標(biāo)再用viewport的convertToViewportPoint方法映射到canvas像素位置最后用canvas.scrollIntoView({ block: center })。這個功能的坑在于搜索結(jié)果可能有重復(fù)位置在縮放后要重新計算。最后給PDF渲染加一個進(jìn)度提示其實(shí)很有價值。getDocument的loadingTask可以通過onProgress拿到加載進(jìn)度但真正耗時的是渲染我一般用page.render().promise加一個requestAnimationFrame計時超過300ms就顯示“正在解析復(fù)雜頁面”。這個優(yōu)化沒有高深技術(shù)但能顯著降低用戶等待時的焦慮感。從第一個demo到上線我唯一堅持的習(xí)慣是每次改動渲染相關(guān)代碼后強(qiáng)制自己用一份包含圖表、大字體和掃描文件的PDF做回歸測試看看canvas尺寸、文本層和打印結(jié)果是否保持穩(wěn)定。這比任何單元測試都靠譜因?yàn)閜df.js在不同文檔上的行為差異實(shí)在太多。希望這份折騰筆記能幫到你少走幾步我當(dāng)年踩過的彎路。本文還有配套的精品資源點(diǎn)擊獲取