Skip to main content

pptx_rs/oxml/
chart.rs

1//! 图表(Chart)OOXML 模型 —— 最小可用集(TODO-004)。
2//!
3//! 本模块定义 `<c:chartSpace>` / `<c:chart>` / `<c:plotArea>` 等元素的强类型模型,
4//! 当前支持**柱状图 / 条形图 / 折线图 / 饼图 / 散点图 / 面积图**六种类型,且数据采用
5//! `<c:numCache>` 内嵌缓存方式(**不依赖**嵌入 Excel)。
6//!
7//! # 与 python-pptx 的对应
8//!
9//! - `pptx.chart.Chart` ←→ [`Chart`];
10//! - `pptx.chart.ChartData` ←→ [`ChartData`];
11//! - `pptx.chart.Series` ←→ [`ChartSeries`];
12//! - `pptx.chart.Category` ←→ [`ChartCategory`];
13//! - `pptx.enum.chart.XL_CHART_TYPE` ←→ [`ChartType`]。
14//!
15//! # 设计要点
16//!
17//! - 数据**全部内嵌**在 chartN.xml 中(`<c:numCache>` / `<c:strCache>`),
18//!   避免引入嵌入式 Excel 计算链;
19//! - 关系 `rId` 指向 `/ppt/charts/chartN.xml` part;
20//! - 类型枚举覆盖 6 种常用集,未来扩展到雷达/气泡等只需追加变体。
21
22use crate::oxml::color::Color;
23use crate::oxml::sppr::{Fill, Line};
24use crate::oxml::writer::XmlWriter;
25
26/// 图表类型。
27#[derive(Copy, Clone, Debug, Eq, PartialEq, Hash)]
28pub enum ChartType {
29    /// 簇状柱形图(`barChart` with `barDir="col"`)。
30    Column,
31    /// 簇状条形图(`barChart` with `barDir="bar"`)。
32    Bar,
33    /// 折线图(`lineChart`)。
34    Line,
35    /// 饼图(`pieChart`)。
36    Pie,
37    /// 散点图(`scatterChart`)。系列需提供 `x_values`,使用两个 valAx。
38    Scatter,
39    /// 面积图(`areaChart`)。
40    Area,
41    /// 雷达图(`radarChart`)。每个类别一个辐射轴,多系列叠加。
42    Radar,
43    /// 气泡图(`bubbleChart`)。系列需提供 `x_values` + `bubble_sizes`,使用两个 valAx。
44    Bubble,
45}
46
47impl ChartType {
48    /// 返回 OOXML `<c:chartSpace>` 内 `plotArea` 下的图表元素名(如 `c:barChart`)。
49    pub fn chart_element(self) -> &'static str {
50        match self {
51            ChartType::Column | ChartType::Bar => "c:barChart",
52            ChartType::Line => "c:lineChart",
53            ChartType::Pie => "c:pieChart",
54            ChartType::Scatter => "c:scatterChart",
55            ChartType::Area => "c:areaChart",
56            ChartType::Radar => "c:radarChart",
57            ChartType::Bubble => "c:bubbleChart",
58        }
59    }
60
61    /// `barChart` 的 `barDir` 属性值。仅对 Column/Bar 有意义。
62    pub fn bar_dir(self) -> &'static str {
63        match self {
64            ChartType::Column => "col",
65            ChartType::Bar => "bar",
66            _ => "",
67        }
68    }
69
70    /// 是否为散点图(特殊处理:使用 c:xVal/c:yVal + 两个 valAx)。
71    pub fn is_scatter(self) -> bool {
72        matches!(self, ChartType::Scatter)
73    }
74
75    /// 是否为 X-Y 坐标图(散点图或气泡图)。
76    ///
77    /// 这两类都使用 `c:xVal` / `c:yVal` + 两个 `valAx`,区别仅在于气泡图额外有 `c:bubbleSize`。
78    pub fn is_xy_chart(self) -> bool {
79        matches!(self, ChartType::Scatter | ChartType::Bubble)
80    }
81
82    /// 是否为气泡图(额外需要 `bubble_sizes` 字段)。
83    pub fn is_bubble(self) -> bool {
84        matches!(self, ChartType::Bubble)
85    }
86}
87
88/// 数据标签位置(`<c:dLblPos val="..."/>`)。
89///
90/// OOXML ST_DLblPos 枚举,控制数据标签相对于数据点的显示位置。
91/// 不同图表类型支持的位置子集不同(参见 ECMA-376 第 21.2.2.40 节)。
92#[derive(Copy, Clone, Debug, Default, Eq, PartialEq, Hash)]
93pub enum LabelPosition {
94    /// 最佳位置(由 PowerPoint 自动选择)。适用于大多数图表类型。
95    #[default]
96    BestFit,
97    /// 数据点上方(柱形/折线/面积图)。
98    Above,
99    /// 数据点下方(柱形/折线/面积图)。
100    Below,
101    /// 数据点中心(柱形/饼图)。
102    Center,
103    /// 基线(柱形图底部)。
104    Base,
105    /// 数据点内侧(柱形/饼图,标签在柱内)。
106    InsideBase,
107    /// 数据点内侧端(柱形图顶部内侧)。
108    InsideEnd,
109    /// 数据点外侧端(柱形图顶部外侧)。
110    OutsideEnd,
111    /// 左侧(折线图/散点图)。
112    Left,
113    /// 右侧(折线图/散点图)。
114    Right,
115    /// 饼图扇区外侧。
116    OutsideEndPie,
117    /// 饼图扇区内侧。
118    InsideEndPie,
119}
120
121impl LabelPosition {
122    /// 转 OOXML `<c:dLblPos val="..."/>` 属性值。
123    pub fn as_str(self) -> &'static str {
124        match self {
125            LabelPosition::BestFit => "bestFit",
126            LabelPosition::Above => "t",
127            LabelPosition::Below => "b",
128            LabelPosition::Center => "ctr",
129            LabelPosition::Base => "inBase",
130            LabelPosition::InsideBase => "inBase",
131            LabelPosition::InsideEnd => "inEnd",
132            LabelPosition::OutsideEnd => "outEnd",
133            LabelPosition::Left => "l",
134            LabelPosition::Right => "r",
135            LabelPosition::OutsideEndPie => "outEnd",
136            LabelPosition::InsideEndPie => "inEnd",
137        }
138    }
139
140    /// 从 OOXML 属性值解析(不区分大小写),未知值返回 `None`。
141    ///
142    /// 注:方法名为 `parse` 而非 `from_str`,以避免与 `std::str::FromStr` trait 冲突。
143    pub fn parse(s: &str) -> Option<Self> {
144        match s {
145            "bestFit" => Some(LabelPosition::BestFit),
146            "t" => Some(LabelPosition::Above),
147            "b" => Some(LabelPosition::Below),
148            "ctr" => Some(LabelPosition::Center),
149            "inBase" => Some(LabelPosition::Base),
150            "inEnd" => Some(LabelPosition::InsideEnd),
151            "outEnd" => Some(LabelPosition::OutsideEnd),
152            "l" => Some(LabelPosition::Left),
153            "r" => Some(LabelPosition::Right),
154            _ => None,
155        }
156    }
157}
158
159/// 数据标签配置(`<c:dLbls>`),对应 OOXML CT_DLbls。
160///
161/// 控制数据点数值/类别/系列名等标签的显示。可挂在图表级(`<c:barChart>` 内)
162/// 或系列级(`<c:ser>` 内)——系列级会覆盖图表级。
163///
164/// # 字段语义
165///
166/// 所有 `show_*` 字段为 `Option<bool>`:
167/// - `None`:不写出该 `<c:showXxx>` 元素(继承上层或 PowerPoint 默认);
168/// - `Some(true)`:写出 `<c:showXxx val="1"/>`;
169/// - `Some(false)`:写出 `<c:showXxx val="0"/>`。
170///
171/// # 与 python-pptx 的对应
172///
173/// - python-pptx `plot.data_labels` ←→ 图表级 `ChartData.data_labels`;
174/// - python-pptx `series.data_labels` ←→ 系列级 `ChartSeries.data_labels`。
175#[derive(Clone, Debug, Default)]
176pub struct DataLabels {
177    /// 显示数值(`<c:showVal val="1"/>`)。最常用的数据标签。
178    pub show_val: Option<bool>,
179    /// 显示类别名(`<c:showCatName val="1"/>`)。
180    pub show_cat_name: Option<bool>,
181    /// 显示系列名(`<c:showSerName val="1"/>`)。
182    pub show_ser_name: Option<bool>,
183    /// 显示图例键(`<c:showLegendKey val="1"/>`,图例色块前缀)。
184    pub show_legend_key: Option<bool>,
185    /// 显示百分比(`<c:showPercent val="1"/>`,主要用于饼图)。
186    pub show_percent: Option<bool>,
187    /// 显示气泡尺寸(`<c:showBubbleSize val="1"/>`,仅气泡图)。
188    pub show_bubble_size: Option<bool>,
189    /// 标签位置(`<c:dLblPos val="..."/>`)。
190    pub position: Option<LabelPosition>,
191    /// 分隔符(`<c:separator val="..."/>`,如 `", "` / `"\n"`)。
192    pub separator: Option<String>,
193    /// 数字格式(`<c:numFmt formatCode="..." sourceLinked="0"/>`)。
194    pub num_fmt: Option<String>,
195}
196
197impl DataLabels {
198    /// 新建一个仅显示数值的数据标签配置(最常用场景)。
199    pub fn show_values() -> Self {
200        DataLabels {
201            show_val: Some(true),
202            ..Default::default()
203        }
204    }
205
206    /// 新建一个显示百分比的饼图数据标签配置。
207    pub fn show_percent_pie() -> Self {
208        DataLabels {
209            show_percent: Some(true),
210            ..Default::default()
211        }
212    }
213
214    /// 写出 `<c:dLbls>` 元素到 XmlWriter。
215    ///
216    /// OOXML 元素顺序约束(ECMA-376 21.2.2.40):
217    /// `numFmt` → `spacing` → `position` → `separator` → `showLegendKey` →
218    /// `showVal` → `showCatName` → `showSerName` → `showPercent` →
219    /// `showBubbleSize` → `dLbl` → `dLblPos` → ...
220    ///
221    /// 实际 PowerPoint 实践中常用顺序:`showVal` → `showCatName` → ... → `dLblPos` → `separator` → `numFmt`。
222    /// 本实现采用 PowerPoint 实践顺序以保证兼容性。
223    pub fn write_xml(&self, w: &mut XmlWriter) {
224        w.open("c:dLbls");
225        if let Some(b) = self.show_legend_key {
226            w.empty_with("c:showLegendKey", &[("val", if b { "1" } else { "0" })]);
227        }
228        if let Some(b) = self.show_val {
229            w.empty_with("c:showVal", &[("val", if b { "1" } else { "0" })]);
230        }
231        if let Some(b) = self.show_cat_name {
232            w.empty_with("c:showCatName", &[("val", if b { "1" } else { "0" })]);
233        }
234        if let Some(b) = self.show_ser_name {
235            w.empty_with("c:showSerName", &[("val", if b { "1" } else { "0" })]);
236        }
237        if let Some(b) = self.show_percent {
238            w.empty_with("c:showPercent", &[("val", if b { "1" } else { "0" })]);
239        }
240        if let Some(b) = self.show_bubble_size {
241            w.empty_with("c:showBubbleSize", &[("val", if b { "1" } else { "0" })]);
242        }
243        if let Some(pos) = self.position {
244            w.empty_with("c:dLblPos", &[("val", pos.as_str())]);
245        }
246        if let Some(sep) = &self.separator {
247            w.empty_with("c:separator", &[("val", sep.as_str())]);
248        }
249        if let Some(fmt) = &self.num_fmt {
250            w.empty_with(
251                "c:numFmt",
252                &[("formatCode", fmt.as_str()), ("sourceLinked", "0")],
253            );
254        }
255        w.close("c:dLbls");
256    }
257
258    /// 是否所有字段都为 `None`(即写出后只有空 `<c:dLbls/>`,无意义)。
259    pub fn is_empty(&self) -> bool {
260        self.show_val.is_none()
261            && self.show_cat_name.is_none()
262            && self.show_ser_name.is_none()
263            && self.show_legend_key.is_none()
264            && self.show_percent.is_none()
265            && self.show_bubble_size.is_none()
266            && self.position.is_none()
267            && self.separator.is_none()
268            && self.num_fmt.is_none()
269    }
270}
271
272/// 图例位置(`<c:legendPos val="..."/>`)。
273///
274/// 对应 OOXML ST_LegendPos 枚举(ECMA-376 21.2.3.24)。
275#[derive(Copy, Clone, Debug, Default, Eq, PartialEq, Hash)]
276pub enum LegendPosition {
277    /// 底部(`b`)。PowerPoint 习惯默认位置。
278    #[default]
279    Bottom,
280    /// 顶部(`t`)。
281    Top,
282    /// 右侧(`r`)。
283    Right,
284    /// 左侧(`l`)。
285    Left,
286}
287
288impl LegendPosition {
289    /// 转 OOXML `<c:legendPos val="..."/>` 属性值。
290    pub fn as_str(self) -> &'static str {
291        match self {
292            LegendPosition::Bottom => "b",
293            LegendPosition::Top => "t",
294            LegendPosition::Right => "r",
295            LegendPosition::Left => "l",
296        }
297    }
298}
299
300/// 图例配置(`<c:legend>`)。
301///
302/// 控制 `plotArea` 之后的图例显示。`ChartData.legend` 为 `None` 时不输出
303/// `<c:legend>` 元素(关闭图例);为 `Some(Legend{...})` 时按配置输出。
304///
305/// # 与 python-pptx 的对应
306///
307/// - python-pptx `chart.has_legend` ←→ `ChartData.legend.is_some()`;
308/// - python-pptx `chart.legend.position` ←→ `Legend.position`。
309#[derive(Clone, Debug, Default)]
310pub struct Legend {
311    /// 图例位置(`<c:legendPos val="..."/>`)。
312    pub position: LegendPosition,
313    /// 是否覆盖绘图区(`<c:overlay val="0|1"/>`)。
314    /// - `false`(默认):不覆盖,绘图区为图例让出空间;
315    /// - `true`:图例覆盖在绘图区上方。
316    pub overlay: bool,
317}
318
319/// 网格线配置(`<c:majorGridlines>` / `<c:minorGridlines>`)。
320///
321/// 挂在 `c:catAx` / `c:valAx` 内,控制坐标轴的主/次网格线显示。
322/// 默认 `major=false, minor=false`(不显示网格线,与 PowerPoint 默认一致)。
323#[derive(Clone, Debug, Default)]
324pub struct Gridlines {
325    /// 是否显示主网格线(`<c:majorGridlines/>`)。
326    pub major: bool,
327    /// 是否显示次网格线(`<c:minorGridlines/>`)。
328    pub minor: bool,
329}
330
331/// 轴标题(`<c:title>` 内的纯文本)。
332///
333/// 挂在 `c:catAx` / `c:valAx` 内,作为坐标轴的标题文本。
334/// `ChartData.category_axis_title` / `ChartData.value_axis_title` 为 `None` 时不输出。
335#[derive(Clone, Debug, Default)]
336pub struct AxisTitle {
337    /// 标题文本。
338    pub text: String,
339}
340
341impl AxisTitle {
342    /// 新建一个轴标题。
343    pub fn new(text: impl Into<String>) -> Self {
344        AxisTitle { text: text.into() }
345    }
346}
347
348/// 图表数据:一维类别轴 + 多个数据系列。
349#[derive(Clone, Debug, Default)]
350pub struct ChartData {
351    /// 类别(X 轴标签或饼图扇区标签)。
352    ///
353    /// **散点图忽略此字段**——散点图的 X 坐标由 `ChartSeries.x_values` 提供。
354    pub categories: Vec<ChartCategory>,
355    /// 数据系列列表(每个系列一条曲线/一组柱子/一个饼图扇区集合)。
356    pub series: Vec<ChartSeries>,
357    /// 图表标题。`None` 表示无标题。
358    pub title: Option<String>,
359    /// 图表级数据标签(`<c:dLbls>`,写在 `<c:barChart>` 等图表元素内、`<c:ser>` 之前)。
360    ///
361    /// 系列级 [`ChartSeries::data_labels`] 会覆盖此处的图表级配置。
362    /// `None` 表示不写出图表级 `<c:dLbls>`(PowerPoint 使用默认:不显示标签)。
363    pub data_labels: Option<DataLabels>,
364    /// 图例配置(`<c:legend>`)。`None` = 不输出 `<c:legend>`(关闭图例)。
365    ///
366    /// 默认 `None`(无图例)。如需图例,设置为 `Some(Legend::default())`(底部图例)。
367    pub legend: Option<Legend>,
368    /// 类别轴(catAx / 底部 valAx)网格线。默认无网格线。
369    pub category_axis_gridlines: Gridlines,
370    /// 数值轴(valAx / 左侧)网格线。默认无网格线。
371    pub value_axis_gridlines: Gridlines,
372    /// 类别轴标题(`<c:catAx><c:title>`)。`None` = 无标题。
373    pub category_axis_title: Option<AxisTitle>,
374    /// 数值轴标题(`<c:valAx><c:title>`)。`None` = 无标题。
375    pub value_axis_title: Option<AxisTitle>,
376    /// 次坐标轴图表类型(组合图模式,模块 D)。
377    ///
378    /// `Some(chart_type)` + 系列中 `secondary_axis=true` 的项 → 该系列写入第二个
379    /// 图表元素(如 `c:lineChart`),与主图表元素(如 `c:barChart`)共用类别轴但
380    /// 绑定次 valAx(axId=444444444)。
381    ///
382    /// `None`(默认)时所有系列写入主图表元素(即使 `secondary_axis=true`,仅绑定次轴 id)。
383    pub secondary_chart_type: Option<ChartType>,
384}
385
386/// 单个类别(X 轴标签或饼图扇区标签)。
387#[derive(Clone, Debug, Default)]
388pub struct ChartCategory {
389    /// 类别名称。
390    pub name: String,
391}
392
393impl ChartCategory {
394    /// 新建一个类别。
395    pub fn new(name: impl Into<String>) -> Self {
396        ChartCategory { name: name.into() }
397    }
398}
399
400/// 系列样式(`<c:spPr>`,写在 `<c:ser>` 内 `<c:tx>` 之后、`<c:cat>`/`<c:xVal>` 之前)。
401///
402/// 控制系列的颜色/填充/边框,对应 python-pptx `series.format.fill` / `series.format.line`。
403/// `None` 时(默认)不写出 `<c:spPr>`,PowerPoint 使用主题色自动分配。
404///
405/// # 与 python-pptx 的对应
406///
407/// - `pptx.chart.series.format.fill` ←→ [`SeriesFormat::fill`];
408/// - `pptx.chart.series.format.line` ←→ [`SeriesFormat::line`]。
409#[derive(Clone, Debug, Default)]
410pub struct SeriesFormat {
411    /// 填充(`<a:solidFill>` / `<a:noFill>` / `<a:gradFill>` 等)。
412    /// 复用 [`crate::oxml::sppr::Fill`],`Fill::Inherit`(默认)时不写出填充元素。
413    pub fill: Fill,
414    /// 边框(`<a:ln>`)。`None` 时不写出 `<a:ln>` 元素。
415    pub line: Option<Line>,
416}
417
418impl SeriesFormat {
419    /// 新建一个实色填充的系列样式(最常用场景:指定系列颜色)。
420    ///
421    /// # 参数
422    /// - `color`:系列颜色(如 `Color::RGB(RGBColor(0x4F, 0x81, 0xBD))`)。
423    pub fn solid_fill(color: Color) -> Self {
424        SeriesFormat {
425            fill: Fill::Solid(color),
426            line: None,
427        }
428    }
429
430    /// 写出 `<c:spPr>` 元素到 XmlWriter。
431    ///
432    /// OOXML 顺序(ECMA-376 21.2.2.197 `c:ser` 子元素顺序):
433    /// `idx` → `order` → `tx` → **`spPr`** → `txPr` → `marker` → `invertIfNegative` →
434    /// `pictureOptions` → `dPt` → `dLbls` → `trendline` → `errBars` → `cat`/`xVal` → `val`/`yVal` → ...
435    ///
436    /// 仅当 `fill` 非 `Inherit` 或 `line` 非 `None` 时写出;否则跳过(不输出空 `<c:spPr/>`)。
437    ///
438    /// # 返回值
439    /// - `true`:已写出 `<c:spPr>` 元素;
440    /// - `false`:跳过(fill 为 Inherit 且 line 为 None)。
441    pub fn write_xml(&self, w: &mut XmlWriter) -> bool {
442        let has_fill = !matches!(self.fill, Fill::Inherit);
443        let has_line = self.line.is_some();
444        if !has_fill && !has_line {
445            return false;
446        }
447        w.open("c:spPr");
448        self.fill.write_xml(w);
449        if let Some(ln) = &self.line {
450            ln.write_xml(w);
451        }
452        w.close("c:spPr");
453        true
454    }
455}
456
457/// 数据系列(一条折线 / 一组柱子 / 一组饼图扇区 / 一组散点 / 一组气泡)。
458#[derive(Clone, Debug, Default)]
459pub struct ChartSeries {
460    /// 系列名称(图例上显示)。
461    pub name: String,
462    /// 数值列表,长度应与 `ChartData.categories` 一致。
463    ///
464    /// 对散点图/气泡图,此字段表示 **Y 坐标**值,需配合 `x_values` 使用。
465    pub values: Vec<f64>,
466    /// 散点图/气泡图的 **X 坐标**值列表。仅 `ChartType::Scatter` / `Bubble` 使用,长度应与 `values` 一致。
467    ///
468    /// `None` 或空表示该系列非 X-Y 坐标图;散点图系列应通过 [`ChartSeries::new_scatter`]
469    /// 构造以同时提供 `x_values` 与 `values`(Y 坐标)。
470    pub x_values: Option<Vec<f64>>,
471    /// 气泡图的**气泡尺寸**列表。仅 `ChartType::Bubble` 使用,长度应与 `values` 一致。
472    ///
473    /// `None` 表示非气泡图;气泡图系列应通过 [`ChartSeries::new_bubble`] 构造。
474    pub bubble_sizes: Option<Vec<f64>>,
475    /// 系列级数据标签(`<c:dLbls>`,写在 `<c:ser>` 内 `<c:val>` / `<c:yVal>` 之后)。
476    ///
477    /// 系列级配置会覆盖图表级 [`ChartData::data_labels`]。`None` 表示继承图表级配置。
478    pub data_labels: Option<DataLabels>,
479    /// 是否将该系列绑定到**次坐标轴**(右侧 Y 轴)。
480    ///
481    /// - `false`(默认):系列绑定主轴(axId=222222222)。
482    /// - `true`:系列绑定次轴(axId=444444444),`Chart::to_xml` 会额外写出
483    ///   次轴定义(含 `<c:crosses val="max"/>`),PowerPoint 将其显示在右侧。
484    ///
485    /// 典型场景:主轴显示柱形图,次轴显示折线图(双轴组合图)。
486    /// **注意**:饼图/散点图/气泡图不支持次坐标轴(写出时该字段被忽略)。
487    pub secondary_axis: bool,
488    /// 系列样式(`<c:spPr>`)。`None`(默认)时不写出,PowerPoint 自动分配主题色。
489    ///
490    /// 通过 [`ChartSeries::new`] 等构造函数创建的系列默认无样式;
491    /// 使用 [`ChartShape::set_series_format`] 或直接设置此字段可指定颜色/填充/边框。
492    pub format: Option<SeriesFormat>,
493}
494
495impl ChartSeries {
496    /// 新建一个数据系列(非散点图/气泡图)。
497    ///
498    /// `values` 为 Y 值列表(柱/条/线/面积/雷达图)或扇区数值(饼图)。
499    pub fn new(name: impl Into<String>, values: Vec<f64>) -> Self {
500        ChartSeries {
501            name: name.into(),
502            values,
503            x_values: None,
504            bubble_sizes: None,
505            data_labels: None,
506            secondary_axis: false,
507            format: None,
508        }
509    }
510
511    /// 新建一个散点图数据系列(提供 X 与 Y 坐标)。
512    ///
513    /// `x_values` 与 `y_values` 长度应一致;不一致时以较短的为准(写出时按 `y_values` 长度迭代)。
514    ///
515    /// # 参数
516    /// - `name`:系列名称(图例显示)。
517    /// - `x_values`:X 坐标列表。
518    /// - `y_values`:Y 坐标列表(与 [`ChartSeries::values`] 同字段)。
519    pub fn new_scatter(name: impl Into<String>, x_values: Vec<f64>, y_values: Vec<f64>) -> Self {
520        ChartSeries {
521            name: name.into(),
522            values: y_values,
523            x_values: Some(x_values),
524            bubble_sizes: None,
525            data_labels: None,
526            secondary_axis: false,
527            format: None,
528        }
529    }
530
531    /// 新建一个气泡图数据系列(提供 X、Y 坐标与气泡尺寸)。
532    ///
533    /// 三个列表长度应一致;不一致时以 `y_values` 长度为准迭代。
534    ///
535    /// # 参数
536    /// - `name`:系列名称(图例显示)。
537    /// - `x_values`:X 坐标列表。
538    /// - `y_values`:Y 坐标列表(与 [`ChartSeries::values`] 同字段)。
539    /// - `bubble_sizes`:气泡尺寸列表(决定每个气泡的面积)。
540    pub fn new_bubble(
541        name: impl Into<String>,
542        x_values: Vec<f64>,
543        y_values: Vec<f64>,
544        bubble_sizes: Vec<f64>,
545    ) -> Self {
546        ChartSeries {
547            name: name.into(),
548            values: y_values,
549            x_values: Some(x_values),
550            bubble_sizes: Some(bubble_sizes),
551            data_labels: None,
552            secondary_axis: false,
553            format: None,
554        }
555    }
556
557    /// Builder 方法:设置系列样式(颜色/填充/边框),返回 `self` 便于链式调用。
558    ///
559    /// # 参数
560    /// - `format`:系列样式;`None` 清除样式(PowerPoint 自动分配主题色)。
561    ///
562    /// # 示例
563    ///
564    /// ```no_run
565    /// use pptx_rs::oxml::chart::{ChartSeries, SeriesFormat};
566    /// use pptx_rs::oxml::color::Color;
567    /// use pptx_rs::units::RGBColor;
568    ///
569    /// let s = ChartSeries::new("Sales", vec![10.0, 20.0])
570    ///     .with_format(SeriesFormat::solid_fill(Color::RGB(RGBColor(0xFF, 0x00, 0x00))));
571    /// ```
572    pub fn with_format(mut self, format: SeriesFormat) -> Self {
573        self.format = Some(format);
574        self
575    }
576}
577
578/// 一个完整的图表,引用 chart part。
579#[derive(Clone, Debug)]
580pub struct Chart {
581    /// 图表类型。
582    pub chart_type: ChartType,
583    /// 图表数据。
584    pub data: ChartData,
585    /// 关系 id(指向 `/ppt/charts/chartN.xml`)。
586    ///
587    /// 由 `Presentation::to_opc_package` 在写出 chart part 时自动分配。
588    /// 高阶 API 创建时通常为空字符串,由 presentation 层填充。
589    pub rid: String,
590    /// 嵌入式 Excel 工作簿的关系 id(TODO-004 Excel 嵌入)。
591    ///
592    /// 指向 `/ppt/embeddings/Microsoft_Excel_WorksheetN.xlsx`,写出时在
593    /// `</c:chart>` 之后、`</c:chartSpace>` 之前生成
594    /// `<c:externalData r:id="..."><c:autoUpdate val="0"/></c:externalData>`。
595    ///
596    /// - `None`:不写出 `<c:externalData>`(图表数据仅靠 numCache/strCache)。
597    /// - `Some(rid)`:写出 `<c:externalData r:id="{rid}"/>`,PowerPoint 打开
598    ///   图表时会从 xlsx part 读取数据源("编辑数据" 启动 Excel)。
599    ///
600    /// 由 `Presentation::to_opc_package` 在写出 xlsx part 时自动分配;
601    /// 高阶 API 创建时通常为 `None`,由 presentation 层填充。
602    pub external_data_rid: Option<String>,
603}
604
605impl Chart {
606    /// 新建一个图表。`rid` 默认为空字符串,由 presentation 层在写出时填充。
607    pub fn new(chart_type: ChartType, data: ChartData) -> Self {
608        Chart {
609            chart_type,
610            data,
611            rid: String::new(),
612            external_data_rid: None,
613        }
614    }
615
616    /// 从 `<c:chartSpace>` XML 字符串解析为强类型 [`Chart`]。
617    ///
618    /// 详见模块级独立函数 [`parse_from_xml`](本关联函数是其委托)。
619    ///
620    /// # 参数
621    /// - `xml`:完整 chartN.xml 内容。
622    ///
623    /// # 错误
624    /// - `Error::Xml`:XML 解析失败。
625    pub fn parse_from_xml(xml: &str) -> crate::Result<Chart> {
626        parse_from_xml(xml)
627    }
628}
629
630impl Default for Chart {
631    /// 默认图表:`ChartType::Column` + 空 `ChartData` + 空 rid。
632    fn default() -> Self {
633        Chart {
634            chart_type: ChartType::Column,
635            data: ChartData::default(),
636            rid: String::new(),
637            external_data_rid: None,
638        }
639    }
640}
641
642impl Chart {
643    /// 写出 `<c:chartSpace>` 完整 XML(chartN.xml 内容)。
644    ///
645    /// # 元素结构(最小可用集)
646    ///
647    /// ```text
648    /// <c:chartSpace xmlns:c=... xmlns:a=... xmlns:r=...>
649    ///   <c:chart>
650    ///     <c:title>...?         ← 可选
651    ///     <c:plotArea>
652    ///       <c:layout/>         ← 自动布局
653    ///       <c:barChart|c:lineChart|c:pieChart>
654    ///         <c:barDir>...     ← 仅 barChart
655    ///         <c:grouping val="clustered"/>
656    ///         <c:ser>           ← 一个或多个系列
657    ///           <c:idx val="0"/>
658    ///           <c:order val="0"/>
659    ///           <c:tx><c:strRef><c:f>Sheet1!$B$1</c:f><c:strCache>...</c:strCache></c:strRef></c:tx>
660    ///           <c:cat><c:strRef>...类别标签...</c:strRef></c:cat>
661    ///           <c:val><c:numRef><c:numCache>...数值...</c:numCache></c:numRef></c:val>
662    ///         </c:ser>
663    ///         <c:axId val="..."/>  ← 1~2 个 axId
664    ///       </c:barChart>
665    ///       <c:catAx>...</c:catAx>     ← 类别轴(饼图无)
666    ///       <c:valAx>...</c:valAx>     ← 数值轴(饼图无)
667    ///     </c:plotArea>
668    ///   </c:chart>
669    /// </c:chartSpace>
670    /// ```
671    pub fn to_xml(&self) -> String {
672        let mut w = XmlWriter::new();
673        w.raw("<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\n");
674        w.open_with(
675            "c:chartSpace",
676            &[
677                (
678                    "xmlns:c",
679                    "http://schemas.openxmlformats.org/drawingml/2006/chart",
680                ),
681                (
682                    "xmlns:a",
683                    "http://schemas.openxmlformats.org/drawingml/2006/main",
684                ),
685                (
686                    "xmlns:r",
687                    "http://schemas.openxmlformats.org/officeDocument/2006/relationships",
688                ),
689            ],
690        );
691        w.open("c:chart");
692        // 标题
693        if let Some(title) = &self.data.title {
694            w.open("c:title");
695            w.open("a:tx");
696            w.open("a:rich");
697            w.open("a:bodyPr");
698            w.close("a:bodyPr");
699            w.open("a:lstStyle");
700            w.close("a:lstStyle");
701            w.open("a:p");
702            w.open("a:pPr");
703            w.empty_with("a:defRPr", &[("sz", "1400"), ("b", "1")]);
704            w.close("a:pPr");
705            w.leaf("a:t", title.as_str());
706            w.close("a:p");
707            w.close("a:rich");
708            w.close("a:tx");
709            w.empty_with("c:overlay", &[("val", "0")]);
710            w.close("c:title");
711        }
712        // plotArea
713        w.open("c:plotArea");
714        w.open("c:layout");
715        w.close("c:layout");
716
717        let chart_elem = self.chart_type.chart_element();
718        w.open(chart_elem);
719        if matches!(self.chart_type, ChartType::Column | ChartType::Bar) {
720            w.empty_with("c:barDir", &[("val", self.chart_type.bar_dir())]);
721            w.empty_with("c:grouping", &[("val", "clustered")]);
722        }
723        // 面积图 grouping(standard = 普通面积图,不堆叠)
724        if matches!(self.chart_type, ChartType::Area) {
725            w.empty_with("c:grouping", &[("val", "standard")]);
726        }
727        // 散点图 scatterStyle(lineMarker = 带标记的折线散点)
728        if matches!(self.chart_type, ChartType::Scatter) {
729            w.empty_with("c:scatterStyle", &[("val", "lineMarker")]);
730        }
731        // 雷达图 radarStyle(marker = 带数据标记的雷达图)
732        if matches!(self.chart_type, ChartType::Radar) {
733            w.empty_with("c:radarStyle", &[("val", "marker")]);
734        }
735        // 气泡图 bubbleScale(100 = 默认缩放比例)
736        if matches!(self.chart_type, ChartType::Bubble) {
737            w.empty_with("c:bubbleScale", &[("val", "100")]);
738        }
739        // varColors
740        if matches!(self.chart_type, ChartType::Pie) {
741            w.empty_with("c:varyColors", &[("val", "1")]);
742        }
743
744        // 图表级数据标签(<c:dLbls>,OOXML 顺序:在 varyColors 之后、<c:ser> 之前)。
745        // 系列级 ChartSeries.data_labels 会覆盖此处的图表级配置。
746        if let Some(dl) = &self.data.data_labels {
747            if !dl.is_empty() {
748                dl.write_xml(&mut w);
749            }
750        }
751
752        // 系列列表(主图表元素)。
753        //
754        // 组合图模式(`secondary_chart_type.is_some()`):主图表元素只写出
755        // `secondary_axis=false` 的系列;`secondary_axis=true` 的系列写入下方
756        // 次图表元素(如 `c:lineChart`),与主图表元素共用类别轴但绑定次 valAx。
757        //
758        // 非组合图模式:所有系列写入主图表元素(即使 `secondary_axis=true`,
759        // 仅绑定次轴 id,不创建第二个图表元素——向后兼容旧行为)。
760        let has_combo = self.data.secondary_chart_type.is_some();
761        for (idx, s) in self.data.series.iter().enumerate() {
762            // 组合图模式下,次轴系列跳过(在次图表元素中写出)
763            if has_combo && s.secondary_axis {
764                continue;
765            }
766            write_series(&mut w, self.chart_type, s, idx, &self.data.categories);
767        }
768
769        // 轴 ID(饼图无轴;散点图/气泡图两个 valAx)。
770        //
771        // 次坐标轴支持(TODO-004 次坐标轴):当某系列 `secondary_axis=true` 时,
772        // 该系列额外引用次轴 axId=444444444;图表级 axId 列表需额外包含次轴 id,
773        // 并在下方轴定义段写出第三个轴(次 valAx,含 `<c:crosses val="max"/>`)。
774        //
775        // 饼图/散点图/气泡图不支持次坐标轴(OOXML 规范约束),secondary_axis 字段被忽略。
776        let has_secondary = !self.chart_type.is_xy_chart()
777            && !matches!(self.chart_type, ChartType::Pie)
778            && self.data.series.iter().any(|s| s.secondary_axis);
779        if matches!(self.chart_type, ChartType::Pie) {
780            // 饼图无轴
781        } else if self.chart_type.is_xy_chart() {
782            // 散点图/气泡图:两个 valAx(不支持次坐标轴)
783            w.empty_with("c:axId", &[("val", "111111111")]);
784            w.empty_with("c:axId", &[("val", "222222222")]);
785        } else {
786            // 柱/条/线/面积/雷达:catAx + valAx
787            w.empty_with("c:axId", &[("val", "111111111")]);
788            w.empty_with("c:axId", &[("val", "222222222")]);
789            // 次坐标轴:当存在 secondary_axis=true 系列时,额外引用次 valAx(axId=444444444)。
790            // 次轴定义在下方轴定义段写出,含 <c:crosses val="max"/> 标记。
791            if has_secondary {
792                w.empty_with("c:axId", &[("val", "444444444")]);
793            }
794        }
795        w.close(chart_elem);
796
797        // 次图表元素(组合图模式,模块 D)。
798        //
799        // 当 `secondary_chart_type.is_some()` 且存在 `secondary_axis=true` 的系列时,
800        // 写出第二个图表元素(如 `c:lineChart`),与主图表元素共用类别轴(catAx 111111111)
801        // 但绑定次 valAx(axId=444444444)。
802        //
803        // OOXML 约束:`<c:plotArea>` 下的多个图表元素共享 catAx,但各自绑定不同的 valAx。
804        // 主图表元素 → 主 valAx (222222222);次图表元素 → 次 valAx (444444444)。
805        // 次 valAx 的定义在下方轴定义段写出(含 `<c:crosses val="max"/>`)。
806        //
807        // 典型场景:主轴柱形图 + 次轴折线图(双轴组合图,如"销量 vs 增长率")。
808        if let Some(sec_ct) = self.data.secondary_chart_type {
809            if self.data.series.iter().any(|s| s.secondary_axis) {
810                let sec_elem = sec_ct.chart_element();
811                w.open(sec_elem);
812                // 次图表元素的 grouping/style(与主元素逻辑一致,按图表类型分支)
813                if matches!(sec_ct, ChartType::Column | ChartType::Bar) {
814                    w.empty_with("c:barDir", &[("val", sec_ct.bar_dir())]);
815                    w.empty_with("c:grouping", &[("val", "clustered")]);
816                }
817                if matches!(sec_ct, ChartType::Area) {
818                    w.empty_with("c:grouping", &[("val", "standard")]);
819                }
820                if matches!(sec_ct, ChartType::Scatter) {
821                    w.empty_with("c:scatterStyle", &[("val", "lineMarker")]);
822                }
823                if matches!(sec_ct, ChartType::Radar) {
824                    w.empty_with("c:radarStyle", &[("val", "marker")]);
825                }
826                if matches!(sec_ct, ChartType::Bubble) {
827                    w.empty_with("c:bubbleScale", &[("val", "100")]);
828                }
829                // 次图表元素复用主图表级数据标签(若有)
830                if let Some(dl) = &self.data.data_labels {
831                    if !dl.is_empty() {
832                        dl.write_xml(&mut w);
833                    }
834                }
835                // 写出 secondary_axis=true 的系列到次图表元素
836                for (idx, s) in self.data.series.iter().enumerate() {
837                    if s.secondary_axis {
838                        write_series(&mut w, sec_ct, s, idx, &self.data.categories);
839                    }
840                }
841                // axId:次图表元素绑定主 catAx + 次 valAx
842                w.empty_with("c:axId", &[("val", "111111111")]);
843                w.empty_with("c:axId", &[("val", "444444444")]);
844                w.close(sec_elem);
845            }
846        }
847
848        // 轴定义(饼图跳过)
849        // OOXML 子元素顺序(ECMA-376 21.2.2.16 catAx / 21.2.2.168 valAx):
850        // axId → scaling → delete → axPos → majorGridlines → minorGridlines →
851        // title → numFmt → ... → crossAx → ...
852        if !matches!(self.chart_type, ChartType::Pie) {
853            if self.chart_type.is_xy_chart() {
854                // 散点图/气泡图:两个 valAx(X 轴底部 + Y 轴左侧)
855                // valAx(X 轴)—— 使用 category_axis_gridlines + category_axis_title
856                w.open("c:valAx");
857                w.empty_with("c:axId", &[("val", "111111111")]);
858                w.empty_with("c:scaling", &[("orientation", "minMax")]);
859                w.empty_with("c:delete", &[("val", "0")]);
860                w.empty_with("c:axPos", &[("val", "b")]);
861                write_gridlines(&mut w, &self.data.category_axis_gridlines);
862                if let Some(t) = &self.data.category_axis_title {
863                    write_axis_title(&mut w, t.text.as_str());
864                }
865                w.empty_with("c:crossAx", &[("val", "222222222")]);
866                w.close("c:valAx");
867                // valAx(Y 轴)—— 使用 value_axis_gridlines + value_axis_title
868                w.open("c:valAx");
869                w.empty_with("c:axId", &[("val", "222222222")]);
870                w.empty_with("c:scaling", &[("orientation", "minMax")]);
871                w.empty_with("c:delete", &[("val", "0")]);
872                w.empty_with("c:axPos", &[("val", "l")]);
873                write_gridlines(&mut w, &self.data.value_axis_gridlines);
874                if let Some(t) = &self.data.value_axis_title {
875                    write_axis_title(&mut w, t.text.as_str());
876                }
877                w.empty_with("c:crossAx", &[("val", "111111111")]);
878                w.close("c:valAx");
879            } else {
880                // 柱/条/线/面积:catAx + valAx
881                // catAx(主类别轴,底部)
882                w.open("c:catAx");
883                w.empty_with("c:axId", &[("val", "111111111")]);
884                w.empty_with("c:scaling", &[("orientation", "minMax")]);
885                w.empty_with("c:delete", &[("val", "0")]);
886                w.empty_with("c:axPos", &[("val", "b")]);
887                write_gridlines(&mut w, &self.data.category_axis_gridlines);
888                if let Some(t) = &self.data.category_axis_title {
889                    write_axis_title(&mut w, t.text.as_str());
890                }
891                w.empty_with("c:crossAx", &[("val", "222222222")]);
892                w.close("c:catAx");
893                // valAx(主数值轴,左侧)
894                w.open("c:valAx");
895                w.empty_with("c:axId", &[("val", "222222222")]);
896                w.empty_with("c:scaling", &[("orientation", "minMax")]);
897                w.empty_with("c:delete", &[("val", "0")]);
898                w.empty_with("c:axPos", &[("val", "l")]);
899                write_gridlines(&mut w, &self.data.value_axis_gridlines);
900                if let Some(t) = &self.data.value_axis_title {
901                    write_axis_title(&mut w, t.text.as_str());
902                }
903                w.empty_with("c:crossAx", &[("val", "111111111")]);
904                w.close("c:valAx");
905                // 次数值轴(右侧,仅当 has_secondary 时写出)。
906                //
907                // <c:crosses val="max"/> 是 PowerPoint 识别次轴的关键元素:
908                // 表示该轴在主轴的最大值处交叉(即显示在右侧/顶部)。
909                // axPos="r" 表示右侧;crossAx 指向主 catAx(111111111)。
910                // 注:次轴标题暂不支持(round-trip 简化),仅写出网格线。
911                if has_secondary {
912                    w.open("c:valAx");
913                    w.empty_with("c:axId", &[("val", "444444444")]);
914                    w.empty_with("c:scaling", &[("orientation", "minMax")]);
915                    w.empty_with("c:delete", &[("val", "0")]);
916                    w.empty_with("c:axPos", &[("val", "r")]);
917                    write_gridlines(&mut w, &self.data.value_axis_gridlines);
918                    w.empty_with("c:crossAx", &[("val", "111111111")]);
919                    w.empty_with("c:crosses", &[("val", "max")]);
920                    w.close("c:valAx");
921                }
922            }
923        }
924        w.close("c:plotArea");
925
926        // legend —— None 时不输出(关闭图例)
927        if let Some(legend) = &self.data.legend {
928            w.open("c:legend");
929            w.empty_with("c:legendPos", &[("val", legend.position.as_str())]);
930            let overlay_val = if legend.overlay { "1" } else { "0" };
931            w.empty_with("c:overlay", &[("val", overlay_val)]);
932            w.close("c:legend");
933        }
934
935        w.empty_with("c:plotVisOnly", &[("val", "1")]);
936        w.empty_with("c:dispBlanksAs", &[("val", "gap")]);
937        w.close("c:chart");
938
939        // 嵌入式 Excel 工作簿引用(TODO-004 Excel 嵌入)。
940        //
941        // OOXML 顺序约束:`<c:externalData>` 必须在 `<c:chart>` 之后、
942        // `<c:chartSpace>` 关闭之前。PowerPoint 读取该元素后会从对应
943        // xlsx part 加载数据源("编辑数据" 启动 Excel)。
944        //
945        // `<c:autoUpdate val="0"/>` 表示不自动同步(用户手动编辑后才更新),
946        // 这是 PowerPoint 默认行为;val="1" 会在打开时自动同步,可能卡顿。
947        if let Some(ext_rid) = &self.external_data_rid {
948            w.open_with("c:externalData", &[("r:id", ext_rid.as_str())]);
949            w.empty_with("c:autoUpdate", &[("val", "0")]);
950            w.close("c:externalData");
951        }
952
953        w.close("c:chartSpace");
954        w.into_string()
955    }
956}
957
958/// 写出坐标轴的网格线(`<c:majorGridlines>` / `<c:minorGridlines>`)。
959///
960/// 仅当 `gridlines.major` / `gridlines.minor` 为 `true` 时输出对应元素。
961/// 空元素(`<c:majorGridlines/>`)即可被 PowerPoint 识别为"显示主网格线"。
962fn write_gridlines(w: &mut XmlWriter, gridlines: &Gridlines) {
963    if gridlines.major {
964        w.open("c:majorGridlines");
965        w.close("c:majorGridlines");
966    }
967    if gridlines.minor {
968        w.open("c:minorGridlines");
969        w.close("c:minorGridlines");
970    }
971}
972
973/// 写出 `<c:title>` 轴标题(含 `a:tx/a:rich/a:p/a:t` 结构)。
974///
975/// 用于 `c:catAx` / `c:valAx` 内的轴标题。OOXML 顺序:在 `majorGridlines` 之后、
976/// `numFmt` 之前。`<c:overlay val="0"/>` 表示标题不覆盖绘图区。
977fn write_axis_title(w: &mut XmlWriter, text: &str) {
978    w.open("c:title");
979    w.open("a:tx");
980    w.open("a:rich");
981    w.open("a:bodyPr");
982    w.close("a:bodyPr");
983    w.open("a:lstStyle");
984    w.close("a:lstStyle");
985    w.open("a:p");
986    w.open("a:pPr");
987    w.empty_with("a:defRPr", &[("sz", "1000"), ("b", "0")]);
988    w.close("a:pPr");
989    w.leaf("a:t", text);
990    w.close("a:p");
991    w.close("a:rich");
992    w.close("a:tx");
993    w.empty_with("c:overlay", &[("val", "0")]);
994    w.close("c:title");
995}
996
997/// 写出单个 `<c:ser>` 系列元素到 XmlWriter。
998///
999/// 从 [`Chart::to_xml`] 的系列循环中提取,供主图表元素与次图表元素(组合图模式)复用。
1000///
1001/// # OOXML 子元素顺序(ECMA-376 21.2.2.197 `c:ser`)
1002///
1003/// `idx` → `order` → `tx` → `spPr` → `txPr` → `marker` → `invertIfNegative` →
1004/// `pictureOptions` → `dPt` → `dLbls` → `trendline` → `errBars` → `cat`/`xVal` →
1005/// `val`/`yVal` → `smooth` → ...(后续元素本库暂不写出)
1006///
1007/// 本实现实际写出顺序:`idx` → `order` → `tx` → `spPr`(可选)→
1008/// `cat`/`xVal` → `val`/`yVal` → `bubbleSize`(仅气泡图)→ `dLbls`(可选)。
1009///
1010/// # 参数
1011/// - `w`:XML 写出器。
1012/// - `chart_type`:当前图表类型(决定 `cat`/`xVal` + `val`/`yVal` 分支)。
1013///   组合图模式下,主图表元素传入主类型,次图表元素传入 [`ChartData::secondary_chart_type`]。
1014/// - `s`:要写出的数据系列。
1015/// - `idx`:系列索引(用于 `c:idx` / `c:order` 属性 + Excel 列字母计算)。
1016/// - `categories`:类别列表(非散点图用于 `c:cat`,散点图/气泡图忽略)。
1017fn write_series(
1018    w: &mut XmlWriter,
1019    chart_type: ChartType,
1020    s: &ChartSeries,
1021    idx: usize,
1022    categories: &[ChartCategory],
1023) {
1024    let idx_s = idx.to_string();
1025    w.open("c:ser");
1026    w.empty_with("c:idx", &[("val", idx_s.as_str())]);
1027    w.empty_with("c:order", &[("val", idx_s.as_str())]);
1028    // 系列名(c:tx)
1029    w.open("c:tx");
1030    w.open("c:strRef");
1031    w.leaf("c:f", &format!("Sheet1!${}$1", col_letter(idx + 1)));
1032    w.open("c:strCache");
1033    w.open_with("c:pt", &[("idx", "0")]);
1034    w.leaf("c:v", s.name.as_str());
1035    w.close("c:pt");
1036    w.close("c:strCache");
1037    w.close("c:strRef");
1038    w.close("c:tx");
1039    // 系列样式(<c:spPr>,OOXML 顺序:在 c:tx 之后、c:cat/c:xVal 之前)
1040    if let Some(fmt) = &s.format {
1041        fmt.write_xml(w);
1042    }
1043    if chart_type.is_xy_chart() {
1044        // 散点图/气泡图:c:xVal / c:yVal(均为数值)
1045        // c:xVal
1046        w.open("c:xVal");
1047        w.open("c:numRef");
1048        w.leaf("c:f", "Sheet1!$A$2:$A$100");
1049        w.open("c:numCache");
1050        w.empty_with("c:formatCode", &[("val", "General")]);
1051        if let Some(xs) = &s.x_values {
1052            for (i, v) in xs.iter().enumerate() {
1053                let i_s = i.to_string();
1054                let v_s = format_f64(*v);
1055                w.open_with("c:pt", &[("idx", i_s.as_str())]);
1056                w.leaf("c:v", v_s.as_str());
1057                w.close("c:pt");
1058            }
1059        }
1060        w.close("c:numCache");
1061        w.close("c:numRef");
1062        w.close("c:xVal");
1063        // c:yVal
1064        w.open("c:yVal");
1065        w.open("c:numRef");
1066        w.leaf(
1067            "c:f",
1068            &format!(
1069                "Sheet1!${}$2:${}$100",
1070                col_letter(idx + 1),
1071                col_letter(idx + 1)
1072            ),
1073        );
1074        w.open("c:numCache");
1075        w.empty_with("c:formatCode", &[("val", "General")]);
1076        for (i, v) in s.values.iter().enumerate() {
1077            let i_s = i.to_string();
1078            let v_s = format_f64(*v);
1079            w.open_with("c:pt", &[("idx", i_s.as_str())]);
1080            w.leaf("c:v", v_s.as_str());
1081            w.close("c:pt");
1082        }
1083        w.close("c:numCache");
1084        w.close("c:numRef");
1085        w.close("c:yVal");
1086        // 气泡图额外:c:bubbleSize(气泡尺寸,决定每个气泡的面积)
1087        if chart_type.is_bubble() {
1088            w.open("c:bubbleSize");
1089            w.open("c:numRef");
1090            w.leaf(
1091                "c:f",
1092                &format!(
1093                    "Sheet1!${}$2:${}$100",
1094                    col_letter(idx + 2),
1095                    col_letter(idx + 2)
1096                ),
1097            );
1098            w.open("c:numCache");
1099            w.empty_with("c:formatCode", &[("val", "General")]);
1100            if let Some(sizes) = &s.bubble_sizes {
1101                for (i, v) in sizes.iter().enumerate() {
1102                    let i_s = i.to_string();
1103                    let v_s = format_f64(*v);
1104                    w.open_with("c:pt", &[("idx", i_s.as_str())]);
1105                    w.leaf("c:v", v_s.as_str());
1106                    w.close("c:pt");
1107                }
1108            }
1109            w.close("c:numCache");
1110            w.close("c:numRef");
1111            w.close("c:bubbleSize");
1112        }
1113    } else {
1114        // 非散点图:c:cat(类别标签) + c:val(数值)
1115        // 类别标签(c:cat)—— 仅 bar/line/area 有
1116        if !matches!(chart_type, ChartType::Pie) && !categories.is_empty() {
1117            w.open("c:cat");
1118            w.open("c:strRef");
1119            w.leaf("c:f", "Sheet1!$A$2:$A$100");
1120            w.open("c:strCache");
1121            for (i, cat) in categories.iter().enumerate() {
1122                let i_s = i.to_string();
1123                w.open_with("c:pt", &[("idx", i_s.as_str())]);
1124                w.leaf("c:v", cat.name.as_str());
1125                w.close("c:pt");
1126            }
1127            w.close("c:strCache");
1128            w.close("c:strRef");
1129            w.close("c:cat");
1130        } else if matches!(chart_type, ChartType::Pie) && !categories.is_empty() {
1131            // 饼图:类别标签写在 c:cat 里
1132            w.open("c:cat");
1133            w.open("c:strRef");
1134            w.leaf("c:f", "Sheet1!$A$2:$A$100");
1135            w.open("c:strCache");
1136            for (i, cat) in categories.iter().enumerate() {
1137                let i_s = i.to_string();
1138                w.open_with("c:pt", &[("idx", i_s.as_str())]);
1139                w.leaf("c:v", cat.name.as_str());
1140                w.close("c:pt");
1141            }
1142            w.close("c:strCache");
1143            w.close("c:strRef");
1144            w.close("c:cat");
1145        }
1146        // 数值(c:val)
1147        w.open("c:val");
1148        w.open("c:numRef");
1149        w.leaf(
1150            "c:f",
1151            &format!(
1152                "Sheet1!${}$2:${}$100",
1153                col_letter(idx + 1),
1154                col_letter(idx + 1)
1155            ),
1156        );
1157        w.open("c:numCache");
1158        w.empty_with("c:formatCode", &[("val", "General")]);
1159        for (i, v) in s.values.iter().enumerate() {
1160            let i_s = i.to_string();
1161            let v_s = format_f64(*v);
1162            w.open_with("c:pt", &[("idx", i_s.as_str())]);
1163            w.leaf("c:v", v_s.as_str());
1164            w.close("c:pt");
1165        }
1166        w.close("c:numCache");
1167        w.close("c:numRef");
1168        w.close("c:val");
1169    }
1170    // 系列级数据标签(<c:dLbls>,OOXML 顺序:在 <c:val>/<c:yVal> 之后、</c:ser> 之前)。
1171    // 覆盖图表级 ChartData.data_labels 配置。
1172    if let Some(dl) = &s.data_labels {
1173        if !dl.is_empty() {
1174            dl.write_xml(w);
1175        }
1176    }
1177    w.close("c:ser");
1178}
1179
1180/// 把 1-based 索引转为 Excel 列字母(1=A, 2=B, ..., 27=AA)。
1181fn col_letter(n: usize) -> String {
1182    let mut s = String::new();
1183    let mut n = n;
1184    while n > 0 {
1185        n -= 1;
1186        let ch = (b'A' + (n % 26) as u8) as char;
1187        s.insert(0, ch);
1188        n /= 26;
1189    }
1190    s
1191}
1192
1193/// 格式化 f64 为 OOXML 兼容字符串(整数不输出小数点)。
1194fn format_f64(v: f64) -> String {
1195    if v.is_nan() || v.is_infinite() {
1196        return "0".to_string();
1197    }
1198    if v.fract() == 0.0 && v.abs() < 1e15 {
1199        format!("{}", v as i64)
1200    } else {
1201        format!("{}", v)
1202    }
1203}
1204
1205/// 从 `<c:chartSpace>` XML 解析回 [`Chart`] 模型(TODO-004 读路径)。
1206///
1207/// 与 [`Chart::to_xml`] 对称的解析函数,用于 `Presentation::from_opc` 阶段
1208/// 读取 `/ppt/charts/chartN.xml` part 内容后还原 Chart 结构。
1209///
1210/// # 解析策略
1211///
1212/// - **chart_type**:根据 `<c:plotArea>` 下的图表元素名(`c:barChart` / `c:lineChart` /
1213///   `c:pieChart` / `c:scatterChart` / `c:areaChart` / `c:radarChart` / `c:bubbleChart`)
1214///   判断;对 `c:barChart` 额外读取 `<c:barDir val="col|bar">` 区分 Column / Bar。
1215/// - **title**:从 `<c:title><a:tx><a:rich>...<a:t>文本</a:t>` 提取第一个文本节点。
1216/// - **categories**:从**第一个** `<c:ser>` 的 `<c:cat><c:strRef><c:strCache>` 提取类别标签
1217///   (所有系列共享同一类别轴,取第一个即可;散点图/气泡图无 `c:cat`,类别列表为空)。
1218/// - **series**:遍历每个 `<c:ser>`,提取:
1219///   - `name`:`<c:tx><c:strRef><c:strCache><c:pt><c:v>`
1220///   - `values`:`<c:val><c:numRef><c:numCache><c:pt><c:v>`(Y 值)
1221///   - `x_values`:`<c:xVal><c:numRef><c:numCache><c:pt><c:v>`(仅散点图/气泡图)
1222///   - `bubble_sizes`:`<c:bubbleSize><c:numRef><c:numCache><c:pt><c:v>`(仅气泡图)
1223///
1224/// # 容错
1225///
1226/// - 缺失 `<c:plotArea>` 或其中无已知图表元素 → 返回 `Column` 占位类型(避免阻塞 round-trip);
1227/// - 单个系列的 numCache/strCache 解析失败 → 该系列用空数据填充,不中断整体解析;
1228/// - 不解析 `<c:axId>` / `<c:catAx>` / `<c:valAx>` / `<c:legend>` 等装饰元素
1229///   (写出时由 [`Chart::to_xml`] 按图表类型自动重建)。
1230///
1231/// # 参数
1232/// - `xml`:完整的 `<c:chartSpace>` XML 字符串(chartN.xml 内容)。
1233///
1234/// # 返回值
1235/// - 成功:返回 [`Chart`],`rid` 字段为空字符串(由调用方根据 slide rels 填充);
1236/// - 失败:返回 `Error::Xml`,包含解析错误上下文。
1237///
1238/// # 示例
1239///
1240/// ```no_run
1241/// # use pptx_rs::oxml::chart::{Chart, ChartType};
1242/// let xml = std::fs::read_to_string("chart1.xml").unwrap();
1243/// let chart = Chart::parse_from_xml(&xml).expect("parse chart");
1244/// println!("type: {:?}, series: {}", chart.chart_type, chart.data.series.len());
1245/// ```
1246pub fn parse_from_xml(xml: &str) -> crate::Result<Chart> {
1247    use quick_xml::events::Event;
1248    use quick_xml::reader::Reader;
1249
1250    let mut chart_type = ChartType::Column;
1251    let mut title: Option<String> = None;
1252    let mut series: Vec<ChartSeries> = Vec::new();
1253    let mut categories: Vec<ChartCategory> = Vec::new();
1254
1255    let mut rd = Reader::from_str(xml);
1256    rd.config_mut().trim_text(true);
1257    let mut buf = Vec::new();
1258    // 当前所在的图表元素名(如 c:barChart / c:lineChart),用于判断是否已进入 plotArea 的图表区域。
1259    let mut in_chart_elem: Option<&'static str> = None;
1260    // 是否在 <c:plotArea> 内(用于检测次坐标轴的 <c:crosses val="max"/> 标记,
1261    // 该标记位于 plotArea 下的 <c:valAx> 子元素内,而非主图表元素 barChart/lineChart 内)。
1262    let mut in_plot_area = false;
1263    // barChart 的 barDir 值(col/bar),用于区分 Column / Bar。
1264    let mut bar_dir: Option<String> = None;
1265    // 是否在 c:title 内的 a:t 元素中(用于精确提取标题文本,避免误取 a:defRPr 等元素的属性文本)。
1266    let mut in_title_text = false;
1267    // 当前正在解析的系列缓冲区。
1268    let mut cur_ser: Option<ChartSeries> = None;
1269    // 当前系列的字段上下文:正在读取的字段(tx/cat/val/xVal/yVal/bubbleSize)。
1270    let mut ser_field: Option<SerField> = None;
1271    // 是否在 numCache/strCache 内(决定 c:pt 的 c:v 是数值还是字符串)。
1272    let mut in_cache = false;
1273    // 嵌入式 Excel 工作簿的关系 id(从 <c:externalData r:id="..."> 提取)。
1274    let mut external_data_rid: Option<String> = None;
1275    // 临时缓冲:当前 cache 内的值列表。
1276    let mut cur_values: Vec<f64> = Vec::new();
1277    let mut cur_strings: Vec<String> = Vec::new();
1278
1279    // ===== 数据标签(<c:dLbls>)解析上下文 =====
1280    //
1281    // dLbls 可出现在两个位置:
1282    //   1. 图表级:在 <c:barChart> 等图表元素内、<c:ser> 之前(plot 级配置);
1283    //   2. 系列级:在 <c:ser> 内 <c:val>/<c:yVal> 之后(覆盖图表级)。
1284    //
1285    // 通过 `dl_target` 区分当前 dLbls 归属:进入 c:ser 之前是 Chart,进入后是 Series。
1286    let mut chart_dl: Option<DataLabels> = None;
1287    let mut series_dl: Option<DataLabels> = None;
1288    // dLbls 解析目标:None=不在 dLbls 内, Some(Chart)=图表级, Some(Series)=系列级。
1289    let mut dl_target: Option<DlTarget> = None;
1290    // 是否检测到次坐标轴(<c:crosses val="max"/> 是 PowerPoint 识别次轴的关键标记)。
1291    // 若解析到次轴定义,所有非散点/非饼图系列的 secondary_axis 字段置为 true。
1292    let mut has_secondary_axis = false;
1293
1294    // ===== 组合图(secondary_chart_type)解析上下文,模块 D =====
1295    //
1296    // OOXML 组合图在 <c:plotArea> 下写出两个图表元素(如 <c:barChart> + <c:lineChart>),
1297    // 各自绑定不同的 valAx。第一个图表元素是主图表,第二个是次图表。
1298    //
1299    // - `primary_chart_seen`:是否已遇到过主图表元素(用于区分主/次);
1300    // - `secondary_chart_type`:次图表类型(解析完成后写入 ChartData.secondary_chart_type);
1301    // - `in_secondary_chart`:当前是否在次图表元素内(用于标记系列 secondary_axis=true)。
1302    //
1303    // 注:`in_chart_elem` 在离开主图表元素时被置为 None,因此遇到第二个图表元素时
1304    // 可通过 `primary_chart_seen && in_chart_elem.is_none()` 判定为次图表。
1305    let mut primary_chart_seen = false;
1306    let mut secondary_chart_type: Option<ChartType> = None;
1307    let mut in_secondary_chart = false;
1308    // 次图表的 barDir(区分次图表的 Column/Bar),与主图表 bar_dir 独立。
1309    let mut secondary_bar_dir: Option<String> = None;
1310
1311    // ===== 图例(<c:legend>)解析上下文 =====
1312    //
1313    // legend_position / legend_overlay 由子元素 c:legendPos / c:overlay 填充。
1314    // 离开 c:legend 时(End 事件)组装 Legend 并写入 legend_buf。
1315    // legend_buf = None 表示无 <c:legend> 元素(关闭图例,对应 ChartData.legend = None)。
1316    let mut legend_position: Option<LegendPosition> = None;
1317    let mut legend_overlay: Option<bool> = None;
1318    let mut in_legend = false;
1319    let mut legend_buf: Option<Legend> = None;
1320
1321    // ===== 系列样式(<c:spPr>)解析上下文 =====
1322    //
1323    // in_sp_pr = true 时,后续 <a:srgbClr val="RRGGBB"/> 写入 sppr_color。
1324    // 离开 c:spPr 时(End 事件)组装 SeriesFormat 并写入 cur_ser.format。
1325    let mut in_sp_pr = false;
1326    let mut cur_series_format: Option<SeriesFormat> = None;
1327    let mut sppr_color: Option<Color> = None;
1328
1329    /// 系列字段枚举(用于 `ser_field` 上下文)。
1330    #[derive(Copy, Clone, Debug, Eq, PartialEq)]
1331    enum SerField {
1332        /// `<c:tx>` 系列名。
1333        Name,
1334        /// `<c:cat>` 类别标签(仅取第一个系列的)。
1335        Cat,
1336        /// `<c:val>` 数值(Y 坐标)。
1337        Val,
1338        /// `<c:xVal>` X 坐标(散点/气泡图)。
1339        XVal,
1340        /// `<c:yVal>` Y 坐标(散点/气泡图,与 Val 同语义但元素名不同)。
1341        YVal,
1342        /// `<c:bubbleSize>` 气泡尺寸。
1343        BubbleSize,
1344    }
1345
1346    /// 数据标签归属(图表级 vs 系列级)。
1347    #[derive(Copy, Clone, Debug, Eq, PartialEq)]
1348    enum DlTarget {
1349        /// 图表级 dLbls(写在 <c:barChart> 等图表元素内、<c:ser> 之前)。
1350        Chart,
1351        /// 系列级 dLbls(写在 <c:ser> 内 <c:val>/<c:yVal> 之后,覆盖图表级)。
1352        Series,
1353    }
1354
1355    /// 从 local name 字节判断是否为已知图表元素,返回 (元素名, 对应 ChartType)。
1356    fn chart_elem_for(local: &[u8]) -> Option<(&'static str, ChartType)> {
1357        match local {
1358            b"barChart" => Some(("barChart", ChartType::Column)), // Column/Bar 由 barDir 后续区分
1359            b"lineChart" => Some(("lineChart", ChartType::Line)),
1360            b"pieChart" => Some(("pieChart", ChartType::Pie)),
1361            b"scatterChart" => Some(("scatterChart", ChartType::Scatter)),
1362            b"areaChart" => Some(("areaChart", ChartType::Area)),
1363            b"radarChart" => Some(("radarChart", ChartType::Radar)),
1364            b"bubbleChart" => Some(("bubbleChart", ChartType::Bubble)),
1365            _ => None,
1366        }
1367    }
1368
1369    /// 提取元素 local name(去命名空间前缀)。
1370    fn local_name_quick(name: &[u8]) -> &[u8] {
1371        match name.iter().position(|&b| b == b':') {
1372            Some(i) => &name[i + 1..],
1373            None => name,
1374        }
1375    }
1376
1377    loop {
1378        match rd.read_event_into(&mut buf) {
1379            Ok(Event::Start(e)) => {
1380                // 把 e.name() 绑定到 let,避免临时 QName 在 as_ref() 后 drop
1381                let name = e.name();
1382                let local = local_name_quick(name.as_ref());
1383                // 提取 <c:externalData r:id="..."> 的关系 id(TODO-004 Excel 嵌入)。
1384                //
1385                // OOXML 中 externalData 通常是 Start + <c:autoUpdate/> + End 形式,
1386                // 这里在 Start 事件中提取 r:id 属性即可。
1387                if local == b"externalData" {
1388                    for a in e.attributes().flatten() {
1389                        // 兼容三种写法:
1390                        // - `r:id="..."`(标准 OOXML, relationships 命名空间前缀)
1391                        // - `ns0:id="..."` / `xxx:id="..."`(其他命名空间前缀 + id 本地名)
1392                        // - 裸 `id="..."`(无命名空间前缀,部分工具不严格加 r:)
1393                        let key = a.key.as_ref();
1394                        if key == b"r:id" || key == b"id" || key.ends_with(b":id") {
1395                            external_data_rid = Some(
1396                                a.normalized_value(quick_xml::XmlVersion::Implicit1_0)
1397                                    .unwrap_or_default()
1398                                    .to_string(),
1399                            );
1400                        }
1401                    }
1402                }
1403                // 进入 <c:plotArea>:标记后续 crosses 检测生效
1404                if local == b"plotArea" {
1405                    in_plot_area = true;
1406                }
1407                // 进入图表元素(plotArea 下)。
1408                //
1409                // 组合图模式(模块 D):<c:plotArea> 下可能有两个图表元素,
1410                // 第一个是主图表(设置 chart_type),第二个是次图表(设置 secondary_chart_type)。
1411                // 通过 `primary_chart_seen` 标志区分主/次。
1412                if let Some((elem, ct)) = chart_elem_for(local) {
1413                    if primary_chart_seen && in_chart_elem.is_none() {
1414                        // 第二个图表元素 → 次图表(组合图模式)
1415                        secondary_chart_type = Some(ct);
1416                        in_secondary_chart = true;
1417                        secondary_bar_dir = None;
1418                    } else if !primary_chart_seen {
1419                        // 第一个图表元素 → 主图表
1420                        chart_type = ct;
1421                        primary_chart_seen = true;
1422                    }
1423                    in_chart_elem = Some(elem);
1424                    // barChart 需要后续 barDir 区分 Column/Bar,此处先占位 Column
1425                }
1426                // barDir 属性(区分 Column / Bar)。
1427                //
1428                // 主图表的 barDir 写入 bar_dir,次图表的写入 secondary_bar_dir,
1429                // 两者独立(避免次图表的 barDir 覆盖主图表的 Column/Bar 判定)。
1430                if local == b"barDir" && in_chart_elem.is_some() {
1431                    for a in e.attributes().flatten() {
1432                        if a.key.as_ref() == b"val" {
1433                            let v = a
1434                                .normalized_value(quick_xml::XmlVersion::Implicit1_0)
1435                                .unwrap_or_default()
1436                                .to_string();
1437                            if in_secondary_chart {
1438                                secondary_bar_dir = Some(v);
1439                            } else {
1440                                bar_dir = Some(v);
1441                            }
1442                        }
1443                    }
1444                }
1445                // 进入 c:title 内的 a:t 元素(精确匹配,避免误取 a:defRPr 等元素的属性文本)
1446                if local == b"t" {
1447                    in_title_text = true;
1448                }
1449                // 进入 c:ser
1450                if local == b"ser" && in_chart_elem.is_some() {
1451                    cur_ser = Some(ChartSeries::default());
1452                    // 重置系列级 dLbls 缓冲(每个系列独立)。
1453                    series_dl = None;
1454                }
1455                // 进入 c:spPr(系列级形状属性,控制颜色/填充/边框)。
1456                //
1457                // 仅在 c:ser 内的 spPr 才解析(plotArea 等其他位置的 spPr 忽略)。
1458                // 进入时初始化 cur_series_format 缓冲,后续 a:srgbClr 子元素填充颜色。
1459                if local == b"spPr" && cur_ser.is_some() {
1460                    in_sp_pr = true;
1461                    cur_series_format = Some(SeriesFormat::default());
1462                    sppr_color = None;
1463                }
1464                // 进入 c:dLbls:根据当前是否在 c:ser 内决定归属。
1465                //
1466                // - 在 c:ser 内 → 系列级(覆盖图表级);
1467                // - 不在 c:ser 内但在图表元素内 → 图表级。
1468                if local == b"dLbls" {
1469                    if cur_ser.is_some() {
1470                        dl_target = Some(DlTarget::Series);
1471                        series_dl = Some(DataLabels::default());
1472                    } else if in_chart_elem.is_some() {
1473                        dl_target = Some(DlTarget::Chart);
1474                        chart_dl = Some(DataLabels::default());
1475                    }
1476                }
1477                // 系列字段上下文
1478                if cur_ser.is_some() {
1479                    match local {
1480                        b"tx" => ser_field = Some(SerField::Name),
1481                        b"cat" => ser_field = Some(SerField::Cat),
1482                        b"val" => ser_field = Some(SerField::Val),
1483                        b"xVal" => ser_field = Some(SerField::XVal),
1484                        b"yVal" => ser_field = Some(SerField::YVal),
1485                        b"bubbleSize" => ser_field = Some(SerField::BubbleSize),
1486                        _ => {}
1487                    }
1488                }
1489                // 进入 numCache / strCache
1490                if matches!(local, b"numCache" | b"strCache") {
1491                    in_cache = true;
1492                    cur_values.clear();
1493                    cur_strings.clear();
1494                }
1495                // 检测次坐标轴的 <c:crosses val="max"/> 标记。
1496                //
1497                // PowerPoint 通过该元素识别次坐标轴(显示在右侧)。
1498                // 解析到该标记后,所有非散点/非饼图系列的 secondary_axis 字段在
1499                // 解析末尾被置为 true(写出时由 to_xml 重建次轴定义)。
1500                //
1501                // 注意:crosses 元素位于 <c:valAx> 内(plotArea 下,但不在主图表元素
1502                // barChart/lineChart 内),因此用 in_plot_area 而非 in_chart_elem 判断。
1503                if local == b"crosses" && in_plot_area {
1504                    for a in e.attributes().flatten() {
1505                        if a.key.as_ref() == b"val" {
1506                            let v = a
1507                                .normalized_value(quick_xml::XmlVersion::Implicit1_0)
1508                                .unwrap_or_default();
1509                            if v == "max" {
1510                                has_secondary_axis = true;
1511                            }
1512                        }
1513                    }
1514                }
1515                // ===== 图例(<c:legend>)解析 =====
1516                //
1517                // <c:legend> 是 <c:chart> 的子元素,位于 <c:plotArea> 之后。
1518                // 进入 c:legend 时设置 in_legend 标志,后续 legendPos / overlay
1519                // 子元素(无论 Start 还是 Empty 形式)在 in_legend 上下文内解析。
1520                if local == b"legend" {
1521                    in_legend = true;
1522                }
1523                // c:legendPos / c:overlay 的非自闭合形式(Start + End)。
1524                // 自闭合形式在下方 Empty 分支处理。
1525                if in_legend {
1526                    if local == b"legendPos" {
1527                        if let Some(v) = attr_val(&e, "val") {
1528                            legend_position = match v.as_str() {
1529                                "b" => Some(LegendPosition::Bottom),
1530                                "t" => Some(LegendPosition::Top),
1531                                "r" => Some(LegendPosition::Right),
1532                                "l" => Some(LegendPosition::Left),
1533                                _ => None,
1534                            };
1535                        }
1536                    }
1537                    if local == b"overlay" {
1538                        legend_overlay = parse_bool_val(&e);
1539                    }
1540                }
1541            }
1542            Ok(Event::Empty(e)) => {
1543                // 把 e.name() 绑定到 let,避免临时 QName 在 as_ref() 后 drop
1544                let name = e.name();
1545                let local = local_name_quick(name.as_ref());
1546                // barDir 自闭合形式。
1547                //
1548                // 与 Start 分支一致:主图表的 barDir 写入 bar_dir,
1549                // 次图表的写入 secondary_bar_dir(组合图模式,模块 D)。
1550                if local == b"barDir" && in_chart_elem.is_some() {
1551                    for a in e.attributes().flatten() {
1552                        if a.key.as_ref() == b"val" {
1553                            let v = a
1554                                .normalized_value(quick_xml::XmlVersion::Implicit1_0)
1555                                .unwrap_or_default()
1556                                .to_string();
1557                            if in_secondary_chart {
1558                                secondary_bar_dir = Some(v);
1559                            } else {
1560                                bar_dir = Some(v);
1561                            }
1562                        }
1563                    }
1564                }
1565                // c:pt 自闭合(无子元素,但通常 c:pt 是 Start + c:v + End)
1566                // c:v 自闭合形式(罕见但兼容)
1567                if local == b"v" && in_cache {
1568                    if let Some(v) = e.attributes().flatten().find(|a| a.key.as_ref() == b"val") {
1569                        let s = v
1570                            .normalized_value(quick_xml::XmlVersion::Implicit1_0)
1571                            .unwrap_or_default()
1572                            .to_string();
1573                        if let Ok(f) = s.parse::<f64>() {
1574                            cur_values.push(f);
1575                        }
1576                        cur_strings.push(s);
1577                    }
1578                }
1579                // ===== 数据标签子元素解析(仅在 dLbls 上下文内) =====
1580                //
1581                // 这些子元素均以自闭合形式出现(<c:showVal val="1"/>),
1582                // 统一在 Empty 事件中处理。归属由 `dl_target` 决定。
1583                if let Some(target) = dl_target.as_ref() {
1584                    // 取目标 dLbls 缓冲的可变引用(图表级或系列级)。
1585                    let dl_buf: &mut Option<DataLabels> = if *target == DlTarget::Chart {
1586                        &mut chart_dl
1587                    } else {
1588                        &mut series_dl
1589                    };
1590                    let dl = dl_buf.get_or_insert_with(DataLabels::default);
1591                    match local {
1592                        b"showVal" => {
1593                            dl.show_val = parse_bool_val(&e);
1594                        }
1595                        b"showCatName" => {
1596                            dl.show_cat_name = parse_bool_val(&e);
1597                        }
1598                        b"showSerName" => {
1599                            dl.show_ser_name = parse_bool_val(&e);
1600                        }
1601                        b"showLegendKey" => {
1602                            dl.show_legend_key = parse_bool_val(&e);
1603                        }
1604                        b"showPercent" => {
1605                            dl.show_percent = parse_bool_val(&e);
1606                        }
1607                        b"showBubbleSize" => {
1608                            dl.show_bubble_size = parse_bool_val(&e);
1609                        }
1610                        b"dLblPos" => {
1611                            if let Some(v) = attr_val(&e, "val") {
1612                                dl.position = LabelPosition::parse(&v);
1613                            }
1614                        }
1615                        b"separator" => {
1616                            if let Some(v) = attr_val(&e, "val") {
1617                                dl.separator = Some(v);
1618                            }
1619                        }
1620                        b"numFmt" => {
1621                            if let Some(fc) = attr_val(&e, "formatCode") {
1622                                dl.num_fmt = Some(fc);
1623                            }
1624                        }
1625                        _ => {}
1626                    }
1627                }
1628                // c:crosses 自闭合形式(部分工具输出自闭合的 <c:crosses val="max"/>)。
1629                // 同 Start 分支:用 in_plot_area 判断(crosses 在 <c:valAx> 内,不在主图表元素内)。
1630                if local == b"crosses" && in_plot_area {
1631                    if let Some(v) = attr_val(&e, "val") {
1632                        if v == "max" {
1633                            has_secondary_axis = true;
1634                        }
1635                    }
1636                }
1637                // ===== spPr 子元素解析(<a:srgbClr val="RRGGBB"/>) =====
1638                //
1639                // 仅在 in_sp_pr 上下文内解析(避免误取其他位置的 srgbClr)。
1640                // 解析到的颜色暂存 sppr_color,离开 c:spPr 时组装为 Fill::Solid。
1641                if in_sp_pr && local == b"srgbClr" {
1642                    if let Some(v) = attr_val(&e, "val") {
1643                        if let Some(c) = parse_hex_color(&v) {
1644                            sppr_color = Some(c);
1645                        }
1646                    }
1647                }
1648                // ===== 图例子元素自闭合形式解析 =====
1649                //
1650                // <c:legendPos val="b"/> / <c:overlay val="0"/> 通常以自闭合形式出现。
1651                // 仅在 in_legend 上下文内解析(避免误取轴标题内的 overlay等同名元素)。
1652                if in_legend {
1653                    if local == b"legendPos" {
1654                        if let Some(v) = attr_val(&e, "val") {
1655                            legend_position = match v.as_str() {
1656                                "b" => Some(LegendPosition::Bottom),
1657                                "t" => Some(LegendPosition::Top),
1658                                "r" => Some(LegendPosition::Right),
1659                                "l" => Some(LegendPosition::Left),
1660                                _ => None,
1661                            };
1662                        }
1663                    }
1664                    if local == b"overlay" {
1665                        legend_overlay = parse_bool_val(&e);
1666                    }
1667                }
1668            }
1669            Ok(Event::Text(t)) => {
1670                // 标题文本(c:title 内的第一个 a:t 元素的文本内容)
1671                if in_title_text && title.is_none() {
1672                    // quick-xml 0.40: BytesText::unescape() 方法已移除,
1673                    // 改用 quick_xml::escape::unescape 函数(接受 &str)。
1674                    // BytesText 的 Deref 目标是 [u8],需要先转成 &str。
1675                    let text_str = std::str::from_utf8(t.as_ref()).unwrap_or("");
1676                    let text = quick_xml::escape::unescape(text_str)
1677                        .unwrap_or_default()
1678                        .to_string();
1679                    if !text.is_empty() {
1680                        title = Some(text);
1681                    }
1682                }
1683                // cache 内的文本(c:v 的文本内容)
1684                if in_cache {
1685                    let text_str = std::str::from_utf8(t.as_ref()).unwrap_or("");
1686                    let text = quick_xml::escape::unescape(text_str)
1687                        .unwrap_or_default()
1688                        .to_string();
1689                    if !text.is_empty() {
1690                        if let Ok(f) = text.parse::<f64>() {
1691                            cur_values.push(f);
1692                        }
1693                        cur_strings.push(text);
1694                    }
1695                }
1696            }
1697            Ok(Event::End(e)) => {
1698                // 把 e.name() 绑定到 let,避免临时 QName 在 as_ref() 后 drop
1699                let name = e.name();
1700                let local = local_name_quick(name.as_ref());
1701                // 离开 <c:plotArea>:清空 in_plot_area 标志
1702                if local == b"plotArea" {
1703                    in_plot_area = false;
1704                }
1705                // 离开图表元素。
1706                //
1707                // 主图表:barChart + barDir=bar → ChartType::Bar(否则保持 Column)。
1708                // 次图表:barChart + secondary_bar_dir=bar → secondary_chart_type = Bar。
1709                // 离开时清空 in_chart_elem 和 in_secondary_chart 标志。
1710                if let Some(elem) = in_chart_elem {
1711                    if local == elem.as_bytes() {
1712                        if !in_secondary_chart {
1713                            // 主图表:最终确定 chart_type
1714                            if elem == "barChart" {
1715                                if let Some(dir) = &bar_dir {
1716                                    if dir == "bar" {
1717                                        chart_type = ChartType::Bar;
1718                                    }
1719                                }
1720                            }
1721                        } else {
1722                            // 次图表:最终确定 secondary_chart_type
1723                            if elem == "barChart" {
1724                                if let Some(dir) = &secondary_bar_dir {
1725                                    if dir == "bar" {
1726                                        secondary_chart_type = Some(ChartType::Bar);
1727                                    }
1728                                }
1729                            }
1730                        }
1731                        in_chart_elem = None;
1732                        in_secondary_chart = false;
1733                    }
1734                }
1735                // 离开 a:t 元素
1736                if local == b"t" {
1737                    in_title_text = false;
1738                }
1739                // 离开 c:dLbls:清空 dl_target 上下文(缓冲已由子元素 Empty 事件填充)。
1740                if local == b"dLbls" {
1741                    dl_target = None;
1742                }
1743                // 离开 c:spPr:组装 SeriesFormat 写入当前系列。
1744                //
1745                // 若解析到 srgbClr 颜色,填充为 Fill::Solid;否则保持默认 Fill::Inherit。
1746                // 组装完成后写入 cur_ser.format,供 round-trip 保真。
1747                if local == b"spPr" && in_sp_pr {
1748                    if let Some(color) = sppr_color.take() {
1749                        if let Some(fmt) = cur_series_format.as_mut() {
1750                            fmt.fill = Fill::Solid(color);
1751                        }
1752                    }
1753                    if let Some(fmt) = cur_series_format.take() {
1754                        if let Some(s) = cur_ser.as_mut() {
1755                            s.format = Some(fmt);
1756                        }
1757                    }
1758                    in_sp_pr = false;
1759                }
1760                // 离开 c:legend:组装 Legend 写入 legend_buf。
1761                //
1762                // 即使未解析到 legendPos(部分工具省略该子元素),也认为是 has_legend,
1763                // 用默认位置(Bottom)——这与 PowerPoint 行为一致(有 <c:legend> 即显示图例)。
1764                if local == b"legend" {
1765                    let pos = legend_position.take().unwrap_or_default();
1766                    legend_buf = Some(Legend {
1767                        position: pos,
1768                        overlay: legend_overlay.take().unwrap_or(false),
1769                    });
1770                    in_legend = false;
1771                }
1772                // 离开 numCache/strCache:把缓冲写入当前字段
1773                if matches!(local, b"numCache" | b"strCache") && cur_ser.is_some() {
1774                    let field = ser_field.take();
1775                    if let Some(f) = field {
1776                        match f {
1777                            SerField::Name => {
1778                                // 系列名取第一个字符串
1779                                if let Some(s) = cur_strings.first() {
1780                                    if let Some(s_obj) = cur_ser.as_mut() {
1781                                        s_obj.name = s.clone();
1782                                    }
1783                                }
1784                                ser_field = None;
1785                            }
1786                            SerField::Cat => {
1787                                // 类别标签:仅第一个系列的 cat 写入 categories
1788                                if categories.is_empty() {
1789                                    for s in &cur_strings {
1790                                        categories.push(ChartCategory::new(s.clone()));
1791                                    }
1792                                }
1793                                ser_field = None;
1794                            }
1795                            SerField::Val | SerField::YVal => {
1796                                if let Some(s_obj) = cur_ser.as_mut() {
1797                                    s_obj.values = cur_values.clone();
1798                                }
1799                                ser_field = None;
1800                            }
1801                            SerField::XVal => {
1802                                if let Some(s_obj) = cur_ser.as_mut() {
1803                                    s_obj.x_values = Some(cur_values.clone());
1804                                }
1805                                ser_field = None;
1806                            }
1807                            SerField::BubbleSize => {
1808                                if let Some(s_obj) = cur_ser.as_mut() {
1809                                    s_obj.bubble_sizes = Some(cur_values.clone());
1810                                }
1811                                ser_field = None;
1812                            }
1813                        }
1814                    }
1815                    in_cache = false;
1816                    cur_values.clear();
1817                    cur_strings.clear();
1818                }
1819                // 离开 c:ser:把当前系列推入 series 列表,并写入系列级 dLbls。
1820                //
1821                // 组合图模式(模块 D):若当前在次图表元素内(in_secondary_chart=true),
1822                // 该系列标记为 secondary_axis=true,写出时绑定次 valAx。
1823                if local == b"ser" && cur_ser.is_some() {
1824                    if let Some(mut s_obj) = cur_ser.take() {
1825                        // 系列级数据标签:覆盖图表级配置。
1826                        if let Some(dl) = series_dl.take() {
1827                            if !dl.is_empty() {
1828                                s_obj.data_labels = Some(dl);
1829                            }
1830                        }
1831                        // 次图表内的系列标记为次轴绑定
1832                        if in_secondary_chart {
1833                            s_obj.secondary_axis = true;
1834                        }
1835                        series.push(s_obj);
1836                    }
1837                    ser_field = None;
1838                    // 清理系列级 dLbls 上下文,避免泄漏到下一个系列。
1839                    series_dl = None;
1840                    if dl_target == Some(DlTarget::Series) {
1841                        dl_target = None;
1842                    }
1843                }
1844            }
1845            Ok(Event::Eof) => break,
1846            Err(e) => return Err(crate::Error::Xml(format!("chart parse_from_xml: {e}"))),
1847            _ => {}
1848        }
1849        buf.clear();
1850    }
1851
1852    // 次坐标轴回退策略(模块 D)。
1853    //
1854    // 优先级:
1855    // 1. 组合图模式(secondary_chart_type.is_some()):次图表元素内的系列已标记
1856    //    secondary_axis=true,无需回退处理;
1857    // 2. 回退策略(secondary_chart_type.is_none() && has_secondary_axis):
1858    //    检测到 <c:crosses val="max"/> 但无第二个图表元素(旧式次轴实现),
1859    //    把所有非散点/非饼图系列标记为 secondary_axis=true(旧行为)。
1860    //
1861    // 对散点图/气泡图/饼图忽略(OOXML 规范约束:这些类型不支持次坐标轴)。
1862    if has_secondary_axis
1863        && secondary_chart_type.is_none()
1864        && !chart_type.is_xy_chart()
1865        && !matches!(chart_type, ChartType::Pie)
1866    {
1867        for s in series.iter_mut() {
1868            s.secondary_axis = true;
1869        }
1870    }
1871
1872    let data = ChartData {
1873        categories,
1874        series,
1875        title,
1876        data_labels: chart_dl.filter(|dl| !dl.is_empty()),
1877        legend: legend_buf,
1878        secondary_chart_type,
1879        ..Default::default()
1880    };
1881    Ok(Chart {
1882        chart_type,
1883        data,
1884        rid: String::new(),
1885        external_data_rid,
1886    })
1887}
1888
1889/// 从 `<c:showXxx val="1|0|true|false"/>` 提取 bool 值。
1890///
1891/// OOXML 规范要求 `val` 属性值为 "0" 或 "1"(XML Schema boolean),
1892/// 但部分工具(如 LibreOffice)会输出 "true" / "false",这里一并兼容。
1893fn parse_bool_val(e: &quick_xml::events::BytesStart<'_>) -> Option<bool> {
1894    for a in e.attributes().flatten() {
1895        if a.key.as_ref() == b"val" {
1896            let v = a
1897                .normalized_value(quick_xml::XmlVersion::Implicit1_0)
1898                .unwrap_or_default();
1899            return match v.as_ref() {
1900                "1" | "true" | "True" => Some(true),
1901                "0" | "false" | "False" => Some(false),
1902                _ => None,
1903            };
1904        }
1905    }
1906    None
1907}
1908
1909/// 从元素提取指定属性值(如 `val` / `formatCode`),未找到返回 `None`。
1910///
1911/// 兼容带命名空间前缀的写法(如 `r:val`),用 local name 后缀匹配。
1912fn attr_val(e: &quick_xml::events::BytesStart<'_>, key: &str) -> Option<String> {
1913    let key_bytes = key.as_bytes();
1914    // 构造 ":key" 字节序列,用于后缀匹配(如匹配 "r:val" 中的 ":val")。
1915    let suffix: Vec<u8> = std::iter::once(b':')
1916        .chain(key_bytes.iter().copied())
1917        .collect();
1918    for a in e.attributes().flatten() {
1919        let k = a.key.as_ref();
1920        if k == key_bytes || k.ends_with(&suffix) {
1921            return Some(
1922                a.normalized_value(quick_xml::XmlVersion::Implicit1_0)
1923                    .unwrap_or_default()
1924                    .to_string(),
1925            );
1926        }
1927    }
1928    None
1929}
1930
1931/// 从 "RRGGBB" 字符串解析为 `Color::RGB(RGBColor)`。
1932///
1933/// 兼容 3 位简写("RGB" → "RRGGBB")和 6 位标准写法。无效输入返回 `None`。
1934///
1935/// # 参数
1936/// - `s`:十六进制颜色字符串(如 `"4F81BD"` / `"FF0000"`)。
1937///
1938/// # 返回值
1939/// - `Some(Color::RGB(...))`:解析成功;
1940/// - `None`:输入长度不为 3 或 6,或包含非法十六进制字符。
1941fn parse_hex_color(s: &str) -> Option<Color> {
1942    use crate::units::RGBColor;
1943    let s = s.trim();
1944    let (r, g, b) = if s.len() == 6 {
1945        (
1946            u8::from_str_radix(&s[0..2], 16).ok()?,
1947            u8::from_str_radix(&s[2..4], 16).ok()?,
1948            u8::from_str_radix(&s[4..6], 16).ok()?,
1949        )
1950    } else if s.len() == 3 {
1951        (
1952            u8::from_str_radix(&s[0..1].repeat(2), 16).ok()?,
1953            u8::from_str_radix(&s[1..2].repeat(2), 16).ok()?,
1954            u8::from_str_radix(&s[2..3].repeat(2), 16).ok()?,
1955        )
1956    } else {
1957        return None;
1958    };
1959    Some(Color::RGB(RGBColor(r, g, b)))
1960}
1961
1962#[cfg(test)]
1963mod tests {
1964    use super::*;
1965
1966    /// 柱状图最小 XML 包含必要元素。
1967    #[test]
1968    fn column_chart_minimal_xml() {
1969        let data = ChartData {
1970            categories: vec![ChartCategory::new("Q1"), ChartCategory::new("Q2")],
1971            series: vec![ChartSeries::new("Sales", vec![10.0, 20.0])],
1972            title: Some("Revenue".to_string()),
1973            data_labels: None,
1974            ..Default::default()
1975        };
1976        let chart = Chart::new(ChartType::Column, data);
1977        let xml = chart.to_xml();
1978        assert!(xml.contains("<c:chartSpace"), "xml: {}", xml);
1979        assert!(xml.contains("<c:barChart>"), "xml: {}", xml);
1980        assert!(xml.contains("barDir"), "xml: {}", xml);
1981        assert!(xml.contains("val=\"col\""), "xml: {}", xml);
1982        assert!(xml.contains("<c:ser>"), "xml: {}", xml);
1983        assert!(xml.contains("<c:catAx>"), "xml: {}", xml);
1984        assert!(xml.contains("<c:valAx>"), "xml: {}", xml);
1985        assert!(xml.contains("Revenue"), "xml: {}", xml);
1986    }
1987
1988    /// 饼图不写出 catAx/valAx。
1989    #[test]
1990    fn pie_chart_no_axes() {
1991        let data = ChartData {
1992            categories: vec![ChartCategory::new("A"), ChartCategory::new("B")],
1993            series: vec![ChartSeries::new("X", vec![1.0, 2.0])],
1994            title: None,
1995            data_labels: None,
1996            ..Default::default()
1997        };
1998        let xml = Chart::new(ChartType::Pie, data).to_xml();
1999        assert!(xml.contains("<c:pieChart>"), "xml: {}", xml);
2000        assert!(!xml.contains("<c:catAx>"), "xml: {}", xml);
2001        assert!(!xml.contains("<c:valAx>"), "xml: {}", xml);
2002        assert!(xml.contains("varyColors"), "xml: {}", xml);
2003    }
2004
2005    /// 折线图使用 lineChart 元素。
2006    #[test]
2007    fn line_chart_uses_line_chart_element() {
2008        let data = ChartData {
2009            categories: vec![ChartCategory::new("X1"), ChartCategory::new("X2")],
2010            series: vec![ChartSeries::new("Y", vec![1.5, 2.5])],
2011            title: None,
2012            data_labels: None,
2013            ..Default::default()
2014        };
2015        let xml = Chart::new(ChartType::Line, data).to_xml();
2016        assert!(xml.contains("<c:lineChart>"), "xml: {}", xml);
2017        assert!(!xml.contains("<c:barChart>"), "xml: {}", xml);
2018        // 浮点值应正确序列化
2019        assert!(xml.contains("1.5"), "xml: {}", xml);
2020        assert!(xml.contains("2.5"), "xml: {}", xml);
2021    }
2022
2023    /// 散点图使用 scatterChart 元素 + c:xVal/c:yVal + 两个 valAx。
2024    #[test]
2025    fn scatter_chart_uses_scatter_elements() {
2026        let data = ChartData {
2027            categories: vec![], // 散点图忽略 categories
2028            series: vec![ChartSeries::new_scatter(
2029                "Series1",
2030                vec![1.0, 2.0, 3.0], // X
2031                vec![2.0, 4.0, 6.0], // Y
2032            )],
2033            title: Some("Scatter".to_string()),
2034            data_labels: None,
2035            ..Default::default()
2036        };
2037        let xml = Chart::new(ChartType::Scatter, data).to_xml();
2038        assert!(xml.contains("<c:scatterChart>"), "xml: {}", xml);
2039        assert!(xml.contains("scatterStyle"), "xml: {}", xml);
2040        assert!(xml.contains("<c:xVal>"), "xml: {}", xml);
2041        assert!(xml.contains("<c:yVal>"), "xml: {}", xml);
2042        // 散点图不应有 catAx
2043        assert!(!xml.contains("<c:catAx>"), "xml: {}", xml);
2044        // 散点图应有两个 valAx
2045        let val_ax_count = xml.matches("<c:valAx>").count();
2046        assert_eq!(
2047            val_ax_count, 2,
2048            "expected 2 valAx, got {}: {}",
2049            val_ax_count, xml
2050        );
2051        // 散点图不应有 c:cat
2052        assert!(!xml.contains("<c:cat>"), "xml: {}", xml);
2053        // X 坐标值应正确序列化
2054        assert!(xml.contains(">1<"), "xml: {}", xml);
2055        assert!(xml.contains(">3<"), "xml: {}", xml);
2056    }
2057
2058    /// 面积图使用 areaChart 元素 + grouping=standard + catAx + valAx。
2059    #[test]
2060    fn area_chart_uses_area_elements() {
2061        let data = ChartData {
2062            categories: vec![ChartCategory::new("A"), ChartCategory::new("B")],
2063            series: vec![ChartSeries::new("S1", vec![10.0, 20.0])],
2064            title: None,
2065            data_labels: None,
2066            ..Default::default()
2067        };
2068        let xml = Chart::new(ChartType::Area, data).to_xml();
2069        assert!(xml.contains("<c:areaChart>"), "xml: {}", xml);
2070        assert!(xml.contains("grouping"), "xml: {}", xml);
2071        assert!(xml.contains("val=\"standard\""), "xml: {}", xml);
2072        assert!(xml.contains("<c:catAx>"), "xml: {}", xml);
2073        assert!(xml.contains("<c:valAx>"), "xml: {}", xml);
2074        // 面积图应有 c:cat(类别标签)
2075        assert!(xml.contains("<c:cat>"), "xml: {}", xml);
2076    }
2077
2078    /// 雷达图使用 radarChart 元素 + radarStyle=marker + catAx + valAx。
2079    #[test]
2080    fn radar_chart_uses_radar_elements() {
2081        let data = ChartData {
2082            categories: vec![
2083                ChartCategory::new("速度"),
2084                ChartCategory::new("力量"),
2085                ChartCategory::new("技巧"),
2086            ],
2087            series: vec![ChartSeries::new("选手A", vec![8.0, 6.0, 9.0])],
2088            title: Some("Radar".to_string()),
2089            data_labels: None,
2090            ..Default::default()
2091        };
2092        let xml = Chart::new(ChartType::Radar, data).to_xml();
2093        assert!(xml.contains("<c:radarChart>"), "xml: {}", xml);
2094        assert!(xml.contains("radarStyle"), "xml: {}", xml);
2095        assert!(xml.contains("val=\"marker\""), "xml: {}", xml);
2096        // 雷达图使用 catAx + valAx(与柱/线相同)
2097        assert!(xml.contains("<c:catAx>"), "xml: {}", xml);
2098        assert!(xml.contains("<c:valAx>"), "xml: {}", xml);
2099        // 雷达图有 c:cat(类别标签)
2100        assert!(xml.contains("<c:cat>"), "xml: {}", xml);
2101        // 雷达图不应有散点图元素
2102        assert!(!xml.contains("<c:xVal>"), "xml: {}", xml);
2103        assert!(!xml.contains("<c:bubbleSize>"), "xml: {}", xml);
2104    }
2105
2106    /// 气泡图使用 bubbleChart 元素 + bubbleScale + c:xVal/c:yVal/c:bubbleSize + 两个 valAx。
2107    #[test]
2108    fn bubble_chart_uses_bubble_elements() {
2109        let data = ChartData {
2110            categories: vec![],
2111            series: vec![ChartSeries::new_bubble(
2112                "S1",
2113                vec![1.0, 2.0, 3.0],    // x_values
2114                vec![10.0, 20.0, 30.0], // y_values
2115                vec![5.0, 15.0, 25.0],  // bubble_sizes
2116            )],
2117            title: Some("Bubble".to_string()),
2118            data_labels: None,
2119            ..Default::default()
2120        };
2121        let xml = Chart::new(ChartType::Bubble, data).to_xml();
2122        assert!(xml.contains("<c:bubbleChart>"), "xml: {}", xml);
2123        assert!(xml.contains("bubbleScale"), "xml: {}", xml);
2124        assert!(xml.contains("val=\"100\""), "xml: {}", xml);
2125        // 气泡图使用 c:xVal / c:yVal / c:bubbleSize
2126        assert!(xml.contains("<c:xVal>"), "xml: {}", xml);
2127        assert!(xml.contains("<c:yVal>"), "xml: {}", xml);
2128        assert!(xml.contains("<c:bubbleSize>"), "xml: {}", xml);
2129        // 气泡图使用两个 valAx(无 catAx)
2130        assert!(!xml.contains("<c:catAx>"), "xml: {}", xml);
2131        assert!(xml.contains("<c:valAx>"), "xml: {}", xml);
2132        // 验证气泡尺寸值被正确写出
2133        assert!(xml.contains(">5<"), "xml: {}", xml);
2134        assert!(xml.contains(">25<"), "xml: {}", xml);
2135    }
2136
2137    /// col_letter 正确转换。
2138    #[test]
2139    fn col_letter_basic() {
2140        assert_eq!(col_letter(1), "A");
2141        assert_eq!(col_letter(2), "B");
2142        assert_eq!(col_letter(26), "Z");
2143        assert_eq!(col_letter(27), "AA");
2144        assert_eq!(col_letter(28), "AB");
2145    }
2146
2147    /// format_f64 整数无小数点。
2148    #[test]
2149    fn format_f64_integer() {
2150        assert_eq!(format_f64(10.0), "10");
2151        assert_eq!(format_f64(1.5), "1.5");
2152        assert_eq!(format_f64(f64::NAN), "0");
2153    }
2154
2155    // ===================== TODO-004 图表读路径测试 =====================
2156
2157    /// 柱状图 round-trip:to_xml → parse_from_xml → 字段一致。
2158    ///
2159    /// 覆盖:Column 类型识别(barDir=col)、标题、类别、单系列数值。
2160    #[test]
2161    fn parse_column_chart_round_trip() {
2162        let original = Chart::new(
2163            ChartType::Column,
2164            ChartData {
2165                categories: vec![
2166                    ChartCategory::new("Q1"),
2167                    ChartCategory::new("Q2"),
2168                    ChartCategory::new("Q3"),
2169                ],
2170                series: vec![ChartSeries::new("Sales", vec![10.0, 20.0, 30.0])],
2171                title: Some("Revenue".to_string()),
2172                data_labels: None,
2173                ..Default::default()
2174            },
2175        );
2176        let xml = original.to_xml();
2177        let parsed = Chart::parse_from_xml(&xml).expect("parse column chart");
2178
2179        assert_eq!(parsed.chart_type, ChartType::Column);
2180        assert_eq!(parsed.data.title.as_deref(), Some("Revenue"));
2181        assert_eq!(parsed.data.categories.len(), 3);
2182        assert_eq!(parsed.data.categories[0].name, "Q1");
2183        assert_eq!(parsed.data.categories[2].name, "Q3");
2184        assert_eq!(parsed.data.series.len(), 1);
2185        assert_eq!(parsed.data.series[0].name, "Sales");
2186        assert_eq!(parsed.data.series[0].values, vec![10.0, 20.0, 30.0]);
2187    }
2188
2189    /// 条形图 round-trip:barDir=bar 区分 Bar 与 Column。
2190    ///
2191    /// 这是 parse_from_xml 的关键边界:barChart 元素本身不区分柱/条,
2192    /// 必须依靠 barDir 属性后续修正 chart_type。
2193    #[test]
2194    fn parse_bar_chart_distinguishes_bar_from_column() {
2195        let original = Chart::new(
2196            ChartType::Bar,
2197            ChartData {
2198                categories: vec![ChartCategory::new("A"), ChartCategory::new("B")],
2199                series: vec![ChartSeries::new("S1", vec![1.0, 2.0])],
2200                title: None,
2201                data_labels: None,
2202                ..Default::default()
2203            },
2204        );
2205        let xml = original.to_xml();
2206        let parsed = Chart::parse_from_xml(&xml).expect("parse bar chart");
2207
2208        // 必须识别为 Bar 而非 Column
2209        assert_eq!(parsed.chart_type, ChartType::Bar);
2210        assert_ne!(parsed.chart_type, ChartType::Column);
2211        assert_eq!(parsed.data.series.len(), 1);
2212        assert_eq!(parsed.data.series[0].values, vec![1.0, 2.0]);
2213    }
2214
2215    /// 折线图 round-trip:lineChart 元素识别 + 浮点数保留。
2216    #[test]
2217    fn parse_line_chart_round_trip() {
2218        let original = Chart::new(
2219            ChartType::Line,
2220            ChartData {
2221                categories: vec![ChartCategory::new("X1"), ChartCategory::new("X2")],
2222                series: vec![ChartSeries::new("Y", vec![1.5, 2.5])],
2223                title: None,
2224                data_labels: None,
2225                ..Default::default()
2226            },
2227        );
2228        let xml = original.to_xml();
2229        let parsed = Chart::parse_from_xml(&xml).expect("parse line chart");
2230
2231        assert_eq!(parsed.chart_type, ChartType::Line);
2232        assert_eq!(parsed.data.categories.len(), 2);
2233        assert_eq!(parsed.data.series[0].name, "Y");
2234        // 浮点值必须正确解析
2235        assert_eq!(parsed.data.series[0].values, vec![1.5, 2.5]);
2236    }
2237
2238    /// 饼图 round-trip:pieChart 元素识别 + categories 保留(饼图也写 c:cat)。
2239    #[test]
2240    fn parse_pie_chart_round_trip() {
2241        let original = Chart::new(
2242            ChartType::Pie,
2243            ChartData {
2244                categories: vec![ChartCategory::new("A"), ChartCategory::new("B")],
2245                series: vec![ChartSeries::new("X", vec![1.0, 2.0])],
2246                title: None,
2247                data_labels: None,
2248                ..Default::default()
2249            },
2250        );
2251        let xml = original.to_xml();
2252        let parsed = Chart::parse_from_xml(&xml).expect("parse pie chart");
2253
2254        assert_eq!(parsed.chart_type, ChartType::Pie);
2255        // 饼图 to_xml 也写 c:cat(line 654-669),所以解析后 categories 应保留
2256        assert_eq!(parsed.data.categories.len(), 2);
2257        assert_eq!(parsed.data.categories[0].name, "A");
2258        assert_eq!(parsed.data.categories[1].name, "B");
2259        assert_eq!(parsed.data.series.len(), 1);
2260        assert_eq!(parsed.data.series[0].values, vec![1.0, 2.0]);
2261    }
2262
2263    /// 散点图 round-trip:scatterChart 元素 + c:xVal/c:yVal 解析。
2264    ///
2265    /// 散点图无 c:cat,使用 c:xVal / c:yVal 提供坐标。
2266    #[test]
2267    fn parse_scatter_chart_round_trip() {
2268        let original = Chart::new(
2269            ChartType::Scatter,
2270            ChartData {
2271                categories: vec![],
2272                series: vec![ChartSeries::new_scatter(
2273                    "Series1",
2274                    vec![1.0, 2.0, 3.0], // X
2275                    vec![2.0, 4.0, 6.0], // Y
2276                )],
2277                title: Some("Scatter".to_string()),
2278                data_labels: None,
2279                ..Default::default()
2280            },
2281        );
2282        let xml = original.to_xml();
2283        let parsed = Chart::parse_from_xml(&xml).expect("parse scatter chart");
2284
2285        assert_eq!(parsed.chart_type, ChartType::Scatter);
2286        assert_eq!(parsed.data.title.as_deref(), Some("Scatter"));
2287        assert_eq!(parsed.data.series.len(), 1);
2288        let s = &parsed.data.series[0];
2289        assert_eq!(s.name, "Series1");
2290        // Y 坐标写入 values
2291        assert_eq!(s.values, vec![2.0, 4.0, 6.0]);
2292        // X 坐标写入 x_values
2293        assert_eq!(s.x_values.as_deref(), Some(&[1.0, 2.0, 3.0][..]));
2294        // 散点图无 bubble_sizes
2295        assert!(s.bubble_sizes.is_none());
2296    }
2297
2298    /// 气泡图 round-trip:bubbleChart 元素 + c:xVal/c:yVal/c:bubbleSize 解析。
2299    #[test]
2300    fn parse_bubble_chart_round_trip() {
2301        let original = Chart::new(
2302            ChartType::Bubble,
2303            ChartData {
2304                categories: vec![],
2305                series: vec![ChartSeries::new_bubble(
2306                    "S1",
2307                    vec![1.0, 2.0, 3.0],    // x_values
2308                    vec![10.0, 20.0, 30.0], // y_values
2309                    vec![5.0, 15.0, 25.0],  // bubble_sizes
2310                )],
2311                title: Some("Bubble".to_string()),
2312                data_labels: None,
2313                ..Default::default()
2314            },
2315        );
2316        let xml = original.to_xml();
2317        let parsed = Chart::parse_from_xml(&xml).expect("parse bubble chart");
2318
2319        assert_eq!(parsed.chart_type, ChartType::Bubble);
2320        assert_eq!(parsed.data.series.len(), 1);
2321        let s = &parsed.data.series[0];
2322        assert_eq!(s.name, "S1");
2323        assert_eq!(s.values, vec![10.0, 20.0, 30.0]);
2324        assert_eq!(s.x_values.as_deref(), Some(&[1.0, 2.0, 3.0][..]));
2325        assert_eq!(s.bubble_sizes.as_deref(), Some(&[5.0, 15.0, 25.0][..]));
2326    }
2327
2328    /// 雷达图 round-trip:radarChart 元素识别 + 类别标签保留。
2329    #[test]
2330    fn parse_radar_chart_round_trip() {
2331        let original = Chart::new(
2332            ChartType::Radar,
2333            ChartData {
2334                categories: vec![
2335                    ChartCategory::new("速度"),
2336                    ChartCategory::new("力量"),
2337                    ChartCategory::new("技巧"),
2338                ],
2339                series: vec![ChartSeries::new("选手A", vec![8.0, 6.0, 9.0])],
2340                title: Some("Radar".to_string()),
2341                data_labels: None,
2342                ..Default::default()
2343            },
2344        );
2345        let xml = original.to_xml();
2346        let parsed = Chart::parse_from_xml(&xml).expect("parse radar chart");
2347
2348        assert_eq!(parsed.chart_type, ChartType::Radar);
2349        assert_eq!(parsed.data.categories.len(), 3);
2350        assert_eq!(parsed.data.categories[0].name, "速度");
2351        assert_eq!(parsed.data.series[0].values, vec![8.0, 6.0, 9.0]);
2352    }
2353
2354    /// 多系列柱状图 round-trip:验证系列顺序与名称。
2355    #[test]
2356    fn parse_multi_series_column_chart() {
2357        let original = Chart::new(
2358            ChartType::Column,
2359            ChartData {
2360                categories: vec![ChartCategory::new("A"), ChartCategory::new("B")],
2361                series: vec![
2362                    ChartSeries::new("S1", vec![1.0, 2.0]),
2363                    ChartSeries::new("S2", vec![3.0, 4.0]),
2364                    ChartSeries::new("S3", vec![5.0, 6.0]),
2365                ],
2366                title: None,
2367                data_labels: None,
2368                ..Default::default()
2369            },
2370        );
2371        let xml = original.to_xml();
2372        let parsed = Chart::parse_from_xml(&xml).expect("parse multi-series chart");
2373
2374        assert_eq!(parsed.data.series.len(), 3);
2375        assert_eq!(parsed.data.series[0].name, "S1");
2376        assert_eq!(parsed.data.series[1].name, "S2");
2377        assert_eq!(parsed.data.series[2].name, "S3");
2378        assert_eq!(parsed.data.series[2].values, vec![5.0, 6.0]);
2379    }
2380
2381    /// 无标题图表 round-trip:title 字段为 None。
2382    #[test]
2383    fn parse_chart_without_title() {
2384        let original = Chart::new(
2385            ChartType::Column,
2386            ChartData {
2387                categories: vec![ChartCategory::new("A")],
2388                series: vec![ChartSeries::new("S", vec![1.0])],
2389                title: None,
2390                data_labels: None,
2391                ..Default::default()
2392            },
2393        );
2394        let xml = original.to_xml();
2395        let parsed = Chart::parse_from_xml(&xml).expect("parse no-title chart");
2396
2397        assert!(parsed.data.title.is_none());
2398    }
2399
2400    /// 解析空 chartSpace(无 plotArea 子元素)应返回默认 Column 类型而非 panic。
2401    ///
2402    /// 验证零 panic 设计:解析失败不阻塞 round-trip。
2403    #[test]
2404    fn parse_empty_chart_space_no_panic() {
2405        let xml = "<?xml version=\"1.0\"?>\
2406                   <c:chartSpace xmlns:c=\"http://schemas.openxmlformats.org/drawingml/2006/chart\">\
2407                     <c:chart></c:chart>\
2408                   </c:chartSpace>";
2409        let parsed = Chart::parse_from_xml(xml).expect("parse empty chartSpace");
2410        // 无 plotArea → chart_type 保持默认 Column
2411        assert_eq!(parsed.chart_type, ChartType::Column);
2412        assert!(parsed.data.series.is_empty());
2413        assert!(parsed.data.categories.is_empty());
2414    }
2415
2416    /// 解析畸形 XML 应返回 Error::Xml 而非 panic。
2417    #[test]
2418    fn parse_malformed_xml_returns_error() {
2419        // 注意:quick-xml 0.40 SAX 流式解析器对未闭合标签较宽容(不跟踪标签栈),
2420        // 需要用真正畸形的 XML 才能触发解析错误。
2421        // 未闭合注释(`<!--` 后必须有 `-->`)是 quick-xml 0.40 必报错的场景。
2422        let xml = "<c:chartSpace><!-- unclosed comment <c:chart/></c:chartSpace>";
2423        let result = Chart::parse_from_xml(xml);
2424        assert!(result.is_err(), "malformed xml should error");
2425    }
2426
2427    /// external_data_rid 写出:to_xml 在 `</c:chart>` 之后、`</c:chartSpace>` 之前
2428    /// 生成 `<c:externalData r:id="..."><c:autoUpdate val="0"/></c:externalData>`(TODO-004 Excel 嵌入)。
2429    #[test]
2430    fn to_xml_writes_external_data() {
2431        let mut chart = Chart::new(
2432            ChartType::Column,
2433            ChartData {
2434                categories: vec![ChartCategory::new("A")],
2435                series: vec![ChartSeries::new("S", vec![1.0])],
2436                title: None,
2437                data_labels: None,
2438                ..Default::default()
2439            },
2440        );
2441        chart.external_data_rid = Some("rIdXlsx1".to_string());
2442        let xml = chart.to_xml();
2443        // 元素顺序:c:chart 关闭后紧跟 c:externalData
2444        assert!(
2445            xml.contains(r#"<c:externalData r:id="rIdXlsx1">"#),
2446            "应输出 c:externalData 开标签"
2447        );
2448        assert!(
2449            xml.contains(r#"<c:autoUpdate val="0"/>"#),
2450            "应输出 autoUpdate=0"
2451        );
2452        assert!(xml.contains("</c:externalData>"), "应闭合 c:externalData");
2453        // 验证顺序:c:chart 关闭在 c:externalData 之前
2454        let pos_chart_close = xml.find("</c:chart>").expect("c:chart 关闭存在");
2455        let pos_ext = xml.find("<c:externalData").expect("c:externalData 存在");
2456        assert!(
2457            pos_chart_close < pos_ext,
2458            "c:externalData 必须在 </c:chart> 之后"
2459        );
2460        let pos_chart_space_close = xml.rfind("</c:chartSpace>").expect("c:chartSpace 关闭存在");
2461        assert!(
2462            pos_ext < pos_chart_space_close,
2463            "c:externalData 必须在 </c:chartSpace> 之前"
2464        );
2465    }
2466
2467    /// external_data_rid = None 时不输出 c:externalData(保持原有行为)。
2468    #[test]
2469    fn to_xml_omits_external_data_when_none() {
2470        let chart = Chart::new(
2471            ChartType::Line,
2472            ChartData {
2473                categories: vec![ChartCategory::new("A")],
2474                series: vec![ChartSeries::new("S", vec![1.0])],
2475                title: None,
2476                data_labels: None,
2477                ..Default::default()
2478            },
2479        );
2480        let xml = chart.to_xml();
2481        assert!(
2482            !xml.contains("c:externalData"),
2483            "None 时不应输出 c:externalData"
2484        );
2485    }
2486
2487    /// parse_from_xml 对称还原 external_data_rid(TODO-004 Excel 嵌入读路径)。
2488    #[test]
2489    fn parse_external_data_rid_round_trip() {
2490        let mut original = Chart::new(
2491            ChartType::Column,
2492            ChartData {
2493                categories: vec![ChartCategory::new("Q1"), ChartCategory::new("Q2")],
2494                series: vec![ChartSeries::new("Sales", vec![10.0, 20.0])],
2495                title: Some("Revenue".into()),
2496                data_labels: None,
2497                ..Default::default()
2498            },
2499        );
2500        original.external_data_rid = Some("rIdXlsx1".into());
2501        let xml = original.to_xml();
2502        let parsed = Chart::parse_from_xml(&xml).expect("parse round-trip");
2503        assert_eq!(parsed.chart_type, ChartType::Column);
2504        assert_eq!(parsed.external_data_rid, Some("rIdXlsx1".to_string()));
2505    }
2506
2507    /// parse_from_xml 兼容 r:id 简写为 id 的写法(部分工具不严格加 r: 前缀)。
2508    #[test]
2509    fn parse_external_data_rid_bare_id_form() {
2510        let xml = "<?xml version=\"1.0\"?>\
2511                   <c:chartSpace xmlns:c=\"http://schemas.openxmlformats.org/drawingml/2006/chart\" \
2512                                 xmlns:r=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships\">\
2513                     <c:chart><c:plotArea><c:barChart><c:barDir val=\"col\"/>\
2514                       <c:ser><c:idx val=\"0\"/><c:order val=\"0\"/>\
2515                         <c:val><c:numCache><c:formatCode>General</c:formatCode>\
2516                           <c:ptCount val=\"1\"/><c:pt idx=\"0\"><c:v>1</c:v></c:pt>\
2517                         </c:numCache></c:val>\
2518                     </c:ser></c:barChart></c:plotArea></c:chart>\
2519                     <c:externalData id=\"rIdBare\"><c:autoUpdate val=\"0\"/></c:externalData>\
2520                   </c:chartSpace>";
2521        let parsed = Chart::parse_from_xml(xml).expect("parse bare id form");
2522        assert_eq!(parsed.external_data_rid, Some("rIdBare".to_string()));
2523    }
2524
2525    // ===================== 数据标签(dLbls)测试 =====================
2526
2527    /// `DataLabels::show_values()` 便捷构造器:仅 show_val=Some(true)。
2528    #[test]
2529    fn data_labels_show_values_constructor() {
2530        let dl = DataLabels::show_values();
2531        assert_eq!(dl.show_val, Some(true));
2532        assert_eq!(dl.show_cat_name, None);
2533        assert_eq!(dl.show_ser_name, None);
2534        assert!(!dl.is_empty());
2535    }
2536
2537    /// `DataLabels::show_percent_pie()` 便捷构造器:仅 show_percent=Some(true)。
2538    #[test]
2539    fn data_labels_show_percent_pie_constructor() {
2540        let dl = DataLabels::show_percent_pie();
2541        assert_eq!(dl.show_percent, Some(true));
2542        assert_eq!(dl.show_val, None);
2543        assert!(!dl.is_empty());
2544    }
2545
2546    /// `DataLabels::is_empty()` 对空配置返回 true。
2547    #[test]
2548    fn data_labels_is_empty_for_default() {
2549        let dl = DataLabels::default();
2550        assert!(dl.is_empty());
2551    }
2552
2553    /// `LabelPosition` as_str / from_str 往返。
2554    #[test]
2555    fn label_position_round_trip() {
2556        for pos in [
2557            LabelPosition::BestFit,
2558            LabelPosition::Above,
2559            LabelPosition::Below,
2560            LabelPosition::Center,
2561            LabelPosition::InsideEnd,
2562            LabelPosition::OutsideEnd,
2563            LabelPosition::Left,
2564            LabelPosition::Right,
2565        ] {
2566            let s = pos.as_str();
2567            // 未知字符串返回 None(不应误识别)
2568            let parsed = LabelPosition::parse(s).expect("known position should parse");
2569            assert_eq!(parsed, pos, "position round-trip failed for {:?}", pos);
2570        }
2571        // 未知字符串返回 None
2572        assert!(LabelPosition::parse("unknown").is_none());
2573    }
2574
2575    /// 图表级 `<c:dLbls>` round-trip:to_xml → parse_from_xml → 字段一致。
2576    ///
2577    /// 覆盖:show_val / show_cat_name / show_ser_name / show_legend_key / position / separator / numFmt。
2578    #[test]
2579    fn parse_chart_level_data_labels_round_trip() {
2580        let dl = DataLabels {
2581            show_val: Some(true),
2582            show_cat_name: Some(true),
2583            show_ser_name: Some(false),
2584            show_legend_key: Some(false),
2585            show_percent: None,
2586            show_bubble_size: None,
2587            position: Some(LabelPosition::OutsideEnd),
2588            separator: Some(", ".to_string()),
2589            num_fmt: Some("0.00".to_string()),
2590        };
2591        let original = Chart::new(
2592            ChartType::Column,
2593            ChartData {
2594                categories: vec![ChartCategory::new("Q1"), ChartCategory::new("Q2")],
2595                series: vec![ChartSeries::new("Sales", vec![10.0, 20.0])],
2596                title: None,
2597                data_labels: Some(dl.clone()),
2598                ..Default::default()
2599            },
2600        );
2601        let xml = original.to_xml();
2602        // 验证 XML 包含 dLbls 子元素
2603        assert!(xml.contains("<c:dLbls>"), "xml: {}", xml);
2604        assert!(xml.contains(r#"<c:showVal val="1"/>"#), "xml: {}", xml);
2605        assert!(xml.contains(r#"<c:showCatName val="1"/>"#), "xml: {}", xml);
2606        assert!(xml.contains(r#"<c:showSerName val="0"/>"#), "xml: {}", xml);
2607        assert!(
2608            xml.contains(r#"<c:showLegendKey val="0"/>"#),
2609            "xml: {}",
2610            xml
2611        );
2612        assert!(xml.contains(r#"<c:dLblPos val="outEnd"/>"#), "xml: {}", xml);
2613        assert!(xml.contains(r#"<c:separator val=", "/>"#), "xml: {}", xml);
2614        assert!(xml.contains(r#"formatCode="0.00""#), "xml: {}", xml);
2615
2616        // round-trip:解析后字段一致
2617        let parsed = Chart::parse_from_xml(&xml).expect("parse chart with dLbls");
2618        let parsed_dl = parsed
2619            .data
2620            .data_labels
2621            .expect("chart-level dLbls should parse");
2622        assert_eq!(parsed_dl.show_val, Some(true));
2623        assert_eq!(parsed_dl.show_cat_name, Some(true));
2624        assert_eq!(parsed_dl.show_ser_name, Some(false));
2625        assert_eq!(parsed_dl.show_legend_key, Some(false));
2626        assert_eq!(parsed_dl.show_percent, None);
2627        assert_eq!(parsed_dl.position, Some(LabelPosition::OutsideEnd));
2628        assert_eq!(parsed_dl.separator.as_deref(), Some(", "));
2629        assert_eq!(parsed_dl.num_fmt.as_deref(), Some("0.00"));
2630    }
2631
2632    /// 系列级 `<c:dLbls>` round-trip:覆盖图表级配置。
2633    #[test]
2634    fn parse_series_level_data_labels_round_trip() {
2635        let series_dl = DataLabels {
2636            show_val: Some(true),
2637            show_percent: Some(true),
2638            position: Some(LabelPosition::InsideEnd),
2639            ..Default::default()
2640        };
2641        let original = Chart::new(
2642            ChartType::Pie,
2643            ChartData {
2644                categories: vec![ChartCategory::new("A"), ChartCategory::new("B")],
2645                series: vec![{
2646                    let mut s = ChartSeries::new("X", vec![1.0, 2.0]);
2647                    s.data_labels = Some(series_dl.clone());
2648                    s
2649                }],
2650                title: None,
2651                data_labels: None,
2652                ..Default::default()
2653            },
2654        );
2655        let xml = original.to_xml();
2656        // 饼图 dLbls 应在 <c:ser> 内(系列级),而非图表级
2657        assert!(xml.contains("<c:dLbls>"), "xml: {}", xml);
2658        assert!(xml.contains(r#"<c:showVal val="1"/>"#), "xml: {}", xml);
2659        assert!(xml.contains(r#"<c:showPercent val="1"/>"#), "xml: {}", xml);
2660        assert!(xml.contains(r#"<c:dLblPos val="inEnd"/>"#), "xml: {}", xml);
2661
2662        let parsed = Chart::parse_from_xml(&xml).expect("parse pie with series dLbls");
2663        assert_eq!(parsed.chart_type, ChartType::Pie);
2664        // 图表级 dLbls 应为 None(pie 未设置图表级)
2665        assert!(parsed.data.data_labels.is_none());
2666        // 系列级 dLbls 应正确解析
2667        let s = &parsed.data.series[0];
2668        let parsed_dl = s.data_labels.as_ref().expect("series dLbls should parse");
2669        assert_eq!(parsed_dl.show_val, Some(true));
2670        assert_eq!(parsed_dl.show_percent, Some(true));
2671        assert_eq!(parsed_dl.position, Some(LabelPosition::InsideEnd));
2672    }
2673
2674    /// 空 `<c:dLbls/>`(无子元素)不应写入 data_labels 字段(保持 None)。
2675    #[test]
2676    fn parse_empty_dlbls_is_ignored() {
2677        // 构造一个图表级 dLbls 但所有字段都 None(is_empty=true)
2678        let original = Chart::new(
2679            ChartType::Column,
2680            ChartData {
2681                categories: vec![ChartCategory::new("A")],
2682                series: vec![ChartSeries::new("S", vec![1.0])],
2683                title: None,
2684                data_labels: Some(DataLabels::default()), // is_empty = true
2685                ..Default::default()
2686            },
2687        );
2688        let xml = original.to_xml();
2689        // 空的 dLbls 不应输出(to_xml 检查 is_empty)
2690        assert!(
2691            !xml.contains("<c:dLbls>"),
2692            "empty dLbls should not be written: {}",
2693            xml
2694        );
2695        // 解析后 data_labels 也应为 None
2696        let parsed = Chart::parse_from_xml(&xml).expect("parse");
2697        assert!(parsed.data.data_labels.is_none());
2698    }
2699
2700    // ===================== 次坐标轴测试 =====================
2701
2702    /// `to_xml` 在 series.secondary_axis=true 时输出次坐标轴定义(含 crosses=max)。
2703    #[test]
2704    fn to_xml_writes_secondary_axis() {
2705        let s2 = {
2706            let mut s = ChartSeries::new("Secondary", vec![1.0, 2.0]);
2707            s.secondary_axis = true;
2708            s
2709        };
2710        let original = Chart::new(
2711            ChartType::Column,
2712            ChartData {
2713                categories: vec![ChartCategory::new("Q1"), ChartCategory::new("Q2")],
2714                series: vec![s2],
2715                title: None,
2716                data_labels: None,
2717                ..Default::default()
2718            },
2719        );
2720        let xml = original.to_xml();
2721        // 应包含次轴 axId=444444444
2722        assert!(xml.contains(r#"<c:axId val="444444444"/>"#), "xml: {}", xml);
2723        // 应包含次轴定义(crosses=max)
2724        assert!(xml.contains(r#"<c:crosses val="max"/>"#), "xml: {}", xml);
2725        // 应有 2 个 valAx(主 valAx + 次 valAx)+ 1 个 catAx
2726        let val_ax_count = xml.matches("<c:valAx>").count();
2727        assert_eq!(
2728            val_ax_count, 2,
2729            "expected 2 valAx (primary + secondary): {}",
2730            xml
2731        );
2732        assert!(xml.contains("<c:catAx>"), "xml: {}", xml);
2733    }
2734
2735    /// 次坐标轴 round-trip:parse_from_xml 解析 crosses=max 后,
2736    /// 所有非散点/非饼图系列的 secondary_axis 字段置为 true。
2737    #[test]
2738    fn parse_secondary_axis_round_trip() {
2739        let original = Chart::new(
2740            ChartType::Column,
2741            ChartData {
2742                categories: vec![ChartCategory::new("Q1"), ChartCategory::new("Q2")],
2743                series: vec![{
2744                    let mut s = ChartSeries::new("S", vec![10.0, 20.0]);
2745                    s.secondary_axis = true;
2746                    s
2747                }],
2748                title: None,
2749                data_labels: None,
2750                ..Default::default()
2751            },
2752        );
2753        let xml = original.to_xml();
2754        let parsed = Chart::parse_from_xml(&xml).expect("parse secondary axis chart");
2755        // 解析后所有非散点/非饼图系列的 secondary_axis 应为 true
2756        assert_eq!(parsed.data.series.len(), 1);
2757        assert!(
2758            parsed.data.series[0].secondary_axis,
2759            "secondary_axis should be true after round-trip"
2760        );
2761    }
2762
2763    /// 饼图不支持次坐标轴:secondary_axis 字段被忽略,不输出次轴定义。
2764    #[test]
2765    fn pie_chart_ignores_secondary_axis() {
2766        let original = Chart::new(
2767            ChartType::Pie,
2768            ChartData {
2769                categories: vec![ChartCategory::new("A"), ChartCategory::new("B")],
2770                series: vec![{
2771                    let mut s = ChartSeries::new("X", vec![1.0, 2.0]);
2772                    s.secondary_axis = true; // 应被忽略
2773                    s
2774                }],
2775                title: None,
2776                data_labels: None,
2777                ..Default::default()
2778            },
2779        );
2780        let xml = original.to_xml();
2781        // 饼图不应输出次轴 axId
2782        assert!(
2783            !xml.contains("444444444"),
2784            "pie should not write secondary axId: {}",
2785            xml
2786        );
2787        assert!(
2788            !xml.contains(r#"<c:crosses val="max"/>"#),
2789            "pie should not write crosses=max: {}",
2790            xml
2791        );
2792    }
2793
2794    /// 散点图不支持次坐标轴:secondary_axis 字段被忽略。
2795    #[test]
2796    fn scatter_chart_ignores_secondary_axis() {
2797        let mut s = ChartSeries::new_scatter("S", vec![1.0, 2.0], vec![3.0, 4.0]);
2798        s.secondary_axis = true; // 应被忽略
2799        let original = Chart::new(
2800            ChartType::Scatter,
2801            ChartData {
2802                categories: vec![],
2803                series: vec![s],
2804                title: None,
2805                data_labels: None,
2806                ..Default::default()
2807            },
2808        );
2809        let xml = original.to_xml();
2810        assert!(
2811            !xml.contains("444444444"),
2812            "scatter should not write secondary axId: {}",
2813            xml
2814        );
2815        assert!(
2816            !xml.contains(r#"<c:crosses val="max"/>"#),
2817            "scatter should not write crosses=max: {}",
2818            xml
2819        );
2820    }
2821
2822    // ===================== 图例(<c:legend>)round-trip 测试 =====================
2823
2824    /// 图例 round-trip:to_xml 写出 legend 后 parse_from_xml 能还原。
2825    ///
2826    /// 覆盖:
2827    /// - legend.position = Top → 写出 <c:legendPos val="t"/> → 解析回 Top
2828    /// - legend.overlay = false → 写出 <c:overlay val="0"/> → 解析回 false
2829    #[test]
2830    fn legend_round_trip() {
2831        let original = Chart::new(
2832            ChartType::Column,
2833            ChartData {
2834                categories: vec![ChartCategory::new("Q1"), ChartCategory::new("Q2")],
2835                series: vec![ChartSeries::new("S", vec![10.0, 20.0])],
2836                title: Some("Legend Test".to_string()),
2837                data_labels: None,
2838                legend: Some(Legend {
2839                    position: LegendPosition::Top,
2840                    overlay: false,
2841                }),
2842                ..Default::default()
2843            },
2844        );
2845        let xml = original.to_xml();
2846        // 验证 XML 包含 legend 元素
2847        assert!(xml.contains("<c:legend>"), "xml: {}", xml);
2848        assert!(xml.contains(r#"<c:legendPos val="t"/>"#), "xml: {}", xml);
2849        assert!(xml.contains(r#"<c:overlay val="0"/>"#), "xml: {}", xml);
2850
2851        let parsed = Chart::parse_from_xml(&xml).expect("parse legend chart");
2852        assert_eq!(
2853            parsed
2854                .data
2855                .legend
2856                .as_ref()
2857                .expect("legend should exist")
2858                .position,
2859            LegendPosition::Top,
2860            "legend position should round-trip"
2861        );
2862        assert!(
2863            !parsed.data.legend.as_ref().unwrap().overlay,
2864            "legend overlay should round-trip"
2865        );
2866    }
2867
2868    /// 无图例 round-trip:ChartData.legend = None 时不写出 <c:legend>,
2869    /// 解析后 legend 仍为 None。
2870    #[test]
2871    fn no_legend_round_trip() {
2872        let original = Chart::new(
2873            ChartType::Line,
2874            ChartData {
2875                categories: vec![ChartCategory::new("A"), ChartCategory::new("B")],
2876                series: vec![ChartSeries::new("S", vec![1.0, 2.0])],
2877                title: None,
2878                data_labels: None,
2879                legend: None, // 显式无图例
2880                ..Default::default()
2881            },
2882        );
2883        let xml = original.to_xml();
2884        assert!(
2885            !xml.contains("<c:legend>"),
2886            "should not write legend: {}",
2887            xml
2888        );
2889
2890        let parsed = Chart::parse_from_xml(&xml).expect("parse no-legend chart");
2891        assert!(
2892            parsed.data.legend.is_none(),
2893            "legend should remain None after round-trip"
2894        );
2895    }
2896
2897    /// 图例所有位置变体 round-trip:Bottom / Top / Right / Left。
2898    #[test]
2899    fn legend_all_positions_round_trip() {
2900        for (pos, val) in [
2901            (LegendPosition::Bottom, "b"),
2902            (LegendPosition::Top, "t"),
2903            (LegendPosition::Right, "r"),
2904            (LegendPosition::Left, "l"),
2905        ] {
2906            let original = Chart::new(
2907                ChartType::Column,
2908                ChartData {
2909                    categories: vec![ChartCategory::new("A")],
2910                    series: vec![ChartSeries::new("S", vec![1.0])],
2911                    title: None,
2912                    data_labels: None,
2913                    legend: Some(Legend {
2914                        position: pos,
2915                        overlay: true,
2916                    }),
2917                    ..Default::default()
2918                },
2919            );
2920            let xml = original.to_xml();
2921            assert!(
2922                xml.contains(&format!(r#"<c:legendPos val="{}"/>"#, val)),
2923                "position {:?} should write val={}: {}",
2924                pos,
2925                val,
2926                xml
2927            );
2928            let parsed = Chart::parse_from_xml(&xml).expect("parse legend positions");
2929            assert_eq!(
2930                parsed.data.legend.as_ref().expect("legend").position,
2931                pos,
2932                "position {:?} should round-trip",
2933                pos
2934            );
2935            assert!(
2936                parsed.data.legend.as_ref().unwrap().overlay,
2937                "overlay=true should round-trip for position {:?}",
2938                pos
2939            );
2940        }
2941    }
2942
2943    // ===================== 系列样式(<c:spPr>)round-trip 测试 =====================
2944
2945    /// 系列样式实色填充 round-trip:构造带红色填充的系列,to_xml → parse_from_xml,
2946    /// 断言 `parsed.data.series[0].format` 存在且 `fill == Fill::Solid(Color::RGB(...))`。
2947    #[test]
2948    fn series_format_solid_color_round_trip() {
2949        use crate::oxml::color::Color;
2950        use crate::oxml::sppr::Fill;
2951        use crate::units::RGBColor;
2952        let original = Chart::new(
2953            ChartType::Column,
2954            ChartData {
2955                categories: vec![ChartCategory::new("A"), ChartCategory::new("B")],
2956                series: vec![{
2957                    let mut s = ChartSeries::new("S", vec![10.0, 20.0]);
2958                    s.format = Some(SeriesFormat::solid_fill(Color::RGB(RGBColor(
2959                        0xFF, 0x00, 0x00,
2960                    ))));
2961                    s
2962                }],
2963                title: None,
2964                data_labels: None,
2965                ..Default::default()
2966            },
2967        );
2968        let xml = original.to_xml();
2969        // XML 应包含 <c:spPr> 和 <a:srgbClr val="FF0000"/>
2970        assert!(xml.contains("<c:spPr>"), "xml should contain spPr: {}", xml);
2971        assert!(
2972            xml.contains(r#"<a:srgbClr val="FF0000"/>"#),
2973            "xml should contain red color: {}",
2974            xml
2975        );
2976
2977        let parsed = Chart::parse_from_xml(&xml).expect("parse series_format chart");
2978        let fmt = parsed.data.series[0]
2979            .format
2980            .as_ref()
2981            .expect("format should round-trip");
2982        // 断言 fill 为 Solid + RGB(FF, 00, 00)
2983        match &fmt.fill {
2984            Fill::Solid(Color::RGB(c)) => {
2985                assert_eq!(c.0, 0xFF, "red component");
2986                assert_eq!(c.1, 0x00, "green component");
2987                assert_eq!(c.2, 0x00, "blue component");
2988            }
2989            other => panic!("expected Fill::Solid(Color::RGB), got {:?}", other),
2990        }
2991    }
2992
2993    /// 无系列样式时不写出 <c:spPr>,解析后 format 仍为 None。
2994    #[test]
2995    fn series_format_none_no_sp_pr() {
2996        let original = Chart::new(
2997            ChartType::Line,
2998            ChartData {
2999                categories: vec![ChartCategory::new("X")],
3000                series: vec![ChartSeries::new("S", vec![1.0])],
3001                title: None,
3002                data_labels: None,
3003                ..Default::default()
3004            },
3005        );
3006        let xml = original.to_xml();
3007        assert!(
3008            !xml.contains("<c:spPr>"),
3009            "should not write spPr when format is None: {}",
3010            xml
3011        );
3012
3013        let parsed = Chart::parse_from_xml(&xml).expect("parse no-format chart");
3014        assert!(
3015            parsed.data.series[0].format.is_none(),
3016            "format should remain None after round-trip"
3017        );
3018    }
3019
3020    /// 组合图(Column + Line 次轴)round-trip:验证 secondary_chart_type 写出与解析。
3021    ///
3022    /// 构造主轴柱形图 + 次轴折线图的双轴组合图,验证:
3023    /// 1. to_xml 写出两个图表元素(c:barChart + c:lineChart);
3024    /// 2. 主图表元素只包含 secondary_axis=false 的系列;
3025    /// 3. 次图表元素只包含 secondary_axis=true 的系列;
3026    /// 4. parse_from_xml 正确还原 secondary_chart_type + secondary_axis 标记。
3027    #[test]
3028    fn chart_combo_round_trip() {
3029        let mut s1 = ChartSeries::new("Sales", vec![10.0, 20.0, 15.0]);
3030        s1.secondary_axis = false;
3031        let mut s2 = ChartSeries::new("Growth", vec![5.0, 8.0, 12.0]);
3032        s2.secondary_axis = true;
3033
3034        let original = Chart::new(
3035            ChartType::Column,
3036            ChartData {
3037                categories: vec![
3038                    ChartCategory::new("Q1"),
3039                    ChartCategory::new("Q2"),
3040                    ChartCategory::new("Q3"),
3041                ],
3042                series: vec![s1, s2],
3043                title: Some("Combo Chart".to_string()),
3044                data_labels: None,
3045                secondary_chart_type: Some(ChartType::Line),
3046                ..Default::default()
3047            },
3048        );
3049
3050        let xml = original.to_xml();
3051        // 验证写出两个图表元素
3052        assert!(
3053            xml.contains("<c:barChart>"),
3054            "should contain primary c:barChart: {}",
3055            xml
3056        );
3057        assert!(
3058            xml.contains("<c:lineChart>"),
3059            "should contain secondary c:lineChart: {}",
3060            xml
3061        );
3062        // 验证次轴 valAx 定义存在(含 crosses=max)
3063        assert!(
3064            xml.contains(r#"<c:crosses val="max"/>"#),
3065            "should contain secondary axis crosses=max: {}",
3066            xml
3067        );
3068
3069        let parsed = Chart::parse_from_xml(&xml).expect("parse combo chart");
3070        // 验证次图表类型还原
3071        assert_eq!(
3072            parsed.data.secondary_chart_type,
3073            Some(ChartType::Line),
3074            "secondary_chart_type should round-trip"
3075        );
3076        // 验证主图表类型保持
3077        assert_eq!(parsed.chart_type, ChartType::Column);
3078        // 验证系列数量
3079        assert_eq!(parsed.data.series.len(), 2, "should have 2 series");
3080        // 验证次轴系列标记
3081        assert!(
3082            !parsed.data.series[0].secondary_axis,
3083            "series 0 (Sales) should be on primary axis"
3084        );
3085        assert!(
3086            parsed.data.series[1].secondary_axis,
3087            "series 1 (Growth) should be on secondary axis"
3088        );
3089    }
3090
3091    /// 组合图写出验证:主图表元素只含主轴系列,次图表元素只含次轴系列。
3092    ///
3093    /// 通过检查 XML 中 c:barChart 和 c:lineChart 块的系列数量,
3094    /// 确保系列被正确分配到各自的图表元素。
3095    #[test]
3096    fn chart_combo_series_distribution() {
3097        let mut s1 = ChartSeries::new("Bar1", vec![1.0, 2.0]);
3098        s1.secondary_axis = false;
3099        let mut s2 = ChartSeries::new("Bar2", vec![3.0, 4.0]);
3100        s2.secondary_axis = false;
3101        let mut s3 = ChartSeries::new("Line1", vec![5.0, 6.0]);
3102        s3.secondary_axis = true;
3103
3104        let chart = Chart::new(
3105            ChartType::Column,
3106            ChartData {
3107                categories: vec![ChartCategory::new("A"), ChartCategory::new("B")],
3108                series: vec![s1, s2, s3],
3109                title: None,
3110                data_labels: None,
3111                secondary_chart_type: Some(ChartType::Line),
3112                ..Default::default()
3113            },
3114        );
3115
3116        let xml = chart.to_xml();
3117        // 统计 c:ser 出现次数(3 个系列共 3 个 c:ser)
3118        let ser_count = xml.matches("<c:ser>").count();
3119        assert_eq!(ser_count, 3, "should have 3 c:ser elements total: {}", xml);
3120
3121        // 验证次轴 axId 存在(444444444)
3122        assert!(
3123            xml.contains(r#"<c:axId val="444444444"/>"#),
3124            "should contain secondary axId 444444444: {}",
3125            xml
3126        );
3127    }
3128}