资讯动态

POI导出Word合并单元格:XWPF水平与垂直合并实战

发布时间:2026/10/2 4:56:02 来源:尧图企业网站定制
做过报表和文档导出的同学大概都有这种体会Excel 那一套玩得还算顺一碰到 Word 就开始别扭。尤其是用 poi 导出 word 并且要合并单元格这件事第一次做的人几乎都要卡上半天。原因很简单Word 的表格模型跟 Excel 完全不是一回事XSSFWorkbook里那套addMergedRegion的思路搬到XWPFDocument上根本用不了你得直接去操作底层的 XML 结构。偏偏业务上这种需求又特别常见排班表、值班表、统计报表、合同附件、成绩单只要是给人看的正式文档几乎都会出现同一个部门只显示一次后面几行合并、表头横跨三列这类版式要求。这篇内容就是围绕这个场景展开的。我会从 POI 的 Word 对象模型讲起把水平合并gridSpan和垂直合并vMerge的底层原理拆开然后给出一套可以直接抄的 Java 实现包含列宽的坑、样式统一、大数据量导出的内存问题以及我自己在项目里踩过的那些让人抓狂的细节。适合已经会用 POI 生成简单 Word、但一遇到合并单元格就头大的后端同学也适合刚接手文档导出模块、需要快速摸清门道的人。代码基于 POI 5.x 主线版本4.x 的差异我会单独标出来。1. 需求拆解Word 表格合并到底难在哪先说清楚为什么这事儿比想象中麻烦。Excel 里合并单元格是一个独立的区域概念你告诉 POI把 A1 到 C1 合并它就在 sheet 级别记录一条 mergedRegion单元格本身的数据和样式都还在原来的位置渲染时再叠加合并逻辑。Word 完全不同Word 的表格是真正由一个个单元格节点拼起来的合并意味着你要在单元格自己的属性里做标记还要决定多余的那些单元格节点是保留还是删掉。这两种思路带来的代码复杂度差了一个量级。1.1 XWPF 对象模型与 XSSF 的关键差异在 Excel 那边你面对的是Sheet - Row - Cell三层合并是额外挂上去的一个区域列表在 Word 这边XWPFDocument - XWPFTable - XWPFTableRow - XWPFTableCell是严格嵌套的实体结构每个XWPFTableCell背后都对应一个w:tc节点节点里带着w:tcPr属性块合并的标记就写在tcPr里。也就是说合并是单元格自身的属性而不是表格的一个全局配置。这个差异直接决定了实现方式。你没法声明式地说一句这一片合并只能一个单元格一个单元格地去设置属性并且要保证同一行、同一列上参与合并的单元格属性互相自洽否则 Word 打开时就会渲染错乱甚至直接报文档损坏。还有一点容易被忽略XWPFTable内部维护着行和单元格的缓存列表你如果绕过 API 直接改底层 CT 对象缓存和实际 DOM 就可能对不上导出结果会出现代码里明明改了文件里没生效的诡异现象。1.2 水平合并和垂直合并是两套逻辑很多人第一次写会想当然地以为合并就是一个通用方法其实水平和垂直是两条完全独立的路径。水平合并靠的是gridSpan它表达的是这一个单元格横向占了几个格子所以处理时你要把被吃掉的那些单元格节点清掉只在最左边那个格子标记跨度垂直合并靠的是vMerge它有两个状态值起始格标restart后续格标continue格子一个都不能删因为每一行的高度和边框还得靠它们撑着。注意把水平合并和垂直合并混着用的时候属性设置的顺序和参与范围必须仔细核对一个单元格同时带gridSpan和vMerge是完全合法的但数值必须和实际占位一致否则 Word 会按最小范围渲染看起来就像合并失效了。我在项目里的做法是把两者拆成两个独立方法然后在业务层自己控制调用顺序先按数据把行生成好再做垂直合并最后处理表头的水平合并。这样出问题时定位范围小改起来也快。2. 底层原理合并单元格在 XML 里究竟做了什么要写得稳得先看清楚 Word 文档里一个合并单元格长什么样。你把一个做好的 docx 后缀改成 zip 解压打开word/document.xml搜索gridSpan或vMerge就能直接看到答案。理解了这段 XML后面所有代码都只是把这段结构用 Java 拼出来而已。2.1 gridSpan水平合并的标记位水平合并后那一行的w:tr里单元格数量会变少。假设原本 3 个格子合并成 1 个XML 里就只剩 1 个w:tc它的tcPr里多出一行w:gridSpan w:val3/意思是我这个格子占 3 列。同时表格顶部的w:tblGrid里定义的列数是不变的还是 3 个w:gridCol因为表格的物理格子结构没有变变的是哪几个格子被同一个单元格占用。这个细节非常关键。tblGrid是表格的骨架它决定了表格总共有多少列、每列多宽gridSpan是在骨架上做占用声明。所以你写代码时合并前一定要先保证 tblGrid 已经按最终列数建好了否则跨度值会越界Word 直接判定文档异常。很多合并后列宽全乱的问题根源都在这里。2.2 vMerge垂直合并的 restart 与 continue垂直合并在 XML 里表现为同一列的每一行对应单元格的tcPr里都带w:vMerge/其中第一行是w:vMerge w:valrestart/下面几行是w:vMerge w:valcontinue/。渲染时 Word 会把 restart 那个格子的高度拉伸到覆盖下面所有 continue 的行continue 格子的内容区被隐藏。这里有个坑必须提前说continue 的格子虽然不显示内容但它的段落节点还在。如果你往里面填了数据不管虽然视觉上被盖住了可一旦用户在 Word 里把合并拆开那些数据就会冒出来更糟的是某些解析工具比如把 docx 再读回来的程序会把隐藏内容也读进去造成数据重复。所以正确的做法是标continue之后把这个单元格里的段落清干净只留一个空段落撑结构。2.3 为什么删除单元格这条路走不通有人会想垂直合并不就是把下面几行的格子删掉吗不行。Word 的表格是逐行定义的每行的单元格数量理论上应该和 tblGrid 的列数对齐考虑 gridSpan 后。如果你把某一行的某个格子物理删掉这一行的列数就比别的行少了Word 会用自己的规则去补补出来的结果通常是一堆错位的空格子视觉效果稀碎。水平合并倒是可以删因为那些格子确实被吃掉了格子数量减少、gridSpan 补上账是平的。这就是两者的本质区别水平合并是减格子加跨度垂直合并是留格子改状态。记住这一条实现时思路就不会乱。3. 手把手实现一个可动态合并的 Word 导出器原理讲完了接下来是能直接用的代码。我把它组织成一个工具类方法职责清晰业务层调用的时候只需要关心哪一行哪一列要合并不用碰底层 CT 对象。这套结构我在两个报表项目里都用过稳定性没问题。3.1 依赖选型与基础骨架Maven 依赖直接用 POI 5.x 主线poi-ooxml一个就够它会把poi和底层 OOXML schema 一起带进来。4.x 的用户注意XWPFTableCell的部分 setter 签名有变化我下面标注了。dependency groupIdorg.apache.poi/groupId artifactIdpoi-ooxml/artifactId version5.2.5/version /dependency工具类的骨架很简单核心就是几个静态方法public final class WordTableUtils { private WordTableUtils() {} /** 设置表格为固定布局列宽不再随内容自适应 */ public static void setFixedLayout(XWPFTable table) { CTTblPr tblPr table.getCTTbl().getTblPr(); CTTblLayoutType layout tblPr.isSetTblLayout() ? tblPr.getTblLayout() : tblPr.addNewTblLayout(); layout.setType(STTblLayoutType.FIXED); } }先把这个放进来是因为它直接关系到后面列宽能不能生效也关系到用户拿到文档后能不能手动拖列宽。3.2 表格初始化与列宽正确设置方式列宽是 Word 导出里最容易翻车的点。很多人调table.setWidth()或者cell.setWidth()发现没反应原因通常有两个一是只设了单元格宽度但没同步tblGrid二是表格布局是 autofitWord 打开时按内容重新算了一遍把你设的值覆盖了。正确做法是三步走先设固定布局再设tblGrid里每列的宽度最后给每个单元格设tcW三者数值保持一致。单位是twips也就是 1/20 磅A4 纸去掉页边距后正文宽度大概是 9000 多 twips你可以按这个来分配。/** 按比例设置列宽weights 是各列的相对权重 */ public static void setColumnWidths(XWPFTable table, int[] weights) { setFixedLayout(table); int totalWidth 9500; // 约等于 A4 正文宽度单位 twips int sum 0; for (int w : weights) sum w; // 1. 重写 tblGrid CTTblGrid grid table.getCTTbl().getTblGrid(); while (grid.sizeOfGridColArray() 0) { grid.removeGridCol(0); } int[] colWidths new int[weights.length]; for (int i 0; i weights.length; i) { colWidths[i] totalWidth * weights[i] / sum; CTTblGridCol gridCol grid.addNewGridCol(); gridCol.setW(BigInteger.valueOf(colWidths[i])); } // 2. 同步每个已有单元格的 tcW for (XWPFTableRow row : table.getRows()) { for (int i 0; i row.getTableCells().size(); i) { XWPFTableCell cell row.getCell(i); if (i colWidths.length) { setCellWidth(cell, colWidths[i]); } } } } public static void setCellWidth(XWPFTableCell cell, int widthTwips) { CTTcPr tcPr getOrCreateTcPr(cell); CTTblWidth tcW tcPr.isSetTcW() ? tcPr.getTcW() : tcPr.addNewTcW(); tcW.setType(STTblWidth.DXA); tcW.setW(BigInteger.valueOf(widthTwips)); } private static CTTcPr getOrCreateTcPr(XWPFTableCell cell) { CTTc ctTc cell.getCTTc(); return ctTc.isSetTcPr() ? ctTc.getTcPr() : ctTc.addNewTcPr(); }提示这里的totalWidth只是经验值真正精确的值应该由页面宽度减去左右页边距算出来。如果你的文档用了横向页面或者自定义纸张记得同步调整不然表格会超出边界。关于列宽拖不动这个问题从导出侧看只要你设了STTblLayoutType.FIXED用户在 Word 里拖动列宽就会受限。如果业务上希望用户拿到后还能自由调整就把布局设成 autofit但那样列宽就得靠单元格内容撑视觉效果不稳定。我的建议是固定的报表就固定布局需要二次编辑的文档就 autofit两害相权而已。3.3 水平合并的完整实现水平合并的写法先删掉被合并的格子再给最左的格子标 gridSpan。删除的时候一定要从右往左删因为删掉一个格子后后面格子的索引会整体前移正序删会删错位置。/** 水平合并将 rowIndex 行从 fromCol 到 toCol 的单元格合并 */ public static void mergeHorizontal(XWPFTable table, int rowIndex, int fromCol, int toCol) { if (toCol fromCol) return; XWPFTableRow row table.getRow(rowIndex); // 从右往左删除多余单元格节点 for (int i toCol; i fromCol; i--) { row.removeCell(i); } XWPFTableCell merged row.getCell(fromCol); CTTcPr tcPr getOrCreateTcPr(merged); CTDecimalNumber span tcPr.isSetGridSpan() ? tcPr.getGridSpan() : tcPr.addNewGridSpan(); span.setVal(BigInteger.valueOf(toCol - fromCol 1)); }这里用到的row.removeCell(int)在 POI 4.0 之后是有的它会同时维护内部的 cells 缓存和底层 CTRow 的节点所以不需要我们手动清理缓存。如果你用的是更老的版本就得自己操作row.getCTRow().removeTc(i)并且手动把XWPFTableRow里的缓存也重置掉否则会读到已经删掉的格子报索引越界。3.4 垂直合并的完整实现垂直合并分两步第一行标 restart后续行标 continue 并清空内容。清空内容这步不能省原因前面讲过。/** 垂直合并将 colIndex 列从 fromRow 到 toRow 的单元格合并 */ public static void mergeVertical(XWPFTable table, int colIndex, int fromRow, int toRow) { if (toRow fromRow) return; for (int r fromRow; r toRow; r) { XWPFTableCell cell table.getRow(r).getCell(colIndex); CTTcPr tcPr getOrCreateTcPr(cell); CTVMerge vMerge tcPr.isSetVMerge() ? tcPr.getVMerge() : tcPr.addNewVMerge(); if (r fromRow) { vMerge.setVal(STMerge.RESTART); } else { vMerge.setVal(STMerge.CONTINUE); clearCellContent(cell); } } } /** 清空单元格内容保留一个空段落撑住结构 */ private static void clearCellContent(XWPFTableCell cell) { ListXWPFParagraph paragraphs cell.getParagraphs(); for (int i paragraphs.size() - 1; i 0; i--) { cell.removeParagraph(i); } cell.addParagraph(); }写到这里要强调一个业务层的注意点垂直合并的前提是被合并的这几行在此之前必须已经真实存在。也就是说你得先把所有数据行都 addRow 出来填好内容再回头做合并。如果边填边合后面 addRow 的时候表格的 gridSpan 状态是混乱的很容易生成畸形结构。3.5 样式统一与表头处理合并之后最容易出现的观感问题是合并出来的大格子里文字没有垂直居中或者边框突然断开。垂直居中的处理方式是在 tcPr 里设vAlignpublic static void setVerticalCenter(XWPFTableCell cell) { CTTcPr tcPr getOrCreateTcPr(cell); CTVerticalJc vAlign tcPr.isSetVAlign() ? tcPr.getVAlign() : tcPr.addNewVAlign(); vAlign.setVal(STVerticalJc.CENTER); }边框的话POI 生成的表格默认继承文档默认样式如果模板里没有定义表格边框导出来就是无框的。想统一控制最省事的办法是用一个预置好样式的模板文档把你的表格插进去样式自动继承如果非要纯代码生成就得逐个单元格设置tcBorders工作量大且容易漏。我在实际项目里一律走模板方案模板里把表头、斑马纹、边框都做好代码只负责填数据和合并省心太多。表头横向合并是高频需求比如第一行是2024 年度考勤统计横跨 6 列第二行才是具体的列名。这时候你要给表格预留出两行表头第一行先合并成一个格子再写标题第二行的 6 个格子正常填列名。顺序是先把表建成本身就有 6 列的骨架然后对第一行做下次mergeHorizontal(table, 0, 0, 5)这个顺序反了就出问题。4. 大数据量导出的性能与内存优化合并逻辑本身不复杂真正把项目拖垮的往往是大数据量。一份几千行的报表用XWPFDocument生成内存曲线能直接给你顶上去稍不注意就 OOM。这块值得单独拎出来说。4.1 XWPFDocument 的内存模型与瓶颈定位XWPFDocument是完整的 DOM 模型整个文档在内存里是一棵树所有段落、运行、表格节点都是 Java 对象。一个单元格大概对应十几个对象几千行的表格就是几万个对象在堆里堆着再加上样式和属性对象几百兆内存跑不掉。Excel 那边好歹有SXSSFWorkbook这种流式写盘的方案Word 这边没有对等的流式 API这是硬伤。所以第一步是定位瓶颈如果是 500 行以内的常规报表直接生成完全没问题别过度设计如果是上万行的统计表那基本可以判定 POI 全内存这条路走不通得换思路。我一般会在生成前先估算行数超过 2000 行就启动降级策略。注意XWPFDocument.write()之后一定要close()否则底层的 opc 包结构不释放循环导出的场景下内存只涨不降这个泄漏非常隐蔽。4.2 分片导出与临时文件策略大数据量的处理原则是分批生成、分批落盘。Word 文档本身不像 Excel 那样适合分 sheet但可以按时间区间、按部门维度拆成多个文档这是最干净的做法。如果业务坚持要一个文件那就只能加大堆内存同时把生成逻辑做得尽量轻——比如复用XWPFParagraph的样式对象、避免给每个单元格单独 new 样式、用完的对象及时置 null。还有一个容易被忽视的点是图片。很多人往 Word 表格里塞公式图片、签名图每张图都会以二进制形式嵌进文档几百张图叠加起来体积和内存双爆。热搜里那个公式图片转 word的场景就是典型我的做法是图片统一压缩到合适分辨率再插入能矢量化的尽量矢量化绝不让原图直接进文档。如果你确实需要处理超大规模数据可以考虑先生成结构简化的文档再用文档处理工具做二次排版或者干脆拆成多个文件打包让用户自己合并。这些方案都比硬扛 OOM 强。5. 常见问题速查与排查实录这部分是我这些年攒下来的排查清单基本覆盖了合并单元格相关的绝大多数翻车场景。5.1 列宽拖不动、合并后宽度错乱用户反馈最多的一条。分两种情形看如果是导出的表格在 Word 里拖动列宽没反应多半是tblLayout被设成了 fixed如果想让它能拖就把布局改成 autofit同时保证tblGrid的宽度和单元格tcW一致。如果是合并之后宽度整个错乱那基本是tblGrid没和最终列数对齐或者合并时格子删多了、删少了导致某行实际占用的列数和骨架对不上。排查方法很土但有效把 docx 改名成 zip解压看document.xml数tblGrid里的gridCol个数再逐行数tc的 gridSpan 之和对不上就是这里的问题。现象最可能原因处理方式列宽设了不生效表格是 autofit 布局设STTblLayoutType.FIXED部分列宽对不上tblGrid 与 tcW 不一致两者用同一套数值重设导出后列宽拖不动固定布局限制业务允许则改 autofit合并后整表错位骨架列数与实际占用不符校验每行 gridSpan 之和5.2 合并后内容错位或内容丢失垂直合并后内容消失先别慌检查是不是没标对 restart。如果所有格子都标成了 continueWord 找不到起点整个合并块就是空的反过来如果多个格子都标了 restartWord 会当成多段独立合并视觉上就是中间断了一截。还有就是合并前填了内容、合并后没清 continue 格子拆开合并的时候数据冒出来这个属于业务逻辑问题不是渲染问题。内容错位还有一种情况是索引算错了。业务层做分组合并时经常用相邻行同部门就合并这样的逻辑如果边界处理不严谨很容易把不该合并的也合进去。我一般的做法是先把分组结果算出来存成{startRow, endRow}的列表再统一执行合并这样逻辑和渲染完全解耦调试起来也只是看列表对不对一眼就能定位。5.3 打开文件报错、版本与安全相关偶尔会遇到打开文件时遇到错误这类提示除了文档结构本身畸形之外还有两个方向要查。一是 POI 版本问题老版本在处理某些复杂属性时生成的结构不完全符合 OOXML 规范升级到 5.x 主线通常能解决。二是安全相关Apache POI 4.1.0 及更早版本里的XSSFExportToXml组件存在 XML 外部实体处理不当的风险官方在后续版本做了加固稳妥做法是直接升到 5.x别长期停留在老版本上。提示如果文档需要和外部系统做 XML 层面的交换务必确认解析入口关闭了外部实体加载这属于基础安全习惯和用哪个库无关。另外文档生成后建议用官方的校验工具或者干脆用 Word 打开一遍再交付别直接扔给用户很多结构问题在生成阶段是看不出来的。6. 几条踩坑经验与实用建议最后聊几点文档里不会写、但每次做这个需求都会用到的东西。第一先画骨架再填肉。表格的列结构、表头合并、列宽这些骨架相关的事一定要在填数据之前全部定死数据填充和内容合并放到最后。我早期是边填边合结果每次加一个合并就翻一次车后来改成两阶段稳定性直接上来了。这个顺序看着简单但真到赶工期的时候最容易被忽略的就是它。第二模板优先代码兜底。能用模板文档解决的事情不要用代码硬写尤其是样式、字体、页边距、页眉页脚这些。代码里堆几百行设置样式的逻辑维护成本极高改一个字号要翻半天。模板方案下样式问题交给设计代码只负责数据职责清晰。第三合并逻辑抽成独立方法并且写单元测试。合并单元格这种逻辑靠肉眼检查导出的文档来判断对错非常低效。我的做法是把分组结果计算这部分纯逻辑抽出来用普通单元测试覆盖各种边界全合并、不合并、单行、单列、空数据根本不用生成文档就能验证正确性剩下的渲染部分出错概率就小多了。第四版本别乱升也别不升。POI 的各个大版本之间 API 有变动尤其 4.x 到 5.x一些 setter 的签名改了。项目里定好一个版本就别乱动要升就整体升并且回归测试一遍导出功能。同时尽量跟着主线走别长期用有已知问题的老版本。第五导出的东西自己先打开看一遍。哪怕逻辑再自信也要生成一份真实数据跑一次用 Word 打开确认版式、合并、列宽、字体都正常。我遇到过好几次代码逻辑完全正确、但就是某个边界数据触发结构畸形的情况不实际打开根本发现不了。关于后续还能怎么扩展如果你手上同时有 Excel 和 Word 两类导出需求其实可以把共性的数据分组逻辑抽出来复用两边只是渲染层不同。再往上一层如果文档有很多固定版式可以往模板引擎的方向走让业务配置模板、代码只灌数据这才是长期收益最高的一条路。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑