简介:面向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> <groupId>com.deepoove</groupId> <artifactId>poi-tl</artifactId> <version>1.12.x</version> </dependency>在模板里,占位符是单独一行写的:{{mergeDoc}}。前后不要混入其他文字,更不要放在句子中间。普通文本占位符混在句子中没问题,但合并标签会让整个段落消失,如果占位符和句子混在一起,整句话都会被删掉。自定义渲染策略的代码骨架如下:public class MergeDocPolicy extends AbstractRenderPolicy<MergeDocData> { @Override public void doRender(RenderContext<MergeDocData> 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 List<XWPFDocument> subDocs; private boolean pageBreakEach; public MergeDocData(List<XWPFDocument> subDocs, boolean pageBreakEach) { this.subDocs = subDocs; this.pageBreakEach = pageBreakEach; } public List<XWPFDocument> getSubDocs() { return subDocs; } public boolean isPageBreakEach() { return pageBreakEach; } }渲染时用 Configure 把这个标签绑定到策略上:Map<String, 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 深拷贝一份加入 target:private 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 复制过去,但不复制图片部件,也不改 rId,Word 打开就是红叉。正确做法是先遍历 src 的所有图片关系,把二进制注册进 target,拿到新的 rId 映射,再回写 src 里所有 run 的 r:embed:private static void fixImages(XWPFDocument target, XWPFDocument src) throws Exception { PackagePart srcPart = src.getPackagePart(); Map<String, 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(List<XWPFRun> runs, Map<String, 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 包里的新 rId;mergeStyles 也保证了 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 控制。两份文档可能都用 numId=1,但它们的编号定义完全不同,合进同一份文档后,同一个 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}},渲染结果把这几个字符原样留在正文中,看起来像是没渲染。原因:最常见的是编译模板时没有传入 Configure,POI-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,和主模板的样式表对比,差异过大就打印警告。第一次跑通多模块合并功能时,我连踩图片红叉和编号错乱两个坑,后来把模板规范、关系迁移、冒烟自检三件事串起来,才敢把这个功能交给运营同事每天自动跑。希望帮到你。本文还有配套的精品资源,点击获取
简介:面向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> <groupId>com.deepoove</groupId> <artifactId>poi-tl</artifactId> <version>1.12.x</version> </dependency>在模板里,占位符是单独一行写的:{{mergeDoc}}。前后不要混入其他文字,更不要放在句子中间。普通文本占位符混在句子中没问题,但合并标签会让整个段落消失,如果占位符和句子混在一起,整句话都会被删掉。自定义渲染策略的代码骨架如下:public class MergeDocPolicy extends AbstractRenderPolicy<MergeDocData> { @Override public void doRender(RenderContext<MergeDocData> 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 List<XWPFDocument> subDocs; private boolean pageBreakEach; public MergeDocData(List<XWPFDocument> subDocs, boolean pageBreakEach) { this.subDocs = subDocs; this.pageBreakEach = pageBreakEach; } public List<XWPFDocument> getSubDocs() { return subDocs; } public boolean isPageBreakEach() { return pageBreakEach; } }渲染时用 Configure 把这个标签绑定到策略上:Map<String, 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 深拷贝一份加入 target:private 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 复制过去,但不复制图片部件,也不改 rId,Word 打开就是红叉。正确做法是先遍历 src 的所有图片关系,把二进制注册进 target,拿到新的 rId 映射,再回写 src 里所有 run 的 r:embed:private static void fixImages(XWPFDocument target, XWPFDocument src) throws Exception { PackagePart srcPart = src.getPackagePart(); Map<String, 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(List<XWPFRun> runs, Map<String, 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 包里的新 rId;mergeStyles 也保证了 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 控制。两份文档可能都用 numId=1,但它们的编号定义完全不同,合进同一份文档后,同一个 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}},渲染结果把这几个字符原样留在正文中,看起来像是没渲染。原因:最常见的是编译模板时没有传入 Configure,POI-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,和主模板的样式表对比,差异过大就打印警告。第一次跑通多模块合并功能时,我连踩图片红叉和编号错乱两个坑,后来把模板规范、关系迁移、冒烟自检三件事串起来,才敢把这个功能交给运营同事每天自动跑。希望帮到你。本文还有配套的精品资源,点击获取