拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

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

Apache POI操作Word实战:表格、模板与图表全解析

Apache POI操作Word实战:表格、模板与图表全解析

如果你是一个Java后端,最近被“把服务器上的数据整理成Word文档导出”这种需求缠住了,那么Apache POI大概率是你绕不过去的坎。这里的POI不是地图上的兴趣点Polygon of Interest,而是Apache旗下那个快二十岁的Java开源组件库。网上关于POI操作Excel的教程一抓一大把,但操作Word的完整案例反而零散,很多细节要靠自己翻源码、试错才能弄明白。这篇东西,我就把自己用POI生成Word、改Word模板、处理表格宽度和图表数据替换的实战经验一次性倒出来,目标很明确:让看完的人能直接动手写一个能上生产的Word导出工具。

先说清楚,整篇围绕的是POI操作.docx格式(XWPF模块),不是老的.doc(HWPF模块)。前者是Office Open XML标准,数据结构本质是一个zip包,里面有document.xml、styles.xml、numbering.xml等一堆XML文件,POI做的事情就是帮你把这堆XML包成一个易用的对象模型。理解了这一层,后面遇到的很多诡异问题就都好解释了。

1. POI操作Word的真实边界:能做什么,别抱什么期望

很多初学者把POI当成“能用Java写Word的万金油”,上手就想去动态排一个几十页的精美标书。我的建议是趁早打消这个念头。POI对Word的支持程度,和它自己对Excel的支持程度完全不在一个量级。Excel那边可以折腾透视表、图表、条件格式,Word这边能稳定驾驭的是段落、文字、表格、图片、页眉页脚、基础样式和域。像“首行缩进两个字符”“标题自动编号”“目录自动更新”这种排版细节,要么需要你自己拼底层XML,要么干脆做不到位。

表格这块我要单独拎出来强调,因为POI生成Word里九成以上的技术问题都出在表格上。单元格合并、列宽控制、行高设置、单元格内边距,这些需求都能做,但写法非常反直觉,直接调XWPFTable的setWidth方法往往不生效,后面我会专门讲。此外,POI对Word中的复杂列表(尤其是中文多级编号“一、(一)1.”这种)支持很差,我的经验是不要在代码里动态做多级编号,而是把编号样式提前做在模板里,生成时只填内容。这是很多项目的通用做法,也是效率最高的方案。

还有一点容易踩:POI对宏和VBA的支持非常有限,涉及宏的Word处理基本不要考虑用POI做。如果客户给了一个带宏的docm文件让你程序替换数据,最靠谱的办法是生成新文件之后再用其他工具去跑宏,或者直接和客户确认能否用纯docx格式。

版本方面,我建议直接用4.1.2以上的poi-ooxml(目前5.x也很稳)。旧版本里很多API和方法都变了,网上老教程的代码经常编译不过。如果你用Maven,注意poi-ooxml在4.1.2之后默认可能不带完整schema类,如果出现ClassNotFoundException,补一个poi-ooxml-full依赖就行。

2. 用XWPF读写段落与文字:先让文档能跑起来

一个最简的POI生成Word流程很简单:创建XWPFDocument,创建段落,创建Run,setText,写文件。但这里有几个细节会影响你后续所有的代码设计。

先看基础代码:

import org.apache.poi.xwpf.usermodel.*; import java.io.FileOutputStream; public class DemoWord { public static void main(String[] args) throws Exception { try (XWPFDocument doc = new XWPFDocument()) { XWPFParagraph p1 = doc.createParagraph(); XWPFRun r1 = p1.createRun(); r1.setText("这是一段测试文字"); r1.setBold(true); r1.setFontSize(14); XWPFParagraph p2 = doc.createParagraph(); XWPFRun r2 = p2.createRun(); r2.setText("第二段内容"); r2.setColor("FF0000"); try (FileOutputStream out = new FileOutputStream("test.docx")) { doc.write(out); } } } }

这里面最关键的概念是Run。在OpenXML模型里,一个段落(Paragraph)由若干Run组成,每个Run代表一段连续、共享相同格式的文本。所以你想在同一个段落里让一部分字变红、一部分加粗,就要拆成两个Run分别设置,而不是一个Run里切来切去。

很多人在一个Run里连续调用多次setText,结果发现只有最后一次生效。正确做法是:

XWPFRun run = paragraph.createRun(); run.setText("第一行"); run.addBreak(); run.setText("第二行");

addBreak是在Run内部插入换行符,这样两行字会出现在同一个段落里。如果你确实需要多个段落,就要createParagraph创建多个段落对象。

2.1 中文宋体、黑体不生效的根源

这是POI新手必踩的坑:明明调用了run.setFontFamily("宋体"),生成的Word里中文还是默认的等线字体。原因是Word的字体体系里,西文字体和中文字体是两个独立字段。setFontFamily("宋体")设置的其实是ASCII字体(ascii)和High Ansi字体(hAnsi),而中文走的是EastAsia字段。

解决办法有两种:

// 方式一:POI封装的枚举 run.setFontFamily("宋体", XWPFRun.FontCharRange.eastAsia); // 方式二:直接操作底层CTRPr CTRPr rpr = run.getCTR().isSetRPr() ? run.getCTR().getRPr() : run.getCTR().addNewRPr(); rpr.addNewRFonts().setEastAsia("宋体");

千万别小看这个细节,很多“生成的文档打开以后字很怪”的反馈,根子就在这里。

2.2 标题和正文结构:用pStyle代替手工调字号加粗

Word的标题之所以是标题,不是因为字号大、加粗了,而是它的段落样式(Style,pStyle值)是Heading1、Heading2。这个区别决定了你能不能生成“真正结构化”的文档:目录能识别、导航窗格能跳转、多级列表能关联。

XWPFParagraph heading = doc.createParagraph(); heading.setStyle("Heading1"); XWPFRun hr = heading.createRun(); hr.setText("第一章 项目概述");

如果你的模板里没有定义Heading1样式,这句话不生效,Word会用默认样式。所以更稳的做法是操作预先设计好的模板,或者手动给段落设置大纲级别:

CTP ctp = heading.getCTP(); ctp.addNewPPr().addNewOutlineLvl().setVal(BigInteger.valueOf(0)); // 0是一级,1是二级

outlineLvl决定了这个大纲在Word导航窗格里属于第几级,这是“生成几十页结构化文档”时真正起作用的东西。

3. 表格单元格宽度:POI里最折磨人的一件事

如果你在网上搜“POI 设置Word表格单元格宽度”,能找到大量帖子说明这个问题有多烦。我用了三四年POI,也直到最近才彻底弄明白它背后的机制。先说结论:单纯调用table.setWidth()或cell.setWidth()在多数情况下不会按你的预期生效,因为Word表格存在自动调整(autofit)机制,它会忽略你设置的单元格宽度,自行根据内容伸缩。

要让宽度设置真正生效,必须同时满足三个条件:

  1. 表格布局必须是固定布局(fixed layout),也就是底层XML里<w:tblLayout w:type="fixed"/>。
  2. 表格整体宽度tblW要设置。
  3. 每个单元格的宽度tcW要设置,不能只设表头。

而这三个条件在POI里的API很隐晦。XWPFTable.setWidth(String)设置的是tblW,XWPFTableCell.setWidth(String)设置的是tcW,但固定布局的type得去底层XML对象上设置。我写了一个工具方法,直接搬过去就能用:

import org.apache.poi.xwpf.usermodel.*; import org.openxmlformats.schemas.wordprocessingml.x2006.main.*; import java.math.BigInteger; import java.util.Arrays; import java.util.List; public class WordTableUtil { /** * 设置表格固定布局 + 各列宽度 * 单位:twips(1厘米 = 567twips,1英寸 = 1440twips) */ public static void setTableFixedWidth(XWPFTable table, int[] columnWidthsTwips) { // 1. 表格布局:fixed CTTblPr tblPr = table.getCTTbl().isSetTblPr() ? table.getCTTbl().getTblPr() : table.getCTTbl().addNewTblPr(); CTTblLayoutType layout = tblPr.isSetTblLayout() ? tblPr.getTblLayout() : tblPr.addNewTblLayout(); layout.setType(STTblLayoutType.FIXED); // 2. 表格总宽度 int totalWidth = Arrays.stream(columnWidthsTwips).sum(); table.setWidth(String.valueOf(totalWidth)); // 3. 表格网格定义(tblGrid)里的列宽 CTTblGrid tblGrid = table.getCTTbl().getTblGrid(); CTTblGridCol[] gridCols = tblGrid.getGridColArray(); for (int i = 0; i < gridCols.length; i++) { if (i < columnWidthsTwips.length) { gridCols[i].setW(BigInteger.valueOf(columnWidthsTwips[i])); } } // 4. 每一行的每个单元格都设置tcW for (XWPFTableRow row : table.getRows()) { List<XWPFTableCell> cells = row.getTableCells(); for (int i = 0; i < cells.size(); i++) { if (i < columnWidthsTwips.length) { cells.get(i).setWidth(String.valueOf(columnWidthsTwips[i])); } } } } }

3.1 为什么明明设了宽度,Word打开还是不对

我把踩过的坑总结成两类,你们可以自查:

第一类:tblGrid的数量和实际单元格列数对不上。当你用table.createRow()、table.createCell()的方式动态建表时,POI底层生成的tblGrid很可能没有同步补充新的gridCol,或者多了一些空列。这样Word在解析时就会拿tblGrid的列数去套实际单元格,导致错位。解决方法是建完表格后检查tblGrid的gridCol数组长度,不够就用tblGrid.addNewGridCol()补,多了就删掉。

第二类:没有考虑页面可用宽度。A4纸宽度是21厘米,换算成twips大概是11907,但页面左右默认有1英寸(约1440twips)的页边距,所以表格可用宽度实际只有约9027twips。如果你把表格总宽设成12000甚至更宽,Word打开后会显示表格溢出页面边界,拖到页面外,看起来就像“列宽拖动失效了”。我习惯在写表格前先算好:

int pageWidthTwips = 11907; // A4 int leftMargin = 1440; // 取决于模板/节边距 int rightMargin = 1440; int tableMaxWidth = pageWidthTwips - leftMargin - rightMargin;

然后把各列宽度的总和控制在这个值以内。这里还没有算单元格边距和边框,如果设置了较大的cellMargin,实际还要再扣一部分。

3.2 合并单元格之后列宽又乱了怎么办

表格里做合并是很常见的需求。POI合并不是“合并两个cell对象”,而是通过底层xml在单元格上追加<w:gridSpan>或<w:vMerge>。比如横向合并两列:

XWPFTableCell cell = row.getCell(0); CTTcPr tcPr = cell.getCTTc().isSetTcPr() ? cell.getCTTc().getTcPr() : cell.getCTTc().addNewTcPr(); tcPr.addNewGridSpan().setVal(BigInteger.valueOf(2));

然后第二列那个被合并的单元格需要删除或留空。但这里有个坑:合并后tblGrid的列数不变,但某些行的实际单元格数变少了,你如果还拿固定的widths[i]去给每个cell设置宽度,就会把被合并单元格的宽度错配到下一格上。我的经验是合并场景下,只给每个实际存在单元格按合并跨度累加设置tcW。例如第一列宽2000、第二列宽1500,合并后那个跨两列的cell直接设width为3500,后续单元格从3500之后继续排。

4. 生成图表与图表数据替换:两条靠谱的路线

热搜里有个很经典的问题:“java poi word能生成图表吗”。直接回答:POI本身能做,但能力比较弱且复杂。POI从4.x开始引入了XWPFChart,可以往Word里塞内置图表对象,但你得先创建一个chart部件,绑定一个内嵌的Excel工作簿,然后定义系列、类目、数据引用……那个复杂度,自己写一次就知道有多酸爽。所以我的建议是,除非你的图表需求非常简单且固定,否则不要用POI去从头创建图表。

实际项目里更常见的是:模板里预先放好了一个Word图表,比如“月度销售额趋势图”,程序跑完数据后去更新这个图的源数据,让Word重新计算并显示新数据。这条路线是行的通的,但需要理解docx的图表结构。

4.1 docx里的图表数据到底存在哪

一个包含图表的docx,解开zip后你会看到类似这样的结构:

  • word/charts/chart1.xml:图表定义,包括类型、系列、类目、以及用于渲染的历史缓存数据(numCache/strCache)。
  • word/embeddings/EmbeddedObject1.xlsx:图表关联的内嵌Excel数据源,里面有图表引用的单元格区域。

Word打开文档时,图表显示的其实是chart1.xml里的缓存数据(cache),而不是直接读EmbeddedObject。但如果你想刷新图表数据,正确方式是把EmbeddedObject里的单元格改掉,同时把chart1.xml里的cache也改掉,否则会出现“图没变”或者“Word报错修复”。这里要特别小心:只改其中一个,另一个就会和数据源不一致,轻则显示旧数据,重则文件损坏打不开。

我的一个稳定做法是:直接用ZipInputStream读docx的entry,找到word/embeddings/EmbeddedObject*.xlsx,用XSSFWorkbook把它读进来,改对应Sheet的单元格,再写回ByteArrayOutputStream,然后重写整个zip包。chart1.xml里的cache可以同步修改,也可以选择不修改但必须保证和源数据一致。如果嫌麻烦,一个更讨巧的办法是图表不保留数据联动,而是用Java图形库(比如JFreeChart、XChart)把数据画成图片,再插入Word。这样肯定能渲染出漂亮的图,但代价是图片不能编辑。

三条路线对比如下:

路线可维护性图表可编辑性实现成本适用场景
用XWPFChart新建图表中好高图表类型固定、要求原生图表
替换模板中图表的内嵌Excel数据中好中模板固定、批量替换数值
用图形库生成图片插入Word高无(图片)低样式要求自由、不要求可编辑

4.2 替换内嵌Excel数据源的核心代码思路

下面这个代码片段展示了读取docx压缩包中嵌入的xlsx并替换数据的核心逻辑,实际使用时要自己封装成工具类:

import org.apache.poi.xssf.usermodel.XSSFWorkbook; import org.apache.poi.openxml4j.opc.OPCPackage; import java.io.*; import java.util.zip.*; public class ReplaceWordChartData { public static void replaceChartData(String templatePath, String outputPath) throws Exception { try (ZipInputStream zin = new ZipInputStream(new FileInputStream(templatePath)); ZipOutputStream zout = new ZipOutputStream(new FileOutputStream(outputPath))) { ZipEntry entry; while ((entry = zin.getNextEntry()) != null) { String name = entry.getName(); if (name.matches("word/embeddings/EmbeddedObject\\d+\\.xlsx")) { // 读取嵌入的xlsx byte[] xlsxBytes = zin.readAllBytes(); XSSFWorkbook wb = new XSSFWorkbook(new ByteArrayInputStream(xlsxBytes)); var sheet = wb.getSheetAt(0); // 假设数据在B2:B8,直接覆盖 for (int i = 2; i <= 8; i++) { sheet.getRow(i).getCell(1).setCellValue(/*你的新数值*/); } ByteArrayOutputStream baos = new ByteArrayOutputStream(); wb.write(baos); wb.close(); zout.putNextEntry(new ZipEntry(name)); zout.write(baos.toByteArray()); } else { zout.putNextEntry(new ZipEntry(name)); byte[] buf = new byte[4096]; int len; while ((len = zin.read(buf)) > 0) { zout.write(buf, 0, len); } } zout.closeEntry(); } } } }

注意:这个方案要求模板里的图表本身是“内置图表”,不是粘贴的图片。判断方法很简单,word里有charts目录和embeddings目录就说明图表是内置的。

4.3 关于chart缓存不同步导致文件打不开的问题

如果你只用上面的方式改xlsx,没有动chart1.xml,那么Word打开时可能有两种表现。一是打开后图表显示旧值,但点击图表“编辑数据”能看到新值,说明缓存和新数据不一致。二是更严重的,Word直接提示“文件已损坏,是否修复”,某些情况下修复后图表丢失。

根因在于chart1.xml里的cache和嵌入xlsx的内容不一致。开源世界里做这个改动的项目不少,但实现都很克制。我自己稳妥的做法是:在更新EmbeddedObject的xlsx时,同时解析chart1.xml,找到<c:numCache>结构,把里面的<c:pt>值同步替换成新数值。这个操作更精细,但需要熟悉chart XML的结构。如果你们的项目没有严格的原生图表编辑需求,我真的建议走图片插入路线,省心太多。

5. 多级标题、页码、页边距:让文档不像是程序生成的

自动生成的Word文档,最容易被嫌弃的一点就是“一眼假”:标题层级混乱,目录不显示,页脚没有页码,字体不统一。这一节我专门说说POI怎么处理这些容易被忽略的排版细节。

5.1 多级标题与多级列表编号

前面提到,多级标题的核心是pStyle配合Heading1/Heading2样式。文档里的“多级自动编号”,比如1、1.1、1.1.1,其实依靠的是numbering.xml里定义的多级列表编号规则。POI对numbering.xml的创建支持非常弱,在代码里动态构建一套完整的中文多级编号太痛苦了,成功率和Word版本兼容性也很难保证。

我强烈建议的做法是:在Word里先做好一个“模板.docx”,把多级列表编号、标题样式、目录域、页眉页脚全部预设好,然后POI只负责往里面填内容。这样生成出来的文档排版稳定,运行速度快,逻辑也简单。模板技术我会在下一节展开。

如果你确实需要在代码里保证某个段落按“二级标题”显示,直接这样:

paragraph.setStyle("Heading2");

如果担心模板里没定义Heading2,就用CTP去设置outlineLvl为1。这至少能保证文档结构上有层级,即使显示样式不完美,导航窗格和目录也能识别。

5.2 页脚页码域的插入方法

页脚的页码不是普通文字,它是域(Field)。Word里按Ctrl+F9插入的那种花括号就是域。POI里要插入页码域,直接往页脚段落里塞“PAGE”这个域指令:

import org.openxmlformats.schemas.wordprocessingml.x2006.main.*; XWPFHeaderFooterPolicy policy = doc.createHeaderFooterPolicy(); XWPFParagraph footerPara = policy.createFooter(XWPFHeaderFooterPolicy.DEFAULT).createParagraph(); CTP ctp = footerPara.getCTP(); // 域开始 CTFldChar begin = ctp.addNewFldChar(); begin.setFldCharType(STFldCharType.BEGIN); // 域指令文本 CTText instrText = ctp.addNewInstrText(); instrText.setStringValue("PAGE \\* MERGEFORMAT"); // 域结束 CTFldChar end = ctp.addNewFldChar(); end.setFldCharType(STFldCharType.END);

需要注意的是,doc.createHeaderFooterPolicy()这个API在POI的不同版本里行为有差异。如果报错,可以尝试直接操作doc.getDocument().getBody().addNewSectPr()下的headerReference/footerReference来追加。这里面的XML引用关系比较绕,建议先把Policy用起来,跑不动再去查引用。

5.3 页边距与全局样式

页边距对应的是section属性里的pgMar。在document.xml的sectPr里设置:

CTSectPr sectPr = doc.getDocument().getBody().addNewSectPr(); CTPageMar pageMar = sectPr.addNewPgMar(); pageMar.setTop(BigInteger.valueOf(1440)); pageMar.setBottom(BigInteger.valueOf(1440)); pageMar.setLeft(BigInteger.valueOf(1440)); pageMar.setRight(BigInteger.valueOf(1440));

单位同样是twips。1440就是1英寸。很多小伙伴设置完页面边距后发现表格宽度的计算又要跟着变,没错,这两者往往是联动的。如果你先设置了固定页边距,再套用我前面的setTableFixedWidth方法,记得把边距值代入总宽计算,不能拿默认值硬算。

还有一个小技巧:设置文档默认字体,可以通过操作styles.xml的docDefaults节点。在POI里可以用doc.getStyles()拿到XWPFStyles对象,再取默认字体。不过很多模板的docDefaults已经由Word写好了,你不要去覆盖它,只改自己要改的段落即可。否则会影响到模板里已经调好的中文标题样式。

6. 模板占位符替换与批量处理:写一个能上线的工具类

真正的生产级需求,很少是“从零新建一个空文档”。更多场景是:拿到一个做好的Word模板,里面有公司名、合同编号、表格明细、甚至嵌好的图表,程序只需把对应占位符替换掉,输出成新文件。

用模板有几个好处。第一,版式好控制,字体字号缩进这些都在Word里调好。第二,样式体系稳定,多级编号、页眉页脚、图表、目录都现成。第三,性能上通常比代码硬画一套文档要好,因为样式定义都在第一次加载时解析。

6.1 占位符为什么会被替换失败

最常见的做法是在模板里写${companyName}这种占位符,然后程序扫描所有段落、表格单元格里的文本,发现占位符就替换。逻辑听起来简单,实际跑起来经常发现“有的替换了有的没替换”。原因是Word会把一段看似连续的文本拆成多个Run——这取决于用户编辑时的输入法、样式切换、修订记录等。比如“${companyName}”这个字符串,在XML里可能被拆成三个Run:${compan、yName、}。你如果直接循环每个Run找包含${companyName}的Run,永远找不到完整字符串。

解决思路是:在每个段落级别,先把所有Run的文本拼出来,看拼起来的整段是否包含占位符,如果包含,则选取第一个Run(或新建一个Run)写入替换后的完整文本,其余Run清空。注意保留第一个Run的字体格式,这样替换后的中文字体、字号才和原来一致。大概逻辑如下:

public static void replaceTextInParagraph(XWPFParagraph para, String placeholder, String replacement) { StringBuilder sb = new StringBuilder(); for (XWPFRun run : para.getRuns()) { sb.append(run.text()); } if (!sb.toString().contains(placeholder)) { return; } String newText = sb.toString().replace(placeholder, replacement); // 清空所有run,把替换文本写到第一个run List<XWPFRun> runs = para.getRuns(); for (int i = 1; i < runs.size(); i++) { runs.get(i).setText("", 0); } runs.get(0).setText(newText, 0); }

还要注意,替换文本本身如果有换行,比如要填入多行地址,不要把\n直接setText,Word里不会有换行效果。要新建Run然后调用addBreak(),或者直接插入一个<w:br/>。

6.2 遍历文档中的所有段落和表格

替换占位符时不能只遍历doc.getParagraphs(),因为表格里的单元格也包含段落,它们是嵌套在表格节点里的。正确做法是遍历body element:

for (IBodyElement element : doc.getBodyElements()) { if (element instanceof XWPFParagraph) { replaceInParagraph((XWPFParagraph) element); } else if (element instanceof XWPFTable) { for (XWPFTableRow row : ((XWPFTable) element).getRows()) { for (XWPFTableCell cell : row.getTableCells()) { for (XWPFParagraph para : cell.getParagraphs()) { replaceInParagraph(para); } } } } }

如果表格里还嵌套了表格,就要写成递归,别只做一层。

6.3 模板复用、并发与多文档导出

如果你们的接口要同时处理几十个模板、几百条数据,有个性能和安全问题必须提前设计:XWPFDocument不是线程安全的。同一个模板文件不能被多个线程同时解析修改。两种常用策略:

  1. 每次生成一个文档,从模板文件new一个XWPFDocument实例,互不干扰。代价是每次都要重新读模板、解析XML,如果模板很多、很大,性能会受影响。
  2. 提前把模板读成byte[]缓存,每次请求来了new ByteArrayInputStream(bytes)再new XWPFDocument,这样避免了重复IO,线程之间也不共享可变对象,是生产环境里最常用的做法。

另外,批量导出多个文档时(你看热词里也有“多表单导出”),建议每个文档生成后立即用ZipOutputStream校验一下能不能正常打开,最简单的校验是重新打开生成的docx:

try (XWPFDocument check = new XWPFDocument(new FileInputStream(output))) { // 至少能解析成功 }

然后再发送给下游。这个校验成本不高,但能把“生成完才发现文档损坏”的事故挡在出口之前。

7. 踩坑实录:修改数据后打不开、列宽拖不动这类问题的排查链路

我把自己和身边同事在POI Word上遇到的高频问题、排查链路和根因整理成下面这张表。如果你也遇到类似问题,照着这个顺序排查能省很多时间。

问题现象常见根因排查链路解决方向
修改图表数据后生成的Word无法打开chart1.xml缓存与内嵌xlsx数据不一致,或者重写zip时entry损坏先用压缩软件打开docx,看word/charts和word/embeddings是否完整;再用XML工具校验chart1.xml里的cache是否和xlsx数值匹配同步更新cache,或者改用图片图表;重写zip时不要重复建同名entry
表格列宽拖不动,或者拖动后乱跳表格不是fixed布局,或tblGrid没有正确同步打开document.xml搜tblLayout,看type是autofit还是fixed;看tblGrid列数和每行列数是否一致用上面的setTableFixedWidth方法整体设置
中文变成方框或乱码字体没设置EastAsia字段看生成文档里该段落的rFonts节点,确认eastAsia属性设置run.setFontFamily("宋体", FontCharRange.eastAsia)
Word提示“文件已损坏,是否恢复”用了简化的zip写入,丢失了docx必需的文件或[Content_Types].xml被改动用7-Zip对比原始docx和新生成docx的entry列表,看有没有多出/缺失的文件不要手工拼zip,尽量基于原始模板做替换
设置了标题样式但目录不显示标题生成的段落没有真正使用Heading样式,或outlineLvl缺失在Word里按住Ctrl点击目录,看“更新目录”能否出来;检查段落样式是否Heading1设置pStyle,或设置outlineLvl
替换占位符后不见了,但文本还在替换逻辑把Run清空时,把有内容的run误清检查所有Run合并后的文本和清空逻辑,是否所有run都被清空只剩第一个只保留第一个run写入,其他run的text清空,但保留格式

这里我特别想展开“修改数据后无法打开生成的Word”这个case,因为这几年遇到的人特别多。很多人改了内嵌xlsx后,会想当然地用一个自己拼的ZipOutputStream重写docx,结果漏掉了zip包里的目录项(Central Directory)或压缩方式异常,导致Word解不开。排查办法很简单:先把生成的docx用7-Zip/WinRAR打开,看能不能正常列出所有文件,再双击document.xml看能不能预览。如果压缩包本身解不开,那问题大概率不在POI,而在你写zip的代码上。另外,有同事用OPCPackage去打开docx然后save到新文件,结果把模板里一些关系的target路径改坏了,也会导致打不开。这种场景下,推荐把docx当普通zip处理,不要用POI的OPCPackage做整体另存。

7.1 一次完整的修复过程复盘

一次真实经历:我们有一个合同模板,里面嵌了一个“年度费用走势图”,程序跑完数据后把EmbeddedObject里的数据替换了。本地开发测试都正常,到了客户机器上,有一台Office版本较旧的电脑报“word无法打开”。一开始以为是Office版本问题,后来用7-Zip对比新旧docx,发现所有文件都在,但chart1.xml里的c:ptCount值和xlsx里的行数不一致——因为旧版Office对缓存中的ptCount校验更严格,直接判定文件结构异常。

修复方式:在替换xlsx数据的同时,按新的行数重算c:ptCount,并同步改写每个c:pt idx节点的v值。改完后在客户机器上验证通过。这个案例说明,POI生态下Word生成常常不是“写对了就行”,而是要确保生成结果符合Word自身的数据一致性校验。遇到打不开,永远第一个怀疑docx内部xml的引用关系和数据一致性,而不是去怀疑Office装坏了。

8. 最后多聊几句:怎么设计一个真正好用的Word导出模块

工程上,我倾向于把“Word导出”单独设计成一个模块,而不是散落在业务代码里。模块里至少包含三层:

第一层是模板管理。所有Word模板统一放在resources或文件系统,模板文件必须有版本号,模板的改动要能追溯。因为模板里的占位符一旦被业务代码引用,任何人都不能随意改名,否则线上必炸。

第二层是数据装配。把业务数据转换成模板需要的Map结构,这个过程要和实际模板解耦。不要让业务Service直接去操作XWPFDocument,而是让Service返回一个Map<String, Object>,由Word模块统一渲染。这样以后换模板、换样式,业务代码一行不用动。

第三层是产物校验。生成完docx后,统一做一次“重新打开”校验,并记录文件大小、生成耗时、占位符是否全部替换完毕。尤其要注意,替换完成后要检查是否还有残留的${字符,那通常意味着数据没配上:

if (doc.getParagraphs().toString().contains("${")) { throw new IllegalStateException("存在未替换的占位符"); }

另外,如果你需要把生成的docx转成PDF供预览或归档,POI本身不负责这件事,推荐用LibreOffice headless模式转换,命令行大概是这样:

soffice --headless --convert-to pdf output.docx

可以在CI流水线里每次构建后自动把样例输出转成PDF,用PDF渲染结果来检查排版。这比肉眼打开Word一个个检查快得多。

根据我个人经验,POI生成Word项目能不能少踩坑,很大程度取决于一开始就选择模板驱动而不是代码硬排,离开这个原则,再多的技巧都救不了复杂版式。最后再分享一个小技巧:如果占位符需要替换的值特别多,比如几百个,建议做一次完整的Map遍历时,统一跑到同一个段落处理器里,避免页面上下文被重复读取,这样性能能提升不少。如果你正在规划一个Word导出功能,能从这篇里带走那几个表格宽度的底层逻辑,我就很满足了。

返回列表