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