1. 为什么要在SpringBoot里折腾Docx4J加Freemarker这套组合
做过企业级报表或者合同生成的朋友应该都有体会,Word文档的动态生成从来都不是一个省心的活儿。早些年大家用POI直接操作XWPF,写出来的代码又臭又长,一个稍微复杂点的表格合并就要写上百行,后期维护简直是噩梦。后来Apache POI推出了基于模板的XWPFDocument方案,但说实话,它的模板语法支持非常有限,循环和条件判断写起来极其别扭,遇到复杂嵌套结构基本就歇菜了。
我最初接触Docx4J是在一个合同管理系统的项目里,当时的需求是要根据用户填写的表单数据,生成一份包含几十个动态字段、多个条件段落、还有动态表格的正式合同文件,生成之后还要转成PDF给客户在线预览和下载。最开始尝试了Freemarker直接生成HTML再转PDF的路子,但排版效果和Word原版差距太大,客户不买账。后来又试了POI的XWPF,代码写到一半就放弃了,光是处理表格里单元格的合并和样式继承就让人崩溃。
Docx4J这个库在国内的讨论度不算高,但在国外企业级应用里用得挺多。它的核心思路是把docx文件看作一个完整的JAXB对象树,你可以用Java代码直接操作这棵树,也可以结合Freemarker模板引擎做数据填充。最关键的是,它内置了docx到PDF的转换能力,虽然底层依赖的字体处理在Linux环境下会踩不少坑,但整体方案是通的。
这套组合适合什么人呢?如果你正在做OA系统、合同管理、报表导出、简历生成这类需要动态产出Word文档并且可能要转PDF的业务,而且对排版格式有比较严格的要求,那Docx4J加Freemarker的方案值得认真考虑。它不像一些商业报表工具那样开箱即用,但胜在灵活可控,一旦跑通,后续扩展非常方便。前提是你得对Word的XML结构有一定了解,至少知道document.xml、styles.xml这些核心部件是干什么的。
2. 整体方案设计与技术选型背后的考量
2.1 为什么是Docx4J而不是POI或Aspose
先说说为什么没选POI。POI的XWPF模块确实能操作docx,但它的API设计偏向底层,很多操作需要你手动处理XML元素。比如你想在模板里做一个表格行的循环,用POI的话得自己遍历表格行、复制行、替换占位符,代码量巨大且容易出错。而Docx4J配合Freemarker的思路是:先用Word做好一个模板文件,把需要动态替换的地方用Freemarker的语法标记出来,然后在Java代码里把docx模板解析成XML,用Freemarker渲染这个XML,最后再把渲染后的XML重新组装成docx。这种方式的好处是模板制作和代码逻辑完全分离,做模板的人只需要会Word和简单的Freemarker语法就行,不需要懂Java。
再说Aspose,功能确实强大,API也友好,但它是商业库,需要授权费用。对于预算有限的项目来说,Docx4J作为开源方案是更现实的选择。当然Docx4J也有它的脾气,比如对某些复杂样式的支持不够完美,转换PDF时字体问题比较头疼,但这些都有办法绕过。
2.2 Freemarker在其中的角色定位
Freemarker在这里的作用是模板渲染引擎。你可能会问,为什么不用Velocity或者Thymeleaf?原因很简单:Freemarker对XML格式的兼容性最好。docx文件本质上是一个ZIP包,里面是一堆XML文件,Freemarker的语法标记(比如${}、<#list>)在XML里不会和标签冲突,而且它的空值处理、默认值设置、数字格式化等功能非常完善,适合处理各种边界情况。
具体的工作流程是这样的:Docx4J把docx模板文件读进来,提取出其中的document.xml(可能还有header.xml、footer.xml等),把这些XML内容作为字符串交给Freemarker渲染,Freemarker根据传入的数据模型替换占位符、展开循环、处理条件判断,生成新的XML字符串,Docx4J再把新的XML写回docx结构里,最终输出一个完整的docx文件。
2.3 PDF转换的路径选择
Docx4J转PDF有两条路:一条是走docx4j-export-fo,用Apache FOP做XSL-FO转换,另一条是走docx4j-to-pdf或者调用外部工具。我实测下来,docx4j-export-fo对复杂排版的还原度更高,但配置起来更麻烦,尤其是字体这块。另一条路是先用Docx4J生成docx,然后用其他方式转PDF,比如用LibreOffice的命令行工具做无头转换。这种方式的好处是排版还原度极高,因为LibreOffice本身就是个完整的办公套件,但缺点是需要额外安装LibreOffice,部署包会大不少。
我最终选的是docx4j-export-fo这条路,原因是它纯Java实现,不依赖外部进程,部署简单,而且通过合理的字体配置,转换效果能满足大部分业务场景。下面重点说说这条路上的各种坑。
3. 环境搭建与核心依赖配置
3.1 Maven依赖的版本选择
Docx4J的版本迭代比较慢,但稳定性不错。我目前用的是8.3.x系列,配合Freemarker 2.3.x。这里要特别注意,Docx4J的某些版本对JDK版本有要求,8.3.x需要JDK 8及以上,如果你用的是JDK 11或17,需要额外引入JAXB相关的依赖,因为从JDK 11开始JAXB被移出了标准库。
<dependency> <groupId>org.docx4j</groupId> <artifactId>docx4j-JAXB-ReferenceImpl</artifactId> <version>8.3.9</version> </dependency> <dependency> <groupId>org.docx4j</groupId> <artifactId>docx4j-export-fo</artifactId> <version>8.3.9</version> </dependency> <dependency> <groupId>org.freemarker</groupId> <artifactId>freemarker</artifactId> <version>2.3.32</version> </dependency>如果你用的是JDK 11以上,还需要加上这些:
<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.1</version> </dependency>注意:docx4j-export-fo会传递依赖Apache FOP,FOP的版本和Docx4J有对应关系,不要随意升级FOP版本,否则可能出现方法找不到的异常。
3.2 模板文件的准备工作
制作模板是整个流程的起点,也是最容易被忽视的环节。我的建议是:先用Word把最终文档的样式、页眉页脚、页码、水印这些都做好,确保静态部分完全符合要求,然后再把需要动态替换的地方改成Freemarker的占位符。
占位符的写法有几种。简单文本替换直接用${variableName},比如${contractNo}。循环用<#list items as item>和</#list>包裹。条件判断用<#if condition>和</#if>。这里有个关键技巧:在Word里输入这些标记时,不要一次性输入完整的标记,因为Word可能会自动更正或者拆分你的文本。正确的做法是先输入${,然后按Ctrl+Z撤销自动更正,再继续输入剩下的部分。或者更稳妥的方式是,先在记事本里写好,再复制到Word里。
还有一个大坑:Word会在XML里把连续的文本拆分成多个<w:r>元素,比如${contractNo}可能被拆成${、contractNo、}三个run。这样Freemarker就识别不了了。解决办法是在Word里选中整个占位符,然后按Ctrl+空格清除格式,或者用"清除所有格式"功能,让Word把它合并成一个run。更保险的做法是,在模板制作完成后,用Docx4J的WordprocessingMLPackage加载模板,遍历所有run,把相邻的、格式相同的run合并,然后再交给Freemarker渲染。
3.3 项目结构规划
我习惯把模板文件放在src/main/resources/templates目录下,生成的临时文件放在/tmp或者项目配置的临时目录里。Freemarker的配置类单独写一个,Docx4J的转换工具类也单独封装,这样代码结构清晰,也方便后续替换实现。
@Configuration public class FreemarkerConfig { @Bean public Configuration freemarkerConfiguration() { Configuration configuration = new Configuration(Configuration.VERSION_2_3_32); configuration.setDefaultEncoding("UTF-8"); configuration.setTemplateExceptionHandler(TemplateExceptionHandler.RETHROW_HANDLER); configuration.setLogTemplateExceptions(false); configuration.setWrapUncheckedExceptions(true); return configuration; } }4. 核心实现:从模板到Word再到PDF的完整链路
4.1 加载模板并提取XML内容
Docx4J加载docx模板很简单,一行代码的事:
WordprocessingMLPackage wordMLPackage = WordprocessingMLPackage.load( new File(templatePath));但接下来要把document.xml提取成字符串,这里有个细节:不能直接用wordMLPackage.getMainDocumentPart().getXML(),因为这样拿到的是整个XML文档,包含了命名空间声明等一堆东西,Freemarker处理起来容易出问题。正确的做法是用XmlUtils.marshaltoString方法,把主文档部分序列化成字符串。
String documentXml = XmlUtils.marshaltoString( wordMLPackage.getMainDocumentPart().getJaxbElement(), true, true);这里marshaltoString的第二个参数表示是否格式化输出,第三个参数表示是否包含XML声明。我一般设为true, false,即格式化但不包含声明,这样Freemarker处理起来更干净。
4.2 Freemarker渲染XML字符串
拿到XML字符串后,用Freemarker的Template类进行渲染:
Template template = new Template("docx-template", new StringReader(documentXml), freemarkerConfiguration); StringWriter writer = new StringWriter(); template.process(dataModel, writer); String renderedXml = writer.toString();数据模型就是一个Map<String, Object>,里面放你的业务数据。这里要注意,Freemarker默认对XML中的特殊字符会做转义,比如&会变成&,<会变成<。对于docx的XML来说,这通常是好事,因为XML本身就需要转义。但如果你传入的数据里包含HTML标签或者特殊符号,可能需要用?no_esc来取消转义,或者用<#ftl output_format="XML">来明确输出格式。
4.3 将渲染后的XML写回docx
渲染完成后,需要把新的XML字符串重新解析成Docx4J的对象,替换掉原来的主文档部分:
MainDocumentPart mainDocumentPart = wordMLPackage.getMainDocumentPart(); mainDocumentPart.setJaxbElement( (Document) XmlUtils.unmarshalString(renderedXml));这里有个性能上的考量:如果每次生成文档都重新加载模板、提取XML、渲染、写回,在高并发场景下会有性能瓶颈。我的做法是把模板的XML字符串缓存起来,每次只做Freemarker渲染和XML解析,省去了加载docx文件和序列化的开销。实测下来,单次生成耗时能从200ms左右降到80ms左右。
4.4 处理页眉页脚中的动态内容
很多合同或报表的页眉页脚里也有动态内容,比如页码、合同编号、公司名称等。Docx4J的WordprocessingMLPackage提供了getHeaderPart和getFooterPart方法来获取页眉页脚部分。处理逻辑和主文档一样,提取XML、渲染、写回。但要注意,页眉页脚可能有多个(比如首页不同、奇偶页不同),需要遍历所有部分。
for (HeaderPart headerPart : wordMLPackage.getHeaderParts()) { String headerXml = XmlUtils.marshaltoString( headerPart.getJaxbElement(), true, false); String renderedHeader = renderWithFreemarker(headerXml, dataModel); headerPart.setJaxbElement( (Hdr) XmlUtils.unmarshalString(renderedHeader)); }4.5 PDF转换的核心配置
Docx4J转PDF的入口是Docx4J.toPDF方法,但直接调用会报一堆字体相关的错误。核心配置在于FOSettings和字体映射。
FOSettings foSettings = Docx4J.createFOSettings(); foSettings.setWmlPackage(wordMLPackage); foSettings.setApacheFopMime("application/pdf"); foSettings.setFoDumpFile(new File("/tmp/debug.fo")); // 调试用 OutputStream os = new FileOutputStream(outputPdfPath); Docx4J.toFO(foSettings, os, Docx4J.FLAG_EXPORT_PREFER_XSL);FLAG_EXPORT_PREFER_XSL表示优先使用XSL-FO转换,这是目前最稳定的方式。setFoDumpFile在调试阶段非常有用,它会把中间生成的FO文件保存下来,如果PDF转换结果不对,可以查看FO文件里的字体配置和布局信息,快速定位问题。
5. Linux环境下字体问题的系统性解决方案
5.1 字体问题的根源分析
Docx4J转PDF时,底层是先把docx转成XSL-FO,再由Apache FOP把FO转成PDF。FOP需要知道每个字体对应的实际字体文件在哪里,如果找不到,就会用默认字体替代,导致PDF里中文变成方框或者乱码。在Windows环境下,系统字体目录里有宋体、黑体、微软雅黑等常用中文字体,FOP能自动找到。但在Linux环境下,尤其是精简版的Docker镜像里,往往一个中文字体都没有,问题就暴露了。
更深层的问题是,docx模板里指定的字体名称(比如"宋体"、"SimSun")和Linux系统里的字体文件名称往往对不上。FOP的字体映射机制是通过配置文件来建立的,你需要告诉FOP:"当文档里要求用宋体时,去加载这个路径下的这个字体文件"。
5.2 字体文件的准备与安装
第一步是准备字体文件。我一般从Windows系统的C:\Windows\Fonts目录里拷贝几个常用的中文字体:simsun.ttc(宋体)、simhei.ttf(黑体)、msyh.ttc(微软雅黑)、simkai.ttf(楷体)。注意版权问题,商用项目建议使用开源字体,比如思源黑体、思源宋体,或者文泉驿系列。
把字体文件放到Linux服务器的某个目录下,比如/usr/share/fonts/chinese/,然后执行fc-cache -fv刷新字体缓存。这一步是为了让系统层面的字体服务能识别到这些字体,虽然FOP不一定走系统字体服务,但有些依赖库会用到。
5.3 FOP字体配置文件的编写
FOP的字体配置是通过一个XML文件来定义的,通常叫fop.xconf。你需要在这个文件里为每个字体建立映射关系。下面是一个典型的配置片段:
<fonts> <font embed-url="/usr/share/fonts/chinese/simsun.ttc"> <font-triplet name="SimSun" style="normal" weight="normal"/> <font-triplet name="宋体" style="normal" weight="normal"/> </font> <font embed-url="/usr/share/fonts/chinese/simhei.ttf"> <font-triplet name="SimHei" style="normal" weight="normal"/> <font-triplet name="黑体" style="normal" weight="normal"/> </font> <font embed-url="/usr/share/fonts/chinese/msyh.ttc"> <font-triplet name="Microsoft YaHei" style="normal" weight="normal"/> <font-triplet name="微软雅黑" style="normal" weight="normal"/> </font> </fonts>这里的关键是font-triplet的name属性,它必须和docx模板里使用的字体名称完全一致。比如模板里用了"宋体",那这里就要写"宋体";模板里用了"SimSun",这里也要写"SimSun"。我一般会把中英文名称都写上,避免遗漏。
5.4 在代码中加载FOP配置
有了配置文件,还需要在Java代码里让FOP加载它。Docx4J的FOSettings提供了setFontMapper方法,但更简单的方式是通过系统属性指定配置文件路径:
System.setProperty("fop.config.file", "/path/to/fop.xconf");或者直接在FOSettings里设置:
foSettings.setFopConfig(new FopFactoryConfig());但这种方式需要你自己构造FopFactory,稍微麻烦一点。我推荐用系统属性的方式,简单直接。注意这个属性必须在Docx4J.toFO调用之前设置,而且只设置一次就行,不需要每次转换都设。
5.5 字体嵌入与子集化
FOP默认会把用到的字体嵌入到PDF里,但有时候嵌入的字体文件太大,导致PDF体积膨胀。可以通过配置开启字体子集化,只嵌入实际用到的字符:
<font embed-url="/usr/share/fonts/chinese/simsun.ttc" sub-font="SimSun"> <font-triplet name="SimSun" style="normal" weight="normal"/> </font>sub-font属性指定了子字体的名称,对于TTC这种字体集合文件特别有用。开启子集化后,一个原本10MB的字体文件可能只嵌入几百KB,PDF体积能缩小很多。
5.6 Docker环境下的字体处理
如果你的应用跑在Docker里,字体问题会更突出。我的做法是在Dockerfile里显式安装字体:
FROM openjdk:11-jre-slim RUN apt-get update && apt-get install -y \ fontconfig \ && mkdir -p /usr/share/fonts/chinese \ && apt-get clean COPY fonts/*.ttc /usr/share/fonts/chinese/ COPY fonts/*.ttf /usr/share/fonts/chinese/ COPY config/fop.xconf /app/config/ RUN fc-cache -fv注意openjdk:11-jre-slim这个基础镜像里没有中文字体,必须自己拷贝进去。拷贝完成后执行fc-cache刷新缓存。另外,fop.xconf里的字体路径要写成容器内的绝对路径,不能写宿主机路径。
提示:如果转换后的PDF里中文显示为方框,九成以上是字体映射没配对。先用
setFoDumpFile导出FO文件,搜索font-family属性,看看实际用的字体名称是什么,然后对照fop.xconf检查有没有对应的font-triplet。
6. 常见问题排查与实战避坑指南
6.1 模板占位符被Word拆分导致渲染失败
这是最常见的问题,没有之一。Word为了处理拼写检查、格式继承等,会把一段连续的文本拆成多个<w:r>元素。比如你输入${userName},Word可能拆成${、userName、}三个run。Freemarker渲染时,它看到的是三个独立的文本节点,无法识别为一个完整的占位符。
解决办法有两个。一是在模板制作阶段,输入占位符后选中它,按Ctrl+空格清除格式,或者用"清除所有格式"按钮,强制Word合并run。二是在代码层面做预处理,遍历所有run,把相邻的、格式相同的run合并。我一般两个方法都用,模板制作时尽量规范,代码里也加一层保险。
private void mergeRuns(Document document) { List<Object> paragraphs = document.getBody().getContent(); for (Object para : paragraphs) { if (para instanceof P) { P p = (P) para; List<Object> runs = p.getContent(); // 合并逻辑:遍历runs,如果相邻run的格式相同,合并文本 // 具体实现略,核心是操作R对象和Text对象 } } }6.2 循环表格行时格式丢失
用<#list>做表格行循环时,经常出现第二行开始格式不对的情况。这是因为Freemarker渲染后,表格行的XML结构可能不符合Word的预期。解决办法是确保<#list>标记放在<w:tr>标签内部,而不是外部。也就是说,模板里应该是这样的结构:
<w:tbl> <w:tr> <w:tc> <w:p> <w:r> <w:t><#list items as item>${item.name}</#list></w:t> </w:r> </w:p> </w:tc> </w:tr> </w:tbl>但这样只能做单元格内的循环,不能做整行的循环。要做整行循环,需要把<#list>放在<w:tr>外面,但这样Word可能不认。我的做法是:在模板里只放一行示例行,用<#list>包裹整行,渲染后Docx4J会生成多行。如果格式有问题,检查渲染后的XML里<w:tr>的<w:trPr>属性是否完整。
6.3 PDF转换后中文变成方框
前面说了,这是字体映射问题。但还有一种情况:字体映射配对了,但PDF里还是方框。这时候要检查FO文件里的font-family属性,看看是不是被FOP替换成了默认字体。常见原因是fop.xconf里的font-triplet的style和weight属性不匹配。比如模板里用了粗体,但配置里只写了weight="normal",FOP找不到粗体变体,就会回退到默认字体。
解决办法是为每个字体配置完整的变体:
<font embed-url="/usr/share/fonts/chinese/simsun.ttc"> <font-triplet name="SimSun" style="normal" weight="normal"/> <font-triplet name="SimSun" style="normal" weight="bold"/> <font-triplet name="SimSun" style="italic" weight="normal"/> <font-triplet name="SimSun" style="italic" weight="bold"/> </font>6.4 生成的PDF体积过大
如果PDF里嵌入了完整的中文字体,一个文件可能几十MB。解决办法是开启字体子集化,只嵌入用到的字符。在fop.xconf里给<font>标签加上sub-font属性即可。另外,如果PDF里有很多图片,检查图片的分辨率,必要时在生成docx前对图片做压缩。
6.5 高并发下的性能问题
Docx4J的WordprocessingMLPackage对象不是线程安全的,每次生成文档都应该新建一个实例。但模板文件的加载可以缓存,避免重复IO。我的做法是用一个ConcurrentHashMap缓存模板的XML字符串,key是模板路径,value是XML内容。每次生成时,从缓存里取XML,用Freemarker渲染,然后unmarshalString成新的对象。这样既保证了线程安全,又避免了重复加载模板的开销。
6.6 常见问题速查表
| 问题现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| 占位符未被替换 | Word拆分了run | 查看document.xml中占位符是否被拆散 | 清除格式或代码合并run |
| 循环表格格式错乱 | list标签位置不对 | 检查渲染后的XML结构 | 调整list标签位置,确保trPr完整 |
| PDF中文方框 | 字体映射缺失 | 导出FO文件查看font-family | 完善fop.xconf配置 |
| PDF体积过大 | 字体全量嵌入 | 检查PDF内嵌字体信息 | 开启sub-font子集化 |
| 转换报字体找不到 | 字体路径错误 | 查看异常堆栈中的路径 | 修正fop.xconf中的embed-url |
| 页眉页脚未替换 | 未处理HeaderPart | 检查代码是否遍历HeaderPart | 补充页眉页脚处理逻辑 |
7. 一些实战中攒下来的经验技巧
模板制作阶段,我强烈建议用Word的"样式"功能来管理格式,而不是手动调字体和字号。因为Docx4J在转换时,样式信息会保留在styles.xml里,FOP能更好地识别。如果手动调格式,每个run上都挂一堆属性,XML会变得很臃肿,渲染和转换都慢。
Freemarker的数据模型里,日期格式化是个高频需求。我一般用${date?string("yyyy-MM-dd")}这种写法,但要注意Freemarker的默认日期格式可能受Locale影响。稳妥的做法是在配置里设置configuration.setLocale(Locale.CHINA),或者在模板里显式指定格式。
数字格式化也是类似,金额通常要千分位分隔和保留两位小数,用${amount?string("#,##0.00")}就行。但要注意,如果amount是null,Freemarker会报错。所以数据模型里要么保证不为null,要么用${(amount!0)?string("#,##0.00")}给个默认值。
调试PDF转换时,setFoDumpFile导出的FO文件是纯文本XML,可以直接用浏览器打开查看。搜索font-family能看到实际使用的字体,搜索page-sequence能看到分页信息。如果PDF分页不对,多半是FO里的page-break设置有问题,可以对照调整模板里的分页符。
最后说一个部署上的小细节:如果应用部署在多个节点上,字体文件和fop.xconf要保证每个节点都有,而且路径一致。我一般把字体和配置打包到应用的resources目录里,启动时解压到临时目录,然后动态生成fop.xconf里的路径。这样应用走到哪,字体就跟到哪,不用每台服务器手动装字体。
这套方案我在几个项目里都跑过,从简单的单页合同到几十页的复杂报表,整体稳定性没问题。关键是把字体这关过了,后面基本就是一马平川。Docx4J的社区虽然不热闹,但文档和示例代码还算齐全,遇到问题多翻翻它的源码和测试用例,往往能找到答案。