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