ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

pdfBox渲染PDF中文变方块?字体映射与源码级修复方案详解

pdfBox渲染PDF中文变方块?字体映射与源码级修复方案详解 前阵子在一个文档转换服务里踩了个典型的坑用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名称比如SimSunMicrosoftYaHeiArialMT这种然后把这个名称交给DefaultFontMapper.getFontByBaseFont()去查表。查到就返回对应的Font查不到就返回一个默认字体通常是一个精简的无衬线西文字体。如果PDF里的字体是嵌入的且带了子集前缀比如ABCDEFSimSun那么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 :langzh # 如果输出为空说明系统没有安装任何中文字体如果在Windows下排查直接打开控制面板的字体管理页面看一眼有没有宋体、黑体这类常用字体即可。这一步做到位你就能判断当前问题到底是环境里没有中文字体还是环境里有字体但pdfBox找不到这两个问题的解法是不一样的。我当时排查的服务器就是一台内存很小的Docker容器fc-list :langzh的输出是空的。当时心里的第一反应是破案了但装上字体之后重跑中文还是方块。这说明问题不止一层环境缺字体是第一重问题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[familyDialog,nameDialog,styleplain,size1]这样的结果说明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 :langzh确认字体已经可用。这一步解决的是系统没有中文字体的问题但它解决不了pdfBox不去扫描系统字体目录的问题。如果你的pdfBox版本默认不扫描系统目录装了字体也白搭。所以这只能算一个必要条件不是充分条件。5.2 从源头上消灭问题把PDF字体先预嵌入进去还有一条路是从PDF生成端入手使用Ghostscript把源PDF里缺失的字体预嵌入进去。这样pdfBox渲染时会优先使用PDF内部嵌入的字体数据而不依赖外部字体映射。gs -dNOPAUSE -dBATCH -sDEVICEpdfwrite \ -dPDFSETTINGS/prepress \ -dEmbedAllFontstrue \ -sOutputFileoutput-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等都适用。排查效率提升的不是一点半点。
返回列表