Skip to main content

pptx_rs/oxml/
shape.rs

1//! 形状 XML 模型:`p:sp` / `p:pic` / `p:grpSp` / `p:cxnSp` / `p:graphicFrame`。
2//!
3//! 包含 OOXML 中所有"可视对象"的强类型表达。每个类型都自带 `write_xml` 方法,
4//! 按规范要求的元素顺序输出。
5//!
6//! # 与 python-pptx 的对应
7//!
8//! - `pptx.shapes.shapetree.*` ←→ [`Sp`] / [`Pic`] / [`Group`] / [`Connector`];
9//! - `pptx.oxml.shape.*` ←→ 同名 oxml 模型;
10//! - `pptx.shapes.graphfrm.GraphicFrame` ←→ [`GraphicFrame`](承载表格/图表)。
11//!
12//! # 序列化约定
13//!
14//! - **非视觉属性隐藏**(`<p:nvSpPr>` 中的 cNvPr)必须最先输出;
15//! - **spPr**(几何/变换/填充/线)紧跟其后;
16//! - **txBody**(文本)最后输出;
17//! - **顺序错误**会导致 PowerPoint 报错 "Invalid OOXML"。
18
19use crate::oxml::sppr::ShapeProperties;
20use crate::oxml::txbody::TextBody;
21use crate::units::Emu;
22
23/// 形状锁定属性(`<a:spLocks>`)。
24///
25/// 对应 OOXML 中 `<p:cNvSpPr>` 内的 `<a:spLocks>` 元素。
26/// 所有属性均为布尔值,`true` 表示禁止对应操作。
27///
28/// 对标 python-pptx 中 `shape.locks` 的底层支撑(python-pptx 尚未暴露高阶 API,
29/// 但读写时保留这些属性)。
30#[derive(Clone, Debug, Default)]
31pub struct ShapeLocks {
32    /// 禁止组合(`noGrp="1"`)。
33    pub no_grp: bool,
34    /// 禁止进入组合(`noDrilldown="1"`)。
35    pub no_drilldown: bool,
36    /// 禁止选择(`noSelect="1"`)。
37    pub no_select: bool,
38    /// 禁止改变宽高比(`noChangeAspect="1"`)。
39    pub no_change_aspect: bool,
40    /// 禁止移动(`noMove="1"`)。
41    pub no_move: bool,
42    /// 禁止缩放(`noResize="1"`)。
43    pub no_resize: bool,
44    /// 禁止旋转(`noRot="1"`)。
45    pub no_rot: bool,
46    /// 禁止编辑顶点(`noEditPoints="1"`)。
47    pub no_edit_points: bool,
48    /// 禁止调整手柄(`noAdjustHandles="1"`)。
49    pub no_adjust_handles: bool,
50    /// 禁止修改箭头(`noChangeArrowheads="1"`)。
51    pub no_change_arrowheads: bool,
52    /// 禁止修改形状类型(`noChangeShapeType="1"`)。
53    pub no_change_shape_type: bool,
54    /// 禁止裁剪(`noCrop="1"`)。
55    pub no_crop: bool,
56}
57
58impl ShapeLocks {
59    /// 是否所有属性都为 false(即无任何锁定)。
60    pub fn is_empty(&self) -> bool {
61        !self.no_grp
62            && !self.no_drilldown
63            && !self.no_select
64            && !self.no_change_aspect
65            && !self.no_move
66            && !self.no_resize
67            && !self.no_rot
68            && !self.no_edit_points
69            && !self.no_adjust_handles
70            && !self.no_change_arrowheads
71            && !self.no_change_shape_type
72            && !self.no_crop
73    }
74
75    /// 写出 `<a:spLocks .../>` 自闭合标签。
76    ///
77    /// 若所有属性均为 false,则**不**写出(调用方应先调用 [`Self::is_empty`] 判断)。
78    pub fn write_xml(&self, w: &mut super::writer::XmlWriter) {
79        let mut attrs: Vec<(&str, &str)> = Vec::new();
80        if self.no_grp {
81            attrs.push(("noGrp", "1"));
82        }
83        if self.no_drilldown {
84            attrs.push(("noDrilldown", "1"));
85        }
86        if self.no_select {
87            attrs.push(("noSelect", "1"));
88        }
89        if self.no_change_aspect {
90            attrs.push(("noChangeAspect", "1"));
91        }
92        if self.no_move {
93            attrs.push(("noMove", "1"));
94        }
95        if self.no_resize {
96            attrs.push(("noResize", "1"));
97        }
98        if self.no_rot {
99            attrs.push(("noRot", "1"));
100        }
101        if self.no_edit_points {
102            attrs.push(("noEditPoints", "1"));
103        }
104        if self.no_adjust_handles {
105            attrs.push(("noAdjustHandles", "1"));
106        }
107        if self.no_change_arrowheads {
108            attrs.push(("noChangeArrowheads", "1"));
109        }
110        if self.no_change_shape_type {
111            attrs.push(("noChangeShapeType", "1"));
112        }
113        if self.no_crop {
114            attrs.push(("noCrop", "1"));
115        }
116        w.empty_with("a:spLocks", &attrs);
117    }
118
119    /// 统一设置指定类型的锁定(TODO-027 高阶 API)。
120    ///
121    /// 对标 python-pptx 风格 `shape.set_lock(LockType::Select, true)`。
122    /// 此方法是所有 `no_*` 字段写入器的统一入口,避免调用方记忆 12 个字段名。
123    ///
124    /// # 参数
125    /// - `lock_type`:锁定类型(见 [`LockType`] 枚举);
126    /// - `locked`:`true` 启用该锁定,`false` 解除该锁定。
127    ///
128    /// # 示例
129    ///
130    /// ```no_run
131    /// # use pptx_rs::oxml::shape::{ShapeLocks, LockType};
132    /// let mut l = ShapeLocks::default();
133    /// l.set_lock(LockType::Select, true);
134    /// l.set_lock(LockType::Rotate, true);
135    /// assert!(l.no_select);
136    /// assert!(l.no_rot);
137    /// ```
138    pub fn set_lock(&mut self, lock_type: LockType, locked: bool) {
139        match lock_type {
140            LockType::Grouping => self.no_grp = locked,
141            LockType::Drilldown => self.no_drilldown = locked,
142            LockType::Select => self.no_select = locked,
143            LockType::ChangeAspect => self.no_change_aspect = locked,
144            LockType::Move => self.no_move = locked,
145            LockType::Resize => self.no_resize = locked,
146            LockType::Rotate => self.no_rot = locked,
147            LockType::EditPoints => self.no_edit_points = locked,
148            LockType::AdjustHandles => self.no_adjust_handles = locked,
149            LockType::ChangeArrowheads => self.no_change_arrowheads = locked,
150            LockType::ChangeShapeType => self.no_change_shape_type = locked,
151            LockType::Crop => self.no_crop = locked,
152        }
153    }
154
155    /// 读取指定类型的锁定状态。详见 [`ShapeLocks::set_lock`]。
156    pub fn get_lock(&self, lock_type: LockType) -> bool {
157        match lock_type {
158            LockType::Grouping => self.no_grp,
159            LockType::Drilldown => self.no_drilldown,
160            LockType::Select => self.no_select,
161            LockType::ChangeAspect => self.no_change_aspect,
162            LockType::Move => self.no_move,
163            LockType::Resize => self.no_resize,
164            LockType::Rotate => self.no_rot,
165            LockType::EditPoints => self.no_edit_points,
166            LockType::AdjustHandles => self.no_adjust_handles,
167            LockType::ChangeArrowheads => self.no_change_arrowheads,
168            LockType::ChangeShapeType => self.no_change_shape_type,
169            LockType::Crop => self.no_crop,
170        }
171    }
172}
173
174/// 形状锁定类型枚举(对应 `<a:spLocks>` 的 12 个属性)。
175///
176/// 用于 [`ShapeLocks::set_lock`] / [`ShapeLocks::get_lock`] 高阶 API,
177/// 避免调用方直接操作 12 个 `no_*` 字段。对标 python-pptx 的
178/// `MSO_SHAPE_LOCK_TYPE` 枚举。
179#[derive(Copy, Clone, Debug, Eq, PartialEq, Hash)]
180pub enum LockType {
181    /// 禁止组合(`noGrp`)。
182    Grouping,
183    /// 禁止进入组合(`noDrilldown`)。
184    Drilldown,
185    /// 禁止选择(`noSelect`)。
186    Select,
187    /// 禁止改变宽高比(`noChangeAspect`)。
188    ChangeAspect,
189    /// 禁止移动(`noMove`)。
190    Move,
191    /// 禁止缩放(`noResize`)。
192    Resize,
193    /// 禁止旋转(`noRot`)。
194    Rotate,
195    /// 禁止编辑顶点(`noEditPoints`)。
196    EditPoints,
197    /// 禁止调整手柄(`noAdjustHandles`)。
198    AdjustHandles,
199    /// 禁止修改箭头(`noChangeArrowheads`)。
200    ChangeArrowheads,
201    /// 禁止修改形状类型(`noChangeShapeType`)。
202    ChangeShapeType,
203    /// 禁止裁剪(`noCrop`)。
204    Crop,
205}
206
207/// `<p:sp>` 普通形状(如文本框、矩形、椭圆等)。
208#[derive(Clone, Debug, Default)]
209pub struct Sp {
210    /// 形状 ID(同一 slide 内唯一)。
211    pub id: u32,
212    /// 形状名(仅显示用)。
213    pub name: String,
214    /// 是否为占位符。
215    pub is_placeholder: bool,
216    /// 占位符 idx(placeholder idx,仅当 `is_placeholder`)。
217    pub ph_idx: Option<u32>,
218    /// 占位符类型(`title` / `body` / ...)。
219    ///
220    /// 完整取值见 `PP_PLACEHOLDER_TYPE` 枚举:
221    /// - `title` / `ctrTitle` / `subTitle`
222    /// - `body` / `ftr` / `dt` / `sldNum` / `hdr`
223    /// - `tbl` / `chart` / `pic` / `sldImg` / `media`
224    /// - `obj` / `vertAlign` 等等
225    ///
226    /// 当 `is_placeholder=true` 但 `ph_type=None` 时,写 XML 时**默认**按 `body` 写出。
227    pub ph_type: Option<String>,
228    /// 形状属性(xfrm / prstGeom / 填充 / 边框)。
229    pub properties: ShapeProperties,
230    /// 主题样式引用(`p:style`,可选)。
231    pub style: Option<ShapeStyle>,
232    /// 文本体(`p:txBody`)。
233    pub text: TextBody,
234    /// 扩展列表(`p:extLst`),用于承载 PowerPoint 私有扩展。
235    pub ext_lst: Option<ExtensionList>,
236    /// 标记该 sp 为**纯文本框**(对应 `<p:cNvSpPr txBox="1"/>`)。
237    ///
238    /// python-pptx 中 `shapes.add_textbox(...)` 创建的就是带这个标志的 `p:sp`。
239    /// 该标志让 PowerPoint 把它识别为"自由文本框"而非"自选图形 + 文本",从而
240    /// 走**非自动布局**(不套用母版占位符约束)。
241    pub c_nv_sp_pr_tx_box: bool,
242    /// 形状锁定属性(`<a:spLocks>`,可选)。
243    ///
244    /// `None` 表示不写出 `<a:spLocks>` 元素。
245    /// `Some(ShapeLocks { .. })` 且 `is_empty() == false` 时写出。
246    pub locks: Option<ShapeLocks>,
247    // 非视觉属性(隐藏、跳转等)——目前不实现。
248}
249
250impl Sp {
251    /// 写 XML。
252    ///
253    /// 按 OOXML `CT_Shape` 的子元素顺序:
254    ///
255    /// ```text
256    /// <p:sp>
257    ///   <p:nvSpPr>...</p:nvSpPr>
258    ///   <p:spPr>...</p:spPr>            ← spPr 必填
259    ///   <p:style>...</p:style>          ← 可选:主题样式引用
260    ///   <p:txBody>...</p:txBody>        ← 可选:文本体
261    ///   <p:extLst>...</p:extLst>        ← 可选:扩展
262    /// </p:sp>
263    /// ```
264    pub fn write_xml(&self, w: &mut super::writer::XmlWriter) {
265        w.open("p:sp");
266        // nvSpPr
267        w.open("p:nvSpPr");
268        let id_s = self.id.to_string();
269        w.empty_with(
270            "p:cNvPr",
271            &[("id", id_s.as_str()), ("name", self.name.as_str())],
272        );
273        // 提前取出 idx 字符串,扩展到 if 块外
274        let idx_s = self.ph_idx.map(|v| v.to_string());
275        if self.is_placeholder {
276            let mut pattrs: Vec<(&str, &str)> = Vec::new();
277            // ph_type 缺省回落到 `body`——这是 OOXML 规范的默认 placeholder 类型,
278            // 也是 PowerPoint 在用户新增占位符时实际写入的值。
279            // 显式 None 会让 PowerPoint 弹"无法识别的占位符"警告。
280            let type_str: &str = self.ph_type.as_deref().unwrap_or("body");
281            pattrs.push(("type", type_str));
282            if let Some(s) = &idx_s {
283                pattrs.push(("idx", s.as_str()));
284            }
285            w.empty_with("p:ph", &pattrs);
286        }
287        // 纯文本框:写 `txBox="1"` 让 PowerPoint 识别为 textbox(不应用母版占位符)。
288        // 若有形状锁定(spLocks),则 cNvSpPr 需要写成 open-close 形式以容纳子元素。
289        let has_locks = self.locks.as_ref().map(|l| !l.is_empty()).unwrap_or(false);
290        if has_locks {
291            // 有 spLocks 子元素:写成 open-close
292            if self.c_nv_sp_pr_tx_box {
293                w.open_with("p:cNvSpPr", &[("txBox", "1")]);
294            } else {
295                w.open("p:cNvSpPr");
296            }
297            self.locks.as_ref().unwrap().write_xml(w);
298            w.close("p:cNvSpPr");
299        } else if self.c_nv_sp_pr_tx_box {
300            w.empty_with("p:cNvSpPr", &[("txBox", "1")]);
301        } else {
302            w.empty_with("p:cNvSpPr", &[]);
303        }
304        w.empty("p:nvPr");
305        w.close("p:nvSpPr");
306        // spPr
307        self.properties.write_xml(w, "p:spPr");
308        // p:style(暂未填充具体内容;保留位置供后续主题样式引用)
309        if let Some(style) = &self.style {
310            style.write_xml(w);
311        }
312        // txBody
313        self.text.write_xml(w, "p:txBody");
314        // extLst
315        write_extlst(w, self.ext_lst.as_ref());
316        w.close("p:sp");
317    }
318}
319
320/// `<p:pic>` 图片。
321#[derive(Clone, Debug, Default)]
322pub struct Pic {
323    pub id: u32,
324    pub name: String,
325    pub rid: String, // r:embed 关系 id
326    /// 是否为占位符(TODO-007)。
327    ///
328    /// `true` 时 `write_xml` 会在 `<p:nvPicPr>/<p:nvPr>` 内写出 `<p:ph .../>`。
329    /// 图片占位符对应版式中的 `<p:ph type="pic"/>`,用户点击后弹出"插入图片"对话框。
330    pub is_placeholder: bool,
331    /// 占位符 idx(`<p:ph idx="..."/>`,仅当 `is_placeholder` 为 true 时有效)。
332    pub ph_idx: Option<u32>,
333    /// 占位符类型(`<p:ph type="..."/>`)。
334    ///
335    /// 图片占位符通常为 `"pic"`;`None` 时 `write_xml` 不写出 `type` 属性。
336    pub ph_type: Option<String>,
337    pub properties: ShapeProperties,
338    /// 图片裁剪。
339    pub src_rect: Option<(i32, i32, i32, i32)>, // l%, t%, r%, b%  (100000=100%)
340    /// 填充模式(拉伸/平铺/无)。
341    pub fill_mode: crate::oxml::sppr::BlipFillMode,
342    /// 图片透明度(0-100000,30000 = 30% 不透明 / 70% 透明)。
343    ///
344    /// 对应 `<a:blip>` 内的 `<a:alphaModFix amt="..."/>` 元素。
345    /// 常用于图片水印场景:`alpha = Some(30_000)` 表示 30% 不透明。
346    pub alpha: Option<i32>,
347    /// 主题样式引用(`p:style`,可选)。
348    pub style: Option<ShapeStyle>,
349    /// 扩展列表。
350    pub ext_lst: Option<ExtensionList>,
351    /// 媒体引用(TODO-033 音视频嵌入)。
352    ///
353    /// `None` 表示普通图片;`Some(MediaKind::Video { rid })` / `Some(MediaKind::Audio { rid })`
354    /// 表示该 `<p:pic>` 实际是视频/音频形状,`write_xml` 会在 `<p:nvPr>` 内写出
355    /// `<a:videoFile r:link="..."/>` 或 `<a:audioFile r:link="..."/>`。
356    /// `rid` 指向媒体 part 的关系 id(与 `self.rid` 指向海报帧图片不同)。
357    pub media: Option<MediaKind>,
358}
359
360/// 媒体类型(TODO-033 音视频嵌入)。
361///
362/// OOXML 中视频/音频形状实际上是带 `<a:videoFile>` / `<a:audioFile>` 的 `<p:pic>`,
363/// 用海报帧图片(`<a:blip r:embed="..."/>`)作为视觉占位。
364#[derive(Clone, Debug, PartialEq, Eq)]
365pub enum MediaKind {
366    /// 视频(`<a:videoFile r:link="..."/>`)。
367    Video {
368        /// 媒体 part 的关系 id(指向 `/ppt/media/mediaN.mp4`)。
369        rid: String,
370    },
371    /// 音频(`<a:audioFile r:link="..."/>`)。
372    Audio {
373        /// 媒体 part 的关系 id(指向 `/ppt/media/mediaN.mp3`)。
374        rid: String,
375    },
376}
377
378impl Default for MediaKind {
379    fn default() -> Self {
380        // 默认视频空 rid(避免 Option 双层包装)
381        MediaKind::Video { rid: String::new() }
382    }
383}
384
385impl Pic {
386    /// 写 XML。
387    ///
388    /// 按 OOXML `CT_Picture` 的子元素顺序:
389    ///
390    /// ```text
391    /// <p:pic>
392    ///   <p:nvPicPr>...</p:nvPicPr>
393    ///   <p:blipFill>...</p:blipFill>
394    ///   <p:spPr>...</p:spPr>
395    ///   <p:style>...</p:style>          ← 可选
396    ///   <p:extLst>...</p:extLst>        ← 可选
397    /// </p:pic>
398    /// ```
399    pub fn write_xml(&self, w: &mut super::writer::XmlWriter) {
400        w.open("p:pic");
401        w.open("p:nvPicPr");
402        let id_s = self.id.to_string();
403        w.empty_with("p:cNvPr", &[("id", &id_s), ("name", &self.name)]);
404        w.empty("p:cNvPicPr");
405        // nvPr:若 is_placeholder,写出 <p:ph type="..." idx="..."/>(TODO-007)。
406        // 若有 media(TODO-033),写出 <a:videoFile r:link="..."/> 或 <a:audioFile r:link="..."/>。
407        // OOXML 顺序:<p:cNvPr> → <p:cNvPicPr> → <p:nvPr>(含 <p:ph> / <a:videoFile> 等)。
408        if self.is_placeholder || self.media.is_some() {
409            w.open_with("p:nvPr", &[("xmlns:r", crate::oxml::ns::NS_DRAWING_RELS)]);
410            if self.is_placeholder {
411                // idx_s 提到 pattrs 之前,保证生命周期覆盖 empty_with 调用
412                let idx_s = self.ph_idx.map(|i| i.to_string());
413                let mut pattrs: Vec<(&str, &str)> = Vec::new();
414                if let Some(t) = self.ph_type.as_deref() {
415                    pattrs.push(("type", t));
416                }
417                if let Some(s) = idx_s.as_deref() {
418                    pattrs.push(("idx", s));
419                }
420                w.empty_with("p:ph", &pattrs);
421            }
422            // 媒体引用(TODO-033)
423            if let Some(media) = &self.media {
424                match media {
425                    MediaKind::Video { rid } => {
426                        w.empty_with("a:videoFile", &[("r:link", rid.as_str())]);
427                    }
428                    MediaKind::Audio { rid } => {
429                        w.empty_with("a:audioFile", &[("r:link", rid.as_str())]);
430                    }
431                }
432            }
433            w.close("p:nvPr");
434        } else {
435            w.empty("p:nvPr");
436        }
437        w.close("p:nvPicPr");
438        // blipFill:开始标签 + 子元素 + 结束标签
439        // 子元素是 `<a:blip r:embed="rIdN"/>`(自闭合)和可选的 `<a:stretch>` / `<a:srcRect>`。
440        // `xmlns:r` 放在最外层 blipFill 上以声明 r: 前缀。
441        w.open_with(
442            "p:blipFill",
443            &[("xmlns:r", crate::oxml::ns::NS_DRAWING_RELS)],
444        );
445        // a:blip:若有 alpha 透明度,需要开标签 + 子元素 + 关标签
446        if let Some(alpha_val) = self.alpha {
447            w.open_with("a:blip", &[("r:embed", self.rid.as_str())]);
448            let amt_s = alpha_val.to_string();
449            w.empty_with("a:alphaModFix", &[("amt", amt_s.as_str())]);
450            w.close("a:blip");
451        } else {
452            w.empty_with("a:blip", &[("r:embed", self.rid.as_str())]);
453        }
454        // srcRect 用于裁剪:l/t/r/b 均为 0..=100000 的千分比(100000=100%)。
455        if let Some((l, t, r, b)) = self.src_rect {
456            let l_s = l.to_string();
457            let t_s = t.to_string();
458            let r_s = r.to_string();
459            let b_s = b.to_string();
460            w.empty_with(
461                "a:srcRect",
462                &[("l", &l_s), ("t", &t_s), ("r", &r_s), ("b", &b_s)],
463            );
464        }
465        // 写出填充模式(拉伸/平铺/无)
466        self.fill_mode.write_xml(w);
467        w.close("p:blipFill");
468        // spPr
469        self.properties.write_xml(w, "p:spPr");
470        // p:style
471        if let Some(style) = &self.style {
472            style.write_xml(w);
473        }
474        // extLst
475        write_extlst(w, self.ext_lst.as_ref());
476        w.close("p:pic");
477    }
478}
479
480/// `<p:grpSp>` 组合形状。
481#[derive(Clone, Debug, Default)]
482pub struct Group {
483    pub id: u32,
484    pub name: String,
485    pub properties: ShapeProperties,
486    /// 子形状(递归)。
487    pub children: Vec<GroupChild>,
488    /// 组合自己的大小(写在 a:xfrm 上)。
489    pub ext: (Emu, Emu),
490    pub off: (Emu, Emu),
491    /// 主题样式引用(`p:style`,可选)。
492    pub style: Option<ShapeStyle>,
493    /// 扩展列表。
494    pub ext_lst: Option<ExtensionList>,
495}
496
497#[derive(Clone, Debug)]
498pub enum GroupChild {
499    Sp(Sp),
500    Pic(Pic),
501    CxnSp(Connector),
502    Group(Box<Group>),
503    /// 图形框(承载表格/图表等)。
504    GraphicFrame(GraphicFrame),
505}
506
507impl Group {
508    /// 写 XML。
509    ///
510    /// 按 OOXML `CT_GroupShape` 的子元素顺序:
511    ///
512    /// ```text
513    /// <p:grpSp>
514    ///   <p:nvGrpSpPr>...</p:nvGrpSpPr>
515    ///   <p:grpSpPr>...</p:grpSpPr>
516    ///   子形状 (sp/pic/cxnSp/grpSp/graphicFrame/contentPart)
517    ///   <p:extLst>...</p:extLst>        ← 可选
518    /// </p:grpSp>
519    /// ```
520    pub fn write_xml(&self, w: &mut super::writer::XmlWriter) {
521        w.open("p:grpSp");
522        w.open("p:nvGrpSpPr");
523        let id_s = self.id.to_string();
524        w.empty_with("p:cNvPr", &[("id", &id_s), ("name", &self.name)]);
525        w.empty("p:cNvGrpSpPr");
526        w.empty("p:nvPr");
527        w.close("p:nvGrpSpPr");
528        // 组合本身 spPr 中有 chExt / chOff
529        w.open("p:grpSpPr");
530        w.open("a:xfrm");
531        let off_x_s = self.off.0.value().to_string();
532        let off_y_s = self.off.1.value().to_string();
533        let ext_cx_s = self.ext.0.value().to_string();
534        let ext_cy_s = self.ext.1.value().to_string();
535        w.empty_with("a:off", &[("x", &off_x_s), ("y", &off_y_s)]);
536        w.empty_with("a:ext", &[("cx", &ext_cx_s), ("cy", &ext_cy_s)]);
537        w.empty_with("a:chOff", &[("x", "0"), ("y", "0")]);
538        w.empty_with("a:chExt", &[("cx", &ext_cx_s), ("cy", &ext_cy_s)]);
539        w.close("a:xfrm");
540        w.close("p:grpSpPr");
541        for c in &self.children {
542            match c {
543                GroupChild::Sp(s) => s.write_xml(w),
544                GroupChild::Pic(p) => p.write_xml(w),
545                GroupChild::CxnSp(c) => c.write_xml(w),
546                GroupChild::Group(g) => g.write_xml(w),
547                GroupChild::GraphicFrame(g) => g.write_xml(w),
548            }
549        }
550        // p:style
551        if let Some(style) = &self.style {
552            style.write_xml(w);
553        }
554        // extLst(组合末尾)
555        write_extlst(w, self.ext_lst.as_ref());
556        w.close("p:grpSp");
557    }
558}
559
560/// `<p:cxnSp>` 连接器。
561#[derive(Clone, Debug, Default)]
562pub struct Connector {
563    pub id: u32,
564    pub name: String,
565    pub properties: ShapeProperties,
566    /// 起点几何坐标(EMU,绝对值,相对于 slide 原点)。
567    /// 仅在 `begin_type == None` 时由 OOXML 渲染。
568    pub begin: Option<(Emu, Emu)>,
569    /// 终点几何坐标(EMU)。
570    pub end: Option<(Emu, Emu)>,
571    /// 起点挂接(`stCxn`)——`Some((shape_id, idx))` 表示挂接到某 shape 的连接点。
572    pub st_cxn: Option<(u32, u32)>,
573    /// 终点挂接(`endCxn`)。
574    pub end_cxn: Option<(u32, u32)>,
575    /// 显式几何类型(直线 / 折线 / 曲线)。`None` 则用 `properties.xfrm.prst_geom`。
576    pub connector_type: Option<crate::oxml::simpletypes::MsoConnectorType>,
577    /// 主题样式引用(`p:style`,可选)。
578    pub style: Option<ShapeStyle>,
579    /// 扩展列表。
580    pub ext_lst: Option<ExtensionList>,
581}
582
583impl Connector {
584    /// 写 XML。
585    ///
586    /// 按 OOXML `CT_Connector` 的子元素顺序:
587    ///
588    /// ```text
589    /// <p:cxnSp>
590    ///   <p:nvCxnSpPr>...</p:nvCxnSpPr>
591    ///   <p:spPr>...</p:spPr>
592    ///   <p:style>...</p:style>          ← 可选
593    ///   <p:extLst>...</p:extLst>        ← 可选
594    /// </p:cxnSp>
595    /// ```
596    pub fn write_xml(&self, w: &mut super::writer::XmlWriter) {
597        w.open("p:cxnSp");
598        w.open("p:nvCxnSpPr");
599        let id_s = self.id.to_string();
600        w.empty_with("p:cNvPr", &[("id", &id_s), ("name", &self.name)]);
601        w.empty("p:cNvCxnSpPr");
602        w.empty("p:nvPr");
603        if let Some((shape_id, idx)) = &self.st_cxn {
604            let sid = shape_id.to_string();
605            let idxs = idx.to_string();
606            w.empty_with("p:stCxn", &[("id", &sid), ("idx", &idxs)]);
607        }
608        if let Some((shape_id, idx)) = &self.end_cxn {
609            let sid = shape_id.to_string();
610            let idxs = idx.to_string();
611            w.empty_with("p:endCxn", &[("id", &sid), ("idx", &idxs)]);
612        }
613        w.close("p:nvCxnSpPr");
614        self.properties.write_xml(w, "p:spPr");
615        // p:style
616        if let Some(style) = &self.style {
617            style.write_xml(w);
618        }
619        // extLst
620        write_extlst(w, self.ext_lst.as_ref());
621        w.close("p:cxnSp");
622    }
623}
624
625/// `<p:graphicFrame>` 容器:表格、图表等使用。
626#[derive(Clone, Debug, Default)]
627pub struct GraphicFrame {
628    /// 图形框 ID(同一 slide 内唯一)。
629    pub id: u32,
630    /// 图形框名(仅显示用)。
631    pub name: String,
632    /// 形状属性(仅 xfrm 有效,不含 prstGeom/填充/边框)。
633    pub properties: ShapeProperties,
634    /// 内部图形。
635    pub graphic: Graphic,
636    /// 扩展列表。
637    pub ext_lst: Option<ExtensionList>,
638    /// **是否**为占位符(TODO-007 图表/表格占位符填充)。
639    ///
640    /// 为 `true` 时 `write_xml` 会在 `<p:nvPr>` 内写出 `<p:ph .../>`。
641    /// 对应 OOXML 中 `<p:nvGraphicFramePr>/<p:nvPr>/<p:ph type="..." idx="..."/>`。
642    pub is_placeholder: bool,
643    /// 占位符索引(对应 `<p:ph idx="..."/>`)。
644    pub ph_idx: Option<u32>,
645    /// 占位符类型(对应 `<p:ph type="..."/>`,如 `"chart"` / `"tbl"` / `"obj"`)。
646    pub ph_type: Option<String>,
647}
648
649#[derive(Clone, Debug)]
650pub enum Graphic {
651    Table(super::table::Table),
652    /// 图表(`<c:chart>` 引用 chart part)。TODO-004 基础图表支持。
653    ///
654    /// 与 Table 不同,Chart 数据**不**直接嵌在 `<a:graphicData>` 中,
655    /// 而是通过 `r:id="..."` 引用独立的 chartN.xml part。
656    /// 序列化时只写出 `<c:chart xmlns:c=... xmlns:r=... r:id="..."/>` 自闭合元素。
657    Chart(super::chart::Chart),
658    /// OLE 对象嵌入(`<p:oleObj>` 引用 oleObjectN.bin)。TODO-043。
659    ///
660    /// 与 Chart 类似,OLE 数据**不**直接嵌在 `<a:graphicData>` 中,
661    /// 而是通过 `r:id="..."` 引用独立的 `/ppt/embeddings/oleObjectN.bin` part。
662    /// 序列化时写出 `<p:oleObj ...>` 完整元素(含 `<p:embed/>` 与可选 `<p:pic>` 图标)。
663    OleObject(super::ole::OleObject),
664    /// SmartArt 图形(`<dgm:relIds>` 引用 4 个 diagram parts)。TODO-037 最小保留。
665    ///
666    /// SmartArt 是 PowerPoint 的"智能图形"(流程图/层次结构/循环/矩阵等),
667    /// 由 4 个独立 part 组成:
668    /// - `diagramDataN.xml`(数据模型,r:dm);
669    /// - `diagramLayoutN.xml`(布局定义,r:lo);
670    /// - `diagramQuickStyleN.xml`(样式,r:qs);
671    /// - `diagramColorsN.xml`(颜色,r:cs)。
672    ///
673    /// **当前实现(最小保留)**:
674    /// - 读路径:识别 `<a:graphicData uri=".../diagram">`,保留完整 XML(含 graphicData 外壳);
675    /// - 写路径:按原样输出 `raw_xml`,保证 slide XML 中的 SmartArt 引用 byte-exact 保留;
676    /// - **限制**:不解析也不保留 4 个 diagram parts,read→save 后 SmartArt 会因为
677    ///   diagram parts 丢失而无法渲染。完整 round-trip 需要 OPC 层保留未识别关系,
678    ///   计划在 0.2.x 实现。
679    SmartArt(SmartArtRef),
680}
681
682/// SmartArt 引用(TODO-037 最小保留)。
683///
684/// 持有完整的 `<a:graphicData uri=".../diagram">...</a:graphicData>` 元素 XML,
685/// 用于 read→save 时 byte-exact 保留 SmartArt 在 slide XML 中的引用。
686///
687/// # 字段说明
688///
689/// - `raw_xml`:完整的 `<a:graphicData>` 元素 XML(**含** `<a:graphicData>` 外壳),
690///   通常形如 `<a:graphicData uri=".../diagram"><dgm:relIds r:dm="rId1" r:lo="rId2" r:qs="rId3" r:cs="rId4"/></a:graphicData>`。
691///   序列化时由 `GraphicFrame::write_xml` 直接 raw 输出,**跳过** open_with/close 流程。
692/// - `dm_rid` / `lo_rid` / `qs_rid` / `cs_rid`:从 raw_xml 中提取的 4 个关系 id,
693///   仅供调用方查询使用,序列化时**不**单独输出(直接走 raw_xml)。
694#[derive(Clone, Debug, Default)]
695pub struct SmartArtRef {
696    /// 完整的 `<a:graphicData>` 元素 XML(byte-exact 保留,含外壳)。
697    pub raw_xml: String,
698    /// 数据模型关系 id(`r:dm`,指向 `/ppt/diagrams/diagramDataN.xml`)。
699    pub dm_rid: Option<String>,
700    /// 布局定义关系 id(`r:lo`,指向 `/ppt/diagrams/diagramLayoutN.xml`)。
701    pub lo_rid: Option<String>,
702    /// 样式关系 id(`r:qs`,指向 `/ppt/diagrams/diagramQuickStyleN.xml`)。
703    pub qs_rid: Option<String>,
704    /// 颜色关系 id(`r:cs`,指向 `/ppt/diagrams/diagramColorsN.xml`)。
705    pub cs_rid: Option<String>,
706}
707
708impl SmartArtRef {
709    /// 从 4 个关系 id 构造 SmartArtRef(用于创建路径,TODO-037 创建 API)。
710    ///
711    /// 用 [`XmlWriter`](crate::oxml::writer::XmlWriter) 链式 API 构造 `raw_xml`,
712    /// **不**用 `format!` 字符串拼接(遵守 `.trae/rules/project_rules.md` §5 安全红线)。
713    ///
714    /// # 参数
715    /// - `dm_rid` / `lo_rid` / `qs_rid` / `cs_rid`:4 个关系 id,分别指向
716    ///   data / layout / quickStyle / colors 四个 diagram part。
717    ///
718    /// # 生成内容
719    ///
720    /// ```xml
721    /// <a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/diagram">
722    ///   <dgm:relIds xmlns:dgm="..." xmlns:r="..."
723    ///               r:dm="..." r:lo="..." r:qs="..." r:cs="..."/>
724    /// </a:graphicData>
725    /// ```
726    ///
727    /// # 示例
728    ///
729    /// ```no_run
730    /// use pptx_rs::oxml::shape::SmartArtRef;
731    /// let r = SmartArtRef::from_rids("rId1", "rId2", "rId3", "rId4");
732    /// assert!(r.raw_xml.contains("r:dm=\"rId1\""));
733    /// ```
734    pub fn from_rids(dm_rid: &str, lo_rid: &str, qs_rid: &str, cs_rid: &str) -> Self {
735        let mut w = super::writer::XmlWriter::new();
736        w.open_with(
737            "a:graphicData",
738            &[(
739                "uri",
740                "http://schemas.openxmlformats.org/drawingml/2006/diagram",
741            )],
742        );
743        w.empty_with(
744            "dgm:relIds",
745            &[
746                ("xmlns:dgm", crate::oxml::ns::NS_DIAGRAM),
747                ("xmlns:r", crate::oxml::ns::NS_DRAWING_RELS),
748                ("r:dm", dm_rid),
749                ("r:lo", lo_rid),
750                ("r:qs", qs_rid),
751                ("r:cs", cs_rid),
752            ],
753        );
754        w.close("a:graphicData");
755        Self {
756            raw_xml: w.into_string(),
757            dm_rid: Some(dm_rid.to_string()),
758            lo_rid: Some(lo_rid.to_string()),
759            qs_rid: Some(qs_rid.to_string()),
760            cs_rid: Some(cs_rid.to_string()),
761        }
762    }
763}
764
765// 由于 `#[default]` 仅支持单元变体,Graphic 需手动实现 Default。
766impl Default for Graphic {
767    fn default() -> Self {
768        Graphic::Table(super::table::Table::default())
769    }
770}
771
772impl GraphicFrame {
773    /// 写 XML。
774    ///
775    /// 按 OOXML `CT_GraphicalObjectFrame` 的子元素顺序:
776    ///
777    /// ```text
778    /// <p:graphicFrame>
779    ///   <p:nvGraphicFramePr>...</p:nvGraphicFramePr>
780    ///   <p:xfrm>...</p:xfrm>            ← 用 ShapeProperties::write_xfrm_only
781    ///   <a:graphic>
782    ///     <a:graphicData uri="...">     ← uri 必填
783    ///       <a:tbl>...</a:tbl>          ← 或 chart / smartArt
784    ///     </a:graphicData>
785    ///   </a:graphic>
786    ///   <p:extLst>...</p:extLst>        ← 可选
787    /// </p:graphicFrame>
788    /// ```
789    pub fn write_xml(&self, w: &mut super::writer::XmlWriter) {
790        w.open("p:graphicFrame");
791        // nvGraphicFramePr
792        w.open("p:nvGraphicFramePr");
793        let id_s = self.id.to_string();
794        w.empty_with("p:cNvPr", &[("id", &id_s), ("name", &self.name)]);
795        w.empty("p:cNvGraphicFramePr");
796        // nvPr:若 is_placeholder=true,写出 <p:ph type="..." idx="..."/>(TODO-007 图表/表格占位符填充)
797        if self.is_placeholder {
798            w.open("p:nvPr");
799            // idx_s 提到 pattrs 之前,保证生命周期覆盖 empty_with 调用
800            let idx_s = self.ph_idx.map(|i| i.to_string());
801            let mut pattrs: Vec<(&str, &str)> = Vec::new();
802            if let Some(t) = self.ph_type.as_deref() {
803                pattrs.push(("type", t));
804            }
805            if let Some(s) = idx_s.as_deref() {
806                pattrs.push(("idx", s));
807            }
808            w.empty_with("p:ph", &pattrs);
809            w.close("p:nvPr");
810        } else {
811            w.empty("p:nvPr");
812        }
813        w.close("p:nvGraphicFramePr");
814        // xfrm(用专用方法,不输出 prstGeom/填充/边框)
815        self.properties.write_xfrm_only(w, "p:xfrm");
816        // graphic
817        w.open("a:graphic");
818        // SmartArt 走完整 raw_xml 输出(含 graphicData 外壳),其它类型走 open_with + 子元素 + close。
819        // 这样设计是因为 SmartArt 的 raw_xml 在读路径上 byte-exact 保留了完整的 <a:graphicData> 元素,
820        // 重新拆解为 open_with + 子元素会丢失原始格式(如命名空间声明位置、属性顺序等)。
821        if let Graphic::SmartArt(s) = &self.graphic {
822            // SmartArt 最小保留:直接输出读路径保存的 raw_xml(byte-exact)。
823            // raw_xml 是完整的 <a:graphicData uri=".../diagram">...</a:graphicData> 元素。
824            w.raw(&s.raw_xml);
825        } else {
826            // a:graphicData 的 uri 标识内部图形类型(表/图/OLE/...),
827            // 必须在 `<a:graphicData uri="...">` 元素上指定,不能放到子元素上。
828            let uri = match &self.graphic {
829                Graphic::Table(_) => "http://schemas.openxmlformats.org/drawingml/2006/table",
830                Graphic::Chart(_) => "http://schemas.openxmlformats.org/drawingml/2006/chart",
831                Graphic::OleObject(_) => super::ole::OLE_GRAPHIC_DATA_URI,
832                Graphic::SmartArt(_) => unreachable!(), // 已在 if 分支处理
833            };
834            w.open_with("a:graphicData", &[("uri", uri)]);
835            match &self.graphic {
836                Graphic::Table(t) => {
837                    // table 自带 <a:tbl> 顶层元素
838                    t.write_xml(w);
839                }
840                Graphic::Chart(c) => {
841                    // chart 仅写出引用元素:<c:chart xmlns:c=... xmlns:r=... r:id="rid"/>
842                    // 实际 chart 数据在独立的 chartN.xml part 中。
843                    let rid = if c.rid.is_empty() {
844                        "rId1"
845                    } else {
846                        c.rid.as_str()
847                    };
848                    w.empty_with(
849                        "c:chart",
850                        &[
851                            ("xmlns:c", "http://schemas.openxmlformats.org/drawingml/2006/chart"),
852                            ("xmlns:r", "http://schemas.openxmlformats.org/officeDocument/2006/relationships"),
853                            ("r:id", rid),
854                        ],
855                    );
856                }
857                Graphic::OleObject(o) => {
858                    // oleObj 写出完整 <p:oleObj ...> 元素(含 <p:embed/> 与可选 <p:pic>)。
859                    // 实际 OLE 二进制数据在独立的 oleObjectN.bin part 中。
860                    // 注意:oleObj 元素需要 r 命名空间,但已在 <p:graphicFrame> 外层默认声明,
861                    // 这里不再重复声明 xmlns:r。
862                    o.write_xml(w);
863                }
864                Graphic::SmartArt(_) => unreachable!(),
865            }
866            w.close("a:graphicData");
867        }
868        w.close("a:graphic");
869        // extLst
870        write_extlst(w, self.ext_lst.as_ref());
871        w.close("p:graphicFrame");
872    }
873}
874
875// ============== 形状样式(p:style) ==============
876
877/// 形状主题样式引用(`<p:style>`)。
878///
879/// OOXML 规范定义 `CT_ShapeStyle` 由 4 个引用组成:
880/// `<a:lnRef>`、`<a:fillRef>`、`<a:effectRef>`、`<a:fontRef>`。
881/// 在 PowerPoint 中,把"主题样式"应用到形状时,**就是写入这 4 个引用**;
882/// 一旦 `<p:style>` 存在,spPr 中的 prstGeom/fill/ln 会被其覆盖。
883///
884/// # 与 python-pptx 的对应
885///
886/// - python-pptx 同样在 `pptx.oxml.shape.CT_Shape` 上提供 `style` 属性;
887/// - python-pptx 通过 `shape.style = "..."` 或读取主题样式来设置。
888/// - 本库采用**强类型**字段,便于序列化与回读。
889#[derive(Clone, Debug, Default)]
890pub struct ShapeStyle {
891    /// `<a:lnRef>` 线条样式引用(`idx` + `schemeClr`)。
892    pub line_ref: Option<StyleRef>,
893    /// `<a:fillRef>` 填充样式引用。
894    pub fill_ref: Option<StyleRef>,
895    /// `<a:effectRef>` 效果样式引用。
896    pub effect_ref: Option<StyleRef>,
897    /// `<a:fontRef>` 字体样式引用(minor/major)。
898    pub font_ref: Option<StyleRef>,
899}
900
901impl ShapeStyle {
902    /// 写 XML。
903    pub fn write_xml(&self, w: &mut super::writer::XmlWriter) {
904        // 即便 4 个引用都是 None,也按 OOXML 规范至少输出 `<p:style>` 外壳以占位。
905        w.open("p:style");
906        if let Some(r) = &self.line_ref {
907            write_style_ref(w, "a:lnRef", r);
908        }
909        if let Some(r) = &self.fill_ref {
910            write_style_ref(w, "a:fillRef", r);
911        }
912        if let Some(r) = &self.effect_ref {
913            write_style_ref(w, "a:effectRef", r);
914        }
915        if let Some(r) = &self.font_ref {
916            // fontRef 特殊:`idx` 取值是 `"minor"` / `"major"` 而非数字
917            let idx_attr = r.idx.as_deref().unwrap_or("minor");
918            w.open_with("a:fontRef", &[("idx", idx_attr)]);
919            if let Some(scheme) = &r.scheme_color {
920                w.empty_with("a:schemeClr", &[("val", scheme)]);
921            }
922            w.close("a:fontRef");
923        }
924        w.close("p:style");
925    }
926}
927
928/// 4 个样式引用的通用结构。
929#[derive(Clone, Debug, Default)]
930pub struct StyleRef {
931    /// 索引(数字索引或 `"minor"` / `"major"` 字面量)。
932    pub idx: Option<String>,
933    /// 关联的主题色(`schemeClr val`)。
934    pub scheme_color: Option<String>,
935}
936
937fn write_style_ref(w: &mut super::writer::XmlWriter, tag: &str, r: &StyleRef) {
938    let idx_s = r.idx.clone().unwrap_or_else(|| "1".to_string());
939    w.open_with(tag, &[("idx", &idx_s)]);
940    if let Some(scheme) = &r.scheme_color {
941        w.empty_with("a:schemeClr", &[("val", scheme)]);
942    }
943    w.close(tag);
944}
945
946// ============== 扩展列表(p:extLst) ==============
947
948/// 扩展列表(`<p:extLst>`)。
949///
950/// OOXML 中"扩展"是 PowerPoint 用来存放私有属性 / future 兼容字段的地方,
951/// 大多数"插入 → 背景效果" 等 UI 操作都会在 extLst 写一条 Microsoft 私有
952/// 扩展。**只要形状被 UI 触碰过**,extLst 就很可能非空。
953///
954/// # 与 python-pptx 的对应
955///
956/// python-pptx 中 `CT_Shape` / `CT_GroupShape` / `CT_GraphicalObjectFrame`
957/// 都有 `ext_lst` 属性;本库同等暴露。
958#[derive(Clone, Debug, Default)]
959pub struct ExtensionList {
960    /// 扩展条目。每个条目包含 `uri`(必填)和原始 XML 字符串。
961    pub entries: Vec<ExtensionEntry>,
962}
963
964#[derive(Clone, Debug)]
965pub struct ExtensionEntry {
966    /// 扩展的 URI(命名空间 GUID)。
967    pub uri: String,
968    /// 扩展 XML 内容(**已转义**的合法 XML 子串)。
969    /// 通常为 `<a:...>...</a:...>` 或 `<p14:...>...</p14:...>`。
970    pub xml: String,
971}
972
973impl ExtensionList {
974    /// 写 XML。
975    pub fn write_xml(&self, w: &mut super::writer::XmlWriter) {
976        if self.entries.is_empty() {
977            return;
978        }
979        w.open("p:extLst");
980        for e in &self.entries {
981            w.open_with("p:ext", &[("uri", e.uri.as_str())]);
982            w.raw(&e.xml);
983            w.close("p:ext");
984        }
985        w.close("p:extLst");
986    }
987}
988
989/// extLst 便捷写出:`Some` 时写出,`None` 时跳过。
990fn write_extlst(w: &mut super::writer::XmlWriter, ext: Option<&ExtensionList>) {
991    if let Some(e) = ext {
992        e.write_xml(w);
993    }
994}
995
996#[cfg(test)]
997mod tests {
998    use super::*;
999
1000    /// 验证 `ShapeLocks` 的序列化。
1001    ///
1002    /// 这是 TODO-027 的测试。
1003    #[test]
1004    fn shape_locks_serialization() {
1005        let locks = ShapeLocks {
1006            no_grp: true,
1007            no_select: true,
1008            no_resize: true,
1009            ..Default::default()
1010        };
1011        let mut w = super::super::writer::XmlWriter::new();
1012        locks.write_xml(&mut w);
1013        let s = w.into_string();
1014        assert!(s.contains("noGrp=\"1\""), "应包含 noGrp=1,实际: {s}");
1015        assert!(s.contains("noSelect=\"1\""), "应包含 noSelect=1,实际: {s}");
1016        assert!(s.contains("noResize=\"1\""), "应包含 noResize=1,实际: {s}");
1017        assert!(!s.contains("noMove"), "不应包含 noMove,实际: {s}");
1018        assert!(!s.contains("noRot"), "不应包含 noRot,实际: {s}");
1019    }
1020
1021    /// 验证空 `ShapeLocks` 的 `is_empty` 判断。
1022    ///
1023    /// 这是 TODO-027 的测试。
1024    #[test]
1025    fn shape_locks_is_empty() {
1026        let empty = ShapeLocks::default();
1027        assert!(empty.is_empty());
1028
1029        let non_empty = ShapeLocks {
1030            no_grp: true,
1031            ..Default::default()
1032        };
1033        assert!(!non_empty.is_empty());
1034    }
1035
1036    /// 验证 `Sp` 带 locks 的完整序列化。
1037    ///
1038    /// 这是 TODO-027 的测试。
1039    #[test]
1040    fn sp_with_locks_serialization() {
1041        let sp = Sp {
1042            id: 1,
1043            name: "Test".into(),
1044            locks: Some(ShapeLocks {
1045                no_select: true,
1046                no_move: true,
1047                ..Default::default()
1048            }),
1049            ..Default::default()
1050        };
1051        let mut w = super::super::writer::XmlWriter::new();
1052        sp.write_xml(&mut w);
1053        let s = w.into_string();
1054        assert!(s.contains("a:spLocks"), "应包含 a:spLocks,实际: {s}");
1055        assert!(s.contains("noSelect=\"1\""), "应包含 noSelect=1,实际: {s}");
1056        assert!(s.contains("noMove=\"1\""), "应包含 noMove=1,实际: {s}");
1057        // cNvSpPr 应为 open-close 形式(因为有子元素)
1058        assert!(
1059            s.contains("<p:cNvSpPr>") || s.contains("<p:cNvSpPr "),
1060            "cNvSpPr 应为 open 标签,实际: {s}"
1061        );
1062        assert!(s.contains("</p:cNvSpPr>"), "应包含 </p:cNvSpPr>,实际: {s}");
1063    }
1064
1065    /// 验证 `Pic` 带 `MediaKind::Video` 的序列化(TODO-033)。
1066    ///
1067    /// 关键断言:
1068    /// - `<a:videoFile r:link="rIdVideo1"/>` 必须出现在 `<p:nvPr>` 内;
1069    /// - `r:link` 属性(不是 `r:embed`);
1070    /// - `xmlns:r` 命名空间声明必须出现在 `<p:nvPr>` 上。
1071    #[test]
1072    fn pic_with_video_media_serialize() {
1073        let pic = Pic {
1074            id: 1,
1075            name: "Video 1".into(),
1076            rid: "rIdImg1".into(),
1077            media: Some(MediaKind::Video {
1078                rid: "rIdVideo1".into(),
1079            }),
1080            ..Default::default()
1081        };
1082        let mut w = super::super::writer::XmlWriter::new();
1083        pic.write_xml(&mut w);
1084        let s = w.into_string();
1085        assert!(s.contains("<a:videoFile"), "应包含 a:videoFile,实际: {s}");
1086        assert!(
1087            s.contains("r:link=\"rIdVideo1\""),
1088            "应包含 r:link=\"rIdVideo1\",实际: {s}"
1089        );
1090        assert!(
1091            !s.contains("r:embed=\"rIdVideo1\""),
1092            "视频不应使用 r:embed,实际: {s}"
1093        );
1094        assert!(
1095            s.contains("xmlns:r="),
1096            "nvPr 应声明 xmlns:r 命名空间,实际: {s}"
1097        );
1098    }
1099
1100    /// 验证 `Pic` 带 `MediaKind::Audio` 的序列化(TODO-033)。
1101    #[test]
1102    fn pic_with_audio_media_serialize() {
1103        let pic = Pic {
1104            id: 2,
1105            name: "Audio 1".into(),
1106            rid: "rIdImg2".into(),
1107            media: Some(MediaKind::Audio {
1108                rid: "rIdAudio1".into(),
1109            }),
1110            ..Default::default()
1111        };
1112        let mut w = super::super::writer::XmlWriter::new();
1113        pic.write_xml(&mut w);
1114        let s = w.into_string();
1115        assert!(s.contains("<a:audioFile"), "应包含 a:audioFile,实际: {s}");
1116        assert!(
1117            s.contains("r:link=\"rIdAudio1\""),
1118            "应包含 r:link=\"rIdAudio1\",实际: {s}"
1119        );
1120    }
1121
1122    /// 验证普通 `Pic`(无 media)不写出 videoFile/audioFile(TODO-033)。
1123    #[test]
1124    fn pic_without_media_no_video_file() {
1125        let pic = Pic {
1126            id: 3,
1127            name: "Plain Picture".into(),
1128            rid: "rIdImg3".into(),
1129            // media = None
1130            ..Default::default()
1131        };
1132        let mut w = super::super::writer::XmlWriter::new();
1133        pic.write_xml(&mut w);
1134        let s = w.into_string();
1135        assert!(
1136            !s.contains("videoFile"),
1137            "普通图片不应包含 videoFile,实际: {s}"
1138        );
1139        assert!(
1140            !s.contains("audioFile"),
1141            "普通图片不应包含 audioFile,实际: {s}"
1142        );
1143    }
1144
1145    /// 验证 `MediaKind` 的 `PartialEq` 与 `Default`(TODO-033)。
1146    #[test]
1147    fn media_kind_eq_and_default() {
1148        // Default 应为 Video { rid: "" }
1149        let d = MediaKind::default();
1150        assert!(matches!(d, MediaKind::Video { ref rid } if rid.is_empty()));
1151
1152        // PartialEq
1153        let v1 = MediaKind::Video { rid: "rId1".into() };
1154        let v2 = MediaKind::Video { rid: "rId1".into() };
1155        let v3 = MediaKind::Video { rid: "rId2".into() };
1156        let a1 = MediaKind::Audio { rid: "rId1".into() };
1157        assert_eq!(v1, v2, "相同 rid 的 Video 应相等");
1158        assert_ne!(v1, v3, "不同 rid 的 Video 应不等");
1159        assert_ne!(v1, a1, "Video 与 Audio 即使 rid 相同也不等");
1160    }
1161
1162    /// 验证 `GraphicFrame` 带 `Graphic::SmartArt` 的序列化(TODO-037 最小保留)。
1163    ///
1164    /// 关键断言:
1165    /// - `<a:graphicData uri=".../diagram">` 必须出现(uri 来自 raw_xml,不再由 write_xml 生成);
1166    /// - `<dgm:relIds r:dm="rId1" r:lo="rId2" r:qs="rId3" r:cs="rId4"/>` 必须 byte-exact 保留;
1167    /// - 不应出现重复的 `<a:graphicData>` 外壳(write_xml 跳过 open_with/close)。
1168    #[test]
1169    fn graphic_frame_with_smartart_serialize() {
1170        // 模拟读路径保存的 raw_xml(完整 graphicData 元素,含外壳)
1171        let raw_xml =
1172            "<a:graphicData uri=\"http://schemas.openxmlformats.org/drawingml/2006/diagram\">\
1173                       <dgm:relIds r:dm=\"rId1\" r:lo=\"rId2\" r:qs=\"rId3\" r:cs=\"rId4\"/>\
1174                       </a:graphicData>";
1175        let frame = GraphicFrame {
1176            id: 100,
1177            name: "SmartArt 1".into(),
1178            graphic: Graphic::SmartArt(SmartArtRef {
1179                raw_xml: raw_xml.into(),
1180                dm_rid: Some("rId1".into()),
1181                lo_rid: Some("rId2".into()),
1182                qs_rid: Some("rId3".into()),
1183                cs_rid: Some("rId4".into()),
1184            }),
1185            ..Default::default()
1186        };
1187        let mut w = super::super::writer::XmlWriter::new();
1188        frame.write_xml(&mut w);
1189        let s = w.into_string();
1190        // 应包含 uri
1191        assert!(
1192            s.contains("uri=\"http://schemas.openxmlformats.org/drawingml/2006/diagram\""),
1193            "应包含 diagram uri,实际: {s}"
1194        );
1195        // 应包含 dgm:relIds(byte-exact)
1196        assert!(
1197            s.contains("<dgm:relIds r:dm=\"rId1\" r:lo=\"rId2\" r:qs=\"rId3\" r:cs=\"rId4\"/>"),
1198            "应包含原始 dgm:relIds,实际: {s}"
1199        );
1200        // 不应出现重复的 graphicData 外壳(write_xml 跳过 open_with/close)
1201        // 统计 <a:graphicData 出现次数:应为 1
1202        let count = s.matches("<a:graphicData").count();
1203        assert_eq!(
1204            count, 1,
1205            "SmartArt 不应重复写出 graphicData 外壳,实际出现 {} 次: {s}",
1206            count
1207        );
1208    }
1209
1210    /// 验证 `SmartArtRef` 的 Default 与字段访问(TODO-037)。
1211    #[test]
1212    fn smartart_ref_default() {
1213        let r = SmartArtRef::default();
1214        assert!(r.raw_xml.is_empty(), "默认 raw_xml 应为空");
1215        assert!(r.dm_rid.is_none(), "默认 dm_rid 应为 None");
1216        assert!(r.lo_rid.is_none(), "默认 lo_rid 应为 None");
1217        assert!(r.qs_rid.is_none(), "默认 qs_rid 应为 None");
1218        assert!(r.cs_rid.is_none(), "默认 cs_rid 应为 None");
1219    }
1220}