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