news 2026/10/12 4:02:06

POI-TL合并多个Word文档:数据合并与内容合并实战解析

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
POI-TL合并多个Word文档:数据合并与内容合并实战解析

简介:这是一份面向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);

好处有两个:校验不过就不落盘,不会产生一堆需要手工清理的坏文件;多个总模板可以先各自渲染成字节数组,最后统一合并,顺序和异常都好控制。这个习惯我从某交付项目一直用到现在,确实省了来回删文件的时间。希望帮到你。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/12 4:00:48

Linux挖矿木马kdevtmpfsi深度分析与实战清除指南

1. 项目概述&#xff1a;kdevtmpfsi不是内核进程&#xff0c;是伪装成内核线程的挖矿木马“服务器中kdevtmpfsi挖矿病毒及其解决方法”——这个标题一出现&#xff0c;很多运维同学第一反应是&#xff1a;“又一个杀不干净的顽固挖矿进程”。确实&#xff0c;kdevtmpfsi这个名字…

作者头像 李华
网站建设 2026/10/12 3:59:52

06 · VRAM 驻留窗口的两个坑

06 VRAM 驻留窗口的两个坑 一句话&#xff1a;显存装不下整模型时&#xff0c;要让设备侧只保留一层有界工作集——但「驱逐」这件事有两个反直觉的坑&#xff1a;驱逐未来层会让代价对 keep 完全平坦&#xff0c;用 cudaFree 做驱逐的同步抖动比省下的重传还贵。 前置&#x…

作者头像 李华
网站建设 2026/10/12 3:59:50

Work Agent深度解读:AI长程任务的执行机制与能力边界

AI的交互形态正在发生持续迭代&#xff0c;从最早期的单轮问答&#xff0c;到支持上下文记忆的多轮对话&#xff0c;再到可以调用外部工具完成特定动作&#xff0c;如今已经演化出能够自主推进多步骤工作链的Work Agent。普通对话型AI擅长即时应答&#xff0c;针对用户单次提出…

作者头像 李华
网站建设 2026/10/12 3:59:33

大语言模型的技术发展现状与应用场景解析

构建一个高质量的国外参考文献库&#xff0c;听起来很宏大&#xff0c;但其实就是把“找、管、用”这三件事做对。整个过程最关键的一步&#xff0c;是选对一个能陪你走完全程的“智能伙伴”。我强烈推荐 切问学术&#xff0c;它能让这件事从杂乱无序变得井井有条。 第一步&am…

作者头像 李华
网站建设 2026/10/12 3:59:24

GPU算力涨价潮下,如何选择平台与省钱实战指南

说句实在话&#xff0c;干深度学习这几年&#xff0c;我见过比模型loss还要让人心慌的东西&#xff0c;就是算力账单。2026年这波算力涨价潮来得既猛又急&#xff0c;GPU算力平台的时租价格一个季度内动辄上涨两到三成&#xff0c;很多原本低价能捡到的算力资源&#xff0c;一夜…

作者头像 李华
网站建设 2026/10/12 3:58:23

【知识讲解】 Linux之动静态库的了解

目录 前言 Part1. 库的意义 Part2. 静态库制作、编译与发布 Part2.1. 静态库制作步骤 Part2.2. 使用静态库编译业务代码 Part2.3. 静态库的打包分发 Part2.4. Makefile 自动化构建静态库、一键打包发布 Part3. 动态库制作、编译、部署 Part3.1. 动态库制作 Part3.2. …

作者头像 李华