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