Skip to main content

pptx_rs/oxml/
presentation.rs

1//! 演示文稿顶级 XML:`<p:presentation>` / `<p:sldIdLst>` / `<p:sldSz>`。
2//!
3//! 本文件定义"根 part"——`/ppt/presentation.xml` 对应的 Rust 模型。
4//!
5//! # 元素结构(OOXML 规范严格顺序)
6//!
7//! ```text
8//! <p:presentation>
9//!   <p:sldMasterIdLst>...</p:sldMasterIdLst>   必须存在(可为空)
10//!   <p:notesSz .../>                             备注尺寸
11//!   <p:sldIdLst>                                 所有 slide 的 ID 列表
12//!     <p:sldId id="256" r:id="rId5"/>
13//!     ...
14//!   </p:sldIdLst>
15//!   <p:sldSz cx="..." cy="..."/>                 幻灯片尺寸(EMU)
16//!   ...其它可选元素...
17//!   <p:defaultTextStyle>...</p:defaultTextStyle> 必须存在
18//! </p:presentation>
19//! ```
20//!
21//! # 与 python-pptx 的对应
22//!
23//! - `pptx.oxml.presentation.Presentation` ←→ [`PresentationRoot`];
24//! - `pptx.oxml.presentation.SldId` ←→ [`SlideIdEntry`]。
25//!
26//! # 序列化要点
27//!
28//! - **`sldMasterIdLst` 必须存在**——PowerPoint 要求至少一个 `sldMasterId`,即使实际不挂母版。
29//! - **`defaultTextStyle` 必须存在**——python-pptx 给出 9 段 `lvlXpPr` 的极简版,
30//!   本库直接复用(`DEFAULT_TEXT_STYLE`)。
31
32use std::str::FromStr;
33
34use crate::oxml::ns::{NS_DRAWING_MAIN, NS_PRESENTATION_MAIN};
35use crate::oxml::writer::XmlWriter;
36use crate::units::Emu;
37
38/// 演示文稿的根 `<p:presentation>` 元素。
39#[derive(Clone, Debug)]
40pub struct PresentationRoot {
41    /// 幻灯片宽度(EMU)。`None` 表示不写出 `<p:sldSz>`。
42    pub slide_width: Option<Emu>,
43    /// 幻灯片高度(EMU)。`None` 表示不写出 `<p:sldSz>`。
44    pub slide_height: Option<Emu>,
45    /// 所有 slide 的 ID 列表(`<p:sldIdLst>`)。
46    pub slide_ids: Vec<SlideIdEntry>,
47    /// 母版 ID 列表(`<p:sldMasterIdLst>`)。
48    ///
49    /// 已实现:from_opc 解析时回填,to_xml 时遍历写出。
50    /// 空列表时 `to_xml` 使用默认的单个母版(id=2147483648, rIdP1)。
51    pub sld_master_ids: Vec<SldMasterIdEntry>,
52    /// 备注母版 ID 列表(`<p:notesMasterIdLst>`,round-trip 保真)。
53    ///
54    /// 已实现:from_opc 解析时回填,to_xml 时遍历写出。
55    /// 空列表时 `to_xml` 不输出 `<p:notesMasterIdLst>`(PowerPoint 兼容:无 notesMaster 即不写)。
56    pub notes_master_ids: Vec<NotesMasterIdEntry>,
57    /// 默认文本样式(`<p:defaultTextStyle>`),XML 字符串。`None` 使用内置默认。
58    pub default_text_style: Option<String>,
59    /// 备注页尺寸(宽, 高,EMU)。`None` 使用默认 6858000 × 9144000。
60    pub notes_size: Option<(Emu, Emu)>,
61    /// 章节分组(`<p14:sectionLst>` 扩展元素,TODO-039)。
62    ///
63    /// 空列表时不输出任何 section 相关 XML;
64    /// 非空时在 `<p:defaultTextStyle>` 之后追加 `<p:extLst><p:ext ...><p14:sectionLst>...`。
65    pub sections: crate::oxml::section::SectionList,
66}
67
68impl Default for PresentationRoot {
69    fn default() -> Self {
70        // PowerPoint 严格要求包含 defaultTextStyle,否则打开时弹错。
71        // 这里使用 python-pptx 默认的 9 段 lvlXpPr 极简版。
72        Self {
73            slide_width: None,
74            slide_height: None,
75            slide_ids: Vec::new(),
76            sld_master_ids: Vec::new(),
77            notes_master_ids: Vec::new(),
78            default_text_style: Some(DEFAULT_TEXT_STYLE.to_string()),
79            notes_size: None,
80            sections: crate::oxml::section::SectionList::default(),
81        }
82    }
83}
84
85/// 单一 slide 引用(`p:sldId` 元素)。
86#[derive(Clone, Debug)]
87pub struct SlideIdEntry {
88    /// slide 在 sldIdLst 中的序号(`id` 属性)。
89    pub id: u32,
90    /// 关系 id(`r:id` 属性),指向 `ppt/slides/slideN.xml`。
91    pub rid: String,
92}
93
94/// 单一母版引用(`<p:sldMasterId>` 元素)。
95///
96/// 已实现:对应 `sldMasterIdLst` 中的每个条目,由 from_opc 解析并透传给 to_xml。
97#[derive(Clone, Debug)]
98pub struct SldMasterIdEntry {
99    /// 母版 ID(`id` 属性,通常 >= 2147483648)。
100    pub id: u32,
101    /// 关系 id(`r:id` 属性),指向 `ppt/slideMasters/slideMasterN.xml`。
102    pub rid: String,
103}
104
105/// 单一 notesMaster 引用(`<p:notesMasterId>` 元素)。
106///
107/// 用于 round-trip 保真:from_opc 解析时回填,to_xml 时遍历写出 `<p:notesMasterIdLst>`。
108/// 空列表时 `to_xml` 不输出 `<p:notesMasterIdLst>`(PowerPoint 兼容:无 notesMaster 即不写)。
109#[derive(Clone, Debug)]
110pub struct NotesMasterIdEntry {
111    /// 备注母版 ID(`id` 属性,通常 >= 2147483648)。
112    pub id: u32,
113    /// 关系 id(`r:id` 属性),指向 `ppt/notesMasters/notesMasterN.xml`。
114    pub rid: String,
115}
116
117impl PresentationRoot {
118    /// 写 XML。
119    pub fn to_xml(&self) -> String {
120        let mut w = XmlWriter::with_decl();
121        let root_attrs: Vec<(&str, &str)> = vec![
122            ("xmlns:a", NS_DRAWING_MAIN),
123            ("xmlns:p", NS_PRESENTATION_MAIN),
124            ("xmlns:r", crate::oxml::ns::NS_DRAWING_RELS),
125            (
126                "xmlns:p14",
127                "http://schemas.microsoft.com/office/powerpoint/2010/main",
128            ),
129        ];
130        w.open_with("p:presentation", &root_attrs);
131
132        // 1) sldMasterIdLst(必须存在,列出所有母版)
133        w.open("p:sldMasterIdLst");
134        if self.sld_master_ids.is_empty() {
135            // 默认:单个母版(兼容旧行为)
136            w.empty_with("p:sldMasterId", &[("id", "2147483648"), ("r:id", "rIdP1")]);
137        } else {
138            // 使用解析出的母版 ID 列表(round-trip 保真)
139            for m in &self.sld_master_ids {
140                let id_s = m.id.to_string();
141                w.empty_with("p:sldMasterId", &[("id", &id_s), ("r:id", m.rid.as_str())]);
142            }
143        }
144        w.close("p:sldMasterIdLst");
145
146        // 1.5) notesMasterIdLst(仅当非空时输出;空时不写该元素,PowerPoint 兼容)
147        // OOXML 顺序约束:sldMasterIdLst → notesMasterIdLst → sldIdLst → sldSz → notesSz → defaultTextStyle
148        if !self.notes_master_ids.is_empty() {
149            w.open("p:notesMasterIdLst");
150            for nm in &self.notes_master_ids {
151                let id_s = nm.id.to_string();
152                w.empty_with(
153                    "p:notesMasterId",
154                    &[("id", &id_s), ("r:id", nm.rid.as_str())],
155                );
156            }
157            w.close("p:notesMasterIdLst");
158        }
159
160        // 2) sldIdLst(所有 slide)
161        w.open("p:sldIdLst");
162        for s in &self.slide_ids {
163            // let 绑定延长生命周期
164            let id_s = s.id.to_string();
165            w.empty_with("p:sldId", &[("id", &id_s), ("r:id", s.rid.as_str())]);
166        }
167        w.close("p:sldIdLst");
168
169        // 3) sldSz
170        if let (Some(w_), Some(h)) = (self.slide_width, self.slide_height) {
171            let cx_s = w_.value().to_string();
172            let cy_s = h.value().to_string();
173            w.empty_with("p:sldSz", &[("cx", &cx_s), ("cy", &cy_s)]);
174        }
175        // 4) notesSz(注释页尺寸:默认 6858000 x 9144000 EMU,约 7.5" x 10")
176        let (nw, nh) = self.notes_size.unwrap_or((Emu(6_858_000), Emu(9_144_000)));
177        let ncx = nw.value().to_string();
178        let ncy = nh.value().to_string();
179        w.empty_with("p:notesSz", &[("cx", &ncx), ("cy", &ncy)]);
180        // 5) defaultTextStyle
181        if let Some(s) = &self.default_text_style {
182            w.raw(s);
183        }
184        // 6) sectionLst(TODO-039,PowerPoint 2010 扩展)
185        // 必须放在 `<p:extLst>` 内的 `<p:ext>` 中,且 uri 固定。
186        // 空列表时不输出任何内容。
187        let section_xml = self.sections.write_xml();
188        if !section_xml.is_empty() {
189            w.raw(&section_xml);
190        }
191
192        w.close("p:presentation");
193        w.into_string()
194    }
195}
196
197impl FromStr for PresentationRoot {
198    type Err = crate::Error;
199    fn from_str(_s: &str) -> Result<Self, Self::Err> {
200        // 完整解析器仍在路线图,读取走专门函数
201        Ok(PresentationRoot::default())
202    }
203}
204
205/// 默认 `<p:defaultTextStyle>` 内容(与 python-pptx 默认输出对齐)。
206/// 9 段 lvlXpPr,每段 18x 字号 / 主题色 / latin/+mn-lt 等。
207const DEFAULT_TEXT_STYLE: &str = r##"<p:defaultTextStyle><a:defPPr><a:defRPr lang="en-US"/></a:defPPr><a:lvl1pPr marL="0" algn="l" defTabSz="457200" rtl="0" eaLnBrk="1" latinLnBrk="0" hangingPunct="1"><a:defRPr sz="1800" kern="1200"><a:solidFill><a:schemeClr val="tx1"/></a:solidFill><a:latin typeface="+mn-lt"/><a:ea typeface="+mn-ea"/><a:cs typeface="+mn-cs"/></a:defRPr></a:lvl1pPr><a:lvl2pPr marL="457200" algn="l" defTabSz="457200" rtl="0" eaLnBrk="1" latinLnBrk="0" hangingPunct="1"><a:defRPr sz="1800" kern="1200"><a:solidFill><a:schemeClr val="tx1"/></a:solidFill><a:latin typeface="+mn-lt"/><a:ea typeface="+mn-ea"/><a:cs typeface="+mn-cs"/></a:defRPr></a:lvl2pPr><a:lvl3pPr marL="914400" algn="l" defTabSz="457200" rtl="0" eaLnBrk="1" latinLnBrk="0" hangingPunct="1"><a:defRPr sz="1800" kern="1200"><a:solidFill><a:schemeClr val="tx1"/></a:solidFill><a:latin typeface="+mn-lt"/><a:ea typeface="+mn-ea"/><a:cs typeface="+mn-cs"/></a:defRPr></a:lvl3pPr><a:lvl4pPr marL="1371600" algn="l" defTabSz="457200" rtl="0" eaLnBrk="1" latinLnBrk="0" hangingPunct="1"><a:defRPr sz="1800" kern="1200"><a:solidFill><a:schemeClr val="tx1"/></a:solidFill><a:latin typeface="+mn-lt"/><a:ea typeface="+mn-ea"/><a:cs typeface="+mn-cs"/></a:defRPr></a:lvl4pPr><a:lvl5pPr marL="1828800" algn="l" defTabSz="457200" rtl="0" eaLnBrk="1" latinLnBrk="0" hangingPunct="1"><a:defRPr sz="1800" kern="1200"><a:solidFill><a:schemeClr val="tx1"/></a:solidFill><a:latin typeface="+mn-lt"/><a:ea typeface="+mn-ea"/><a:cs typeface="+mn-cs"/></a:defRPr></a:lvl5pPr><a:lvl6pPr marL="2286000" algn="l" defTabSz="457200" rtl="0" eaLnBrk="1" latinLnBrk="0" hangingPunct="1"><a:defRPr sz="1800" kern="1200"><a:solidFill><a:schemeClr val="tx1"/></a:solidFill><a:latin typeface="+mn-lt"/><a:ea typeface="+mn-ea"/><a:cs typeface="+mn-cs"/></a:defRPr></a:lvl6pPr><a:lvl7pPr marL="2743200" algn="l" defTabSz="457200" rtl="0" eaLnBrk="1" latinLnBrk="0" hangingPunct="1"><a:defRPr sz="1800" kern="1200"><a:solidFill><a:schemeClr val="tx1"/></a:solidFill><a:latin typeface="+mn-lt"/><a:ea typeface="+mn-ea"/><a:cs typeface="+mn-cs"/></a:defRPr></a:lvl7pPr><a:lvl8pPr marL="3200400" algn="l" defTabSz="457200" rtl="0" eaLnBrk="1" latinLnBrk="0" hangingPunct="1"><a:defRPr sz="1800" kern="1200"><a:solidFill><a:schemeClr val="tx1"/></a:solidFill><a:latin typeface="+mn-lt"/><a:ea typeface="+mn-ea"/><a:cs typeface="+mn-cs"/></a:defRPr></a:lvl8pPr><a:lvl9pPr marL="3657600" algn="l" defTabSz="457200" rtl="0" eaLnBrk="1" latinLnBrk="0" hangingPunct="1"><a:defRPr sz="1800" kern="1200"><a:solidFill><a:schemeClr val="tx1"/></a:solidFill><a:latin typeface="+mn-lt"/><a:ea typeface="+mn-ea"/><a:cs typeface="+mn-cs"/></a:defRPr></a:lvl9pPr></p:defaultTextStyle>"##;