Java POI 模板生成 Word 文档:占位符替换与循环表格实战
简介这份资源面向需要在Java环境中批量生成定制化Word文档的开发者聚焦于用Apache POI读取预设模板、替换占位符并输出个性化文档这一典型场景适合已具备一定Java基础、希望将报表、合同等文档生成流程自动化的中高级程序员参考。压缩包共11个文件约7.37MB包含5个jar依赖包poi、poi-ooxml、xmlbeans、dom4j等核心库、3个java源码文件如WordUtil、CustomXWPFDocument、SimpleDocument等工具类、2个docx模板示例以及1张效果图覆盖从依赖引入到模板读写的完整链路。目前已有4138人学习下载说明该方案在实际项目中具有较高参考价值。读者可借助其中的工具类与模板样例快速理解XWPFDocument、XWPFParagraph、XWPFTable等对象的操作方式掌握段落文本替换、表格数据填充及文件写出关闭等关键环节并在此基础上扩展图片处理、动态数据映射与异常处理逻辑从而搭建起可复用的Word文档生成模块。1. 模板引擎选型为什么 POI 仍是 Java 生成 Word 的稳妥底牌做过企业级文档导出的人多半有过这种经历需求方丢来一份排版精美的 Word 模板要求把数据库里的字段填进去保留页眉页脚、表格样式、字体字号还要支持批量生成。第一反应可能是用 POI 从零画表格、设样式结果写了三百行代码导出的文档还是歪的。更省力的路径是「模板 占位符替换」——让设计人员在 Word 里把版式调好程序只负责把数据塞进指定位置。Java 生态里做这件事Apache POI 配合 XWPF 组件是最常见也最稳的组合它不依赖 Office 环境纯 Java 操作 .docx跨平台部署没有额外负担。这篇笔记就围绕「java poi 通过模板生成 word 文档」这条主线把选型理由、占位符设计、段落与表格替换、循环行处理、以及那些让人加班到深夜的坑一条条拆开讲清楚。适合已经会用 Java 读写文件、但被 Word 排版折磨过的后端开发者。2. 用 XWPF 打开模板并替换占位符最小可跑通的骨架2.1 依赖引入与模板文件放置先把依赖理清楚。POI 操作 .docx 需要poi-ooxml它内部会带上poi和poi-ooxml-lite。版本上不必追最新选一个团队里已经在用的稳定版即可避免和现有依赖树冲突。Maven 里加这么一段dependency groupIdorg.apache.poi/groupId artifactIdpoi-ooxml/artifactId version5.2.3/version /dependency模板文件放在src/main/resources/templates/下用类路径加载这样打成 jar 后依然能读到。注意模板必须是.docx.doc是二进制格式XWPF 处理不了这是第一个容易翻车的地方。2.2 读取模板与段落文本替换最小骨架就三步打开模板、遍历段落、替换占位符。占位符建议用{{fieldName}}这种双花括号形式比${}更不容易和 Word 域代码冲突。import org.apache.poi.xwpf.usermodel.*; import java.io.*; import java.util.Map; public class WordTemplateWriter { public static void write(String templatePath, String outputPath, MapString, String data) throws IOException { try (InputStream is WordTemplateWriter.class .getResourceAsStream(templatePath); XWPFDocument doc new XWPFDocument(is); FileOutputStream fos new FileOutputStream(outputPath)) { // 1. 替换段落中的占位符 for (XWPFParagraph paragraph : doc.getParagraphs()) { replaceInParagraph(paragraph, data); } // 2. 替换表格中的占位符 for (XWPFTable table : doc.getTables()) { for (XWPFTableRow row : table.getRows()) { for (XWPFTableCell cell : row.getTableCells()) { for (XWPFParagraph p : cell.getParagraphs()) { replaceInParagraph(p, data); } } } } doc.write(fos); } } private static void replaceInParagraph(XWPFParagraph paragraph, MapString, String data) { // 先拼接整段文本再统一替换避免 run 被拆散 StringBuilder full new StringBuilder(); for (XWPFRun run : paragraph.getRuns()) { full.append(run.text()); } String replaced full.toString(); for (Map.EntryString, String e : data.entrySet()) { replaced replaced.replace({{ e.getKey() }}, e.getValue()); } if (!replaced.equals(full.toString())) { // 清空原有 run把替换后的文本写回第一个 run for (int i paragraph.getRuns().size() - 1; i 0; i--) { paragraph.removeRun(i); } XWPFRun newRun paragraph.createRun(); newRun.setText(replaced); } } }这段代码的关键在replaceInParagraph。Word 会把一段文字拆成多个 run比如「{{name}}」可能被拆成「{{」「name」「}}」三个 run直接对单个 run 做 replace 会漏掉。所以先把整段 run 的文本拼起来替换完再清空重写。代价是段落内原有的局部样式比如某个词加粗会丢失如果模板里同一段有混合样式需要更细的 run 级处理后面避坑章节会讲。参数说明templatePath是类路径下的相对路径data的 key 不带花括号value 为 null 时建议替换成空字符串而不是字面量「null」否则文档里会出现尴尬的 null 字样。2.3 表格单元格替换的注意点表格替换和段落逻辑一样但要注意单元格里可能有多个段落每个段落都要走一遍替换。另外合并单元格在 POI 里表现为多个 cell 引用同一块区域遍历时可能重复替换不过因为替换是幂等的占位符已经没了重复执行不会出错。真正要小心的是单元格里嵌套表格getTables()只返回顶层表格嵌套表格需要递归取cell.getTables()这个在复杂模板里很常见。3. 循环行与动态表格把 List 数据铺进 Word 表格3.1 循环行的占位符约定静态字段替换只是开胃菜真正难的是动态行——比如订单明细有 N 条模板里只画了一行需要根据数据量复制出 N 行。常见做法是在模板表格里放一行「模板行」用{{item.name}}、{{item.price}}这类带前缀的占位符标记程序识别到这一行含循环占位符后按数据量克隆行并逐行替换。private static void fillTableRows(XWPFTable table, String prefix, ListMapString, String rows) { // 找到含 {{prefix. 的模板行下标 int templateIdx -1; for (int i 0; i table.getRows().size(); i) { if (rowContains(table.getRow(i), {{ prefix .)) { templateIdx i; break; } } if (templateIdx 0 || rows.isEmpty()) return; XWPFTableRow templateRow table.getRow(templateIdx); // 从后往前插入避免下标错乱 for (int i rows.size() - 1; i 0; i--) { XWPFTableRow newRow table.insertNewTableRow(templateIdx 1); copyRow(templateRow, newRow); replaceRow(newRow, prefix, rows.get(i)); } // 删除模板行本身 table.removeRow(templateIdx); }逻辑说明先定位模板行然后按数据条数克隆。克隆用insertNewTableRow在模板行下方插入复制时要把模板行每个 cell 的段落和 run 样式一并复制过去否则新行会丢样式。替换完成后删掉模板行避免文档里残留一行占位符。参数prefix用来区分同一文档里多组循环数据比如item和fee互不干扰。3.2 复制行样式的实现细节copyRow是这段逻辑里最容易写糙的地方。POI 没有现成的行克隆 API需要手动复制 cell 的宽度、段落的对齐方式、run 的字体和加粗状态。private static void copyRow(XWPFTableRow src, XWPFTableRow dest) { dest.setHeight(src.getHeight()); for (int i 0; i src.getTableCells().size(); i) { XWPFTableCell srcCell src.getCell(i); XWPFTableCell destCell dest.getCell(i); // 清空目标 cell 默认段落 destCell.removeParagraph(0); for (XWPFParagraph srcP : srcCell.getParagraphs()) { XWPFParagraph destP destCell.addParagraph(); destP.setAlignment(srcP.getAlignment()); for (XWPFRun srcRun : srcP.getRuns()) { XWPFRun destRun destP.createRun(); destRun.setText(srcRun.text()); destRun.setBold(srcRun.isBold()); destRun.setFontSize(srcRun.getFontSize()); destRun.setFontFamily(srcRun.getFontFamily()); } } } }这里只复制了常用样式属性实际项目里可能还要处理颜色、下划线、单元格底纹。如果模板样式复杂更省事的做法是让模板行本身足够简单把复杂样式放在表头数据行统一用基础样式减少复制负担。3.3 数据量过大时的分页控制当循环行超过一页时Word 会自动分页但表头不会自动重复。如果需求要求每页都带表头需要在模板里把表头行设为「重复标题行」——这是 Word 的功能POI 可以通过XWPFTableRow的底层 CTTblTr 设置tblHeader属性。另一个思路是数据量超过阈值时拆成多个文档避免单文档过大导致打开缓慢。实测单文档表格行超过两千行时Word 打开会明显卡顿建议在业务层做分批。4. 避坑与排查模板生成 Word 最常见的五类翻车4.1 占位符被 Word 拆成多个 run 导致替换失败现象模板里明明写了{{name}}程序跑完文档里还是原样或者只替换了一半。原因Word 在编辑过程中会把连续文本拆成多个 run尤其是中英文混排、复制粘贴后。解决不要对单个 run 做 replace先拼接整段文本再统一替换如第 2 章代码所示。如果段落内有混合样式必须保留可以按 run 边界做滑动窗口匹配但实现复杂度高多数场景直接整段重写更划算。4.2 替换后字体样式丢失现象替换进去的文字变成了默认宋体小五和模板里设定的字体不一致。原因清空 run 后新建的 run 没有继承原样式。解决在新建 run 之前记录第一个原 run 的字体、字号、加粗状态赋给新 run。或者更稳妥的做法是保留第一个 run只改它的文本删除其余 run这样样式天然继承。4.3 表格循环行插入后样式错乱现象克隆出来的行边框消失、列宽变化、文字挤在一起。原因insertNewTableRow创建的行没有复制原行的单元格宽度和边框定义。解决复制行时显式设置 cell 宽度destCell.setWidth(srcCell.getWidth())边框则要通过底层 CTTblBorders 复制或者干脆在模板里把表格边框设为「全部框线」让新行自动继承表格级样式。4.4 中文乱码或特殊字符丢失现象导出的文档里中文变成问号或者、等字符导致文档损坏。原因POI 处理的是 XML特殊字符需要转义中文乱码通常是字体问题而非编码问题。解决替换值里的、、用run.setText(text, 0)时 POI 会自动处理转义但如果手动拼 XML 就要自己转。中文乱码检查模板默认字体是否设置了中文字体如宋体、微软雅黑纯英文模板插入中文可能显示异常。4.5 模板文件被占用或输出流未关闭现象第二次生成时报「文件被另一个进程占用」或者生成的文档打不开。原因XWPFDocument和FileOutputStream没有正确关闭Windows 下文件句柄未释放。解决用 try-with-resources 包裹所有流确保doc.close()和fos.close()都被调用。如果输出文件已存在且被 Word 打开写入会失败业务层要捕获 IOException 并给出明确提示。5. 进阶技巧用 XWPFDocument 做条件段落与图片插入模板生成走到后面需求往往会升级某些段落要根据条件显示或隐藏文档里还要插入动态图片。这两件事 POI 都能做但姿势要对。条件段落的核心思路是「标记 删除」。在模板里给可选段落加上{{#if flag}}和{{/if}}标记程序遍历段落时识别标记区间flag 为 false 就删除区间内所有段落。删除段落用doc.removeBodyElement(doc.getPosOfParagraph(paragraph))注意删除时下标会变建议先收集要删的段落对象再统一删。private static void removeParagraph(XWPFDocument doc, XWPFParagraph p) { int pos doc.getPosOfParagraph(p); if (pos 0) { doc.removeBodyElement(pos); } }图片插入用paragraph.createRun().addPicture(is, XWPFDocument.PICTURE_TYPE_PNG, img.png, Units.toEMU(120), Units.toEMU(80))。参数依次是输入流、图片类型、文件名、宽、高单位是 EMUUnits.toEMU把像素转成 EMU。图片流记得单独关闭否则会泄漏。如果图片要放在表格单元格里先拿到 cell 的段落再 createRun。一个我踩过的坑在循环行里插图片时如果图片流被多个 run 复用第二个 run 会读到空流。解决办法是每次插入都重新打开一次输入流或者把图片字节缓存到 byte 数组用new ByteArrayInputStream(bytes)反复构造。验证生成结果是否正确的习惯不要只用 Word 打开肉眼看写个单元测试用 POI 读回文档断言关键字段的文本和表格行数。这样回归时能自动发现模板改动导致的替换失效。我一般会在测试里覆盖「空数据」「单条数据」「超过一页数据」三种情况基本能拦住大部分低级错误。希望帮到你。本文还有配套的精品资源点击获取