Skip to main content

pptx_rs/oxml/
theme.rs

1//! Office 主题 `<a:theme>` 标准版。
2//!
3//! PowerPoint 强制要求一份合法的 theme1.xml,否则会被 Office/WPS 拒绝打开。
4//! 这里直接采用 python-pptx 默认输出的 Office 主题,保留完整结构与所有字体脚本。
5//! 出于二进制大小考虑,省略了 latin/ea/cs 之外的 `<a:font>` 列表(实际 Office 主题里全是它们)。
6//!
7//! # 与 python-pptx 的对应
8//!
9//! - `pptx.oxml.theme.Theme` ←→ [`Theme`] 结构体(v0.2 起支持结构化解析);
10//! - `pptx.parts.theme.ThemePart._element` 在加载时由 `parse_theme` 解析为 [`Theme`];
11//! - 写路径使用 [`Theme::to_xml`](结构化序列化)或 [`default_theme_xml`](完整 Office 主题 XML)。
12//!
13//! # 字体脚本表
14//!
15//! `THEME_XML` 完整列出了 30+ 个 script(`Jpan` / `Hang` / `Hans` / `Hant` / ...),
16//! 缺失任何一个都会导致 PowerPoint 警告"主题不完整"。
17
18use crate::oxml::ns::NS_DRAWING_MAIN;
19use crate::oxml::writer::XmlWriter;
20
21#[allow(dead_code)]
22const THEME_NAME: &str = "Office Theme";
23
24/// 结构化主题模型(对应 `<a:theme>`)。
25///
26/// 解析 `name` / `color_scheme` / `font_scheme` / `format_scheme` 四个核心字段。
27/// `<a:objectDefaults>` / `<a:extraClrSchemeLst>` 等暂不解析(写路径用 [`default_theme_xml`])。
28#[derive(Clone, Debug, Default)]
29pub struct Theme {
30    /// 主题名(`<a:theme name="Office Theme">`)。
31    pub name: String,
32    /// 颜色方案(`<a:clrScheme>`)。
33    pub color_scheme: ColorScheme,
34    /// 字体方案(`<a:fontScheme>`)。
35    pub font_scheme: FontScheme,
36    /// 格式方案(`<a:fmtScheme>`)。
37    pub format_scheme: FormatScheme,
38}
39
40/// 颜色方案(对应 `<a:clrScheme>`)。
41///
42/// 包含 12 个颜色槽位:dk1/lt1/dk2/lt2/accent1-6/hlink/folHlink。
43#[derive(Clone, Debug, Default)]
44pub struct ColorScheme {
45    /// 颜色方案名(`<a:clrScheme name="Office">`)。
46    pub name: String,
47    /// 暗色 1(通常为黑色/文字色)。
48    pub dk1: Option<ThemeColor>,
49    /// 亮色 1(通常为白色/背景色)。
50    pub lt1: Option<ThemeColor>,
51    /// 暗色 2。
52    pub dk2: Option<ThemeColor>,
53    /// 亮色 2。
54    pub lt2: Option<ThemeColor>,
55    /// 强调色 1-6。
56    pub accent1: Option<ThemeColor>,
57    pub accent2: Option<ThemeColor>,
58    pub accent3: Option<ThemeColor>,
59    pub accent4: Option<ThemeColor>,
60    pub accent5: Option<ThemeColor>,
61    pub accent6: Option<ThemeColor>,
62    /// 超链接颜色。
63    pub hlink: Option<ThemeColor>,
64    /// 已访问超链接颜色。
65    pub fol_hlink: Option<ThemeColor>,
66}
67
68/// 字体方案(对应 `<a:fontScheme>`)。
69///
70/// 解析 majorFont / minorFont 的 latin / ea / cs typeface。
71#[derive(Clone, Debug, Default)]
72pub struct FontScheme {
73    /// 字体方案名(`<a:fontScheme name="Office">`)。
74    pub name: String,
75    /// 主标题字体 latin(`<a:majorFont><a:latin typeface="..."/>`)。
76    pub major_latin: String,
77    /// 主标题字体 东亚(`<a:majorFont><a:ea typeface="..."/>`)。
78    pub major_ea: String,
79    /// 主标题字体 复杂文种(`<a:majorFont><a:cs typeface="..."/>`)。
80    pub major_cs: String,
81    /// 正文字体 latin(`<a:minorFont><a:latin typeface="..."/>`)。
82    pub minor_latin: String,
83    /// 正文字体 东亚(`<a:minorFont><a:ea typeface="..."/>`)。
84    pub minor_ea: String,
85    /// 正文字体 复杂文种(`<a:minorFont><a:cs typeface="..."/>`)。
86    pub minor_cs: String,
87}
88
89/// 格式方案(对应 `<a:fmtScheme>`)。
90///
91/// FormatScheme 包含 fillStyleLst / lnStyleLst / effectStyleLst / bgFillStyleLst,
92/// 每个都含有复杂的嵌套元素(gradFill / ln / effectLst / scene3d / sp3d 等)。
93///
94/// # 结构化策略
95///
96/// 采用**适度结构化**:保留 raw_xml 用于 round-trip,同时把 4 个 style 列表
97/// 拆分为 `fill_styles` / `line_styles` / `effect_styles` / `bg_fill_styles`,
98/// 每个元素是对应子元素的原始 XML 字符串(如 `<a:gradFill>...</a:gradFill>`)。
99/// 这样用户可以查询/替换某个 style,而无需完整解析每个 fill/ln/effect 的内部结构。
100#[derive(Clone, Debug, Default)]
101pub struct FormatScheme {
102    /// 格式方案名(`<a:fmtScheme name="Office">`)。
103    pub name: String,
104    /// 原始 XML 内容(`<a:fmtScheme>` 内部的子元素 XML)。
105    ///
106    /// 为空时 [`Theme::to_xml`] 会使用默认的 Office 格式方案。
107    pub raw_xml: String,
108    /// 填充样式列表(`<a:fillStyleLst>` 内的每个 fill 元素 XML)。
109    ///
110    /// 通常 3 个:solidFill / gradFill / gradFill。
111    pub fill_styles: Vec<String>,
112    /// 线条样式列表(`<a:lnStyleLst>` 内的每个 `<a:ln>` 元素 XML)。
113    ///
114    /// 通常 3 个。
115    pub line_styles: Vec<String>,
116    /// 效果样式列表(`<a:effectStyleLst>` 内的每个 `<a:effectStyle>` 元素 XML)。
117    ///
118    /// 通常 3 个。
119    pub effect_styles: Vec<String>,
120    /// 背景填充样式列表(`<a:bgFillStyleLst>` 内的每个 fill 元素 XML)。
121    ///
122    /// 通常 2 个。
123    pub bg_fill_styles: Vec<String>,
124}
125
126/// 主题颜色值(对应 `<a:srgbClr>` 或 `<a:sysClr>`)。
127#[derive(Clone, Debug, Default, PartialEq, Eq)]
128pub enum ThemeColor {
129    /// sRGB 颜色(`<a:srgbClr val="RRGGBB"/>`)。
130    Srgb(String),
131    /// 系统颜色(`<a:sysClr val="windowText" lastClr="000000"/>`)。
132    Sys(String, String),
133    /// 无颜色(解析失败或空槽位)。
134    #[default]
135    None,
136}
137
138impl ThemeColor {
139    /// 写出对应的 XML 元素到 writer。
140    ///
141    /// # 元素结构
142    ///
143    /// - `Srgb` → `<a:srgbClr val="RRGGBB"/>`
144    /// - `Sys` → `<a:sysClr val="..." lastClr="..."/>`
145    /// - `None` → 不写出任何元素
146    pub fn write_xml(&self, w: &mut XmlWriter) {
147        match self {
148            ThemeColor::Srgb(val) => {
149                w.empty_with("a:srgbClr", &[("val", val.as_str())]);
150            }
151            ThemeColor::Sys(val, last_clr) => {
152                w.empty_with(
153                    "a:sysClr",
154                    &[("val", val.as_str()), ("lastClr", last_clr.as_str())],
155                );
156            }
157            ThemeColor::None => { /* 不写出 */ }
158        }
159    }
160}
161
162impl ColorScheme {
163    /// 写出 `<a:clrScheme>` 元素到 writer。
164    ///
165    /// # 元素顺序(OOXML 规范)
166    ///
167    /// dk1 → lt1 → dk2 → lt2 → accent1 → accent2 → accent3 → accent4 →
168    /// accent5 → accent6 → hlink → folHlink
169    pub fn write_xml(&self, w: &mut XmlWriter) {
170        let name = if self.name.is_empty() {
171            "Office"
172        } else {
173            self.name.as_str()
174        };
175        w.open_with("a:clrScheme", &[("name", name)]);
176        // 按 OOXML 规范顺序写出 12 个颜色槽位
177        self.write_slot(w, "a:dk1", &self.dk1);
178        self.write_slot(w, "a:lt1", &self.lt1);
179        self.write_slot(w, "a:dk2", &self.dk2);
180        self.write_slot(w, "a:lt2", &self.lt2);
181        self.write_slot(w, "a:accent1", &self.accent1);
182        self.write_slot(w, "a:accent2", &self.accent2);
183        self.write_slot(w, "a:accent3", &self.accent3);
184        self.write_slot(w, "a:accent4", &self.accent4);
185        self.write_slot(w, "a:accent5", &self.accent5);
186        self.write_slot(w, "a:accent6", &self.accent6);
187        self.write_slot(w, "a:hlink", &self.hlink);
188        self.write_slot(w, "a:folHlink", &self.fol_hlink);
189        w.close("a:clrScheme");
190    }
191
192    /// 写出单个颜色槽位(如 `<a:dk1><a:srgbClr val="..."/></a:dk1>`)。
193    fn write_slot(&self, w: &mut XmlWriter, tag: &str, color: &Option<ThemeColor>) {
194        w.open(tag);
195        if let Some(c) = color {
196            c.write_xml(w);
197        }
198        w.close(tag);
199    }
200}
201
202impl FontScheme {
203    /// 写出 `<a:fontScheme>` 元素到 writer。
204    ///
205    /// # 元素结构
206    ///
207    /// ```text
208    /// <a:fontScheme name="...">
209    ///   <a:majorFont>
210    ///     <a:latin typeface="..."/>
211    ///     <a:ea typeface="..."/>
212    ///     <a:cs typeface="..."/>
213    ///   </a:majorFont>
214    ///   <a:minorFont>
215    ///     <a:latin typeface="..."/>
216    ///     <a:ea typeface="..."/>
217    ///     <a:cs typeface="..."/>
218    ///   </a:minorFont>
219    /// </a:fontScheme>
220    /// ```
221    pub fn write_xml(&self, w: &mut XmlWriter) {
222        let name = if self.name.is_empty() {
223            "Office"
224        } else {
225            self.name.as_str()
226        };
227        w.open_with("a:fontScheme", &[("name", name)]);
228        // majorFont
229        w.open("a:majorFont");
230        w.empty_with("a:latin", &[("typeface", self.major_latin.as_str())]);
231        w.empty_with("a:ea", &[("typeface", self.major_ea.as_str())]);
232        w.empty_with("a:cs", &[("typeface", self.major_cs.as_str())]);
233        w.close("a:majorFont");
234        // minorFont
235        w.open("a:minorFont");
236        w.empty_with("a:latin", &[("typeface", self.minor_latin.as_str())]);
237        w.empty_with("a:ea", &[("typeface", self.minor_ea.as_str())]);
238        w.empty_with("a:cs", &[("typeface", self.minor_cs.as_str())]);
239        w.close("a:minorFont");
240        w.close("a:fontScheme");
241    }
242}
243
244impl FormatScheme {
245    /// 写出 `<a:fmtScheme>` 元素到 writer。
246    ///
247    /// 优先级:结构化字段(fill_styles 等)> raw_xml > 默认 Office 格式方案。
248    /// 若结构化字段非空,按 OOXML 顺序输出 4 个 style 列表;否则回退到 raw_xml。
249    pub fn write_xml(&self, w: &mut XmlWriter) {
250        let name = if self.name.is_empty() {
251            "Office"
252        } else {
253            self.name.as_str()
254        };
255        // 判断是否使用结构化字段
256        let has_structured = !self.fill_styles.is_empty()
257            || !self.line_styles.is_empty()
258            || !self.effect_styles.is_empty()
259            || !self.bg_fill_styles.is_empty();
260
261        if has_structured {
262            // 使用结构化字段按 OOXML 顺序输出
263            w.open_with("a:fmtScheme", &[("name", name)]);
264            if !self.fill_styles.is_empty() {
265                w.open("a:fillStyleLst");
266                for s in &self.fill_styles {
267                    w.raw(s);
268                }
269                w.close("a:fillStyleLst");
270            }
271            if !self.line_styles.is_empty() {
272                w.open("a:lnStyleLst");
273                for s in &self.line_styles {
274                    w.raw(s);
275                }
276                w.close("a:lnStyleLst");
277            }
278            if !self.effect_styles.is_empty() {
279                w.open("a:effectStyleLst");
280                for s in &self.effect_styles {
281                    w.raw(s);
282                }
283                w.close("a:effectStyleLst");
284            }
285            if !self.bg_fill_styles.is_empty() {
286                w.open("a:bgFillStyleLst");
287                for s in &self.bg_fill_styles {
288                    w.raw(s);
289                }
290                w.close("a:bgFillStyleLst");
291            }
292            w.close("a:fmtScheme");
293        } else if self.raw_xml.is_empty() {
294            // 使用默认的 Office 格式方案(从 THEME_XML 中提取)
295            w.raw(DEFAULT_FMT_SCHEME);
296        } else {
297            // 回退到 raw_xml
298            w.open_with("a:fmtScheme", &[("name", name)]);
299            w.raw(&self.raw_xml);
300            w.close("a:fmtScheme");
301        }
302    }
303
304    /// 从 raw_xml 解析填充 4 个结构化字段(fill_styles / line_styles / effect_styles / bg_fill_styles)。
305    ///
306    /// 解析后结构化字段非空,后续 [`write_xml`](Self::write_xml) 会优先使用结构化字段。
307    /// 若 raw_xml 为空,此方法不做任何操作。
308    ///
309    /// # 解析策略
310    ///
311    /// 使用简单字符串查找定位 4 个 `<a:xxxStyleLst>` 容器,然后收集每个容器的
312    /// 直接子元素 XML。不解析子元素的内部结构(如 gradFill 的 gsLst),保留原始 XML。
313    pub fn parse_from_raw_xml(&mut self) {
314        if self.raw_xml.is_empty() {
315            return;
316        }
317        self.fill_styles = collect_style_lst_children(&self.raw_xml, "fillStyleLst");
318        self.line_styles = collect_style_lst_children(&self.raw_xml, "lnStyleLst");
319        self.effect_styles = collect_style_lst_children(&self.raw_xml, "effectStyleLst");
320        self.bg_fill_styles = collect_style_lst_children(&self.raw_xml, "bgFillStyleLst");
321    }
322
323    /// 填充样式数量。
324    pub fn fill_style_count(&self) -> usize {
325        self.fill_styles.len()
326    }
327
328    /// 线条样式数量。
329    pub fn line_style_count(&self) -> usize {
330        self.line_styles.len()
331    }
332
333    /// 效果样式数量。
334    pub fn effect_style_count(&self) -> usize {
335        self.effect_styles.len()
336    }
337
338    /// 背景填充样式数量。
339    pub fn bg_fill_style_count(&self) -> usize {
340        self.bg_fill_styles.len()
341    }
342}
343
344/// 从 XML 字符串中收集指定 `<a:xxxLst>` 容器的直接子元素 XML。
345///
346/// 例如 `collect_style_lst_children(xml, "fillStyleLst")` 会找到 `<a:fillStyleLst>...</a:fillStyleLst>`,
347/// 返回其内部每个直接子元素的完整 XML(如 `<a:solidFill>...</a:solidFill>`)。
348///
349/// # 算法
350///
351/// 使用简单的括号配对算法:在容器内遇到 Start 事件开始收集,遇到对应的 End 事件结束收集。
352/// 自闭合 Empty 事件直接收集。不递归到子元素(只收集直接子元素)。
353fn collect_style_lst_children(xml: &str, container_local: &str) -> Vec<String> {
354    let mut result = Vec::new();
355    let mut rd = quick_xml::Reader::from_str(xml);
356    rd.config_mut().trim_text(true);
357    let mut buf = Vec::new();
358
359    // 状态机:先找容器,再收集子元素
360    enum State {
361        Seeking,
362        InContainer,
363    }
364    let mut state = State::Seeking;
365
366    loop {
367        match rd.read_event_into(&mut buf) {
368            Ok(quick_xml::events::Event::Start(e)) => {
369                // 把 e.name() 绑定到 let,避免临时 QName 在 as_ref() 后 drop
370                let name = e.name();
371                let local = local_name_quick(name.as_ref());
372                match state {
373                    State::Seeking => {
374                        if local == container_local.as_bytes() {
375                            state = State::InContainer;
376                        }
377                    }
378                    State::InContainer => {
379                        // 收集完整子元素(含开闭标签)
380                        if let Ok(inner) = collect_full_element_str(&mut rd, e.into_owned()) {
381                            result.push(inner);
382                        }
383                    }
384                }
385            }
386            Ok(quick_xml::events::Event::End(e)) => {
387                let name = e.name();
388                let local = local_name_quick(name.as_ref());
389                if let State::InContainer = state {
390                    if local == container_local.as_bytes() {
391                        state = State::Seeking;
392                    }
393                }
394            }
395            Ok(quick_xml::events::Event::Empty(e)) => {
396                let name = e.name();
397                let local = local_name_quick(name.as_ref());
398                if let State::InContainer = state {
399                    // 自闭合子元素,重构为完整 XML 字符串
400                    // 注意:用完整 name(含命名空间前缀)而非 local,保证 round-trip
401                    let _ = local; // local 仅用于上面的状态判断保留
402                    let mut s = String::new();
403                    s.push('<');
404                    s.push_str(std::str::from_utf8(name.as_ref()).unwrap_or(""));
405                    for a in e.attributes().flatten() {
406                        s.push(' ');
407                        s.push_str(std::str::from_utf8(a.key.as_ref()).unwrap_or(""));
408                        s.push_str("=\"");
409                        s.push_str(
410                            a.normalized_value(quick_xml::XmlVersion::Implicit1_0)
411                                .unwrap_or_default()
412                                .as_ref(),
413                        );
414                        s.push('"');
415                    }
416                    s.push_str("/>");
417                    result.push(s);
418                }
419            }
420            Ok(quick_xml::events::Event::Eof) => break,
421            Err(_) => break,
422            _ => {}
423        }
424        buf.clear();
425    }
426    result
427}
428
429/// 取 XML 元素名的 local part(去掉命名空间前缀)。
430fn local_name_quick(name: &[u8]) -> &[u8] {
431    // 处理 "a:fillStyleLst" → "fillStyleLst"
432    match name.iter().position(|&b| b == b':') {
433        Some(pos) => &name[pos + 1..],
434        None => name,
435    }
436}
437
438/// 收集从当前 Start 事件到对应 End 事件的完整 XML 字符串(含开闭标签)。
439///
440/// 与 parse_sld.rs 中的 `collect_full_element` 功能相同,但返回 String 而非写入 buffer。
441fn collect_full_element_str(
442    rd: &mut quick_xml::Reader<&[u8]>,
443    start: quick_xml::events::BytesStart<'_>,
444) -> quick_xml::Result<String> {
445    let mut result = String::new();
446    // 写入开标签
447    // 把 start.name() 绑定到 let,避免临时 QName 在 as_ref() 后 drop
448    let start_name = start.name();
449    let name = std::str::from_utf8(start_name.as_ref()).unwrap_or("");
450    result.push('<');
451    result.push_str(name);
452    for a in start.attributes().flatten() {
453        result.push(' ');
454        result.push_str(std::str::from_utf8(a.key.as_ref()).unwrap_or(""));
455        result.push_str("=\"");
456        result.push_str(
457            a.normalized_value(quick_xml::XmlVersion::Implicit1_0)
458                .unwrap_or_default()
459                .as_ref(),
460        );
461        result.push('"');
462    }
463    result.push('>');
464
465    let target = start.name();
466    let mut depth = 1;
467    let mut buf = Vec::new();
468    loop {
469        match rd.read_event_into(&mut buf) {
470            Ok(quick_xml::events::Event::Start(e)) => {
471                // 把 e.name() 绑定到 let,避免临时 QName 在 as_ref() 后 drop
472                let e_name = e.name();
473                let n = std::str::from_utf8(e_name.as_ref()).unwrap_or("");
474                result.push('<');
475                result.push_str(n);
476                for a in e.attributes().flatten() {
477                    result.push(' ');
478                    result.push_str(std::str::from_utf8(a.key.as_ref()).unwrap_or(""));
479                    result.push_str("=\"");
480                    result.push_str(
481                        a.normalized_value(quick_xml::XmlVersion::Implicit1_0)
482                            .unwrap_or_default()
483                            .as_ref(),
484                    );
485                    result.push('"');
486                }
487                result.push('>');
488                if e.name() == target {
489                    depth += 1;
490                }
491            }
492            Ok(quick_xml::events::Event::End(e)) => {
493                let e_name = e.name();
494                let n = std::str::from_utf8(e_name.as_ref()).unwrap_or("");
495                result.push_str("</");
496                result.push_str(n);
497                result.push('>');
498                if e.name() == target {
499                    depth -= 1;
500                    if depth == 0 {
501                        break;
502                    }
503                }
504            }
505            Ok(quick_xml::events::Event::Empty(e)) => {
506                let e_name = e.name();
507                let n = std::str::from_utf8(e_name.as_ref()).unwrap_or("");
508                result.push('<');
509                result.push_str(n);
510                for a in e.attributes().flatten() {
511                    result.push(' ');
512                    result.push_str(std::str::from_utf8(a.key.as_ref()).unwrap_or(""));
513                    result.push_str("=\"");
514                    result.push_str(
515                        a.normalized_value(quick_xml::XmlVersion::Implicit1_0)
516                            .unwrap_or_default()
517                            .as_ref(),
518                    );
519                    result.push('"');
520                }
521                result.push_str("/>");
522            }
523            Ok(quick_xml::events::Event::Text(t)) => {
524                // quick-xml 0.40: BytesText::unescape() 方法已移除,
525                // 改用 quick_xml::escape::unescape 函数(接受 &str)。
526                // BytesText 的 Deref 目标是 [u8],需要先转成 &str。
527                let text_str = std::str::from_utf8(t.as_ref()).unwrap_or("");
528                result.push_str(&quick_xml::escape::unescape(text_str).unwrap_or_default());
529            }
530            Ok(quick_xml::events::Event::Eof) => break,
531            Err(e) => return Err(e),
532            _ => {}
533        }
534        buf.clear();
535    }
536    Ok(result)
537}
538
539impl Theme {
540    /// 从结构化模型序列化为完整 theme XML。
541    ///
542    /// 与 [`default_theme_xml`] 不同,此方法根据 [`Theme`] 结构体的字段值
543    /// 生成 XML,允许用户修改颜色方案、字体方案等。
544    ///
545    /// # 注意
546    ///
547    /// - 字体脚本表(`<a:font script="..." typeface="..."/>`)不在此方法中生成,
548    ///   因为它们不影响 PowerPoint 的核心解析。如需完整字体脚本,使用 [`default_theme_xml`]。
549    /// - `FormatScheme` 若 `raw_xml` 为空,使用默认 Office 格式方案。
550    pub fn to_xml(&self) -> String {
551        let mut w = XmlWriter::with_decl();
552        let theme_name = if self.name.is_empty() {
553            "Office Theme"
554        } else {
555            self.name.as_str()
556        };
557        w.open_with(
558            "a:theme",
559            &[("xmlns:a", NS_DRAWING_MAIN), ("name", theme_name)],
560        );
561        // themeElements
562        w.open("a:themeElements");
563        self.color_scheme.write_xml(&mut w);
564        self.font_scheme.write_xml(&mut w);
565        self.format_scheme.write_xml(&mut w);
566        w.close("a:themeElements");
567        // objectDefaults(简化:空)
568        w.empty("a:objectDefaults");
569        // extraClrSchemeLst(简化:空)
570        w.empty("a:extraClrSchemeLst");
571        w.close("a:theme");
572        w.into_string()
573    }
574}
575
576/// 返回完整且经过验证的 Office 主题 XML(与 python-pptx 默认输出对齐)。
577///
578/// 这是写路径的默认实现,保证 PowerPoint/WPS 能正确打开。
579/// 如需根据 [`Theme`] 结构体生成自定义 XML,使用 [`Theme::to_xml`]。
580pub fn default_theme_xml() -> String {
581    THEME_XML.to_string()
582}
583
584/// 默认的 `<a:fmtScheme>` 元素(从完整 Office 主题中提取)。
585///
586/// 包含 fillStyleLst / lnStyleLst / effectStyleLst / bgFillStyleLst。
587const DEFAULT_FMT_SCHEME: &str = r#"<a:fmtScheme name="Office"><a:fillStyleLst><a:solidFill><a:schemeClr val="phClr"/></a:solidFill><a:gradFill rotWithShape="1"><a:gsLst><a:gs pos="0"><a:schemeClr val="phClr"><a:tint val="50000"/><a:satMod val="300000"/></a:schemeClr></a:gs><a:gs pos="35000"><a:schemeClr val="phClr"><a:tint val="37000"/><a:satMod val="300000"/></a:schemeClr></a:gs><a:gs pos="100000"><a:schemeClr val="phClr"><a:tint val="15000"/><a:satMod val="350000"/></a:schemeClr></a:gs></a:gsLst><a:lin ang="16200000" scaled="1"/></a:gradFill><a:gradFill rotWithShape="1"><a:gsLst><a:gs pos="0"><a:schemeClr val="phClr"><a:tint val="100000"/><a:shade val="100000"/><a:satMod val="130000"/></a:schemeClr></a:gs><a:gs pos="100000"><a:schemeClr val="phClr"><a:tint val="50000"/><a:shade val="100000"/><a:satMod val="350000"/></a:schemeClr></a:gs></a:gsLst><a:lin ang="16200000" scaled="0"/></a:gradFill></a:fillStyleLst><a:lnStyleLst><a:ln w="9525" cap="flat" cmpd="sng" algn="ctr"><a:solidFill><a:schemeClr val="phClr"><a:shade val="95000"/><a:satMod val="105000"/></a:schemeClr></a:solidFill><a:prstDash val="solid"/></a:ln><a:ln w="25400" cap="flat" cmpd="sng" algn="ctr"><a:solidFill><a:schemeClr val="phClr"/></a:solidFill><a:prstDash val="solid"/></a:ln><a:ln w="38100" cap="flat" cmpd="sng" algn="ctr"><a:solidFill><a:schemeClr val="phClr"/></a:solidFill><a:prstDash val="solid"/></a:ln></a:lnStyleLst><a:effectStyleLst><a:effectStyle><a:effectLst><a:outerShdw blurRad="40000" dist="20000" dir="5400000" rotWithShape="0"><a:srgbClr val="000000"><a:alpha val="38000"/></a:srgbClr></a:outerShdw></a:effectLst></a:effectStyle><a:effectStyle><a:effectLst><a:outerShdw blurRad="40000" dist="23000" dir="5400000" rotWithShape="0"><a:srgbClr val="000000"><a:alpha val="35000"/></a:srgbClr></a:outerShdw></a:effectLst></a:effectStyle><a:effectStyle><a:effectLst><a:outerShdw blurRad="40000" dist="23000" dir="5400000" rotWithShape="0"><a:srgbClr val="000000"><a:alpha val="35000"/></a:srgbClr></a:outerShdw></a:effectLst><a:scene3d><a:camera prst="orthographicFront"><a:rot lat="0" lon="0" rev="0"/></a:camera><a:lightRig rig="threePt" dir="t"><a:rot lat="0" lon="0" rev="1200000"/></a:lightRig></a:scene3d><a:sp3d><a:bevelT w="63500" h="25400"/></a:sp3d></a:effectStyle></a:effectStyleLst><a:bgFillStyleLst><a:solidFill><a:schemeClr val="phClr"/></a:solidFill><a:gradFill rotWithShape="1"><a:gsLst><a:gs pos="0"><a:schemeClr val="phClr"><a:tint val="40000"/><a:satMod val="350000"/></a:schemeClr></a:gs><a:gs pos="40000"><a:schemeClr val="phClr"><a:tint val="45000"/><a:shade val="99000"/><a:satMod val="350000"/></a:schemeClr></a:gs><a:gs pos="100000"><a:schemeClr val="phClr"><a:shade val="20000"/><a:satMod val="255000"/></a:schemeClr></a:gs></a:gsLst><a:path path="circle"><a:fillToRect l="50000" t="-80000" r="50000" b="180000"/></a:path></a:gradFill><a:gradFill rotWithShape="1"><a:gsLst><a:gs pos="0"><a:schemeClr val="phClr"><a:tint val="80000"/><a:satMod val="300000"/></a:schemeClr></a:gs><a:gs pos="100000"><a:schemeClr val="phClr"><a:shade val="30000"/><a:satMod val="200000"/></a:schemeClr></a:gs></a:gsLst><a:path path="circle"><a:fillToRect l="50000" t="50000" r="50000" b="50000"/></a:path></a:gradFill></a:bgFillStyleLst></a:fmtScheme>"#;
588
589/// 完整且经过验证的 Office 主题 XML(与 python-pptx 默认输出对齐)。
590const THEME_XML: &str = r##"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
591<a:theme xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" name="Office Theme"><a:themeElements><a:clrScheme name="Office"><a:dk1><a:sysClr val="windowText" lastClr="000000"/></a:dk1><a:lt1><a:sysClr val="window" lastClr="FFFFFF"/></a:lt1><a:dk2><a:srgbClr val="1F497D"/></a:dk2><a:lt2><a:srgbClr val="EEECE1"/></a:lt2><a:accent1><a:srgbClr val="4F81BD"/></a:accent1><a:accent2><a:srgbClr val="C0504D"/></a:accent2><a:accent3><a:srgbClr val="9BBB59"/></a:accent3><a:accent4><a:srgbClr val="8064A2"/></a:accent4><a:accent5><a:srgbClr val="4BACC6"/></a:accent5><a:accent6><a:srgbClr val="F79646"/></a:accent6><a:hlink><a:srgbClr val="0000FF"/></a:hlink><a:folHlink><a:srgbClr val="800080"/></a:folHlink></a:clrScheme><a:fontScheme name="Office"><a:majorFont><a:latin typeface="Calibri"/><a:ea typeface=""/><a:cs typeface=""/><a:font script="Jpan" typeface="MS Pゴシック"/><a:font script="Hang" typeface="맑은 고딕"/><a:font script="Hans" typeface="宋体"/><a:font script="Hant" typeface="新細明體"/><a:font script="Arab" typeface="Times New Roman"/><a:font script="Hebr" typeface="Times New Roman"/><a:font script="Thai" typeface="Angsana New"/><a:font script="Ethi" typeface="Nyala"/><a:font script="Beng" typeface="Vrinda"/><a:font script="Gujr" typeface="Shruti"/><a:font script="Khmr" typeface="MoolBoran"/><a:font script="Knda" typeface="Tunga"/><a:font script="Guru" typeface="Raavi"/><a:font script="Cans" typeface="Euphemia"/><a:font script="Cher" typeface="Plantagenet Cherokee"/><a:font script="Yiii" typeface="Microsoft Yi Baiti"/><a:font script="Tibt" typeface="Microsoft Himalaya"/><a:font script="Thaa" typeface="MV Boli"/><a:font script="Deva" typeface="Mangal"/><a:font script="Telu" typeface="Gautami"/><a:font script="Taml" typeface="Latha"/><a:font script="Syrc" typeface="Estrangelo Edessa"/><a:font script="Orya" typeface="Kalinga"/><a:font script="Mlym" typeface="Kartika"/><a:font script="Laoo" typeface="DokChampa"/><a:font script="Sinh" typeface="Iskoola Pota"/><a:font script="Mong" typeface="Mongolian Baiti"/><a:font script="Viet" typeface="Times New Roman"/><a:font script="Uigh" typeface="Microsoft Uighur"/><a:font script="Geor" typeface="Sylfaen"/></a:majorFont><a:minorFont><a:latin typeface="Calibri"/><a:ea typeface=""/><a:cs typeface=""/><a:font script="Jpan" typeface="MS Pゴシック"/><a:font script="Hang" typeface="맑은 고딕"/><a:font script="Hans" typeface="宋体"/><a:font script="Hant" typeface="新細明體"/><a:font script="Arab" typeface="Arial"/><a:font script="Hebr" typeface="Arial"/><a:font script="Thai" typeface="Cordia New"/><a:font script="Ethi" typeface="Nyala"/><a:font script="Beng" typeface="Vrinda"/><a:font script="Gujr" typeface="Shruti"/><a:font script="Khmr" typeface="DaunPenh"/><a:font script="Knda" typeface="Tunga"/><a:font script="Guru" typeface="Raavi"/><a:font script="Cans" typeface="Euphemia"/><a:font script="Cher" typeface="Plantagenet Cherokee"/><a:font script="Yiii" typeface="Microsoft Yi Baiti"/><a:font script="Tibt" typeface="Microsoft Himalaya"/><a:font script="Thaa" typeface="MV Boli"/><a:font script="Deva" typeface="Mangal"/><a:font script="Telu" typeface="Gautami"/><a:font script="Taml" typeface="Latha"/><a:font script="Syrc" typeface="Estrangelo Edessa"/><a:font script="Orya" typeface="Kalinga"/><a:font script="Mlym" typeface="Kartika"/><a:font script="Laoo" typeface="DokChampa"/><a:font script="Sinh" typeface="Iskoola Pota"/><a:font script="Mong" typeface="Mongolian Baiti"/><a:font script="Viet" typeface="Arial"/><a:font script="Uigh" typeface="Microsoft Uighur"/><a:font script="Geor" typeface="Sylfaen"/></a:minorFont></a:fontScheme><a:fmtScheme name="Office"><a:fillStyleLst><a:solidFill><a:schemeClr val="phClr"/></a:solidFill><a:gradFill rotWithShape="1"><a:gsLst><a:gs pos="0"><a:schemeClr val="phClr"><a:tint val="50000"/><a:satMod val="300000"/></a:schemeClr></a:gs><a:gs pos="35000"><a:schemeClr val="phClr"><a:tint val="37000"/><a:satMod val="300000"/></a:schemeClr></a:gs><a:gs pos="100000"><a:schemeClr val="phClr"><a:tint val="15000"/><a:satMod val="350000"/></a:schemeClr></a:gs></a:gsLst><a:lin ang="16200000" scaled="1"/></a:gradFill><a:gradFill rotWithShape="1"><a:gsLst><a:gs pos="0"><a:schemeClr val="phClr"><a:tint val="100000"/><a:shade val="100000"/><a:satMod val="130000"/></a:schemeClr></a:gs><a:gs pos="100000"><a:schemeClr val="phClr"><a:tint val="50000"/><a:shade val="100000"/><a:satMod val="350000"/></a:schemeClr></a:gs></a:gsLst><a:lin ang="16200000" scaled="0"/></a:gradFill></a:fillStyleLst><a:lnStyleLst><a:ln w="9525" cap="flat" cmpd="sng" algn="ctr"><a:solidFill><a:schemeClr val="phClr"><a:shade val="95000"/><a:satMod val="105000"/></a:schemeClr></a:solidFill><a:prstDash val="solid"/></a:ln><a:ln w="25400" cap="flat" cmpd="sng" algn="ctr"><a:solidFill><a:schemeClr val="phClr"/></a:solidFill><a:prstDash val="solid"/></a:ln><a:ln w="38100" cap="flat" cmpd="sng" algn="ctr"><a:solidFill><a:schemeClr val="phClr"/></a:solidFill><a:prstDash val="solid"/></a:ln></a:lnStyleLst><a:effectStyleLst><a:effectStyle><a:effectLst><a:outerShdw blurRad="40000" dist="20000" dir="5400000" rotWithShape="0"><a:srgbClr val="000000"><a:alpha val="38000"/></a:srgbClr></a:outerShdw></a:effectLst></a:effectStyle><a:effectStyle><a:effectLst><a:outerShdw blurRad="40000" dist="23000" dir="5400000" rotWithShape="0"><a:srgbClr val="000000"><a:alpha val="35000"/></a:srgbClr></a:outerShdw></a:effectLst></a:effectStyle><a:effectStyle><a:effectLst><a:outerShdw blurRad="40000" dist="23000" dir="5400000" rotWithShape="0"><a:srgbClr val="000000"><a:alpha val="35000"/></a:srgbClr></a:outerShdw></a:effectLst><a:scene3d><a:camera prst="orthographicFront"><a:rot lat="0" lon="0" rev="0"/></a:camera><a:lightRig rig="threePt" dir="t"><a:rot lat="0" lon="0" rev="1200000"/></a:lightRig></a:scene3d><a:sp3d><a:bevelT w="63500" h="25400"/></a:sp3d></a:effectStyle></a:effectStyleLst><a:bgFillStyleLst><a:solidFill><a:schemeClr val="phClr"/></a:solidFill><a:gradFill rotWithShape="1"><a:gsLst><a:gs pos="0"><a:schemeClr val="phClr"><a:tint val="40000"/><a:satMod val="350000"/></a:schemeClr></a:gs><a:gs pos="40000"><a:schemeClr val="phClr"><a:tint val="45000"/><a:shade val="99000"/><a:satMod val="350000"/></a:schemeClr></a:gs><a:gs pos="100000"><a:schemeClr val="phClr"><a:shade val="20000"/><a:satMod val="255000"/></a:schemeClr></a:gs></a:gsLst><a:path path="circle"><a:fillToRect l="50000" t="-80000" r="50000" b="180000"/></a:path></a:gradFill><a:gradFill rotWithShape="1"><a:gsLst><a:gs pos="0"><a:schemeClr val="phClr"><a:tint val="80000"/><a:satMod val="300000"/></a:schemeClr></a:gs><a:gs pos="100000"><a:schemeClr val="phClr"><a:shade val="30000"/><a:satMod val="200000"/></a:schemeClr></a:gs></a:gsLst><a:path path="circle"><a:fillToRect l="50000" t="50000" r="50000" b="50000"/></a:path></a:gradFill></a:bgFillStyleLst></a:fmtScheme></a:themeElements><a:objectDefaults><a:spDef><a:spPr/><a:bodyPr/><a:lstStyle/><a:style><a:lnRef idx="1"><a:schemeClr val="accent1"/></a:lnRef><a:fillRef idx="3"><a:schemeClr val="accent1"/></a:fillRef><a:effectRef idx="2"><a:schemeClr val="accent1"/></a:effectRef><a:fontRef idx="minor"><a:schemeClr val="lt1"/></a:fontRef></a:style></a:spDef><a:lnDef><a:spPr/><a:bodyPr/><a:lstStyle/><a:style><a:lnRef idx="2"><a:schemeClr val="accent1"/></a:lnRef><a:fillRef idx="0"><a:schemeClr val="accent1"/></a:fillRef><a:effectRef idx="1"><a:schemeClr val="accent1"/></a:effectRef><a:fontRef idx="minor"><a:schemeClr val="tx1"/></a:fontRef></a:style></a:lnDef></a:objectDefaults><a:extraClrSchemeLst/></a:theme>"##;
592
593#[cfg(test)]
594mod tests {
595    use super::*;
596
597    /// 验证 `Theme::to_xml` 能生成合法的 theme XML。
598    #[test]
599    fn theme_to_xml_generates_valid_xml() {
600        let mut theme = Theme {
601            name: "Test Theme".to_string(),
602            ..Default::default()
603        };
604        theme.color_scheme.name = "Test".to_string();
605        theme.color_scheme.dk1 = Some(ThemeColor::Srgb("000000".to_string()));
606        theme.color_scheme.lt1 = Some(ThemeColor::Srgb("FFFFFF".to_string()));
607        theme.font_scheme.major_latin = "Arial".to_string();
608        theme.font_scheme.minor_latin = "Calibri".to_string();
609
610        let xml = theme.to_xml();
611        // 验证关键元素存在
612        assert!(xml.contains(r#"name="Test Theme""#), "theme name missing");
613        assert!(
614            xml.contains(r#"<a:clrScheme name="Test">"#),
615            "clrScheme missing"
616        );
617        assert!(
618            xml.contains(r#"<a:dk1><a:srgbClr val="000000"/></a:dk1>"#),
619            "dk1 missing"
620        );
621        assert!(
622            xml.contains(r#"<a:lt1><a:srgbClr val="FFFFFF"/></a:lt1>"#),
623            "lt1 missing"
624        );
625        assert!(
626            xml.contains(r#"<a:latin typeface="Arial"/>"#),
627            "major latin missing"
628        );
629        assert!(
630            xml.contains(r#"<a:latin typeface="Calibri"/>"#),
631            "minor latin missing"
632        );
633        // 验证 fmtScheme 存在(默认)
634        assert!(xml.contains("<a:fmtScheme"), "fmtScheme missing");
635    }
636
637    /// 验证 `ThemeColor::write_xml` 对 Srgb 类型的序列化。
638    #[test]
639    fn theme_color_srgb_write_xml() {
640        let mut w = XmlWriter::new();
641        let color = ThemeColor::Srgb("FF0000".to_string());
642        color.write_xml(&mut w);
643        assert_eq!(w.buf, r#"<a:srgbClr val="FF0000"/>"#);
644    }
645
646    /// 验证 `ThemeColor::write_xml` 对 Sys 类型的序列化。
647    #[test]
648    fn theme_color_sys_write_xml() {
649        let mut w = XmlWriter::new();
650        let color = ThemeColor::Sys("windowText".to_string(), "000000".to_string());
651        color.write_xml(&mut w);
652        assert_eq!(w.buf, r#"<a:sysClr val="windowText" lastClr="000000"/>"#);
653    }
654
655    /// 验证 `ThemeColor::write_xml` 对 None 类型不输出任何内容。
656    #[test]
657    fn theme_color_none_write_xml() {
658        let mut w = XmlWriter::new();
659        let color = ThemeColor::None;
660        color.write_xml(&mut w);
661        assert_eq!(w.buf, "");
662    }
663
664    /// 验证 `ColorScheme::write_xml` 按 OOXML 顺序写出 12 个颜色槽位。
665    #[test]
666    fn color_scheme_write_xml_order() {
667        let scheme = ColorScheme {
668            name: "Test".to_string(),
669            dk1: Some(ThemeColor::Srgb("000000".to_string())),
670            lt1: Some(ThemeColor::Srgb("FFFFFF".to_string())),
671            accent1: Some(ThemeColor::Srgb("4F81BD".to_string())),
672            ..Default::default()
673        };
674
675        let mut w = XmlWriter::new();
676        scheme.write_xml(&mut w);
677        let xml = &w.buf;
678
679        // 验证顺序:dk1 在 lt1 之前,lt1 在 accent1 之前
680        let dk1_pos = xml.find("<a:dk1>").unwrap();
681        let lt1_pos = xml.find("<a:lt1>").unwrap();
682        let accent1_pos = xml.find("<a:accent1>").unwrap();
683        assert!(dk1_pos < lt1_pos, "dk1 should come before lt1");
684        assert!(lt1_pos < accent1_pos, "lt1 should come before accent1");
685    }
686
687    /// 验证 `FontScheme::write_xml` 写出 majorFont 和 minorFont。
688    #[test]
689    fn font_scheme_write_xml() {
690        let fs = FontScheme {
691            name: "Test".to_string(),
692            major_latin: "Calibri Light".to_string(),
693            major_ea: "宋体".to_string(),
694            minor_latin: "Calibri".to_string(),
695            minor_ea: "宋体".to_string(),
696            ..Default::default()
697        };
698
699        let mut w = XmlWriter::new();
700        fs.write_xml(&mut w);
701        let xml = &w.buf;
702
703        assert!(
704            xml.contains(r#"<a:latin typeface="Calibri Light"/>"#),
705            "major latin missing"
706        );
707        assert!(
708            xml.contains(r#"<a:ea typeface="宋体"/>"#),
709            "ea typeface missing"
710        );
711        assert!(
712            xml.contains(r#"<a:latin typeface="Calibri"/>"#),
713            "minor latin missing"
714        );
715    }
716
717    /// 验证 `default_theme_xml` 返回非空且包含关键元素。
718    #[test]
719    fn default_theme_xml_is_valid() {
720        let xml = default_theme_xml();
721        assert!(!xml.is_empty());
722        assert!(xml.contains("<a:theme"));
723        assert!(xml.contains("<a:clrScheme"));
724        assert!(xml.contains("<a:fontScheme"));
725        assert!(xml.contains("<a:fmtScheme"));
726    }
727
728    // ===================== TODO-005:FormatScheme 结构化解析测试 =====================
729
730    /// 构造一份简化的 fmtScheme raw_xml,用于测试结构化解析。
731    ///
732    /// 包含完整的 4 个 style 列表容器,每个容器有 1-2 个子元素。
733    fn sample_fmt_scheme_raw_xml() -> &'static str {
734        r#"<a:fillStyleLst><a:solidFill><a:schemeClr val="phClr"/></a:solidFill><a:gradFill rotWithShape="1"><a:gsLst><a:gs pos="0"><a:schemeClr val="phClr"/></a:gs></a:gsLst></a:gradFill></a:fillStyleLst><a:lnStyleLst><a:ln w="9525" cap="flat"><a:solidFill><a:schemeClr val="phClr"/></a:solidFill></a:ln><a:ln w="25400"><a:solidFill><a:schemeClr val="phClr"/></a:solidFill></a:ln></a:lnStyleLst><a:effectStyleLst><a:effectStyle><a:effectLst><a:outerShdw blurRad="40000"/></a:effectLst></a:effectStyle></a:effectStyleLst><a:bgFillStyleLst><a:solidFill><a:schemeClr val="phClr"/></a:solidFill></a:bgFillStyleLst>"#
735    }
736
737    /// 验证 `parse_from_raw_xml` 能正确拆分 4 个 style 列表。
738    ///
739    /// 每个列表的子元素数量与原始 XML 中一致:
740    /// - fillStyleLst: 2 个(solidFill + gradFill)
741    /// - lnStyleLst: 2 个(两个 ln)
742    /// - effectStyleLst: 1 个(effectStyle)
743    /// - bgFillStyleLst: 1 个(solidFill)
744    #[test]
745    fn fmt_scheme_parse_from_raw_xml_splits_four_lists() {
746        let mut fmt = FormatScheme {
747            name: "Test".to_string(),
748            raw_xml: sample_fmt_scheme_raw_xml().to_string(),
749            ..Default::default()
750        };
751
752        // 解析前为空
753        assert_eq!(fmt.fill_style_count(), 0);
754        assert_eq!(fmt.line_style_count(), 0);
755        assert_eq!(fmt.effect_style_count(), 0);
756        assert_eq!(fmt.bg_fill_style_count(), 0);
757
758        fmt.parse_from_raw_xml();
759
760        // 解析后正确拆分
761        assert_eq!(fmt.fill_style_count(), 2, "fillStyleLst 应有 2 个子元素");
762        assert_eq!(fmt.line_style_count(), 2, "lnStyleLst 应有 2 个子元素");
763        assert_eq!(
764            fmt.effect_style_count(),
765            1,
766            "effectStyleLst 应有 1 个子元素"
767        );
768        assert_eq!(
769            fmt.bg_fill_style_count(),
770            1,
771            "bgFillStyleLst 应有 1 个子元素"
772        );
773    }
774
775    /// 验证解析后的子元素 XML 内容正确(含完整开闭标签与属性)。
776    #[test]
777    fn fmt_scheme_parsed_children_content_correct() {
778        let mut fmt = FormatScheme {
779            raw_xml: sample_fmt_scheme_raw_xml().to_string(),
780            ..Default::default()
781        };
782        fmt.parse_from_raw_xml();
783
784        // 第一个 fill 子元素是 solidFill
785        let fill0 = &fmt.fill_styles[0];
786        assert!(
787            fill0.starts_with("<a:solidFill>"),
788            "fill[0] 应以 <a:solidFill> 开头"
789        );
790        assert!(
791            fill0.contains(r#"<a:schemeClr val="phClr"/>"#),
792            "fill[0] 应包含 schemeClr"
793        );
794        assert!(
795            fill0.ends_with("</a:solidFill>"),
796            "fill[0] 应以 </a:solidFill> 结尾"
797        );
798
799        // 第二个 fill 子元素是 gradFill(带属性 rotWithShape="1")
800        let fill1 = &fmt.fill_styles[1];
801        assert!(
802            fill1.starts_with(r#"<a:gradFill rotWithShape="1">"#),
803            "fill[1] 应包含属性"
804        );
805
806        // 第一个 ln 子元素带 w/cap 属性
807        let ln0 = &fmt.line_styles[0];
808        assert!(
809            ln0.starts_with(r#"<a:ln w="9525" cap="flat">"#),
810            "ln[0] 应带属性"
811        );
812
813        // effectStyle 子元素嵌套 outerShdw
814        let eff0 = &fmt.effect_styles[0];
815        assert!(eff0.contains("<a:outerShdw"), "effect[0] 应包含 outerShdw");
816    }
817
818    /// 验证结构化 `write_xml` 输出正确的 `<a:fillStyleLst>` 等容器。
819    #[test]
820    fn fmt_scheme_structured_write_xml_outputs_containers() {
821        let mut fmt = FormatScheme {
822            name: "Office".to_string(),
823            raw_xml: sample_fmt_scheme_raw_xml().to_string(),
824            ..Default::default()
825        };
826        fmt.parse_from_raw_xml();
827
828        let mut w = XmlWriter::new();
829        fmt.write_xml(&mut w);
830        let xml = &w.buf;
831
832        // 验证 4 个容器都存在且按 OOXML 顺序
833        let fill_pos = xml.find("<a:fillStyleLst>").expect("fillStyleLst missing");
834        let ln_pos = xml.find("<a:lnStyleLst>").expect("lnStyleLst missing");
835        let eff_pos = xml
836            .find("<a:effectStyleLst>")
837            .expect("effectStyleLst missing");
838        let bg_pos = xml
839            .find("<a:bgFillStyleLst>")
840            .expect("bgFillStyleLst missing");
841
842        // 顺序:fillStyleLst → lnStyleLst → effectStyleLst → bgFillStyleLst
843        assert!(fill_pos < ln_pos, "fillStyleLst 应在 lnStyleLst 之前");
844        assert!(ln_pos < eff_pos, "lnStyleLst 应在 effectStyleLst 之前");
845        assert!(eff_pos < bg_pos, "effectStyleLst 应在 bgFillStyleLst 之前");
846
847        // 验证根元素带 name 属性
848        assert!(
849            xml.contains(r#"<a:fmtScheme name="Office">"#),
850            "fmtScheme 应带 name 属性"
851        );
852
853        // 验证容器内有子元素内容(不是空容器)
854        assert!(xml.contains("<a:solidFill>"), "应包含 solidFill 子元素");
855        assert!(xml.contains("<a:ln "), "应包含 ln 子元素");
856    }
857
858    /// 验证查询方法(fill_style_count 等)在解析后返回正确数量。
859    #[test]
860    fn fmt_scheme_count_methods_after_parse() {
861        let mut fmt = FormatScheme {
862            raw_xml: sample_fmt_scheme_raw_xml().to_string(),
863            ..Default::default()
864        };
865        fmt.parse_from_raw_xml();
866
867        assert_eq!(fmt.fill_style_count(), 2);
868        assert_eq!(fmt.line_style_count(), 2);
869        assert_eq!(fmt.effect_style_count(), 1);
870        assert_eq!(fmt.bg_fill_style_count(), 1);
871    }
872
873    /// 验证 `parse_from_raw_xml` 在 raw_xml 为空时不做任何操作。
874    #[test]
875    fn fmt_scheme_parse_empty_raw_xml_is_noop() {
876        let mut fmt = FormatScheme::default();
877        // raw_xml 默认为空
878        fmt.parse_from_raw_xml();
879
880        assert_eq!(fmt.fill_style_count(), 0);
881        assert_eq!(fmt.line_style_count(), 0);
882        assert_eq!(fmt.effect_style_count(), 0);
883        assert_eq!(fmt.bg_fill_style_count(), 0);
884    }
885
886    /// 验证 `write_xml` 在结构化字段为空但 raw_xml 非空时回退到 raw_xml。
887    #[test]
888    fn fmt_scheme_write_xml_falls_back_to_raw_xml() {
889        let fmt = FormatScheme {
890            name: "Office".to_string(),
891            raw_xml: r#"<a:customChild/>"#.to_string(),
892            ..Default::default()
893        };
894        // 不调用 parse_from_raw_xml,结构化字段保持为空
895
896        let mut w = XmlWriter::new();
897        fmt.write_xml(&mut w);
898        let xml = &w.buf;
899
900        // 应输出 <a:fmtScheme name="Office"> + raw_xml + </a:fmtScheme>
901        assert!(
902            xml.contains(r#"<a:fmtScheme name="Office">"#),
903            "应使用 raw_xml 路径"
904        );
905        assert!(xml.contains("<a:customChild/>"), "应包含 raw_xml 内容");
906    }
907
908    /// 验证 `write_xml` 在 raw_xml 与结构化字段都为空时使用默认 Office 格式方案。
909    #[test]
910    fn fmt_scheme_write_xml_uses_default_when_both_empty() {
911        let fmt = FormatScheme::default();
912        // name / raw_xml / 4 个结构化字段全部为空
913
914        let mut w = XmlWriter::new();
915        fmt.write_xml(&mut w);
916        let xml = &w.buf;
917
918        // 应使用 DEFAULT_FMT_SCHEME(直接 raw 输出,无 a:fmtScheme 包裹)
919        assert!(xml.contains("<a:fmtScheme"), "应包含默认 fmtScheme");
920        assert!(
921            xml.contains("<a:fillStyleLst>"),
922            "默认方案应含 fillStyleLst"
923        );
924        assert!(xml.contains("<a:lnStyleLst>"), "默认方案应含 lnStyleLst");
925        assert!(
926            xml.contains("<a:effectStyleLst>"),
927            "默认方案应含 effectStyleLst"
928        );
929        assert!(
930            xml.contains("<a:bgFillStyleLst>"),
931            "默认方案应含 bgFillStyleLst"
932        );
933    }
934
935    /// 验证默认 Office 主题 XML 中的 fmtScheme 能被正确结构化解析。
936    ///
937    /// 这是 round-trip 测试:默认方案的 fillStyleLst 应有 3 个元素,
938    /// lnStyleLst 应有 3 个,effectStyleLst 应有 3 个,bgFillStyleLst 应有 3 个。
939    #[test]
940    fn fmt_scheme_parse_default_office_format_scheme() {
941        // 从默认 Office 主题 XML 中提取 fmtScheme 内部内容
942        let theme_xml = default_theme_xml();
943        let fmt_start = theme_xml
944            .find("<a:fmtScheme")
945            .expect("默认主题应包含 <a:fmtScheme");
946        let fmt_end = theme_xml
947            .find("</a:fmtScheme>")
948            .expect("默认主题应包含 </a:fmtScheme>");
949        // 提取 <a:fmtScheme> 到 </a:fmtScheme> 之间的内部内容(不含根元素)
950        let inner_start = theme_xml[fmt_start..].find('>').unwrap() + fmt_start + 1;
951        let fmt_inner = &theme_xml[inner_start..fmt_end];
952
953        let mut fmt = FormatScheme {
954            raw_xml: fmt_inner.to_string(),
955            ..Default::default()
956        };
957        fmt.parse_from_raw_xml();
958
959        // 默认 Office 主题:每个 style 列表都有 3 个子元素
960        assert_eq!(
961            fmt.fill_style_count(),
962            3,
963            "默认 fillStyleLst 应有 3 个子元素"
964        );
965        assert_eq!(fmt.line_style_count(), 3, "默认 lnStyleLst 应有 3 个子元素");
966        assert_eq!(
967            fmt.effect_style_count(),
968            3,
969            "默认 effectStyleLst 应有 3 个子元素"
970        );
971        assert_eq!(
972            fmt.bg_fill_style_count(),
973            3,
974            "默认 bgFillStyleLst 应有 3 个子元素"
975        );
976    }
977
978    /// 验证结构化字段可被直接修改(替换某个 fill style)。
979    #[test]
980    fn fmt_scheme_structured_fields_are_mutable() {
981        let mut fmt = FormatScheme {
982            raw_xml: sample_fmt_scheme_raw_xml().to_string(),
983            ..Default::default()
984        };
985        fmt.parse_from_raw_xml();
986
987        // 替换第一个 fill style 为自定义 XML
988        fmt.fill_styles[0] = r#"<a:noFill/>"#.to_string();
989
990        let mut w = XmlWriter::new();
991        fmt.write_xml(&mut w);
992        let xml = &w.buf;
993
994        // 验证替换后的内容出现在输出中
995        assert!(
996            xml.contains("<a:noFill/>"),
997            "替换后的 fill style 应出现在输出"
998        );
999        // 验证其他 fill style 仍然存在
1000        assert!(xml.contains("<a:gradFill"), "其他 fill style 应保留");
1001    }
1002
1003    /// 验证自闭合子元素(Empty 事件)能被正确收集为完整 XML 字符串。
1004    #[test]
1005    fn fmt_scheme_collect_self_closing_children() {
1006        // raw_xml 中 fillStyleLst 内只有自闭合子元素
1007        let raw = r#"<a:fillStyleLst><a:noFill/><a:solidFill><a:schemeClr val="phClr"/></a:solidFill></a:fillStyleLst>"#;
1008        let children = collect_style_lst_children(raw, "fillStyleLst");
1009        assert_eq!(children.len(), 2, "应有 2 个子元素");
1010        // 第一个是自闭合,应被重构为 <a:noFill/>
1011        assert_eq!(children[0], "<a:noFill/>", "自闭子元素应重构为完整 XML");
1012        // 第二个是带子元素的
1013        assert!(
1014            children[1].starts_with("<a:solidFill>"),
1015            "第二个子元素应为 solidFill"
1016        );
1017    }
1018
1019    /// 验证 `local_name_quick` 正确处理带命名空间前缀的元素名。
1020    #[test]
1021    fn local_name_quick_handles_namespaced_elements() {
1022        assert_eq!(local_name_quick(b"a:fillStyleLst"), b"fillStyleLst");
1023        assert_eq!(local_name_quick(b"fillStyleLst"), b"fillStyleLst");
1024        assert_eq!(local_name_quick(b"a:ln"), b"ln");
1025    }
1026
1027    /// 验证 `collect_style_lst_children` 在容器不存在时返回空列表。
1028    #[test]
1029    fn collect_style_lst_children_returns_empty_when_container_missing() {
1030        let raw = r#"<a:otherLst><a:child/></a:otherLst>"#;
1031        let children = collect_style_lst_children(raw, "fillStyleLst");
1032        assert!(children.is_empty(), "容器不存在时应返回空列表");
1033    }
1034}