Skip to main content

pptx_rs/oxml/
diagram.rs

1//! SmartArt(Diagram)OOXML 模型 —— 结构化解析(TODO-037)。
2//!
3//! 本模块为 SmartArt 图形的 4 个 part(`/ppt/diagrams/*.xml`)提供**结构化解析**能力:
4//!
5//! | part | 根元素 | 结构体 | 解析深度 |
6//! |---|---|---|---|
7//! | data | `<dgm:dataModel>` | [`DataModel`] | **完全结构化**(节点 + 连接 + 文本) |
8//! | layout | `<dgm:layoutDef>` | [`LayoutDef`] | 半结构化(元数据 + layoutNode 原始 XML) |
9//! | quickStyle | `<dgm:styleData>` | [`QuickStyleDef`] | 半结构化(styleLbl 列表 + 原始 XML) |
10//! | colors | `<dgm:colorsDef>` | [`ColorsDef`] | 半结构化(元数据 + styleClrLbl 列表) |
11//!
12//! # 设计要点
13//!
14//! - **按需解析(lazy parsing)**:[`crate::presentation::DiagramEntry`] 仍以 `String` blob
15//!   持有原始 XML,保证 byte-exact round-trip;调用方需要结构化访问时,通过
16//!   `DiagramEntry::data_model()` / `layout_def()` 等方法按需触发解析。
17//! - **零 panic 设计**:解析失败返回 `Error::Xml`,不阻塞 round-trip。
18//! - **不引入 layoutNode 算法树**:layoutNode 拓扑结构复杂(含 alg/forEach 嵌套),
19//!   本模块仅保留 layoutNode 整段子树的原始 XML,不展开为强类型树。
20//!
21//! # 与 python-pptx 的对应
22//!
23//! python-pptx **不支持** SmartArt(参见 `docs/CHANGELOG.md`),因此本模块是
24//! pptx-rs 相对 python-pptx 的扩展能力。
25//!
26//! # OOXML 命名空间
27//!
28//! 所有 4 个 part 都使用 `http://schemas.openxmlformats.org/drawingml/2006/diagram`
29//! 命名空间(前缀 `dgm`),文本元素使用 `a:` 前缀(DrawingML main)。
30
31use crate::oxml::writer::XmlWriter;
32
33// ============================================================================
34// data part: <dgm:dataModel>
35// ============================================================================
36
37/// SmartArt 数据模型(data part 的结构化表达)。
38///
39/// 对应 `/ppt/diagrams/dataN.xml` 的 `<dgm:dataModel>` 根元素。
40/// 一个 SmartArt 图形的所有节点(pt)与连接(cxn)都在这里——
41/// 这是 SmartArt 的**核心数据**,编辑/查询节点都基于此结构。
42///
43/// # 结构示例
44///
45/// ```xml
46/// <dgm:dataModel xmlns:dgm="..." xmlns:a="...">
47///   <dgm:ptLst>
48///     <dgm:pt modelId="0" type="doc"/>
49///     <dgm:pt modelId="1" type="par">
50///       <dgm:prSet ang="0"/>
51///       <dgm:spPr/>
52///       <dgm:t><a:bodyPr/><a:lstStyle/><a:p><a:r><a:t>根节点</a:t></a:r></a:p></dgm:t>
53///     </dgm:pt>
54///   </dgm:ptLst>
55///   <dgm:cxnLst>
56///     <dgm:cxn type="parChld" srcId="1" destId="2"/>
57///   </dgm:cxnLst>
58/// </dgm:dataModel>
59/// ```
60#[derive(Clone, Debug, Default)]
61pub struct DataModel {
62    /// 节点列表(`<dgm:ptLst>/<dgm:pt>`)。
63    pub points: Vec<DataModelPoint>,
64    /// 连接列表(`<dgm:cxnLst>/<dgm:cxn>`)。
65    pub connections: Vec<DataModelConnection>,
66}
67
68/// 单个 SmartArt 节点(`<dgm:pt>`)。
69///
70/// 每个 pt 都有唯一的 `model_id`,通过 `<dgm:cxn>` 建立父子/兄弟关系。
71#[derive(Clone, Debug, Default)]
72pub struct DataModelPoint {
73    /// 节点 ID(`modelId` 属性,必需,同一 dataModel 内唯一)。
74    pub model_id: u32,
75    /// 节点类型(`type` 属性)。
76    ///
77    /// OOXML 定义取值:`doc`(文档根)/ `par`(父节点)/ `ch`(子节点)/
78    /// `sib`(兄弟节点)/ `prev`(前驱)/ `next`(后继)。
79    pub pt_type: Option<String>,
80    /// 节点显示文本(从 `<dgm:t>/<a:p>/<a:r>/<a:t>` 提取的首个文本)。
81    ///
82    /// 若节点无文本(如 `type="doc"` 的虚拟根),此字段为 `None`。
83    pub text: Option<String>,
84    /// 节点原始 XML(保留 byte-exact,用于 round-trip 兜底)。
85    pub raw_xml: String,
86}
87
88/// SmartArt 节点连接(`<dgm:cxn>`)。
89///
90/// 描述两个节点的拓扑关系(父子 / 兄弟)。
91#[derive(Clone, Debug, Default)]
92pub struct DataModelConnection {
93    /// 源节点 ID(`srcId` 属性,必需)。
94    pub src_id: u32,
95    /// 目标节点 ID(`destId` 属性,必需)。
96    pub dest_id: u32,
97    /// 连接类型(`type` 属性)。
98    ///
99    /// OOXML 定义取值:`parChld`(父子)/ `sib`(兄弟)等。
100    pub cxn_type: Option<String>,
101    /// 连接原始 XML(保留 byte-exact)。
102    pub raw_xml: String,
103}
104
105impl DataModelPoint {
106    /// 设置节点显示文本,同步更新 `text` 字段与 `raw_xml` 中的 `<a:t>` 内容。
107    ///
108    /// # 工作原理
109    ///
110    /// 1. 在 `raw_xml` 中查找 `<a:t>...</a:t>` 元素;
111    /// 2. 若找到,替换其中的文本内容为 `new_text`(自动 XML 转义);
112    /// 3. 若未找到(如 `type="doc"` 虚拟根节点无 `<dgm:t>` 子元素),仅更新 `text` 字段,
113    ///    `raw_xml` 保持不变(虚拟根通常不需要显示文本)。
114    ///
115    /// # 参数
116    /// - `new_text`:新的节点文本内容。
117    ///
118    /// # 限制
119    ///
120    /// - 仅替换第一个 `<a:t>` 元素的内容(SmartArt 节点通常只有一个文本运行);
121    /// - 若 `raw_xml` 为空(用户新建的节点),调用本方法后还需要调用
122    ///   [`DataModel::to_xml`] 的结构化重建分支才能生成完整 XML。
123    pub fn set_text(&mut self, new_text: impl Into<String>) {
124        let new_text = new_text.into();
125        let escaped = escape_xml_text(&new_text);
126        // 在 raw_xml 中查找 <a:t>...</a:t> 并替换内容
127        if let Some(start) = self.raw_xml.find("<a:t>") {
128            // 查找对应的 </a:t> 闭合标签
129            if let Some(end_rel) = self.raw_xml[start..].find("</a:t>") {
130                let content_start = start + "<a:t>".len();
131                let content_end = start + end_rel;
132                let prefix = &self.raw_xml[..content_start];
133                let suffix = &self.raw_xml[content_end..];
134                self.raw_xml = format!("{}{}{}", prefix, escaped, suffix);
135            }
136        }
137        self.text = Some(new_text);
138    }
139
140    /// 清除节点显示文本。
141    ///
142    /// 同步更新 `text` 字段为 `None` 与 `raw_xml` 中的 `<a:t>` 内容为空字符串。
143    /// 若 `raw_xml` 中无 `<a:t>` 元素,仅更新 `text` 字段。
144    pub fn clear_text(&mut self) {
145        // 在 raw_xml 中查找 <a:t>...</a:t> 并清空内容
146        if let Some(start) = self.raw_xml.find("<a:t>") {
147            if let Some(end_rel) = self.raw_xml[start..].find("</a:t>") {
148                let content_start = start + "<a:t>".len();
149                let content_end = start + end_rel;
150                let prefix = &self.raw_xml[..content_start];
151                let suffix = &self.raw_xml[content_end..];
152                self.raw_xml = format!("{}{}", prefix, suffix);
153            }
154        }
155        self.text = None;
156    }
157
158    /// 按 model_id 查询节点是否为指定类型。
159    ///
160    /// 便捷方法:避免外部重复匹配 `pt_type` 字段。
161    pub fn is_type(&self, type_str: &str) -> bool {
162        self.pt_type.as_deref() == Some(type_str)
163    }
164}
165
166/// XML 文本转义:把 `&` / `<` / `>` / `'` / `"` 替换为实体引用。
167///
168/// 用于 `set_text` 写入 `<a:t>` 内容时确保 XML 合法。
169fn escape_xml_text(s: &str) -> String {
170    let mut out = String::with_capacity(s.len());
171    for c in s.chars() {
172        match c {
173            '&' => out.push_str("&amp;"),
174            '<' => out.push_str("&lt;"),
175            '>' => out.push_str("&gt;"),
176            '\'' => out.push_str("&apos;"),
177            '"' => out.push_str("&quot;"),
178            _ => out.push(c),
179        }
180    }
181    out
182}
183
184impl DataModel {
185    /// 从 `<dgm:dataModel>` XML 字符串解析为强类型 [`DataModel`]。
186    ///
187    /// # 参数
188    /// - `xml`:完整 dataN.xml 内容(含 `<?xml?>` 声明与 `<dgm:dataModel>` 根元素)。
189    ///
190    /// # 返回值
191    /// - 成功:返回 [`DataModel`],包含所有节点与连接。
192    /// - 失败:返回 `Error::Xml`,包含解析错误上下文。
193    ///
194    /// # 解析策略
195    ///
196    /// 使用 quick-xml SAX 事件流解析:
197    /// - 进入 `<dgm:pt>` 时收集属性(`modelId` / `type`),开始累积 raw_xml;
198    /// - 在 `<dgm:pt>` 内的 `<a:t>` 文本事件提取节点文本;
199    /// - 离开 `<dgm:pt>` 时把累积的 raw_xml 切片写入 `point.raw_xml`;
200    /// - `<dgm:cxn>` 类似处理。
201    ///
202    /// # 错误
203    /// - `Error::Xml`:XML 解析失败(畸形 / 未闭合等)。
204    pub fn parse_from_xml(xml: &str) -> crate::Result<DataModel> {
205        let _ = xml; // xml 仅用于 Reader::from_str,不直接切片(避免 buffer_position 语义变化)
206        let mut points: Vec<DataModelPoint> = Vec::new();
207        let mut connections: Vec<DataModelConnection> = Vec::new();
208
209        let mut rd = quick_xml::reader::Reader::from_str(xml);
210        rd.config_mut().trim_text(true);
211        let mut buf = Vec::new();
212
213        // 当前正在解析的 pt/cxn 缓冲
214        let mut cur_pt: Option<DataModelPoint> = None;
215        let mut cur_cxn: Option<DataModelConnection> = None;
216        // 当前所在元素深度(用于判断是否累积 raw_xml)
217        let mut pt_depth: i32 = 0;
218        let mut cxn_depth: i32 = 0;
219        let mut in_pt_text = false; // 在 <a:t> 内
220                                    // 手动累积 raw_xml(避免依赖 buffer_position 的语义变化)
221        let mut cur_pt_raw = String::new();
222        let mut cur_cxn_raw = String::new();
223        // 累积当前 pt 的文本片段
224        let mut cur_text_buf = String::new();
225
226        loop {
227            match rd.read_event_into(&mut buf) {
228                Ok(quick_xml::events::Event::Start(e)) => {
229                    // 把 e.name() 绑定到 let,避免临时 QName 在 as_ref() 后 drop
230                    let name = e.name();
231                    let local = local_name(name.as_ref());
232                    if local == b"pt" && pt_depth == 0 {
233                        // 进入顶层 <dgm:pt>
234                        pt_depth = 1;
235                        let mut pt = DataModelPoint::default();
236                        for a in e.attributes().flatten() {
237                            let key = a.key.as_ref();
238                            let val = a
239                                .normalized_value(quick_xml::XmlVersion::Implicit1_0)
240                                .unwrap_or_default()
241                                .to_string();
242                            if key == b"modelId" {
243                                if let Ok(n) = val.parse::<u32>() {
244                                    pt.model_id = n;
245                                }
246                            } else if key == b"type" {
247                                pt.pt_type = Some(val);
248                            }
249                        }
250                        cur_pt = Some(pt);
251                        cur_text_buf.clear();
252                        // 开始累积 raw_xml
253                        cur_pt_raw.clear();
254                        cur_pt_raw.push('<');
255                        cur_pt_raw.push_str(std::str::from_utf8(e.as_ref()).unwrap_or(""));
256                        cur_pt_raw.push('>');
257                    } else if local == b"cxn" && cxn_depth == 0 {
258                        // 进入顶层 <dgm:cxn>
259                        cxn_depth = 1;
260                        let mut cxn = DataModelConnection::default();
261                        for a in e.attributes().flatten() {
262                            let key = a.key.as_ref();
263                            let val = a
264                                .normalized_value(quick_xml::XmlVersion::Implicit1_0)
265                                .unwrap_or_default()
266                                .to_string();
267                            if key == b"srcId" {
268                                if let Ok(n) = val.parse::<u32>() {
269                                    cxn.src_id = n;
270                                }
271                            } else if key == b"destId" {
272                                if let Ok(n) = val.parse::<u32>() {
273                                    cxn.dest_id = n;
274                                }
275                            } else if key == b"type" {
276                                cxn.cxn_type = Some(val);
277                            }
278                        }
279                        cur_cxn = Some(cxn);
280                        cur_cxn_raw.clear();
281                        cur_cxn_raw.push('<');
282                        cur_cxn_raw.push_str(std::str::from_utf8(e.as_ref()).unwrap_or(""));
283                        cur_cxn_raw.push('>');
284                    } else {
285                        // pt/cxn 内部的子元素 Start:累积到 raw
286                        if pt_depth > 0 {
287                            cur_pt_raw.push('<');
288                            cur_pt_raw.push_str(std::str::from_utf8(e.as_ref()).unwrap_or(""));
289                            cur_pt_raw.push('>');
290                            if local == b"t" {
291                                in_pt_text = true;
292                            }
293                            // 嵌套同名元素也增加深度(防止提前结束)
294                            if local == b"pt" {
295                                pt_depth += 1;
296                            }
297                        } else if cxn_depth > 0 {
298                            cur_cxn_raw.push('<');
299                            cur_cxn_raw.push_str(std::str::from_utf8(e.as_ref()).unwrap_or(""));
300                            cur_cxn_raw.push('>');
301                            if local == b"cxn" {
302                                cxn_depth += 1;
303                            }
304                        }
305                    }
306                }
307                Ok(quick_xml::events::Event::Empty(e)) => {
308                    let name = e.name();
309                    let local = local_name(name.as_ref());
310                    if local == b"pt" && pt_depth == 0 {
311                        // 顶层自闭合 <dgm:pt ... />(无子元素,常见于 type="doc" 虚拟根)
312                        let mut pt = DataModelPoint::default();
313                        for a in e.attributes().flatten() {
314                            let key = a.key.as_ref();
315                            let val = a
316                                .normalized_value(quick_xml::XmlVersion::Implicit1_0)
317                                .unwrap_or_default()
318                                .to_string();
319                            if key == b"modelId" {
320                                if let Ok(n) = val.parse::<u32>() {
321                                    pt.model_id = n;
322                                }
323                            } else if key == b"type" {
324                                pt.pt_type = Some(val);
325                            }
326                        }
327                        // 自闭合元素的 raw_xml 就是 <tag attrs/>
328                        let mut raw = String::from("<");
329                        raw.push_str(std::str::from_utf8(e.as_ref()).unwrap_or(""));
330                        raw.push_str("/>");
331                        pt.raw_xml = raw;
332                        points.push(pt);
333                    } else if local == b"cxn" && cxn_depth == 0 {
334                        // 顶层自闭合 <dgm:cxn ... />
335                        let mut cxn = DataModelConnection::default();
336                        for a in e.attributes().flatten() {
337                            let key = a.key.as_ref();
338                            let val = a
339                                .normalized_value(quick_xml::XmlVersion::Implicit1_0)
340                                .unwrap_or_default()
341                                .to_string();
342                            if key == b"srcId" {
343                                if let Ok(n) = val.parse::<u32>() {
344                                    cxn.src_id = n;
345                                }
346                            } else if key == b"destId" {
347                                if let Ok(n) = val.parse::<u32>() {
348                                    cxn.dest_id = n;
349                                }
350                            } else if key == b"type" {
351                                cxn.cxn_type = Some(val);
352                            }
353                        }
354                        let mut raw = String::from("<");
355                        raw.push_str(std::str::from_utf8(e.as_ref()).unwrap_or(""));
356                        raw.push_str("/>");
357                        cxn.raw_xml = raw;
358                        connections.push(cxn);
359                    } else if pt_depth > 0 {
360                        // pt 内部自闭合子元素
361                        cur_pt_raw.push('<');
362                        cur_pt_raw.push_str(std::str::from_utf8(e.as_ref()).unwrap_or(""));
363                        cur_pt_raw.push_str("/>");
364                    } else if cxn_depth > 0 {
365                        // cxn 内部自闭合子元素
366                        cur_cxn_raw.push('<');
367                        cur_cxn_raw.push_str(std::str::from_utf8(e.as_ref()).unwrap_or(""));
368                        cur_cxn_raw.push_str("/>");
369                    }
370                }
371                Ok(quick_xml::events::Event::Text(t)) => {
372                    if in_pt_text && pt_depth > 0 {
373                        // quick-xml 0.40: BytesText::unescape() 方法已移除,
374                        // 改用 quick_xml::escape::unescape 函数(接受 &str)。
375                        // BytesText 的 Deref 目标是 [u8],需要先转成 &str。
376                        let text_str = std::str::from_utf8(t.as_ref()).unwrap_or("");
377                        let text = quick_xml::escape::unescape(text_str)
378                            .unwrap_or_default()
379                            .to_string();
380                        if !text.is_empty() {
381                            cur_text_buf.push_str(&text);
382                        }
383                    }
384                    // 同时累积文本到 raw_xml
385                    if pt_depth > 0 {
386                        cur_pt_raw.push_str(std::str::from_utf8(t.as_ref()).unwrap_or(""));
387                    } else if cxn_depth > 0 {
388                        cur_cxn_raw.push_str(std::str::from_utf8(t.as_ref()).unwrap_or(""));
389                    }
390                }
391                Ok(quick_xml::events::Event::End(e)) => {
392                    let name = e.name();
393                    let local = local_name(name.as_ref());
394                    if pt_depth > 0 {
395                        // 累积 End 事件到 pt raw
396                        cur_pt_raw.push_str("</");
397                        cur_pt_raw.push_str(std::str::from_utf8(name.as_ref()).unwrap_or(""));
398                        cur_pt_raw.push('>');
399                        if local == b"t" {
400                            in_pt_text = false;
401                        } else if local == b"pt" {
402                            pt_depth -= 1;
403                            if pt_depth == 0 {
404                                // 离开顶层 pt:写回 raw_xml + text
405                                if let Some(mut pt) = cur_pt.take() {
406                                    pt.raw_xml = std::mem::take(&mut cur_pt_raw);
407                                    if !cur_text_buf.is_empty() {
408                                        pt.text = Some(cur_text_buf.clone());
409                                    }
410                                    points.push(pt);
411                                }
412                                cur_text_buf.clear();
413                            }
414                        }
415                    } else if cxn_depth > 0 {
416                        cur_cxn_raw.push_str("</");
417                        cur_cxn_raw.push_str(std::str::from_utf8(name.as_ref()).unwrap_or(""));
418                        cur_cxn_raw.push('>');
419                        if local == b"cxn" {
420                            cxn_depth -= 1;
421                            if cxn_depth == 0 {
422                                if let Some(mut cxn) = cur_cxn.take() {
423                                    cxn.raw_xml = std::mem::take(&mut cur_cxn_raw);
424                                    connections.push(cxn);
425                                }
426                            }
427                        }
428                    }
429                }
430                Ok(quick_xml::events::Event::Eof) => break,
431                Err(e) => return Err(crate::Error::Xml(format!("DataModel parse_from_xml: {e}"))),
432                _ => {}
433            }
434            buf.clear();
435        }
436
437        Ok(DataModel {
438            points,
439            connections,
440        })
441    }
442
443    /// 把 [`DataModel`] 序列化为 `<dgm:dataModel>` XML 字符串。
444    ///
445    /// **注意**:本方法用于"从结构化模型重建 XML"场景;如果只是 round-trip,
446    /// 应直接使用 [`crate::presentation::DiagramEntry`] 持有的 `data_xml` 字段。
447    ///
448    /// # 输出结构
449    ///
450    /// ```xml
451    /// <?xml version="1.0" encoding="UTF-8" standalone="yes"?>
452    /// <dgm:dataModel xmlns:dgm="..." xmlns:a="...">
453    ///   <dgm:ptLst>
454    ///     <!-- 每个 point 的 raw_xml 直接透传(保留 byte-exact) -->
455    ///   </dgm:ptLst>
456    ///   <dgm:cxnLst>
457    ///     <!-- 每个 connection 的 raw_xml 直接透传 -->
458    ///   </dgm:cxnLst>
459    /// </dgm:dataModel>
460    /// ```
461    ///
462    /// # 设计要点
463    ///
464    /// 节点/连接的子元素(`<dgm:prSet>` / `<dgm:spPr>` / `<dgm:t>` 等)通过
465    /// `raw_xml` 字段直接透传,避免重新序列化时丢失属性。
466    ///
467    /// # 结构化重建分支
468    ///
469    /// 当 `raw_xml` 为空但 `text` 字段非空时(用户新建的节点),按 OOXML 顺序
470    /// 构造完整 `<dgm:pt>` 结构:
471    ///
472    /// ```xml
473    /// <dgm:pt modelId="..." type="...">
474    ///   <dgm:t><a:bodyPr/><a:lstStyle/><a:p><a:r><a:t>文本</a:t></a:r></a:p></dgm:t>
475    /// </dgm:pt>
476    /// ```
477    ///
478    /// 这是为了支持"从零构造 SmartArt 数据模型并写出"的场景。
479    pub fn to_xml(&self) -> String {
480        let mut w = XmlWriter::new();
481        w.raw("<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\n");
482        w.open_with(
483            "dgm:dataModel",
484            &[
485                (
486                    "xmlns:dgm",
487                    "http://schemas.openxmlformats.org/drawingml/2006/diagram",
488                ),
489                (
490                    "xmlns:a",
491                    "http://schemas.openxmlformats.org/drawingml/2006/main",
492                ),
493            ],
494        );
495        // ptLst
496        w.open("dgm:ptLst");
497        for pt in &self.points {
498            if pt.raw_xml.is_empty() {
499                // 无 raw_xml(用户新建):构造 <dgm:pt>,若有 text 则包含 <dgm:t> 子元素
500                let id_s = pt.model_id.to_string();
501                let mut attrs: Vec<(&str, &str)> = vec![("modelId", id_s.as_str())];
502                if let Some(t) = &pt.pt_type {
503                    attrs.push(("type", t.as_str()));
504                }
505                if let Some(text) = &pt.text {
506                    // 有文本:构造完整 <dgm:pt> + <dgm:t>
507                    w.open_with("dgm:pt", &attrs);
508                    w.open("dgm:t");
509                    w.empty("a:bodyPr");
510                    w.empty("a:lstStyle");
511                    w.open("a:p");
512                    w.open("a:r");
513                    let escaped = escape_xml_text(text);
514                    w.leaf("a:t", escaped.as_str());
515                    w.close("a:r");
516                    w.close("a:p");
517                    w.close("dgm:t");
518                    w.close("dgm:pt");
519                } else {
520                    // 无文本:自闭合 <dgm:pt/>
521                    w.empty_with("dgm:pt", &attrs);
522                }
523            } else {
524                // 有 raw_xml:直接透传
525                w.raw(&pt.raw_xml);
526            }
527        }
528        w.close("dgm:ptLst");
529        // cxnLst(仅当有连接时写出)
530        if !self.connections.is_empty() {
531            w.open("dgm:cxnLst");
532            for cxn in &self.connections {
533                if cxn.raw_xml.is_empty() {
534                    let src_s = cxn.src_id.to_string();
535                    let dst_s = cxn.dest_id.to_string();
536                    let mut attrs: Vec<(&str, &str)> =
537                        vec![("srcId", src_s.as_str()), ("destId", dst_s.as_str())];
538                    if let Some(t) = &cxn.cxn_type {
539                        attrs.push(("type", t.as_str()));
540                    }
541                    w.empty_with("dgm:cxn", &attrs);
542                } else {
543                    w.raw(&cxn.raw_xml);
544                }
545            }
546            w.close("dgm:cxnLst");
547        }
548        w.close("dgm:dataModel");
549        w.into_string()
550    }
551
552    /// 按 `model_id` 查找节点,返回可变引用。
553    ///
554    /// 便捷方法:避免外部遍历 `points` 列表。未找到返回 `None`。
555    pub fn point_mut(&mut self, model_id: u32) -> Option<&mut DataModelPoint> {
556        self.points.iter_mut().find(|p| p.model_id == model_id)
557    }
558
559    /// 按 `model_id` 查找节点,返回不可变引用。
560    pub fn point(&self, model_id: u32) -> Option<&DataModelPoint> {
561        self.points.iter().find(|p| p.model_id == model_id)
562    }
563
564    /// 设置指定 `model_id` 节点的显示文本(便捷方法)。
565    ///
566    /// 内部调用 [`DataModelPoint::set_text`]。未找到节点返回 `false`,
567    /// 成功修改返回 `true`。
568    pub fn set_point_text(&mut self, model_id: u32, new_text: impl Into<String>) -> bool {
569        if let Some(pt) = self.point_mut(model_id) {
570            pt.set_text(new_text);
571            true
572        } else {
573            false
574        }
575    }
576}
577
578// ============================================================================
579// layout part: <dgm:layoutDef>
580// ============================================================================
581
582/// SmartArt 布局定义(layout part 的半结构化表达)。
583///
584/// 对应 `/ppt/diagrams/layoutN.xml` 的 `<dgm:layoutDef>` 根元素。
585/// 布局定义描述了 SmartArt 图形的**拓扑算法**(如线性/层次/循环/矩阵),
586/// 是 SmartArt 的"模板"。
587///
588/// # 半结构化策略
589///
590/// layoutNode 算法树结构复杂(含 alg/forEach 嵌套),本结构仅提取顶层元数据
591/// (uniqueId / title / desc / catLst),layoutNode 子树保留原始 XML。
592#[derive(Clone, Debug, Default)]
593pub struct LayoutDef {
594    /// 唯一 ID(`uniqueId` 属性,用于跨 pptx 引用同一布局)。
595    pub unique_id: Option<String>,
596    /// 标题(`<dgm:title val="..."/>`)。
597    pub title: Option<String>,
598    /// 描述(`<dgm:desc val="..."/>`)。
599    pub desc: Option<String>,
600    /// 类别列表(`<dgm:catLst>/<dgm:cat>`)。
601    pub categories: Vec<LayoutCategory>,
602    /// layoutNode 子树原始 XML(`<dgm:layoutNode>...</dgm:layoutNode>` 整段)。
603    ///
604    /// 保留 byte-exact,避免重新序列化复杂的算法树。
605    pub layout_node_xml: String,
606}
607
608/// SmartArt 布局类别(`<dgm:cat>`)。
609#[derive(Clone, Debug, Default)]
610pub struct LayoutCategory {
611    /// 类别类型(`type` 属性,如 `process` / `hierarchy` / `cycle` / `matrix` / `pyramid` 等)。
612    pub cat_type: Option<String>,
613    /// 优先级(`pri` 属性,数值越小优先级越高)。
614    pub priority: Option<i32>,
615}
616
617impl LayoutDef {
618    /// 从 `<dgm:layoutDef>` XML 字符串解析为 [`LayoutDef`]。
619    ///
620    /// # 解析策略
621    ///
622    /// - 提取 `<dgm:layoutDef>` 的 `uniqueId` 属性;
623    /// - 提取 `<dgm:title val="..."/>` 与 `<dgm:desc val="..."/>` 的 `val` 属性;
624    /// - 提取 `<dgm:catLst>` 下的所有 `<dgm:cat>` 的 `type` 与 `pri` 属性;
625    /// - 把 `<dgm:layoutNode>` 整段切片为 `layout_node_xml`(byte-exact)。
626    ///
627    /// # 错误
628    /// - `Error::Xml`:XML 解析失败。
629    pub fn parse_from_xml(xml: &str) -> crate::Result<LayoutDef> {
630        let _ = xml; // xml 仅用于 Reader::from_str,不直接切片
631        let mut layout = LayoutDef::default();
632
633        let mut rd = quick_xml::reader::Reader::from_str(xml);
634        rd.config_mut().trim_text(true);
635        let mut buf = Vec::new();
636
637        let mut in_cat_lst = false;
638        let mut layout_node_depth: i32 = 0;
639        // 手动累积 layoutNode 子树 raw_xml(避免依赖 buffer_position 语义)
640        let mut cur_layout_raw = String::new();
641
642        loop {
643            match rd.read_event_into(&mut buf) {
644                Ok(quick_xml::events::Event::Start(e)) => {
645                    // 把 e.name() 绑定到 let,避免临时 QName 在 as_ref() 后 drop
646                    let name = e.name();
647                    let local = local_name(name.as_ref());
648                    if local == b"layoutDef" {
649                        // 提取 uniqueId 属性
650                        for a in e.attributes().flatten() {
651                            if a.key.as_ref() == b"uniqueId" {
652                                layout.unique_id = Some(
653                                    a.normalized_value(quick_xml::XmlVersion::Implicit1_0)
654                                        .unwrap_or_default()
655                                        .to_string(),
656                                );
657                            }
658                        }
659                    } else if local == b"title" {
660                        for a in e.attributes().flatten() {
661                            if a.key.as_ref() == b"val" {
662                                layout.title = Some(
663                                    a.normalized_value(quick_xml::XmlVersion::Implicit1_0)
664                                        .unwrap_or_default()
665                                        .to_string(),
666                                );
667                            }
668                        }
669                    } else if local == b"desc" {
670                        for a in e.attributes().flatten() {
671                            if a.key.as_ref() == b"val" {
672                                layout.desc = Some(
673                                    a.normalized_value(quick_xml::XmlVersion::Implicit1_0)
674                                        .unwrap_or_default()
675                                        .to_string(),
676                                );
677                            }
678                        }
679                    } else if local == b"catLst" {
680                        in_cat_lst = true;
681                    } else if local == b"cat" && in_cat_lst {
682                        let mut cat = LayoutCategory::default();
683                        for a in e.attributes().flatten() {
684                            let key = a.key.as_ref();
685                            let val = a
686                                .normalized_value(quick_xml::XmlVersion::Implicit1_0)
687                                .unwrap_or_default()
688                                .to_string();
689                            if key == b"type" {
690                                cat.cat_type = Some(val);
691                            } else if key == b"pri" {
692                                if let Ok(n) = val.parse::<i32>() {
693                                    cat.priority = Some(n);
694                                }
695                            }
696                        }
697                        layout.categories.push(cat);
698                    } else if local == b"layoutNode" {
699                        layout_node_depth += 1;
700                        if layout_node_depth == 1 {
701                            // 进入顶层 layoutNode:开始累积 raw_xml
702                            cur_layout_raw.clear();
703                        }
704                        // 累积 Start 事件(quick-xml 0.40: as_ref 不含 `<` 与 `>`)
705                        cur_layout_raw.push('<');
706                        cur_layout_raw.push_str(std::str::from_utf8(e.as_ref()).unwrap_or(""));
707                        cur_layout_raw.push('>');
708                    } else if layout_node_depth > 0 {
709                        // layoutNode 内其他子元素 Start
710                        cur_layout_raw.push('<');
711                        cur_layout_raw.push_str(std::str::from_utf8(e.as_ref()).unwrap_or(""));
712                        cur_layout_raw.push('>');
713                    }
714                }
715                Ok(quick_xml::events::Event::Empty(e)) => {
716                    let name = e.name();
717                    let local = local_name(name.as_ref());
718                    if local == b"title" {
719                        for a in e.attributes().flatten() {
720                            if a.key.as_ref() == b"val" {
721                                layout.title = Some(
722                                    a.normalized_value(quick_xml::XmlVersion::Implicit1_0)
723                                        .unwrap_or_default()
724                                        .to_string(),
725                                );
726                            }
727                        }
728                    } else if local == b"desc" {
729                        for a in e.attributes().flatten() {
730                            if a.key.as_ref() == b"val" {
731                                layout.desc = Some(
732                                    a.normalized_value(quick_xml::XmlVersion::Implicit1_0)
733                                        .unwrap_or_default()
734                                        .to_string(),
735                                );
736                            }
737                        }
738                    } else if local == b"cat" && in_cat_lst {
739                        let mut cat = LayoutCategory::default();
740                        for a in e.attributes().flatten() {
741                            let key = a.key.as_ref();
742                            let val = a
743                                .normalized_value(quick_xml::XmlVersion::Implicit1_0)
744                                .unwrap_or_default()
745                                .to_string();
746                            if key == b"type" {
747                                cat.cat_type = Some(val);
748                            } else if key == b"pri" {
749                                if let Ok(n) = val.parse::<i32>() {
750                                    cat.priority = Some(n);
751                                }
752                            }
753                        }
754                        layout.categories.push(cat);
755                    } else if layout_node_depth > 0 {
756                        // layoutNode 内自闭合子元素
757                        cur_layout_raw.push('<');
758                        cur_layout_raw.push_str(std::str::from_utf8(e.as_ref()).unwrap_or(""));
759                        cur_layout_raw.push_str("/>");
760                    }
761                }
762                Ok(quick_xml::events::Event::End(e)) => {
763                    // 把 e.name() 绑定到 let,避免临时 QName 在 as_ref() 后 drop
764                    let name = e.name();
765                    let local = local_name(name.as_ref());
766                    if local == b"catLst" {
767                        in_cat_lst = false;
768                    } else if local == b"layoutNode" && layout_node_depth > 0 {
769                        // 累积 End 事件
770                        cur_layout_raw.push_str("</");
771                        cur_layout_raw.push_str(std::str::from_utf8(name.as_ref()).unwrap_or(""));
772                        cur_layout_raw.push('>');
773                        layout_node_depth -= 1;
774                        if layout_node_depth == 0 {
775                            // 离开顶层 layoutNode:写回 raw_xml
776                            layout.layout_node_xml = std::mem::take(&mut cur_layout_raw);
777                        }
778                    } else if layout_node_depth > 0 {
779                        // layoutNode 内其他子元素 End
780                        cur_layout_raw.push_str("</");
781                        cur_layout_raw.push_str(std::str::from_utf8(name.as_ref()).unwrap_or(""));
782                        cur_layout_raw.push('>');
783                    }
784                }
785                Ok(quick_xml::events::Event::Text(t)) => {
786                    if layout_node_depth > 0 {
787                        cur_layout_raw.push_str(std::str::from_utf8(t.as_ref()).unwrap_or(""));
788                    }
789                }
790                Ok(quick_xml::events::Event::Eof) => break,
791                Err(e) => return Err(crate::Error::Xml(format!("LayoutDef parse_from_xml: {e}"))),
792                _ => {}
793            }
794            buf.clear();
795        }
796
797        Ok(layout)
798    }
799
800    /// 把 [`LayoutDef`] 序列化为 `<dgm:layoutDef>` XML 字符串。
801    ///
802    /// layoutNode 子树通过 `layout_node_xml` 直接透传。
803    pub fn to_xml(&self) -> String {
804        let mut w = XmlWriter::new();
805        w.raw("<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\n");
806        let mut attrs: Vec<(&str, &str)> = vec![(
807            "xmlns:dgm",
808            "http://schemas.openxmlformats.org/drawingml/2006/diagram",
809        )];
810        if let Some(id) = &self.unique_id {
811            attrs.push(("uniqueId", id.as_str()));
812        }
813        w.open_with("dgm:layoutDef", &attrs);
814        if let Some(t) = &self.title {
815            w.empty_with("dgm:title", &[("val", t.as_str())]);
816        }
817        if let Some(d) = &self.desc {
818            w.empty_with("dgm:desc", &[("val", d.as_str())]);
819        }
820        if !self.categories.is_empty() {
821            w.open("dgm:catLst");
822            for cat in &self.categories {
823                // p_s 提到 cattrs 之前,保证生命周期覆盖 empty_with 调用
824                let p_s = cat.priority.as_ref().map(|p| p.to_string());
825                let mut cattrs: Vec<(&str, &str)> = Vec::new();
826                if let Some(t) = &cat.cat_type {
827                    cattrs.push(("type", t.as_str()));
828                }
829                if let Some(s) = p_s.as_deref() {
830                    cattrs.push(("pri", s));
831                }
832                w.empty_with("dgm:cat", &cattrs);
833            }
834            w.close("dgm:catLst");
835        }
836        // layoutNode 子树直接透传
837        if !self.layout_node_xml.is_empty() {
838            w.raw(&self.layout_node_xml);
839        }
840        w.close("dgm:layoutDef");
841        w.into_string()
842    }
843}
844
845// ============================================================================
846// quickStyle part: <dgm:styleData>
847// ============================================================================
848
849/// SmartArt 快速样式(quickStyle part 的半结构化表达)。
850///
851/// 对应 `/ppt/diagrams/quickStylesN.xml` 的 `<dgm:styleData>` 根元素。
852/// quickStyle 定义了 SmartArt 节点/连接线的视觉样式(填充/边框/效果)。
853///
854/// # 半结构化策略
855///
856/// 样式标签 `<dgm:styleLbl>` 内部结构复杂(含 scene3d/sp3d/effectLst/fillLst/lnLst),
857/// 本结构仅提取 `name` 属性,body 保留原始 XML。
858#[derive(Clone, Debug, Default)]
859pub struct QuickStyleDef {
860    /// 样式标签列表(`<dgm:styleLbl>`)。
861    pub style_labels: Vec<StyleLabel>,
862}
863
864/// SmartArt 样式标签(`<dgm:styleLbl>`)。
865///
866/// quickStyle 与 colors 都使用此结构(colors 中的 `<dgm:styleLbl>` 在 `<dgm:styleClrData>` 内)。
867#[derive(Clone, Debug, Default)]
868pub struct StyleLabel {
869    /// 标签名(`name` 属性,如 `node0` / `node1` / `parTrans` / `sibTrans` 等)。
870    pub name: Option<String>,
871    /// 标签原始 XML(保留 byte-exact,含所有子元素)。
872    pub raw_xml: String,
873}
874
875impl QuickStyleDef {
876    /// 从 `<dgm:styleData>` XML 字符串解析为 [`QuickStyleDef`]。
877    ///
878    /// # 解析策略
879    ///
880    /// 遍历所有 `<dgm:styleLbl>` 元素(无论嵌套深度),提取 `name` 属性 + 整段 raw_xml。
881    /// raw_xml 通过手动累积事件字节收集,不依赖 `buffer_position()`,保证 quick-xml 版本兼容。
882    ///
883    /// # 错误
884    /// - `Error::Xml`:XML 解析失败。
885    pub fn parse_from_xml(xml: &str) -> crate::Result<QuickStyleDef> {
886        let _ = xml; // xml 仅用于 Reader::from_str,不直接切片
887        let mut style_labels: Vec<StyleLabel> = Vec::new();
888
889        let mut rd = quick_xml::reader::Reader::from_str(xml);
890        rd.config_mut().trim_text(true);
891        let mut buf = Vec::new();
892
893        let mut cur_lbl: Option<StyleLabel> = None;
894        let mut lbl_depth: i32 = 0;
895        // 手动累积 raw_xml(避免依赖 buffer_position 的语义变化)
896        let mut cur_raw = String::new();
897
898        loop {
899            match rd.read_event_into(&mut buf) {
900                Ok(quick_xml::events::Event::Start(e)) => {
901                    // 把 e.name() 绑定到 let,避免临时 QName 在 as_ref() 后 drop
902                    let name = e.name();
903                    let local = local_name(name.as_ref());
904                    if local == b"styleLbl" {
905                        if lbl_depth == 0 {
906                            cur_lbl = Some(StyleLabel::default());
907                            lbl_depth = 1;
908                            cur_raw.clear();
909                            // quick-xml 0.40: BytesStart::as_ref() 返回 `tag attrs`(不含 `<` 与 `>`)。
910                            // 因此手动补 `<` 与 `>` 还原 Start 事件原始文本。
911                            cur_raw.push('<');
912                            cur_raw.push_str(std::str::from_utf8(e.as_ref()).unwrap_or(""));
913                            cur_raw.push('>');
914                            // 提取 name 属性
915                            for a in e.attributes().flatten() {
916                                if a.key.as_ref() == b"name" {
917                                    if let Some(lbl) = cur_lbl.as_mut() {
918                                        lbl.name = Some(
919                                            a.normalized_value(quick_xml::XmlVersion::Implicit1_0)
920                                                .unwrap_or_default()
921                                                .to_string(),
922                                        );
923                                    }
924                                }
925                            }
926                        } else {
927                            lbl_depth += 1;
928                            // 嵌套的 styleLbl Start 事件也累积
929                            cur_raw.push('<');
930                            cur_raw.push_str(std::str::from_utf8(e.as_ref()).unwrap_or(""));
931                            cur_raw.push('>');
932                        }
933                    } else if lbl_depth > 0 {
934                        // styleLbl 内的其他 Start 元素
935                        cur_raw.push('<');
936                        cur_raw.push_str(std::str::from_utf8(e.as_ref()).unwrap_or(""));
937                        cur_raw.push('>');
938                    }
939                }
940                Ok(quick_xml::events::Event::End(e)) => {
941                    let name = e.name();
942                    let local = local_name(name.as_ref());
943                    if lbl_depth > 0 {
944                        // 累积 End 事件 </tag>
945                        cur_raw.push_str("</");
946                        cur_raw.push_str(std::str::from_utf8(name.as_ref()).unwrap_or(""));
947                        cur_raw.push('>');
948                    }
949                    if local == b"styleLbl" && lbl_depth > 0 {
950                        lbl_depth -= 1;
951                        if lbl_depth == 0 {
952                            if let Some(mut lbl) = cur_lbl.take() {
953                                lbl.raw_xml = std::mem::take(&mut cur_raw);
954                                style_labels.push(lbl);
955                            }
956                        }
957                    }
958                }
959                Ok(quick_xml::events::Event::Empty(e)) => {
960                    if lbl_depth > 0 {
961                        // 累积 Empty 事件 <tag attrs/>
962                        // quick-xml 0.40: BytesStart::as_ref() 不含 `<` 与 `/>`,需手动补全。
963                        cur_raw.push('<');
964                        cur_raw.push_str(std::str::from_utf8(e.as_ref()).unwrap_or(""));
965                        cur_raw.push_str("/>");
966                    }
967                }
968                Ok(quick_xml::events::Event::Text(t)) => {
969                    if lbl_depth > 0 {
970                        cur_raw.push_str(std::str::from_utf8(&t).unwrap_or(""));
971                    }
972                }
973                Ok(quick_xml::events::Event::Eof) => break,
974                Err(e) => {
975                    return Err(crate::Error::Xml(format!(
976                        "QuickStyleDef parse_from_xml: {e}"
977                    )))
978                }
979                _ => {}
980            }
981            buf.clear();
982        }
983
984        Ok(QuickStyleDef { style_labels })
985    }
986
987    /// 把 [`QuickStyleDef`] 序列化为 `<dgm:styleData>` XML 字符串。
988    ///
989    /// styleLbl 子元素通过 `raw_xml` 直接透传。
990    pub fn to_xml(&self) -> String {
991        let mut w = XmlWriter::new();
992        w.raw("<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\n");
993        w.open_with(
994            "dgm:styleData",
995            &[(
996                "xmlns:dgm",
997                "http://schemas.openxmlformats.org/drawingml/2006/diagram",
998            )],
999        );
1000        for lbl in &self.style_labels {
1001            if lbl.raw_xml.is_empty() {
1002                let mut attrs: Vec<(&str, &str)> = Vec::new();
1003                if let Some(n) = &lbl.name {
1004                    attrs.push(("name", n.as_str()));
1005                }
1006                w.empty_with("dgm:styleLbl", &attrs);
1007            } else {
1008                w.raw(&lbl.raw_xml);
1009            }
1010        }
1011        w.close("dgm:styleData");
1012        w.into_string()
1013    }
1014}
1015
1016// ============================================================================
1017// colors part: <dgm:colorsDef>
1018// ============================================================================
1019
1020/// SmartArt 颜色定义(colors part 的半结构化表达)。
1021///
1022/// 对应 `/ppt/diagrams/colorsN.xml` 的 `<dgm:colorsDef>` 根元素。
1023/// colors 定义了 SmartArt 节点的颜色变体映射(基于主题色 accent1-6 的不同组合)。
1024#[derive(Clone, Debug, Default)]
1025pub struct ColorsDef {
1026    /// 唯一 ID(`uniqueId` 属性)。
1027    pub unique_id: Option<String>,
1028    /// 标题(`<dgm:title val="..."/>`)。
1029    pub title: Option<String>,
1030    /// 描述(`<dgm:desc val="..."/>`)。
1031    pub desc: Option<String>,
1032    /// 颜色样式标签列表(`<dgm:styleClrData>/<dgm:styleLbl>`)。
1033    pub style_color_labels: Vec<StyleLabel>,
1034}
1035
1036impl ColorsDef {
1037    /// 从 `<dgm:colorsDef>` XML 字符串解析为 [`ColorsDef`]。
1038    ///
1039    /// # 解析策略
1040    ///
1041    /// - 提取 `<dgm:colorsDef>` 的 `uniqueId` 属性;
1042    /// - 提取 `<dgm:title>` / `<dgm:desc>` 的 `val` 属性;
1043    /// - 遍历 `<dgm:styleClrData>` 下的所有 `<dgm:styleLbl>`,提取 `name` + 整段 raw_xml。
1044    ///
1045    /// # 错误
1046    /// - `Error::Xml`:XML 解析失败。
1047    pub fn parse_from_xml(xml: &str) -> crate::Result<ColorsDef> {
1048        let _ = xml; // xml 仅用于 Reader::from_str,不直接切片
1049        let mut colors = ColorsDef::default();
1050
1051        let mut rd = quick_xml::reader::Reader::from_str(xml);
1052        rd.config_mut().trim_text(true);
1053        let mut buf = Vec::new();
1054
1055        let mut cur_lbl: Option<StyleLabel> = None;
1056        let mut lbl_depth: i32 = 0;
1057        // 手动累积 styleLbl 子树 raw_xml(避免依赖 buffer_position 语义)
1058        let mut cur_raw = String::new();
1059
1060        loop {
1061            match rd.read_event_into(&mut buf) {
1062                Ok(quick_xml::events::Event::Start(e)) => {
1063                    // 把 e.name() 绑定到 let,避免临时 QName 在 as_ref() 后 drop
1064                    let name = e.name();
1065                    let local = local_name(name.as_ref());
1066                    if local == b"colorsDef" {
1067                        for a in e.attributes().flatten() {
1068                            if a.key.as_ref() == b"uniqueId" {
1069                                colors.unique_id = Some(
1070                                    a.normalized_value(quick_xml::XmlVersion::Implicit1_0)
1071                                        .unwrap_or_default()
1072                                        .to_string(),
1073                                );
1074                            }
1075                        }
1076                    } else if local == b"title" {
1077                        // title 通常是自闭合 <dgm:title val="..."/>,但若以 Start 形式出现也兼容
1078                        for a in e.attributes().flatten() {
1079                            if a.key.as_ref() == b"val" {
1080                                colors.title = Some(
1081                                    a.normalized_value(quick_xml::XmlVersion::Implicit1_0)
1082                                        .unwrap_or_default()
1083                                        .to_string(),
1084                                );
1085                            }
1086                        }
1087                    } else if local == b"desc" {
1088                        for a in e.attributes().flatten() {
1089                            if a.key.as_ref() == b"val" {
1090                                colors.desc = Some(
1091                                    a.normalized_value(quick_xml::XmlVersion::Implicit1_0)
1092                                        .unwrap_or_default()
1093                                        .to_string(),
1094                                );
1095                            }
1096                        }
1097                    } else if local == b"styleLbl" {
1098                        if lbl_depth == 0 {
1099                            cur_lbl = Some(StyleLabel::default());
1100                            lbl_depth = 1;
1101                            cur_raw.clear();
1102                            // 累积 Start 事件(quick-xml 0.40: as_ref 不含 `<` 与 `>`)
1103                            cur_raw.push('<');
1104                            cur_raw.push_str(std::str::from_utf8(e.as_ref()).unwrap_or(""));
1105                            cur_raw.push('>');
1106                            for a in e.attributes().flatten() {
1107                                if a.key.as_ref() == b"name" {
1108                                    if let Some(lbl) = cur_lbl.as_mut() {
1109                                        lbl.name = Some(
1110                                            a.normalized_value(quick_xml::XmlVersion::Implicit1_0)
1111                                                .unwrap_or_default()
1112                                                .to_string(),
1113                                        );
1114                                    }
1115                                }
1116                            }
1117                        } else {
1118                            lbl_depth += 1;
1119                            // 嵌套 styleLbl 也累积
1120                            cur_raw.push('<');
1121                            cur_raw.push_str(std::str::from_utf8(e.as_ref()).unwrap_or(""));
1122                            cur_raw.push('>');
1123                        }
1124                    } else if lbl_depth > 0 {
1125                        // styleLbl 内其他子元素 Start
1126                        cur_raw.push('<');
1127                        cur_raw.push_str(std::str::from_utf8(e.as_ref()).unwrap_or(""));
1128                        cur_raw.push('>');
1129                    }
1130                }
1131                Ok(quick_xml::events::Event::Empty(e)) => {
1132                    let name = e.name();
1133                    let local = local_name(name.as_ref());
1134                    if local == b"title" {
1135                        // <dgm:title val="..."/> 自闭合形式
1136                        for a in e.attributes().flatten() {
1137                            if a.key.as_ref() == b"val" {
1138                                colors.title = Some(
1139                                    a.normalized_value(quick_xml::XmlVersion::Implicit1_0)
1140                                        .unwrap_or_default()
1141                                        .to_string(),
1142                                );
1143                            }
1144                        }
1145                    } else if local == b"desc" {
1146                        for a in e.attributes().flatten() {
1147                            if a.key.as_ref() == b"val" {
1148                                colors.desc = Some(
1149                                    a.normalized_value(quick_xml::XmlVersion::Implicit1_0)
1150                                        .unwrap_or_default()
1151                                        .to_string(),
1152                                );
1153                            }
1154                        }
1155                    } else if lbl_depth > 0 {
1156                        // styleLbl 内自闭合子元素
1157                        cur_raw.push('<');
1158                        cur_raw.push_str(std::str::from_utf8(e.as_ref()).unwrap_or(""));
1159                        cur_raw.push_str("/>");
1160                    }
1161                }
1162                Ok(quick_xml::events::Event::End(e)) => {
1163                    // 把 e.name() 绑定到 let,避免临时 QName 在 as_ref() 后 drop
1164                    let name = e.name();
1165                    let local = local_name(name.as_ref());
1166                    if lbl_depth > 0 {
1167                        // 累积 End 事件
1168                        cur_raw.push_str("</");
1169                        cur_raw.push_str(std::str::from_utf8(name.as_ref()).unwrap_or(""));
1170                        cur_raw.push('>');
1171                    }
1172                    if local == b"styleLbl" && lbl_depth > 0 {
1173                        lbl_depth -= 1;
1174                        if lbl_depth == 0 {
1175                            if let Some(mut lbl) = cur_lbl.take() {
1176                                lbl.raw_xml = std::mem::take(&mut cur_raw);
1177                                colors.style_color_labels.push(lbl);
1178                            }
1179                        }
1180                    }
1181                }
1182                Ok(quick_xml::events::Event::Text(t)) => {
1183                    if lbl_depth > 0 {
1184                        cur_raw.push_str(std::str::from_utf8(t.as_ref()).unwrap_or(""));
1185                    }
1186                }
1187                Ok(quick_xml::events::Event::Eof) => break,
1188                Err(e) => return Err(crate::Error::Xml(format!("ColorsDef parse_from_xml: {e}"))),
1189                _ => {}
1190            }
1191            buf.clear();
1192        }
1193
1194        Ok(colors)
1195    }
1196
1197    /// 把 [`ColorsDef`] 序列化为 `<dgm:colorsDef>` XML 字符串。
1198    pub fn to_xml(&self) -> String {
1199        let mut w = XmlWriter::new();
1200        w.raw("<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\n");
1201        let mut attrs: Vec<(&str, &str)> = vec![(
1202            "xmlns:dgm",
1203            "http://schemas.openxmlformats.org/drawingml/2006/diagram",
1204        )];
1205        if let Some(id) = &self.unique_id {
1206            attrs.push(("uniqueId", id.as_str()));
1207        }
1208        w.open_with("dgm:colorsDef", &attrs);
1209        if let Some(t) = &self.title {
1210            w.empty_with("dgm:title", &[("val", t.as_str())]);
1211        }
1212        if let Some(d) = &self.desc {
1213            w.empty_with("dgm:desc", &[("val", d.as_str())]);
1214        }
1215        if !self.style_color_labels.is_empty() {
1216            w.open("dgm:styleClrData");
1217            for lbl in &self.style_color_labels {
1218                if lbl.raw_xml.is_empty() {
1219                    let mut lattrs: Vec<(&str, &str)> = Vec::new();
1220                    if let Some(n) = &lbl.name {
1221                        lattrs.push(("name", n.as_str()));
1222                    }
1223                    w.empty_with("dgm:styleLbl", &lattrs);
1224                } else {
1225                    w.raw(&lbl.raw_xml);
1226                }
1227            }
1228            w.close("dgm:styleClrData");
1229        }
1230        w.close("dgm:colorsDef");
1231        w.into_string()
1232    }
1233}
1234
1235// ============================================================================
1236// 辅助函数
1237// ============================================================================
1238
1239/// 提取元素 local name(去命名空间前缀)。
1240///
1241/// 例:`b"dgm:pt"` → `b"pt"`;`b"pt"` → `b"pt"`。
1242fn local_name(name: &[u8]) -> &[u8] {
1243    match name.iter().position(|&b| b == b':') {
1244        Some(i) => &name[i + 1..],
1245        None => name,
1246    }
1247}
1248
1249// ============================================================================
1250// 单元测试
1251// ============================================================================
1252
1253#[cfg(test)]
1254mod tests {
1255    use super::*;
1256
1257    /// 构造最小 dataModel XML(含虚拟根 + 1 个有文本的节点 + 1 个连接)。
1258    fn sample_data_model_xml() -> &'static str {
1259        "<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\n\
1260         <dgm:dataModel xmlns:dgm=\"http://schemas.openxmlformats.org/drawingml/2006/diagram\" xmlns:a=\"http://schemas.openxmlformats.org/drawingml/2006/main\">\
1261           <dgm:ptLst>\
1262             <dgm:pt modelId=\"0\" type=\"doc\"/>\
1263             <dgm:pt modelId=\"1\" type=\"par\">\
1264               <dgm:prSet ang=\"0\"/>\
1265               <dgm:spPr/>\
1266               <dgm:t><a:bodyPr/><a:lstStyle/><a:p><a:r><a:t>根节点</a:t></a:r></a:p></dgm:t>\
1267             </dgm:pt>\
1268           </dgm:ptLst>\
1269           <dgm:cxnLst>\
1270             <dgm:cxn type=\"parChld\" srcId=\"1\" destId=\"2\"/>\
1271           </dgm:cxnLst>\
1272         </dgm:dataModel>"
1273    }
1274
1275    /// DataModel 解析:验证节点数 / modelId / type / text / 连接。
1276    #[test]
1277    fn data_model_parse_basic() {
1278        let xml = sample_data_model_xml();
1279        let dm = DataModel::parse_from_xml(xml).expect("parse dataModel");
1280
1281        assert_eq!(dm.points.len(), 2, "should have 2 points");
1282        // 虚拟根(自闭合 <dgm:pt modelId="0" type="doc"/>)
1283        assert_eq!(dm.points[0].model_id, 0);
1284        assert_eq!(dm.points[0].pt_type.as_deref(), Some("doc"));
1285        assert!(dm.points[0].text.is_none(), "doc node has no text");
1286
1287        // 有文本节点
1288        assert_eq!(dm.points[1].model_id, 1);
1289        assert_eq!(dm.points[1].pt_type.as_deref(), Some("par"));
1290        assert_eq!(dm.points[1].text.as_deref(), Some("根节点"));
1291
1292        // 连接
1293        assert_eq!(dm.connections.len(), 1);
1294        assert_eq!(dm.connections[0].src_id, 1);
1295        assert_eq!(dm.connections[0].dest_id, 2);
1296        assert_eq!(dm.connections[0].cxn_type.as_deref(), Some("parChld"));
1297    }
1298
1299    /// DataModel 解析:raw_xml 字段非空且含原始标签。
1300    #[test]
1301    fn data_model_parse_preserves_raw_xml() {
1302        let xml = sample_data_model_xml();
1303        let dm = DataModel::parse_from_xml(xml).expect("parse dataModel");
1304
1305        // 第二个 pt 的 raw_xml 应包含 <dgm:prSet> 等子元素
1306        let pt_raw = &dm.points[1].raw_xml;
1307        assert!(
1308            pt_raw.contains("<dgm:pt"),
1309            "raw_xml should contain pt tag: {}",
1310            pt_raw
1311        );
1312        assert!(
1313            pt_raw.contains("<dgm:prSet"),
1314            "raw_xml should contain prSet: {}",
1315            pt_raw
1316        );
1317        assert!(
1318            pt_raw.contains("根节点"),
1319            "raw_xml should contain text: {}",
1320            pt_raw
1321        );
1322
1323        // 连接的 raw_xml 应包含 <dgm:cxn
1324        let cxn_raw = &dm.connections[0].raw_xml;
1325        assert!(cxn_raw.contains("<dgm:cxn"), "cxn raw_xml: {}", cxn_raw);
1326    }
1327
1328    /// DataModel round-trip:parse → to_xml → 关键字段仍可识别。
1329    #[test]
1330    fn data_model_round_trip() {
1331        let xml = sample_data_model_xml();
1332        let dm = DataModel::parse_from_xml(xml).expect("parse");
1333        let out = dm.to_xml();
1334        assert!(out.contains("<dgm:dataModel"), "out: {}", out);
1335        assert!(out.contains("<dgm:ptLst>"), "out: {}", out);
1336        assert!(out.contains("<dgm:cxnLst>"), "out: {}", out);
1337        // 节点文本应保留
1338        assert!(out.contains("根节点"), "out should contain text: {}", out);
1339        // modelId 应保留
1340        assert!(out.contains("modelId=\"0\""), "out: {}", out);
1341        assert!(out.contains("modelId=\"1\""), "out: {}", out);
1342    }
1343
1344    /// DataModel 解析空 XML(无 ptLst)应返回空结构而非 panic。
1345    #[test]
1346    fn data_model_parse_empty_no_panic() {
1347        let xml = "<?xml version=\"1.0\"?>\
1348                   <dgm:dataModel xmlns:dgm=\"http://schemas.openxmlformats.org/drawingml/2006/diagram\"/>";
1349        let dm = DataModel::parse_from_xml(xml).expect("parse empty");
1350        assert!(dm.points.is_empty());
1351        assert!(dm.connections.is_empty());
1352    }
1353
1354    /// DataModel 解析畸形 XML 应返回 Error::Xml。
1355    #[test]
1356    fn data_model_parse_malformed_returns_error() {
1357        // 注意:quick-xml 0.40 SAX 流式解析器对未闭合标签/裸属性值较宽容(不跟踪标签栈),
1358        // 需要用真正畸形的 XML 才能触发解析错误。
1359        // 未闭合注释(`<!--` 后必须有 `-->`)是 quick-xml 0.40 必报错的场景。
1360        let xml = "<dgm:dataModel xmlns:dgm=\"http://schemas.openxmlformats.org/drawingml/2006/diagram\"><!-- unclosed comment <dgm:pt modelId=\"1\"/></dgm:dataModel>";
1361        let result = DataModel::parse_from_xml(xml);
1362        assert!(result.is_err(), "畸形 XML 应返回错误,实际: {result:?}");
1363    }
1364
1365    /// 构造 layoutDef XML(含元数据 + layoutNode 子树)。
1366    fn sample_layout_def_xml() -> &'static str {
1367        "<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\n\
1368         <dgm:layoutDef xmlns:dgm=\"http://schemas.openxmlformats.org/drawingml/2006/diagram\" uniqueId=\"process1\">\
1369           <dgm:title val=\"Process\"/>\
1370           <dgm:desc val=\"A simple process layout\"/>\
1371           <dgm:catLst>\
1372             <dgm:cat type=\"process\" pri=\"1\"/>\
1373             <dgm:cat type=\"cycle\" pri=\"2\"/>\
1374           </dgm:catLst>\
1375           <dgm:layoutNode name=\"root\">\
1376             <dgm:alg type=\"lin\"/>\
1377             <dgm:forEach name=\"node\"/>\
1378           </dgm:layoutNode>\
1379         </dgm:layoutDef>"
1380    }
1381
1382    /// LayoutDef 解析:验证 uniqueId / title / desc / catLst / layoutNode。
1383    #[test]
1384    fn layout_def_parse_basic() {
1385        let xml = sample_layout_def_xml();
1386        let layout = LayoutDef::parse_from_xml(xml).expect("parse layoutDef");
1387
1388        assert_eq!(layout.unique_id.as_deref(), Some("process1"));
1389        assert_eq!(layout.title.as_deref(), Some("Process"));
1390        assert_eq!(layout.desc.as_deref(), Some("A simple process layout"));
1391        assert_eq!(layout.categories.len(), 2);
1392        assert_eq!(layout.categories[0].cat_type.as_deref(), Some("process"));
1393        assert_eq!(layout.categories[0].priority, Some(1));
1394        assert_eq!(layout.categories[1].cat_type.as_deref(), Some("cycle"));
1395        assert_eq!(layout.categories[1].priority, Some(2));
1396
1397        // layoutNode 子树应保留
1398        let ln = &layout.layout_node_xml;
1399        assert!(ln.contains("<dgm:layoutNode"), "layout_node_xml: {}", ln);
1400        assert!(ln.contains("name=\"root\""), "layout_node_xml: {}", ln);
1401        assert!(ln.contains("<dgm:alg"), "layout_node_xml: {}", ln);
1402    }
1403
1404    /// LayoutDef round-trip:parse → to_xml → 关键字段仍可识别。
1405    #[test]
1406    fn layout_def_round_trip() {
1407        let xml = sample_layout_def_xml();
1408        let layout = LayoutDef::parse_from_xml(xml).expect("parse");
1409        let out = layout.to_xml();
1410        assert!(out.contains("<dgm:layoutDef"), "out: {}", out);
1411        assert!(out.contains("uniqueId=\"process1\""), "out: {}", out);
1412        assert!(out.contains("val=\"Process\""), "out: {}", out);
1413        assert!(out.contains("<dgm:catLst>"), "out: {}", out);
1414        assert!(out.contains("type=\"process\""), "out: {}", out);
1415        // layoutNode 子树应保留
1416        assert!(out.contains("<dgm:layoutNode"), "out: {}", out);
1417        assert!(out.contains("name=\"root\""), "out: {}", out);
1418    }
1419
1420    /// 构造 quickStyle XML(含 2 个 styleLbl)。
1421    fn sample_quick_style_xml() -> &'static str {
1422        "<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\n\
1423         <dgm:styleData xmlns:dgm=\"http://schemas.openxmlformats.org/drawingml/2006/diagram\">\
1424           <dgm:styleLbl name=\"node0\">\
1425             <dgm:spPr/>\
1426           </dgm:styleLbl>\
1427           <dgm:styleLbl name=\"node1\">\
1428             <dgm:spPr/>\
1429           </dgm:styleLbl>\
1430           <dgm:extLst/>\
1431         </dgm:styleData>"
1432    }
1433
1434    /// QuickStyleDef 解析:验证 styleLbl 数量与 name。
1435    #[test]
1436    fn quick_style_parse_basic() {
1437        let xml = sample_quick_style_xml();
1438        let qs = QuickStyleDef::parse_from_xml(xml).expect("parse quickStyle");
1439
1440        assert_eq!(qs.style_labels.len(), 2, "should have 2 styleLbl");
1441        assert_eq!(qs.style_labels[0].name.as_deref(), Some("node0"));
1442        assert_eq!(qs.style_labels[1].name.as_deref(), Some("node1"));
1443        // raw_xml 应含 <dgm:styleLbl
1444        assert!(qs.style_labels[0].raw_xml.contains("<dgm:styleLbl"));
1445    }
1446
1447    /// QuickStyleDef round-trip:parse → to_xml → styleLbl 保留。
1448    #[test]
1449    fn quick_style_round_trip() {
1450        let xml = sample_quick_style_xml();
1451        let qs = QuickStyleDef::parse_from_xml(xml).expect("parse");
1452        let out = qs.to_xml();
1453        assert!(out.contains("<dgm:styleData"), "out: {}", out);
1454        assert!(out.contains("name=\"node0\""), "out: {}", out);
1455        assert!(out.contains("name=\"node1\""), "out: {}", out);
1456    }
1457
1458    /// 构造 colorsDef XML(含元数据 + styleClrData/styleLbl)。
1459    fn sample_colors_def_xml() -> &'static str {
1460        "<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\n\
1461         <dgm:colorsDef xmlns:dgm=\"http://schemas.openxmlformats.org/drawingml/2006/diagram\" uniqueId=\"color1\">\
1462           <dgm:title val=\"Primary Colors\"/>\
1463           <dgm:desc val=\"Theme color variants\"/>\
1464           <dgm:catLst><dgm:cat type=\"primary\" pri=\"1\"/></dgm:catLst>\
1465           <dgm:styleClrData>\
1466             <dgm:styleLbl name=\"node0\">\
1467               <a:effectClrLst xmlns:a=\"http://schemas.openxmlformats.org/drawingml/2006/main\"><a:schemeClr val=\"accent1\"/></a:effectClrLst>\
1468               <a:fillClrLst xmlns:a=\"http://schemas.openxmlformats.org/drawingml/2006/main\"><a:schemeClr val=\"accent1\"/></a:fillClrLst>\
1469             </dgm:styleLbl>\
1470           </dgm:styleClrData>\
1471         </dgm:colorsDef>"
1472    }
1473
1474    /// ColorsDef 解析:验证元数据 + styleClrLbl。
1475    #[test]
1476    fn colors_def_parse_basic() {
1477        let xml = sample_colors_def_xml();
1478        let colors = ColorsDef::parse_from_xml(xml).expect("parse colorsDef");
1479
1480        assert_eq!(colors.unique_id.as_deref(), Some("color1"));
1481        assert_eq!(colors.title.as_deref(), Some("Primary Colors"));
1482        assert_eq!(colors.desc.as_deref(), Some("Theme color variants"));
1483        assert_eq!(colors.style_color_labels.len(), 1);
1484        assert_eq!(colors.style_color_labels[0].name.as_deref(), Some("node0"));
1485        // raw_xml 应含 <dgm:styleLbl
1486        let raw = &colors.style_color_labels[0].raw_xml;
1487        assert!(raw.contains("<dgm:styleLbl"), "raw: {}", raw);
1488        assert!(
1489            raw.contains("accent1"),
1490            "raw should contain color ref: {}",
1491            raw
1492        );
1493    }
1494
1495    /// ColorsDef round-trip:parse → to_xml → 关键字段保留。
1496    #[test]
1497    fn colors_def_round_trip() {
1498        let xml = sample_colors_def_xml();
1499        let colors = ColorsDef::parse_from_xml(xml).expect("parse");
1500        let out = colors.to_xml();
1501        assert!(out.contains("<dgm:colorsDef"), "out: {}", out);
1502        assert!(out.contains("uniqueId=\"color1\""), "out: {}", out);
1503        assert!(out.contains("val=\"Primary Colors\""), "out: {}", out);
1504        assert!(out.contains("<dgm:styleClrData>"), "out: {}", out);
1505        assert!(out.contains("name=\"node0\""), "out: {}", out);
1506    }
1507
1508    /// local_name 辅助函数:去命名空间前缀。
1509    #[test]
1510    fn local_name_strips_prefix() {
1511        assert_eq!(local_name(b"dgm:pt"), b"pt");
1512        assert_eq!(local_name(b"pt"), b"pt");
1513        assert_eq!(local_name(b"a:t"), b"t");
1514    }
1515
1516    // ===================== 文本节点编辑 API 测试(TODO-037 剩余) =====================
1517
1518    /// `DataModelPoint::set_text` 同步更新 `text` 字段与 `raw_xml` 中的 `<a:t>` 内容。
1519    #[test]
1520    fn point_set_text_updates_both_fields() {
1521        let xml = sample_data_model_xml();
1522        let mut dm = DataModel::parse_from_xml(xml).expect("parse");
1523        // 修改 model_id=1 的节点文本
1524        let pt = dm.point_mut(1).expect("point 1 should exist");
1525        pt.set_text("新文本");
1526        // text 字段更新
1527        assert_eq!(pt.text.as_deref(), Some("新文本"));
1528        // raw_xml 中的 <a:t> 内容更新
1529        assert!(
1530            pt.raw_xml.contains("<a:t>新文本</a:t>"),
1531            "raw_xml: {}",
1532            pt.raw_xml
1533        );
1534        // 原始文本应被替换
1535        assert!(
1536            !pt.raw_xml.contains("根节点"),
1537            "old text should be replaced: {}",
1538            pt.raw_xml
1539        );
1540    }
1541
1542    /// `DataModelPoint::set_text` 对无 `<a:t>` 的虚拟根节点(type=doc)只更新 text 字段。
1543    #[test]
1544    fn point_set_text_on_doc_node_no_a_t() {
1545        let xml = sample_data_model_xml();
1546        let mut dm = DataModel::parse_from_xml(xml).expect("parse");
1547        let pt = dm.point_mut(0).expect("point 0 should exist");
1548        // 虚拟根节点无 <a:t>,set_text 仅更新 text 字段
1549        pt.set_text("doc文本");
1550        assert_eq!(pt.text.as_deref(), Some("doc文本"));
1551        // raw_xml 保持不变(无 <a:t> 可替换)
1552        assert!(
1553            !pt.raw_xml.contains("doc文本"),
1554            "raw_xml should not change: {}",
1555            pt.raw_xml
1556        );
1557    }
1558
1559    /// `DataModelPoint::clear_text` 清空 text 字段与 `<a:t>` 内容。
1560    #[test]
1561    fn point_clear_text_empties_both_fields() {
1562        let xml = sample_data_model_xml();
1563        let mut dm = DataModel::parse_from_xml(xml).expect("parse");
1564        let pt = dm.point_mut(1).expect("point 1 should exist");
1565        assert!(pt.text.is_some());
1566        pt.clear_text();
1567        // text 字段为 None
1568        assert!(pt.text.is_none());
1569        // raw_xml 中的 <a:t> 内容为空
1570        assert!(
1571            pt.raw_xml.contains("<a:t></a:t>"),
1572            "raw_xml: {}",
1573            pt.raw_xml
1574        );
1575        assert!(!pt.raw_xml.contains("根节点"), "raw_xml: {}", pt.raw_xml);
1576    }
1577
1578    /// `DataModelPoint::set_text` 自动 XML 转义特殊字符。
1579    #[test]
1580    fn point_set_text_escapes_xml_special_chars() {
1581        let xml = sample_data_model_xml();
1582        let mut dm = DataModel::parse_from_xml(xml).expect("parse");
1583        let pt = dm.point_mut(1).expect("point 1 should exist");
1584        pt.set_text("a<b>&c\"d'e");
1585        // raw_xml 中应包含转义后的实体引用
1586        assert!(
1587            pt.raw_xml.contains("a&lt;b&gt;&amp;c&quot;d&apos;e"),
1588            "raw_xml: {}",
1589            pt.raw_xml
1590        );
1591        // text 字段保留原始未转义文本
1592        assert_eq!(pt.text.as_deref(), Some("a<b>&c\"d'e"));
1593    }
1594
1595    /// `DataModel::set_point_text` 便捷方法:按 model_id 修改文本。
1596    #[test]
1597    fn data_model_set_point_text_by_id() {
1598        let xml = sample_data_model_xml();
1599        let mut dm = DataModel::parse_from_xml(xml).expect("parse");
1600        // 修改存在的节点
1601        assert!(dm.set_point_text(1, "节点1新文本"));
1602        assert_eq!(dm.point(1).unwrap().text.as_deref(), Some("节点1新文本"));
1603        // 修改不存在的节点返回 false
1604        assert!(!dm.set_point_text(999, "不存在"));
1605    }
1606
1607    /// `DataModel::to_xml` 结构化重建:用户新建节点(raw_xml 为空)+ text 字段非空。
1608    #[test]
1609    fn data_model_to_xml_structured_rebuild() {
1610        let mut dm = DataModel::default();
1611        // 用户新建一个有文本的节点
1612        dm.points.push(DataModelPoint {
1613            model_id: 1,
1614            pt_type: Some("par".to_string()),
1615            text: Some("新节点".to_string()),
1616            raw_xml: String::new(), // 空 raw_xml 触发结构化重建
1617        });
1618        let xml = dm.to_xml();
1619        // 应包含 <dgm:pt modelId="1" type="par">
1620        assert!(
1621            xml.contains(r#"<dgm:pt modelId="1" type="par">"#),
1622            "xml: {}",
1623            xml
1624        );
1625        // 应包含 <dgm:t> 子元素
1626        assert!(xml.contains("<dgm:t>"), "xml: {}", xml);
1627        // 应包含 <a:bodyPr/> / <a:lstStyle/>(OOXML 顺序约束)
1628        assert!(xml.contains("<a:bodyPr/>"), "xml: {}", xml);
1629        assert!(xml.contains("<a:lstStyle/>"), "xml: {}", xml);
1630        // 应包含 <a:t>新节点</a:t>
1631        assert!(xml.contains("<a:t>新节点</a:t>"), "xml: {}", xml);
1632    }
1633
1634    /// `DataModel::to_xml` 结构化重建:用户新建节点但无文本(自闭合 <dgm:pt/>)。
1635    #[test]
1636    fn data_model_to_xml_structured_rebuild_no_text() {
1637        let mut dm = DataModel::default();
1638        dm.points.push(DataModelPoint {
1639            model_id: 0,
1640            pt_type: Some("doc".to_string()),
1641            text: None,
1642            raw_xml: String::new(),
1643        });
1644        let xml = dm.to_xml();
1645        // 应自闭合 <dgm:pt modelId="0" type="doc"/>
1646        assert!(
1647            xml.contains(r#"<dgm:pt modelId="0" type="doc"/>"#),
1648            "xml: {}",
1649            xml
1650        );
1651        // 不应有 <dgm:t>
1652        assert!(
1653            !xml.contains("<dgm:t>"),
1654            "xml should not have dgm:t: {}",
1655            xml
1656        );
1657    }
1658
1659    /// `escape_xml_text` 转义所有 5 个特殊字符。
1660    #[test]
1661    fn escape_xml_text_all_special_chars() {
1662        assert_eq!(escape_xml_text("&"), "&amp;");
1663        assert_eq!(escape_xml_text("<"), "&lt;");
1664        assert_eq!(escape_xml_text(">"), "&gt;");
1665        assert_eq!(escape_xml_text("'"), "&apos;");
1666        assert_eq!(escape_xml_text("\""), "&quot;");
1667        // 混合
1668        assert_eq!(
1669            escape_xml_text("a&b<c>d'e\"f"),
1670            "a&amp;b&lt;c&gt;d&apos;e&quot;f"
1671        );
1672        // 无特殊字符
1673        assert_eq!(escape_xml_text("普通文本"), "普通文本");
1674    }
1675
1676    /// `DataModelPoint::is_type` 便捷查询方法。
1677    #[test]
1678    fn point_is_type_query() {
1679        let xml = sample_data_model_xml();
1680        let dm = DataModel::parse_from_xml(xml).expect("parse");
1681        assert!(dm.point(0).unwrap().is_type("doc"));
1682        assert!(dm.point(1).unwrap().is_type("par"));
1683        assert!(!dm.point(0).unwrap().is_type("par"));
1684    }
1685}