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