
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 的字体字典里它通常长这样类型是Type0Encoding是UniGB-UCS2-HDescendantFonts里挂一个CIDFontType0BaseFont是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后者是生成端自己塞进去的。如果你拿到的embeddedtrue那就别再往字体替换这个方向使劲了问题在别处比如字形缺失、渲染参数、页面内容流的 CTM 变换、或者干脆是图层被裁掉了。2.2 读懂输出四类结果对应四种病诊断输出大致会落到四种情况里我按出现频率排一下输出特征结论主攻方向embeddedfalsebase 是 STSong-Light 类字体未嵌入渲染端无字形第 3 节 FontMapper 方案embeddedtrue但渲染出来是方块字库缺字形子集化不完整换完整字库或修生成端embeddedfalsebase 是 Helvetica 等西文字体字体名本身就是错的生成端问题需修 PDF渲染出乱码而非空白CMap 解析异常或编码不匹配检查 CMap 资源是否完整第一类是绝大多数。第二类也不少见尤其是某些生成工具子集化时漏字。第三类属于生成端 bug比如把中文字符塞进了一个声明为 Helvetica 的字体里这种 PDF 在阅读器里能看到中文纯属阅读器容错。第四类最隐蔽往往和精简过的依赖包有关下面单独说。2.3 日志里那些容易被忽略的线索PDFBox 渲染时不会为字体找不到抛异常这是最坑的地方——它会静默降级画不出就跳过。但把日志级别调到DEBUG你能看到一些有用的信息# 打开 PDFBox 的内部日志 -Dorg.apache.commons.logging.Logorg.apache.commons.logging.impl.SimpleLog -Dorg.apache.commons.logging.simplelog.defaultlogdebug如果日志里出现类似找不到预定义 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 的最小实现下面