☰
Java使用Apache POI生成Word文档:标题、表格、图片与目录实战
2026/10/5 3:05:26 网站建设 项目流程

做了一段时间的办公自动化系统,最常被安排的需求就是“用代码生成Word报告”。刚开始我以为就是简单套模板,后来发现真正难的不是文字,而是文档里这些元素怎么组合在一起:多级标题、表格、图片、自动目录,再加上各种行列合并,每一项都有坑。这篇文章就围绕我用 Apache POI 写 Word 的实际经历,把这套流程里最关键的东西拆开讲讲。

我会从方案选型讲起,然后是依赖准备、核心API结构,再给出完整的代码实线路,最后把我踩过的坑和排查思路整理成清单。内容偏实战,适合已经在用 POI 做文档生成、或者正准备接手类似需求的 Java 开发。

1. 为什么我最终还是用POI硬写Word

1.1 生成Word报表的场景痛点

真实业务里,“按模板生成Word”这件事远没有想象中简单。比如项目周报、质检报告、设备巡检单,这些文档的结构通常是:封面标题、目录、若干章节、每章节下面有说明文字和数据表格,偶尔还要插入设备照片、趋势图。如果数据量一大,手工复制粘贴到 Word 里不仅低效,还特别容易出错——漏行、错列、图片放错位置,这些问题几乎无法避免。

用程序生成Word,最初我尝试过几种路线:用 freemarker 套模板、用 html 转 word、还有用 POI 直接在代码里构建文档。对比下来,POI 是最“硬核”的一种,它不依赖 Word 软件本身,服务端只要有 JDK 就能跑,生成的 docx 文件可以被 Word、WPS 正常打开,也方便后续做文件流输出、加密、转PDF。正因为 POI 底层直接操作 OOXML 结构,所以在一些别人看起来奇怪的需求上,反而不容易被模板引擎的语法限制卡住。

这个项目里,我需要实现的最终效果大概是这样的:一个 blank.docx,开头居中显示大标题,下面是自动生成的目录;紧接着若干章节,每章有二级/三级标题,章节内有说明段落,有带表头的表格,表格里某些列需要对连续多行做纵向合并,最后还要在指定位置插入一张统计图。这些能力,POI 都能覆盖到,只是很多 API 不会主动告诉你该怎么组合。

1.2 POI与Freemarker、Poi-TL的选型对比

很多文章会推荐用 freemarker + docx4j 或者 openhtmltopdf 等方式生成Word,各有优点,但我的项目最终选择了 POI 硬写,核心原因有三点:一是数据来源是数据库动态查询,文档结构随着数据变化会增删表格和章节,模板方式反而不灵活;二是需要精确控制单元格合并、图片尺寸这些底层细节,模板驱动的方案在这些地方要么不支持,要么要写很诡异的自定义指令;三是 POI 的技术资料丰富,出了问题能排查到 XML 层,看得见摸得着。

方案优点缺点适用场景
Freemarker + XML模板模板直观、维护成本低复杂表格和合并处理困难固定结构文档
Poi-TL基于POI封装,标签丰富遇到特殊需求仍需写POI90%常规模板场景
Apache POI 直接构建灵活、可控、无模板学习成本代码量相对大、API偏底层动态结构、复杂合并场景

Poi-TL 其实也使用了 POI 作为底层引擎,它把段落、图片、表格的指令做了简化。我也用过一阵 Poi-TL,确实在“快速套模板”这件事上效率很高。但一旦涉及到动态行数、动态列合并、动态生成目录时,Poi-TL 的某些标签会暴露限制,最后还是得回归原生 POI 手动写。所以如果你项目的模板结构相对固定,可以直接用 Poi-TL;如果你的文档结构会随着业务数据大幅度变化,那么直接啃 POI 反而是效率最高的选择。

2. 动手前的准备:依赖、版本与文档结构

2.1 Maven依赖与版本避坑

使用 POI 操作 Word 主要是基于poi-ooxml模块。这里有个关键点:POI 的 4.1.0 及以下版本存在 XXE 等安全漏洞,尤其是和 Excel XML 导出相关的XSSFExportToXML模块,官方在后续版本中修复了这部分问题,所以如果你的项目对安全性有要求,并且还有同时操作 Excel 的需求,尽量选择 5.2.x 或者更高版本。

<dependency> <groupId>org.apache.poi</groupId> <artifactId>poi-ooxml</artifactId> <version>5.2.5</version> </dependency>

POI 5.x 之后对 JDK 版本有一定要求,我这边用的是 JDK 8,匹配 5.2.5 没有发现问题。但如果你的项目还在更老的 JDK 环境,可能需要降级到 4.1.2。另外需要注意,POI 5.x 依赖的是新版的 xmlbeans,项目里如果还有其他模块引用了低版本 xmlbeans,可能会出现NoSuchMethodError,这种情况建议在 dependencyManagement 里统一版本。

2.2 Word文档对象层级与POI映射关系

在用 POI 写 Word 之前,要先理解它操作的是什么。docx 本质上是一个压缩包,里面最主要的XML文件是word/document.xml,它描述的就是文档正文学。POI 里的XWPFDocument对应这个 document.xml,一个XWPFDocument可以包含多个 body 元素,这些元素基本就是段落(XWPFParagraph)和表格(XWPFTable),段落里再包含多个 Run(XWPFRun)。

可以用一个生活化类比来理解:段落相当于 Word 里的一次回车分隔的内容,Run 则是段落里具有相同字体格式的一小段文字。例如“这是一段加粗的红色文字”,在程序里就是同一个段落里创建两个 Run,第一个 Run 设置加粗,第二个 Run 设置红色。表格则是由行(XWPFTableRow)和单元格(XWPFTableCell)组成。

这个层级关系决定了写代码时的顺序:

XWPFDocument document = new XWPFDocument(); // 创建一个段落 XWPFParagraph paragraph = document.createParagraph(); XWPFRun run = paragraph.createRun(); run.setText("一段文字");

如果搞不清段落和 Run 的区别,后面设置样式时容易一头雾水。很多人写 POI 代码时经常遇到“字体设置了没生效”的问题,往往就是设置在了错误的对象上——比如给段落设置了字体,实际上字体是设置在 Run 上的。理解了这个层级,后面很多问题都能自然解决。

3. 从零实现:标题、表格、图片、目录、合并单元格

3.1 标题段落:不止是加粗,要能进目录

很多新手用 POI 建标题时,第一反应是创建一个段落,然后设置字体大小、加粗,以为这就是标题了。这样做视觉上看着像标题,但 Word 的目录识别不出来,因为目录是按照段落内置样式(Heading 1、Heading 2)来抓取结构的。

要让标题可以被目录识别,必须给段落设置样式:

XWPFParagraph heading1 = document.createParagraph(); heading1.setStyle("Heading 1"); XWPFRun run = heading1.createRun(); run.setText("1. 项目概述"); run.setBold(true); run.setFontSize(18); run.setFontFamily("微软雅黑");

setStyle("Heading 1")是核心,它会让这个段落拥有标题 1的内置样式属性。如果你只设置字体和字号,目录是永远抓不到的。同理,二级标题用Heading 2,三级标题用Heading 3。

如果业务上需要自定义章节标题的显示效果,比如颜色、行距等,可以在设置完内置样式之后,再针对 Run 做样式覆盖。但要注意,最好不要把Heading样式完全拆掉,否则目录还是识别不到。还有一种做法是给段落设置大纲级别setOutlineLevel,但相对复杂,用内置样式最省事。

3.2 表格创建与列宽控制

创建表格本身不难,document.createTable(rows, cols)就能生成一个 N 行 M 列的空表格。但在实际项目里,表格通常会遇到两个问题:列宽设置不生效,以及表头跨页重复。

先看列宽。POI 里单元格宽度默认是不确定的,会随着内容自动撑开。如果希望列宽固定,需要设置表格布局方式为 FIXED,然后逐列指定宽度。宽度单位是 twips(1英寸=1440 twips,1厘米约等于567 twips):

XWPFTable table = document.createTable(3, 3); // 设置表格布局为固定 CTTblPr tblPr = table.getCTTbl().getTblPr(); if (tblPr == null) { tblPr = table.getCTTbl().addNewTblPr(); } CTTblLayoutType layoutType = tblPr.addNewTblLayout(); layoutType.setType(STTblLayoutType.FIXED); // 设置每列宽度 for (int i = 0; i < 3; i++) { table.getRow(0).getCell(i).setWidth("3000"); table.getRow(1).getCell(i).setWidth("3000"); table.getRow(2).getCell(i).setWidth("3000"); }

这里有个细节:setWidth("3000")里的字符串是 twips 数值,不是像素,也不是厘米。3000 twips 大约是 5.3 厘米,所以要根据页面实际宽度来分配列宽。A4 纸默认正文宽度大约 16 厘米,也就是约 9000 twips。如果三列等宽,那每列设置 3000 左右比较合适。

表头跨页重复是一个比较少有人提但很实用的功能,当表格跨越两页时,希望第一行表头在新一页也能显示。这个属性在 POI 里没有直接的 API,需要操作底层 XML:

for (XWPFTableRow row : table.getRows()) { if (row.getTableRow() == null) { continue; } // 设置表头行,需要在trPr里加 tblHeader CTTrPr trPr = row.getCtRow().isSetTrPr() ? row.getCtRow().getTrPr() : row.getCtRow().addNewTrPr(); trPr.addNewTblHeader(); }

3.3 合并单元格:横向与纵向的一致性操作

合并单元格是 POI 里最容易踩坑的地方,因为XWPFTable并没有类似mergeCells的现成方法。网上能找到不少代码片段,但直接复制过来十有八九会出错,原因在于合并不仅仅是把 java 层的“表格单元数组”删减一下,而是要修改底层<w:tc>节点的属性。

横向合并,也就是把某一行的几个单元格拼成一个,核心是设置第一个单元格的gridSpan属性,然后把被合并的单元格从行里移除:

private void mergeCellsHorizontal(XWPFTable table, int rowIndex, int startCol, int endCol) { XWPFTableCell firstCell = table.getRow(rowIndex).getCell(startCol); // 设置第一个单元格横跨的列数 CTTcPr tcPr = firstCell.getCTTc().isSetTcPr() ? firstCell.getCTTc().getTcPr() : firstCell.getCTTc().addNewTcPr(); tcPr.addNewGridSpan().setVal(BigInteger.valueOf(endCol - startCol + 1)); // 从后往前删除被合并的单元格 for (int i = endCol; i > startCol; i--) { table.getRow(rowIndex).removeCell(i); } }

纵向合并,也就是把一列的多行合并成一个单元格,需要使用vMerge属性。第一个单元格设置为restart,后面单元格设置为continue,同时要把后面单元格里的内容清空,否则会出现文字重叠的现象:

private void mergeCellsVertical(XWPFTable table, int colIndex, int startRow, int endRow) { // 第一个单元格标记为合并起点 XWPFTableCell startCell = table.getRow(startRow).getCell(colIndex); CTTcPr startTcPr = startCell.getCTTc().isSetTcPr() ? startCell.getCTTc().getTcPr() : startCell.getCTTc().addNewTcPr(); startTcPr.addNewVMerge().setVal(STMerge.RESTART); // 后续单元格标记为继续合并,并清空内容 for (int row = startRow + 1; row <= endRow; row++) { XWPFTableCell cell = table.getRow(row).getCell(colIndex); CTTcPr tcPr = cell.getCTTc().isSetTcPr() ? cell.getCTTc().getTcPr() : cell.getCTTc().addNewTcPr(); tcPr.addNewVMerge().setVal(STMerge.CONTINUE); // 清空被合并单元格内的文字 for (XWPFParagraph para : cell.getParagraphs()) { for (XWPFRun r : para.getRuns()) { r.setText("", 0); } } } }

横向合并里从后往前删,是因为removeCell会改变行内索引,从前面删会导致后续索引错位。纵向合并里一定要清空内容,否则 Word 打开时“continue”单元格的残留文本会显示出来,看起来就乱了。这些细节如果不实际操作一遍,很难注意到。

有人可能会问,直接移除单元格会不会导致表格行列结构不完整?其实不会,gridSpan已经告诉 Word 这个单元格占据了几个网格列。如果只设置gridSpan而不删除单元格,Word 会认为一行里多了几个本该是下一行的单元格,表格就错乱了。所以这两步必须配合使用。

3.4 插入图片与动态尺寸换算

在 Word 里插入图片有两种情况:一种是从本地文件读取后添加,一种是接收 byte[] 上传的文件流。POI 的XWPFRun.addPicture方法接收的是输入流、图片类型、文件名、宽高,这里的宽高单位是 EMU(English Metric Units),和平时用的像素不一样。

先看一个基础示例,插入一张本地图片:

XWPFParagraph imageParagraph = document.createParagraph(); imageParagraph.setAlignment(ParagraphAlignment.CENTER); try (FileInputStream is = new FileInputStream("D:/chart.png")) { XWPFRun run = imageParagraph.createRun(); run.addPicture(is, XWPFDocument.PICTURE_TYPE_PNG, "chart.png", Units.toEMU(500), Units.toEMU(250)); } catch (Exception e) { e.printStackTrace(); }

Units.toEMU(500)是把“点”转换为 EMU。实际业务里,我们往往希望图片按原始尺寸插入,或者按比例缩放。像素转 EMU 的标准做法是:1 像素等于 9525 EMU。如果直接用Units.toEMU(500),按 72 DPI 算 500 点大约是 6.9 英寸,图片会特别大。所以建议用像素直接乘以 9525 的方式:

BufferedImage image = ImageIO.read(new File("D:/chart.png")); double width = image.getWidth() * 9525; double height = image.getHeight() * 9525; // 如果图片太宽,等比缩放 if (image.getWidth() > 500) { double scale = 500.0 / image.getWidth(); width = width * scale; height = height * scale; } run.addPicture(is, XWPFDocument.PICTURE_TYPE_PNG, "chart.png", (int) width, (int) height);

如果图片格式是 JPG,类型要选择XWPFDocument.PICTURE_TYPE_JPEG;是 GIF 也可以支持,但 Word 对 GIF 的兼容性比较差,建议统一转成 PNG 再插入。图片插入之后,段落默认没有缩进和对齐,最好设置居中或左对齐,视觉效果才正常。

还有一个比较隐蔽的点:如果一张图片所在的段落紧挨着一个表格,Word 可能会把图片“吸”进表格里或者和表格间距异常。这种情况下,可以在图片段落前后多创建一个空段落来隔开。

3.5 自动生成目录:域代码的完整实现

自动目录是很多人卡住的地方。POI 没有直接生成目录内容的 API,因为目录实际上是一个 Word 域(field),域本身不会在代码生成时被计算出来,而是交给 Word 打开文档时再刷新。这个刷新过程就是我们手动写TOC域代码,然后设置打开时自动更新域。

先看一下目录域的 XML 结构。一个最简单的目录域长这样:

<w:p> <w:r> <w:fldChar w:fldCharType="begin"/> </w:r> <w:r> <w:instrText xml:space="preserve"> TOC \o "1-3" \h \z \u </w:instrText> </w:r> <w:r> <w:fldChar w:fldCharType="separate"/> </w:r> <w:r> <w:t>右键更新目录</w:t> </w:r> <w:r> <w:fldChar w:fldCharType="end"/> </w:r> </w:p>

POI 里实现这个结构,需要手动创建多个 Run 来分别放begin、instrText、separate、占位文字和end:

private void addTOC(XWPFDocument doc) { XWPFParagraph tocParagraph = doc.createParagraph(); XWPFRun runBegin = tocParagraph.createRun(); runBegin.getCTR().addNewFldChar().setFldCharType(STFldCharType.BEGIN); XWPFRun runInstr = tocParagraph.createRun(); CTText instrText = runInstr.getCTR().addNewInstrText(); instrText.setSpace(XmlTokenType.SPACE); instrText.setStringValue(" TOC \\o \"1-3\" \\h \\z \\u "); XWPFRun runSeparate = tocParagraph.createRun(); runSeparate.getCTR().addNewFldChar().setFldCharType(STFldCharType.SEPARATE); XWPFRun runText = tocParagraph.createRun(); runText.setText("右键点击此处选择“更新域”,或选中内容后按F9刷新目录"); XWPFRun runEnd = tocParagraph.createRun(); runEnd.getCTR().addNewFldChar().setFldCharType(STFldCharType.END); }

但光有域代码还不够,Word 默认打开文档时不会自动刷新域。要让 Word 在打开时弹窗提示“是否更新域”,需要在文档设置里把updateFields打开:

doc.getSettings().setUpdateFields(true);

如果你的 POI 版本较老,没有getSettings()方法,可以手动操作底层 XML 来设置updateFields标志,这里我不再展开,核心思路是在<w:settings>节点下增加<w:updateFields w:val="true"/>。

目录通常要放在正文最前面,并且“目录”两个字本身不能使用 Heading 样式,否则目录里会出现自己。我一般先创建一个居中段落,设置“目 录”两个字,再紧跟着创建目录域段落。这样整篇文档的顺序就是:大标题、目录、正文。

注意事项:如果生成的文档里没有标题样式段落,目录刷新后就是一片空白。所以目录和标题样式是强关联的,这也是我在前面强调setStyle("Heading 1")的真正原因。

4. 实操中的坑与排查思路

4.1 打开Word没有目录,目录内容为空白

这种问题十有八九是标题段落没有设置内置样式。代码里只是把文字加粗放大了,Word 的目录抓取不到;或者是标题用了自定义样式,但自定义样式没有响应到目录域的大纲级别上。还有一种情况是域代码已生成,但用户没有按 F9 刷新。

排查思路可以这样:先看文档结构,在 Word 里打开导航窗格,如果能正常显示标题层级,说明样式没问题,目录刷新不出来也正常,按 F9 即可;如果导航窗格没有标题,说明是标题样式没有设置成功,需要回去检查setStyle是否调用了。

我个人的习惯是给目录的占位文字写清楚一点:“请按F9更新目录”,这样使用者一看就知道要刷新,不会误以为生成失败。

4.2 合并单元格后表格样式错乱

合并之后最常见的现象是:表格行数变多/变少,或者文字重叠。前面已经说了,纵向合并后继续单元格必须清空内容。另一个容易被忽略的问题是,如果一行里某个单元格被设置了gridSpan,但该行下面还有多个单元格标签残留,Word 渲染时会把残留单元格推向下一个网格,导致表格产生“缺列”或“多列”的错位。

遇到这种问题,最好的排查方式是解压 docx,直接看 document.xml 里对应<w:tr>标签下的<w:tc>数量是否和视觉上的列数一致。POI 操作不到位的本质,往往就是 XML 层结构不对。不要只盯着 Java 代码逻辑,把 XML 打开一看,什么都明白了。

4.3 列宽设了不生效

一个非常经典的问题:明明给每个单元格都设置了setWidth,打开 Word 后列宽还是乱跑。原因在于表格的布局方式仍然是自动调整(autofit),这时候 Word 会根据内容重新计算列宽,你设的宽度被当成了“建议值”。

解决方式就是前面提到的,把CTTblLayoutType设为FIXED。设置之后列宽才会按照 twips 数值严格渲染。这里还有一个伴生问题:固定列宽后,用户在某些 Word 版本里拖动列宽时会发现拖不动,这也是 POI 生成固定表格的特性之一,不是 bug,是 Word 对固定布局表格的默认交互行为。如果希望用户可以拖动,就不要设置 FIXED,或者给宽度留余地。

4.4 图片插入报错与格式兼容

用run.addPicture时最常见的异常是IllegalArgumentException: Picture type not supported,多数情况是因为图片确实不是标准的 JPEG/PNG,或者扩展名和实际格式不一致。比如把一张实际上为 BMP 的文件命名为.png,POI 根据扩展名判断格式时就会失败。建议插入前用ImageIO.read做一次格式校验,或者统一转成 PNG 字节数组再插入。

另外,如果图片的输入流在使用前被关闭了,也会报 IOException。比如 try-with-resources 先关闭了 FileInputStream,再调用addPicture,就会读取不到内容。正确做法是先把图片读成 byte[],再用ByteArrayInputStream传入。

byte[] imageBytes = Files.readAllBytes(Paths.get("D:/chart.png")); ByteArrayInputStream bais = new ByteArrayInputStream(imageBytes); run.addPicture(bais, XWPFDocument.PICTURE_TYPE_PNG, "chart.png", widthEmu, heightEmu);

4.5 文件损坏与乱码排查

生成的文件打不开,或者打开提示“文件已损坏”,多数时候不是 POI 本身的问题,而是代码中途异常退出,导致XWPFDocument没有正常关闭输出流。POI 在写文件时,如果最后没有document.write(outputStream)或document.close(),压缩包的结构是不完整的。

还有一个容易忽视的问题:在循环里创建了大量段落/表格但从不调用document.close(),文件句柄和内存会越积越多,导致服务端卡顿或生成的文件越来越大。我习惯把文档生成放在 try-with-resources 里,写完立即关闭:

try (XWPFDocument doc = new XWPFDocument(); FileOutputStream fos = new FileOutputStream("D:/output.docx")) { // 构建文档... doc.write(fos); }

如果是生成后 Word 关闭特别慢,排除文档内容本身特别庞大之外,通常是文档里包含了大量域代码(目录域、书签域),关闭时 Word 要做一次域计算和页码更新。这种情况下,把不必要的域代码移除,或者不做updateFields的自动设置,关闭速度会明显改善。

最后分享两个小技巧

在实际项目里,我还总结了两条经验。一条是:生成完 Word 之后,尽量再用一个独立程序打开并转换成 PDF 做“抽检”。因为很多结构问题(比如目录空白、表格错位)在 Word 客户端里不明显,但转成 PDF 后页面结构会原形毕露。这样可以在交付前发现问题,而不是等业务方吐槽。

另一条是:如果只是想在某个半成品文档基础上批量改样式,完全没有必要重新生成整个文档。POI 支持直接打开已经存在的 docx 文件,修改其中对应段落的文字和样式,再保存。很多报表平台就是这么做的——先用一个空文档把固定的封面、页眉页脚结构建好,再在服务端填充动态数据。

用 POI 写 Word 这件事,说难不难,说简单也不简单。难的是各种底层 XML 细节,比如 TOC 域、合并单元格、列宽布局,这些不在 API 表面上看得到;简单的是,只要理解了 docx 本质就是一堆 XML,再复杂的需求也有办法落地,因为你对结构的掌控永远在。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询