资讯动态

POI-TL合并多个Word文档:两条路线与5个常见坑的完整解法

发布时间:2026/10/20 14:55:44 来源:尧图企业网站定制
简介面向Java后端开发与办公自动化实施人员这份资源系统讲解如何借助POI-TL技术实现多个Word文档的快速合并。POI-TL是Apache POI的扩展专门用于处理.docx格式提供了模板化编程接口支持占位符替换以及段落、表格的遍历复制。资源完整覆盖从读取源文档、创建目标文档到复制段落与表格内容并保留原始样式再到输出保存的整个流程同时介绍如何通过预设模板批量生成结构相似但内容不同的合同或报告大幅节省重复性手工操作。压缩包采用rar格式大小约3.96MB内含配套示例参考文档便于读者对照实践快速掌握核心合并逻辑。目前已有3526学习下载适合需要处理批量报告、合同生成或系统集成场景的开发者能帮助规避合并时格式错乱、样式丢失等常见问题。1. 模块各自出 Word最后合并成一份总报告先说结论和适用人群很多团队做周报、投标文件、验收材料时都是几个模块各自维护一份 Word 模板先用 POI-TL 把各自的数据灌进去再把多份结果合并成一份主文档。POI-TL 合并多个 Word 文档这个需求听起来只是「把文件拼在一起」实际做起来却经常变成图片贴出来是红叉、第二份文档的自动编号从头开始、页眉页脚被最后一份子文档覆盖。很多人第一反应是直接用 Apache POI 把两个 XWPFDocument 打开后手动拼接但拼到一半才发现样式、图片关系、编号定义的迁移才是真正的难点。这篇文章不写概念直接给两条能落地的合并路线模板内用自定义 POI-TL 标签合并、渲染后用 POI 底层组装正文元素并把最常见的 5 个坑按「现象 → 原因 → 解决」写清楚。适合正在写多模块文档生成系统的开发者以及被「合并后格式混乱」折磨的运维同学。2. 先想明白合并发生在哪个阶段模板占位符还是渲染结果2.1 合并的本质是 body 元素迁移不是字符串拼接POI-TL 的工作方式很简单compile 读模板、render 灌数据、write 写结果。一个 Word 文档的正文底层是一串 body 元素段落是 XWPFParagraph表格是 XWPFTable还有少量内容控件。合并两个 Word本质上就是把这串 body 元素追加到另一个文档的 body 末尾顺便把它引用的样式、图片、编号定义一起带过去。难点在于一个 docx 不只是 document.xml它还有 styles.xml、numbering.xml、media 目录下的图片文件、header/footer 部件。追加一个段落 XML 很容易但段落里的 r:embed 引用的是源文档包里的图片部件段落用的 styleId 可能不存在于目标文档的样式表里。这些关系不迁移合并后的文档打开就是各种异常。所以不要指望写好一个「appendParagraph」就万事大吉。合并前必须回答三个问题子文档用到的自定义样式目标文档有没有、子文档的图片怎么注册进目标包、子文档的自动编号怎么和目标的编号定义共存。这三个问题不解决合并代码写得再快也是表面功夫。2.2 两条路线的取舍模板内标签合并与渲染后拼接实际项目里合并动作发生在哪个阶段决定了整个方案形态。模板内标签合并是指主模板里放一个 {{mergeDoc}} 占位符渲染时把多份已经渲染好的子文档作为特殊数据传入由自定义渲染策略把子文档的正文元素插到占位符位置。优点是顺序由数据决定模板固定维护成本低缺点是占位符会整段消失且需要适配 POI-TL 的插件 API。渲染后拼接是指每个子文档先用 POI-TL 渲染成独立 docx再用 Apache POI 的底层 API 把其中一个文档的正文元素逐个复制到另一个文档。优点是控制力强可以精确决定分页、顺序、跳过哪些元素缺点是要自己处理样式、图片、编号的迁移。下面这张表是我在方案评审时常用来和同事对齐的维度模板内标签合并渲染后拼接子文档顺序控制渲染数据顺序代码调用顺序主模板维护需要加占位符不需要改动对 POI-TL 版本依赖较高只依赖 Apache POI样式迁移复杂度与渲染后相同与模板内相同适合场景主模板固定、子模块多子文档结构复杂、需要精细控制选择标准我说得直白一点如果主模板只有一份子模块经常增减用模板内合并如果子文档是从外部系统拿来的带自己的页眉页脚和分节符用渲染后拼接。两条路线底层用的复制函数完全一致区别只是入口不同。2.3 合并前必须做的模板规范样式、编号、分页规则我做过多模块合并之后得到一个结论合并翻车多半不是代码问题而是模板设计问题。子文档模板之间样式不统一合并代码写得再完美也救不回来。我给子文档模板定了四个约束在动手写合并代码之前先照着检查正文样式统一用同一个 styleId比如「子文档正文」不要这个模块用 Normal、那个模块用「正文」否则合并后需要做样式映射。能不用 Word 自动编号就不用。自动编号依赖 numbering.xml 的 numId两份文档的 numId 很可能都叫 1合并后互相污染这是最麻烦的坑。子文档末尾不要带分节符分页用分页符而不是分节符避免把源文档的页面设置、页眉页脚一起带进目标文档。图片统一用嵌入型减少浮动图片在合并后位置漂移的问题。最好在合并流程的第一步做一个模板校验读每个子文档的 styles.xml提取用到的 styleId 集合和主模板的样式表对比差异过大直接中止合并并提示。不要等合并完、用户打开才发现格式全变样了那时候再定位是哪个模板的问题成本高得多。3. 模板内合并用自定义 POI-TL 标签把子文档插进 {{mergeDoc}}3.1 自定义渲染策略的骨架绑定占位符与数据先看依赖。POI-TL 本身会带 Apache POI不需要额外引一份 POI 依赖版本以 maven 仓库当前稳定版为准。dependency groupIdcom.deepoove/groupId artifactIdpoi-tl/artifactId version1.12.x/version /dependency在模板里占位符是单独一行写的{{mergeDoc}}。前后不要混入其他文字更不要放在句子中间。普通文本占位符混在句子中没问题但合并标签会让整个段落消失如果占位符和句子混在一起整句话都会被删掉。自定义渲染策略的代码骨架如下public class MergeDocPolicy extends AbstractRenderPolicyMergeDocData { Override public void doRender(RenderContextMergeDocData context) throws Exception { // 占位符所在段落不同版本 API 名称略有差异 XWPFParagraph placeholder context.getElement(); if (placeholder null) { return; } // 把占位符段落从正文中移除避免渲染后残留空段 XWPFDocument targetDoc placeholder.getDocument(); targetDoc.removeBodyElement(placeholder); MergeDocData data context.getData(); for (XWPFDocument subDoc : data.getSubDocs()) { // 公共合并工具第 4 章会给出完整实现 WordDocMerger.appendDocument(targetDoc, subDoc, data.isPageBreakEach()); } } }逻辑说明doRender 是 POI-TL 渲染标签时的回调context.getElement() 返回占位符所在的对象我这里只处理段落形式如果版本较老没有 getElement()改用 context.getParagraph()。先把占位符段落从目标文档 body 里移除然后遍历数据里的子文档逐个追加到目标文档末尾。3.2 注册标签并渲染bind 与数据根对象自定义标签需要用一个数据对象来承载多个子文档和分页开关public class MergeDocData { private ListXWPFDocument subDocs; private boolean pageBreakEach; public MergeDocData(ListXWPFDocument subDocs, boolean pageBreakEach) { this.subDocs subDocs; this.pageBreakEach pageBreakEach; } public ListXWPFDocument getSubDocs() { return subDocs; } public boolean isPageBreakEach() { return pageBreakEach; } }渲染时用 Configure 把这个标签绑定到策略上MapString, Object root new HashMap(); root.put(mergeDoc, new MergeDocData(Arrays.asList(doc1, doc2), true)); Configure config Configure.builder() .bind(mergeDoc, new MergeDocPolicy()) .build(); XWPFTemplate template XWPFTemplate.compile( new FileInputStream(主模板.docx), config); template.render(root); template.writeAndClose(new FileOutputStream(合并结果.docx));参数说明占位符名必须和 bind 的第一个参数完全一致MergeDocData 里的 subDocs 是按最终顺序排好的子文档列表pageBreakEach 为 true 时每份子文档追加前先插一个分页符。这里最容易翻车的是编译模板时忘记传 config导致 {{mergeDoc}} 被当成普通文本原样输出。另一个容易翻车的是同一份模板如果既渲染普通文档又渲染合并文档必须分开 compile各自用不同配置。3.3 为什么复制逻辑要放公共工具类而不是插件里插件里只做三件事取数据、删占位符、调用合并工具。真正复杂的 body 元素复制、图片关系迁移、样式合并不应该写在插件类里原因有两个。第一POI-TL 的插件 API 在不同版本之间是有改动的。我见过同事把几百行复制逻辑全塞在插件类里升级 POI-TL 版本后整个类报废重写成本很高。把复制逻辑放到 WordDocMerger 公共类只用 Apache POI 的标准 API升级时只需要改插件那层很薄的适配代码。第二模板内合并和渲染后合并两个场景共用同一个合并工具。如果你的项目某天决定从模板内合并切换到渲染后拼接公共类不用动只是调用入口变了。这个设计能让两种方案之间切换的成本降到最低。4. 渲染后拼接用 POI 把两份 docx 的正文元素组装成一份4.1 WordDocMerger 的公共入口追加文档到目标文档末尾这一章的代码只依赖 Apache POI不依赖 POI-TL所以任何「合并多个 Word 文档」的需求都可以直接复用。先看入口方法public class WordDocMerger { public static void appendDocument(XWPFDocument target, XWPFDocument src, boolean pageBreakBefore) throws Exception { if (target null || src null) { return; } // 1. 迁移样式与图片关系 mergeStyles(target, src); fixImages(target, src); // 2. 可选追加前先分页 if (pageBreakBefore) { XWPFParagraph breakPara target.createParagraph(); breakPara.setPageBreak(true); } // 3. 逐个 body 元素复制 for (IBodyElement element : src.getBodyElements()) { if (element instanceof XWPFParagraph) { appendParagraph(target, (XWPFParagraph) element); } else if (element instanceof XWPFTable) { appendTable(target, (XWPFTable) element); } else { // 内容控件 SDT 在模板规范里约定不使用遇到直接跳过 System.err.println(skip unsupported element: element.getClass()); } } } }逻辑说明第 1 步迁移样式和图片关系必须在复制正文之前执行尤其是图片关系迁移它会直接修改 src 文档内部 run 的 r:embed 引用第 2 步分页符用段落上的 page break 实现比插入空段落更干净第 3 步遍历 src 的 body 元素段落和表格分别处理内容控件在实际业务里很罕见模板规范里会提前约定子文档不用内容控件这里跳过并告警即可。需要注意一个细节fixImages 会把 src 里的图片关系改掉指向 target 的新关系 id。这意味着 src 是一个「一次性对象」合并完 src 就不能再复用了。如果同一个 src 需要合并进多份目标文档调用前先把 src 转成字节数组重新 new 一个 XWPFDocument再传给合并函数。参数说明pageBreakBefore 为 true 时每一份子文档都从新的一页开始对于「多个报告拼成一份总报告」这种场景几乎是必需的否则两份文档的正文会直接粘连在一起。4.2 样式与图片关系迁移决定成败的两段代码样式迁移解决的是「字体全变」的问题。源文档段落引用了某个自定义 styleId目标文档的 styles.xml 里没有这个样式Word 会回退到默认样式。做法是把 src 用到的 CTStyle 深拷贝一份加入 targetprivate static void mergeStyles(XWPFDocument target, XWPFDocument src) { XWPFStyles targetStyles target.getStyle(); XWPFStyles srcStyles src.getStyle(); if (targetStyles null || srcStyles null) { return; } for (CTStyle srcStyle : srcStyles.getStyleList()) { String styleId srcStyle.getStyleId(); if (targetStyles.getStyleById(styleId) null) { targetStyles.getStyle().addNewStyle().set(srcStyle); } } }逻辑说明addNewStyle().set(srcStyle) 是深拷贝不会影响 src 文档通过 getStyleById 存在性判断避免 target 里已有同名样式被覆盖。这里有意保留 target 的同名样式因为主模板的样式优先级更高子文档应该去适配主模板而不是反过来。图片迁移是合并里最容易做漏的部分。每个 docx 包的 media 目录里有图片文件document.xml.rels 里记录 rId 到文件的映射正文 run 的 r:embed 再引用这个 rId。合并时如果把段落 XML 复制过去但不复制图片部件也不改 rIdWord 打开就是红叉。正确做法是先遍历 src 的所有图片关系把二进制注册进 target拿到新的 rId 映射再回写 src 里所有 run 的 r:embedprivate static void fixImages(XWPFDocument target, XWPFDocument src) throws Exception { PackagePart srcPart src.getPackagePart(); MapString, String rIdMap new HashMap(); for (Relationship rel : srcPart.getRelationshipsByType(Document.IMAGE_RELATIONSHIP)) { PackagePart imagePart srcPart.getRelatedPart(rel); byte[] bytes IOUtils.toByteArray(imagePart.getInputStream()); int picType imagePart.getContentType().contains(png) ? Document.PictureType.PNG : Document.PictureType.JPEG; String newRelId target.addPictureData(bytes, picType); rIdMap.put(rel.getId(), newRelId); } // 遍历 src 里所有段落和表格单元格段落回写 r:embed for (XWPFParagraph p : src.getParagraphs()) { replaceEmbedInRuns(p.getRuns(), rIdMap); } for (XWPFTable table : src.getTables()) { for (XWPFTableRow row : table.getRows()) { for (XWPFTableCell cell : row.getTableCells()) { for (XWPFParagraph p : cell.getParagraphs()) { replaceEmbedInRuns(p.getRuns(), rIdMap); } } } } } private static void replaceEmbedInRuns(ListXWPFRun runs, MapString, String rIdMap) { for (XWPFRun run : runs) { for (CTDrawing drawing : run.getCTR().getDrawingList()) { for (CTInline inline : drawing.getInlineList()) { CTPic pic inline.getGraphic().getGraphicData().getPic(); if (pic ! null pic.getBlipFill() ! null) { String embed pic.getBlipFill().getBlip().getEmbed(); if (embed ! null rIdMap.containsKey(embed)) { pic.getBlipFill().getBlip().setEmbed(rIdMap.get(embed)); } } } } } }参数说明图片类型根据 content type 判断不能一律按 JPEG 处理否则 png 图片的拓展名和内容不一致部分查看器会打不开。replaceEmbedInRuns 只处理 inline 类型图片floating 图片在两个文档合并时会出现位置信息仍然指向源文档页面的情况模板规范里约定子文档图片用嵌入型就是给这段代码减少压力。4.3 段落与表格复制把 XML 节点搬进目标正文段落复制不能直接 set 同一个底层对象要 copy 一份独立 XML否则两个文档共享同一份 CTP后续写操作会在两个文档间互相干扰private static void appendParagraph(XWPFDocument target, XWPFParagraph srcPara) { XWPFParagraph newPara target.createParagraph(); CTP ctp (CTP) srcPara.getCTP().copy(); newPara.getCTP().set(ctp); }逻辑说明createParagraph 会在目标文档 body 末尾新建一个空段落再把 src 段落的 CT XML 整个覆盖进去。因为前面 fixImages 已经改好了 r:embed复制的 XML 里图片引用就是 target 包里的新 rIdmergeStyles 也保证了 styleId 在 target 里存在。复制到这里段落的文本、图片、样式引用基本都是自洽的。表格复制逻辑和段落一样private static void appendTable(XWPFDocument target, XWPFTable srcTable) { XWPFTable newTable target.createTable(); CTTbl ctTbl (CTTbl) srcTable.getCTTbl().copy(); newTable.getCTTbl().set(ctTbl); }表格复制后表格里单元格的段落和 run 已经在 src 阶段被 fixImages 处理过所以这里不需要再做图片替换。如果子文档里表格嵌套了图片遍历 src 表格时 fixImages 已经递归处理了所有单元格段落复制出来的 XML 是安全的。完整的调用流程是先把主文档和各个子文档分别用 POI-TL 渲染成字节数组再逐个打开合并byte[] mainBytes renderMainToBytes(); XWPFDocument targetDoc new XWPFDocument(new ByteArrayInputStream(mainBytes)); for (byte[] subBytes : subDocBytesList) { XWPFDocument subDoc new XWPFDocument(new ByteArrayInputStream(subBytes)); WordDocMerger.appendDocument(targetDoc, subDoc, true); subDoc.close(); } targetDoc.write(new FileOutputStream(合并结果.docx)); targetDoc.close();参数说明这里用字节数组做中间态避免流被提前关闭子文档合并完立刻 close防止多份文档同时占用内存。十几份大文档合并时这个「读一份、并一份、关一份」的顺序比全部加载进内存再合并要稳得多。5. 合并 Word 文档的 5 个常见坑与排查方法5.1 合并后图片变成红叉或丢失现象合并结果打开后原来子文档里的图片区域变成红叉或者整块空白。原因段落 XML 被复制过去但 run 里的 r:embed 仍然指向源文档包的图片部件目标文档包里没有对应图片文件Word 无法解析。排查用解压工具打开合并后的 docx看 word/media 目录下有没有图片再打开 word/_rels/document.xml.rels对照 rId 是否存在。解决在复制正文前遍历 src 的所有图片关系用 target.addPictureData 把二进制注册进目标包拿到新 rId再把 src 里所有 run 的 r:embed 替换成新 rId。注意 content type 判断PNG 和 JPG 不能混用。5.2 第二份子文档的自动编号重头开始或错乱现象两份子文档各自从「1.」开始编号合并后第二份直接从头开始或者两个文档的编号交错。原因OOXML 的列表编号由 numbering.xml 的 numId 和 abstractNumId 控制。两份文档可能都用 numId1但它们的编号定义完全不同合进同一份文档后同一个 numId 只能解析成一套定义。排查解压后对比两份文档的 numbering.xml看 numId 对应的 abstractNumId 是否一致再看 document.xml 里各段落的 numId 引用。解决最省事的办法是合并前把子文档里的自动编号段落转成纯文本把编号文字写死在段落开头去掉 numPr 引用。如果必须保留动态编号需要把 src 的 numbering.xml 重新映射为每套 abstractNumId 分配新的 numId这工作量不小我一般只在模板规范层面禁掉子文档用 Word 自动编号。5.3 页眉页脚被最后一份子文档覆盖现象主文档原本有统一页眉合并后所有页都变成某份子文档的页眉或者主文档页眉直接消失。原因docx 的页面设置 sectPr 既存在于 body 末尾也可能附着在某个段落的 pPr 里。如果子文档末尾自带 sectPr复制到目标文档后它会覆盖目标文档原有的全局页面设置连带页眉页脚引用全部切换。排查把子文档解压看 word/document.xml 的 body 末尾有没有 sectPr再查段落 pPr 里有没有嵌套 sectPr。解决复制 body 元素之前把 src body 末尾的 sectPr 移除追加时跳过任何带 sectPr 的段落。目标文档页面设置始终由主模板控制子文档只贡献正文内容不贡献页面配置。5.4 自定义 merge 标签渲染后原样留在文档里现象模板里写 {{mergeDoc}}渲染结果把这几个字符原样留在正文中看起来像是没渲染。原因最常见的是编译模板时没有传入 ConfigurePOI-TL 对未注册的标签默认当普通文本渲染其次是占位符前后有空格或特殊字符导致标签解析失败。排查检查 compile 是否用了 XWPFTemplate.compile(ins, config)确认 bind 的标签名和模板占位符完全一致把占位符单独放一段做最小复现。解决编译时传入 config占位符严格遵守「{{标签名}}」写法且独占一行。如果还不行降低 POI-TL 版本差异因素日志里打印标签解析结果定位是否在 render 阶段绑定的数据对象类型不匹配。5.5 多份大文档合并时内存溢出现象文档数量一多JVM 直接 OOM堆栈指向 POI 的 XWPFDocument 相关对象。原因XWPFDocument 会把整个 OOXML 包和所有部件都加载进内存多份大文档同时存活堆内存被成倍吃掉。排查用 VisualVM 或 jmap 看堆占用确认大部分是 POIXMLDocumentPart 相关对象数一下同时 open 的 XWPFDocument 实例数量。解决不要用一个 List 把所有子文档都装进来改成读一份、合并一份、关闭一份中间结果尽量用字节数组而不是保留完整对象。十几份文档的场景内存占用从「全部文档之和」降到「两份文档之和」效果立竿见影。6. 合并完成后的收尾目录刷新与冒烟验证合并完不代表能直接交付目录、页码、书签跳转是三个最容易被忽略的收尾项。POI 写出来的 TOC 域结果是静态的合并前的目录显示的是子文档各自的页码合并后不会自动更新。如果是人工交付我一般会在文档开头留一行提示打开后全选并按 F9 更新域如果是自动化流水线常见做法是用 LibreOffice headless 把合并结果转一遍 PDF 触发域更新或者用 docx4j 的 FieldUpdater 批量刷新字段结果。我自己的习惯是每次交付合并功能前先跑一个冒烟验证脚本统计合并后文档的段落数、表格数、图片数分别和所有子文档对应数量之和比对。数量对不上就说明复制逻辑丢了内容直接报错而不是把残缺文档交付出去。这个自检方法不复杂几十行代码就能实现但能挡住九成以上的低级问题。再补一个很实用的习惯合并前对子文档做样式归一化校验读每个子文档的 styles.xml提取实际用到的 styleId和主模板的样式表对比差异过大就打印警告。第一次跑通多模块合并功能时我连踩图片红叉和编号错乱两个坑后来把模板规范、关系迁移、冒烟自检三件事串起来才敢把这个功能交给运营同事每天自动跑。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑