简介:面向Java开发者的POI-TL合并多个Word文档示例压缩包,主题清晰:利用Apache POI与POI-TL库,在Java环境中将多个.docx文件顺次合并为一个输出文档,解决手工拼接周期长、格式不一致的问题。压缩包大小约3.96MB,下载页未标注文件总数与类型明细,但围绕XWPFDocument的操作逻辑完整,涵盖文档对象创建、源文件读取、段落和表格元素遍历、内容复制到目标文档、最后保存等环节。这类方案非常适用于批量合同生成、多部门报告汇总、投标文件组装等办公自动化场景。目前已有3526人学习浏览,适合有Java编程基础、正在攻关Word批处理功能的开发者。通过这份示例,读者能理清POI中XWPF系列API的调用层次,掌握保留段落格式、处理表格边框以及图片等复杂元素的技巧;进一步结合POI-TL模板占位符,还能实现动态数据填充,让同一模板快速产出多份结构一致的文档,从而在自有系统中封装出稳定高效的Word合并与生成服务。整体结构清晰,可直接作为项目中文档自动化模块的起步脚手架。
1. POI-TL合并多个Word文档:别再手动Ctrl+C/V了
接到一个项目,要把几十个Word文档合并成一份总报告。如果靠人工复制粘贴,光是标题编号、表格边框和图片位置就能熬到凌晨,而且改一次源文件就要整个重来。POI-TL合并多个Word文档是Java侧最常用的处理方式:poi-tl负责按模板渲染出结构化片段,Apache POI负责在底层把多个docx的段落、表格、图片真正拼进一个新文档。组合起来,脚本跑一遍,几分钟就出结果,源文件更新后重新执行一次即可。
这篇文章想解决三件事:方案怎么选、代码怎么写、哪些坑绕不开。适合用Java做文档生成、批量导出报表的开发者,也适合刚接触POI和poi-tl、不清楚两者边界在哪的新手。我会从原理讲到可运行的代码,再把高频翻车点按“现象→原因→解决”拆开,最后给出合并后的自动校验方法。全程不绕弯,直接落代码和参数。
2. 合并方案的技术拆解:POI和poi-tl分别在解决什么
2.1 poi-tl和Apache POI的边界:模板渲染与文档对象操作
先说清楚两个库的分工。Apache POI是一组底层API,能直接读写Word的XML对象模型,比如XWPFDocument代表整个文档,XWPFParagraph代表一个段落,XWPFRun代表一段带格式的文本,XWPFTable代表表格。它能做到的事情最底层,但也最啰嗦,要手动处理样式、图片、关系文件这些细节。
poi-tl是基于POI封装出来的Word模板引擎。它的定位是“模板+数据”:你在Word里写好{{title}}、{{list}}这样的占位符,然后用一个Map把数据填进去,它帮你渲染成最终段落和表格。它擅长的是生成单个文档、批量渲染同一套模板,而不是把多个文档拼接起来。
合并场景里,这两个库其实是前后配合的关系:poi-tl先把每个独立章节从模板渲染成XWPFDocument对象,POI再把这些对象里的body元素逐个复制到目标文档。所以“POI-TL合并多个Word文档”不是某个现成API调用,而是一条组合技术路线。理解了这层,后面看代码就不会觉得别扭。
2.2 三种常见的合并思路与选型对比
处理多文档合并,我在不同项目里见过三种主流做法,各有取舍。
第一种:直接对底层XML节点操作。把源文档的CTP段落节点查出来,用addNewElement之类的方式追加到目标文档的body里,然后手动刷新文档的段落索引。这个方案代码量最小,但很容易让Word打开时报“内容损坏”,因为样式ID、关系ID没有跟着迁移,而且节点顺序一旦错位,整个文档结构就乱了。
第二种:逐元素复制。遍历源文档body里的段落和表格,用POI的对象级API——createParagraph、createRun、createTable——在目标文档里重建内容,同时把格式、图片、超链接一并搬过去。代码长一点,但每一步都可控,出问题能定位到具体元素。这是目前最稳的方案,也是我推荐的做法。
第三种:先把所有源文档用poi-tl统一渲染成同一套干净模板的产物,再合并。这种做法适合所有章节都来自同一个模板的批量报告,比如周报、检测报告,能最大程度避免样式冲突,因为模板天然一致。但面对来源杂乱的存量文档,没法强制套模板。
下面这张表是三个方案的直观对比:
| 方案 | 代码量 | 稳定性 | 样式保留 | 与poi-tl配合 | 适用场景 |
|---|---|---|---|---|---|
| 底层XML节点追加 | 少 | 较低,易损坏文档 | 差 | 不直接 | 快速原型、临时脚本 |
| 逐元素复制 | 多 | 高 | 好 | 直接 | 生产环境、混合来源文档 |
| 统一模板渲染后合并 | 中 | 高 | 最好 | 强 | 同模板批量生成 |
2.3 为什么最终选择“逐元素复制 + poi-tl配合”
我选择逐元素复制,核心原因是它把“合并”这个动作拆成了最小粒度的对象操作:段落归段落、表格归表格、图片归图片,每一步失败都能精准定位。另一个原因是它和poi-tl的配合最自然。poi-tl渲染出来的XWPFDocument本身就是一个标准的POI文档对象,直接丢给逐元素复制的函数去处理,不需要做任何格式转换。
这套组合还有个好处:对源文档的来源没有限制。既可以是poi-tl从模板渲染出来的,也可以是用户上传的旧文档,甚至是另一个合并产出的文档。只要它在POI里能被XWPFDocument打开,就能被合并。这正好覆盖了生产环境里最头疼的“文档来源五花八门”的问题。
3. 用POI-TL合并多个Word文档:逐元素复制的核心实现
3.1 依赖引入与最简单的合并流程
先在pom.xml里引入依赖。POI主库和poi-tl必须一起上,同时记得把poi-ooxml-full也带上,否则一些XMLBeans类型在运行时找不到。版本选择上,POI用5.x分支的较新稳定版,poi-tl用1.12.x分支,具体以你项目仓库里的release为准,但不要混用跨度太大的版本,容易碰到方法签名不兼容。
<dependency> <groupId>org.apache.poi</groupId> <artifactId>poi-ooxml</artifactId> <version>5.2.5</version> </dependency> <dependency> <groupId>org.apache.poi</groupId> <artifactId>poi-ooxml-full</artifactId> <version>5.2.5</version> </dependency> <dependency> <groupId>com.deepoove</groupId> <artifactId>poi-tl</artifactId> <version>1.12.1</version> </dependency>主流程非常简单:新建一个空的目标文档,循环读取每个源文档,调用合并方法,最后写文件。下面这段代码只负责流程骨架,真正的合并逻辑在mergeDocument方法里,后面两节会展开。
public static void main(String[] args) throws Exception { File outputFile = new File("merged.docx"); List<String> sourcePaths = Arrays.asList("part1.docx", "part2.docx", "part3.docx"); try (XWPFDocument target = new XWPFDocument()) { for (String path : sourcePaths) { try (XWPFDocument source = new XWPFDocument(new FileInputStream(path))) { mergeDocument(target, source); } } try (FileOutputStream out = new FileOutputStream(outputFile)) { target.write(out); } } }这段代码要注意几个参数细节。target用try-with-resources自动关闭,避免内存里残留文件句柄;每个源文档在合并完后就立刻关闭,这是应对大文档合并内存溢出的第一道防线。FileOutputStream和outputFile的路径如果带中文目录,建议直接用File构造,少走字符串拼接的坑。
3.2 段落合并代码:复制段落属性和run内容
段落合并是整个方案的核心,也是最容易出错的地方。不能图省事直接把源段落的CTP节点塞给目标文档,那样样式ID和关系ID会失效。正确做法是在目标文档里新建一个XWPFParagraph,再逐项复制属性、文本、图片和特殊符号。
我先给出段落复制的完整方法,然后逐段解释关键参数。
private static void mergeParagraph(XWPFParagraph srcPara, XWPFParagraph targetPara) throws Exception { // 复制段落级属性:缩进、对齐、行距、编号等 if (srcPara.getCTP().getPPr() != null) { CTPPr pPr = (CTPPr) srcPara.getCTP().getPPr().copy(); targetPara.getCTP().setPPr(pPr); } // 复制run:文本、字体、图片、换行 for (XWPFRun srcRun : srcPara.getRuns()) { // 创建目标run,默认追加到段落末尾 XWPFRun targetRun = targetPara.createRun(); // 复制run级字符属性:字体、字号、颜色、加粗、下划线 if (srcRun.getCTR().getRPr() != null) { CTRPr rPr = (CTRPr) srcRun.getCTR().getRPr().copy(); targetRun.getCTR().setRPr(rPr); } // 文本内容:按segment拼接,避免getText(0)丢失多段文本 StringBuilder sb = new StringBuilder(); for (var segment : srcRun.getTextSegments()) { sb.append(segment.getText()); } targetRun.setText(sb.toString(), 0); // 换行符:按源run里的br数量补齐 int brCount = srcRun.getCTR().sizeOfBrArray(); for (int i = 0; i < brCount; i++) { targetRun.addBreak(); } // 图片:单独处理,见4.2 copyRunPictures(srcRun, targetRun); } }这里最关键的参数是setText的第二个参数,写0表示从run内容索引0的位置写入。如果你写成appendText,它会在末尾追加一个新的w:t节点,当一个run里本身有多段文本时,appendText和setText混用会导致文本顺序错乱。另一个容易被忽略的是getTextSegments,它返回run内部所有文本分段,用getText(0)只能拿到第一段,所以必须循环拼接。
关于addBreak,我踩过坑:直接setText原始文本时,Word文档里的换行符不会被自动转换,硬塞进去会变成空格甚至非法字符。所以要单独统计源run里的换行数量,再用addBreak补齐。如果你的源run里既有穿插的换行又有文本,这个简化版会丢失穿插位置,更严谨的做法是按CTR子元素顺序遍历,遇到w:t就写文本、遇到w:br就addBreak。需要的时候再升级。
3.3 表格合并代码:行、单元格与嵌套段落
表格合并不能直接new XWPFTable然后把源行复制过来,因为每个XWPFTableRow内部关联的XML节点在目标文档里并没有对应的容器关系。我的做法是按“表格属性→行→单元格→单元格内段落”逐层重建,每一层都复制XML属性对象。
private static void mergeTable(XWPFTable srcTable, XWPFTable targetTable) throws Exception { // 复制表格级属性:对齐方式、表格宽度、边框 if (srcTable.getCTTbl().getTblPr() != null) { CTTblPr tblPr = (CTTblPr) srcTable.getCTTbl().getTblPr().copy(); targetTable.getCTTbl().setTblPr(tblPr); } for (XWPFTableRow srcRow : srcTable.getRows()) { XWPFTableRow targetRow = targetTable.createRow(); // 复制行属性:行高、行高类型 if (srcRow.getCTRow().getTrPr() != null) { CTTrPr trPr = (CTTrPr) srcRow.getCTRow().getTrPr().copy(); targetRow.getCTRow().setTrPr(trPr); } // createRow会生成一个空单元格,先清掉 while (targetRow.getTableCells().size() > 0) { targetRow.removeCell(0); } for (XWPFTableCell srcCell : srcRow.getTableCells()) { XWPFTableCell targetCell = targetRow.addNewTableCell(); // 复制单元格属性:宽度、合并、垂直对齐 if (srcCell.getCTTc().getTcPr() != null) { CTTcPr tcPr = (CTTcPr) srcCell.getCTTc().getTcPr().copy(); targetCell.getCTTc().setTcPr(tcPr); } // 单元格里可能有多段文本,复用段落合并逻辑 for (IBodyElement element : srcCell.getBodyElements()) { if (element instanceof XWPFParagraph) { XWPFParagraph newPara = targetCell.addParagraph(); mergeParagraph((XWPFParagraph) element, newPara); } } } } }这段代码里最值得说的是removeCell(0)这个动作。XWPFTableRow.createRow()在底层XML中会带出一个默认的w:tc节点,如果不先清掉就直接addNewTableCell,结果就是每行都比源表多出一列,而且这个空列还会带着默认宽度把整张表撑变形。如果你用的POI版本里removeCell方法不存在,替代方案是拿到row.getCTRow(),用getTcList().clear()把该行下的tc节点全部清空。
嵌入式表格的情况会更复杂。如果源单元格里还有嵌套表格,上面的代码会把嵌套表格丢掉,只会保留段落。处理嵌套表格需要在遍历srcCell.getBodyElements()时额外判断element instanceof XWPFTable,然后递归创建嵌套表格。这个递归逻辑本身不复杂,但要注意每次递归都要新建表格对象,不能复用同一个XWPFTable实例。
3.4 与poi-tl模板渲染串联:从模板到合并结果
现在把poi-tl接进来。常见做法是先用poi-tl渲染一个章节模板,把渲染出来的文档对象直接交给合并函数,让poi-tl和POI在同一个流程里协作。
import com.deepoove.poi.XWPFTemplate; import com.deepoove.poi.config.Configure; // 准备模板数据 Map<String, Object> data = new HashMap<>(); data.put("chapterTitle", "第二章 实验设计"); data.put("desc", "这里是模板渲染出的正文说明"); // 编译模板并渲染,返回的XWPFTemplate内部持有XWPFDocument XWPFTemplate template = XWPFTemplate.compile("chapter_template.docx", Configure.builder().build()).render(data); XWPFDocument chapterDoc = template.getXWPFDocument(); mergeDocument(target, chapterDoc);这里有个容易忽略的细节:poi-tl的render方法如果传入OutputStream,会把结果写进流;如果只传Map,返回的XWPFTemplate对象内部才持有完整的文档对象。要拿到XWPFDocument继续合并,必须用无OutputStream的render重载,然后调用getXWPFDocument()。模板渲染完成且合并结束后,记得把template和chapterDoc都关闭,它们底层持有同一个文件句柄和内存对象。
这套组合的真正价值在于,你可以把“不同章节用不同模板渲染”和“渲染结果合并成总报告”拆成两个独立阶段。新加一个章节,只需要新增一个模板文件和一个Map,合并主流程完全不用动。这在批量生成标书、检测报告这类场景里非常实用。
4. 合并时的样式与资源:参数配置与四个关键处理
4.1 样式继承与冲突:先把styles.xml统一起来
样式问题在合并时最隐蔽。源文档里一个段落引用了一个名为“Heading1”的样式ID,但目标文档的styles.xml里没有这个ID,Word打开时会静默回退到默认样式,看起来就是字体大变、编号丢失,甚至报告文档损坏。逐元素复制PPr属性只能解决内联格式,解决不了样式字典缺失。
我从项目里总结出来的第一道防线是:合并前先检查源文档是否基于同一套模板。如果所有章节都是用同一个模板生成的,样式ID天然一致,根本不需要额外复制样式。如果来源不同,就得在合并前同步样式定义。下面这段代码按需把源文档的样式合并到目标文档中。
private static void mergeStyles(XWPFDocument target, XWPFDocument source) throws Exception { XWPFStyles srcStyles = source.getStyles(); XWPFStyles tgtStyles = target.getStyles(); if (srcStyles == null || tgtStyles == null) { return; } CTStyles ctSrcStyles = srcStyles.getCTStyles(); CTStyles ctTgtStyles = tgtStyles.getCTStyles(); for (CTStyle ctStyle : ctSrcStyles.getStyleList()) { String styleId = ctStyle.getStyleId(); boolean exists = false; for (CTStyle existing : ctTgtStyles.getStyleList()) { if (styleId.equals(existing.getStyleId())) { exists = true; break; } } if (!exists) { ctTgtStyles.addNewStyle().set(ctStyle); } } }这段代码直接操作CTStyles,好处是绕开了XWPFStyles在不同POI版本中方法不稳定的问题。addNewStyle().set(ctStyle)是XMLBeans的标准做法,把源样式节点深拷贝到目标样式列表。要特别注意styleId冲突的判断标准:不光要判断ID存在,还要看basedOn和next指向的样式ID是否也都存在。如果某个样式依赖的父样式没被复制进来,这个复制过去的样式同样会失效。
样式合并的顺序也有讲究。必须在复制任何段落或表格之前先执行mergeStyles,因为段落复制过程中setPPr只是建立了一个指向样式ID的引用,并不负责检查样式是否存在。等到最后再补样式,Word解析文档属性时就会漏掉中间状态。
4.2 图片复制:重新写入二进制并保留原始尺寸
图片是合并翻车的重灾区。直接用XML节点复制图片,结果通常是目标文档里留下一堆指向不存在关系文件的红叉。原因在于docx中的图片并不在body XML里,而是通过rId关联到word/media目录下的二进制文件,body XML里只有一个引用标记。复制段落时如果不把图片二进制同步写入目标文档的PackagePart,引用就断了。
正确做法是通过XWPFRun的addPicture方法,把源图片的二进制数据重新写进目标文档,再建立新的关系。下面是合并时调用图片处理的完整代码。
private static void copyRunPictures(XWPFRun srcRun, XWPFRun targetRun) throws Exception { List<XWPFPicture> pictures = srcRun.getEmbeddedPictures(); if (pictures.isEmpty()) { return; } for (XWPFPicture picture : pictures) { XWPFPictureData picData = picture.getPictureData(); byte[] data = picData.getData(); int pictureType = picData.getPictureType(); String ext = picData.suggestFileExtension(); // 尝试读取原图尺寸,读不到就回退到200x200px long widthEmu = 200 * 9525; // 200px long heightEmu = 200 * 9525; try { CTPoint2D extPoint = picture.getCTPicture().getSpPr().getXfrm().getExt(); if (extPoint != null) { widthEmu = extPoint.getCx(); heightEmu = extPoint.getCy(); } } catch (Exception ignored) { // 拿不到尺寸信息时使用默认值 } targetRun.addPicture(new ByteArrayInputStream(data), pictureType, "image." + ext, widthEmu, heightEmu); } }addPicture的尺寸参数单位是EMU,不是像素也不是磅。9525这个换算系数是固定的:1像素等于9525 EMU。如果直接传像素值,图片会变成一张极小的缩略图,肉眼几乎看不见。另一个常见坑是pictureType,必须用picData.getPictureType()返回的常量,比如JPEG就传XWPFDocument.PICTURE_TYPE_JPEG,不能自己根据扩展名去猜,否则POI在写数据时会用错误的格式头包装图片。
图片复制还有一个隐藏问题:同一张图片在多个段落里被反复引用。逐元素复制时,每遇到一个引用就会重新写入一份二进制,导致合并后的文档体积暴涨。如果源文档里图片数量多,可以在mergeDocument外面维护一个以源图片hash为key的map,遇到相同的图片直接复用已经写入目标文档的rId。这块优化对体积敏感的场景很重要,但对一般报告合并不是必须的。
4.3 页眉页脚与页码:合并边界的取舍与连续页码
合并多个文档时,页眉页脚的处理原则是:别把每个源文档的页眉页脚都搬过来,否则一份报告里会有好几套互相冲突的页眉。我一般只保留第一份文档的页眉页脚,或者在合并完成后统一覆盖。逐元素复制的函数默认不复制页眉页脚,这正是想要的隔离效果。
如果你需要给合并后的主文档统一加页眉页脚,操作比想象中简单:用target.createHeader(HeaderFooterType.DEFAULT)创建页眉,然后像写普通段落一样填充内容。连续页码则要插入PAGE域代码,下面是一个可以用的简化实现。
XWPFHeaderFooter footer = target.createFooter(HeaderFooterType.DEFAULT); XWPFParagraph p = footer.getParagraphs().isEmpty() ? footer.createParagraph() : footer.getParagraphs().get(0); p.setAlignment(ParagraphAlignment.CENTER); CTP ctP = p.getCTP(); // 域代码开始标记 CTFldChar begin = ctP.addNewFldChar(); begin.setFldCharType(STFldCharType.BEGIN); // 域代码文本 CTR r = ctP.addNewR(); r.addNewT().setStringValue(" PAGE "); // 域代码结束标记 CTFldChar end = ctP.addNewFldChar(); end.setFldCharType(STFldCharType.END);这段代码生成的“PAGE”域会在Word打开时动态计算页码,不需要手工修正。如果你用WPS打开,个别版本需要按F9刷新域才能显示正确页码,这是客户端行为,不是文档本身的问题。另一个常见的需求是“第X页共Y页”,在PAGE域的旁边再加一个NUMPAGES域即可,原理完全一样。
页眉页脚还有个容易忽略的细节:docx的页眉分首页、奇偶页、默认页三种类型。如果源文档里用了不同的首页页眉,合并后只想保留一种,最简单的方法是只设置DEFAULT类型的页眉页脚,把firstHeader和evenHeader显式删掉,否则旧关系残留会造成页眉错乱。
5. 合并多个Word文档的避坑记录:五个高频问题与排查思路
5.1 合并后提示文档损坏,或打开时提示“无法读取的内容”
现象:用POI合并后,文件能正常写出,但Word打开时弹“无法读取的内容”,点修复后又恢复正常。
原因:最常见的是直接复制CTP节点或CT tbl节点时,这些节点引用了目标文档中不存在的rId或样式ID。Word对坏引用非常敏感,宁可把整个内容标记为损坏也不显示局部错误。另一个原因是body元素顺序错乱,比如表格插到了段落索引没刷新的位置。
解决:严格按照逐元素复制的方式,段落用XWPFParagraph重建,表格用XWPFTable重建,不要直接往body的XML里append节点。如果已经出现了损坏文档,先用Word的“打开并修复”看具体是哪些引用报错,再回到代码里检查mergeStyles和copyRunPictures是否漏掉了某类对象。我排查这种问题时,习惯用一个最小的两页测试文档反复合,比拿真实大文档试错效率高得多。
5.2 图片全部显示红叉
现象:合并完成后,文档里的图片全部变成红色叉号,文字和表格正常。
原因:图片的二进制没有写进目标文档的PackagePart,只有body XML里的rId引用被复制了。目标文档的word/media目录下根本没有对应文件,自然渲染不出来。
解决:不要复制引用,要复制数据。按4.2节的方式,通过XWPFPictureData.getData()拿到字节流,再用targetRun.addPicture重新写入。注意addPicture的图片类型参数必须与二进制实际格式一致,写错后图片也能插入,但显示会花屏或打不开。排查时先看生成文档的体积,如果图片没写进去,体积会比源文档总和小很多,一眼就能发现问题。
5.3 合并后表格宽度错乱、边框消失
现象:表格内容完整,但列宽完全变形,有的列特别宽,有的列挤在一起,部分边框线消失。
原因:表格级属性TblPr里包含表格宽度、对齐方式和边框定义,行属性TrPr包含行高,单元格属性TcPr包含列宽和合并信息。逐元素复制时少复制任何一层,Word都会尝试用默认值补齐,结果就是错乱。这个坑尤其容易出现在createRow()自带的空白单元格上,那一列虽然没有内容,但宽度定义还在。
解决:三层属性必须都复制:CTTblPr、CTTrPr、CTTcPr。复制顺序也很重要,先TblPr再TrPr再TcPr,让每一层都能正确解析下层结构。另外要检查源表格里是否有水平合并或垂直合并的单元格,合并单元格的tcPr里会带gridSpan和vMerge属性,如果srcCell的TcPr不为空,直接copy就能保留合并信息,但前提是目标表格的grid列定义也同步了。grid定义在tblGrid里,这个也要一并复制,否则合并单元格的宽度会和grid冲突。
5.4 大文档合并直接内存溢出
现象:合并几十个文档或单个文档超过几十MB时,抛出OutOfMemoryError,或者程序卡在GC上长时间无响应。
原因:XWPFDocument是DOM模型,整个文档的XML树都驻留在内存里。合并N个文档就至少同时存在N+1棵完整的XML树,加上图片二进制,内存很容易被打爆。这是POI的固有特性,不是代码写错了。
解决:从两个方向压内存。第一个方向是控制同时打开的源文档数量,读完一个合并完就立即close,绝对不要把所有XWPFDocument对象先放进List再统一合并。第二个方向是调整JVM堆内存,给执行脚本设置-Xmx2g或更高,具体值根据文档大小评估。如果文档实在太大,就得换流式处理思路,用XWPFDocument的低层XML事件解析按段落分批处理,但这个复杂度高,一般报告合并用不上。
5.5 合并后段落顺序错乱或内容重复
现象:合并结果里,源文档的段落顺序被打乱,有的段落出现两次,有的段落跑到表格后面去了。
原因:XWPFDocument的body元素索引在每次createParagraph或createTable后都会变化。如果在遍历源文档的bodyElements时,边遍历边往目标文档写内容,同时又把源文档内容截断了,索引就会错位。内容重复则常见于同一个XWPFParagraph对象被两个目标段落引用,导致底层XML节点被复制两次。
解决:合并前先把源文档的所有body元素快照到一个List里,再遍历这个List,不要在遍历时修改源文档的任何内容。复制段落时要确保srcPara.getRuns()拿到的run对象是只读的,只用它读取数据,所有写入都发生在target段落上。检查是否重复,可以给每个复制的run打印一个内容摘要存到Set里,重复会出现完全相同的摘要,这个办法对定位循环合并很有用。
6. 进阶:指定位置合并、结果自动校验与工程化优化
6.1 在指定位置插入合并内容
默认的mergeDocument方法把内容追加到目标文档末尾。如果希望把某个章节插到指定段落的后面,可以借助XmlCursor控制插入位置。先用目标文档里的某个段落当作锚点,拿到它的CTP节点游标,再调用target.insertNewParagraph(cursor)创建新段落位置,后续的复制逻辑不变。
// 找到目标文档中的锚点段落,比如第5段 XWPFParagraph anchor = target.getParagraphs().get(5); XmlCursor cursor = anchor.getCTP().newCursor(); cursor.toEndToken(); // 从锚点位置创建新段落并写入源内容 XWPFParagraph newPara = target.insertNewParagraph(cursor); mergeParagraph(srcPara, newPara); cursor.dispose();这里必须手动调用cursor.dispose()释放游标资源,否则在循环插段落的场景下会产生大量XmlCursor对象,造成内存泄漏。insertNewParagraph返回的XWPFParagraph是目标文档body里的真实对象,可以放心往里写run。表格的指定位置插入更麻烦,POI没有直接对应API,我的做法是先把内容合并到临时文档,再用同样的XmlCursor方案把临时文档的段落逐段插到目标位置。
6.2 合并结果的自动化校验
合并代码改完后,每次都拿Word打开检查太累。我在CI流程里加了一段自动化校验,思路是重新打开合并结果,统计段落数、表格数、图片数,再和源文档累加值对比。数量对不上,说明有元素在合并过程中被吞了。
try (XWPFDocument merged = new XWPFDocument(new FileInputStream("merged.docx"))) { int paraCount = merged.getParagraphs().size(); int tableCount = merged.getTables().size(); int picCount = 0; for (XWPFParagraph p : merged.getParagraphs()) { picCount += p.getEmbeddedPictures().size(); } System.out.printf("段落数: %d, 表格数: %d, 图片数: %d%n", paraCount, tableCount, picCount); }除了数量校验,我还会用POI的ParagraphTextExtractor把所有段落文本抽出来,和源文档拼接后的文本做全文比对。这一步能抓到数量对上了但内容顺序不对的问题。文本比对建议用规范化处理,把连续空格和换行先统一,否则排版上的细微差异会让比对永远通不过。
6.3 工程化优化与我的血泪经验
工程化改造主要集中在三个点。第一,把所有合并方法收进一个工具类,输入参数简化为List 和File,内部处理打开、关闭、异常,业务方不需要接触任何POI对象。第二,对大图片做临时压缩,合并前把超过2MB的图片等比缩放,合并速度提升明显,前提是业务对图片精度要求不高。第三,多章节目录用模板集中渲染,不要把十几个独立的docx直接合并,而是先把每个章节渲染成干净的XWPFDocument再统一合并,样式冲突率会大幅下降。
我之前处理某个项目时就犯过懒,图省事直接调底层XML追加节点,结果合并完几十个文档,Word打开后没过几秒就提示修复,数据差点找不回来。后来老老实实改成逐元素复制,又花了一个晚上把图片和样式字典的复制补上,才彻底稳下来。从那以后,文档合并这条线我再也不敢走捷径,毕竟文件损坏这种事没有后悔药。希望这篇笔记能让你少走这些弯路,把合并方案一次做对。
本文还有配套的精品资源,点击获取