基于docx4j实现DOCX转PDF:字体注入与SpringBoot工程实践
1. 项目概述与方案选型1.1 为什么会有这个需求做企业级应用的开发者应该都有过这种经历合同管理系统要在线预览合同正文审批流系统要归档最终版文件报表系统要把当月数据导出给管理层查阅。这些场景里把 DOCX 转成 PDF 几乎是个绕不开的坎。DOCX 本质是一个压缩包里面装着 XML 结构的文档数据它的渲染逻辑依赖 Word 软件或兼容组件PDF 则是固定版面格式跨平台阅读不会乱版。所以 DOCX 转 PDF 的核心矛盾就两个字还原。转换结果越接近用户在 Word 里看到的原始样貌这个方案就越成功。我调研过几种主流方案这里简单说一下各自的取舍。方案一调用 LibreOffice / OpenOffice 的 headless 模式这个方案原理是通过命令行soffice --headless --convert-to pdf完成转换社区里也有一堆封装库。优点是转换效果极好因为 LibreOffice 本身就是完整办公套件对 DOCX 的兼容性很高。缺点是部署重服务器上要么安装 LibreOffice 套件体积大概几百兆要么跑 Docker 容器而且并发转换时 CPU 开销很猛接口延迟不稳定。方案二用 POI PDFBox 手写转码POI 负责解析 DOCX 的段落、表格、图片PDFBox 负责把解析结果画到 PDF 上。这条路能做但工程量不可控。DOCX 里的样式体系非常复杂——段落级样式、字符级样式、编号列表、分节符、页眉页脚、表格宽度计算哪怕只支持 80% 的场景代码量也会到几千行维护成本巨高。我见过有人这么干写到一半直接放弃了。方案三商用组件比如 Aspose.Words效果确实好几乎 100% 还原但是 License 费用不低小团队往往不愿意为低频功能花这笔钱。方案四docx4j纯 Java 开源库直接操作 OOXMLOffice Open XML文档对象模型自带IWordToPdfConverter接口底层对接的是开源的 PDF 渲染引擎。轻量、嵌入方便、不依赖外部服务SpringBoot 项目里打个依赖就能用适合绝大多数业务场景。局限是复杂排版会有还原偏差但通过字体注入和布局调优可以做到 90% 以上的还原度对内部管理类系统完全够用。我这篇文章选择 docx4j也是因为在开源免费、部署轻量、代码可控这三个维度上它是对我这样不想折腾服务器的开发者最友好的选择。下面我会完整拆解这个方案的实现细节、字体问题的解法、常见故障的排查路径以及上线前必须做的性能优化。1.2 项目结构和目标功能先交代一下这个模拟项目 X 的总体设计。它是一个典型的 SpringBoot 工程核心模块是docx-converter对外暴露一个 REST 接口POST /api/convert/docx-to-pdf。前端传文件上来后接口完成三件事校验文件类型和大小。调用 docx4j 做异步转换。返回下载链接。转换的核心维护一个线程池避免大文件转换时阻塞 Tomcat 的工作线程。完整项目结构如下docx-converter/ ├── pom.xml ├── src/main/java/ │ └── com/example/converter/ │ ├── DocxConverterApplication.java // SpringBoot 启动类 │ ├── controller/ │ │ └── ConversionController.java // 文件上传与转换接口 │ ├── service/ │ │ ├── ConversionService.java // 核心转换服务 │ │ └── ConversionTask.java // 异步任务包装 │ └── util/ │ └── Docx4jFontUtil.java // 字体绑定和中文环境处理 └── src/main/resources/ └── fonts/ // 服务端字体目录全文代码我会尽量给出可直接落地的片段运行时环境以 JDK 8 和 SpringBoot 2.x 为主高版本 SpringBoot 3.x 需要注意 javax 到 jakarta 的命名空间迁移这点稍后会提。2. 环境准备与依赖引入2.1 Maven 依赖怎么引docx4j 在 Maven 中央仓库的坐标是org.docx4j:docx4j-JAXB-ReferenceImpl这里要注意版本选择。早期版本用的是 javax.xml.bindJDK 8 内置还能直接用从 JDK 11 开始 JAXB API 被移出 JDK必须依赖项目里显式引入的 JAXB 实现。所以版本不能随便填。我实测稳定的组合是这样的SpringBoot 2.7.x 本身自带jakarta.validation等一批依赖管理docx4j 11.x 系列和它兼容良好properties java.version11/java.version docx4j.version11.4.9/docx4j.version /properties dependencies dependency groupIdorg.docx4j/groupId artifactIddocx4j-JAXB-ReferenceImpl/artifactId version${docx4j.version}/version /dependency /dependencies如果项目还在用 JDK 8也可以用 8.x 系列的 docx4j例如8.3.1配合 SpringBoot 2.7.x 没有问题。选版本时最稳妥的做法是打开https://mvnrepository.com/artifact/org.docx4j/docx4j-JAXB-ReferenceImpl看一眼发布时间和你的 JDK 版本做个匹配别拿最新版无脑冲转换这种功能性模块稳定第一。2.2 依赖传递带来的隐藏坑引入 docx4j 时会有几个传递依赖大部分情况下 SpringBoot 的依赖管理能搞定但有一个例外SLF4J 版本冲突。docx4j 内部大量使用 slf4j 做日志输出如果项目里 web 容器自带的日志实现版本太老控制台会出现一堆Failed to load class org.slf4j.impl.StaticLoggerBinder之类的报错转换功能虽然正常但日志输出会很头疼。我踩过这个坑后的做法是在依赖声明里排除掉 docx4j 自带的旧版 slf4j-api以 SpringBoot 依赖管理的为准dependency groupIdorg.docx4j/groupId artifactIddocx4j-JAXB-ReferenceImpl/artifactId version${docx4j.version}/version exclusions exclusion groupIdorg.slf4j/groupId artifactIdslf4j-api/artifactId /exclusion /exclusions /dependency另外注意docx4j 11.x 版本会引入org.apache.logging.log4j相关的可传递依赖如果项目统一用 Logback建议同样做排除。排查这类问题时最有用的命令是mvn dependency:tree -Dincludesorg.slf4j先把冲突的具体模块看清楚再动手。3. 核心转换代码实现3.1 最基础的转换工具类先给一个不掺杂任何封装的最小可用代码这是整个功能的骨架。它做的事情就三步加载 DOCX 文件 → 创建 PDF 转换对象 → 输出结果。// DocxToPdfConverter.java import org.docx4j.Docx4J; import org.docx4j.convert.out.pdf.viaXSLFO.PdfSettings; import org.docx4j.openpackaging.packages.WordprocessingMLPackage; import java.io.File; import java.io.FileOutputStream; public class DocxToPdfConverter { public static void convert(File docxFile, File pdfFile) throws Exception { // 步骤1加载文档 WordprocessingMLPackage wordMLPackage WordprocessingMLPackage.load(docxFile); // 步骤2配置 PDF 转换参数 PdfSettings pdfSettings new PdfSettings(); // 步骤3执行转换 FileOutputStream fos new FileOutputStream(pdfFile); Docx4J.toPDF(wordMLPackage, fos, pdfSettings); fos.close(); } }这段代码能跑但只适用于最简单的文档。实际业务中如果文档里出现了中文字体、复杂表格、页眉页脚你会发现转出来的 PDF 有各种奇怪问题最常见的是中文变方块、表格边框消失、行距错乱。这些问题的根源不在 docx4j 的核心解析逻辑而在一步最容易被忽略的操作字体注入。3.2 为什么要做字体注入docx4j 默认的 PDF 转换走的是 XSL-FO 中间格式路线它的原理可以简单类比成先把你 DOCX 里的内容翻译成一套通用排版描述XSL-FO再让渲染引擎把这份描述画成 PDF 页面。这个翻译过程里最敏感的环节就是字体的 mapping。DOCX 文档里记录的字名比如宋体只在你的电脑里存在服务器上可没有。服务器如果找不到对应字体渲染引擎就会用默认字体替代。对于中文默认替代往往是一个不存在或者不可用的字体结果就是 PDF 里中文变成方块□。所以转换系统上线前服务器必须装好中文字体并且告诉 docx4j 这些字体在哪里。我在 Linux 环境下测试时最省心的做法是先检查系统缺失fc-list :langzh看一下有没有中文字体。没有就装例如fonts-wqy-zenhei、fonts-wqy-microhei然后程序里主动注册字体目录。3.3 完整版服务实现带字体注册和异常处理下面是项目里真正会用的版本我多做了几件事字体注册把服务端fonts目录下的字体文件加载进 docx4j 的字体缓存。转换前做文档校验避免空文件或者格式损坏导致异常。封装成 Spring Service注入线程池做异步化。// ConversionService.java import org.docx4j.convert.out.pdf.viaXSLFO.PdfSettings; import org.docx4j.openpackaging.packages.WordprocessingMLPackage; import org.docx4j.utils.FooterStyler; import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; import org.springframework.stereotype.Service; import java.io.File; import java.io.FileOutputStream; Service public class ConversionService { private final ThreadPoolTaskExecutor executor; public ConversionService(ThreadPoolTaskExecutor executor) { this.executor executor; } public void convertAsync(File docxFile, File pdfFile, String taskId) { executor.submit(() - { try { long start System.currentTimeMillis(); convert(docxFile, pdfFile); long cost System.currentTimeMillis() - start; System.out.println(任务 taskId 转换完成耗时 cost ms); } catch (Throwable e) { // 这里应该记日志、更新任务状态 e.printStackTrace(); } }); } public void convert(File docxFile, File pdfFile) throws Exception { WordprocessingMLPackage wordMLPackage WordprocessingMLPackage.load(docxFile); // 关键确保每个转换会话的字体配置都载入 Docx4jFontUtil.registerFontsOnce(); PdfSettings settings new PdfSettings(); FileOutputStream fos new FileOutputStream(pdfFile); Docx4J.toPDF(wordMLPackage, fos, settings); fos.close(); } }3.4 线程池和异步任务如何配置转换是一件吃 CPU的活。用 Tomcat 默认工作线程同步执行一旦有人传一个 100 页的大文档接口响应会长时间挂起进而拖垮整个服务的吞吐量。所以我这里用ThreadPoolTaskExecutor独立维护转换线程池接口先返回任务已接收转换完成后把结果文件放到临时目录前端再轮询或者拿下载链接。// ThreadPoolConfig.java import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; Configuration public class ThreadPoolConfig { Bean(name conversionExecutor) public ThreadPoolTaskExecutor conversionExecutor() { ThreadPoolTaskExecutor executor new ThreadPoolTaskExecutor(); executor.setCorePoolSize(4); executor.setMaxPoolSize(8); executor.setQueueCapacity(100); executor.setThreadNamePrefix(docx-pdf-); // 转换任务即使失败也不能丢弃 executor.setWaitForTasksToCompleteOnShutdown(true); executor.setAwaitTerminationSeconds(60); executor.initialize(); return executor; } }核心池设多少合适我实测下来单核 CPU 并发跑 4 个转换任务时 CPU 已经接近满载。所以小服务器上宁可核心线程数小一点让任务排队也不要开大线程池然后频繁上下文切换。量大的场景建议至少 4 核 8G 起步。4. 字体处理的完整实操4.1 服务器字体安装这一节太重要了单独拿出来讲。字体问题不解决后面做再多优化都是白搭。第一步检查服务器上的中文字体。fc-list :langzh输出如果是空说明系统里没有中文字体。Debian/Ubuntu 系sudo apt-get install -y fonts-wqy-zenhei fonts-wqy-microheiCentOS/RHEL 系可以直接安装文泉驿sudo yum install -y wqy-zenhei-fonts wqy-microhei-fonts装完后再fc-list :langzh确认。第二步把字体文件直接放进项目资源目录。这种做法的好处是和部署环境解耦不依赖系统有没有装字体。我实际操作中是把.ttf字体文件放在src/main/resources/fonts下程序启动时加载进去。下面这段代码就是把 resource 目录下所有字体注册到 docx4j 的字体映射里的方法。// Docx4jFontUtil.java import org.docx4j.fonts.PhysicalFonts; import java.io.File; import java.io.InputStream; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.StandardCopyOption; public class Docx4jFontUtil { private static boolean registered false; public synchronized static void registerFontsOnce() { if (registered) { return; } // 复制临时文件到系统临时目录后加载 try (InputStream in Docx4jFontUtil.class.getResourceAsStream(/fonts/simhei.ttf)) { Path temp Files.createTempFile(simhei, .ttf); Files.copy(in, temp, StandardCopyOption.REPLACE_EXISTING); PhysicalFonts.addPhysicalFont(SimHei, temp.toFile(), true); registered true; } catch (Exception e) { throw new RuntimeException(加载字体失败, e); } } }注意我这里的写法是复制成临时文件再加载原因稍后讲。docx4j 的PhysicalFonts.addPhysicalFont除了接受 File 参数外还能直接传流但传流形式有些版本实现是拿不到字重的复制成文件最兼容。4.2 不注册字体会出现哪些怪异现象字体没配好现象不一定是全方块。我实际见过下面这三种典型症状第一种全部中文变成方块。这种最直白就是机器完全没有匹配到中文字体XSL-FO 渲染时用了不带中文字形的默认字体。第二种粗体和正常体显示反了。文档里设了微软雅黑服务器没装系统自动 fallback 到另一个字体文件这个字体文件的 family 名和 map 关系对不上导致 Bold 信息丢失或者错配。症状就是该加粗的字不加粗不该加粗的反而变粗了排版观感很差。第三种表格里中文全丢了只剩英文和数字。这个问题隐蔽因为段落和表格的字体走的是两套不同的继承链。表格内默认字型和默认段落样式不一致时字体 fallback 也会不一致。只有把所有文档级别的默认字体都映射到服务器已有的字体上才能彻底解决。4.3 强制替换文档内字体名好就算服务器装了字体文档里写的是微软雅黑而你服务器装的是文泉驿微米黑docx4j 在按名字查找时依然会落空。最彻底的办法是转换前遍历文档树把所有字体声明替换成服务器实际安装的字体名。// ReplaceFontsPdfSettings.java 局部示例 import org.docx4j.openpackaging.parts.WordprocessingML.MainDocumentPart; import org.docx4j.wml.RPr; import org.docx4j.wml.RFonts; import org.docx4j.wml.Style; import org.docx4j.wml.Styles; public class FontReplacer { private final String targetFont; // 例如 SimHei public FontReplacer(String targetFont) { this.targetFont targetFont; } public void replace(WordprocessingMLPackage wordMLPackage) { MainDocumentPart documentPart wordMLPackage.getMainDocumentPart(); // 处理样式表里的字体声明 Styles styles documentPart.getStyles(); if (styles ! null styles.getStyle() ! null) { for (Style style : styles.getStyle()) { if (style.getRPr() ! null style.getRPr().getRFonts() ! null) { RFonts fonts style.getRPr().getRFonts(); fonts.setAscii(targetFont); fonts.setHAnsi(targetFont); fonts.setEastAsia(targetFont); } } } // 遍历正文段落也是同样的道理这里通过 XPath 或 JAXB 树递归实现 // 具体遍历工具 docx4j 提供了 XmlUtils.traverseElementChildren 来简化 } }我这里只是截了关键片段实际实现里还需要处理documentPart.getJaxbElement().getBody()下的所有段落和表格。更简洁的做法是利用 docx4j 的XmlUtils.traverseElementChildren写一个通用回调把RPr.RFonts里出现过的字体重定向。这一段代码比较机械网上模板也很多照抄基本没问题。5. 样式异常和格式兼容问题5.1 表格宽度溢出怎么办现实中大量业务文档都用表格排版比如合同里的付款条款、报价单、审批单。转换后常见问题是表格比页面宽右边被截断或者表格列宽和 Word 里看到的不一样。这个问题的根源在于 docx4j 对表格宽度的计算基准和 Word 不完全一致。DOCX 表格的宽度可以设置为固定值或百分比默认的tblW如果没有显式声明Word 会根据自己的布局算法决定docx4j 这里往往按内容自适应处理结果和原文档会有一两厘米的偏差。我的应对办法是转换前统一把表格宽度改成百分比模式import org.docx4j.wml.Tbl; import org.docx4j.wml.TblPr; import org.docx4j.wml.TblWidth; import org.docx4j.wml.Ul; import org.docx4j.wml.TblLayoutType; private void normalizeTableWidth(Tbl tbl) { TblPr tblPr tbl.getTblPr(); if (tblPr null) { tblPr new TblPr(); tbl.setTblPr(tblPr); } // 宽度改为占页面 100% TblWidth tblW new TblWidth(); tblW.setType(Ul.STRING); tblW.setW(100%); tblPr.setTblW(tblW); }实际操作中发现某些老文件里tblW的w:w值写的是正整数以 twips 为单位但渲染时 docx4j 不一定读它。主动覆盖为百分比模式后绝大多数表格能正常适应页面宽度。但表格内单元格合并、固定列宽这种复杂场景例外如果你对还原度要求极高这里只能通过人工校验样本来微调。5.2 页眉页脚丢失页眉页脚在 DOCX 里属于节级别的内容不在正文的 body 节点下。docx4j 默认转换时会处理页眉页脚的引用但前提是文档结构完整。有些文档保存时页眉页脚关系丢失或者用了 Word 里链接到前一节的功能转换时就可能遗漏。排查方法很简单转换前用 Word 打开原始文件看页眉是否存在再查看转换结果 PDF。如果确实丢了就需要定位SectionPr下的headerReference和footerReference。实践中发现大多数情况下不是 docx4j 不支持页眉页脚而是被转换文档本身含有未解析的关系引用。docx4j 转换报错日志里会出现relationship not found一类的信息顺着日志修改文档即可。其实大部分页眉页脚问题我的建议是尽量不处理让用户在源头控制文档质量。作为系统方在文档上传时做一次格式校验用 WPS 或 Word 打开另存一份再上传就能规避大量奇葩问题。5.3 复杂浮动物体和文本框优化文本框和浮动图片是最难还原的部分。docx4j 走 XSL-FO 转换时对浮动对象的定位支持有限有时会出现图片叠在文字上方、位置偏移半个页面这种事故。我的经验是业务上对公文类文档尽量要求嵌入型图片。不规则文本框里的内容转换成 PDF 前先导出成图片再插回文档能保证 100% 还原但这属于预处理增强代码复杂度倍增一般项目用不到。6. 常见问题与排查技巧实录6.1 转换后中文乱码或者变方块症状排查思路解决方案全部中文变方块服务器缺少中文字体安装fonts-wqy-zenhei或把字体放进资源目录注册部分中文变方块指定字体缺失遍历所有RFonts替换成已有字体标题字号对不上字体回退默认字号在样式表中显式替换字体和字号PDF 中粗体变普通字体 family 映射错误检查PhysicalFonts注册的 family 名保持和系统一致6.2 转换过程抛异常XmlUtils.unwrap有些 DOCX 文件里包含宏、表单控件、扉页等特殊元素docx4j 在解析时可能无法处理异常信息常带有com.sun.xml.bind字样的报文。这类问题我处理过几次最终妥协的方案是捕获异常后把原文件另存为.docx即关闭修订与批注痕迹。用 WPS 打开并另存为 docx一次去除内部无用的 compatibility 元素。服务端如果接收用户文件在上传时就做预检用 regex 或解析word/document.xml检查是否存在非常规元素。简单说转换失败很多时候不是程序问题而是源文件本身不干净。从源头收严文件格式规范比改造程序去兼容所有畸形文件更划算。6.3 内存溢出与并发压力DOCX 转 PDF 是一次典型的把所有内容读进内存再重新渲染的操作一个大文档可能占用内存 200MB 到 1GB。服务器如果用默认的 JVM 参数跑 SpringBoot并发 10 个转换任务可能直接 OOM。我项目里做过两个治理措施措施一限制上传文件大小和转换并发数。spring: servlet: multipart: max-file-size: 10MB max-request-size: 10MB虽然用户可能传更大的文件但 10MB 的 DOCX 往往已经包含大量高清图片转出来几百页 PDF。对内部系统而言限制在 10MB 平衡了业务和资源消耗。措施二转换完成后立即释放引用并调用 GC 兜底。wordMLPackage null; System.gc(); // 不推荐频繁调用但转换任务这种池化场景里可以辅助现实一点讲最有效的还是让用户知道超过 20MB 的文档请拆分或者转 PDF 后再传同时后台记录每次转换耗时和峰值内存用量化指标规划扩容。6.4 转换出来的 PDF 部分文字无法选中这个问题常发生在包含图片型文字的 DOCX 文件里。比如文档里有些文字其实是e嵌入的 OLE 对象或图片转换后自然无法选中。这不算程序 bug而是文档本身如此。如果业务要求可复制 PDF最好在源头要求用户用真实文本排版文档这属于交付规范和产品流程问题。7. 性能优化与项目经验7.1 缓存策略同一个模板文件被不同业务反复转换是很常见的场景。比如部门发一份会议通知模板给一百个人填填完各自上传转换。这种情况下模板解析工作加载 WordprocessingMLPackage完全可以复用。docx4j 的WordprocessingMLPackage.load是耗时大头之一一个 5MB 的文档加载解析可能需要 1~3 秒。如果模板固定可以把解析后的WordprocessingMLPackage对象放在 JVM 缓存里仅替换正文部分内容后再转换。但注意WordprocessingMLPackage里含有可变引用缓存前务必确认不会被多处修改否则多线程共享状态下容易踩到脏数据的坑。7.2 保存中间文件和文件清理策略转换服务的临时文件如果不清理磁盘再大也会爆。我的做法是上传的 DOCX 和生成的 PDF 统一放进/tmp/docx-pdf/{taskId}/目录。文件在响应下载请求后设置一个 30 分钟的定时删除任务。启动一个Scheduled任务每小时清理超过 4 小时的文件目录。用独立目录的好处是删除时直接FileUtils.deleteDirectory不用逐个遍历。7.3 接口设计上的避坑建议如果您设计的接口是同步返回字节流要小心大文件场景下网关超时、浏览器内存占用过高等问题。对生产系统我更推荐上传接口返回taskId即异步提交。轮询GET /api/convert/status/{taskId}获取状态和结果 URL。文件下载走独立 URL加下载凭证。这样无论单文件转换是 1 秒还是 30 秒用户体验都稳定。异步化的成本不高但收益非常大强烈建议按这个形态做。7.4 SpringBoot 3.x 的迁移注意点如果您用的是 SpringBoot 3.x比如 3.1、3.2docx4j 11.x 大概率可以编译通过但要注意javax.xml.bind这会迁移到jakarta.xml.bind。docx4j 的 JAXB-ReferenceImpl 版本已经兼容 Jakarta 命名空间需要同时引入jakarta.xml.bind:jakarta.xml.bind-api和一个运行期实现例如org.glassfish.jaxb:jaxb-runtime。如果启动时报ClassNotFoundException: javax/xml/bind/JAXBException多半是版本不对或者依赖冲突顺着异常堆栈排除即可。写在最后我花了不少篇幅讲字体和格式兼容因为在真实项目里这类问题占掉了 80% 的调试时间。docx4j 本身的 API 很稳定代码就那么几行难的从来不是转换本身而是如何让转换结果贴近用户预期。根据我的个人经验做这个功能最稳妥的路线是先搞定中文字体再用真实业务文档跑回归样例每修一个兼容 bug 就把它固化进自动化测试防止后面改代码把老问题又带回来。这套方案我已经落地过不止一个项目运行稳定后你基本不需要去碰它它就像公司后台里一个沉默且可靠的小齿轮。如果后续需要在同一工程里扩展 PPT、XLS 转 PDFdocx4j 生态也有对应模块但换汤不换药核心的字体注入 格式适配 异步化三板斧依然通用。希望这篇文章能帮你把 DOCX 转 PDF 这条路走顺少踩几个我当年踩过的坑。