尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

Java使用Apache POI生成Word文档:标题、表格、图片与目录实战

Java使用Apache POI生成Word文档:标题、表格、图片与目录实战 做了一段时间的办公自动化系统最常被安排的需求就是“用代码生成Word报告”。刚开始我以为就是简单套模板后来发现真正难的不是文字而是文档里这些元素怎么组合在一起多级标题、表格、图片、自动目录再加上各种行列合并每一项都有坑。这篇文章就围绕我用 Apache POI 写 Word 的实际经历把这套流程里最关键的东西拆开讲讲。我会从方案选型讲起然后是依赖准备、核心API结构再给出完整的代码实线路最后把我踩过的坑和排查思路整理成清单。内容偏实战适合已经在用 POI 做文档生成、或者正准备接手类似需求的 Java 开发。1. 为什么我最终还是用POI硬写Word1.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 groupIdorg.apache.poi/groupId artifactIdpoi-ooxml/artifactId version5.2.5/version /dependencyPOI 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段落里再包含多个 RunXWPFRun。可以用一个生活化类比来理解段落相当于 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然后逐列指定宽度。宽度单位是 twips1英寸1440 twips1厘米约等于567 twipsXWPFTable 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需要操作底层 XMLfor (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方法接收的是输入流、图片类型、文件名、宽高这里的宽高单位是 EMUEnglish 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:fldCharTypebegin/ /w:r w:r w:instrText xml:spacepreserve TOC \o 1-3 \h \z \u /w:instrText /w:r w:r w:fldChar w:fldCharTypeseparate/ /w:r w:r w:t右键更新目录/w:t /w:r w:r w:fldChar w:fldCharTypeend/ /w:r /w:pPOI 里实现这个结构需要手动创建多个 Run 来分别放begin、instrText、separate、占位文字和endprivate 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:valtrue/。目录通常要放在正文最前面并且“目录”两个字本身不能使用 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 的文件命名为.pngPOI 根据扩展名判断格式时就会失败。建议插入前用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再复杂的需求也有办法落地因为你对结构的掌控永远在。
返回列表