
先说一句在高性能的 Java 后端服务里“把 Word 转成 PDF”这个需求十有八九是 OA、合同管理、文档预览这类系统里躲不掉的硬骨头。我最早接这个需求时第一反应是搜java word转pdf然后被各种商业库报价吓了一跳最后在开源方案里翻来覆去选中了docx4j。这中间踩过的坑尤其是中文字体乱码问题堪称连环雷今天这篇就把整个方案、代码和避坑记录完整摊开。如果你是那种“只想赶紧把功能跑通不要三天两头出幺蛾子”的开发者这篇文章应该能帮你省下一整周的排查时间。我会从选型逻辑讲起再给出一套可复用的Word转PDF实现重点拆解docx4j在中文字体配置上的完整解法最后把我在生产环境里遇到的典型问题整理成速查清单。1. 为什么挑docx4j技术选型的真实考量先说清楚一个观点在 Java 生态里做这种文档格式转换没有银弹。每种方案都有明显短板选型其实就是看你愿意在哪个维度上吃亏。1.1 需求从哪来我这次接手的场景是公司内部的合同审批平台。业务方要求用户上传 Word 合同模板系统填好数据后要能一键导出 PDF 存档。验收标准很明确第一PDF 里的中文不能乱码第二页面排版要和 Word 原稿基本一致第三不能给每台服务器装 Office 或 WPS运维不答应。这三个条件一摆出来基本就把几条老路堵死了。用 Windows 服务器的 Office COM 组件跨平台部署直接出局而且并发调用 COM 容易把进程搞崩。用 OpenOffice/LibreOffice headless 模式Linux 下能跑但要把整个办公套件装进容器镜像体积蹭蹭涨处理复杂文档时样式漂移也比较厉害。1.2 主流方案横向对比我给当时候选的方案做了个简单对比现在也整理成表格供你参考实现方式优点缺点适合场景Apache POI 直接读写 PDF纯 Java、轻量POI 侧重 docx 数据读写对版式渲染支持极弱转 PDF 效果基本不可用先转 HTML 再转 PDF 的中间环节docx4j XSL-FO纯 Java、开源、文档结构完整保留转换性能一般复杂排版需调样式服务端批量转换、OEM 集成LibreOffice headless转换质量高依赖重、并发能力弱、样式会因本机字体变化漂移少量人工转换、内网工具Aspose/Spire 等商业库开箱即用、效果最好授权费感人动辄几万起步预算充足的企业项目每条路都有人走没有绝对的好坏。但我的约束条件是“开源、纯 Java、可 Docker 化”综合对比下来还是docx4j最合适。1.3 docx4j本身的取舍说实话docx4j的上手曲线算是有点陡的。它的原理是先解析 OOXML 文档结构再把内容转换成 XSL-FO 中间格式最终通过 Apache FOP 渲染成 PDF。这套链路的好处是只要 docx 里的样式信息完整排版还原度就很高代价是流程长、开销大对内存和 CPU 都不算友好。但话又说回来Word转PDF这种需求本身就是重度 I/O 操作不是每秒几十次的接口所以性能只要控制在线程池和超时机制里完全够用。真正让项目卡壳的从来不是转换本身而是字体渲染。2. 环境准备和最小可用转换做任何技术方案都建议先把“最小可用闭环”跑通再逐步加细节。我这里用的版本组合是能稳定复现的请照抄。2.1 版本选择和依赖引入我用的是docx4j 8.3.9配合 Java 8。如果你项目已经上了 Java 11 或 JDK 17会多一个 JAXB 模块问题需要额外引入jakarta.xml.bind相关依赖后面排查清单里会细说。Maven 依赖配置如下dependency groupIdorg.docx4j/groupId artifactIddocx4j-JAXB-ReferenceImpl/artifactId version8.3.9/version /dependency这个坐标是参考资料实现适合 JDK 8。如果你用 JDK 11建议改为dependency groupIdorg.docx4j/groupId artifactIddocx4j-JAXB-ReferenceImpl/artifactId version8.3.9/version exclusions exclusion groupIdjavax.xml.bind/groupId artifactIdjaxb-api/artifactId /exclusion /exclusions /dependency dependency groupIdjakarta.xml.bind/groupId artifactIdjakarta.xml.bind-api/artifactId version2.3.3/version /dependency2.2 一个“能跑但会乱码”的最简转换先来一段最基础的转换代码注释里我会标清每一步的作用import org.docx4j.openpackaging.packages.WordprocessingMLPackage; import org.docx4j.openpackaging.parts.PartName; import org.docx4j.convert.out.pdf.PdfConversion; import org.docx4j.convert.out.pdf.viaXSLFO.PdfConversion; import java.io.File; import java.io.FileOutputStream; public class WordToPdfBasic { public static void main(String[] args) throws Exception { // 1. 加载 docx 文件 WordprocessingMLPackage wordMLPackage WordprocessingMLPackage.load(new File(/data/input.docx)); // 2. 创建 PDF 转换器 PdfConversion conversion new PdfConversion(wordMLPackage); // 3. 输出 PDF FileOutputStream fos new FileOutputStream(/data/output.pdf); conversion.output(fos); fos.close(); System.out.println(convert done.); } }在本地 Mac/Windows 上跑这段代码大概率一切正常。但一旦把 jar 丢到 Linux 服务器上输出的 PDF 里中文就全变成方块或乱码了。这就是无数新手第一脚踩进去的坑本地好好的生产环境全废。2.3 为什么本地正常、Linux乱码根因就一句话docx4j 是通过操作系统的字体库来做字体查找的Windows/Mac 自带中文字体而精简版的 Linux 服务器往往一个中文字体都没装。docx4j 找不到宋体、黑体就只能用默认字体替代最终渲染出来的 PDF 就是“豆腐块”。记住这个机制接下来的所有配置都是围绕“让转换环境能找到正确字体”这一件事展开的。3. 中文字体配置的完整方案这节是全篇的重点我会把字体问题的三层处理逻辑讲透。很多人只知道往服务器上装个字体却不知道 docx 里没显式设置中文字体时照样会乱码。正确做法是两层同时抓一是文档层面的字体标记二是服务器层面的物理字体注册。3.1 字体渲染链路docx4j 渲染 PDF 时的字体解析顺序大致是读取 OOXML 里的w:rFonts拿到字符引用的字体名比如“宋体”或“SimSun”。去字体映射器FontMapper里查这个字体名映射到物理字体文件TTF/TTC。用物理字体文件渲染字形生成 PDF。也就是说如果第一步的字体名是空的、或者映射不到具体字体文件都会导致字符无法正确绘制。3.2 第一层在docx里显式设置中文字体很多 Word 文档看似正常但打开 XML 你会发现段落里的中文并没有显式指定eastAsia字体它们把字体定义放在了主题theme里。docx4j 处理这类依赖主题的文档时一旦主题里指定的字体在服务器上不存在就会直接翻车。所以最稳的做法是在转换前主动遍历 docx 的所有段落和文本节点把中文字符的eastAsia属性统一设置为目标字体。下面这段代码是遍历设置的核心逻辑建议放在转换方法的入口执行import org.docx4j.openpackaging.packages.WordprocessingMLPackage; import org.docx4j.wml.P; import org.docx4j.wml.R; import org.docx4j.wml.RPr; import org.docx4j.wml.RFonts; import java.util.List; /** * 递归遍历所有段落为每个run设置中文字体 * param wordMLPackage 文档包 * param fontName 中文字体名如 SimSun */ public static void setDocumentChineseFont(WordprocessingMLPackage wordMLPackage, String fontName) { ListObject paragraphs wordMLPackage.getMainDocumentPart().getContent(); for (Object obj : paragraphs) { if (obj instanceof P) { setParagraphFont((P) obj, fontName); } } // 不要把页眉页脚漏掉很多合同模板的页眉信息很重要 ListObject hdrParts wordMLPackage.getParts().getParts().values() .stream() .filter(part - part.getPartName().getName().contains(header)) .collect(java.util.stream.Collectors.toList()); for (Object hdr : hdrParts) { // 实际类型为 org.docx4j.openpackaging.parts.WordprocessingML.HeaderPart // 这里建议用反射或直接强转后遍历其内容 } } /** * 设置单个段落的字体 */ private static void setParagraphFont(P p, String fontName) { ListObject runs p.getParagraphContent(); for (Object runObj : runs) { if (runObj instanceof R) { R run (R) runObj; RPr rPr run.getRPr(); if (rPr null) { rPr new RPr(); } RFonts rFonts rPr.getRFonts(); if (rFonts null) { rFonts new RFonts(); } // ascii 设置西文字体eastAsia 设置中文字体 rFonts.setAscii(Times New Roman); rFonts.setEastAsia(fontName); rPr.setRFonts(rFonts); run.setRPr(rPr); } } }注意上面的示例只处理了主文档里的段落。真实场景中表格里的文字、页眉页脚里的文字都可能有字体问题。我在项目里写过一个更完整的方法遍历整个 OOXML 树凡是碰到P节点就调用一次setParagraphFont可以覆盖绝大多数情况。这种“主动写死字体”的方式兼容性最好的原因在于它不依赖原文档的字体声明是否规范直接强制兜底。3.3 第二层在服务器上安装物理字体光在文档里写“SimSun”没用服务器必须真有这个字体文件否则 FOP 找不到字体会退化成默认字体照样乱码。先检查服务器上有没有中文字体fc-list :langzh如果输出为空说明系统里没有任何中文字体。此时有两种安装方式。方式一系统级安装把字体文件放进字体目录然后刷新缓存mkdir -p /usr/share/fonts/chinese cp simsun.ttc /usr/share/fonts/chinese/ fc-cache -fv方式二不想动系统目录想在应用启动时通过代码加载字体。docx4j 提供了物理字体注册 API可以加载特定路径下的字体文件import org.docx4j.fonts.PhysicalFonts; // 加载系统字体目录 PhysicalFonts.addPhysicalFonts(new File(/usr/share/fonts/chinese/simsun.ttc)); // 或者注册指定别名的字体文件 PhysicalFonts.addPhysicalFonts(MySimSun, new File(/data/fonts/simsun.ttc));这两种方式不冲突我建议字量不多的环境直接走系统级安装因为 FOP 本身也会去系统字体目录里找字体代码加载不能覆盖所有渲染路径。3.4 第三层字体映射与兜底策略有些模板里用的字体名特别“随意”什么“微软雅黑”“黑体”“宋体”各种叫法都有。Linux 上即便装了字体字体名对不上也白搭。此时就需要配置一个FontResolver或自定义字体映射把所有业务里用到的中文别名映射到同一个物理字体上。docx4j 里的做法是设置转换器的 FontMapperimport org.docx4j.convert.out.pdf.viaXSLFO.PdfConversion; import org.docx4j.fonts.BestMatchingFontMapper; PdfConversion conversion new PdfConversion(wordMLPackage); conversion.setFontMapper(new BestMatchingFontMapper());BestMatchingFontMapper会根据系统可用字体做模糊匹配能在一定程度上兜住字体名不完全一致的情况。但它不是万能的如果服务器上一个中文字体都没有它匹配出花来也没用。所以三层配置的优先级排序应该是先装物理字体再设置文档字体名最后用 FontMapper 兜底。缺了任何一个环节都可能在某些文档上出乱码。4. 完整实现从方法设计到生产可用讲完了字体原理下面给出一套我在生产环境里实际使用的完整实现。它比最小版多考虑了并发、文件流关闭、字体路径配置等细节。4.1 一个可复用的转换工具类下面是整合了字体处理和转换逻辑的完整工具类可以直接复制改造import org.docx4j.Docx4J; import org.docx4j.openpackaging.packages.WordprocessingMLPackage; import org.docx4j.openpackaging.parts.WordprocessingML.MainDocumentPart; import org.docx4j.fonts.PhysicalFonts; import org.docx4j.wml.P; import org.docx4j.wml.R; import org.docx4j.wml.RFonts; import org.docx4j.wml.RPr; import java.io.File; import java.io.FileOutputStream; import java.io.OutputStream; import java.util.List; public class Docx4jConverter { private static final String DEFAULT_CN_FONT SimSun; /** * 转换入口 */ public static void convert(String srcPath, String destPath, String fontDir) throws Exception { // 1. 加载字体目录如果提供了额外字体路径 if (fontDir ! null !fontDir.isEmpty()) { PhysicalFonts.addPhysicalFonts(new File(fontDir)); } // 2. 加载文档 WordprocessingMLPackage wordMLPackage WordprocessingMLPackage.load(new File(srcPath)); // 3. 统一设置中文字体避免Linux上字体缺失导致乱码 setChineseFont(wordMLPackage, DEFAULT_CN_FONT); // 4. 转换输出 try (OutputStream os new FileOutputStream(destPath)) { Docx4J.toPDF(wordMLPackage, os); } } /** * 递归设置文档中的所有中文文本字体 */ private static void setChineseFont(WordprocessingMLPackage pkg, String fontName) { MainDocumentPart documentPart pkg.getMainDocumentPart(); ListObject contents documentPart.getContent(); processParagraphs(contents, fontName); // 添加页眉页脚遍历实现可以参考上文3.2中的说明 // 由于PageHeaderPart/FooterPart类型在不同版本有差异这里省略具体代码 } SuppressWarnings(unchecked) private static void processParagraphs(ListObject contents, String fontName) { for (Object obj : contents) { if (obj instanceof P) { P p (P) obj; ListObject paras p.getParagraphContent(); for (Object item : paras) { if (item instanceof R) { setRunFont((R) item, fontName); } } } else if (obj instanceof java.util.List) { processParagraphs((ListObject) obj, fontName); } } } private static void setRunFont(R run, String fontName) { RPr rPr run.getRPr(); if (rPr null) { rPr new RPr(); } RFonts rFonts rPr.getRFonts(); if (rFonts null) { rFonts new RFonts(); } rFonts.setAscii(Times New Roman); rFonts.setEastAsia(fontName); rPr.setRFonts(rFonts); run.setRPr(rPr); } }代码里把字体名写成了SimSun这在大多数合同和公文模板里都适用。如果你的业务用户喜欢用“微软雅黑”就把常量改成Microsoft YaHei然后确保服务器装的是同名物理字体。4.2 图片、目录和链接的处理细节有人在搜索word转pdf如何不压缩图片这个担忧在 docx4j 这里其实不太需要。docx4j 是保留原始图片数据进行 PDF 嵌入的它不会像某些截图工具那样重新采样压缩图片。如果 PDF 里的图片看起来模糊往往不是转换器压了图片而是原 Word 文档里本身就用了低分辨率图片。但有一个必须注意的点如果 Word 文档中的图片是使用“链接”方式插入的而不是嵌入文档那么 docx4j 在转换时可能会因为找不到外部图片文件而留下空白。这种问题无法在后处理中解决只能要求业务方在上传前把图片嵌入文档。可以在转换日志里增加警告提示发现文档里有 external relationship 时输出 warn便于排查。关于目录和链接网上还有一种高频提问叫“为了转pdf把word链接取消了现在更新目录不可以动怎么办”。这其实是另一个操作场景某些人为了让 PDF 能导出先手动取消了 Word 里的超链接结果目录域也失效了。在 docx4j 这边正确处理方式是不要去手工动 Word 里的域代码直接让 docx4j 解析即可。如果 PDF 里目录页码不对可以在转换前调用 field updater 或提示用户在 Word 里先更新目录再上传。docx4j 对 TOC 域的自动更新支持有限我一般会在上游就做一次校验确保用户上传前 Word 中的目录已经是最新状态。4.3 并发、内存和超时docx4j 转大文档时非常吃内存。一个 30MB 的 docx转换过程峰值堆占用能到 800MB 甚至上 GB。如果是提供 HTTP 接口给内部系统调用建议做三件事第一把转换操作丢进有界线程池控制并发数我建议单机并发不超过 2-3 个具体看机器内存。第二接口层设置超时时间。我用的是 120 秒作为阈值超出就直接返回“文档过于复杂转换失败”的提示避免请求排队导致线程被长时间占住。第三JVM 堆大小给足。容器化部署时Xmx 至少要给到 1.5GB 以上否则转换复杂模板时很容易 OOM。如果你需要处理批量转换几十上百个文件建议用队列削峰一步一步来别一把梭塞给 docx4j。5. 常见问题排查与避坑记录最后这部分是我踩坑最多的地方整理成速查式清单。你如果遇到相同问题直接按顺序检查。5.1 中文字体问题排查顺序乱码/方块字问题不要一上来就怀疑代码按下面的顺序排查先执行fc-list :langzh确认服务器真的装了中文字体。确认 docx 中显式声明了中文字体比如在 Document 里搜w:eastAsia。确认字体名拼写与物理字体实际名称一致比如字体文件是SimSun文档里写了宋体这不能直接匹配。确认 FontMapper 配置没有被覆盖比如高位优先的转换逻辑里又 new 了默认的 IdentityMapper。如果上面四步都过了还是乱码把产生的 PDF 和日志发出来重点看 FOP 在渲染时有没有输出了glyph not found之类的警告。5.2 高频问题表下面这几种问题是社区里问得最多的我列成表格方便你直接对照。现象直接原因推荐解决中文全部变方块服务器无中文字体安装字体到/usr/share/fonts并fc-cache -fv本地正常Linux乱码本地有宋体Linux没有容器打包镜像时就把字体文件 COPY 进去转换出来的PDF排版换行错乱文档原字体与替换字体度量不同统一使用同款常用字体如 SimSun/雅黑并保持字宽一致表格边框缺失或错位docx4j对复杂表格支持不完整尝试降级到 8.2.x 版本或简化表格嵌套大文档转换 OOMXSL-FO 渲染内存开销高增加 JVM 堆限制并发设置转换超时JDK 11 报 JAXB 类找不到高版本 JDK 移除了 JAXB 模块引入 jakarta.xml.bind 依赖或用 JDK 8图片空白原文档使用了链接图片在上游重做嵌入图片5.3 转换效果与Word打开不一致另外一个高频问题同一个 docx用 Word 打开和用 docx4j 转 PDF 出来的页面效果有差异。这是 XSL-FO 渲染机制自身的限制docx4j 只能尽量还原不可能 100% 等同 Word 渲染。遇到这种情况我的经验是先把模板复杂度降下来。比如避免使用艺术字、文本框、复杂嵌套表格尽量用 Word 内置的标题样式和普通表格。模板规范化之后转换质量能上一个大台阶。5.4 我的一点个人体会在这套方案上线后的半年里我学到的最重要的一件事是字体问题不是靠转换器解决的而是靠环境管理和模板规范共同解决的。下载的字体文件要统一放在一个目录Dockerfile 里显式复制进镜像禁止运维人员手动改字体文件名字。模板侧最好在 Word 里把所有正文样式统一改成一种中文字体这样源头就不会有过多字体别名带来的匹配问题。如果你刚开始接触 docx4j先把最小转换跑通再动手加上字体配置不要一上来就套完整工具类。把每一步的输入输出都检查清楚后面扩展功能时会顺畅很多。希望这篇文章能帮你绕开那些我已经替你们踩平的坑。