ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Java后端实战:用Apache POI XWPF生成带目录表格图片的Word文档

Java后端实战:用Apache POI XWPF生成带目录表格图片的Word文档 做Java后端的朋友迟早会撞上“帮我在系统里加个导出Word”这种需求。一开始觉得简单等真用Apache POI去写才发现标题、表格、图片、目录、合并单元格每一项都暗藏玄机。尤其是“自动生成目录”和“合并单元格”这两个点百度一圈下来能一次跑通的方案并不多。今天我把这套玩法的完整思路和实操代码整理出来带着POI的XWPF从零写一份带目录、带表格、带图片的Word文档帮你在做报表生成、周报导出、合同文书这类需求时少走点弯路。先说清楚这篇东西适合谁你在用Java写Word自动生成的功能或者正准备从iText转过来做docx再或者已经被“POI目录不显示”“合并单元格错位”坑到怀疑人生那这篇内容基本就是给你准备的。看完你能直接上手照着撸一个能用的Demo。1. 为什么是POI的XWPF不是HWPF也不是模板引擎1.1 现实背景后端为什么会被逼着写Word日常项目里“导出Word”通常不是核心需求但却是最能体现交付质量的杂活。客户提需求时往往一句话带过“把报告内容导出来就行。”可真正做起来涉及的细节一个比一个碎标题要分级、正文要分页、表格要对齐、图片要放正、目录最好能自动更新。我见过不少团队为了省事直接往HTML里塞内容再改后缀名为.doc。这招在Word老版本里能糊弄过去但新版本Office打开后要不就是报格式错误要不就是样式全乱。真正靠谱的路子还是用Apache POI直接操作docx的底层XML结构把每一个段落、表格、图片、域代码老老实实写进去。1.2 XWPF与HWPF怎么选POI里操作Word其实有两条API线一条是HWPF针对老的.doc二进制格式另一条是XWPF针对Office 2007以后的.docx格式。现在谁还给你传.doc基本都是.docx。所以别犹豫直接学XWPF。XWPF的本质是把docx当作一个zip压缩包来处理。里面全是xml文件比如word/document.xml是正文word/styles.xml是样式word/media下面放图片。XWPF帮你把这些包装成Java对象比如XWPFDocument代表整个文档XWPFParagraph代表一个段落XWPFTable代表一张表格。你操作对象它帮你翻译成XML。这也是为什么很多人觉得POI写Word很别扭它不像MyBatis或者模板引擎那样“填变量”就行而是要你像拼积木一样一个节点一个节点往上搭。但好处是你能控制的细节远超模板方案目录、合并单元格、域更新这些硬需求只有走底层才能做干净。1.3 版本选型为什么我最后停在5.2.5POI版本这块必须单独说因为踩坑太深了。早期我用过3.17、4.1.0后来遇到一个安全通报Apache POI 4.1.0及以下版本在XSSFExportToXml相关功能上存在XXE漏洞。虽然我们写Word不一定走这个功能但依赖里带着旧版本扫描出来就够喝一壶的。我现在统一用5.2.5。这个版本对XWPF的支持比较稳Java 8也能跑合并单元格、目录域、字体设置这些API都齐。5.x版本在依赖上做了拆分只引入poi-ooxml一个包就能把主要的docx能力带进来比早期需要同时引poi和poi-ooxml省心得多。dependency groupIdorg.apache.poi/groupId artifactIdpoi-ooxml/artifactId version5.2.5/version /dependency2. 开工前的准备依赖、初始化、几个基础工具方法2.1 Maven依赖与三个隐性问题上面那个依赖是基础但实际项目里通常还会牵扯到日志冲突、XmlBeans版本冲突、以及commons-compress版本冲突。经验是如果项目里已经用了老版本的XmlBeans一定要确认POI 5.x要求的版本号是一致的否则启动时会出现类似“NoSuchMethodError”的诡异报错。另外POI 5.x默认使用Log4j2的API来做日志你要是项目里用的是Logback记得把log4j-api和log4j-core也一起引进去不然运行期会出现“找不到LogManager”这类问题。这些属于环境问题不算POI本身但实际开发中遇到的人不在少数。2.2 初始化文档与页面设置新建一个XWPFDocument非常直接new XWPFDocument()就完了。但这里有一个容易忽略的细节POI默认建出来的空白docx页面纸张可能是Letter不是国内常用的A4。如果只是自己预览不打印可能无感但正式交付给用户打印格式就会偏。设置A4页面需要操作底层CTSectPrXWPFDocument doc new XWPFDocument(); CTSectPr sectPr doc.getDocument().getBody().addNewSectPr(); CTPageSz pageSize sectPr.addNewPgSz(); pageSize.setW(11906); // A4宽度单位twips1mm约等于56.7 pageSize.setH(16838); // A4高度 pageSize.setOrient(STPageOrientation.PORTRAIT);这里单位不是像素也不是毫米而是twips1磅等于20twips1厘米大约等于567twips。A4纸210mm x 297mm换算过来就是11906 x 16838。记不住也没关系理解这个换算逻辑后面设置表格列宽时还会用到。2.3 公共方法字体、边框、单元格清空写POI代码最容易陷入一个泥潭每个功能都从零开始写代码越堆越长。我的习惯是先把几个高频操作抽象成工具方法设置中文字体、给表格加边框、清空单元格内容。这几个方法后面所有案例都能复用。中文字体这块特别容易踩坑POI里设置字体不只是调一个setFontFamily那么简单。docx的字体分ascii字体和eastAsia字体两个属性前者管英文数字后者管中文。只设置前者中文会变成默认等线或者宋体样式不对。所以封装时要把两个都设置到位public static void setRunFont(XWPFRun run, String fontName, int fontSize) { run.setFontFamily(fontName, XWPFRun.FontCharRange.eastAsia); run.setFontFamily(Calibri, XWPFRun.FontCharRange.ascii); run.setFontSize(fontSize); }FontCharRange.eastAsia这个参数就是管中文字体的初学POI的人很少注意到。我用这个方法给所有标题和正文统一样式比到处散落着setFontFamily干净得多。3. 核心能力落地标题、表格、图片、目录、合并单元格逐一攻破3.1 添加标题样式比手动加粗更重要添加标题很多人的第一反应是把文字加粗、调大字号。但如果你后续要做“自动生成目录”这么做就废了。Word的目录是依靠标题样式来提取条目的不是看你字够不够大、够不够粗。也就是说你必须给段落设置Heading 1、Heading 2之类的样式。public static XWPFParagraph addHeading(XWPFDocument doc, String text, int level) { XWPFParagraph para doc.createParagraph(); para.setStyle(Heading level); para.setAlignment(ParagraphAlignment.LEFT); XWPFRun run para.createRun(); run.setText(text); setRunFont(run, 微软雅黑, level 1 ? 16 : 14); run.setBold(true); if (level 1) { run.setColor(1F4E79); } else if (level 2) { run.setColor(2E74B5); } return para; }这里有一个细节setStyle(Heading 1)之后再手动覆盖run的字体、字号、颜色是安全的。但如果你不设置字体Word打开后标题可能用的是默认英文标题字体中文不生效。所以我的习惯是“样式兜底run覆盖字体”两者结合最稳妥。3.2 创建表格与列宽固定布局是前提POI创建表格非常简单doc.createTable(rows, cols)。但创建出来的表格默认是“自动适应窗口”的布局你设置的列宽很可能不生效。要让列宽听话必须先把表格布局改成FIXED再给每一列设置宽度。列宽单位前面说过是twips1厘米约等于567。假设我要做一个5列的表格每列分别是2cm、3cm、3cm、4cm、4cm代码这样写public static XWPFTable createTable(XWPFDocument doc, int rows, int cols, double[] widthsCm) { XWPFTable table doc.createTable(rows, cols); table.getCTTbl().getTblPr().addNewTblLayout().setType(STTblLayoutType.FIXED); for (int r 0; r rows; r) { for (int c 0; c cols; c) { XWPFTableCell cell table.getRow(r).getCell(c); cell.setWidth(String.valueOf((int) (widthsCm[c] * 567))); } } return table; }注意三点第一必须先设置固定布局再设置列宽顺序反了可能不生效第二setWidth传的是字符串内容是twips数值第三列宽要在每一行的每个单元格上都设置只设置表头那一行没用。还有一个隐藏问题默认创建出来的表格是没有边框线的。Word里表格边框是独立属性你在POI里建一个表不设置边框打印出来完全看不到格线。给表格加边框的方法如下public static void setTableBorders(XWPFTable table) { CTTblPr tblPr table.getCTTbl().getTblPr(); CTTblBorders borders tblPr.addNewTblBorders(); CTBorder[] borderArr { borders.addNewTop(), borders.addNewLeft(), borders.addNewBottom(), borders.addNewRight(), borders.addNewInsideH(), borders.addNewInsideV() }; for (CTBorder border : borderArr) { border.setVal(STBorder.SINGLE); border.setSz(BigInteger.valueOf(4)); border.setColor(999999); } }setSz(BigInteger.valueOf(4))里的4代表0.5磅线宽Word默认的单线就是0.5磅。颜色这里用的是浅灰想要黑线改成“000000”。3.3 合并单元格水平靠hMerge垂直靠vMerge合并单元格是POI里最让新手痛苦的操作之一因为POI没有提供mergeCell这种一把梭的API你需要直接操作底层XML标记。Word合并单元格的本质是合并区域的第一格标记为restart后面的格子标记为continue。水平合并的代码public static void mergeCellsHorizontal(XWPFTable table, int rowIndex, int colFrom, int colTo) { for (int i colFrom; i colTo; i) { XWPFTableCell cell table.getRow(rowIndex).getCell(i); CTTcPr tcPr cell.getCTTc().isSetTcPr() ? cell.getCTTc().getTcPr() : cell.getCTTc().addNewTcPr(); if (i colFrom) { tcPr.setHMerge(STMerge.RESTART); } else { tcPr.setHMerge(STMerge.CONTINUE); clearCell(cell); } } }垂直合并的代码public static void mergeCellsVertically(XWPFTable table, int colIndex, int rowFrom, int rowTo) { for (int i rowFrom; i rowTo; i) { XWPFTableCell cell table.getRow(i).getCell(colIndex); CTTcPr tcPr cell.getCTTc().isSetTcPr() ? cell.getCTTc().getTcPr() : cell.getCTTc().addNewTcPr(); if (i rowFrom) { tcPr.setVMerge(STMerge.RESTART); } else { tcPr.setVMerge(STMerge.CONTINUE); clearCell(cell); } } }clearCell是我自己封装的方法作用是清空continue单元格里的内容。为什么要清因为合并后continue单元格虽然在视觉上消失了但它内部的文本和段落还在如果不清空Word打开后可能在合并区域的角落冒出多余文字。public static void clearCell(XWPFTableCell cell) { for (XWPFParagraph p : cell.getParagraphs()) { for (XWPFRun r : p.getRuns()) { r.setText(, 0); } } }这里要特别提醒一个顺序问题如果一张表既有水平合并又有垂直合并尽量先做水平合并再做垂直合并。顺序反了后续合并的表头行号可能因为格子数量变化而错位排查起来很麻烦。3.4 插入图片尺寸换算和居中对齐POI插入图片本身不难难的是控制尺寸。XWPFRun.addPicture这个方法传进去的width和height参数会被POI视为像素但它内部实际按照“磅值”来换算EMU这就会导致尺寸和你预期不一致。准确的做法是先拿到图片原始分辨率按显示DPI换算成磅值再传给addPicture。假设一张图是1000x600像素我要按96DPI显示那么double dpi 96; double widthPt 1000.0 / dpi * 72; // 750磅 double heightPt 600.0 / dpi * 72; // 450磅然后调用XWPFParagraph p doc.createParagraph(); p.setAlignment(ParagraphAlignment.CENTER); XWPFRun run p.createRun(); try (InputStream is new FileInputStream(product.png)) { run.addPicture(is, XWPFDocument.PICTURE_TYPE_PNG, product.png, (int) widthPt, (int) heightPt); }750磅实际上还是很大A4正文可用宽度大概是450磅左右所以实际生成时需要对图片做等比缩放。最简单的办法就是提前给图片设置目标宽度比如“宽度不超过420磅”然后按比例自动计算高度double targetWidth 420; double ratio targetWidth / widthPt; widthPt targetWidth; heightPt heightPt * ratio;很多教程让你引入Units.toEMU做转换其实没必要反而容易把单位搞混。我自己实践下来直接按像素转磅再传参最直观也最不容易出错。3.5 自动生成目录插入TOC域代码这一步是整个需求里最唬人的部分但说穿了就是往文档里插入一段Word的“域代码”。Word的目录并不是一个静态列表而是一个可更新的域用户打开文档后按F9或者右键“更新域”Word会扫描全文的标题样式自动把目录条目填进去。用POI实现TOC域需要在段落里插入BEGIN、instrText、SEPARATE、END这几个域节点public static void addTOC(XWPFDocument doc) { XWPFParagraph p doc.createParagraph(); p.setAlignment(ParagraphAlignment.LEFT); XWPFRun runBegin p.createRun(); runBegin.getCTR().addNewFldChar().setFldCharType(STFldCharType.BEGIN); XWPFRun runInstr p.createRun(); CTText instrText runInstr.getCTR().addNewInstrText(); instrText.setString( TOC \\o \1-3\ \\h \\z \\u ); instrText.setSpace(preserve); XWPFRun runSep p.createRun(); runSep.getCTR().addNewFldChar().setFldCharType(STFldCharType.SEPARATE); XWPFRun runPlaceholder p.createRun(); runPlaceholder.setText(此处为目录区域请在Word中右键更新域); XWPFRun runEnd p.createRun(); runEnd.getCTR().addNewFldChar().setFldCharType(STFldCharType.END); }instrText里的\\o 1-3表示提取1到3级标题\\h表示目录带超链接\\z表示忽略Web视图\\u表示使用大纲级别。这段指令是Word内置的标准目录指令直接照抄即可。这里有一个必须提醒的点只有标题使用了Heading 1、Heading 2这样的内置标题样式TOC域才能识别到。你要是图省事手动加粗放大文字来代替标题更新域之后目录里一个字都不会出现。4. 完整实践生成一份带目录、表格、图片的产品运营周报4.1 报告结构与需求拆解理论说了那么多下面用一个“产品运营周报”的案例把整个流程串起来。这份周报要包含第一页标题“2024年第31周产品运营周报”下方自动生成目录第一章本周数据概览包含一张3行4列的数据表格表头要做水平合并第一列要做垂直合并第二章销售趋势分析插入一张产品销量趋势图第三章重点项目进度放一张3列5列的进度表最后一个单元格要垂直合并两行我先把文档结构用标题样式铺开再往每个章节下填充内容。这样目录域更新后能形成清晰的层级。4.2 主流程代码与实现说明XWPFDocument doc new XWPFDocument(); initPageA4(doc); // 大标题 XWPFParagraph title doc.createParagraph(); title.setAlignment(ParagraphAlignment.CENTER); XWPFRun titleRun title.createRun(); titleRun.setText(2024年第31周产品运营周报); setRunFont(titleRun, 微软雅黑, 22); titleRun.setBold(true); // 自动生成目录 addTOC(doc); doc.createParagraph(); // 目录后空一行 // 第一章 addHeading(doc, 一、本周数据概览, 1); // 数据表格4列合并表头第一行前两列合并第一列前两行 XWPFTable dataTable createTable(doc, 3, 4, new double[]{2.5, 4, 4, 4}); setTableBorders(dataTable); dataTable.getRow(0).getCell(0).setText(指标); dataTable.getRow(0).getCell(2).setText(渠道A); dataTable.getRow(0).getCell(3).setText(渠道B); dataTable.getRow(1).getCell(0).setText(新增用户); dataTable.getRow(1).getCell(1).setText(12450); dataTable.getRow(1).getCell(2).setText(8340); dataTable.getRow(1).getCell(3).setText(4110); dataTable.getRow(2).getCell(0).setText(留存率); dataTable.getRow(2).getCell(1).setText(38.6%); dataTable.getRow(2).getCell(2).setText(35.2%); dataTable.getRow(2).getCell(3).setText(42.1%); mergeCellsHorizontal(dataTable, 0, 0, 1); mergeCellsVertical(dataTable, 0, 0, 1); // 第二章 addHeading(doc, 二、销售趋势分析, 1); addParagraph(doc, 下图展示了本周每日销售额变化趋势。); insertPicture(doc, sales-trend.png, 420); // 第三章 addHeading(doc, 三、重点项目进度, 1); XWPFTable projTable createTable(doc, 3, 3, new double[]{2, 8, 6}); setTableBorders(projTable); projTable.getRow(0).getCell(0).setText(编号); projTable.getRow(0).getCell(1).setText(项目); projTable.getRow(0).getCell(2).setText(状态); projTable.getRow(1).getCell(0).setText(A001); projTable.getRow(1).getCell(1).setText(会员体系重构); projTable.getRow(1).getCell(2).setText(开发中); projTable.getRow(2).getCell(0).setText(A002); projTable.getRow(2).getCell(1).setText(支付通道升级); mergeCellsVertical(projTable, 2, 1, 2); projTable.getRow(2).getCell(2).setText(验收中); try (FileOutputStream fos new FileOutputStream(运营周报.docx)) { doc.write(fos); }上面这段代码里数据表格的表头“指标”横跨了第一第二列第一列“新增用户”和“留存率”上面的空单元格被竖直接管视觉上看起来就是一个完整的合并单元格。项目进度表第二行第三列和第三行第三列做了垂直合并显示“验收中”。运行完这段代码生成的docx里图片和表格都能正常显示但目录区域内只有一行灰色占位文字这是正常的。接下来要做的是Word打开后右键“更新域”。4.3 打开文档后的正确姿势这一步不做前面所有努力白费一半。生成好的docx用Word打开后目录区域默认不会自动变成目录列表需要用户手动在目录区域点击右键选择“更新域”再选择“更新整个目录”。如果不想每次都让用户手动操作可以在生成文档时设置updateFields让Word打开时自动弹出更新域提示CTSettings settings doc.getSettings(); settings.addNewUpdateFields().setVal(true);设置好之后用户用Word打开文档顶部会提示“此文档包含可能引用其他文件的域。是否更新这些域”点“是”即可。虽然不能完全免掉确认操作但至少比用户自己找“更新域”入口强多了。如果你的部署环境能用LibreOffice headless还可以在服务端做一次文档转换来触发域更新但对大多数业务场景来说让用户点一下“更新域”是最简单也最不容易出错的方案。5. 踩坑实录POI写Word最常见的6个问题5.1 目录不显示或打开不提示更新域目录不显示十有八九是标题没有用Heading样式。我排查过不少这类问题打开生成的docx看左下角“样式”栏如果标题显示的是“正文”而不是“标题1”那TOC域无论如何都不会识别到它。解决方法是标题段落必须调用para.setStyle(Heading 1)不能只调setBold和setFontSize。另外生成文档时加上doc.getSettings().addNewUpdateFields().setVal(true)这样用户打开文档会收到更新域提示体验会好很多。如果用户打开后还是没提示可以在Word里按CtrlA全选再按F9强制更新所有域。这个快捷键属于Word基本功但业务方不一定知道交接文档时最好写一句说明。5.2 表格列宽怎么设都不生效很多人设置了cell.setWidth但生成后发现列宽还是“自适应”状态铺满整页。原因基本是没设置表格布局为FIXED。Word表格有两种布局autofit和fixed前者会根据内容自动调整列宽后者才完全按照你设置的宽度来渲染。正确的做法是table.getCTTbl().getTblPr().addNewTblLayout().setType(STTblLayoutType.FIXED);这句话要在设置单元格宽度之前执行。另外每一行的每个单元格都要设置宽度不要只设置第一行。POI在某些版本里允许只设置表头宽度就沿用整列但不同版本行为不一致建议老老实实每行都设置。5.3 中文字体被吃掉打开全是英文默认字体这个问题非常常见原因是设置字体时只设置了ascii字体没设置eastAsia字体。POI的run.setFontFamily(宋体)默认只改ascii域的字体对中文字符不生效。必须用run.setFontFamily(宋体, XWPFRun.FontCharRange.eastAsia)显式指定中文字体。所以我在前面封装了setRunFont方法统一处理中英文两套字体。5.4 合并完单元格内容错位或文字残留合并单元格最容易出现两个现象一是continue区域还有内容残留二是合并后行号错乱导致后续设置数据设到了错误的单元格。关于内容残留务必在设置CONTINUE后调用clearCell清空该单元格全部run的内容。关于行号错乱我的经验是先创建好足够的行列再执行合并合并之后不要再用createRow或getRow去新增行。还有一个隐藏坑如果你水平合并后又需要在该行增加单元格POI可能会在getRow().getCell(col)时自动补一个空单元格导致合并标记丢失。所以操作顺序非常重要先建全表格骨架再填数据最后做合并这样最稳。5.5 图片直接顶到页面外图片顶出页面常见原因是直接把图片原始像素尺寸传给了addPicture。比如一张2000x1500的截图按2000像素传进去生成出来的图片宽度比整个A4页面还宽。解决办法是自定义一个等比缩放方法。我的习惯是固定目标宽度为页面可用宽度减两侧边距即约420磅然后按图片原始宽高比算出高度。超过这个宽度就缩放没超过就不动。5.6 依赖版本混乱引入花式报错最后说一个环境问题。POI 5.x的依赖树里有XmlBeans、commons-compress、log4j-api等一堆传递依赖。如果你的项目里已经有旧版本的XmlBeans启动时可能会出现java.lang.NoSuchMethodError但报错堆栈指向的又是POI内部排查起来很迷惑。我的经验是把POI 5.2.5相关的传递依赖统一管理起来IDE里看一下依赖树把重复的旧版本排除掉。另外项目里如果同时有POI 3.x的代码升级后很多API变了特别是HWPF旧类、XWPFDocument构造方式这些别指望向下兼容。写在这事最后说实话POI写Word这套东西文档少、坑多尤其合并单元格和目录域这两块官方示例基本等于没有。但摸清底层之后会发现docx无非就是一坨有规律的XMLPOI只是帮你把对象转成XML而已。把“域代码”“合并标记”“字体域”这几个概念吃透后面再遇到导出Word的需求心态会稳很多。上面这套代码我一直在用改改参数就能适配大多数报表场景你上手跑一遍比对着文档猜要快得多。
返回列表