Spring Boot项目中DOCX转PDF,我没选付费方案:docx4j轻量级落地实录
做后端开发的兄弟应该都有同感,文档格式转换这需求听起来简单,真正落地全是坑。尤其DOCX转PDF,Word文档本身的排版模型和PDF的渲染模型差异太大,想转得不变形、不乱码、页码还对得上,比想象中麻烦得多。最近我在一个Spring Boot项目中就遇到了这个需求,客户要求上传Word合同模板后自动生成PDF归档,文件规模不大,但格式要求很高。调研了一圈开源方案,最后选了docx4j,折腾了几天把坑基本都填平了。这篇文章就把整个调研过程、技术选型思考和实际踩坑记录都分享出来,给后面要做类似功能的人一个参考。
先交代一下背景。项目是Spring Boot 2.7 + JDK 8的技术栈,部署环境是CentOS 7,内网服务器,不能访问外网下载字体。需要转换的DOCX文件主要是合同、协议、公告模板,包含中文字体设置、表格、页眉页脚、页码域,偶尔带几张图片。输出要求是排版基本一致、中文不变成方块或乱码、文件大小合理。
这个需求范围基本就把路堵死了。第一个想到的肯定是LibreOffice Headless方案,毕竟很多人推荐,但服务器环境受限,安装LibreOffice还涉及系统依赖和字体库配置,运维那边很难配合。第二个是Aspose.Words这类商业库,但客户预算有限,而且文档授权模式对内网激活不友好。第三个是Apache POI直接解析再自己渲染成PDF,这个工作量太大,等于自己手写一个排版引擎,完全不现实。最后剩下的就是docx4j,开源的,纯Java,能解析DOCX的XML结构,然后用内置的PDF转换器配合iText渲染输出,理论上不需要外部依赖,正好符合项目约束条件。
标题里说的“轻量级开源方案”,指的就是docx4j。它本质上是一个操作Office Open XML文档的Java库,不仅支持DOCX转PDF,还支持DOCX创建、编辑、内容提取,以及DOCX转HTML等格式。它设计上把WordprocessingML文档结构映射成Java对象,转换PDF时用一套自己实现的布局引擎把文档内容流式排版到PDF页面。不过要特别说明一下,docx4j做PDF转换依赖Plutext的PDF renderer模块,而这个模块其实内部封装了iText 2.x版本,所以它的PDF输出能力很大程度上是iText在托底。
当然,绕过商业库选了开源,就意味着要做好接受某些限制的准备。docx4j对复杂的Word排版支持有限,比如某些文本框、艺术字、复杂目录、嵌套SmartArt这些,很可能会渲染出不是原样的效果。但做合同协议这种以段落、表格、页眉页脚为主的文档,它的表现还是可以的。所以在方案选型的时候,一定要根据自己项目的具体文档类型来做取舍,不能指望它能100%复刻所有Word文档。
说完了选型思考,进入实际开发环节。我先讲环境搭建和依赖引入,这部分看着简单,但版本搞错了能让你怀疑人生。
先看Maven依赖。docx4j目前主要维护两个大版本线,一个是JDK 8兼容的8.x系列,一个是基于JDK 11的11.x系列。另外还有一个更早的6.x系列在不少老项目中还在用,但官方基本已经不维护了。我们项目是JDK 8,所以直接选8.3.x。这里有个关键点,docx4j的PDF转换功能在8.x之后被拆到了单独的模块里,不手动引入的话,代码里调用PDF转换相关类会直接报NoClassDefFoundError。
Maven配置长这样:
xml复制<properties>
<docx4j.version>8.3.9</docx4j.version>
</properties>
<dependencies>
<dependency>
<groupId>org.docx4j</groupId>
<artifactId>docx4j-core</artifactId>
<version>${docx4j.version}</version>
</dependency>
<!-- PDF转换依赖,必须单独引入 -->
<dependency>
<groupId>org.docx4j</groupId>
<artifactId>docx4j-export-fo</artifactId>
<version>${docx4j.version}</version>
</dependency>
<!-- docx4j底层依赖的XML绑定库 -->
<dependency>
<groupId>org.docx4j</groupId>
<artifactId>docx4j-JAXB-ReferenceImpl</artifactId>
<version>${docx4j.version}</version>
</dependency>
</dependencies>
这里解释一下docx4j-export-fo这个模块做了什么。FO全称是Formatting Objects,也就是XSL-FO格式。docx4j的PDF转换链路是把DOCX的WordprocessingML内容转换成XSL-FO中间格式,再把XSL-FO交给内置的Apache FOP渲染引擎生成PDF。所以docx4j-export-fo模块实际上包含了FOP的相关依赖。这个链路也解释了为什么docx4j转PDF对复杂排版支持不好的原因,XSL-FO本身是面向分页媒体设计的格式,能力边界就在那里。
依赖配置完了,接下来写核心转换逻辑。网上不少教程直接甩一段两三行的代码,本地一跑能出文件,就觉得万事大吉。但真实项目里几乎不可能这么顺利,因为上线的服务器环境是CentOS,中文字体缺失、Linux字体机制跟Windows不一样,会带来一堆问题。先把最简单的转换代码写出来,再说怎么处理那些坑。
java复制import org.docx4j.Docx4J;
import org.docx4j.openpackaging.packages.WordprocessingMLPackage;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.InputStream;
public class DocxToPdfConverter {
public static void convertDocxToPdf(String docxPath, String pdfPath) throws Exception {
// 加载DOCX文件
InputStream docxInputStream = new FileInputStream(new File(docxPath));
WordprocessingMLPackage wordMLPackage = WordprocessingMLPackage.load(docxInputStream);
// 执行转换
FileOutputStream pdfOutputStream = new FileOutputStream(new File(pdfPath));
Docx4J.toPDF(wordMLPackage, pdfOutputStream);
pdfOutputStream.close();
docxInputStream.close();
}
}
这段代码在Windows上跑,如果DOCX文档比较简单,基本能直接出来一个像模像样的PDF。但把同一个文件丢到Linux服务器上跑,结果大概率惨不忍睹。我第一次在测试环境跑就遇到了,转换出来的PDF中文全是方块,英文正常。排查了半天,最终定位到问题根源:docx4j转换PDF时,需要通过Java的字体映射机制去寻找系统里匹配的字体,Windows上系统自带SimSun、Microsoft YaHei,而裸的CentOS上装的可能只有DejaVu系列字体,里面根本没有中文字形。FOP渲染时找不到中文字体,就只能吐一排空心方块或者干脆跳过。这跟docx4j本身关系不大,是运行环境的字体问题。解决思路有两个,一个是在系统里安装中文字体,另一个是给docx4j指定自定义的字体映射配置。
先看第一种解决方案,也是最直接、最推荐的方式:安装字体到系统中。服务端部署脚本里加一段字体安装命令:
bash复制# CentOS 7环境,安装中文字体
yum install -y fontconfig
mkdir -p /usr/share/fonts/chinese
# 上传字体文件,比如 simsun.ttc、msyh.ttf,也可以用思源黑体 SourceHanSansSC-Regular.otf
cp simsun.ttc msyh.ttf /usr/share/fonts/chinese/
# 更新字体缓存
fc-cache -fv
# 确认字体已经识别
fc-list :lang=zh
字体文件哪来?如果公司的合规要求允许,可以从Windows系统拷贝常见的宋体和新雅黑字体到服务器,但要注意版权问题。更稳妥的做法是直接用开源的思源黑体,这是Google和Adobe联合开发的开源字体,免费商用没问题,而且中文字形覆盖也很全。安装完字体之后还要注意一个问题,Java进程的字体缓存。JDK在首次使用字体时建立一个字体缓存文件,如果字体是在Java进程启动后才装进去的,一定要重启Java服务,否则新的字体可能不被识别。这一点很容易忽略,我曾经在部署脚本里安装了字体后直接跑转换任务,还是报方块字,排查到最后发现是服务进程没重启,缓存里还是旧的字体列表。
既然装了字体,docx4j按字体名称去系统里匹配即可。例如Word里用的是宋体SimSun,docx4j就会尝试找名字叫SimSun的字体,系统里装了simsun.ttc,它就能匹配上。问题来了,Word里设定的字体名,跟Linux系统里字体文件实际登记的名字不一定完全一致。比如Windows里的“微软雅黑”显示名称是Microsoft YaHei,Linux下安装的某个开源字体可能注册名是Noto Sans CJK SC。这种情况下就需要第二种方案:字体映射。docx4j提供了自定义字体解析器的接口,我们可以继承并改写字体逻辑。
java复制import org.docx4j.fonts.PhysicalFont;
import org.docx4j.fonts.PhysicalFonts;
import org.docx4j.fonts.Substituent;
import org.docx4j.fonts.FontMapper;
import java.util.HashMap;
import java.util.Map;
public class CustomFontMapper implements FontMapper {
private Map<String, PhysicalFont> fontMap = new HashMap<>();
public CustomFontMapper() {
// 预先建立映射关系:Word字体名 -> Linux系统可用字体
fontMap.put("宋体", PhysicalFonts.get("SimSun"));
fontMap.put("SimSun", PhysicalFonts.get("SimSun"));
fontMap.put("黑体", PhysicalFonts.get("SimHei"));
fontMap.put("SimHei", PhysicalFonts.get("SimHei"));
fontMap.put("微软雅黑", PhysicalFonts.get("Microsoft YaHei"));
fontMap.put("Microsoft YaHei", PhysicalFonts.get("Microsoft YaHei"));
fontMap.put("楷体", PhysicalFonts.get("KaiTi"));
fontMap.put("仿宋", PhysicalFonts.get("FangSong"));
}
@Override
public PhysicalFont getFontMapped(PhysicalFont physicalFont) {
return physicalFont;
}
@Override
public PhysicalFont fontForWordML(String fontName, String fontPitch, String fontFamily) {
PhysicalFont physicalFont = fontMap.get(fontName);
if (physicalFont == null) {
// 没匹配到的字体,用默认字体兜底
return PhysicalFonts.get("SimSun");
}
return physicalFont;
}
@Override
public boolean fontMapped(PhysicalFont physicalFont) {
return false;
}
@Override
public boolean fontMapped(String fontName, PhysicalFont physicalFont) {
return false;
}
@Override
public Map<String, PhysicalFont> getFontMapping() {
return fontMap;
}
}
使用的时候把它设置到WordprocessingMLPackage上:
java复制wordMLPackage.setFontMapper(new CustomFontMapper());
主要解决这个问题,后续转换的PDF才能看起来正常。但在实际排查中,我发现很多兄弟反映物理字体加载不出来,打印PhysicalFonts.getFontMap()的时候列表是空的。这是另一个原因,也就是PhysicalFonts这个类的初始化问题。docx4j在启动的时候扫描系统字体目录并注册字体,但扫描动作是一个静态初始化过程,需要显式调用才生效。在新版docx4j中,可以这样触发:
java复制// 触发字体扫描,必须在加载DOCX之前执行
PhysicalFonts.initialize();
或者更彻底一点,注册自定义的字体目录,尤其是字体没有装在默认路径,而是放在应用自己的resource目录下,这种方法就非常必要:
java复制PhysicalFonts.addPhysicalFonts("/app/fonts", null);
PhysicalFonts.initialize();
这个方法在docx4j的源码里要求传入的路径是一个文件而不是目录。我们在实测写法上需要注意。正确的写法是遍历目录下的每个字体文件,逐个注册:
java复制import org.docx4j.fonts.PhysicalFonts;
File fontDir = new File("/app/fonts");
File[] fontFiles = fontDir.listFiles();
if (fontFiles != null) {
// 逐个字体文件注册
PhysicalFonts.addPhysicalFonts(fontFiles[0].getAbsolutePath(), null);
}
PhysicalFonts.initialize();
这段代码的意思比较晦涩,我解释一下。PhysicalFonts.addPhysicalFonts接收两个参数,第一个是字体文件路径,第二个是可选的自定义字体名。反复调用注册文件后,再调initialize刷新字体注册表。所以可以把一个目录下所有ttf文件循环注册进去。注册完成后,可以从物理字体注册表里找出对应字体的实例:
java复制PhysicalFont simSunFont = PhysicalFonts.get("SimSun");
这里要注意,PhysicalFonts的key通常是字体文件里的内部字体名,不一定是文件名。比如simsun.ttc这个文件名,内部实际注册的key可能是SimSun,也可能因为ttc集合里有多个字体而注册为SimSun、NSimSun等。保险的做法是注册完后遍历PhysicalFonts.getFontMap()把key打印出来看看,再决定映射关系怎么写。
字体问题搞定了,还有一类问题也经常出现:表格和图片的显示异常。前面说过docx4j转换链路是DOCX到XSL-FO再到PDF,这个链路上有几个点会让你摔得莫名其妙。
表格单元格高度异常这个比较常见。DOCX的表格在Word里显示正常,条高固定,但转PDF后有一些行的文字被截断、显示不全。排查下来是表格行的Exact高度设置和字体渲染高度冲突。XSL-FO里行高如果设为固定值,而实际渲染的字体超过行高,内容就会被裁切。解决方案是在生成的XSL-FO里把行高从Exact改成AtLeast。但docx4j封装得太深,直接改底层FO不现实,最简单的办法是修改Word文档模板,把表格行高由固定值改为最小值,然后在docx4j里设置兼容模式:
java复制// 设置兼容选项,避免行高溢出
Docx4J.toFO(wordMLPackage, pdfOutputStream, Docx4J.FLAG_EXPORT_PREFER_XSL);
这个FLAG_EXPORT_PREFER_XSL调整的是FO生成引擎的选择。docx4j内部存在两套PDF生成路径,默认路径用Plutext的FO生成器,另一套是较早时期的XSLT转换。两套引擎处理某些文档细节时行为不一致。遇到表格行高的问题时,可以切换到另一套引擎看看效果差异。我实际测试下来,FLAG_EXPORT_PREFER_XSL在部分复杂表格上反而表现更好,但缺点是对某些特殊段落样式支持也不好。建议遇到问题时,两套路径都试一下,看哪个输出结果更接近源文档。没有绝对更好的引擎,只有更适合当前文档的配置。
第二类问题是页码在页脚位置显示不对。DOCX的页脚一般是一个包含PAGE域的复杂结构。docx4j对Word域的支持有限,尤其PAGE域在XSL-FO转换时,如果不做处理,可能会丢。解决方式是设置FOP的页码处理能力,docx4j本身在FO生成时会把PAGE域转成XSL-FO的page-number标记。如果丢了,检查Word模板中的页码是怎么插入的。有些Windows上特殊操作生成的域代码不是标准的PAGE,而是带复杂前缀的PAGE字段。这时候建议直接改造模板,把页脚的页码域清理掉,重新用Word的“插入页码”功能插入一遍,确保字段是标准的WordprocessingML格式。
第三类问题是图片显示不出。DOCX里的图片默认存放在word/media目录下,docx4j正常情况下能解析并随文档转换输出。如果图片是WMF或EMF格式,那FOP默认不支持这类矢量图渲染,输出时图片位置是空的。解决办法:模板准备阶段尽量用PNG或JPEG格式插入图片,不要直接粘贴WMF格式的剪贴板图片。如果是已经存在的历史文档,可以写个前置处理程序,把DOCX里的EMF图片替换成PNG。
java复制// 伪代码:遍历wordMLPackage的图片关系,检测到EMF后替换为PNG
List<Object> images = wordMLPackage.getMainDocumentPart().getContent();
// 遍历找出包含EMF引用的Drawing对象
// 用Java ImageIO或第三方库把EMF渲染为PNG
// 替换关系中的二进制数据
这个替换逻辑处理起来比较繁琐,涉及关系ID、ContentType校验、二进制替换等,实际项目中如果遇到,建议还是从源头控制模板格式。
处理完转换链路本身的问题,接下来要关注的是文件体积和性能表现。DOCX转PDF,输入文件往往不大,但输出的PDF可能因为字体嵌入而变得非常大。docx4j默认情况下的FO转换不会自动嵌入字体到PDF,因为用的是基础14种字体加系统映射,所以PDF体积膨胀的根源往往是文档中嵌入了大量高清原图或字体文件。但有一种情况值得注意:如果模板里包含了大量重复使用的相同图片,docx4j在FO阶段可能会对同一张图片多次引用而重复嵌入,导致PDF体积成倍增加。解决办法是检查Word文档中图片是否被重复粘贴了多份,如果是的话,尽量在模板层面先清理成单次引用。
性能方面,在服务器配置2核4G的环境下实测,一个十几页的纯文字合同用docx4j转换耗时基本在1到3秒,如果是带大量表格和图片的文档,耗时可能到5秒以上。由于转换任务是CPU密集型的,并发量上来之后,FOP引擎的内存占用会快速上升。线上出现过并发转换20个文档时JVM直接OOM的情况,后来加了线程池做限流,核心线程数设置为CPU核数的一半,配合有界队列,问题就解决了。这里也提供一个线程池配置的例子:
java复制import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.ThreadFactory;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
public class DocxConverterThreadPool {
private static final AtomicInteger THREAD_INDEX = new AtomicInteger(0);
private static ThreadPoolExecutor executor = new ThreadPoolExecutor(
2, // 核心线程数
4, // 最大线程数
5L, // 空闲线程存活时间
TimeUnit.SECONDS,
new ArrayBlockingQueue<>(50), // 有界队列
new ThreadFactory() {
@Override
public Thread newThread(Runnable runnable) {
Thread thread = new Thread(runnable);
thread.setName("docx-converter-" + THREAD_INDEX.getAndIncrement());
thread.setDaemon(true);
return thread;
}
},
new ThreadPoolExecutor.CallerRunsPolicy()
);
public static ThreadPoolExecutor getExecutor() {
return executor;
}
}
CallerRunsPolicy是拒绝策略里比较适合这种场景的,队列满了的时候由调用者线程来执行当前任务,相当于自然背压,不会把请求直接打抛。
另外要提醒一点,docx4j在转换过程中会生成大量的中间对象,尤其是WordprocessingMLPackage加载时会把全文档构建成内存对象树。一个50MB的高清图片Word文档,加载阶段可能直接消耗300MB到500MB堆内存。所以接口被调用时要做文件大小校验,建议限制上传文件不超过20MB,超过就走人工处理流程或者加内存再优化。
我把上面这些坑和经验汇总成一个相对完整可复用的Service类。这个类里集成了字体注册、字体映射、转换参数设置和线程池调用,你拿到项目里基本改一改就能直接用:
java复制import org.docx4j.Docx4J;
import org.docx4j.fonts.PhysicalFonts;
import org.docx4j.openpackaging.packages.WordprocessingMLPackage;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.InputStream;
import java.util.concurrent.Future;
public class DocxConversionService {
// 静态块里完成全局字体初始化
static {
try {
File fontDir = new File("/app/fonts");
File[] fontFiles = fontDir.listFiles();
if (fontFiles != null) {
for (File fontFile : fontFiles) {
PhysicalFonts.addPhysicalFonts(fontFile.getAbsolutePath(), null);
}
}
PhysicalFonts.initialize();
} catch (Exception e) {
// 字体加载失败不应该阻断后续逻辑,但要做好日志
System.err.println("字体初始化失败: " + e.getMessage());
}
}
public static Future<File> convertAsync(String docxPath, String pdfPath) {
return DocxConverterThreadPool.getExecutor().submit(() -> {
convertSync(docxPath, pdfPath);
return new File(pdfPath);
});
}
public static void convertSync(String docxPath, String pdfPath) throws Exception {
long startTime = System.currentTimeMillis();
File docxFile = new File(docxPath);
if (!docxFile.exists()) {
throw new IllegalArgumentException("DOCX文件不存在: " + docxPath);
}
File parentDir = new File(pdfPath).getParentFile();
if (parentDir != null && !parentDir.exists()) {
parentDir.mkdirs();
}
try (InputStream docxInputStream = new FileInputStream(docxFile);
FileOutputStream pdfOutputStream = new FileOutputStream(new File(pdfPath))) {
// 加载文档
WordprocessingMLPackage wordMLPackage = WordprocessingMLPackage.load(docxInputStream);
// 字体映射,关键必须设置
wordMLPackage.setFontMapper(new CustomFontMapper());
// 执行转换
Docx4J.toPDF(wordMLPackage, pdfOutputStream);
}
long costTime = System.currentTimeMillis() - startTime;
System.out.println("DOCX转PDF完成,耗时: " + costTime + " ms");
}
}
到这里,主体转换已经实现了。但做项目不是把功能跑通就完了,后端的健壮性和异常处理也很重要。线上跑了几天,收集到的实际反馈里,有几个代表性的问题值得拿出来单独做一份速查整理。
一个一个来看。
问题一:抛异常Caused by: java.lang.NoClassDefFoundError: org/plutext/jaxb/util/ContextUtil。
这个比较典型,但根因跟代码没关系。前面说过docx4j-JAXB-ReferenceImpl这个依赖是必需的。它没被引入,很可能是在打包的时候被maven排除掉了。也有一种情况是项目里同时依赖了其他JAXB实现,比如JAXB-RI和EclipseLink MOXy,classpath里面类冲突,docx4j找不到自己需要的实现类。处理方式:确认依赖确实引入了,然后检查整个依赖树里是否有多套JAXB实现,把冲突项排除。执行mvn dependency:tree可以很快看清依赖关系。
问题二:转换结果中中文变成了方框或者问号。
这个就是字体问题。优先级从上到下排查:第一,确认服务器上装了中文字体,fc-list :lang=zh能看到输出。第二,确认Java进程重启过,JDK字体缓存已经刷新。第三,确认自定义字体映射里的字体名、文档中的字体名、系统字体注册名三者能对上。可以在代码里临时打印PhysicalFonts.getFontMap().keySet()来比对实际注册字体名。这套流程走完基本能解决90%以上的方块字问题。
问题三:转换得到的PDF存在乱码符,集中在特殊符号里。
这种情况不像方块字那么有规律,比如项目符号是黑色的实心圆点,转换后变成不认识的符号,或变成乱七八糟的一团。主要是因为Word文档里使用了Wingdings或Symbol这类特殊字体。Word自身能正常渲染是因为系统里内置了这些符号字体。Linux上没装,所以FOP拿不到对应字形。解决方式有两个,要么安装wingdings.ttf等字体到服务器,要么把Word模板中的符号替换成普通字体,例如直接在文档里插入Unicode字符而不是引用Wingdings字体。对模板这个源头做改造是最省心的。
问题四:有时候转换任务进程还在跑但接口已经超时返回了。
如果是Tomcat部署模式下,线程池里的转换线程是daemon线程,理论上不影响正常退出,但CPU占用高会让接口整体响应变慢。建议在Controller层调用转换服务时,预估文档大小,超过10页或者超过10MB的文档直接走异步任务模式,先把任务ID返回给前端,处理完回调通知。另外,如果转换线程活太多,文档转换结束后JVM迟迟不回收内存,可以考虑在方法最后显式调用wordMLPackage = null并触发一次System.gc(),但这个方法不推荐频繁使用,适合在线程池场景中配合有界队列做兜底,核心思路还是要把并发数限住。
问题五:同一个DOCX在Windows转换正常,Linux上转出来偏移像素。
这类问题基本无解。docx4j依赖的字体渲染环境不同,字体在不同操作系统上的字间距、行高等度量信息有差异,Windows上的SimSun和Linux里注册的SimSun可能是不同版本的同一个字体,度量差异导致排版偏离。比较好的规避方式是在服务端复用一致的产品字体版本,同时转换后做一轮基础的像素对比测试。在自己的代码里固定,不允许运营在Windows和Linux两边混用不同的字体文件,能省去很多麻烦。
说到这里,docx4j方案的局限也要摆到台面上来讲。它跟Aspose这类商业库的差距主要集中在三个方面。一是复杂排版还原度不够。Word中复杂的多级列表缩进、分栏、文本框叠加、艺术字效果,在docx4j转换时大概率会失真。二是页眉页脚的全面支持有限。docx4j能转换基础页眉页脚,但如果页眉引用了文档属性域、插入了一堆嵌套表格,渲染结果就会出问题。三是性能跟商用库比没有优势。Aspose有自己的优化渲染器,在大文件场景下比docx4j快很多,内存占用也更低。But回到标题中的“轻量级开源方案”,docx4j的意义就在于它让你在不想支付版权费、不能安装大型外部软件、又想保持纯Java栈部署的条件下,得到一条还过得去的转换路径。如果你的需求场景是合同、公告、正式公文这类结构化程度高、复杂视觉效果少的DOCX,那docx4j是非常合适的。
还有一个方向值得再补充一点,就是docx4j不只是做转换。它对DOCX的读取和修改能力其实很强。我在实现转换之前,还额外做了一个功能,利用docx4j的DocumentBuilder在合同模板的占位符位置插入内容,比如合同编号、甲方名称、签署日期,然后再转换成PDF。这相当于把合同生成和格式转换打通了,整个业务流的效率比原来高很多。如果你们项目是动态内容填充再导出PDF,docx4j这套链路能覆盖到完整需求。有兴趣的可以关注一下Docx4J和WordprocessingMLPackage的文本替换API,不需要正则去解析XML那么麻烦。
最后再分享一个我在部署交付中总结的经验:在正式切生产之前,一定要建立一份包含各种典型模板的回归测试文档集。Docx转PDF这种功能,模板一变可能就翻车,不能只看一个文件转换成功就当完成。我自己的做法是收集了约30份不同结构、不同来源的Word文档放在测试目录下,每份文档转完后人工检查一次PDF页面,把有问题的文件特征记录下来。后续只要版本升级或者文档中心改模板,第一件事就是跑一遍全量回归。这比任何代码保护都让人安心。
这个项目做到后面,我实际的最大收获反而是对DOCX格式的理解:它本质上是一个Zip包,内部是一堆XML,跟PDF的画布模型完全是两回事。开源的转换方案没有银弹,知道自己的文档结构在哪个范围内是可控的,比无脑迷信某个工具要重要得多。希望这篇实践记录能帮到正在为DOCX转PDF发愁的同行,少走一点弯路。
