☰
Java批量图片转PDF实战:PDFBox选型、布局缩放与踩坑记录
2026/10/16 13:13:28 网站建设 项目流程

去年年底,我帮某个业务部门处理一整年的报销单据,两百多张拍摄得歪歪扭扭的发票图片要按顺序合成一个PDF交给审计。起初我用系统自带的“打印为PDF”功能,一张张拖进去,调纸型、调边距,折腾了一下午才弄了四十多张,崩溃感直接拉满。后来我改用Java写了一个批量图片转PDF的小工具,两百多张图跑完不到十秒钟,输出文件整整齐齐,后来这个工具被我们扩展成了带合并、加密、自动缩放功能的内部模块,至今还在用。

这篇文章就把这套“Java实现将多张图片保存至PDF”的完整思路拆开讲讲。从第三方库选型、第一个可运行的Demo,到图片尺寸换算、超大图内存控制,再到合并已有PDF、添加元数据、设置访问密码,每一步都会给出具体的代码和踩坑记录。不管是Java新手还是平时只写业务代码的朋友,照着这套逻辑去改,基本都能落地。

1. 为什么不用“另存为PDF”:批量场景下手工操作才是最大的坑

很多人的第一反应是:图片转PDF不是PDF阅读器自带功能吗?为什么还要写代码?

这么说其实没错。三五张图片,打开PDF阅读器,把图片拖进去,甚至直接右键用系统“打印为PDF”,都是可行的。但一旦图片数量来到三位数、四位数量级,并且这个操作不是偶尔做一次,而是每周、每月都要重复做,手工方案的缺点就会非常明显。

第一是顺序不稳定。不同图片的命名规则经常不一致,有叫“1.jpg”的,有叫“扫描件_20240501_001.jpg”的,还有从聊天记录里保存下来的长图,按文件名排序和按修改时间排序结果完全不同。手工操作时稍不注意顺序就错了,审计和归档场景下这是致命问题。

第二是尺寸和清晰度失控。手机拍照的图片往往带大量空白边角,扫描仪导出的图片分辨率又各不相同。手工把图插进PDF,要么图片被拉伸变形,要么页面出现大片空白,要么图片边缘被裁掉。用PDF阅读器生成PDF时,边距、页宽这些参数要反复试,很难保证每次输出规格一致。

第三是重复劳动。我见过某同事处理月度报表归档,一个月就要操作一次,每次将近两小时,内容一模一样,只是图片换了。这种毫无增值空间的重复操作,恰恰是应该交给程序去做的。

所以,用Java写一个批量转换工具的动机其实很朴素:输入一个图片文件夹,输出一个按指定顺序排版、统一页面尺寸、可选加密的PDF。第一次写可能要一两个小时,但以后每次运行只需一条命令,时间和心智成本都归零。接下来的内容,就围绕这个目标展开。

2. 选库不踩坑:iText、PDFBox、OpenPDF 三选一

Java生态里能操作PDF的库不少,但真正适合图片转PDF场景的就那么几个。一开始我查资料的时候,被各种博客里“推荐iText”的说法带偏过,后来深入了解许可证条款才明白,库的选型不是看谁功能多,而是看你敢不敢在项目里用。

2.1 三个主流库到底有什么区别

iText是Java PDF领域的老牌库,功能确实全面,文字、表格、图片、签章、表单统统能做。但它的许可证是AGPL,这意味着如果你的项目是闭源商业软件,直接使用iText就必须把整个项目的源码按AGPL协议开源,或者购买商业授权。个人学习无所谓,公司内部工具一旦涉及对外分发,这就是个不小的合规隐患。

PDFBox是Apache基金会的开源项目,许可证是Apache License 2.0,对商业项目非常友好。它的API风格偏底层,操作起来像在“手撸PDF结构”,不像iText那样高屋建瓴,但也正因如此,图片布局、页面控制这类需求反而更容易掌控,没什么隐藏逻辑。

OpenPDF则是一个从iText 4时代分叉出来的社区维护项目,许可证是LGPL/MPL双许可,商业场景也可以免费使用,但要留意动态链接和再分发的条款要求。功能上比iText 5少一点,胜在轻量。

我用一张表概括一下当时做选型时考虑的关键点:

对比项iText 7PDFBox 2.xOpenPDF
许可证AGPL(商业受限)Apache 2.0(宽松)LGPL/MPL(需注意条款)
图片支持强,封装度高中等,需自己算布局较强,继承自iText 4
页面控制底层到上层都有完全暴露PDF对象偏传统
文档体积模块化,按需引入单个依赖,简单直接单个依赖
适合场景复杂PDF生成工具类、批量处理轻量处理
坑点许可证传染性某些API偏底层文档更新较慢

2.2 为什么最终选了PDFBox

我的使用场景是内部工具,没有开源诉求,许可证是第一优先级。PDFBox恰好提供了最简单的依赖方式:一个Maven坐标引入,就能获得Document创建、图片写入、页面合并、加密等几乎所有能力。相比iText还要拆core、io、fontbox、images等模块,PDFBox的“一键引入”对快速开发更友好。

这里补充一点,PDFBox 3.x已经发布,API相比2.x有一些改动,比如创建图片对象的方式从LosslessFactory迁移到了新的PDImageXObject.createFromFile。考虑到当前网上大量资料和大多数存量项目还是2.x,我下面演示的代码全部基于2.0.x,逻辑清晰、容易对照,如果你想迁移到3.x,主要是类的构造方式变化,整体套路是一样的。

3. 环境准备与第一个能跑的Demo

在动手写布局细节之前,先跑通一个最简版本,把“图片能进PDF”这个基本链路打通。这样后面调缩放、调边距时,心里能确定问题到底出在PDF写入层还是布局计算层。

3.1 Maven依赖与基本参数

用Maven管理项目,只需要引入一个依赖:

<dependency> <groupId>org.apache.pdfbox</groupId> <artifactId>pdfbox</artifactId> <version>2.0.31</version> </dependency>

JDK版本建议8以上,代码里会用到try-with-resources和Lambda,低于8会有点别扭。然后定义一个最简的转换流程:遍历一个文件夹下的图片文件,创建PDF文档,每张图新建一页并绘制到页面上。

3.2 最简代码:先把图片“塞”进PDF

假设你的图片都在一个List里,文件名已经排好序:

import org.apache.pdfbox.pdmodel.PDDocument; import org.apache.pdfbox.pdmodel.PDPage; import org.apache.pdfbox.pdmodel.PDPageContentStream; import org.apache.pdfbox.pdmodel.graphics.image.PDImageXObject; import javax.imageio.ImageIO; import java.awt.image.BufferedImage; import java.io.File; import java.util.List; public class SimpleImageToPdf { public static void convert(List<String> imagePaths, String outputPath) throws Exception { File outFile = new File(outputPath); try (PDDocument document = new PDDocument()) { for (String imagePath : imagePaths) { File imgFile = new File(imagePath); // 1. 读取图片像素数据 BufferedImage image = ImageIO.read(imgFile); // 2. 新建一页A4页面 PDPage page = new PDPage(PDRectangle.A4); document.addPage(page); // 3. 将BufferedImage转换为PDFBox可识别的图片对象 PDImageXObject pdImage = LosslessFactory.createFromImage(document, image); // 4. 打开内容流,把图片画到页面上 try (PDPageContentStream contentStream = new PDPageContentStream(document, page)) { // 先把图片的原始像素尺寸原样画上去 contentStream.drawImage(pdImage, 0, 0, image.getWidth(), image.getHeight()); } } document.save(outFile); } } }

看一下这段代码做了什么。PDDocument相当于一个PDF文件的内存模型,所有页面都加在它上面。PDPage代表一页,这里用PDRectangle.A4指定A4纸型。LosslessFactory.createFromImage的作用是把Java图像对象转成PDFBox能写入的图片对象,它内部会保留无损格式,所以图片质量不会在这里被二次压缩损耗。最后PDPageContentStream负责向指定页面写入绘图指令,drawImage的参数是:图片对象、左下角x坐标、左下角y坐标、显示宽度、显示高度。

如果你只是想把图片“放进去”,这段代码已经够了。但跑过之后你很快会发现两件事:图片大概率超出版面被裁掉,而且所有图片都堆在页面左下角,位置很难看。这就引出下一章要重点讲的布局问题。

3.3 第一次运行容易遇到的坑

很多第一次跑通的朋友都会报同一个错:java.lang.NoClassDefFoundError。这通常不是代码问题,而是PDFBox 2.x依赖了commons-logging,如果只拉了pdfbox而漏掉了传递依赖,会出现运行时类找不到。最稳妥的做法是使用Spring Boot等自带依赖管理的方式,或者确保Maven的依赖下载和解析正常。

另外,图片格式方面,ImageIO默认支持JPEG、PNG、BMP、GIF等常见格式,但某些特殊编码的TIFF文件可能读不出来。处理扫描件时如果遇到“图片读取为null”,先别怀疑PDFBox,先用ImageIO单独读一下,验证图片本身是否正常。

4. 把图片“装”得好看:尺寸换算、缩放布局与分页细节

PDF的坐标系统和我们平时屏幕上的坐标系不一样。屏幕坐标系原点通常在上方,Y轴向下;PDF的原点在页面左下角,Y轴向上。单位也不是像素,而是“点”(point),1点等于1/72英寸。A4纸对应的点是595.2756 x 841.8898,因此代码里直接使用PDRectangle.A4就不用手动算这两个数字。

图片在PDF中没有天然的物理尺寸概念,你调用drawImage时指定多大矩形,它就显示多大矩形。这和直接在打印机上输出图片的逻辑很像,关键看你打算让它占据页面的多少面积。

4.1 保持比例、自动缩放并居中

手机拍摄的照片常见比例是4:3或者3:4,直接拉伸到A4页面要么变形,要么贴边。标准做法是按页面有效区域等比缩放,并居中放置。

比例缩放的算法很简单:分别算出图片尺寸与页面可用宽高的比值,取其中较小值作为缩放因子,然后把图片按相同比例整体缩小或放大,这样一个维度贴合页面,另一个维度则留有空白,图片不会变形。

import org.apache.pdfbox.pdmodel.PDPage; import org.apache.pdfbox.pdmodel.PDPageContentStream; import org.apache.pdfbox.pdmodel.graphics.image.PDImageXObject; import java.awt.image.BufferedImage; public class ImageLayoutUtils { // 页面四周留 30pt 边距 private static final float MARGIN = 30f; public static void drawCenteredScaledImage( PDDocument document, PDPage page, BufferedImage image, PDImageXObject pdImage) throws Exception { // 页面可用宽高 = 页面尺寸减去上下左右边距 float usableWidth = page.getMediaBox().getWidth() - 2 * MARGIN; float usableHeight = page.getMediaBox().getHeight() - 2 * MARGIN; float imageWidth = image.getWidth(); float imageHeight = image.getHeight(); // 等比缩放系数:取两个方向缩放值中的较小值 float scale = Math.min(usableWidth / imageWidth, usableHeight / imageHeight); float displayWidth = imageWidth * scale; float displayHeight = imageHeight * scale; // 居中:X从左侧边距开始,Y从页面底部开始留出边距 float startX = MARGIN + (usableWidth - displayWidth) / 2; float startY = MARGIN + (usableHeight - displayHeight) / 2; try (PDPageContentStream contentStream = new PDPageContentStream(document, page)) { contentStream.drawImage(pdImage, startX, startY, displayWidth, displayHeight); } } }

调用时,先把BufferedImage转成PDImageXObject,再调用上面的方法:

PDImageXObject pdImage = LosslessFactory.createFromImage(document, image); ImageLayoutUtils.drawCenteredScaledImage(document, page, image, pdImage);

这段代码的关键点在于startY的计算。如果直接用0作为Y坐标,图片会紧贴页面底部,视觉上非常奇怪。把Y设为页面高度减去图片显示高度再除以2,是PDF中常用的居中方式。

4.2 页面尺寸的选择:A4、信纸还是自定义

不是所有场景都适合A4。某基层单位做档案数字化时,原始扫描件是A3的横向表格,硬塞进A4页面会导致字体缩到看不清。这时有两种选择:一是用更大的页面,比如PDRectangle.A3;二是保持A4但把页面方向改为横向。

PDFBox支持在创建页面时手动指定方向,方法是在new PDPage时传递一个旋转后的矩形。比如A4横向页面:

PDRectangle landscapeRect = new PDRectangle( PDRectangle.A4.getHeight(), PDRectangle.A4.getWidth()); PDPage page = new PDPage(landscapeRect);

其实相当于把A4的长边和短边互换。更灵活的做法是先读取所有图片,按图片尺寸判断应该用竖向还是横向页面,再决定创建哪种页面。批量场景下我通常按第一张图的方向来决定整个PDF的页面方向,因为一个PDF内页面方向保持一致更符合阅读习惯。

4.3 超大图片的内存控制

图片尺寸这件事很容易被忽略,尤其是现在手机拍摄的图片动辄4000x3000,甚至部分平板扫描文件有8000x6000像素。ImageIO.read会把整张图片的像素加载进内存,一张4000x3000的RGBA图片光像素数据就要占用大约4800万字节,好几张图同时处理时,内存占用会非常可观。

更好的做法是,在图片读取阶段做一次“智能扫描”:先用ImageReader读取图片宽高,如果尺寸超过一个阈值(比如2000x2000像素),就先把图片缩小再交给PDFBox。这样内存占用大幅下降,同时因为PDF最终是矢量容器,显示时由阅读器按页面尺寸渲染,适度缩小的图片肉眼很难分辨差别。

BufferedImage readAndScaleIfNeeded(String imagePath, int maxWidth, int maxHeight) throws Exception { BufferedImage original = ImageIO.read(new File(imagePath)); int w = original.getWidth(); int h = original.getHeight(); // 需要缩小时才处理 if (w <= maxWidth && h <= maxHeight) { return original; } double scale = Math.min((double) maxWidth / w, (double) maxHeight / h); int targetW = (int) Math.round(w * scale); int targetH = (int) Math.round(h * scale); BufferedImage scaled = new BufferedImage(targetW, targetH, BufferedImage.TYPE_INT_RGB); Graphics2D g = scaled.createGraphics(); g.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BICUBIC); g.drawImage(original, 0, 0, targetW, targetH, null); g.dispose(); return scaled; }

这里有个小技巧:把目标类型设成TYPE_INT_RGB而不是TYPE_INT_ARGB。因为普通照片不需要透明度,转成RGB之后还可以顺带解决后面会提到的PNG透明背景问题。

5. 进阶能力:合并已有PDF、设置元数据与密码保护

图片转PDF只是这个工具的最基础能力。归档场景里,经常还要处理“已经有几个PDF文件,要把它们合并成一个”或者“生成的PDF不能让别人随意修改”这类需求。PDFBox在这方面的API其实很简洁。

5.1 把两个PDF合并成一个PDF

合并的底层逻辑很简单:把源PDF里的每一页逐个加入目标文档。注意页面对象必然属于某个文档,不能直接在两个PDDocument之间传递同一个页面实例的引用,必须通过addPage把页面“复制”进目标文档,由PDFBox内部处理归属关系。

public static void mergePdfFiles(List<File> pdfFiles, String outputPath) throws Exception { try (PDDocument merged = new PDDocument()) { for (File pdfFile : pdfFiles) { try (PDDocument src = PDDocument.load(pdfFile)) { for (PDPage page : src.getPages()) { merged.addPage(page); } } } merged.save(outputPath); } }

确保源文档在循环内被及时关闭,否则大量PDF同时打开会耗尽文件句柄。这个细节在批量合并上百个文件时特别重要,我因为漏掉内层close,曾经遇到过FileNotFoundException,排查了很久才发现是句柄泄漏。

5.2 文档信息与加密:保护PDF不被随意改动

PDF的元数据包括标题、作者、主题等,可以在保存前写入:

PDDocumentInformation info = document.getDocumentInformation(); info.setTitle("序列扫描件合集"); info.setAuthor("档案整理助手"); info.setSubject("2024年度资料归档");

如果需要设置打开密码,PDFBox提供了一套基于标准安全策略的加密方式:

AccessPermission permission = new AccessPermission(); permission.setCanModify(false); // 禁止修改 permission.setCanPrint(true); // 允许打印 StandardProtectionPolicy policy = new StandardProtectionPolicy("owner123", "user123", permission); policy.setEncryptionKeyLength(128); document.protect(policy);

注意两个坑。第一,StandardProtectionPolicy的ownerPassword不允许为空,否则会抛异常,这是PDF规范对“所有者权限”的强制要求,不是PDFBox的限制。第二,加密之后的PDF在其它第三方库里再次加载时,需要传入密码才能打开,比如合并操作就要用到PDDocument.load(file, password)。如果这个逻辑写进了工具里,调用方在传参时需要把密码一起传进来,否则后面处理会中断。

5.3 在图片转PDF的基础上同时完成合并

实际项目里,图片转PDF和PDF合并往往是打包一起用的。比如某份资料里既有图片扫描件,又有一套系统导出的纯PDF表格,需求是把图片生成的PDF放在前几页,表格PDF接在后面,变成一个完整文件。实现时不需要先落盘两个临时PDF再合并,可以直接在一个PDDocument里依次添加图片页面,然后再把源PDF的页面add进去,一步到位,避免磁盘临时文件带来的路径和清理问题。

6. 大批量图片实战经验:我踩过的坑和最终的优化版本

前面几章已经把核心实现讲完了,这一章是我在批量处理过程中总结出来的几个高频问题,每一个都让代码发生过“看起来正常实际输出错误”的状况,很值得提前避开。

6.1 坑一:PNG透明背景变成黑色

PNG图片常带透明通道,而部分PDF阅读器在渲染带透明区域的图片时表现得不太一致,常见的现象是透明背景显示为黑色色块。看起来像PDFBox转换出了错,其实问题出在图片格式的Alpha通道上。

解决办法有两个。最省事的是在读取图片后,把所有图片统一转成RGB模式,把透明背景用白色填充。上面的readAndScaleIfNeeded里把BufferedImage设成TYPE_INT_RGB就是这个用意。

另一种方式是在画图前用Graphics2D先在一张白色底图上绘制原图,再用这张白色底图作为PDF图片来源:

BufferedImage whiteBackground = new BufferedImage( image.getWidth(), image.getHeight(), BufferedImage.TYPE_INT_RGB); Graphics2D g = whiteBackground.createGraphics(); g.setColor(Color.WHITE); g.fillRect(0, 0, image.getWidth(), image.getHeight()); g.drawImage(image, 0, 0, null); g.dispose();

6.2 坑二:手机图片的EXIF方向信息导致图片被旋转

现在的手机照片很多带有EXIF方向标记,提示阅读器应该旋转90度或180度展示。ImageIO.read读取到的像素数组本身可能没有做方向换算,也就是说你看到的BufferedImage实际是“躺着的”,但元数据里写着“请顺时针旋转90度”。直接写入PDF就会得到一张方向不对的图。

判断是否用ImageIO处理了方向,最简单的方法是写一段测试:读取一张已知“横着拍”的图片,打印宽和高,如果宽和高和肉眼看到的画面方向不一致,就需要处理EXIF方向。处理的库很多,Java标准库没有原生支持,我用的是开源组件metadata-extractor,在读取图片后手动旋转BufferedImage。判断逻辑一般参考EXIF Orientation字段的1到8,其中3代表旋转180度,6代表需要顺时针旋转90度,8代表逆时针旋转90度。如果不想引入额外依赖,也可以先对常见方向值做硬编码处理,多数办公扫描场景不会遇到太复杂的维度。

6.3 坑三:文件名排序不是自然顺序

批量处理时,图片文件名常常是“page_1.jpg”“page_10.jpg”“page_2.jpg”。普通的字符串排序会把10排在2前面,导致页码完全错乱。这个问题不体现为报错,而是生成出来的PDF顺序错乱,等到审查时才发现,非常浪费时间。

解决方案是对文件名做自然排序,或者先抽取文件名的数字编号再按整数排序:

List<File> sortedFiles = imageFiles.stream() .sorted(Comparator.comparingInt(f -> extractNumber(f.getName()))) .collect(Collectors.toList());

extractNumber可以用正则把文件名中的第一组连续数字提取出来。如果文件本身就是按录入顺序命名的,这个办法最直接。

6.4 优化后的主流程

把前面提到的所有能力整合起来后,我最终的工具主流程结构大概是这样的:

public static void batchImageToPdf(String inputDir, String outputPath, String password) throws Exception { List<File> imageFiles = collectAndSortImages(inputDir); try (PDDocument document = new PDDocument()) { for (File imgFile : imageFiles) { // 1. 读取并缩放图片 BufferedImage image = readAndScaleIfNeeded( imgFile.getAbsolutePath(), 2000, 2000); // 2. 根据图片宽高决定页面方向 PDPage page = createPageBasedOnImageSize(image); document.addPage(page); // 3. 生成PDF图片对象 PDImageXObject pdImage = LosslessFactory.createFromImage(document, image); // 4. 居中绘制并等比缩放 drawCenteredScaledImage(document, page, image, pdImage); } document.save(outputPath); } }

到了这一步,最基本的批量转换、页面自适应、超大图内存控制已经成形。如果你的需求到这里就满足了,后面的内容可以当扩展阅读。

7. 再进一步:自定义缩略图网格与多图分页

前面的实现默认一页一张大图,这是扫描件归档最合理的方式。但有些场景希望一页放下多张图片,比如会议翻拍的PPT截图,一页四张排列更加便于快速浏览。PDFBox同样可以做,只是需要自己计算网格位置。

核心思路是:先在逻辑上把一页横向分成若干列、纵向分成若干行,然后根据网格行列计算每个图片所在的小矩形区域,再把每张图按这个区域等比缩放居中绘制。

public static void drawGridImages(PDDocument document, PDPage page, List<BufferedImage> images, int cols, int rows, float margin) throws Exception { float pageWidth = page.getMediaBox().getWidth(); float pageHeight = page.getMediaBox().getHeight(); float gridWidth = (pageWidth - (cols + 1) * margin) / cols; float gridHeight = (pageHeight - (rows + 1) * margin) / rows; for (int i = 0; i < images.size(); i++) { int row = i / cols; int col = i % cols; float cellX = margin + col * (gridWidth + margin); float cellY = pageHeight - margin - (row + 1) * gridHeight; // 每张图在cell内居中等比绘制 // drawIntoRect(document, page, images.get(i), cellX, cellY, gridWidth, gridHeight); } }

注意这里Y轴的计算是从页面顶部往下铺网格,把PDF的底部原点坐标系转换成我们更熟悉的“从上往下”的网格逻辑。没有这一步,图片顺序会从上到下反着排,看起来像个镜像PDF。

不过网格布局会和前文的“一页一张图”产生需求冲突,我建议在工具里做成模式参数,比如MODE_SINGLE和MODE_GRID,而不是各写一套。实际使用中,我还会根据图片数量自动选择:如果图片总数是4的倍数且单张尺寸不够大,就自动切换成4宫格,整体阅读体验更好。

8. 写在最后:几个非常实用的建议

整个工具写完之后,代码已经稳定运行了大半年。回过头看,有几点经验很想分享给同样在做图片转PDF的人。

一是工具类一定要保持无状态。PDFBox的操作对象PDDocument本身有状态,但你的工具方法最好每次都重新创建,不要设计成复用同一个PDDocument实例,否则在多线程或者连续多次调用时,会出现页面归属异常。

二是图片的清晰度要按“显示目标做减法”,不一定要保留原图4000x3000的全部细节。归档文件打印出来通常是A4幅面,输出到PDF时的有效分辨率大约在150到200 DPI之间就已经很清晰了。如果原始图片超过2500像素,缩放到2000像素左右基本无损。目的是内存和文件体积双双下降。

三是不要迷信单一库能解决所有问题。图像处理方面如果需要裁剪、矫正、去噪,建议单独用OpenCV或Java的图像处理库处理后再交给PDFBox,职责边界越清晰,排查问题越容易。

这个工具到现在依然是我们内部归档流程里的一个常驻模块,后续又加了根据文件名自动分卷、按日期生成目录、OCR文字层嵌入等功能。如果你也需要处理类似的批量扫描、图片打包归档问题,从本文的思路出发去实现,很快就能跑出第一版。实际调整过程中有什么奇怪的现象,欢迎在评论区交流,踩过坑的人总能互相救一把。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询