做报表导出功能的时候凡是涉及Word文档生成几乎都会碰上这几件事动态标题、表格数据、图片插入、目录自动生成、单元格合并。Apache POI在这条路上算是Java生态里的老大哥但它的API设计实在不算友好尤其是操作.docx时很多功能没有现成方法得自己拼接底层XML结构。这篇文章我就把自己用POI生成Word的完整思路和踩坑经验整理出来重点围绕标题、表格、图片、目录、合并单元格这五个需求展开希望能帮你少走点弯路。先说清楚适用人群有Java基础、正在做办公文档生成类功能、打算用POI操作Word模板或动态生成docx文件的开发者。如果你只是想把一个固定Word文件里的几个占位符替换掉那可以考虑更上层的封装工具但如果你要动态生成结构化文档那POI这套底层能力绕不开值得系统性掌握。1. 动手前先理清POI操作Word的API主线和环境依赖1.1 为什么是XWPF而不是HWPFPOI对Word的支持分两套HWPF操作.doc老格式XWPF操作.docx新格式。现在的业务场景里.docx基本是绝对主流所以这整篇文章只讲XWPF相关的内容。XWPF的对象模型和docx的XML结构是对应的XWPFDocument对应一个文档XWPFParagraph对应段落XWPFRun对应段落里的一段文本run可以理解成具有相同格式的文本片段XWPFTable对应表格XWPFTableCell对应单元格XWPFHeaderFooterPolicy管页眉页脚。掌握这套模型有一条关键主线文档 → 段落 → run表格其实也挂在这条主线上。XWPFDocument.createParagraph()创建段落paragraph.createRun()创建文本片段document.createTable()创建表格。所有内容的插入顺序由document.getBodyElements()里的元素顺序决定这一点后面写自动生成目录时会用到。1.2 依赖坐标和版本选择Maven坐标很直接dependency groupIdorg.apache.poi/groupId artifactIdpoi-ooxml/artifactId version5.2.5/version /dependency版本建议直接上5.x系列。这里有个安全相关的背景POI 4.1.0及以下版本里XSSFExportToXml存在XXE漏洞虽然那是Excel相关组件的问题但既然都踩到这块了建议直接用5.2.x以后的版本顺手把漏洞面收窄。5.x版本还有一个好处是底层依赖的xmlbeans版本更高对大文档的处理稳定很多。另外提醒一句如果项目里还用到poi-scratchpad处理旧版.doc文件要和poi-ooxml保持同一个大版本不然运行期会出现NoSuchMethodError这类兼容性问题。这个坑很隐蔽因为编译期不会报错。2. 标题处理样式ID和字体设置是两码事2.1 段落、run和Word内置样式之间的关系很多人第一次用POI写标题会直接这么做XWPFParagraph title document.createParagraph(); XWPFRun run title.createRun(); run.setText(这是标题); run.setBold(true); run.setFontSize(18); run.setFontFamily(微软雅黑);这样确实能得到一行看起来像标题的文字但它本质上只是一个普通段落。Word里的标题是有结构含义的它对应内置样式heading 1、heading 2等。没有样式ID的段落既不会出现在导航窗格里也不能被自动生成目录识别。目录能生成的前提就是文档里存在真正应用了标题样式的段落。所以正确的思路是先给段落设置样式ID再处理字体细节。POI里设置样式ID的方法很简单但有个容易忽略的前提文档必须存在对应的样式定义。新建的空白文档styles.xml里只包含Normal等少数几个样式heading 1这类样式不一定存在。2.2 实操创建标题并确保样式和字体都正常显示完整可用的标题创建代码public void addHeading(XWPFDocument document, String text, int level) { XWPFParagraph paragraph document.createParagraph(); // 直接设置样式ID String styleId Heading level; // Heading1, Heading2... paragraph.setStyle(styleId); XWPFRun run paragraph.createRun(); run.setText(text); // 处理中文字体必须同时设置ascii和eastAsia // 因为默认的字体设置很可能只对西文字符生效 run.setFontFamily(微软雅黑); // 关键需要设置到rPr的rFonts中eastAsia属性 setEastAsiaFont(run, 微软雅黑); // 如果样式ID对应的样式不存在手动兜底设置格式 paragraph.setSpacingBefore(240); paragraph.setSpacingAfter(120); paragraph.setIndentationFirstLine(0); // 保持大纲级别保证导航窗格能识别 paragraph.getCTP().getPPr().setOutlineLvl( CTDecimalNumber.Factory.newInstance() .setVal(BigInteger.valueOf(level - 1)) ); }setEastAsiaFont这个辅助方法值得单独说明因为中文字体是高频需求private void setEastAsiaFont(XWPFRun run, String fontName) { // 取到run的底层CTR对象直接操作rFonts节点 CTR ctr run.getCTR(); CTFonts fonts ctr.isSetRPr() ctr.getRPr().isSetRFonts() ? ctr.getRPr().getRFonts() : ctr.addNewRPr().addNewRFonts(); fonts.setEastAsia(fontName); fonts.setAscii(fontName); fonts.setHAnsi(fontName); }eastAsia属性不设置的后果很实际中文环境里标题字体有可能回退到宋体哪怕你setFontFamily(微软雅黑)也只对英文字符生效。这个细节在POI的文档里写得很含糊属于典型的不实测根本发现不了的问题。2.3 推荐做法通过修styles.xml来治理标题样式如果你需要生成的文档数量大、标题样式多逐个段落设置格式既慢又容易遗漏。更经济和规范的做法是直接准备一个模板docx在模板的styles.xml里预先定义好Heading1、Heading2等样式然后用POI往模板里追加内容。这里有个实用技巧把模板放在src/main/resources下每次操作时用文件流加载并操作副本InputStream is YourClass.class.getClassLoader().getResourceAsStream(template.docx); XWPFDocument document new XWPFDocument(is);这样标题会直接套用模板里的预设颜色、字体、间距代码里基本不用写格式配置。这种方式也是目前工业级项目里最常用的方案因为它把生成逻辑和样式管理彻底解耦了。3. 表格创建、列宽控制与单元格合并的完整实现3.1 创建表格的基本流程POI创建表格的入口有两个document.createTable(rows, cols)和document.createTable()。前者直接创建带行列数的表后者创建空表。实际项目中我几乎都用前者因为大多数表格的结构是确定的。XWPFTable table document.createTable(4, 3);创建之后表格没有边框默认是不可见的。需要给表格设置边框样式。表格的边框控制挂在tblPr节点上POI没有直接的方法得操作底层XML// 给表格添加边框 CTTableBorders borders table.getCTTbl().getTblPr().addNewTblBorders(); // 设置上下左右和内部边线 for (CTBorder border : new CTBorder[]{ borders.addNewTop(), borders.addNewBottom(), borders.addNewLeft(), borders.addNewRight(), borders.addNewInsideH(), borders.addNewInsideV() }) { border.setVal(STBorder.SINGLE); // 单实线 border.setSz(BigInteger.valueOf(4)); // 线宽单位是1/8 pt4表示0.5pt border.setColor(000000); }这一步不做的话生成的表格在Word里长得像一段纯文本堆在一起很容易被误认为格式问题。3.2 列宽失效的真正原因POI设置表格列宽最直观的方法是table.setWidth(6000); // Twips单位但实测你会发现这段代码很多时候没效果或者Word打开后列宽被自动调整了。原因在于Word有自动调整机制表格布局可能是autofit而且tblW和tcW必须协同设置才能固定列宽。完整的列宽控制需要三个条件同时满足tblLayout类型要设为fixed禁用自动调整表格级宽度tblW要设置每个单元格的宽度tcW也要设置// 设置固定布局 CTTblPr tblPr table.getCTTbl().getTblPr(); if (tblPr null) { tblPr table.getCTTbl().addNewTblPr(); } tblPr.addNewTblLayout().setType(STTblLayoutType.FIXED); // 设置表格总宽度单位是Twips1厘米约567Twips tblPr.addNewTblW().setType(STTblWidth.DXA); tblPr.getTblW().setW(BigInteger.valueOf(9600)); // 约16.9cm // 给每个单元格设置宽度 for (XWPFTableRow row : table.getRows()) { ListXWPFTableCell cells row.getTableCells(); for (int i 0; i cells.size(); i) { cells.get(i).setWidth(String.valueOf(3200)); // 三列各3200Twips } }顺便提醒一个新人常踩的坑table.setWidth(...)接收的是字符串底层会去设置tblW但如果你先设置了autofit布局这个宽度值在Word里会被忽略。所以顺序必须是先固定布局再设置宽度。3.3 合并单元格底层是CTVMerge不是裁剪POI没有mergeCells(row, startCol, endCol)这种直接的封装方法合并单元格的底层操作是修改每个单元格的vMerge或gridSpan属性。这大概是整篇文章里最绕的一块但理解了原理之后就很顺。横向合并和纵向合并底层机制是不同方向完全不同的。横向合并把起始单元格的gridSpan值设置为合并的列数被合并的单元格从表格的grid节点中移除或标记。实际操作中POI里主要是给起始单元格设置gridSpan然后把被合并单元格的XML节点删除。// 横向合并第0行第1列到第2列 XWPFTableCell cell table.getRow(0).getCell(1); CTTcPr tcPr cell.getCTTc().addNewTcPr(); CTDecimalNumber gridSpan tcPr.addNewGridSpan(); gridSpan.setVal(BigInteger.valueOf(2)); // 占2列宽 // 然后删除第2个单元格的XML节点否则表格会多出一列 table.getRow(0).getCtRow().removeTc(2);这里要注意删除后面那个单元格的时候必须操作底层的CTRow.removeTc()不能只调用row.removeCell()因为POI高层API的XWPFTableRow.removeCell(int)在某些版本里只移除Java对象不更新底层的XML节点生成的文档结构仍然保留着那个单元格合并就失效了。纵向合并这是真正有合并语义的方向。纵向合并需要在合并区域的第一个单元格设置vMerge为restart后续单元格设置vMerge为continue// 纵向合并第1列第0行到第2行 XWPFTableCell firstCell table.getRow(0).getCell(1); firstCell.getCTTc().addNewTcPr().addNewVMerge().setVal(STMerge.RESTART); XWPFTableCell secondCell table.getRow(1).getCell(1); secondCell.getCTTc().addNewTcPr().addNewVMerge().setVal(STMerge.CONTINUE); XWPFTableCell thirdCell table.getRow(2).getCell(1); thirdCell.getCTTc().addNewTcPr().addNewVMerge().setVal(STMerge.CONTINUE);restart和continue这两个值非常容易记反。我最初写的时候把continue写到了第一个单元格上结果Word打开后合并区域错乱有的行多出空白有的行直接丢内容。后来查了WordprocessingML规范才明白restart标记的是合并起点continue标记的是延续部分起点只能有一个延续可以有很多个。对于被continue的单元格里面的内容在视觉上不会显示或者显示在合并区域的起点单元格里所以如果你需要在合并后的单元格里写数据往起点单元格写就行。3.4 单元格的段落控制还有个小问题XWPFTableCell本身不是直接放文本的容器它内部包含一个或多个段落文字是放在段落里的。默认创建单元格时自带一个空的XWPFParagraph直接用XWPFParagraph cellParagraph cell.getParagraphs().get(0); cellParagraph.setAlignment(ParagraphAlignment.CENTER); XWPFRun cellRun cellParagraph.createRun(); cellRun.setText(合并单元格内容); cellRun.setFontFamily(微软雅黑);这里建议在写单元格内容之前先统一设置所有单元格文字的字体、字号、居中等格式再逐格填数据。按单元格逐个设置也可以但不推荐因为样式的散弹式修改很难维护。4. 图片插入大小换算、居中和多图混排4.1 插入图片的单位换算逻辑POI插入图片的API是XWPFRun.addPictureXWPFParagraph imageParagraph document.createParagraph(); XWPFRun run imageParagraph.createRun(); try (InputStream imageStream new FileInputStream(chart.png)) { run.addPicture(imageStream, XWPFDocument.PICTURE_TYPE_PNG, chart.png, Units.toEMU(400), Units.toEMU(250)); }Units.toEMU接收的是像素值返回的是EMU单位。EMU是Office文档里的长度单位1英寸等于914400 EMU1像素在96DPI下等于9525 EMU。Units.toEMU(400)实际就把400像素转换成对应的EMU值这个工具类是POI提供的比自己手写换算省事。一个很实际的换算实例如果你希望图片占满页面宽度以A4纸为例A4宽度595磅左右页边距各约90磅可用宽度约415磅。1磅等于12700 EMU所以满宽图片的宽度大约是415 * 12700 5270500 EMU。直接用Units.toEMU(415)得到的是415像素对应的EMU按96DPI换算成磅大约是311磅达不到满宽效果。所以涉及到打印、排版场景时我建议直接用Units.toEMU配合磅值计算或者事先在Word里量好目标尺寸。4.2 图片居中以及图片和表格之间的留白插入图片后它默认是行内元素靠左排列。要让图片居中需要设置图片所在段落的对齐方式imageParagraph.setAlignment(ParagraphAlignment.CENTER);这里有个经验图片不要和标题、正文挤在同一个段落里最好单独开一个段落并且在这个段落前后各留一个空段落或者用段间距控制imageParagraph.setSpacingBefore(120); imageParagraph.setSpacingAfter(120);否则图片会和文本贴在一起视觉上很难看。4.3 图片显示不完整的排查图片显示不完整最常见的症状是文档打开后图片只有上半部分或者显示一半就截断了。这个问题的根源通常是图片所在行的高度不够。Word的默认行高是固定的当你插入一张比行高还大的图片时图片会被裁剪掉一部分。解决方式是设置该段落的line spacing为自动或者明确给大行高// 方式1设置自动行高 imageParagraph.setSpacingLineRule(LineSpacingRule.AUTO); imageParagraph.setSpacingBetween(1.5); // 1.5倍行距 // 方式2直接设置最小行高Twips imageParagraph.getCTP().getPPr().addNewSpacing().setLine(BigInteger.valueOf(480));如果图片尺寸特别大比如截图动辄几千像素宽建议写入前先做一次尺寸缩放把宽度压到600~800像素范围一是避免文档体积暴涨二是防止Word在大图片上渲染卡顿。5. 自动生成目录域代码方案和传统方案的取舍5.1 为什么POI没有一键生成目录先说结论POI没有提供document.addTOC()这样的方法。原因是目录在Word里不是一段静态文本而是一组复杂的域代码TOC FieldWord打开文档时会去解析文档里的标题结构动态计算目录条目和页码。域代码在docx的XML里表现为w:fldSimple或w:fldCharw:instrText的组合。POI的高层API没有完全封装这块所以我们得自己去拼XML。另外补充一点即使静态地生成了目录文本比如自己遍历标题然后拼页码在大多数场景下维护成本也很高因为Word的页码是动态排版的结果任何一处编辑都可能让页码整体漂移。所以正确做法永远是插入域代码让Word在打开文档时自己计算。5.2 插入TOC域代码的做法下面这段代码可以生成一个可用的目录public void addTOC(XWPFDocument document) { XWPFParagraph tocParagraph document.createParagraph(); // 目录段落建议单独放一页保证结构清晰 tocParagraph.setPageBreakBefore(true); // 添加目录标题 XWPFRun titleRun tocParagraph.createRun(); titleRun.setText(目 录); titleRun.setBold(true); titleRun.setFontSize(18); titleRun.setFontFamily(微软雅黑); // 插入TOC域代码 XWPFParagraph tocFieldParagraph document.createParagraph(); // 使用CTP的底层能力构造域代码结构 CTP ctp tocFieldParagraph.getCTP(); // 添加fldChar begin // 需要构造一个包含fldChar的run org.openxmlformats.schemas.wordprocessingml.x2006.main.CTR runBegin ctp.addNewR(); org.openxmlformats.schemas.wordprocessingml.x2006.main.CTFldChar fldCharBegin runBegin.addNewFldChar(); fldCharBegin.setFldCharType(STFldCharType.BEGIN); // 添加域指令文本 CTR runInstr ctp.addNewR(); CTText instrText runInstr.addNewInstrText(); instrText.setStringValue(TOC \\o \1-3\ \\h \\z \\u); // 添加separate标记 CTR runSep ctp.addNewR(); CTFldChar fldCharSeparate runSep.addNewFldChar(); fldCharSeparate.setFldCharType(STFldCharType.SEPARATE); // 这里可以放一条目录将在打开文档后自动生成占位文本 CTR runPlaceholder ctp.addNewR(); CTText placeholderText runPlaceholder.addNewT(); placeholderText.setStringValue(右键更新域以刷新目录); // 添加end标记 CTR runEnd ctp.addNewR(); CTFldChar fldCharEnd runEnd.addNewFldChar(); fldCharEnd.setFldCharType(STFldCharType.END); }代码里的指令段是最核心的部分TOC \o 1-3表示目录收集1~3级标题样式\h表示目录条目带上超链接\z表示隐藏Tab引导符和页码在Web视图中的显示\u表示使用段落大纲级别来构建目录。这些开关的含义记住常用的几个就够不用背完整列表。5.3 让Word打开时自动提示更新目录仅仅插入域代码还不够。因为域代码里的目录占位是一个静态区域如果不触发域更新Word打开后只会显示我们塞进去的那句右键更新域以刷新目录不会自动填充实际目录。要让Word打开时弹出更新域提示需要设置文档配置里的updateFields属性// 设置打开文档时自动更新域 document.getSettings().setUpdateFields(true);这一步很多人会漏。setUpdateFields(true)等价于在settings.xml里加了一个w:updateFields w:valtrue/节点让Word在打开文档时认为文档需要刷新域于是弹出是否更新域的提示。用户点是目录就自动生成了。还有一条路是直接用LibreOffice或者WPS的命令行无头模式把域更新掉再输出PDF但那是后处理流程本文不展开有需要的可以留言聊。5.4 目录页码对不上的排查思路如果你发现生成的目录确实有但页码和正文对不上排查顺序建议是确认正文标题用的是内置标题样式。目录\o 1-3默认收集的是Heading1、Heading2、Heading3这三个样式。如果你用普通段落加粗充当标题目录是识别不到的。确认没有被分页符干扰。封面、目录页、正文之间的分页方式如果处理不当页码会连续计数而不是从正文重新开始。这个需要给目录前的分节符设置pgNumType的start属性属于分节符的范畴和POI本身关系不大但在完整报告生成项目中很常见。确认没有设置标题字体太大导致自动换页。这个不太常见但发生过标题用超大字号Word排版时把标题挤到了上一页导致目录页码偏移。遇到这种问题检查标题段的段前段后间距设置即可。6. 完整示例把标题、表格、合并单元格、图片、目录串起来这里给一个可以直接跑通的整合示例把上面几块内容都串一遍。public class WordReportGenerator { public static void main(String[] args) throws Exception { XWPFDocument document new XWPFDocument(); // 1. 一级标题 addHeading(document, 一、项目概况, 1); addParagraph(document, 这是项目概况的正文内容。); // 2. 插入一张项目进度图 insertCenteredImage(document, schedule.png, 400, 250); // 3. 二级标题 addHeading(document, 二、费用明细, 2); // 4. 创建带合并单元格的表格 XWPFTable table createStyledTable(document, 4, 3); setTableFixedWidth(table, new int[]{3200, 3200, 3200}); // 合并表头第0行第0~1列横向合并 mergeCellsHorizontal(table, 0, 0, 1); table.getRow(0).getCell(0).setText(费用类型合并列); // 第1列第0~2行纵向合并 mergeCellsVertical(table, 1, 0, 2); table.getRow(0).getCell(1).setText(金额合并行); // 填充其它单元格 table.getRow(0).getCell(2).setText(备注); table.getRow(1).getCell(2).setText(预算内); table.getRow(2).getCell(0).setText(人力成本); table.getRow(2).getCell(1).setText(10万); table.getRow(2).getCell(2).setText(已支付); // 5. 自动生成目录 addTOC(document); // 6. 设置更新域并保存 document.getSettings().setUpdateFields(true); try (FileOutputStream fos new FileOutputStream(report.docx)) { document.write(fos); } document.close(); } }辅助方法的实现我在前面几个小节里已经分别给出来了。把addHeading、insertCenteredImage、createStyledTable、setTableFixedWidth、mergeCellsHorizontal、mergeCellsVertical、addTOC这些方法按前面的代码补齐这份示例就能跑通。有一点要注意目录插入的位置决定了它在文档里的物理位置。如果你希望目录出现在封面之后、正文之前那么生成顺序应当是先写封面段落再调用addTOC最后写正文内容和表格。因为POI是在文档尾部追加元素的顺序错了目录就会跑到最后面。7. 高频问题排查用POI生成Word时的几个实战坑7.1 生成的文件WPS打开提示文档已损坏这个坑多数出在XML结构不完整上。常见原因有几个一是手动操作底层XML节点后没有调用document.close()释放资源文件流不完整二是xmlbeans版本冲突导致序列化出问题三是插入了无效的图片流。排查建议先用压缩工具打开docxdocx本质是zip逐个检查word/document.xml等核心XML文件能否被正常解析。如果某个XML末尾明显少闭合标签基本可以确定是代码在操作底层节点时出了逻辑问题。这种场景下document.getCTDocument().validate()可以在保存前主动校验结构能早发现早修复。7.2 表格里插入的图片只显示边框、内容空白这个和单元格的属性设置有关。表格单元格默认没有单独的段落格式直接往单元格里的run上塞图片会出现单元格行高不够、图片被裁掉的情况。和正文里插图的处理方式一样先给单元格里的段落设置行距控制再插入图片XWPFParagraph p cell.getParagraphs().get(0); p.setSpacingLineRule(LineSpacingRule.AUTO); p.setSpacingBetween(1.5); p.createRun().addPicture(stream, XWPFDocument.PICTURE_TYPE_PNG, img.png, Units.toEMU(200), Units.toEMU(100));7.3 合并单元格后表格列宽变了这个属于合并操作的必然副作用。因为gridSpan会把被合并单元格的宽度合入起始单元格所以合并后必须重新计算并设置一次tcW。比如原本三列各1000宽把前两列合并后合并单元格的宽度应该是2000第三个单元格仍是1000。很多人在合并前设置了一次宽度合并后没有重新设置于是Word里的表格看起来忽宽忽窄。最稳妥的做法是先合并单元格再统一重设一次整表的列宽。7.4 大文档内存占用过高POI处理Word文档的特点是全文加载到内存尤其是包含大量图片时内存飙升非常明显。文档体积超过20MB、图片几十张这种场景XWPFDocument轻松吃满几百MB堆内存。优化思路可以分层图片写入前统一压缩到目标宽度避免原图直接塞进docx文本内容分段写入避免用一个Paragraph塞几千行文字如果只是替换模板里的占位符用XWPFDocument配合XWPFWordExtractor做流式处理不要反复打开同一个文档对象实在需要极大文档时考虑分段生成多个docx再合并或者直接用LibreOffice无头模式做后处理7.5 生成的docx在Word里打开后目录提示文档中包含的域可能引用其他文件这个提示一般出现在域代码路径或指令写错的情况下Word会认为你的TOC域指向外部文件。检查instrText里的内容尤其是引号是否用了中文全角引号这会让Word解析域指令失败从而给出误导性的提示。域指令必须使用英文半角引号。8. 一点个人体会我前前后后做了几年文档导出功能最大的感受是POI写Word这件事技术难度本身不高真正的成本在对Word文档模型的理解上。POI暴露的只是一层Java API背后是Office Open XML那套复杂规则。如果你愿意花半天时间大致翻一遍[Content_Types].xml、word/document.xml、word/styles.xml这三个文件的结构很多网上查不到的问题都会迎刃而解因为你会直接从XML层面理解POI为什么那样设计API。另外如果你们的项目只需要简单的模板填充POI并不是效率最高的选择可以考虑POI-TL这类专门做模板渲染的库它对占位符替换、表格循环、图片嵌入做了很好的封装开发效率高一个量级。但它也有自己的边界复杂合并单元格、跨页单元格拆分等场景反而绕。我的建议是简单模板填充用POI-TL复杂动态文档生成直接用原生POI加自己的工具类封装两者并不冲突。后续有机会我再把POI-TL的实战经验整理出来分享。