简介:这是一份面向Java开发者的Word文档批量合并工具资料包,基于Apache POI与POI-TL实现.docx文件的读取、复制、样式处理与合成。资源围绕POI-TL核心用法,覆盖XWPFDocument对象创建、段落与表格遍历、样式保留、结果输出及模板变量动态填充等环节,适合批量报告、合同生成、招投标书组装等自动化场景,也适合正在学POI的中高级Java开发者对照实践,快速建立docx文档编程模型。合并时需兼顾段落、表格、图片与页眉页脚,按顺序复制易漏格式,而POI-TL模板替换能大幅减少手工排版。压缩包整体约3.96MB,内含示例Word文件与可参考的实现代码。读者需具备Java基础、熟悉Maven依赖和文件流操作,可据此搭建合并流程,理解遍历XWPFParagraph与XWPFTable并保留格式,掌握模板占位符替换,便于迁移到实际项目。无论是日常办公文档整理还是系统级文档装配,都能从中获得直接帮助。已有3500余人学习/下载,该资源对需要高效合并多个Word文档的开发者具有直接参考价值。
1. 合并多个Word文档为什么先被当成"模板渲染"问题:POI-TL的切入点
月底要把几个小组交上来的测试记录、验收单、问题清单合并成一份总 docx,手工复制粘贴一下午,用 Apache POI 直接拼 XWPFDocument 又要自己维护段落和表格的插入位置,改两版就想摔键盘。POI-TL 合并多个 Word 文档的思路和"拼接"不一样,它把合并当成一次模板渲染:总模板里用 {{?list}} 圈循环区、用 {{name}} 标插入点,代码逐个解析源文档,内容统一喂给渲染器,一份汇总 Word 就出来了。适合用 Java 写文档生成、被多文档汇总折磨过的开发者和实施工程师。动手前先分清楚"数据合并"和"内容合并"两条路,接下来的章节就按这两条路展开。
2. POI-TL合并多个Word文档的两种实现模式:数据合并与内容合并
把 N 份 Word 文档合成一份,先别急着写代码,先回答一个问题:这些文档是同构的还是异构的?同构,也就是所有源文档来自同一套模板,走数据合并;异构,也就是每份文档格式都不一样、必须保留原文,走内容合并。这两条路在 POI-TL 里的实现差别很大,选错路后面全是玄学级别的 bug。
2.1 数据合并:同构文档集的一次性汇总渲染
数据合并是 POI-TL 最擅长、也最稳的模式。前提是源文档结构一致,比如一周 7 天的巡检记录、5 个小组的测试报告,都是同一张 Word 模板渲染出来的,只有编号、结论、日期这些字段不同。你要做的不是拼文本,而是把每份源文档里的字段抽出来,组装成一个List<Map>,再让总模板用循环块渲染一遍。
模板侧只做两件事:一是用{{?items}}和{{/items}}圈住要重复的区域,二是把循环外的汇总标签(比如{{total}})留给总数据。渲染时 POI-TL 会遍历 items 里的每个 Map,循环区域中的{{dept}}、{{conclusion}}会自动取当前行的值。样式完全由总模板控制,源文档长什么样不重要,重要的只有字段值。
这就是为什么我推荐用它做汇总:你不需要关心 7 份周报各自的段落顺序,只需要能稳定提取字段。字段提取用 POI 的 XWPFTable 遍历即可,后面第 3 章给完整代码。
2.2 内容合并:异构文档片段的模板化插入
内容合并要处理的是另一种需求:源文档之间没有任何结构共性,比如合同附件、公告、会议纪要,要把这些正文原样并入母报告。POI-TL 对此的解法是绑定自定义渲染策略:总模板里放一个{{fragment}}标签,代码读取每份源文档的段落,转成轻量片段对象放进 List,然后由你写的 RenderPolicy 在标签位置逐段生成新 run。
本质上这还是模板渲染,只是数据形态从"键值对"变成了"文档片段"。好处是插入顺序由模板决定,哪份文档放在哪一节、哪份不放,都在母模板里看得见;将来要加条件,比如某批次验收通过才插入对应附件,只要在模板里再包一层判断标签即可,不用改代码。
2.3 选型边界:什么时候该绕开 POI-TL
数据合并和内容合并不是二选一的对手,它们是两个适用面。落地前先对比一下:
| 场景 | 推荐模式 | 理由 |
|---|---|---|
| 多份同模板周报合并成月报 | 数据合并 | 字段稳定,样式统一,循环块一次渲染 |
| 多个合同附件拼入主合同 | 内容合并 | 附件格式不同,需保留原文顺序 |
| 多张填报表合并成汇总表 | 数据合并 | 表格结构固定,渲染策略更可靠 |
| 多个已完成文档直接首尾相连 | 都不推荐 | 用 POI 遍历 body 元素逐段复制更快 |
如果只是按顺序把几个独立 docx 拼在一起,我一般不推荐上 POI-TL,直接用 XWPFDocument 的 body 元素复制反而少折腾。POI-TL 的价值出现在"合并过程中有判断、有汇总字段、有动态章节"的时候,这些是手写 POI 最容易翻车的地方。
3. 数据合并模式落地:读 N 份源文档并渲染成一份汇总 Word
数据合并不是高深技巧,难在把流程走完整:配版本、做模板、抽字段、渲染输出。下面按最小可跑通的方式拆开。
3.1 环境准备:poi-tl 与 POI 的版本搭配
poi-tl 不是独立解析 docx 的库,它是在 Apache POI 的 XWPF 组件上封装的模板引擎,所以版本必须配套。我常用的组合是 poi-tl 1.12.x 配 POI 5.2.x:
<!-- POI 5.2.x 与 poi-tl 1.12.x 是常用的配套组合,版本以你仓库实际拉取的为准 --> <dependency> <groupId>org.apache.poi</groupId> <artifactId>poi-ooxml</artifactId> <version>5.2.5</version> </dependency> <dependency> <groupId>com.deepoove</groupId> <artifactId>poi-tl</artifactId> <version>1.12.1</version> </dependency>两个坑先说在前面。第一,poi-tl 自带传递依赖,你显式声明的 poi-ooxml 版本如果和它编译期版本差太远,启动时大概率抛 NoSuchMethodError,报错指向的都是 POI 内部类。第二,如果你在维护老项目用的是 poi-tl 1.10.x,那 POI 要配合 4.1.2,别混着升级。升级 POI 大版本时,记得跑一遍所有模板回归,POI 内部 API 变化会影响 XWPFRun 的样式读写。
3.2 模板准备:循环块、汇总位与最小模板实例
母模板建议用 Word 直接做,存成 .docx。先用最小模板跑通流程,再往里加真实章节。模板内容示意如下:
汇总说明:本批共收到 {{total}} 份材料。 {{?items}} 报告单位:{{dept}} 验收结论:{{conclusion}} 提交日期:{{date}} {{/items}} 签发意见:{{opinion}}注意{{?items}}与{{/items}}之间的区域会被循环渲染,items 对应 Java 侧的List<Map>;循环外的{{total}}、{{opinion}}对应普通字符串或数字。{{?items}}这行本身不会显示,它是循环边界。如果你在{{/items}}后面误加了一个空段落,每轮循环都会把空段带出来,最终文档里全是空行,这是新手最常见的模板问题。
3.3 读取 N 份源文档:用 POI 把字段从表格里抽出来
源文档如果是同一模板生成的,字段大概率落在表格里:左边一列键名、右边一列值。用 POI 读这种结构非常稳:
// 读取"源文档"目录下所有 docx,从每个文档的表格中提取键值对 public static Map<String, Object> extractFields(XWPFDocument doc) { Map<String, Object> map = new HashMap<>(); for (XWPFTable table : doc.getTables()) { for (XWPFTableRow row : table.getRows()) { List<XWPFTableCell> cells = row.getTableCells(); if (cells.size() < 2) continue; // 跳过不足两列的行 String key = cells.get(0).getText().trim(); String val = cells.get(1).getText().trim(); if (!key.isEmpty()) map.put(key, val); } } return map; }这段代码只取每行前两个单元格,把第一格当键、第二格当值。掉到坑里要注意:有的模板表格自带标题行,提取前先跳过表头;还有的源文档里一张表被拆成多段,键值对混在段落里,这时候就得改成逐行扫描段落文本,比如匹配编号:xxx这种固定前缀。判断标准很简单,先打印一个文档的提取结果,看键名是不是你要的字段,再批量处理。
3.4 组装数据模型并渲染输出
字段提取完,接下来就是合并渲染。所有源文档的 Map 收进一个 List,作为循环数据:
List<Map<String, Object>> items = new ArrayList<>(); File dir = new File("source-docs"); File[] files = dir.listFiles((d, name) -> name.endsWith(".docx")); for (File f : files) { // 逐个打开源文档,提取完字段就关闭,避免文件句柄堆积 try (XWPFDocument doc = new XWPFDocument(Files.newInputStream(f.toPath()))) { items.add(extractFields(doc)); } } Map<String, Object> data = new HashMap<>(); data.put("items", items); // 循环区数据 data.put("total", items.size()); // 循环外汇总字段 data.put("opinion", "同意归档"); // 普通文本字段 File outDir = new File("build"); if (!outDir.exists()) outDir.mkdirs(); try (XWPFTemplate template = XWPFTemplate.compile("templates/summary.docx")) { template.render(data); // 渲染数据模型,标签在此步被替换 try (FileOutputStream out = new FileOutputStream("build/merged.docx")) { template.write(out); // 渲染结果写盘 } }render 是渲染动作,write 是落盘动作,close 是释放动作,顺序不能乱。我强调两点:一是template.write()之前必须 render,否则模板文件原样复制出去,标签一个都没替换;二是template.close()别忘了,docx 本质是 zip 包,流不关轻则资源泄漏,重则写出来的文件损坏。
4. 内容合并模式落地:自定义渲染策略把文档片段拼进母模板
数据合并能覆盖七八成的汇总场景,剩下的就是内容合并。你拿到的是已经写好的 docx,结构五花八门,要整段并入母报告。这一步最稳妥的落地方式是:抽片段、转轻量对象、自定义 RenderPolicy 插入。
4.1 为什么不能直接把 XWPFParagraph 当数据传
最直觉的做法是把源文档的 XWPFParagraph 对象直接塞进 data map,让策略去复制。血泪经验是这条路走不远:XWPFParagraph 是 POI 对文档内存结构的活引用,源文档的 XWPFDocument 一旦 close,这些段落对象就处于不可用状态,轻则取到的文本是空,重则抛空指针;而如果不 close,又会把一堆文件句柄攒在内存里。
另一个麻烦是表格。跨文档复制 XWPFTable 涉及底层 XML 的 body 元素迁移,POI 的公开 API 对这件事支持很弱,强行复制出来的表格经常丢边框、丢合并单元格。所以我的习惯是:段落走自定义策略复制,表格里的数据抽出来改走数据合并。别跟 POI 的表格复制较劲。
4.2 抽取轻量片段:文本、加粗、字号、中文字体
既然不能直接传活对象,就退一步:只抽真正需要的东西,也就是文本、加粗、字号、字体。定义一个轻量对象:
// 轻量文档片段对象,只保留渲染需要的最小样式信息 public class TextFragment { private String text; private boolean bold; private int fontSize = 10; private String fontFamily = "宋体"; // 省略 getter / setter }抽取逻辑如下,段落优先用第一个 run 的样式代表整段样式,这个近似对绝大多数正式文档够用:
private static List<TextFragment> extractParagraphs(XWPFDocument doc) { List<TextFragment> result = new ArrayList<>(); for (XWPFParagraph p : doc.getParagraphs()) { String text = p.getText().trim(); if (text.isEmpty()) continue; // 跳过空段,避免合并后全是空行 TextFragment f = new TextFragment(); f.setText(text); if (!p.getRuns().isEmpty()) { XWPFRun r = p.getRuns().get(0); f.setBold(r.isBold()); int fontSize = r.getFontSize(); if (fontSize > 0) f.setFontSize(fontSize); if (r.getFontFamily() != null) f.setFontFamily(r.getFontFamily()); } result.add(f); } return result; }注意getFontFamily()取到的主要是 ASCII 字体,中文很多时候靠 eastAsia 属性生效,所以 4.3 节的策略里要单独处理中文字体。这也是为什么抽取时要把 fontFamily 留出来,而不是硬编码。
4.3 自定义渲染策略:在标签位置生成 run
策略类的核心逻辑是三步:清掉标签、在锚点段落上新建 run、把片段逐条写进去。以 poi-tl 1.12.x 的 API 为例:
// 自定义策略:把 List<TextFragment> 渲染到 {{fragment}} 标签处 public class FragmentMergePolicy implements RenderPolicy { @Override public void render(Element element, Object data) { if (!(data instanceof List)) return; XWPFRun tagRun = element.getXWPFRun(); // 标签所在的 run tagRun.setText("", 0); // 第一步:清掉 {{fragment}} XWPFParagraph anchor = tagRun.getParagraph(); for (Object item : (List<?>) data) { if (!(item instanceof TextFragment)) continue; TextFragment f = (TextFragment) item; XWPFRun newRun = anchor.createRun(); // 第二步:在锚点段落新建 run newRun.setText(f.getText(), 0); newRun.setBold(f.isBold()); newRun.setFontSize(f.getFontSize()); newRun.setFontFamily(f.getFontFamily()); // 第三步:单独设置中文字体,否则中文会退化到文档默认字体 CTRPr rPr = newRun.getCTR().getRPr(); if (rPr == null) rPr = newRun.getCTR().addNewRPr(); if (rPr.getRFonts() == null) rPr.addNewRFonts(); rPr.getRFonts().setEastAsia(f.getFontFamily()); } } }getCTR()是 POI 暴露底层 XML 对象的入口,中文字体绕不开它。绑定策略要在 compile 之前完成:
Configure config = Configure.builder() .bind("fragment", new FragmentMergePolicy()) // 模板里 {{fragment}} 交给自定义策略 .build(); try (XWPFTemplate template = XWPFTemplate.compile("templates/main.docx", config)) { Map<String, Object> data = new HashMap<>(); data.put("fragment", extractParagraphs(sourceDoc)); // 某份源文档的正文片段 template.render(data).write(new FileOutputStream("build/merged-content.docx")); }提示:不同 poi-tl 小版本的 Element API 略有差异,核心是先拿到标签所在的 run,之后的操作都是 XWPFRun 的 POI 原生方法。
如果有多份源文档要插入不同位置,就在模板里分别放{{part1}}、{{part2}},多个标签各配一个策略实例,data map 的 key 与标签名保持一致。所有片段渲染完成后,锚点段落会变成一串拼接起来的新 run,Word 打开完全正常。
4.4 分页控制与空段落处理
内容合并最常见的格式诉求是按源文档分页。做法是在每个源文档的片段末尾加一个分页符:
// 在每个源文档的片段末尾追加分页 if (isLastFragmentOfThisSource) { XWPFRun pageBreakRun = anchor.createRun(); pageBreakRun.addBreak(BreakType.PAGE); // POI 原生能力,分页符写入当前段 }addBreak(BreakType.PAGE)是 XWPFRun 的原生方法,不是 poi-tl 的,但它和模板渲染不冲突。另一个经验:抽出片段时如果没跳过空段,合并后的文档会每隔一段就夹一个空行,看着像排版没对齐,实际是源文档里本来就有的空段落被一起复制了。我在 4.2 的抽取代码里特意跳过了空段,这条建议记下来。
5. POI-TL合并Word文档常见问题排查:5条踩坑记录
以下是这几年做文档合并最常见的 5 个现场,每个都是现象先行,再给原因和解决办法。
5.1 循环块渲染出 N 行,但每行内容全都一样
现象:{{?items}}循环区确实输出了多行,但每行内容相同,而且都是最后一条数据。
原因:代码里复用了同一个 HashMap 实例,循环内只改值不 new,往 List 里 add 的是同一个引用。这是 Java 基本功问题,但 POI-TL 的循环会把问题放大成整页错误数据。
解决:每次往 items 里 add 前new HashMap<>();如果字段来自 extractFields 方法返回的 map,确认每个文档调用返回的是新对象。
5.2 合并后的 docx 打开就提示文件损坏
现象:合并程序没报错,输出文件大小也正常,但 Word 打开直接提示文件已损坏,修复都修不回来。
原因:绝大多数是输出流关闭顺序问题。template.write(out)之后,如果 out 没 close 或者模板没 close,POI 写出的 zip 包缺少完整尾部结构,就会被判损坏。也有少部分是模板本身有问题,比如用其他办公软件另存的 docx 结构不规范。
解决:统一用 try-with-resources,先 write 再让 out 和 template 依次关闭;换一个 Word 自带的模板文件测试,排除模板源问题。
5.3 模板里的图片标签变成了"图片"两个字
现象:{{cover}}标签原样输出成"图片",或者渲染后是空白。
原因:data map 里给的是字符串路径data.put("cover", "assets/cover.png"),poi-tl 不知道这是个图片,默认按文本策略渲染成字面内容。
解决:显式包一层图片对象:
import com.deepoove.poi.data.PictureRenderData; Map<String, Object> data = new HashMap<>(); data.put("cover", new PictureRenderData(240, 160, "assets/cover.png"));width、height 单位是像素,图片超过一定体量时部分 POI 版本会抛异常,先压缩再渲染。
5.4 内容合并插入的中文全变成默认宋体
现象:用自定义策略插入的段落,英文和数字字体正常,中文却退回默认宋体,模板里明明设置的是其他字体。
原因:中文字体存放在 run 的 rFonts 属性里的 eastAsia 字段,setFontFamily()设置的多是 ascii/hAnsi,管不住中文。新创建的 run 又不会继承模板段落样式,于是中文丢字体。
解决:按 4.3 节的做法,拿到 CTRPr 的 RFonts,单独 setEastAsia。这是内容合并绕不开的一步,我见过太多人在这里改了半天模板一无所获,最后一看是 eastAsia 没设置。
5.5 并发渲染同一个模板实例,结果互相串内容
现象:同一进程里多个线程复用同一个 XWPFTemplate 实例渲染不同数据,文档一会是 A 请求的内容,一会混着 B 请求的表格,偶尔还抛并发异常。
原因:XWPFTemplate编译后内部持有可变的 XWPFDocument 状态,render()不是线程安全的,生产环境实测就会串数据。
解决:每次请求都重新compile(),或者用一个按模板路径缓存的工厂方法,compile 完立即 render,绝不要把一个实例挂到静态变量里复用。如果确实想省 compile 开销,按模板路径做 ThreadLocal 缓存,注意用完 close。
6. 合并结果验证与一个落盘技巧:内存快照先于文件写入
6.1 用断言代码做最小回归
合并代码最怕的不是报错,而是静默生成一份标签没替换干净的坏文档。我的习惯是每次跑完合并立刻读回输出文件,做两个断言:全文档不能残留{{字符,关键字段必须出现。
try (XWPFDocument check = new XWPFDocument(new FileInputStream("build/merged.docx"))) { String allText = check.getParagraphs().stream() .map(XWPFParagraph::getText) .collect(Collectors.joining("\n")); assert !allText.contains("{{") : "模板标签未渲染干净"; assert allText.contains("同意归档") : "关键内容缺失"; }模板每改一次就跑一遍这个回归,比任何测试框架都直接。
6.2 进阶:先渲染成字节数组,校验通过再落盘
批量合并 7 份、14 份文档时,我从来不直接 write 到最终文件,先渲染进内存:
byte[] bytes; try (XWPFTemplate template = XWPFTemplate.compile("templates/summary.docx")) { template.render(data); try (ByteArrayOutputStream bos = new ByteArrayOutputStream()) { template.write(bos); bytes = bos.toByteArray(); // 内存快照,尚未落盘 } } // 用上面的断言校验 bytes 对应的 XWPFDocument,通过后再落盘 Files.write(Paths.get("build/final-merged.docx"), bytes);好处有两个:校验不过就不落盘,不会产生一堆需要手工清理的坏文件;多个总模板可以先各自渲染成字节数组,最后统一合并,顺序和异常都好控制。这个习惯我从某交付项目一直用到现在,确实省了来回删文件的时间。希望帮到你。
本文还有配套的精品资源,点击获取