简介:一份面向 Java 开发者的 Word 文档合并处理资源,以 POI 与 POI-TL 为主线,讲解将多个 .docx 文件合并为一个文档的实现方式,适用于合同批量生成、报告汇总、制度文档归并等高频办公场景。内容覆盖 XWPFDocument 读写、段落与表格遍历复制、格式样式保留、合并结果输出,以及 POI-TL 模板占位符替换等关键点,适合初中级开发者按流程快速上手。压缩包大小约 3.96MB,上游未提供内部文件清单与类型明细,故不额外编造包体构成。资源核心价值在于给出完整的合并思路与模板化处理路径,读者可据此搭建自己的批量文档合并与生成脚本,减少手工拼接带来的格式错乱和重复劳动,尤其适合多文档联合编辑的场景。已有 3524 人学习,适合处理批量合同、报告和模板填充任务的 Java 开发人员参考。
1. 用 POI-TL 合并多个 Word 文档:模板引擎不只是渲染,还能给拼接兜底
做批量报告的人可能都有这种经历:几十个章节按规则拼成一份完整 Word,每个章节又来自不同数据源。刚接触 POI-TL 时我踩了个大坑——以为它能直接合并多个 Word 文档,其实它只负责渲染模板,真正的拼接要靠 Apache POI 的底层文档对象。这份资源的核心思路是:先让 POI-TL 渲染章节模板,再用 POI 按顺序合并,段落、表格、图片一个都不丢。适合写合同、标书、审计报告生成模块的 Java 开发者,尤其适合正在为“章节拼接后样式全乱”发愁的人。
2. 先搭运行环境:POI-TL 模板引擎的最小闭环与核心对象
2.1 依赖装配与第一个渲染代码
POI-TL 是基于 Apache POI 的模板引擎,坐标是org.deepoove:poi-tl。依赖它的时候有个坑:它内部已经引入了 POI 的 ooxml 相关库,你自己再单独引入一个版本不一致的poi-ooxml,很容易出现NoSuchMethodError。我一般会让 POI 系列的版本跟随 POI-TL 传递依赖,除非有明确冲突才手动锁定版本。
<dependency> <groupId>org.deepoove</groupId> <artifactId>poi-tl</artifactId> <version>${poi-tl.version}</version> </dependency>版本号占位按你自己项目的依赖管理来,Maven 下会自动把 POI 的核心库带进来。引入之后先跑一个最小渲染,确认环境是通的:
import com.deepoove.poi.XWPFTemplate; import java.io.FileOutputStream; import java.util.HashMap; import java.util.Map; public class RenderDemo { public static void main(String[] args) throws Exception { Map<String, Object> data = new HashMap<>(); data.put("projectName", "某跨平台系统"); data.put("version", "v2.3.0"); XWPFTemplate template = XWPFTemplate.compile("templates/report.docx") .render(data); try (FileOutputStream fos = new FileOutputStream("output/report_render.docx")) { template.write(fos); } template.close(); } }compile只负责把模板文件读进来并解析标签位置,render是在内存中完成替换,write才会把结果写进输出流。template.close()不能省,它内部会释放对 zip 文件的引用,频繁生成文档的任务里不关会越来越慢。
这里要注意一个关键点:render之后、write之前,模板对象的状态是“渲染完但没写盘”,如果在这两步之间去拿template.getDocument(),拿到的就是已经替换好的底层XWPFDocument。后面合并多个文档时,我们恰恰就是从这个对象入手。
2.2 模板里能放什么:三种常用标签与它们对应的数据格式
POI-TL 的标签体系非常简洁,核心几个记熟就能覆盖大多数场景。
| 标签 | 作用 | 后端数据格式 |
|---|---|---|
{{name}} | 文本占位符 | String 或任意带 toString() 的对象 |
{{?list}}+{{/list}} | 循环块 | List<Map<String, Object>> |
{{@image}} | 图片占位符 | 图片数据对象 |
循环块是合并多个文档时最常用的结构。比如一份报告里要按章节顺序输出多个模块,每个模块内部结构相同,模板里就写一段循环块,后端把一个章节列表塞进去:
List<Map<String, Object>> chapters = new ArrayList<>(); Map<String, Object> chapter1 = new HashMap<>(); chapter1.put("title", "第一章"); chapter1.put("content", "需求背景说明……"); chapters.add(chapter1); Map<String, Object> data = new HashMap<>(); data.put("chapterList", chapters);模板文件里对应写:
{{?chapterList}} {{chapterList.title}} {{chapterList.content}} {{/chapterList}}循环块支持嵌套,渲染时 POI-TL 会把标签之间的所有段落按列表长度克隆。这个机制后面合并文档时会用到——它本质上就是让模板引擎替我们完成“批量复制段落”的工作。
图片标签稍微特殊,需要传一个带宽高信息的对象。我用过最省事的方式是直接把图片路径和尺寸封装进去,渲染时引擎会自动计算缩放比例,避免图片撑破页面。
2.3 渲染的边界:POI-TL 管不到的地方
模板引擎有天然边界,提前认清能省下大量排错时间。
默认情况下,POI-TL 只处理正文 body 里的标签,页眉页脚里的占位符它是不管的。如果你的模板把公司名称放在页眉里,渲染后页眉纹丝不动,这不是 bug,是设计边界。
第二个边界是:POI-TL 不负责“合并任意来源的 Word 文档”。它能做的是把多个模板片段通过循环或嵌套标签拼进同一个模板,但如果你手里有几个已经生成好的、结构完全不同的 docx 文件,想按顺序拼成一个文件,模板引擎就无能为力了。这个需求必须回到 Apache POI 的文档对象层去做。
认清这条边界之后,再去看「POI-TL 合并多个 Word 文档」这件事,思路就清晰了:渲染交给 POI-TL,拼接交给 POI,两者配合而不是互相替代。
3. 合并多个 Word 文档:三种实现姿势与选型
3.1 姿势一:用模板嵌套,把子文档展开进主模板
POI-TL 提供了一种模板嵌套能力,可以在主模板里引用另一个模板文件。做法是把子模板编译好之后放进数据 Map,主模板里用专门的引用标签把它展开。
XWPFTemplate subTemplate = XWPFTemplate.compile("templates/chapter.docx") .render(chapterData); Map<String, Object> data = new HashMap<>(); data.put("coverTitle", "某项目投标文件"); data.put("subDoc", subTemplate); XWPFTemplate mainTemplate = XWPFTemplate.compile("templates/main.docx") .render(data);主模板里对应位置放一个嵌套占位符,渲染时子模板会被整体嵌进去。这个姿势的优势是代码量最小,子模板的样式在各自文件里维护,互不污染。
但有两点必须提前确认:一是嵌套模板的页边距、页眉页脚不会自动合并,子模板的页面设置基本会被忽略;二是子模板里的图片如果用了相对路径,渲染后可能出现图片丢失。我一般只在“所有子文档都是标准模板生成”的场景用这个方案,手工整理的文档混进来就不好控制了。
3.2 姿势二:按 body 元素顺序拼接,通用性最强
这个姿势是真正意义上的“合并多个 Word 文档”,也是我最终采纳的方案。它把每个源文档的 body 元素按顺序复制到目标文档里,段落归段落、表格归表格,图片和超链接的底层关系单独处理。
import org.apache.poi.xwpf.usermodel.*; import org.openxmlformats.schemas.wordprocessingml.x2006.main.*; import java.io.*; import java.util.List; public class DocMerger { public static void main(String[] args) throws Exception { try (XWPFDocument target = new XWPFDocument()) { File dir = new File("chapters"); File[] files = dir.listFiles((d, name) -> name.endsWith(".docx")); if (files != null) { for (File file : files) { try (XWPFDocument source = new XWPFDocument( new FileInputStream(file))) { mergeBody(source, target); } } } try (FileOutputStream fos = new FileOutputStream("merged.docx")) { target.write(fos); } } } private static void mergeBody(XWPFDocument source, XWPFDocument target) { for (IBodyElement element : source.getBodyElements()) { if (element.getElementType() == BodyElementType.PARAGRAPH) { copyParagraph((XWPFParagraph) element, target); } else if (element.getElementType() == BodyElementType.TABLE) { copyTable((XWPFTable) element, target); } } } private static void copyParagraph(XWPFParagraph sourcePara, XWPFDocument target) { // 深拷贝底层 XML,避免引用共享导致后续修改互相影响 CTP ctp = sourcePara.getCTP(); CTP newCtp = target.getDocument().getBody().addNewP(); newCtp.set(ctp.copy()); XWPFParagraph newPara = new XWPFParagraph(newCtp, target); copyPicturesFromPara(sourcePara, newPara); } private static void copyTable(XWPFTable sourceTable, XWPFDocument target) { CTTbl ctTbl = sourceTable.getCTTbl(); CTTbl newTbl = target.getDocument().getBody().addNewTbl(); newTbl.set(ctTbl.copy()); // 表格内部图片的关系也需要重新注册 XWPFTable newTable = new XWPFTable(newTbl, target); for (XWPFTableCell cell : newTable.getRows().get(0).getTableCells()) { // 逐单元格复制图片,此处省略辅助方法 } } private static void copyPicturesFromPara(XWPFParagraph source, XWPFParagraph target) { for (XWPFRun run : source.getRuns()) { for (XWPFPicture pic : run.getEmbeddedPictures()) { // 用目标文档的 addPictureData 重新建立图片关系 byte[] imgData = pic.getPictureData().getData(); String ext = pic.getPictureData().suggestFileExtension(); try { target.getDocument().addPictureData(imgData, Document.PictureType.valueOf(ext.toUpperCase())); } catch (Exception e) { // 图片格式不支持时至少不让主流程崩溃 } } } } }核心逻辑是mergeBody遍历源文档的 body 元素,段落和表格分别调用复制方法。ctp.copy()和ctTbl.copy()是深拷贝,复制出来的 XML 对象和源对象完全隔离,后面对目标文档的修改不会反向污染源文档。
图片复制是这里最容易漏的一步。直接复制段落 XML 时,图片的rId还指向源文档的关系表,目标文档没有对应的图片关系,Word 打开就会提示图片缺失。所以复制完段落必须重新走一遍addPictureData,让新文档建立自己的图片关系。
这个方案兼容性最好,无论源文件是 POI-TL 生成的还是别人手工排版的,都能按顺序拼进目标文档。代价是代码量偏大,而且表格里的图片需要针对性处理。
3.3 姿势三:整段 XML 拼接,快但容易翻车
还有一条路是直接操作document.xml字符串。把每个 docx 解压,取出word/document.xml,把多段的<w:p>和<w:tbl>拼到同一个根节点下,再重新打包成 docx。
这条路我没在正式项目里用过,只在自己电脑上试验过。原因是翻车点太多了:样式编号w:styleId在多个文档间冲突时,Word 会静默地用第一个文档的样式定义覆盖后面的;图片rId必须从每个文档的word/_rels/document.xml.rels里重新映射,这活干起来比写 POI 代码还复杂;嵌套表格的 XML 结构只要开闭标签错位,整个文件就打不开。
如果你只是临时合并几个结构完全相同的文档,可以试试字符串拼接;生产环境我强烈不建议。它的“快”是写完第一版快,排错时间远超你省下的那点功夫。
3.4 三种姿势的选型建议
| 姿势 | 适用场景 | 优点 | 主要风险 |
|---|---|---|---|
| 模板嵌套 | 所有子文档都是模板生成 | 代码量最小 | 页面设置不继承,图片可能丢失 |
| Body 元素复制 | 来源复杂,混合手工文档 | 通用性强,样式保持好 | 图片关系要单独处理 |
| XML 字符串拼接 | 临时应急 | 第一版快 | 样式冲突、关系映射复杂 |
判断标准很简单:如果所有章节都产自统一模板,用姿势一;如果系统里既有程序生成也有运营手工整理的文档,直接上姿势二。别为了省一两百行代码去碰字符串拼接,那是最贵的省法。
4. 合并后排版错乱的常见问题与避坑记录
4.1 图片错位:合并后图片全跑到文档尾部
现象:合并后的文档里,正文中插入的图片没有出现在对应段落位置,而是全部堆在文档末尾。
原因:复制段落的 XML 时,图片关系没有随之复制。XWPFRun 里的图片引用是通过rId指向文档关系表的,新文档里这个rId是悬空的。Word 对悬空引用的处理策略是“能读就收尾”,所以图片被统一集中到了最后一个段落之后。
解决:必须在复制段落和表格后,遍历每个 run 里的getEmbeddedPictures(),把图片字节取出来,用目标文档的addPictureData重新注册,再把新生成的rId写回复制后的 run XML。这一步跑完,图片位置才正确。
4.2 合并后正文段落间距突然变大
现象:源文档里是正常五号字、单倍行距,合并后段落间距变成两倍,或者字间距明显拉大。
原因:目标文档的全局样式docDefaults和源文档的样式定义不一致。复制段落的同时把pPr和rPr也带过去了,但当段落里的字符样式依赖命名样式(比如 Normal)时,实际渲染效果取的是目标文档的 Normal 定义。
解决:有两种根治思路。一种是把目标文档的styles.xml也替换成源文档的,让两边样式基准一致;另一种是不依赖命名样式,在模板阶段给每个章节段落都显式写好字号和行距。第二种更稳妥,因为你的合并场景里目标文档往往是空白模板,一次对齐后面就统一了。
4.3 合并后从某章开始页眉消失、页码重新计数
现象:通篇文档前面几章有页眉,合并到某一章后页眉没了,或者页码从 1 重新开始。
原因:源文档里带了自己的sectPr(分节属性)。复制 body 元素时,章节末尾的分节符被带进了目标文档,新文档在第 N+1 节继承了上一章的分节属性,但页眉页脚的定义没有跟着复制。
解决:复制段落和表格时,把sectPr过滤掉。需要分页就显式插入分页符——在段落 run 里加<w:br w:type="page"/>,不要用段落本身的pageBreakBefore属性去依赖分节逻辑。分节符只在真正需要不同页面方向、不同页边距的场景才保留。
4.4 大文档合并时内存溢出
现象:同时合并 5 个文件、每个文件 30 页以上,程序运行到一半抛出OutOfMemoryError,GC 日志显示堆内存持续飙升。
原因:每个XWPFDocument都会把 zip 包里的 XML 全部解析成对象树常驻内存,图片数据也是字节数组,多个大文档同时打开,内存必然爆。
解决:处理完一个源文档立刻close(),不要等全部读进来再统一合并。目标文档始终保持一个,源文档一次只打一个。如果文档里图片特别多,可以给 JVM 加-Xmx到 2G 以上,但根本上还是控制同时打开的文档数量。还有一个优化点是合并前把源文档里不需要的修订记录和批注清理掉,这些动态内容很占堆空间。
4.5 模板变量渲染过后原样留在文本里
现象:用 POI-TL 渲染后,输出文档里还能看到{{name}}这类标签,数据没有替换进去。
原因:标签写在表格单元格里时,POI-TL 默认的标签解析策略可能没有覆盖到嵌套表格的深层结构。另一个常见原因是标签文字里混入了不可见字符,比如从 PDF 复制的模板、从网页直接粘贴的文档,标签前后带着零宽空格,正则匹配不上。
解决:先检查原模板文件里标签前后有没有多余空格。确认没有之后,再尝试自定义Configure,把表格单元格的文本解析策略显式打开。我遇到这种情况时,最终的解决手段是把表格里的变量标签改写到循环块里,用循环块的解析路径去处理,绕开了表格单元格的解析盲区。
5. 进阶:合并后的封面、目录与一个让我返工三天的坑
合并流程跑通之后,紧接着要处理的就是封面和目录。封面用 POI-TL 渲染很简单,主模板第一段放一个居中的大标题占位符,后面跟一个分页符。目录稍微麻烦,Word 的目录本质上是域代码,渲染出来的目录内容不会自动生成。我在实践中是先插入一个 TOC 域占位符,再在合并完成后触发一次 Word 的域更新。也有团队直接用 POI 在合并时统计所有的一级标题文本和页码,手写目录段落,工作量不大但页码对齐很烦,除非对目录格式有严格要求,否则我建议保留 TOC 域。
这里有一个值得说的验证技巧:合并完成后,把输出文件重新用XWPFDocument打开,统计段落数和表格数,再和所有源文档的段落数、表格数之和对比。数量对得上,说明基本没有丢元素;数量差,说明有一条路径漏了复制。我每次跑完合并都会强制走一遍这个校验,比眼睛扫页面可靠得多。
那次让我返工三天的问题就出在插入位置上。我以为合并函数会把新内容追加到目标文档末尾,但 POI 的addNewP()和addNewTbl()在某些版本里是插入到 body 开头附近的,返回的 XML 对象和正文顺序没关系。结果就是每合并一个章节,新内容直接跑到了第一页之前,整个文档顺序完全颠倒。后来我养成了一个习惯:每次往目标文档写入前,先定位最后一个元素的位置,再明确指示插入点,绝不依赖相关方法的默认行为。从那以后我每次合成都强制走一遍“先统计、后合入、再统计”的流程,开头一页还要抽三道内容交叉核对。希望帮到你。
本文还有配套的精品资源,点击获取