Skip to main content

pptx_rs/
slide_layouts.rs

1//! # 幻灯片版式(Slide Layout)—— 高阶 API
2//!
3//! 对应 OOXML 规范中的 `<p:sldLayout>` 元素。
4//!
5//! # 概念
6//!
7//! "版式"是 PowerPoint 的核心抽象之一,位于"主-版-页"三层中的中间层:
8//!
9//! ```text
10//!   SlideMaster  (母版:全局默认样式)
11//!        ↑ 引用
12//!   SlideLayout  (版式:每页的"页面模板",可指定占位符位置)
13//!        ↑ 引用
14//!   Slide        (页:实际内容)
15//! ```
16//!
17//! # 当前实现范围
18//!
19//! 本模块是**极简实现**:
20//!
21//! - 仅暴露 name / partname / rid / placeholders / shapes 等元数据;
22//! - 完整读取/编辑版式内的 placeholder 是路线图任务。
23//!
24//! 完整 API 设计可参考 python-pptx 的 `SlideLayouts` / `SlideLayout` 类。
25
26use std::cell::RefCell;
27use std::rc::Rc;
28
29use crate::oxml::shape::Sp as OxmlSp;
30use crate::oxml::slidelayout::SldLayout as OxmlSldLayout;
31
32/// 单个版式的引用。
33///
34/// 设计上使用 `Rc<RefCell<OxmlSldLayout>>` 而**非**直接拥有,以兼容"版式被母版
35/// 与页共享引用"的未来场景。当前实现下每个版式只被一个 [`SlideLayouts`] 拥有。
36#[derive(Debug, Clone)]
37pub struct SlideLayoutRef {
38    /// 在所属 `SlideLayouts` 中的索引(用于 `get(idx)` / `at(idx)`)。
39    #[allow(dead_code)]
40    pub(crate) idx: usize,
41    /// OPC part 路径(`/ppt/slideLayouts/slideLayoutN.xml`)。
42    pub(crate) partname: String,
43    /// 关系 id(在所属 `SlideMaster` 的 `.rels` 中使用)。
44    pub(crate) rid: String,
45    /// 内部 oxml 模型(共享,以便母版与页可同时访问)。
46    pub(crate) oxml: Rc<RefCell<OxmlSldLayout>>,
47}
48
49impl SlideLayoutRef {
50    /// 取出 part 路径(如 `/ppt/slideLayouts/slideLayout1.xml`)。
51    pub fn partname(&self) -> &str {
52        &self.partname
53    }
54    /// 取出关系 id(如 `rIdLayout1`)。
55    pub fn rid(&self) -> &str {
56        &self.rid
57    }
58    /// 取版式名(对应 OOXML 中的 `<p:sldLayout name="..."/>`)。
59    pub fn name(&self) -> String {
60        self.oxml.borrow().name.clone()
61    }
62    /// 设置版式名。
63    pub fn set_name(&mut self, n: String) {
64        self.oxml.borrow_mut().name = n;
65    }
66    /// 取版式类型(OOXML 中的 `type` 属性,可选;如 `blank` / `title` / `section`)。
67    pub fn layout_type(&self) -> String {
68        self.oxml.borrow().type_.clone()
69    }
70    /// 设置版式类型。
71    pub fn set_layout_type(&mut self, t: String) {
72        self.oxml.borrow_mut().type_ = t;
73    }
74
75    /// 不可变 shape 列表(python-pptx `slide_layout.shapes` 风格)。
76    ///
77    /// 返回的是 `OxmlSp` 句柄的借用,不触发 clone;适合"只读浏览"。
78    pub fn shapes(&self) -> Vec<OxmlSp> {
79        self.oxml.borrow().shapes.clone()
80    }
81
82    /// 可变 shape 列表。
83    pub fn shapes_mut(&self) -> std::cell::RefMut<'_, Vec<OxmlSp>> {
84        std::cell::RefMut::map(self.oxml.borrow_mut(), |s| &mut s.shapes)
85    }
86
87    /// 占位符列表(python-pptx `slide_layout.placeholders` 风格)。
88    ///
89    /// 当前实现把所有带 `is_placeholder=true` 的 [`OxmlSp`] 视作占位符。
90    /// 返回不可变快照,避免借用期跨越。
91    pub fn placeholders(&self) -> Vec<Placeholder> {
92        self.oxml
93            .borrow()
94            .shapes
95            .iter()
96            .filter(|s| s.is_placeholder)
97            .map(|s| Placeholder {
98                idx: s.ph_idx.unwrap_or(0),
99                ph_type: s.ph_type.clone().unwrap_or_else(|| "body".to_string()),
100                name: s.name.clone(),
101            })
102            .collect()
103    }
104
105    /// 返回所有占位符在 `shapes` 中的索引列表(TODO-008)。
106    ///
107    /// 用于配合 `shapes_mut` 按索引修改占位符:
108    ///
109    /// ```no_run
110    /// # use pptx_rs::Presentation;
111    /// # let mut p = Presentation::new().unwrap();
112    /// # let layout = p.slide_layouts().get(0).unwrap().clone();
113    /// let indices = layout.placeholder_indices();
114    /// let mut shapes = layout.shapes_mut();
115    /// for i in indices {
116    ///     // 修改第 i 个 shape(它是占位符)
117    ///     shapes[i].name = format!("PH {}", i);
118    /// }
119    /// ```
120    pub fn placeholder_indices(&self) -> Vec<usize> {
121        self.oxml
122            .borrow()
123            .shapes
124            .iter()
125            .enumerate()
126            .filter(|(_, s)| s.is_placeholder)
127            .map(|(i, _)| i)
128            .collect()
129    }
130
131    /// 按 `ph_idx` 查找占位符在 `shapes` 中的索引(TODO-008)。
132    ///
133    /// 返回第一个 `ph_idx` 匹配的 shapes 索引;未找到返回 `None`。
134    /// 用于配合 `shapes_mut` 修改特定占位符。
135    pub fn placeholder_index_by_ph_idx(&self, ph_idx: u32) -> Option<usize> {
136        self.oxml
137            .borrow()
138            .shapes
139            .iter()
140            .enumerate()
141            .find(|(_, s)| s.is_placeholder && s.ph_idx == Some(ph_idx))
142            .map(|(i, _)| i)
143    }
144}
145
146/// 占位符(python-pptx `slide_layout.placeholders[i]` 风格)。
147///
148/// 仅承载**只读元数据**;如需 mutate 形状本身,请用 [`SlideLayoutRef::shapes_mut`]
149/// 再按 `ph_idx` 过滤。
150#[derive(Debug, Clone)]
151pub struct Placeholder {
152    /// 占位符 idx(对应 `<p:ph idx="N"/>`)。
153    pub idx: u32,
154    /// 占位符类型(对应 `<p:ph type="..."/>`,如 `title` / `body`)。
155    pub ph_type: String,
156    /// 形状名(cNvPr name)。
157    pub name: String,
158}
159
160impl Placeholder {
161    /// python-pptx 风格 `placeholder.placeholder_format.type` 的简化版。
162    pub fn ph_type(&self) -> &str {
163        &self.ph_type
164    }
165    /// python-pptx 风格 `placeholder.placeholder_format.idx`。
166    pub fn idx(&self) -> u32 {
167        self.idx
168    }
169    /// 形状名。
170    pub fn name(&self) -> &str {
171        &self.name
172    }
173}
174
175/// 全部版式的集合。
176///
177/// 在 [`crate::presentation::Presentation`] 中由 `slide_layouts` 字段持有。
178/// 至少包含 1 个默认版式(由 [`crate::presentation::Presentation::new`] 自动创建)。
179#[derive(Debug, Default, Clone)]
180pub struct SlideLayouts {
181    pub(crate) items: Vec<SlideLayoutRef>,
182}
183
184impl SlideLayouts {
185    /// 新建一个空集合。
186    pub fn new() -> Self {
187        SlideLayouts::default()
188    }
189    /// 数量。
190    pub fn len(&self) -> usize {
191        self.items.len()
192    }
193    /// 是否为空。
194    pub fn is_empty(&self) -> bool {
195        self.items.is_empty()
196    }
197    /// 遍历所有版式引用。
198    pub fn iter(&self) -> std::slice::Iter<'_, SlideLayoutRef> {
199        self.items.iter()
200    }
201    /// 按索引取不可变引用。
202    pub fn get(&self, idx: usize) -> Option<&SlideLayoutRef> {
203        self.items.get(idx)
204    }
205    /// 按索引取可变引用。
206    pub fn get_mut(&mut self, idx: usize) -> Option<&mut SlideLayoutRef> {
207        self.items.get_mut(idx)
208    }
209
210    /// 取一个版式(克隆为不可变轻量句柄 [`SlideLayout`])。
211    ///
212    /// `idx` 越界返回 `None`。该 API 是 python-pptx `slide_layouts[i]` 的等价物。
213    pub fn at(&self, idx: usize) -> Option<SlideLayout> {
214        self.items.get(idx).map(|r| SlideLayout {
215            name: r.oxml.borrow().name.clone(),
216            r#type: r.oxml.borrow().type_.clone(),
217        })
218    }
219
220    /// 追加一个版式(**仅**内存模型;`presentation::to_opc_package` 会一并写出)。
221    ///
222    /// `partname` 必须以 `/ppt/slideLayouts/slideLayout<N>.xml` 形式。
223    pub fn push(&mut self, layout: SlideLayoutRef) {
224        self.items.push(layout);
225    }
226
227    /// 按索引移除一个版式(TODO-008)。
228    ///
229    /// 对标 python-pptx `SlideLayouts.remove(layout)`。
230    ///
231    /// # 注意
232    /// 移除版式**不会**自动更新引用该版式的 slide。调用方应确保没有 slide
233    /// 仍在引用被移除的版式(可通过 [`crate::presentation::Presentation::slides_using_layout`]
234    /// 检查)。
235    ///
236    /// # 返回
237    /// - `Some(SlideLayoutRef)`:被移除的版式;
238    /// - `None`:索引越界。
239    pub fn remove(&mut self, idx: usize) -> Option<SlideLayoutRef> {
240        if idx < self.items.len() {
241            Some(self.items.remove(idx))
242        } else {
243            None
244        }
245    }
246
247    /// 按关系 id 查找版式索引(TODO-008)。
248    ///
249    /// 对标 python-pptx `SlideLayouts.index(layout)`。
250    /// 返回第一个 `rid` 匹配的索引;未找到返回 `None`。
251    pub fn index_of(&self, rid: &str) -> Option<usize> {
252        self.items.iter().position(|l| l.rid == rid)
253    }
254
255    /// 按名称查找版式(TODO-008)。
256    ///
257    /// 对标 python-pptx `SlideLayouts.get_by_name(name)`。
258    /// 返回第一个 `name` 匹配的版式引用;未找到返回 `None`。
259    pub fn get_by_name(&self, name: &str) -> Option<&SlideLayoutRef> {
260        self.items.iter().find(|l| l.oxml.borrow().name == name)
261    }
262
263    /// 按名称查找版式(可变引用,TODO-008)。
264    pub fn get_by_name_mut(&mut self, name: &str) -> Option<&mut SlideLayoutRef> {
265        self.items.iter_mut().find(|l| l.oxml.borrow().name == name)
266    }
267}
268
269/// 版式(不可变视图)。
270///
271/// 当前为"快照"风格 —— 修改 [`SlideLayoutRef`] 不会反映到此结构。
272/// 主要用于"展示给用户当前有哪些版式"的场景。
273#[derive(Debug, Clone)]
274pub struct SlideLayout {
275    /// 版式显示名(如 `Blank` / `Title Slide`)。
276    pub name: String,
277    /// 版式类型(OOXML 中的 `type` 属性,可选)。
278    /// 使用 `r#type` 保留字面量 `type`。
279    pub r#type: String,
280}
281
282#[cfg(test)]
283#[allow(clippy::field_reassign_with_default)]
284mod tests {
285    use super::*;
286    use std::cell::RefCell;
287    use std::rc::Rc;
288
289    /// 构造一个测试用的 SlideLayoutRef。
290    fn make_layout(name: &str, rid: &str) -> SlideLayoutRef {
291        let oxml = OxmlSldLayout {
292            name: name.to_string(),
293            type_: "blank".to_string(),
294            shapes: Vec::new(),
295        };
296        SlideLayoutRef {
297            idx: 0,
298            partname: format!("/ppt/slideLayouts/{}.xml", name),
299            rid: rid.to_string(),
300            oxml: Rc::new(RefCell::new(oxml)),
301        }
302    }
303
304    /// TODO-008:`SlideLayouts::remove` 按索引移除。
305    #[test]
306    fn layouts_remove_by_index() {
307        let mut layouts = SlideLayouts::new();
308        layouts.push(make_layout("Blank", "rId1"));
309        layouts.push(make_layout("Title", "rId2"));
310        layouts.push(make_layout("Content", "rId3"));
311        assert_eq!(layouts.len(), 3);
312
313        let removed = layouts.remove(1);
314        assert!(removed.is_some());
315        assert_eq!(removed.unwrap().rid(), "rId2");
316        assert_eq!(layouts.len(), 2);
317        assert_eq!(layouts.get(1).unwrap().rid(), "rId3");
318
319        // 越界返回 None
320        assert!(layouts.remove(99).is_none());
321    }
322
323    /// TODO-008:`SlideLayouts::index_of` 按 rid 查找索引。
324    #[test]
325    fn layouts_index_of_by_rid() {
326        let mut layouts = SlideLayouts::new();
327        layouts.push(make_layout("Blank", "rId1"));
328        layouts.push(make_layout("Title", "rId2"));
329
330        assert_eq!(layouts.index_of("rId1"), Some(0));
331        assert_eq!(layouts.index_of("rId2"), Some(1));
332        assert_eq!(layouts.index_of("rId999"), None);
333    }
334
335    /// TODO-008:`SlideLayouts::get_by_name` 按名查找。
336    #[test]
337    fn layouts_get_by_name() {
338        let mut layouts = SlideLayouts::new();
339        layouts.push(make_layout("Blank", "rId1"));
340        layouts.push(make_layout("Title Slide", "rId2"));
341
342        let found = layouts.get_by_name("Title Slide");
343        assert!(found.is_some());
344        assert_eq!(found.unwrap().rid(), "rId2");
345
346        assert!(layouts.get_by_name("Nonexistent").is_none());
347    }
348
349    /// TODO-008:`SlideLayoutRef::placeholder_indices` 返回占位符索引。
350    #[test]
351    fn layout_placeholder_indices() {
352        let mut sp1 = OxmlSp::default();
353        sp1.is_placeholder = true;
354        sp1.ph_idx = Some(0);
355        sp1.ph_type = Some("title".into());
356
357        let mut sp2 = OxmlSp::default();
358        sp2.is_placeholder = false; // 非占位符
359
360        let mut sp3 = OxmlSp::default();
361        sp3.is_placeholder = true;
362        sp3.ph_idx = Some(1);
363        sp3.ph_type = Some("body".into());
364
365        let oxml = OxmlSldLayout {
366            name: "Test".into(),
367            type_: "obj".into(),
368            shapes: vec![sp1, sp2, sp3],
369        };
370        let layout = SlideLayoutRef {
371            idx: 0,
372            partname: "/ppt/slideLayouts/slideLayout1.xml".into(),
373            rid: "rId1".into(),
374            oxml: Rc::new(RefCell::new(oxml)),
375        };
376
377        let indices = layout.placeholder_indices();
378        assert_eq!(indices, vec![0, 2]);
379
380        // 按 ph_idx 查找
381        assert_eq!(layout.placeholder_index_by_ph_idx(0), Some(0));
382        assert_eq!(layout.placeholder_index_by_ph_idx(1), Some(2));
383        assert_eq!(layout.placeholder_index_by_ph_idx(99), None);
384    }
385}