
上个月同事甩了个Word文件给我说里面有十几个图表数据源全变了需要程序批量替换。这需求听着不难但真正用Apache POI做起来坑比预想的多很多。尤其“替换完数据后图表不刷新、打开还是旧数字”这个老大难折腾了我整整一下午。今天把最终跑通的方案完整写出来包括docx里图表到底存了什么、为什么只改Excel工作簿不管用、怎么把图表XML里的缓存和引用一并改掉以及遇到的各种坑。希望能让后面做同类需求的人少走点弯路。1. 动手之前先把Word图表的结构看清1.1 你以为的图表 vs docx里的图表很多人听到“Word里的图表”第一反应是图表就是个图片替换数据就等于重画一张图。这个理解对了一半。Word里常见的图表有三种形态真正的原生图表插入时弹出一个类似Excel的小表格填数据后自动生成图形从Excel复制粘贴过来的图表对象底层可能带一个Embedded xlsx部件更坑的还有一种作者直接用截图软件把图表截成图片贴进了文档。第三种不用聊POI只能改图片不能让图片里的柱子长高变矮。前两种才是“数据图表”在docx包里对应的是chart部件和embeddings目录下的xlsx文件。实际拆开一个docx看会看到类似下面的部件结构word/charts/chart1.xml真正的图表内容定义图表类型、系列、坐标轴、引用范围以及一份已经算好的缓冲数据word/embeddings/Microsoft_Excel_工作表1.xlsx内嵌的Excel数据源图表数据真正存放的地方word/charts/_rels/chart1.xml.rels把chart1.xml和xlsx关联起来。也就是说图表本身不是一个孤立的图形而是“图表定义 数据工作簿”的复合体。要改数据并让图表正确显示必须同时动这两个地方忽略任何一个都会出问题。1.2 数据层与展示层的双份数据更绕的地方在于docx里同一份图表数据竟然存了两份。第一份在嵌入的xlsx里这是数据的“源头”。第二份在chart1.xml的系列节点里作为numCache或strCache缓存是绘图时实际读取的“展示值”。为什么OpenXML要这么设计因为Word打开文档时直接读图表XML里的缓存值就能快速画出图不必每次都去解析、计算嵌入的xlsx否则文档越大打开越慢。图表缓存类似Excel图表里的“缓存数据”在Excel里你改工作簿数据后图表会跟着变因为Excel自己会对缓存做同步刷新但在docx里POI没有现成的“刷新整个图表”的按钮必须自己把xlsx和chart xml两边的数据都处理掉。所以很多人的经验“用POI打开chart对应的工作簿改完数据另存”打开Word一看图表纹丝不动。原因就在这里xlsx改了但chart xml里的缓存还是旧值Word画图的时候压根没去读xlsx。搞清楚这个结构后续的方案就清楚了改数据必须“两头堵”。2. 技术选型为什么方案最终落在XWPFChart上2.1 几种常见做法为什么都不够好接到需求后我第一反应是找现成模板引擎。比如有人用poi-tl做Word模板导出它处理普通表格、列表、文本替换很舒服但面对文档里已经画好的图表poi-tl并没有提供“替换图表数据并刷新”的API它更多是负责生成新文档时插入图表或者操作表格区域。还有人想到把Word里所有图表找出来删掉再用代码重新插入新图表。这个思路在POI里虽然可行但代价非常高。重新插入图表意味着要处理XWPFChart的创建、图表数据的绑定、绘图区域大小、坐标轴样式、系列颜色、图例位置……等于把作者原来精心调过的图表重画一遍而且很容易失真。对“保样式、只换数”的需求来说这是典型的杀鸡用牛刀。所以最终选择用XWPFChart直接操作原图表的底层XML。它保留所有图表样式只更新数据引用和缓存属于精准手术。2.2 POI版本和Maven依赖这里要强调一个版本问题。POI对Word图表的支持起步晚4.1.0之前很多图表API残缺后来才慢慢补全。而且4.1.0及之前的版本存在XML外部实体注入漏洞现在开发新项目建议直接上5.x。我的POM里用的是dependency groupIdorg.apache.poi/groupId artifactIdpoi-ooxml/artifactId version5.2.5/version /dependency这个版本里XWPFChart已经能通过XWPFDocument.getCharts()拿到文档中所有图表也能用chart.getWorkbook()拿到底层XSSFWorkbook。如果你还在用4.1.0很可能连getWorkbook()都访问不到那就只能从底层part关系里自己抠代码会平白无故多出一大截。2.3 核心API一览整理一下这次实际用得最多的API功能API说明获取所有图表XWPFDocument.getCharts()返回ListXWPFChart按文档顺序获取嵌入工作簿XWPFChart.getWorkbook()返回XSSFWorkbook可像普通Excel一样操作获取图表空间XWPFChart.getCTChartSpace()拿到OpenXML底层对象获取图表定义CTChartSpace.getChart()图表根节点下面挂着绘图区、图例、系列获取绘图区CTChart.getPlotArea()所有系列都在这里获取工作簿重算属性XSSFWorkbook.getCTWorkbook().getCalcPr()设置全量重算开关比较有用的一个习惯是拿到图表后先dump一下CTChartSpace的XML确认图表类型和系列结构。这比对着代码猜要快得多。3. 实操从读文档到替换图表数据全流程3.1 第一步遍历文档中的图表先写一个最外层的入口方法import org.apache.poi.xwpf.usermodel.XWPFChart; import org.apache.poi.xwpf.usermodel.XWPFDocument; import org.apache.poi.xssf.usermodel.XSSFWorkbook; import java.io.FileInputStream; import java.io.FileOutputStream; import java.io.InputStream; import java.io.OutputStream; import java.util.List; public class WordChartReplacer { public static void main(String[] args) throws Exception { try (InputStream in new FileInputStream(template.docx); XWPFDocument doc new XWPFDocument(in)) { ListXWPFChart charts doc.getCharts(); if (charts null || charts.isEmpty()) { System.out.println(文档里没有可编辑的原生图表); return; } for (int i 0; i charts.size(); i) { System.out.println(处理第 (i 1) 个图表); replaceChartData(charts.get(i), i); } try (OutputStream out new FileOutputStream(output.docx)) { doc.write(out); } } } }这里有个细节doc.write保存时POI会把XWPFChart关联的嵌入工作簿一并写回docx包所以不需要另外手动保存XSSFWorkbook。如果在这里调用了wb.close()反而可能造成后续部件写不出数据别多此一举。3.2 第二步修改嵌入的Excel数据源chart.getWorkbook()拿到XSSFWorkbook之后剩下的操作和普通Excel读写完全一样。假设我的模板里图表数据长这样季度销售额2024Q11202024Q21502024Q31802024Q4210替换逻辑private static void replaceChartData(XWPFChart chart, int chartIndex) { XSSFWorkbook wb (XSSFWorkbook) chart.getWorkbook(); org.apache.poi.ss.usermodel.Sheet sheet wb.getSheetAt(0); String[] categories {2025Q1, 2025Q2, 2025Q3, 2025Q4}; double[] values {260, 310, 275, 390}; // 假设第一行是表头数据从第二行开始 for (int i 0; i categories.length; i) { int rowIndex i 1; org.apache.poi.ss.usermodel.Row row sheet.getRow(rowIndex); if (row null) { row sheet.createRow(rowIndex); } row.getCell(0, org.apache.poi.ss.usermodel.Row.MissingCellPolicy.CREATE_NULL_AS_BLANK) .setCellValue(categories[i]); row.getCell(1, org.apache.poi.ss.usermodel.Row.MissingCellPolicy.CREATE_NULL_AS_BLANK) .setCellValue(values[i]); } // 更新图表XML中的引用和缓存 updateChartXml(chart, categories, values); }需要注意如果模板里数据区后面还有旧的多余行最好顺手清掉避免图表的引用区域多出空白或者残留点。我一般会把原来用过的数据区域直接清空重建确保数据干净。3.3 第三步更新图表XML中的系列引用和缓存这一步是核心也是大家最容易卡住的地方。拿到CTChartSpace后进入chart → plotArea根据图表类型取出对应的系列数组。这里以最常见的柱状图为例import org.apache.poi.xwpf.usermodel.XWPFChart; import org.openxmlformats.schemas.drawingml.x2006.chart.CTBarChart; import org.openxmlformats.schemas.drawingml.x2006.chart.CTBarSer; import org.openxmlformats.schemas.drawingml.x2006.chart.CTChart; import org.openxmlformats.schemas.drawingml.x2006.chart.CTChartSpace; import org.openxmlformats.schemas.drawingml.x2006.chart.CTPlotArea; import org.openxmlformats.schemas.drawingml.x2006.chart.CTAxDataSource; import org.openxmlformats.schemas.drawingml.x2006.chart.CTNumDataSource; import org.openxmlformats.schemas.drawingml.x2006.chart.CTNumRef; import org.openxmlformats.schemas.drawingml.x2006.chart.CTStrRef; import org.openxmlformats.schemas.drawingml.x2006.chart.CTNumCache; import org.openxmlformats.schemas.drawingml.x2006.chart.CTStrCache; private static void updateChartXml(XWPFChart chart, String[] categories, double[] values) { CTChartSpace chartSpace chart.getCTChartSpace(); CTChart ctChart chartSpace.getChart(); CTPlotArea plotArea ctChart.getPlotArea(); // 一张图可能同时有多个图表类型比如柱子折线的组合图因此用循环处理 for (CTBarChart barChart : plotArea.getBarChartArray()) { for (CTBarSer ser : barChart.getSerArray()) { // 更新分类轴通常是文本季度名 CTAxDataSource cat ser.getCat(); if (cat.isSetStrRef()) { CTStrRef strRef cat.getStrRef(); strRef.setF(Sheet1!$A$2:$A$ (categories.length 1)); CTStrCache strCache strRef.getStrCache(); rebuildStrCache(strCache, categories); } // 更新数值轴 CTNumDataSource val ser.getVal(); if (val.isSetNumRef()) { CTNumRef numRef val.getNumRef(); numRef.setF(Sheet1!$B$2:$B$ (values.length 1)); CTNumCache numCache numRef.getNumCache(); rebuildNumCache(numCache, values); } } } }其中的引用范围字符串要和嵌入xlsx里的工作表名、行列位置严格一致。如果工作表名带空格或特殊字符要加单引号比如My Data!$A$2:$B$5不然Word识别不了。3.4 重建缓存节点的细节缓存节点在XML里长这样c:numRef c:fSheet1!$B$2:$B$5/c:f c:numCache c:formatCodeGeneral/c:formatCode c:ptCount val4/ c:pt idx0c:v260/c:v/c:pt c:pt idx1c:v310/c:v/c:pt c:pt idx2c:v275/c:v/c:pt c:pt idx3c:v390/c:v/c:pt /c:numCache /c:numRef重建缓存的方法要注意两点先清旧的pt再按顺序添加新pt最后设ptCount。代码可以这样写private static void rebuildNumCache(CTNumCache cache, double[] values) { // 清掉旧点 for (int i cache.sizeOfPtArray() - 1; i 0; i--) { cache.removePt(i); } cache.setPtCount(values.length); for (int i 0; i values.length; i) { org.openxmlformats.schemas.drawingml.x2006.chart.CTPt pt cache.addNewPt(); pt.setIdx(i); pt.setV(String.valueOf(values[i])); } } private static void rebuildStrCache(CTStrCache cache, String[] values) { for (int i cache.sizeOfPtArray() - 1; i 0; i--) { cache.removePt(i); } cache.setPtCount(values.length); for (int i 0; i values.length; i) { org.openxmlformats.schemas.drawingml.x2006.chart.CTPt pt cache.addNewPt(); pt.setIdx(i); pt.setV(values[i]); } }这里有个容易踩的坑有的老版本POI里缓存类的removePt方法不可用或者编译不到如果碰到类似问题可以直接用cache.setPtArray(new CTPt[0])把旧数组置空再重新添加。效果一样只是代码看起来没那么优雅。3.5 第四步让嵌入的Excel重新计算并标记全量刷新图表有很多数据不是直接输入而是用了Excel公式比如B2*1.2这种。如果只替换了源单元格而公式没重新算出结果图表缓存就会是旧计算值。可以在替换完数据后显式做一次公式求值wb.getCreationHelper().createFormulaEvaluator().evaluateAll();然后再设置工作簿的calcPr让Word打开文档时强制重新计算一次import org.openxmlformats.schemas.spreadsheetml.x2006.main.CTCalcPr; import org.openxmlformats.schemas.spreadsheetml.x2006.main.CTWorkbook; CTWorkbook ctWorkbook wb.getCTWorkbook(); CTCalcPr calcPr ctWorkbook.isSetCalcPr() ? ctWorkbook.getCalcPr() : ctWorkbook.addNewCalcPr(); calcPr.setFullCalcOnLoad(true);这个fullCalcOnLoad等于告诉打开的Excel/Word别拿缓存当回事打开后把公式全部算一遍。对动态替换数据这种场景特别有用。到这里主体代码就齐了。如果模板里只有简单数据没有公式那个evaluateAll()可以不要但fullCalcOnLoad建议保留它有备无患。4. 常见问题与排查技巧实录4.1 打开Word后图表还是老数据这是问得最多的一个问题。如果代码逻辑都对、数据也进了嵌入工作簿但打开新文件看到的还是旧图表大概率是只改了xlsx没改chart xml里的缓存。Word绘制图表第一优先读的是chart1.xml里的numCache不是xlsx。遇到这种情况先不要怀疑POI直接用压缩软件解开docx检查word/charts/chart1.xml里的numCache值是不是新数据。如果还是旧的说明缓存重建代码没执行成功多检查一下ser.getCat()、ser.getVal()是否真的存在numRef/strRef有些图表类型里可能是numLit字面量数据、strLit处理方法不一样。另外部分第三方阅读器不解析图表XML只显示文档里缓存的预览图片这种属于查看器限制用Word打开计算后通常会正常。4.2 图表数据行数变了坐标轴范围却没有变化改数据前图表引用的区域是1到4季度改完变成1到12个月数据刷新了但横轴只显示4个点这是引用范围没更新到位。解决方案是在更新系列缓存时同时把cat和val的ref字符串改成新的区域。上面代码里已经做了strRef.setF(Sheet1!$A$2:$A$ (categories.length 1)); numRef.setF(Sheet1!$B$2:$B$ (values.length 1));如果这样做了横轴范围还是不对可以进一步查看坐标轴对象里是否写死了maximum、minimum或者majorUnit。OpenXML里坐标轴可能带有手动刻度值如果是这样需要重新计算并更新这些属性或者干脆删掉手动值让Word自动计算。排查方法很简单看CTCatAx或CTValAx里有没有scaling下的手动max/min有就清掉。4.3 图表类型、组合图和多种系列怎么处理不是所有图表都是柱状图。折线图、饼图、面积图、散点图的系列名称不一样。组合图柱状折线的绘图区里同时存在多个图表数组。处理办法是不要只写死一个图表类型而是先打印出plotArea的类型清单再分类型处理CTPlotArea plotArea ctChart.getPlotArea(); System.out.println(bar: plotArea.sizeOfBarChartArray()); System.out.println(line: plotArea.sizeOfLineChartArray()); System.out.println(pie: plotArea.sizeOfPieChartArray());比如折线图系列是CTLineSer饼图是CTPieSer它们的getCat()和getVal()方法名基本一致只是外层类型不同。把同一份缓存重建逻辑抽成公共方法不同类型分别调用即可。散点图略有区别它的x和y都是数值没有cat要改的是xVal和yVal。如果文档里图表类型特别多不想一个个写匹配也可以绕着走只改嵌入xlsx里的数据然后删除chart xml里所有的cache相关子元素让Word打开后强制从引用区域重算。但这个方法不是标准做法某些版本的Word会把缺失缓存看成错误稳妥起见还是按类型重建缓存。4.4 文档里的图表根本不是chart部件而是图片有些图表是别人从Excel复制后“选择性粘贴为图片”贴进来的。这类图表在docx里没有chart部件也没有嵌入xlsx只是一个blip图片。用XWPFDocument.getCharts()可能返回空列表或者拿到的是别的对象。排查方式很简单解压docx看有没有word/charts/chart1.xml没有的话基本不用白费力气写POI替换逻辑。真要改这种图只能重新渲染一张新图片替换原图或者要求文档作者改成原生图表。4.5 运行时报错、依赖冲突和其他零碎问题我在测试过程中遇到过几次NoClassDefFoundError或者XmlException基本都是POI和旧的xmlbeans版本冲突。解决方式是用Maven依赖树排查把项目里老版本的poi、poi-ooxml、xmlbeans统一排除全部走5.2.5。也遇到过修改后Word提示“文件已损坏无法打开”。这种情况绝大多数是图表XML的节点顺序被破坏了。OpenXML对元素顺序有严格要求比如numRef里必须先c:f再c:numCache有的POI在调用addNewPt时可能会在一个本来不存在的顺序位置插入节点。如果出现损坏最直接的办法是打开修复前后的chart1.xml做一次对比看节点顺序差异在哪。我的经验是能用setV覆盖的就不删节点能复用旧节点就复用旧节点少动XML结构文件越稳。现象可能原因处理建议打开图表未变只改了xlsx图表缓存未更新更新chart xml里的numCache/strCache图表显示旧范围引用区域溜号了同步修改cat和val的ref字符串公式结果不对没有重新计算公式evaluateAll fullCalcOnLoad打开报损坏XML节点顺序错乱对比chart1.xml找顺序差异少增删节点图表找不到图表是图片不是原生chart检查word/charts目录是否有chart部件运行时报ClassNotFound依赖版本冲突统一POI版本排除旧xmlbeans5. 最后再补充几个经验图表处理这种需求最忌讳一上来就写代码。建议拿到模板之后先花十分钟把docx后缀改成zip解压出来把charts和embeddings目录里的XML、xlsx都看一遍。看清楚作者的原始图表类型、工作表名称、数据引用区域后面写代码至少省一半时间。另外模板文件如果很多建议做一个批量回归测试用同一个模板生成多个版本分别改不同数据然后用Word逐一打开验证。我这次就遇到过只改一个图表的模板没问题但文档里有多个同类型图表时第二个图表的系列引用忘了更新导致数据串了。图表多的时候最好在代码里打印每个图表的系列名和引用范围人工比对一轮避免漏网之鱼。这个思路还能扩展出不少玩法。既然能拿到XWPFChart底层XML就可以不只是替换数据还能改系列颜色、图例位置、坐标轴标题甚至把柱状图改成折线图的类型。只要把握住“图表XML 嵌入工作簿”双份数据这个核心后面一切操作都顺理成章。