前阵子在一个文档转换服务里踩了个典型的坑:用pdfBox把PDF渲染成PNG,英文和数字都清清楚楚,所有中文标题全部变成一排排小方块。业务那边催得急,一开始我以为是PDF本身编码有问题,折腾了一圈才发现,问题出在pdfBox的字体映射环节——它压根没找到能画中文的字体文件,只能拿一个没有中文字形的兜底字体硬画,画出来的自然就是方块。这个坑在Java生态里太常见了,尤其当你用pdfBox做pdf转图片、发票预览、电子签章底图这类功能时,几乎必然遇到。这篇文章就把从现象到根因、再到修改源码解决的过程完整写出来,给后面遇到同样问题的朋友一条可以直接照抄的路。
1. 乱码的真面目:PDF转图片时中文变方块,问题出在渲染管线最后一棒
1.1 方块不等于乱码,先把症状定义清楚
很多人一看到中文变成方块就说是"乱码",其实这两个东西的排查方向完全不同。真正的乱码,指的是一段文本以错误的编码方式被解码,比如UTF-8的字节被当成GBK读取,你会看到一堆"锟斤拷""烫烫烫"之类的错乱字符,字符本身是有形的,只是内容错了。而方块(很多渲染器里叫做"豆腐块"或"notdef"字形)是字形缺失的表现——字体文件里根本没有对应字符的形状,渲染引擎只能画一个空框框占位。
这个区别非常关键。遇到乱码,你该去查字符编码、CMap映射表、内容流里的ToUnicode信息。遇到方块,你该去查字体库、字体映射、系统字体环境。这两个方向差着十万八千里,第一步定性定错了,后面所有排查都是白费。
我当时的PDF表现是:英文、数字、标点全部正常,只有中文全是方块。这恰恰是"字体缺失"最典型的特征——如果整页都是乱码,那可能是内容流解析问题,但英文正常、中文单独出问题,几乎可以断定是渲染管线处理中文字体时出了岔子。
1.2 从PDF内容到屏幕像素,中间发生了什么
要理解这个问题的本质,得先搞清楚pdfBox把一个PDF页面渲染成图片时,内部到底做了哪些事。我们可以把整个过程想象成一个"画师照着图纸作画"的流程:
- PDF文件里面的文本对象,其实只是一堆"字符码",它并不直接保存每个字的长相。比如某个文本对象写着"这是测试",在PDF内部可能是类似"<\u8fd9\u662f> Tj"这样的字节序列,这些字节是什么含义,需要查字体描述和编码表才能知道。
- pdfBox解析内容流,拿到每个字符的字符码后,需要通过字体对象的CMap(字符映射表)把它转换成Unicode码点,或者至少转换成一个能够定位字形索引的ID。
- 拿到字形索引后,渲染引擎要找到对应的字体文件,从字体文件里提取该字符的轮廓(glyph outline),然后在Graphics2D上绘制出来。
- 最后一步才是把绘制好的BufferedImage保存成PNG、JPEG等图片格式。
前三步看似简单,实际是整套流程里最脆弱的环节。因为pdfBox本身不携带任何中文字体文件。Apache PDFBox是一个纯Java库,它内置的字体非常少,基本只有Type1标准字体里那几款西文字体。它没有SimSun、没有微软雅黑、没有文泉驿正黑。渲染PDF时,它必须依赖运行环境里存在的字体文件。
这就是问题的根源:pdfBox不是一个造字引擎,它只是一个找字引擎。它负责把PDF里声明的字体名和运行环境里的字体文件做匹配。匹配上了,一切正常;匹配不上,它就随便找一个默认字体顶上。而那个默认字体多半不包含中文字形,于是中文字符全部渲染成方块。
1.3 最后一棒:FontMapper是怎么寻找字体的
pdfBox里负责"找字"的核心组件是FontMapper接口,它的默认实现是DefaultFontMapper。这个类的工作原理是:在初始化时,遍历一系列被注册的字体目录,把目录下每个字体文件解析一遍,提取出字体名称信息,建立起一个"字体名称 → java.awt.Font对象"的映射表。
当pdfBox渲染PDF时,会读取PDF里字体对象的BaseFont名称,比如"SimSun""MicrosoftYaHei""ArialMT"这种,然后把这个名称交给DefaultFontMapper.getFontByBaseFont()去查表。查到就返回对应的Font,查不到就返回一个默认字体(通常是一个精简的无衬线西文字体)。如果PDF里的字体是嵌入的,且带了子集前缀,比如"ABCDEF+SimSun",那么pdfBox会剥掉前缀,再拿"SimSun"去查表。
问题就出在这个查表环节。DefaultFontMapper并不会自动扫描C:/Windows/Fonts或者/usr/share/fonts这些系统字体目录。它只认你显式注册进去的目录。在实际项目中,99%的人使用pdfBox直接new PDFRenderer(document)就开始渲染了,根本没有调用过字体注册相关的API。结果就是:当PDF里用到中文字体时,映射表里查不到对应项,乱码方块就这么产生了。
打个比方:画师手里只有一套英文字帖,你递给他一份用中文写的图纸,他也不是不努力,只是手里没有中文字模,最后只能拿方框把位置标出来。
2. 三步定位法:先证明是字体映射问题,再动源码
2.1 第一步:检查源PDF的字体列表,确认字体有没有嵌入
在动代码之前,先用工具把源PDF的字体情况摸清楚。pdfBox官方自带一个调试工具,可以方便地查看PDF内部结构。我这里用的是pdfbox-app的调试模式:
java -jar pdfbox-app-2.0.27.jar debug input.pdf启动后,在PDF Debugger的窗口里切到字体相关的标签页,你能看到这个PDF里用到了哪些字体,它们的类型是什么(Type1、TrueType、Type0/CIDFontType2等),最关键的是看Embedded这一列——字体到底有没有嵌入PDF文件内部。
这一步能帮你快速分流:如果字体已经嵌入,那问题可能出在子集映射或者字体解析上;如果字体没有嵌入,那问题就变成了"pdfBox能否在运行环境中找到同名字体"。就我遇到的这批PDF来看,字体基本来自微软系软件导出,字体名写在BaseFont里,但并没有嵌入字体数据。这意味着pdfBox必须依赖本地字体环境。
还有一个有意思的细节:有些PDF的BaseFont名称写得并不规范。比如明明是宋体,但字体名在PDF里写的是"SimSun";有些是"SimSun, Bold"这种带样式后缀的;还有些甚至把字体名写成了"Unknown"。这些非标准写法都会影响后续的字体映射匹配。这一步的产出应该是一个清单:这个PDF用了哪些中文字体、字体是否嵌入、字体名称长什么样。有了这个清单,后面排查才有依据。
2.2 第二步:检查运行环境里到底有没有可用的中文字体
确定完PDF侧的情况,再看运行环境的字体情况。Windows本机通常不用太担心,C:/Windows/Fonts下中文字体一大堆,大多数情况下问题不大。真正的坑在Linux服务器上,尤其是那些精简过的Docker容器。
很多基础镜像为了减小体积,只装了极少数字体,可能只有DejaVu系列。DejaVu这个字体家族对拉丁字母、西里尔字母、甚至一些符号支持都很好,但就是不支持中文。你在一台干净的Ubuntu服务器上跑pdfBox渲染中文PDF,如果你没有装fonts-wqy-zenhei、fonts-wqy-microhei这类中文字体包,那不管是pdfBox还是其他任何渲染引擎,都不可能把中文画出来。巧妇难为无米之炊。
验证环境字体的方法很简单:
# Linux下查看系统已安装的中文字体 fc-list :lang=zh # 如果输出为空,说明系统没有安装任何中文字体如果在Windows下排查,直接打开控制面板的字体管理页面看一眼有没有宋体、黑体这类常用字体即可。这一步做到位,你就能判断当前问题到底是"环境里没有中文字体"还是"环境里有字体但pdfBox找不到",这两个问题的解法是不一样的。
我当时排查的服务器就是一台内存很小的Docker容器,fc-list :lang=zh的输出是空的。当时心里的第一反应是"破案了",但装上字体之后重跑,中文还是方块。这说明问题不止一层:环境缺字体是第一重问题,pdfBox没有扫描系统字体目录是第二重问题。只解决第一重远远不够。
2.3 第三步:写一个小程序,把DefaultFontMapper的匹配结果打出来
环境缺字体是明面上的问题,但真正隐蔽的是pdfBox的映射行为。我当时写了一个只有十来行的测试程序,目的很简单:看看DefaultFontMapper到底把"SimSun"解析成了什么:
import org.apache.pdfbox.pdmodel.font.DefaultFontMapper; public class FontMapperDebug { public static void main(String[] args) { DefaultFontMapper mapper = new DefaultFontMapper(); java.awt.Font font = mapper.getFontByBaseFont("SimSun"); System.out.println("SimSun -> " + font); } }如果输出是类似java.awt.Font[family=Dialog,name=Dialog,style=plain,size=1]这样的结果,说明pdfBox根本没有找到宋体,最终匹配到了Java默认的Dialog字体。这个Dialog字体是JRE自带的一个精简字体,里面没有中文字形,渲染出来的中文自然全是方块。
这个调试程序的妙处在于:它把你的问题从"整个渲染流程黑盒"里剥离出来,直接聚焦到"字体映射是否成功"这一个环节。你不需要生成图片、不需要肉眼观察方块,一行输出就能告诉你映射是否失效。在我后来的经验里,这个定位手法几乎成了处理一切PDF文字渲染问题的第一步,百试百灵。
做完这三步,整个问题的证据链就闭环了:源PDF没有嵌入中文字体、运行环境缺少中文字体、pdfBox的映射器也没有能力找到中文字体。三条线索全部指向字体映射机制。现在可以讨论怎么改了。
3. 动刀源码:在DefaultFontMapper里给中文留条后路
3.1 修改前的准备:找到对应版本的源码
pdfBox 2.x和3.x的源码结构有差异,这里以我实际使用的2.0.x版本为例。先说清楚,无论是修改源码重新编译,还是通过继承覆盖方法,你都需要拿到对应版本的源码包。从Maven仓库下载源码jar,或者直接把pdfbox源码拉下来都行。
在2.0.x版本里,核心类的位置在:
pdfbox/src/main/java/org/apache/pdfbox/pdmodel/font/DefaultFontMapper.java这个类做的事情说白了就是两个:addDir()注册字体目录,以及getFontByBaseFont()查表返回字体。修改思路也就围绕这两个点展开。一个是从源头上解决"没得查"——把系统字体目录注册进去;一个是从结果上解决"查不到"——在查不到的时候提供一个兜底字体。
3.2 方案A:最直接的改法,让系统字体目录自动进入扫描范围
DefaultFontMapper不会自动扫描所有系统字体目录,这是中文方块问题最直接的原因之一。最简单的源码级修改,就是在初始化的时候把当前操作系统常见的中文字体目录全部注册进去。
我这里给出一个可直接参考的修改片段,具体位置在DefaultFontMapper的构造器或者初始化方法里。思路就是根据操作系统类型,把对应的系统字体目录加进扫描路径:
public DefaultFontMapper() { // 原有初始化逻辑... String os = System.getProperty("os.name", "").toLowerCase(); if (os.contains("win")) { addDir("C:/Windows/Fonts"); } else if (os.contains("linux")) { addDir("/usr/share/fonts"); addDir("/usr/local/share/fonts"); addDir(System.getProperty("user.home") + "/.fonts"); } else if (os.contains("mac")) { addDir("/Library/Fonts"); addDir("/System/Library/Fonts"); } }这段代码的思路很朴素:提前把系统字体目录全部注册进去,让DefaultFontMapper在解析PDF里的字体名称时,有更大的概率找到真实可用的字体文件。它解决的是"环境有字体但pdfBox看不到"这一类问题。
但这个改法有个值得注意的性能问题。addDir在注册目录时,会对目录下的字体文件逐个解析,提取字体名称信息。Windows字体目录下字体文件有好几百个,如果每次创建DefaultFontMapper都重新扫描一遍系统字体目录,性能开销是肉眼可见的。我当时的做法是使用一个静态Map做缓存,让整个进程生命周期内只扫描一次,后续实例化都直接使用缓存结果。扫描一次构建映射表,单个字体文件解析大概几毫秒到几十毫秒,几百个文件加起来可能好几秒时间,但缓存下来之后查询就是纯内存操作,完全不需要担心。
3.3 方案B:更稳妥的改法,给getFontByBaseFont加一层中文Fallback
只扫描系统目录并不能解决所有问题。有些PDF里的BaseFont名字写得比较奇怪,比如"FZSongS"这种字体公司自定义的名字,即便你扫描了系统字体目录,映射表里也找不到这个名称,照样返回默认字体。
这时候更有保障的做法是加一层兜底逻辑。具体来说,就是继承DefaultFontMapper,重写getFontByBaseFont()方法:先走父类的正常逻辑,如果返回的是默认字体或者返回null,就手动加载一个我们指定的中文字体文件作为兜底。
import org.apache.pdfbox.pdmodel.font.DefaultFontMapper; import java.awt.Font; import java.io.File; public class ChineseSupportFontMapper extends DefaultFontMapper { private static final String FALLBACK_FONT_PATH = "/data/fonts/NotoSansSC-Regular.ttf"; private Font fallbackFont; public ChineseSupportFontMapper() { try { File fontFile = new File(FALLBACK_FONT_PATH); if (fontFile.exists()) { fallbackFont = Font.createFont(Font.TRUETYPE_FONT, fontFile); } } catch (Exception e) { // 兜底字体加载失败不能影响正常渲染流程,记录日志即可 System.err.println("Failed to load fallback font: " + e.getMessage()); } } @Override public Font getFontByBaseFont(String baseFont) { Font font = super.getFontByBaseFont(baseFont); if (font == null || "Dialog".equals(font.getFamily())) { return fallbackFont != null ? fallbackFont : font; } return font; } }这个兜底不仅对"SimSun"这种常规字体名有效,对任何查不到的字体名都能起到拦截作用。只要PDF里出现中文字符且映射失败,最后都会落到我们指定的中文字体上,保证至少能画出正确的字形。
这里有几个细节需要提醒:
- 兜底字体文件建议用TTF或者OTF格式。TTC格式(比如Windows的simsun.ttc)虽然多数情况下也能被
Font.createFont加载,但偶尔会有随机性问题,而且部分精简版Java环境对TTC的支持并不好。我后来直接下载了思源黑体的单字重TTF放到服务器上,稳得很。 - 判断"查不到"的标准,不同版本略有差异。有些版本返回null,有些版本返回默认的Dialog字体。稳妥的做法是两个条件都判断。
- 兜底字体文件路径不要硬编码在类里,最好做成配置项,这样换环境时不需要重新编译。
3.4 源码改完之后,编译替换这一步别犯糊涂
如果你选择直接修改pdfBox源码,改完之后需要重新编译并替换依赖。用Maven构建的直接在pdfbox源码根目录跑:
mvn clean package -DskipTests然后把生成的新jar替换到你的项目依赖中。如果你的项目是直接引用Maven中央仓库的pdfbox,不想自己维护一个fork,又确实需要改源码,可以把改好的DefaultFontMapper.class单独打成一个jar,放到classpath前面,让类加载器优先加载你的版本。但这条路坑很多,classpath顺序稍微出点问题,版本就被老jar盖回去了,排查起来非常费劲。所以我个人更推荐方案B——在自己业务工程里写一个子类,而不是去改pdfBox的源码。维护成本低,升级pdfBox版本时也不会被覆盖。
用方案B怎么让pdfBox在渲染时用上自定义的FontMapper呢?这需要看你的pdfBox版本。在某些版本里,PDFRenderer内部会自己创建DefaultFontMapper,并没有提供直接的setter。这种情况下,可以通过修改PDFRenderer源码来替换mapper实例,或者更简单一点,在业务代码里直接初始化好你自定义的mapper,并且在渲染前把它设置到合适的位置。如果你使用的pdfBox版本恰好没暴露入口,那"修改源码"这步就不可避免,不过改动量也就是一行替换,不会有太大风险。
4. 验证与反例:为什么有时候改了源码还是方块
4.1 一个干净的验证用例:中英文混合渲染
改完代码,第一件事不是直接跑线上PDF,而是用一个可控的测试用例验证效果。我当时的验证PDF特意设计了三种元素:中文标题、英文正文、数字混合文本,还在页脚放了一段纯中文的小字。这种设计能快速判断字体映射是否生效,而不被复杂版式的干扰带偏。
验证代码用pdfBox最常见的渲染方式:
import org.apache.pdfbox.Loader; import org.apache.pdfbox.pdmodel.PDDocument; import org.apache.pdfbox.rendering.PDFRenderer; import javax.imageio.ImageIO; import java.awt.image.BufferedImage; import java.io.File; public class PdfToImageTest { public static void main(String[] args) throws Exception { File pdfFile = new File("sample-cn.pdf"); try (PDDocument document = Loader.loadPDF(pdfFile)) { PDFRenderer renderer = new PDFRenderer(document); for (int pageIndex = 0; pageIndex < document.getNumberOfPages(); pageIndex++) { BufferedImage image = renderer.renderImageWithDPI(pageIndex, 150); File output = new File("output-page-" + pageIndex + ".png"); ImageIO.write(image, "png", output); System.out.println("Rendered: " + output.getAbsolutePath()); } } } }渲染出来的图片,中文部分应该轮廓清晰、笔画完整,不再出现方块。如果这个测试用例通过了,说明源码修改方向正确。如果你改完之后这个用例还是方块,那说明问题超出了单纯的字体映射范围,需要继续往下排查。
4.2 改了代码还是方块?排查这几个隐藏点
我在这个坑里反复横跳过好几次,总结下来,改了字体映射之后仍然出现方块,最常见的原因有这么几类:
第一类是字体文件本身加载失败。Font.createFont并不是对所有字体文件都买账,TTC格式的字体集合文件,某些Java版本只能读取其中第一个子字体,如果你的兜底字体恰好落在第二个子字体上,就会失败。此外,损坏的字体文件、下载了一半的TTF、权限不足无法读取文件,这些都会静默失败。注意我在方案B的代码里用try-catch包住了字体加载,失败时只打日志不影响主流程,这既是优点也是隐患——你会看到日志但不会报错,如果没注意日志,就会以为字体加载成功了。
第二类是PDF里的字体名称在映射时已经带上了样式后缀。有些PDF会把BaseFont写成SimSun,Bold或者SimSun-Italic这种带样式信息的名称。如果你的映射表里只注册了SimSun,那SimSun,Bold是查不到的。解决思路是在getFontByBaseFont里对传入的名称做一次清理,把逗号后面的样式部分剥掉,再走正常查询流程。
第三类是PDF里使用的字体不是普通TrueType字体,而是Type0/CID字体。这类字体内部的字符映射方式依赖CMap,渲染时如果CMap资源不完整,同样会出现字形对不上的问题。这类问题的典型特征是:纯中文文本能渲染一部分,但某些字符仍显示为方块,而且方块分布没有规律。这种时候你需要在环境里补充对应的Adobe CMap资源,或者检查PDF使用的CID字体名称是否被pdfBox正确识别。
第四类是字体种类的问题:有些PDF里嵌入了字体子集,但子集本身不完整,只包含PDF里出现过的字符。渲染时如果你用子集字体去查一个子集里没有的字符,也会得到方块。这种问题通常和你的源码修改无关,是PDF生成端的问题,需要在PDF生成端解决。
4.3 几个容易忽略的暗坑:缓存、并发与无头环境
如果你是在Linux服务器上运行,还要注意headless环境下的字体现象。Java在无头模式下,GraphicsEnvironment.getLocalGraphicsEnvironment()拿到的字体列表,可能和fc-list看到的不完全一致。JVM启动时会读取fontconfig配置,如果系统里的字体是在JVM启动之后安装的,那么已经运行的JVM进程里看不到新字体,必须重启进程才能生效。我有一次排了半天问题,发现新装的字体没生效,最后重启了服务好了,气得不行。
并发场景也要提一下。PDFRenderer本身不是线程安全的,多线程渲染时每个线程应该创建自己的PDFRenderer实例。但如果你的自定义FontMapper里有静态缓存,那么多个线程同时访问时要注意线程安全问题。我遇到过ConcurrentModificationException,原因就是两个线程同时在往映射表的Map里写数据。后来把缓存逻辑改成了初始化时一次性构建、后续只读,问题就消失了。
还有一个很多人容易忽略的点:如果PDF本身就是扫描件,也就是每一页都是一张图片,根本没有文字层,那字体映射逻辑根本不参与渲染,你改什么都没用。这种情况下要验证PDF是否有文字层,可以尝试在阅读器里选中一段文字看看能不能复制,能复制说明有文字层,不能复制说明是纯图片。不要在没有任何文本对象的扫描件上浪费时间排查字体问题。
5. 不修改源码也能缓解的几条路,以及什么情况下非动源码不可
5.1 在系统里补齐中文字体:成本最低的一步
如果你的问题只是"服务器上根本没有中文字体",那最简单的解法就是装字体。Debian/Ubuntu系统上,两行命令就能搞定:
sudo apt install fonts-wqy-zenhei fonts-wqy-microhei fc-cache -fvCentOS/RHEL系可以用yum install wqy-zenhei-fonts。安装之后,用fc-list :lang=zh确认字体已经可用。这一步解决的是"系统没有中文字体"的问题,但它解决不了"pdfBox不去扫描系统字体目录"的问题。如果你的pdfBox版本默认不扫描系统目录,装了字体也白搭。所以这只能算一个必要条件,不是充分条件。
5.2 从源头上消灭问题:把PDF字体先预嵌入进去
还有一条路,是从PDF生成端入手,使用Ghostscript把源PDF里缺失的字体预嵌入进去。这样pdfBox渲染时,会优先使用PDF内部嵌入的字体数据,而不依赖外部字体映射。
gs -dNOPAUSE -dBATCH -sDEVICE=pdfwrite \ -dPDFSETTINGS=/prepress \ -dEmbedAllFonts=true \ -sOutputFile=output-embedded.pdf input.pdf这个方案本质上是在pdfBox之前加一道预处理工序。对于需要批量处理的PDF,可以用脚本把所有待转文件先过一遍Ghostscript,再交给pdfBox渲染。优点是后续所有处理都基于内嵌字体的PDF,渲染结果稳定。缺点是额外引入了一个外部工具依赖,虽然Ghostscript很常见,但在某些受限环境里安装它未必比改源码容易。
5.3 权衡之后:为什么我最后还是选择了自定义FontMapper
对比几条路之后,我最终的选择是在业务项目里写一个自定义FontMapper子类,配合静态缓存,而不是直接修改pdfBox的jar。原因很简单:第一,改jar会造成依赖维护噩梦,pdfBox升级时每次都要重新打补丁;第二,子类方案可以在业务代码里统一管理,代码审查、版本管理都走正常的开发流程;第三,这个方案纯Java实现,不引入任何外部工具,放到Docker镜像里也毫无压力。
如果你也想走这条路线,实施顺序建议是:先装中文字体、再写自定义FontMapper子类、最后把兜底字体的路径放到配置中心。每一步都快速验证,不要一次性改太多东西,否则出了问题很难定位是哪一步生效了或者没生效。
5.4 最后分享一个排查捷径
处理过几次PDF渲染乱码之后,我养成一个习惯:拿到出问题的PDF,第一件事不是看代码,而是先用调试工具导出它的字体列表,再在渲染机上跑一个字体清单命令,两边一对照,问题基本就浮出水面了。PDF声明的字体和运行环境实际拥有的字体,两者之间的差集就是你要补的课。这种思路不仅适用于pdfBox,所有围绕字体的渲染问题(Java2D、iText、Apache POI生成Word转PDF等)都适用。排查效率提升的不是一点半点。