1. 从一个真实需求说起为什么要把PDF“洗”成黑白去年帮一个做学术资料整理的朋友处理一批扫描版文献他丢过来一个压缩包里面是几十份PDF每份都是彩色的——封面有底色、正文有高亮标注、页眉页脚还带着各种颜色的装饰线。他的诉求很直接打印出来要清爽最好全是黑白省墨、省纸、看着也不花哨。我一开始想偷懒直接用打印机的“灰度打印”选项不就完了结果试了几份发现两个问题一是不同打印机的灰度算法差异很大有些浅色高亮直接糊成一片灰底文字反而看不清二是他还要把这些文件归档到电子资料库里希望文件本身就是黑白的而不是依赖打印设置。这就引出了一个很实际的技术需求在程序层面把彩色PDF转换成真正的黑白PDF。注意这里说的“黑白”不是简单的灰度而是二值化——每个像素非黑即白没有中间灰阶。这种处理对扫描件、手写笔记、线框图这类内容特别友好文件体积能大幅缩小打印效果也干净利落。Java生态里处理PDF的方案不少但要把“PDF转图片、图片黑白化、图片转PDF”这条链路完整跑通中间有不少细节容易翻车。我前后折腾了大概一周踩了分辨率、内存、字体、页面尺寸好几个坑最后沉淀出一套比较稳的实现方案。这篇文章就把整个思路和代码拆开讲清楚适合有Java基础、需要做文档批处理的朋友参考。哪怕你之前没接触过PDF处理库跟着思路走也能理解每一步在干什么。核心链路其实就三步用PDF渲染库把每页转成位图对位图做二值化处理再把处理后的图片重新组装成PDF。听起来简单但每一步的选型和参数都直接决定最终效果。下面我按实际开发顺序把每个环节掰开揉碎讲。2. 技术选型PDF渲染与图片处理库怎么挑2.1 渲染引擎的对比为什么最终选了PDFBoxJava里能把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 groupIdorg.apache.pdfbox/groupId artifactIdpdfbox/artifactId version2.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标准能保证转换后的灰度图在视觉上与原彩色图亮度一致。如果直接用(RGB)/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. 第三步把黑白图片重新组装成PDF5.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或更大尺寸的PDF300 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. 完整流程的串联与参数速查把前面所有环节串起来一个完整的处理流程是这样的加载PDF文档获取总页数对每一页按设定DPI渲染为GRAY类型位图对位图做固定阈值二值化阈值默认160将二值位图写入新PDF的对应页面页面尺寸按DPI换算每100页保存一次临时文件最后合并输出最终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个代表性文件做小批量验证确认参数和流程没问题再放开全量跑。这个习惯帮我省下了大量返工时间。