Skip to main content

pptx_rs/
slide.rs

1//! # 单张幻灯片(Slide)—— 高阶 API
2//!
3//! 对标 python-pptx 中 `pptx.slide.Slide` 类。
4//!
5//! 在三层架构中处于**高阶 API 层**,直接聚合 [`Shapes`] / [`ShapesMut`]
6//! 视图(用于访问形状集合),并通过 `inner: OxmlSld` 桥接到 [`crate::oxml::slide`]
7//! 中的 OOXML 模型。
8//!
9//! # 设计要点
10//!
11//! - 每个 `Slide` 独立持有一份 `OxmlSld`,**不**跨 slide 共享;
12//! - `id_counter` 与 `Presentation` 共享 —— 保证跨 slide 的 shape id 全局唯一;
13//! - 形状增删只走 `shapes_mut()`,走完整借用检查(`&mut Slide` 才能 mutate)。
14//!
15//! # 示例
16//!
17//! ```no_run
18//! use pptx_rs::Presentation;
19//! use pptx_rs::Inches;
20//!
21//! let mut p = Presentation::new().unwrap();
22//! let counter = p.id_counter();
23//! let s = p.slides_mut().add_slide(counter).unwrap();
24//! s.shapes_mut().add_textbox_with_text(
25//!     Inches(1.0), Inches(1.0), Inches(4.0), Inches(1.0),
26//!     "hello",
27//! ).unwrap();
28//! ```
29//!
30//! (doctest 用 `unwrap` 仅为缩短示例;生产代码应使用 `?` 传播错误。)
31
32use std::cell::Cell;
33use std::rc::Rc;
34
35use crate::oxml::slide::Sld as OxmlSld;
36use crate::oxml::SlideShape as OxmlSlideShape;
37use crate::presentation::{AudioEntry, ChartEntry, DiagramEntry, MediaEntry, OleEntry, VideoEntry};
38use crate::shape::base::Shape;
39use crate::shape::freeform::Freeform;
40use crate::shape::picture::Picture;
41use crate::shape::{
42    AutoShape, ChartShape, Connector, Group, OleObjectShape, ShapeKind, SmartArtShape, TableShape,
43    TextBox,
44};
45
46/// 从 [`TextBody`] 中提取纯文本(段落间 `\n`)。
47///
48/// 辅助函数,供 [`Slide::extract_text`] 使用。
49fn extract_textbody_text(tb: &crate::oxml::txbody::TextBody) -> String {
50    let mut s = String::new();
51    let mut first = true;
52    for p in &tb.paragraphs {
53        if !first {
54            s.push('\n');
55        }
56        first = false;
57        for r in &p.runs {
58            s.push_str(&r.text);
59        }
60    }
61    s
62}
63use crate::units::{Emu, EmuExt};
64
65/// 幻灯片的高阶包装。**直接拥有** oxml [`OxmlSld`]。
66///
67/// 该类型既"包装"oxml 模型,又把 id 计数器共享给 `Presentation`,
68/// 是"高阶 API ↔ OOXML 模型"之间的唯一桥梁。
69#[derive(Clone, Debug)]
70pub struct Slide {
71    /// 共享 oxml 模型。每个 Slide 自己持有一份,不共享。
72    pub(crate) inner: OxmlSld,
73    /// 共享 ID 分配计数器(与 [`crate::presentation::Presentation`] 共享)。
74    pub(crate) id_counter: Rc<Cell<u32>>,
75    /// 本 slide 内的图片关系 id 计数器(`rIdImg1` / `rIdImg2` ...)。
76    pub(crate) image_rid_counter: Rc<Cell<u32>>,
77    /// 本 slide 注册到 Presentation 的媒体条目列表(保存时统一写入 zip)。
78    pub(crate) media_entries: Vec<MediaEntry>,
79    /// 媒体索引计数器。
80    pub(crate) media_index_counter: Rc<Cell<u32>>,
81    /// 本 slide 注册到 Presentation 的图表条目列表(保存时统一写入 zip)。
82    ///
83    /// 每个 [`ChartEntry`] 对应一个 `/ppt/charts/chartN.xml` part,
84    /// 由 `ShapesMut::add_chart` 在创建图表时调用 `register_chart` 注入。
85    pub(crate) chart_entries: Vec<ChartEntry>,
86    /// 图表索引计数器(用于生成 `chart{N}.xml` 文件名)。
87    pub(crate) chart_index_counter: Rc<Cell<u32>>,
88    /// 本 slide 内的图表关系 id 计数器(`rIdChart1` / `rIdChart2` ...)。
89    pub(crate) chart_rid_counter: Rc<Cell<u32>>,
90    /// 引用的版式(默认 `rIdLayout1`)。
91    ///
92    /// 注意:实际写入 sld XML 时使用的是 `inner.layout_rid`(由 `set_layout_rid` 同步),
93    /// 此字段保留作为 from_opc 解析时的初始值载体。
94    #[allow(dead_code)]
95    pub(crate) layout_rid: String,
96    /// **包内**:引用的版式 part 路径(`/ppt/slideLayouts/slideLayoutN.xml`)。
97    ///
98    /// 用于 round-trip 保真:`from_opc` 解析 slide rels 的 SlideLayout 关系时回填,
99    /// 写路径优先使用此字段生成 `slideN.xml.rels` 的 layout target。
100    /// `None` 时写路径兜底 `../slideLayouts/slideLayout1.xml`。
101    pub(crate) layout_partname: Option<String>,
102    /// 备注 part 关系 id(指向 `ppt/notesSlides/notesSlideN.xml`)。
103    /// 当 `notes` 为 `Some` 时由 `to_opc_package` 注入 `rIdNotesN`。
104    pub(crate) notes_rid: Option<String>,
105    /// **包内**:备注 part 路径(`/ppt/notesSlides/notesSlideN.xml`)。
106    ///
107    /// 用于在 read-modify-write 循环中**保留**原始 partname,
108    /// 避免重新分配时导致 partname 漂移、外部引用断链。
109    /// - `None`:由 `to_opc_package` 按 `notesSlide{idx}.xml` 分配;
110    /// - `Some(_)`:写路径直接使用,**禁止重命名**。
111    pub(crate) notes_partname: Option<String>,
112    /// **包内**:`notesSlideN.xml.rels` 中指向所属 slide 的 `Target`(如 `../slides/slide1.xml`)。
113    ///
114    /// 由 [`crate::presentation::Presentation::from_opc`] 读 `notesSlideN.xml.rels`
115    /// 中的 `Slide` 关系后回填。保存时优先复用,缺失再按 `../slides/slide{idx+1}.xml` 拼。
116    pub(crate) notes_slide_rel_target: Option<String>,
117    /// 该 slide 的评论列表(`<p:cmLst>`)。
118    ///
119    /// `None` 表示该 slide 没有评论;`Some(CommentList)` 即使为空也会写出 `commentN.xml`。
120    /// 由 `Presentation::to_opc_package` 在打包阶段写入 `/ppt/comments/commentN.xml`。
121    pub(crate) comments: Option<crate::oxml::comments::CommentList>,
122    /// **包内**:评论 part 路径(`/ppt/comments/commentN.xml`)。
123    ///
124    /// 用于 read-modify-write 循环中保留原始 partname,避免漂移。
125    pub(crate) comments_partname: Option<String>,
126    /// **包内**:评论 part 的关系 id(`rIdCommentsN`),由 `to_opc_package` 注入。
127    pub(crate) comments_rid: Option<String>,
128    /// 本 slide 注册到 Presentation 的 OLE 对象条目列表(保存时统一写出 oleObjectN.bin)。
129    ///
130    /// 每个 [`OleEntry`] 对应一个 `/ppt/embeddings/oleObjectN.bin` part,
131    /// 由 `ShapesMut::add_ole_object` 在创建 OLE 对象时调用 `register_ole` 注入。
132    pub(crate) ole_entries: Vec<OleEntry>,
133    /// OLE 对象索引计数器(用于生成 `oleObject{N}.bin` 文件名)。
134    pub(crate) ole_index_counter: Rc<Cell<u32>>,
135    /// 本 slide 内的 OLE 关系 id 计数器(`rIdOle1` / `rIdOle2` ...)。
136    pub(crate) ole_rid_counter: Rc<Cell<u32>>,
137    /// 本 slide 注册到 Presentation 的视频条目列表(保存时统一写出 mediaN.mp4,TODO-033)。
138    ///
139    /// 每个 [`VideoEntry`] 对应一个 `/ppt/media/mediaN.mp4` part,
140    /// 由 `ShapesMut::add_video` 在创建视频形状时调用 `register_video` 注入。
141    pub(crate) video_entries: Vec<VideoEntry>,
142    /// 视频索引计数器(用于生成 `media{N}.mp4` 文件名)。
143    pub(crate) video_index_counter: Rc<Cell<u32>>,
144    /// 本 slide 内的视频关系 id 计数器(`rIdVideo1` / `rIdVideo2` ...)。
145    pub(crate) video_rid_counter: Rc<Cell<u32>>,
146    /// 本 slide 注册到 Presentation 的音频条目列表(保存时统一写出 mediaN.mp3,TODO-033)。
147    ///
148    /// 每个 [`AudioEntry`] 对应一个 `/ppt/media/mediaN.mp3` part,
149    /// 由 `ShapesMut::add_audio` 在创建音频形状时调用 `register_audio` 注入。
150    pub(crate) audio_entries: Vec<AudioEntry>,
151    /// 音频索引计数器(用于生成 `media{N}.mp3` 文件名)。
152    pub(crate) audio_index_counter: Rc<Cell<u32>>,
153    /// 本 slide 内的音频关系 id 计数器(`rIdAudio1` / `rIdAudio2` ...)。
154    pub(crate) audio_rid_counter: Rc<Cell<u32>>,
155    /// 本 slide 注册到 Presentation 的 SmartArt 条目列表(保存时统一写出 4 个 diagramN.xml,TODO-037)。
156    ///
157    /// 每个 [`DiagramEntry`] 对应 4 个 `/ppt/diagrams/{data,layout,quickStyles,colors}N.xml` part,
158    /// 由 `ShapesMut::add_diagram` 在创建 SmartArt 图形时调用 `register_diagram` 注入。
159    pub(crate) diagram_entries: Vec<DiagramEntry>,
160    /// SmartArt 索引计数器(用于生成 `data{N}.xml` 等文件名)。
161    pub(crate) diagram_index_counter: Rc<Cell<u32>>,
162    /// 本 slide 内的 SmartArt 关系 id 计数器(`rIdDgmData1` / `rIdDgmLayout1` / ...)。
163    pub(crate) diagram_rid_counter: Rc<Cell<u32>>,
164}
165
166impl Slide {
167    /// 包内构造:构造一个**空白** slide。
168    ///
169    /// 仅供 `crate::slide::Slides::add_slide` 调用;公开 API 应使用
170    /// [`crate::presentation::Presentation::slides_mut`] 间接获取。
171    pub(crate) fn blank(id_counter: Rc<Cell<u32>>) -> Self {
172        Slide {
173            inner: OxmlSld::default(),
174            id_counter,
175            image_rid_counter: Rc::new(Cell::new(0)),
176            media_entries: Vec::new(),
177            media_index_counter: Rc::new(Cell::new(0)),
178            chart_entries: Vec::new(),
179            chart_index_counter: Rc::new(Cell::new(0)),
180            chart_rid_counter: Rc::new(Cell::new(0)),
181            layout_rid: "rId1".to_string(),
182            layout_partname: None,
183            notes_rid: None,
184            // 新建 slide 默认没有历史 partname:由 to_opc_package 按 notes_index 分配
185            notes_partname: None,
186            notes_slide_rel_target: None,
187            comments: None,
188            comments_partname: None,
189            comments_rid: None,
190            ole_entries: Vec::new(),
191            ole_index_counter: Rc::new(Cell::new(0)),
192            ole_rid_counter: Rc::new(Cell::new(0)),
193            video_entries: Vec::new(),
194            video_index_counter: Rc::new(Cell::new(0)),
195            video_rid_counter: Rc::new(Cell::new(0)),
196            audio_entries: Vec::new(),
197            audio_index_counter: Rc::new(Cell::new(0)),
198            audio_rid_counter: Rc::new(Cell::new(0)),
199            diagram_entries: Vec::new(),
200            diagram_index_counter: Rc::new(Cell::new(0)),
201            diagram_rid_counter: Rc::new(Cell::new(0)),
202        }
203    }
204
205    /// 包内构造:从一个**已解析的** [`OxmlSld`] 还原为 `Slide`。
206    ///
207    /// 典型用途:[`crate::presentation::Presentation::from_opc`] 在
208    /// 读路径里把 `slideN.xml` 解析为 `OxmlSld` 后,**直接接管**为 Slide,
209    /// 跳过 `blank` 的空壳构造。
210    ///
211    /// # 参数
212    /// - `inner`:从 `slideN.xml` 解析得到的 `OxmlSld`;
213    /// - `id_counter`:必须与所属 `Presentation` 共享(保持 shape id 全局唯一);
214    /// - `layout_rid`:从 `slideN.xml.rels` 中查到的 `SlideLayout` 关系 id。
215    pub(crate) fn from_sld(inner: OxmlSld, id_counter: Rc<Cell<u32>>, layout_rid: String) -> Self {
216        Slide {
217            inner,
218            id_counter,
219            // 已加载的 slide 不再分配新 image rId(由 `parse_sld` 直接接管 pic.rid)。
220            image_rid_counter: Rc::new(Cell::new(0)),
221            media_entries: Vec::new(),
222            media_index_counter: Rc::new(Cell::new(0)),
223            chart_entries: Vec::new(),
224            chart_index_counter: Rc::new(Cell::new(0)),
225            chart_rid_counter: Rc::new(Cell::new(0)),
226            layout_rid,
227            layout_partname: None,
228            notes_rid: None,
229            // 这两个字段由 `Presentation::from_opc` 解析出 notes 后**单独**回填,
230            // 构造器阶段无法获取 rels 信息。
231            notes_partname: None,
232            notes_slide_rel_target: None,
233            comments: None,
234            comments_partname: None,
235            comments_rid: None,
236            // OLE 嵌入:读路径当前不解析已有 oleObj,所以 from_sld 阶段为空。
237            ole_entries: Vec::new(),
238            ole_index_counter: Rc::new(Cell::new(0)),
239            ole_rid_counter: Rc::new(Cell::new(0)),
240            // 视频/音频:读路径当前不解析已有 videoFile/audioFile,所以 from_sld 阶段为空。
241            video_entries: Vec::new(),
242            video_index_counter: Rc::new(Cell::new(0)),
243            video_rid_counter: Rc::new(Cell::new(0)),
244            audio_entries: Vec::new(),
245            audio_index_counter: Rc::new(Cell::new(0)),
246            audio_rid_counter: Rc::new(Cell::new(0)),
247            // SmartArt:读路径当前不解析已有 graphicFrame/diagram,所以 from_sld 阶段为空。
248            // 写路径由 register_diagram 注入。
249            diagram_entries: Vec::new(),
250            diagram_index_counter: Rc::new(Cell::new(0)),
251            diagram_rid_counter: Rc::new(Cell::new(0)),
252        }
253    }
254
255    /// 把当前 slide 序列化为 XML 字符串。
256    ///
257    /// # 用途
258    /// - 调试:直接 `println!("{}", slide.to_xml())`;
259    /// - 测试 fixture:与已知 snapshot 对比;
260    /// - 自定义输出管道:把 XML 与 zip 步骤解耦。
261    pub fn to_xml(&self) -> String {
262        self.inner.to_xml()
263    }
264
265    /// 不可变形状集合视图。
266    pub fn shapes(&self) -> Shapes<'_> {
267        Shapes { slide: self }
268    }
269    /// 可变形状集合视图。
270    pub fn shapes_mut(&mut self) -> ShapesMut<'_> {
271        ShapesMut { slide: self }
272    }
273
274    /// 内部 ID(在所属 `Presentation` 内的 sldIdLst 序号,与 shape id 独立)。
275    pub fn internal_id(&self) -> u32 {
276        self.inner.id
277    }
278    /// 取 layout 关系 id(指向 `ppt/slideLayouts/slideLayoutN.xml`)。
279    pub fn layout_rid(&self) -> String {
280        self.inner.layout_rid.clone()
281    }
282    /// 设置 layout 关系 id。
283    pub fn set_layout_rid(&mut self, rid: String) {
284        self.inner.layout_rid = rid;
285    }
286
287    /// 取 slide layout 的 OPC part 路径(`/ppt/slideLayouts/slideLayoutN.xml`)。
288    ///
289    /// 返回 `None` 表示未捕获(新建场景);写路径兜底为 `../slideLayouts/slideLayout1.xml`。
290    pub fn layout_partname(&self) -> Option<&str> {
291        self.layout_partname.as_deref()
292    }
293
294    /// 设置 slide layout 的 OPC part 路径(由 from_opc 在解析 slideN.xml.rels 时回填)。
295    pub(crate) fn set_layout_partname(&mut self, partname: String) {
296        self.layout_partname = Some(partname);
297    }
298
299    /// 取 slide 名(对应 `p:sld/p:cSld/@name`,空字符串表示未命名)。
300    ///
301    /// 对标 python-pptx `Slide.name`。
302    pub fn name(&self) -> &str {
303        &self.inner.name
304    }
305    /// 设置 slide 名。`None` 或空字符串等价于"移除名字"。
306    pub fn set_name(&mut self, name: Option<&str>) {
307        self.inner.name = name.unwrap_or("").to_string();
308    }
309
310    /// 备注文本(speaker notes)拼成单字符串。
311    ///
312    /// 返回 `None` 表示当前 slide 没有任何 `<p:notes>` 部分。
313    /// 多段落以 `\n` 拼接,与 python-pptx 行为一致。
314    pub fn notes_text(&self) -> Option<String> {
315        self.inner.notes.as_ref().map(|tb| {
316            let mut s = String::new();
317            let mut first = true;
318            for p in &tb.paragraphs {
319                if !first {
320                    s.push('\n');
321                }
322                first = false;
323                for r in &p.runs {
324                    s.push_str(&r.text);
325                }
326            }
327            s
328        })
329    }
330
331    /// **是否**存在 notes slide。
332    ///
333    /// 对标 python-pptx `Slide.has_notes_slide`。
334    /// 与 [`Self::notes_text`] 不同:后者读取 notes 内容,前者只判断"是否创建了
335    /// notes 容器"——一旦写过 notes,本值即**持续为 true**,直至显式 `set_notes_text(None)`。
336    pub fn has_notes_slide(&self) -> bool {
337        self.inner.notes.is_some()
338    }
339
340    /// **是否**继承 master 背景。
341    ///
342    /// 对标 python-pptx `Slide.follow_master_background`。
343    ///
344    /// # 实现语义
345    /// - `inner.background` 为 `None`:未设置独立背景,渲染时回退到 master → 返回 `true`;
346    /// - `inner.background` 为 `Some(SlideBackground::Reference { idx=1001, scheme_color="bg1" })`:
347    ///   显式引用 master 背景 → 返回 `true`;
348    /// - `inner.background` 为 `Some(SlideBackground::Property(_))`:已设置独立背景 → 返回 `false`;
349    /// - 其它 `Reference`(非 bg1/1001):视为"引用主题背景样式",仍算继承 → 返回 `true`。
350    pub fn follow_master_background(&self) -> bool {
351        match &self.inner.background {
352            None => true,
353            Some(crate::oxml::slide::SlideBackground::Reference(_)) => true,
354            Some(crate::oxml::slide::SlideBackground::Property(_)) => false,
355        }
356    }
357
358    /// 设置"是否继承 master 背景"。
359    ///
360    /// - `v = true`:清空独立背景(`inner.background = None`),渲染时回退到 master;
361    /// - `v = false`:若当前已是独立背景则保留;否则写入一个"占位"的纯白背景,
362    ///   后续可通过 [`Self::set_background_solid`] 修改颜色。
363    ///
364    /// 对标 python-pptx `Slide.follow_master_background = True/False`。
365    pub fn set_follow_master_background(&mut self, v: bool) {
366        if v {
367            // 清空独立背景,回退到 master
368            self.inner.background = None;
369        } else if self.follow_master_background() {
370            // 当前是继承状态,切换为独立背景:写入一个默认纯白背景占位
371            self.inner.background = Some(crate::oxml::slide::SlideBackground::solid(
372                crate::oxml::color::Color::RGB(crate::units::RGBColor::WHITE),
373            ));
374        }
375        // 若已经是独立背景且 v=false,则保持不变
376    }
377
378    /// 设置**纯色**背景(写出 `<p:bg><p:bgPr><a:solidFill>...</a:solidFill></p:bgPr></p:bg>`)。
379    ///
380    /// 对标 python-pptx `slide.background.fill.solid(); slide.background.fill.fore_color.rgb = ...`。
381    ///
382    /// # 参数
383    /// - `color`:填充颜色(`Color::RGB` / `Color::Scheme` / `Color::Preset`);
384    ///   `Color::None` 等价于 [`Self::clear_background`]。
385    ///
386    /// # 示例
387    /// ```no_run
388    /// # use pptx_rs::{Presentation, Inches, RGBColor};
389    /// # use pptx_rs::oxml::color::Color;
390    /// # let mut p = Presentation::new().unwrap();
391    /// # let counter = p.id_counter();
392    /// # let s = p.slides_mut().add_slide(counter).unwrap();
393    /// s.set_background_solid(Color::RGB(RGBColor::RED));
394    /// ```
395    pub fn set_background_solid(&mut self, color: crate::oxml::color::Color) {
396        if matches!(color, crate::oxml::color::Color::None) {
397            self.clear_background();
398            return;
399        }
400        self.inner.background = Some(crate::oxml::slide::SlideBackground::solid(color));
401    }
402
403    /// 清空独立背景(回退到继承 master 背景)。
404    ///
405    /// 等价于 `set_follow_master_background(true)`。
406    pub fn clear_background(&mut self) {
407        self.inner.background = None;
408    }
409
410    /// 提取 slide 中所有文本内容(纯文本,不含格式)。
411    ///
412    /// 对标 pypdf `PageObject.extract_text()` / python-pptx `Shape.text_frame.text`。
413    /// 遍历 slide 上所有形状的文本体,把每个 `Run` 的 `text` 拼接成单字符串,
414    /// 段落间以 `\n` 分隔,形状间以 `\n\n` 分隔。
415    ///
416    /// # 与 pypdf 的差异
417    /// - pypdf 的 `extract_text()` 按 PDF 内容流顺序提取,可能乱序;
418    /// - 本方法按 slide XML 中的形状声明顺序提取,与 PowerPoint 中阅读顺序一致。
419    pub fn extract_text(&self) -> String {
420        let mut out = String::new();
421        let mut first_shape = true;
422        for sh in &self.inner.shapes {
423            let tb = match sh {
424                OxmlSlideShape::Sp(sp) => &sp.text,
425                OxmlSlideShape::Pic(_) => continue,
426                OxmlSlideShape::CxnSp(_) => continue,
427                OxmlSlideShape::Group(grp) => {
428                    // 递归提取 group 内的文本
429                    let mut grp_text = String::new();
430                    let mut first = true;
431                    for child in &grp.children {
432                        if let crate::oxml::shape::GroupChild::Sp(sp) = child {
433                            if !first {
434                                grp_text.push_str("\n\n");
435                            }
436                            first = false;
437                            grp_text.push_str(&extract_textbody_text(&sp.text));
438                        }
439                    }
440                    if grp_text.is_empty() {
441                        continue;
442                    }
443                    if !first_shape {
444                        out.push_str("\n\n");
445                    }
446                    first_shape = false;
447                    out.push_str(&grp_text);
448                    continue;
449                }
450                OxmlSlideShape::GraphicFrame(_) => continue,
451            };
452            let text = extract_textbody_text(tb);
453            if text.is_empty() {
454                continue;
455            }
456            if !first_shape {
457                out.push_str("\n\n");
458            }
459            first_shape = false;
460            out.push_str(&text);
461        }
462        out
463    }
464
465    /// 深拷贝当前 slide(分配新 id / rid / partname 由 `Slides` 在插入时处理)。
466    ///
467    /// 对标 pypdf `PdfWriter.clone_page_from_reader`。
468    /// 返回的 `Slide` 与原 slide **完全独立**——修改克隆体不影响原件。
469    pub fn clone_slide(&self) -> Slide {
470        self.clone()
471    }
472
473    /// 设置标题占位符的文本(TODO-007)。
474    ///
475    /// 对标 python-pptx `slide.shapes.title.text = "..."`。
476    ///
477    /// 查找策略与 [`Shapes::title`] 一致:优先 `ph_type == "title"` / `"ctrTitle"`,
478    /// 其次 `ph_idx == 0`。找到后**替换**其文本体为单段落单 Run。
479    ///
480    /// # 返回
481    /// - `true`:找到标题占位符并已设置;
482    /// - `false`:未找到标题占位符。
483    pub fn set_title_text(&mut self, text: &str) -> bool {
484        for sh in &mut self.inner.shapes {
485            if let OxmlSlideShape::Sp(sp) = sh {
486                if sp.is_placeholder {
487                    let is_title = sp
488                        .ph_type
489                        .as_deref()
490                        .map(|t| t == "title" || t == "ctrTitle")
491                        .unwrap_or(false)
492                        || sp.ph_idx == Some(0);
493                    if is_title {
494                        let mut tb = crate::oxml::txbody::TextBody::new();
495                        tb.set_text(text);
496                        sp.text = tb;
497                        return true;
498                    }
499                }
500            }
501        }
502        false
503    }
504
505    /// 取标题占位符的文本(TODO-007)。
506    ///
507    /// 对标 python-pptx `slide.shapes.title.text`。
508    /// 未找到标题占位符时返回 `None`。
509    pub fn title_text(&self) -> Option<String> {
510        for sh in &self.inner.shapes {
511            if let OxmlSlideShape::Sp(sp) = sh {
512                if sp.is_placeholder {
513                    let is_title = sp
514                        .ph_type
515                        .as_deref()
516                        .map(|t| t == "title" || t == "ctrTitle")
517                        .unwrap_or(false)
518                        || sp.ph_idx == Some(0);
519                    if is_title {
520                        return Some(extract_textbody_text(&sp.text));
521                    }
522                }
523            }
524        }
525        None
526    }
527
528    /// 向正文占位符**追加**一个段落(TODO-007)。
529    ///
530    /// 对标 python-pptx `slide.placeholders[1].text_frame.add_paragraph()`。
531    ///
532    /// 查找策略:优先 `ph_type == "body"`,其次 `ph_idx == 1`。
533    /// 找到后在文本体末尾追加一个新段落(单 Run,文本为 `text`)。
534    ///
535    /// # 返回
536    /// - `true`:找到正文占位符并已追加;
537    /// - `false`:未找到正文占位符。
538    pub fn append_body_paragraph(&mut self, text: &str) -> bool {
539        for sh in &mut self.inner.shapes {
540            if let OxmlSlideShape::Sp(sp) = sh {
541                if sp.is_placeholder {
542                    let is_body = sp
543                        .ph_type
544                        .as_deref()
545                        .map(|t| t == "body" || t == "obj")
546                        .unwrap_or(false)
547                        || sp.ph_idx == Some(1);
548                    if is_body {
549                        let r = crate::oxml::txbody::Run {
550                            text: text.to_string(),
551                            ..Default::default()
552                        };
553                        let mut p = crate::oxml::txbody::Paragraph::default();
554                        p.runs.push(r);
555                        sp.text.paragraphs.push(p);
556                        return true;
557                    }
558                }
559            }
560        }
561        false
562    }
563
564    /// 设置正文占位符的文本(**替换**全部段落,TODO-007)。
565    ///
566    /// 对标 python-pptx `slide.placeholders[1].text_frame.text = "..."`。
567    ///
568    /// # 返回
569    /// - `true`:找到正文占位符并已设置;
570    /// - `false`:未找到正文占位符。
571    pub fn set_body_text(&mut self, text: &str) -> bool {
572        for sh in &mut self.inner.shapes {
573            if let OxmlSlideShape::Sp(sp) = sh {
574                if sp.is_placeholder {
575                    let is_body = sp
576                        .ph_type
577                        .as_deref()
578                        .map(|t| t == "body" || t == "obj")
579                        .unwrap_or(false)
580                        || sp.ph_idx == Some(1);
581                    if is_body {
582                        let mut tb = crate::oxml::txbody::TextBody::new();
583                        tb.set_text(text);
584                        sp.text = tb;
585                        return true;
586                    }
587                }
588            }
589        }
590        false
591    }
592
593    /// 取正文占位符的文本(TODO-007)。
594    ///
595    /// 对标 python-pptx `slide.placeholders[1].text_frame.text`。
596    /// 未找到正文占位符时返回 `None`。
597    pub fn body_text(&self) -> Option<String> {
598        for sh in &self.inner.shapes {
599            if let OxmlSlideShape::Sp(sp) = sh {
600                if sp.is_placeholder {
601                    let is_body = sp
602                        .ph_type
603                        .as_deref()
604                        .map(|t| t == "body" || t == "obj")
605                        .unwrap_or(false)
606                        || sp.ph_idx == Some(1);
607                    if is_body {
608                        return Some(extract_textbody_text(&sp.text));
609                    }
610                }
611            }
612        }
613        None
614    }
615
616    /// 设置页脚占位符的文本(TODO-007 剩余小项)。
617    ///
618    /// 对标 python-pptx `slide.placeholders[footer_idx].text_frame.text = "..."`。
619    ///
620    /// 查找策略:仅按 `ph_type == "ftr"` 匹配(不按 `ph_idx` 回退,因为页脚
621    /// 占位符的 idx 在不同版式中取值不一)。找到后**替换**其文本体为单段落单 Run。
622    ///
623    /// # 返回
624    /// - `true`:找到页脚占位符并已设置;
625    /// - `false`:未找到页脚占位符(页脚占位符需由版式/母版提供)。
626    pub fn set_footer_text(&mut self, text: &str) -> bool {
627        for sh in &mut self.inner.shapes {
628            if let OxmlSlideShape::Sp(sp) = sh {
629                if sp.is_placeholder && sp.ph_type.as_deref() == Some("ftr") {
630                    let mut tb = crate::oxml::txbody::TextBody::new();
631                    tb.set_text(text);
632                    sp.text = tb;
633                    return true;
634                }
635            }
636        }
637        false
638    }
639
640    /// 取页脚占位符的文本(TODO-007 剩余小项)。
641    ///
642    /// 未找到页脚占位符时返回 `None`。
643    pub fn footer_text(&self) -> Option<String> {
644        for sh in &self.inner.shapes {
645            if let OxmlSlideShape::Sp(sp) = sh {
646                if sp.is_placeholder && sp.ph_type.as_deref() == Some("ftr") {
647                    return Some(extract_textbody_text(&sp.text));
648                }
649            }
650        }
651        None
652    }
653
654    /// 设置日期占位符的文本(TODO-007 剩余小项)。
655    ///
656    /// 对标 python-pptx `slide.placeholders[dt_idx].text_frame.text = "..."`。
657    ///
658    /// 查找策略:仅按 `ph_type == "dt"` 匹配。找到后**替换**其文本体。
659    ///
660    /// # 注意
661    /// PowerPoint 默认会让日期占位符显示"自动更新日期";一旦显式设置文本,
662    /// 会覆盖自动日期。如需恢复自动日期,请重新从版式继承占位符。
663    ///
664    /// # 返回
665    /// - `true`:找到日期占位符并已设置;
666    /// - `false`:未找到日期占位符。
667    pub fn set_date_text(&mut self, text: &str) -> bool {
668        for sh in &mut self.inner.shapes {
669            if let OxmlSlideShape::Sp(sp) = sh {
670                if sp.is_placeholder && sp.ph_type.as_deref() == Some("dt") {
671                    let mut tb = crate::oxml::txbody::TextBody::new();
672                    tb.set_text(text);
673                    sp.text = tb;
674                    return true;
675                }
676            }
677        }
678        false
679    }
680
681    /// 取日期占位符的文本(TODO-007 剩余小项)。
682    ///
683    /// 未找到日期占位符时返回 `None`。
684    pub fn date_text(&self) -> Option<String> {
685        for sh in &self.inner.shapes {
686            if let OxmlSlideShape::Sp(sp) = sh {
687                if sp.is_placeholder && sp.ph_type.as_deref() == Some("dt") {
688                    return Some(extract_textbody_text(&sp.text));
689                }
690            }
691        }
692        None
693    }
694
695    /// 设置幻灯片编号占位符的文本(TODO-007 剩余小项)。
696    ///
697    /// 对标 python-pptx `slide.placeholders[sldNum_idx].text_frame.text = "..."`。
698    ///
699    /// 查找策略:仅按 `ph_type == "sldNum"` 匹配。找到后**替换**其文本体。
700    ///
701    /// # 注意
702    /// 与日期占位符类似,PowerPoint 默认会自动渲染当前页码;显式设置文本
703    /// 会覆盖自动页码。
704    ///
705    /// # 返回
706    /// - `true`:找到编号占位符并已设置;
707    /// - `false`:未找到编号占位符。
708    pub fn set_slide_number_text(&mut self, text: &str) -> bool {
709        for sh in &mut self.inner.shapes {
710            if let OxmlSlideShape::Sp(sp) = sh {
711                if sp.is_placeholder && sp.ph_type.as_deref() == Some("sldNum") {
712                    let mut tb = crate::oxml::txbody::TextBody::new();
713                    tb.set_text(text);
714                    sp.text = tb;
715                    return true;
716                }
717            }
718        }
719        false
720    }
721
722    /// 取幻灯片编号占位符的文本(TODO-007 剩余小项)。
723    ///
724    /// 未找到编号占位符时返回 `None`。
725    pub fn slide_number_text(&self) -> Option<String> {
726        for sh in &self.inner.shapes {
727            if let OxmlSlideShape::Sp(sp) = sh {
728                if sp.is_placeholder && sp.ph_type.as_deref() == Some("sldNum") {
729                    return Some(extract_textbody_text(&sp.text));
730                }
731            }
732        }
733        None
734    }
735
736    /// 取得 slide 背景的**高阶视图**(只读)。
737    ///
738    /// 对标 python-pptx `Slide.background`。
739    ///
740    /// # 实现说明
741    /// 返回的 [`SlideBackground`] 句柄仅提供**读取**能力(如 `fill_type()`)。
742    /// 若需修改背景,请使用以下方法:
743    /// - [`Self::set_background_solid`]:设置纯色背景;
744    /// - [`Self::clear_background`]:清空独立背景;
745    /// - [`Self::set_follow_master_background`]:切换继承/独立。
746    pub fn background(&self) -> SlideBackground<'_> {
747        SlideBackground { slide: self }
748    }
749
750    /// 读取幻灯片过渡(`<p:transition>`)。
751    ///
752    /// 对标 python-pptx `slide.transition`(python-pptx 实际只暴露底层元素,本 API 返回结构体)。
753    ///
754    /// 返回 `Some(&Transition)` 表示该幻灯片已设置过渡;`None` 表示未设置(遵循 PowerPoint 默认行为)。
755    ///
756    /// # 示例
757    ///
758    /// ```no_run
759    /// # use pptx_rs::Presentation;
760    /// # let mut p = Presentation::new().unwrap();
761    /// # let counter = p.id_counter();
762    /// # let s = p.slides_mut().add_slide(counter).unwrap();
763    /// if let Some(t) = s.transition() {
764    ///     println!("speed = {:?}", t.speed);
765    /// }
766    /// ```
767    pub fn transition(&self) -> Option<&crate::oxml::slide::Transition> {
768        self.inner.transition.as_ref()
769    }
770
771    /// 设置幻灯片过渡(`<p:transition>`)。
772    ///
773    /// 对标 python-pptx 中通过 `slide.element.transition` 操作过渡的方式,本 API 接收结构体直接覆盖。
774    ///
775    /// 若传入 `TransitionType::None`,等价于 [`Self::clear_transition`]。
776    ///
777    /// # 参数
778    /// - `transition`:过渡配置(速度/换片方式/类型)
779    ///
780    /// # 示例
781    ///
782    /// ```no_run
783    /// # use pptx_rs::{Presentation, Transition, TransitionSpeed, TransitionType};
784    /// # let mut p = Presentation::new().unwrap();
785    /// # let counter = p.id_counter();
786    /// # let s = p.slides_mut().add_slide(counter).unwrap();
787    /// let t = Transition {
788    ///     speed: TransitionSpeed::Slow,
789    ///     advance_click: true,
790    ///     advance_after_ms: Some(5000),
791    ///     transition_type: TransitionType::Fade { thru_blk: false },
792    /// };
793    /// s.set_transition(t);
794    /// ```
795    pub fn set_transition(&mut self, transition: crate::oxml::slide::Transition) {
796        if matches!(
797            transition.transition_type,
798            crate::oxml::slide::TransitionType::None
799        ) {
800            self.inner.transition = None;
801        } else {
802            self.inner.transition = Some(transition);
803        }
804    }
805
806    /// 清除幻灯片过渡(等价于删除 `<p:transition>` 元素)。
807    ///
808    /// 清除后该幻灯片将使用 PowerPoint 默认的"无过渡"行为。
809    pub fn clear_transition(&mut self) {
810        self.inner.transition = None;
811    }
812
813    /// 备注文本体(`TextBody`)的可选引用。
814    ///
815    /// 对标 python-pptx `slide.notes_slide.notes_text_frame`。
816    /// 当未设置备注时返回 `None`。
817    pub fn notes(&self) -> Option<&crate::oxml::txbody::TextBody> {
818        self.inner.notes.as_ref()
819    }
820    /// 备注文本体(`TextBody`)的可变引用。
821    pub fn notes_mut(&mut self) -> Option<&mut crate::oxml::txbody::TextBody> {
822        self.inner.notes.as_mut()
823    }
824    /// 直接覆盖备注文本体。`None` 表示删除备注。
825    pub fn set_notes(&mut self, tb: Option<crate::oxml::txbody::TextBody>) {
826        self.inner.notes = tb;
827    }
828
829    /// 备注 part 的关系 id(`rIdNotesN`),由 `Presentation::save` 在打包时注入。
830    pub fn notes_rid(&self) -> Option<&str> {
831        self.notes_rid.as_deref()
832    }
833    /// 显式设置备注 part 的关系 id(**仅供 `Presentation::save` 内部使用**)。
834    pub(crate) fn set_notes_rid(&mut self, rid: String) {
835        self.notes_rid = Some(rid);
836    }
837
838    /// 取备注 part 路径(仅供 `Presentation::save` 内部使用)。
839    pub(crate) fn notes_partname(&self) -> Option<&str> {
840        self.notes_partname.as_deref()
841    }
842    /// 设置备注 part 路径(**仅供 `Presentation::from_opc` 内部使用**)。
843    ///
844    /// 由读路径在解析 `slideN.xml.rels` 找到 `NotesSlide` 关系后回填,
845    /// 保证 save 时复用原始 partname。
846    pub(crate) fn set_notes_partname(&mut self, partname: String) {
847        self.notes_partname = Some(partname);
848    }
849
850    /// 取 `notesSlideN.xml.rels` 中的 `Slide` 关系 target(仅供内部)。
851    pub(crate) fn notes_slide_rel_target(&self) -> Option<&str> {
852        self.notes_slide_rel_target.as_deref()
853    }
854    /// 设置 `notesSlideN.xml.rels` 中的 `Slide` 关系 target(**仅供内部**)。
855    ///
856    /// 由读路径在解析 `notesSlideN.xml.rels` 找到 `Slide` 关系后回填。
857    pub(crate) fn set_notes_slide_rel_target(&mut self, target: String) {
858        self.notes_slide_rel_target = Some(target);
859    }
860
861    /// 设置备注文本(**整体替换**)。
862    ///
863    /// 若 `text` 为 `None`,则删除 notes;否则按 `\n` 切分为多段。
864    /// 备注的持久化由 [`crate::presentation::Presentation::save`] 在打包阶段
865    /// 写入 `/ppt/notesSlides/notesSlideN.xml`;本方法仅设置内存模型。
866    pub fn set_notes_text(&mut self, text: Option<&str>) {
867        if let Some(t) = text {
868            let mut tb = crate::oxml::txbody::TextBody::new();
869            for line in t.split('\n') {
870                let mut p = crate::oxml::txbody::Paragraph::new();
871                p.runs.push(crate::oxml::txbody::Run::new(line));
872                tb.paragraphs.push(p);
873            }
874            self.inner.notes = Some(tb);
875        } else {
876            self.inner.notes = None;
877        }
878    }
879
880    // ==================== 评论 API ====================
881
882    /// 返回该 slide 的评论列表(不可变)。
883    ///
884    /// `None` 表示该 slide 没有评论 part。
885    pub fn comments(&self) -> Option<&crate::oxml::comments::CommentList> {
886        self.comments.as_ref()
887    }
888
889    /// 返回该 slide 的评论列表(可变)。
890    pub fn comments_mut(&mut self) -> Option<&mut crate::oxml::comments::CommentList> {
891        self.comments.as_mut()
892    }
893
894    /// 直接覆盖评论列表。`None` 表示删除评论。
895    pub fn set_comments(&mut self, lst: Option<crate::oxml::comments::CommentList>) {
896        self.comments = lst;
897    }
898
899    /// 添加一条评论。
900    ///
901    /// 这是便捷 API:自动维护评论索引(`idx` 在该 slide 内递增),
902    /// 并确保 `comments` 字段为 `Some`。
903    ///
904    /// # 参数
905    /// - `author_id`:作者 ID(需在 `Presentation.comment_authors` 中存在);
906    /// - `pos_x` / `pos_y`:评论锚点坐标(EMU);
907    /// - `text`:评论正文。
908    ///
909    /// # 返回值
910    /// 新评论在该 slide 中的 `idx`。
911    ///
912    /// # 示例
913    ///
914    /// ```no_run
915    /// use pptx_rs::Presentation;
916    /// use pptx_rs::Inches;
917    ///
918    /// let mut p = Presentation::new().unwrap();
919    /// let counter = p.id_counter();
920    /// // 先注册作者,拿到 author_id(必须在借用 slides_mut 之前完成)
921    /// let author_id = p.comment_authors_mut().get_or_insert_id("张三", "ZS");
922    /// let slide = p.slides_mut().add_slide(counter).unwrap();
923    /// let idx = slide.add_comment(author_id, Inches(1.0), Inches(1.0), "评论内容");
924    /// ```
925    pub fn add_comment<X: crate::units::EmuExt, Y: crate::units::EmuExt>(
926        &mut self,
927        author_id: u32,
928        pos_x: X,
929        pos_y: Y,
930        text: impl Into<String>,
931    ) -> u32 {
932        let lst = self
933            .comments
934            .get_or_insert_with(crate::oxml::comments::CommentList::new);
935        // idx 在该 slide 内递增(从 1 开始)
936        let next_idx = lst
937            .comments
938            .iter()
939            .map(|c| c.idx)
940            .max()
941            .unwrap_or(0)
942            .saturating_add(1);
943        let c = crate::oxml::comments::Comment::new(
944            author_id,
945            next_idx,
946            pos_x.emu().value(),
947            pos_y.emu().value(),
948            text,
949        );
950        lst.push(c);
951        next_idx
952    }
953
954    /// 清除该 slide 的所有评论。
955    pub fn clear_comments(&mut self) {
956        self.comments = None;
957    }
958
959    /// 评论 part 路径(仅供 `Presentation::save` 内部使用)。
960    pub(crate) fn comments_partname(&self) -> Option<&str> {
961        self.comments_partname.as_deref()
962    }
963
964    /// 设置评论 part 路径(**仅供 `Presentation::from_opc` 内部使用**)。
965    pub(crate) fn set_comments_partname(&mut self, partname: String) {
966        self.comments_partname = Some(partname);
967    }
968
969    /// 评论 part 的关系 id(仅供内部)。
970    #[allow(dead_code)]
971    pub(crate) fn comments_rid(&self) -> Option<&str> {
972        self.comments_rid.as_deref()
973    }
974
975    /// 设置评论 part 的关系 id(**仅供 `Presentation::save` 内部使用**)。
976    pub(crate) fn set_comments_rid(&mut self, rid: String) {
977        self.comments_rid = Some(rid);
978    }
979
980    /// 分配一个**全局唯一**的 shape id(在所属 `Presentation` 内)。
981    ///
982    /// 由 `ShapesMut` 的 `add_*` 流程调用;用户**不应**直接调用。
983    pub(crate) fn next_shape_id(&self) -> u32 {
984        let v = self.id_counter.get() + 1;
985        self.id_counter.set(v);
986        v
987    }
988
989    /// 分配一个**全局唯一**的图片关系 id(`rIdImg1` / `rIdImg2` ...)。
990    ///
991    /// 由 [`crate::slide::ShapesMut::add_picture`] 内部调用。
992    pub(crate) fn allocate_image_rid(&self) -> String {
993        let v = self.image_rid_counter.get() + 1;
994        self.image_rid_counter.set(v);
995        format!("rIdImg{}", v)
996    }
997
998    /// 分配一个**全局唯一**的媒体索引。
999    pub(crate) fn next_media_index(&self) -> u32 {
1000        let v = self.media_index_counter.get() + 1;
1001        self.media_index_counter.set(v);
1002        v
1003    }
1004
1005    /// 把媒体条目注册到本 slide(保存时统一写 zip)。
1006    pub(crate) fn register_media(&mut self, entry: MediaEntry) {
1007        self.media_entries.push(entry);
1008    }
1009
1010    /// 分配一个**本 slide 内**唯一的图表关系 id(`rIdChart1` / `rIdChart2` ...)。
1011    ///
1012    /// 由 [`ShapesMut::add_chart`] 内部调用,用于在 `slideN.xml.rels` 中
1013    /// 显式添加 `<Relationship Type=".../chart" Target="../charts/chartN.xml"/>`。
1014    pub(crate) fn allocate_chart_rid(&self) -> String {
1015        let v = self.chart_rid_counter.get() + 1;
1016        self.chart_rid_counter.set(v);
1017        format!("rIdChart{}", v)
1018    }
1019
1020    /// 分配一个图表 part 索引(用于生成 `chart{N}.xml` 文件名)。
1021    ///
1022    /// **注意**:该计数器是 slide 局部的;`to_opc_package` 在打包阶段会
1023    /// 用一个**全局**递增的 `chart_index` 重新分配 partname,避免多 slide
1024    /// 之间的索引冲突。本方法仅用于在 `add_chart` 时占位。
1025    pub(crate) fn next_chart_index(&self) -> u32 {
1026        let v = self.chart_index_counter.get() + 1;
1027        self.chart_index_counter.set(v);
1028        v
1029    }
1030
1031    /// 把图表条目注册到本 slide(保存时统一写出 chartN.xml part + rels)。
1032    pub(crate) fn register_chart(&mut self, entry: ChartEntry) {
1033        self.chart_entries.push(entry);
1034    }
1035
1036    /// 分配一个**本 slide 内**唯一的 OLE 关系 id(`rIdOle1` / `rIdOle2` ...)。
1037    ///
1038    /// 由 [`ShapesMut::add_ole_object`] 内部调用,用于在 `slideN.xml.rels` 中
1039    /// 显式添加 `<Relationship Type=".../oleObject" Target="../embeddings/oleObjectN.bin"/>`。
1040    pub(crate) fn allocate_ole_rid(&self) -> String {
1041        let v = self.ole_rid_counter.get() + 1;
1042        self.ole_rid_counter.set(v);
1043        format!("rIdOle{}", v)
1044    }
1045
1046    /// 分配一个 OLE part 索引(用于生成 `oleObject{N}.bin` 文件名)。
1047    ///
1048    /// **注意**:该计数器是 slide 局部的;`to_opc_package` 在打包阶段会
1049    /// 用一个**全局**递增的 `ole_global_index` 重新分配 partname,避免多 slide
1050    /// 之间的索引冲突。本方法仅用于在 `add_ole_object` 时占位。
1051    pub(crate) fn next_ole_index(&self) -> u32 {
1052        let v = self.ole_index_counter.get() + 1;
1053        self.ole_index_counter.set(v);
1054        v
1055    }
1056
1057    /// 把 OLE 对象条目注册到本 slide(保存时统一写出 oleObjectN.bin part + rels)。
1058    pub(crate) fn register_ole(&mut self, entry: OleEntry) {
1059        self.ole_entries.push(entry);
1060    }
1061
1062    /// 分配一个**本 slide 内**唯一的视频关系 id(`rIdVideo1` / `rIdVideo2` ...,TODO-033)。
1063    ///
1064    /// 由 [`ShapesMut::add_video`] 内部调用,用于在 `slideN.xml.rels` 中
1065    /// 显式添加 `<Relationship Type=".../video" Target="../media/mediaN.mp4"/>`。
1066    pub(crate) fn allocate_video_rid(&self) -> String {
1067        let v = self.video_rid_counter.get() + 1;
1068        self.video_rid_counter.set(v);
1069        format!("rIdVideo{}", v)
1070    }
1071
1072    /// 分配一个视频 part 索引(用于生成 `media{N}.mp4` 文件名,TODO-033)。
1073    ///
1074    /// **注意**:该计数器是 slide 局部的;`to_opc_package` 在打包阶段会
1075    /// 用一个**全局**递增的 `video_global_index` 重新分配 partname,避免多 slide
1076    /// 之间的索引冲突。本方法仅用于在 `add_video` 时占位。
1077    pub(crate) fn next_video_index(&self) -> u32 {
1078        let v = self.video_index_counter.get() + 1;
1079        self.video_index_counter.set(v);
1080        v
1081    }
1082
1083    /// 把视频条目注册到本 slide(保存时统一写出 mediaN.mp4 part + rels,TODO-033)。
1084    pub(crate) fn register_video(&mut self, entry: VideoEntry) {
1085        self.video_entries.push(entry);
1086    }
1087
1088    /// 分配一个**本 slide 内**唯一的音频关系 id(`rIdAudio1` / `rIdAudio2` ...,TODO-033)。
1089    ///
1090    /// 由 [`ShapesMut::add_audio`] 内部调用,用于在 `slideN.xml.rels` 中
1091    /// 显式添加 `<Relationship Type=".../audio" Target="../media/mediaN.mp3"/>`。
1092    pub(crate) fn allocate_audio_rid(&self) -> String {
1093        let v = self.audio_rid_counter.get() + 1;
1094        self.audio_rid_counter.set(v);
1095        format!("rIdAudio{}", v)
1096    }
1097
1098    /// 分配一个音频 part 索引(用于生成 `media{N}.mp3` 文件名,TODO-033)。
1099    ///
1100    /// **注意**:该计数器是 slide 局部的;`to_opc_package` 在打包阶段会
1101    /// 用一个**全局**递增的 `audio_global_index` 重新分配 partname,避免多 slide
1102    /// 之间的索引冲突。本方法仅用于在 `add_audio` 时占位。
1103    pub(crate) fn next_audio_index(&self) -> u32 {
1104        let v = self.audio_index_counter.get() + 1;
1105        self.audio_index_counter.set(v);
1106        v
1107    }
1108
1109    /// 把音频条目注册到本 slide(保存时统一写出 mediaN.mp3 part + rels,TODO-033)。
1110    pub(crate) fn register_audio(&mut self, entry: AudioEntry) {
1111        self.audio_entries.push(entry);
1112    }
1113
1114    // --------------------- SmartArt(TODO-037) ---------------------
1115
1116    /// 分配一个 SmartArt part 索引(用于生成 `data{N}.xml` / `layout{N}.xml` 等文件名,TODO-037)。
1117    ///
1118    /// **注意**:该计数器是 slide 局部的;`to_opc_package` 在打包阶段会
1119    /// 用一个**全局**递增的 `diagram_global_index` 重新分配 partname,避免多 slide
1120    /// 之间的索引冲突。本方法仅用于在 `add_diagram` 时占位。
1121    pub(crate) fn next_diagram_index(&self) -> u32 {
1122        let v = self.diagram_index_counter.get() + 1;
1123        self.diagram_index_counter.set(v);
1124        v
1125    }
1126
1127    /// 分配 4 个 SmartArt 关系 id(`rIdDgmData1` / `rIdDgmLayout1` / `rIdDgmQs1` / `rIdDgmColors1`,TODO-037)。
1128    ///
1129    /// 由 `ShapesMut::add_diagram` 内部调用,用于在 `slideN.xml.rels` 中
1130    /// 显式添加 4 个关系:`diagramData` / `diagramLayout` / `diagramQuickStyle` / `diagramColors`。
1131    ///
1132    /// 返回值顺序固定为 `(data_rid, layout_rid, quick_style_rid, colors_rid)`。
1133    pub(crate) fn allocate_diagram_rids(&self) -> (String, String, String, String) {
1134        let v = self.diagram_rid_counter.get() + 1;
1135        self.diagram_rid_counter.set(v);
1136        (
1137            format!("rIdDgmData{}", v),
1138            format!("rIdDgmLayout{}", v),
1139            format!("rIdDgmQs{}", v),
1140            format!("rIdDgmColors{}", v),
1141        )
1142    }
1143
1144    /// 把 SmartArt 条目注册到本 slide(保存时统一写出 4 个 diagramN.xml part + rels,TODO-037)。
1145    pub(crate) fn register_diagram(&mut self, entry: DiagramEntry) {
1146        self.diagram_entries.push(entry);
1147    }
1148}
1149
1150/// 不可变形状视图。
1151///
1152/// 通过 `slide.shapes()` 获取,提供 `len` / `is_empty` / `iter` / `get` 等只读 API。
1153#[derive(Debug)]
1154pub struct Shapes<'a> {
1155    slide: &'a Slide,
1156}
1157
1158impl<'a> Shapes<'a> {
1159    /// 形状数量。
1160    pub fn len(&self) -> usize {
1161        self.slide.inner.shapes.len()
1162    }
1163    /// 是否为空。
1164    pub fn is_empty(&self) -> bool {
1165        self.slide.inner.shapes.is_empty()
1166    }
1167
1168    /// 遍历所有形状(克隆为 `ShapeKind` 枚举)。
1169    ///
1170    /// 返回的 `ShapeKind` 是高阶句柄,与原 `Slide` 拥有独立生命周期;
1171    /// 适合"先收集再逐个处理"的链式风格。
1172    pub fn iter(&self) -> impl Iterator<Item = ShapeKind> + 'a {
1173        let v: Vec<ShapeKind> = self
1174            .slide
1175            .inner
1176            .shapes
1177            .iter()
1178            .map(crate::shape::wrap)
1179            .collect();
1180        v.into_iter()
1181    }
1182
1183    /// 按索引取一个形状(克隆为 `ShapeKind`)。
1184    pub fn get(&self, idx: usize) -> Option<ShapeKind> {
1185        self.slide.inner.shapes.get(idx).map(crate::shape::wrap)
1186    }
1187
1188    /// 取**标题占位符**(若有)。
1189    ///
1190    /// 对标 python-pptx `Slide.shapes.title`。
1191    ///
1192    /// # 判定策略
1193    /// 按以下顺序查找第一个命中的形状(顺序与 python-pptx 略有差异,但行为更直观):
1194    /// 1. `ph_type == "title"` 或 `ph_type == "ctrTitle"`(居中标题);
1195    /// 2. `ph_idx == 0` 的占位符(OOXML 默认 idx=0 即为标题位);
1196    /// 3. `name` 含 "title"(不区分大小写)的占位符(兼容手工制作的 slide)。
1197    ///
1198    /// 返回 `None` 表示该 slide 没有标题占位符。
1199    pub fn title(&self) -> Option<ShapeKind> {
1200        for sh in &self.slide.inner.shapes {
1201            if let OxmlSlideShape::Sp(sp) = sh {
1202                if sp.is_placeholder {
1203                    if let Some(t) = &sp.ph_type {
1204                        if t == "title" || t == "ctrTitle" {
1205                            return Some(crate::shape::wrap(sh));
1206                        }
1207                    }
1208                    if sp.ph_idx == Some(0) {
1209                        return Some(crate::shape::wrap(sh));
1210                    }
1211                }
1212                if sp.name.to_ascii_lowercase().contains("title") {
1213                    return Some(crate::shape::wrap(sh));
1214                }
1215            }
1216        }
1217        None
1218    }
1219
1220    /// 取所有**占位符**形状(按 `ph_idx` 升序排列;同 idx 多个按原顺序)。
1221    ///
1222    /// 对标 python-pptx `Slide.placeholders` / `Slide.shapes.placeholders`。
1223    pub fn placeholders(&self) -> Vec<ShapeKind> {
1224        let mut out: Vec<(Option<u32>, usize, ShapeKind)> = Vec::new();
1225        for (i, sh) in self.slide.inner.shapes.iter().enumerate() {
1226            if let OxmlSlideShape::Sp(sp) = sh {
1227                if sp.is_placeholder {
1228                    out.push((sp.ph_idx, i, crate::shape::wrap(sh)));
1229                }
1230            }
1231        }
1232        // 按 (ph_idx, i) 排序——保证稳定的遍历顺序
1233        out.sort_by_key(|(idx, i, _)| (*idx, *i));
1234        out.into_iter().map(|(_, _, s)| s).collect()
1235    }
1236
1237    /// 按 `ph_idx` 查占位符。
1238    ///
1239    /// 对标 python-pptx `slide.placeholders[idx]` 的 `__getitem__` 语义。
1240    /// 返回第一个 `ph_idx == idx` 的占位符;没有则返回 `None`。
1241    pub fn placeholder(&self, idx: u32) -> Option<ShapeKind> {
1242        for sh in &self.slide.inner.shapes {
1243            if let OxmlSlideShape::Sp(sp) = sh {
1244                if sp.is_placeholder && sp.ph_idx == Some(idx) {
1245                    return Some(crate::shape::wrap(sh));
1246                }
1247            }
1248        }
1249        None
1250    }
1251
1252    /// 取所有占位符,并从给定版式继承位置/尺寸/填充/边框。
1253    ///
1254    /// 对标 python-pptx `slide.placeholders` 的继承语义:当 slide 占位符
1255    /// 未显式设置 xfrm / fill / line 时,从 layout 中同 `ph_idx` 的占位符继承。
1256    ///
1257    /// # 参数
1258    /// - `layout`:所属 Presentation 中的版式引用(通过 `Presentation::layout_for_slide` 获取)。
1259    ///
1260    /// # 返回
1261    /// 返回的 `ShapeKind::Placeholder` 携带**继承后**的属性快照(clone),
1262    /// 修改返回值**不会**回写到 slide。如需修改 slide 上的占位符,请用
1263    /// `ShapesMut::placeholder_mut`。
1264    ///
1265    /// # 继承规则
1266    /// 1. 若 slide 占位符 `xfrm.is_empty()`,从 layout 占位符继承 xfrm;
1267    /// 2. 若 slide 占位符 `fill == Fill::Inherit`,从 layout 占位符继承 fill;
1268    /// 3. 若 slide 占位符 `line == None`,从 layout 占位符继承 line;
1269    /// 4. `ph_type` / `ph_idx` 保持 slide 自身值不变。
1270    pub fn placeholders_inherited(
1271        &self,
1272        layout: &crate::slide_layouts::SlideLayoutRef,
1273    ) -> Vec<ShapeKind> {
1274        let layout_spans = layout.oxml.borrow();
1275        let mut out: Vec<(Option<u32>, usize, ShapeKind)> = Vec::new();
1276        for (i, sh) in self.slide.inner.shapes.iter().enumerate() {
1277            match sh {
1278                OxmlSlideShape::Sp(sp) if sp.is_placeholder => {
1279                    // 在 layout 中查找匹配的占位符(按 ph_idx 优先,其次 ph_type)
1280                    let layout_ph = find_layout_placeholder(&layout_spans.shapes, sp);
1281                    let merged = if let Some(lph) = layout_ph {
1282                        let mut cloned = sp.clone();
1283                        inherit_placeholder_from_layout(&mut cloned, lph);
1284                        crate::shape::ShapeKind::Placeholder(crate::shape::PlaceholderShape(
1285                            crate::shape::AutoShape::from_sp(cloned),
1286                        ))
1287                    } else {
1288                        crate::shape::wrap(sh)
1289                    };
1290                    out.push((sp.ph_idx, i, merged));
1291                }
1292                // TODO-007:识别 Pic 占位符(图片占位符填充)。
1293                // Pic 占位符的位置/尺寸在 add_picture_to_placeholder 时已从 layout 继承,
1294                // 此处不再二次合并,直接以 ShapeKind::Picture 返回。
1295                OxmlSlideShape::Pic(pic) if pic.is_placeholder => {
1296                    out.push((pic.ph_idx, i, crate::shape::wrap(sh)));
1297                }
1298                // TODO-007:识别 GraphicFrame 占位符(图表/表格占位符填充)。
1299                // GraphicFrame 占位符的位置/尺寸在 add_chart_to_placeholder /
1300                // add_table_to_placeholder 时已从 layout 继承,此处不再二次合并,
1301                // 直接以 ShapeKind::Chart / ShapeKind::Table 返回。
1302                OxmlSlideShape::GraphicFrame(gf) if gf.is_placeholder => {
1303                    out.push((gf.ph_idx, i, crate::shape::wrap(sh)));
1304                }
1305                _ => {}
1306            }
1307        }
1308        out.sort_by_key(|(idx, i, _)| (*idx, *i));
1309        out.into_iter().map(|(_, _, s)| s).collect()
1310    }
1311
1312    /// 按 `ph_idx` 查占位符,并从给定版式继承位置/尺寸/填充/边框。
1313    ///
1314    /// 与 [`Shapes::placeholders_inherited`] 的区别:仅返回指定 idx 的占位符。
1315    pub fn placeholder_inherited(
1316        &self,
1317        idx: u32,
1318        layout: &crate::slide_layouts::SlideLayoutRef,
1319    ) -> Option<ShapeKind> {
1320        let _layout_spans = layout.oxml.borrow();
1321        for sh in &self.slide.inner.shapes {
1322            match sh {
1323                OxmlSlideShape::Sp(sp) if sp.is_placeholder && sp.ph_idx == Some(idx) => {
1324                    let layout_ph = find_layout_placeholder(&_layout_spans.shapes, sp);
1325                    let merged = if let Some(lph) = layout_ph {
1326                        let mut cloned = sp.clone();
1327                        inherit_placeholder_from_layout(&mut cloned, lph);
1328                        crate::shape::ShapeKind::Placeholder(crate::shape::PlaceholderShape(
1329                            crate::shape::AutoShape::from_sp(cloned),
1330                        ))
1331                    } else {
1332                        crate::shape::wrap(sh)
1333                    };
1334                    return Some(merged);
1335                }
1336                // TODO-007:识别 Pic 占位符(图片占位符填充)。
1337                OxmlSlideShape::Pic(pic) if pic.is_placeholder && pic.ph_idx == Some(idx) => {
1338                    return Some(crate::shape::wrap(sh));
1339                }
1340                // TODO-007:识别 GraphicFrame 占位符(图表/表格占位符填充)。
1341                OxmlSlideShape::GraphicFrame(gf) if gf.is_placeholder && gf.ph_idx == Some(idx) => {
1342                    return Some(crate::shape::wrap(sh));
1343                }
1344                _ => {}
1345            }
1346        }
1347        None
1348    }
1349}
1350
1351/// 在 layout 的 shapes 中查找与 slide 占位符匹配的占位符。
1352///
1353/// 匹配规则(与 python-pptx 对齐):
1354/// 1. `ph_idx` 相同 → 命中(最常见情况);
1355/// 2. `ph_type` 相同且 `ph_idx` 都为 None → 命中(兼容无 idx 的占位符)。
1356fn find_layout_placeholder<'a>(
1357    layout_shapes: &'a [crate::oxml::shape::Sp],
1358    slide_sp: &crate::oxml::shape::Sp,
1359) -> Option<&'a crate::oxml::shape::Sp> {
1360    // 优先按 ph_idx 匹配
1361    if let Some(idx) = slide_sp.ph_idx {
1362        for lsp in layout_shapes {
1363            if lsp.is_placeholder && lsp.ph_idx == Some(idx) {
1364                return Some(lsp);
1365            }
1366        }
1367    }
1368    // 回退:按 ph_type 匹配(当 slide 占位符有 type 但无 idx 时)
1369    if let Some(pht) = &slide_sp.ph_type {
1370        for lsp in layout_shapes {
1371            if lsp.is_placeholder && lsp.ph_type.as_deref() == Some(pht.as_str()) {
1372                return Some(lsp);
1373            }
1374        }
1375    }
1376    None
1377}
1378
1379/// 把 layout 占位符的属性继承到 slide 占位符(原地修改)。
1380///
1381/// 继承规则见 [`Shapes::placeholders_inherited`] 文档。
1382fn inherit_placeholder_from_layout(
1383    slide_sp: &mut crate::oxml::shape::Sp,
1384    layout_sp: &crate::oxml::shape::Sp,
1385) {
1386    // 1. xfrm 继承:slide 占位符未设置位置/尺寸时,从 layout 继承
1387    if slide_sp.properties.xfrm.is_empty() {
1388        slide_sp.properties.xfrm = layout_sp.properties.xfrm;
1389    }
1390    // 2. fill 继承:slide 占位符 fill 为 Inherit 时,从 layout 继承
1391    if matches!(slide_sp.properties.fill, crate::oxml::sppr::Fill::Inherit) {
1392        slide_sp.properties.fill = layout_sp.properties.fill.clone();
1393    }
1394    // 3. line 继承:slide 占位符 line 为 None 时,从 layout 继承
1395    if slide_sp.properties.line.is_none() {
1396        slide_sp.properties.line = layout_sp.properties.line.clone();
1397    }
1398    // 4. 几何继承:slide 占位符 geometry 为 None 时,从 layout 继承
1399    if slide_sp.properties.geometry.is_none() {
1400        slide_sp.properties.geometry = layout_sp.properties.geometry.clone();
1401    }
1402}
1403
1404/// 可变形状视图。
1405///
1406/// 通过 `slide.shapes_mut()` 获取,是 `add_*` / `remove` 唯一入口。
1407#[derive(Debug)]
1408pub struct ShapesMut<'a> {
1409    slide: &'a mut Slide,
1410}
1411
1412impl<'a> ShapesMut<'a> {
1413    /// 形状数量。
1414    pub fn len(&self) -> usize {
1415        self.slide.inner.shapes.len()
1416    }
1417    /// 是否为空。
1418    pub fn is_empty(&self) -> bool {
1419        self.slide.inner.shapes.is_empty()
1420    }
1421
1422    /// 遍历(**只读克隆**)。
1423    pub fn iter(&self) -> impl Iterator<Item = ShapeKind> + '_ {
1424        let v: Vec<ShapeKind> = self
1425            .slide
1426            .inner
1427            .shapes
1428            .iter()
1429            .map(crate::shape::wrap)
1430            .collect();
1431        v.into_iter()
1432    }
1433
1434    /// 按索引取(克隆)。
1435    pub fn get(&self, idx: usize) -> Option<ShapeKind> {
1436        self.slide.inner.shapes.get(idx).map(crate::shape::wrap)
1437    }
1438
1439    /// 按索引移除一个形状。
1440    ///
1441    /// 返回被移除形状的高阶克隆(`None` 表示 idx 越界)。
1442    /// 移除后,**后续 shape 的索引会前移**。
1443    pub fn remove(&mut self, idx: usize) -> Option<ShapeKind> {
1444        if idx < self.slide.inner.shapes.len() {
1445            let v = self.slide.inner.shapes.remove(idx);
1446            Some(crate::shape::wrap(&v))
1447        } else {
1448            None
1449        }
1450    }
1451
1452    /// 从版式占位符定义创建一个新的 slide 占位符(TODO-007)。
1453    ///
1454    /// 对标 python-pptx "从模板创建 slide 后填充占位符"的工作流。
1455    ///
1456    /// 本方法会:
1457    /// 1. 在 layout 的 shapes 中查找 `ph_idx` 匹配的占位符;
1458    /// 2. 克隆该 layout 占位符作为 slide 占位符的初始状态(继承位置/尺寸/格式);
1459    /// 3. 分配新的 shape id,清空文本内容(等待调用方填充);
1460    /// 4. 追加到 slide 的 shapes 末尾。
1461    ///
1462    /// # 参数
1463    /// - `ph_idx`:要创建的占位符 idx(对应 `<p:ph idx="N"/>`);
1464    /// - `layout`:所属 Presentation 中的版式引用。
1465    ///
1466    /// # 返回
1467    /// - `Ok(AutoShape)`:创建成功,返回占位符的高阶视图;
1468    /// - `Err(Error::Other)`:layout 中未找到 `ph_idx` 匹配的占位符。
1469    pub fn add_placeholder_from_layout(
1470        &mut self,
1471        ph_idx: u32,
1472        layout: &crate::slide_layouts::SlideLayoutRef,
1473    ) -> crate::Result<crate::shape::AutoShape> {
1474        let layout_borrow = layout.oxml.borrow();
1475        // 在 layout 中查找匹配 ph_idx 的占位符
1476        let mut found: Option<crate::oxml::shape::Sp> = None;
1477        for lsp in &layout_borrow.shapes {
1478            if lsp.is_placeholder && lsp.ph_idx == Some(ph_idx) {
1479                found = Some(lsp.clone());
1480                break;
1481            }
1482        }
1483        let mut sp = found.ok_or_else(|| {
1484            crate::Error::Other(format!("layout 中未找到 ph_idx={} 的占位符", ph_idx))
1485        })?;
1486        // 分配新 id,清空文本(等待调用方填充)
1487        sp.id = self.slide.next_shape_id();
1488        sp.text = crate::oxml::txbody::TextBody::new();
1489        let auto = crate::shape::AutoShape::from_sp(sp.clone());
1490        self.slide.inner.shapes.push(OxmlSlideShape::Sp(sp));
1491        Ok(auto)
1492    }
1493
1494    /// 添加一个**空**文本框。
1495    ///
1496    /// 文本内容留空,需要后续 `text_frame_mut().set_text(...)`。
1497    /// 位置/尺寸参数接受任意实现 [`EmuExt`] 的类型(`Inches` / `Cm` / `Emu` / ...)。
1498    pub fn add_textbox<L: EmuExt, T: EmuExt, W: EmuExt, H: EmuExt>(
1499        &mut self,
1500        left: L,
1501        top: T,
1502        width: W,
1503        height: H,
1504    ) -> crate::Result<TextBox> {
1505        let mut tb = TextBox::new(format!("TextBox {}", self.slide.inner.shapes.len() + 1));
1506        tb.set_left(left.emu());
1507        tb.set_top(top.emu());
1508        tb.set_width(width.emu());
1509        tb.set_height(height.emu());
1510        tb.set_id(self.slide.next_shape_id());
1511        let sp = tb.shape.sp.clone();
1512        self.slide.inner.shapes.push(OxmlSlideShape::Sp(sp));
1513        Ok(tb)
1514    }
1515
1516    /// 添加一个文本框,**并直接写入文本**(一步完成,避免 clone-sp 与 sp 失同步)。
1517    ///
1518    /// 比 `add_textbox` + 后续 `set_text` 略快,且不易写错。
1519    pub fn add_textbox_with_text<L: EmuExt, T: EmuExt, W: EmuExt, H: EmuExt>(
1520        &mut self,
1521        left: L,
1522        top: T,
1523        width: W,
1524        height: H,
1525        text: &str,
1526    ) -> crate::Result<TextBox> {
1527        let mut tb = TextBox::new(format!("TextBox {}", self.slide.inner.shapes.len() + 1));
1528        tb.set_left(left.emu());
1529        tb.set_top(top.emu());
1530        tb.set_width(width.emu());
1531        tb.set_height(height.emu());
1532        tb.set_id(self.slide.next_shape_id());
1533        tb.set_text(text);
1534        // set_text 修改的是 tb.shape.sp.text;现在再 clone 推入
1535        let sp = tb.shape.sp.clone();
1536        self.slide.inner.shapes.push(OxmlSlideShape::Sp(sp));
1537        Ok(tb)
1538    }
1539
1540    /// 从本地路径添加一张图片。
1541    ///
1542    /// 自动推断 Content-Type 与扩展名(png / jpg / gif / bmp / svg 等)。
1543    ///
1544    /// 内部会自动:
1545    /// - 分配全局唯一的 `rIdImgN` 关系 id;
1546    /// - 注册 `/ppt/media/imageN.<ext>` part(保存时被写入 zip);
1547    /// - 在 `slideN.xml.rels` 中添加 `Image` 关系。
1548    pub fn add_picture<P: AsRef<std::path::Path>, L: EmuExt, T: EmuExt, W: EmuExt, H: EmuExt>(
1549        &mut self,
1550        path: P,
1551        left: L,
1552        top: T,
1553        width: W,
1554        height: H,
1555    ) -> crate::Result<Picture> {
1556        let mut pic = Picture::from_path(path.as_ref())?;
1557        pic.set_left(left.emu());
1558        pic.set_top(top.emu());
1559        pic.set_width(width.emu());
1560        pic.set_height(height.emu());
1561        pic.set_id(self.slide.next_shape_id());
1562        // 给 pic 设置默认 name(避免 cNvPr name 为空,PowerPoint 严格模式下会报警)
1563        if pic.name().is_empty() {
1564            pic.set_name(format!("Picture {}", self.slide.inner.shapes.len() + 1));
1565        }
1566        // 分配图片关系 id(rIdImgN 命名空间,与 layout 区分)
1567        let rid = self.slide.allocate_image_rid();
1568        // 同步写到 oxml
1569        pic.pic_mut().rid = rid.clone();
1570        // 注册 media 到 Presentation
1571        let partname = crate::opc::part::new_part_name(
1572            format!(
1573                "/ppt/media/image{}.{}",
1574                self.slide.next_media_index(),
1575                pic.ext.trim_start_matches('.')
1576            )
1577            .as_str(),
1578        );
1579        let ct = crate::shape::picture::content_type_for(&pic.ext);
1580        let blob = pic.blob.clone().unwrap_or_default();
1581        self.slide.register_media(MediaEntry {
1582            partname,
1583            content_type: ct.to_string(),
1584            blob,
1585            rid: rid.clone(),
1586        });
1587        let oxml_pic = pic.pic.clone();
1588        self.slide.inner.shapes.push(OxmlSlideShape::Pic(oxml_pic));
1589        Ok(pic)
1590    }
1591
1592    /// 添加一张图片并标记为占位符填充(TODO-007 高阶 API)。
1593    ///
1594    /// 与 [`add_picture`](Self::add_picture) 的区别:
1595    /// - 本方法创建的 `<p:pic>` 会带 `<p:ph type="pic" idx="N"/>` 标记,
1596    ///   PowerPoint 会把它识别为"占位符填充"而非自由图片;
1597    /// - 位置 / 尺寸**自动从版式占位符继承**(`layout` 中 `ph_idx == ph_idx` 的占位符),
1598    ///   调用方无需手动指定 left/top/width/height;
1599    /// - 若版式中找不到匹配的占位符,回退到 `left=0, top=0, width=9144000, height=6858000`
1600    ///   (默认 10" × 7.5"),并仍然写出占位符标记。
1601    ///
1602    /// # 参数
1603    /// - `ph_idx`:占位符 idx(对应版式中 `<p:ph idx="N"/>` 的 N);
1604    /// - `path`:图片文件路径;
1605    /// - `layout`:当前 slide 关联的版式引用(用于继承位置/尺寸)。
1606    ///
1607    /// # 返回值
1608    /// - 成功:返回 [`Picture`](已设置占位符标记);
1609    /// - 失败:文件读取失败返回 [`crate::Error::Io`]。
1610    ///
1611    /// # 与 python-pptx 的对应
1612    ///
1613    /// python-pptx 中 `slide.shapes.add_picture(path, ...)` 不会自动绑定占位符;
1614    /// 用户需要先 `slide.placeholders[idx]` 取占位符再 `placeholder.insert_picture(path)`。
1615    /// 本方法把两步合并为一个原子操作,语义更清晰。
1616    ///
1617    /// # 示例
1618    /// ```no_run
1619    /// use pptx_rs::Presentation;
1620    ///
1621    /// let mut p = Presentation::new().unwrap();
1622    /// // 先取版式(避免与 slides_mut 的可变借用冲突)
1623    /// let layout = p.slide_layouts().get(0).cloned().unwrap();
1624    /// let counter = p.id_counter();
1625    /// let s = p.slides_mut().add_slide(counter).unwrap();
1626    /// // 假设版式有 idx=10 的图片占位符
1627    /// let pic = s.shapes_mut().add_picture_to_placeholder(10, "logo.png", &layout).unwrap();
1628    /// ```
1629    pub fn add_picture_to_placeholder<P: AsRef<std::path::Path>>(
1630        &mut self,
1631        ph_idx: u32,
1632        path: P,
1633        layout: &crate::slide_layouts::SlideLayoutRef,
1634    ) -> crate::Result<Picture> {
1635        // 从版式中查找匹配的占位符,取其位置/尺寸
1636        let (left, top, width, height) = {
1637            let layout_ref = layout.oxml.borrow();
1638            let mut found = None;
1639            for lsp in layout_ref.shapes.iter() {
1640                if lsp.is_placeholder && lsp.ph_idx == Some(ph_idx) {
1641                    found = Some((
1642                        lsp.properties.xfrm.off_x.unwrap_or_default(),
1643                        lsp.properties.xfrm.off_y.unwrap_or_default(),
1644                        lsp.properties
1645                            .xfrm
1646                            .ext_cx
1647                            .unwrap_or(crate::units::Emu(9_144_000)),
1648                        lsp.properties
1649                            .xfrm
1650                            .ext_cy
1651                            .unwrap_or(crate::units::Emu(6_858_000)),
1652                    ));
1653                    break;
1654                }
1655            }
1656            found.unwrap_or((
1657                crate::units::Emu::default(),
1658                crate::units::Emu::default(),
1659                crate::units::Emu(9_144_000),
1660                crate::units::Emu(6_858_000),
1661            ))
1662        };
1663        // 复用 add_picture 创建基础图片
1664        let mut pic = self.add_picture(path, left, top, width, height)?;
1665        // 标记为占位符(type="pic")
1666        pic.set_placeholder(ph_idx, Some("pic"));
1667        // 同步到 oxml(add_picture 已经 push 了 oxml_pic,需要更新最后一个 Pic)
1668        if let Some(OxmlSlideShape::Pic(last_pic)) = self.slide.inner.shapes.last_mut() {
1669            last_pic.is_placeholder = true;
1670            last_pic.ph_idx = Some(ph_idx);
1671            last_pic.ph_type = Some("pic".to_string());
1672        }
1673        Ok(pic)
1674    }
1675
1676    /// 添加一个图表并绑定到指定占位符(TODO-007 图表占位符类型化填充)。
1677    ///
1678    /// 与 [`Self::add_chart`] 的区别:本方法会从 `layout` 中查找 `ph_idx` 对应的
1679    /// 占位符,继承其位置/尺寸,并把生成的 graphicFrame 标记为占位符
1680    /// (写出 `<p:ph type="chart" idx="..."/>`)。
1681    ///
1682    /// # 参数
1683    /// - `ph_idx`:占位符索引(对应版式中 `<p:ph idx="..."/>`)。
1684    /// - `chart_type`:图表类型(柱/条/线/饼)。
1685    /// - `data`:图表数据(类别 + 系列 + 可选标题)。
1686    /// - `layout`:所属 Presentation 中的版式引用。
1687    ///
1688    /// # 返回值
1689    /// - 成功:返回 [`ChartShape`](已设置占位符标记 + 继承的位置/尺寸)。
1690    ///
1691    /// # 与 python-pptx 的对应
1692    ///
1693    /// python-pptx 中通过 `slide.placeholders[idx].insert_chart(...)` 实现图表占位符填充;
1694    /// 本方法把"取占位符 + 创建图表 + 绑定"合并为一个原子操作。
1695    ///
1696    /// # 示例
1697    /// ```no_run
1698    /// # use pptx_rs::*;
1699    /// # use pptx_rs::oxml::chart::{ChartData, ChartSeries, ChartCategory, ChartType};
1700    /// # let mut prs = Presentation::new().unwrap();
1701    /// # let layout = prs.slide_layouts().get(0).cloned().unwrap();
1702    /// # let counter = prs.id_counter();
1703    /// # let s = prs.slides_mut().add_slide(counter).unwrap();
1704    /// let mut data = ChartData::default();
1705    /// data.categories = vec![ChartCategory::new("Q1"), ChartCategory::new("Q2")];
1706    /// data.series = vec![ChartSeries::new("Sales", vec![10.0, 20.0])];
1707    /// // 假设版式有 idx=12 的图表占位符
1708    /// let chart = s.shapes_mut().add_chart_to_placeholder(
1709    ///     12, ChartType::Column, data, &layout,
1710    /// ).unwrap();
1711    /// ```
1712    pub fn add_chart_to_placeholder(
1713        &mut self,
1714        ph_idx: u32,
1715        chart_type: crate::oxml::chart::ChartType,
1716        data: crate::oxml::chart::ChartData,
1717        layout: &crate::slide_layouts::SlideLayoutRef,
1718    ) -> crate::Result<ChartShape> {
1719        // 从版式中查找匹配的占位符,取其位置/尺寸
1720        let (left, top, width, height) = {
1721            let layout_ref = layout.oxml.borrow();
1722            let mut found = None;
1723            for lsp in layout_ref.shapes.iter() {
1724                if lsp.is_placeholder && lsp.ph_idx == Some(ph_idx) {
1725                    found = Some((
1726                        lsp.properties.xfrm.off_x.unwrap_or_default(),
1727                        lsp.properties.xfrm.off_y.unwrap_or_default(),
1728                        lsp.properties
1729                            .xfrm
1730                            .ext_cx
1731                            .unwrap_or(crate::units::Emu(9_144_000)),
1732                        lsp.properties
1733                            .xfrm
1734                            .ext_cy
1735                            .unwrap_or(crate::units::Emu(6_858_000)),
1736                    ));
1737                    break;
1738                }
1739            }
1740            found.unwrap_or((
1741                crate::units::Emu::default(),
1742                crate::units::Emu::default(),
1743                crate::units::Emu(9_144_000),
1744                crate::units::Emu(6_858_000),
1745            ))
1746        };
1747        // 复用 add_chart 创建基础图表
1748        let mut chart = self.add_chart(chart_type, data, left, top, width, height)?;
1749        // 标记为占位符(type="chart")
1750        chart.set_placeholder(ph_idx, Some("chart"));
1751        // 同步到 oxml(add_chart 已经 push 了 oxml GraphicFrame,需要更新最后一个)
1752        if let Some(OxmlSlideShape::GraphicFrame(last_gf)) = self.slide.inner.shapes.last_mut() {
1753            last_gf.is_placeholder = true;
1754            last_gf.ph_idx = Some(ph_idx);
1755            last_gf.ph_type = Some("chart".to_string());
1756        }
1757        Ok(chart)
1758    }
1759
1760    /// 添加一个表格并绑定到指定占位符(TODO-007 表格占位符类型化填充)。
1761    ///
1762    /// 与 [`Self::add_table`] 的区别:本方法会从 `layout` 中查找 `ph_idx` 对应的
1763    /// 占位符,继承其位置/尺寸,并把生成的 graphicFrame 标记为占位符
1764    /// (写出 `<p:ph type="tbl" idx="..."/>`)。
1765    ///
1766    /// # 参数
1767    /// - `ph_idx`:占位符索引(对应版式中 `<p:ph idx="..."/>`)。
1768    /// - `rows` / `cols`:表格行列数。
1769    /// - `layout`:所属 Presentation 中的版式引用。
1770    ///
1771    /// # 返回值
1772    /// - 成功:返回 [`TableShape`](已设置占位符标记 + 继承的位置/尺寸)。
1773    ///
1774    /// # 与 python-pptx 的对应
1775    ///
1776    /// python-pptx 中通过 `slide.placeholders[idx].insert_table(rows, cols)` 实现表格占位符填充;
1777    /// 本方法把"取占位符 + 创建表格 + 绑定"合并为一个原子操作。
1778    ///
1779    /// # 示例
1780    /// ```no_run
1781    /// # use pptx_rs::*;
1782    /// # let mut prs = Presentation::new().unwrap();
1783    /// # let layout = prs.slide_layouts().get(0).cloned().unwrap();
1784    /// # let counter = prs.id_counter();
1785    /// # let s = prs.slides_mut().add_slide(counter).unwrap();
1786    /// // 假设版式有 idx=14 的表格占位符
1787    /// let tbl = s.shapes_mut().add_table_to_placeholder(
1788    ///     14, 3, 4, &layout,
1789    /// ).unwrap();
1790    /// ```
1791    pub fn add_table_to_placeholder(
1792        &mut self,
1793        ph_idx: u32,
1794        rows: usize,
1795        cols: usize,
1796        layout: &crate::slide_layouts::SlideLayoutRef,
1797    ) -> crate::Result<TableShape> {
1798        // 从版式中查找匹配的占位符,取其位置/尺寸
1799        let (left, top, width, height) = {
1800            let layout_ref = layout.oxml.borrow();
1801            let mut found = None;
1802            for lsp in layout_ref.shapes.iter() {
1803                if lsp.is_placeholder && lsp.ph_idx == Some(ph_idx) {
1804                    found = Some((
1805                        lsp.properties.xfrm.off_x.unwrap_or_default(),
1806                        lsp.properties.xfrm.off_y.unwrap_or_default(),
1807                        lsp.properties
1808                            .xfrm
1809                            .ext_cx
1810                            .unwrap_or(crate::units::Emu(9_144_000)),
1811                        lsp.properties
1812                            .xfrm
1813                            .ext_cy
1814                            .unwrap_or(crate::units::Emu(6_858_000)),
1815                    ));
1816                    break;
1817                }
1818            }
1819            found.unwrap_or((
1820                crate::units::Emu::default(),
1821                crate::units::Emu::default(),
1822                crate::units::Emu(9_144_000),
1823                crate::units::Emu(6_858_000),
1824            ))
1825        };
1826        // 复用 add_table 创建基础表格
1827        let mut tbl = self.add_table(rows, cols, left, top, width, height)?;
1828        // 标记为占位符(type="tbl")
1829        tbl.set_placeholder(ph_idx, Some("tbl"));
1830        // 同步到 oxml(add_table 已经 push 了 oxml GraphicFrame,需要更新最后一个)
1831        if let Some(OxmlSlideShape::GraphicFrame(last_gf)) = self.slide.inner.shapes.last_mut() {
1832            last_gf.is_placeholder = true;
1833            last_gf.ph_idx = Some(ph_idx);
1834            last_gf.ph_type = Some("tbl".to_string());
1835        }
1836        Ok(tbl)
1837    }
1838
1839    /// 添加一个**自选图形**(预设几何)。
1840    ///
1841    /// 几何形如 [`crate::oxml::simpletypes::PresetGeometry::Rectangle`] / `Ellipse` /
1842    /// `RightArrow` / ...,完整列表见 OOXML 规范。
1843    pub fn add_shape<L: EmuExt, T: EmuExt, W: EmuExt, H: EmuExt>(
1844        &mut self,
1845        geometry: crate::oxml::simpletypes::PresetGeometry,
1846        left: L,
1847        top: T,
1848        width: W,
1849        height: H,
1850    ) -> crate::Result<AutoShape> {
1851        let name = format!("Shape {}", self.slide.inner.shapes.len() + 1);
1852        let mut s = AutoShape::new(name, geometry);
1853        s.set_left(left.emu());
1854        s.set_top(top.emu());
1855        s.set_width(width.emu());
1856        s.set_height(height.emu());
1857        s.set_id(self.slide.next_shape_id());
1858        let sp = s.sp.clone();
1859        self.slide.inner.shapes.push(OxmlSlideShape::Sp(sp));
1860        Ok(s)
1861    }
1862
1863    /// 添加一个**连接器**。
1864    ///
1865    /// # 参数
1866    /// - `connector_type`:连接器几何(`MSO_CONNECTOR_TYPE`,默认 `MsoConnectorType::Straight`);
1867    /// - `begin_x` / `begin_y`:起点 EMU 坐标;
1868    /// - `end_x` / `end_y`:终点 EMU 坐标;
1869    ///
1870    /// # 与 python-pptx 的对应
1871    /// 对应 `SlideShapes.add_connector(connector_type, begin_x, begin_y, end_x, end_y)`。
1872    /// 在 pptx-rs 中所有长度参数都实现 [`EmuExt`],可直接传 `Inches(...)`。
1873    ///
1874    /// # 修订历史
1875    /// 早期签名采用 4 参版(仅 `begin_x, begin_y, end_x, end_y`),后被改写为
1876    /// `(begin_x, _begin_y_ignored, end_x, _end_y_ignored)`,但实现却把 y 强制为 0,
1877    /// 导致连接器始终在 y=0 —— 现已**恢复**为 4 参版本并正确使用 y 坐标。
1878    pub fn add_connector<BX: EmuExt, BY: EmuExt, EX: EmuExt, EY: EmuExt>(
1879        &mut self,
1880        connector_type: crate::oxml::simpletypes::MsoConnectorType,
1881        begin_x: BX,
1882        begin_y: BY,
1883        end_x: EX,
1884        end_y: EY,
1885    ) -> crate::Result<Connector> {
1886        let mut c = Connector::new_with_type(
1887            format!("Connector {}", self.slide.inner.shapes.len() + 1),
1888            connector_type,
1889        );
1890        c.set_left(Emu(0));
1891        c.set_top(Emu(0));
1892        c.set_width(Emu(0));
1893        c.set_height(Emu(0));
1894        // ✅ 使用真实传入的 y 坐标(不再被强制为 0)
1895        c.set_begin(crate::units::EmuPoint(
1896            begin_x.emu().value(),
1897            begin_y.emu().value(),
1898        ));
1899        c.set_end(crate::units::EmuPoint(
1900            end_x.emu().value(),
1901            end_y.emu().value(),
1902        ));
1903        c.set_id(self.slide.next_shape_id());
1904        let cx = c.cxn.clone();
1905        self.slide.inner.shapes.push(OxmlSlideShape::CxnSp(cx));
1906        Ok(c)
1907    }
1908
1909    /// 添加一个**连接器**(完整 4 端点 + 类型版本)。
1910    ///
1911    /// 真正对齐 python-pptx `add_connector(connector_type, begin_x, begin_y, end_x, end_y)`。
1912    ///
1913    /// # 弃用说明
1914    /// 此方法自 0.1.x 起**已与 [`Self::add_connector`] 合并**——后者已恢复 4 端点签名。
1915    /// 保留仅为**兼容**既有调用方;新代码请直接使用 [`Self::add_connector`]。
1916    #[deprecated(
1917        since = "0.1.0",
1918        note = "已与 add_connector 合并;请改用 add_connector(connector_type, begin_x, begin_y, end_x, end_y)"
1919    )]
1920    pub fn add_connector_geom<BL: EmuExt, BT: EmuExt, EL: EmuExt, ET: EmuExt>(
1921        &mut self,
1922        connector_type: crate::oxml::simpletypes::MsoConnectorType,
1923        begin_x: BL,
1924        begin_y: BT,
1925        end_x: EL,
1926        end_y: ET,
1927    ) -> crate::Result<Connector> {
1928        self.add_connector(connector_type, begin_x, begin_y, end_x, end_y)
1929    }
1930
1931    /// 添加一个**自由形**(已完成 [`crate::shape::freeform::FreeformBuilder`] 流程)。
1932    ///
1933    /// # 当前实现
1934    /// 0.1.0 的 `Freeform::build` 内部退化为 `AutoShape` + 矩形 prstGeom——
1935    /// 真正的 `a:custGeom` 描述(折线/曲线/闭合)将在 0.2.0 接入。
1936    /// 因此**当前** `add_freeform` 在视觉上与 `add_shape(Rectangle, ...)` 一致,
1937    /// 但 `Freeform` 的 `points()` 仍可被读取。
1938    pub fn add_freeform(
1939        &mut self,
1940        mut freeform: Freeform,
1941        left: Emu,
1942        top: Emu,
1943        width: Emu,
1944        height: Emu,
1945    ) -> crate::Result<Freeform> {
1946        freeform.set_left(left);
1947        freeform.set_top(top);
1948        freeform.set_width(width);
1949        freeform.set_height(height);
1950        freeform.set_id(self.slide.next_shape_id());
1951        // 内部 AutoShape 句柄——推入 sp 列表
1952        let sp = freeform.shape.sp.clone();
1953        self.slide.inner.shapes.push(OxmlSlideShape::Sp(sp));
1954        Ok(freeform)
1955    }
1956
1957    /// 把 idx 处的形状移到末尾(z-order 顶层)。
1958    pub fn move_to_front(&mut self, idx: usize) {
1959        if idx < self.slide.inner.shapes.len() {
1960            let s = self.slide.inner.shapes.remove(idx);
1961            self.slide.inner.shapes.push(s);
1962        }
1963    }
1964
1965    /// 把 idx 处的形状移到首位置(z-order 底层)。
1966    pub fn move_to_back(&mut self, idx: usize) {
1967        if idx < self.slide.inner.shapes.len() {
1968            let s = self.slide.inner.shapes.remove(idx);
1969            self.slide.inner.shapes.insert(0, s);
1970        }
1971    }
1972
1973    /// 把 idx 处的形状向上移动一级(z-order 提升)。
1974    ///
1975    /// 对标 python-pptx 中通过 XML 操作调整形状顺序的能力。
1976    /// 若 idx 已是顶层(最后一个)或越界,则为 no-op。
1977    pub fn move_up(&mut self, idx: usize) {
1978        let len = self.slide.inner.shapes.len();
1979        if idx < len.saturating_sub(1) {
1980            // 与后一个交换位置
1981            self.slide.inner.shapes.swap(idx, idx + 1);
1982        }
1983    }
1984
1985    /// 把 idx 处的形状向下移动一级(z-order 降低)。
1986    ///
1987    /// 对标 python-pptx 中通过 XML 操作调整形状顺序的能力。
1988    /// 若 idx 已是底层(第一个)或越界,则为 no-op。
1989    pub fn move_down(&mut self, idx: usize) {
1990        if idx > 0 && idx < self.slide.inner.shapes.len() {
1991            // 与前一个交换位置
1992            self.slide.inner.shapes.swap(idx, idx - 1);
1993        }
1994    }
1995
1996    /// 找某个 ShapeKind 的索引(克隆比较)。
1997    ///
1998    /// 等价 python-pptx 中 `SlideShapes.index(shape)`。
1999    pub fn index(&self, kind: &ShapeKind) -> Option<usize> {
2000        // 用名字 + 位置比较(最稳定可观察)。
2001        let target_name = kind.name().to_string();
2002        for (i, s) in self.slide.inner.shapes.iter().enumerate() {
2003            if crate::shape::name_of(s) == target_name {
2004                return Some(i);
2005            }
2006        }
2007        None
2008    }
2009
2010    /// 添加一个**空**组合(后续通过 `Group::children_mut` 加入子形状)。
2011    #[allow(clippy::field_reassign_with_default)]
2012    pub fn add_group<L: EmuExt, T: EmuExt, W: EmuExt, H: EmuExt>(
2013        &mut self,
2014        left: L,
2015        top: T,
2016        width: W,
2017        height: H,
2018    ) -> crate::Result<Group> {
2019        let name = format!("Group {}", self.slide.inner.shapes.len() + 1);
2020        let mut g = crate::oxml::shape::Group::default();
2021        g.id = self.slide.next_shape_id();
2022        g.name = name;
2023        g.off = (left.emu(), top.emu());
2024        g.ext = (width.emu(), height.emu());
2025        let grp = Group::from_group(g.clone());
2026        self.slide
2027            .inner
2028            .shapes
2029            .push(OxmlSlideShape::Group(Box::new(g)));
2030        Ok(grp)
2031    }
2032
2033    /// 添加一个**等分**表格。
2034    ///
2035    /// 行高 = `height / rows`,列宽 = `width / cols`。如需不等分请改用
2036    /// `TableShape::set_row_height` / `set_col_width`。
2037    pub fn add_table<L: EmuExt, T: EmuExt, W: EmuExt, H: EmuExt>(
2038        &mut self,
2039        rows: usize,
2040        cols: usize,
2041        left: L,
2042        top: T,
2043        width: W,
2044        height: H,
2045    ) -> crate::Result<TableShape> {
2046        let col_w = Emu(width.emu().value() / (cols as i64).max(1));
2047        let row_h = Emu(height.emu().value() / (rows as i64).max(1));
2048        let mut tbl = TableShape::new(rows, cols, col_w, row_h);
2049        tbl.set_left(left.emu());
2050        tbl.set_top(top.emu());
2051        tbl.set_width(width.emu());
2052        tbl.set_height(height.emu());
2053        tbl.set_id(self.slide.next_shape_id());
2054        let frame = tbl.frame.clone();
2055        self.slide
2056            .inner
2057            .shapes
2058            .push(OxmlSlideShape::GraphicFrame(frame));
2059        Ok(tbl)
2060    }
2061
2062    /// 添加一个图表(TODO-004 基础图表支持)。
2063    ///
2064    /// 当前支持 4 种图表类型:柱状图(`ChartType::Column`)、条形图(`ChartType::Bar`)、
2065    /// 折线图(`ChartType::Line`)、饼图(`ChartType::Pie`)。数据通过 `<c:numCache>`
2066    /// 内嵌,不依赖嵌入 Excel。
2067    ///
2068    /// # 参数
2069    /// - `chart_type`:图表类型。
2070    /// - `data`:图表数据(类别 + 系列 + 可选标题)。
2071    /// - `left` / `top` / `width` / `height`:图表在 slide 中的位置与尺寸。
2072    ///
2073    /// # 内部行为
2074    ///
2075    /// 1. 创建一个 [`ChartShape`],设置几何 + id;
2076    /// 2. 分配一个**本 slide 内**唯一的关系 id `rIdChartN`;
2077    /// 3. 把 `rIdChartN` 同步写入 `ChartShape.frame.graphic.Chart.rid`,
2078    ///    供 `<c:chart r:id="rIdChartN"/>` 引用;
2079    /// 4. 注册 [`ChartEntry`] 到本 slide(保存时由 `to_opc_package` 写出独立的
2080    ///    `/ppt/charts/chartN.xml` part + `slideN.xml.rels` 关系);
2081    /// 5. 把 `ChartShape.frame` 作为 `GraphicFrame` 推入 `spTree`。
2082    ///
2083    /// # 示例
2084    ///
2085    /// ```no_run
2086    /// # use pptx_rs::*;
2087    /// # use pptx_rs::oxml::chart::{ChartData, ChartSeries, ChartCategory, ChartType};
2088    /// # let mut prs = Presentation::new().unwrap();
2089    /// # let counter = prs.id_counter();
2090    /// # let slide = prs.slides_mut().add_slide(counter).unwrap();
2091    /// let mut data = ChartData::default();
2092    /// data.categories = vec![ChartCategory::new("Q1"), ChartCategory::new("Q2")];
2093    /// data.series = vec![ChartSeries::new("Sales", vec![10.0, 20.0])];
2094    /// data.title = Some("Revenue".to_string());
2095    /// let _chart = slide.shapes_mut().add_chart(
2096    ///     ChartType::Column, data,
2097    ///     Inches(1.0), Inches(2.0), Inches(8.0), Inches(4.0),
2098    /// ).unwrap();
2099    /// ```
2100    pub fn add_chart<L: EmuExt, T: EmuExt, W: EmuExt, H: EmuExt>(
2101        &mut self,
2102        chart_type: crate::oxml::chart::ChartType,
2103        data: crate::oxml::chart::ChartData,
2104        left: L,
2105        top: T,
2106        width: W,
2107        height: H,
2108    ) -> crate::Result<ChartShape> {
2109        let mut chart = ChartShape::new(chart_type, data);
2110        chart.set_left(left.emu());
2111        chart.set_top(top.emu());
2112        chart.set_width(width.emu());
2113        chart.set_height(height.emu());
2114        chart.set_id(self.slide.next_shape_id());
2115        if chart.name().is_empty() {
2116            chart.set_name(format!("Chart {}", self.slide.inner.shapes.len() + 1));
2117        }
2118        // 分配本 slide 内唯一的关系 id(rIdChartN 命名空间,与 image/notes/comments 区分)
2119        let rid = self.slide.allocate_chart_rid();
2120        chart.set_rid(rid.clone());
2121        // 注册 chart part 元数据;to_opc_package 阶段会用全局索引重新分配 partname,
2122        // 这里先用 slide 局部索引占位(实际 partname 在打包时确定)。
2123        let local_idx = self.slide.next_chart_index();
2124        let partname =
2125            crate::opc::part::new_part_name(format!("/ppt/charts/chart{}.xml", local_idx).as_str());
2126        // 取出 chart 的强类型模型克隆一份挂到 ChartEntry。
2127        // 注意:ChartShape.frame.graphic 与 ChartEntry.chart 是两份独立数据,
2128        // 修改 ChartShape 后需要重新调用 `chart()` 同步——但本 API 在创建时一次性
2129        // 注入,后续用户修改 chart 数据需要重新 save 才能反映到 chartN.xml。
2130        // 后续可考虑用 Rc<RefCell<Chart>> 共享,0.2.x 暂保持 clone 语义。
2131        let oxml_chart = chart
2132            .chart()
2133            .cloned()
2134            .unwrap_or_else(crate::oxml::chart::Chart::default);
2135        self.slide.register_chart(ChartEntry {
2136            partname,
2137            chart: oxml_chart,
2138            rid: rid.clone(),
2139            xlsx_blob: None,
2140        });
2141        let frame = chart.frame.clone();
2142        self.slide
2143            .inner
2144            .shapes
2145            .push(OxmlSlideShape::GraphicFrame(frame));
2146        Ok(chart)
2147    }
2148
2149    /// 添加一个**带嵌入式 Excel 工作簿**的图表(TODO-004 Excel 嵌入)。
2150    ///
2151    /// 与 [`Self::add_chart`] 的区别:本方法额外接受 `xlsx_blob` 参数(有效的
2152    /// `.xlsx` 文件字节流),保存时会在 `.pptx` 内生成
2153    /// `/ppt/embeddings/Microsoft_Excel_WorksheetN.xlsx` part + chart part 的
2154    /// 独立关系文件 `_rels/chartN.xml.rels`(Type=Package),并在 chart XML
2155    /// 中写入 `<c:externalData r:id="rIdXlsxN"/>` 引用。PowerPoint 打开图表时
2156    /// 会从该 xlsx part 读取数据源,"编辑数据" 会启动 Excel。
2157    ///
2158    /// # 参数
2159    /// - `chart_type`:图表类型。
2160    /// - `data`:图表数据(类别 + 系列 + 可选标题)。**仍会**写入 numCache/strCache,
2161    ///   即使 PowerPoint 不打开 xlsx 也能渲染图表。
2162    /// - `xlsx_blob`:有效的 `.xlsx` 文件字节流(OOXML SpreadsheetML 包格式)。
2163    ///   库不校验内容有效性,PowerPoint 会按 zip + XML 解析。
2164    /// - `left` / `top` / `width` / `height`:图表在 slide 中的位置与尺寸。
2165    ///
2166    /// # 返回值
2167    /// - 成功:返回 [`ChartShape`] 高阶句柄。
2168    ///
2169    /// # 内部行为
2170    ///
2171    /// 1. 与 [`Self::add_chart`] 一致地构造 `ChartShape` + 分配 `rIdChartN`;
2172    /// 2. 注册 [`ChartEntry`],`xlsx_blob` 字段填入 `Some(xlsx_blob.to_vec())`;
2173    /// 3. 保存时由 `to_opc_package` 写出 xlsx part + chart rels + externalData 引用。
2174    ///
2175    /// # 关键约束
2176    ///
2177    /// - **xlsx 内容应由调用方保证有效**:库不解析 xlsx,PowerPoint 打开时
2178    ///   若 xlsx 损坏会报错。
2179    /// - **xlsx 内数据应与 `data` 一致**:PowerPoint 优先用 numCache 渲染,
2180    ///   但用户"编辑数据"时会以 xlsx 为准。若两者不一致,编辑后图表会变化。
2181    /// - **xlsx_rid 由 presentation 层自动分配**:用户无需手动设置。
2182    ///
2183    /// # 示例
2184    ///
2185    /// ```no_run
2186    /// # use pptx_rs::*;
2187    /// # use pptx_rs::oxml::chart::{ChartData, ChartSeries, ChartCategory, ChartType};
2188    /// # let mut prs = Presentation::new().unwrap();
2189    /// # let counter = prs.id_counter();
2190    /// # let slide = prs.slides_mut().add_slide(counter).unwrap();
2191    /// # let xlsx_bytes: Vec<u8> = vec![]; // 实际场景应为有效 .xlsx 文件内容
2192    /// let mut data = ChartData::default();
2193    /// data.categories = vec![ChartCategory::new("Q1"), ChartCategory::new("Q2")];
2194    /// data.series = vec![ChartSeries::new("Sales", vec![10.0, 20.0])];
2195    /// let _chart = slide.shapes_mut().add_chart_with_excel(
2196    ///     ChartType::Column, data, xlsx_bytes,
2197    ///     Inches(1.0), Inches(2.0), Inches(8.0), Inches(4.0),
2198    /// ).unwrap();
2199    /// ```
2200    #[allow(clippy::too_many_arguments)]
2201    pub fn add_chart_with_excel<L, T, W, H>(
2202        &mut self,
2203        chart_type: crate::oxml::chart::ChartType,
2204        data: crate::oxml::chart::ChartData,
2205        xlsx_blob: Vec<u8>,
2206        left: L,
2207        top: T,
2208        width: W,
2209        height: H,
2210    ) -> crate::Result<ChartShape>
2211    where
2212        L: EmuExt,
2213        T: EmuExt,
2214        W: EmuExt,
2215        H: EmuExt,
2216    {
2217        let mut chart = ChartShape::new(chart_type, data);
2218        chart.set_left(left.emu());
2219        chart.set_top(top.emu());
2220        chart.set_width(width.emu());
2221        chart.set_height(height.emu());
2222        chart.set_id(self.slide.next_shape_id());
2223        if chart.name().is_empty() {
2224            chart.set_name(format!("Chart {}", self.slide.inner.shapes.len() + 1));
2225        }
2226        let rid = self.slide.allocate_chart_rid();
2227        chart.set_rid(rid.clone());
2228        let local_idx = self.slide.next_chart_index();
2229        let partname =
2230            crate::opc::part::new_part_name(format!("/ppt/charts/chart{}.xml", local_idx).as_str());
2231        let oxml_chart = chart
2232            .chart()
2233            .cloned()
2234            .unwrap_or_else(crate::oxml::chart::Chart::default);
2235        // 关键差异:xlsx_blob 字段填入 Some(...),触发 to_opc_package 写出
2236        // xlsx part + chart rels + externalData 引用。
2237        self.slide.register_chart(ChartEntry {
2238            partname,
2239            chart: oxml_chart,
2240            rid: rid.clone(),
2241            xlsx_blob: Some(xlsx_blob),
2242        });
2243        let frame = chart.frame.clone();
2244        self.slide
2245            .inner
2246            .shapes
2247            .push(OxmlSlideShape::GraphicFrame(frame));
2248        Ok(chart)
2249    }
2250
2251    /// 在当前幻灯片上嵌入一个 OLE 对象(TODO-043)。
2252    ///
2253    /// 对标 python-pptx 0.6.19+ 的 `shapes.add_ole_object()`。把指定文件
2254    /// 作为 OLE 复合文档嵌入到 `.pptx` 中,PowerPoint 双击时会调用对应
2255    /// OLE 服务器(由 `prog_id` 决定)打开编辑。
2256    ///
2257    /// # 参数
2258    /// - `path`:OLE 文件路径(如 `.xls` / `.doc` / `.bin`)。
2259    ///   文件内容会以原始字节写入 `/ppt/embeddings/oleObjectN.bin`。
2260    /// - `prog_id`:OLE 程序标识符(如 `"Excel.Sheet.12"` / `"Word.Document.12"` /
2261    ///   `"Package"`)。PowerPoint 通过 progId 决定双击时调用哪个 OLE 服务器。
2262    /// - `name`:显示名(在 PowerPoint 中作为对象名,如 `"Worksheet"`)。
2263    /// - `left` / `top` / `width` / `height`:OLE 对象在 slide 上的位置与尺寸(EMU)。
2264    ///
2265    /// # 返回值
2266    /// - 成功:返回 [`OleObjectShape`] 高阶句柄,可继续调整 `show_as_icon` /
2267    ///   `set_image_rid`(图标图片)等属性。
2268    /// - 失败:返回 [`crate::Error::Io`](文件读取失败)。
2269    ///
2270    /// # 内部流程
2271    ///
2272    /// 1. 读取文件内容为 `Vec<u8>`;
2273    /// 2. 创建一个 [`OleObjectShape`],设置几何 + id + progId + name;
2274    /// 3. 分配一个**本 slide 内**唯一的关系 id `rIdOleN`;
2275    /// 4. 把 `rIdOleN` 同步写入 `OleObjectShape.frame.graphic.OleObject.rid`,
2276    ///    供 `<p:oleObj r:id="rIdOleN"/>` 引用;
2277    /// 5. 注册 [`OleEntry`] 到本 slide(保存时由 `to_opc_package` 写出独立的
2278    ///    `/ppt/embeddings/oleObjectN.bin` part + `slideN.xml.rels` 关系);
2279    /// 6. 把 `OleObjectShape.frame` 作为 `GraphicFrame` 推入 `spTree`。
2280    ///
2281    /// # 示例
2282    ///
2283    /// ```no_run
2284    /// # use pptx_rs::*;
2285    /// # let mut prs = Presentation::new().unwrap();
2286    /// # let counter = prs.id_counter();
2287    /// # let slide = prs.slides_mut().add_slide(counter).unwrap();
2288    /// let _ole = slide.shapes_mut().add_ole_object(
2289    ///     "data.xlsx", "Excel.Sheet.12", "Worksheet",
2290    ///     Inches(1.0), Inches(2.0), Inches(4.0), Inches(3.0),
2291    /// ).unwrap();
2292    /// ```
2293    #[allow(clippy::too_many_arguments)]
2294    pub fn add_ole_object<L, T, W, H, P>(
2295        &mut self,
2296        path: P,
2297        prog_id: &str,
2298        name: &str,
2299        left: L,
2300        top: T,
2301        width: W,
2302        height: H,
2303    ) -> crate::Result<OleObjectShape>
2304    where
2305        L: EmuExt,
2306        T: EmuExt,
2307        W: EmuExt,
2308        H: EmuExt,
2309        P: AsRef<std::path::Path>,
2310    {
2311        // 1) 读取 OLE 文件二进制内容(io::Error 由 `#[from]` 自动转为 Error::Io)
2312        let blob = std::fs::read(path.as_ref())?;
2313
2314        // 2) 创建 OleObjectShape
2315        let mut ole = OleObjectShape::new(prog_id, name);
2316        ole.set_left(left.emu());
2317        ole.set_top(top.emu());
2318        ole.set_width(width.emu());
2319        ole.set_height(height.emu());
2320        ole.set_id(self.slide.next_shape_id());
2321        if ole.name().is_empty() {
2322            ole.set_name(format!("OLE Object {}", self.slide.inner.shapes.len() + 1));
2323        }
2324        // 图标默认尺寸与对象尺寸一致(PowerPoint 默认行为)
2325        ole.set_icon_size(width.emu(), height.emu());
2326        // 图标 Pic 形状的 id 与 OleObjectShape 的 id 区分(避免重复)
2327        ole.set_pic_id_name(self.slide.next_shape_id(), "OLE Icon");
2328
2329        // 3) 分配本 slide 内唯一的 OLE 关系 id
2330        let rid = self.slide.allocate_ole_rid();
2331        ole.set_rid(rid.clone());
2332
2333        // 4) 注册 OLE part 元数据(to_opc_package 阶段会用全局索引重新分配 partname)
2334        let local_idx = self.slide.next_ole_index();
2335        let partname = crate::opc::part::new_part_name(
2336            format!("/ppt/embeddings/oleObject{}.bin", local_idx).as_str(),
2337        );
2338        self.slide.register_ole(OleEntry {
2339            partname,
2340            blob,
2341            rid: rid.clone(),
2342        });
2343
2344        // 5) 推入 spTree
2345        let frame = ole.frame.clone();
2346        self.slide
2347            .inner
2348            .shapes
2349            .push(OxmlSlideShape::GraphicFrame(frame));
2350        Ok(ole)
2351    }
2352
2353    /// 在当前幻灯片上创建一个 **SmartArt** 图形(从 4 份原始 XML 字符串,TODO-037 创建 API)。
2354    ///
2355    /// 这是"逃生舱"入口,适合用户已有 4 份 diagram XML(如从其它 `.pptx`
2356    /// 复制、或手工构造)的场景。若希望通过结构化模型程序化构建,请使用
2357    /// [`Self::add_smartart`]。
2358    ///
2359    /// # 参数
2360    /// - `data_xml`:`<dgm:dataModel>` 完整 XML(对应 `/ppt/diagrams/dataN.xml`)。
2361    /// - `layout_xml`:`<dgm:layoutDef>` 完整 XML(对应 `/ppt/diagrams/layoutN.xml`)。
2362    /// - `quick_style_xml`:`<dgm:styleData>` 完整 XML(对应 `/ppt/diagrams/quickStylesN.xml`)。
2363    /// - `colors_xml`:`<dgm:colorsDef>` 完整 XML(对应 `/ppt/diagrams/colorsN.xml`)。
2364    /// - `left` / `top` / `width` / `height`:SmartArt 在 slide 上的位置与尺寸(EMU)。
2365    ///
2366    /// # 返回值
2367    /// - 成功:返回 [`SmartArtShape`] 高阶句柄,可继续调整位置 / 占位符等属性。
2368    /// - 失败:返回 [`crate::Error`](目前为不可失败,但保留 `Result` 以便未来扩展)。
2369    ///
2370    /// # 内部流程
2371    ///
2372    /// 1. 调用 `Slide::allocate_diagram_rids` 分配本 slide 内唯一的 4 个关系 id
2373    ///    (`rIdDgmDataN` / `rIdDgmLayoutN` / `rIdDgmQsN` / `rIdDgmColorsN`);
2374    /// 2. 用 4 个 rid 构造 [`SmartArtShape`](内部通过 [`crate::oxml::shape::SmartArtRef::from_rids`]
2375    ///    生成 `<a:graphicData><dgm:relIds .../></a:graphicData>`);
2376    /// 3. 调用 `Slide::next_diagram_index` 取局部索引,构造 4 个 partname 占位
2377    ///    (`to_opc_package` 阶段会用全局索引重新分配,覆盖此处的占位);
2378    /// 4. 注册 [`DiagramEntry`] 到本 slide(保存时由 `to_opc_package` 写出 4 个
2379    ///    diagram part + slide rels 的 4 个关系);
2380    /// 5. 推入 `spTree`。
2381    ///
2382    /// # 关键约束
2383    ///
2384    /// - **partname 占位**:本方法生成的 4 个 partname 仅用于 `DiagramEntry` 字段
2385    ///   占位,`to_opc_package` 写出时会用**全局索引**重新分配(避免多 slide 冲突)。
2386    /// - **rid 全程不变**:4 个 rid 一旦分配就贯穿 slide XML / slideN.xml.rels /
2387    ///   diagram parts 引用链,不会被重写。
2388    /// - **XML 透传**:4 份 XML 字符串**原样**写入 zip part,不做任何解析或重建
2389    ///   (byte-exact round-trip 友好)。
2390    ///
2391    /// # 示例
2392    ///
2393    /// ```no_run
2394    /// # use pptx_rs::*;
2395    /// # use pptx_rs::shape::Shape;
2396    /// # let mut prs = Presentation::new().unwrap();
2397    /// # let counter = prs.id_counter();
2398    /// # let slide = prs.slides_mut().add_slide(counter).unwrap();
2399    /// let data_xml = r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
2400    /// <dgm:dataModel xmlns:dgm="http://schemas.openxmlformats.org/drawingml/2006/diagram"
2401    ///                xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main">
2402    ///   <dgm:ptLst><dgm:pt modelId="1" type="doc"/></dgm:ptLst>
2403    /// </dgm:dataModel>"#;
2404    /// // ... 其余 3 份 XML 省略
2405    /// # let layout_xml = "<dgm:layoutDef/>";
2406    /// # let quick_style_xml = "<dgm:styleData/>";
2407    /// # let colors_xml = "<dgm:colorsDef/>";
2408    /// let sa = slide.shapes_mut().add_smartart_from_xml(
2409    ///     data_xml, layout_xml, quick_style_xml, colors_xml,
2410    ///     Inches(1.0), Inches(1.0), Inches(8.0), Inches(4.0),
2411    /// ).unwrap();
2412    /// assert_eq!(sa.shape_type(), "smart_art");
2413    /// ```
2414    #[allow(clippy::too_many_arguments)]
2415    pub fn add_smartart_from_xml<L, T, W, H>(
2416        &mut self,
2417        data_xml: &str,
2418        layout_xml: &str,
2419        quick_style_xml: &str,
2420        colors_xml: &str,
2421        left: L,
2422        top: T,
2423        width: W,
2424        height: H,
2425    ) -> crate::Result<SmartArtShape>
2426    where
2427        L: EmuExt,
2428        T: EmuExt,
2429        W: EmuExt,
2430        H: EmuExt,
2431    {
2432        // 1) 分配 4 个关系 id(本 slide 内唯一)
2433        let (dm_rid, lo_rid, qs_rid, cs_rid) = self.slide.allocate_diagram_rids();
2434
2435        // 2) 构造 SmartArtShape(内部生成 raw_xml)
2436        let mut sa = SmartArtShape::from_rids(&dm_rid, &lo_rid, &qs_rid, &cs_rid);
2437        sa.set_left(left.emu());
2438        sa.set_top(top.emu());
2439        sa.set_width(width.emu());
2440        sa.set_height(height.emu());
2441        sa.set_id(self.slide.next_shape_id());
2442        if sa.name().is_empty() {
2443            sa.set_name(format!("SmartArt {}", self.slide.inner.shapes.len() + 1));
2444        }
2445
2446        // 3) 取局部索引,构造 4 个 partname 占位(to_opc_package 阶段会重写)
2447        let local_idx = self.slide.next_diagram_index();
2448        let data_partname = crate::opc::part::new_part_name(
2449            format!("/ppt/diagrams/data{}.xml", local_idx).as_str(),
2450        );
2451        let layout_partname = crate::opc::part::new_part_name(
2452            format!("/ppt/diagrams/layout{}.xml", local_idx).as_str(),
2453        );
2454        let quick_style_partname = crate::opc::part::new_part_name(
2455            format!("/ppt/diagrams/quickStyles{}.xml", local_idx).as_str(),
2456        );
2457        let colors_partname = crate::opc::part::new_part_name(
2458            format!("/ppt/diagrams/colors{}.xml", local_idx).as_str(),
2459        );
2460
2461        // 4) 注册 DiagramEntry
2462        self.slide.register_diagram(DiagramEntry {
2463            data_partname,
2464            layout_partname,
2465            quick_style_partname,
2466            colors_partname,
2467            data_xml: data_xml.to_string(),
2468            layout_xml: layout_xml.to_string(),
2469            quick_style_xml: quick_style_xml.to_string(),
2470            colors_xml: colors_xml.to_string(),
2471            data_rid: dm_rid.clone(),
2472            layout_rid: lo_rid.clone(),
2473            quick_style_rid: qs_rid.clone(),
2474            colors_rid: cs_rid.clone(),
2475        });
2476
2477        // 5) 推入 spTree
2478        let frame = sa.frame.clone();
2479        self.slide
2480            .inner
2481            .shapes
2482            .push(OxmlSlideShape::GraphicFrame(frame));
2483        Ok(sa)
2484    }
2485
2486    /// 在当前幻灯片上创建一个 **SmartArt** 图形(从结构化模型,TODO-037 创建 API)。
2487    ///
2488    /// 这是高阶友好入口,适合程序化构建 SmartArt。内部调用 4 个结构化模型的
2489    /// `to_xml()` 生成 XML 字符串,再委托给 [`Self::add_smartart_from_xml`]。
2490    ///
2491    /// # 参数
2492    /// - `data_model`:数据模型(`<dgm:dataModel>`,含 points + connections)。
2493    /// - `layout_def`:布局定义(`<dgm:layoutDef>`,含 layoutNode 子树)。
2494    /// - `quick_style`:样式定义(`<dgm:styleData>`,含 styleLbl 列表)。
2495    /// - `colors`:颜色定义(`<dgm:colorsDef>`,含 styleClrLbl 列表)。
2496    /// - `left` / `top` / `width` / `height`:位置与尺寸(EMU)。
2497    ///
2498    /// # 返回值
2499    /// - 成功:返回 [`SmartArtShape`] 高阶句柄。
2500    /// - 失败:返回 [`crate::Error`](来自 4 个 `to_xml()` 调用,目前不会失败)。
2501    ///
2502    /// # 示例
2503    ///
2504    /// ```no_run
2505    /// # use pptx_rs::*;
2506    /// use pptx_rs::oxml::diagram::{DataModel, DataModelPoint, LayoutDef, QuickStyleDef, ColorsDef};
2507    /// # let mut prs = Presentation::new().unwrap();
2508    /// # let counter = prs.id_counter();
2509    /// # let slide = prs.slides_mut().add_slide(counter).unwrap();
2510    /// let mut dm = DataModel::default();
2511    /// dm.points.push(DataModelPoint {
2512    ///     model_id: 1,
2513    ///     pt_type: Some("doc".into()),
2514    ///     text: Some("根节点".into()),
2515    ///     ..Default::default()
2516    /// });
2517    /// let sa = slide.shapes_mut().add_smartart(
2518    ///     dm,
2519    ///     LayoutDef::default(),
2520    ///     QuickStyleDef::default(),
2521    ///     ColorsDef::default(),
2522    ///     Inches(1.0), Inches(1.0), Inches(8.0), Inches(4.0),
2523    /// ).unwrap();
2524    /// assert!(sa.dm_rid().starts_with("rIdDgmData"));
2525    /// ```
2526    #[allow(clippy::too_many_arguments)]
2527    pub fn add_smartart<L, T, W, H>(
2528        &mut self,
2529        data_model: crate::oxml::diagram::DataModel,
2530        layout_def: crate::oxml::diagram::LayoutDef,
2531        quick_style: crate::oxml::diagram::QuickStyleDef,
2532        colors: crate::oxml::diagram::ColorsDef,
2533        left: L,
2534        top: T,
2535        width: W,
2536        height: H,
2537    ) -> crate::Result<SmartArtShape>
2538    where
2539        L: EmuExt,
2540        T: EmuExt,
2541        W: EmuExt,
2542        H: EmuExt,
2543    {
2544        let data_xml = data_model.to_xml();
2545        let layout_xml = layout_def.to_xml();
2546        let quick_style_xml = quick_style.to_xml();
2547        let colors_xml = colors.to_xml();
2548        self.add_smartart_from_xml(
2549            &data_xml,
2550            &layout_xml,
2551            &quick_style_xml,
2552            &colors_xml,
2553            left,
2554            top,
2555            width,
2556            height,
2557        )
2558    }
2559
2560    /// 在当前幻灯片上嵌入一个**视频**形状(TODO-033)。
2561    ///
2562    /// 对标 python-pptx 0.6.19+ 的 `shapes.add_movie()`。把指定视频文件
2563    /// 嵌入到 `.pptx` 中,PowerPoint 双击视频区域时会播放该视频。
2564    ///
2565    /// # 参数
2566    /// - `video_path`:视频文件路径(如 `.mp4`)。文件内容会以原始字节
2567    ///   写入 `/ppt/media/mediaN.mp4`。
2568    /// - `poster_path`:海报帧图片路径(视频未播放时显示的静态画面,如 `.png` / `.jpg`)。
2569    ///   若为 `None`,PowerPoint 会显示空白占位(推荐传入代表视频首帧的图片)。
2570    /// - `left` / `top` / `width` / `height`:视频形状在 slide 上的位置与尺寸(EMU)。
2571    ///
2572    /// # 返回值
2573    /// - 成功:返回 [`Picture`] 高阶句柄(已标记为视频形状),可继续调整裁剪 /
2574    ///   填充模式等图片属性。
2575    /// - 失败:返回 [`crate::Error::Io`](视频或海报文件读取失败)。
2576    ///
2577    /// # 内部流程
2578    ///
2579    /// 1. 读取视频文件内容为 `Vec<u8>`;
2580    /// 2. 读取海报帧图片内容(若 `poster_path` 为 `None`,用 1x1 透明 PNG 占位);
2581    /// 3. 手动构造 [`Picture`] 并注册海报帧 `MediaEntry`(`rIdImgN` + `imageN.png` part);
2582    /// 4. 分配**本 slide 内**唯一的视频关系 id `rIdVideoN`;
2583    /// 5. 调用 `pic.set_video(rIdVideoN)` 把图片标记为视频形状(写出 `<a:videoFile r:link="rIdVideoN"/>`);
2584    /// 6. 注册 [`VideoEntry`] 到本 slide(保存时由 `to_opc_package` 写出独立的
2585    ///    `/ppt/media/mediaN.mp4` part + `slideN.xml.rels` Video 关系)。
2586    ///
2587    /// # 关键约束
2588    ///
2589    /// - **r:embed vs r:link**:海报帧图片用 `r:embed`(嵌入图片),视频文件用 `r:link`(外部链接方式);
2590    /// - **partname 命名**:视频文件 `/ppt/media/mediaN.mp4`,海报帧图片 `/ppt/media/imageN.png`,
2591    ///   两者命名空间不同但同在 `/ppt/media/` 目录下;
2592    /// - **关系类型**:视频用 `.../relationships/video`,海报帧用 `.../relationships/image`。
2593    ///
2594    /// # 示例
2595    ///
2596    /// ```no_run
2597    /// # use pptx_rs::*;
2598    /// # let mut prs = Presentation::new().unwrap();
2599    /// # let counter = prs.id_counter();
2600    /// # let slide = prs.slides_mut().add_slide(counter).unwrap();
2601    /// let _video = slide.shapes_mut().add_video(
2602    ///     "intro.mp4", Some("poster.png"),
2603    ///     Inches(1.0), Inches(1.0), Inches(6.0), Inches(4.0),
2604    /// ).unwrap();
2605    /// ```
2606    pub fn add_video<L, T, W, H, P>(
2607        &mut self,
2608        video_path: P,
2609        poster_path: Option<P>,
2610        left: L,
2611        top: T,
2612        width: W,
2613        height: H,
2614    ) -> crate::Result<Picture>
2615    where
2616        L: EmuExt,
2617        T: EmuExt,
2618        W: EmuExt,
2619        H: EmuExt,
2620        P: AsRef<std::path::Path>,
2621    {
2622        // 1) 读取视频文件二进制内容
2623        let video_blob = std::fs::read(video_path.as_ref())?;
2624
2625        // 2) 取海报帧图片:若调用方未提供,用一个 1x1 透明 PNG 占位。
2626        //    (PowerPoint 在没有海报帧时会显示空白,这里给出最小合法 PNG 避免渲染异常)
2627        let poster_pic = if let Some(poster) = poster_path.as_ref() {
2628            Picture::from_path(poster.as_ref())?
2629        } else {
2630            // 1x1 透明 PNG(67 字节,标准 base64: iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+M8AAAMBAQDJ/IQ7AAAAAElFTkSuQmCC)
2631            const TRANSPARENT_PNG_1X1: &[u8] = &[
2632                0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D, 0x49, 0x48,
2633                0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x06, 0x00, 0x00,
2634                0x00, 0x1F, 0x15, 0xC4, 0x89, 0x00, 0x00, 0x00, 0x0D, 0x49, 0x44, 0x41, 0x54, 0x78,
2635                0x9C, 0x63, 0x00, 0x01, 0x00, 0x00, 0x05, 0x00, 0x01, 0x0D, 0x0A, 0x2D, 0xB4, 0x00,
2636                0x00, 0x00, 0x00, 0x49, 0x45, 0x4E, 0x44, 0xAE, 0x42, 0x60, 0x82,
2637            ];
2638            Picture::from_bytes(TRANSPARENT_PNG_1X1.to_vec(), ".png")
2639        };
2640
2641        // 3) 直接构造 Picture(不走 add_picture,因为它强制从文件路径读取,
2642        //    而本方法的 poster_path 可能为 None,需要用内置占位 PNG)
2643        let mut pic = poster_pic;
2644        pic.set_left(left.emu());
2645        pic.set_top(top.emu());
2646        pic.set_width(width.emu());
2647        pic.set_height(height.emu());
2648        pic.set_id(self.slide.next_shape_id());
2649        if pic.name().is_empty() {
2650            pic.set_name(format!("Video {}", self.slide.inner.shapes.len() + 1));
2651        }
2652        // 4a) 海报帧图片关系 id(rIdImgN 命名空间)
2653        let poster_rid = self.slide.allocate_image_rid();
2654        pic.pic_mut().rid = poster_rid.clone();
2655        // 4b) 注册海报帧图片 MediaEntry(imageN.png part + Image 关系)
2656        let poster_partname = crate::opc::part::new_part_name(
2657            format!(
2658                "/ppt/media/image{}.{}",
2659                self.slide.next_media_index(),
2660                pic.ext.trim_start_matches('.')
2661            )
2662            .as_str(),
2663        );
2664        let poster_ct = crate::shape::picture::content_type_for(&pic.ext);
2665        let poster_blob = pic.blob.clone().unwrap_or_default();
2666        self.slide.register_media(MediaEntry {
2667            partname: poster_partname,
2668            content_type: poster_ct.to_string(),
2669            blob: poster_blob,
2670            rid: poster_rid.clone(),
2671        });
2672
2673        // 5) 分配视频关系 id(rIdVideoN 命名空间,与海报帧 image 区分)
2674        let video_rid = self.slide.allocate_video_rid();
2675        pic.set_video(video_rid.clone());
2676
2677        // 6) 注册 VideoEntry(to_opc_package 阶段会用全局索引重新分配 partname)
2678        let local_idx = self.slide.next_video_index();
2679        let video_partname =
2680            crate::opc::part::new_part_name(format!("/ppt/media/media{}.mp4", local_idx).as_str());
2681        self.slide.register_video(VideoEntry {
2682            partname: video_partname,
2683            blob: video_blob,
2684            rid: video_rid.clone(),
2685        });
2686
2687        // 7) 推入 spTree
2688        let oxml_pic = pic.pic.clone();
2689        self.slide.inner.shapes.push(OxmlSlideShape::Pic(oxml_pic));
2690        Ok(pic)
2691    }
2692
2693    /// 在当前幻灯片上嵌入一个**音频**形状(TODO-033)。
2694    ///
2695    /// 对标 python-pptx 0.6.19+ 的 `shapes.add_audio()`。把指定音频文件
2696    /// 嵌入到 `.pptx` 中,PowerPoint 双击音频形状时会播放该音频。
2697    ///
2698    /// # 参数
2699    /// - `audio_path`:音频文件路径(如 `.mp3`)。文件内容会以原始字节
2700    ///   写入 `/ppt/media/mediaN.mp3`。
2701    /// - `poster_path`:海报帧图片路径(音频未播放时显示的图标,如 `.png` / `.jpg`)。
2702    ///   若为 `None`,PowerPoint 会显示默认音频图标(推荐传入代表音频的图标图片)。
2703    /// - `left` / `top` / `width` / `height`:音频形状在 slide 上的位置与尺寸(EMU)。
2704    ///
2705    /// # 返回值
2706    /// - 成功:返回 [`Picture`] 高阶句柄(已标记为音频形状);
2707    /// - 失败:返回 [`crate::Error::Io`](音频或海报文件读取失败)。
2708    ///
2709    /// # 与 [`Self::add_video`] 的差异
2710    /// - `add_video` 写出 `<a:videoFile r:link="..."/>`,partname 后缀 `.mp4`;
2711    /// - `add_audio` 写出 `<a:audioFile r:link="..."/>`,partname 后缀 `.mp3`;
2712    /// - 关系类型分别为 `.../video` 与 `.../audio`。
2713    ///
2714    /// # 示例
2715    ///
2716    /// ```no_run
2717    /// # use pptx_rs::*;
2718    /// # let mut prs = Presentation::new().unwrap();
2719    /// # let counter = prs.id_counter();
2720    /// # let slide = prs.slides_mut().add_slide(counter).unwrap();
2721    /// let _audio = slide.shapes_mut().add_audio(
2722    ///     "bgm.mp3", Some("speaker.png"),
2723    ///     Inches(1.0), Inches(1.0), Inches(2.0), Inches(2.0),
2724    /// ).unwrap();
2725    /// ```
2726    pub fn add_audio<L, T, W, H, P>(
2727        &mut self,
2728        audio_path: P,
2729        poster_path: Option<P>,
2730        left: L,
2731        top: T,
2732        width: W,
2733        height: H,
2734    ) -> crate::Result<Picture>
2735    where
2736        L: EmuExt,
2737        T: EmuExt,
2738        W: EmuExt,
2739        H: EmuExt,
2740        P: AsRef<std::path::Path>,
2741    {
2742        // 1) 读取音频文件二进制内容
2743        let audio_blob = std::fs::read(audio_path.as_ref())?;
2744
2745        // 2) 取海报帧图片(同 add_video 流程)
2746        let poster_pic = if let Some(poster) = poster_path.as_ref() {
2747            Picture::from_path(poster.as_ref())?
2748        } else {
2749            const TRANSPARENT_PNG_1X1: &[u8] = &[
2750                0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D, 0x49, 0x48,
2751                0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x06, 0x00, 0x00,
2752                0x00, 0x1F, 0x15, 0xC4, 0x89, 0x00, 0x00, 0x00, 0x0D, 0x49, 0x44, 0x41, 0x54, 0x78,
2753                0x9C, 0x63, 0x00, 0x01, 0x00, 0x00, 0x05, 0x00, 0x01, 0x0D, 0x0A, 0x2D, 0xB4, 0x00,
2754                0x00, 0x00, 0x00, 0x49, 0x45, 0x4E, 0x44, 0xAE, 0x42, 0x60, 0x82,
2755            ];
2756            Picture::from_bytes(TRANSPARENT_PNG_1X1.to_vec(), ".png")
2757        };
2758
2759        // 3) 直接构造 Picture(不走 add_picture,避免文件读取限制)
2760        let mut pic = poster_pic;
2761        pic.set_left(left.emu());
2762        pic.set_top(top.emu());
2763        pic.set_width(width.emu());
2764        pic.set_height(height.emu());
2765        pic.set_id(self.slide.next_shape_id());
2766        if pic.name().is_empty() {
2767            pic.set_name(format!("Audio {}", self.slide.inner.shapes.len() + 1));
2768        }
2769        // 4a) 海报帧图片关系 id
2770        let poster_rid = self.slide.allocate_image_rid();
2771        pic.pic_mut().rid = poster_rid.clone();
2772        // 4b) 注册海报帧图片 MediaEntry
2773        let poster_partname = crate::opc::part::new_part_name(
2774            format!(
2775                "/ppt/media/image{}.{}",
2776                self.slide.next_media_index(),
2777                pic.ext.trim_start_matches('.')
2778            )
2779            .as_str(),
2780        );
2781        let poster_ct = crate::shape::picture::content_type_for(&pic.ext);
2782        let poster_blob = pic.blob.clone().unwrap_or_default();
2783        self.slide.register_media(MediaEntry {
2784            partname: poster_partname,
2785            content_type: poster_ct.to_string(),
2786            blob: poster_blob,
2787            rid: poster_rid.clone(),
2788        });
2789
2790        // 5) 分配音频关系 id
2791        let audio_rid = self.slide.allocate_audio_rid();
2792        pic.set_audio(audio_rid.clone());
2793
2794        // 6) 注册 AudioEntry
2795        let local_idx = self.slide.next_audio_index();
2796        let audio_partname =
2797            crate::opc::part::new_part_name(format!("/ppt/media/media{}.mp3", local_idx).as_str());
2798        self.slide.register_audio(AudioEntry {
2799            partname: audio_partname,
2800            blob: audio_blob,
2801            rid: audio_rid.clone(),
2802        });
2803
2804        // 7) 推入 spTree
2805        let oxml_pic = pic.pic.clone();
2806        self.slide.inner.shapes.push(OxmlSlideShape::Pic(oxml_pic));
2807        Ok(pic)
2808    }
2809}
2810
2811/// Slide 的"高阶背景"句柄(对标 python-pptx `_Background`)。
2812///
2813/// # 能力
2814/// - **只读视图**:通过 `fill_type()` 查询当前背景填充类型;
2815/// - **写入入口**:通过 `Slide` 上的 `set_background_solid` / `clear_background` /
2816///   `set_follow_master_background` 方法修改背景(这些方法直接写入 oxml 模型,
2817///   序列化时产出 `<p:cSld><p:bg>...</p:bg></p:cSld>`)。
2818///
2819/// # 与 python-pptx 的差异
2820/// python-pptx 中 `slide.background.fill.solid()` 是链式写入;本库改为
2821/// `slide.set_background_solid(color)` 直接写入,避免可变句柄的生命周期问题。
2822#[derive(Debug)]
2823pub struct SlideBackground<'a> {
2824    /// 内部只读引用。
2825    slide: &'a Slide,
2826}
2827
2828impl<'a> SlideBackground<'a> {
2829    /// 当前背景填充类型。
2830    ///
2831    /// # 返回值
2832    /// - `MsoFillType::Inherit`:未设置独立背景(`inner.background` 为 `None` 或 `Reference`);
2833    /// - `MsoFillType::Solid`:已设置纯色背景(`inner.background` 为 `Property` 且 `solid_fill` 非 `None`);
2834    /// - 其它类型暂未支持,遇到时返回 `Inherit` 作为兜底。
2835    pub fn fill_type(&self) -> crate::oxml::simpletypes::MsoFillType {
2836        use crate::oxml::slide::SlideBackground as OxmlBg;
2837        match &self.slide.inner.background {
2838            None => crate::oxml::simpletypes::MsoFillType::Inherit,
2839            Some(OxmlBg::Reference(_)) => crate::oxml::simpletypes::MsoFillType::Inherit,
2840            Some(OxmlBg::Property(p)) => {
2841                if matches!(p.solid_fill, crate::oxml::color::Color::None) {
2842                    crate::oxml::simpletypes::MsoFillType::Inherit
2843                } else {
2844                    crate::oxml::simpletypes::MsoFillType::Solid
2845                }
2846            }
2847        }
2848    }
2849}
2850
2851/// 幻灯片 ID(指向 `sldIdLst` 中的条目)。
2852///
2853/// 当前实现未暴露给用户(仅作类型占位),留作后续 read-modify-write 流程使用。
2854#[derive(Copy, Clone, Debug, Eq, PartialEq, Hash, Default)]
2855pub struct SlideId(
2856    /// 幻灯片 ID 值(对应 `sldIdLst` 中的 `id` 属性)。
2857    pub u32,
2858);
2859
2860/// 关系 id 引用(指向 `ppt/slides/slideN.xml`)。
2861///
2862/// 当前实现未暴露给用户(同 [`SlideId`])。
2863#[derive(Clone, Debug, Eq, PartialEq, Hash, Default)]
2864pub struct SlideRef(
2865    /// 关系 id(如 `rId10`,指向 `ppt/slides/slideN.xml`)。
2866    pub String,
2867);
2868
2869/// `Slides` —— [`crate::presentation::Presentation`] 上的幻灯片集合。
2870///
2871/// # 内部表示
2872///
2873/// 仅持有一个 `Vec<SlideEntry>`,每个 entry 包含:
2874///
2875/// - `sld`:高阶 [`Slide`];
2876/// - `sld_id`:`sldIdLst` 中用到的 id(一般从 `256` 开始递增);
2877/// - `rid`:与 `presentation.xml.rels` 中的 `<Relationship Id="..."/>` 对应;
2878/// - `partname`:相对 zip 根的 part 路径(如 `/ppt/slides/slide1.xml`)。
2879#[derive(Debug, Default)]
2880pub struct Slides {
2881    pub(crate) slides: Vec<SlideEntry>,
2882}
2883
2884/// 单张幻灯片在 `Slides` 集合内的"条目"(含 oxml 与 OPC 元数据)。
2885#[derive(Debug, Clone)]
2886pub(crate) struct SlideEntry {
2887    /// 高阶 Slide。
2888    pub sld: Slide,
2889    /// `sldIdLst` 中用到的 id。
2890    pub sld_id: u32,
2891    /// 关系 id(指向 `ppt/slides/slideN.xml`)。
2892    pub rid: String,
2893    /// slide 文件名(`/ppt/slides/slideN.xml`)。
2894    pub partname: String,
2895}
2896
2897impl SlideEntry {
2898    /// **包内**构造一个 [`SlideEntry`]。
2899    ///
2900    /// 三个 OPC 字段由 [`crate::presentation::Presentation::from_opc`] 在
2901    /// 解析 `presentation.xml.rels` + `sldIdLst` 后填入。
2902    pub(crate) fn new(sld: Slide, sld_id: u32, rid: String, partname: String) -> Self {
2903        SlideEntry {
2904            sld,
2905            sld_id,
2906            rid,
2907            partname,
2908        }
2909    }
2910}
2911
2912impl Slides {
2913    /// 新建一个空集合。
2914    pub fn new() -> Self {
2915        Slides::default()
2916    }
2917
2918    /// 数量。
2919    pub fn len(&self) -> usize {
2920        self.slides.len()
2921    }
2922    /// 是否为空。
2923    pub fn is_empty(&self) -> bool {
2924        self.slides.is_empty()
2925    }
2926
2927    /// 遍历所有 entry。
2928    #[allow(private_interfaces)]
2929    pub fn iter(&self) -> std::slice::Iter<'_, SlideEntry> {
2930        self.slides.iter()
2931    }
2932
2933    /// 按下标取不可变 entry。
2934    #[allow(private_interfaces)]
2935    pub fn get(&self, idx: usize) -> Option<&SlideEntry> {
2936        self.slides.get(idx)
2937    }
2938
2939    /// 按下标取可变 entry。
2940    #[allow(private_interfaces)]
2941    pub fn get_mut(&mut self, idx: usize) -> Option<&mut SlideEntry> {
2942        self.slides.get_mut(idx)
2943    }
2944
2945    /// 添加一个新 slide(空白)。
2946    ///
2947    /// 返回新 slide 的可变引用,调用方可继续 `shapes_mut().add_*(...)`。
2948    /// `id_counter` 必须从 [`crate::presentation::Presentation::id_counter`] 传入。
2949    ///
2950    /// 默认使用第一个 layout(索引 0)。如需指定版式,调用
2951    /// [`Slides::add_slide_with_layout`]。
2952    pub fn add_slide(&mut self, id_counter: Rc<Cell<u32>>) -> crate::Result<&mut Slide> {
2953        self.add_slide_with_layout(id_counter, 0)
2954    }
2955
2956    /// 添加一个新 slide,并指定使用的版式索引(对应 `SlideLayouts[i]`)。
2957    ///
2958    /// # 参数
2959    /// - `id_counter`:全局 shape id 计数器(必须与所属 Presentation 共享);
2960    /// - `layout_idx`:版式索引。`<= 0` 时退化为 `0`;越界会被钳制为最后一个。
2961    ///
2962    /// # 行为
2963    /// - 新 slide 的 `layout_rid` 形如 `rIdLayout<N>`;
2964    /// - 在保存时 `presentation.xml` 的 `<p:sldIdLst/>` 内会按调用顺序排列。
2965    pub fn add_slide_with_layout(
2966        &mut self,
2967        id_counter: Rc<Cell<u32>>,
2968        layout_idx: usize,
2969    ) -> crate::Result<&mut Slide> {
2970        let next_no = self.slides.len() + 1;
2971        let mut sld = Slide::blank(id_counter);
2972        sld.set_layout_rid(format!("rIdLayout{}", layout_idx + 1));
2973        let entry = SlideEntry {
2974            sld,
2975            sld_id: 256 + next_no as u32,
2976            rid: format!("rId{}", 10 + next_no),
2977            partname: format!("/ppt/slides/slide{}.xml", next_no),
2978        };
2979        // 先 push,然后用最后位置的索引获取可变引用,避免在借用时再借 self
2980        self.slides.push(entry);
2981        let last = self.slides.len() - 1;
2982        Ok(&mut self.slides[last].sld)
2983    }
2984
2985    /// **包内**:把一个外部构造的 [`SlideEntry`] 推入集合末尾。
2986    ///
2987    /// 主要服务于 [`crate::presentation::Presentation::from_opc`]:
2988    /// 在从 zip 读出所有 slide 后,按 `sldIdLst` 顺序依次推入,
2989    /// 保证后续 `save` 时 `sldIdLst` 与 `slide_ids` 顺序一致。
2990    pub(crate) fn push_entry(&mut self, entry: SlideEntry) {
2991        self.slides.push(entry);
2992    }
2993
2994    /// 按下标移除。
2995    ///
2996    /// 对标 python-pptx `Presentation.slides._sldIdLst.remove(index)`。
2997    /// 返回被移除的 `Slide`;越界返回 `None`。
2998    pub fn remove(&mut self, idx: usize) -> Option<Slide> {
2999        if idx < self.slides.len() {
3000            let removed = self.slides.remove(idx);
3001            // 重新分配 sld_id / rid / partname,避免空号
3002            self.reindex();
3003            Some(removed.sld)
3004        } else {
3005            None
3006        }
3007    }
3008
3009    /// 将幻灯片从 `from_idx` 移动到 `to_idx`,实现重排序。
3010    ///
3011    /// 对标 python-pptx 中通过 XML 操作调整 `sldIdLst` 子元素顺序的能力。
3012    ///
3013    /// # 参数
3014    /// - `from_idx`:源位置索引(0-based);
3015    /// - `to_idx`:目标位置索引(0-based,基于移除前的长度)。
3016    ///
3017    /// # 行为
3018    /// - 越界时返回 `Err(IndexOutOfRange)`;
3019    /// - `from_idx == to_idx` 时为 no-op;
3020    /// - 移动后所有 `sld_id` / `rid` / `partname` 会重新分配(调用 `reindex`)。
3021    ///
3022    /// # 示例
3023    /// ```no_run
3024    /// # use pptx_rs::Presentation;
3025    /// # let mut p = Presentation::new().unwrap();
3026    /// # let counter = p.id_counter();
3027    /// # p.slides_mut().add_slide(counter.clone()).unwrap();
3028    /// # p.slides_mut().add_slide(counter.clone()).unwrap();
3029    /// # p.slides_mut().add_slide(counter).unwrap();
3030    /// // 将第 0 张移到末尾
3031    /// p.slides_mut().move_slide(0, 2).unwrap();
3032    /// ```
3033    pub fn move_slide(&mut self, from_idx: usize, to_idx: usize) -> crate::Result<()> {
3034        let len = self.slides.len();
3035        if from_idx >= len {
3036            return Err(crate::Error::IndexOutOfRange(from_idx));
3037        }
3038        // to_idx 允许等于 len(表示移到末尾),但实际插入位置不超过 len-1
3039        if to_idx > len {
3040            return Err(crate::Error::IndexOutOfRange(to_idx));
3041        }
3042        if from_idx == to_idx || from_idx + 1 == to_idx {
3043            // no-op:移到自身或紧邻后方
3044            return Ok(());
3045        }
3046        // 取出元素
3047        let entry = self.slides.remove(from_idx);
3048        // 计算实际插入位置:如果 from_idx < to_idx,由于已移除一个元素,目标位置需 -1
3049        let insert_at = if to_idx > from_idx {
3050            to_idx - 1
3051        } else {
3052            to_idx
3053        };
3054        self.slides.insert(insert_at, entry);
3055        // 重新分配 sld_id / rid / partname
3056        self.reindex();
3057        Ok(())
3058    }
3059
3060    /// 按给定索引顺序批量重排幻灯片。
3061    ///
3062    /// 对标 python-pptx 中通过 XML 操作批量调整 `sldIdLst` 子元素顺序的能力。
3063    ///
3064    /// # 参数
3065    /// - `indices`:新顺序的索引列表(基于重排前的位置)。长度必须等于当前幻灯片数,
3066    ///   且每个索引在 `[0, len)` 范围内、不重复、全覆盖。
3067    ///
3068    /// # 行为
3069    /// - 验证失败时返回 `Err` 且**不修改**当前顺序;
3070    /// - 重排后所有 `sld_id` / `rid` / `partname` 会重新分配(调用 `reindex`)。
3071    ///
3072    /// # 示例
3073    /// ```no_run
3074    /// # use pptx_rs::Presentation;
3075    /// # let mut p = Presentation::new().unwrap();
3076    /// # let counter = p.id_counter();
3077    /// # p.slides_mut().add_slide(counter.clone()).unwrap();
3078    /// # p.slides_mut().add_slide(counter.clone()).unwrap();
3079    /// # p.slides_mut().add_slide(counter).unwrap();
3080    /// // 反转顺序:[0,1,2] -> [2,1,0]
3081    /// p.slides_mut().reorder(&[2, 1, 0]).unwrap();
3082    /// ```
3083    pub fn reorder(&mut self, indices: &[usize]) -> crate::Result<()> {
3084        let len = self.slides.len();
3085        if indices.len() != len {
3086            return Err(crate::Error::Other(format!(
3087                "reorder: 索引数 {} 与幻灯片数 {} 不匹配",
3088                indices.len(),
3089                len
3090            )));
3091        }
3092        // 验证:所有索引在范围内且不重复
3093        let mut seen = vec![false; len];
3094        for &i in indices {
3095            if i >= len {
3096                return Err(crate::Error::IndexOutOfRange(i));
3097            }
3098            if seen[i] {
3099                return Err(crate::Error::Other(format!("reorder: 索引 {i} 重复出现")));
3100            }
3101            seen[i] = true;
3102        }
3103        // 执行重排:按 indices 顺序收集 entries
3104        let mut new_slides: Vec<SlideEntry> = Vec::with_capacity(len);
3105        // 先取出所有 entries(避免部分借用问题)
3106        let old_slides = std::mem::take(&mut self.slides);
3107        for &i in indices {
3108            // safety: 已验证 i < len 且 old_slides.len() == len
3109            new_slides.push(old_slides[i].clone());
3110        }
3111        self.slides = new_slides;
3112        // 重新分配 sld_id / rid / partname
3113        self.reindex();
3114        Ok(())
3115    }
3116
3117    /// 在指定位置插入一个空白 slide。
3118    ///
3119    /// 对标 pypdf `PdfWriter.insert_page(index)` / python-pptx 中通过
3120    /// XML 操作在 `sldIdLst` 中间插入条目的能力。
3121    ///
3122    /// # 参数
3123    /// - `id_counter`:全局 shape id 计数器;
3124    /// - `index`:插入位置(0 = 最前,`len` = 末尾,等价于 `add_slide`)。
3125    ///
3126    /// # 注意
3127    /// 插入后所有 `sld_id` / `rid` / `partname` 会**重新分配**——
3128    /// 因为 OOXML 的 `sldIdLst` 要求 id 单调递增。调用方不应依赖
3129    /// 插入前的 `sld_id` / `rid` 值。
3130    pub fn insert_slide(
3131        &mut self,
3132        id_counter: Rc<Cell<u32>>,
3133        index: usize,
3134    ) -> crate::Result<&mut Slide> {
3135        let clamped = index.min(self.slides.len());
3136        let mut sld = Slide::blank(id_counter);
3137        sld.set_layout_rid("rIdLayout1".to_string());
3138        let entry = SlideEntry {
3139            sld,
3140            sld_id: 0, // 占位;reindex 会重算
3141            rid: String::new(),
3142            partname: String::new(),
3143        };
3144        self.slides.insert(clamped, entry);
3145        // 重新分配 sld_id / rid / partname
3146        self.reindex();
3147        let last = clamped.min(self.slides.len() - 1);
3148        Ok(&mut self.slides[last].sld)
3149    }
3150
3151    /// 克隆一个已有 slide 到指定位置。
3152    ///
3153    /// 对标 pypdf `PdfWriter.clone_page_from_reader` + `insert_page`。
3154    /// 深拷贝源 slide 的所有形状、文本、备注等,但**分配新的** id / rid / partname。
3155    ///
3156    /// # 参数
3157    /// - `src_idx`:源 slide 索引;
3158    /// - `insert_at`:目标位置(`len` = 末尾)。
3159    pub fn clone_slide(&mut self, src_idx: usize, insert_at: usize) -> crate::Result<&mut Slide> {
3160        if src_idx >= self.slides.len() {
3161            return Err(crate::Error::IndexOutOfRange(src_idx));
3162        }
3163        let clamped = insert_at.min(self.slides.len());
3164        let cloned_sld = self.slides[src_idx].sld.clone();
3165        let entry = SlideEntry {
3166            sld: cloned_sld,
3167            sld_id: 0,
3168            rid: String::new(),
3169            partname: String::new(),
3170        };
3171        self.slides.insert(clamped, entry);
3172        self.reindex();
3173        let last = clamped.min(self.slides.len() - 1);
3174        Ok(&mut self.slides[last].sld)
3175    }
3176
3177    /// 从另一个 `Slides` 集合追加所有 slide(深拷贝)。
3178    ///
3179    /// 对标 pypdf `PdfWriter.append_pages_from_reader`。
3180    /// 每个源 slide 被深拷贝后追加到当前集合末尾。
3181    ///
3182    /// # 注意
3183    /// - 源 slide 的 `id_counter` **不会被**共享——新 slide 使用
3184    ///   当前集合的 `id_counter`;
3185    /// - 追加后所有 `sld_id` / `rid` / `partname` 重新分配。
3186    pub fn append_slides_from(&mut self, other: &Slides) {
3187        for entry in &other.slides {
3188            let cloned = entry.sld.clone();
3189            self.slides.push(SlideEntry {
3190                sld: cloned,
3191                sld_id: 0,
3192                rid: String::new(),
3193                partname: String::new(),
3194            });
3195        }
3196        self.reindex();
3197    }
3198
3199    /// 重新分配所有 slide 的 `sld_id` / `rid` / `partname`。
3200    ///
3201    /// 在 `insert_slide` / `clone_slide` / `remove` / `append_slides_from`
3202    /// 之后调用,确保 `sldIdLst` 中的 id 单调递增、partname 不冲突。
3203    fn reindex(&mut self) {
3204        for (i, entry) in self.slides.iter_mut().enumerate() {
3205            let no = i + 1;
3206            entry.sld_id = 256 + no as u32;
3207            entry.rid = format!("rId{}", 10 + no);
3208            entry.partname = format!("/ppt/slides/slide{}.xml", no);
3209        }
3210    }
3211
3212    /// 按引用找到第一个匹配的 slide 索引。
3213    ///
3214    /// 形如 python-pptx 中 `slides.index(slide)`;**未找到返回 None**(不抛异常),
3215    /// 与 python-pptx 略不同——但更符合 Rust 的"零异常"习惯。
3216    pub fn index_of(&self, sld: &Slide) -> Option<usize> {
3217        // 比较内部 oxml 引用即可(每个 slide 独立持有 OxmlSld)
3218        self.slides
3219            .iter()
3220            .position(|e| std::ptr::eq(&e.sld.inner, &sld.inner))
3221    }
3222}
3223
3224#[cfg(test)]
3225#[allow(clippy::field_reassign_with_default)]
3226mod tests {
3227    use super::*;
3228    use crate::oxml::simpletypes::PresetGeometry;
3229    use crate::units::Inches;
3230
3231    /// 在 slide 上手工添加一个**标题占位符** sp,验证 `Shapes::title()` 能找到它。
3232    #[test]
3233    fn shapes_title_finds_title_placeholder() {
3234        let mut s = Slide::blank(Rc::new(Cell::new(0)));
3235        let mut sp = crate::oxml::shape::Sp::default();
3236        sp.id = 2;
3237        sp.name = "Title 1".into();
3238        sp.is_placeholder = true;
3239        sp.ph_idx = Some(0);
3240        sp.ph_type = Some("title".into());
3241        sp.text = crate::oxml::txbody::TextBody::new();
3242        s.inner.shapes.push(OxmlSlideShape::Sp(sp));
3243        let _t = s.shapes().title().expect("title exists");
3244    }
3245
3246    /// 验证 `Shapes::placeholders()` 收集占位符 + 按 idx 排序。
3247    #[test]
3248    fn shapes_placeholders_sorted_by_idx() {
3249        let mut s = Slide::blank(Rc::new(Cell::new(0)));
3250        for (i, idx) in [3u32, 0, 5].iter().copied().enumerate() {
3251            let mut sp = crate::oxml::shape::Sp::default();
3252            sp.id = (i + 10) as u32;
3253            sp.name = format!("PH {idx}");
3254            sp.is_placeholder = true;
3255            sp.ph_idx = Some(idx);
3256            sp.text = crate::oxml::txbody::TextBody::new();
3257            s.inner.shapes.push(OxmlSlideShape::Sp(sp));
3258        }
3259        let phs = s.shapes().placeholders();
3260        assert_eq!(phs.len(), 3);
3261        // 第 0 个应是 idx=0
3262        if let crate::shape::ShapeKind::AutoShape(a) = &phs[0] {
3263            assert_eq!(a.sp().name, "PH 0");
3264        } else {
3265            panic!("expected AutoShape");
3266        }
3267    }
3268
3269    /// TODO-007:`set_title_text` / `title_text` 基本流程。
3270    #[test]
3271    fn placeholder_title_text_set_and_get() {
3272        let mut s = Slide::blank(Rc::new(Cell::new(0)));
3273        let mut sp = crate::oxml::shape::Sp::default();
3274        sp.id = 2;
3275        sp.name = "Title 1".into();
3276        sp.is_placeholder = true;
3277        sp.ph_idx = Some(0);
3278        sp.ph_type = Some("title".into());
3279        sp.text = crate::oxml::txbody::TextBody::new();
3280        s.inner.shapes.push(OxmlSlideShape::Sp(sp));
3281
3282        // 初始无文本
3283        assert_eq!(s.title_text(), Some("".to_string()));
3284        // 设置标题
3285        assert!(s.set_title_text("Hello Title"));
3286        assert_eq!(s.title_text(), Some("Hello Title".to_string()));
3287    }
3288
3289    /// TODO-007:`set_title_text` 未找到标题占位符时返回 false。
3290    #[test]
3291    fn placeholder_title_text_not_found() {
3292        let mut s = Slide::blank(Rc::new(Cell::new(0)));
3293        // 无任何占位符
3294        assert!(!s.set_title_text("test"));
3295        assert_eq!(s.title_text(), None);
3296    }
3297
3298    /// TODO-007:`append_body_paragraph` / `set_body_text` / `body_text`。
3299    #[test]
3300    fn placeholder_body_text_append_and_set() {
3301        let mut s = Slide::blank(Rc::new(Cell::new(0)));
3302        let mut sp = crate::oxml::shape::Sp::default();
3303        sp.id = 3;
3304        sp.name = "Content Placeholder 1".into();
3305        sp.is_placeholder = true;
3306        sp.ph_idx = Some(1);
3307        sp.ph_type = Some("body".into());
3308        sp.text = crate::oxml::txbody::TextBody::new();
3309        s.inner.shapes.push(OxmlSlideShape::Sp(sp));
3310
3311        // 追加段落
3312        assert!(s.append_body_paragraph("第一段"));
3313        assert!(s.append_body_paragraph("第二段"));
3314        assert_eq!(s.body_text(), Some("第一段\n第二段".to_string()));
3315
3316        // 替换全部
3317        assert!(s.set_body_text("替换文本"));
3318        assert_eq!(s.body_text(), Some("替换文本".to_string()));
3319    }
3320
3321    /// TODO-007:占位符继承——从 layout 继承 xfrm / fill / line。
3322    #[test]
3323    fn placeholder_inheritance_from_layout() {
3324        use crate::oxml::sppr::{Fill, Transform};
3325        use crate::units::Emu;
3326        use std::cell::RefCell;
3327        use std::rc::Rc;
3328
3329        // 构造 layout:含一个 title 占位符(idx=0),带完整 xfrm
3330        let mut layout_sp = crate::oxml::shape::Sp::default();
3331        layout_sp.is_placeholder = true;
3332        layout_sp.ph_idx = Some(0);
3333        layout_sp.ph_type = Some("title".into());
3334        layout_sp.properties.xfrm = Transform {
3335            off_x: Some(Emu(457200)),
3336            off_y: Some(Emu(457200)),
3337            ext_cx: Some(Emu(8229600)),
3338            ext_cy: Some(Emu(1143000)),
3339            rot: None,
3340            flip_h: false,
3341            flip_v: false,
3342        };
3343        layout_sp.properties.fill =
3344            Fill::Solid(crate::oxml::color::Color::RGB(crate::units::RGBColor::RED));
3345        let layout_oxml = crate::oxml::slidelayout::SldLayout {
3346            name: "Title Slide".into(),
3347            type_: "title".into(),
3348            shapes: vec![layout_sp],
3349        };
3350        let layout_ref = crate::slide_layouts::SlideLayoutRef {
3351            idx: 0,
3352            partname: "/ppt/slideLayouts/slideLayout1.xml".into(),
3353            rid: "rIdLayout1".into(),
3354            oxml: Rc::new(RefCell::new(layout_oxml)),
3355            master_partname: String::new(),
3356        };
3357
3358        // 构造 slide:含一个 title 占位符(idx=0),但 xfrm 为空、fill 为 Inherit
3359        let mut s = Slide::blank(Rc::new(Cell::new(0)));
3360        let mut slide_sp = crate::oxml::shape::Sp::default();
3361        slide_sp.id = 2;
3362        slide_sp.name = "Title 1".into();
3363        slide_sp.is_placeholder = true;
3364        slide_sp.ph_idx = Some(0);
3365        slide_sp.ph_type = Some("title".into());
3366        slide_sp.text = crate::oxml::txbody::TextBody::new();
3367        // xfrm 为空(应继承)、fill 为 Inherit(应继承)、line 为 None(应继承)
3368        s.inner.shapes.push(OxmlSlideShape::Sp(slide_sp));
3369
3370        // 调用 placeholders_inherited
3371        let phs = s.shapes().placeholders_inherited(&layout_ref);
3372        assert_eq!(phs.len(), 1);
3373        if let crate::shape::ShapeKind::Placeholder(p) = &phs[0] {
3374            // 验证 xfrm 已继承
3375            assert_eq!(p.0.sp().properties.xfrm.off_x, Some(Emu(457200)));
3376            assert_eq!(p.0.sp().properties.xfrm.ext_cx, Some(Emu(8229600)));
3377            // 验证 fill 已继承
3378            assert!(matches!(
3379                &p.0.sp().properties.fill,
3380                Fill::Solid(crate::oxml::color::Color::RGB(_))
3381            ));
3382        } else {
3383            panic!("expected Placeholder");
3384        }
3385
3386        // 验证原 slide 上的占位符**未被修改**(继承是 clone 后的快照)
3387        if let OxmlSlideShape::Sp(orig) = &s.inner.shapes[0] {
3388            assert!(orig.properties.xfrm.is_empty(), "原 slide 占位符不应被修改");
3389        } else {
3390            panic!("expected Sp");
3391        }
3392    }
3393
3394    /// TODO-007:`add_placeholder_from_layout` 从 layout 创建新占位符。
3395    #[test]
3396    fn add_placeholder_from_layout_creates_new() {
3397        use std::cell::RefCell;
3398        use std::rc::Rc;
3399
3400        // 构造 layout:含一个 body 占位符(idx=1)
3401        let mut layout_sp = crate::oxml::shape::Sp::default();
3402        layout_sp.is_placeholder = true;
3403        layout_sp.ph_idx = Some(1);
3404        layout_sp.ph_type = Some("body".into());
3405        layout_sp.name = "Content Placeholder 1".into();
3406        let layout_oxml = crate::oxml::slidelayout::SldLayout {
3407            name: "Title and Content".into(),
3408            type_: "obj".into(),
3409            shapes: vec![layout_sp],
3410        };
3411        let layout_ref = crate::slide_layouts::SlideLayoutRef {
3412            idx: 0,
3413            partname: "/ppt/slideLayouts/slideLayout1.xml".into(),
3414            rid: "rIdLayout1".into(),
3415            oxml: Rc::new(RefCell::new(layout_oxml)),
3416            master_partname: String::new(),
3417        };
3418
3419        // 在 slide 上创建占位符
3420        let mut s = Slide::blank(Rc::new(Cell::new(10)));
3421        let auto = s
3422            .shapes_mut()
3423            .add_placeholder_from_layout(1, &layout_ref)
3424            .expect("创建成功");
3425        // 验证:新占位符继承了 layout 的 ph_idx / ph_type
3426        assert!(auto.sp().is_placeholder);
3427        assert_eq!(auto.sp().ph_idx, Some(1));
3428        assert_eq!(auto.sp().ph_type.as_deref(), Some("body"));
3429        // 验证:分配了新 id(>10)
3430        assert!(auto.sp().id > 10);
3431        // 验证:文本为空
3432        assert!(auto.sp().text.paragraphs.is_empty() || auto.sp().text.paragraphs.len() == 1);
3433    }
3434
3435    /// TODO-007:`add_placeholder_from_layout` 未找到匹配占位符时返回错误。
3436    #[test]
3437    fn add_placeholder_from_layout_not_found() {
3438        use std::cell::RefCell;
3439        use std::rc::Rc;
3440
3441        let layout_oxml = crate::oxml::slidelayout::SldLayout::default();
3442        let layout_ref = crate::slide_layouts::SlideLayoutRef {
3443            idx: 0,
3444            partname: "/ppt/slideLayouts/slideLayout1.xml".into(),
3445            rid: "rIdLayout1".into(),
3446            oxml: Rc::new(RefCell::new(layout_oxml)),
3447            master_partname: String::new(),
3448        };
3449
3450        let mut s = Slide::blank(Rc::new(Cell::new(0)));
3451        let result = s.shapes_mut().add_placeholder_from_layout(99, &layout_ref);
3452        assert!(result.is_err());
3453    }
3454
3455    /// `has_notes_slide` / `follow_master_background` / `name` 同款对齐。
3456    #[test]
3457    fn slide_metadata_apis() {
3458        let mut s = Slide::blank(Rc::new(Cell::new(0)));
3459        // 初始:无 notes
3460        assert!(!s.has_notes_slide());
3461        // 默认遵循 master 背景
3462        assert!(s.follow_master_background());
3463        // 切换为独立背景
3464        s.set_follow_master_background(false);
3465        assert!(!s.follow_master_background(), "切换后应不遵循 master");
3466        // 切回继承
3467        s.set_follow_master_background(true);
3468        assert!(s.follow_master_background(), "切回后应遵循 master");
3469        // name
3470        assert_eq!(s.name(), "");
3471        s.set_name(Some("intro"));
3472        assert_eq!(s.name(), "intro");
3473        s.set_name(None);
3474        assert_eq!(s.name(), "");
3475        // notes 触发 has_notes_slide
3476        s.set_notes_text(Some("hello"));
3477        assert!(s.has_notes_slide());
3478    }
3479
3480    /// 验证纯色背景写入 oxml 模型并正确序列化为 `<p:bg>`。
3481    #[test]
3482    fn slide_background_solid_writes_xml() {
3483        use crate::oxml::color::Color;
3484        use crate::oxml::simpletypes::MsoFillType;
3485        use crate::units::RGBColor;
3486
3487        let mut s = Slide::blank(Rc::new(Cell::new(0)));
3488        // 初始:继承 master
3489        assert_eq!(s.background().fill_type(), MsoFillType::Inherit);
3490        assert!(s.follow_master_background());
3491
3492        // 设置红色纯色背景
3493        s.set_background_solid(Color::RGB(RGBColor::RED));
3494        assert_eq!(s.background().fill_type(), MsoFillType::Solid);
3495        assert!(!s.follow_master_background());
3496
3497        // 序列化检查:XML 中应包含 <p:bg><p:bgPr><a:solidFill><a:srgbClr val="FF0000"/>
3498        let xml = s.to_xml();
3499        assert!(xml.contains("<p:bg>"), "应写出 <p:bg> 元素");
3500        assert!(xml.contains("<p:bgPr>"), "应写出 <p:bgPr> 元素");
3501        assert!(xml.contains("FF0000"), "应写出红色 srgbClr");
3502        // bg 必须在 spTree 之前
3503        let bg_pos = xml.find("<p:bg>").expect("bg exists");
3504        let sptree_pos = xml.find("<p:spTree>").expect("spTree exists");
3505        assert!(bg_pos < sptree_pos, "<p:bg> 必须在 <p:spTree> 之前");
3506
3507        // 清空背景
3508        s.clear_background();
3509        assert_eq!(s.background().fill_type(), MsoFillType::Inherit);
3510        assert!(s.follow_master_background());
3511        let xml2 = s.to_xml();
3512        assert!(!xml2.contains("<p:bg>"), "清空后不应再有 <p:bg>");
3513    }
3514
3515    /// 验证 `set_background_solid(None)` 等价于 `clear_background`。
3516    #[test]
3517    fn slide_background_solid_none_clears() {
3518        use crate::oxml::color::Color;
3519        use crate::units::RGBColor;
3520
3521        let mut s = Slide::blank(Rc::new(Cell::new(0)));
3522        s.set_background_solid(Color::RGB(RGBColor::BLUE));
3523        assert!(!s.follow_master_background());
3524        // Color::None 等价于清空
3525        s.set_background_solid(Color::None);
3526        assert!(s.follow_master_background());
3527    }
3528
3529    /// 验证 `set_follow_master_background(false)` 写入占位背景。
3530    #[test]
3531    fn slide_background_follow_master_toggle() {
3532        use crate::oxml::simpletypes::MsoFillType;
3533
3534        let mut s = Slide::blank(Rc::new(Cell::new(0)));
3535        // 初始继承
3536        assert!(s.follow_master_background());
3537        // 切换为独立:写入白色占位
3538        s.set_follow_master_background(false);
3539        assert!(!s.follow_master_background());
3540        assert_eq!(s.background().fill_type(), MsoFillType::Solid);
3541        // 再次 set(false) 应保持不变(不覆盖已有独立背景)
3542        s.set_follow_master_background(false);
3543        assert!(!s.follow_master_background());
3544        // 切回继承
3545        s.set_follow_master_background(true);
3546        assert!(s.follow_master_background());
3547    }
3548
3549    /// `add_connector` 必须正确使用 4 端点(包括 y 坐标)。
3550    ///
3551    /// 早期版本会把 y 强制为 0,导出的 PPT 中连接器无法显示正确的倾斜。
3552    #[test]
3553    fn add_connector_uses_y_coords() {
3554        let mut s = Slide::blank(Rc::new(Cell::new(0)));
3555        let c = s
3556            .shapes_mut()
3557            .add_connector(
3558                crate::oxml::simpletypes::MsoConnectorType::Straight,
3559                Inches(1.0),
3560                Inches(2.0),
3561                Inches(3.0),
3562                Inches(4.0),
3563            )
3564            .expect("add connector");
3565        // begin/end 已正确设置(不依赖 xfrm)
3566        let b = c.begin().expect("begin");
3567        let e = c.end().expect("end");
3568        assert_eq!(b.0, Inches(1.0).emu().value());
3569        assert_eq!(b.1, Inches(2.0).emu().value());
3570        assert_eq!(e.0, Inches(3.0).emu().value());
3571        assert_eq!(e.1, Inches(4.0).emu().value());
3572    }
3573
3574    /// `add_shape` 后形状的 `id` / `name` 应正常设置。
3575    #[test]
3576    fn add_shape_preserves_id_and_name() {
3577        let mut s = Slide::blank(Rc::new(Cell::new(0)));
3578        let sh = s
3579            .shapes_mut()
3580            .add_shape(
3581                PresetGeometry::Rectangle,
3582                Inches(0.5),
3583                Inches(0.5),
3584                Inches(3.0),
3585                Inches(2.0),
3586            )
3587            .expect("add shape");
3588        assert!(sh.id() > 0, "shape id 应已分配");
3589        assert!(
3590            sh.name().contains("Shape") || sh.name().contains("Rectangle"),
3591            "shape name 应反映类型,实际:{}",
3592            sh.name()
3593        );
3594    }
3595
3596    /// `move_slide` 重排序:将第 0 张移到末尾,验证顺序变化与 reindex。
3597    #[test]
3598    fn move_slide_reorders_correctly() {
3599        let counter = Rc::new(Cell::new(2));
3600        let mut slides = Slides::new();
3601        // 添加 3 张 slide,分别设置不同的 name 以便区分
3602        for i in 0..3 {
3603            let s = slides.add_slide(counter.clone()).unwrap();
3604            s.set_name(Some(&format!("slide{}", i)));
3605        }
3606        assert_eq!(slides.len(), 3);
3607        // 移动前:[slide0, slide1, slide2]
3608        assert_eq!(slides.get(0).unwrap().sld.name(), "slide0");
3609        // 将第 0 张移到末尾(to_idx=3 表示移到 len 位置)
3610        slides.move_slide(0, 3).unwrap();
3611        // 移动后:[slide1, slide2, slide0]
3612        assert_eq!(slides.get(0).unwrap().sld.name(), "slide1");
3613        assert_eq!(slides.get(1).unwrap().sld.name(), "slide2");
3614        assert_eq!(slides.get(2).unwrap().sld.name(), "slide0");
3615        // reindex 后 sld_id 应单调递增
3616        assert_eq!(slides.get(0).unwrap().sld_id, 257);
3617        assert_eq!(slides.get(1).unwrap().sld_id, 258);
3618        assert_eq!(slides.get(2).unwrap().sld_id, 259);
3619    }
3620
3621    /// `move_slide` 越界返回错误。
3622    #[test]
3623    fn move_slide_out_of_bounds_returns_error() {
3624        let counter = Rc::new(Cell::new(2));
3625        let mut slides = Slides::new();
3626        slides.add_slide(counter.clone()).unwrap();
3627        // from_idx 越界
3628        assert!(slides.move_slide(5, 0).is_err());
3629        // to_idx 越界
3630        assert!(slides.move_slide(0, 5).is_err());
3631    }
3632
3633    /// `move_slide` 同位置为 no-op。
3634    #[test]
3635    fn move_slide_same_index_is_noop() {
3636        let counter = Rc::new(Cell::new(2));
3637        let mut slides = Slides::new();
3638        slides.add_slide(counter.clone()).unwrap();
3639        slides.add_slide(counter.clone()).unwrap();
3640        let before = slides.get(0).unwrap().sld_id;
3641        slides.move_slide(0, 0).unwrap();
3642        assert_eq!(slides.get(0).unwrap().sld_id, before);
3643    }
3644
3645    /// `remove` 删除后 reindex 保证 sld_id 连续。
3646    #[test]
3647    fn remove_reindexes_remaining_slides() {
3648        let counter = Rc::new(Cell::new(2));
3649        let mut slides = Slides::new();
3650        for i in 0..3 {
3651            let s = slides.add_slide(counter.clone()).unwrap();
3652            s.set_name(Some(&format!("slide{}", i)));
3653        }
3654        // 删除中间一张
3655        let removed = slides.remove(1).unwrap();
3656        assert_eq!(removed.name(), "slide1");
3657        // 剩余 2 张,sld_id 应重新分配为 257/258
3658        assert_eq!(slides.len(), 2);
3659        assert_eq!(slides.get(0).unwrap().sld.name(), "slide0");
3660        assert_eq!(slides.get(1).unwrap().sld.name(), "slide2");
3661        assert_eq!(slides.get(0).unwrap().sld_id, 257);
3662        assert_eq!(slides.get(1).unwrap().sld_id, 258);
3663    }
3664
3665    /// `reorder` 批量重排幻灯片顺序。
3666    ///
3667    /// 这是 TODO-021 的测试。
3668    #[test]
3669    fn reorder_batch_reorders_slides() {
3670        let counter = Rc::new(Cell::new(2));
3671        let mut slides = Slides::new();
3672        for i in 0..4 {
3673            let s = slides.add_slide(counter.clone()).unwrap();
3674            s.set_name(Some(&format!("slide{}", i)));
3675        }
3676        // 反转顺序:[0,1,2,3] -> [3,2,1,0]
3677        slides.reorder(&[3, 2, 1, 0]).unwrap();
3678        assert_eq!(slides.get(0).unwrap().sld.name(), "slide3");
3679        assert_eq!(slides.get(1).unwrap().sld.name(), "slide2");
3680        assert_eq!(slides.get(2).unwrap().sld.name(), "slide1");
3681        assert_eq!(slides.get(3).unwrap().sld.name(), "slide0");
3682        // sld_id 应重新分配为 257/258/259/260
3683        assert_eq!(slides.get(0).unwrap().sld_id, 257);
3684        assert_eq!(slides.get(3).unwrap().sld_id, 260);
3685    }
3686
3687    /// `reorder` 索引数不匹配时返回错误。
3688    ///
3689    /// 这是 TODO-021 的测试。
3690    #[test]
3691    fn reorder_length_mismatch_returns_error() {
3692        let counter = Rc::new(Cell::new(2));
3693        let mut slides = Slides::new();
3694        for _ in 0..3 {
3695            slides.add_slide(counter.clone()).unwrap();
3696        }
3697        // 索引数不足
3698        assert!(slides.reorder(&[0, 1]).is_err());
3699        // 索引数过多
3700        assert!(slides.reorder(&[0, 1, 2, 3]).is_err());
3701    }
3702
3703    /// `reorder` 索引重复或越界时返回错误。
3704    ///
3705    /// 这是 TODO-021 的测试。
3706    #[test]
3707    fn reorder_duplicate_or_out_of_bounds_returns_error() {
3708        let counter = Rc::new(Cell::new(2));
3709        let mut slides = Slides::new();
3710        for _ in 0..3 {
3711            slides.add_slide(counter.clone()).unwrap();
3712        }
3713        // 重复索引
3714        assert!(slides.reorder(&[0, 0, 1]).is_err());
3715        // 越界索引
3716        assert!(slides.reorder(&[0, 1, 5]).is_err());
3717    }
3718
3719    /// `move_up` 把 idx 处形状与后一个交换(z-order 提升)。
3720    ///
3721    /// 这是 TODO-025 的测试。
3722    #[test]
3723    fn shapes_mut_move_up_swaps_with_next() {
3724        let mut s = Slide::blank(Rc::new(Cell::new(0)));
3725        // 依次添加 3 个文本框,文本分别为 "A" / "B" / "C"
3726        s.shapes_mut()
3727            .add_textbox_with_text(Inches(1.0), Inches(1.0), Inches(2.0), Inches(1.0), "A")
3728            .unwrap();
3729        s.shapes_mut()
3730            .add_textbox_with_text(Inches(1.0), Inches(2.0), Inches(2.0), Inches(1.0), "B")
3731            .unwrap();
3732        s.shapes_mut()
3733            .add_textbox_with_text(Inches(1.0), Inches(3.0), Inches(2.0), Inches(1.0), "C")
3734            .unwrap();
3735        // 初始顺序:A B C
3736        assert_eq!(shape_text_at(&s, 0), "A");
3737        assert_eq!(shape_text_at(&s, 1), "B");
3738        assert_eq!(shape_text_at(&s, 2), "C");
3739        // 把 idx=0 上移一级 → B A C
3740        s.shapes_mut().move_up(0);
3741        assert_eq!(shape_text_at(&s, 0), "B");
3742        assert_eq!(shape_text_at(&s, 1), "A");
3743        assert_eq!(shape_text_at(&s, 2), "C");
3744    }
3745
3746    /// `move_up` 对最后一个形状为 no-op。
3747    ///
3748    /// 这是 TODO-025 的测试。
3749    #[test]
3750    fn shapes_mut_move_up_on_last_is_noop() {
3751        let mut s = Slide::blank(Rc::new(Cell::new(0)));
3752        s.shapes_mut()
3753            .add_textbox_with_text(Inches(1.0), Inches(1.0), Inches(2.0), Inches(1.0), "A")
3754            .unwrap();
3755        s.shapes_mut()
3756            .add_textbox_with_text(Inches(1.0), Inches(2.0), Inches(2.0), Inches(1.0), "B")
3757            .unwrap();
3758        // 对最后一个(idx=1)上移:no-op
3759        s.shapes_mut().move_up(1);
3760        assert_eq!(shape_text_at(&s, 0), "A");
3761        assert_eq!(shape_text_at(&s, 1), "B");
3762        // 越界 idx 也应为 no-op
3763        s.shapes_mut().move_up(999);
3764        assert_eq!(shape_text_at(&s, 0), "A");
3765        assert_eq!(shape_text_at(&s, 1), "B");
3766    }
3767
3768    /// `move_down` 把 idx 处形状与前一个交换(z-order 降低)。
3769    ///
3770    /// 这是 TODO-025 的测试。
3771    #[test]
3772    fn shapes_mut_move_down_swaps_with_prev() {
3773        let mut s = Slide::blank(Rc::new(Cell::new(0)));
3774        s.shapes_mut()
3775            .add_textbox_with_text(Inches(1.0), Inches(1.0), Inches(2.0), Inches(1.0), "A")
3776            .unwrap();
3777        s.shapes_mut()
3778            .add_textbox_with_text(Inches(1.0), Inches(2.0), Inches(2.0), Inches(1.0), "B")
3779            .unwrap();
3780        s.shapes_mut()
3781            .add_textbox_with_text(Inches(1.0), Inches(3.0), Inches(2.0), Inches(1.0), "C")
3782            .unwrap();
3783        // 初始顺序:A B C
3784        // 把 idx=2 下移一级 → A C B
3785        s.shapes_mut().move_down(2);
3786        assert_eq!(shape_text_at(&s, 0), "A");
3787        assert_eq!(shape_text_at(&s, 1), "C");
3788        assert_eq!(shape_text_at(&s, 2), "B");
3789    }
3790
3791    /// `move_down` 对第一个形状为 no-op。
3792    ///
3793    /// 这是 TODO-025 的测试。
3794    #[test]
3795    fn shapes_mut_move_down_on_first_is_noop() {
3796        let mut s = Slide::blank(Rc::new(Cell::new(0)));
3797        s.shapes_mut()
3798            .add_textbox_with_text(Inches(1.0), Inches(1.0), Inches(2.0), Inches(1.0), "A")
3799            .unwrap();
3800        s.shapes_mut()
3801            .add_textbox_with_text(Inches(1.0), Inches(2.0), Inches(2.0), Inches(1.0), "B")
3802            .unwrap();
3803        // 对第一个(idx=0)下移:no-op
3804        s.shapes_mut().move_down(0);
3805        assert_eq!(shape_text_at(&s, 0), "A");
3806        assert_eq!(shape_text_at(&s, 1), "B");
3807        // 越界 idx 也应为 no-op
3808        s.shapes_mut().move_down(999);
3809        assert_eq!(shape_text_at(&s, 0), "A");
3810        assert_eq!(shape_text_at(&s, 1), "B");
3811    }
3812
3813    /// 辅助:取 slide 上第 idx 个形状的纯文本(仅支持 Sp 文本框)。
3814    fn shape_text_at(s: &Slide, idx: usize) -> String {
3815        match &s.inner.shapes[idx] {
3816            OxmlSlideShape::Sp(sp) => {
3817                let mut out = String::new();
3818                for p in &sp.text.paragraphs {
3819                    for r in &p.runs {
3820                        out.push_str(&r.text);
3821                    }
3822                }
3823                out
3824            }
3825            _ => String::new(),
3826        }
3827    }
3828}