做后端开发的,迟早会碰到“把Word转成PDF”这种需求。不管是合同归档、电子签章、报表导出,还是给客户生成正式的订单确认函,总有一个场景需要你把docx变成pdf。这事听起来简单:“不就是另存为一下吗?”——在Windows的Office里确实是点两下鼠标的事,可一旦放到Java服务端,放到Linux服务器上,问题就来了:要么乱码,要么排版错位,要么干脆没反应。
我最初是用Apache POI硬读Word内容,然后调用外部进程转PDF,后来在项目中换了docx4j,写了一整套转换服务,才彻底把这件事捋顺。这篇文章就把我踩过的坑、最终落地的方案、以及中文字体配置的完整思路都整理出来。无论你是被“java word转pdf”逼疯的Java开发,还是刚接手文档转换模块的新手,照着做基本能搞定。
1. 为什么是docx4j:Word转PDF的方案选型对比
刚开始调研Word转PDF的时候,我能找到的路线五花八门,有直接调系统的,有引入第三方库的,还有用开源软件做桥接的。选型这件事不能拍脑袋,我花了一个下午把所有主流方案都跑了一遍,下面直接放出对比结果。
1.1 市面主流方案盘点
先说说我用过的几种路线,以及它们各自的实际表现。
路线一:Apache POI + 手动渲染
这个方案的思路是用POI解析docx文件内容,然后自己拼PDF。看起来灵活,实际上坑深到看不到底:Word的排版模型极其复杂,页眉页脚、表格样式、文本框、分页符、字体设置,每一项都需要自己解析再映射到PDF渲染,工作量相当于重新造一个转换引擎的轮子。而且docx里的内容远不止文本,图片、公式、合并单元格、嵌套表格,这些只要有一个没处理好,输出PDF就废了。
路线二:调用LibreOffice/OpenOffice命令行
这个方案在Linux服务器上很流行,本质是利用LibreOffice的headless模式做格式转换。命令大致是:
soffice --headless --convert-to pdf --outdir /output /input.docx优点是转换效果接近真实Office渲染,缺点也明显:服务器上要装一套完整的LibreOffice,内存开销大,启动慢,并发转换时容易撑爆资源。而且它的字体依赖系统字体库,字体配置不到位照样乱码。
路线三:Aspose.Words等商业库
转换效果确实好,但License价格不菲,尤其是高并发场景下的授权费用,小团队很难接受。
路线四:docx4j
docx4j是一个纯Java的Word文档处理库,它不仅能读写docx、解析内容,还自带了一套基于XSL FO的PDF转换引擎——把Word文档先转成FO格式,再交给Apache FOP渲染成PDF。这样整条链路都在JVM里完成,部署简单,没有外部进程依赖,也不存在跨平台编译问题。
1.2 我的选择理由
我最后选了docx4j,主要看中这几点:
- 纯Java实现,不依赖外部软件,Docker镜像里不用装LibreOffice,运维省心。
- 对docx的OOXML结构支持得比较完整,能保留大部分Word排版细节。
- 转换过程中可以直接操作底层XML节点,这意味着我可以对文档内容做预处——比如批量替换字体,这是后面解决乱码问题的关键能力。
- 项目体积可控,依赖也不算复杂。
当然docx4j也有局限,后文会展开说,比如复杂排版还原度不如LibreOffice,但这些都可以通过代码层面的技巧补回来。
2. 环境准备与依赖引入
选型确定之后,先把基础环境搭好。这一步很多人会跳过,直接去写转换代码,结果一跑就报NoClassDefFoundError,到处找jar包,反而更浪费时间。
2.1 基础环境与Maven依赖
我的开发环境是JDK 1.8,Spring Boot 2.7,docx4j用的是8.3.10版本。需要说明的是,docx4j从8.3.3开始把PDF导出功能拆分到了额外的模块里,如果你只引入docx4j核心包,转换PDF时肯定会找不到类。正确的引入方式如下:
<dependency> <groupId>org.docx4j</groupId> <artifactId>docx4j-core</artifactId> <version>8.3.10</version> </dependency> <dependency> <groupId>org.docx4j</groupId> <artifactId>docx4j-export-filters</artifactId> <version>8.3.10</version> </dependency> <dependency> <groupId>org.docx4j</groupId> <artifactId>docx4j-elements</artifactId> <version>8.3.10</version> </dependency> <dependency> <groupId>org.docx4j</groupId> <artifactId>docx4j-docx4j-export-filters</artifactId> <version>8.3.10</version> </dependency>有一个细节需要注意:docx4j-export-filters依赖了Apache FOP,而FOP又依赖了xmlgraphics-commons。如果项目里其他模块已经引入了不同版本的xmlgraphics,可能会出现类冲突。我的做法是显式指定FOP版本:
<dependency> <groupId>org.apache.xmlgraphics</groupId> <artifactId>xmlgraphics-commons</artifactId> <version>2.9</version> </dependency>2.2 Linux服务器中文字体安装
这一步常被忽略,但它是解决乱码的重中之重。docx4j在把docx转换成FO再转PDF的过程中,最终PDF的文字渲染依赖于系统字体库。如果Linux服务器上压根没有中文字体文件,无论你怎么设置字体名,渲染出来的都是方块。
以CentOS为例,安装基础中文字体的命令是:
yum install -y fontconfig yum install -y wqy-microhei wqy-zenheiUbuntu/Debian系统则用:
apt-get install -y fonts-wqy-microhei fonts-wqy-zenhei fonts-noto-cjk装完以后,用fc-list :lang=zh命令验证:
fc-list :lang=zh /usr/share/fonts/wqy-microhei/wqy-microhei.ttc: 文泉驿微米黑:style=Regular这个命令输出的字体名很关键,后面配置字体映射时会用到。我建议至少装一套“文泉驿微米黑”和一套“Noto Sans CJK”,前者兼容性好,后者字形更完整,能覆盖生僻字。
3. 核心转换流程:从docx到PDF的完整实现
环境准备好之后,开始写核心转换代码。我先给出一版最基础的实现,然后再逐步加入中文字体配置。
3.1 基础代码:加载Word文档并输出PDF
新建一个转换工具类,先用最简单的代码打通链路:
import org.docx4j.openpackaging.packages.WordprocessingMLPackage; import org.docx4j.convert.out.pdf.PdfConversion; import org.docx4j.convert.out.pdf.viaXSLFO.Conversion; import org.docx4j.openpackaging.exceptions.Docx4JException; import java.io.File; import java.io.FileOutputStream; import java.io.OutputStream; public class DocxToPdfConverter { public static void convert(String docxPath, String pdfPath) throws Docx4JException { // 1. 加载Word文档 WordprocessingMLPackage wordMLPackage = WordprocessingMLPackage.load(new File(docxPath)); // 2. 创建PDF转换器 PdfConversion converter = new Conversion(wordMLPackage); // 3. 输出到文件 try (OutputStream os = new FileOutputStream(pdfPath)) { converter.output(os); } catch (Exception e) { throw new Docx4JException("PDF输出失败", e); } } }这段代码在Windows本地上跑,没有任何中文字体问题的docx时,通常能正常出PDF。但一旦拿到Linux服务器上,或者遇到包含中文的文档,就会原形毕露。我先讲这条主链路,乱码问题的详细解决方案放在第4节。
3.2 代码逐段拆解:转换流程中的关键节点
上面这段代码虽然短,但每一步都有值得展开说的地方。
第一步,加载Word文档。WordprocessingMLPackage.load()做的不只是读文件,它会把docx文件解压,解析出document.xml、styles.xml、fontTable.xml等所有部件,在内存中构建出一个完整的对象树。这个对象树就是后面所有操作的入口:修改字体、处理图片、调整页边距,全都在这棵树上展开。
你可以把这个过程想象成把一本书扫描成电子文档,每一页的文字、样式、图片都被抽取出来,变成结构化的数据,之后你对这些数据做任何修改,再重新“排版印刷成PDF”。
第二步,创建PDF转换器。这里选的是viaXSLFO包下的Conversion实现,它的工作方式是把WordprocessingMLPackage里的内容转换成XSL FO文档。XSL FO是一种专门描述页面布局的XML语言,FOP引擎拿到FO以后,才能排版输出PDF。这个中间步骤虽然多了一层,但也给了我们一个可操作的空间——FO文档生成后、渲染前,可以插入各种自定义设置和字体配置。
第三步,输出PDF。converter.output(os)接受一个OutputStream参数,这说明docx4j支持输出到任何流式目的地——文件、内存、HTTP响应都行。在Web项目中,我通常直接输出到HttpServletResponse的输出流,省去临时文件操作。
我的经验是,第一步和第三步的代码几乎不用变,真正需要花心思的,是第二步之前和第三步之间的那一大段“内容预处理”逻辑。这些逻辑我直接封装成工具方法,后面详细演示。
4. 中文字体配置的完整方案
重头戏来了。这一节从乱码产生的根本原因讲起,然后给出三种实用的配置方案,按需选用即可。
4.1 乱码的根因:字体找不到,而不是字符不支持
很多开发第一次遇到“Word转PDF后中文全部变成方块”的问题,会下意识以为是编码问题,转码试了一通,毫无效果。实际上,Unicode编码层面的转换完全没有问题,问题出在“字体映射”环节。
docx文件里的文字本身以Unicode编码保存,无论转为PDF还是其他格式,字符数据不会丢失。最终渲染PDF时,FOP引擎需要根据文档里指定的字体名(比如“宋体”“微软雅黑”),去系统字体库里找一个能显示这些字符的字体文件。如果系统里没有“宋体”,FOP也不会自动用其他中文字体顶替,而是直接渲染成“.notdef”缺字字符,也就是我们看到的方块。
可以这么理解:文字是听话的演员,字体是演出的服装。演员(文字)一直都在,但服装间(字体库)里没有对应的衣服,最后只能光着上场(渲染成方块)。
所以解决乱码的核心就一句话:让文档里出现的每个中文字体名,都能在服务器上找到对应的物理字体文件。两条路,要么改文档,把字体名替换成服务器上存在的字体;要么改服务器,把文档需要的字体装上去。实际操作中,两条路往往要同时走。
4.2 方案一:批量替换文档内字体
这个方案是处理乱码最有效、最可控的。思路是遍历docx文档对象树里所有的Run属性(RPr),把字体信息统一替换成服务器上已安装的中文字体名。
先看完整代码:
import org.docx4j.openpackaging.packages.WordprocessingMLPackage; import org.docx4j.wml.RFonts; import org.docx4j.wml.RPr; import org.docx4j.wml.Text; import org.docx4j.TraversalUtil; import org.docx4j.wml.WmlObjectFactory; import org.docx4j.XmlUtils; import java.util.List; import java.util.Map; import java.util.HashMap; public class FontReplacer { // 字体映射表:文档常用中文字体 -> 服务器物理字体 private static final Map<String, String> FONT_MAPPING = new HashMap<>(); static { FONT_MAPPING.put("宋体", "SimSun"); FONT_MAPPING.put("SimSun", "SimSun"); FONT_MAPPING.put("新宋体", "SimSun"); FONT_MAPPING.put("NSimSun", "SimSun"); FONT_MAPPING.put("黑体", "SimHei"); FONT_MAPPING.put("SimHei", "SimHei"); FONT_MAPPING.put("微软雅黑", "Microsoft YaHei"); FONT_MAPPING.put("Microsoft YaHei", "Microsoft YaHei"); FONT_MAPPING.put("Microsoft YaHei UI", "Microsoft YaHei"); FONT_MAPPING.put("楷体", "KaiTi"); FONT_MAPPING.put("KaiTi", "KaiTi"); FONT_MAPPING.put("仿宋", "FangSong"); FONT_MAPPING.put("FangSong", "FangSong"); FONT_MAPPING.put("等线", "DengXian"); FONT_MAPPING.put("DengXian", "DengXian"); FONT_MAPPING.put("华文琥珀", "STHupo"); FONT_MAPPING.put("华文行楷", "STXingkai"); FONT_MAPPING.put("华文新魏", "STXinwei"); } public static void replaceFonts(WordprocessingMLPackage wordMLPackage) { // 遍历文档主内容中的所有文本元素 TraversalUtil.visit(wordMLPackage, new TraversalUtil.Callback() { @Override public List<Object> apply(Object element) { if (element instanceof Text) { handleText((Text) element); } return null; } @Override public boolean shouldTraverse(Object element) { return true; } }); } private static void handleText(Text text) { try { Object parent = text.getParent(); if (parent instanceof RPr) { // 特殊情况:文本直接挂在RPr下的场景 RPr rpr = (RPr) parent; replaceRprFonts(rpr); } else if (parent instanceof org.docx4j.wml.R) { org.docx4j.wml.R run = (org.docx4j.wml.R) parent; RPr rpr = run.getRPr(); if (rpr == null) { rpr = new WmlObjectFactory().createRPr(); run.setRPr(rpr); } replaceRprFonts(rpr); } } catch (Exception e) { // 单个文本节点处理失败不阻塞整体转换 e.printStackTrace(); } } private static void replaceRprFonts(RPr rpr) { RFonts rFonts = rpr.getRFonts(); if (rFonts == null) { rFonts = new WmlObjectFactory().createRFonts(); rpr.setRFonts(rFonts); } String ascii = rFonts.getAscii(); String eastAsia = rFonts.getEastAsia(); String hAnsi = rFonts.getHAnsi(); String cs = rFonts.getCs(); if (eastAsia != null && FONT_MAPPING.containsKey(eastAsia)) { rFonts.setEastAsia(FONT_MAPPING.get(eastAsia)); } if (ascii != null && FONT_MAPPING.containsKey(ascii)) { rFonts.setAscii(FONT_MAPPING.get(ascii)); } if (hAnsi != null && FONT_MAPPING.containsKey(hAnsi)) { rFonts.setHAnsi(FONT_MAPPING.get(hAnsi)); } if (cs != null && FONT_MAPPING.containsKey(cs)) { rFonts.setCs(FONT_MAPPING.get(cs)); } } }重点解释几个细节。
为什么用TraversalUtil.Callback?docx的文档结构是嵌套的:Body里包含Paragraph,Paragraph里包含Run,Run里才有RPr和Text。如果自己写递归,很容易漏掉页眉页脚、表格单元格里的文本。docx4j的TraversalUtil会自动遍历这些嵌套结构,无论文本藏在表格里还是页眉里,都能被检索到。shouldTraverse返回true表示遍历所有节点。
RFonts的几个属性有什么区别?ascii控制普通拉丁字符的字体,eastAsia控制中文字符的字体,hAnsi控制高ASCII码范围字符(如一些特殊符号),cs控制复杂文种字体。在中文场景下,最核心的是eastAsia,但为了稳妥,四个字段我统统替换。有些docx生成工具只写ascii不写eastAsia,如果你只处理eastAsia,就会出现“英文正常、中文乱码”的诡异现象。
为什么要建映射表而不是直接替换成一种字体?因为Word文档里可能混用了宋体和黑体,如果全部替换成一种,标题和正文的视觉区分就没有了。映射表按语义映射,宋体映射到服务器上最接近的宋体字形,黑体映射到黑体字形。如果服务器上装了Noto系列,也可以把SimSun、SimHei都映射到Noto Serif CJK SC或Noto Sans CJK SC,它们在Linux上渲染效果更平滑。
4.3 方案二:通过PhysicalFonts注册系统字体
方案一解决的是“文档里指定的字体名不存在”的问题。但如果服务器上连对应的物理字体文件都没有,方案一再怎么替换都白搭。所以方案二是必不可少的底座:把系统字体库里的字体文件注册给docx4j。
docx4j有一个PhysicalFonts类,专门负责物理字体管理。在调用转换之前,先执行字体发现:
import org.docx4j.fonts.PhysicalFonts; // 扫描系统字体目录,注册所有可用物理字体 PhysicalFonts.discoverPhysicalFonts(); // 获取已注册的字体名称列表 System.out.println(PhysicalFonts.getPhysicalFonts().keySet());discoverPhysicalFonts()会扫描系统的Fontconfig配置,把所有安装的字体注册到docx4j内部。在Linux上,它读取的是/etc/fonts/fonts.conf和~/.fonts目录。
如果有些字体放在非标准目录下,还可以手动注册:
import org.docx4j.fonts.PhysicalFont; PhysicalFont font = PhysicalFonts.getPhysicalFonts().get("SimSun"); if (font == null) { // 手动指定字体文件路径 font = PhysicalFontUtils.loadPhysicalFont("/usr/share/fonts/simsun/simsun.ttc", "SimSun"); PhysicalFonts.put("SimSun", font); }注意,simsun.ttc是TrueType字体集合文件,同时包含多个字型,docx4j加载时可能会只识别第一套字型。如果出现识别不出来的情况,可以用fontTools一类的工具把ttc拆成单独的ttf再注册。
4.4 方案三:FOP字体配置文件
docx4j的PDF转换底层走的是XSL FO → FOP → PDF。FOP支持通过配置文件显式声明字体,这个方案在需要精确控制字体时很好用。
以fop.xconf为例:
<?xml version="1.0" encoding="UTF-8"?> <fop version="1.0"> <renderers> <renderer mime="application/pdf"> <fonts> <directory recursive="true">/usr/share/fonts</directory> <auto-detect/> </fonts> </renderer> </renderers> </fop>然后在代码中把它传给FOP引擎:
import org.apache.fop.apps.FopFactory; import org.docx4j.convert.out.pdf.viaXSLFO.Conversion; import org.docx4j.openpackaging.packages.WordprocessingMLPackage; public class FopConfigConverter { public static void convertWithConfig(String docxPath, String pdfPath) throws Exception { WordprocessingMLPackage wordMLPackage = WordprocessingMLPackage.load(new File(docxPath)); // 自定义FOP工厂,加载字体配置文件 FopFactory fopFactory = FopFactory.newInstance(new File("fop.xconf").toURI()); Conversion conversion = new Conversion(wordMLPackage, fopFactory); try (OutputStream os = new FileOutputStream(pdfPath)) { conversion.output(os); } } }FOP配置文件的优势在于,它可以把字体目录、字体别名、嵌入策略一次性集中管理,不用在Java代码里做任何字体替换操作。但它的写法比较“XML化”,而且不同docx4j版本对FopFactory构造方式有细微调整,建议先跑通简单示例再套到项目里。
4.5 自动化判断:转换前如何检测字体是否有效
生产环境里可以加一道体检逻辑:转换前扫描文档里的字体,检查系统里是否有对应物理字体,没有的直接打日志告警。做法是把4.2节里的字体映射表反过来用,遍历文档中的字体名,去PhysicalFonts.getPhysicalFonts()里查key是否存在。
import org.docx4j.fonts.PhysicalFonts; public static void checkFontAvailability(WordprocessingMLPackage wordMLPackage) { Set<String> documentFonts = extractFontsUsedInDocument(wordMLPackage); Map<String, PhysicalFont> physicalFonts = PhysicalFonts.getPhysicalFonts(); for (String fontName : documentFonts) { if (!physicalFonts.containsKey(fontName)) { System.err.println("[字体告警] 未找到字体: " + fontName); } } }这个方法在公司内部服务里很有用,字体缺失可以提前发现,不用等到用户反馈PDF乱码。
5. 常见问题与排查技巧实录
做Word转PDF这一年多,我遇到了不少奇奇怪怪的问题。这一节从两个维度来回顾:一是高频报错的解决方案速查表,二是排版细节上的“隐形雷区”。
5.1 高频报错与解决方案速查表
下面这些异常,都是我实际项目里踩过的,不是网上抄的。
| 现象 | 原因 | 解决方案 |
|---|---|---|
java.lang.NoClassDefFoundError: org/apache/fop/apps/FopFactory | 缺少docx4j-export-filters或FOP相关依赖 | 检查pom中是否引入了docx4j-export-filters,并确认版本与docx4j-core一致 |
| 中文全部变成方块 | Linux服务器缺少中文字体 | 安装wqy-zenhei或Noto CJK字体,参考2.2节 |
| 转出的PDF字体大小不一致 | 文档中部分文本没有显式设置字体名称 | 用4.2节的字体替换方案,确保所有Run都有rFonts节点 |
org.docx4j.openpackaging.exceptions.Docx4JException: Error unpacking package | 上传的docx文件损坏或者不是合法的OOXML文件 | 检查文件是否被加密、是否为docx后缀但实际是doc,可用压缩工具打开确认 |
| 转换大文档时内存溢出 | 特殊字符或大量图片一次性加载进内存 | 调整JVM堆内存,转换逻辑放到独立线程池,必要时拆分转换 |
NullPointerException出现在Conversion.output()中 | 文档中包含docx4j无法识别的对象(如某些内嵌控件) | 转换前用TraversalUtil删除无法识别的对象,或降级为LibreOffice方案 |
| 页眉页脚里的中文字体正常,正文乱码 | 页眉和正文的样式定义存放在不同部件 | 字体替换逻辑要同时处理页眉、页脚、页脚注释等所有部件 |
这里重点讲一下页眉页脚字体处理的坑。docx的页眉页脚内容不在主文档的body里,而是独立存储在word/header1.xml、word/footer1.xml中。用TraversalUtil.visit(wordMLPackage, ...)遍历时,需要额外遍历所有HeaderPart、FooterPart,否则页眉页脚的字体就漏掉了。我的做法是写一个方法,逐一加载所有头部和尾部部件,再对他们内部做字体替换。
import org.docx4j.openpackaging.parts.WordprocessingML.HeaderPart; import org.docx4j.openpackaging.parts.WordprocessingML.FooterPart; public static void replaceFontsInHeadersAndFooters(WordprocessingMLPackage wordMLPackage) { for (HeaderPart headerPart : wordMLPackage.getParts().getPartsOfType(HeaderPart.class)) { replaceFontsInPart(headerPart.getContent()); } for (FooterPart footerPart : wordMLPackage.getParts().getPartsOfType(FooterPart.class)) { replaceFontsInPart(footerPart.getContent()); } }5.2 表格错位、图片被压缩、目录失效等细节问题
字体乱码解决之后,还会遇到一连串“软性”问题,它们不报错,但输出的PDF就是不好看。
表格边框消失或列宽变形。docx4j的FOP渲染对“自动调整列宽”的表格支持不太理想。解决思路有两个:一是转换前在Word文档里把表格列宽设为固定值;二是在代码里遍历表格,显式给每个GridColumn设置宽度。有个小技巧,把表格的tblLayout设为fixed类型,能大幅减少列宽错乱概率。
PDF中图片被压缩变模糊。docx4j转换时默认会把文档里的图片按原始尺寸输出。但有一种情况要注意:如果你原文档里的图片本身是低分辨率(比如从网页截图粘贴进来的),转出来的PDF放大后自然模糊。这不是转换的问题,是源文档的问题。如果希望“word转pdf如何不压缩图片”,需要在文档层面保证源图片分辨率充足。docx4j本身不会主动压缩图片。
目录失效。这个现象也常见:Word文档里的目录(TOC)在转PDF后页码对不上。原因是docx里的目录实际上是一段“伪文本”,它存储的是生成目录那一刻的静态目录内容。如果在转换前不刷新域(比如更新目录),那么渲染出来的目录里的页码就是旧的。处理办法有两个:一是转换前在Word里按Ctrl+A然后按F9刷新所有域再保存;二是在docx4j代码层面不处理——因为docx4j没有直接刷新域的能力。所以我的习惯是,上游生成的Word文档,必须先把目录更新保存好,再来调转换接口。
这里说一下用户的痛点“为了转pdf,把word链接取消了,现在更新目录是不可以动的,怎么办”。这本质上是用户在Word里手动更新域时遇到了交互限制,pdf转换工具拿到的是旧目录,输出后页码错乱。我的建议是:源文档交给转换服务前,先另存一份并手动刷新域,转换这份新副本,原文档保持不动。这样既不会破坏原文档的目录,又能保证PDF里的目录是准的。
文本框位置偏移。docx4j对Word文本框的渲染,尤其是绝对定位的文本框,还原度会打折扣。这个问题不容易彻底修复。我在实践中发现,如果文本框只用于展示少量文字,直接建议业务方改用表格加艺术字或图片的方式替换,转换效果会更稳定。
6. 性能优化与生产环境落地建议
最后聊聊生产环境。一个转换工具如果在测试环境跑得很欢,一上线就被高并发打挂,那等于没写。这块我给你几条实际有用的建议。
6.1 大文档转换的内存设置
docx4j转换PDF是内存密集型操作。一份20MB、带大量图片的docx,加载时就要占掉约200MB~500MB的堆内存,FOP渲染阶段还会再翻一倍。所以给转换服务单独分配内存很重要。
JVM参数建议:
java -Xms1g -Xmx2g -XX:MaxMetaspaceSize=512m -jar doc-convert-service.jar如果你用Docker,记得在docker-compose.yml里对应设置:
services: doc-convert: image: doc-convert:latest environment: - JAVA_OPTS=-Xms1g -Xmx2g -XX:MaxMetaspaceSize=512m另外,转换完一定要及时把WordprocessingMLPackage对象置为null,有close()方法的调close。docx4j内部持有大量DOM节点,不释放的话,在一次批量转换里连续处理几十个文件,就算堆内存再大也会被慢慢吃光。
6.2 批量转换的并发策略
如果业务上有批量转换需求,比如定时任务里一次转几百份合同,我强烈建议用线程池做控制,而不是开无限线程。
ExecutorService executor = new ThreadPoolExecutor( 4, 8, 60L, TimeUnit.SECONDS, new ArrayBlockingQueue<>(100), new ThreadPoolExecutor.CallerRunsPolicy() );核心线程数4、最大线程数8,这个配置对普通服务器来说比较适中。CallerRunsPolicy很关键:当队列满了,新任务不会丢弃,而是由提交任务的线程自己执行,起到了天然限流的作用,避免任务无限制积压。
同时注意,docx4j的PhysicalFonts是类级别的静态缓存,线程安全,多个线程同时调用discoverPhysicalFonts()不会出问题,但没必要每次转换前都扫描一次,放在服务启动时做一次就好。
6.3 从文档预处理开始的完整生产流程
实际生产环境里的转换服务,不会只写一个convert()方法。我经过一年多迭代,沉淀出一套标准流程,每个步骤都有明确的职责:
- 文档合法性校验:检查文件扩展名、MIME类型、数据大小,防止无效文件进入转换流程。
- 目录域刷新预处理:如果原始文档包含目录域,先提示上游刷新域,或者直接对副本尝试刷新(经验有限,能刷则刷)。
- 字体体检:扫描文档中实际使用的字体,与系统字体库对比,缺失字体记录日志并预警。
- 字体替换与注册:执行方案一和方案二,注册物理字体 + 替换文档字体。
- 表格列宽治理:遍历文档中的表格,把自动列宽改为固定值,避免PDF排版错乱。
- 执行PDF转换:走
Conversion进行渲染输出。 - 产物校验:转换完成后打开PDF文件信息,检查页数是否为0、文件大小是否异常,并记录转换耗时。
这套流程跑下来,转换成功率能保持在99.5%以上。剩下的0.5%基本都是源文档结构过于复杂导致的,比如嵌套多层的文本框、内嵌Excel对象,这些场景docx4j确实无能为力,只能通过人工介入或更换转换路线解决。
说回“word文档右键转pdf选项突然消失”这个问题。我遇到过用户环境里Word顶部的右键菜单转PDF入口不见了,这多半是Office插件(Microsoft Print to PDF或加载项)被禁用,或者是Word处于受保护视图。对开发者来说,这反而更坚定了我“在服务端用代码转换”的信念——终端用户本地的Office配置千奇百怪,与其教他们修菜单,不如让转换逻辑完全脱离Office环境,做到一键调用。
写在最后
用docx4j做Word转PDF,到目前已经稳定运行了一年多,支撑了几十万次转换。刚开始踩坑的时候,我也想过要不要干脆换LibreOffice方案,但每次调整完转换链路里加一段预处理,docx4j的稳定性就高一分。docx4j这种纯Java、可编程、可干预中间过程的方案,才是服务端文档处理的正解。
最后分享一个我自己的习惯:在转换服务的入口和出口都打印日志,记录文件名、大小、转换耗时、异常摘要。文档转换这种黑盒操作,排查问题时有一份完整日志比什么都管用。如果你也在改造转换逻辑,建议第一时间加上,后面能省掉太多排查成本。