1. 问题现场:中文在 PDFBox 转图片时"整段消失"
先说一个我很熟悉的场景:后台服务收到用户上传的 PDF,要把每页转成 PNG 做缩略图和在线预览。代码写得很标准,PDFRenderer一页页渲染,英文数字、表格线、图片全在,唯独中文标题和正文整块消失——不是乱码、不是方块,而是干干净净的空白,控制台一行异常都没有。这类问题十有八九发生在用 PDFBox 处理 PDF 转图片(PNG/JPG)的时候,而病灶往往藏在字体名上:STSong-Light + UniGB-UCS2-H。
这个组合在中文 PDF 里出现频率极高。很多服务端生成 PDF 的库为了减小文件体积、加快生成速度,默认使用一套"标准中文 CID 字体"的名字来写字体字典,但不把字体文件真正嵌入 PDF。用常见的阅读器打开完全正常,因为阅读器自带或者能拿到这套字体的字形数据;换成 PDFBox 这种纯 Java 的渲染库,它一个中文字形的轮廓都没有,只能去运行环境里找,找不到就什么都不画。
这篇文章面向的是被这个坑卡住过的 Java 后端、文档处理方向的同学,也适合刚接手 PDF 转图片需求的开发者。我会把现象分类、字形查找链路、FontMapper 定制、运行环境字体、完整可用代码、DPI 换算、批量与并发、以及从生成端根治的办法一次讲透。基础薄弱的读者可以只看诊断和完整代码那两节,先把线上问题止住;想彻底搞明白的,建议按顺序读。
1.1 一条最小复现路径
想验证是不是同一个病,最快的办法是拿一份"中文正常但体积很小"的 PDF 来试。判断特征很简单:文件几 MB 的扫描件不算,那种几百 KB、里面却有大量中文文本的 PDF,多半没嵌字体。你写十行代码:
try (PDDocument doc = PDDocument.load(new File("in.pdf"))) { PDFRenderer renderer = new PDFRenderer(doc); BufferedImage img = renderer.renderImageWithDPI(0, 150, ImageType.RGB); ImageIO.write(img, "png", new File("out.png")); }跑完打开out.png,如果英文和线条都在、中文位置一片空白,基本可以锁定本文说的问题。注意,这个判断要和另外两种现象区分开:中文变成一个个小方块(俗称豆腐块),说明字体找到了但缺字形;中文变成一堆莫名其妙的符号或者拼音字母,说明编码和 CMap 对不上。三种现象对应三套完全不同的处理思路,混为一谈会浪费大量时间。
我在实际排查中还遇到过一个更迷惑的版本:大部分页面空白,少数页面中文正常。原因是同一份 PDF 里,封面用了模板里嵌好的字体,正文用了另一套没嵌的字体。所以别只看第一页就下结论,逐页 dump 字体信息才靠谱,方法在第 2 节。
1.2 STSong-Light 与 UniGB-UCS2-H 到底是什么关系
把这两个名字拆开看,问题的轮廓就清楚了。
STSong-Light是一个字体名,属于一套标准的中文宋体 CID 字体,特点是"有名字、有字形规范、但通常不作为文件嵌入"。UniGB-UCS2-H是一个 CMap 名字,它的作用是规定"PDF 内容流里的字节如何映射到字形编号"。名字里的含义拆开就是:Unicode、GB(中文)、UCS2(两字节编码)、H(横写)。也就是说,内容流里每两个字节是一个 Unicode 码位,先经这张 CMap 转成一个 CID(字符编号),再去字体里取对应字形。
在 PDF 的字体字典里,它通常长这样:类型是Type0,Encoding是UniGB-UCS2-H,DescendantFonts里挂一个CIDFontType0,BaseFont是STSong-Light,而FontDescriptor里没有FontFile、FontFile2、FontFile3任何一个键。最后这一点就是命门——没有这三个键中的任何一个,就意味着这个 PDF 里一个字形数据都没有,全靠阅读器自己想办法。
生成端为什么这么干?因为嵌入一套完整中文字库动辄几 MB 到十几 MB,一份合同 PDF 从 200KB 涨到 8MB,传输和存储成本都上去了。用标准字体名不嵌入,生成快、体积小,在桌面阅读器上看起来毫无问题,问题就被推迟到了服务端渲染这一步。
1.3 渲染库和阅读器的根本差异
很多人卡在这里想不通:"我用阅读器打开是好的,凭什么你渲染不出来?"差异就在这里:阅读器是本地应用,操作系统里装着中文字体,或者软件自带一套 CJK 字库,遇到STSong-Light就用自己的字库顶上。PDFBox 是纯 Java 库,它的设计原则是"只解析 PDF 结构,不捆绑任何商业字体",所以遇到未嵌入的字体,它会走一条兜底链路去本机找,找不到就画不出任何东西。
顺带说一句,这也是为什么同一个程序在开发同学的 Windows 机器上跑得好好的,一上 Linux 容器就全空白——Windows 系统里有一堆中文字体,精简版容器镜像里连一个中文字体文件都没有。这个现象太典型了,我在第 4 节专门讲。
2. 先诊断再动手:把 PDF 的字体底细全打印出来
改代码之前,我强烈建议先花十分钟写个诊断脚本。原因很实际:PDF 字体问题的分支太多,凭现象猜方案,改三遍都不一定对;把字体字典里的关键字段打出来,一眼就知道该走哪条路。
2.1 十行代码摸清字体是否嵌入
下面这段代码遍历每页的资源字典,把字体名、类型、是否嵌入、CMap、CID 系统信息全打出来。它不依赖任何业务代码,可以当独立工具类用:
try (PDDocument doc = PDDocument.load(new File("in.pdf"))) { for (int i = 0; i < doc.getNumberOfPages(); i++) { PDResources res = doc.getPage(i).getResources(); for (COSName name : res.getFontNames()) { PDFont font = res.getFont(name); System.out.printf("page=%d key=%s base=%s type=%s embedded=%s%n", i, name.getName(), font.getName(), font.getClass().getSimpleName(), font.isEmbedded()); if (font instanceof PDType0Font) { PDType0Font t0 = (PDType0Font) font; PDCIDFont cid = t0.getDescendantFont(); PDCIDSystemInfo info = cid.getCIDSystemInfo(); System.out.printf(" descendant=%s registry=%s ordering=%s%n", cid.getName(), info == null ? "-" : info.getRegistry(), info == null ? "-" : info.getOrdering()); System.out.println(" encoding=" + t0.getCOSObject().getDictionaryObject(COSName.ENCODING)); } } } }font.isEmbedded()是核心判据,返回false且base是STSong-Light、STSongStd-Light这类名字,就是本文说的问题。encoding那一行会打印出COSName{UniGB-UCS2-H}或者一个COSStream(内嵌 CMap),前者是预定义 CMap,后者是生成端自己塞进去的。
如果你拿到的embedded=true,那就别再往字体替换这个方向使劲了,问题在别处,比如字形缺失、渲染参数、页面内容流的 CTM 变换、或者干脆是图层被裁掉了。
2.2 读懂输出:四类结果对应四种病
诊断输出大致会落到四种情况里,我按出现频率排一下:
| 输出特征 | 结论 | 主攻方向 |
|---|---|---|
embedded=false,base 是 STSong-Light 类 | 字体未嵌入,渲染端无字形 | 第 3 节 FontMapper 方案 |
embedded=true,但渲染出来是方块 | 字库缺字形(子集化不完整) | 换完整字库或修生成端 |
embedded=false,base 是 Helvetica 等西文字体 | 字体名本身就是错的 | 生成端问题,需修 PDF |
| 渲染出乱码而非空白 | CMap 解析异常或编码不匹配 | 检查 CMap 资源是否完整 |
第一类是绝大多数。第二类也不少见,尤其是某些生成工具子集化时漏字。第三类属于生成端 bug,比如把中文字符塞进了一个声明为 Helvetica 的字体里,这种 PDF 在阅读器里能看到中文,纯属阅读器容错。第四类最隐蔽,往往和精简过的依赖包有关,下面单独说。
2.3 日志里那些容易被忽略的线索
PDFBox 渲染时不会为"字体找不到"抛异常,这是最坑的地方——它会静默降级,画不出就跳过。但把日志级别调到DEBUG,你能看到一些有用的信息:
# 打开 PDFBox 的内部日志 -Dorg.apache.commons.logging.Log=org.apache.commons.logging.impl.SimpleLog -Dorg.apache.commons.logging.simplelog.defaultlog=debug如果日志里出现类似"找不到预定义 CMap"或者"无法解析字体文件"的告警,说明问题比字体替换更靠前。另外补一个很多人不知道的点:预定义的UniGB-UCS2-H这类 CMap 资源是随 PDFBox 的 jar 一起分发的,正常引入依赖不会缺;但如果打包时做了裁剪、或者用了某些二次封装的精简版依赖,可能就丢了,这时候表现是中文全部错乱而不是空白。用 Maven 依赖树看一眼pdfbox的 jar 是不是完整的,比瞎猜快得多。
还有一个小技巧:临时降级排查。把同一份 PDF 用另一台装了中文桌面环境的机器转一遍,如果正常,就确认是运行环境字体缺失,直接跳到第 4 节;如果两台机器都一样空白,那就老老实实做第 3 节的代码级替换。这个对照实验能省掉一半的排查时间。
3. 主方案:自定义 FontMapper 把 CJK 字体顶上去
方案的核心一句话:告诉 PDFBox,"遇到这个没嵌字体的中文名字,别去系统里瞎找了,用我这个字体文件"。这句话落到 API 上就是FontMapper。
3.1 渲染链路里,字形是在哪一步丢的
理解链路才能理解为什么替换有效。渲染一页时,PDFBox 逐条解析内容流,遇到文本绘制指令,就拿着当前字体和字节串去取字形轮廓(一个GeneralPath),再把轮廓填充成像素。取轮廓这一步,对于 Type0 复合字体,流程是:字节 → 按 CMap 转成 CID → 拿 CID 去字体里要路径。
如果字体嵌入了,路径直接从 PDF 内嵌的字体程序里取,一步到位。如果没嵌入,字体对象就得去问一个"字体映射器"要一个可用的字体对象,映射器去系统字体目录里找,找不到就返回空,后续取路径的环节只能放弃——于是画布上什么都没有,还不报错。
所以替换点就在映射器这一层。你给它一个稳定的字体文件,链路后半段就能跑通。
3.2 FontMapper 的最小实现
下面