ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Apache POI实战:搞定Word文档生成中的格式与样式难题

Apache POI实战:搞定Word文档生成中的格式与样式难题

作为常年和 POI 打交道的 Java 开发,我最初以为处理 Word 文档比 Excel 简单——毕竟 Word 不就是几段文字嘛,结果第一次交付就翻车了。后来踩完一堆坑才明白:Word 文档在开发眼里从来不是“一页纸”,它是一个带样式层、表格网格、图表缓存、页眉页脚、修订记录的 XML 包裹结构,每一个你以为“没问题”的细节,最后都可能变成用户的报错截图。

这篇博文我不打算写成官方文档的复读机,只讲那些我在实际项目里验证过、摔过、然后修好的东西。内容围绕 Apache POI 操作 Word 文档展开,覆盖段落样式、表格宽度、图片插入、变量替换、图表数据联动、批量导出,以及热搜里反复出现的“表格宽度设了没用”“三级标题变二级”“修改图表后文档打不开”“表格下多出一行”这些真实问题。适合刚接手 POI 生成 Word 任务的开发,也适合已经写了几个版本但总被格式问题折磨的同行。对了,搜索资料时你可能会看到“高德 POI 下载工具”这类词,那是指地图兴趣点,和 Apache POI 完全是两个物种,别搞混。下文所有 POI 均指 Apache 基金会的 Java 库。

1. 先别急着写代码:Word 文档的几个“反直觉”真相

1.1 Word 不是“一页纸”,它是一棵 XML 树

我刚接触 POI 时有一个根深蒂固的错觉:Word 文档就像 PDF 一样,是一个“渲染完成”的平面。实际操作后才知道,一个 .docx 文件本质上是一个 zip 压缩包,里面装着 word/document.xml、word/styles.xml、word/settings.xml、word/numbering.xml、word/media/ 等一堆 XML 和资源文件。

document.xml 里的内容是一棵树:主体 body 下面挂着一堆块级元素,段落是<w:p>,表格是<w:tbl>,表格又有若干行<w:tr>,行里有单元格<w:tc>,单元格里又嵌套段落。每个段落下面再挂若干个 run(<w:r>),run 里才是真正的文字<w:t>。同一段话里字体、加粗、颜色不一样,就会被拆成多个 run。

这个结构意味着两件重要的事。第一,修改一段文字不是“找到字符串然后替换”,而是可能要跨多个 run 重新组装;第二,格式不是孤立的,段落样式(<w:pStyle>)引用了 styles.xml 里的定义,表格宽度同时受表格属性、网格列、单元格属性三重控制。如果只盯着某个 API 看,很容易忽略它背后还有一层 XML 在起作用。摸清这棵树的长相,后面所有问题都事半功倍。

1.2 用 Excel 思维写 Word,是所有人的第一个坑

很多人从 POI 操作 Excel 转过来,会带着一套惯性:Worksheet 是网格,Cell 是格子,setCellValue 写数据,然后指望 Word 也一样规整。但 Word 的主题是“流式排版”,不是“表格定位”。段落是一个紧跟一个的,表格虽然是块级元素,但它内部的行列并不会自动撑满页面,也不会像 Excel 那样天然有网格线。

Excel 里你可以随意在某一行某一列写东西,Word 里你必须先创建段落或表格,再往里填 run。Excel 里合并单元格只是一条属性,Word 里<:w:vMerge>需要配对设置,而且合并后还要处理单元格里的残留段落。Excel API 里设置列宽很直观,Word 里你设了tcW还可能被“自动适应窗口”干掉。

举一个具体的例子。用XWPFTable table = doc.createTable(3, 3)创建表格后,如果你觉得“给每个单元格 setWidth 就完事了”,那 Word 打开后大概率表格宽度完全不受控。因为表格本身有一个tblLayout控制布局模式,如果 Word 处于“自动适应”,它会根据内容重新分配列宽,你设的值会被忽略。这就是为什么很多教程强调要先把 tblLayout 设成 fixed,再分别设置 gridCol 和 tcW。

如果带着 Excel 的思维去写,最多半天就会发现:代码没报错,生成的文件长风不羁。转过身来承认“Word 是树、是流、是 XML”,心态就稳了。

1.3 路线怎么选:纯 POI 还是模板填充

动手之前,先想清楚采用哪种实现路线。常见的有三条:

第一条是纯代码从零生成,new XWPFDocument(),然后一个段落一个段落地创建。优点是灵活,适合文档结构简单的场景;缺点也很明显,任何复杂格式(多级列表、专业页眉页脚、复杂表格)都得手写大量配置,维护成本高。

第二条是模板填充。预先在 Word 里把模板做好,用${xxx}这类占位符标记动态内容,程序加载模板后只做替换和局部操作。这是我实际项目里的主力方案,因为文档的排版、页眉页脚、水印这些视觉工作由 Word 完成最自然,程序员只需要关心数据。缺点是占位符可能被 Word 拆成多个 run,需要专门处理。

第三条是模板 + 图表/图片联动。如果业务报表里有图表,纯 POI 原生生成图表的功能很有限,一般做法是模板中预置好图表,程序只更新图表背后的数据缓存。这条路线比较冷门,但真实场景很常见。

没有绝对最好的路线,只有最合适的。如果动态内容少、格式简单,纯代码没问题;如果要做排班表、合同、报告这类重格式文档,模板填充几乎是唯一选项。

2. 核心实操:段落样式、表格宽度与图文混排

2.1 段落样式与标题层级:三级标题为什么会变二级

关于“三级标题变二级标题格式不对”这个热搜问题,POI 场景下出现的原因非常集中:XWPFParagraph.setStyle()只是设置了一个样式 ID 字符串,它不会帮你检查 styles.xml 里有没有这个 ID,也不会管这个 ID 对应的outlineLvl是多少级。

假如你要生成三级标题,代码是这样的:

XWPFParagraph para = doc.createParagraph(); para.setStyle("Heading3"); XWPFRun run = para.createRun(); run.setText("这是一个三级标题");

这段代码看着没问题,但它成立的前提是:

  • 模板或文档的 styles.xml 里存在 styleId 为Heading3的段落样式;
  • 该样式的w:outlineLvl值是 2(对应大纲级别第三级);
  • 字体、段落间距等属性都由样式接管。

如果模板里根本没有 Heading3,POI 不会报错,打开后 Word 会自动用默认样式渲染,形态就可能落到 Heading2 甚至 Normal 上。还有一种情况:你用的是setStyle("3")而不是setStyle("Heading3"),而文档样式库里恰好没有 ID 为“3”的样式,抛出文档主题样式混乱,标题层级自然不对。

我的排查套路是三步。第一步,用压缩工具打开模板 docx,直接看 word/styles.xml 里有哪些 styleId。第二步,检查 Heading3 对应样式的<w:outlineLvl w:val="2"/>是否在。第三步,如果要在代码里确保安全,干脆不进样式表,直接给段落 run 设置字体、字号、加粗、段前段后间距,写成一组工具方法,相当于“手动模拟”标题样式。

从底层来看,Word 的“标题层级”不只靠肉眼字体大小区分,更关键的是大纲级别,它影响导航窗格和目录生成。所以诊断问题别只看字体大小对不对,要抓 outlineLvl 这个根因。

2.2 表格单元格宽度:设置了两遍还是失效,根源在 tblW 和 tcW

这是热搜词里出现频率极高的一项:poi 设置 word 表格单元格宽度。很多人卡在一个现象上——代码里已经给XWPFTableCell调用了setWidth(),甚至给表格也设了宽度,但生成的 Word 打开后列宽完全不受控。

根因是我在 1.2 提到的“三重控制”。Word 表格宽度由三层属性共同决定:

第一层是表格级属性<w:tblPr>里的<w:tblW>,这是表格整体宽度,可以填具体数值(单位是 twips,1 厘米约等于 567 twips),也可以填"auto"或百分比。第二层是<w:tblGrid>里的<w:gridCol>,它定义了每一列的默认宽度。第三层是单元格级属性<w:tcPr>里的<w:tcW>,它可以覆盖 gridCol。

只设置tcW,但表格级tblLayout还是autofit,Word 会根据内容自动重算宽度,你的设置等于白做。正确姿势是三步走:

XWPFTable table = doc.createTable(2, 3); // 第一步:表格布局设为固定 CTTblPr tblPr = table.getCTTbl().getTblPr(); CTTblLayoutType layout = tblPr.addNewTblLayout(); layout.setType(STTblLayoutType.FIXED); // 第二步:设置表格整体宽度(建议用固定值,不用 percent) table.setWidth("100%"); // 第三步:设置每个网格列的宽度 CTTblGrid grid = table.getCTTbl().getTblGrid() == null ? table.getCTTbl().addNewTblGrid() : table.getCTTbl().getTblGrid(); for (int i = 0; i < 3; i++) { CTTblGridCol col = grid.sizeOfGridColArray() > i ? grid.getGridColArray(i) : grid.addNewGridCol(); col.setW(BigInteger.valueOf(2400)); // 2400 twips ≈ 4.23cm } // 第四步:同步设置每个单元格的 tcW for (XWPFTableRow row : table.getRows()) { for (XWPFTableCell cell : row.getTableCells()) { cell.getCTTc().addNewTcPr().addNewTcW().setW(BigInteger.valueOf(2400)); } }

我在给表格设置宽度时,习惯让三处数值保持一致。gridCol 是整个表格的“骨架子”,tcW 是每个单元格的实际表现,如果只有其中一处,生成的 docx 虽然也能打开,但列宽在不同 Word 版本里表现可能不一致。另外,如果表格是从已有模板复制的,还要检查模板里是否有一个<w:tblW w:type="pct" w:w="5000"/>这种百分比宽度,它和代码里的 twips 值会叠加产生奇怪结果,务必统一。

2.3 图片插入与“文本以外”的内容清理

热搜词里有一条“word 文档怎么去除文本以外的”,开发视角下它对应的常见操作有两类:插入图片后清理多余区域,以及删除文档里残留的水印、批注、修订记录。

先讲图片插入。POI 的图片插入没有想象中那么复杂,但有几个细节决定成败:

XWPFParagraph para = doc.createParagraph(); para.setAlignment(ParagraphAlignment.CENTER); XWPFRun run = para.createRun(); try (InputStream in = new FileInputStream("chart.png")) { run.addPicture(in, Document.PICTURE_TYPE_PNG, "chart.png", Units.toEMU(360), Units.toEMU(200)); } catch (Exception e) { // 图片格式不支持时,这里会抛异常 }

注意Units.toEMU()这个转换。Word 内部图片尺寸用的是 EMU(English Metric Unit),1 像素在 96 DPI 下约等于 9525 EMU。直接传360这种数字会被当作 EMU,图片就变成比针尖还小。我见过不少同事栽在这里,图片插入成功但什么都看不见。

至于“去除文本以外的内容”,要分对象处理。水印通常在页眉区域,可能是图片也可能是艺术字,清除需要拿到 header 的段落逐个删除 run 里的 drawing;批注在 word/comments.xml 里,POI 没有现成的删除 API,要拿到 XML 节点再移除;修订痕迹表现为<w:ins>和<w:del>节点,需要遍历 body 把这些节点替换为普通文本。

写一个通用清理方法时,我建议直接操作底层 CT 对象,而不是依赖高层 XWPF 类。原因是很多“文本以外的内容”在高层 API 里根本没有暴露对应的方法,与其反射绕路,不如用document.getDocument().getBody()拿 CTBody,再配合 XPath 找到目标节点删除。这种方式代码看起来丑一点,但能处理的情况广得多。

3. 高阶玩法:变量替换、图表数据联动与批量导出

3.1 变量替换:占位符被拆成好几段 run,是原生的核心痛点

模板填充最核心的痛点,就是${name}这个占位符在 Word 里被拆成了好几个 run,比如${一个 run,name一个 run,}一个 run。如果你直接用paragraph.getText()去搜,找到的是拼接后的字符串,但没法定位到具体哪个 run 要替换。

我最早的处理方案是“整段重写”:发现段落文本包含占位符后,把段落里所有 run 的文字清空,用第一个 run 重新写入替换后的文本。这样做简单,但会丢失段落内部的特殊格式。比如一段话里“金额是 ${amount} 元”,其中“元”是红色字体,整段重写后红色字体就没了。

后来我换成一种更稳妥的做法:基于 CT 层遍历 run,把每个<w:r>下的<w:t>文本收集起来,和目标字符串做前缀匹配。匹配到占位符开始位置后,把占位符所在的 run 拆开处理——保留格式不变,只修改w:t的文本内容。思路类似字符串处理里的“区间替换”,但落点放在 run 上。

public static void replaceInParagraph(XWPFParagraph paragraph, String oldText, String newText) { List<XWPFRun> runs = paragraph.getRuns(); StringBuilder fullText = new StringBuilder(); for (XWPFRun run : runs) { fullText.append(run.text()); } int idx = fullText.indexOf(oldText); if (idx < 0) { return; } // 将整段文本重组后用第一个 run 写入,其余 run 置空 // 适合占位符分散在多个 run 且段落内不需要复杂格式的情况 String first = fullText.substring(0, idx); String last = fullText.substring(idx + oldText.length()); if (runs.size() > 0) { runs.get(0).setText(first + newText + last, 0); for (int i = 1; i < runs.size(); i++) { runs.get(i).setText("", 0); } } }

上面这段代码属于“能用但不完美”的级别,适合段落格式简单、占位符是独立词的场景。如果段落里既有占位符又有复杂格式,我建议引入第三方封装库比如 poi-tl,它内部已经处理了 run 拆分问题。虽然标题是“POI 生成 Word”,但实际工作中合理借助成熟轮子并不丢人。

还有一个容易忽略的点:替换后生成的文档可能残留空格。Word 在输入${name}时,词法检查会自动在单词前后加空格,这些空格在 XML 里是实实在在的字符,替换时要把它们一并考虑进去。否则你会看到“金额是 500 元”变成“金额是 500 元”,中间多个空格,用户一眼就能看出是程序生成的。

3.2 图表能生成吗?修改模板图表数据才是主流方案

热搜词里有好几条关于图表:java poi word 能生成图表吗、通过修改模板中的图表数据修改 word 图表、poi word 替换 word 中的图表数据。结论先说:POI 能拿到文档里的图表对象,但想从零创建一个美观的图表非常痛苦,主流的可靠方案是“模板预置图表 + 程序改数据”。

一个 docx 里的图表,实际上由两部分组成:图表定义 XML(在 word/charts/chartXXX.xml)和一份内嵌的 Excel 工作表(在 word/embeddings/Microsoft_Excel_工作表.xlsx)。Word 打开文档时,图表显示的图形来自 chart XML,但图表对应的原始数据表来自内嵌 Excel。这就是“修改数据后无法打开生成的 word”这条热搜的根本原因——很多人只改了 chart XML,忘了内嵌的 Excel 缓存,Word 打开时发现两边数据对不上,就直接报错或要求修复。

正确的修改流程要两条线一起走。首先通过 POI 拿到图表对象,读取它的底层 CTChart:

List<XWPFChart> charts = doc.getCharts(); if (!charts.isEmpty()) { XWPFChart chart = charts.get(0); CTChart ctChart = chart.getCTChart(); // 找到要修改的序列,更新 c:numCache 或 c:strCache 里的值 // ctChart.getPlotArea().getLineChartArray(0).getSerArray(0)... }

第二步是同步更新内嵌 Excel。POI 对 embedding 这类关联文件的支持不算好,我实际项目中是直接用一个额外的XSSFWorkbook打开那份内嵌 xlsx,修改对应单元格,再写回 zip。因为 docx 是 zip,用 ZipInputStream 拆开、改掉 charts 和 embeddings 两份文件、再 ZipOutputStream 拼回去,这条路看着原始,但最可控。

这里有一个经验之谈:每次更新图表数据后,都要留一份“修改前”的原始模板做对比。如果用户反馈某个图表打开报错,先检查是不是只更新了 chart XML 而没有更新 embedding,这是九成图表损坏问题的来源。

3.3 批量导出多份文档,如何给每份加上独立编号

“多表单导出”和“每份打印带不同编号”这两个热搜,本质是同一个需求:循环模板,每次替换不同的数据,生成多份文档。

先给一个最简单的框架:

FileInputStream templateStream = new FileInputStream("template.docx"); for (int i = 0; i < list.size(); i++) { XWPFDocument doc = new XWPFDocument(templateStream); replaceTokens(doc, list.get(i)); try (FileOutputStream out = new FileOutputStream("output_" + i + ".docx")) { doc.write(out); } doc.close(); templateStream.getChannel().position(0); // 重置流位置,或者干脆每次重新 new }

注意上面这段代码有个隐藏问题——XWPFDocument构造函数会读取整个模板到内存,同一个模板重复创建十份,内存里就有十份完整的文档树。模板稍微复杂一点(带图表、图片、页眉页脚),单份可能吃掉几十 MB 堆内存,循环一多就容易 OOM。

我的改良做法是:模板解析一次,得到XWPFDocument后存入变量,每次导出时通过深拷贝文档树来获得新实例;如果不想引入深拷贝库,就在每次循环里重新从模板文件读取并立即使用,用完马上 close,把存活时间压到最短。

给不同文档加编号也有几种方案。最简单的是把${docNo}作为普通变量替换;如果编号需要出现在页眉或页脚,就要定位到 header/footer 里的段落再替换。更“高级”一点的做法是利用 docx 的 custom properties,把编号写入文档属性,这样在系统的“文档信息”里也能看到编号。我之前在合同批量生成项目里,就把合同编号同时写进正文和文档属性,用户查文件时不用打开就能确认是哪份合同。

还有一个实战细节:批量导出失败时,不要一个文件覆盖一个文件地写。先写到临时目录,全部成功后再统一移动到目标位置。否则中途报错时,目标目录里可能残留一批“半成品”文档,用户不小心打开还以为生成成功了。

4. 问题排查实录:从报错到格式错乱,逐一拆给你看

4.1 图表数据修改后,生成的文件打不开或提示修复

这类问题我在 3.2 说了根因——chart XML 和内嵌 Excel 数据不一致。但实际现场还有几种变体值得单独列出来。

第一种是修改了 chart XML 里的数值,但忘记同步数字格式。比如图表数据是金额,你在 chart XML 里写了1234.5,但内嵌 Excel 里的单元格格式还是“文本”,Word 打开图表后无法正常格式化,表现就是图表数据错乱。

第二种是只改了序列的显示值,没改分类轴标签。柱状图、折线图都有<w:cat>和<w:val>两套数据,只改 val 不改 cat,图表会显示旧标签配新数值,用户一看就是“数据对不上”。

第三种最隐蔽:图表里带有缓存数据<w:numCache>和<w:strCache>,这里的数据是图表渲染前预先生成的快照。你改了<w:numRef>指向的外部引用,但如果 cache 没更新,Word 还是会用旧的 cache 渲染。这也是为什么很多修改图表数据的代码“看起来改了、打开没变”。

我的处理原则是:宁可把图表相关的所有缓存节点全部更新,也不要偷懒只改一处。虽然代码多一些,但生成的文件在 Word 和 WPS 里打开都稳定。

让我整理一份常见问题速查表,把项目里遇到过的典型问题列在一起:

问题现象根源排查方向
表格列宽设置无效tblLayout 不是 fixed,gridCol 与 tcW 不一致检查 TblPr、TblGrid、TcPr 三处
三级标题变成二级样式 ID 不存在或 outlineLvl 不对检查 styles.xml
修改图表后文件损坏chart XML 与 embedding xlsx 不同步对比两份数据
占位符没被替换占位符被多个 run 拆分先合并全文再替换
表格下多出一行表格末尾有空白段落或分节符检查 body 元素顺序
图片不显示EMU 换算错误,尺寸为 0用 Units.toEMU 转换
文档打开很慢图片过大或 run 数量过多压缩图片、合并 run

4.2 表格下方多出一行删不掉、局部空白无法删除

“word 文档表格下多出一行,在下一页如何删除”和“局部有空白无法删除”这两条热搜,在开发生成的文档里非常典型。

先说表格下多出一行。POI 创建表格时,表格后面会跟着一个空段落,这是 Word 文档结构的硬性要求——表格不能是 body 的最后一个元素,必须有一个段落跟在后面。如果你在表格后直接创建新表格或分页符,这个“厂”出来的空段落就会被挤到下一页,用户看到的就是“表格下面多出一行且删不掉”。

应对办法有两种。一种是把空段落的字号设为 1pt、段前段后间距设为 0,让它变得几乎不可见。另一种是保留空段落但把它的样式设为模板里的“正文无间距”样式,避免占用额外空间。

至于“局部空白无法删除”,常见原因有两个。第一个是单元格或段落里有大量的<w:spacing w:before="..."/>和<w:spacing w:after="..."/>,视觉上空出一大块,但用户怎么按删除键都没反应。第二个是文档里残留了分页符<w:br w:type="page"/>或分节符,它们是不可见字符,却强制把后续内容推到下一页。

排查这类问题时,我习惯用 Notepad++ 打开生成的 docx 的 document.xml,直接搜索w:br和w:spacing。代码里看不到的问题,在 XML 里通常一目了然。

4.3 文档体量一大就卡死:性能与内存的取舍

热搜词“mac 运行大型 word 文档卡顿”是桌面端用户遇到的性能问题,但我们在生成端同样会遇到:几 MB 的模板,循环填充数据后生成 500 页的报告,POI 的XWPFDocument.write()耗时十几秒,内存飙到上 GB,甚至直接 OOM。

原因要从 POI 的模型结构说起。XWPFDocument 把整个文档树加载到内存,段落、run、表格、图片全部是 Java 对象。一个 500 页的文档动辄几十万个 run 对象,每个 run 又持有 XML 节点引用。再加上模板里如果嵌入了多张高清图片,内存压力会更大。

我的优化手段按收益从高到低排列:

第一,压缩图片。插入前先用 ImageIO 把图片缩放到目标尺寸,不要直接把 4000x3000 的高清大图塞进文档。一张压缩后的图片可能从 5MB 降到 100KB,效果立竿见影。

第二,合并 run。Word 编辑文档时会产生大量内容碎片,POI 生成时如果反复在同一个段落里createRun(),就会生成很多细碎的 run。可以在每段写完时做一个合并:把同一段落内格式相同的 run 合并为一个。这个优化能显著减少 XML 节点数量。

第三,分批写入。如果业务上可行,不要一次性生成 500 页,而是按章节生成多个 docx 再合并。合并时只操作 XML 层面的 body 内容,绕开高层 API。

第四,及时关闭。doc.close()不只是释放文件句柄,它还会清理内部缓存。批量导出场景里,忘记 close 是内存泄漏的头号原因。

结合桌面端的卡顿,还有一个思路给到前端用户:尽量用“另存为”而不是在同一个大文档里持续编辑,因为 Word 在编辑时会在内存里维护整个文档的撤销栈,文档越大越容易卡。这不是开发能解决的问题,但可以在交付说明里提醒用户。


最后说点实际操作里的体会。用 POI 生成 Word 文档,最大的成本不是学会 API,而是建立起“文档底层是 XML”这个心智模型。遇到任何诡异问题,先解压 docx 看 XML,比在 Java 代码里盲猜快得多。我在一个合同生成项目里维护了一个公共工具类,里面就三个方法:清理无效节点、修复表格宽度、合并碎 run,这个工具类帮我把大部分“玄学格式问题”都变成了确定性的代码问题。

另外一个值得分享的经验是:每次生成完文档后,不要只看结果对不对,要用 Word 的“打开并修复”功能跑一遍,或者用 LibreOffice 转换一次,把转 PDF 的过程作为回归测试。能通过转换的文档,才大概率在用户的各种环境下表现正常。POI 这条路走到后面,拼的其实是细心——你替用户多想一层格式问题,用户就少发一次脾气。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进