出中文PDF亂碼?pdfmake字體嵌入與分頁(yè)實(shí)戰(zhàn))
簡(jiǎn)介這份資源面向需要在Web前端實(shí)現(xiàn)PDF導(dǎo)出的開發(fā)者尤其是使用pdfmake時(shí)被中文亂碼問(wèn)題困擾、希望快速拿到可用方案的人群。資源包圍繞pdfmake導(dǎo)出中文無(wú)亂碼PDF這一核心場(chǎng)景提供了一套可直接運(yùn)行的演示環(huán)境省去自行配置中文字體與虛擬文件系統(tǒng)的摸索成本。壓縮包共4個(gè)文件以js腳本和html頁(yè)面為主另有系統(tǒng)緩存文件整體約2.36MB體積輕量便于本地保存與二次修改。目前已有1313人學(xué)習(xí)下載說(shuō)明該方案在實(shí)際開發(fā)中具備一定參考價(jià)值。解壓后打開演示頁(yè)面即可在瀏覽器中觸發(fā)下載直觀看到中文正常顯示的導(dǎo)出效果讀者可據(jù)此對(duì)照自身項(xiàng)目理解字體注冊(cè)與文檔定義的關(guān)鍵寫法并在此基礎(chǔ)上替換業(yè)務(wù)數(shù)據(jù)、調(diào)整表格與排版樣式快速遷移到實(shí)際導(dǎo)出功能中。1. 前端導(dǎo)出中文 PDF 的坑pdfmake 這套方案能繞過(guò)去做過(guò)管理后臺(tái)的都知道導(dǎo)出 PDF 這個(gè)需求看著簡(jiǎn)單真上手就翻車。用瀏覽器打印吧分頁(yè)和頁(yè)眉頁(yè)腳根本控不住用后端生成吧前后端來(lái)回傳文件、調(diào)接口一個(gè)字段對(duì)不上就得聯(lián)調(diào)半天。更別提中文了——字體沒(méi)嵌進(jìn)去導(dǎo)出來(lái)滿屏方塊用戶截圖發(fā)過(guò)來(lái)問(wèn)“這是什么亂碼”你還得解釋半天。pdfmake 這個(gè)庫(kù)就是沖著這類場(chǎng)景來(lái)的純前端、聲明式文檔定義、支持自定義字體嵌入導(dǎo)出的中文 PDF 不亂碼分頁(yè)、表格、頁(yè)眉頁(yè)腳都能在代碼里寫死。它適合誰(shuí)適合那些不想為了一份 PDF 去搭一套后端渲染服務(wù)、又需要穩(wěn)定輸出中文文檔的前端開發(fā)者。下面我按“它怎么組織文檔 → 怎么把中文字體塞進(jìn)去 → 怎么跑起來(lái) → 怎么改”的順序把這份資源拆開講。2. 文檔定義模型拆解pdfmake 是怎么把 JSON 變成 PDF 的pdfmake 的核心思路很直接你不寫繪圖指令你寫一棵“文檔定義樹”庫(kù)負(fù)責(zé)把它翻譯成 PDF 的底層對(duì)象。理解這棵樹的結(jié)構(gòu)后面調(diào)樣式、調(diào)分頁(yè)才不會(huì)瞎試。2.1 docDefinition 的四個(gè)關(guān)鍵字段一份 pdfmake 文檔就是一個(gè)普通對(duì)象最常用的字段是content、styles、defaultStyle、pageMargins。content是內(nèi)容數(shù)組里面每個(gè)元素可以是一段文字、一個(gè)表格、一張圖片也可以嵌套列columns和棧stack。styles定義命名樣式defaultStyle兜底全局字體和字號(hào)。下面是一個(gè)最小可運(yùn)行的定義// 最小文檔定義一段中文 一個(gè)兩列表格 const docDefinition { // 全局默認(rèn)樣式font 必須指向已注冊(cè)的字體名 defaultStyle: { font: NotoSansSC, // 后面 registerFonts 時(shí)用的名字 fontSize: 11, lineHeight: 1.4 }, // 頁(yè)面邊距單位是 pt1pt ≈ 1/72 英寸 pageMargins: [40, 60, 40, 60], content: [ { text: 項(xiàng)目驗(yàn)收?qǐng)?bào)告, style: title }, { text: 本報(bào)告由前端直接生成無(wú)需后端參與。, margin: [0, 8, 0, 16] }, { table: { // headerRows: 1 表示第一行是表頭跨頁(yè)時(shí)自動(dòng)重復(fù) headerRows: 1, widths: [30%, *], body: [ [字段, 值], [項(xiàng)目名稱, 某跨平臺(tái)系統(tǒng)], [導(dǎo)出時(shí)間, new Date().toLocaleString(zh-CN)] ] } } ], styles: { title: { fontSize: 18, bold: true, margin: [0, 0, 0, 12] } } };這段定義里content的順序就是 PDF 里從上到下的順序margin數(shù)組是[左, 上, 右, 下]這個(gè)順序容易記反我第一次寫的時(shí)候把上下寫顛倒了調(diào)了半天間距。table.widths里*表示剩余寬度自動(dòng)分配30%是相對(duì)頁(yè)面可用寬度的百分比。headerRows是個(gè)很實(shí)用的參數(shù)表格跨頁(yè)時(shí)表頭會(huì)自動(dòng)重復(fù)不用自己手動(dòng)拆表。2.2 內(nèi)容節(jié)點(diǎn)的嵌套規(guī)則pdfmake 的內(nèi)容節(jié)點(diǎn)可以嵌套但嵌套有邊界columns里放的是并排的列每列本身又是一個(gè)內(nèi)容數(shù)組stack是垂直堆疊等價(jià)于不加任何布局的默認(rèn)行為。真正需要注意的是text字段既可以是一個(gè)字符串也可以是一個(gè)數(shù)組——數(shù)組形式用來(lái)做行內(nèi)混排比如一段話里某幾個(gè)字加粗、變色{ text: [ 狀態(tài), { text: 已通過(guò), bold: true, color: #1a7f37 }, 審批人某導(dǎo)師 ] }這種行內(nèi)數(shù)組的寫法在生成帶狀態(tài)標(biāo)記的報(bào)告時(shí)特別順手不用為了一個(gè)加粗詞去拆成多個(gè)段落。但要注意行內(nèi)數(shù)組里的每個(gè)元素只支持文本級(jí)樣式bold、italic、color、fontSize不支持margin這類塊級(jí)屬性寫了也不生效這是很多人調(diào)樣式調(diào)不出來(lái)的原因。2.3 分頁(yè)與頁(yè)眉頁(yè)腳的控制點(diǎn)分頁(yè)在 pdfmake 里有兩個(gè)層面自動(dòng)分頁(yè)和手動(dòng)分頁(yè)。自動(dòng)分頁(yè)由內(nèi)容高度決定庫(kù)會(huì)盡量不讓一個(gè)塊被切斷但表格行、圖片這類原子塊如果放不下就整體挪到下一頁(yè)。手動(dòng)分頁(yè)用{ text: , pageBreak: after }注意這個(gè)節(jié)點(diǎn)本身要占一個(gè)位置通常放在兩個(gè)章節(jié)之間。頁(yè)眉頁(yè)腳通過(guò)header和footer函數(shù)定義函數(shù)接收當(dāng)前頁(yè)碼和總頁(yè)數(shù)// 頁(yè)腳第 X 頁(yè) / 共 Y 頁(yè) footer: function (currentPage, pageCount) { return { margin: [40, 0, 40, 0], columns: [ { text: 內(nèi)部資料, fontSize: 9, color: #888 }, { text: 第 ${currentPage} 頁(yè) / 共 ${pageCount} 頁(yè), alignment: right, fontSize: 9 } ] }; }這里有個(gè)細(xì)節(jié)footer返回的也是一個(gè)內(nèi)容節(jié)點(diǎn)所以它同樣受defaultStyle的字體影響。如果頁(yè)腳中文變成方塊說(shuō)明字體注冊(cè)那一步?jīng)]覆蓋到不是頁(yè)腳本身的問(wèn)題。pageCount是庫(kù)在排版完成后回填的所以頁(yè)腳里拿到的總頁(yè)數(shù)是準(zhǔn)確的不用擔(dān)心“共 1 頁(yè)”這種錯(cuò)誤。3. 中文字體嵌入不亂碼的關(guān)鍵就在這一步中文亂碼的根因只有一個(gè)PDF 里引用的字體沒(méi)有嵌入字形閱讀器找不到對(duì)應(yīng)字符就畫方塊。pdfmake 默認(rèn)只帶 Roboto 這類拉丁字體中文必須自己注冊(cè)。這一步做對(duì)了后面基本不會(huì)再有亂碼問(wèn)題。3.1 字體文件從哪來(lái)、放哪常見做法是選一個(gè)開源中文字體比如思源黑體Noto Sans SC或者更輕量的子集字體。完整中文字體動(dòng)輒十幾 MB直接打進(jìn)前端包會(huì)讓首屏體積爆炸所以實(shí)際項(xiàng)目里一般做兩件事一是把字體文件放到靜態(tài)資源目錄按需加載二是用字體子集化工具裁掉用不到的字形。下面假設(shè)字體文件已經(jīng)放在public/fonts/下文件名是NotoSansSC-Regular.ttf。# 目錄結(jié)構(gòu)示意 public/ fonts/ NotoSansSC-Regular.ttf # 常規(guī)體用于正文 NotoSansSC-Bold.ttf # 粗體用于標(biāo)題字體文件必須是 TTF 或 OTFpdfmake 不接受 WOFF/WOFF2這一點(diǎn)在選字體時(shí)就要確認(rèn)別下載完發(fā)現(xiàn)格式不對(duì)又回頭找。3.2 registerFonts 的兩種寫法pdfmake 注冊(cè)字體有兩種方式一種是把字體文件轉(zhuǎn)成 base64 字符串直接內(nèi)聯(lián)適合字體很小或者要打包成單文件的場(chǎng)景另一種是傳 URL讓庫(kù)在運(yùn)行時(shí)去加載。URL 方式更清爽但要注意跨域和加載時(shí)序。下面是 URL 方式的寫法import pdfMake from pdfmake/build/pdfmake; import pdfFonts from pdfmake/build/vfs_fonts; // 先掛上默認(rèn)字體表否則 pdfMake 內(nèi)部找不到基礎(chǔ)字體 pdfMake.vfs pdfFonts.pdfMake.vfs; // 注冊(cè)中文字體名字自己定后面 docDefinition 里引用同一個(gè)名字 pdfMake.fonts { NotoSansSC: { normal: /fonts/NotoSansSC-Regular.ttf, bold: /fonts/NotoSansSC-Bold.ttf, italics: /fonts/NotoSansSC-Regular.ttf, // 沒(méi)有斜體就用常規(guī)體頂替 bolditalics: /fonts/NotoSansSC-Bold.ttf } };四個(gè)鍵normal、bold、italics、bolditalics必須都給缺一個(gè)的話用到對(duì)應(yīng)樣式時(shí)庫(kù)會(huì)報(bào)字體未定義。沒(méi)有真正的斜體文件時(shí)用常規(guī)體頂替是常見做法視覺上不會(huì)斜但至少不報(bào)錯(cuò)。pdfMake.vfs那行不能省它是 pdfmake 內(nèi)置的虛擬文件系統(tǒng)默認(rèn)字體表掛上去之后庫(kù)才能正常初始化。3.3 生成與下載的完整調(diào)用鏈注冊(cè)完字體生成 PDF 就是一行createPdf的事。但下載環(huán)節(jié)有個(gè)坑pdfMake.createPdf(docDefinition).download(文件名.pdf)在部分瀏覽器里對(duì)中文文件名處理不一致穩(wěn)妥的做法是自己拿 blob 再走一遍a標(biāo)簽下載// 生成 PDF 并觸發(fā)下載 pdfMake.createPdf(docDefinition).getBlob((blob) { // 用 URL.createObjectURL 生成臨時(shí)地址避免中文文件名被截?cái)?const url URL.createObjectURL(blob); const link document.createElement(a); link.href url; link.download 項(xiàng)目驗(yàn)收?qǐng)?bào)告.pdf; document.body.appendChild(link); link.click(); document.body.removeChild(link); // 釋放內(nèi)存長(zhǎng)列表導(dǎo)出多次時(shí)這步不能省 URL.revokeObjectURL(url); });getBlob的回調(diào)是異步的字體加載和排版都在這個(gè)過(guò)程中完成所以不要在回調(diào)外面假設(shè) PDF 已經(jīng)生成。revokeObjectURL這行在單次導(dǎo)出時(shí)可有可無(wú)但如果頁(yè)面上有“批量導(dǎo)出”按鈕連續(xù)點(diǎn)幾次不釋放就會(huì)累積內(nèi)存血淚經(jīng)驗(yàn)是加上。4. 從零跑起來(lái)環(huán)境、依賴與最小可運(yùn)行示例拿到一份 pdfmake 的示例代碼最怕的是依賴版本對(duì)不上、字體路徑 404。這一章按實(shí)際搭建順序走一遍確保你能在本地看到一個(gè)中文正常的 PDF。4.1 依賴安裝與版本注意pdfmake 的 npm 包和vfs_fonts是分開的兩個(gè)都要裝。版本上建議鎖同一個(gè)大版本混用容易出現(xiàn)vfs結(jié)構(gòu)不一致的問(wèn)題# 安裝核心庫(kù)和內(nèi)置字體表 npm install pdfmake0.2 --save # vfs_fonts 通常隨 pdfmake 一起提供單獨(dú)引入時(shí)確認(rèn)路徑 npm install pdfmake0.2 --save如果項(xiàng)目用的是 Vite 或 Webpack 5pdfmake/build/pdfmake這個(gè)路徑一般能直接解析如果報(bào)模塊找不到檢查一下構(gòu)建工具的resolve.alias有沒(méi)有把pdfmake指到build目錄。另一個(gè)常見問(wèn)題是打包后字體 URL 變成相對(duì)路徑導(dǎo)致 404這時(shí)候把字體放到public下、用絕對(duì)路徑/fonts/xxx.ttf引用最穩(wěn)。4.2 一個(gè)能直接跑的最小頁(yè)面把前面的字體注冊(cè)和文檔定義拼起來(lái)就是一個(gè)完整的最小示例。下面這段代碼可以直接放進(jìn)一個(gè) HTML 頁(yè)面或者 Vue/React 組件里import pdfMake from pdfmake/build/pdfmake; import pdfFonts from pdfmake/build/vfs_fonts; pdfMake.vfs pdfFonts.pdfMake.vfs; pdfMake.fonts { NotoSansSC: { normal: /fonts/NotoSansSC-Regular.ttf, bold: /fonts/NotoSansSC-Bold.ttf, italics: /fonts/NotoSansSC-Regular.ttf, bolditalics: /fonts/NotoSansSC-Bold.ttf } }; export function exportReport() { const docDefinition { defaultStyle: { font: NotoSansSC, fontSize: 11 }, pageMargins: [40, 60, 40, 60], content: [ { text: 導(dǎo)出測(cè)試, fontSize: 18, bold: true }, { text: 如果這行中文顯示正常說(shuō)明字體注冊(cè)成功。, margin: [0, 10, 0, 0] } ] }; pdfMake.createPdf(docDefinition).getBlob((blob) { const url URL.createObjectURL(blob); const a document.createElement(a); a.href url; a.download 導(dǎo)出測(cè)試.pdf; a.click(); URL.revokeObjectURL(url); }); }跑起來(lái)之后先看兩件事一是 PDF 里中文是否正常二是控制臺(tái)有沒(méi)有字體加載失敗的 404。如果中文是方塊但控制臺(tái)沒(méi)報(bào)錯(cuò)多半是defaultStyle.font的名字和pdfMake.fonts的鍵對(duì)不上大小寫敏感NotoSansSC和notosanssc是兩個(gè)東西。4.3 驗(yàn)證導(dǎo)出結(jié)果的三個(gè)檢查點(diǎn)導(dǎo)出成功不等于導(dǎo)出正確。我一般按三個(gè)點(diǎn)驗(yàn)收第一打開 PDF 看中文和標(biāo)點(diǎn)是否完整特別是全角逗號(hào)、書名號(hào)這類容易缺字形的符號(hào)第二翻到第二頁(yè)看頁(yè)眉頁(yè)腳是否正常重復(fù)表格表頭有沒(méi)有跟著走第三用 PDF 閱讀器的“文檔屬性”看字體是否顯示為嵌入狀態(tài)。第三點(diǎn)最容易被忽略但它是判斷“換臺(tái)電腦打開會(huì)不會(huì)亂碼”的硬指標(biāo)——字體嵌入了文件到哪臺(tái)機(jī)器上顯示都一樣。5. 避坑與排查中文 PDF 導(dǎo)出最常見的五類問(wèn)題這一章是我在實(shí)際項(xiàng)目里踩過(guò)的坑按“現(xiàn)象 → 原因 → 解決”整理遇到問(wèn)題先對(duì)照這里查一遍能省不少時(shí)間。5.1 中文全是方塊控制臺(tái)無(wú)報(bào)錯(cuò)現(xiàn)象PDF 能生成但所有中文顯示為方塊或空白。原因defaultStyle.font引用的字體名沒(méi)有在pdfMake.fonts里注冊(cè)或者注冊(cè)了但四個(gè)樣式鍵沒(méi)給全。解決先確認(rèn)pdfMake.fonts的鍵名和defaultStyle.font完全一致再確認(rèn)normal指向的字體文件路徑在瀏覽器里能直接訪問(wèn)。可以在注冊(cè)后打印一下pdfMake.fonts看結(jié)構(gòu)對(duì)不對(duì)。5.2 字體文件 404但本地開發(fā)正常現(xiàn)象本地npm run dev導(dǎo)出正常打包部署后中文變方塊。原因字體文件放在src/assets下被構(gòu)建工具處理成了帶哈希的路徑而代碼里寫的是固定路徑。解決把字體移到public/fonts/下用絕對(duì)路徑引用構(gòu)建工具不會(huì)改寫public下的文件路徑。這個(gè)坑我翻過(guò)兩次后來(lái)養(yǎng)成習(xí)慣凡是運(yùn)行時(shí)按 URL 加載的靜態(tài)資源一律放public。5.3 表格跨頁(yè)后表頭消失現(xiàn)象長(zhǎng)表格翻到第二頁(yè)表頭沒(méi)了讀者不知道每列是什么。原因table定義里漏了headerRows。解決給table加上headerRows: 1如果表頭有兩行就寫2。注意這個(gè)值要和body里實(shí)際表頭行數(shù)一致寫多了會(huì)把數(shù)據(jù)行當(dāng)表頭重復(fù)。5.4 導(dǎo)出大文檔時(shí)頁(yè)面卡死現(xiàn)象幾百行表格導(dǎo)出時(shí)瀏覽器卡住幾秒甚至無(wú)響應(yīng)。原因pdfmake 在主線程做排版內(nèi)容量大了就是會(huì)阻塞。解決一是控制單次導(dǎo)出量分批生成再合并二是把生成邏輯放到 Web Worker 里主線程只負(fù)責(zé)接收 blob 和觸發(fā)下載。常見做法是超過(guò)兩百行的報(bào)表就走 Worker用戶體驗(yàn)會(huì)好很多。5.5 中文文件名下載后變成亂碼現(xiàn)象download(報(bào)告.pdf)下載下來(lái)文件名變成一串百分號(hào)編碼。原因不同瀏覽器對(duì)download屬性的中文處理不一致。解決不用庫(kù)自帶的download方法改用getBlob拿 blob 后自己創(chuàng)建a標(biāo)簽設(shè)置download現(xiàn)代瀏覽器基本都能正確處理中文文件名。6. 二次開發(fā)把導(dǎo)出邏輯抽成可配置的報(bào)表模塊示例代碼跑通之后真正要落地的是把它變成項(xiàng)目里可復(fù)用的模塊。我的做法是把“文檔定義”和“生成下載”拆開前者是一個(gè)純函數(shù)輸入業(yè)務(wù)數(shù)據(jù)、輸出docDefinition后者是一個(gè)通用工具只負(fù)責(zé)字體注冊(cè)和 blob 下載。這樣換一份報(bào)表只需要寫一個(gè)新的定義函數(shù)下載邏輯一行不用改。具體改造時(shí)我會(huì)先定義一個(gè)報(bào)表配置的接口把標(biāo)題、列定義、數(shù)據(jù)源、頁(yè)腳文案都參數(shù)化// 通用報(bào)表生成器配置驅(qū)動(dòng)避免每份報(bào)表復(fù)制粘貼 function buildReport(config) { return { defaultStyle: { font: NotoSansSC, fontSize: 10 }, pageMargins: [36, 54, 36, 54], content: [ { text: config.title, fontSize: 16, bold: true, margin: [0, 0, 0, 10] }, { table: { headerRows: 1, widths: config.columns.map(() *), // 等寬分配也可傳百分比 body: [ config.columns.map(c ({ text: c.label, bold: true })), ...config.rows.map(row config.columns.map(c String(row[c.key] ?? ))) ] } } ], footer: (page, total) ({ margin: [36, 0, 36, 0], text: ${config.footerText || } 第 ${page} 頁(yè) / 共 ${total} 頁(yè), alignment: right, fontSize: 8 }) }; }這段代碼的關(guān)鍵點(diǎn)是widths用map生成等寬數(shù)組body第一行是表頭、后面用展開運(yùn)算符拼數(shù)據(jù)行?? 兜住空值避免undefined被打印成字符串。改造完之后新增一份報(bào)表就是傳一個(gè)配置對(duì)象的事不用再碰字體和下載邏輯。驗(yàn)證改造是否成功我一般做兩件事一是拿一份含空字段、超長(zhǎng)文本、跨頁(yè)表格的數(shù)據(jù)跑一遍看有沒(méi)有崩二是把生成的 PDF 用閱讀器打開檢查字體嵌入和分頁(yè)。從那以后我每次改報(bào)表配置都強(qiáng)制走一遍這兩個(gè)檢查再?zèng)]出現(xiàn)過(guò)上線后用戶反饋亂碼的情況。希望幫到你。本文還有配套的精品資源點(diǎn)擊獲取