Skip to main content

pptx_parse/
package.rs

1use std::collections::{BTreeMap, HashMap, HashSet};
2
3use crate::drawing::{common_slide_data, parse_text_styles};
4use crate::model::*;
5use crate::relationships::{Relationship, parse_relationships, relationship_types};
6use crate::theme::parse_theme;
7use crate::xml::{ParseBudget, XmlElement, parse_xml};
8use crate::{ParseLimits, PptxError};
9
10pub fn parse_pptx(data: &[u8]) -> Result<PptxPackage, PptxError> {
11    parse_pptx_with_limits(data, &ParseLimits::default())
12}
13
14pub fn parse_pptx_with_limits(data: &[u8], limits: &ParseLimits) -> Result<PptxPackage, PptxError> {
15    let source_parts = ooxml_opc::unzip_parts(data).map_err(PptxError::Container)?;
16    let parts: HashMap<&str, &[u8]> = source_parts
17        .iter()
18        .map(|(path, bytes)| (path.as_str(), bytes.as_slice()))
19        .collect();
20    let mut budget = ParseBudget::new(limits);
21    let relationships = parse_package_relationships(&source_parts, &mut budget)?;
22    let presentation_path = relationships
23        .get("")
24        .and_then(|entries| {
25            entries
26                .iter()
27                .find(|relationship| relationship.has_type("/officeDocument"))
28        })
29        .and_then(|relationship| relationship.resolved_target.clone())
30        .unwrap_or_else(|| "ppt/presentation.xml".to_owned());
31    let presentation_root = parse_part(&parts, &presentation_path, &mut budget)?;
32    let presentation_relationships = relationships
33        .get(&presentation_path)
34        .map(Vec::as_slice)
35        .unwrap_or_default();
36    let presentation = parse_presentation(
37        &presentation_root,
38        &presentation_path,
39        presentation_relationships,
40    )?;
41
42    let mut slides = Vec::with_capacity(presentation.slides.len());
43    for reference in &presentation.slides {
44        let root = parse_part(&parts, &reference.part_path, &mut budget)?;
45        let slide_relationships = relationships
46            .get(&reference.part_path)
47            .map(Vec::as_slice)
48            .unwrap_or_default();
49        let data = common_slide_data(
50            &root,
51            slide_relationships,
52            &reference.part_path,
53            &mut budget,
54        )?;
55        slides.push(Slide {
56            part_path: reference.part_path.clone(),
57            name: data.name,
58            layout_part_path: relationship_by_type(
59                slide_relationships,
60                relationship_types::SLIDE_LAYOUT,
61            ),
62            show_master_shapes: bool_attribute(&root, "showMasterSp", true),
63            background: data.background,
64            shapes: data.shapes,
65        });
66    }
67
68    let master_paths = ordered_part_paths(
69        presentation.master_part_paths.clone(),
70        &source_parts,
71        "ppt/slideMasters/",
72    );
73    let mut masters = Vec::with_capacity(master_paths.len());
74    for part_path in &master_paths {
75        let root = parse_part(&parts, part_path, &mut budget)?;
76        let master_relationships = relationships
77            .get(part_path)
78            .map(Vec::as_slice)
79            .unwrap_or_default();
80        let data = common_slide_data(&root, master_relationships, part_path, &mut budget)?;
81        masters.push(SlideMaster {
82            part_path: part_path.clone(),
83            name: data.name,
84            theme_part_path: relationship_by_type(master_relationships, relationship_types::THEME),
85            layout_part_paths: master_relationships
86                .iter()
87                .filter(|relationship| relationship.has_type(relationship_types::SLIDE_LAYOUT))
88                .filter_map(|relationship| relationship.resolved_target.clone())
89                .collect(),
90            background: data.background,
91            shapes: data.shapes,
92            text_styles: parse_text_styles(&root),
93        });
94    }
95
96    let layout_paths = ordered_part_paths(
97        masters
98            .iter()
99            .flat_map(|master| master.layout_part_paths.iter().cloned())
100            .collect(),
101        &source_parts,
102        "ppt/slideLayouts/",
103    );
104    let mut layouts = Vec::with_capacity(layout_paths.len());
105    for part_path in &layout_paths {
106        let root = parse_part(&parts, part_path, &mut budget)?;
107        let layout_relationships = relationships
108            .get(part_path)
109            .map(Vec::as_slice)
110            .unwrap_or_default();
111        let data = common_slide_data(&root, layout_relationships, part_path, &mut budget)?;
112        layouts.push(SlideLayout {
113            part_path: part_path.clone(),
114            name: root
115                .attribute("matchingName")
116                .map(str::to_owned)
117                .or(data.name),
118            layout_type: root.attribute("type").map(str::to_owned),
119            master_part_path: relationship_by_type(
120                layout_relationships,
121                relationship_types::SLIDE_MASTER,
122            ),
123            show_master_shapes: bool_attribute(&root, "showMasterSp", true),
124            background: data.background,
125            shapes: data.shapes,
126        });
127    }
128
129    let theme_paths = ordered_part_paths(
130        masters
131            .iter()
132            .filter_map(|master| master.theme_part_path.clone())
133            .collect(),
134        &source_parts,
135        "ppt/theme/",
136    );
137    let mut themes = Vec::with_capacity(theme_paths.len());
138    for part_path in theme_paths {
139        let root = parse_part(&parts, &part_path, &mut budget)?;
140        themes.push(ThemePart {
141            part_path,
142            theme: parse_theme(&root),
143        });
144    }
145
146    let content_types = parse_content_types(&parts, &mut budget)?;
147    let media = source_parts
148        .iter()
149        .filter(|(path, _)| path.starts_with("ppt/media/"))
150        .map(|(path, bytes)| MediaPart {
151            part_path: path.clone(),
152            content_type: content_type_for(path, &content_types),
153            bytes: bytes.clone(),
154        })
155        .collect();
156    let parts = source_parts
157        .into_iter()
158        .map(|(path, bytes)| PackagePart { path, bytes })
159        .collect();
160    Ok(PptxPackage {
161        presentation,
162        slides,
163        layouts,
164        masters,
165        themes,
166        media,
167        relationships,
168        parts,
169    })
170}
171
172pub fn write_pptx(package: &PptxPackage) -> Result<Vec<u8>, PptxError> {
173    let parts = package
174        .parts
175        .iter()
176        .map(|part| (part.path.clone(), part.bytes.clone()))
177        .collect::<Vec<_>>();
178    ooxml_opc::rezip_parts(&parts).map_err(PptxError::Container)
179}
180
181fn parse_package_relationships(
182    parts: &[(String, Vec<u8>)],
183    budget: &mut ParseBudget<'_>,
184) -> Result<BTreeMap<String, Vec<Relationship>>, PptxError> {
185    let mut output = BTreeMap::new();
186    for (path, bytes) in parts {
187        let Some(source) = relationship_source(path) else {
188            continue;
189        };
190        let parsed = parse_relationships(bytes, path, &source, budget)?;
191        output.insert(source, parsed);
192    }
193    Ok(output)
194}
195
196fn relationship_source(path: &str) -> Option<String> {
197    if path == "_rels/.rels" {
198        return Some(String::new());
199    }
200    let (directory, filename) = path.rsplit_once("/_rels/")?;
201    let source_filename = filename.strip_suffix(".rels")?;
202    Some(format!("{directory}/{source_filename}"))
203}
204
205fn parse_part(
206    parts: &HashMap<&str, &[u8]>,
207    path: &str,
208    budget: &mut ParseBudget<'_>,
209) -> Result<XmlElement, PptxError> {
210    let bytes = parts
211        .get(path)
212        .ok_or_else(|| PptxError::MissingPart(path.to_owned()))?;
213    parse_xml(bytes, path, budget)
214}
215
216fn parse_presentation(
217    root: &XmlElement,
218    part_path: &str,
219    relationships: &[Relationship],
220) -> Result<Presentation, PptxError> {
221    let slide_size = root.child("sldSz");
222    let width_emu = positive_integer_attribute(slide_size, "cx").unwrap_or(12_192_000);
223    let height_emu = positive_integer_attribute(slide_size, "cy").unwrap_or(6_858_000);
224    let mut slides = Vec::new();
225    if let Some(list) = root.child("sldIdLst") {
226        for slide in list.children_named("sldId") {
227            let relationship_id = slide
228                .attribute("r:id")
229                .or_else(|| slide.attribute_local("id"))
230                .unwrap_or_default()
231                .to_owned();
232            let part_path =
233                relationship_target(relationships, &relationship_id).ok_or_else(|| {
234                    PptxError::MissingPart(format!("slide relationship {relationship_id}"))
235                })?;
236            slides.push(SlideReference {
237                id: slide
238                    .attribute("id")
239                    .and_then(|value| value.parse().ok())
240                    .unwrap_or_default(),
241                relationship_id,
242                part_path,
243            });
244        }
245    }
246    let master_part_paths = root
247        .child("sldMasterIdLst")
248        .into_iter()
249        .flat_map(|list| list.children_named("sldMasterId"))
250        .filter_map(|master| {
251            master
252                .attribute("r:id")
253                .or_else(|| master.attribute_local("id"))
254                .and_then(|id| relationship_target(relationships, id))
255        })
256        .collect();
257    Ok(Presentation {
258        part_path: part_path.to_owned(),
259        width_emu,
260        height_emu,
261        slides,
262        master_part_paths,
263    })
264}
265
266#[derive(Default)]
267struct ContentTypes {
268    defaults: HashMap<String, String>,
269    overrides: HashMap<String, String>,
270}
271
272fn parse_content_types(
273    parts: &HashMap<&str, &[u8]>,
274    budget: &mut ParseBudget<'_>,
275) -> Result<ContentTypes, PptxError> {
276    let root = parse_part(parts, "[Content_Types].xml", budget)?;
277    let mut content_types = ContentTypes::default();
278    for child in root.child_elements() {
279        match child.local_name() {
280            "Default" => {
281                if let (Some(extension), Some(content_type)) =
282                    (child.attribute("Extension"), child.attribute("ContentType"))
283                {
284                    content_types
285                        .defaults
286                        .insert(extension.to_ascii_lowercase(), content_type.to_owned());
287                }
288            }
289            "Override" => {
290                if let (Some(part_name), Some(content_type)) =
291                    (child.attribute("PartName"), child.attribute("ContentType"))
292                {
293                    content_types.overrides.insert(
294                        part_name.trim_start_matches('/').to_owned(),
295                        content_type.to_owned(),
296                    );
297                }
298            }
299            _ => {}
300        }
301    }
302    Ok(content_types)
303}
304
305fn content_type_for(path: &str, content_types: &ContentTypes) -> String {
306    content_types
307        .overrides
308        .get(path)
309        .cloned()
310        .or_else(|| {
311            path.rsplit_once('.').and_then(|(_, extension)| {
312                content_types
313                    .defaults
314                    .get(&extension.to_ascii_lowercase())
315                    .cloned()
316            })
317        })
318        .unwrap_or_else(|| "application/octet-stream".to_owned())
319}
320
321fn ordered_part_paths(
322    preferred: Vec<String>,
323    parts: &[(String, Vec<u8>)],
324    prefix: &str,
325) -> Vec<String> {
326    let mut seen = HashSet::new();
327    preferred
328        .into_iter()
329        .chain(
330            parts
331                .iter()
332                .map(|(path, _)| path.clone())
333                .filter(|path| path.starts_with(prefix) && path.ends_with(".xml")),
334        )
335        .filter(|path| seen.insert(path.clone()))
336        .collect()
337}
338
339fn relationship_by_type(relationships: &[Relationship], suffix: &str) -> Option<String> {
340    relationships
341        .iter()
342        .find(|relationship| relationship.has_type(suffix))
343        .and_then(|relationship| relationship.resolved_target.clone())
344}
345
346fn relationship_target(relationships: &[Relationship], id: &str) -> Option<String> {
347    relationships
348        .iter()
349        .find(|relationship| relationship.id == id)
350        .and_then(|relationship| relationship.resolved_target.clone())
351}
352
353fn positive_integer_attribute(element: Option<&XmlElement>, name: &str) -> Option<i64> {
354    let value = element?.attribute(name)?.parse::<i64>().ok()?;
355    (value > 0 && value <= 1_000_000_000_000_000).then_some(value)
356}
357
358fn bool_attribute(element: &XmlElement, name: &str, default: bool) -> bool {
359    match element.attribute(name) {
360        Some("1" | "true" | "on") => true,
361        Some("0" | "false" | "off") => false,
362        _ => default,
363    }
364}
365
366#[cfg(test)]
367mod tests {
368    use super::*;
369
370    const FIXTURE: &[u8] = include_bytes!("../../../apps/demo/public/betteroffice-demo.pptx");
371
372    #[test]
373    fn parses_betteroffice_demo_deck_surface() {
374        let package = parse_pptx(FIXTURE).unwrap();
375        assert_eq!(package.presentation.width_emu, 12_192_000);
376        assert_eq!(package.presentation.height_emu, 6_858_000);
377        assert_eq!(package.slides.len(), 3);
378        assert_eq!(package.layouts.len(), 1);
379        assert_eq!(package.masters.len(), 1);
380        assert_eq!(package.themes.len(), 1);
381        assert_eq!(package.media.len(), 1);
382        assert_eq!(package.themes[0].theme.name, "BetterOffice");
383
384        let kinds = package
385            .slides
386            .iter()
387            .flat_map(|slide| slide.shapes.iter())
388            .map(|shape| match shape {
389                ShapeNode::Shape(_) => "shape",
390                ShapeNode::Picture(_) => "picture",
391                ShapeNode::GraphicFrame(_) => "graphicFrame",
392                ShapeNode::Group(_) => "group",
393            })
394            .collect::<HashSet<_>>();
395        assert_eq!(
396            kinds,
397            HashSet::from(["shape", "picture", "graphicFrame", "group"])
398        );
399        assert!(package.slides.iter().flat_map(|slide| &slide.shapes).any(|shape| {
400            matches!(
401                shape,
402                ShapeNode::Shape(Shape {
403                    text: Some(TextBody { paragraphs, .. }),
404                    ..
405                }) if paragraphs.iter().flat_map(|paragraph| &paragraph.runs).any(|run| run.text.contains("Rust"))
406            )
407        }));
408    }
409
410    #[test]
411    fn untouched_save_preserves_every_part_byte_and_order() {
412        let package = parse_pptx(FIXTURE).unwrap();
413        let written = write_pptx(&package).unwrap();
414        let before = ooxml_opc::unzip_parts(FIXTURE).unwrap();
415        let after = ooxml_opc::unzip_parts(&written).unwrap();
416        assert_eq!(after, before);
417    }
418
419    #[test]
420    fn package_limits_apply_across_xml_parts() {
421        let limits = ParseLimits {
422            max_xml_bytes: 100,
423            ..ParseLimits::default()
424        };
425        assert!(matches!(
426            parse_pptx_with_limits(FIXTURE, &limits),
427            Err(PptxError::ResourceLimit {
428                kind: "xmlBytes",
429                ..
430            })
431        ));
432    }
433}