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