POI-TL合并多个Word文档:模板渲染与批量生成实战指南

发布时间:2026/10/8 2:19:34
POI-TL合并多个Word文档:模板渲染与批量生成实战指南 简介这份资源面向需要处理批量Word文档的Java开发者讲解如何利用Apache POI与POI-TL在Java环境中合并多个.docx文件。内容覆盖POI基础组件XWPF的文档解析能力以及POI-TL模板化编程方式依次介绍创建XWPFDocument对象、使用FileInputStream读取源文档、遍历段落XWPFParagraph与表格XWPFTable并复制到目标文档、通过FileOutputStream保存结果等核心环节同时强调合并过程中保留原格式与样式、避免内容错乱的关键细节。压缩包为RAR格式整体大小约3.96MB方便下载后直接查阅。该资源已有3523人学习包内含关键技术说明和可参考的示例文件读者既可对照学习从文档读取到合并输出的完整实现原理也能将其中方法直接应用于批量合同生成、报表整合等实际工作场景尤其适合需要摆脱手动复制粘贴、提升办公效率的初中级工程师。1. POI-TL 合并多个 Word 文档先分清你合并的是数据还是文件实际业务里“合并多个 Word”这句话至少有十种需求。最常见的是把几个分部写的报告拼成一份总报告也有的是把几百条数据库记录渲染进同一个 Word 模板。POI-TL 这个库名字里带着 POI但它的核心能力不是文档拼接而是模板渲染给你一个 .docx 模板、一组 Java 数据它把数据循环填充成完整文档。如果你把它当成“把两个 .docx 粘起来”的工具来用会遇到样式丢、图片空、表格宽度乱等一系列连环坑。我写这篇文章就是把用 POI-TL 合并多个 Word 文档的完整思路讲清楚适合谁、怎么选、代码怎么写、坑在哪、交付前怎么验。目标读者是 Java 后端和数据化文档生成场景的工程师。2. POI-TL 合并多个 Word 的底层逻辑为什么“追加文档”这条路在 POI 里走不通2.1 POI-TL 的渲染模型与合并的边界很多从 Apache POI 原生 API 转过来的人第一反应是能不能把两个XWPFDocument的 body 元素 append 在一起答案是不能简单这么做。POI-TL 的模型是“模板 数据 文档”模板里的一切内容都由标签和数据驱动。你给它一份List数据它把{{?sections}}到{{/sections}}之间的内容重复渲染 N 次。每一次循环渲染都等价于把一个子文档的正文内容“铺”进目标文档。所以用 POI-TL 合并多个 Word 文档最正确的姿势不是复制文件而是把一个文档抽象成一条 section 数据结构然后用循环块把它成批渲染。这里有必要先理解循环块的边界。{{?sections}}是循环开始{{/sections}}是循环结束两者之间可以放标题、段落、图片、表格、嵌套循环。这个区域就是 POI-TL 意识里的“文档容器”。比如一个 section 对应一个子文档的标题和正文模板里可以这样设计{{?sections}} 《{{title}}》 {{content}} {{*image}} {{table}} {{/sections}}这个模板片段没有写成代码但已经能说明关键{{*image}}是图片渲染标签{{table}}是表格渲染标签{{title}}和{{content}}是普通文本标签。POI-TL 渲染时会按 list 中每个 section 展开一次最终在输出文档里生成多个连续的子文档块。这个机制决定了它能应对“结构统一”的合并需求但无法自动合并两个风格完全不同的旧 Word 文件。2.2 为什么直接操作 XWPFDocument 拼接会“翻车”直接使用底层 POI 的XWPFDocument拼接多个 Word 文件是能跑的但跑完的产品往往“豆腐块”。主要坑集中在五个方面图片关系 ID 冲突导致图片错乱编号列表重新计数页眉页脚互相覆盖样式定义互相污染表格列宽被自动布局重算。这些问题的根源是 docx 本质上是一个 zip 包里面除了 document.xml 还有 styles.xml、rels 关系文件、media 图片目录、header/footer 目录。物理拼接只复制了正文节点却没有把关系文件里的映射一起合并所以输出文件要么缺图片要么在 Word 打开时提示“需要修复”。我这么说不是否定纯 POI 方案而是强调选择边界。实际项目里我一般先做一张对比表来决定合并方案合并方案适用场景实现工作量样式还原度主要风险纯 POI 物理拼接一次性小批量、临时脚本中低关系 ID、列表编号、页眉页脚、表格列宽POI-TL 循环渲染结构统一、批量生成、模板规范低高模板标签写错、数据模型不匹配POI-TL POI 混合历史文档杂乱、需抽取公式/批注高中抽取逻辑复杂、图片格式转换选型时还有一个容易被忽略的点如果你的上游是 markdown 转 word 工作流或者有人用 Coze 之类的编排工具生成文档片段那么产物往往只是“看起来像 Word 的文本”里面大量的自动编号和域都是缺失的。这样的文档交给 POI-TL 合并反而比从零建模更麻烦。这也是我前面强调“先把内容结构化再谈合并”的原因。POI-TL 不是清洁工它不负责把别人留下的乱文档洗干净。3. 可复现的最小示例用 POI-TL 把多个 Word 片段合并成一份正式文档3.1 模板设计把主文档做成可循环的多段容器先说模板怎么做。打开 Word 或 WPS新建一个 .docx把标题和循环区域排好。第一个段落写“报告汇编”不参与循环。第二段写{{?sections}}这是循环开始的声明。接下来是循环体内的占位结构一行标题《{{title}}》一段正文{{content}}一行图片占位{{*image}}一个表格占位{{table}}。最后写{{/sections}}结束循环。保存时选 .docx不要选 .docm这点后面在宏安全坑里会详细解释。模板里的标签是可以自定义的但团队协作时我建议固定用{{}}风格因为它在 Word 正文里不太容易被误删也比#{}风格直观。有一点要特别注意模板里不要留批注。如果模板是从同事那继承来的里面还有 Word 批注POI-TL 渲染时批注不会自动关联到新文档输出后的 docx 在 Word 里会反复弹“是否恢复批注”。这就是用 Java 批注 Word 时容易踩的坑。批注清理用 POI 的CTComment可以做到但更简单的是直接把模板另存成一份新文件放弃原文件的批注。3.2 Java 代码骨架一份能跑的合并逻辑下面的代码是我在项目里用过的最小可运行骨架。它把多个 section 数据渲染成一个 Word图片、表格、文本都覆盖到了。用 Maven 依赖 poi-tl 时注意版本和 POI 版本对齐我用的是 poi-tl 1.12.x Apache POI 5.2.x 这一组搭配。import java.io.FileOutputStream; import java.io.IOException; import java.util.*; import com.deepoove.poi.XWPFTemplate; import com.deepoove.poi.config.Configure; import com.deepoove.poi.config.ELMode; import com.deepoove.poi.data.Pictures; import com.deepoove.poi.data.RowRenderData; import com.deepoove.poi.data.Tables; public class MergeWordByPoiTl { public void merge(ListMapString, Object sections, String templatePath, String outPath) throws IOException { // sections 是每个子文档的数据列表顺序就是合并顺序 MapString, Object data new HashMap(); data.put(sections, sections); // 显式指定 EL 模式避免默认语法混淆 Configure config Configure.builder() .setElMode(ELMode.POIL_TL_EL) .build(); // 每次合并单独编译模板防止多线程共享状态 XWPFTemplate template XWPFTemplate.compile(templatePath, config); template.render(data); try (FileOutputStream out new FileOutputStream(outPath)) { template.write(out); } finally { template.close(); } } private MapString, Object buildSection( String title, String content, byte[] imageBytes, String[] headers, ListString[] rows) { MapString, Object sec new HashMap(); sec.put(title, title); sec.put(content, content); // 图片用字节数组传入避免 File 生命周期问题 if (imageBytes ! null imageBytes.length 0) { sec.put(image, Pictures.ofBytes(imageBytes, Pictures.Type.PNG) .size(400, 260).create()); } else { // 允许空图片但渲染端要有占位逻辑 sec.put(image, null); } // 表格数据第一行作为表头 RowRenderData header RowRenderData.build(headers); ListRowRenderData bodyRows new ArrayList(); for (String[] row : rows) { bodyRows.add(RowRenderData.build(row)); } sec.put(table, Tables.of(header, bodyRows).create()); return sec; } }这里的三个参数值得单独说。ELMode.POIL_TL_EL是 poi-tl 最经典的模板语法模式对应{{ }}标签如果模板里混入 Word 域代码建议保持这个模式它会忽略不认识的域。Pictures.Type.PNG是图片类型poi-tl 支持 PNG、JPG但不支持 EMF所以 MathType 生成的公式图片要提前转成 PNG。.size(400, 260)是图片显示尺寸单位是像素这里写死尺寸能避免源文档里超大图片把页面撑破实际项目中我一般先从源文档记录原始宽高再做等比缩放。3.3 参数怎么改Configure、模板编译和输出流Configure是 POI-TL 的运行时配置中心。除了setElMode我经常用的还有setDefaultRenderPolicyFactory和bind。前者可以改变某类标签的默认渲染策略后者可以把某个具体标签绑到自定义策略上。比如合并时想为每个 section 自动加分页符就需要写一个继承AbstractRenderPolicy的策略类再用Configure.builder().bind(sections, policy)注册。这类代码在第 4 章会展开讲。XWPFTemplate.compile有两个重载一个接收String路径一个接收InputStream。在线程池并发生成多个文档时不能复用同一个XWPFTemplate实例因为它内部持有XWPFDocument的可变状态。我在批量生成时是每次 new 一个模板实例虽然会多一次模板解析但换来了稳定。最后说输出。template.writeToFile(String)适合临时调试正式接口建议用template.write(OutputStream)这样可以把文档流直接返回给前端下载或者上传到对象存储。写完以后必须调用template.close()释放 document 的内存和 zip 文件句柄。如果不 close长时间批量合并会出现文件句柄泄漏这是生产环境最常见的隐性故障。4. 把合并变成生产可用的完整流程从拆文档到统一渲染4.1 先拆后合把不规则的历史文档加工成模板片段业务上真正难的不是渲染而是数据准备。假设你拿到的是一堆用 Word 2024 编辑过的旧文档里面有手工标题、AxMath 公式、WPS 表格、页眉页脚甚至还有同事留下的批注。直接扔给 POI-TL 是不行的POI-TL 只认识“模板 数据”。我一般先写一段抽取代码用 Apache POI 打开源文档遍历 body把段落、表格、图片、公式图片分别提取并归档。简单说就是把每个 Word 文档拆成若干个 section每个 section 包含标题文本、正文段落列表、图片数组、表格二维数组。这一步的关键是图片抽取。Word 里的图片资源都放在 zip 包的word/media目录下正文通过r:embed关系引用。抽取时要保存图片在文档中的出现顺序并且记录它的类型。如果是 EMF 格式顺手转成 PNG这样后续 POI-TL 的Pictures.Type才能处理。抽取完以后把每个 section 的图片和文字打包成一个Map丢进ListMapString, Object。至此你才真正把一批“物理 Word 文件”变成了 POI-TL 能消费的“逻辑数据”。这里也顺带说一句公式图片转 Word 的常见场景论文里的公式如果用的是 MathType 或 AxMath 插件插入 Word 后其实是一个嵌入的 OLE 对象或者图片。抽取时不要只抓word/media下的常规图片还要检查word/embeddings目录。如果发现 OLE 对象最省事的办法是调用 LibreOffice 转 PDF再从 PDF 转 PNG把公式彻底像素化POI-TL 合并时就不会再依赖任何外部 OLE 环境。4.2 控制合并顺序、分页和编号模板循环里的隐藏参数合并顺序是最简单的需求直接控制List的顺序即可但分页和编号才是真正的难点。如果循环块里没有分页符两个 section 会黏在一起。为了让每个子文档独立成篇我通常会给循环块末尾插入分页符。推荐的做法是用自定义策略因为模板里加空段落占位分页符不够灵活而且会在最后一个 section 末尾多出一个空白页。下面是一个简化版策略类。它的作用是在每个 section 渲染完成后向当前段落追加一个分页符同时清掉模板占位符。import com.deepoove.poi.policy.AbstractRenderPolicy; import com.deepoove.poi.render.RenderContext; import org.apache.poi.xwpf.usermodel.BreakType; import org.apache.poi.xwpf.usermodel.XWPFParagraph; import org.apache.poi.xwpf.usermodel.XWPFRun; public class SectionPageBreakPolicy extends AbstractRenderPolicyObject { Override public void doRender(RenderContextObject context) throws Exception { // context 持有当前模板段落和渲染数据 XWPFParagraph paragraph context.getParagraph(); XWPFRun run paragraph.createRun(); // 追加一个分页符让下一个 section 重新起页 run.addBreak(BreakType.PAGE); // 清掉模板里 {{sections}} 占位残留 clearPlaceholder(paragraph); } }这段代码不是完整实现但思路已经够用。RenderContext是 POI-TL 渲染时的上下文对象里面能拿到当前段落、当前标签名和当前数据。clearPlaceholder是AbstractRenderPolicy提供的方法它会把模板中与当前策略绑定的标签文本移除。实际开发时还需要在Configure里绑定Configure.builder().bind(sections, new SectionPageBreakPolicy()).build()这样整个循环块在展开时就会在每个 section 结束后自动分页。编号问题更隐蔽。如果每个子文档里都有一级标题Word 自动编号会在合并后继续累加而不是从 1 重新开始。解决办法是在模板里使用“标题序列”而不是列表编号。具体做法模板里的标题样式用“标题 1”不要用“List Number”样式。因为 POI-TL 渲染的是正文样式不会触发底层 numId 的重新分配。如果你必须保留原文档的自动编号那就要在渲染前对 numId 做一次归一化这部分工作量大一般不值得做。4.3 处理批注、书签和页眉页脚的三种现实情况合并文档时最容易被忽视的是页眉页脚。POI-TL 的循环块只能控制 body 内容页眉页脚是 Word 分节级别的东西。每个 section break 对应一套页眉页脚。多文档合并后如果只用一个循环块最终只有一份页眉页脚所有子文档共享。这样对大多数报告是够用的。如果业务要求每个子文档保留自己的 logo 和页眉就必须要有多节SectPr机制。这个机制 POI-TL 不会替你自动建你需要先设计模板的分节结构再用数据控制使用哪套页眉页脚。我的经验是预算不充足就别接这种需求统一页眉是性价比最高的解决方案。批注是第二个麻烦。用 POI-TL 合并时批注不会自动跟着数据走。如果你想保留批注需要先用 POI 读取源文档的 comments.xml再从数据模型中带过去。实际上大部分“合并多个 Word 文档”的需求并不需要保留批注用户要的是最终正文。我通常会在接口文档里写明“批注默认丢弃如需保留请在需求单里备注。”书签相对简单。docx 的书签实际上就是正文里的w:bookmarkStart和w:bookmarkEnd。POI-TL 渲染后的文档书签不会自动重建。如果合并后的文档要供自动化流程跳转定位我一般建议在数据模型里增加 bookmark 字段通过自定义策略在指定位置插入书签。这个做法需要懂一点老牌的 POI 底层 API但对最终交付的稳定性很有帮助。5. 避坑指南POI-TL 合并多个 Word 文档最容易翻车的六个点5.1 表格列宽合并后对不上“原始宽度”现象源文档里的表格列宽正常合并后部分列宽变成默认值甚至表格整体超过页面宽度。尤其从 WPS 编辑过的文档转出来列宽丢失概率更高。原因docx 表格宽度由w:tblW和w:gridCol共同控制。POI-TL 渲染表格时如果模板没有定义w:tblLayout为fixed新表格会自动布局列宽按内容重新计算。合并场景里表格行的来源是多个文档网格列定义如果没有显式写入最终就会被 POI 或 Word 重排。解决模板建表时先设置表格属性为固定列宽再为每一列显式写w:gridCol。我还会在合并完成后的自检代码里用一个XWPFTable的口径去检查列宽而不是等用户反馈。验证脚本可以这样写XWPFTable table ...; table.setWidth(100%); table.getCTTbl().getTblGrid().getGridColList().get(0).setW(2000); table.getCTTbl().getTblGrid().getGridColList().get(1).setW(3000);上面的 setW 单位是 dxa1 厘米大约等于 567 dxa2000 就是 3.5 厘米左右。记得同时设置w:tblLayout为 fixed否则 Word 仍可以自动调整。5.2 公式和图片合并后丢图或错位现象合并后的文档里MathType / AxMath 插入的公式图片变成空白占位或者图片位置整体前移/后错。原因docx 中图片引用是r:embed指向文档 zip 包 media 目录里的文件。物理拼接时源文档和目标文档的图片关系 ID 可能冲突POI-TL 循环渲染时如果是模板自带的静态图片也有可能在重复循环中复用同一条关系。公式图片如果还是 EMF 格式POI-TL 的Pictures.Type里没有 EMF直接处理就会抛异常。解决把所有动态图片都转为 PNG 字节数组统一走Pictures.ofBytes并显式设置尺寸。EMF 图片在抽取阶段就转成 PNG不要等渲染前再转。图片宽高设置要注意POI-TL 的 size 单位是像素如果按厘米缩放1 厘米约等于 38 像素。合并完成后再检查一下getAllPictures().size()与预期数量是否一致这个习惯能救回很多返工。5.3 Word 宏安全模板带宏导致生成文档打开提示异常现象模板从 .docm 另存来POI-TL 渲染完成后Word 打开提示“此文档包含宏宏签名已损坏”用户不敢启用。原因poi-tl 按 docx 格式写入但模板 zip 包里如果还残留vbaProject.bin和相关宏 XMLPOI 重写时会原样保留却不会重新计算宏签名于是 Word 判定宏文件被篡改。解决模板一律使用 .docx不带宏。如果确实拿到 .docm先用 Word 另存为 .docx或者用 zip 工具删除包内word/vbaProject.bin。删除后合并文档不再触发宏安全提示这个坑在批量生成标书时尤其常见。5.4 字体WPS 里正常Word 里乱了反之亦然现象合并后的文档在 Word 里正常在 WPS 里字体变“等线”或者某台机器装了 wechat 字体另一台机器不认中文对齐全乱。原因docx 只记录字体名不嵌字体文件。目标机器查不到字体时就按回退规则替换。POI-TL 渲染循环块时文字样式继承模板段落如果模板里有本机特有字体所有 section 都会跟着乱。解决模板做字体归一化。正文样式统一用“宋体 Times New Roman”代码块用“Consolas”。重点是要同时设置rFonts的ascii和eastAsia否则中文还是不对。在 API 层面强制设置可以用前面第 4 章给过的CTFonts代码这里不再重复。WPS 和 Word 的兼容性问题本质上是字体回退表不同字体单一化是最有效的止血方案。5.5 目录和页码合并完是静态的必须强制刷新现象合并后的文档开头有目录但目录内容全是模板里的旧标题页码也没更新手动 CtrlA 再 F9 才恢复。原因docx 目录是 Word 字段字段结果缓存在文档里。POI-TL 渲染文本后不会自动重算字段缓存。解决两种情况分开处理。对目录要求不严格的合并后利用 Word 的“更新域”弹窗让用户手动确认。对自动化交付要求严格的在后台用 Apache POI 的XWPFDocument.updateFields()尝试更新。实测 POI 对 TOC 域更新能力有限我最终的做法是合并前模板不放具体目录项只放一个 TOC 字段合并后用脚本删除旧的目录缓存行保留字段结构让 Word 打开时自动提醒更新目录。这个方法在老版本的 Word 2024 里也有效。5.6 编号列表合并后标题编号从上一节继续现象第一节标题是 1.1第二节明明是新文档编号却接着变成 1.2没有从 1 重新开始。原因docx 里的自动编号通过w:numId引用编号定义。POI 物理拼接时复制了多个段落它们引用同一个 numIdPOI-TL 循环渲染时如果模板里用的是自动编号列表循环展开十次就会连续编号十次。解决模板中不要使用“List Paragraph 自动编号”样式来表达层级标题改用真正的“标题 1”“标题 2”样式。标题样式理论上也是自动编号但只要不在同一个 numId 链上循环展开时每个标题会保持独立。如果必须保留自动编号样式就在预处理阶段把每个 section 的编号重置给每个 section 一个独立的 numId这个方案维护成本高我一般不用。6. 合并后的最后一道体检把输出文档拆开检查再交付合并完的 docx 是一个 zip 包。Word 打开不报错不代表内容完整我习惯用一个小脚本把输出文件“拆开”做体检。第一步用 Apache POI 读一遍统计段落、表格、图片数量和预期的 section 数量对比。第二步用 ZipFile 检查word/media目录下的文件个数是否与getAllPictures()一致防止有引用指向不存在的图片。第三步把.docx后缀改成.zip解压在 document.xml 里搜{{或}}一旦搜到就说明某个模板标签没有渲染成功直接定位是哪个数据字段传了空值。除了这些数值检查我还会做一次“破坏性打开测试”。用 WPS 和 Word 各开一遍重点看表格列宽和图片位置。这一步看起来很原始但能发现所有脚本查不出来的渲染偏差。比如页边距和字体替换问题只有真实打开才能观察到。如果和上游沟通时要求输出文档还必须能通过爱思唯尔期刊模板那类审核那我会再把页眉页脚、标题层级、参考文献编号无论用 EndNote 还是 Zotero 插入逐一对一遍但这是独立的排版校验不属于合并本身。从我第一次用纯 POI 拼文档翻车到后来换成 POI-TL 循环渲染最大的变化就是我把“合并文档”从代码问题变成了“数据建模 模板规范”问题。从那以后我每次合并完都强制走一遍“拆包检查 → 图片计数 → 表格列宽 → WPS/Word 双开”四步流程才把文件交出去。希望这篇分享能帮到你少踩几个我用血泪换来的坑。本文还有配套的精品资源点击获取

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询