简介面向 Java 开发者的 iText 高清版图片转 PDF 示例资源针对将 PNG 等图片生成 PDF 时常出现的清晰度不足问题提供可直接运行的 demo适合需要在项目中实现图片转 PDF、批量导出高清文档的 Java 初学者或集成开发者。压缩包为 rar 格式共 11 个文件覆盖 jar、java、class、jsp、xml、txt 等类型包含 iText.jar 运行库、Java 源码、Eclipse 工程配置及 readme 说明文档整体大小约 1MB下载后即可导入开发环境使用。该资源发布至今已有 5467 人学习阅读。示例完整演示了从引入 iText 依赖、创建 Document 和 PdfWriter到加载图片、设置 300 DPI 并按需缩放适配页面、最后关闭文档的完整流程同时避开了图片失真的常见坑点配套的源码结构清晰明了包含 WebRoot 等目录可帮助读者快速理解 iText 处理图片和生成 PDF 的关键 API并可在此基础上继续扩展水印、页眉页脚等功能适合作为项目中的参考模板。1. 从“扫描件导出PDF糊成一团”说起这个高清版demo到底解决了什么去年做图书数字化的时候我收到一批扫描PNG用常规的iText Demo直接 add 进 A4 页面导出后的PDF在屏幕上看着还行打印出来却糊得不行连边缘文字都有毛边。后来才发现问题不在扫描源文件而是页面尺寸没按图片物理尺寸来——PNG 是 300 DPI 扫描导出时 PDF 页面被强制设成 A4图片被拉伸打印机的渲染又做了二次采样清晰度全丢了。这个 demo 的定位很纯粹用 iText 读取每张图片的像素宽高按目标 DPI默认 300反算出 PDF 页面大小再把原图以原始尺寸嵌入最终得到一份“放大不发虚、打印不模糊”的高清 PDF。适合做档案数字化、电商商品图打包、照片书生成这类场景人群是手里已经有图片、想把它们做成高质量 PDF 的 Java 开发者。2. 方案对比与整体架构为什么高清版本选用 iText 而不是 PDFBox2.1 图片转 PDF 的三条主流技术路线如果只是在项目里临时转一个文件方法很多但要做成高清且可控的批量工具库的选择就变得比较关键。我前前后后对比过三条路线这里直接给出结论PDFBox纯 Apache 协议没有 AGPL 的合规压力核心 API 偏底层。图片转 PDF 需要PDImageXObject.createFromByteArray然后自己算PDRectangle和矩阵变换写起来倒不复杂但图片的颜色空间、alpha 通道、DPI 这些元数据需要自己处理做高清版要补很多边缘逻辑。OpenPDF最早是 iText 4 的社区分支包名还是com.lowagie.textAPI 和老 iText 长得几乎一样对只需基础图片嵌入的场景比较友好。不过它的更新节奏慢遇到新图片格式比如 WebP、HEIF时扩展点比较少而且部分老代码里对 ICC 色彩空间的处理并不完整。iText图片处理是它的强项Image.getInstance()能直接吃下 PNG/JPEG/TIFF而且生成的 PDF 中图片数据是原样嵌入不会经过 JVM 的二次再采样。iText 5 的 API 足够简洁社区讨论也多对“高清版”要做的页面尺寸换算、目标 DPI 控制都有现成思路。三条路线对比下来这个 demo 用 iText 是合适的。PDFBox 的优势在合规但它的图片渲染路径更像“画布上贴图画”需要自己处理 DPIiText 的Image对象自带物理尺寸信息缩放和页面匹配都更方便。OpenPDF 虽然 API 相似但遇到新版 JDK 的时候老代码有时会出现IllegalAccessError的问题排查起来比较费劲。提示iText 5 沿用 AGPL/商业双许可如果你的项目是纯内部使用可以不管要做对外分发或 SaaS 服务建议先走一遍合规评估或者改用 OpenPDF。2.2 版本选择iText 5 还是 iText 7这个 demo 选择的是 iText 5.5.13.3坐标如下dependency groupIdcom.itextpdf/groupId artifactIditextpdf/artifactId version5.5.13.3/version /dependency选 5 而不是 7不是因为 7 不好而是这个 demo 的目标读者里有大量老项目还在用com.lowagie.text.*这套 API直接移植最省事。iText 7 的包名换成了com.itextpdf.kernel.*图片处理从Image变成了ImageDataFactory页面尺寸从Rectangle变成了PageSize接口变化很大。我在写 demo 时发现5 的Image.getInstance(path).getDpiX()可以直接拿到 PNG 里的 DPI 元数据7 则要手动从ImageData的getDpiX()里读思路是一样的但代码位置不一样。如果你在新项目里从 5 升级到 7需要注意包名和类名都变了不能光替换依赖就指望编译通过setCompressionLevel这类方法在 7 里也改到了PdfWriterProperties上。2.3 高清版 Demo 的工程结构demo 是一个标准的 Maven 工程四个类各司其职没有多余抽象src/main/java/com/example/imagepdf/ ├── ImageToPdfDemo.java // 入口解析命令行参数编排批量流程 ├── ImageInfo.java // 图片信息载体像素宽高、目标DPI、物理尺寸 ├── ImageInfoReader.java // 读取图片像素宽高给出目标DPI下的物理尺寸 ├── PageLayoutCalculator.java // 根据像素和目标DPI计算PDF页面Rectangle └── PdfExportEngine.java // 真正创建Document、写入图片、关闭文件ImageInfo只是一个普通 POJO用来在几个类之间传递数据ImageInfoReader负责从图片文件读取宽高PageLayoutCalculator是高清策略的核心PdfExportEngine负责把页面尺寸和图片文件组合成 PDF。入口类只做参数解析和流程编排这种分层的好处是后续想改成 HTTP 接口或加消息队列时只需要替换入口层不用动核心算法。高清版的本质是让 PDF 页面的物理尺寸和图片的像素数量匹配。例如一张 3000×4000 的 PNG若按 300 DPI 输出物理尺寸就是 10×13.33 英寸换算成 PDF 点为单位是 720×960。如果直接把这个尺寸作为页面大小图表之间的比例关系就不会被破坏打印时每英寸能容纳的像素数正好是 300观感自然清晰。这个换算关系在后文的核心模块里会反复用到。3. 核心模块拆解一张 PNG 从磁盘到 PDF 页面的完整链路3.1 读取图片信息像素尺寸和 DPI 是高清的起点在 demo 中第一步不是把图片塞进 PDF而是先读它的宽高。代码如下public class ImageInfoReader { public static ImageInfo read(String path, int targetDpi) throws IOException { try (ImageInputStream iis ImageIO.createImageInputStream(new File(path))) { IteratorImageReader readers ImageIO.getImageReaders(iis); if (!readers.hasNext()) { throw new IOException(不支持的图片格式: path); } ImageReader reader readers.next(); reader.setInput(iis, true, true); int pixelWidth reader.getWidth(0); int pixelHeight reader.getHeight(0); reader.dispose(); float physicalWidthInch pixelWidth / (float) targetDpi; float physicalHeightInch pixelHeight / (float) targetDpi; return new ImageInfo(pixelWidth, pixelHeight, physicalWidthInch * 72f, physicalHeightInch * 72f, targetDpi); } } }这段代码的作用很直接用ImageIO的ImageReader拿到 PNG 的解码宽高注意这里取的是getWidth(0)不是BufferedImage.getWidth()因为ImageReader不需要把整个图片解码成像素数组内存占用小很多。如果你用ImageIO.read(path).getWidth()大图会先解压成 ARGB 数组还没开始生成 PDF 就先吃掉几十 M 堆内存批量场景很容易翻车。targetDpi是用户输入的目标分辨率默认 300。我们把pixelWidth / targetDpi得到物理宽度英寸再乘以 72 得到 PDF 用的“点”point。一个 PDF 点严格等于 1/72 英寸这是 iText 页面尺寸的单位基础理解不了这一点后面所有缩放计算都会写错。ImageInfo里存的widthPt/heightPt就是后续PdfExportEngine要用的页面尺寸。3.2 计算页面尺寸把 72dpi 的 PDF 世界换算成图片物理尺寸拿到像素宽高后接着要根据目标 DPI 算出com.lowagie.text.Rectangle。同样的尺寸如果目标是 150 DPI页面会被放大一倍图片在打印时清晰度会下降如果目标是 600 DPI页面会缩小到原来四分之一图片在屏幕上看起来会比较拥挤但因为原始像素密度高打印依然清晰。PageLayoutCalculator的代码public class PageLayoutCalculator { public static Rectangle calc(ImageInfo info, Rectangle maxPage) { float widthPt info.getWidthPt(); float heightPt info.getHeightPt(); if (maxPage ! null) { float scale Math.min(maxPage.getWidth() / widthPt, maxPage.getHeight() / heightPt); if (scale 1f) { widthPt * scale; heightPt * scale; } } return new Rectangle(widthPt, heightPt); } }参数maxPage是可选的比如传入 A4 的 Rectangle当图片像素特别大时会等比缩到 A4 内。注意这里的缩放比例同时作用于宽高因为widthPt/heightPt和原始像素宽高比是严格对应的缩放不会改变图片的横竖比。在 demo 中我一般会建议用户不传maxPage让每一页都按图片物理尺寸生成这样最接近“高清版”的定义只有需要统一输出纸张大小的时候才开启这个参数。这里有一个容易踩坑的点Rectangle的构造器接受的是点坐标而Document构造器里的new Rectangle(widthPt, heightPt)直接把 widthPt 当页面宽、heightPt 当页面高。如果你再把image.scaleAbsolute(pageSize.getWidth(), pageSize.getHeight())设置成一样的值pdf 阅读器渲染时会用矢量坐标做等比映射不会因为浮点误差产生明显变形。3.3 写入 PDF创建零边距页面并把原图原样嵌入写入过程是整条链路里最短的一段但也是最容易出错的一段。核心代码public class PdfExportEngine { public static void export(ImageInfo info, String inputPath, String outputPath) throws Exception { Rectangle pageSize new Rectangle(info.getWidthPt(), info.getHeightPt()); Document document new Document(pageSize, 0, 0, 0, 0); PdfWriter writer PdfWriter.getInstance(document, new FileOutputStream(outputPath)); // 压缩级别 1~99 最省体积但写文件会慢一些 writer.setCompressionLevel(9); document.open(); Image img Image.getInstance(inputPath); // 原图嵌入不做二次采样 img.scaleAbsolute(info.getWidthPt(), info.getHeightPt()); document.add(img); document.close(); } }四个零边距参数很关键PDF 默认有 36pt 的边距如果你用默认边距图片会被挤在中间而且 iText 会悄悄把图片缩到页面宽度减边距的范围内导致看起来比页面尺寸小一圈。设置成 0 之后页面物理尺寸等于图片物理尺寸图片满幅铺满整页打印时才不会出现白边和自动缩放。writer.setCompressionLevel(9)是 iText 5 的 PDF 流压缩级别级别越高文件越小代价是 CPU 占用升高。对于扫描 PNG 这种大体积图片压缩级别对最终 PDF 体积影响有限因为图片本身已经压缩过但能压缩 PDF 内部的 xref 表和页面内容流还是有帮助的。如果你要极致体积可以在读取图片前先用图像工具把 PNG 转成高质量 JPEG再交给 iText 嵌入这个取舍会在后面的避坑章节详细展开。3.4 批量写入与内存控制一次塞 300 张图不要被堆内存打趴批量操作相比单张导出最大的风险是内存溢出。很多人写循环时这样写for (String imgPath : imgPathList) { Image img Image.getInstance(imgPath); // 每张都持有了原图片的字节数组 document.add(img); }问题在于Image对象如果被保留引用GC 不会回收它内部持有的原始图片字节数组。demo 里我用了一个更保守的写法for (String imgPath : imgPathList) { ImageInfo info ImageInfoReader.read(imgPath, targetDpi); Image img Image.getInstance(imgPath); img.scaleAbsolute(info.getWidthPt(), info.getHeightPt()); document.add(img); // 显式释放引用帮助GC回收图片字节数组 img null; if (count % 20 0) { logger.info(已处理 {} 张图片剩余内存 {} M, count, Runtime.getRuntime().freeMemory() / 1024 / 1024); } }img null只是移除引用真正的释放交给 JVM但批量循环里能显著减少堆压力。如果你用的是 iText 7同样适用这个思路。另一个技巧是在循环外只创建一次Document每张图片加完就写入磁盘由PdfWriter的流式写入机制保证不会把整本 PDF 都压在内存里。iText 5 的Document.add()会把内容流写到PdfWriter的缓冲区不是攒到最后一次性写所以循环本身是安全的。批量处理时我一般会用java -Xmx2g启动配合上面每张释放300 张扫描件每张 10M跑下来峰值堆内存约 800 M不设置的话很容易 OOM。如果还有更大的图可以考虑把图片转成 JPEG 后再写入这样可以显著降低堆外内存压力。4. 从源码到跑通运行 Demo 的最小依赖和参数说明4.1 环境准备与 Maven 依赖跑起来只需要三样东西JDK 8 或更高、Maven 3.6、一份pom.xml。依赖除了 iText 5 和一个日志门面之外没有其他复杂的传递依赖。pom 最小配置project groupIdcom.example/groupId artifactIdimage-pdf-demo/artifactId version1.0.0/version dependencies dependency groupIdcom.itextpdf/groupId artifactIditextpdf/artifactId version5.5.13.3/version /dependency dependency groupIdorg.slf4j/groupId artifactIdslf4j-api/artifactId version1.7.36/version /dependency /dependencies /project注意 iText 5 在 Maven 中央仓库的坐标经历了多次变化早期是com.lowagie:itext后来是com.itextpdf:itextpdf。如果你在旧仓库搜到com.lowagie:itext:2.1.7那是非常老的版本也走com.lowagie.text包但 API 和 5.x 有细微差别。这个 demo 按5.5.13.3写包名统一是com.lowagie.text、com.lowagie.text.pdf。JDK 8 需要注意的是javax.imageio是标准库一部分无需额外引用。如果你用 JDK 9ImageIO 被封装到java.desktop模块默认就能用不用特殊处理。若用纯 JRE 跑要保证 JRE 包含 desktop 模块。4.2 命令行参数说明入口类ImageToPdfDemo支持 4 个参数我用一个表格列清楚参数名含义示例--input图片文件或目录目录时按文件名排序后批量转--input ./scans--output输出 PDF 文件路径--output ./result.pdf--target-dpi目标分辨率控制页面物理尺寸默认 300--target-dpi 300--max-page限制最大页面可传A4、LETTER或NONE默认NONE--max-page A4运行方式用 Maven 插件最省事mvn exec:java \ -Dexec.mainClasscom.example.imagepdf.ImageToPdfDemo \ -Dexec.args--input ./scans --output ./result.pdf --target-dpi 300打包成可执行 jar 也可以mvn package -DskipTests java -jar target/image-pdf-demo.jar \ --input ./scans --output ./result.pdf --target-dpi 300 --max-page NONE解释一下--target-dpi为什么默认是 300。300 是印刷行业通用的扫描分辨率图书扫描件大多数是 300 DPI如果你手里的图片是手机照片通常输出 exif 里标记 72 DPI按 300 排版会把 4000x3000 的照片放到约 33.9 x 25.4 厘米的页面上屏幕上看比原图还“紧凑”一点但打印出来清晰度很好。如果你想要每个图片刚好一屏看完可以调到 150 DPIPDF 页面会变大体积基本不变。4.3 验证生成结果是否真的高清跑完 demo 后不能只看 PDF 能不能打开。我习惯用 Poppler 工具集的pdfimages命令检查嵌入图片的分辨率pdfimages -list result.pdf输出里会列出每一页嵌入图片的Sizing例如4200x3000。只要这个尺寸和你输入的扫描 PNG 原图尺寸一致就说明图片数据没有被二次采样。如果输出尺寸比原图小说明代码在某个环节用了BufferedImage做了缩放或类型转换。另外可以用 Java 程序读取 PDF 页面尺寸页面宽高应该等于原图像素 / targetDpi * 72。我把这段验证逻辑直接写在 demo 的--verify模式里运行时打印每个页面的宽度和高度一目了然。这里也提醒一句PDF 阅读器默认的“适合窗口”会让大尺寸页面显示得比较小你放大到 300% 看边缘没有锯齿才是有效验证方式。5. 高清版图片转 PDF 避坑记录模糊、方向、内存溢出和文件膨胀的四个典型案例5.1 图片写进 PDF 后边缘发虚放大有马赛克现象扫描 PNG 原图 3000x4000生成 PDF 后放在 100% 显示还能接受一放大到 200% 就起毛刺。原因页面固定为 A4图片被 iText 缩放到 A4 内容区域PDF 查看器为了弥补缩放误差做了双线性插值物理上像素数量没变但显示比例变了看起来就糊。解决不要固定页面用 3.2 的公式把页面尺寸设成原图宽度 / targetDpi * 72然后img.scaleAbsolute(pageSize.getWidth(), pageSize.getHeight())这样图片与页面的映射是一比一不受查看器缩放影响。如果你一定要统一纸张也要按图片比例只缩放页面宽度允许页面高度变化而不是把不同比例的图片硬塞进同一个固定尺寸。5.2 竖拍照片到 PDF 里被旋转了 90 度现象用手机拍的 JPEG 转 PDF 后方向不对横的变竖的。原因JPEG 的 EXIF 里记录了一个 Orientation 字段很多拍照类图片软件在保存时会修正像素数组并把 Orientation 设为 1但有些图片源比如聊天工具压缩过的图Orientation 是 6表示需要右转 90 度才能正常显示。iText 的Image.getInstance默认不读 EXIF直接按存储的像素数组嵌入。解决用metadata读取 EXIF Orientation 值然后设置图片旋转Image img Image.getInstance(inputPath); if (orientation 6) { img.setRotationDegrees(90); } else if (orientation 8) { img.setRotationDegrees(270); }注意旋转之后img.getWidth()和img.getHeight()不会自动交换计算页面尺寸时要判断一下如果 orientation 是 5/6/7/8宽高要互换再算页面。这个坑很容易在图片分类场景里翻车我见过有人因为漏了这个批量生成的 PDF 一半页面方向不对。5.3 一次性塞 300 张图堆内存溢出现象循环处理 300 张 15MB 的 PNG跑到 180 张时抛OutOfMemoryError: Java heap space。原因Image.getInstance(path)会把图片的原始字节数组读进 JVM 堆内存一张 15MB 的 PNG 解码成BufferedImage后会变成约 60MB 的 ARGB 数据如果你的代码又持有Image对象的引用上一张的字节数组不会被 GC 回收堆自然爆掉。解决把处理图片的循环体拆成独立方法让Image对象在方法内变成局部变量处理完一张后把局部变量置空必要时用Runtime.getRuntime().totalMemory()打印剩余内存。另外iText 5 的Image对象内部有个rawData字段堆外释放不了所以最好的办法是及时丢弃引用。我通常还会在启动参数里加-Xmx2g比默认的 256M 宽裕很多。5.4 生成 PDF 体积比所有原图加起来还大现象10 张 PNG 原图总共 30MB生成的 PDF 却有 50MB。原因PNG 已经是无损压缩iText 嵌入 PDF 时会把 PNG 数据流原样放入PDF 容器自身再加一遍 FlateDecode但 PNG 的像素数据已经编码过再压缩往往是负优化体积反而膨胀如果原图是带 alpha 通道的 PNG还会多一份透明图层数据。解决在嵌入前把 PNG 转换成高质量 JPEG比如用ImageIO.write转成 quality0.85 的 JPEG体积会小很多但要注意 alpha 通道会丢失。或者设置writer.setCompressionLevel(9)只对 PDF 内部内容流有效对嵌入图片的压缩帮助有限。这个案例告诉我们高清版不等于零压缩实际做批量工具时要给用户一个--convert-to-jpeg选项。6. 从 Demo 到生产级批量工具加页码、上接口和回归验证6.1 抽出 execute 方法封装成 HTTP 接口是第一步demo 的入口类main方法里有一层参数解析和一层执行逻辑。生产环境改造时我一般会把execute(String input, String output, int targetDpi)单独抽成公共方法然后包一层 Spring Controller或者丢到消息队列的消费者里。注意PdfWriter和Document都不是线程安全的并发场景每线程要新建实例不能共享。这块没什么高深技巧关键是别把命令行参数解析耦合进核心流程。6.2 高级功能用 PdfPageEventHelper 给每页加页码水印很多归档场景要求 PDF 带页码。iText 5 提供一个PdfPageEventHelper事件机制在页面结束的瞬间写入水印或页码。代码public class PageWatermark extends PdfPageEventHelper { private int pageNumber 1; Override public void onEndPage(PdfWriter writer, Document document) { PdfContentByte canvas writer.getDirectContentUnder(); canvas.saveState(); canvas.beginText(); try { BaseFont bf BaseFont.createFont(STSong-Light, UniGB-UCS2-H, BaseFont.NOT_EMBEDDED); canvas.setFontAndSize(bf, 10); canvas.showTextAligned(Element.ALIGN_CENTER, - pageNumber -, (document.leftMargin() document.rightMargin() document.getPageSize().getWidth()) / 2f, document.bottomMargin() - 12, 0); } catch (DocumentException | IOException e) { throw new RuntimeException(e); } canvas.endText(); canvas.restoreState(); pageNumber; } }getDirectContentUnder()把页码画在图片下层不会遮挡扫描件内容BaseFont.createFont(STSong-Light, UniGB-UCS2-H)是系统自带的中文字体不需要额外字体文件但如果部署环境没有该字体就会抛异常。我一般会改成加载 classpath 下的自定义 TTF这样跨环境更稳。6.3 改造完怎么验证改造完别的模块不要只看一两张图的输出。我的习惯是写一个回归脚本固定用三组样本一张 3000x4000 的竖图、一张 6000x3000 的横图、一张 2000x2000 带 alpha 的 PNG跑完批量后检查三件事PDF 页面尺寸分别等于像素按 300 DPI 计算出的物理尺寸嵌入图片像素宽高与原图一致每页页码出现在预期位置字体没有乱码。之前我在做扫描件归档时省掉了页面尺寸校验结果有一批黑白扫描件被压扁成了 A4 比例导致打印出来全是畸形人脸。从那以后我每次跑完批量工具都会强制走一遍上面这个三连检查特别是页面尺寸和原图物理尺寸的对比养成习惯之后基本没有再因为图片拉伸翻过车。希望这篇笔记能帮你少踩一次这个坑。本文还有配套的精品资源点击获取