上周又被一个 Word 导出的需求按在地上摩擦了一遍客户要一张带分组表头的季度对账单第一行是第一季度第二季度横跨三个子列左侧客户名称还要竖向占两行。这事放在 Excel 里CellRangeAddress一填addMergedRegion一调五分钟收工。换到用 poi 导出 word我习惯性地去找类似的方法结果发现 XWPF 这套 API 里压根没有合并单元格这四个字。折腾了大半天翻 OOXML 的 schema 定义才把横向合并和纵向合并两条路摸清楚。这篇就把 poi 导出 word 并合并单元格的完整链路写透包括底层 XML 长什么样、代码怎么写、合并后表格为什么错乱、列宽为什么在 Word 里拖不动。只要你用 Java 做过 Word 报表导出不管你写的是对账单、成绩单还是排班表本文提到的坑应该都能对上号。1. 建表三行代码合并折腾三小时XWPF 的能力边界在哪1.1 XWPF 给了你顺手的东西也藏了你必须懂的东西poi 操作 Word 用的是org.apache.poi.xwpf.usermodel这一包简称 XWPF。它和操作 Excel 的 XSSF/HSSF 是两套完全独立的对象模型虽然都挂在同一个 jar 下但内部实现差得很远。Excel 那边 POI 封装得极其完善合并单元格有专门 API样式有CellStyle池Word 这边就朴素多了——XWPFDocument、XWPFTable、XWPFTableRow、XWPFTableCell四个类撑起全部表格能力createTable(rows, cols)建表、getRow(i).getCell(j)取格、setText()填字日常够用。但你一旦越过这条线——合并、精确列宽、边框、单元格内边距、重复标题行——就必须往下一层走去操作 xmlbeans 生成的CT*对象。所谓CT是 OOXML 的 schema 编译产物CTTc就是表格单元格w:tc的 Java 映射CTTbl对应w:tblCTTcPr对应w:tcPr单元格属性块。XWPF 为什么不给你封装合并我的理解是Word 的表格合并语义比 Excel 复杂得多。Excel 的合并是一个矩形区域对象Word 的合并是这一行的这几个格子合成一个和这一列的这几行合成一个两套机制叠在一起还能互相嵌套封装起来 API 会很难看。所以 POI 干脆把底层暴露出来让你自己写。提醒网上不少教程直接告诉你调XWPFTableCell的某个merge方法那是把 poi-tl 或第三方封装库的 API 当成了原生 POI。原生 POI 5.x 里没有这个方法照抄会编译不过。1.2 为什么这个报表我最后没走 poi-tl 模板路线先说清楚我用过的两条路。一条是 poi-tl一个基于 POI XWPF 的模板引擎写.docx模板里面埋{{}}标签代码侧给数据渲染。另一条就是原生 XWPF 硬写。poi-tl 的优势非常明显版式交给设计同学在 Word 里调代码只管塞数据一个XWPFTemplate.compile()加render()就完事行合并、列合并这类需求它也有对应的标记能力大部分常规报表用它能省掉一大半代码量。我在做固定表头 动态数据行这种结构时第一选择一直是它。但这次的季度表头不吃这一套。它的合并边界是数据算出来的不同客户的季度分组不一样有的客户只有两个季度有数据第二季度那组要少一列整个表头的 span 矩阵随数据变。用模板意味着我要动态调整模板结构模板反而成了负担。所以判断标准很简单——表格形态在编译期确定用模板表格形态在运行期由数据决定直接写原生 XWPF。我这次是后者。2. 扒开 XML横向合并写 gridSpan纵向合并靠 vMerge2.1 一张 Word 表格的骨架tblGrid 定列tc 声明自己占几列先把最小的结构摆出来不然后面全是黑话。一张三列表格在word/document.xml里长这样w:tbl w:tblPr/ w:tblGrid w:gridCol w:w2000/ w:gridCol w:w2000/ w:gridCol w:w2000/ /w:tblGrid w:tr w:tc w:tcPrw:gridSpan w:val3//w:tcPr w:p/ /w:tc /w:tr /w:tblw:tblGrid定义了这张表总共几列、每列多宽它是整张表的骨架。w:tr是行w:tc是单元格。关键点在于Word 判断这一行有几个格子看的是这一行所有w:tc的gridSpan之和而不是w:tc的个数。上面这行只有一个w:tc但它声明了gridSpan3所以 Word 认为这行占满三列是一个横跨三列的合并格。这就是横向合并的全部秘密把第一个格子的gridSpan设成跨度再把后面被吞掉的格子从行里删掉。只设gridSpan不删格子Word 会觉得这行有 3115 列多出两列来表格直接冲出版面。2.2 横向合并的三步操作与一个删除顺序的坑写成一个工具方法import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTTcPr; import java.math.BigInteger; /** * 横向合并把 rowIndex 行的 [fromCol, toCol] 合并成一格 */ public static void mergeHorizontally(XWPFTable table, int rowIndex, int fromCol, int toCol) { if (toCol fromCol) { return; } XWPFTableRow row table.getRow(rowIndex); int span toCol - fromCol 1; // 第一步给起始格打上 gridSpan 标记 CTTcPr tcPr row.getCell(fromCol).getCTTc().getTcPr(); if (tcPr null) { tcPr row.getCell(fromCol).getCTTc().addNewTcPr(); } if (tcPr.isSetGridSpan()) { tcPr.unsetGridSpan(); // 防止重复调用时跨度累加 } tcPr.addNewGridSpan().setVal(BigInteger.valueOf(span)); // 第二步倒序删除被合并掉的格子 for (int i toCol; i fromCol; i--) { row.removeCell(i); // POI 3.x 上没有 removeCell用row.getCtRow().removeTc(i); } }三个细节值得单拎出来讲。第一必须倒序删。removeCell(i)之后第 i 个之后的所有格子索引整体前移一位。如果你从fromCol1往toCol正着删删第二个的时候索引已经错位了会漏删或者删错。第二必须先删后取内容。合并动作改变了这一行的格子数量任何在合并之前用row.getCell(2)拿到的对象引用在合并之后可能已经不存在于文档树里了。稳妥的顺序是建表 → 全部合并动作做完 → 再按最终索引填内容。第三unsetGridSpan()这一句看着多余其实很有用。我在写批量导出时踩过一次同一个行对象被复用了两次第二次合并时addNewGridSpan()又加了一个w:gridSpan节点XML 里出现两个同名字段Word 打开直接报文档损坏。xmlbeans 的addNew*是追加不是覆盖这一点记住能省很多调试时间。2.3 纵向合并为什么一个格子都不能删纵向合并走的是另一套机制——vMerge。规则很直白这一列上参与合并的每一行都要写上vMerge第一行写RESTART其余行写CONTINUE。import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTVMerge; import org.openxmlformats.schemas.wordprocessingml.x2006.main.STMerge; /** * 纵向合并把 col 列的 [fromRow, toRow] 合并成一格 */ public static void mergeVertically(XWPFTable table, int col, int fromRow, int toRow) { for (int r fromRow; r toRow; r) { XWPFTableCell cell table.getRow(r).getCell(col); CTTcPr tcPr cell.getCTTc().getTcPr(); if (tcPr null) { tcPr cell.getCTTc().addNewTcPr(); } CTVMerge vMerge tcPr.isSetVMerge() ? tcPr.getVMerge() : tcPr.addNewVMerge(); vMerge.setVal(r fromRow ? STMerge.RESTART : STMerge.CONTINUE); } }注意这里没有删任何单元格因为每行的格子数量必须保持一致——纵向合并在视觉上把上下两格连成一体但结构上它们仍然是两个独立的w:tc只是通过vMerge标记告诉 Word这两格是一体的只显示第一格的内容。由此引出两个高频问题。其一只给第一格写RESTART忘了给下面的格子写CONTINUEWord 里看到的就是只有上半个格子合并了下面还留着一条横线表格看起来断成两截。其二下面的格子里原本填了文字合并后 Word 只渲染第一格的文字下面那些内容等于白写有些版本还会把行高撑得很奇怪。所以正确的做法是合并范围内的非首格内容一律清空连空段落都不用留。2.4 横向和纵向叠在一起时先做谁交叉场景比如客户名称格既参与纵向合并同时又处在被横向合并的表头区域经常出现。我的规则是先把树形的合并算清楚再按行从下往上、从右往左执行。原因是横向合并会删除格子让列索引发生塌陷如果先做横向合并后面按列号去取纵向合并的目标格就会取错位置。具体一点先把整个表格的合并意图整理成一个矩阵——rowSpan[r][c]和colSpan[r][c]两个二维数组然后遍历矩阵遇到colSpan1就执行横向合并遇到rowSpan1就执行纵向合并最后统一填内容。这套流程我在三个项目里复用基本没翻过车。3. 完整落地一张季度分组表头从数据到文档3.1 先把合并形状算出来再碰任何一行代码新手最容易犯的错是边建表边合并边填字三件事搅在一起出问题根本不知道是哪一步错的。我现在的固定套路是三步分离算形状 → 建结构 → 填内容。算形状这一步我习惯用一个CellDef描述每个逻辑单元格public class CellDef { int row; // 起始行0 基 int col; // 起始列0 基 int rowSpan; // 纵向跨度 int colSpan; // 横向跨度 String text; // 内容 boolean header; // 是否表头决定底色和加粗 }表头部分手工写死第 0 行第 0 列rowSpan2客户名称竖跨两行第 0 行第 1 列colSpan3第一季度第 0 行第 4 列colSpan3第二季度。数据行的CellDef按查询结果循环生成rowSpan、colSpan全是 1。这个数据结构的好处是它和渲染代码完全解耦。以后加一个合计分组只在形状计算里改几行渲染逻辑一行不动。3.2 建表、设页面、按形状执行合并页面先设成横向 A4不然七列的表纵向根本排不下XWPFDocument doc new XWPFDocument(); // 页面设为横向 A4 CTBody body doc.getDocument().getBody(); CTSectPr sectPr body.isSetSectPr() ? body.getSectPr() : body.addNewSectPr(); CTPageSz pgSz sectPr.isSetPgSz() ? sectPr.getPgSz() : sectPr.addNewPgSz(); pgSz.setW(BigInteger.valueOf(16838)); // 横向宽度twips pgSz.setH(BigInteger.valueOf(11906)); // 横向高度twips pgSz.setOrient(STPageOrientation.LANDSCAPE); // 页边距统一 1000 twips约 1.76cm给表格留足横向空间 CTPageMar mar sectPr.isSetPgMar() ? sectPr.getPgMar() : sectPr.addNewPgMar(); mar.setLeft(BigInteger.valueOf(1000)); mar.setRight(BigInteger.valueOf(1000)); mar.setTop(BigInteger.valueOf(1000)); mar.setBottom(BigInteger.valueOf(1000));这里的 16838 和 11906 不是拍脑袋来的。A4 纸横向宽 297mm1 英寸 25.4mm 1440 twips所以 297 / 25.4 × 1440 ≈ 16838。高度 210mm 同理算得 11906。w:pgSz的w一定要大于h否则 Word 会把方向又掰回纵向这是我在一个项目里查了半天的灵异现象。建表和执行合并int totalRows 2 dataList.size(); XWPFTable table doc.createTable(totalRows, 7); // 一次性执行全部合并 for (CellDef def : allCells) { if (def.colSpan 1) { mergeHorizontally(table, def.row, def.col, def.col def.colSpan - 1); } if (def.rowSpan 1) { mergeVertically(table, def.col, def.row, def.row def.rowSpan - 1); } }注意这里建表用的是完整的 7 列 × N 行然后靠合并把形状捏出来。不要试图建表时就少建几列createTable(rows, cols)的列数是全表统一的行的长短差异只能靠 gridSpan 删格来实现。3.3 填内容时用合并后索引这是最容易错的一步合并执行完之后每个逻辑单元格在行里的物理索引已经变了。比如表头第一行原本有 7 个w:tc横向合并两次之后只剩 3 个客户名称、第一季度、第二季度。这时候如果你还按逻辑列号getCell(4)去写第二季度直接IndexOutOfBoundsException。我的做法是在CellDef里加一个字段记录这一格在本行物理位置上的序号我当时给这个字段起了个很土的名字叫physicalIndex。物理序号的计算规则是按列遍历这一行的所有CellDef如果这一格是被别人横向合并吞掉的col落在某个colSpan1的区间内但不是区间起点跳过不计否则计数加一。private static void fillCell(XWPFTable table, int rowIdx, int physicalIdx, String text, boolean header) { XWPFTableCell cell table.getRow(rowIdx).getCell(physicalIdx); XWPFParagraph p cell.getParagraphs().get(0); p.setAlignment(ParagraphAlignment.CENTER); p.setSpacingBefore(0); p.setSpacingAfter(0); XWPFRun run p.createRun(); run.setText(text); run.setFontSize(header ? 10 : 9); run.setBold(header); applyChineseFont(run, 微软雅黑); cell.setVerticalAlignment(XWPFTableCell.XWPFVertAlign.CENTER); if (header) { setCellBackground(cell, DCE6F1); } }这套组合拳跑下来一张带分组表头、竖排客户名的对账单就出来了。我的实测数据是150 行数据、7 列生成耗时在 300ms 左右文件大小 20KB 上下Word 打开流畅。数据量再往上走问题就不在合并上了后面单独说。4. 列宽、字体、边框三个让人反复返工的细节4.1 列宽其实有三处只改一处迟早出事Word 的列宽由三个地方共同决定很多教程只讲其中一个结果换个环境就崩位置XML 路径对应 POI 调用作用表级列骨架w:tbl/w:tblGrid/w:gridColtable.setColumnWidth(col, width)定义整表每列基准宽度单元格属性w:tc/w:tcPr/w:tcWcell.setWidth(String)cell.setWidthType(...)覆盖单个格子的宽度表格总宽与布局w:tblPr/w:tblW、w:tblLayouttable.setWidth(String)table.setWidthType(...)决定总宽来源和是否固定单位统一是 twips。换算关系记住这几条就够用1 磅pt 20 twips 1 厘米 约 567 twips 1 英寸 1440 twips A4 纵向页宽 11906 twips A4 纵向正文可用宽度默认页边距 约 8300 twips A4 横向页宽 16838 twipssetColumnWidth的签名在不同大版本里不一样POI 4.x 之后是long3.x 是int。写代码时显式给个L后缀最安全table.setColumnWidth(0, 2400L);。单元格级别的宽度要注意TableWidthUnit必须一起设否则 Word 会把你写的数字当成百分比理解cell.setWidth(2400); cell.setWidthType(TableWidthUnit.DXA); // DXA 绝对值 twips我的经验是三者对齐——tblGrid的列宽之和等于tblW的dxa值每个非合并格的tcW等于对应列的gridCol合并格的tcW等于它跨过的所有列宽之和。不对齐的后果是在你这台机器上看着正常发给同事或者另存为 PDF 之后列宽就跳了。4.2 Word 里列宽拖不动问题出在 tblLayout这个现象被问得特别多我用 poi 生成的表用户在 Word 里想拖一下列宽线一松手又弹回去是不是文件坏了 不是坏了是你的文档自己声明了固定布局。w:tblPr/w:tblLayout有两个取值fixed和autofit。fixed表示严格按tblGrid走用户拖拽只改变临时视觉重排后立刻复位autofit表示允许 Word 根据内容和用户操作自动调整列宽。CTTblPr tblPr table.getCTTbl().isSetTblPr() ? table.getCTTbl().getTblPr() : table.getCTTbl().addNewTblPr(); CTTblLayoutType layout tblPr.isSetTblLayout() ? tblPr.getTblLayout() : tblPr.addNewTblLayout(); layout.setType(STTblLayoutType.AUTOFIT); // 想锁死就换成 FIXED两种都合理看你给谁用。给财务做对账单金额列必须严格右对齐成一列那就fixed用户拖乱了我还不愿意。给人做排班表、会议纪要这类会被二次编辑的文档就autofit不然用户会来投诉。顺带说一句createTable出来的表格默认布局状态跟 POI 版本有关不要依赖默认值。只要你对列宽有明确要求就显式设置tblLayout这一步花不了三行代码能省掉一轮扯皮。4.3 中文字体设了没生效八成是漏了 eastAsia下图这个坑只要用 POI 生成过中文 Word 的人基本都踩过代码里明明写了run.setFontFamily(微软雅黑)打开文档一看还是宋体。原因是 OOXML 的w:rFonts节点有四个字体槽位ascii西文、hAnsiANSI 字符、eastAsia东亚字符、cs复杂脚本。XWPFRun.setFontFamily(String)这个便捷方法只写了ascii和hAnsi两个中文走的是eastAsia槽位没写就回退到主题默认字体。private static void applyChineseFont(XWPFRun run, String fontName) { CTRPr rpr run.getCTR().isSetRPr() ? run.getCTR().getRPr() : run.getCTR().addNewRPr(); CTFonts fonts rpr.isSetRFonts() ? rpr.getRFonts() : rpr.addNewRFonts(); fonts.setAscii(fontName); fonts.setHAnsi(fontName); fonts.setEastAsia(fontName); fonts.setCs(fontName); }还有一个更隐蔽的情况模板里带主题字体。如果你是在现成模板上改w:rFonts上可能还挂着asciiTheme、eastAsiaTheme这些属性它们的优先级高于显式的字体名会让你的设置完全失效。解决办法是把这些 theme 属性清掉CTFonts上对应unsetXxxTheme()系列方法具体方法名按你用的 POI 版本对一下不同版本命名略有差异。另外提醒一点服务器上没有装微软雅黑完全不影响文件生成POI 只是往 XML 里写了个字体名字符串真正渲染是打开文档的 Word 干的。但如果你的下游链路里有 LibreOffice 转 PDF字体缺失就会静默替换成别的字页面的行高、分页全变。这种情况要么在转换机上装字体要么把字体名字换成服务器上确实存在的字体。5. 症状倒推合并出问题时按这个顺序查5.1 症状一表格右边冲出版面或者凭空多出一列这是最典型的合并 bug99% 是gridSpan和实际w:tc数量对不上。可能是删格没删干净也可能是addNewGridSpan被重复调用叠加了。排查手法我必须推荐一下比看日志快十倍把.docx的扩展名改成.zip解压用编辑器打开word/document.xml格式化之后数数。具体数三样东西w:tblGrid里有几个w:gridCol记作 N每一行里w:tc的gridSpan值加起来是多少应该都等于 N直接数w:tc的个数横跨多列的合并格会让这个数小于 N但绝不会大于 N。只要有一行加起来不等于 N就是它。这个方法我在排查表格多一列最后一列特别窄这类问题时用了无数次比在代码里打断点高效得多。5.2 症状二文字跑到隔壁格或者合并格里的内容凭空消失两个原因。第一个是前面提过的索引问题——合并后还用旧索引填内容字写到了错误的物理格子里看起来就是文字串门。第二个是纵向合并时非首格写了文字被 Word 吞掉了。判断方法很简单如果串门是整列偏移基本是索引问题如果只有纵向合并的那一格内容不见了那是vMerge的CONTINUE格没清空。修复方式就是严格保持合并完了再填内容的顺序并且在填内容之前先遍历一遍纵向合并区间把非首格的内容全部清空。5.3 症状三Word 弹窗遇到错误请尝试下列方法这个弹窗几乎是所有 POI 生成 Word 的人的心理阴影。表格这块常见的触发点有三个。第一同一行里gridSpan总和超过tblGrid列数Word 认为文档结构非法。第二w:tcPr里出现了重复的子元素比如两个w:gridSpan这是 xmlbeans 的addNew*追加特性造成的。第三直接手工造了 CT 对象但没挂到文档树上比如CTTc.Factory.newInstance()造了一个格子却没有addNewTc()加进去序列化出来的 XML 就缺节点。排查顺序就是上面那个解压看 XML 的老办法。另外提一句用 Word 打开一个损坏的 docx 时它能提示的往往只是结构不对具体哪里不对还得自己看 XML不要指望错误弹窗给线索。5.4 症状四NoClassDefFoundError 指向某个 CT 类这个症状看起来像合并的问题其实跟合并没关系是依赖打架。POI 5.x 的poi-ooxml默认带的是精简版 schema 包poi-ooxml-lite只包含常用的那部分 CT 类。你如果用到了偏门一点的节点比如操作页眉页脚、复杂域代码就会在运行时报类找不到。解决办法是把它换成完整版dependency groupIdorg.apache.poi/groupId artifactIdpoi-ooxml-full/artifactId version5.2.5/version /dependency另一个隐藏问题是 Maven 依赖树里同时存在多个 POI 版本比如poi-ooxml是 5.2.5但某个中间件拖进来了 3.17编译时用的是一套、运行时加载的是另一套症状就是本地能跑、打包上线就炸。上生产之前跑一次mvn dependency:tree | grep poi对一下版本几秒钟的事。6. 数据量上来之后内存、耗时与依赖版本的处理6.1 几万行数据下瓶颈不在合并而在节点数量XWPFDocument 是纯内存模型这一点和 Excel 完全不同——Excel 有 SXSSF 那种流式写出Word 这边没有对应实现。所有段落、文本 run、表格节点都在堆里构建最后一次性序列化。节点数量的增长是乘法的行数 × 列数 × 每格节点数。一个格子至少包含w:tc、w:tcPr、w:p、w:r如果还设了颜色、边框、内边距轻松到七八个节点。一万行八列就是八万个格子、将近六十万个 XML 节点。我实测过的量级参考普通 8 核开发机堆内存 2G一万行 × 8 列生成耗时 8 到 15 秒堆峰值几百 MB三万行以上如果不加大堆内存直接 OOM。优化手段按性价比排序第一减少 run 数量一个格子里只createRun一次把整段文字一次性setText不要分次追加第二能省的样式不要写默认的边框、内边距就不要显式声明第三确实要几十万行时考虑拆文件或者干脆换个思路把明细导出成 Excel、Word 里只放汇总表格——这是我给客户做方案时的常规建议业务上往往也能接受。还有一个跟合并相关的性能点合并动作本身不慢但每次removeCell都会触发行内列表的元素移动。表头那几行的合并可以忽略不计但如果你的表格体里存在大量小范围合并比如每行都有两格要合并那这个开销会累积。此时更划算的做法是在建表阶段就不建那么多列——把并行数据在 Java 层拼成一个字符串放进一格比建两格再合并要快得多。6.2 别把 POI 版本停留在老版本上顺手说一个跟安全相关的点。Apache POI 4.1.0 及更早的版本里XSSFExportToXml这个工具类在解析外部 XML 时没有禁用外部实体存在外部实体注入XXE的风险面。这个类本身是 Excel 相关的但很多人把 POI 当一个大包用依赖版本管理松散的团队很容易把整条链路都留在老版本上。我的建议很直接新项目直接上 POI 5.2.x 及以上老项目至少把版本挪到 4.1.2 之后。升级成本通常在可控范围主要是不兼容点集中在setWidth(int)改成setWidth(String)这类签名变化上全局搜一下 PR 就能改完。真正麻烦的是那些老项目里到处cast的地方升级前先把依赖树理一遍。另外无论版本多新不要用 POI 去解析来源不明的外部 OOXML 文件这个原则比版本号本身更重要。7. 我在几个项目里沉淀下来的几条规矩如果说这套东西有什么心得我总结成几条写在团队文档里的规矩都是被真实事故教育出来的。先画 span 矩阵再写一行渲染代码。我见过太多人一上来就createTable然后开始getCell(i).setText()等发现要合并的时候整个方法已经两百行了。把合并形状单独抽成一个数据结构渲染逻辑只负责照着形状填这个拆分看起来多花半小时实际能省掉后面所有的返工。合并和填内容必须是两个阶段任何情况下都不混。合并会改变行内的物理索引这是 Word 表格结构决定的不是 POI 的锅。把这两件事分开你就永远不用去猜我这时候getCell(3)到底拿的是哪个格。任何addNewXxx()之前先isSetXxx()判一次。xmlbeans 的addNew是追加语义重复调用会在 XML 里堆出重复节点而 Word 对重复节点的容忍度是零。养成这个习惯之后我生成的文档再也没出现过打开时遇到错误这个弹窗。列宽三处对齐tblLayout显式声明。别依赖默认值POI 不同大版本的默认行为不完全一致。你要么明确告诉 Word 我要固定布局要么明确告诉它允许自由调整模棱两可的结果就是用户来投诉。最后合并这件事的本质是手动维护一棵树的一致性。Word 没有帮你维护这个格子被合并了所以它不存在了这件事你得自己保证每行的gridSpan之和等于表格列数。只要这条不变式成立表格就不会坏一旦它被破坏Word 的表现从看起来怪怪的到文件打不开都有可能而排查手段永远只有那一个——解压看document.xml。把这个动作练熟比记住任何 API 都有用。