做报表导出功能的时候,凡是涉及Word文档生成,几乎都会碰上这几件事:动态标题、表格数据、图片插入、目录自动生成、单元格合并。Apache POI在这条路上算是Java生态里的老大哥,但它的API设计实在不算友好,尤其是操作.docx时,很多功能没有现成方法,得自己拼接底层XML结构。这篇文章我就把自己用POI生成Word的完整思路和踩坑经验整理出来,重点围绕标题、表格、图片、目录、合并单元格这五个需求展开,希望能帮你少走点弯路。
先说清楚适用人群:有Java基础、正在做办公文档生成类功能、打算用POI操作Word模板或动态生成docx文件的开发者。如果你只是想把一个固定Word文件里的几个占位符替换掉,那可以考虑更上层的封装工具;但如果你要动态生成结构化文档,那POI这套底层能力绕不开,值得系统性掌握。
1. 动手前先理清POI操作Word的API主线和环境依赖
1.1 为什么是XWPF而不是HWPF
POI对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> <groupId>org.apache.poi</groupId> <artifactId>poi-ooxml</artifactId> <version>5.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 pt,4表示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); // 设置表格总宽度(单位是Twips,1厘米约567Twips) tblPr.addNewTblW().setType(STTblWidth.DXA); tblPr.getTblW().setW(BigInteger.valueOf(9600)); // 约16.9cm // 给每个单元格设置宽度 for (XWPFTableRow row : table.getRows()) { List<XWPFTableCell> 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.addPicture:
XWPFParagraph 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 EMU,1像素在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 Field),Word打开文档时会去解析文档里的标题结构,动态计算目录条目和页码。
域代码在docx的XML里表现为w:fldSimple或w:fldChar+w: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:val="true"/>节点,让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版本冲突导致序列化出问题;三是插入了无效的图片流。
排查建议:先用压缩工具打开docx(docx本质是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的实战经验整理出来分享。