做PDF转图片这个需求,几乎每个后端团队都会碰到一次:合同、发票、报告、报表,业务方一句"要图片,别给PDF",剩下的活就落在我们身上。用PDFBox做这件事本身不难,PDFRenderer几行代码就能出图,真正让人抓狂的是画出来的图里中文全是空白——正文位置该有字的地方什么都没有,或者只剩几个方块,英文数字却好端端的。这类问题里,STSong-Light-UniGB-UCS2-H是最典型的一个:报错没有,异常没有,图就是空的。这篇东西我想把它彻底讲透,从字体命名规则、CMap编码链路,到PDFBox的字体查找顺序,再到三套能落地的替换方案和打包部署阶段的坑。不管你是第一次遇到中文不显示,还是已经把FontMapper抄了一遍却没生效,应该都能在里面找到对得上的那一段。
1. 先说清楚:图片里中文消失,锅到底在谁
1.1 三个案例暴露出的同一类问题
我经手过的这类故障,表面上五花八门,剥开看内核高度一致。第一个是某公司的电子发票导出功能,开发本机(Windows + 装了完整Office套件)跑得好好的,一上Linux容器,发票抬头和明细栏全空,金额却是完整的。第二个是某高校的教务系统,批量把成绩单PDF转成图片做归档,几百份文件里只有一部分出问题,规律是"越老的模板越容易坏"。第三个是某内容平台的文档预览服务,用户上传的PDF里,用某国产办公软件导出的文件中文必丢,用另一款软件导出的就正常。
这三个案例的共同点,不是PDFBox版本,也不是代码写错,而是PDF文件本身没有嵌入中文字体。当一份PDF里的字体是"引用式"的——只写了字体名,把真正的字形数据留给阅读器自己去系统里找——那么渲染结果就完全取决于运行环境里有没有这个字体、以及PDFBox能不能把它对上号。开发机上装了一堆中文字体,随便蒙对一个就显示出来了;容器镜像里往往只有最精简的几个字体包,于是就空白了。
所以别急着改代码,第一件事是确认:这份PDF到底嵌没嵌字体。
1.2 STSong-Light-UniGB-UCS2-H 这个名字是怎么拼出来的
很多人被这串名字唬住,其实它拆开看非常规整,是三段信息的拼接:
| 片段 | 含义 | 说明 |
|---|---|---|
STSong-Light | 字体基础名(BaseFont) | 华文宋体 Light 字重,属于Adobe定义的CJK标准字体之一 |
UniGB-UCS2-H | 编码名(Encoding) | 简体中文、UCS2双字节、横向书写 |
-连接符 | 命名约定 | Type0复合字体常见的写法是基础名-编码名 |
关键理解在于:UniGB-UCS2-H不是一个"字体",它是一张字符到字形编号(CID)的翻译表,也就是CMap。PDF内部的文字并不是直接存"中""文"这样的字符,而是存一串字节;阅读器拿到字节后,先查CMap确定这是第几号字形,再去字体文件里取第几号字形的轮廓。STSong-Light提供的是"第几号长什么样",UniGB-UCS2-H提供的是"哪个字节对应第几号"。
这个分工非常重要,因为它直接决定了排查方向:空白可能是因为翻译表丢了(不知道第几号),也可能是因为字形库丢了(知道第几号但画不出来)。这两种情况的现象几乎一样,但修法完全不同,混在一起查会浪费大量时间。
1.3 两条容易混淆的故障线
我把它分成两条线,你可以对着自己的日志判断属于哪条。
第一条线叫CMap缺失。PDFBox 会把UniGB-UCS2-H这类预定义CMap作为资源文件打在jar包里,路径大致在org/apache/pdfbox/resources/cmap/下。如果打包环节把这些资源弄丢了——比如用了某些shade配置、或者把jar解压重组成别的结构、或者在Android这类没有标准classpath资源的环境里跑——那么解析阶段就会出问题。典型表现是解析时就抛异常,或者文字层能抽出乱码。这条线的特征是:问题在解析阶段就发生,跟渲染无关,也跟系统装了什么字体无关。
第二条线叫字形缺失。CMap正常,字符成功翻译成了CID,但PDFBox要去拿字形轮廓时发现:这个字体没嵌入,系统里也找不到能替代的。PDFBox 的处理策略是降级——降级到某个默认字体,而默认字体里没有中文字形,画出来就是空白或者豆腐块。这条线的特征是:解析完全正常,文本抽取也能拿到正确的中文,只有渲染成图片时才空。
区分方法很直接:用PDFTextStripper抽一下文本。抽出来是正确的中文,说明编码链路没问题,问题在字形,走第二类修法;抽出来是乱码或者抛异常,那就是第一类。这一步花不了五分钟,但能省掉几个小时的瞎试。
1.4 为什么"文本正常但渲染空白"是最常见的一种
实际统计下来,第二类占绝大多数,原因也很简单:现代PDF生成库(各种报表引擎、办公软件导出、在线签章服务)绝大多数都会把标准CJK字体名写进去但不嵌入本体。这不算是文件损坏,PDF规范允许这么做——它假设阅读器环境里有对应的字体,就像HTML里写font-family: 宋体而不把字体文件塞进网页一样。
问题在于,浏览器环境里用户机器上通常有宋体,而服务器容器里没有。这个假设在PC端成立,在服务端就不成立了。所以这类问题有个非常鲜明的特征:本机不复现,服务器必现;Windows不复现,Linux必现。如果你正在被"我这边是好的啊"这句话折磨,基本可以锁定是这一类。
2. PDF 中文渲染链路拆解:从字符到像素要过几道关
2.1 第一关:CMap 把字节流翻译成 CID
PDF内容流里的文字,写作(字节序列) Tj。对于使用UniGB-UCS2-H的复合字体,这串字节是两个一组(UCS2),每组对应一个Unicode码位附近的编号。PDFBox 拿到之后,会去加载名为UniGB-UCS2-H的CMap对象,用它把这组编号映射成CID。
这一步在PDFBox内部是由PDType0Font配合CMapManager完成的。CMap的来源有两个:一是PDF文件里自带的(内嵌CMap流),二是PDFBox资源包里预置的(预定义CMap)。UniGB-UCS2-H属于后者,是PDF规范里列出的标准预定义CMap之一,PDFBox 必须从自己的资源里加载它。
这里有个特别容易被忽略的细节:预定义CMap的资源文件必须在classpath里能被找到。PDFBox 用的是相对固定的资源路径,一旦打包工具把资源挪了位置、或者用了某种会重写资源路径的构建方式,加载就会失败。失败时的日志不一定显眼,有时候只是一行warn,然后就悄悄按默认方式处理了。
所以第一关的检查方式是:确认pdfbox依赖是完整引入的(不是只引了某个精简分包),确认打包后jar里能翻到cmap目录下的资源文件。用jar tf或者解压看一眼,几十秒的事。
2.2 第二关:CID 到字形轮廓
拿到CID之后,PDFBox 需要找到"第N号字形长什么样"。这个数据只能来自字体文件(TrueType/OpenType/CFF)。如果PDF里嵌了字体子集,直接读就行,这一关稳过——这也是为什么有些PDF怎么转都不会丢字。
如果没嵌,PDFBox 就要走替代逻辑:先看字体描述符里有没有给替代字体名,没有的话就按基础名去系统里找。找不到,就用兜底字体。兜底字体通常是某个西文字体(比如Helvetica的等价物),里面根本没有汉字字形,于是每个汉字的宽度算出来了、位置也占住了,但画不出东西——这就是你看到的"空白"而不是"错位"。
这里还要提一个容易误解的点:空白不等于没画。PDFBox 在布局阶段是按字体的Widths数组算宽度的,宽度信息在PDF里是显式写着的(或者通过/DW默认宽度给),所以位置是对的。真正丢的只有最后的绘制指令。这也解释了为什么有些图里中文位置留了空白,前后英文却紧挨着——间距是对的,只是没墨。
2.3 第三关:PDFBox 的字体查找顺序
这一关是解决问题的钥匙,必须记清楚。PDFBox 在需要替代字体时,查找顺序大致是:
- 字体描述符里的
/FontFile、/FontFile2、/FontFile3:有就用,最优先。 - 描述符里的
/FontFamily、/BaseFont名称:拿去系统里匹配。 - 系统字体扫描:通过AWT的字体环境或者字体目录扫描,找一个名字对得上的。
- 默认兜底字体:前面全失败时的最后选择。
关键的坑在第3步:这个"系统字体扫描"依赖运行环境。在headless模式下(服务端几乎必然开启),AWT的字体环境依然可用,但它能看到的只是操作系统已经注册的字体。容器镜像里如果只有dejavu这类基础西文字体,扫描结果里就永远不会有中文字体。你在Dockerfile里apt install一堆字体包,如果没触发字体缓存刷新,AWT也未必立刻能看到。
还有一个更隐蔽的问题:名称匹配是模糊的、有容错的、也可能误判的。PDF里的基础名可能带子集前缀(形如ABCDEF+STSong-Light),可能带连字符和编码后缀,还可能有大小写差异。PDFBox 会做一些规范化处理(去前缀、去空格、转小写之类),但规范化的规则和你的字体内部名称不一定能对上。所以"我把字体装进系统了"和"PDFBox 认出来了"之间,还隔着一层匹配逻辑。
理解了这三关,后面的方案就好懂了:第一类方案是绕过第3步的匹配,直接强制指定字体;第二类方案是把第3步的环境做扎实;第三类方案是彻底不走渲染,改成抽文字重排。
3. 环境与依赖准备
3.1 版本选择与避坑
PDFBox 的2.x和3.x在字体这一块的API差异不小,选版本之前先想清楚。
2.0.x 系列是事实上的稳定主力,PDFRenderer加FontMapper的这套接口用了很多年,社区里的方案、示例、踩坑记录基本都基于它。如果你现在是从零开始,且没有强制的升级理由,2.0.x 的最后一个稳定版是最省心的选择,因为它和绝大多数教程对得上,遇到问题好查。
3.x 做了比较大的重构,引入了Loader.loadPDF这样的新入口,字体这块调整为FontProvider体系,还多了PDFBoxResourceLoader用来做资源初始化。API更清晰,但社区资料相对少,遇到冷门问题得自己啃源码。
我的建议很直接:生产环境如果已经在2.0.x上跑着,别为了这个字体问题去升3.x,升级带来的回归风险远大于收益。反过来,如果是新项目、团队有能力读源码,3.x的字体模型确实更好理解。
不管选哪个版本,有一个动作必须做:把pdfbox和fontbox的版本对齐。这俩是配套的,混用版本会出现各种莫名其妙的问题,其中最典型的就是字体解析阶段的NoSuchMethodError或者 CMap 加载异常——这类错误看着像字体问题,实际是依赖冲突。
3.2 运行环境的要素清单
服务端跑PDF渲染,环境上有几个点必须先定下来,否则后面所有排查都会被干扰。
| 要素 | 建议值 | 原因 |
|---|---|---|
| headless模式 | true,且尽早设置 | 避免AWT尝试连显示设备,容器里必然失败 |
| 中文字体 | 至少装一套覆盖GB18030的 | 兜底用,缺了必现空白 |
| 字体缓存 | 装完字体执行一次刷新 | 不然AWT扫描不到 |
| JVM内存 | 单页300DPI按35MB估算 | 大图容易OOM |
| 临时目录 | 可写且空间充足 | 字体解析会落临时文件 |
java.awt.headless这个参数要在任何AWT类被加载之前设置,也就是在main方法的第一行,或者通过启动参数-Djava.awt.headless=true传进去。写在中途是无效的,因为AWT的图形环境在第一次被触碰时就初始化好了,之后再改属性不会重新初始化。这个坑我见过不止一次:代码里明明设了,但还是报Can't connect to X11 window server,原因就是设置得太晚——某个静态初始化块或者日志框架先碰到了AWT。
3.3 搭一个最小可复现的验证工程
别在业务代码里直接改,先单独建一个能跑通的最小工程,把问题复现出来,改好了再往回搬。步骤大概是这样:
第一步,准备一份"坏"的PDF。找一份用标准CJK字体但没嵌入的文件,或者用某办公软件导出时选"不嵌入字体"。验证方法是用文本抽取跑一遍,能抽出正确中文就说明这份文件属于第二类故障,适合做实验样本。
第二步,写一个最小转换程序:加载文档、取第一页、用PDFRenderer以150DPI渲染成PNG、落盘。
第三步,在Linux环境下跑(容器或者一台干净虚拟机),确认问题复现。这一步不能省,本机跑不出问题就没有实验价值。
第四步,加上你的修复方案,再跑一遍,确认图片里有中文了。
这个小工程的价值在于可控。业务代码里变量太多——多线程、字体缓存、文件句柄、各种配置,出问题根本不知道是哪一层。隔离出来之后,改动和结果是一一对应的。
4. 方案一:自定义 FontMapper 强制字体替换(主推)
4.1 为什么这个方案最可靠
前面说过,PDFBox 找替代字体时依赖名称匹配,而名称匹配是个"看运气"的环节——它成功了你不知道为啥成功,失败了也不知道为啥失败。自定义FontMapper的思路是把这一步接管过来:不管PDF里写的是什么名字,只要判断出这是个中文复合字体,就直接返回我指定的那个TTF。
这样做的好处有三个。一是确定性,结果完全由你的代码决定,不受运行环境字体安装情况影响。二是可观测,你可以在里面打日志,清楚看到哪些字体被替换了、替换成了什么。三是可移植,同一份代码在开发机、测试环境、生产容器里行为完全一致。
代价是你要自己管字体文件。这个代价其实很低——一个开源中文字体文件几MB到十几MB,打进镜像或者挂载进去都行。
4.2 代码骨架与关键点
以2.0.x为例,核心是继承默认的FontMapperImpl,只覆盖复合字体(CID字体)那一个方法:
import org.apache.fontbox.ttf.CIDFontMapping; import org.apache.fontbox.ttf.TTFParser; import org.apache.fontbox.ttf.TrueTypeFont; import org.apache.pdfbox.pdmodel.font.PDCIDFont; import org.apache.pdfbox.pdmodel.font.PDFontDescriptor; import org.apache.pdfbox.rendering.FontMapperImpl; import java.io.File; import java.io.IOException; public class CjkFontMapper extends FontMapperImpl { private final File cjkFontFile; private volatile TrueTypeFont cachedFont; public CjkFontMapper(File cjkFontFile) { this.cjkFontFile = cjkFontFile; } private synchronized TrueTypeFont font() throws IOException { if (cachedFont == null) { TTFParser parser = new TTFParser(); cachedFont = parser.parse(cjkFontFile); } return cachedFont; } @Override public CIDFontMapping getCIDFont(String baseFont, PDFontDescriptor fontDescriptor, PDCIDFont cidFont) { // 先让默认实现尝试一次,能找到就用它 CIDFontMapping original = super.getCIDFont(baseFont, fontDescriptor, cidFont); if (original != null && original.getTrueTypeFont() != null) { return original; } // 兜底:只有确实是中文相关的字体名才替换 if (isCjkFont(baseFont)) { try { TrueTypeFont ttf = font(); return new CIDFontMapping(ttf, null); } catch (IOException e) { // 记日志,别吞 } } return original; } private boolean isCjkFont(String baseFont) { if (baseFont == null) { return false; } String name = baseFont.toLowerCase(); return name.contains("song") || name.contains("hei") || name.contains("kai") || name.contains("ming") || name.contains("cjk") || name.contains("gothic"); } }使用的时候挂到渲染器上:
try (PDDocument doc = PDDocument.load(new File("input.pdf"))) { PDFRenderer renderer = new PDFRenderer(doc); renderer.setFontMapper(new CjkFontMapper(new File("/opt/fonts/cjk.ttf"))); BufferedImage image = renderer.renderImageWithDPI(0, 150); ImageIO.write(image, "png", new File("page-1.png")); }有几个细节决定了这段代码能不能真跑通,必须说清楚。
第一,TrueTypeFont对象要做缓存。TTFParser.parse是会读整个文件、解析表的,每页每字体都重新解析一次,性能会成倍下降。用成员变量缓存,配合synchronized或者volatile处理并发。但要注意,TrueTypeFont不是线程安全的,如果多个线程同时渲染,最好每个线程一份,或者用对象池。
第二,不要无脑替换所有字体。如果isCjkFont判断过于宽松,把所有字体都换成中文字体,结果是西文的字距、字形全变了,排版一塌糊涂。我上面用的是关键字白名单,实际项目里可以再加一层判断:看fontDescriptor里有没有嵌入文件,有嵌入的直接放行,不给替换。
第三,CIDFontMapping的构造参数。不同小版本之间签名可能有出入,第二个参数是字体描述符,传null表示不附加额外信息。如果你编译报错,看IDE提示对齐一下就行,逻辑是不变的。
第四,日志一定要打。在isCjkFont返回true的地方打一行log.info("font substituted: {}", baseFont),在返回original的地方也打一行。上线之后遇到新样本,看日志就知道哪些字体名没被覆盖到,把关键字补进去即可。这个日志我只花了十分钟加,后面省了至少两天。
4.3 字体文件从哪来、怎么选
替换用的字体,最好选那种覆盖范围广、字形规范、许可证宽松的开源中文字体。选的时候重点看三个指标:
- 字符覆盖面。至少要覆盖GB18030的常用部分,不然遇到生僻字(人名、地名、专业术语)还是会空。别只看"支持中文",要实际拿生僻字测一遍。
- 字重是否够用。如果PDF里同时有常规体和粗体,单字重的字体替换过去,粗体也会显示成常规,视觉上会"塌"。要么准备两套字体按字重分发,要么接受这个妥协。
- 文件大小。全量字体十几MB,打进镜像会让镜像变大不少。如果确定业务场景字符集有限,可以做字体子集化,但子集化本身有维护成本,一般项目不值当。
字体文件在代码里读,路径建议做成可配置项,别硬编码。常见做法是通过环境变量或者配置中心下发路径,程序启动时检查文件存不存在,不存在直接启动失败——早失败比运行到一半发现字体读不到要好得多。
注意:字体文件是二进制资源,用git管理会让仓库膨胀。用制品库、对象存储或者镜像层管理更合适。如果一定要进git,考虑用git-lfs。
5. 方案二:往系统字体目录里塞字体
5.1 目录位置与缓存刷新
如果你的思路是"不动代码,只改环境",那这条路可以走,但要接受它的不确定性。
Linux下的字体目录大致分三层:系统级/usr/share/fonts/(或者发行版对应的位置)、用户级~/.fonts/或~/.local/share/fonts/、以及单独配置的目录。把TTF文件拷进去只是第一步,第二步是刷新字体缓存——不刷新,很多程序(包括通过AWT找字体的PDFBox)看不到新装的字体。
刷新命令通常是fc-cache -fv。执行完会打印扫描到了哪些目录、缓存了多少字体。如果输出里没有你放的目录,说明配置有问题,这时候装再多文件也白搭。检查方式是fc-list | grep -i 字体名,能列出来才算真的注册成功。
容器镜像里的做法,是在Dockerfile里装字体包或者拷文件,然后在同一个RUN指令里执行刷新:
# 装系统包的中文字体 apt-get update && apt-get install -y --no-install-recommends fonts-wqy-zenhei # 或者把自带字体拷进去 mkdir -p /usr/share/fonts/truetype/custom cp /tmp/cjk.ttf /usr/share/fonts/truetype/custom/ fc-cache -fv关键点:fc-cache必须和文件拷贝在同一个层,或者至少在文件仍然存在的层执行。如果分了两层,后一层可能拿不到前一层删除的临时文件,缓存刷新就失效了。这个坑很隐蔽,表现是"镜像构建明明成功了,跑起来字体还是没有"。
5.2 字体名匹配到底怎么算对得上
这一步是方案二最玄学的地方,值得展开说。
PDFBox 在系统字体里找的时候,拿的是PDF里的字体名(经过一定规范化),去和系统字体的内部名称比对。系统字体的内部名称有三个地方看:字体文件的family name、full name、postscript name。这三个名字不一样!比如一个开源中文字体,family name可能是"文泉驿正黑",full name可能带版本后缀,postscript name可能是"WenQuanYiZenHei"。
PDF里的STSong-Light显然对不上任何一个。所以指望"装个中文字体,PDFBox自己就能匹配上",成功率其实不高。真正生效的情况是:PDFBox 在找不到精确匹配时会走一个"模糊匹配"或者"任意中文字体也行"的降级逻辑——具体取决于版本和内部实现,不能指望。
这也是为什么我把方案一排在前面。方案二真正靠谱的用法,是配合方案一作为兜底:代码里做了强制替换,同时环境里装了一套中文字体作为双保险。万一遇到代码没覆盖到的字体名,系统字体还有机会救一下。
5.3 一个更稳的变体:改字体内部名
如果一定要走"环境匹配"这条路,有个技巧是把字体的内部名称改成和PDF里一致。用fonttools这类工具可以修改字体元数据:
# 把字体的family name改成目标名称 python3 -m fontTools.ttLib.???具体命令这里不铺开,思路是:读取TTF,修改name表中的 family / full / postscript 三个记录,另存为新文件,再装进系统。这样fc-list就能列出你期望的名字,PDFBox 匹配上的概率大幅提升。
但我不推荐把这个做法用在生产上。原因很简单:它把一个代码层面的确定性问题,变成了一个环境配置的隐式约定,后来接手的人根本不知道字体文件被改过名。真要用,至少写一份文档说明清楚。
6. 方案三:兜底路线,文本重排
6.1 什么时候该走这条路
如果前两个方案都不行——比如字体文件拿不到、或者PDF结构特殊到替换也无效——还有最后一条路:放弃"原样渲染",改成"抽取文字 + 自己排版 + 生成图片"。这条路能保证内容在,但排版一定会变,只适合对视觉还原要求不高的场景,比如全文检索预览、内容审核缩略图、纯文本归档。
判断标准很明确:业务方能不能接受"图片和原PDF长得不一样"。能接受就走,不能接受就别浪费时间,回去继续修字体。
6.2 实现要点与代价
实现上是两段拼接。第一段用PDFTextStripper抽文本,可以按页抽,也可以带位置信息抽(setSortByPosition(true)让它按坐标排序,出来的顺序更接近阅读顺序)。第二段用Java2D自己画,把文字按宽度换行,写进BufferedImage,再存成PNG。
代价主要有三个。一是丢失排版,表格会散架,多栏会串行,图文混排基本没法看。二是丢失样式,字号、颜色、加粗全没了。三是逻辑要自己写,换行规则、分页、标点避头尾,每一样都是坑,写到最后你会发现自己在造一个微型排版引擎。
我的实际经验是:这条路适合做降级开关,不适合做主力方案。做法是在渲染失败时(检测到页面文字几乎为空但文本抽取有内容)自动切到这条路,生成一张"简版预览图",同时在响应里标记degraded: true,让前端知道这是降级结果。这样用户至少能看到内容,不会看到一个白板。
7. 打包与部署阶段最容易翻车的三个点
7.1 fat jar 里的 CMap 资源去哪了
这是第一类故障的头号原因,也是"本地好好的,打成就跑不了"的典型。
把依赖打成一个可执行jar时,如果shade插件的配置里做了minimizeJar(只保留被引用到的类),资源文件很容易被裁掉,因为资源不是"类引用",静态分析看不到。CMap的.bin文件、字体的资源文件都可能中招。
排查方法很直接:把打好的jar解开,看org/apache/pdfbox/resources/cmap/目录还在不在,UniGB-UCS2-H对应的文件在不在。
unzip -l app.jar | grep -i cmap如果在,说明资源没问题,往字体方向查;如果不在,那就是打包配置的锅,加白名单:
<filter> <artifact>*:*</artifact> <excludes> <exclude>...</exclude> </excludes> </filter>注意这里要做的是保留而不是排除,别把方向搞反。有些构建工具默认会把jar合并时的重复资源丢掉,而CMap在不同依赖里可能重名,被丢掉的可能正是你需要的那份。
7.2 headless 与字体缓存的时序问题
前面提过java.awt.headless要早设置,这里补一个更细的点:AWT的字体环境初始化之后,会缓存一份字体列表。如果你的程序在运行过程中才往字体目录里放文件,缓存不会自动刷新,新字体依然不可见。
这在"启动时下载字体"的场景里特别容易踩:程序启动、初始化AWT、从对象存储下载字体到本地、渲染——顺序错了,字体就白下载了。解决办法有两个:要么把下载放到AWT初始化之前(也就是在最早期执行),要么下载完之后不依赖系统字体,直接用方案一按文件路径加载。后者更干净,我一般推荐这个。
7.3 只读根文件系统与临时目录
容器安全加固时经常把根文件系统设为只读,只挂载几个可写目录。这时候要注意两件事。
第一,PDFBox 在解析字体时可能往临时目录写东西。默认临时目录是/tmp,如果/tmp也没挂成可写(有些加固方案会这么做),就会抛IOException,报错信息往往指向字体,让人误以为是字体问题。启动参数里显式指定-Djava.io.tmpdir=/data/tmp并确保它可写,能省掉很多困惑。
第二,如果你把字体文件放在镜像里的/opt/fonts/,只读是没问题的;但如果代码里做了"字体缺失时自动下载",那就需要一个可写目录。这两种策略要提前定,别在加固之后才发现跑不起来。
8. 参数调优:DPI、内存、批量
8.1 DPI 怎么定,尺寸怎么算
renderImageWithDPI的DPI参数直接决定输出图片的像素尺寸和内存占用。算法很简单:页面尺寸(单位pt)除以72,乘以DPI,就是像素数。
以A4为例,页面是595.28 × 841.89 pt,不同DPI下的输出尺寸和单页内存占用大致如下(按每像素4字节计):
| DPI | 输出尺寸(约) | 单页位图内存 | 适用场景 |
|---|---|---|---|
| 96 | 794 × 1123 | 3.4 MB | 网页缩略图 |
| 150 | 1240 × 1754 | 8.3 MB | 通用预览 |
| 200 | 1653 × 2339 | 14.8 MB | 打印预览 |
| 300 | 2480 × 3508 | 34.8 MB | 归档、OCR前置 |
这张表只是位图本身。实际内存还要加上解码后的字体缓存、PDF文档对象、以及JVM自己的开销。批量处理时,如果开10个线程各渲染300DPI的A4页面,光位图就是350MB,再加上GC压力,很容易OOM。
我的经验值:预览类业务150DPI足够了,肉眼几乎看不出和300DPI的差别,文件大小能小一半以上。归档类业务再上300DPI。如果是给OCR做前置,反而要考虑的是清晰度和二值化,不是一味提DPI。
8.2 内存与并发的平衡
PDFBox 的PDFDocument不是为高并发设计的。同一个PDDocument实例被多线程同时渲染,会出现各种诡异问题——字体缓存错乱、内容流读取异常、甚至死循环。正确做法是每个线程一份文档实例,或者用文档池。
PDFRenderer同样不是线程安全的。即使用同一个文档,也要每线程新建一个PDFRenderer。这一点在压测时藏不住,单线程跑一万次没问题,多线程一上就出各种异常。
批量任务的参数建议:
- 单实例并发控制在CPU核心数附近,别开太大,PDF解析是CPU密集型,开多了只是增加上下文切换。
- 每个线程设置超时(比如30秒),PDF结构异常时PDFBox有可能卡住不返回,没有超时会把线程池耗干。
- 处理完及时
close()文档,别指望GC,PDFBox持有的本地资源(比如内存映射文件)不及时释放会累积。 - 输出图片用流式写,别在内存里攒一堆
BufferedImage再统一写盘,攒着攒着就OOM了。
9. 排查速查表与经验
9.1 症状对照表
这张表是我这几年攒下来的,遇到问题先对号入座,能省掉大量试错。
| 现象 | 最可能的原因 | 快速验证 | 处理方向 |
|---|---|---|---|
| 中文全空白,英文正常 | 字体未嵌入且无替代 | 文本抽取是否正常 | 方案一字体替换 |
| 中文是方块或豆腐块 | 找到了字体但无对应字形 | 换字体文件试 | 换覆盖面更广的字体 |
| 解析就抛异常 | CMap资源缺失 | 查jar内资源 | 修打包配置 |
| 文本抽取乱码 | 编码表不对 | 看字体Encoding字段 | 检查CMap加载 |
| 本机好服务器坏 | 环境字体差异 | 服务器fc-list | 方案一或方案二 |
| 部分页正常部分页空 | 同一文档混用多种字体 | 逐页看字体列表 | 白名单补关键字 |
| 图片全白 | 渲染DPI或尺寸异常 | 打印图片尺寸 | 检查页面MediaBox |
| 渲染卡死不返回 | PDF结构损坏或递归引用 | 加超时看堆栈 | 跳过该文件并告警 |
9.2 日志该埋在哪
排查这类问题,日志的粒度决定了效率。我的做法是在三个位置埋点,都是低成本的。
第一个点在文档加载后:遍历每一页的Resources,把用到的字体基础名全部打出来。这一步能立刻看出这份PDF用了哪些字体、有没有嵌入。代码不长,十行左右,但对定位极有帮助。
第二个点在字体替换时:前面说的,替换成功和替换失败都打一行,带上原字体名和目标字体名。
第三个点在渲染完成后:打一行"页数、耗时、输出尺寸、是否有文字层"。这个主要是为了发现"渲染成功但内容为空"的情况——PDFBox 不报错,图就是白的,没有这行日志你根本不知道。
三行日志加起来不到二十行代码,但它把整个链路变成了可观测的。我现在的项目里这套日志是标配,新同事接手也能很快上手。
9.3 几个不太容易想到的坑
说几个文档里不会写、但实际会遇到的。
坑一:字体文件本身有问题。网上下的字体文件,有些是残缺的,缺表、缺字形、甚至被截断。表现是替换之后还是空白,或者干脆抛解析异常。遇到这种情况别死磕代码,换一个来源可靠的字体文件试一下,往往立刻就好了。验证字体文件是否完整,可以用工具打开看看字符集,或者用它渲染一段生僻字文本试试。
坑二:PDF里同一页用了多个中文变体。比如正文用宋体,标题用黑体,注释用楷体。你只替换了一种,结果就是部分文字出来部分还是空。所以isCjkFont的白名单要做全,或者干脆做成配置项,出问题改配置不用发版。我现在的做法是配置文件里放一组关键字,启动时加载,遇到新样本补一行配置重启即可。
坑三:字体子集前缀干扰匹配。有些PDF里的字体名带六个大写字母加加号的前缀,这是子集标记,不是字体名的一部分。你的关键字匹配逻辑要考虑这种情况,做一下前缀剥离,不然ABCDEF+STSong-Light匹配不上song。这个坑很典型,我当时盯着日志看了半天才反应过来。
坑四:内存映射文件与容器文件系统。PDFBox 在处理大文件时会用到内存映射,某些容器的存储驱动对内存映射支持不好,表现为读取极慢或者抛IO异常。这种情况换用普通流式读取的配置能绕过,但会牺牲一点性能。判断方法是看同一个文件在宿主机和容器里的耗时差异。
坑五:中文标点被单独处理。有些PDF把标点符号用另一套字体编码,替换主字体之后,正文出来了,标点还是空的。这类问题看图片会以为是"个别字符缺失",实际上要单独处理标点涉及的字体。检查方法是把缺失的字符记下来,看它们的Unicode范围是不是集中在标点区。
9.4 一套可以照抄的落地顺序
最后把我实际用的顺序整理一遍,按这个走基本不会绕远路。
第一步,拿到出问题的PDF,用文本抽取验证是"编码问题"还是"字形问题"。这一步定方向,不能跳。
第二步,如果文本抽取正常,检查运行环境里有没有中文字体。没有就先装一套,看问题是否缓解。这一步是为了确认诊断,不是最终方案。
第三步,不管上一步结果如何,直接上方案一的自定义字体替换。这是确定性的解法,值得作为标准配置沉淀下来。替换的白名单从实际样本里收集,先覆盖已知的,后面按日志补。
第四步,把字体文件的管理方式定下来。推荐打进镜像,路径通过配置下发,启动时校验存在性,不存在就快速失败。
第五步,补齐三处日志,把这套机制变成可观测的。这一步的价值在上线之后才体现,但必须现在做,事后补成本高得多。
第六步,加一个降级开关。万一某个PDF替换也救不了,自动切到文本重排并标记降级,保证服务可用性。宁可给用户看一个简版,也别给一张白图。
第七步,建一个坏样本库。把遇到过的所有问题PDF存下来,作为回归测试用例。这个库会随着时间越来越值钱,它是你唯一能验证"改了代码没有引入回归"的依据。
我在几个项目里都是按这个顺序落地的,中间那次踩得最狠的坑,是一开始想省事直接改环境、结果换了三台机器才发现问题根本不是字体没装、而是镜像构建时字体缓存刷错了层。回头看,如果第一步就老老实实做文本抽取验证,能省掉整整一天。所以这套顺序里我最强调的其实是第一步——先判断,再动手,别上来就改代码或者装字体,那跟蒙着眼睛修车没什么区别。