HotXLS 把 Excel 瀑布图和树状图写成独立的 cx:chartSpace 部件——xl/charts/chartExN.xml,带内容类型 application/vnd.ms-office.chartex+xml——并用 mc:AlternateContent 包装器把它们锚进工作表,让 Excel 2016 及以后版本渲染图表,而更老的读取器拿到一个 fallback 图形。选 xlsxChartWaterfall 或 xlsxChartTreemap,整条部件流水线就跟着走
这类问题通常以一张支持工单的形式宣告自己。客户发来一个财务团队在 Excel 2019 里做的工作簿,你的报表工具打开它、保存它,瀑布图就没了。没有异常、没有警告、日志里什么都没有——图表就是不在输出里。如果你想在经典图表代码路径里找它,你会找很久,因为图表一开始就不在那里
为什么瀑布图和树状图不在 c:chartSpace 里?
因为微软没法把它们放进去。ECMA-376 Part 1 §21.2 的 DrawingML 图表 schema 把它的图表元素——c:barChart、c:lineChart、c:pieChart 等等——枚举成一个封闭的 choice,所以在 Excel 2016 里加瀑布图、树状图、旭日图和漏斗图,就意味着打破一个所有既有读取器都依赖的 schema。新类型进了另一个平行的文档:2014 chartex 命名空间(http://schemas.microsoft.com/office/drawing/2014/chartex)里的 cx:chartSpace 部件,与经典图表并排存放,但结构上和它们毫无关系。绘图侧用 mc:AlternateContent——ECMA-376 Part 3 的兼容机制——把两个世界缝在一起:mc:Choice 分支声明 Requires="cx1" 并持有一个 xdr:graphicFrame,其 a:graphicData/@uri 是 chartex 命名空间,负载是一个裸的 <cx:chart r:id="rIdN"/> 引用;mc:Fallback 则持有 Excel 自己写在那里的提示矩形。不理解 cx1 的读取器按 Part 3 的契约必须走 fallback
关系类型是另一个线索。经典图表用标准 OOXML chart 关系挂在 drawing 部件上;chartEx 部件改用 http://schemas.microsoft.com/office/2014/relationships/chartEx,见 [MS-ODRAWXML] 文档。HotXLS 把两个世界都暴露成常量——XlsxRtChartEx 是那个关系、XlsxCtChartEx 是内容类型、XlsxNsMsChartEx 和 XlsxNsMsChartEx2015 是锚点需要的两个命名空间——而谓词 XlsxChartIsChartEx 负责在保存时把图表路由到扩展路径。组合图表和保留下来的经典图表 XML 走另一条,见关于保留图表 XML 与组合图表的文章
决定 Excel 能否打开文件的四条 schema 规则
chartex schema 在四个具体位置比经典 schema 更严格,每一条弄错都会弹出同一个毫无帮助的修复提示。HotXLS 里的 BuildChartExKnownXml 和 BuildChartExRelsXml 把四条都内置了,但如果你要手工检查 XML,它们值得知道
- 顺序。
cx:chartData在文档里必须位于cx:chart之前。这与经典图表建立的直觉正好相反——经典图表里缓存住在绘图区里的系列内部;而在 chartEx 部件里数据放在前面自己的块中,图表主体只按 id 引用它 - 基数。
cx:plotArea只能包含恰好一个cx:plotAreaRegion。不是零个,不是两个。这里没有组合图表的戏份——一个区域,系列在它里面 - 类型放置。图表类型根本不是元素名。它是
cx:series上的layoutId属性,取值来自 Microsoft chartex schema 里的ST_SeriesLayout枚举。XlsxChartExLayoutId把 HotXLS 枚举映射到那个 token,返回'waterfall'或'treemap';XlsxChartExLayoutIdToType在读取时反向映射 - 经典图表从不需要的关系。
cx:chartData持有一个<cx:externalData r:id="rId1"/>元素,这个关系必须存在于xl/charts/_rels/chartExN.xml.rels,指回拥有数据的 worksheet,因为 Excel 用它解析cx:f区域公式。漏掉 rels 部件,图表会空着打开,且没有任何东西解释原因
在 Delphi 里怎么添加瀑布图或树状图?
和你在 HotXLS 里添加任何其他图表的方式一样——扩展类型是 TXLSXChartType 的成员,所以 TXLSXWorksheet.AddChart 直接接受它们,下游一切以枚举值为键。没有另一套 chartEx API 要学
var
Book: TXLSXWorkbook;
Sheet: TXLSXWorksheet;
Chart: TXLSXChart;
begin
Book := TXLSXWorkbook.Create;
try
Book.Open('cashflow.xlsx');
Sheet := Book.Sheets[0];
// 锚定矩形用行/列坐标表示,与经典图表相同
Chart := Sheet.AddChart(xlsxChartWaterfall, 'Q3 Cash Flow',
2, 5, 20, 12);
Chart.AddSeries('Movement', 'Sheet1!$A$2:$A$9', 'Sheet1!$B$2:$B$9');
Book.SaveAs('cashflow-waterfall.xlsx');
finally
Book.Free;
end;
end;
保存时 HotXLS 看到 XlsxChartIsChartEx 返回 True,就从独立的 chartEx 计数器分配部件名,写出 xl/charts/chartEx1.xml、它的 rels 文件、内容类型覆盖,以及由 XlsxChartExAnchorPayloadXml 生成的 mc:AlternateContent 锚点。独立计数器很重要:一个有两个经典图表和一个瀑布图的工作簿得到 chart1.xml、chart2.xml 和 chartEx1.xml,而不是 chartEx3.xml。树状图和瀑布图的差别在两个细节上,都由同一个 builder 处理。数值维度的类型变了——瀑布图的 cx:numDim type="val" 对树状图变成 type="size",因为树状图矩形用面积而不是绘图值编码数值大小——而且树状图通常想要一个分类层级,模型通过 TXLSXChartSeries.CategoriesMultiLevel 表达。设置那个标志后,S.ResolveCategoryLevels 在写入时从 worksheet 读区域,把它拆成 S.CategoryLevelCount 和 S.CategoryLevels[] 后面的每级一个 TXLSXChartDataCache,BuildChartExLevelXml 在单个 cx:strDim 里每级发一个 cx:lvl,叶子级在前——这正是 ECMA-376 Part 1 §21.2 对 c:multiLvlStrCache 用的物理顺序,所以这个约定被继承下来,尽管标记语言一点都没继承
Chart := Sheet.AddChart(xlsxChartTreemap, 'Revenue by Region',
2, 8, 24, 16);
S := Chart.AddSeries('Revenue',
'Sheet1!$A$2:$B$40', // 两列:叶子标签,然后是父分组
'Sheet1!$C$2:$C$40');
S.CategoriesMultiLevel := True;
打破经典图表一切假设的缓存形状
如果你写过经典图表缓存读取器,有三个习惯会在 chartEx 部件上背叛你,而三个都来自同一个设计决策:chartex 把 DrawingML 放在子元素里的东西放进属性与元素内容
- 一个缓存点是
<cx:pt idx="0">42</cx:pt>。值就是元素内容。没有c:v子元素可以下去,所以一个找v节点走的解析器什么也找不到,报告空缓存 ptCount和formatCode是cx:lvl上的属性。在经典c:numCache里两者都是子元素- 多个层级就是单个
cx:strDim里的多个cx:lvl元素。没有像c:multiLvlStrCache之于c:strCache那样的独立多级缓存元素
BuildChartExLevelXml 写这个形状、ParseChartExXml 读回来,输出时每个值都过一遍 XlsxEscapeText,所以含 & 号或尖括号的分类标签能扛过往返。值得注意 builder 在数值级没有格式码时的做法:它写 formatCode="General" 而不是丢掉属性,因为这里的属性不像经典对应物那样可选
让类型回读失败的命名空间前缀
HotXLS chartEx 解析器的第一版写出完全正常的文件,却把每个都读回成柱状图。根因只有一行,教训适用于你将来写的任何 OOXML 解析器:TXMLReader.Name 返回的名称按文档里的写法,含前缀。在经典图表部件里大多数元素不带前缀到达,所以 SameText(Reader.Name, 'series') 能用,你就不再想它。在 chartEx 部件里每个元素都有前缀——读取器给你 'cx:series',比较在它们全部身上静默失败,而且没有异常、没有畸形 XML,只有一个空系列列表和一个回退到默认值的图表类型。修复是统一在一个地方按本地名比较,而不是在每个调用点
// Reader.Name 携带命名空间前缀:'cx:series',而非 'series'
function IsEl(const LocalName: string): Boolean;
var
NodeName: string;
begin
NodeName := String(Reader.Name);
Result := SameText(NodeName, LocalName) or
EndsText(':' + LocalName, NodeName);
end;
ParseChartExXml 对每个元素测试用那个辅助函数,对属性用等价的 AttrVal 辅助函数,因为 cx:autoUpdate 和它的朋友们也有前缀。一旦系列元素真正被看到,类型就从遇到的第一个 layoutId 回来,每个 cx:series 通过把 cx:dataId/@val 和 cx:data/@id 匹配起来接到它的 cx:data 块。打开但从未修改的图表走更老的那条路:原始字节从 FRawChartXml 重放,所以未动过的客户文件原样进出——与约束迷你图扩展文章里工作表级扩展的保守写入纪律是同一条
HotXLS 刻意不拿这些图表做什么
两条边界值得说清楚,因为两者都是选择,而不是等着被填的缺口。内置 PDF 和 SVG 渲染器不画瀑布图或树状图系列:这些类型匹配不到任何绘图分支,所以系列渲染被跳过,工作表其余部分正常导出——在导出的 PDF 里伪造一个看起来合理的树状图比什么都不画更糟,因为它会和 Excel 对同一文件显示的内容静默不一致。而且 xlsxChartWaterfall 和 xlsxChartTreemap 是 HotXLS 从模型构建的仅有的两个扩展布局;ST_SeriesLayout 还定义了旭日图、漏斗图、箱线图和直方图,包含其中任何一种的工作簿仍然能通过 raw-XML 重放路径往返,只是你今天没法从 Delphi 代码创建它们。经典图表类型的渲染没有变化,见关于图表、图片和绘图的文章,chartEx 流水线随它一起交付在 HotXLS Delphi Excel Component 里,适用于 Delphi 和 C++Builder