docx4j 实战:Java 操作 Word 模板替换与表格填充指南
简介这份资源是docx4j项目的完整开发资料包面向需要在Java应用中处理Office文档的开发者尤其适合希望深入掌握Open XML格式、实现复杂文档操作的中高级程序员。docx4j是一款功能强大的Java库支持创建、编辑和转换Word、Excel、PowerPoint文档相比Apache POI提供更丰富的API与更高灵活性可完成模板填充、格式转换、XML解析及数据导入导出等任务。资源包共3726个文件以3209个java源码为主体辅以127个xsd结构定义、101个docx示例文档、68个xml配置及html、xslt、jar、pom等配套文件压缩包约23.17MB源码、Javadoc文档与示例一应俱全。目前已有3553人学习下载。通过研读源码与Javadoc开发者可快速理解WordprocessingMLPackage、SpreadsheetMLPackage等核心类的用法掌握文档创建、样式设置、图像表格处理与邮件合并等进阶技巧为实际项目中的文档自动化需求提供可靠参考。1. 从一份 javadoc 说起docx4j 到底能帮你省下多少折腾上周帮一个做企业报表的朋友看代码他接手的老系统里有一堆 Word 模板每次生成合同都要手动改占位符运营改一次格式开发就得跟着调一次代码。他问我有没有办法让 Java 直接读写 docx把模板里的变量替换掉顺便把表格里的数据也填了。我让他去翻 docx4j 的 javadoc他回了一句“文档太散不知道从哪看起”。这其实是很多人第一次接触 docx4j 的真实状态——功能强但入口不明显。docx4j 是一个用 Java 操作 OOXML 文档的开源库核心能力是读写 docx、pptx、xlsx底层基于 JAXB 把 WordprocessingML 映射成 Java 对象。它不像 POI 那样把文档抽象成行列单元格而是让你直接面对 OOXML 的树形结构所以做模板替换、样式控制、页眉页脚、内容控件这些事更顺手。这份资源把 javadoc 文档、源码和示例打包在一起适合两类人一是需要在后端批量生成或解析 Word 的 Java 开发者二是想搞懂 OOXML 结构但不想从 ECMA 标准啃起的人。如果你只是偶尔导出一个简单表格POI 够用但只要涉及模板占位符、复杂样式继承、文档合并拆分docx4j 的 javadoc 配合源码能让你少走很多弯路。2. 把 javadoc 和源码对照着看docx4j 的类结构怎么理2.1 先认清三个核心包别一上来就翻 WordprocessingMLdocx4j 的 javadoc 里类非常多新手容易陷进org.docx4j.wml这个包出不来。我一般建议先看三个入口包org.docx4j.openpackaging负责包级别的读写org.docx4j.jaxb处理 JAXB 上下文和命名空间org.docx4j.model放了一些高层封装比如目录生成、字段更新。真正操作文档内容时才会用到org.docx4j.wml它对应的是 WordprocessingML 的 schema 定义类名基本和 XML 元素一一对应比如P是段落、R是 run、T是文本。源码里有一个细节值得注意WordprocessingMLPackage这个类并不是直接继承某个抽象包而是通过OpcPackage做了一层包装。你在 javadoc 里看到getMainDocumentPart()返回的是MainDocumentPart它才是真正持有Document对象的地方。很多人第一次写代码会写成pkg.getDocument()结果编译不过就是因为没理清这层关系。对照源码看MainDocumentPart的getContents()方法能清楚看到它返回的是ListObject里面混着段落、表格、sectPr 等元素遍历时要做类型判断。2.2 用一段最小代码验证 javadoc 里的方法签名光看文档容易记混我习惯写一个最小可运行片段来验证。下面这段代码创建一个空文档加一个段落然后保存。依赖只需要 docx4j 的核心包和它的 JAXB 上下文初始化。// 初始化 JAXB 上下文docx4j 3.x 之后需要显式调用 // 如果用的是 6.x 或 8.x部分版本已自动处理但显式写不会错 org.docx4j.jaxb.Context.getWmlObjectFactory(); // 创建一个空的 WordprocessingMLPackage默认是 docx 格式 WordprocessingMLPackage pkg WordprocessingMLPackage.createPackage(); // 获取主文档部分所有正文内容都挂在这里 MainDocumentPart mainPart pkg.getMainDocumentPart(); // 用工厂创建段落和 run再设置文本 ObjectFactory factory org.docx4j.jaxb.Context.getWmlObjectFactory(); P paragraph factory.createP(); R run factory.createR(); Text text factory.createText(); text.setValue(第一个 docx4j 段落); run.getContent().add(text); paragraph.getContent().add(run); // 把段落加到主文档内容列表里 mainPart.getContent().add(paragraph); // 保存到磁盘注意这里用的是 File 对象 pkg.save(new java.io.File(/tmp/demo.docx));这段代码里ObjectFactory是 JAXB 生成的工厂类javadoc 里在org.docx4j.wml包下能找到。createP()、createR()、createText()这些方法名和 XML 元素名对应参数为空表示创建一个空元素。getContent()返回的是ListObject所以加段落时不需要强制转换。保存时如果目标文件已存在docx4j 会直接覆盖不会报错这一点和 POI 的write行为一致。2.3 源码里的Text和P继承关系决定了你能不能直接改样式javadoc 里Text类继承自JAXBElementString的包装实际存文本用的是value字段。而P继承自PPr相关的属性容器段落样式、对齐方式、缩进都在PPr里。源码中P的getPPr()方法返回的是PPr对象如果段落没有显式设置过属性这个方法可能返回 null。我见过有人直接paragraph.getPPr().setJc(...)结果空指针就是因为没做判空。正确的做法是先判断再创建// 如果段落还没有 PPr先创建一个 if (paragraph.getPPr() null) { paragraph.setPPr(factory.createPPr()); } // 设置居中对齐Jc 的值是一个枚举 paragraph.getPPr().setJc(factory.createJc()); paragraph.getPPr().getJc().setVal(JcEnumeration.CENTER);JcEnumeration是 javadoc 里的枚举类值包括LEFT、CENTER、RIGHT、BOTH等。源码里Jc的val属性是JcEnumeration类型不是字符串所以不能直接传center。这种细节在 javadoc 里能看到类型但如果不点进去看枚举定义很容易写错。3. 模板替换实战从占位符到内容控件哪条路更稳3.1 用Text替换做简单占位符但要注意 run 拆分最常见的需求是把模板里的${name}替换成实际值。docx4j 的示例里有一种做法是遍历所有Text节点用字符串替换。但 Word 在保存时可能把一个占位符拆成多个 run比如${在一个 runname在另一个 run}在第三个 run。直接对单个Text做替换会漏掉。我一般用两种策略一是先做一次“合并相邻 run”的预处理把同一个段落里连续的Text合并成一个二是用 docx4j 提供的RangeFinder或者自己写遍历逻辑按段落取全文再替换。下面是一个按段落合并文本再替换的简化版// 遍历主文档里所有段落 for (Object obj : mainPart.getContent()) { if (obj instanceof P) { P p (P) obj; // 收集段落里所有 Text 的值拼成完整字符串 StringBuilder sb new StringBuilder(); ListText textNodes new ArrayList(); for (Object child : p.getContent()) { if (child instanceof R) { for (Object rChild : ((R) child).getContent()) { if (rChild instanceof Text) { textNodes.add((Text) rChild); sb.append(((Text) rChild).getValue()); } } } } String fullText sb.toString(); // 如果包含占位符做替换 if (fullText.contains(${name})) { String replaced fullText.replace(${name}, 张三); // 把替换后的文本写回第一个 Text清空其余 if (!textNodes.isEmpty()) { textNodes.get(0).setValue(replaced); for (int i 1; i textNodes.size(); i) { textNodes.get(i).setValue(); } } } } }这段代码的逻辑是先把段落里的文本拼起来替换后再塞回第一个Text其余置空。这样做会丢失原本 run 级别的格式差异比如占位符里一部分加粗一部分不加粗替换后格式会统一成第一个 run 的格式。如果模板对格式要求不高这是最省事的做法如果要求保留格式就得用更细粒度的替换比如只替换Text里匹配的部分不合并 run。3.2 内容控件SDT才是模板替换的正路docx4j 对内容控件Structured Document TagSDT的支持比较完整。在 Word 里插入一个纯文本内容控件给它设一个 tag 或 alias然后在 Java 里按 tag 找到这个 SDT直接设置它的内容。这种方式不受 run 拆分影响因为 SDT 本身就是一个容器里面的文本节点是独立的。javadoc 里SdtBlock、SdtRun、SdtContentRun这些类都在org.docx4j.wml包下。源码里SdtBlock的getSdtContent()返回的是SdtContentBlock里面可以包含段落。下面是一个按 tag 查找并替换的示例// 递归遍历文档找到指定 tag 的 SdtBlock public static SdtBlock findSdtBlockByTag(ListObject content, String tag) { for (Object obj : content) { if (obj instanceof SdtBlock) { SdtBlock sdt (SdtBlock) obj; // 检查 sdtPr 里的 tag if (sdt.getSdtPr() ! null sdt.getSdtPr().getTag() ! null tag.equals(sdt.getSdtPr().getTag().getVal())) { return sdt; } // 递归进 sdtContent 继续找 if (sdt.getSdtContent() ! null) { SdtBlock found findSdtBlockByTag(sdt.getSdtContent().getContent(), tag); if (found ! null) return found; } } else if (obj instanceof P) { // 段落里也可能嵌套 SdtRun这里简化处理 for (Object child : ((P) obj).getContent()) { if (child instanceof SdtRun) { SdtRun sdtRun (SdtRun) child; if (sdtRun.getSdtPr() ! null sdtRun.getSdtPr().getTag() ! null tag.equals(sdtRun.getSdtPr().getTag().getVal())) { // 找到 SdtRun 后替换其内容里的文本 // 具体替换逻辑略思路同 SdtBlock } } } } } return null; }找到SdtBlock后清空sdtContent里的原有段落新建一个P和R把文本塞进去再设回去。这种方式在 Word 模板里很稳定运营改模板时只要不动 tag代码就不用改。我一般会建议团队在模板规范里写清楚所有需要程序填充的地方都用内容控件tag 用英文加下划线不要用中文。3.3 表格填充按行遍历注意Tc和Tr的层级表格在 docx4j 里对应Tbl行是Tr单元格是Tc。javadoc 里Tbl的getContent()返回的列表里混着Tr和TblPr、TblGrid等遍历时要判断类型。源码里Tr的getContent()返回ListObject里面是Tc和TrPr。填充表格时我通常先定位到Tbl然后按行索引取Tr再按列索引取Tc最后替换Tc里的段落文本。// 假设已经拿到 Tbl 对象 ListObject rows tbl.getContent(); int rowIndex 0; for (Object rowObj : rows) { if (rowObj instanceof Tr) { Tr tr (Tr) rowObj; int colIndex 0; for (Object cellObj : tr.getContent()) { if (cellObj instanceof Tc) { Tc tc (Tc) cellObj; // 每个单元格里至少有一个段落 for (Object pObj : tc.getContent()) { if (pObj instanceof P) { P p (P) pObj; // 清空原有 run新建一个 p.getContent().clear(); R run factory.createR(); Text text factory.createText(); text.setValue(第 rowIndex 行第 colIndex 列); run.getContent().add(text); p.getContent().add(run); } } colIndex; } } rowIndex; } }这段代码里p.getContent().clear()会清掉单元格里原有的所有 run包括可能的图片或超链接。如果模板单元格里有固定格式的文本需要保留就不能直接 clear而是找到对应的Text做替换。另外合并单元格在 OOXML 里是通过TcPr的gridSpan和vMerge控制的遍历时Tc的数量和视觉上的列数可能不一致这一点在 javadoc 里没有直观说明需要看源码里TcPr的定义。4. 避坑与排查docx4j 上手时最容易翻车的五个地方4.1 现象保存后打开文档提示“内容有问题”原因JAXB 上下文没有初始化或者命名空间前缀不对。docx4j 在序列化时依赖Context.getWmlObjectFactory()返回的工厂如果没调用生成的 XML 可能缺少必要的命名空间声明。另外如果手动拼接 XML 字符串再塞进Text里面的特殊字符没有转义也会导致文档损坏。解决在创建WordprocessingMLPackage之前先调用Context.getWmlObjectFactory()。如果是从外部读入 XML 片段用XmlUtils.unmarshalString()做反序列化不要直接setValue带尖括号的字符串。保存后可以用 Word 打开验证或者用 docx4j 的XmlUtils.marshaltoString()打印主文档 XML 检查命名空间。4.2 现象替换后的文本格式全变了加粗和字体丢失原因直接对Text的value做替换或者合并 run 时只保留了第一个 run 的格式。Word 的格式是挂在RPr上的Text本身不携带格式信息。如果替换时新建了R但没有复制原来的RPr格式就会回退到默认样式。解决替换前先保存原R的RPr对象新建R后调用setRPr()设回去。如果是合并 run 的方案至少要保留第一个 run 的RPr。更稳妥的做法是用内容控件因为 SDT 内部的段落可以预先设好样式替换文本时只动Text节点不动RPr。4.3 现象遍历文档时抛出ClassCastException原因getContent()返回的ListObject里混着多种类型比如P、Tbl、SdtBlock、SectPr直接强转成P就会崩。源码里MainDocumentPart的getContent()返回的就是ListObjectjavadoc 里也是这么写的但很多人不看返回类型。解决遍历时用instanceof做判断只处理目标类型。如果嵌套层级深写一个递归方法每层都判断类型。不要图省事用(P) obj强转。4.4 现象中文字体在生成的文档里显示为宋体但模板里设的是微软雅黑原因OOXML 里中文字体是通过w:rFonts的w:eastAsia属性控制的docx4j 的RFonts类有setEastAsia()方法。如果只设了setAscii()或setHAnsi()中文会走默认的 eastAsia 字体。javadoc 里RFonts的属性比较多容易漏。解决设置字体时同时设ascii、hAnsi、eastAsia三个属性。如果是从模板继承的样式检查模板里对应样式的rFonts是否完整。下面是一个设置字体的片段RPr rpr factory.createRPr(); RFonts fonts factory.createRFonts(); fonts.setAscii(Microsoft YaHei); fonts.setHAnsi(Microsoft YaHei); fonts.setEastAsia(Microsoft YaHei); rpr.setRFonts(fonts); run.setRPr(rpr);4.5 现象处理大文档时内存溢出或速度极慢原因docx4j 默认会把整个文档加载到内存里包括图片、嵌入对象。如果文档有几十页且包含大量图片JVM 堆不够就会 OOM。另外频繁调用save()会反复序列化整个包速度很慢。解决增大 JVM 堆内存比如-Xmx2g。如果只是做文本替换可以在加载时设置LoadSaveOption不加载图片二进制只保留关系。保存时尽量一次性保存不要每改一个段落就存一次。对于超大文档考虑用流式 API 或者拆分处理。5. 进阶技巧用源码里的FieldUpdater和TocGenerator做自动化5.1 自动更新目录和页码不用打开 Word 按 F9docx4j 的源码里有一个FieldUpdater类可以更新文档里的字段比如页码、目录、交叉引用。javadoc 里在org.docx4j.model.fields包下。用法是先把文档加载进来然后调用FieldUpdater.updateFields()它会遍历所有字段并重新计算结果。对于目录还有一个TocGenerator类可以根据段落样式生成目录条目。我一般会在模板替换完成后加一段更新字段的逻辑// 加载文档后先做模板替换 // 然后更新字段 FieldUpdater.getInstance().updateFields(pkg, null); // 如果有目录重新生成 TocGenerator tocGenerator new TocGenerator(pkg); tocGenerator.generateToc(0, TOC \\o \1-3\ \\h \\z \\u, false);generateToc的第一个参数是目录插入位置0 表示插到最前面第二个参数是域代码\o 1-3表示包含 1 到 3 级标题第三个参数表示是否在生成后立即更新页码。这段逻辑在源码的示例里有但 javadoc 里没有详细说明参数含义需要对照源码里的注释看。5.2 用Diff工具对比两个 docx 的差异docx4j 的源码里还带了一个Diff工具可以比较两个文档的差异并生成带修订标记的文档。这个功能在合同版本对比场景里很有用。javadoc 里在org.docx4j.diff包下核心类是Differencer。用法是加载两个WordprocessingMLPackage然后调用diff()方法把结果输出到一个新的包。WordprocessingMLPackage oldPkg WordprocessingMLPackage.load(new File(old.docx)); WordprocessingMLPackage newPkg WordprocessingMLPackage.load(new File(new.docx)); WordprocessingMLPackage result WordprocessingMLPackage.createPackage(); Differencer differencer new Differencer(); differencer.diff(oldPkg, newPkg, result); result.save(new File(diff.docx));生成的diff.docx里会用 Word 的修订模式标出插入和删除的内容。这个功能对格式变化比较敏感如果两个文档的样式定义不同可能会产生大量无意义的差异。我一般会先统一模板样式再做对比。5.3 一个我踩过的坑save()之后流没关文件被占用docx4j 的save()方法内部会打开FileOutputStream但在某些版本里如果保存过程中抛异常流可能不会关闭。我在 Windows 上遇到过保存后文件被 JVM 占用删不掉也改不了。后来养成的习惯是保存时用OutputStream显式管理或者保存后手动调用pkg.close()如果版本支持。另外如果只是临时生成可以用ByteArrayOutputStream保存到内存再自己写文件这样流可控。ByteArrayOutputStream baos new ByteArrayOutputStream(); pkg.save(baos); // 再用 Files.write 写磁盘流由自己管理 Files.write(Paths.get(/tmp/out.docx), baos.toByteArray());从那以后我每次用 docx4j 做批量生成都会在保存环节强制走一遍ByteArrayOutputStream确认内存和文件句柄都释放干净。希望帮到你。本文还有配套的精品资源点击获取