
做报表导出的人大概都遇到过这种需求系统里生成了一份Word里面有一张现成的柱状图或饼图业务方要求把图里的数据替换成数据库查出来的最新值然后批量输出几十个文件。用Apache POI操作Word的文本、表格都很成熟唯独图表这块资料少、坑多很多人卡在“数据替换了但打开Word看到的还是旧图”这一步甚至直接把文件改坏。这篇文章我就把用POI编辑Word中chart、替换图表数据并刷新图表的完整路径讲清楚。核心思路不复杂但必须理解Word图表的存储机制——它不像普通文本那样改一个节点就能生效而是同时存在“缓存数据”和“外部数据源”两份数据处理不好就会“你改了但没完全改”。1. Word图表到底是什么从docx文件结构说起1.1 docx就是一个zip图表散落在几个部件里很多人以为Word中的图表是“画”在文档里的矢量图改一改XML定义就行。其实Word图表是几个Office部件联合工作的结果。拿一个最简单的柱状图docx举例用解压工具打开后和图表相关的文件大致是这些word/document.xml // 文档主体引用图表 word/charts/chart1.xml // 图表的核心定义类型、系列、格式、数据 word/charts/_rels/chart1.xml.rels // 图表到嵌入Excel的关联 word/embeddings/Microsoft_Excel_工作表1.xlsx // 图表的“数据源真相” word/_rels/document.xml.rels // 文档到图表的关联其中chart1.xml描述图表的样式和结构而真正的原始数据存放在embeddings下的xlsx工作簿里。这两个部分通过关系文件关联起来。1.2 chart XML里的“双份数据”陷阱打开chart1.xml你会看到一个典型的数据系列结构c:ser c:cat c:strRef c:fSheet1!$A$2:$A$6/c:f c:strCache c:ptCount val5/ c:pt idx0c:v1月/c:v/c:pt c:pt idx1c:v2月/c:v/c:pt c:pt idx2c:v3月/c:v/c:pt c:pt idx3c:v4月/c:v/c:pt c:pt idx4c:v5月/c:v/c:pt /c:strCache /c:strRef /c:cat c:val c:numRef c:fSheet1!$B$2:$B$6/c:f c:numCache c:formatCodeGeneral/c:formatCode c:ptCount val5/ c:pt idx0c:v42.6/c:v/c:pt c:pt idx1c:v51.3/c:v/c:pt c:pt idx2c:v38.9/c:v/c:pt c:pt idx3c:v63.2/c:v/c:pt c:pt idx4c:v55.7/c:v/c:pt /c:numCache /c:numRef /c:val /c:ser注意两点c:strRef和c:numRef中的c:f指向嵌入的Excel工作表的单元格区域c:strCache和c:numCache是当前图表“正在显示”的数据副本。Word渲染图表时默认优先读取缓存中的数值。但很多情况下Word也会根据chart1.xml里的外部数据源引用在打开文档时从嵌入的Excel重新加载数据。这就导致一个问题如果你只改了缓存里的c:v值没有改嵌入的Excel那么Word打开时可能用Excel里的旧数据把缓存覆盖回去你看到的还是老数据。2. POI版本选择与依赖清单别用低版本踩雷2.1 为什么不建议用POI 4.1.0及以下Apache POI从3.x时代就零零散散支持读取图表真正能通过API定位到Word中的图表要到POI 4.1.x以后。但这里有个安全上的硬指标POI 4.1.0及更早版本在处理XSSFExportToXml时存在已公开的XXE漏洞如果你们公司的安全扫描比较严格扫描到等于直接返工。所以建议直接用5.x的最新稳定版我目前用的是5.2.3。2.2 Maven依赖配置图表操作除了基础的poi-ooxml还需要poi-ooxml-full因为图表相关的一些XMLBeans类在full包里才能找到。我的pom里是这样配的dependencies dependency groupIdorg.apache.poi/groupId artifactIdpoi-ooxml/artifactId version5.2.3/version /dependency dependency groupIdorg.apache.poi/groupId artifactIdpoi-ooxml-full/artifactId version5.2.3/version /dependency !-- 如果项目还在用log4j2记得加桥接包否则POI会提示找不到日志实现 -- dependency groupIdorg.apache.logging.log4j/groupId artifactIdlog4j-core/artifactId version2.20.0/version /dependency /dependencies版本统一很重要POI内部的包如果版本冲突最常见的报错是NoClassDefFoundError: org/openxmlformats/schemas/drawingml/x2006/chart/CTChartSpace一查全是jar包互相踩踏。2.3 POI对图表的支持边界POI的XWPFDocument提供了getCharts()方法可以拿到文档中的所有XWPFChart。XWPFChart下有getCTChartSpace()返回的是CTChartSpace对象这个对象对应的就是chart1.xml的Java类视图。但注意POI目前并没有提供“一键更新图表数值”的高层API。XWPFChart能让你获取到图表信息、关系、标题但“修改series的值”这种操作需要自己操作CTChartSpace底层的XML对象。这就意味着我们必须对OpenXML图表结构有一定了解但不用害怕下面我会把最通用的一条路走通。3. 模板准备与图表定位先做一个可用的图表模板3.1 模板怎么做最省事别用代码去生成图表那是自讨苦吃。最省事的方式是打开Word插入你最终需要的图表类型柱状图、折线图、饼图都可能在图表自带的Excel里填好示例数据和真实数据的行列结构保持一致保存为docx文件作为后续操作的模板。这样一个模板中的图表就同时包含了chart XML和嵌入的xlsx。后续要替换数据只需要把“值”换成动态的即可。3.2 通过POI定位并检查图表读取并定位图表的代码非常简单import org.apache.poi.openxml4j.opc.OPCPackage; import org.apache.poi.xwpf.usermodel.XWPFChart; import org.apache.poi.xwpf.usermodel.XWPFDocument; import java.io.FileInputStream; public class LocateCharts { public static void main(String[] args) throws Exception { try (XWPFDocument doc new XWPFDocument(new FileInputStream(template.docx))) { for (XWPFChart chart : doc.getCharts()) { System.out.println(发现图表: chart.getPartName()); // 打印chart XML内容方便排查结构 System.out.println(chart.getCTChartSpace().xmlText()); } } } }运行后能看到图表部件名和完整的Chart XML。这一步非常关键你必须先搞清楚自己的模板里有多少个图表、每个图表的series结构是什么样的才能决定后面的替换策略。3.3 多图表场景怎么区分如果一个Word模板里有多个图表比如封面一张趋势图、正文一张占比图你需要区分它们。简单的方法是通过图表标题for (XWPFChart chart : doc.getCharts()) { String title chart.getChartTitle(); System.out.println(title); }如果模板里的图表没有标题也可以根据chart.getPartName().toString()来区分一般按顺序是chart1.xml、chart2.xml。提前把“业务含义-图表部件名”做一个映射表写死在代码里更可靠。4. 替换图表数据的核心逻辑缓存和嵌入Excel必须同步更新4.1 替换策略的选择我试过两种常见的替换方式只改chart XML里的cache简单但极不稳定。如果模板里存在外部数据源引用Word打开时可能用Excel里的旧数据把缓存覆盖回去导致白改。只改嵌入的Excel数据源改完之后打开Word大部分情况下图表不会自动刷新因为当前显示用的缓存还是旧的。所以正确且稳妥的做法是同时修改chart XML中的缓存数据和嵌入Excel中的数据并把外部数据源的自动更新关掉。这样“所见即所得”而且用户双击图表看数据时数据源也是新的。4.2 更新chart XML缓存用XPath方式做到图表类型无关不要针对CTBarChart、CTLineChart写一堆if-else直接用XPath遍历所有c:numCache和c:strCache与图表类型无关扩展性最好。步骤如下获取CTChartSpace通过selectPath找到所有c:numCache或c:strCache根据传入的数据列表按顺序替换c:pt中的c:v。下面是一段可运行的完整代码支持替换分类名称和数值import org.apache.poi.xwpf.usermodel.XWPFChart; import org.openxmlformats.schemas.drawingml.x2006.chart.CTChartSpace; import org.openxmlformats.schemas.drawingml.x2006.chart.CTNumData; import org.openxmlformats.schemas.drawingml.x2006.chart.CTNumVal; import org.openxmlformats.schemas.drawingml.x2006.chart.CTStrData; import org.openxmlformats.schemas.drawingml.x2006.chart.CTStrVal; import org.apache.xmlbeans.XmlObject; import java.util.List; public class ChartCacheReplacer { private static final String CHART_NS http://schemas.openxmlformats.org/drawingml/2006/chart; /** * 替换图表中所有数值缓存的数据 * 注意dataList的索引顺序与chart XML中series定义顺序一致 */ public static void replaceNumCache(CTChartSpace chartSpace, ListDouble dataList) { // 选取所有 c:numCache 节点 XmlObject[] numCaches chartSpace.selectPath( declare namespace c CHART_NS .//c:numCache); int cacheIndex 0; for (XmlObject obj : numCaches) { CTNumData numData (CTNumData) obj; // 每个cache可能有多个pt也可能是多个series共享一个cache结构 // 这里按顺序填充第一个cache通常对应第一个数值系列 if (cacheIndex dataList.size()) { break; } CTNumVal[] pts numData.getPtArray(); for (int i 0; i pts.length cacheIndex dataList.size(); i) { pts[i].setV(String.valueOf(dataList.get(cacheIndex))); } } } /** * 替换图表中所有字符串分类缓存 */ public static void replaceStrCache(CTChartSpace chartSpace, ListString categoryList) { XmlObject[] strCaches chartSpace.selectPath( declare namespace c CHART_NS .//c:strCache); int cacheIndex 0; for (XmlObject obj : strCaches) { CTStrData strData (CTStrData) obj; CTStrVal[] pts strData.getPtArray(); for (int i 0; i pts.length cacheIndex categoryList.size(); i) { pts[i].setV(categoryList.get(cacheIndex)); } } } }这里有一个坑需要注意selectPath返回的所有c:numCache可能不止一个比如饼图有“数据标签”缓存、图例缓存等。所以我们在代码里用了cacheIndex来控制填充顺序只填充需要替换的数据系列。如果你的图表结构复杂建议先打印chart.xml看清楚再写匹配规则。4.3 更新嵌入的Excel数据源避免旧数据回灌接下来处理嵌入的xlsx。找到XWPFChart关联的Excel Part读取其中的工作表更新对应单元格然后写回Part。关键点先要把Excel Part的数据读到字节数组关闭输入流改完以后再拿到OutputStream写回。不要边读边写同一个Part否则POI在保存时会抛“并发修改”或者文件损坏。import org.apache.poi.openxml4j.opc.PackagePart; import org.apache.poi.ss.usermodel.Cell; import org.apache.poi.ss.usermodel.Sheet; import org.apache.poi.xssf.usermodel.XSSFWorkbook; import org.apache.poi.xwpf.usermodel.XWPFChart; import org.apache.poi.util.IOUtils; import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; import java.io.InputStream; import java.io.OutputStream; import java.util.List; public class EmbeddedExcelUpdater { public static void updateEmbeddedWorkbook(XWPFChart chart, ListString categories, ListDouble values) throws Exception { for (org.apache.poi.ooxml.POIXMLRelationPart rp : chart.getRelationParts()) { PackagePart part rp.getPart(); // 图表关联的工作簿通常是xlsx类型 if (part.getContentType().equals( application/vnd.openxmlformats-officedocument.spreadsheetml.sheet)) { // 先整读关闭输入流再修改 byte[] bytes IOUtils.toByteArray(part.getInputStream()); try (XSSFWorkbook workbook new XSSFWorkbook(new ByteArrayInputStream(bytes))) { Sheet sheet workbook.getSheetAt(0); // 按模板约定分类从A2开始数值从B2开始 for (int i 0; i categories.size(); i) { Cell catCell sheet.getRow(i 1).getCell(0); if (catCell null) { catCell sheet.getRow(i 1).createCell(0); } catCell.setCellValue(categories.get(i)); } for (int i 0; i values.size(); i) { Cell valCell sheet.getRow(i 1).getCell(1); if (valCell null) { valCell sheet.getRow(i 1).createCell(1); } valCell.setCellValue(values.get(i)); } // 写回字节数组再写入part ByteArrayOutputStream bos new ByteArrayOutputStream(); workbook.write(bos); workbook.close(); try (OutputStream partOut part.getOutputStream()) { partOut.write(bos.toByteArray()); } } break; } } } }这里我写死“分类从A列数值从B列”实际项目中建议从chart XML中解析出c:f的引用区域比如$B$2:$B$6再动态写入对应单元格。解析区域引用也不难用CellRangeAddress.valueOf(reference)即可这里不再赘述。4.4 关闭autoUpdate锁定当前缓存在更新完缓存和Excel后还需要处理chart1.xml中可能存在的c:externalData节点。这个节点有一个autoUpdate属性如果为trueWord打开时会根据外部Excel重新计算图表缓存把我们刚刚写好的缓存覆盖掉。所以要在保存前把这个属性改成falseimport org.openxmlformats.schemas.drawingml.x2006.chart.CTChartSpace; import org.openxmlformats.schemas.drawingml.x2006.chart.CTExternalData; public static void disableAutoUpdate(CTChartSpace chartSpace) { CTExternalData externalData chartSpace.getExternalData(); if (externalData ! null) { externalData.setAutoUpdate(false); } }如果chart XML里没有externalData节点说明图表数据完全来自缓存就不需要处理。4.5 完整调用示例把上面的方法组合起来一个简单的完整流程如下import org.apache.poi.openxml4j.opc.OPCPackage; import org.apache.poi.xwpf.usermodel.XWPFChart; import org.apache.poi.xwpf.usermodel.XWPFDocument; import java.io.FileOutputStream; import java.util.Arrays; import java.util.List; public class WordChartReplacerDemo { public static void main(String[] args) throws Exception { ListString categories Arrays.asList(一月, 二月, 三月, 四月, 五月); ListDouble values Arrays.asList(88.5, 92.3, 79.1, 100.2, 95.8); try (XWPFDocument doc new XWPFDocument( OPCPackage.open(template.docx))) { for (XWPFChart chart : doc.getCharts()) { // 1. 替换chart XML缓存 ChartCacheReplacer.replaceStrCache(chart.getCTChartSpace(), categories); ChartCacheReplacer.replaceNumCache(chart.getCTChartSpace(), values); // 2. 关闭自动更新 disableAutoUpdate(chart.getCTChartSpace()); // 3. 更新嵌入的Excel数据源 EmbeddedExcelUpdater.updateEmbeddedWorkbook(chart, categories, values); } // 4. 保存 try (FileOutputStream out new FileOutputStream(output.docx)) { doc.write(out); } } System.out.println(完成); } }注意doc.write之后一定要关闭流否则POI可能不会把所有部件写全。5. 刷新图表为什么数据改了Word还是显示旧图5.1 Word到底什么时候“刷新”图表Word文档里的图表不是一张位图它更像一个“带数据记忆的交互对象”。打开docx时Word会按下面的优先级处理数据默认情况下先用chart XML中的缓存numCache/strCache把图表画出来如果发现externalData且autoUpdate1会用嵌入的Excel数据重新计算缓存并重新渲染如果Excel中引用的区域变了但缓存没变且autoUpdate是0则图表还是显示缓存的老样式。所以“刷新”这个动作其实取决于你是否同时把“显示层”和“数据层”都改对了。把缓存改了、外部Excel也改了、autoUpdate关掉了图表打开就是新的。如果只改了外部Excel而缓存没改图看起来还是旧的必须手动在Word里点“全部刷新”才变。这就是很多人在POI里改完数据后打开Word发现“没刷新”的本质原因。5.2 关于frozen和cached的误区你可能会在网上看到有人提到“设置frozen”、“删除cached节点”等操作。这些主要来自PowerPoint图表或旧版GraphChart的机制。对Word里的现代ChartSpace来说控制显示数据的核心还是cache节点本身。你可以简单理解cache就是当前渲染用的“快照”Word的反向刷新机制如果被触发会从数据源重算快照。所以我们只要保证快照缓存正确数据源Excel正确不要触发自动重算autoUpdatefalse。最终效果就是“打开即刷新”。5.3 如果打开Word时弹出“此工作簿包含到其他数据源的链接”这是因为docx里依然保留着对嵌入Excel的引用。对最终交付的报表文件来说通常不需要再动态编辑这个提示会让用户困惑。我们可以通过删除c:f引用和externalData节点来彻底断开数据源链接但这样做有风险如果Ref节点和Cache节点互相引用不一致文件可能会报损坏。我的建议是在开发阶段保留数据源链接方便调试正式上线前再跑一遍“数据源剥离”逻辑把autoUpdate关掉并删除externalData节点这样用户打开就干净了。如果担心文件损坏至少先测试通过再上线。6. 避坑实录我在实际项目中踩过的那些坑6.1 坑一只改缓存打开后图表还是旧数据这是我犯过的第一个错。当时用DOM方法把numCache里的值全替换了生成文件后本地打开一看图表纹丝不动。排查后发现模板里externalData的autoUpdate是trueWord在打开时自动从嵌入Excel把旧数据重新灌了回来。解决方式就是前面说的同步更新外部Excel并把autoUpdate设为false。从那以后我的替换逻辑里这三步就固定下来了。6.2 坑二打开Word提示“文件损坏是否修复”多半是修改XML时破坏了结构。最容易出问题的是c:ptCount没有跟着更新。比如原来有5个点你把某几个pt节点删了或者改了值但ptCount还写着5Word解析时就认为节点数不匹配。要么保持点数不变只改值要么在删除/增加节点时同步更新ptCount。一般业务场景都是“模板点位固定只改值”所以不需要动节点数。6.3 坑三不同图表类型的系列名不同用getBarChartArray()的方式只能处理柱状图遇到折线图、饼图就要写重复代码。我后来统一改用selectPath遍历numCache/strCache让替换逻辑和图表类型解耦。它在处理普通柱状图、折线图、面积图时都很稳定饼图要注意多个series场景的数据条目顺序。6.4 坑四嵌入的Excel有些不是xlsx而是xls一般Office 2010创建的图表嵌入工作簿都是xlsx。但如果你拿到的模板是从老版本WPS或Office 2007处理的嵌入工作簿可能是xls。这个时候XSSFWorkbook会直接抛异常。稳妥做法是先判断content type如果是xls就改用HSSFWorkbook处理或者让业务方统一模板版本。6.5 坑五误用了POI 4.1.0的低版本库某次环境里同事用了4.1.0运行某种涉及导出XML的操作时被安全扫描工具拦下来了。后来把版本升到5.x一切正常。这里不是在说某个具体漏洞的技术细节而是提醒所有做文档生成的同学能升级就升级别在POI版本上省事安全评审能给你省出一周工作量。6.6 坑六save后图表的数字格式丢了有些图表模板里数值有小数位、千分符直接改c:v不会影响formatCode但如果误把numCache里的formatCode节点删了Word会默认成“常规”格式显示成一大串小数。所以修改的时候只动pt节点的v不要动其他结构。6.7 坑七多个图表的顺序不稳定在同一个文档里doc.getCharts()的返回顺序未必和文档中出现顺序严格一致但绝大多数情况下是按部件名chart1.xml、chart2.xml排序的。如果你靠“第一个图表是地区分布、第二个图表是趋势”这种方式硬编码模板一旦被调整顺序就会错位。建议给图表加上标题用标题来路由。7. 封装建议把图表替换做成一个通用服务项目里如果只是偶尔替换一两次直接写个Demo脚本就够了。但如果你们的系统要批量生成报告我建议把上面这些逻辑封装成一个独立的ChartReplacer服务输入参数包括Word模板路径图表标识标题或部件名分类数据列表数值数据列表支持多系列输出路径。内部统一走“替换缓存 → 更新嵌入Excel → 关autoUpdate → 保存”这条链路对外暴露一个干净的方法。另外如果你们团队用poi-tl做Word模板引擎也可以把图表替换做成插件或者后置处理步骤。先让poi-tl渲染文本、表格再用我上面这套逻辑处理图表两个工具各管各的互相不干扰。从业务价值来看这套能力让“动态图表报表”从手工复制粘贴变成了全自动API调用一次开发长期受益。唯一需要你花时间的是把模板里的图表结构摸清楚然后把替换逻辑调试稳定。一旦跑通后续换图表类型、加系列都是小改动。