☰
Java实现彩色PDF转黑白二值化:PDFBox渲染与图片处理实战
2026/10/16 5:10:09 网站建设 项目流程

1. 从一个真实需求说起:为什么要把PDF“洗”成黑白

去年帮一个做学术资料整理的朋友处理一批扫描版文献,他丢过来一个压缩包,里面是几十份PDF,每份都是彩色的——封面有底色、正文有高亮标注、页眉页脚还带着各种颜色的装饰线。他的诉求很直接:打印出来要清爽,最好全是黑白,省墨、省纸、看着也不花哨。我一开始想偷懒,直接用打印机的“灰度打印”选项不就完了?结果试了几份发现两个问题:一是不同打印机的灰度算法差异很大,有些浅色高亮直接糊成一片灰底,文字反而看不清;二是他还要把这些文件归档到电子资料库里,希望文件本身就是黑白的,而不是依赖打印设置。

这就引出了一个很实际的技术需求:在程序层面把彩色PDF转换成真正的黑白PDF。注意,这里说的“黑白”不是简单的灰度,而是二值化——每个像素非黑即白,没有中间灰阶。这种处理对扫描件、手写笔记、线框图这类内容特别友好,文件体积能大幅缩小,打印效果也干净利落。

Java生态里处理PDF的方案不少,但要把“PDF转图片、图片黑白化、图片转PDF”这条链路完整跑通,中间有不少细节容易翻车。我前后折腾了大概一周,踩了分辨率、内存、字体、页面尺寸好几个坑,最后沉淀出一套比较稳的实现方案。这篇文章就把整个思路和代码拆开讲清楚,适合有Java基础、需要做文档批处理的朋友参考。哪怕你之前没接触过PDF处理库,跟着思路走也能理解每一步在干什么。

核心链路其实就三步:用PDF渲染库把每页转成位图,对位图做二值化处理,再把处理后的图片重新组装成PDF。听起来简单,但每一步的选型和参数都直接决定最终效果。下面我按实际开发顺序,把每个环节掰开揉碎讲。

2. 技术选型:PDF渲染与图片处理库怎么挑

2.1 渲染引擎的对比:为什么最终选了PDFBox

Java里能把PDF转成图片的库,主流的有Apache PDFBox、iText(及其衍生版本)、还有基于Ghostscript的JNI封装。我一开始用的是iText,因为它API确实顺手,但后来发现两个问题:一是较新版本的授权协议对商业使用有约束,二是它在渲染某些复杂字体嵌入的PDF时,偶尔会出现文字偏移。对于要批量处理未知来源PDF的场景,这个不确定性太致命。

后来换到Apache PDFBox,它是Apache基金会下的项目,协议宽松,渲染质量在2.x版本之后已经相当可靠。最关键的是它的PDFRenderer类提供了直接的渲染接口,配合ImageIO就能拿到BufferedImage,整个链路非常清晰。Ghostscript方案虽然渲染质量公认最好,但需要额外安装本地程序并通过命令行调用,部署时多一层依赖,对纯Java项目来说不够干净。

所以最终选型是:PDFBox负责PDF到图片的渲染,Java原生BufferedImage和ImageIO负责图片读写,二值化自己写像素遍历逻辑。不引入OpenCV这类重型库,因为我们的需求只是简单的阈值二值化,没必要为了一个功能拖进来几十兆的依赖。

2.2 版本选择与依赖引入

PDFBox我锁定在2.0.x的较新稳定版,3.x虽然出了但API变动较大,社区资料还没跟上。Maven依赖如下:

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

如果你用的是Gradle,对应写法自行转换即可。这里只引一个包就够了,PDFBox的渲染模块已经包含在核心包里,不需要额外引pdfbox-tools。

注意:PDFBox 2.0.x依赖了commons-logging,如果你的项目里已经有其他日志框架,可能会看到重复绑定的警告,加个排除或者桥接就行,不影响功能。

2.3 一个容易被忽略的前置问题:内存

在动手写代码之前,必须先算一笔内存账。假设一份PDF有50页,每页按A4尺寸、300 DPI渲染,单页位图的分辨率是2480×3508像素。每个像素用int存储(ARGB),单页就是2480×3508×4字节,约34.8MB。50页如果全部加载到内存,就是1.7GB。这还没算PDFBox解析文档本身的开销。

所以绝对不能一次性把所有页面都转成图片再处理。正确做法是逐页处理:渲染一页、二值化一页、写入新PDF一页、然后释放这一页的位图引用。这样内存峰值只取决于单页大小,控制在几十兆以内。这个思路贯穿后面的所有代码,是保证程序不OOM的关键。

3. 第一步:把PDF每页渲染成位图

3.1 渲染的核心参数:DPI决定了成败

PDF是矢量格式,渲染成位图时必须指定一个分辨率,也就是DPI(每英寸点数)。这个参数直接决定输出图片的清晰度和文件大小。DPI太低,文字边缘发虚,二值化后笔画会断裂;DPI太高,内存和耗时都飙升。

我的经验值是:纯文字文档用200 DPI就够,扫描件或含细线条的图用300 DPI,超过300 DPI对二值化来说收益很小。为什么?因为二值化本身会丢失灰阶信息,过高的分辨率带来的细节在阈值处理时大部分会被抹掉,反而增加了计算量。

PDFBox的PDFRenderer默认是96 DPI,必须手动设置。设置方式是通过renderImageWithDPI方法:

PDDocument document = PDDocument.load(new File("input.pdf")); PDFRenderer renderer = new PDFRenderer(document); int dpi = 300; for (int i = 0; i < document.getNumberOfPages(); i++) { BufferedImage image = renderer.renderImageWithDPI(i, dpi, ImageType.RGB); // 后续处理 }

注意第三个参数ImageType.RGB,它决定输出位图的色彩模式。用RGB就够了,不需要ARGB,因为PDF页面背景默认是白色,没有透明通道需求,用RGB能省掉一个通道的内存。

3.2 页面尺寸不一致时的处理

有些PDF的页面尺寸五花八门,比如一份文档里混了A4和Letter。渲染出来的位图尺寸自然也不一样。这在后续转回PDF时会造成页面大小不统一。我的处理策略是:以第一页的尺寸为基准,后续页面如果尺寸差异在5%以内就按原样保留,差异过大则统一缩放到基准尺寸。缩放用Graphics2D的drawImage方法,配合双线性插值,避免锯齿。

不过大多数场景下,同一份PDF的页面尺寸是一致的,这个逻辑属于防御性编程,防止个别异常文件导致输出错乱。

3.3 渲染时的异常处理

PDFBox在渲染损坏页面时会抛IOException。批量处理时不能让一页的失败中断整个任务。我的做法是用try-catch包住单页渲染,失败时记录页码和异常信息,然后跳过这一页,继续处理下一页。最后统计成功和失败的页数,输出一份日志。这样即使输入文件有瑕疵,也能拿到大部分结果。

还有一个坑:某些PDF包含加密或权限限制,PDDocument.load会直接抛异常。如果只是“禁止编辑”这类权限,可以用MemoryUsageSetting配合空密码加载;如果是真正的加密文档,那就只能跳过并提示用户。

4. 第二步:图片黑白化的算法与实现细节

4.1 灰度化:二值化之前必须做的事

彩色图片直接做二值化是不对的,因为RGB三个通道的值差异很大,单纯按某个通道阈值判断会丢失信息。正确顺序是先转灰度,再二值化。

灰度化的标准公式是加权平均,因为人眼对绿色最敏感、蓝色最不敏感:

gray = 0.299 * R + 0.587 * G + 0.114 * B

这个权重不是随便定的,它来自ITU-R BT.601标准,能保证转换后的灰度图在视觉上与原彩色图亮度一致。如果直接用(R+G+B)/3,绿色和蓝色会被同等对待,结果会偏暗或偏亮。

在Java里,可以用ColorConvertOp一行搞定灰度转换,也可以自己遍历像素算。我选择自己遍历,因为后面二值化也要遍历,合并成一次循环效率更高。

4.2 阈值怎么定:固定阈值 vs 自适应阈值

最简单的二值化是固定阈值:灰度值大于128就设为白,否则设为黑。这个方案对背景干净、对比度高的文档效果很好,但遇到扫描件就抓瞎——扫描件往往背景发灰、光照不均,固定阈值会把大片背景判成黑色,或者把浅色文字判成白色。

我实测下来,对大多数文档类PDF,固定阈值配合一个可调参数就够了。默认用160而不是128,因为文档背景通常是接近白色的浅灰,用160能更好地把背景归为白色,同时保留黑色文字。这个值可以根据实际效果微调:如果发现文字笔画变细甚至断裂,说明阈值偏高,往下调;如果背景出现黑斑,说明阈值偏低,往上调。

对于质量较差的扫描件,可以考虑局部自适应阈值(比如Otsu算法自动求全局阈值,或者分块计算局部阈值)。但Otsu在Java里要自己实现直方图统计,代码量不小。我的建议是:先用固定阈值跑一遍,效果能接受就不折腾自适应。如果确实需要,Otsu的全局阈值实现也就几十行代码,核心是统计256级灰度的直方图,然后找让类间方差最大的那个阈值。

4.3 二值化的像素遍历代码

下面是核心的二值化方法,输入是RGB位图,输出是只有黑白两色的位图:

public static BufferedImage toBinary(BufferedImage src, int threshold) { int w = src.getWidth(); int h = src.getHeight(); BufferedImage binary = new BufferedImage(w, h, BufferedImage.TYPE_BYTE_BINARY); for (int y = 0; y < h; y++) { for (int x = 0; x < w; x++) { int rgb = src.getRGB(x, y); int r = (rgb >> 16) & 0xFF; int g = (rgb >> 8) & 0xFF; int b = rgb & 0xFF; int gray = (int) (0.299 * r + 0.587 * g + 0.114 * b); int value = gray > threshold ? 0xFFFFFF : 0x000000; binary.setRGB(x, y, value); } } return binary; }

这里输出位图用的是TYPE_BYTE_BINARY,每个像素只占1位,比RGB的32位省了32倍内存。对于300 DPI的A4页面,二值化后的位图只有约1MB,非常轻量。

提示:getRGB和setRGB是逐像素调用,对大数据量来说有性能开销。如果追求极致速度,可以直接操作DataBuffer,但代码复杂度会上升。实测300 DPI单页处理在普通笔记本上约200-400毫秒,完全可以接受,没必要过早优化。

4.4 处理后的视觉校验

二值化完不能直接往下走,得先看一眼效果。我的习惯是每处理完一页,把二值化后的图片存一份PNG到临时目录,人工抽查前几页。重点看三个地方:文字笔画是否完整、背景是否干净、有没有大块误判的黑斑。如果发现问题,调整阈值重新跑。这个校验步骤在批量处理前一定要做,否则几十份文件跑完才发现效果不对,返工成本很高。

5. 第三步:把黑白图片重新组装成PDF

5.1 用PDFBox创建新文档

图片转PDF这一步,PDFBox提供了PDImageXObject和PDPageContentStream。核心逻辑是:新建一个PDDocument,为每张图片创建一个页面,页面尺寸设为图片的像素尺寸按DPI换算成的点(point)。PDF里的长度单位是点,1点等于1/72英寸。所以如果图片是300 DPI、2480×3508像素,对应页面尺寸就是2480/300×72 ≈ 595点宽、3508/300×72 ≈ 842点高,正好是A4。

PDDocument doc = new PDDocument(); for (BufferedImage img : images) { float widthPt = img.getWidth() * 72f / dpi; float heightPt = img.getHeight() * 72f / dpi; PDPage page = new PDPage(new PDRectangle(widthPt, heightPt)); doc.addPage(page); PDImageXObject pdImage = LosslessFactory.createFromImage(doc, img); try (PDPageContentStream cs = new PDPageContentStream(doc, page)) { cs.drawImage(pdImage, 0, 0, widthPt, heightPt); } } doc.save("output.pdf"); doc.close();

这里用LosslessFactory而不是JPEGFactory,因为二值化后的图片只有黑白两色,用无损压缩能保证边缘锐利,不会出现JPEG的块状伪影。而且二值图的无损压缩率极高,文件体积反而比JPEG更小。

5.2 页面尺寸的精确控制

上面代码里页面尺寸是按图片像素和DPI反推的。但有个细节:如果原始PDF的页面不是标准尺寸,反推出来的点值可能带小数。PDFBox接受浮点数,没问题。但如果你希望输出统一为A4,那就得在创建页面时固定用PDRectangle.A4,然后把图片缩放到页面内。缩放时保持宽高比,避免拉伸变形。

我一般倾向于保持原始页面尺寸,因为这样最忠实于源文件。只有用户明确要求统一尺寸时才做缩放。

5.3 逐页写入与内存释放

前面强调过不能一次性加载所有图片。所以实际代码结构应该是:渲染一页→二值化→立即写入PDF→释放位图。写入时PDImageXObject会持有图片数据,但PDFBox在save之前会把图像数据缓存在文档对象里。如果页数特别多(比如上千页),即使逐页写入,最终save时内存占用还是会累积。

解决办法是分批保存:每处理100页就save一次到一个临时文件,最后用PDFMergerUtility把所有临时文件合并。PDFBox自带合并工具:

PDFMergerUtility merger = new PDFMergerUtility(); merger.setDestinationFileName("final.pdf"); for (String tmp : tempFiles) { merger.addSource(tmp); } merger.mergeDocuments(MemoryUsageSetting.setupTempFileOnly());

MemoryUsageSetting.setupTempFileOnly()让合并过程使用临时文件而不是内存,进一步降低内存压力。这套组合拳下来,处理上千页的文档也不会OOM。

6. 踩过的坑与性能调优实录

6.1 字体缺失导致的渲染异常

有一次处理一批PDF,渲染出来的图片里中文全部变成方框。排查后发现是PDFBox默认字体不含中文字形。解决办法是引入fontbox并加载系统中文字体,或者在渲染前用PDFont替换。但更简单的方案是:如果只是要黑白化,不关心文字是否可选中,可以直接用系统安装了中文字体的环境运行。PDFBox会尝试从系统字体目录加载。在Linux服务器上部署时,记得装fonts-noto-cjk这类字体包,否则中文必然乱码。

6.2 大页面PDF的内存溢出

A3或更大尺寸的PDF,300 DPI下单页位图可能超过100MB。这时候即使逐页处理,单页也可能撑爆默认堆内存。解决方案有两个:一是降低DPI到200,二是用renderImageWithDPI的重载版本指定ImageType.GRAY,灰度图每像素1字节,比RGB省三分之二内存。二值化本来就需要灰度,所以直接用GRAY类型渲染,既省内存又省去灰度转换步骤。

6.3 处理速度的优化

逐像素的getRGB/setRGB在Java里是方法调用,JIT优化后仍然有开销。如果处理量很大,可以改用int[]数组批量读取:

int[] pixels = src.getRGB(0, 0, w, h, null, 0, w); // 在数组上做二值化 binary.setRGB(0, 0, w, h, pixels, 0, w);

这样一次读写整行或整图,减少方法调用次数。实测能提速30%左右。另外,多页处理可以用线程池并行,但要注意PDFBox的PDDocument不是线程安全的,每个线程必须用自己的文档实例。并行度建议设为CPU核心数,太高反而因为内存带宽瓶颈没收益。

6.4 输出文件体积的对比

同一份50页的彩色PDF,原始体积12MB。经过300 DPI渲染+二值化+无损压缩后,输出体积约1.8MB,缩小了85%。如果改用200 DPI,体积能压到1MB以内,但文字边缘会稍微毛糙。这个体积差异主要来自二值图的超高压缩率——只有黑白两色,CCITT Group 4压缩算法能把它压到极致。PDFBox的LosslessFactory内部对二值图会自动选用合适的压缩方式,不需要手动指定。

7. 完整流程的串联与参数速查

把前面所有环节串起来,一个完整的处理流程是这样的:

  1. 加载PDF文档,获取总页数
  2. 对每一页:按设定DPI渲染为GRAY类型位图
  3. 对位图做固定阈值二值化,阈值默认160
  4. 将二值位图写入新PDF的对应页面,页面尺寸按DPI换算
  5. 每100页保存一次临时文件,最后合并
  6. 输出最终PDF,记录处理日志

关键参数速查表:

参数推荐值说明
渲染DPI200-300文字文档200,扫描件300
渲染色彩类型GRAY省内存,省去灰度转换
二值化阈值160背景偏灰时上调,笔画断裂时下调
输出位图类型TYPE_BYTE_BINARY每像素1位,极致省内存
分批保存页数100平衡内存与临时文件数量
并行线程数CPU核心数每线程独立PDDocument

这套方案我在几个不同场景下跑过:学术文献归档、合同扫描件处理、手写笔记数字化,效果都稳定。唯一需要根据实际情况调整的就是阈值,而这个调整通过抽查前几页就能快速确定。

8. 几个值得注意的边界情况

最后分享几个实际遇到的边界情况,这些在文档里通常不会写,但踩过一次就忘不了。

第一,空白页的处理。有些PDF包含纯空白页,二值化后整页全白。这种页面写入PDF后是合法的,但如果你后续要做OCR,空白页会浪费识别时间。可以在二值化后统计黑色像素占比,低于0.1%的页面标记为空白,按需跳过。

第二,图片型PDF和文字型PDF的差异。文字型PDF渲染出来边缘锐利,二值化效果好;图片型PDF(比如扫描件)本身就有噪点,二值化后可能出现孤立黑点。对扫描件可以在二值化前加一个简单的中值滤波,去掉椒盐噪声。Java里实现3×3中值滤波不难,取周围9个像素灰度的中位数即可。

第三,输出PDF的页面旋转。有些PDF页面带有旋转属性(比如横版表格旋转了90度)。PDFBox渲染时会自动应用旋转,所以输出的是正向图片。但如果你在创建新页面时没有考虑旋转,可能导致页面方向不对。解决办法是渲染后检查图片宽高比,如果宽大于高而原始页面是纵向的,说明发生了旋转,需要在写入时相应调整页面方向。

第四,并发处理的日志混乱。多线程处理时,不同线程的日志会交错。建议每个线程用独立的日志前缀(比如页码范围),或者用线程安全的日志框架按线程ID分组输出。排查问题时能快速定位是哪个线程、哪一页出的错。

这些细节看起来琐碎,但正是它们决定了批量处理是“跑通”还是“跑稳”。我现在的习惯是:任何批量任务,先拿3-5个代表性文件做小批量验证,确认参数和流程没问题,再放开全量跑。这个习惯帮我省下了大量返工时间。

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

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

立即咨询