
做小程序開發(fā)這幾年但凡涉及“生成 PDF 報告”“導出電子合同”“分享帶圖卡片”十有八九都會撞上同一個難題圖片在頁面上顯示得好好的一進 PDF 就消失或者只有一行排版的“幽靈”占位符。踩過幾次坑之后我才徹底明白這個小程序里的圖片想lnthtml 轉 PDF基本繞不開 base64 這條路。今天就把這個問題的來龍去脈、各種轉換方案、以及我在 SelectPdf 上踩過的坑一次性講清楚。1. 問題本質(zhì)為什么 PDF 引擎讀不到小程序里的圖片先別急著寫代碼搞清楚底層原因比啥都重要。你用 SelectPdf 這類服務端渲染庫去轉換 PDF 時它本質(zhì)上是一個獨立于小程序環(huán)境的 HTML 渲染引擎。這個引擎執(zhí)行 JS、解析 CSS、加載資源全都發(fā)生在你的服務器上。它和你的小程序客戶端隔著整個網(wǎng)絡。1.1 小程序圖片的三種來源后端一個都拿不到小程序里的圖片資源無非這三種來源網(wǎng)絡 URLhttps://your-cdn.com/images/logo.png。理論上后端能訪問但現(xiàn)實中往往被防盜鏈、跨域策略、臨時簽名失效擋住而且如果這張圖來自小程序云存儲URL 里多半帶動態(tài)簽名轉 PDF 那一刻可能已經(jīng)過期。本地臨時文件wxfile://tmp_xxx/photo.jpg。這是小程序最常用的方式場景里拍照、選圖后得到的都是這種本地路徑??蛇@個路徑是客戶端文件系統(tǒng)里的路徑服務器端 SelectPdf 連你這臺電腦的文件都讀不到更何況是千里之外的手機沙箱。云文件 IDcloud://env-id.xxxx/xxx.png。這個更特殊只有小程序端通過云能力才能解析后端拿到的就是一個字符串 ID無法直接當圖片 URL 用。所以你會看到一種詭異現(xiàn)象在小程序里用 web-view 預覽那個 HTML 時圖片正常顯示因為 web-view 在客戶端運行能訪問本地文件但一旦把同樣的 HTML 字符串 POST 給后端 SelectPdf圖片全掛。1.2 PDF 渲染引擎的加載機制要理解怎么辦先得理解 SelectPdf 這類引擎的工作方式。它會解析 HTML 字符串構建 DOM碰到img標簽時根據(jù)src屬性去發(fā)起資源請求。如果src是wxfile://開頭引擎根本不認識這個協(xié)議如果src是相對路徑/images/a.png引擎會嘗試基于一個 BaseUrl 去拼接但你顯然沒給它配如果是公網(wǎng) URL 但圖片服務器有防盜鏈Referer 校驗失敗就直接返回 403??偠灾矆D片路徑存在一點不確定性最終 PDF 里就是一片空白。這也就是標題里那句“圖片必須編碼”的由來——不是玄學是傳輸鏈路決定的。1.3 base64 為什么能成為終極解決方案base64 本質(zhì)上是用 64 個可打印字符來表示二進制數(shù)據(jù)。一張圖片的二進制內(nèi)容經(jīng)過編碼后變成一串字符然后以內(nèi)聯(lián)方式塞進 HTMLimg srcdata:image/jpeg;base64,/9j/4AAQSkZJRgABAQEAYABgAAD... /data:URI 是 RFC 2397 定義的方案瀏覽器的渲染引擎見到這種src不需要發(fā)任何網(wǎng)絡請求直接解碼字符串里的二進制數(shù)據(jù)并渲染。不管你是本地臨時文件、網(wǎng)絡圖片還是云文件只要在小程序端把它轉成了 base64 字符串塞進 HTML 里后端 SelectPdf 就一定能渲染出圖片來。這個方案跨平臺、跨語言、沒有防盜鏈問題笨但是絕對可靠。2. 小程序圖片轉 base64 的三種實操方案在實際開發(fā)中不同場景的圖片得用不同姿勢去轉 base64。我總結下來就是三板斧讀臨時文件、下載網(wǎng)絡圖再讀、canvas 繪制導出。2.1 方案一FileSystemManager 讀取本地臨時文件如果你手里的圖片已經(jīng)在小程序的本地文件系統(tǒng)里比如wx.chooseImage、wx.chooseMedia返回的tempFilePath直接用FileSystemManager.readFile指定編碼為base64就能拿到 base64 字符串。const fs wx.getFileSystemManager(); function fileToBase64(filePath) { return new Promise((resolve, reject) { fs.readFile({ filePath: filePath, encoding: base64, success(res) { resolve(res.data); // 這里是純 base64 字符串沒有 data:image 前綴 }, fail(err) { reject(err); } }); }); } // 使用示例選擇圖片后馬上轉 base64 wx.chooseMedia({ count: 1, mediaType: [image], success: async (res) { const tempFilePath res.tempFiles[0].tempFilePath; const base64 await fileToBase64(tempFilePath); console.log(base64); } });注意這里有個細節(jié)readFile返回的base64字符串是不含data:image/jpeg;base64,前綴的。你得自己拼上圖片的 MIME 類型才能變成 HTML 能識別的 data URIfunction buildDataUri(base64, mimeType image/jpeg) { return data:${mimeType};base64,${base64}; }MIME 類型怎么拿可以根據(jù)文件后綴判斷.jpg-image/jpeg.png-image/png.gif-image/gif.webp-image/webp。如果你用wx.chooseMedia返回的tempFiles[0].fileType也可以作為參考。2.2 方案二網(wǎng)絡圖片先下載再轉 base64如果圖片本身是網(wǎng)絡 URL直接轉 base64 需要在服務端解決防盜鏈小程序端最穩(wěn)妥的辦法是先用wx.downloadFile把圖片下載成臨時文件然后再走方案一的readFile。function downloadFileToBase64(url) { return new Promise((resolve, reject) { wx.downloadFile({ url: url, success: async (res) { if (res.statusCode 200) { try { const base64 await fileToBase64(res.tempFilePath); resolve({ tempFilePath: res.tempFilePath, base64 }); } catch (e) { reject(e); } } else { reject(new Error(下載失敗HTTP ${res.statusCode})); } }, fail: reject }); }); }為什么先下載再讀取因為wx.downloadFile幫你繞過了很多瀏覽器環(huán)境和后端環(huán)境的限制。小程序內(nèi)部有自己的一套網(wǎng)絡棧能處理一些特殊域名證書、跳過跨域限制前提是在后臺配置了合法域名。下載成功后圖片就變成了本地臨時文件再用readFile轉 base64 就順理成章。注意wx.downloadFile有 10MB 的單文件大小限制iOS/Android 略有差異超過會走 fail 回調(diào)。真機測試時尤其要留意大圖場景。2.3 方案三canvas 重繪后導出 dataURL有些場景下圖片不能直接讀取文件內(nèi)容比如你從后端拿到的是一張需要加水印合成的圖片或者你想在導出 PDF 前把圖片壓縮一下。這時候可以用wx.createOffscreenCanvas或者傳統(tǒng)的canvas組件把圖片繪制到畫布上再通過wx.canvasToDataURL導出。function drawImageToDataUrl(imagePath, { width 750, height 750 } {}) { return new Promise((resolve, reject) { const offscreenCanvas wx.createOffscreenCanvas({ type: 2d, width, height }); const ctx offscreenCanvas.getContext(2d); const img offscreenCanvas.createImage(); img.onload () { ctx.clearRect(0, 0, width, height); // 等比縮放繪制 const scale Math.min(width / img.width, height / img.height); const dw img.width * scale; const dh img.height * scale; const dx (width - dw) / 2; const dy (height - dh) / 2; ctx.drawImage(img, dx, dy, dw, dh); const dataUrl offscreenCanvas.toDataURL(image/jpeg, 0.8); resolve(dataUrl); }; img.onerror reject; img.src imagePath; }); }這里產(chǎn)出的dataUrl是完整的data:image/jpeg;base64,...格式可以直接拼接進 HTML。這種方式最大的好處是可以順便壓縮圖片把 2MB 的圖壓到 200KB后面請求后端接口時壓力小很多。2.4 三種方案怎么選一張表講清楚場景首選方案原因拍照/相冊選圖后的臨時文件方案一直接讀文件開銷最小網(wǎng)絡圖片、CDN 圖片方案二先下載解決防盜鏈再轉碼需要壓縮、加水印、裁剪方案三順便處理圖片一舉兩得云文件 ID先換 https 鏈接再走方案二wx.cloud.getTempFileURL換臨時鏈接我個人的習慣是只要圖片不是特別大一律先走 canvas 壓縮到 80% 質(zhì)量再編碼。省下來的流量和時間在弱網(wǎng)環(huán)境下體感差異非常明顯。3. SelectPdf 集成與圖片渲染完整實操當你手里已經(jīng)有了一堆 base64 字符串接下來要做的就是把它們拼進 HTML交給 SelectPdf 轉 PDF。這一節(jié)我會給出一套能直接跑通的完整流程。3.1 SelectPdf 的基本定位和工作原理SelectPdf 是一個 .NET 平臺的 HTML 轉 PDF 組件它基于自家的渲染內(nèi)核能解析 HTML CSS JavaScript生成 PDF 文件。它解決的核心痛點是PDF 排版極難手工控制而 HTML/CSS 排版有天然優(yōu)勢寫完頁面模板直接轉 PDF省去報表引擎那一大堆代碼。使用它非常直觀核心就是一個HtmlToPdf類using SelectPdf; var converter new HtmlToPdf(); var pdfDoc converter.ConvertHtmlString(htmlContent); pdfDoc.Save(output.pdf); pdfDoc.Close();你可能會問為什么不在前端用 html2canvas jsPDF老實說小程序環(huán)境的 DOM 模型和瀏覽器差別很大html2canvas 在小程序里水土不服而 jsPDF 是一行行手動追加內(nèi)容做復雜排版能寫到懷疑人生。服務端 SelectPdf 用 HTML CSS 控制樣式模板復用度高后端還能順手加頁眉頁腳、頁碼水印所以在正經(jīng)業(yè)務里我更推薦這個鏈路。3.2 轉換前的圖片壓縮與編碼處理回到小程序端。假設用戶在小程序里填完一份體檢報告里面有一個指標異常提示圖、一個趨勢圖canvas 繪制。你需要把這些圖都轉成 base64然后塞進待傳給后端的 JSON 里。async function buildPdfPayload(formData, images) { const imageDataUris []; for (let i 0; i images.length; i) { const imgInfo images[i]; let dataUri ; if (imgInfo.type temp) { const b64 await fileToBase64(imgInfo.path); dataUri data:${imgInfo.mime};base64,${b64}; } else if (imgInfo.type network) { const res await downloadFileToBase64(imgInfo.url); dataUri data:${imgInfo.mime};base64,${res.base64}; } else if (imgInfo.type canvas) { dataUri await drawImageToDataUrl(imgInfo.path, { width: 600, height: 400 }); } imageDataUris.push(dataUri); } return { ...formData, htmlContent: renderReportHtml(formData, imageDataUris) }; }renderReportHtml就用模板字符串把圖片的 data URI 嵌進去function renderReportHtml(formData, imageDataUris) { const imageTags imageDataUris.map((uri, idx) { return img src${uri} stylemax-width:100%;margin:10px 0; /; }).join(); return !DOCTYPE html html head meta charsetutf-8 / style body { font-family: PingFang SC, Microsoft YaHei, sans-serif; padding: 20px; color: #333; } h1 { text-align: center; border-bottom: 2px solid #1890ff; padding-bottom: 10px; } .info-row { display: flex; justify-content: space-between; margin: 8px 0; } .highlight { color: #e6a23c; font-weight: bold; } /style /head body h1${formData.title}/h1 div classinfo-rowspan姓名/spanspan${formData.name}/span/div div classinfo-rowspan報告日期/spanspan${formData.date}/span/div div classinfo-rowspan異常指標/spanspan classhighlight${formData.alertCount} 項/span/div ${imageTags} /body /html; }然后把htmlContent通過wx.request發(fā)給后端wx.request({ url: https://your-server.com/api/convert, method: POST, data: { html: htmlContent }, success(res) { if (res.statusCode 200) { // 拿到 PDF 文件臨時路徑 const pdfPath res.data.pdfPath; wx.openDocument({ filePath: pdfPath, fileType: pdf }); } } });3.3 服務端 C# 接收 HTML 并生成 PDF服務端這邊我用 ASP.NET Core 寫了個接口接收 JSON 里的 HTML調(diào)用 SelectPdf 轉換。要注意幾個關鍵配置[HttpPost(api/convert)] public IActionResult ConvertPdf([FromBody] PdfRequest request) { var converter new HtmlToPdf(); // 關鍵配置 converter.Options.PdfPageSize PdfPageSize.A4; converter.Options.PdfPageOrientation PdfPageOrientation.Portrait; converter.Options.MarginLeft 20; converter.Options.MarginRight 20; converter.Options.MarginTop 20; converter.Options.MarginBottom 20; converter.Options.WebPageWidth 750; // 按小程序設計稿寬度渲染 converter.Options.WebPageHeight 0; // 0 表示高度自適應 // 渲染 HTML var doc converter.ConvertHtmlString(request.Html); // 輸出到內(nèi)存流 using var ms new MemoryStream(); doc.Save(ms); doc.Close(); var pdfBytes ms.ToArray(); return File(pdfBytes, application/pdf, report.pdf); }WebPageWidth我建議設成 750 或者和你的小程序頁面寬度一致。SelectPdf 渲染頁面時會把 HTML 當成一個 750px 寬的網(wǎng)頁來排版這樣圖片的max-width: 100%、流式布局才會有正確的視覺比例。如果默認 1024有些擠壓效果或者換行位置會有偏差。3.4 圖片格式與 base64 編碼的細節(jié)控制SelectPdf 對圖片格式的兼容性不錯JPG、PNG、WebP 基本都能正確處理。但有幾個細節(jié)圖片格式統(tǒng)一成 JPG 或 PNG 就夠了。小程序里經(jīng)常會遇到image/gif轉 PDF 時 gif 動圖只會取第一幀而且 base64 體積很大。如果只是靜態(tài)展示建議在 canvas 方案里強制轉成 JPEG。base64 字符串的完整性。后端接到的 base64 可能有換行符、空格SelectPdf 解析 data URI 時比較挑剔。你可以在后端做個清理request.Html Regex.Replace(request.Html, data:image/[^;];base64,([^])(?|), m { var clean m.Groups[1].Value.Replace(\r, ).Replace(\n, ).Replace( , ); return $data:image/jpeg;base64,{clean}; });我遇到過非常詭異的問題前端傳給后端時請求體里 base64 被encodeURIComponent了一遍后端忘了decodeURIComponent導致data:image/jpeg;base64,%2F9j%2F...SelectPdf 當然認不出來。這種問題排查起來很煩最好在前端發(fā)送前就約定好HTML 原文傳不轉義后端收到直接進轉換器。base64 體積暴漲 33%接口要提前做好預案。原始圖片 1MB轉 base64 后約 1.37MB。如果一個 PDF 里有 5 張這樣的圖POST 體積就接近 7MB。很多網(wǎng)關默認有 1MB/10MB 的請求體限制線上環(huán)境要確認 nginx 的client_max_body_size和后端框架的MaxRequestBodySize否則會出現(xiàn)“小圖正常大圖 413”的詭異問題。4. 常見問題與排查技巧實錄這段是我最想寫的因為光是“圖片轉 base64 后 PDF 里還是不顯示”這一個問題我就在生產(chǎn)環(huán)境折騰過整整一天。下面把典型的坑和排查思路都列出來。4.1 圖片不顯示但 HTML 里明明有 data URI現(xiàn)象后端日志里能看到 HTML 里有data:image/jpeg;base64,...但 PDF 輸出中圖片位置是空白。排查步驟把后端收到的 HTML 字符串原樣保存成.html文件用 Chrome 打開。這是最重要的一步——先確認 HTML 本身沒問題。如果 Chrome 里也空白說明 base64 數(shù)據(jù)本身就是壞的問題出在前端編碼環(huán)節(jié)。檢查 base64 前綴里的 MIME 是否和真實文件類型匹配。常見錯誤是PNG 圖片卻標注了data:image/jpeg某些渲染內(nèi)核會比較嚴格按 jpeg 解碼 png 數(shù)據(jù)直接失敗。檢查 base64 里是否混入了\n、\r、空格。理論上 data URI 里不應該有這些字符。部分庫能容忍SelectPdf 我實測下來對它很敏感清理干凈最穩(wěn)。我踩過的具體例子小程序端用了wx.getFileSystemManager().readFile的encoding: base64但圖片路徑是云文件 IDcloud://...readFile 直接報錯我當時沒接fail回調(diào)結果 base64 是一個空字符串頁面和 HTML 都“正?!本褪菦]圖。4.2 圖片在部分手機上正常部分手機空白現(xiàn)象同樣一套代碼iOS 上生成 PDF 有圖Android 上沒有。這種問題大多數(shù)出在圖片路徑的時效性上。wx.downloadFile的臨時文件在onUnload后基本就沒了如果用戶從選擇圖片頁面跳轉到預覽頁面兩個頁面之間通過全局變量存了tempFilePath但底層的臨時文件已經(jīng)被回收那你readFile時拿到的是已經(jīng)失效的路徑。解決辦法在拿到臨時文件后立即轉 base64不要存路徑只存 base64 字符串。這樣圖片數(shù)據(jù)就變成了內(nèi)存字符串和文件生命周期無關了。// 錯誤做法只存路徑下次頁面再用 globalData.tempImagePath tempFilePath; // 正確做法立刻轉 base64 存起來 globalData.tempImageBase64 await fileToBase64(tempFilePath);4.3 base64 太大導致請求超時或內(nèi)存暴漲現(xiàn)象圖片一多小程序端wx.request直接fail超時或者后端進程內(nèi)存突然飆高。這不僅是網(wǎng)絡問題還是性能問題。base64 文本在 JSON 序列化/反序列化時會被復制多份內(nèi)存圖片數(shù)據(jù)動輒幾 MB在小程序這種 JSCore 環(huán)境下很容易觸發(fā)內(nèi)存告警。我的處理思路是分級優(yōu)化第一級canvas 壓縮。把長邊壓到 800px、質(zhì)量 80%肉眼基本看不出差異體積卻能縮小 70% 以上。 第二級PDF 不需要透明通道的場景全部轉 JPEG不要用 PNG。PNG 的 base64 膨脹率更高。 第三級大圖拆分請求不要一次性把 10 張圖塞一個請求里按 3~4 張一批后端分頁合成 PDF。4.4 防盜鏈與 Referer 校驗的坑小程序端wx.downloadFile的網(wǎng)絡棧是白名單制的所以能下載的圖基本都是合法域名。但有時候你從某個圖片 CDN 下載沒問題后端 SelectPdf 直接訪問原圖 URL 卻被 403這就是防盜鏈。遇到這種情況不必和后端去糾結配 Referer 白名單小程序端直接把圖下載轉成 base64 就完事了。base64 內(nèi)聯(lián)進 HTML 后SelectPdf 不會再去發(fā)圖片請求防盜鏈規(guī)則形同虛設。這是我強烈推薦“先下載再轉碼”的核心理由。4.5 臨時文件堆滿存儲空間小程序端每downloadFile一次都會在用戶設備上殘留臨時文件。如果用戶高頻操作臨時文件積累多了會占滿存儲空間。雖然wx.downloadFile返回的tempFilePath會在小程序退出時清理但同一會話內(nèi)頻繁生成也會有問題。實操建議轉完 base64 以后主動清理臨時文件const fs wx.getFileSystemManager(); // 不需要的臨時文件直接刪掉 try { fs.unlinkSync(tempFilePath); } catch (e) { // 忽略刪除失敗臨時文件后續(xù)還會被系統(tǒng)回收 }4.6 常見問題速查表癥狀可能原因快速解決PDF 圖片空白但 HTML 正常base64 含換行空格、MIME 類型不對后端正則清理嚴格校驗 MIME部分機型失敗臨時文件被回收拿到路徑立刻轉 base64別存路徑請求 413 或超時base64 體積過大、網(wǎng)關限制壓縮圖片、分批提交、調(diào)大請求體限制圖片拉伸變形canvas 繪制時未等比縮放用Math.min計算縮放比例居中繪制圖片模糊原圖分辨率低、canvas 導出尺寸小提高 canvas 尺寸到 2 倍設置 quality 0.9WebP 格式異常SelectPdf 對部分 WebP 解碼兼容性差統(tǒng)一轉 JPEG5. 這套流程還能怎么擴展從單圖到批量 PDF 的工程化改造前面講的都是單份 PDF 的生成流程。真實業(yè)務里往往是一個訂單下有多個報告或者一個批次要生成幾百份合同。在這個基礎上我對這個方案又做了幾層工程化改造也算是給讀者一條進階路徑。5.1 模板與數(shù)據(jù)分離小程序的 HTML 模板不要硬編碼在后端 C# 里也不要在前端字符串拼建議統(tǒng)一放到后端模板管理表里。小程序端只傳業(yè)務數(shù)據(jù)name、date、images 數(shù)組后端用 Razor 模板引擎或者簡單的字符串模板去渲染最終 HTML。好處是排版的調(diào)整不需要發(fā)版小程序只更新服務端模板就行。5.2 批量生成 異步任務隊列當圖片數(shù)量巨大時同步ConvertHtmlString會長時間占用后端線程。建議引入消息隊列比如 RabbitMQ 或者簡單的 Redis 隊列。請求進來先返回“任務ID”后臺 Worker 逐份渲染 PDF完成后推送到小程序。小程序的體驗就是用戶點了“生成報告”頁面出現(xiàn)一個“處理中”的進度條1~2 秒后服務端主動推送 PDF 地址再wx.openDocument打開。比同步卡死優(yōu)雅太多。5.3 PDF 的頁眉頁腳、頁碼、水印SelectPdf 這塊做得比較完善。合同場景下頁腳可以放頁碼、公司電話頁眉放公司 logo這個 logo 又是一張圖同樣轉 base64。水印可以在converter.Options里配置也可以在 HTML 里用 CSS 畫一個半透明背景層。我實測下來CSS 水印在 PDF 渲染里很穩(wěn)定多頁內(nèi)容會重復出現(xiàn)在每頁上效果比組件自帶的更可控。6. 寫在最后的一點經(jīng)驗如果要在這一堆實操里挑一句最想對后來人說的別在小程序端做任何依賴“文件路徑”的持久化圖片數(shù)據(jù)必須在你還在當前頁面環(huán)境時立刻轉化為 base64。這個原則能幫你規(guī)避掉上面 90% 的坑。另外大家可能也發(fā)現(xiàn)了整個鏈路里所有圖片在服務端看來都不是“文件”而是“字符串”——這其實是 PDF 生成領域的一種穩(wěn)定哲學把資源內(nèi)聯(lián)化消除外部依賴。小到一篇文章大到一份合同只要圖片全部以 base64 內(nèi)嵌PDF 生成器就成了一個無狀態(tài)引擎不會因為路徑、權限、防盜鏈、過期時間這些變量而翻車。最后再補充一個個人習慣小程序端每次轉完 base64 后順手把生成 HTML 的片段打個日志打印出來挑幾個字符看一眼前綴是不是data:image。這個動作只要 5 秒鐘卻能讓你在后續(xù)“圖片又沒了”的排查里少走一個小時彎路。祝你一次跑通再也不被圖片空白問題折磨。