
接手过一堆办公文档自动化需求之后我最大的感受是POI处理Word段落、表格早就很成熟了但一碰到图表Chart资料就会突然变得又少又零散。偏偏“替换Word图表数据并刷新图表”又是招投标、经营分析、季度汇报里最高频的需求客户给一份带图表、带样式的Word模板里面数据是死的你只需要把关键数值换掉让图表跟着更新再输出成一份能直接交付的文档。这里的核心难点在于POI没有提供“一行代码改完图表自动刷新”的API。你表面上看到的是Word里的一个Chart对象实际上背后藏着三样东西一段描述图表结构的XML、一份内嵌的Excel工作簿、还有一份用于渲染的缓存数据。三者不同步图表就不会变化甚至会让Word打开文档时提示损坏。这篇文章我就围绕“如何用POI编辑Word中的chart、替换图表数据并刷新图表”这个主题把背后的原理、可落地的代码以及我踩过的坑一次讲清楚。1. 先把Word里的Chart拆开看看1.1 docx本质是个zipChart只是几段XML很多初学者刚接触Word图表时都会下意识地认为图表是“一张图片”或者“一个独立对象”。这个直觉并不完全正确。docx本质是一个zip压缩包展开之后Word文档里的图表实际上由几个互相引用的部分组成word/document.xml记录图表在正文中的位置和大小以及一个指向图表关系文件的引用。word/charts/chart1.xml描述图表的类型、系列、分类轴、数值轴、引用公式和缓存数据。word/embeddings/Microsoft_Excel_工作表1.xlsx真正存放图表数据源的内嵌Excel工作簿。[Content_Types].xml、word/_rels/document.xml.rels等负责把这些部分关联起来。你在Word里右键图表选择“编辑数据”时看到的那个弹出式Excel窗口本质就是第三部分里的那个xlsx而Word在渲染图表时优先读取的是chart1.xml里写好的缓存值numCache/strCache而不是实时去查Excel。这也就解释了为什么很多人只改了xlsx里的数据保存后重新打开Word图表纹丝不动——因为渲染用的是缓存不是数据源。注意最新版Word确实可以在设置里开启“打开时刷新”之类的选项但你不能指望交付端的用户都会去开这个开关。作为程序我们要做的就是把内嵌Excel数据和chart缓存一并改掉确保无论谁打开文档看到的都是新数据和对应的新图表。1.2 XWPFChart能做什么、不能做什么Apache POI从3.16左右开始提供XWPFChart类它在org.apache.poi.xwpf.usermodel包下可以读取图表的类型、系列、分类、标题也能拿到内嵌工作簿。常见的可用方法包括chart.getCTChartSpace()拿到底层的CTChartSpace对象可以操作图表标题、坐标轴等。chart.getChartSeries()返回图表系列列表。chart.getWorkbookPart()返回内嵌Excel所在的PackagePart。但它并没有提供类似“setCategoryData”“setValues”这种一眼就能看懂的高级接口。网上能找到的demo大多数是“读取”图表信息真正做“替换数据并刷新”的非常少。原因在于POI没有把Excel数据同步到chart缓存这个动作封装成现成方法你必须自己动手去操作两层数据。我先把结论放在这里替换Word图表数据核心就是同时修改embeddings下的xlsx和chart1.xml里的cache节点任何只改一层的方案都会在某个环节出问题。2. 动手前的准备版本、依赖与样例文档2.1 POI版本怎么选POI对Word图表支持是从3.15版本开始逐渐完善的但生产环境我建议直接用4.1.2或5.x系列原因有两个第一3.x时代的XWPFChart接口很不稳定getWorkbookPart()等方法经常缺失避免在版本上折腾。第二POI 4.1.0及更低版本存在XXE漏洞比较知名的是与XSSFExportToXml相关的CVE-2022-26336等多数公司的安全扫描会直接拦截这类旧版本。我平时项目里用的是下面的依赖组合dependency groupIdorg.apache.poi/groupId artifactIdpoi/artifactId version5.2.6/version /dependency dependency groupIdorg.apache.poi/groupId artifactIdpoi-ooxml/artifactId version5.2.6/version /dependency dependency groupIdorg.apache.poi/groupId artifactIdpoi-ooxml-schemas/artifactId version4.1.2/version /dependency注意POI 5.x中poi-ooxml-schemas已经逐渐被poi-ooxml-lite取代但部分chart相关类在lite版本中不完整如果运行时出现NoClassDefFoundError可以显式引入poi-ooxml-full。最省心的方式是保证poi、poi-ooxml、poi-ooxml-schemas或poi-ooxml-full的版本一致避免出现奇怪的类加载问题。2.2 先造一个实验模板建议你动手写代码之前先手工准备一份测试文档用Word或WPS新建一个空白文档插入一个柱状图使用默认示例数据比如“类别1/类别2/类别3”和“系列1/系列2”。保存后用压缩工具打开这份docx先看word/charts/chart1.xml和word/embeddings目录下的xlsx确认结构。然后再写代码替换数据每完成一步都用编辑软件或解压工具验证。这个习惯能帮你快速定位问题到底是代码没改到数据还是改了但没写回还是写回了但被chart缓存覆盖。我带过不少新人发现他们最喜欢跳过这一步直接调线上模板结果出现问题时分不清是代码bug还是模板结构特殊白白浪费时间。3. 核心方案内嵌Excel与图表缓存必须一起改3.1 完整操作流程把整个需求拆开可以分成下面这几个步骤用XWPFDocument读取Word模板。定位文档中的所有图表对象。找到图表对应的内嵌Excel也就是workbookPart用XSSFWorkbook打开。修改Sheet中的单元格数据。把修改后的Excel写回PackagePart。打开图表的CTChartSpace根据图表类型更新series的缓存XML。保存XWPFDocument。这里最关键、也最容易被忽视的是第6步。为什么不能只改series中的numRef引用范围因为如果你的模板结构固定只是数据值变化那保持原引用地址只改cache里的值是最省事、最不容易破坏格式的方式。如果你硬要去动引用范围需要考虑图表数据区域、系列名称、图例项、缓存等一系列联动改造复杂度立刻上去。3.2 如何定位文档里的XWPFChartPOI提供了document.getCharts()方法遍历后会拿到文档中所有XWPFChart。但在部分POI版本里这个方法可能只返回内联图inline如果图表是浮动锚定anchor的就需要自己遍历document.xml中的drawing节点来获取。先给一个最简单的遍历示例try (XWPFDocument document new XWPFDocument(inputStream)) { ListXWPFChart charts document.getCharts(); for (XWPFChart chart : charts) { System.out.println(成功获取图表 chart.getChartSeries().size()); // 在这里处理你的目标图表 } }如果你想定位到指定图表比较实用的方式是给图表命名。Word里选中图表后可以在“图表工具-格式-名称框”里设置图表名称也可以通过代码读取图表标题来判断private String getChartTitle(XWPFChart chart) { CTChart ctChart chart.getCTChartSpace().getChart(); if (!ctChart.isSetTitle()) { return ; } CTTitle title ctChart.getTitle(); if (!title.isSetTx()) { return ; } // 拼接图表标题文本 StringBuilder sb new StringBuilder(); for (CTTextParagraph p : title.getTx().getRichArray()) { for (CTRegularTextRun r : p.getPList()) { sb.append(r.getT()); } } return sb.toString(); }如果遇到需要兼容老版本POI、getCharts()返回为空的情况那就要手动从XWPFParagraph里拉取CTDrawing中的CTGraphicalObjectFrame拿到图表part的关系id再通过document.getRelationById(rId)获取对应part。这个方法兼容性确实更好但代码量会明显增加普通场景下getCharts()已经够用。3.3 替换内嵌Excel里的数据拿到XWPFChart之后通过getWorkbookPart()获取内嵌工作簿的PackagePart再用XSSFWorkbook把它读成完整的内存工作簿修改之后写回。这里我特别提醒一点workbookPart.getOutputStream()返回的输出流在POI内部会替换zip包中对应的entry。也就是说XSSFWorkbook.write(out)这一步无论如何都不能漏。如果只修改内存里的XSSFWorkbook最后保存docx时老数据依然还在这种“写了但没写回”的坑我见太多了。PackagePart workbookPart chart.getWorkbookPart(); XSSFWorkbook workbook new XSSFWorkbook(workbookPart.getInputStream()); XSSFSheet sheet workbook.getSheetAt(0); // 假设模板里第0行是表头数据从第1行开始 XSSFRow row sheet.getRow(1); if (row null) { row sheet.createRow(1); } XSSFCell cell row.getCell(1); if (cell null) { cell row.createCell(1); } cell.setCellValue(12345); // 写回内嵌Excel try (OutputStream out workbookPart.getOutputStream()) { workbook.write(out); } workbook.close();为什么不直接改chart对象里的某个数值字段因为Word图表的数据源本质上就是这份内嵌xlsx只有把它改了用户点击“编辑数据”时才看得到新数据而图表画面上是否更新还要看下一步。3.4 刷新图表缓存XML先用最简单的话解释chart1.xml里的结构。以柱状图为例核心内容大致是下面这个样子c:barChart c:ser c:idx val0/ c:order val0/ c:tx.../c:tx c:cat c:numRef c:fSheet1!$A$2:$A$4/c:f c:numCache c:formatCodeGeneral/c:formatCode c:ptCount val3/ c:pt idx0c:v类别1/c:v/c:pt c:pt idx1c:v类别2/c:v/c:pt c:pt idx2c:v类别3/c:v/c:pt /c:numCache /c:numRef /c:cat c:val c:numRef c:fSheet1!$B$2:$B$4/c:f c:numCache c:ptCount val3/ c:pt idx0c:v4.3/c:v/c:pt c:pt idx1c:v2.5/c:v/c:pt c:pt idx2c:v3.7/c:v/c:pt /c:numCache /c:numRef /c:val /c:ser /c:barChartc:f里面是数据源引用公式c:numCache或c:strCache里面是渲染时真正展示的缓存值。Word打开文档时优先渲染cache里的数据只有在用户“编辑数据”时才会通过f里的引用去连接内嵌Excel。所以正确做法是缓存值同步更新引用公式最好也保持一致这样图表显示新数据用户查看数据源时看到的地图区域也对得上。在POI的XMLBeans模型里这些节点都有对应的类型化Java对象。下面是一个针对柱状图、折线图、饼图等常见图表类型的缓存刷新逻辑骨架我先给柱状图的版本其他类型只是外层list类不同处理方式完全一致private void refreshChartCache(XWPFChart chart) throws Exception { CTChart ctChart chart.getCTChartSpace().getChart(); CTPlotArea plotArea ctChart.getPlotArea(); // 柱状图 for (CTBarChart barChart : plotArea.getBarChartList()) { for (CTBarSer ser : barChart.getSerList()) { if (ser.isSetCat()) { refreshDataSource(ser.getCat(), chart); } if (ser.isSetVal()) { refreshDataSource(ser.getVal(), chart); } if (ser.isSetTx()) { refreshSeriesName(ser.getTx(), chart); } } } // 折线图把CTBarChart换成CTLineChart逻辑一样 // 面积图换成CTAreaChart // 饼图处理CTPieChart的ser }下面的refreshDataSource方法负责解析引用公式从内嵌Excel里读取区域数据再写回cache节点。这里我用到了POI的AreaReference来解析区域引用private void refreshDataSource(CTDataSource dataSource, XWPFChart chart) throws Exception { if (dataSource null) { return; } if (dataSource.isSetNumRef()) { CTNumRef numRef dataSource.getNumRef(); CTNumData cache numRef.getNumCache(); if (cache null) { cache numRef.addNewNumCache(); cache.setFormatCode(General); } String f numRef.getF(); CellReference[] cells parseCellReferences(f); cache.setPtCount(cells.length); try (XSSFWorkbook wb new XSSFWorkbook(chart.getWorkbookPart().getInputStream())) { for (int i 0; i cells.length; i) { CTNumVal pt cache.insertNewPt(i); pt.setIdx(i); XSSFCell cell getCellFromReference(wb, cells[i]); if (cell ! null cell.getCellType() CellType.NUMERIC) { pt.setV(String.valueOf(cell.getNumericCellValue())); } else { pt.setV(0); } } } } // 如果分类轴是字符串类型会走strRef/strCache思路一样 }parseCellReferences这个方法的核心是解析公式里的Sheet名和区域范围。正常的写法是直接构造AreaReference然后用getAllReferencedCells()获取所有单元格。不过如果Sheet名带空格或特殊符号公式里会出现单引号包裹AreaReference直接解析偶尔会出问题稳健的做法是先手动把Sheet名分离出来再构造CellReference或AreaReference。getCellFromReference就是根据CellReference里的Sheet名切换到对应工作表然后取出单元格private XSSFCell getCellFromReference(XSSFWorkbook wb, CellReference ref) { XSSFSheet sheet wb.getSheet(ref.getSheetName()); if (sheet null) { sheet wb.getSheetAt(0); } XSSFRow row sheet.getRow(ref.getRow()); if (row null) { return null; } return row.getCell(ref.getCol()); }这里有一个容易被忽略的细节系列名称c:tx也会在缓存里存一份。如果只改了分类和数值的缓存图例里的“系列1”可能还是旧名字。所以在刷新图表缓存时最好把ser.getTx().getStrRef().getStrCache()也一并处理从引用公式指向的单元格中读取文本并写入缓存。这样图例、数据标签才不会出现新旧混杂的情况。4. 一个能直接跑的完整示例4.1 统一替换模板数据假设模板里已经有一个柱状图图表数据区域是Sheet1!$A$1:$B$4其中第1行是表头第2行到第4行是分类和数值。业务上的需求是每个周期替换这三个数值其他内容不变。写一个方法接收模板路径、输出路径、分类列表和数值列表内部自动完成“替换xlsx数据 刷新chart缓存 保存文档”三步public void replaceChartData(String templatePath, String outputPath, ListString categories, ListDouble values) throws Exception { try (XWPFDocument document new XWPFDocument( Files.newInputStream(Paths.get(templatePath)))) { ListXWPFChart charts document.getCharts(); for (XWPFChart chart : charts) { // 当模板里有多个图表时可以按标题或名称过滤 updateEmbeddedWorkbook(chart, categories, values); refreshChartCache(chart); } try (FileOutputStream fos new FileOutputStream(outputPath)) { document.write(fos); } } }updateEmbeddedWorkbook负责把新的数据写入内嵌Excel的固定区域private void updateEmbeddedWorkbook(XWPFChart chart, ListString categories, ListDouble values) throws Exception { PackagePart workbookPart chart.getWorkbookPart(); try (XSSFWorkbook workbook new XSSFWorkbook(workbookPart.getInputStream())) { XSSFSheet sheet workbook.getSheetAt(0); int startRow 1; for (int i 0; i categories.size(); i) { XSSFRow row sheet.getRow(startRow i); if (row null) { row sheet.createRow(startRow i); } XSSFCell catCell row.getCell(0); if (catCell null) { catCell row.createCell(0); } catCell.setCellValue(categories.get(i)); XSSFCell valCell row.getCell(1); if (valCell null) { valCell row.createCell(1); } valCell.setCellValue(values.get(i)); } try (OutputStream out workbookPart.getOutputStream()) { workbook.write(out); } } }refreshChartCache核心逻辑见上一节。有一点需要再次强调如果你的模板里同时存在柱状图、折线图、饼图需要分别处理getBarChartList()、getLineChartList()、getPieChartList()。这几个方法名字类似返回的类型不同但处理模式几乎一模一样。我实际开发时会把“刷新一个ser”的逻辑抽成公共方法再写几个薄封装避免大量复制粘贴。4.2 保存前必须确认的事保存文档时注意不要用document.write()去覆盖正在被读取的源文件最好输出到一个新文件。生成之后不要只看程序不报错就认为完成要按下面三步做验证用压缩工具打开输出的docx查看word/charts/chart1.xml里的缓存值是否已经更新。用Word或WPS直接打开文档看图表是否显示新数据。右键图表选择“编辑数据”看弹出的Excel里数据是否同步。这三步验证对应了三个环节XML渲染缓存、图表最终显示、内嵌数据源。任何一个不对都说明还有某层数据没有改到位。5. 常见问题与排查技巧实录5.1 改了Excel数据图表为什么纹丝不动这是最常见的坑原因十有八九是只改了内嵌Excel没有刷新chart XML中的缓存。解决办法就是上面的refreshChartCache把缓存数据同步更新。如果已经写了刷新逻辑但图表还是不变就要检查保存链路是否正确。一个容易被忽略的点是XSSFWorkbook对象用完后有没有关闭workbookPart.getOutputStream()的输出流有没有关闭。如果流没有正常关闭part数据可能没有被真正替换。5.2 打开文档提示“图表不可用”或文件损坏这种情况通常是chart XML与内嵌Excel产生了严重的不一致。最常见的诱因是模板里的数据区域被动态缩短或增加了行但chart XML里引用的范围和缓存数量没有同步。比如你原模板引用A1:B4现在业务只需要两行数据你把Excel里第3行删除后chart XML还记录着A1:B4Word打开时找不到完整区域自然报错。我的建议是模板里预留固定大小的数据区域多余的行政空置不删除代码只改有数据的单元格。这样图表的引用区域、系列数量都不会变稳定性最高。如果确实需要动态改变数据区域大小就需要把引用公式f和缓存节点一起更新复杂度会明显提升。5.3 柱状图、折线图、饼图刷新逻辑有什么差异图表的Java类型不同但处理模式一样。柱状图用CTBarChart折线图用CTLineChart饼图用CTPieChart它们都包含getSerList()方法。区别在于饼图一般只有一个系列而且没有分类轴数值轴之分依然是有cat和val两个数据源。需要注意的是散点图、气泡图、雷达图的XML结构更复杂比如气泡图的体积字段、雷达图的分类轴特殊结构常规的刷新方法不一定适用。真遇到这些特殊类型建议先解压文档看一遍chart1.xml再决定怎么处理。5.4 poi-ooxml-lite缺类导致ClassNotFoundPOI 5.x默认使用poi-ooxml-lite精简schema部分chart相关的类不在lite范围内。运行时报NoClassDefFoundError时可以考虑引入完整的poi-ooxml-full。这个依赖体积比较大但处理复杂图表时需要它建议在使用chart功能时优先用full包省去排查依赖的麻烦。5.5 不要忽略POI版本的安全问题前面提过POI 4.1.0及以下版本存在XXE漏洞这个漏洞与XSSFExportToXml有关。如果你的项目需要处理来自外部的不可信文档或者公司安全扫描会把旧版本列入黑名单建议趁早升级POI版本。做这个功能时顺便升一下版本本质上也是降低项目风险。5.6 文档里有几十个图表性能顶不住怎么办一个大型Word里如果嵌了几十个图表每个图表都对应一份内嵌xlsx频繁用XSSFWorkbook打开关闭确实会让处理时间成倍上升。优化方向有三个只处理目标图表按图表标题或名称过滤。一次拿到workbookPart多个series共用一个XSSFWorkbook实例读取数据避免重复读取。如果只是替换少量单元格且模板结构固定可以不加载完整xlsx用更底层的解析方式读取单元格。但这种方式实现成本高普通场景不划算。6. 备选方案用poi-tl或交给模板引擎6.1 什么时候应该考虑模板引擎如果你的场景是“同一个固定模板 不同数据源 批量生成几十或上百份Word”用原生POI一步步替换图表也不是不行但代码量会比较大维护起来也累。这个时候可以考虑用poi-tl这类Word模板引擎它提供了图表绑定插件把数据源、分类、系列等信息直接绑定到模板中的图表变量上使用体验会好很多。但需要理解的是poi-tl图表插件的底层并没有完全绕开POI的机制它处理图表时同样要面对“内嵌Excel chart缓存”的结构。遇到模板引擎不支持的图表类型或者特殊绑定需求时你最终还是得回到原生POI来手动处理。6.2 从“替换数据”升级到“动态增删系列”有些报表会要求每期动态增加一条产品线、一个分公司。这种场景下仅仅替换数值是不够的需要在xlsx里追加行或列同时还要在chart XML中新增一个系列节点并且补齐idx、order、tx、cat、val等全套子节点。这个过程比“替换数据”高一个量级。如果业务上能接受“预留最大系列数”的方案比如数据量最多10个就在模板里一次性生成10个系列用不到的就留空或填0代码里只更新有数据的系列。这样能避免动态增删节点带来的复杂度和不稳定因素。6.3 能不能让Word打开时强制刷新理论上存在一种思路只改内嵌Excel数据不更新chart缓存然后修改某种配置让Word打开时强制刷新。我也在本地测试过但这个配置在不同Office版本中的行为不稳定WPS和Microsoft Office的表现也不完全一致所以不建议作为生产环境的首选方案。最稳妥、最能保证交付质量的仍然是按照本文的方法把内嵌Excel和chart缓存一并更新到位。我个人的体会是这类需求真正难的不是写代码而是理解文档结构。你只要清楚chart XML里的引用是f、渲染数据是cache、数据源是embeddings里的xlsx这个需求就已经解决了八成。剩下的事无非是根据业务规则把数据填到对应位置而已。最后分享一个我一直在用的小技巧接到这类需求时先别急着写代码花十分钟打开docx内部看一眼结构。不同模板的图表的组织方式可能有差异但只要先确认了Sheet名、数据区域、series数量和图表类型后面的代码写起来就会非常顺。这个习惯帮我少踩了非常多的坑也希望对你有所帮助。