做后端这些年,被问得最多的需求之一就是 DOCX 转 PDF。上个月刚交付的一个合同管理系统中,甲方明确要求上传的合同附件必须能在网页端直接预览,同时还要做只读归档和电子签章的原件保存。一圈调研下来,很多方案要么重、要么贵、要么效果差——最终我用 docx4j 在 SpringBoot 工程里做了一套轻量级转换模块,核心代码不到百行就把问题解决了。这篇文章把整个落地过程、原理和踩过的坑都整理出来,正在做同类需求的朋友可以直接参考。
整件事的适用范围比想象中广:文档在线预览、合同归档、盖章文件签发、导出报表的落地动作,都会遇到“把 Word 变成 PDF”的需求。不是每种场景都要上重型文档服务,如果你只是要一个轻量、可维护、纯 Java 的实现,docx4j 这套组合值得认真看看。
1. 为什么我最终选了 docx4j
1.1 需求里真正的硬指标
先交代背景:当时要做的系统是某公司的内部合同管理系统,用户上传的 docx 五花八门。有的带复杂的跨页表格,有的封面是艺术字,有的内嵌多张高清图片和页眉页脚,还有一部分会走审批流之后生成电子签章文件。初期需求列得很泛,但我把它拆成了两个硬指标。
第一个硬指标:转换结果不能依赖任何外部软件或独立进程。这意味着 LibreOffice、WPS 这类需要安装 Office 环境的方案在第一轮就变得很重。因为系统要部署多个节点,如果每个节点都要先装一个 Office 软件、再维护一个常驻监听进程,镜像体积、内存占用、故障排查的成本都会直线上升。
第二个硬指标:转换接口要能扛住每年一两万份合同的量。这个量级单看不大,但合同文档往往有几 MB 到几十 MB,且转换是典型的 CPU 密集 + 内存密集操作。如果方案本身线程不安全、内存控制差,到月底集中归档时容易把服务打垮。
这两个硬指标直接影响方案选型的方向:必须是纯 Java、能嵌入现有应用、内存模型可控的库。决定走这条路之后,我把市面上主流方案都过了一遍。
1.2 四种方案横着比了一遍
当时对比过的方案大致有四类,我直接整理成了一个表格,方便你对照参考:
| 方案 | 原理 | 优点 | 缺点/风险 |
|---|---|---|---|
| Apache POI + iText | POI 解析 docx 结构,iText 生成 PDF | 纯 Java、生态成熟 | docx 排版信息分散在 XML、样式表和流文件中,POI 只保证结构读得对,不负责渲染还原。真要自己做段落、分页、字体、图片的坐标计算,工程量足以再开一条产品线 |
| LibreOffice 无头模式 + JODConverter | 起一个 LibreOffice 服务进程,用命令行或 API 驱动转换 | 转换质量好,表格、分页、字体还原度高 | 要安装外部软件、维护监听端口;容器化部署时镜像体积大,多实例并发时内存压力很直观 |
| 云文档转换接口 | 上传文件到云端,拿回 PDF 结果 | 效果好、实现快 | 要外网、要付费,而且合同文件属于敏感业务数据,很多企业内部系统直接不允许数据出内网 |
| docx4j | 纯 Java 库,直接读写 Office Open XML,自带 XSL-FO 到 PDF 的输出链 | 无外部进程、依赖简单、可嵌入任意 JVM 应用 | 复杂排版还原度不如 LibreOffice;性能受文档复杂度影响明显 |
这个对比结果其实没有完美答案。如果业务是“五花八门的重排版文档必须高保真还原”,那 LibreOffice 仍然是最强选择,没有之一。但我们的场景是合同扫描件、标准模板、内部流转文件,docx4j 的转换效果完全够用,而且它对部署和运维太友好了:在 SpringBoot 工程里引入几个 Maven 依赖,写一个工具类就能跑,不需要独立服务、不需要 OCR、不需要外网。对团队规模不大的项目来说,这种“低成本先跑起来、后续可平滑升级”的路线,比一上来就上重型方案务实得多。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 转换原理与依赖搭建
2.1 先搞清楚 docx 到底是什么
docx4j 这个名字容易让人误以为它是个“转换器”,但它本质上是一个处理 Office Open XML 的 Java 类库。要理解它的转换过程,先得明白 docx 文件的真实结构。
docx 表面上是一个单个文件,实际是一个 ZIP 压缩包。你可以直接把一个 docx 文件改成 zip 后缀解压开看看:里面会有一堆 XML、图片、字体资源,其中最核心的是 word/document.xml,存储了正文段落、表格、文字内容和格式引用。排版逻辑全部落在 XML 节点上,而不是像 PDF 那样直接记录每个字符在页面上的坐标。styles.xml 存样式定义,settings.xml 存页面设置和兼容选项,media 文件夹里是图片资源。
所以“docx 转 PDF”本质上做的是两件事:把 OpenXML 的结构读出来,再按某种布局算法把内容渲染到固定页面上。这件事难就难在“按某种布局算法渲染”这一步,它需要对字体度量、行距、分页规则、表格宽度计算、页眉页脚规则都有完整理解。这解释了为什么很多方案效果差别巨大。
2.2 docx4j 的转换链路
docx4j 处理 docx 的方式,可以拆成三步:
- 加载:
WordprocessingMLPackage.load()读取 docx 的 ZIP 结构,用 JAXB 把 XML 反序列化为一组 Java 对象模型,也就是各种*Part和*Content对象。 - 转换到中间格式:把 Java 对象模型还原成一套 XSL-FO 中间格式。XSL-FO 是专门用于排版输出的 XML 技术,对段落、页眉页脚、页边距、分页规则有完整表达能力。
- 渲染:把 XSL-FO 交给 Apache FOP 渲染引擎,生成最终的 PDF 文件。
这条链路也解释了 docx4j 的边界在哪。常规的合同、报告、论文,还原度都不错,因为 XSL-FO 对核心排版元素有完善的描述力。但遇到艺术字文本框、复杂嵌套表格、ActiveX 控件或者某些域代码,XSL-FO 里只能做近似表达,还原度就会打折扣。提前知道这个边界,验收阶段就不会被效果差异惊吓到。
从架构上看,docx4j 的“中间格式”设计还有一个隐藏优势:如果觉得 FOP 渲染不够好,可以替换渲染引擎,或者直接拿 XSL-FO 中间态做自定义处理,比如统一替换字体名、调整表格宽度、插入页脚水印。这些操作稳定且可复用。
2.3 SpringBoot 工程里加依赖
docx4j 从 11.x 开始做了模块拆分,和以前“一个 jar 包全搞定”不一样。核心 API 分布在 docx4j-core 与 docx4j-JAXB-ReferenceImpl 两个包里,Maven 依赖如下:
xml复制<dependency>
<groupId>org.docx4j</groupId>
<artifactId>docx4j-core</artifactId>
<version>11.4.9</version>
</dependency>
<dependency>
<groupId>org.docx4j</groupId>
<artifactId>docx4j-JAXB-ReferenceImpl</artifactId>
<version>11.4.9</version>
</dependency>
docx4j-core 提供加载 docx、操作内容模型、字体管理等能力;docx4j-JAXB-ReferenceImpl 是 JAXB 的实现适配,负责真正把 XML 反射成 Java 对象。如果你所在的团队用其他 JAXB 实现,也可以选 docx4j-JAXB-MOXy,不过大多数人用默认的 ReferenceImpl 就够了。
这里有个非常常见的坑必须提醒:如果你用的是 JDK 11 及以上版本,需要手动补上 JAXB 的运行时依赖。JDK 9 开始把 JAXB 从标准库移除了,不补的话运行时直接抛 ClassNotFoundException: javax.xml.bind.JAXBException,网上搜报错很容易搜到一堆互相矛盾的解法。我建议在一开始就在 pom 里加好:
xml复制<dependency>
<groupId>javax.xml.bind</groupId>
<artifactId>jaxb-api</artifactId>
<version>2.3.1</version>
</dependency>
<dependency>
<groupId>org.glassfish.jaxb</groupId>
<artifactId>jaxb-runtime</artifactId>
<version>2.3.5</version>
</dependency>
JDK 8 环境不需要这两行,但加了也不会有副作用。加上之后,无论本地开发还是生产环境,都不会被这个历史遗留问题缠住。
3. 核心代码与并发设计实操
3.1 两个核心类搞定转换
先展示一个最精简的转换工具类,这个类我在多个项目里复用过,核心逻辑完全够用:
java复制import org.docx4j.convert.out.viaXSLFO.Conversion;
import org.docx4j.fonts.Mapper;
import org.docx4j.fonts.PhysicalFont;
import org.docx4j.fonts.PhysicalFonts;
import org.docx4j.openpackaging.packages.WordprocessingMLPackage;
import org.docx4j.openpackaging.exceptions.Docx4JException;
import java.io.InputStream;
import java.io.OutputStream;
public class DocxConverter {
public static void convert(InputStream inputStream, OutputStream outputStream) throws Docx4JException {
WordprocessingMLPackage wordMLPackage = WordprocessingMLPackage.load(inputStream);
applyChineseFontMapping(wordMLPackage);
Conversion.toPdf(wordMLPackage, outputStream);
outputStream.flush();
}
private static void applyChineseFontMapping(WordprocessingMLPackage pkg) {
PhysicalFonts.discoverPhysicalFonts();
Mapper mapper = new Mapper();
mapper.put("宋体", PhysicalFonts.get("SimSun"));
mapper.put("黑体", PhysicalFonts.get("SimHei"));
mapper.put("仿宋", PhysicalFonts.get("FangSong"));
mapper.put("微软雅黑", PhysicalFonts.get("Microsoft YaHei"));
pkg.setFontMapper(mapper);
}
}
解释几个关键点。WordprocessingMLPackage.load() 可以接收 File、Path 或 InputStream。我偏好传 InputStream,这样调用方可以自己管理文件来源,比如从对象存储拉流,或在测试里直接丢一个字节数组。要留意的是这个方法会把整个 docx 的 XML 结构一次性解析进内存,所以对大文件来说,内存峰值会明显高于文件本身的体积。
applyChineseFontMapping 这步很多人第一次写会漏掉,漏掉的结果是本地 Windows 上转出来的 PDF 一切正常,部署到 Linux 服务器全部变成方框。原理在下一节展开,但方向你要记住:docx4j 转换时会把文档里的逻辑字体名映射到系统的物理字体,映射不到就回退默认字体;中文字体名和物理字体名的对应关系必须显式声明。
Conversion.toPdf(wordMLPackage, outputStream) 是同步 API,它内部会完成 XSL-FO 的生成和 FOP 的渲染。老版本的 docx4j 里这里的入口可能叫 PdfConversion 或通过工厂类调用,如果你是 11.x 系列,直接按上面写法没问题。转换过程中如果遇到无法解析的 XSL-FO 结构,docx4j 通常不会直接抛异常,而是打印 WARN 日志继续渲染,这就可能导致“转换成功了但内容缺了一块”的情况。所以日志里的 WARN 级别信息要认真看,特别是 FOP 输出的 layout warning。
3.2 提供同步接口的两个注意点
如果业务方只要求“传一个 docx,返回一个 PDF”,同步接口可以快速上线。用 SpringBoot 写起来很简单:
java复制@RestController
@RequestMapping("/api/docx")
public class DocxConvertController {
@PostMapping("/convert")
public ResponseEntity<byte[]> convert(@RequestParam("file") MultipartFile file) throws IOException, Docx4JException {
ByteArrayOutputStream baos = new ByteArrayOutputStream();
DocxConverter.convert(file.getInputStream(), baos);
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_PDF);
headers.setContentDispositionFormData("attachment", "converted.pdf");
return new ResponseEntity<>(baos.toByteArray(), headers, HttpStatus.OK);
}
}
但同步接口有两个问题必须提前考虑。第一个是内存问题:ResponseEntity<byte[]> 意味着整个 PDF 在内存里再复制一份,再加上转换过程中 WordprocessingMLPackage 占用的对象模型,一份 20MB 的 docx 转换时峰值内存可能轻松超过 500MB。低频内部系统可以忍,线上高并发场景不建议这么干。
第二个是超时问题:当文档复杂时,同步调用可能持续几十秒,前端如果只设了 30 秒超时,用户看到的是“请求失败”,但后端任务其实还在继续跑,线程资源被白白占着。所以我的建议很直接:对外提供的接口尽量做成异步,提交转换任务后立即返回任务 ID,前端轮询查询状态。
如果内部系统临时用一下同步接口,至少要把 3.3 的线程池隔离做好,并且在 Nginx 层把超时时间调大,否则各种超时错乱会让排查问题的人疯掉。
3.3 异步与线程池隔离设计
docx4j 的转换是 CPU 密集和内存密集并存的任务。它不像 I/O 密集型任务那样适合开大量线程,线程一多,CPU 上下文切换和 GC 压力会迅速抵消并发收益。我在 4 核 8G 的容器上反复测试过,转换线程数控制在 2~4 是最稳的区间。
线程池的推荐写法:
java复制ExecutorService docxPool = new ThreadPoolExecutor(
2,
4,
60L,
TimeUnit.SECONDS,
new LinkedBlockingQueue<>(50),
r -> new Thread(r, "docx-convert-"),
new ThreadPoolExecutor.AbortPolicy()
);
这里有几个设计细节:
- 核心线程数 2,最大线程数 4,队列容量 50。超过 50 个任务时直接拒绝,由调用方决定是重试还是丢弃。这样批量归档时不会把业务接口拖死。
AbortPolicy抛出的RejectedExecutionException可以在外层捕获,记录任务失败日志并通知人工处理。- 线程命名固定前缀
docx-convert-。生产环境排查问题时,用jstack或 Arthas 一眼就能找到转换线程,不用在几十个线程里猜哪个是哪个。
异步任务的简单设计是把提交记录存数据库,比如一张任务表,字段包含文件路径、状态、失败原因、重试次数。前端轮询任务表状态,拿到 SUCCESS 后再去下载 PDF。
容器启动参数也要配套调整。我建议至少给 JVM 预留 2GB 堆空间:
bash复制java -Xmx2g -XX:MaxMetaspaceSize=512m -jar your-app.jar
如果你们喜欢用容器环境变量配置,同理。这里踩过的坑后面详细说,但结论先给出来:不要用默认堆大小跑 docx4j,转几个大文件之后 Young GC 会把 CPU 打满,表现就是整体服务变慢,所有接口都遭殃。
4. 踩坑记录与效果验证
4.1 中文字体乱码,从根上解决
中文字体问题是我在生产环境遇到最多的问题,没有之一。症状很典型:本地开发转出来一切正常,部署到 Linux 服务器之后,PDF 里的中文全部变成方框或者消失。
原因分两层。第一层:服务器上没有中文字体。FOP 渲染时找不到对应的物理字体,只能回退到默认字体,默认字体没有中文字形,于是出现方框。第二层:docx 里的字体名和服务器上的物理字体名对不上。比如 docx 里写的是“宋体”,但 Linux 服务器上的字体名可能是 SimSun,docx4j 默认不自动做这种别名映射。
解决思路按优先级排列:
- 安装中文字体:在服务器上安装字体文件。Ubuntu/Debian 系可以直接
apt-get install -y fonts-wqy-microhei,这个文泉驿微米黑字体覆盖面不错。如果客户对字体样式有要求,就把宋体、黑体、微软雅黑等字体文件放到/usr/share/fonts/truetype/下,然后执行fc-cache -f -v刷新字体缓存。 - 代码里做字体映射:就像 3.1 的
applyChineseFontMapping那样,用PhysicalFonts.discoverPhysicalFonts()发现系统字体,再通过Mapper.put()把逻辑字体名映射到物理字体。建议把常见中文字体名都加进去,包括“等线”“楷体”“仿宋”“微软雅黑 Light”这种偏门写法。 - 兜底方案:直接改 document.xml。如果映射表和系统字体都解决不了某些特殊 docx,可以直接在加载后修改 XML 里的字体属性,把
w:rFonts的ascii和eastAsia属性值替换成系统存在的字体名。这个方法侵入性大,但效果直接。
我在实际项目里遇到过一个特别折磨人的情况:某个部门统一用 WPS 编辑合同,docx 里的中文字体名被写成“等线(正文)”,末尾还带括号,映射表写了“等线”也匹配不上。后来我干脆把字体映射做成了一张可配置的表,放在配置中心里,不同客户环境可以独立配置字体映射关系,才算把这个问题的余波彻底压住。
4.2 表格宽度、表头跨页问题
表格是 docx 转 PDF 里第二大痛点。常见的现象有两种:一是 docx 里 15cm 宽的表格,转换后 PDF 里直接超出页面边界;二是表格跨页时表头没有复制到下一页,读者根本不知道那一堆数字代表什么列。
背后原因和 XSL-FO 的表格布局计算逻辑有关。docx 指定了“固定布局 + 绝对列宽”时,docx4j 转换的兼容性往往不错;如果表格用的是“自动适应窗口”或者百分比宽度,XSL-FO 在计算宽度的时会按自己的逻辑调整,结果就可能变宽。
解决思路有三个层面:
- 在生成 docx 模板的阶段就固定表格列宽,不要使用“100% 宽度”或“自动调整”。这个要求要写进文档模板规范里,从源头规避。
- 对已有 docx,在转换前遍历表格,把表格布局类型设置为固定布局,并给每列设置具体的
GridCol宽度。docx4j 对象模型里可以操作TblLayoutType和TblGrid。 - 表格跨页重复表头,需要在 docx 里对表头行设置“在每页重复标题行”属性。docx4j 加载后可以用代码设置
<w:tblHeader/>,转换时 FOP 会识别这个属性并在下一页重复表头。
如果你们业务里大量使用复杂表格,我的建议是:先拿 10 份不同格式的真实 docx 做一轮转换测试,把问题表格挑出来单独处理,不要指望一个通用方案解决所有表格问题。经过几轮适配后,你的转换工具类会积累越来越多的针对性处理逻辑,这是正常现象。
4.3 内存溢出与任务堆积
OOM 问题通常藏在大文档转换里。我遇到过一封带几十页高清图片的标书,转 PDF 到一半直接 OutOfMemoryError: Java heap space。docx4j 的模型加载本身就很吃内存,FOP 渲染时还要在内存里维护完整的页面布局数据,峰值内存远大于文件本身体积,这是脱不开的。
对策有几条,都很实际:
- 堆内存要预留够。
-Xmx2g起步,如果大文档比例高,直接给到 4G。前提是容器物理内存充足,别把 JVM 堆调到超过容器内存限制,否则直接整机 OOM。 - 转换接口不要传
byte[]、不要返回byte[]。传InputStream或File,结果直接写到文件或ByteArrayOutputStream后立即落盘。不要让 docx 字节数组、对象模型、输出 PDF 字节数组同时驻留内存。 - 大文件要做前置限制。我们定义 50MB 上限,超过直接返回“文件过大”的错误提示,让用户知道这不是 bug,而是业务规则。
- 配合前文的线程池隔离,避免转换任务把业务线程池占满。生产环境里最怕的不是这一次转换慢,而是慢任务占满了线程,后续所有请求都被阻塞。
任务堆积的问题往往和超时设计耦合在一起。很多团队把线程池队列设得很大,结果高峰期积压了几千个待转换任务,用户看到的还是“排队中”。建议队列长度设置一个业务可接受的上限,比如 50,配合监控报警。队列满了之后直接失败,并通知调用方“请稍后重试”,比让用户无脑等待更负责。
4.4 自动化验证与性能数据
说到验证,很多项目在“能转出 PDF”之后就以为结束了,其实转换质量是要持续回归的。我自己的做法是三层验证:
第一层,文本内容比对。用 PDFBox 从转出来的 PDF 提取文本,和原始 docx 提取出的文本做关键词比对,确保内容没有丢。下面是一个最小验证代码:
java复制import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.text.PDFTextStripper;
public class PdfVerifier {
public static void verify(String pdfPath, String[] expectKeywords) throws IOException {
try (PDDocument document = PDDocument.load(new File(pdfPath))) {
PDFTextStripper stripper = new PDFTextStripper();
String text = stripper.getText(document);
for (String keyword : expectKeywords) {
if (!text.contains(keyword)) {
throw new RuntimeException("PDF 缺少关键内容: " + keyword);
}
}
}
}
}
第二层,页数和视觉检查。转换前后页数差异如果在 30% 以内算合理,超过这个范围基本可以断定有内容没渲染出来。视觉检查靠人工,每个模板类型抽首屏、中屏、尾屏扫一眼,重点看表格和图片。
第三层,回归测试。每改一次工具类,把之前积累的 10~20 个样例文档重新跑一遍,自动对比文本关键词,人工抽查视觉效果。这一层跑顺了,后面的维护就轻松很多。
性能数据给大家一个参考基线:一份 10 页、带表格和多张图片的合同,在 4 核 8G 容器上转换耗时约 2~4 秒;一份 60 页带插图的标书,耗时可以到 30 秒以上。瓶颈主要在 FOP 渲染阶段,和图片数量、复杂版面高度相关。如果你们单文档超过 50MB 或页数超过 100 页,建议先拆分文档或走人工归档流程。
这套方案我拿到生产环境用了四个多月,累计转换了两万多份 docx,整体表现非常稳定。再分享两个小细节:第一,服务器上的字体集要固定版本,运维变更字体后一定要跑一轮转换回归,我遇到过“昨天还好好的,今天突然乱码”的情况,最后发现是运维清理了旧字体包。第二,转出来的 PDF 如果只是给用户预览,可以顺手把 PDF 的元数据标题改成合同编号,用户下载下来文件名和标题都整齐,客户验收时印象分会拉高不少。
如果后续需求升级到“必须高保真还原复杂排版”,我会考虑把 LibreOffice 作为可切换的后端方案加进去,而不是推翻现有链路。已经跑顺的系统,即使方案有短板,先保证稳定,再按需演进,才是做技术选型最务实的姿势。
