免费获取学习方案
ARTICLE DETAIL

资讯详情

深耕编程基础知识与建站技术分享的一线实战洞察。

PDFBox渲染PDF中文变方块?修改源码实现字体兜底

PDFBox渲染PDF中文变方块?修改源码实现字体兜底 1. 问题现场一段被“方块字”支配的战斗上个月接了一个文档管理系统的需求要把 PDF 在线预览转成图片展示。技术选型时没怎么纠结Java 生态里做 PDF 渲染的轮子无非就是 PDFBox、iText、Apache 的 PDFBox 在开源和免费这块最省心于是直接引入pdfbox依赖写了个简单的转换工具类。PDDocument doc PDDocument.load(inputFile); PDFRenderer renderer new PDFRenderer(doc); BufferedImage image renderer.renderImageWithDPI(pageIndex, 144); ImageIO.write(image, png, outputFile); doc.close();本地 Windows 测试一切正常页面上的中文、英文、数字都很清晰。结果部署到测试服务器一台银河麒麟系统后转出来的图片里所有中文字符全变成了一个个空心方块英文和数字倒是好好的。第一反应是 PDF 文件本身有问题但用系统自带 PDF 阅读器打开中文显示毫无异常。这就说明问题不在 PDF 文件而在 PDFBox 渲染图片时“找不到能画中文字形的字体”。如果你也遇到类似情况先别急着怀疑 PDF 文件大概率是字体映射环节出了问题。这篇博文就带你从头到尾复盘一遍把这层窗户纸捅破。2. 为什么 pdfBox 会渲染出中文方块2.1 PDF 里的文字不是“图片”是“字体指令”很多人对 PDF 有个误解觉得 PDF 里的文字已经是“画好”的像素了转成图片就是直接抠出来。其实不是。PDF 文件里保存的是文本数据加字体指令拿阅读器打开时阅读器会调用系统里合适的字体文件把每个字符的轮廓画出来。PDFBox 做渲染时也一样它需要一份能覆盖中文字符集的字体文件才能把“中”这个字符从编码映射成字形轮廓。如果这份 PDF 文件把用到的字体完整嵌入了比如嵌入了 SimSun 的子集PDFBox 理论上可以从 PDF 里提取字体数据来画。但问题是很多 PDF 生成工具默认不嵌入中文字体或者只记录字体名称指望目标机器的系统里有同名或相近的字体。一旦系统里没有PDFBox 就只能随便找个默认字体顶替顶替的字体没有中文字形方块就出来了。2.2 字体缺失时的默认行为SANS_SERIF 的陷阱PDFBox 里负责字体替换的核心是PDFontMapper默认实现是DefaultFontMapper。它内部维护了一张字体映射表把 PDF 里的字体名字和系统字体做匹配。匹配不上时它不会抛异常而是返回一个默认的new Font(Font.SANS_SERIF, ...)。问题就在这。绝大多数服务器环境下SANS_SERIF会对应到 Arial 或 Liberation Sans 这类不含中文的字体。PDFBox 得到这个字体后逐个字符去查字库查不到“中”的轮廓就只能画一个代表“缺字”的方块占位。英文和数字在 Arial 里都有自然不受影响。2.3 另外两个隐藏原因子集字体与系统字体库除了“没字体可用”这个主因还有两个情况容易被忽视。第一个是子集字体。很多 PDF 里嵌入了类似ABCDEFSimSun的字体子集只包含文档里实际用到的几十或几百个字符。PDFBox 在提取这种子集字体时如果 CID 到 GID 的映射处理失败即便字体数据是完整的也可能拿不到正确的字形最终依旧走回默认字体兜底路线。第二个是服务器环境本身就缺中文字体。银河麒麟这类 Linux 发行版默认通常不装 SimSun、微软雅黑等商业字体。系统里只会有文泉驿、Noto Sans CJK 之类的开源字体甚至有些最小化部署的服务器一个中文字体都没有。PDFBox 就算想做好替换也无从下手。3. 先试这些常规“补丁”再动手改源码3.1 给系统装中文字体先看环境能不能救很多情况下只要服务器装了中文字体问题就迎刃而解。以常见的 Debian/Ubuntu 系为例apt-get install -y fonts-noto-cjk fc-list :langzh安装完成后再跑一遍 PDF 转图片。PDFBox 在匹配字体时会走系统字体列表只要检测到 Noto Sans CJK 支持中文就会自动用它替换缺失的字体。实测下来这个方案能解决大约六成“方块”问题尤其是那些压根没有内嵌字体的 PDF。但要注意如果你改不了服务器环境或者公司安全策略不允许你随便装系统软件包这条路就走不通了。另外有些老掉牙的 PDF 虽然嵌入了字体但 PDFBox 解析时依然会失败装字体也白搭。3.2 用子类扩展 PageDrawer 来兜底如果装字体没用或者不能动系统环境很多人会想到继承 PDFBox 的渲染类重写字体获取逻辑。PDFBox 是开放扩展的你可以自定义一个PageDrawerpublic class CustomPageDrawer extends PageDrawer { public CustomPageDrawer(PageDrawerParameters parameters) throws IOException { super(parameters); } Override protected Font getFont(PDType0Font font) throws IOException { try { Font f super.getFont(font); if (f.canDisplay(中)) { return f; } } catch (Exception ignore) { } return new Font(Noto Sans CJK SC, Font.PLAIN, 12); } }再通过继承PDFRenderer把自定义的PageDrawer挂进去public class CustomPDFRenderer extends PDFRenderer { public CustomPDFRenderer(PDDocument document) { super(document); } Override protected PageDrawer createPageDrawer(PageDrawerParameters parameters) throws IOException { return new CustomPageDrawer(parameters); } }这个方案不需要改 PDFBox 的 jar 包代码也在你自己的工程里管理看起来挺完美。但它有个隐患super.getFont()内部可能已经抛了异常或者返回的字体虽然能显示中文但字重方向、间距信息跟原 PDF 的设计不一致导致文字挤成一团或者错位。而且PDFBox 在计算文本布局时用的是 PDF 原字体描述符里的宽度数据不是最终 java.awt.Font 里的宽度数据。你就算换了显示字体布局阶段可能还是按错误数据来排版最终图片上文字位置会偏移治标不治本。3.3 为什么子类方案有时仍失效我见过的翻车案例里有一类很典型重写了getFont后图片确实没有方块了但段落里的中文字变大了一行 10 个字变成了 8 个字换行位置全乱了。原因就是布局计算和字形渲染用的字体度量不一致。要解决得连字体度量一起覆盖但 PDFBox 里跟字体度量相关的代码散落在GlyphRenderer、PDFontDescriptor、Type0Font好几个类里子类重写会非常痛苦。与其强行在外层打补丁不如直接改库里对应的方法从源头把字体替换逻辑理顺。这也是为什么我说“简单修改源码”反而是更省事的路径。4. 简单修改源码直接 patch pdfBox4.1 准备源码工程Maven 构建第一步先把 PDFBox 源码拉下来。如果你的项目用 Maven最直接的方式是让 Maven 帮你下载源码包mvn dependency:sources -DincludeArtifactIdspdfbox源码会出现在本地仓库的对应目录里。或者直接去 GitHub 上拉取 taggit clone --branch 2.0.24 https://github.com/apache/pdfbox.git我用的 2.0.24 版本不同版本的源码结构略有差异但核心类的名字基本不变。解压后首先看下构建脚本PDFBox 用的是 Maven 多模块结构核心模块是pdfbox在里面找到src/main/java/org/apache/pdfbox/rendering/DefaultFontMapper.java。4.2 定位核心类DefaultFontMapper 与 PageDrawerDefaultFontMapper实现了PDFontMapper接口负责把 PDF 字体对象转换成java.awt.Font。打开这个类你会看到几个重载方法public Font getFont(PDType1Font font) throws IOException public Font getFont(PDType0Font font) throws IOException public Font getFont(PDTrueTypeFont font) throws IOException其中getFont(PDType0Font)是处理中文字体的关键入口。PDFBox 对 Type0 字体的处理流程是先从字体描述符里拿字体名去字体缓存里匹配匹配不到就用Font.SANS_SERIF创建一个默认字体返回。这里我直接把关注点放在这个方法的最后一段兜底逻辑上改造的思路很简单在返回默认字体之前检查这个字体到底能不能显示中文不能的话就手动替换成系统里存在的 CJK 字体。4.3 修改思路给“找不到的字”指定一个中文字体下面是我实际打上去的补丁核心思路是三步先调父类逻辑拿一个字体再检查这个字体能不能显示中文字符不能就加载服务器上的 Noto Sans CJK 字体兜底Override public Font getFont(PDType0Font font) throws IOException { Font result super.getFont(font); if (result.canDisplay(中)) { return result; } Font cjkFont loadCjkFallbackFont(); if (cjkFont ! null) { return cjkFont.deriveFont(result.getStyle(), result.getSize()); } return result; } private Font loadCjkFallbackFont() { String[] candidates { /usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc, /usr/share/fonts/truetype/wqy/wqy-microhei.ttc, /usr/share/fonts/truetype/arphic/uming.ttc, C:/Windows/Fonts/simsun.ttc, C:/Windows/Fonts/msyh.ttc }; for (String path : candidates) { File file new File(path); if (!file.exists()) { continue; } try (InputStream in new FileInputStream(file)) { return Font.createFont(Font.TRUETYPE_FONT, in); } catch (Exception e) { // 尝试下一个候选字体 } } return null; }这个补丁有个细节需要解释一下。我用result.canDisplay(中)判断字体是否含中文而不是直接检查字体名。原因是有时候字体名看起来像中文字体但实际加载失败或者编码表不完整名字靠谱不如字形靠谱。canDisplay是 JDK 提供的原生态 API准确率高代价就是每次渲染都要跑一遍后面我会说怎么优化性能。还有个容易踩的坑是.ttc字体集文件。Font.createFont在加载.ttc时经常会抛FontFormatException因为集合文件里包含多个字体JDK 的公开 API 默认只支持从.ttf或.otf单字体文件创建。如果你服务器上只有.ttc可以改成用GraphicsEnvironment先注册字体再从注册列表里按名称找GraphicsEnvironment.getLocalGraphicsEnvironment().registerFont(Font.createFont(Font.TRUETYPE_FONT, file)); Font cjk new Font(Noto Sans CJK SC, Font.PLAIN, 12);但这条路在部分 Linux 上依然不稳。更保险的做法是把一个单独的.ttf中文字体打进项目资源目录然后用getClass().getResourceAsStream()加载。比如用思源黑体的单字重 TTF 版本或者文泉驿正黑闭着眼睛都不会出错。4.4 处理多字体源 / 提升健壮性上面代码里把字体文件路径写死在实际生产环境我觉得还不够因为不同 Linux 发行版的中文字体路径差异很大。更健壮的做法是先遍历系统里已安装的字体族挑一个能显示中文的private Font findSystemFontFallback() { GraphicsEnvironment ge GraphicsEnvironment.getLocalGraphicsEnvironment(); String[] families ge.getAvailableFontFamilyNames(); for (String family : families) { if (family.toLowerCase().contains(noto sans cjk) || family.toLowerCase().contains(wenquanyi) || family.toLowerCase().contains(wqy)) { return new Font(family, Font.PLAIN, 12); } } return null; }这个方案不依赖具体文件路径兼容性更好。缺点是有可能误判一个叫“Noto Sans CJK JP”的字体但它在大多数字库文件里也带简体中文字形问题不大。补丁写完之后用 Maven 编译并安装到本地仓库mvn -DskipTests install如果你的项目有多个模块共享同一个 pdfbox 依赖确保其他模块引用的是本地仓库里的 patched 版本必要时在pom.xml里临时把version改成本地构建的版本号。4.5 重新打包并替换本地仓库中的 pdfBox有些人不想下载整个源码工程重新构建只想替换 jar 里的单个 class也可以。先用javac单独编译修改后的DefaultFontMapper.java注意带上 pdfbox 原 jar 作为 classpathjavac -cp ./pdfbox-2.0.24.jar -d ./classes src/org/apache/pdfbox/rendering/DefaultFontMapper.java jar uf ./pdfbox-2.0.24.jar -C ./classes/ .这样把编译好的 class 直接塞进原 jar就能替换旧类。不过这种方法容易踩 Java 版本坑你本地编译环境一定要和线上运行环境的 JDK 版本一致否则会出UnsupportedClassVersionError。4.6 验证修复用同样 PDF 输出图片改完之后写个简单的验证程序重点检查三个点中文是否显示、文字位置是否准确、批量转换是否稳定。try (PDDocument doc PDDocument.load(new File(test.pdf))) { PDFRenderer renderer new PDFRenderer(doc); for (int i 0; i doc.getNumberOfPages(); i) { BufferedImage image renderer.renderImageWithDPI(i, 144); File imgFile new File(out-page- (i 1) .png); ImageIO.write(image, png, imgFile); System.out.println(Page (i 1) - imgFile.getAbsolutePath()); } }我自己的测试结果里原来的方块全部变成了正常中文段落换行也跟 PDF 阅读器里一致前后对比肉眼几乎无差别。5. 实战中的坑与排查速查表5.1 “我改了源码但没生效”的常见原因最让人血压飙升的情况就是明明改了代码、重新打包了结果跑出来还是方块。我把踩过的坑列出来。第一Maven 仓库缓存。本地仓库还存着旧 jarmvn install后新 jar 可能没覆盖到。我建议先直接删掉~/.m2/repository/org/apache/pdfbox/pdfbox/2.0.24/整个目录再 install或者加上-U强制更新。第二依赖树里有多个 pdfbox 版本。排查一下mvn dependency:tree -Dincludesorg.apache.pdfbox:pdfbox如果项目里某个间接依赖把老版本 pdfbox比如 2.0.19拉进来了你改的是 2.0.24运行时就可能用了旧版本。最稳妥的做法是在pom.xml里把版本统一通过dependencyManagement锁死。第三字体路径不对。补丁里的候选字体系列路径必须和实际部署环境一致。我在银河麒麟上就遇到过一个诡异情况系统里明明有NotoSansCJK-Regular.ttc但 Java 读取时抛权限异常后来发现是目录权限只有 root 能读而应用是用普通用户跑的。5.2 从“方块”到“乱码字形”的区分很多人把“方块”和“乱码”混为一谈修了半天发现完全不是一回事。我整理了一个速查表方便你对着症状定位现象可能原因优先级中文字符变成空心方块或实心方块替换字体缺少中文字形检查系统字体、FontMapper文字变成不易读懂的错乱符号或韩文编码映射错误通常是 CID 编码表解析失败检查 pdfbox 版本升级到新版本中文内容整段消失字体度量/宽度计算异常导致绘制位置越界检查页面边界和 PDF 源代码生成工具图片上中文正常查询文字时搜不到PDF 本身文字已矢量化为曲线不是渲染问题是文档生成逻辑问题如果你遇到的是第二类“乱码字形”改DefaultFontMapper没用得关注 pdfBox 对 Type0 字体 CID 映射的处理这个通常依赖 PDF 生成方的标准化程度升级 pdfBox 版本往往能解决一部分。5.3 性能与缓存我在补丁里每次都 newFont.createFont渲染几十页 PDF 时会明显变慢。因为字体创建涉及文件 IO 和字体解析属于高开销操作。所以在实际补丁里我用了静态变量缓存private static volatile Font cjkFallbackFont; private Font loadCjkFallbackFont() { if (cjkFallbackFont ! null) { return cjkFallbackFont; } // 加载逻辑... cjkFallbackFont ...; return cjkFallbackFont; }这里还有个并发细节DefaultFontMapper在多线程下可能会被多个渲染线程同时调用静态变量必须用volatile或AtomicReference保证可见性否则一个线程拿到的可能还是null导致重复加载。5.4 和“图片转 PDF、Word 转 PDF”场景的关系说到最近搜得比较多的两个热词银河麒麟图片转 pdf、word 转 pdf 如何不压缩图片它们和咱们聊的字体问题是两条线。图片转 PDF比如手机扫描的文件转 PDF、截图拼 PDF生成时会直接把图片嵌进去字体缺失影响小。但如果你的系统里有“PDF 转图片后给用户预览”的需求来自 Word 导出或网页打印生成的 PDF 反而最容易踩中字体坑。Word 里默认会用系统字体导出 PDF 时如果不勾选“嵌入字体”PDF 里就只有字体名和字符编码换一台机器渲染就极容易复现方块问题。至于“不压缩图片”的痛点本质上是 PDF 生成端对图像编码参数的取舍跟字体替换不是同一层。但有一条经验是通用的不管做图片转 PDF、Word 转 PDF 还是 PDF 转图片提前检查字体嵌入状态很多下游渲染问题都不会发生。我常用的验证方式是拿 PDF 阅读器打开后看“字体属性”或者用 PDFBox 写个小工具把文档里引用的字体和嵌没嵌入列出来。6. 最后想说的一些体会这次排查让我重新认识了 PDF 渲染链路从文件解析、字体映射、布局计算到最终光栅化任何一环的默认行为都可能把你带到沟里。源码修改这个方案听起来吓人实际上只是在DefaultFontMapper的兜底逻辑里加了一个“如果默认字体不支持中文就换一个支持的”分支改动面非常小比在外层子类里去强行覆盖各种字体度量问题可靠得多。如果让我重新选我会先做两件预处理一是确认 PDF 文件有没有嵌入字体二是在部署环境提前规划好 CJK 字体文件路径。从源头减少“找不到字体”的概率比什么都重要。另外源码修改前建议用一个最小测试 PDF 固定复现步骤改完马上验证别等到集成环境再排查节省的时间足够你多写几个页面的渲染逻辑。
返回列表