圖片合并PDF:內(nèi)存優(yōu)化與壓縮實踐)
1. 項目整體思路拆解需求到底是什么先說一個容易被低估的事實把多張圖片合并成PDF看起來只是調(diào)個接口、寫個循環(huán)的事但實際落地時你會發(fā)現(xiàn)真正的問題全藏在細節(jié)里——圖片尺寸不一、內(nèi)存占用、PDF文件體積失控、圖片方向錯亂、透明通道變黑底……哪一個都能讓你本來半小時能跑完的活兒變成一整天的返工。這個項目的核心需求可以拆成三層第一層最基本的輸入輸出。給定一組圖片可能是JPG、PNG也可能混合按指定順序合并成一個PDF文件每一張圖片占用PDF的一頁。這是所有方案的共同底線。第二層工程化約束。圖片數(shù)量可能是幾張也可能是幾千張圖片來源可能是本地文件、Base64字符串、遠程URL輸出的PDF需要控制體積不能一張高分辨率照片直接變成幾十MB的怪獸文件內(nèi)存占用要可控不能在處理百張以上的圖片時直接把堆內(nèi)存吃滿。第三層體驗與擴展。最終使用者可能希望每頁圖片自適應(yīng)鋪滿頁面、希望在圖片之間插入空白頁或自定義頁眉頁腳、希望圖片按指定尺寸擺放比如證件掃描件要保證物理尺寸一致。這些需求會直接影響技術(shù)選型。從技術(shù)方案上看Java生態(tài)里能實現(xiàn)圖片轉(zhuǎn)PDF的主流路徑有三個iText系列iText 5、iText 7、Apache PDFBox、以及OpenPDF。我在實際項目里三個都試過簡單說下取舍邏輯。iText功能最強對圖片壓縮、頁面布局、字體嵌入的控制粒度最細但iText的AGPL授權(quán)對很多公司來說是個繞不開的坎——除非你愿意把項目開源或者購買商業(yè)授權(quán)。OpenPDF是iText 4的社區(qū)分支授權(quán)相對寬松但功能停留在比較老的形態(tài)遇到特殊需求時文檔少、擴展也麻煩。PDFBox是Apache基金會的項目Apache 2.0許可完全不用操心授權(quán)問題對圖片PDF這種基礎(chǔ)需求支持得很穩(wěn)唯一的缺點是API沒有iText那么高冷好用但勝在邏輯清晰、調(diào)試容易。這個項目我用的是PDFBox理由很簡單圖片轉(zhuǎn)PDF本身就是PDFBox的看家功能之一它把創(chuàng)建文檔—添加圖片頁—寫入文件的過程封裝得足夠干凈同時沒有授權(quán)風(fēng)險。如果你的項目只是把圖片合并成PDF沒有太多花哨的排版需求PDFBox是性價比最高的選擇。也許你會問為什么不用更簡單的方案比如直接用命令行工具如果你在服務(wù)器上手動執(zhí)行一次用工具確實快但如果這個能力要被集成到Web應(yīng)用里、被不同的用戶以不同的參數(shù)反復(fù)調(diào)用就必須有一個程序化的實現(xiàn)。這才是這個項目的題中之義——把能力沉淀成代碼而不是依賴人工操作。2. 核心細節(jié)解析從圖片讀取到PDF生成的每一步2.1 圖片讀取策略按需加載別一口氣全塞進內(nèi)存最容易踩的坑就是寫代碼的時候圖省事先把所有圖片全部讀進BufferedImage列表再遍歷生成PDF。圖片少比如十張以內(nèi)一點問題沒有但一旦超過三五十張每張又是三四MB的手機照片原圖內(nèi)存直接就爆了。我見過一個真實案例某開發(fā)者在Windows本地測得好好的一上Linux服務(wù)器處理兩百張圖片就OOM就是吃了這個虧。正確策略是邊讀邊寫一次只處理一張圖片寫完一頁就釋放引用。try (PDDocument document new PDDocument()) { for (File imageFile : imageFiles) { BufferedImage image ImageIO.read(imageFile); PDPage page new PDPage(PDRectangle.A4); document.addPage(page); PDImageXObject pdImage LosslessFactory.createFromImage(document, image); try (PDPageContentStream contentStream new PDPageContentStream(document, page)) { contentStream.drawImage(pdImage, 0, 0, pageWidth, pageHeight); } // 關(guān)鍵主動釋放當(dāng)前圖片引用讓GC盡快回收 image.flush(); } document.save(outputPath); }image.flush()這一行很多人會忽略。BufferedImage的像素數(shù)據(jù)存在Java堆外或者說由ImageIO內(nèi)部管理flush之后可以釋放內(nèi)部緩沖區(qū)雖然不保證立刻回收但至少給了GC明確信號。處理大批量文件時這行代碼能讓內(nèi)存曲線平穩(wěn)不少。2.2 頁面尺寸與圖片適配圖片鋪滿頁面但不裁切每張圖片都要單獨建一個PDPage。這里有個設(shè)計決策頁面尺寸怎么定兩種常見策略。第一種固定頁面大小A4、Letter等圖片等比縮放后居中繪制頁面統(tǒng)一、適合打印場景。第二種每頁的尺寸跟隨圖片自身的寬高比生成畫冊式PDF文件打開時每頁大小不一。實際項目中絕大多數(shù)需求都是第一種——尤其是要做成電子檔案或打印輸出時統(tǒng)一的頁面尺寸更符合預(yù)期。但直接在drawImage時把圖片拉伸到整個頁面會改變圖片比例人臉會變形表格線會歪。正確做法是按比例縮放計算出圖片在頁面內(nèi)的實際繪制區(qū)域。假設(shè)頁面尺寸為寬pageW、高pageH圖片原始尺寸為寬imgW、高imgH縮放比例取Math.min(pageW / imgW, pageH / imgH)這樣圖片在保持比例的前提下至少在一個維度上鋪滿頁面然后居中繪制。float scale Math.min(pageW / imgW, pageH / imgH); float drawW imgW * scale; float drawH imgH * scale; float x (pageW - drawW) / 2; float y (pageH - drawH) / 2; contentStream.drawImage(pdImage, x, y, drawW, drawH);再補一個更貼合真實需求的設(shè)計如果希望圖片在頁面上留一點邊距可以把pageW和pageH先扣除兩側(cè)邊距再參與計算。比如左右各留20磅那么實際可用寬度就是pageW - 40。這種細節(jié)在實際產(chǎn)出中很能體現(xiàn)差異。2.3 圖片格式與色彩模式透明底、CMYK這些坑怎么填I(lǐng)mageIO在讀取JPEG和PNG上基本沒問題但有兩個高頻問題需要特別注意。第一PNG透明背景。PDFBox在LosslessFactory.createFromImage轉(zhuǎn)換帶Alpha通道的BufferedImage時透明部分有可能變成黑色。這是因為PDF本身支持透明但繪制方式和渲染器的兼容性參差不齊。穩(wěn)妥的做法是先把帶透明的圖片繪制到一張白色底的RGB畫布上再做轉(zhuǎn)換。BufferedImage rgb new BufferedImage(src.getWidth(), src.getHeight(), BufferedImage.TYPE_INT_RGB); Graphics2D g rgb.createGraphics(); g.setColor(Color.WHITE); g.fillRect(0, 0, src.getWidth(), src.getHeight()); g.drawImage(src, 0, 0, null); g.dispose();第二某些JPEG是CMYK色彩模式。ImageIO在部分JDK版本下讀取這種圖片會直接拋異常甚至導(dǎo)致整個進程異常退出。如果業(yè)務(wù)中無法避免上傳這種圖片一個可靠的兜底方案是用工具庫做色彩空間轉(zhuǎn)換。建議在讀取圖片時使用ImageReader并設(shè)置ImageReadParam或者干脆對異常圖片做二次處理先通過Toolkit.createImage或者第三方庫解析成RGB再繼續(xù)。2.4 圖片方向問題為什么手機拍出來的照片是歪的這是我在實際項目中踩過最深的一個坑。手機拍攝的照片尤其是iPhone和一些Android機型JPEG文件里會寫入EXIF信息其中Orientation字段標(biāo)識了拍攝時的設(shè)備方向。但ImageIO.read()返回的BufferedImage已經(jīng)丟掉了EXIF信息直接把這張圖寫進PDF出來的效果就是豎著拍的圖在PDF里橫躺著。解決思路有兩種。簡單粗暴的讀取EXIF中的Orientation值如果是旋轉(zhuǎn)過的比如6表示順時針旋轉(zhuǎn)90度就先用AffineTransform把圖片旋轉(zhuǎn)回正確方向再交給PDFBox。// 用 ImageIO 的 ImageReader 提取 EXIF ImageReader reader ImageIO.getImageReadersByFormatName(jpeg).next(); reader.setInput(ImageIO.createImageInputStream(imageFile)); IIOMetadata metadata reader.getImageMetadata(0); // 從 metadata 中解析 Orientation 字段標(biāo)準(zhǔn) Exif TIFF tag 0x0112 // 6: 旋轉(zhuǎn)90度3: 旋轉(zhuǎn)180度8: 旋轉(zhuǎn)270度圖片方向處理邏輯不復(fù)雜但它屬于那種不遇到就永遠不知道存在的問題。如果你的項目涉及手機圖片上傳這步必須提前設(shè)計好。3. 實操過程完整代碼實現(xiàn)與參數(shù)選擇3.1 工程結(jié)構(gòu)與依賴配置以Maven項目為例只需要一個核心依賴dependency groupIdorg.apache.pdfbox/groupId artifactIdpdfbox/artifactId version2.0.27/version /dependency2.0.27是目前基于2.x系列的穩(wěn)定版本。PDFBox 3.x也發(fā)布了但包結(jié)構(gòu)和部分API有變動如果你是從老項目升級建議先在測試環(huán)境驗證兼容性再切換。另外PDFBox在處理圖片壓縮時會依賴commons-logging一般情況下Maven會幫我?guī)湍銕氩恍枰獑为毰渲谩?.2 完整實現(xiàn)支持本地圖片目錄轉(zhuǎn)PDF我把核心代碼完整貼出來這個版本支持目錄下所有圖片按文件名順序合并、自適應(yīng)頁面等比縮放、透明通道處理、以及基礎(chǔ)的內(nèi)存優(yōu)化。import org.apache.pdfbox.pdmodel.PDDocument; import org.apache.pdfbox.pdmodel.PDPage; import org.apache.pdfbox.pdmodel.PDPageContentStream; import org.apache.pdfbox.pdmodel.common.PDRectangle; import org.apache.pdfbox.pdmodel.graphics.image.PDImageXObject; import org.apache.pdfbox.pdmodel.graphics.image.LosslessFactory; import javax.imageio.ImageIO; import java.awt.Color; import java.awt.Graphics2D; import java.awt.image.BufferedImage; import java.io.File; import java.io.IOException; import java.util.Arrays; import java.util.Comparator; public class ImageToPdfConverter { public static void convert(File imageDir, File outputPdf, float margin) throws IOException { File[] images imageDir.listFiles((dir, name) - { String lower name.toLowerCase(); return lower.endsWith(.jpg) || lower.endsWith(.jpeg) || lower.endsWith(.png); }); if (images null || images.length 0) { throw new IllegalArgumentException(目錄下沒有找到圖片文件: imageDir.getAbsolutePath()); } // 按文件名排序保證合并順序穩(wěn)定 Arrays.sort(images, Comparator.comparing(File::getName)); try (PDDocument document new PDDocument()) { for (File imageFile : images) { BufferedImage image ImageIO.read(imageFile); if (image null) { System.err.println(跳過無法讀取的圖片: imageFile.getName()); continue; } // 透明PNG轉(zhuǎn)白底RGB避免黑底問題 if (image.getColorModel().hasAlpha()) { BufferedImage rgb new BufferedImage(image.getWidth(), image.getHeight(), BufferedImage.TYPE_INT_RGB); Graphics2D g rgb.createGraphics(); g.setColor(Color.WHITE); g.fillRect(0, 0, image.getWidth(), image.getHeight()); g.drawImage(image, 0, 0, null); g.dispose(); image.flush(); image rgb; } // 頁面大小默認(rèn)A4減去邊距 float pageW PDRectangle.A4.getWidth() - 2 * margin; float pageH PDRectangle.A4.getHeight() - 2 * margin; float imgW image.getWidth(); float imgH image.getHeight(); float scale Math.min(pageW / imgW, pageH / imgH); float drawW imgW * scale; float drawH imgH * scale; float x (PDRectangle.A4.getWidth() - drawW) / 2; float y (PDRectangle.A4.getHeight() - drawH) / 2; PDPage page new PDPage(PDRectangle.A4); document.addPage(page); PDImageXObject pdImage LosslessFactory.createFromImage(document, image); try (PDPageContentStream cs new PDPageContentStream(document, page)) { cs.drawImage(pdImage, x, y, drawW, drawH); } image.flush(); } document.save(outputPdf); } System.out.println(PDF生成成功: outputPdf.getAbsolutePath()); } public static void main(String[] args) throws IOException { File imageDir new File(args.length 0 ? args[0] : ./images); File outputPdf new File(args.length 1 ? args[1] : ./output.pdf); convert(imageDir, outputPdf, 20f); } }幾個參數(shù)解釋PDRectangle.A4是PDFBox內(nèi)置的A4尺寸常量寬595磅、高842磅。1磅等于1/72英寸A4紙的物理尺寸是210mm x 297mm換算過來正是這個數(shù)值。margin控制頁邊距我這里默認(rèn)20磅約7mm實際打印場景建議不小于10磅。排序用了Comparator.comparing(File::getName)字符串排序。如果要按拍攝時間或自定義序號排序需要自行擴展比較器。3.3 圖片壓縮控制PDF體積的關(guān)鍵把多張高分辨率圖片直接塞進PDF生成的文件會大得離譜。一張4000x3000的JPEG照片不處理直接嵌入PDF體積可能在8~12MB。如果有一百張這種照片PDF就是1GB級別的怪物。PDFBox提供了一個簡單有效的壓縮方式用JPEGFactory替代LosslessFactory。前者會讓PDFBox對圖片重新編碼你可以用JPEGFactory.createFromImage(document, image, quality)指定質(zhì)量參數(shù)0.0f ~ 1.0f之間0.75f通常是一個視覺質(zhì)量和體積的平衡點。// 替換上一版代碼中的 LosslessFactory 調(diào)用 PDImageXObject pdImage JPEGFactory.createFromImage(document, image, 0.75f);壓縮后單張4000x3000照片的體積能壓到1.5MB以內(nèi)一百張圖片的PDF大約在150MB左右已經(jīng)相對可接受了。但要注意JPEG是有損壓縮如果業(yè)務(wù)要求圖片必須無損保存比如合同掃描件、醫(yī)療影像那就不能用這個方案老老實實用LosslessFactory。還有一個可以優(yōu)化的點如果圖片尺寸本身遠遠超過頁面能顯示的尺寸可以在建立BufferedImage時就做縮放。比如A4紙頁面滿幅顯示大約需要1500x2100像素的清晰度超過這個數(shù)值的圖片在打印和屏幕上已經(jīng)看不出區(qū)別了提前降采樣能顯著控制內(nèi)存和PDF體積。private BufferedImage scaleToFit(BufferedImage src, int maxWidth, int maxHeight) { int w src.getWidth(); int h src.getHeight(); if (w maxWidth h maxHeight) { return src; } double scale Math.min((double) maxWidth / w, (double) maxHeight / h); int newW (int) (w * scale); int newH (int) (h * scale); BufferedImage resized new BufferedImage(newW, newH, BufferedImage.TYPE_INT_RGB); Graphics2D g resized.createGraphics(); g.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BICUBIC); g.drawImage(src, 0, 0, newW, newH, null); g.dispose(); src.flush(); return resized; }實測經(jīng)驗在圖片壓縮上花費的功夫比整個PDF生成邏輯都要多。因為PDF文件的體積直接關(guān)系到后續(xù)的存儲成本和傳輸時間這個環(huán)節(jié)省不得。3.4 目標(biāo)生成一個可復(fù)用的工具類上面三段代碼可以組合成一個完整的工具類。注意我把文件讀取、圖片預(yù)處理、縮放、壓縮、PDF寫入分成了獨立步驟這樣每個環(huán)節(jié)都能單獨測試出問題時容易定位。真實項目中不建議把所有邏輯都堆在一個main方法里我之前為了趕工這么干過后續(xù)維護加需求時非常痛苦。4. 常見問題與排查技巧實錄4.1 生成PDF時內(nèi)存溢出OOM這是排行榜第一的問題。原因基本集中在一次性讀取全部圖片到內(nèi)存、未釋放BufferedImage、以及沒有對超大圖片做降采樣。排查手段很簡單在JVM啟動參數(shù)里加-Xmx512m甚至更小復(fù)現(xiàn)問題看異常堆棧指向哪一行。如果是createFromImage附近基本就是圖片太大或者積累太多。處理順序是先做降采樣再做單張?zhí)幚碜詈蠹觙lush()。三個都做了幾百張圖片跑下來應(yīng)該很穩(wěn)。4.2 生成的PDF里圖片模糊模糊的根源通常是兩種情況一是縮放比例算錯了把圖片畫到了比原始尺寸更大的區(qū)域二是drawImage傳入的寬度高度參數(shù)用了整型截斷導(dǎo)致比例輕微變形。檢查點確認(rèn)drawW和drawH與原始圖片的寬高比一致確認(rèn)PDF頁面尺寸沒有設(shè)置成過大的大尺寸。另外如果是用JPEGFactory壓縮質(zhì)量參數(shù)低于0.5會明顯看到壓縮噪點。4.3 圖片旋轉(zhuǎn)90度問題定位方法打開原始圖片文件如果圖片在系統(tǒng)自帶查看器里正常但在生成的PDF里橫過來了99%是EXIF方向信息沒處理。解決方案就是前面提到的解析Orientation字段并執(zhí)行對應(yīng)旋轉(zhuǎn)。常見Orientation值對照1為正常3為旋轉(zhuǎn)180度6為順時針旋轉(zhuǎn)90度即需要逆時針旋轉(zhuǎn)90度來修正8為逆時針旋轉(zhuǎn)90度需要順時針旋轉(zhuǎn)90度修正。4.4 頁面出現(xiàn)黑色或異常色塊基本是Alpha通道問題。解決方式就是轉(zhuǎn)白底RGB代碼里已經(jīng)有了。如果是CMYK的JPEG現(xiàn)象是顏色明顯偏色或者拋異常需要引入額外的色彩轉(zhuǎn)換邏輯。4.5 大文件PDF打開很慢原因通常是圖片未做壓縮PDF整體體積太大。上面給的JPEGFactory壓縮方案可以直接緩解。另外PDFBox還支持設(shè)置document.setAllSecurityToBeRemoved()這類參數(shù)優(yōu)化打開速度不過對圖片型PDF幫助有限。問題可能原因解決方案OOM一次性讀取過多圖片、未釋放資源單張?zhí)幚? flush 降采樣圖片模糊縮放比例錯誤、壓縮質(zhì)量低檢查寬高比、提高quality參數(shù)圖片橫向EXIF方向未處理解析Orientation字段并旋轉(zhuǎn)黑色背景Alpha通道未處理繪制到白色RGB畫布文件過大圖片未壓縮使用JPEGFactory 降采樣5. 場景擴展單圖片多頁面、自定義排版與批量處理能力5.1 單張圖片跨多頁輸出有些場景下一張圖片非常大比如一張超長截圖聊天記錄、網(wǎng)頁全頁截圖需要切成多頁輸出。思路是把圖片按固定高度切片每一片生成一個PDF頁面。int sliceHeight 1200; // 每頁顯示1200像素高度 int totalSlices (image.getHeight() sliceHeight - 1) / sliceHeight; for (int i 0; i totalSlices; i) { BufferedImage subImage image.getSubimage(0, i * sliceHeight, image.getWidth(), Math.min(sliceHeight, image.getHeight() - i * sliceHeight)); // 將 subImage 添加到新頁面 }這種方式生成的PDF每一頁顯示圖片的一個連續(xù)片段上下銜接屏幕閱讀很自然。注意getSubimage返回的圖片與原始圖片共享數(shù)據(jù)緩沖區(qū)如果原始圖片在循環(huán)中被flush()會影響子圖——所以切片完成后立即處理每片或者用BufferedImage復(fù)制一份。5.2 圖片與文字混排如果PDF里需要章節(jié)標(biāo)題、頁碼或者頁眉頁腳可以在PDPageContentStream里同時畫圖片和文字PDFBox本身支持showText配合字體配置就能實現(xiàn)圖文混排。cs.beginText(); cs.setFont(PDType1Font.HELVETICA_BOLD, 16); cs.newLineAtOffset(margin, pageHeight - 30); cs.showText(業(yè)務(wù)文件 - index); cs.endText();對于中文內(nèi)容需要加載中文字體文件通常是TTF否則中文會變成亂碼。這個坑不建議躲直接在資源目錄放一個字體文件用PDType0Font.load(document, fontFile)加載即可。5.3 批量任務(wù)并行化當(dāng)圖片數(shù)量特別多例如上千張單線程處理時間可能達到幾十秒甚至數(shù)分鐘??梢园褕D片列表分片用固定線程池并行處理每個線程生成獨立的PDDocument最后再用PDFBox的PDFMergerUtility合并。PDFMergerUtility merger new PDFMergerUtility(); merger.setDestinationFileName(outputPath); for (File tempPdf : tempFiles) { merger.addSource(tempPdf); } merger.mergeDocuments(null);實測下來四核機器上并行處理的加速比大約在2.5~3倍之間內(nèi)存占用會比單線程高需要根據(jù)圖片大小調(diào)整分片數(shù)和每個線程處理的圖片數(shù)量上限。并行化的前提是每張圖片的處理相互獨立這個場景天然滿足。6. 實操總結(jié)與避坑指南項目做到后面我自己的體會是圖片轉(zhuǎn)PDF不難難的是在真實業(yè)務(wù)約束下把內(nèi)存、體積、清晰度、順序、方向這些因素同時控制住。幾個值得沉淀的經(jīng)驗第一圖片處理鏈路中每一個BufferedImage都是資源不是在用完之后馬上flush()就是在計算完尺寸后立刻降采樣不要讓大圖在內(nèi)存里停留超過必要的時間。第二PDFBox的LosslessFactory與JPEGFactory不是隨便選的。業(yè)務(wù)要求無損就選前者業(yè)務(wù)強調(diào)體積可控就選后者兩者切換非常容易所以建議把壓縮開關(guān)做成一個可配置參數(shù)而不是寫死在代碼里。第三圖片排序邏輯一定要顯式設(shè)計。按文件名排序看起來簡單但如果文件名是1.jpg、2.jpg、10.jpg字符串排序會得到1、10、2的順序這種問題不跑到實際數(shù)據(jù)上很難發(fā)現(xiàn)。需要自然排序時可以用Comparator自行比較數(shù)字部分。第四保留原始圖片。PDF生成只是導(dǎo)出動作不意味著原始素材可以被刪除。很多項目上線后才發(fā)現(xiàn)用戶需要的是PDF的同時還需要保留原圖做編輯這個需求往往等到開發(fā)完成后才會提出來。這些經(jīng)驗不是一兩次就能總結(jié)全的好在這個方案本身足夠簡單后續(xù)維護起來壓力不大。如果在實際使用中遇到新的問題回到這幾個關(guān)鍵環(huán)節(jié)排查大概率能找到答案。