docx4j转PDF中文乱码根治:字体注册与FontMapper映射
如果你在 Java 服务里用 docx4j 做过 Word 转 PDF大概率见过这种乱码英文、数字、标点一切正常一到中文就成了整齐的空心方块或者几根细线在纸上扭曲成不可名状的形状。第一反应往往是换库、换版本、改编码折腾一圈之后发现问题纹丝不动。作为踩过这个坑的人我可以负责任地说docx4j 转 PDF 的乱码问题绝大多数不是编码问题而是中文字体配置的问题取决于你的运行环境里有没有对应的物理字体文件以及 docx4j 有没有把 Word 文档里的逻辑字体名映射到那个物理字体上。这篇文章就围绕这一点把整个转换链路拆开讲透从环境准备、基础转换代码到完整中文字体配置和核对方法都给出可以直接抄走的方案。1. 先搞懂乱码的根因docx4j 转 PDF 时字体是怎么被翻译的1.1 docx 里的字体和 PDF 里的字体不是一回事Word 文档里存的字体其实只是一个逻辑名称。打开 docx 压缩包里的word/document.xml你会看到类似这样的片段w:rFonts w:asciiCalibri w:eastAsia宋体 w:hAnsiCalibri/。docx4j 读取文档后它操作的是 JAXB 对象和字符串它并不知道宋体这两个字长什么样只知道文档里标记了一段文本需要用名为宋体的样式来渲染。PDF 的运行逻辑完全不同。PDF 页面上的每个字符最终都要通过字形 ID 来描述渲染器必须拿到实际字体文件里的字形数据才能在对应坐标上画出汉字轮廓。也就是说从 docx 到 PDF 必然需要一个从逻辑字体名到物理字体文件的翻译过程这一步翻译如果断掉结果就是中文变成乱码或者空白。docx4j 转换 PDF 的默认路径是先把 WordprocessingML 文档转换成 XSL-FO 中间格式再交给 Apache FOP 渲染成 PDF。大概流程是docx4j 解析 docx读取段落、表格、文本样式根据文档样式生成一个 XSL-FO 文件里面会带上font-family宋体这类属性FOP 加载自己的字体配置查找font-family对应的物理字体找到字体后FOP 读取字形数据渲染成 PDF 页面。乱码问题就出在第三步。FOP 在服务器上找不到宋体对应的字体文件就会静默地退回默认西文字体把汉字全部渲染成方框或空白。整条链路里没有一步是在错误地编码文本纯粹是字体映射的锅。1.2 乱码其实是字形缺失不是编码错误很多人遇到乱码第一反应是修改编码UTF-8 改 GBK、加-Dfile.encodingUTF-8、甚至去改数据库连接串的编码。实际上对 docx4j 转 PDF 这个场景来说这些基本都是无用功。docx 是 XML 存储docx4j 内部也是 Java String 处理文本流到 PDF 之前都是 Unicode不存在传统意义上的中文编码错乱。真正的病根在字形映射。FOP 拿到font-family宋体之后会在自己可用的字体集合里找一个名字精确匹配的字体。如果匹配不上就按备选规则退到一个默认字体。绝大部分 Linux 服务器上这个默认字体是没有中文字形的 Helvetica 或者其他西文字体于是汉字全部落到缺失字形路径PDF 里呈现出来的就是空心方块□或充满占位符的空白区域某些环境下汉字变成一团细线或乱序的笔画更隐蔽的情况是英文字符正常只有中文位置是空白。不管表现成哪种本质都是字体映射失败不是在文本编码层面丢了数据。所以排查思路应该立刻从编码对不对切换到环境里有没有字体、映射有没有建立。你甚至不需要理解 FOP 的细节只要记住这条原则后面所有配置方案都能对号入座。2. 环境准备依赖、字体和 Java 参数这些提前弄好能省一半维修时间2.1 Maven 依赖怎么引才干净docx4j 不同大版本的依赖结构差异很大。还在用 3.x、6.x 的老项目一个docx4j包可能就包含了转换所需的所有类从 8.x 开始XSL-FO 导出模块被单独拆出来了。如果你只引了主包调用Docx4J.toPDF()时会直接遇到 NoClassDefFoundError或者提示找不到 convert 类的编译错误。我目前推荐使用的依赖组合是dependency groupIdorg.docx4j/groupId artifactIddocx4j-JAXB-ReferenceImpl/artifactId version8.3.1/version /dependency dependency groupIdorg.docx4j/groupId artifactIddocx4j-export-fo/artifactId version8.3.1/version /dependency说明一下docx4j-JAXB-ReferenceImpl是主模块提供WordprocessingMLPackage、Document等核心 APIdocx4j-export-fo才是Docx4J.toPDF()底层的 XSL-FO 导出支持如果项目里已经用了别的 JAXB 实现可能会和 docx4j 内置的 ReferenceImpl 产生冲突此时需要按实际模块做排除。如果转换中遇到 SLF4J 的绑定警告多半是项目里同时混入了多个 slf4j 实现。比较省事的做法是把不需要的排除掉统一只留一个比如logback-classic或者log4j-slf4j2-impl。2.2 Linux 服务器上怎么装中文字体本地开发时 Windows 和 macOS 都自带中文字体所以本地转换大多没事。但 Linux 服务器经常是裸的只带一些基本西文字体。这是本地正常线上乱码最常见的根源。装开源中文字体最快的方式# Debian / Ubuntu apt update apt install -y fonts-noto-cjk # CentOS / RHEL 系 yum install -y wqy-zenhei-fonts wqy-microhei-fonts装完以后检查fc-list :langzh如果能输出一堆中文字体信息说明系统层面已经有中文字体了。需要留意的是这里说的是系统和 LibreOffice 能识别的中文字体不代表 docx4j 的PhysicalFonts就一定能发现它们。docx4j 扫描字体路径时依赖的是 JRE 的字体索引和它内部的字体发现逻辑不是直接遍历/usr/share/fonts。还有一个容易踩的坑不要下.ttc格式的字体集合文件给 docx4j 用。很多开源中文字体比如文泉驿正黑默认提供的是 TTC 集合格式docx4j 底层的字体解析对 TTC 支持很弱注册时可能直接抛异常或者发现了但无法读取字形。更稳妥的做法是找单文件.otf或.ttf比如思源黑体的单个 OTF 文件、Noto Sans CJK 的 OTF 副本这类文件注册进来的成功率最高。2.3 无头模式和其他 JVM 参数docx4j 在初始化字体相关功能时可能会触发 AWT 字体逻辑。在无图形界面的 Linux 服务器上JVM 默认没有显示设备某些方法调用会抛出 HeadlessException。所以建议启动参数里加-Djava.awt.headlesstrue如果你用的是 Tomcat 或 Spring Boot可以在启动脚本里加也可以在CATALINA_OPTS或JAVA_OPTS里设置。另外-Dfile.encodingUTF-8不是万能药但建议加上。它不直接解决字体映射却能减少很多中文环境相关的坑至少保证日志和文件读写的编码基线是统一的。JDK 18 之后默认字符集跟随系统环境如果你不想被系统 locale 干扰显式指定 UTF-8 更稳妥。3. 第一版转换代码在没有乱码问题之前先让转换跑起来3.1 最简转换代码先把最简单的路径跑通。下面这段代码就是 docx4j 转 PDF 的最小可用实现import org.docx4j.Docx4J; import org.docx4j.openpackaging.packages.WordprocessingMLPackage; import java.io.File; import java.io.FileOutputStream; import java.io.OutputStream; public class WordToPdfDemo { public static void main(String[] args) throws Exception { WordprocessingMLPackage wordMLPackage WordprocessingMLPackage.load(new File(/tmp/test.docx)); try (OutputStream out new FileOutputStream(/tmp/test.pdf)) { Docx4J.toPDF(wordMLPackage, out); } System.out.println(转换完成); } }在本地开发机上跑如果 docx 里的字体是宋体、微软雅黑这类常见中文字体转出来的 PDF 基本是正常的。原因是 docx4j 启动时会扫描 JRE 的字体目录Windows 系统字体目录里正好有simsun.ttc、msyh.ttc等文件docx4j 能从中提取出SimSun、Microsoft YaHei这些物理字体名并且把文档里的宋体和SimSun对应起来。3.2 这版代码的边界为什么本地能用不等于生产能用上面这段代码最坑的地方在于它依赖运行时环境恰好有对应字体。一旦换到纯净的 Linux 服务器docx4j 扫描不到任何中文字体转换出来的 PDF 中文部分就是一片空白或方框。这个现象和你业务代码写得多好无关纯粹是运行环境差异。所以我的建议是写完这段最简代码后先不要急着接业务先打印一下当前环境的物理字体表import org.docx4j.fonts.PhysicalFont; import java.util.Map; MapString, PhysicalFont fonts org.docx4j.fonts.PhysicalFonts.getPhysicalFonts(); fonts.keySet().forEach(System.out::println);在 Windows 上你会看到一串SimSun、Microsoft YaHei、Arial、Calibri之类的名字。在裸 Linux 服务器上输出可能只有Helvetica等西文字体甚至中文字体一个都没有。这一步可以直接确认问题层次环境缺字体还是映射没写对。另外还要注意日志。docx4j 转 PDF 时FOP 找不到字体不会直接抛中断异常而是打印一条 WARNING 并继续用替代字体渲染。如果生产环境日志级别比较高或者只关注 ERROR这条 WARNING 很容易被漏掉排查时很难溯源。建议调试阶段把org.docx4j和org.apache.fop的日志调到 DEBUG能看到更详细的字体查找过程。4. 中文字体配置的完整方案从字体注册到 FontMapper 映射这是标题里最核心的部分。中文字体配置不是改一个参数就完事至少要在两层做文章一是让 docx4j 的PhysicalFonts认识新的物理字体文件二是让 Word 文档里的逻辑字体名正确映射到物理字体。4.1 方案一注册物理字体文件让 docx4j 认识新字体如果你的服务器上没有可用的中文字体文件最简单的办法是把一个 OTF/TTF 中文字体文件放到项目资源目录或固定路径然后启动时用PhysicalFonts.addPhysicalFont()注册。import org.docx4j.fonts.PhysicalFont; import org.docx4j.fonts.PhysicalFonts; import java.io.File; public static void registerChineseFont() throws Exception { String fontFilePath /usr/share/fonts/opentype/noto/NotoSansCJKsc-Regular.otf; // 字体名需要和 FontMapper 后续映射时用到的名字一致 String fontName NotoSansCJKsc; // 多次注册同名会出问题先判断是否存在 if (PhysicalFonts.getPhysicalFonts().get(fontName) null) { PhysicalFont font PhysicalFonts.addPhysicalFont(fontName, new File(fontFilePath)); System.out.println(注册成功: font); } }这里有个细节字体文件路径不要依赖相对路径。生产环境尽量用绝对路径再把路径放到配置中心或环境变量里。如果字体文件打进 jar可以启动时释放到临时目录再注册但要注意临时文件生命周期别边转边删。4.2 方案二自定义 FontMapper把宋体黑体映射到实际字体这是最核心、使用频率最高的方案。docx4j 允许你实现FontMapper接口并通过wordMLPackage.setFontMapper()注入自己的映射逻辑。import org.docx4j.fonts.FontMapper; import org.docx4j.fonts.PhysicalFont; import org.docx4j.fonts.PhysicalFonts; import org.docx4j.model.styles.FormattingState; import java.util.HashMap; import java.util.Map; public class ChineseFontMapper implements FontMapper { private static final MapString, String ALIAS new HashMap(); static { ALIAS.put(宋体, NotoSansCJKsc); ALIAS.put(SimSun, NotoSansCJKsc); ALIAS.put(新宋体, NotoSansCJKsc); ALIAS.put(NSimSun, NotoSansCJKsc); ALIAS.put(黑体, NotoSansCJKsc); ALIAS.put(SimHei, NotoSansCJKsc); ALIAS.put(微软雅黑, NotoSansCJKsc); ALIAS.put(Microsoft YaHei, NotoSansCJKsc); ALIAS.put(楷体, NotoSansCJKsc); ALIAS.put(KaiTi, NotoSansCJKsc); ALIAS.put(仿宋, NotoSansCJKsc); ALIAS.put(FangSong, NotoSansCJKsc); } Override public PhysicalFont getMappedFont(String fontName, FormattingState formattingState) { // 1. 已有同名物理字体优先直接用 PhysicalFont direct PhysicalFonts.getPhysicalFonts().get(fontName); if (direct ! null) { return direct; } // 2. 查别名表 String targetName ALIAS.get(fontName); if (targetName ! null) { PhysicalFont mapped PhysicalFonts.getPhysicalFonts().get(targetName); if (mapped ! null) { return mapped; } } // 3. 兜底如果 docx4j 版本里没有 getDefaultPhysicalFont()这里可以返回 null return PhysicalFonts.getDefaultPhysicalFont(); } Override public MapString, PhysicalFont getFontMappings() { return PhysicalFonts.getPhysicalFonts(); } }使用方式WordprocessingMLPackage wordMLPackage WordprocessingMLPackage.load(new File(/tmp/test.docx)); wordMLPackage.setFontMapper(new ChineseFontMapper()); try (OutputStream out new FileOutputStream(/tmp/test.pdf)) { Docx4J.toPDF(wordMLPackage, out); }这段代码的思路是docx4j 在转换成 XSL-FO 时每遇到一个字体名字都会调用getMappedFont返回的PhysicalFont的字体名会被写进 FO 的font-family。FOP 再去查找这个名字时正好能找到我们提前注册的NotoSansCJKsc渲染链路就通了。这里有一个很容易忽略的点PhysicalFonts是 JVM 级全局状态。字体注册和setFontMapper是两码事注册字体文件只需要在进程启动时做一次而setFontMapper是每个WordprocessingMLPackage对象转换前都要设置的。如果你在框架里封装了转换服务最好把 FontMapper 做成单例复用不要每次 new 一个新的然后重新注册字体。4.3 方案三走 fop.xconf 配置适合大规模团队统一管理如果团队里有多套服务都要转换 Word每个服务都在 Java 代码里注册字体容易维护疲劳。更规范的做法是维护一份fop.xconf把字体目录和嵌入策略集中管理。fop version1.0 renderers renderer mimeapplication/pdf fonts directory/usr/share/fonts/opentype/noto/directory font embed-file/usr/share/fonts/opentype/noto/NotoSansCJKsc-Regular.otf/embed-file font-familyNotoSansCJKsc/font-family embedtrue/embed /font /fonts /renderer /renderers /fop关键配置解释embed-file字体文件绝对路径font-family这个字体在 FOP 里对外暴露的名字要和你 FontMapper 里返回的名字保持一致embedtrue/embed强制把字体子集嵌入 PDF避免阅读端缺字。Java 侧注入这个配置的方式是创建FOConversionSettings并设置FopFactory再传给转换器。具体调用在不同 docx4j 版本里略有差异常见形式是import org.docx4j.convert.out.fo.FOConversionSettings; import org.docx4j.convert.out.pdf.viaXSLFO.PdfViaXSLFO; import org.apache.fop.apps.FopFactory; FopFactory fopFactory FopFactory.newInstance(new File(/path/to/fop.xconf).toURI()); FOConversionSettings settings new FOConversionSettings(); settings.setFopFactory(fopFactory); PdfViaXSLFO converter new PdfViaXSLFO(wordMLPackage); converter.output(new FileOutputStream(/tmp/test.pdf), settings);如果你的项目里 docx4j 版本较老可能要换成其他方式注入设置具体以你所用版本 API 为准。思路都一样docx4j 负责把 Word 文档转成 FOFOP 负责按 FO 里声明的字体名去已注册字体里找物理字体。方案三的好处是运维和开发可以分离坏处是多了一层配置文件的同步问题。字体文件路径变了、版本升级了团队要记得同步更新。如果只有单个应用在用 docx4j我反而不推荐为了规范去引 fop.xconf直接在代码里注册更直观。4.4 验证输出怎么确认 PDF 里真的嵌入了中文字体配置完了不代表就能交付一定要验证 PDF 里真的嵌入了中文字体。最快的验证工具是pdffonts它是 poppler-utils 的子命令。pdffonts /tmp/test.pdf正常输出类似name type encoding emb sub uni object ID ------------------------------- --------------- --------------- --- --- --- --------- NotoSansCJKsc-Regular CID TrueType Identity-H yes yes yes 4重点关注emb列yes表示字体嵌入成功。如果看到的是Helvetica、Arial说明字体映射根本没生效返回到 FontMapper 和PhysicalFonts的注册环节继续查。如果emb是no说明字体被使用了但没有嵌入需要在 fop.xconf 里设置embedtrue/embed。另外可以用调试手段输出中间 FO 文件看看 docx4j 生成的font-family到底是什么Docx4J.toFO(wordMLPackage, new FileOutputStream(/tmp/result.fo));打开result.fo搜font-family如果里面写的是NotoSansCJKsc说明 FontMapper 已经生效如果还是写宋体说明你的 FontMapper 根本没有被调用到优先检查setFontMapper是不是漏了或者包版本接口对不上。5. 实战排错五种常见的看起来像乱码的场景与排查链路配置讲完了真正上线时你还会遇到各种歪打正着的坑。下面这五类场景是我实际排查过程中最常碰到的。5.1 场景对照表| 场景 | 表现 | 根因 | 解决方案 | | 本地正常服务器乱码 | 服务器 PDF 中文是方块或空白 | 服务器缺少中文字体文件 | 安装 Noto CJK 或注册字体文件 | | 字体名映射失败 | 中文区域显示成默认西文字体占位符 | docx 中逻辑字体名和物理字体名对不上 | 在 FontMapper 中加别名映射 | | 字体已注册但 FOP 仍然找不到 | 日志有 cannot load font 警告 | FOP 和 docx4j 的字体注册链路不一致 | 用 fop.xconf 显式注册确认名字一致 | | PDF 里能看但复制出来是乱码 | 视觉正常复制/搜索文本乱码 | 字体未正确嵌入ToUnicode 映射缺失 | 设置 embedtrue重新生成 | | 部分字体正常部分异常 | 用了仿宋/楷体/华文细黑等字体时异常 | 服务器没有这些字体也没有 fallback | 增加别名映射或安装对应字体 |这张表可以作为你上线前的自检清单。遇到问题先对号入座比盲改代码高效很多。5.2 完整排查链路从 PDF 到 FO 到物理字体排查的顺序很重要我建议遵循从结果倒推的原则先看 PDF 用了哪些字体pdffonts test.pdf确认emb和字体名再看 docx4j 生成的中间 FO 文件Docx4J.toFO()确认font-family值是否已经是目标字体打印PhysicalFonts.getPhysicalFonts()的 key确认 docx4j 发现了哪些物理字体确认 FontMapper 是否被调用在getMappedFont里加一行日志打印fontName看看入参是否包含预期值最后检查字体文件本身可读性直接用 Java 代码读 OTF/TTF确认文件没有损坏、没有权限问题。实际项目里我遇到过一个隐蔽问题字体文件本身正常PhysicalFonts里也能看到NotoSansCJKsc但转出来的 PDF 仍然用默认字体。最后定位到是另一个同事在项目里配置了docx4j.properties覆盖了字体目录的扫描路径导致 FOP 初始化时压根没拿 docx4j 注册的字体。排查了整整大半天。所以你在项目里搜索一下有没有docx4j.properties或类似配置能少走很多弯路。还有一点值得强调FOP 找不到字体时通常只打 WARNING 不抛异常所以别只看有没有报错而是要看日志里有没有Unable to load font、cannot resolve font之类的关键词。把org.apache.fop、org.docx4j的日志级别调到 DEBUG 是最直接的排查手段。5.3 字体版权和文件格式的坑如果你打算直接把 Windows 的simsun.ttc、msyh.ttc拷贝到 Linux 服务器上用从技术上能解决一时的问题但从合规角度我不建议这么做。宋体、黑体、微软雅黑等字体都是商业字体授权范围通常不包括随意部署到服务器。开源项目里更稳妥的选择是思源黑体/思源宋体Source Han Sans / Source Han SerifOFL 协议Noto Sans CJK / Noto Serif CJKGoogle 和 Adobe 合作的 OFL 字体文泉驿正黑/微米黑GPL/APL 双许可。选择这些开源字体还有一个额外好处大部分都是单文件 OTF/TTF或者比较容易找到单文件副本注册到 docx4j 时兼容性好。字体文件格式方面尽量避开 TTC 集合如果只有 TTC 可用可以先用工具把需要的字重提取成单独的 TTF/OTF再注册进去。6. 转换性能、复杂排版以及一个更省事的替代方案6.1 大文件、并发和缓存问题docx4j 的PhysicalFonts是全局共享的字体的动态注册会影响所有线程。如果系统里多个线程同时在转不同的 Word 文档而你又在转换过程中动态添加字体另一个线程可能读到不完整的字体列表出现这种偶发乱码排查起来非常玄学。我的实践建议是字体注册放在应用启动阶段一次性完成拒绝运行时动态注册FontMapper 做成单例转换服务每次直接复用同一个实例如果转换量很大可以考虑复用FopFactory这类重型对象但要注意线程安全一般以转换为粒度就好。大文档转换时docx4j 会先生成一个完整的 XSL-FO 中间文档再交给 FOP。如果文档特别大中间文件可能占据不少临时空间。可以在启动参数里指定临时目录避免默认的/tmp空间不足。文件大小方面包含大量高清图片的 docx 转 PDF 要注意 PDF 图像压缩策略docx4j 默认不会对图片做激进压缩转出来的 PDF 可能会比原 docx 大不少。6.2 复杂 Word 排版和备选方案docx4j 的转换保真度对常见排版已经足够但遇到文本框、复杂数学公式、SVG 图形、多级嵌套表格、批注修订痕迹这类高阶玩法XSL-FO 这条路还原度就可能打折扣。如果业务对视觉还原度要求特别高我验证过的替代方案有这么几个LibreOffice headlesssoffice --headless --convert-to pdf test.docx在 Linux 服务器上装一个 LibreOffice转换保真度高而且字体走系统字库很多 docx4j 的字体映射问题自然消失。缺点是部署依赖重并发的转换吞吐不如纯 Java 方案而且对 Java 项目来说多了一个跨进程依赖。Aspose.Words商业库还原度在所有方案里属于第一梯队但授权费用不低适合预算宽松、对保真度有硬性要求的场景。云文档转换服务适合不想维护转换能力的场景但涉及文档外发要先确认数据合规。从维护成本角度讲我的个人倾向是如果转换量不大、格式不算太复杂docx4j 加上字体配置完全够用如果文档类型非常杂或者团队没有精力在 Java 进程里继续调样式直接上 LibreOffice headless 会更省心。两者也可以共存遇到 docx4j 解决不了的复杂文档再切到 LibreOffice按文档特征路由。字体配置这件事docx4j 真的不算复杂核心就三个词字体文件、PhysicalFonts、FontMapper。但初次接触的人往往被各种帖子绕得一头雾水因为大多数资料只给了某个片段没有把 FOP 渲染的完整链路讲清楚。我希望这篇文章能帮你把这条链路彻底理顺。如果你现在还在被方框乱码困扰别急着换库先把pdffonts的结果看一眼再对照上面的注册和映射步骤走一遍大概率十分钟内就能定位到问题。
上一篇/下一篇内容由系统自动关联
返回资讯列表 →