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::writer::XmlWriter;
23
24/// 图表类型。
25#[derive(Copy, Clone, Debug, Eq, PartialEq, Hash)]
26pub enum ChartType {
27    /// 簇状柱形图(`barChart` with `barDir="col"`)。
28    Column,
29    /// 簇状条形图(`barChart` with `barDir="bar"`)。
30    Bar,
31    /// 折线图(`lineChart`)。
32    Line,
33    /// 饼图(`pieChart`)。
34    Pie,
35    /// 散点图(`scatterChart`)。系列需提供 `x_values`,使用两个 valAx。
36    Scatter,
37    /// 面积图(`areaChart`)。
38    Area,
39    /// 雷达图(`radarChart`)。每个类别一个辐射轴,多系列叠加。
40    Radar,
41    /// 气泡图(`bubbleChart`)。系列需提供 `x_values` + `bubble_sizes`,使用两个 valAx。
42    Bubble,
43}
44
45impl ChartType {
46    /// 返回 OOXML `<c:chartSpace>` 内 `plotArea` 下的图表元素名(如 `c:barChart`)。
47    pub fn chart_element(self) -> &'static str {
48        match self {
49            ChartType::Column | ChartType::Bar => "c:barChart",
50            ChartType::Line => "c:lineChart",
51            ChartType::Pie => "c:pieChart",
52            ChartType::Scatter => "c:scatterChart",
53            ChartType::Area => "c:areaChart",
54            ChartType::Radar => "c:radarChart",
55            ChartType::Bubble => "c:bubbleChart",
56        }
57    }
58
59    /// `barChart` 的 `barDir` 属性值。仅对 Column/Bar 有意义。
60    pub fn bar_dir(self) -> &'static str {
61        match self {
62            ChartType::Column => "col",
63            ChartType::Bar => "bar",
64            _ => "",
65        }
66    }
67
68    /// 是否为散点图(特殊处理:使用 c:xVal/c:yVal + 两个 valAx)。
69    pub fn is_scatter(self) -> bool {
70        matches!(self, ChartType::Scatter)
71    }
72
73    /// 是否为 X-Y 坐标图(散点图或气泡图)。
74    ///
75    /// 这两类都使用 `c:xVal` / `c:yVal` + 两个 `valAx`,区别仅在于气泡图额外有 `c:bubbleSize`。
76    pub fn is_xy_chart(self) -> bool {
77        matches!(self, ChartType::Scatter | ChartType::Bubble)
78    }
79
80    /// 是否为气泡图(额外需要 `bubble_sizes` 字段)。
81    pub fn is_bubble(self) -> bool {
82        matches!(self, ChartType::Bubble)
83    }
84}
85
86/// 数据标签位置(`<c:dLblPos val="..."/>`)。
87///
88/// OOXML ST_DLblPos 枚举,控制数据标签相对于数据点的显示位置。
89/// 不同图表类型支持的位置子集不同(参见 ECMA-376 第 21.2.2.40 节)。
90#[derive(Copy, Clone, Debug, Default, Eq, PartialEq, Hash)]
91pub enum LabelPosition {
92    /// 最佳位置(由 PowerPoint 自动选择)。适用于大多数图表类型。
93    #[default]
94    BestFit,
95    /// 数据点上方(柱形/折线/面积图)。
96    Above,
97    /// 数据点下方(柱形/折线/面积图)。
98    Below,
99    /// 数据点中心(柱形/饼图)。
100    Center,
101    /// 基线(柱形图底部)。
102    Base,
103    /// 数据点内侧(柱形/饼图,标签在柱内)。
104    InsideBase,
105    /// 数据点内侧端(柱形图顶部内侧)。
106    InsideEnd,
107    /// 数据点外侧端(柱形图顶部外侧)。
108    OutsideEnd,
109    /// 左侧(折线图/散点图)。
110    Left,
111    /// 右侧(折线图/散点图)。
112    Right,
113    /// 饼图扇区外侧。
114    OutsideEndPie,
115    /// 饼图扇区内侧。
116    InsideEndPie,
117}
118
119impl LabelPosition {
120    /// 转 OOXML `<c:dLblPos val="..."/>` 属性值。
121    pub fn as_str(self) -> &'static str {
122        match self {
123            LabelPosition::BestFit => "bestFit",
124            LabelPosition::Above => "t",
125            LabelPosition::Below => "b",
126            LabelPosition::Center => "ctr",
127            LabelPosition::Base => "inBase",
128            LabelPosition::InsideBase => "inBase",
129            LabelPosition::InsideEnd => "inEnd",
130            LabelPosition::OutsideEnd => "outEnd",
131            LabelPosition::Left => "l",
132            LabelPosition::Right => "r",
133            LabelPosition::OutsideEndPie => "outEnd",
134            LabelPosition::InsideEndPie => "inEnd",
135        }
136    }
137
138    /// 从 OOXML 属性值解析(不区分大小写),未知值返回 `None`。
139    ///
140    /// 注:方法名为 `parse` 而非 `from_str`,以避免与 `std::str::FromStr` trait 冲突。
141    pub fn parse(s: &str) -> Option<Self> {
142        match s {
143            "bestFit" => Some(LabelPosition::BestFit),
144            "t" => Some(LabelPosition::Above),
145            "b" => Some(LabelPosition::Below),
146            "ctr" => Some(LabelPosition::Center),
147            "inBase" => Some(LabelPosition::Base),
148            "inEnd" => Some(LabelPosition::InsideEnd),
149            "outEnd" => Some(LabelPosition::OutsideEnd),
150            "l" => Some(LabelPosition::Left),
151            "r" => Some(LabelPosition::Right),
152            _ => None,
153        }
154    }
155}
156
157/// 数据标签配置(`<c:dLbls>`),对应 OOXML CT_DLbls。
158///
159/// 控制数据点数值/类别/系列名等标签的显示。可挂在图表级(`<c:barChart>` 内)
160/// 或系列级(`<c:ser>` 内)——系列级会覆盖图表级。
161///
162/// # 字段语义
163///
164/// 所有 `show_*` 字段为 `Option<bool>`:
165/// - `None`:不写出该 `<c:showXxx>` 元素(继承上层或 PowerPoint 默认);
166/// - `Some(true)`:写出 `<c:showXxx val="1"/>`;
167/// - `Some(false)`:写出 `<c:showXxx val="0"/>`。
168///
169/// # 与 python-pptx 的对应
170///
171/// - python-pptx `plot.data_labels` ←→ 图表级 `ChartData.data_labels`;
172/// - python-pptx `series.data_labels` ←→ 系列级 `ChartSeries.data_labels`。
173#[derive(Clone, Debug, Default)]
174pub struct DataLabels {
175    /// 显示数值(`<c:showVal val="1"/>`)。最常用的数据标签。
176    pub show_val: Option<bool>,
177    /// 显示类别名(`<c:showCatName val="1"/>`)。
178    pub show_cat_name: Option<bool>,
179    /// 显示系列名(`<c:showSerName val="1"/>`)。
180    pub show_ser_name: Option<bool>,
181    /// 显示图例键(`<c:showLegendKey val="1"/>`,图例色块前缀)。
182    pub show_legend_key: Option<bool>,
183    /// 显示百分比(`<c:showPercent val="1"/>`,主要用于饼图)。
184    pub show_percent: Option<bool>,
185    /// 显示气泡尺寸(`<c:showBubbleSize val="1"/>`,仅气泡图)。
186    pub show_bubble_size: Option<bool>,
187    /// 标签位置(`<c:dLblPos val="..."/>`)。
188    pub position: Option<LabelPosition>,
189    /// 分隔符(`<c:separator val="..."/>`,如 `", "` / `"\n"`)。
190    pub separator: Option<String>,
191    /// 数字格式(`<c:numFmt formatCode="..." sourceLinked="0"/>`)。
192    pub num_fmt: Option<String>,
193}
194
195impl DataLabels {
196    /// 新建一个仅显示数值的数据标签配置(最常用场景)。
197    pub fn show_values() -> Self {
198        DataLabels {
199            show_val: Some(true),
200            ..Default::default()
201        }
202    }
203
204    /// 新建一个显示百分比的饼图数据标签配置。
205    pub fn show_percent_pie() -> Self {
206        DataLabels {
207            show_percent: Some(true),
208            ..Default::default()
209        }
210    }
211
212    /// 写出 `<c:dLbls>` 元素到 XmlWriter。
213    ///
214    /// OOXML 元素顺序约束(ECMA-376 21.2.2.40):
215    /// `numFmt` → `spacing` → `position` → `separator` → `showLegendKey` →
216    /// `showVal` → `showCatName` → `showSerName` → `showPercent` →
217    /// `showBubbleSize` → `dLbl` → `dLblPos` → ...
218    ///
219    /// 实际 PowerPoint 实践中常用顺序:`showVal` → `showCatName` → ... → `dLblPos` → `separator` → `numFmt`。
220    /// 本实现采用 PowerPoint 实践顺序以保证兼容性。
221    pub fn write_xml(&self, w: &mut XmlWriter) {
222        w.open("c:dLbls");
223        if let Some(b) = self.show_legend_key {
224            w.empty_with("c:showLegendKey", &[("val", if b { "1" } else { "0" })]);
225        }
226        if let Some(b) = self.show_val {
227            w.empty_with("c:showVal", &[("val", if b { "1" } else { "0" })]);
228        }
229        if let Some(b) = self.show_cat_name {
230            w.empty_with("c:showCatName", &[("val", if b { "1" } else { "0" })]);
231        }
232        if let Some(b) = self.show_ser_name {
233            w.empty_with("c:showSerName", &[("val", if b { "1" } else { "0" })]);
234        }
235        if let Some(b) = self.show_percent {
236            w.empty_with("c:showPercent", &[("val", if b { "1" } else { "0" })]);
237        }
238        if let Some(b) = self.show_bubble_size {
239            w.empty_with("c:showBubbleSize", &[("val", if b { "1" } else { "0" })]);
240        }
241        if let Some(pos) = self.position {
242            w.empty_with("c:dLblPos", &[("val", pos.as_str())]);
243        }
244        if let Some(sep) = &self.separator {
245            w.empty_with("c:separator", &[("val", sep.as_str())]);
246        }
247        if let Some(fmt) = &self.num_fmt {
248            w.empty_with(
249                "c:numFmt",
250                &[("formatCode", fmt.as_str()), ("sourceLinked", "0")],
251            );
252        }
253        w.close("c:dLbls");
254    }
255
256    /// 是否所有字段都为 `None`(即写出后只有空 `<c:dLbls/>`,无意义)。
257    pub fn is_empty(&self) -> bool {
258        self.show_val.is_none()
259            && self.show_cat_name.is_none()
260            && self.show_ser_name.is_none()
261            && self.show_legend_key.is_none()
262            && self.show_percent.is_none()
263            && self.show_bubble_size.is_none()
264            && self.position.is_none()
265            && self.separator.is_none()
266            && self.num_fmt.is_none()
267    }
268}
269
270/// 图表数据:一维类别轴 + 多个数据系列。
271#[derive(Clone, Debug, Default)]
272pub struct ChartData {
273    /// 类别(X 轴标签或饼图扇区标签)。
274    ///
275    /// **散点图忽略此字段**——散点图的 X 坐标由 `ChartSeries.x_values` 提供。
276    pub categories: Vec<ChartCategory>,
277    /// 数据系列列表(每个系列一条曲线/一组柱子/一个饼图扇区集合)。
278    pub series: Vec<ChartSeries>,
279    /// 图表标题。`None` 表示无标题。
280    pub title: Option<String>,
281    /// 图表级数据标签(`<c:dLbls>`,写在 `<c:barChart>` 等图表元素内、`<c:ser>` 之前)。
282    ///
283    /// 系列级 [`ChartSeries::data_labels`] 会覆盖此处的图表级配置。
284    /// `None` 表示不写出图表级 `<c:dLbls>`(PowerPoint 使用默认:不显示标签)。
285    pub data_labels: Option<DataLabels>,
286}
287
288/// 单个类别(X 轴标签或饼图扇区标签)。
289#[derive(Clone, Debug, Default)]
290pub struct ChartCategory {
291    /// 类别名称。
292    pub name: String,
293}
294
295impl ChartCategory {
296    /// 新建一个类别。
297    pub fn new(name: impl Into<String>) -> Self {
298        ChartCategory { name: name.into() }
299    }
300}
301
302/// 数据系列(一条折线 / 一组柱子 / 一组饼图扇区 / 一组散点 / 一组气泡)。
303#[derive(Clone, Debug, Default)]
304pub struct ChartSeries {
305    /// 系列名称(图例上显示)。
306    pub name: String,
307    /// 数值列表,长度应与 `ChartData.categories` 一致。
308    ///
309    /// 对散点图/气泡图,此字段表示 **Y 坐标**值,需配合 `x_values` 使用。
310    pub values: Vec<f64>,
311    /// 散点图/气泡图的 **X 坐标**值列表。仅 `ChartType::Scatter` / `Bubble` 使用,长度应与 `values` 一致。
312    ///
313    /// `None` 或空表示该系列非 X-Y 坐标图;散点图系列应通过 [`ChartSeries::new_scatter`]
314    /// 构造以同时提供 `x_values` 与 `values`(Y 坐标)。
315    pub x_values: Option<Vec<f64>>,
316    /// 气泡图的**气泡尺寸**列表。仅 `ChartType::Bubble` 使用,长度应与 `values` 一致。
317    ///
318    /// `None` 表示非气泡图;气泡图系列应通过 [`ChartSeries::new_bubble`] 构造。
319    pub bubble_sizes: Option<Vec<f64>>,
320    /// 系列级数据标签(`<c:dLbls>`,写在 `<c:ser>` 内 `<c:val>` / `<c:yVal>` 之后)。
321    ///
322    /// 系列级配置会覆盖图表级 [`ChartData::data_labels`]。`None` 表示继承图表级配置。
323    pub data_labels: Option<DataLabels>,
324    /// 是否将该系列绑定到**次坐标轴**(右侧 Y 轴)。
325    ///
326    /// - `false`(默认):系列绑定主轴(axId=222222222)。
327    /// - `true`:系列绑定次轴(axId=444444444),`Chart::to_xml` 会额外写出
328    ///   次轴定义(含 `<c:crosses val="max"/>`),PowerPoint 将其显示在右侧。
329    ///
330    /// 典型场景:主轴显示柱形图,次轴显示折线图(双轴组合图)。
331    /// **注意**:饼图/散点图/气泡图不支持次坐标轴(写出时该字段被忽略)。
332    pub secondary_axis: bool,
333}
334
335impl ChartSeries {
336    /// 新建一个数据系列(非散点图/气泡图)。
337    ///
338    /// `values` 为 Y 值列表(柱/条/线/面积/雷达图)或扇区数值(饼图)。
339    pub fn new(name: impl Into<String>, values: Vec<f64>) -> Self {
340        ChartSeries {
341            name: name.into(),
342            values,
343            x_values: None,
344            bubble_sizes: None,
345            data_labels: None,
346            secondary_axis: false,
347        }
348    }
349
350    /// 新建一个散点图数据系列(提供 X 与 Y 坐标)。
351    ///
352    /// `x_values` 与 `y_values` 长度应一致;不一致时以较短的为准(写出时按 `y_values` 长度迭代)。
353    ///
354    /// # 参数
355    /// - `name`:系列名称(图例显示)。
356    /// - `x_values`:X 坐标列表。
357    /// - `y_values`:Y 坐标列表(与 [`ChartSeries::values`] 同字段)。
358    pub fn new_scatter(name: impl Into<String>, x_values: Vec<f64>, y_values: Vec<f64>) -> Self {
359        ChartSeries {
360            name: name.into(),
361            values: y_values,
362            x_values: Some(x_values),
363            bubble_sizes: None,
364            data_labels: None,
365            secondary_axis: false,
366        }
367    }
368
369    /// 新建一个气泡图数据系列(提供 X、Y 坐标与气泡尺寸)。
370    ///
371    /// 三个列表长度应一致;不一致时以 `y_values` 长度为准迭代。
372    ///
373    /// # 参数
374    /// - `name`:系列名称(图例显示)。
375    /// - `x_values`:X 坐标列表。
376    /// - `y_values`:Y 坐标列表(与 [`ChartSeries::values`] 同字段)。
377    /// - `bubble_sizes`:气泡尺寸列表(决定每个气泡的面积)。
378    pub fn new_bubble(
379        name: impl Into<String>,
380        x_values: Vec<f64>,
381        y_values: Vec<f64>,
382        bubble_sizes: Vec<f64>,
383    ) -> Self {
384        ChartSeries {
385            name: name.into(),
386            values: y_values,
387            x_values: Some(x_values),
388            bubble_sizes: Some(bubble_sizes),
389            data_labels: None,
390            secondary_axis: false,
391        }
392    }
393}
394
395/// 一个完整的图表,引用 chart part。
396#[derive(Clone, Debug)]
397pub struct Chart {
398    /// 图表类型。
399    pub chart_type: ChartType,
400    /// 图表数据。
401    pub data: ChartData,
402    /// 关系 id(指向 `/ppt/charts/chartN.xml`)。
403    ///
404    /// 由 `Presentation::to_opc_package` 在写出 chart part 时自动分配。
405    /// 高阶 API 创建时通常为空字符串,由 presentation 层填充。
406    pub rid: String,
407    /// 嵌入式 Excel 工作簿的关系 id(TODO-004 Excel 嵌入)。
408    ///
409    /// 指向 `/ppt/embeddings/Microsoft_Excel_WorksheetN.xlsx`,写出时在
410    /// `</c:chart>` 之后、`</c:chartSpace>` 之前生成
411    /// `<c:externalData r:id="..."><c:autoUpdate val="0"/></c:externalData>`。
412    ///
413    /// - `None`:不写出 `<c:externalData>`(图表数据仅靠 numCache/strCache)。
414    /// - `Some(rid)`:写出 `<c:externalData r:id="{rid}"/>`,PowerPoint 打开
415    ///   图表时会从 xlsx part 读取数据源("编辑数据" 启动 Excel)。
416    ///
417    /// 由 `Presentation::to_opc_package` 在写出 xlsx part 时自动分配;
418    /// 高阶 API 创建时通常为 `None`,由 presentation 层填充。
419    pub external_data_rid: Option<String>,
420}
421
422impl Chart {
423    /// 新建一个图表。`rid` 默认为空字符串,由 presentation 层在写出时填充。
424    pub fn new(chart_type: ChartType, data: ChartData) -> Self {
425        Chart {
426            chart_type,
427            data,
428            rid: String::new(),
429            external_data_rid: None,
430        }
431    }
432
433    /// 从 `<c:chartSpace>` XML 字符串解析为强类型 [`Chart`]。
434    ///
435    /// 详见模块级独立函数 [`parse_from_xml`](本关联函数是其委托)。
436    ///
437    /// # 参数
438    /// - `xml`:完整 chartN.xml 内容。
439    ///
440    /// # 错误
441    /// - `Error::Xml`:XML 解析失败。
442    pub fn parse_from_xml(xml: &str) -> crate::Result<Chart> {
443        parse_from_xml(xml)
444    }
445}
446
447impl Default for Chart {
448    /// 默认图表:`ChartType::Column` + 空 `ChartData` + 空 rid。
449    fn default() -> Self {
450        Chart {
451            chart_type: ChartType::Column,
452            data: ChartData::default(),
453            rid: String::new(),
454            external_data_rid: None,
455        }
456    }
457}
458
459impl Chart {
460    /// 写出 `<c:chartSpace>` 完整 XML(chartN.xml 内容)。
461    ///
462    /// # 元素结构(最小可用集)
463    ///
464    /// ```text
465    /// <c:chartSpace xmlns:c=... xmlns:a=... xmlns:r=...>
466    ///   <c:chart>
467    ///     <c:title>...?         ← 可选
468    ///     <c:plotArea>
469    ///       <c:layout/>         ← 自动布局
470    ///       <c:barChart|c:lineChart|c:pieChart>
471    ///         <c:barDir>...     ← 仅 barChart
472    ///         <c:grouping val="clustered"/>
473    ///         <c:ser>           ← 一个或多个系列
474    ///           <c:idx val="0"/>
475    ///           <c:order val="0"/>
476    ///           <c:tx><c:strRef><c:f>Sheet1!$B$1</c:f><c:strCache>...</c:strCache></c:strRef></c:tx>
477    ///           <c:cat><c:strRef>...类别标签...</c:strRef></c:cat>
478    ///           <c:val><c:numRef><c:numCache>...数值...</c:numCache></c:numRef></c:val>
479    ///         </c:ser>
480    ///         <c:axId val="..."/>  ← 1~2 个 axId
481    ///       </c:barChart>
482    ///       <c:catAx>...</c:catAx>     ← 类别轴(饼图无)
483    ///       <c:valAx>...</c:valAx>     ← 数值轴(饼图无)
484    ///     </c:plotArea>
485    ///   </c:chart>
486    /// </c:chartSpace>
487    /// ```
488    pub fn to_xml(&self) -> String {
489        let mut w = XmlWriter::new();
490        w.raw("<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\n");
491        w.open_with(
492            "c:chartSpace",
493            &[
494                (
495                    "xmlns:c",
496                    "http://schemas.openxmlformats.org/drawingml/2006/chart",
497                ),
498                (
499                    "xmlns:a",
500                    "http://schemas.openxmlformats.org/drawingml/2006/main",
501                ),
502                (
503                    "xmlns:r",
504                    "http://schemas.openxmlformats.org/officeDocument/2006/relationships",
505                ),
506            ],
507        );
508        w.open("c:chart");
509        // 标题
510        if let Some(title) = &self.data.title {
511            w.open("c:title");
512            w.open("a:tx");
513            w.open("a:rich");
514            w.open("a:bodyPr");
515            w.close("a:bodyPr");
516            w.open("a:lstStyle");
517            w.close("a:lstStyle");
518            w.open("a:p");
519            w.open("a:pPr");
520            w.empty_with("a:defRPr", &[("sz", "1400"), ("b", "1")]);
521            w.close("a:pPr");
522            w.leaf("a:t", title.as_str());
523            w.close("a:p");
524            w.close("a:rich");
525            w.close("a:tx");
526            w.empty_with("c:overlay", &[("val", "0")]);
527            w.close("c:title");
528        }
529        // plotArea
530        w.open("c:plotArea");
531        w.open("c:layout");
532        w.close("c:layout");
533
534        let chart_elem = self.chart_type.chart_element();
535        w.open(chart_elem);
536        if matches!(self.chart_type, ChartType::Column | ChartType::Bar) {
537            w.empty_with("c:barDir", &[("val", self.chart_type.bar_dir())]);
538            w.empty_with("c:grouping", &[("val", "clustered")]);
539        }
540        // 面积图 grouping(standard = 普通面积图,不堆叠)
541        if matches!(self.chart_type, ChartType::Area) {
542            w.empty_with("c:grouping", &[("val", "standard")]);
543        }
544        // 散点图 scatterStyle(lineMarker = 带标记的折线散点)
545        if matches!(self.chart_type, ChartType::Scatter) {
546            w.empty_with("c:scatterStyle", &[("val", "lineMarker")]);
547        }
548        // 雷达图 radarStyle(marker = 带数据标记的雷达图)
549        if matches!(self.chart_type, ChartType::Radar) {
550            w.empty_with("c:radarStyle", &[("val", "marker")]);
551        }
552        // 气泡图 bubbleScale(100 = 默认缩放比例)
553        if matches!(self.chart_type, ChartType::Bubble) {
554            w.empty_with("c:bubbleScale", &[("val", "100")]);
555        }
556        // varColors
557        if matches!(self.chart_type, ChartType::Pie) {
558            w.empty_with("c:varyColors", &[("val", "1")]);
559        }
560
561        // 图表级数据标签(<c:dLbls>,OOXML 顺序:在 varyColors 之后、<c:ser> 之前)。
562        // 系列级 ChartSeries.data_labels 会覆盖此处的图表级配置。
563        if let Some(dl) = &self.data.data_labels {
564            if !dl.is_empty() {
565                dl.write_xml(&mut w);
566            }
567        }
568
569        // 系列列表
570        for (idx, s) in self.data.series.iter().enumerate() {
571            let idx_s = idx.to_string();
572            w.open("c:ser");
573            w.empty_with("c:idx", &[("val", idx_s.as_str())]);
574            w.empty_with("c:order", &[("val", idx_s.as_str())]);
575            // 系列名(c:tx)
576            w.open("c:tx");
577            w.open("c:strRef");
578            w.leaf("c:f", &format!("Sheet1!${}$1", col_letter(idx + 1)));
579            w.open("c:strCache");
580            w.open_with("c:pt", &[("idx", "0")]);
581            w.leaf("c:v", s.name.as_str());
582            w.close("c:pt");
583            w.close("c:strCache");
584            w.close("c:strRef");
585            w.close("c:tx");
586            if self.chart_type.is_xy_chart() {
587                // 散点图/气泡图:c:xVal / c:yVal(均为数值)
588                // c:xVal
589                w.open("c:xVal");
590                w.open("c:numRef");
591                w.leaf("c:f", "Sheet1!$A$2:$A$100");
592                w.open("c:numCache");
593                w.empty_with("c:formatCode", &[("val", "General")]);
594                if let Some(xs) = &s.x_values {
595                    for (i, v) in xs.iter().enumerate() {
596                        let i_s = i.to_string();
597                        let v_s = format_f64(*v);
598                        w.open_with("c:pt", &[("idx", i_s.as_str())]);
599                        w.leaf("c:v", v_s.as_str());
600                        w.close("c:pt");
601                    }
602                }
603                w.close("c:numCache");
604                w.close("c:numRef");
605                w.close("c:xVal");
606                // c:yVal
607                w.open("c:yVal");
608                w.open("c:numRef");
609                w.leaf(
610                    "c:f",
611                    &format!(
612                        "Sheet1!${}$2:${}$100",
613                        col_letter(idx + 1),
614                        col_letter(idx + 1)
615                    ),
616                );
617                w.open("c:numCache");
618                w.empty_with("c:formatCode", &[("val", "General")]);
619                for (i, v) in s.values.iter().enumerate() {
620                    let i_s = i.to_string();
621                    let v_s = format_f64(*v);
622                    w.open_with("c:pt", &[("idx", i_s.as_str())]);
623                    w.leaf("c:v", v_s.as_str());
624                    w.close("c:pt");
625                }
626                w.close("c:numCache");
627                w.close("c:numRef");
628                w.close("c:yVal");
629                // 气泡图额外:c:bubbleSize(气泡尺寸,决定每个气泡的面积)
630                if self.chart_type.is_bubble() {
631                    w.open("c:bubbleSize");
632                    w.open("c:numRef");
633                    w.leaf(
634                        "c:f",
635                        &format!(
636                            "Sheet1!${}$2:${}$100",
637                            col_letter(idx + 2),
638                            col_letter(idx + 2)
639                        ),
640                    );
641                    w.open("c:numCache");
642                    w.empty_with("c:formatCode", &[("val", "General")]);
643                    if let Some(sizes) = &s.bubble_sizes {
644                        for (i, v) in sizes.iter().enumerate() {
645                            let i_s = i.to_string();
646                            let v_s = format_f64(*v);
647                            w.open_with("c:pt", &[("idx", i_s.as_str())]);
648                            w.leaf("c:v", v_s.as_str());
649                            w.close("c:pt");
650                        }
651                    }
652                    w.close("c:numCache");
653                    w.close("c:numRef");
654                    w.close("c:bubbleSize");
655                }
656            } else {
657                // 非散点图:c:cat(类别标签) + c:val(数值)
658                // 类别标签(c:cat)—— 仅 bar/line/area 有
659                if !matches!(self.chart_type, ChartType::Pie) && !self.data.categories.is_empty() {
660                    w.open("c:cat");
661                    w.open("c:strRef");
662                    w.leaf("c:f", "Sheet1!$A$2:$A$100");
663                    w.open("c:strCache");
664                    for (i, cat) in self.data.categories.iter().enumerate() {
665                        let i_s = i.to_string();
666                        w.open_with("c:pt", &[("idx", i_s.as_str())]);
667                        w.leaf("c:v", cat.name.as_str());
668                        w.close("c:pt");
669                    }
670                    w.close("c:strCache");
671                    w.close("c:strRef");
672                    w.close("c:cat");
673                } else if matches!(self.chart_type, ChartType::Pie)
674                    && !self.data.categories.is_empty()
675                {
676                    // 饼图:类别标签写在 c:cat 里
677                    w.open("c:cat");
678                    w.open("c:strRef");
679                    w.leaf("c:f", "Sheet1!$A$2:$A$100");
680                    w.open("c:strCache");
681                    for (i, cat) in self.data.categories.iter().enumerate() {
682                        let i_s = i.to_string();
683                        w.open_with("c:pt", &[("idx", i_s.as_str())]);
684                        w.leaf("c:v", cat.name.as_str());
685                        w.close("c:pt");
686                    }
687                    w.close("c:strCache");
688                    w.close("c:strRef");
689                    w.close("c:cat");
690                }
691                // 数值(c:val)
692                w.open("c:val");
693                w.open("c:numRef");
694                w.leaf(
695                    "c:f",
696                    &format!(
697                        "Sheet1!${}$2:${}$100",
698                        col_letter(idx + 1),
699                        col_letter(idx + 1)
700                    ),
701                );
702                w.open("c:numCache");
703                w.empty_with("c:formatCode", &[("val", "General")]);
704                for (i, v) in s.values.iter().enumerate() {
705                    let i_s = i.to_string();
706                    let v_s = format_f64(*v);
707                    w.open_with("c:pt", &[("idx", i_s.as_str())]);
708                    w.leaf("c:v", v_s.as_str());
709                    w.close("c:pt");
710                }
711                w.close("c:numCache");
712                w.close("c:numRef");
713                w.close("c:val");
714            }
715            // 系列级数据标签(<c:dLbls>,OOXML 顺序:在 <c:val>/<c:yVal> 之后、</c:ser> 之前)。
716            // 覆盖图表级 ChartData.data_labels 配置。
717            if let Some(dl) = &s.data_labels {
718                if !dl.is_empty() {
719                    dl.write_xml(&mut w);
720                }
721            }
722            w.close("c:ser");
723        }
724
725        // 轴 ID(饼图无轴;散点图/气泡图两个 valAx)。
726        //
727        // 次坐标轴支持(TODO-004 次坐标轴):当某系列 `secondary_axis=true` 时,
728        // 该系列额外引用次轴 axId=444444444;图表级 axId 列表需额外包含次轴 id,
729        // 并在下方轴定义段写出第三个轴(次 valAx,含 `<c:crosses val="max"/>`)。
730        //
731        // 饼图/散点图/气泡图不支持次坐标轴(OOXML 规范约束),secondary_axis 字段被忽略。
732        let has_secondary = !self.chart_type.is_xy_chart()
733            && !matches!(self.chart_type, ChartType::Pie)
734            && self.data.series.iter().any(|s| s.secondary_axis);
735        if matches!(self.chart_type, ChartType::Pie) {
736            // 饼图无轴
737        } else if self.chart_type.is_xy_chart() {
738            // 散点图/气泡图:两个 valAx(不支持次坐标轴)
739            w.empty_with("c:axId", &[("val", "111111111")]);
740            w.empty_with("c:axId", &[("val", "222222222")]);
741        } else {
742            // 柱/条/线/面积/雷达:catAx + valAx
743            w.empty_with("c:axId", &[("val", "111111111")]);
744            w.empty_with("c:axId", &[("val", "222222222")]);
745            // 次坐标轴:当存在 secondary_axis=true 系列时,额外引用次 valAx(axId=444444444)。
746            // 次轴定义在下方轴定义段写出,含 <c:crosses val="max"/> 标记。
747            if has_secondary {
748                w.empty_with("c:axId", &[("val", "444444444")]);
749            }
750        }
751        w.close(chart_elem);
752
753        // 轴定义(饼图跳过)
754        if !matches!(self.chart_type, ChartType::Pie) {
755            if self.chart_type.is_xy_chart() {
756                // 散点图/气泡图:两个 valAx(X 轴底部 + Y 轴左侧)
757                // valAx(X 轴)
758                w.open("c:valAx");
759                w.empty_with("c:axId", &[("val", "111111111")]);
760                w.empty_with("c:scaling", &[("orientation", "minMax")]);
761                w.empty_with("c:delete", &[("val", "0")]);
762                w.empty_with("c:axPos", &[("val", "b")]);
763                w.empty_with("c:crossAx", &[("val", "222222222")]);
764                w.close("c:valAx");
765                // valAx(Y 轴)
766                w.open("c:valAx");
767                w.empty_with("c:axId", &[("val", "222222222")]);
768                w.empty_with("c:scaling", &[("orientation", "minMax")]);
769                w.empty_with("c:delete", &[("val", "0")]);
770                w.empty_with("c:axPos", &[("val", "l")]);
771                w.empty_with("c:crossAx", &[("val", "111111111")]);
772                w.close("c:valAx");
773            } else {
774                // 柱/条/线/面积:catAx + valAx
775                // catAx(主类别轴,底部)
776                w.open("c:catAx");
777                w.empty_with("c:axId", &[("val", "111111111")]);
778                w.empty_with("c:scaling", &[("orientation", "minMax")]);
779                w.empty_with("c:delete", &[("val", "0")]);
780                w.empty_with("c:axPos", &[("val", "b")]);
781                w.empty_with("c:crossAx", &[("val", "222222222")]);
782                w.close("c:catAx");
783                // valAx(主数值轴,左侧)
784                w.open("c:valAx");
785                w.empty_with("c:axId", &[("val", "222222222")]);
786                w.empty_with("c:scaling", &[("orientation", "minMax")]);
787                w.empty_with("c:delete", &[("val", "0")]);
788                w.empty_with("c:axPos", &[("val", "l")]);
789                w.empty_with("c:crossAx", &[("val", "111111111")]);
790                w.close("c:valAx");
791                // 次数值轴(右侧,仅当 has_secondary 时写出)。
792                //
793                // <c:crosses val="max"/> 是 PowerPoint 识别次轴的关键元素:
794                // 表示该轴在主轴的最大值处交叉(即显示在右侧/顶部)。
795                // axPos="r" 表示右侧;crossAx 指向主 catAx(111111111)。
796                if has_secondary {
797                    w.open("c:valAx");
798                    w.empty_with("c:axId", &[("val", "444444444")]);
799                    w.empty_with("c:scaling", &[("orientation", "minMax")]);
800                    w.empty_with("c:delete", &[("val", "0")]);
801                    w.empty_with("c:axPos", &[("val", "r")]);
802                    w.empty_with("c:crossAx", &[("val", "111111111")]);
803                    w.empty_with("c:crosses", &[("val", "max")]);
804                    w.close("c:valAx");
805                }
806            }
807        }
808        w.close("c:plotArea");
809
810        // legend
811        w.open("c:legend");
812        w.empty_with("c:legendPos", &[("val", "r")]);
813        w.empty_with("c:overlay", &[("val", "0")]);
814        w.close("c:legend");
815
816        w.empty_with("c:plotVisOnly", &[("val", "1")]);
817        w.empty_with("c:dispBlanksAs", &[("val", "gap")]);
818        w.close("c:chart");
819
820        // 嵌入式 Excel 工作簿引用(TODO-004 Excel 嵌入)。
821        //
822        // OOXML 顺序约束:`<c:externalData>` 必须在 `<c:chart>` 之后、
823        // `<c:chartSpace>` 关闭之前。PowerPoint 读取该元素后会从对应
824        // xlsx part 加载数据源("编辑数据" 启动 Excel)。
825        //
826        // `<c:autoUpdate val="0"/>` 表示不自动同步(用户手动编辑后才更新),
827        // 这是 PowerPoint 默认行为;val="1" 会在打开时自动同步,可能卡顿。
828        if let Some(ext_rid) = &self.external_data_rid {
829            w.open_with("c:externalData", &[("r:id", ext_rid.as_str())]);
830            w.empty_with("c:autoUpdate", &[("val", "0")]);
831            w.close("c:externalData");
832        }
833
834        w.close("c:chartSpace");
835        w.into_string()
836    }
837}
838
839/// 把 1-based 索引转为 Excel 列字母(1=A, 2=B, ..., 27=AA)。
840fn col_letter(n: usize) -> String {
841    let mut s = String::new();
842    let mut n = n;
843    while n > 0 {
844        n -= 1;
845        let ch = (b'A' + (n % 26) as u8) as char;
846        s.insert(0, ch);
847        n /= 26;
848    }
849    s
850}
851
852/// 格式化 f64 为 OOXML 兼容字符串(整数不输出小数点)。
853fn format_f64(v: f64) -> String {
854    if v.is_nan() || v.is_infinite() {
855        return "0".to_string();
856    }
857    if v.fract() == 0.0 && v.abs() < 1e15 {
858        format!("{}", v as i64)
859    } else {
860        format!("{}", v)
861    }
862}
863
864/// 从 `<c:chartSpace>` XML 解析回 [`Chart`] 模型(TODO-004 读路径)。
865///
866/// 与 [`Chart::to_xml`] 对称的解析函数,用于 `Presentation::from_opc` 阶段
867/// 读取 `/ppt/charts/chartN.xml` part 内容后还原 Chart 结构。
868///
869/// # 解析策略
870///
871/// - **chart_type**:根据 `<c:plotArea>` 下的图表元素名(`c:barChart` / `c:lineChart` /
872///   `c:pieChart` / `c:scatterChart` / `c:areaChart` / `c:radarChart` / `c:bubbleChart`)
873///   判断;对 `c:barChart` 额外读取 `<c:barDir val="col|bar">` 区分 Column / Bar。
874/// - **title**:从 `<c:title><a:tx><a:rich>...<a:t>文本</a:t>` 提取第一个文本节点。
875/// - **categories**:从**第一个** `<c:ser>` 的 `<c:cat><c:strRef><c:strCache>` 提取类别标签
876///   (所有系列共享同一类别轴,取第一个即可;散点图/气泡图无 `c:cat`,类别列表为空)。
877/// - **series**:遍历每个 `<c:ser>`,提取:
878///   - `name`:`<c:tx><c:strRef><c:strCache><c:pt><c:v>`
879///   - `values`:`<c:val><c:numRef><c:numCache><c:pt><c:v>`(Y 值)
880///   - `x_values`:`<c:xVal><c:numRef><c:numCache><c:pt><c:v>`(仅散点图/气泡图)
881///   - `bubble_sizes`:`<c:bubbleSize><c:numRef><c:numCache><c:pt><c:v>`(仅气泡图)
882///
883/// # 容错
884///
885/// - 缺失 `<c:plotArea>` 或其中无已知图表元素 → 返回 `Column` 占位类型(避免阻塞 round-trip);
886/// - 单个系列的 numCache/strCache 解析失败 → 该系列用空数据填充,不中断整体解析;
887/// - 不解析 `<c:axId>` / `<c:catAx>` / `<c:valAx>` / `<c:legend>` 等装饰元素
888///   (写出时由 [`Chart::to_xml`] 按图表类型自动重建)。
889///
890/// # 参数
891/// - `xml`:完整的 `<c:chartSpace>` XML 字符串(chartN.xml 内容)。
892///
893/// # 返回值
894/// - 成功:返回 [`Chart`],`rid` 字段为空字符串(由调用方根据 slide rels 填充);
895/// - 失败:返回 `Error::Xml`,包含解析错误上下文。
896///
897/// # 示例
898///
899/// ```no_run
900/// # use pptx_rs::oxml::chart::{Chart, ChartType};
901/// let xml = std::fs::read_to_string("chart1.xml").unwrap();
902/// let chart = Chart::parse_from_xml(&xml).expect("parse chart");
903/// println!("type: {:?}, series: {}", chart.chart_type, chart.data.series.len());
904/// ```
905pub fn parse_from_xml(xml: &str) -> crate::Result<Chart> {
906    use quick_xml::events::Event;
907    use quick_xml::reader::Reader;
908
909    let mut chart_type = ChartType::Column;
910    let mut title: Option<String> = None;
911    let mut series: Vec<ChartSeries> = Vec::new();
912    let mut categories: Vec<ChartCategory> = Vec::new();
913
914    let mut rd = Reader::from_str(xml);
915    rd.config_mut().trim_text(true);
916    let mut buf = Vec::new();
917    // 当前所在的图表元素名(如 c:barChart / c:lineChart),用于判断是否已进入 plotArea 的图表区域。
918    let mut in_chart_elem: Option<&'static str> = None;
919    // 是否在 <c:plotArea> 内(用于检测次坐标轴的 <c:crosses val="max"/> 标记,
920    // 该标记位于 plotArea 下的 <c:valAx> 子元素内,而非主图表元素 barChart/lineChart 内)。
921    let mut in_plot_area = false;
922    // barChart 的 barDir 值(col/bar),用于区分 Column / Bar。
923    let mut bar_dir: Option<String> = None;
924    // 是否在 c:title 内的 a:t 元素中(用于精确提取标题文本,避免误取 a:defRPr 等元素的属性文本)。
925    let mut in_title_text = false;
926    // 当前正在解析的系列缓冲区。
927    let mut cur_ser: Option<ChartSeries> = None;
928    // 当前系列的字段上下文:正在读取的字段(tx/cat/val/xVal/yVal/bubbleSize)。
929    let mut ser_field: Option<SerField> = None;
930    // 是否在 numCache/strCache 内(决定 c:pt 的 c:v 是数值还是字符串)。
931    let mut in_cache = false;
932    // 嵌入式 Excel 工作簿的关系 id(从 <c:externalData r:id="..."> 提取)。
933    let mut external_data_rid: Option<String> = None;
934    // 临时缓冲:当前 cache 内的值列表。
935    let mut cur_values: Vec<f64> = Vec::new();
936    let mut cur_strings: Vec<String> = Vec::new();
937
938    // ===== 数据标签(<c:dLbls>)解析上下文 =====
939    //
940    // dLbls 可出现在两个位置:
941    //   1. 图表级:在 <c:barChart> 等图表元素内、<c:ser> 之前(plot 级配置);
942    //   2. 系列级:在 <c:ser> 内 <c:val>/<c:yVal> 之后(覆盖图表级)。
943    //
944    // 通过 `dl_target` 区分当前 dLbls 归属:进入 c:ser 之前是 Chart,进入后是 Series。
945    let mut chart_dl: Option<DataLabels> = None;
946    let mut series_dl: Option<DataLabels> = None;
947    // dLbls 解析目标:None=不在 dLbls 内, Some(Chart)=图表级, Some(Series)=系列级。
948    let mut dl_target: Option<DlTarget> = None;
949    // 是否检测到次坐标轴(<c:crosses val="max"/> 是 PowerPoint 识别次轴的关键标记)。
950    // 若解析到次轴定义,所有非散点/非饼图系列的 secondary_axis 字段置为 true。
951    let mut has_secondary_axis = false;
952
953    /// 系列字段枚举(用于 `ser_field` 上下文)。
954    #[derive(Copy, Clone, Debug, Eq, PartialEq)]
955    enum SerField {
956        /// `<c:tx>` 系列名。
957        Name,
958        /// `<c:cat>` 类别标签(仅取第一个系列的)。
959        Cat,
960        /// `<c:val>` 数值(Y 坐标)。
961        Val,
962        /// `<c:xVal>` X 坐标(散点/气泡图)。
963        XVal,
964        /// `<c:yVal>` Y 坐标(散点/气泡图,与 Val 同语义但元素名不同)。
965        YVal,
966        /// `<c:bubbleSize>` 气泡尺寸。
967        BubbleSize,
968    }
969
970    /// 数据标签归属(图表级 vs 系列级)。
971    #[derive(Copy, Clone, Debug, Eq, PartialEq)]
972    enum DlTarget {
973        /// 图表级 dLbls(写在 <c:barChart> 等图表元素内、<c:ser> 之前)。
974        Chart,
975        /// 系列级 dLbls(写在 <c:ser> 内 <c:val>/<c:yVal> 之后,覆盖图表级)。
976        Series,
977    }
978
979    /// 从 local name 字节判断是否为已知图表元素,返回 (元素名, 对应 ChartType)。
980    fn chart_elem_for(local: &[u8]) -> Option<(&'static str, ChartType)> {
981        match local {
982            b"barChart" => Some(("barChart", ChartType::Column)), // Column/Bar 由 barDir 后续区分
983            b"lineChart" => Some(("lineChart", ChartType::Line)),
984            b"pieChart" => Some(("pieChart", ChartType::Pie)),
985            b"scatterChart" => Some(("scatterChart", ChartType::Scatter)),
986            b"areaChart" => Some(("areaChart", ChartType::Area)),
987            b"radarChart" => Some(("radarChart", ChartType::Radar)),
988            b"bubbleChart" => Some(("bubbleChart", ChartType::Bubble)),
989            _ => None,
990        }
991    }
992
993    /// 提取元素 local name(去命名空间前缀)。
994    fn local_name_quick(name: &[u8]) -> &[u8] {
995        match name.iter().position(|&b| b == b':') {
996            Some(i) => &name[i + 1..],
997            None => name,
998        }
999    }
1000
1001    loop {
1002        match rd.read_event_into(&mut buf) {
1003            Ok(Event::Start(e)) => {
1004                // 把 e.name() 绑定到 let,避免临时 QName 在 as_ref() 后 drop
1005                let name = e.name();
1006                let local = local_name_quick(name.as_ref());
1007                // 提取 <c:externalData r:id="..."> 的关系 id(TODO-004 Excel 嵌入)。
1008                //
1009                // OOXML 中 externalData 通常是 Start + <c:autoUpdate/> + End 形式,
1010                // 这里在 Start 事件中提取 r:id 属性即可。
1011                if local == b"externalData" {
1012                    for a in e.attributes().flatten() {
1013                        // 兼容三种写法:
1014                        // - `r:id="..."`(标准 OOXML, relationships 命名空间前缀)
1015                        // - `ns0:id="..."` / `xxx:id="..."`(其他命名空间前缀 + id 本地名)
1016                        // - 裸 `id="..."`(无命名空间前缀,部分工具不严格加 r:)
1017                        let key = a.key.as_ref();
1018                        if key == b"r:id" || key == b"id" || key.ends_with(b":id") {
1019                            external_data_rid = Some(
1020                                a.normalized_value(quick_xml::XmlVersion::Implicit1_0)
1021                                    .unwrap_or_default()
1022                                    .to_string(),
1023                            );
1024                        }
1025                    }
1026                }
1027                // 进入 <c:plotArea>:标记后续 crosses 检测生效
1028                if local == b"plotArea" {
1029                    in_plot_area = true;
1030                }
1031                // 进入图表元素(plotArea 下)
1032                if let Some((elem, ct)) = chart_elem_for(local) {
1033                    in_chart_elem = Some(elem);
1034                    chart_type = ct;
1035                    // barChart 需要后续 barDir 区分 Column/Bar,此处先占位 Column
1036                }
1037                // barDir 属性(区分 Column / Bar)
1038                if local == b"barDir" && in_chart_elem.is_some() {
1039                    for a in e.attributes().flatten() {
1040                        if a.key.as_ref() == b"val" {
1041                            let v = a
1042                                .normalized_value(quick_xml::XmlVersion::Implicit1_0)
1043                                .unwrap_or_default()
1044                                .to_string();
1045                            bar_dir = Some(v);
1046                        }
1047                    }
1048                }
1049                // 进入 c:title 内的 a:t 元素(精确匹配,避免误取 a:defRPr 等元素的属性文本)
1050                if local == b"t" {
1051                    in_title_text = true;
1052                }
1053                // 进入 c:ser
1054                if local == b"ser" && in_chart_elem.is_some() {
1055                    cur_ser = Some(ChartSeries::default());
1056                    // 重置系列级 dLbls 缓冲(每个系列独立)。
1057                    series_dl = None;
1058                }
1059                // 进入 c:dLbls:根据当前是否在 c:ser 内决定归属。
1060                //
1061                // - 在 c:ser 内 → 系列级(覆盖图表级);
1062                // - 不在 c:ser 内但在图表元素内 → 图表级。
1063                if local == b"dLbls" {
1064                    if cur_ser.is_some() {
1065                        dl_target = Some(DlTarget::Series);
1066                        series_dl = Some(DataLabels::default());
1067                    } else if in_chart_elem.is_some() {
1068                        dl_target = Some(DlTarget::Chart);
1069                        chart_dl = Some(DataLabels::default());
1070                    }
1071                }
1072                // 系列字段上下文
1073                if cur_ser.is_some() {
1074                    match local {
1075                        b"tx" => ser_field = Some(SerField::Name),
1076                        b"cat" => ser_field = Some(SerField::Cat),
1077                        b"val" => ser_field = Some(SerField::Val),
1078                        b"xVal" => ser_field = Some(SerField::XVal),
1079                        b"yVal" => ser_field = Some(SerField::YVal),
1080                        b"bubbleSize" => ser_field = Some(SerField::BubbleSize),
1081                        _ => {}
1082                    }
1083                }
1084                // 进入 numCache / strCache
1085                if matches!(local, b"numCache" | b"strCache") {
1086                    in_cache = true;
1087                    cur_values.clear();
1088                    cur_strings.clear();
1089                }
1090                // 检测次坐标轴的 <c:crosses val="max"/> 标记。
1091                //
1092                // PowerPoint 通过该元素识别次坐标轴(显示在右侧)。
1093                // 解析到该标记后,所有非散点/非饼图系列的 secondary_axis 字段在
1094                // 解析末尾被置为 true(写出时由 to_xml 重建次轴定义)。
1095                //
1096                // 注意:crosses 元素位于 <c:valAx> 内(plotArea 下,但不在主图表元素
1097                // barChart/lineChart 内),因此用 in_plot_area 而非 in_chart_elem 判断。
1098                if local == b"crosses" && in_plot_area {
1099                    for a in e.attributes().flatten() {
1100                        if a.key.as_ref() == b"val" {
1101                            let v = a
1102                                .normalized_value(quick_xml::XmlVersion::Implicit1_0)
1103                                .unwrap_or_default();
1104                            if v == "max" {
1105                                has_secondary_axis = true;
1106                            }
1107                        }
1108                    }
1109                }
1110            }
1111            Ok(Event::Empty(e)) => {
1112                // 把 e.name() 绑定到 let,避免临时 QName 在 as_ref() 后 drop
1113                let name = e.name();
1114                let local = local_name_quick(name.as_ref());
1115                // barDir 自闭合形式
1116                if local == b"barDir" && in_chart_elem.is_some() {
1117                    for a in e.attributes().flatten() {
1118                        if a.key.as_ref() == b"val" {
1119                            let v = a
1120                                .normalized_value(quick_xml::XmlVersion::Implicit1_0)
1121                                .unwrap_or_default()
1122                                .to_string();
1123                            bar_dir = Some(v);
1124                        }
1125                    }
1126                }
1127                // c:pt 自闭合(无子元素,但通常 c:pt 是 Start + c:v + End)
1128                // c:v 自闭合形式(罕见但兼容)
1129                if local == b"v" && in_cache {
1130                    if let Some(v) = e.attributes().flatten().find(|a| a.key.as_ref() == b"val") {
1131                        let s = v
1132                            .normalized_value(quick_xml::XmlVersion::Implicit1_0)
1133                            .unwrap_or_default()
1134                            .to_string();
1135                        if let Ok(f) = s.parse::<f64>() {
1136                            cur_values.push(f);
1137                        }
1138                        cur_strings.push(s);
1139                    }
1140                }
1141                // ===== 数据标签子元素解析(仅在 dLbls 上下文内) =====
1142                //
1143                // 这些子元素均以自闭合形式出现(<c:showVal val="1"/>),
1144                // 统一在 Empty 事件中处理。归属由 `dl_target` 决定。
1145                if let Some(target) = dl_target.as_ref() {
1146                    // 取目标 dLbls 缓冲的可变引用(图表级或系列级)。
1147                    let dl_buf: &mut Option<DataLabels> = if *target == DlTarget::Chart {
1148                        &mut chart_dl
1149                    } else {
1150                        &mut series_dl
1151                    };
1152                    let dl = dl_buf.get_or_insert_with(DataLabels::default);
1153                    match local {
1154                        b"showVal" => {
1155                            dl.show_val = parse_bool_val(&e);
1156                        }
1157                        b"showCatName" => {
1158                            dl.show_cat_name = parse_bool_val(&e);
1159                        }
1160                        b"showSerName" => {
1161                            dl.show_ser_name = parse_bool_val(&e);
1162                        }
1163                        b"showLegendKey" => {
1164                            dl.show_legend_key = parse_bool_val(&e);
1165                        }
1166                        b"showPercent" => {
1167                            dl.show_percent = parse_bool_val(&e);
1168                        }
1169                        b"showBubbleSize" => {
1170                            dl.show_bubble_size = parse_bool_val(&e);
1171                        }
1172                        b"dLblPos" => {
1173                            if let Some(v) = attr_val(&e, "val") {
1174                                dl.position = LabelPosition::parse(&v);
1175                            }
1176                        }
1177                        b"separator" => {
1178                            if let Some(v) = attr_val(&e, "val") {
1179                                dl.separator = Some(v);
1180                            }
1181                        }
1182                        b"numFmt" => {
1183                            if let Some(fc) = attr_val(&e, "formatCode") {
1184                                dl.num_fmt = Some(fc);
1185                            }
1186                        }
1187                        _ => {}
1188                    }
1189                }
1190                // c:crosses 自闭合形式(部分工具输出自闭合的 <c:crosses val="max"/>)。
1191                // 同 Start 分支:用 in_plot_area 判断(crosses 在 <c:valAx> 内,不在主图表元素内)。
1192                if local == b"crosses" && in_plot_area {
1193                    if let Some(v) = attr_val(&e, "val") {
1194                        if v == "max" {
1195                            has_secondary_axis = true;
1196                        }
1197                    }
1198                }
1199            }
1200            Ok(Event::Text(t)) => {
1201                // 标题文本(c:title 内的第一个 a:t 元素的文本内容)
1202                if in_title_text && title.is_none() {
1203                    // quick-xml 0.40: BytesText::unescape() 方法已移除,
1204                    // 改用 quick_xml::escape::unescape 函数(接受 &str)。
1205                    // BytesText 的 Deref 目标是 [u8],需要先转成 &str。
1206                    let text_str = std::str::from_utf8(t.as_ref()).unwrap_or("");
1207                    let text = quick_xml::escape::unescape(text_str)
1208                        .unwrap_or_default()
1209                        .to_string();
1210                    if !text.is_empty() {
1211                        title = Some(text);
1212                    }
1213                }
1214                // cache 内的文本(c:v 的文本内容)
1215                if in_cache {
1216                    let text_str = std::str::from_utf8(t.as_ref()).unwrap_or("");
1217                    let text = quick_xml::escape::unescape(text_str)
1218                        .unwrap_or_default()
1219                        .to_string();
1220                    if !text.is_empty() {
1221                        if let Ok(f) = text.parse::<f64>() {
1222                            cur_values.push(f);
1223                        }
1224                        cur_strings.push(text);
1225                    }
1226                }
1227            }
1228            Ok(Event::End(e)) => {
1229                // 把 e.name() 绑定到 let,避免临时 QName 在 as_ref() 后 drop
1230                let name = e.name();
1231                let local = local_name_quick(name.as_ref());
1232                // 离开 <c:plotArea>:清空 in_plot_area 标志
1233                if local == b"plotArea" {
1234                    in_plot_area = false;
1235                }
1236                // 离开图表元素
1237                if let Some(elem) = in_chart_elem {
1238                    if local == elem.as_bytes() {
1239                        // 最终确定 chart_type(barChart + barDir=bar → Bar)
1240                        if elem == "barChart" {
1241                            if let Some(dir) = &bar_dir {
1242                                if dir == "bar" {
1243                                    chart_type = ChartType::Bar;
1244                                }
1245                            }
1246                        }
1247                        in_chart_elem = None;
1248                    }
1249                }
1250                // 离开 a:t 元素
1251                if local == b"t" {
1252                    in_title_text = false;
1253                }
1254                // 离开 c:dLbls:清空 dl_target 上下文(缓冲已由子元素 Empty 事件填充)。
1255                if local == b"dLbls" {
1256                    dl_target = None;
1257                }
1258                // 离开 numCache/strCache:把缓冲写入当前字段
1259                if matches!(local, b"numCache" | b"strCache") && cur_ser.is_some() {
1260                    let field = ser_field.take();
1261                    if let Some(f) = field {
1262                        match f {
1263                            SerField::Name => {
1264                                // 系列名取第一个字符串
1265                                if let Some(s) = cur_strings.first() {
1266                                    if let Some(s_obj) = cur_ser.as_mut() {
1267                                        s_obj.name = s.clone();
1268                                    }
1269                                }
1270                                ser_field = None;
1271                            }
1272                            SerField::Cat => {
1273                                // 类别标签:仅第一个系列的 cat 写入 categories
1274                                if categories.is_empty() {
1275                                    for s in &cur_strings {
1276                                        categories.push(ChartCategory::new(s.clone()));
1277                                    }
1278                                }
1279                                ser_field = None;
1280                            }
1281                            SerField::Val | SerField::YVal => {
1282                                if let Some(s_obj) = cur_ser.as_mut() {
1283                                    s_obj.values = cur_values.clone();
1284                                }
1285                                ser_field = None;
1286                            }
1287                            SerField::XVal => {
1288                                if let Some(s_obj) = cur_ser.as_mut() {
1289                                    s_obj.x_values = Some(cur_values.clone());
1290                                }
1291                                ser_field = None;
1292                            }
1293                            SerField::BubbleSize => {
1294                                if let Some(s_obj) = cur_ser.as_mut() {
1295                                    s_obj.bubble_sizes = Some(cur_values.clone());
1296                                }
1297                                ser_field = None;
1298                            }
1299                        }
1300                    }
1301                    in_cache = false;
1302                    cur_values.clear();
1303                    cur_strings.clear();
1304                }
1305                // 离开 c:ser:把当前系列推入 series 列表,并写入系列级 dLbls。
1306                if local == b"ser" && cur_ser.is_some() {
1307                    if let Some(mut s_obj) = cur_ser.take() {
1308                        // 系列级数据标签:覆盖图表级配置。
1309                        if let Some(dl) = series_dl.take() {
1310                            if !dl.is_empty() {
1311                                s_obj.data_labels = Some(dl);
1312                            }
1313                        }
1314                        series.push(s_obj);
1315                    }
1316                    ser_field = None;
1317                    // 清理系列级 dLbls 上下文,避免泄漏到下一个系列。
1318                    series_dl = None;
1319                    if dl_target == Some(DlTarget::Series) {
1320                        dl_target = None;
1321                    }
1322                }
1323            }
1324            Ok(Event::Eof) => break,
1325            Err(e) => return Err(crate::Error::Xml(format!("chart parse_from_xml: {e}"))),
1326            _ => {}
1327        }
1328        buf.clear();
1329    }
1330
1331    // 若解析到次坐标轴(<c:crosses val="max"/>),把所有非散点/非饼图系列的
1332    // `secondary_axis` 字段置为 true。这是简化策略:OOXML 没有显式的"系列-轴绑定"元素,
1333    // 实际绑定通过图表级 axId 列表 + 次轴 crosses=max 隐式表达。
1334    //
1335    // 对散点图/气泡图/饼图忽略(OOXML 规范约束:这些类型不支持次坐标轴)。
1336    if has_secondary_axis && !chart_type.is_xy_chart() && !matches!(chart_type, ChartType::Pie) {
1337        for s in series.iter_mut() {
1338            s.secondary_axis = true;
1339        }
1340    }
1341
1342    let data = ChartData {
1343        categories,
1344        series,
1345        title,
1346        data_labels: chart_dl.filter(|dl| !dl.is_empty()),
1347    };
1348    Ok(Chart {
1349        chart_type,
1350        data,
1351        rid: String::new(),
1352        external_data_rid,
1353    })
1354}
1355
1356/// 从 `<c:showXxx val="1|0|true|false"/>` 提取 bool 值。
1357///
1358/// OOXML 规范要求 `val` 属性值为 "0" 或 "1"(XML Schema boolean),
1359/// 但部分工具(如 LibreOffice)会输出 "true" / "false",这里一并兼容。
1360fn parse_bool_val(e: &quick_xml::events::BytesStart<'_>) -> Option<bool> {
1361    for a in e.attributes().flatten() {
1362        if a.key.as_ref() == b"val" {
1363            let v = a
1364                .normalized_value(quick_xml::XmlVersion::Implicit1_0)
1365                .unwrap_or_default();
1366            return match v.as_ref() {
1367                "1" | "true" | "True" => Some(true),
1368                "0" | "false" | "False" => Some(false),
1369                _ => None,
1370            };
1371        }
1372    }
1373    None
1374}
1375
1376/// 从元素提取指定属性值(如 `val` / `formatCode`),未找到返回 `None`。
1377///
1378/// 兼容带命名空间前缀的写法(如 `r:val`),用 local name 后缀匹配。
1379fn attr_val(e: &quick_xml::events::BytesStart<'_>, key: &str) -> Option<String> {
1380    let key_bytes = key.as_bytes();
1381    // 构造 ":key" 字节序列,用于后缀匹配(如匹配 "r:val" 中的 ":val")。
1382    let suffix: Vec<u8> = std::iter::once(b':')
1383        .chain(key_bytes.iter().copied())
1384        .collect();
1385    for a in e.attributes().flatten() {
1386        let k = a.key.as_ref();
1387        if k == key_bytes || k.ends_with(&suffix) {
1388            return Some(
1389                a.normalized_value(quick_xml::XmlVersion::Implicit1_0)
1390                    .unwrap_or_default()
1391                    .to_string(),
1392            );
1393        }
1394    }
1395    None
1396}
1397
1398#[cfg(test)]
1399mod tests {
1400    use super::*;
1401
1402    /// 柱状图最小 XML 包含必要元素。
1403    #[test]
1404    fn column_chart_minimal_xml() {
1405        let data = ChartData {
1406            categories: vec![ChartCategory::new("Q1"), ChartCategory::new("Q2")],
1407            series: vec![ChartSeries::new("Sales", vec![10.0, 20.0])],
1408            title: Some("Revenue".to_string()),
1409            data_labels: None,
1410        };
1411        let chart = Chart::new(ChartType::Column, data);
1412        let xml = chart.to_xml();
1413        assert!(xml.contains("<c:chartSpace"), "xml: {}", xml);
1414        assert!(xml.contains("<c:barChart>"), "xml: {}", xml);
1415        assert!(xml.contains("barDir"), "xml: {}", xml);
1416        assert!(xml.contains("val=\"col\""), "xml: {}", xml);
1417        assert!(xml.contains("<c:ser>"), "xml: {}", xml);
1418        assert!(xml.contains("<c:catAx>"), "xml: {}", xml);
1419        assert!(xml.contains("<c:valAx>"), "xml: {}", xml);
1420        assert!(xml.contains("Revenue"), "xml: {}", xml);
1421    }
1422
1423    /// 饼图不写出 catAx/valAx。
1424    #[test]
1425    fn pie_chart_no_axes() {
1426        let data = ChartData {
1427            categories: vec![ChartCategory::new("A"), ChartCategory::new("B")],
1428            series: vec![ChartSeries::new("X", vec![1.0, 2.0])],
1429            title: None,
1430            data_labels: None,
1431        };
1432        let xml = Chart::new(ChartType::Pie, data).to_xml();
1433        assert!(xml.contains("<c:pieChart>"), "xml: {}", xml);
1434        assert!(!xml.contains("<c:catAx>"), "xml: {}", xml);
1435        assert!(!xml.contains("<c:valAx>"), "xml: {}", xml);
1436        assert!(xml.contains("varyColors"), "xml: {}", xml);
1437    }
1438
1439    /// 折线图使用 lineChart 元素。
1440    #[test]
1441    fn line_chart_uses_line_chart_element() {
1442        let data = ChartData {
1443            categories: vec![ChartCategory::new("X1"), ChartCategory::new("X2")],
1444            series: vec![ChartSeries::new("Y", vec![1.5, 2.5])],
1445            title: None,
1446            data_labels: None,
1447        };
1448        let xml = Chart::new(ChartType::Line, data).to_xml();
1449        assert!(xml.contains("<c:lineChart>"), "xml: {}", xml);
1450        assert!(!xml.contains("<c:barChart>"), "xml: {}", xml);
1451        // 浮点值应正确序列化
1452        assert!(xml.contains("1.5"), "xml: {}", xml);
1453        assert!(xml.contains("2.5"), "xml: {}", xml);
1454    }
1455
1456    /// 散点图使用 scatterChart 元素 + c:xVal/c:yVal + 两个 valAx。
1457    #[test]
1458    fn scatter_chart_uses_scatter_elements() {
1459        let data = ChartData {
1460            categories: vec![], // 散点图忽略 categories
1461            series: vec![ChartSeries::new_scatter(
1462                "Series1",
1463                vec![1.0, 2.0, 3.0], // X
1464                vec![2.0, 4.0, 6.0], // Y
1465            )],
1466            title: Some("Scatter".to_string()),
1467            data_labels: None,
1468        };
1469        let xml = Chart::new(ChartType::Scatter, data).to_xml();
1470        assert!(xml.contains("<c:scatterChart>"), "xml: {}", xml);
1471        assert!(xml.contains("scatterStyle"), "xml: {}", xml);
1472        assert!(xml.contains("<c:xVal>"), "xml: {}", xml);
1473        assert!(xml.contains("<c:yVal>"), "xml: {}", xml);
1474        // 散点图不应有 catAx
1475        assert!(!xml.contains("<c:catAx>"), "xml: {}", xml);
1476        // 散点图应有两个 valAx
1477        let val_ax_count = xml.matches("<c:valAx>").count();
1478        assert_eq!(
1479            val_ax_count, 2,
1480            "expected 2 valAx, got {}: {}",
1481            val_ax_count, xml
1482        );
1483        // 散点图不应有 c:cat
1484        assert!(!xml.contains("<c:cat>"), "xml: {}", xml);
1485        // X 坐标值应正确序列化
1486        assert!(xml.contains(">1<"), "xml: {}", xml);
1487        assert!(xml.contains(">3<"), "xml: {}", xml);
1488    }
1489
1490    /// 面积图使用 areaChart 元素 + grouping=standard + catAx + valAx。
1491    #[test]
1492    fn area_chart_uses_area_elements() {
1493        let data = ChartData {
1494            categories: vec![ChartCategory::new("A"), ChartCategory::new("B")],
1495            series: vec![ChartSeries::new("S1", vec![10.0, 20.0])],
1496            title: None,
1497            data_labels: None,
1498        };
1499        let xml = Chart::new(ChartType::Area, data).to_xml();
1500        assert!(xml.contains("<c:areaChart>"), "xml: {}", xml);
1501        assert!(xml.contains("grouping"), "xml: {}", xml);
1502        assert!(xml.contains("val=\"standard\""), "xml: {}", xml);
1503        assert!(xml.contains("<c:catAx>"), "xml: {}", xml);
1504        assert!(xml.contains("<c:valAx>"), "xml: {}", xml);
1505        // 面积图应有 c:cat(类别标签)
1506        assert!(xml.contains("<c:cat>"), "xml: {}", xml);
1507    }
1508
1509    /// 雷达图使用 radarChart 元素 + radarStyle=marker + catAx + valAx。
1510    #[test]
1511    fn radar_chart_uses_radar_elements() {
1512        let data = ChartData {
1513            categories: vec![
1514                ChartCategory::new("速度"),
1515                ChartCategory::new("力量"),
1516                ChartCategory::new("技巧"),
1517            ],
1518            series: vec![ChartSeries::new("选手A", vec![8.0, 6.0, 9.0])],
1519            title: Some("Radar".to_string()),
1520            data_labels: None,
1521        };
1522        let xml = Chart::new(ChartType::Radar, data).to_xml();
1523        assert!(xml.contains("<c:radarChart>"), "xml: {}", xml);
1524        assert!(xml.contains("radarStyle"), "xml: {}", xml);
1525        assert!(xml.contains("val=\"marker\""), "xml: {}", xml);
1526        // 雷达图使用 catAx + valAx(与柱/线相同)
1527        assert!(xml.contains("<c:catAx>"), "xml: {}", xml);
1528        assert!(xml.contains("<c:valAx>"), "xml: {}", xml);
1529        // 雷达图有 c:cat(类别标签)
1530        assert!(xml.contains("<c:cat>"), "xml: {}", xml);
1531        // 雷达图不应有散点图元素
1532        assert!(!xml.contains("<c:xVal>"), "xml: {}", xml);
1533        assert!(!xml.contains("<c:bubbleSize>"), "xml: {}", xml);
1534    }
1535
1536    /// 气泡图使用 bubbleChart 元素 + bubbleScale + c:xVal/c:yVal/c:bubbleSize + 两个 valAx。
1537    #[test]
1538    fn bubble_chart_uses_bubble_elements() {
1539        let data = ChartData {
1540            categories: vec![],
1541            series: vec![ChartSeries::new_bubble(
1542                "S1",
1543                vec![1.0, 2.0, 3.0],    // x_values
1544                vec![10.0, 20.0, 30.0], // y_values
1545                vec![5.0, 15.0, 25.0],  // bubble_sizes
1546            )],
1547            title: Some("Bubble".to_string()),
1548            data_labels: None,
1549        };
1550        let xml = Chart::new(ChartType::Bubble, data).to_xml();
1551        assert!(xml.contains("<c:bubbleChart>"), "xml: {}", xml);
1552        assert!(xml.contains("bubbleScale"), "xml: {}", xml);
1553        assert!(xml.contains("val=\"100\""), "xml: {}", xml);
1554        // 气泡图使用 c:xVal / c:yVal / c:bubbleSize
1555        assert!(xml.contains("<c:xVal>"), "xml: {}", xml);
1556        assert!(xml.contains("<c:yVal>"), "xml: {}", xml);
1557        assert!(xml.contains("<c:bubbleSize>"), "xml: {}", xml);
1558        // 气泡图使用两个 valAx(无 catAx)
1559        assert!(!xml.contains("<c:catAx>"), "xml: {}", xml);
1560        assert!(xml.contains("<c:valAx>"), "xml: {}", xml);
1561        // 验证气泡尺寸值被正确写出
1562        assert!(xml.contains(">5<"), "xml: {}", xml);
1563        assert!(xml.contains(">25<"), "xml: {}", xml);
1564    }
1565
1566    /// col_letter 正确转换。
1567    #[test]
1568    fn col_letter_basic() {
1569        assert_eq!(col_letter(1), "A");
1570        assert_eq!(col_letter(2), "B");
1571        assert_eq!(col_letter(26), "Z");
1572        assert_eq!(col_letter(27), "AA");
1573        assert_eq!(col_letter(28), "AB");
1574    }
1575
1576    /// format_f64 整数无小数点。
1577    #[test]
1578    fn format_f64_integer() {
1579        assert_eq!(format_f64(10.0), "10");
1580        assert_eq!(format_f64(1.5), "1.5");
1581        assert_eq!(format_f64(f64::NAN), "0");
1582    }
1583
1584    // ===================== TODO-004 图表读路径测试 =====================
1585
1586    /// 柱状图 round-trip:to_xml → parse_from_xml → 字段一致。
1587    ///
1588    /// 覆盖:Column 类型识别(barDir=col)、标题、类别、单系列数值。
1589    #[test]
1590    fn parse_column_chart_round_trip() {
1591        let original = Chart::new(
1592            ChartType::Column,
1593            ChartData {
1594                categories: vec![
1595                    ChartCategory::new("Q1"),
1596                    ChartCategory::new("Q2"),
1597                    ChartCategory::new("Q3"),
1598                ],
1599                series: vec![ChartSeries::new("Sales", vec![10.0, 20.0, 30.0])],
1600                title: Some("Revenue".to_string()),
1601                data_labels: None,
1602            },
1603        );
1604        let xml = original.to_xml();
1605        let parsed = Chart::parse_from_xml(&xml).expect("parse column chart");
1606
1607        assert_eq!(parsed.chart_type, ChartType::Column);
1608        assert_eq!(parsed.data.title.as_deref(), Some("Revenue"));
1609        assert_eq!(parsed.data.categories.len(), 3);
1610        assert_eq!(parsed.data.categories[0].name, "Q1");
1611        assert_eq!(parsed.data.categories[2].name, "Q3");
1612        assert_eq!(parsed.data.series.len(), 1);
1613        assert_eq!(parsed.data.series[0].name, "Sales");
1614        assert_eq!(parsed.data.series[0].values, vec![10.0, 20.0, 30.0]);
1615    }
1616
1617    /// 条形图 round-trip:barDir=bar 区分 Bar 与 Column。
1618    ///
1619    /// 这是 parse_from_xml 的关键边界:barChart 元素本身不区分柱/条,
1620    /// 必须依靠 barDir 属性后续修正 chart_type。
1621    #[test]
1622    fn parse_bar_chart_distinguishes_bar_from_column() {
1623        let original = Chart::new(
1624            ChartType::Bar,
1625            ChartData {
1626                categories: vec![ChartCategory::new("A"), ChartCategory::new("B")],
1627                series: vec![ChartSeries::new("S1", vec![1.0, 2.0])],
1628                title: None,
1629                data_labels: None,
1630            },
1631        );
1632        let xml = original.to_xml();
1633        let parsed = Chart::parse_from_xml(&xml).expect("parse bar chart");
1634
1635        // 必须识别为 Bar 而非 Column
1636        assert_eq!(parsed.chart_type, ChartType::Bar);
1637        assert_ne!(parsed.chart_type, ChartType::Column);
1638        assert_eq!(parsed.data.series.len(), 1);
1639        assert_eq!(parsed.data.series[0].values, vec![1.0, 2.0]);
1640    }
1641
1642    /// 折线图 round-trip:lineChart 元素识别 + 浮点数保留。
1643    #[test]
1644    fn parse_line_chart_round_trip() {
1645        let original = Chart::new(
1646            ChartType::Line,
1647            ChartData {
1648                categories: vec![ChartCategory::new("X1"), ChartCategory::new("X2")],
1649                series: vec![ChartSeries::new("Y", vec![1.5, 2.5])],
1650                title: None,
1651                data_labels: None,
1652            },
1653        );
1654        let xml = original.to_xml();
1655        let parsed = Chart::parse_from_xml(&xml).expect("parse line chart");
1656
1657        assert_eq!(parsed.chart_type, ChartType::Line);
1658        assert_eq!(parsed.data.categories.len(), 2);
1659        assert_eq!(parsed.data.series[0].name, "Y");
1660        // 浮点值必须正确解析
1661        assert_eq!(parsed.data.series[0].values, vec![1.5, 2.5]);
1662    }
1663
1664    /// 饼图 round-trip:pieChart 元素识别 + categories 保留(饼图也写 c:cat)。
1665    #[test]
1666    fn parse_pie_chart_round_trip() {
1667        let original = Chart::new(
1668            ChartType::Pie,
1669            ChartData {
1670                categories: vec![ChartCategory::new("A"), ChartCategory::new("B")],
1671                series: vec![ChartSeries::new("X", vec![1.0, 2.0])],
1672                title: None,
1673                data_labels: None,
1674            },
1675        );
1676        let xml = original.to_xml();
1677        let parsed = Chart::parse_from_xml(&xml).expect("parse pie chart");
1678
1679        assert_eq!(parsed.chart_type, ChartType::Pie);
1680        // 饼图 to_xml 也写 c:cat(line 654-669),所以解析后 categories 应保留
1681        assert_eq!(parsed.data.categories.len(), 2);
1682        assert_eq!(parsed.data.categories[0].name, "A");
1683        assert_eq!(parsed.data.categories[1].name, "B");
1684        assert_eq!(parsed.data.series.len(), 1);
1685        assert_eq!(parsed.data.series[0].values, vec![1.0, 2.0]);
1686    }
1687
1688    /// 散点图 round-trip:scatterChart 元素 + c:xVal/c:yVal 解析。
1689    ///
1690    /// 散点图无 c:cat,使用 c:xVal / c:yVal 提供坐标。
1691    #[test]
1692    fn parse_scatter_chart_round_trip() {
1693        let original = Chart::new(
1694            ChartType::Scatter,
1695            ChartData {
1696                categories: vec![],
1697                series: vec![ChartSeries::new_scatter(
1698                    "Series1",
1699                    vec![1.0, 2.0, 3.0], // X
1700                    vec![2.0, 4.0, 6.0], // Y
1701                )],
1702                title: Some("Scatter".to_string()),
1703                data_labels: None,
1704            },
1705        );
1706        let xml = original.to_xml();
1707        let parsed = Chart::parse_from_xml(&xml).expect("parse scatter chart");
1708
1709        assert_eq!(parsed.chart_type, ChartType::Scatter);
1710        assert_eq!(parsed.data.title.as_deref(), Some("Scatter"));
1711        assert_eq!(parsed.data.series.len(), 1);
1712        let s = &parsed.data.series[0];
1713        assert_eq!(s.name, "Series1");
1714        // Y 坐标写入 values
1715        assert_eq!(s.values, vec![2.0, 4.0, 6.0]);
1716        // X 坐标写入 x_values
1717        assert_eq!(s.x_values.as_deref(), Some(&[1.0, 2.0, 3.0][..]));
1718        // 散点图无 bubble_sizes
1719        assert!(s.bubble_sizes.is_none());
1720    }
1721
1722    /// 气泡图 round-trip:bubbleChart 元素 + c:xVal/c:yVal/c:bubbleSize 解析。
1723    #[test]
1724    fn parse_bubble_chart_round_trip() {
1725        let original = Chart::new(
1726            ChartType::Bubble,
1727            ChartData {
1728                categories: vec![],
1729                series: vec![ChartSeries::new_bubble(
1730                    "S1",
1731                    vec![1.0, 2.0, 3.0],    // x_values
1732                    vec![10.0, 20.0, 30.0], // y_values
1733                    vec![5.0, 15.0, 25.0],  // bubble_sizes
1734                )],
1735                title: Some("Bubble".to_string()),
1736                data_labels: None,
1737            },
1738        );
1739        let xml = original.to_xml();
1740        let parsed = Chart::parse_from_xml(&xml).expect("parse bubble chart");
1741
1742        assert_eq!(parsed.chart_type, ChartType::Bubble);
1743        assert_eq!(parsed.data.series.len(), 1);
1744        let s = &parsed.data.series[0];
1745        assert_eq!(s.name, "S1");
1746        assert_eq!(s.values, vec![10.0, 20.0, 30.0]);
1747        assert_eq!(s.x_values.as_deref(), Some(&[1.0, 2.0, 3.0][..]));
1748        assert_eq!(s.bubble_sizes.as_deref(), Some(&[5.0, 15.0, 25.0][..]));
1749    }
1750
1751    /// 雷达图 round-trip:radarChart 元素识别 + 类别标签保留。
1752    #[test]
1753    fn parse_radar_chart_round_trip() {
1754        let original = Chart::new(
1755            ChartType::Radar,
1756            ChartData {
1757                categories: vec![
1758                    ChartCategory::new("速度"),
1759                    ChartCategory::new("力量"),
1760                    ChartCategory::new("技巧"),
1761                ],
1762                series: vec![ChartSeries::new("选手A", vec![8.0, 6.0, 9.0])],
1763                title: Some("Radar".to_string()),
1764                data_labels: None,
1765            },
1766        );
1767        let xml = original.to_xml();
1768        let parsed = Chart::parse_from_xml(&xml).expect("parse radar chart");
1769
1770        assert_eq!(parsed.chart_type, ChartType::Radar);
1771        assert_eq!(parsed.data.categories.len(), 3);
1772        assert_eq!(parsed.data.categories[0].name, "速度");
1773        assert_eq!(parsed.data.series[0].values, vec![8.0, 6.0, 9.0]);
1774    }
1775
1776    /// 多系列柱状图 round-trip:验证系列顺序与名称。
1777    #[test]
1778    fn parse_multi_series_column_chart() {
1779        let original = Chart::new(
1780            ChartType::Column,
1781            ChartData {
1782                categories: vec![ChartCategory::new("A"), ChartCategory::new("B")],
1783                series: vec![
1784                    ChartSeries::new("S1", vec![1.0, 2.0]),
1785                    ChartSeries::new("S2", vec![3.0, 4.0]),
1786                    ChartSeries::new("S3", vec![5.0, 6.0]),
1787                ],
1788                title: None,
1789                data_labels: None,
1790            },
1791        );
1792        let xml = original.to_xml();
1793        let parsed = Chart::parse_from_xml(&xml).expect("parse multi-series chart");
1794
1795        assert_eq!(parsed.data.series.len(), 3);
1796        assert_eq!(parsed.data.series[0].name, "S1");
1797        assert_eq!(parsed.data.series[1].name, "S2");
1798        assert_eq!(parsed.data.series[2].name, "S3");
1799        assert_eq!(parsed.data.series[2].values, vec![5.0, 6.0]);
1800    }
1801
1802    /// 无标题图表 round-trip:title 字段为 None。
1803    #[test]
1804    fn parse_chart_without_title() {
1805        let original = Chart::new(
1806            ChartType::Column,
1807            ChartData {
1808                categories: vec![ChartCategory::new("A")],
1809                series: vec![ChartSeries::new("S", vec![1.0])],
1810                title: None,
1811                data_labels: None,
1812            },
1813        );
1814        let xml = original.to_xml();
1815        let parsed = Chart::parse_from_xml(&xml).expect("parse no-title chart");
1816
1817        assert!(parsed.data.title.is_none());
1818    }
1819
1820    /// 解析空 chartSpace(无 plotArea 子元素)应返回默认 Column 类型而非 panic。
1821    ///
1822    /// 验证零 panic 设计:解析失败不阻塞 round-trip。
1823    #[test]
1824    fn parse_empty_chart_space_no_panic() {
1825        let xml = "<?xml version=\"1.0\"?>\
1826                   <c:chartSpace xmlns:c=\"http://schemas.openxmlformats.org/drawingml/2006/chart\">\
1827                     <c:chart></c:chart>\
1828                   </c:chartSpace>";
1829        let parsed = Chart::parse_from_xml(xml).expect("parse empty chartSpace");
1830        // 无 plotArea → chart_type 保持默认 Column
1831        assert_eq!(parsed.chart_type, ChartType::Column);
1832        assert!(parsed.data.series.is_empty());
1833        assert!(parsed.data.categories.is_empty());
1834    }
1835
1836    /// 解析畸形 XML 应返回 Error::Xml 而非 panic。
1837    #[test]
1838    fn parse_malformed_xml_returns_error() {
1839        // 注意:quick-xml 0.40 SAX 流式解析器对未闭合标签较宽容(不跟踪标签栈),
1840        // 需要用真正畸形的 XML 才能触发解析错误。
1841        // 未闭合注释(`<!--` 后必须有 `-->`)是 quick-xml 0.40 必报错的场景。
1842        let xml = "<c:chartSpace><!-- unclosed comment <c:chart/></c:chartSpace>";
1843        let result = Chart::parse_from_xml(xml);
1844        assert!(result.is_err(), "malformed xml should error");
1845    }
1846
1847    /// external_data_rid 写出:to_xml 在 `</c:chart>` 之后、`</c:chartSpace>` 之前
1848    /// 生成 `<c:externalData r:id="..."><c:autoUpdate val="0"/></c:externalData>`(TODO-004 Excel 嵌入)。
1849    #[test]
1850    fn to_xml_writes_external_data() {
1851        let mut chart = Chart::new(
1852            ChartType::Column,
1853            ChartData {
1854                categories: vec![ChartCategory::new("A")],
1855                series: vec![ChartSeries::new("S", vec![1.0])],
1856                title: None,
1857                data_labels: None,
1858            },
1859        );
1860        chart.external_data_rid = Some("rIdXlsx1".to_string());
1861        let xml = chart.to_xml();
1862        // 元素顺序:c:chart 关闭后紧跟 c:externalData
1863        assert!(
1864            xml.contains(r#"<c:externalData r:id="rIdXlsx1">"#),
1865            "应输出 c:externalData 开标签"
1866        );
1867        assert!(
1868            xml.contains(r#"<c:autoUpdate val="0"/>"#),
1869            "应输出 autoUpdate=0"
1870        );
1871        assert!(xml.contains("</c:externalData>"), "应闭合 c:externalData");
1872        // 验证顺序:c:chart 关闭在 c:externalData 之前
1873        let pos_chart_close = xml.find("</c:chart>").expect("c:chart 关闭存在");
1874        let pos_ext = xml.find("<c:externalData").expect("c:externalData 存在");
1875        assert!(
1876            pos_chart_close < pos_ext,
1877            "c:externalData 必须在 </c:chart> 之后"
1878        );
1879        let pos_chart_space_close = xml.rfind("</c:chartSpace>").expect("c:chartSpace 关闭存在");
1880        assert!(
1881            pos_ext < pos_chart_space_close,
1882            "c:externalData 必须在 </c:chartSpace> 之前"
1883        );
1884    }
1885
1886    /// external_data_rid = None 时不输出 c:externalData(保持原有行为)。
1887    #[test]
1888    fn to_xml_omits_external_data_when_none() {
1889        let chart = Chart::new(
1890            ChartType::Line,
1891            ChartData {
1892                categories: vec![ChartCategory::new("A")],
1893                series: vec![ChartSeries::new("S", vec![1.0])],
1894                title: None,
1895                data_labels: None,
1896            },
1897        );
1898        let xml = chart.to_xml();
1899        assert!(
1900            !xml.contains("c:externalData"),
1901            "None 时不应输出 c:externalData"
1902        );
1903    }
1904
1905    /// parse_from_xml 对称还原 external_data_rid(TODO-004 Excel 嵌入读路径)。
1906    #[test]
1907    fn parse_external_data_rid_round_trip() {
1908        let mut original = Chart::new(
1909            ChartType::Column,
1910            ChartData {
1911                categories: vec![ChartCategory::new("Q1"), ChartCategory::new("Q2")],
1912                series: vec![ChartSeries::new("Sales", vec![10.0, 20.0])],
1913                title: Some("Revenue".into()),
1914                data_labels: None,
1915            },
1916        );
1917        original.external_data_rid = Some("rIdXlsx1".into());
1918        let xml = original.to_xml();
1919        let parsed = Chart::parse_from_xml(&xml).expect("parse round-trip");
1920        assert_eq!(parsed.chart_type, ChartType::Column);
1921        assert_eq!(parsed.external_data_rid, Some("rIdXlsx1".to_string()));
1922    }
1923
1924    /// parse_from_xml 兼容 r:id 简写为 id 的写法(部分工具不严格加 r: 前缀)。
1925    #[test]
1926    fn parse_external_data_rid_bare_id_form() {
1927        let xml = "<?xml version=\"1.0\"?>\
1928                   <c:chartSpace xmlns:c=\"http://schemas.openxmlformats.org/drawingml/2006/chart\" \
1929                                 xmlns:r=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships\">\
1930                     <c:chart><c:plotArea><c:barChart><c:barDir val=\"col\"/>\
1931                       <c:ser><c:idx val=\"0\"/><c:order val=\"0\"/>\
1932                         <c:val><c:numCache><c:formatCode>General</c:formatCode>\
1933                           <c:ptCount val=\"1\"/><c:pt idx=\"0\"><c:v>1</c:v></c:pt>\
1934                         </c:numCache></c:val>\
1935                     </c:ser></c:barChart></c:plotArea></c:chart>\
1936                     <c:externalData id=\"rIdBare\"><c:autoUpdate val=\"0\"/></c:externalData>\
1937                   </c:chartSpace>";
1938        let parsed = Chart::parse_from_xml(xml).expect("parse bare id form");
1939        assert_eq!(parsed.external_data_rid, Some("rIdBare".to_string()));
1940    }
1941
1942    // ===================== 数据标签(dLbls)测试 =====================
1943
1944    /// `DataLabels::show_values()` 便捷构造器:仅 show_val=Some(true)。
1945    #[test]
1946    fn data_labels_show_values_constructor() {
1947        let dl = DataLabels::show_values();
1948        assert_eq!(dl.show_val, Some(true));
1949        assert_eq!(dl.show_cat_name, None);
1950        assert_eq!(dl.show_ser_name, None);
1951        assert!(!dl.is_empty());
1952    }
1953
1954    /// `DataLabels::show_percent_pie()` 便捷构造器:仅 show_percent=Some(true)。
1955    #[test]
1956    fn data_labels_show_percent_pie_constructor() {
1957        let dl = DataLabels::show_percent_pie();
1958        assert_eq!(dl.show_percent, Some(true));
1959        assert_eq!(dl.show_val, None);
1960        assert!(!dl.is_empty());
1961    }
1962
1963    /// `DataLabels::is_empty()` 对空配置返回 true。
1964    #[test]
1965    fn data_labels_is_empty_for_default() {
1966        let dl = DataLabels::default();
1967        assert!(dl.is_empty());
1968    }
1969
1970    /// `LabelPosition` as_str / from_str 往返。
1971    #[test]
1972    fn label_position_round_trip() {
1973        for pos in [
1974            LabelPosition::BestFit,
1975            LabelPosition::Above,
1976            LabelPosition::Below,
1977            LabelPosition::Center,
1978            LabelPosition::InsideEnd,
1979            LabelPosition::OutsideEnd,
1980            LabelPosition::Left,
1981            LabelPosition::Right,
1982        ] {
1983            let s = pos.as_str();
1984            // 未知字符串返回 None(不应误识别)
1985            let parsed = LabelPosition::parse(s).expect("known position should parse");
1986            assert_eq!(parsed, pos, "position round-trip failed for {:?}", pos);
1987        }
1988        // 未知字符串返回 None
1989        assert!(LabelPosition::parse("unknown").is_none());
1990    }
1991
1992    /// 图表级 `<c:dLbls>` round-trip:to_xml → parse_from_xml → 字段一致。
1993    ///
1994    /// 覆盖:show_val / show_cat_name / show_ser_name / show_legend_key / position / separator / numFmt。
1995    #[test]
1996    fn parse_chart_level_data_labels_round_trip() {
1997        let dl = DataLabels {
1998            show_val: Some(true),
1999            show_cat_name: Some(true),
2000            show_ser_name: Some(false),
2001            show_legend_key: Some(false),
2002            show_percent: None,
2003            show_bubble_size: None,
2004            position: Some(LabelPosition::OutsideEnd),
2005            separator: Some(", ".to_string()),
2006            num_fmt: Some("0.00".to_string()),
2007        };
2008        let original = Chart::new(
2009            ChartType::Column,
2010            ChartData {
2011                categories: vec![ChartCategory::new("Q1"), ChartCategory::new("Q2")],
2012                series: vec![ChartSeries::new("Sales", vec![10.0, 20.0])],
2013                title: None,
2014                data_labels: Some(dl.clone()),
2015            },
2016        );
2017        let xml = original.to_xml();
2018        // 验证 XML 包含 dLbls 子元素
2019        assert!(xml.contains("<c:dLbls>"), "xml: {}", xml);
2020        assert!(xml.contains(r#"<c:showVal val="1"/>"#), "xml: {}", xml);
2021        assert!(xml.contains(r#"<c:showCatName val="1"/>"#), "xml: {}", xml);
2022        assert!(xml.contains(r#"<c:showSerName val="0"/>"#), "xml: {}", xml);
2023        assert!(
2024            xml.contains(r#"<c:showLegendKey val="0"/>"#),
2025            "xml: {}",
2026            xml
2027        );
2028        assert!(xml.contains(r#"<c:dLblPos val="outEnd"/>"#), "xml: {}", xml);
2029        assert!(xml.contains(r#"<c:separator val=", "/>"#), "xml: {}", xml);
2030        assert!(xml.contains(r#"formatCode="0.00""#), "xml: {}", xml);
2031
2032        // round-trip:解析后字段一致
2033        let parsed = Chart::parse_from_xml(&xml).expect("parse chart with dLbls");
2034        let parsed_dl = parsed
2035            .data
2036            .data_labels
2037            .expect("chart-level dLbls should parse");
2038        assert_eq!(parsed_dl.show_val, Some(true));
2039        assert_eq!(parsed_dl.show_cat_name, Some(true));
2040        assert_eq!(parsed_dl.show_ser_name, Some(false));
2041        assert_eq!(parsed_dl.show_legend_key, Some(false));
2042        assert_eq!(parsed_dl.show_percent, None);
2043        assert_eq!(parsed_dl.position, Some(LabelPosition::OutsideEnd));
2044        assert_eq!(parsed_dl.separator.as_deref(), Some(", "));
2045        assert_eq!(parsed_dl.num_fmt.as_deref(), Some("0.00"));
2046    }
2047
2048    /// 系列级 `<c:dLbls>` round-trip:覆盖图表级配置。
2049    #[test]
2050    fn parse_series_level_data_labels_round_trip() {
2051        let series_dl = DataLabels {
2052            show_val: Some(true),
2053            show_percent: Some(true),
2054            position: Some(LabelPosition::InsideEnd),
2055            ..Default::default()
2056        };
2057        let original = Chart::new(
2058            ChartType::Pie,
2059            ChartData {
2060                categories: vec![ChartCategory::new("A"), ChartCategory::new("B")],
2061                series: vec![{
2062                    let mut s = ChartSeries::new("X", vec![1.0, 2.0]);
2063                    s.data_labels = Some(series_dl.clone());
2064                    s
2065                }],
2066                title: None,
2067                data_labels: None,
2068            },
2069        );
2070        let xml = original.to_xml();
2071        // 饼图 dLbls 应在 <c:ser> 内(系列级),而非图表级
2072        assert!(xml.contains("<c:dLbls>"), "xml: {}", xml);
2073        assert!(xml.contains(r#"<c:showVal val="1"/>"#), "xml: {}", xml);
2074        assert!(xml.contains(r#"<c:showPercent val="1"/>"#), "xml: {}", xml);
2075        assert!(xml.contains(r#"<c:dLblPos val="inEnd"/>"#), "xml: {}", xml);
2076
2077        let parsed = Chart::parse_from_xml(&xml).expect("parse pie with series dLbls");
2078        assert_eq!(parsed.chart_type, ChartType::Pie);
2079        // 图表级 dLbls 应为 None(pie 未设置图表级)
2080        assert!(parsed.data.data_labels.is_none());
2081        // 系列级 dLbls 应正确解析
2082        let s = &parsed.data.series[0];
2083        let parsed_dl = s.data_labels.as_ref().expect("series dLbls should parse");
2084        assert_eq!(parsed_dl.show_val, Some(true));
2085        assert_eq!(parsed_dl.show_percent, Some(true));
2086        assert_eq!(parsed_dl.position, Some(LabelPosition::InsideEnd));
2087    }
2088
2089    /// 空 `<c:dLbls/>`(无子元素)不应写入 data_labels 字段(保持 None)。
2090    #[test]
2091    fn parse_empty_dlbls_is_ignored() {
2092        // 构造一个图表级 dLbls 但所有字段都 None(is_empty=true)
2093        let original = Chart::new(
2094            ChartType::Column,
2095            ChartData {
2096                categories: vec![ChartCategory::new("A")],
2097                series: vec![ChartSeries::new("S", vec![1.0])],
2098                title: None,
2099                data_labels: Some(DataLabels::default()), // is_empty = true
2100            },
2101        );
2102        let xml = original.to_xml();
2103        // 空的 dLbls 不应输出(to_xml 检查 is_empty)
2104        assert!(
2105            !xml.contains("<c:dLbls>"),
2106            "empty dLbls should not be written: {}",
2107            xml
2108        );
2109        // 解析后 data_labels 也应为 None
2110        let parsed = Chart::parse_from_xml(&xml).expect("parse");
2111        assert!(parsed.data.data_labels.is_none());
2112    }
2113
2114    // ===================== 次坐标轴测试 =====================
2115
2116    /// `to_xml` 在 series.secondary_axis=true 时输出次坐标轴定义(含 crosses=max)。
2117    #[test]
2118    fn to_xml_writes_secondary_axis() {
2119        let s2 = {
2120            let mut s = ChartSeries::new("Secondary", vec![1.0, 2.0]);
2121            s.secondary_axis = true;
2122            s
2123        };
2124        let original = Chart::new(
2125            ChartType::Column,
2126            ChartData {
2127                categories: vec![ChartCategory::new("Q1"), ChartCategory::new("Q2")],
2128                series: vec![s2],
2129                title: None,
2130                data_labels: None,
2131            },
2132        );
2133        let xml = original.to_xml();
2134        // 应包含次轴 axId=444444444
2135        assert!(xml.contains(r#"<c:axId val="444444444"/>"#), "xml: {}", xml);
2136        // 应包含次轴定义(crosses=max)
2137        assert!(xml.contains(r#"<c:crosses val="max"/>"#), "xml: {}", xml);
2138        // 应有 2 个 valAx(主 valAx + 次 valAx)+ 1 个 catAx
2139        let val_ax_count = xml.matches("<c:valAx>").count();
2140        assert_eq!(
2141            val_ax_count, 2,
2142            "expected 2 valAx (primary + secondary): {}",
2143            xml
2144        );
2145        assert!(xml.contains("<c:catAx>"), "xml: {}", xml);
2146    }
2147
2148    /// 次坐标轴 round-trip:parse_from_xml 解析 crosses=max 后,
2149    /// 所有非散点/非饼图系列的 secondary_axis 字段置为 true。
2150    #[test]
2151    fn parse_secondary_axis_round_trip() {
2152        let original = Chart::new(
2153            ChartType::Column,
2154            ChartData {
2155                categories: vec![ChartCategory::new("Q1"), ChartCategory::new("Q2")],
2156                series: vec![{
2157                    let mut s = ChartSeries::new("S", vec![10.0, 20.0]);
2158                    s.secondary_axis = true;
2159                    s
2160                }],
2161                title: None,
2162                data_labels: None,
2163            },
2164        );
2165        let xml = original.to_xml();
2166        let parsed = Chart::parse_from_xml(&xml).expect("parse secondary axis chart");
2167        // 解析后所有非散点/非饼图系列的 secondary_axis 应为 true
2168        assert_eq!(parsed.data.series.len(), 1);
2169        assert!(
2170            parsed.data.series[0].secondary_axis,
2171            "secondary_axis should be true after round-trip"
2172        );
2173    }
2174
2175    /// 饼图不支持次坐标轴:secondary_axis 字段被忽略,不输出次轴定义。
2176    #[test]
2177    fn pie_chart_ignores_secondary_axis() {
2178        let original = Chart::new(
2179            ChartType::Pie,
2180            ChartData {
2181                categories: vec![ChartCategory::new("A"), ChartCategory::new("B")],
2182                series: vec![{
2183                    let mut s = ChartSeries::new("X", vec![1.0, 2.0]);
2184                    s.secondary_axis = true; // 应被忽略
2185                    s
2186                }],
2187                title: None,
2188                data_labels: None,
2189            },
2190        );
2191        let xml = original.to_xml();
2192        // 饼图不应输出次轴 axId
2193        assert!(
2194            !xml.contains("444444444"),
2195            "pie should not write secondary axId: {}",
2196            xml
2197        );
2198        assert!(
2199            !xml.contains(r#"<c:crosses val="max"/>"#),
2200            "pie should not write crosses=max: {}",
2201            xml
2202        );
2203    }
2204
2205    /// 散点图不支持次坐标轴:secondary_axis 字段被忽略。
2206    #[test]
2207    fn scatter_chart_ignores_secondary_axis() {
2208        let mut s = ChartSeries::new_scatter("S", vec![1.0, 2.0], vec![3.0, 4.0]);
2209        s.secondary_axis = true; // 应被忽略
2210        let original = Chart::new(
2211            ChartType::Scatter,
2212            ChartData {
2213                categories: vec![],
2214                series: vec![s],
2215                title: None,
2216                data_labels: None,
2217            },
2218        );
2219        let xml = original.to_xml();
2220        assert!(
2221            !xml.contains("444444444"),
2222            "scatter should not write secondary axId: {}",
2223            xml
2224        );
2225        assert!(
2226            !xml.contains(r#"<c:crosses val="max"/>"#),
2227            "scatter should not write crosses=max: {}",
2228            xml
2229        );
2230    }
2231}