Skip to main content

pptx_parse/
package.rs

1use std::collections::{BTreeMap, HashMap, HashSet};
2
3use ooxml_drawingml::{TableStyleList, Theme};
4
5use crate::chart::parse_chart_part;
6use crate::comments::{
7    Comment, CommentAuthor, CommentFlavor, authors_part, parse_comment_authors, parse_comments,
8    slide_comment_parts,
9};
10use crate::drawing::{common_slide_data, parse_text_styles};
11use crate::model::*;
12use crate::relationships::{Relationship, parse_relationships, relationship_types};
13use crate::table_style::parse_table_styles;
14use crate::theme::{parse_format_scheme, parse_theme};
15use crate::xml::{ParseBudget, XmlElement, parse_xml};
16use crate::{ParseLimits, PptxError};
17
18pub fn parse_pptx(data: &[u8]) -> Result<PptxPackage, PptxError> {
19    parse_pptx_with_limits(data, &ParseLimits::default())
20}
21
22pub fn parse_pptx_with_limits(data: &[u8], limits: &ParseLimits) -> Result<PptxPackage, PptxError> {
23    parse_package(data, limits, ShapeElements::WithConnectors)
24}
25
26/// Preserves pre-connector source ordinals.
27pub fn parse_pptx_without_connectors(data: &[u8]) -> Result<PptxPackage, PptxError> {
28    parse_package(
29        data,
30        &ParseLimits::default(),
31        ShapeElements::WithoutConnectors,
32    )
33}
34
35fn parse_package(
36    data: &[u8],
37    limits: &ParseLimits,
38    shape_elements: ShapeElements,
39) -> Result<PptxPackage, PptxError> {
40    let source_parts = ooxml_opc::unzip_parts(data).map_err(PptxError::Container)?;
41    let parts: HashMap<&str, &[u8]> = source_parts
42        .iter()
43        .map(|(path, bytes)| (path.as_str(), bytes.as_slice()))
44        .collect();
45    let mut budget = ParseBudget::new(limits);
46    let relationships = parse_package_relationships(&source_parts, &mut budget)?;
47    let presentation_path = relationships
48        .get("")
49        .and_then(|entries| {
50            entries
51                .iter()
52                .find(|relationship| relationship.has_type("/officeDocument"))
53        })
54        .and_then(|relationship| relationship.resolved_target.clone())
55        .unwrap_or_else(|| "ppt/presentation.xml".to_owned());
56    let presentation_root = parse_part(&parts, &presentation_path, &mut budget)?;
57    let presentation_relationships = relationships
58        .get(&presentation_path)
59        .map(Vec::as_slice)
60        .unwrap_or_default();
61    let presentation = parse_presentation(
62        &presentation_root,
63        &presentation_path,
64        presentation_relationships,
65    )?;
66
67    let mut has_connectors = false;
68    let mut slides = Vec::with_capacity(presentation.slides.len());
69    for reference in &presentation.slides {
70        let root = parse_part(&parts, &reference.part_path, &mut budget)?;
71        let slide_relationships = relationships
72            .get(&reference.part_path)
73            .map(Vec::as_slice)
74            .unwrap_or_default();
75        has_connectors |= !root.descendants_named("cxnSp").is_empty();
76        let data = common_slide_data(
77            &root,
78            slide_relationships,
79            &reference.part_path,
80            &mut budget,
81            shape_elements,
82        )?;
83        let notes = match crate::notes::slide_notes_part(slide_relationships) {
84            Some(notes_path) => match parts.get(notes_path.as_str()) {
85                Some(bytes) => crate::notes::parse_notes_text(bytes, &notes_path, &mut budget)?,
86                None => String::new(),
87            },
88            None => String::new(),
89        };
90        slides.push(Slide {
91            part_path: reference.part_path.clone(),
92            name: data.name,
93            layout_part_path: relationship_by_type(
94                slide_relationships,
95                relationship_types::SLIDE_LAYOUT,
96            ),
97            show_master_shapes: bool_attribute(&root, "showMasterSp", true),
98            background: data.background,
99            shapes: data.shapes,
100            notes,
101        });
102    }
103
104    let master_paths = ordered_part_paths(
105        presentation.master_part_paths.clone(),
106        &source_parts,
107        "ppt/slideMasters/",
108    );
109    let mut masters = Vec::with_capacity(master_paths.len());
110    for part_path in &master_paths {
111        let root = parse_part(&parts, part_path, &mut budget)?;
112        let master_relationships = relationships
113            .get(part_path)
114            .map(Vec::as_slice)
115            .unwrap_or_default();
116        has_connectors |= !root.descendants_named("cxnSp").is_empty();
117        let data = common_slide_data(
118            &root,
119            master_relationships,
120            part_path,
121            &mut budget,
122            shape_elements,
123        )?;
124        masters.push(SlideMaster {
125            part_path: part_path.clone(),
126            name: data.name,
127            theme_part_path: relationship_by_type(master_relationships, relationship_types::THEME),
128            layout_part_paths: master_relationships
129                .iter()
130                .filter(|relationship| relationship.has_type(relationship_types::SLIDE_LAYOUT))
131                .filter_map(|relationship| relationship.resolved_target.clone())
132                .collect(),
133            background: data.background,
134            shapes: data.shapes,
135            text_styles: parse_text_styles(&root),
136        });
137    }
138
139    let layout_paths = ordered_part_paths(
140        masters
141            .iter()
142            .flat_map(|master| master.layout_part_paths.iter().cloned())
143            .collect(),
144        &source_parts,
145        "ppt/slideLayouts/",
146    );
147    let mut layouts = Vec::with_capacity(layout_paths.len());
148    for part_path in &layout_paths {
149        let root = parse_part(&parts, part_path, &mut budget)?;
150        let layout_relationships = relationships
151            .get(part_path)
152            .map(Vec::as_slice)
153            .unwrap_or_default();
154        has_connectors |= !root.descendants_named("cxnSp").is_empty();
155        let data = common_slide_data(
156            &root,
157            layout_relationships,
158            part_path,
159            &mut budget,
160            shape_elements,
161        )?;
162        layouts.push(SlideLayout {
163            part_path: part_path.clone(),
164            name: root
165                .attribute("matchingName")
166                .map(str::to_owned)
167                .or(data.name),
168            layout_type: root.attribute("type").map(str::to_owned),
169            master_part_path: relationship_by_type(
170                layout_relationships,
171                relationship_types::SLIDE_MASTER,
172            ),
173            show_master_shapes: bool_attribute(&root, "showMasterSp", true),
174            background: data.background,
175            shapes: data.shapes,
176        });
177    }
178
179    let theme_paths = ordered_part_paths(
180        masters
181            .iter()
182            .filter_map(|master| master.theme_part_path.clone())
183            .collect(),
184        &source_parts,
185        "ppt/theme/",
186    );
187    let mut themes = Vec::with_capacity(theme_paths.len());
188    for part_path in theme_paths {
189        let root = parse_part(&parts, &part_path, &mut budget)?;
190        themes.push(ThemePart {
191            part_path,
192            theme: parse_theme(&root),
193            format_scheme: parse_format_scheme(&root),
194        });
195    }
196
197    let table_styles = parse_table_style_part(&parts, presentation_relationships, &mut budget)?;
198
199    let charts = parse_chart_parts(
200        &parts,
201        &ChartSources {
202            slides: &slides,
203            layouts: &layouts,
204            masters: &masters,
205            themes: &themes,
206            relationships: &relationships,
207        },
208        limits,
209    );
210
211    let deck_comments = parse_package_comments(
212        &parts,
213        &presentation,
214        presentation_relationships,
215        &relationships,
216        &mut budget,
217    )?;
218
219    let content_types = parse_content_types(&parts, &mut budget)?;
220    let media = source_parts
221        .iter()
222        .filter(|(path, _)| path.starts_with("ppt/media/"))
223        .map(|(path, bytes)| MediaPart {
224            part_path: path.clone(),
225            content_type: content_type_for(path, &content_types),
226            bytes: bytes.clone(),
227        })
228        .collect();
229    let parts = source_parts
230        .into_iter()
231        .map(|(path, bytes)| PackagePart { path, bytes })
232        .collect();
233    Ok(PptxPackage {
234        presentation,
235        slides,
236        layouts,
237        masters,
238        themes,
239        charts,
240        media,
241        table_styles,
242        comment_authors: deck_comments.authors,
243        comments: deck_comments.comments,
244        comment_flavor: deck_comments.flavor,
245        relationships,
246        parts,
247        shape_elements: if has_connectors {
248            shape_elements
249        } else {
250            ShapeElements::WithoutConnectors
251        },
252    })
253}
254
255fn parse_package_comments(
256    parts: &HashMap<&str, &[u8]>,
257    presentation: &Presentation,
258    presentation_relationships: &[Relationship],
259    relationships: &BTreeMap<String, Vec<Relationship>>,
260    budget: &mut ParseBudget<'_>,
261) -> Result<PackageComments, PptxError> {
262    let mut found: Vec<(CommentFlavor, String, String)> = Vec::new();
263    for reference in &presentation.slides {
264        let slide_relationships = relationships
265            .get(&reference.part_path)
266            .map(Vec::as_slice)
267            .unwrap_or_default();
268        let slide_parts = slide_comment_parts(slide_relationships);
269        if let Some(part) = slide_parts.legacy {
270            found.push((CommentFlavor::Legacy, reference.part_path.clone(), part));
271        }
272        if let Some(part) = slide_parts.modern {
273            found.push((CommentFlavor::Modern, reference.part_path.clone(), part));
274        }
275    }
276    let Some(flavor) = found
277        .iter()
278        .any(|(flavor, _, _)| *flavor == CommentFlavor::Legacy)
279        .then_some(CommentFlavor::Legacy)
280        .or_else(|| (!found.is_empty()).then_some(CommentFlavor::Modern))
281    else {
282        return Ok(PackageComments::default());
283    };
284
285    let mut comments = Vec::new();
286    for (_, slide_part_path, part) in found
287        .iter()
288        .filter(|(candidate, _, _)| *candidate == flavor)
289    {
290        let Some(bytes) = parts.get(part.as_str()) else {
291            continue;
292        };
293        comments.extend(parse_comments(
294            bytes,
295            part,
296            slide_part_path,
297            flavor,
298            budget,
299        )?);
300    }
301
302    let comment_authors = match authors_part(presentation_relationships, flavor)
303        .and_then(|part| parts.get(part.as_str()).map(|bytes| (part, *bytes)))
304    {
305        Some((part, bytes)) => parse_comment_authors(bytes, &part, flavor, budget)?,
306        None => Vec::new(),
307    };
308    Ok(PackageComments {
309        authors: comment_authors,
310        comments,
311        flavor: Some(flavor),
312    })
313}
314
315#[derive(Default)]
316struct PackageComments {
317    authors: Vec<CommentAuthor>,
318    comments: Vec<Comment>,
319    flavor: Option<CommentFlavor>,
320}
321
322pub fn write_pptx(package: &PptxPackage) -> Result<Vec<u8>, PptxError> {
323    let parts = package
324        .parts
325        .iter()
326        .map(|part| (part.path.clone(), part.bytes.clone()))
327        .collect::<Vec<_>>();
328    ooxml_opc::rezip_parts(&parts).map_err(PptxError::Container)
329}
330
331fn parse_package_relationships(
332    parts: &[(String, Vec<u8>)],
333    budget: &mut ParseBudget<'_>,
334) -> Result<BTreeMap<String, Vec<Relationship>>, PptxError> {
335    let mut output = BTreeMap::new();
336    for (path, bytes) in parts {
337        let Some(source) = relationship_source(path) else {
338            continue;
339        };
340        let parsed = parse_relationships(bytes, path, &source, budget)?;
341        output.insert(source, parsed);
342    }
343    Ok(output)
344}
345
346fn relationship_source(path: &str) -> Option<String> {
347    if path == "_rels/.rels" {
348        return Some(String::new());
349    }
350    let (directory, filename) = path.rsplit_once("/_rels/")?;
351    let source_filename = filename.strip_suffix(".rels")?;
352    Some(format!("{directory}/{source_filename}"))
353}
354
355fn parse_part(
356    parts: &HashMap<&str, &[u8]>,
357    path: &str,
358    budget: &mut ParseBudget<'_>,
359) -> Result<XmlElement, PptxError> {
360    let bytes = parts
361        .get(path)
362        .ok_or_else(|| PptxError::MissingPart(path.to_owned()))?;
363    parse_xml(bytes, path, budget)
364}
365
366fn parse_table_style_part(
367    parts: &HashMap<&str, &[u8]>,
368    presentation_relationships: &[Relationship],
369    budget: &mut ParseBudget<'_>,
370) -> Result<TableStyleList, PptxError> {
371    let path = relationship_by_type(presentation_relationships, relationship_types::TABLE_STYLES)
372        .unwrap_or_else(|| "ppt/tableStyles.xml".to_owned());
373    let Some(bytes) = parts.get(path.as_str()) else {
374        return Ok(TableStyleList::default());
375    };
376    Ok(parse_table_styles(&parse_xml(bytes, &path, budget)?))
377}
378
379fn parse_presentation(
380    root: &XmlElement,
381    part_path: &str,
382    relationships: &[Relationship],
383) -> Result<Presentation, PptxError> {
384    let slide_size = root.child("sldSz");
385    let width_emu = positive_integer_attribute(slide_size, "cx").unwrap_or(12_192_000);
386    let height_emu = positive_integer_attribute(slide_size, "cy").unwrap_or(6_858_000);
387    let first_slide_num = root
388        .attribute("firstSlideNum")
389        .and_then(|value| value.parse().ok())
390        .unwrap_or(1);
391    let mut slides = Vec::new();
392    if let Some(list) = root.child("sldIdLst") {
393        for slide in list.children_named("sldId") {
394            let relationship_id = slide
395                .attribute("r:id")
396                .or_else(|| slide.attribute_local("id"))
397                .unwrap_or_default()
398                .to_owned();
399            let part_path =
400                relationship_target(relationships, &relationship_id).ok_or_else(|| {
401                    PptxError::MissingPart(format!("slide relationship {relationship_id}"))
402                })?;
403            slides.push(SlideReference {
404                id: slide
405                    .attribute("id")
406                    .and_then(|value| value.parse().ok())
407                    .unwrap_or_default(),
408                relationship_id,
409                part_path,
410            });
411        }
412    }
413    let master_part_paths = root
414        .child("sldMasterIdLst")
415        .into_iter()
416        .flat_map(|list| list.children_named("sldMasterId"))
417        .filter_map(|master| {
418            master
419                .attribute("r:id")
420                .or_else(|| master.attribute_local("id"))
421                .and_then(|id| relationship_target(relationships, id))
422        })
423        .collect();
424    Ok(Presentation {
425        part_path: part_path.to_owned(),
426        width_emu,
427        height_emu,
428        first_slide_num,
429        slides,
430        master_part_paths,
431    })
432}
433
434/// The parts a chart may be referenced from, plus the theme cascade that
435/// decides which colours it resolves against.
436struct ChartSources<'a> {
437    slides: &'a [Slide],
438    layouts: &'a [SlideLayout],
439    masters: &'a [SlideMaster],
440    themes: &'a [ThemePart],
441    relationships: &'a BTreeMap<String, Vec<Relationship>>,
442}
443
444impl ChartSources<'_> {
445    fn chart_target(&self, source_part: &str, relationship_id: &str) -> Option<String> {
446        self.relationships
447            .get(source_part)?
448            .iter()
449            .find(|relationship| relationship.id == relationship_id)
450            .filter(|relationship| relationship.has_type(relationship_types::CHART))
451            .and_then(|relationship| relationship.resolved_target.clone())
452    }
453
454    fn theme_for(&self, source_part: &str) -> Option<&ThemePart> {
455        if let Some(slide) = self
456            .slides
457            .iter()
458            .find(|slide| slide.part_path == source_part)
459        {
460            return self.theme_via_layout(slide.layout_part_path.as_deref());
461        }
462        if self
463            .layouts
464            .iter()
465            .any(|layout| layout.part_path == source_part)
466        {
467            return self.theme_via_layout(Some(source_part));
468        }
469        if self
470            .masters
471            .iter()
472            .any(|master| master.part_path == source_part)
473        {
474            return self.theme_via_master(Some(source_part));
475        }
476        self.themes.first()
477    }
478
479    fn theme_via_layout(&self, layout_part_path: Option<&str>) -> Option<&ThemePart> {
480        let layout = layout_part_path
481            .and_then(|path| self.layouts.iter().find(|layout| layout.part_path == path))
482            .or_else(|| self.layouts.first());
483        let master_part_path = layout
484            .and_then(|layout| layout.master_part_path.as_deref())
485            .or_else(|| {
486                let layout = layout?;
487                self.masters
488                    .iter()
489                    .find(|master| {
490                        master
491                            .layout_part_paths
492                            .iter()
493                            .any(|path| path == &layout.part_path)
494                    })
495                    .map(|master| master.part_path.as_str())
496            });
497        self.theme_via_master(master_part_path)
498    }
499
500    fn theme_via_master(&self, master_part_path: Option<&str>) -> Option<&ThemePart> {
501        let master = master_part_path
502            .and_then(|path| self.masters.iter().find(|master| master.part_path == path))
503            .or_else(|| self.masters.first());
504        master
505            .and_then(|master| master.theme_part_path.as_deref())
506            .and_then(|path| self.themes.iter().find(|theme| theme.part_path == path))
507            .or_else(|| self.themes.first())
508    }
509}
510
511/// Every chart the deck references, read against the theme its source part
512/// resolves to.
513///
514/// A chart part is a decorative leaf: nothing structural is read from it and
515/// nothing structural is read after it. So a part `parse_xml` refuses is
516/// skipped, leaving the deck to open with that chart missing exactly as it opens
517/// when the part is absent. `parse_xml` reports only `MalformedXml`, `UnsafeXml`
518/// and `ResourceLimit`, each naming this part; a hostile package is refused
519/// earlier, by `ooxml_opc::unzip_parts`.
520///
521/// What bounds the reading: each part gets its own [`ParseBudget`], so no cached
522/// series can starve a slide or spend another part's depth, byte and text
523/// limits; all of them draw their XML events from one pool the size of
524/// `max_xml_events` ([`read_chart_root`]); and each part is read once however
525/// many themes reference it, the tree being the same for all of them and only
526/// the colours it resolves against differing.
527fn parse_chart_parts(
528    parts: &HashMap<&str, &[u8]>,
529    sources: &ChartSources<'_>,
530    limits: &ParseLimits,
531) -> Vec<ChartPart> {
532    let mut references = Vec::new();
533    for slide in sources.slides {
534        collect_chart_references(&slide.shapes, &slide.part_path, &mut references);
535    }
536    for layout in sources.layouts {
537        collect_chart_references(&layout.shapes, &layout.part_path, &mut references);
538    }
539    for master in sources.masters {
540        collect_chart_references(&master.shapes, &master.part_path, &mut references);
541    }
542    let default_theme = Theme::default();
543    let mut reads = Vec::new();
544    let mut loaded = HashSet::new();
545    let mut pending_reads: HashMap<String, usize> = HashMap::new();
546    for (source_part, relationship_id) in references {
547        let Some(part_path) = sources.chart_target(&source_part, &relationship_id) else {
548            continue;
549        };
550        let theme_part = sources.theme_for(&source_part);
551        let theme_part_path = theme_part.map(|part| part.part_path.clone());
552        if !loaded.insert((part_path.clone(), theme_part_path.clone())) {
553            continue;
554        }
555        *pending_reads.entry(part_path.clone()).or_default() += 1;
556        let theme = theme_part.map(|part| &part.theme).unwrap_or(&default_theme);
557        reads.push((part_path, theme_part_path, theme));
558    }
559
560    let mut charts = Vec::new();
561    let mut roots: HashMap<String, Option<XmlElement>> = HashMap::new();
562    let mut remaining_events = limits.max_xml_events;
563    for (part_path, theme_part_path, theme) in reads {
564        if !roots.contains_key(&part_path) {
565            let root = parts.get(part_path.as_str()).and_then(|bytes| {
566                read_chart_root(bytes, &part_path, limits, &mut remaining_events)
567            });
568            roots.insert(part_path.clone(), root);
569        }
570        let chart = roots
571            .get(&part_path)
572            .and_then(Option::as_ref)
573            .and_then(|root| parse_chart_part(root, theme));
574        if let Some(chart) = chart {
575            charts.push(ChartPart {
576                part_path: part_path.clone(),
577                theme_part_path,
578                chart,
579            });
580        }
581        if let Some(pending) = pending_reads.get_mut(&part_path) {
582            *pending -= 1;
583            if *pending == 0 {
584                roots.remove(&part_path);
585            }
586        }
587    }
588    charts
589}
590
591/// Reads one chart part against its own budget, drawing its XML events from
592/// `remaining_events` — a pool the size of the package's `max_xml_events` that
593/// every chart part shares. A part that empties the pool is declined like a
594/// malformed one, so the deck's charts together cost no more than the one budget
595/// they used to be read against.
596fn read_chart_root(
597    bytes: &[u8],
598    part_path: &str,
599    limits: &ParseLimits,
600    remaining_events: &mut usize,
601) -> Option<XmlElement> {
602    let part_limits = ParseLimits {
603        max_xml_events: *remaining_events,
604        ..limits.clone()
605    };
606    let mut budget = ParseBudget::new(&part_limits);
607    let root = parse_xml(bytes, part_path, &mut budget).ok();
608    *remaining_events = remaining_events.saturating_sub(budget.xml_events_spent());
609    root
610}
611
612/// `(referencing part, relationship id)` for every chart in `shapes`, groups
613/// included, in document order.
614fn collect_chart_references(
615    shapes: &[ShapeNode],
616    part_path: &str,
617    output: &mut Vec<(String, String)>,
618) {
619    for shape in shapes {
620        match shape {
621            ShapeNode::GraphicFrame(frame) => {
622                if let GraphicFrameData::Chart {
623                    relationship_id, ..
624                } = &frame.data
625                {
626                    output.push((part_path.to_owned(), relationship_id.clone()));
627                }
628            }
629            ShapeNode::Group(group) => collect_chart_references(&group.children, part_path, output),
630            ShapeNode::Shape(_) | ShapeNode::Picture(_) => {}
631        }
632    }
633}
634
635#[derive(Default)]
636struct ContentTypes {
637    defaults: HashMap<String, String>,
638    overrides: HashMap<String, String>,
639}
640
641fn parse_content_types(
642    parts: &HashMap<&str, &[u8]>,
643    budget: &mut ParseBudget<'_>,
644) -> Result<ContentTypes, PptxError> {
645    let root = parse_part(parts, "[Content_Types].xml", budget)?;
646    let mut content_types = ContentTypes::default();
647    for child in root.child_elements() {
648        match child.local_name() {
649            "Default" => {
650                if let (Some(extension), Some(content_type)) =
651                    (child.attribute("Extension"), child.attribute("ContentType"))
652                {
653                    content_types
654                        .defaults
655                        .insert(extension.to_ascii_lowercase(), content_type.to_owned());
656                }
657            }
658            "Override" => {
659                if let (Some(part_name), Some(content_type)) =
660                    (child.attribute("PartName"), child.attribute("ContentType"))
661                {
662                    content_types.overrides.insert(
663                        part_name.trim_start_matches('/').to_owned(),
664                        content_type.to_owned(),
665                    );
666                }
667            }
668            _ => {}
669        }
670    }
671    Ok(content_types)
672}
673
674fn content_type_for(path: &str, content_types: &ContentTypes) -> String {
675    content_types
676        .overrides
677        .get(path)
678        .cloned()
679        .or_else(|| {
680            path.rsplit_once('.').and_then(|(_, extension)| {
681                content_types
682                    .defaults
683                    .get(&extension.to_ascii_lowercase())
684                    .cloned()
685            })
686        })
687        .unwrap_or_else(|| "application/octet-stream".to_owned())
688}
689
690fn ordered_part_paths(
691    preferred: Vec<String>,
692    parts: &[(String, Vec<u8>)],
693    prefix: &str,
694) -> Vec<String> {
695    let mut seen = HashSet::new();
696    preferred
697        .into_iter()
698        .chain(
699            parts
700                .iter()
701                .map(|(path, _)| path.clone())
702                .filter(|path| path.starts_with(prefix) && path.ends_with(".xml")),
703        )
704        .filter(|path| seen.insert(path.clone()))
705        .collect()
706}
707
708fn relationship_by_type(relationships: &[Relationship], suffix: &str) -> Option<String> {
709    relationships
710        .iter()
711        .find(|relationship| relationship.has_type(suffix))
712        .and_then(|relationship| relationship.resolved_target.clone())
713}
714
715fn relationship_target(relationships: &[Relationship], id: &str) -> Option<String> {
716    relationships
717        .iter()
718        .find(|relationship| relationship.id == id)
719        .and_then(|relationship| relationship.resolved_target.clone())
720}
721
722fn positive_integer_attribute(element: Option<&XmlElement>, name: &str) -> Option<i64> {
723    let value = element?.attribute(name)?.parse::<i64>().ok()?;
724    (value > 0 && value <= 1_000_000_000_000_000).then_some(value)
725}
726
727fn bool_attribute(element: &XmlElement, name: &str, default: bool) -> bool {
728    match element.attribute(name) {
729        Some("1" | "true" | "on") => true,
730        Some("0" | "false" | "off") => false,
731        _ => default,
732    }
733}
734
735#[cfg(test)]
736mod tests {
737    use super::*;
738
739    const FIXTURE: &[u8] = include_bytes!("../../../apps/demo/public/betteroffice-demo.pptx");
740    const CHART_FIXTURE: &[u8] = include_bytes!("../tests/fixtures/chart-deck.pptx");
741    const NUMBERED_FIXTURE: &[u8] = include_bytes!("../tests/fixtures/slide-number-fields.pptx");
742    const CHART_PART: &str = "ppt/charts/chart1.xml";
743
744    #[test]
745    fn parses_betteroffice_demo_deck_surface() {
746        let package = parse_pptx(FIXTURE).unwrap();
747        assert_eq!(package.presentation.width_emu, 12_192_000);
748        assert_eq!(package.presentation.height_emu, 6_858_000);
749        assert_eq!(package.slides.len(), 3);
750        assert_eq!(package.layouts.len(), 1);
751        assert_eq!(package.masters.len(), 1);
752        assert_eq!(package.themes.len(), 1);
753        assert_eq!(package.media.len(), 1);
754        assert_eq!(package.themes[0].theme.name, "BetterOffice");
755
756        let kinds = package
757            .slides
758            .iter()
759            .flat_map(|slide| slide.shapes.iter())
760            .map(|shape| match shape {
761                ShapeNode::Shape(_) => "shape",
762                ShapeNode::Picture(_) => "picture",
763                ShapeNode::GraphicFrame(_) => "graphicFrame",
764                ShapeNode::Group(_) => "group",
765            })
766            .collect::<HashSet<_>>();
767        assert_eq!(
768            kinds,
769            HashSet::from(["shape", "picture", "graphicFrame", "group"])
770        );
771        assert!(package.slides.iter().flat_map(|slide| &slide.shapes).any(|shape| {
772            matches!(
773                shape,
774                ShapeNode::Shape(Shape {
775                    text: Some(TextBody { paragraphs, .. }),
776                    ..
777                }) if paragraphs.iter().flat_map(|paragraph| &paragraph.runs).any(|run| run.text.contains("Rust"))
778            )
779        }));
780    }
781
782    #[test]
783    fn first_slide_num_is_read_signed_and_defaults_to_one() {
784        assert_eq!(parse_pptx(FIXTURE).unwrap().presentation.first_slide_num, 1);
785        assert_eq!(
786            parse_pptx(NUMBERED_FIXTURE)
787                .unwrap()
788                .presentation
789                .first_slide_num,
790            10
791        );
792        for (value, expected) in [
793            ("0", 0),
794            ("-3", -3),
795            ("-2147483648", i32::MIN),
796            ("2147483647", i32::MAX),
797            ("2147483648", 1),
798            ("-2147483649", 1),
799            ("ten", 1),
800            ("", 1),
801        ] {
802            let package = parse_pptx(&demo_deck_numbered_from(value)).unwrap();
803            assert_eq!(
804                package.presentation.first_slide_num, expected,
805                "firstSlideNum={value:?}"
806            );
807        }
808    }
809
810    fn demo_deck_numbered_from(value: &str) -> Vec<u8> {
811        let mut parts = ooxml_opc::unzip_parts(FIXTURE).unwrap();
812        let slot = parts
813            .iter_mut()
814            .find(|(path, _)| path == "ppt/presentation.xml")
815            .unwrap();
816        let xml = String::from_utf8(slot.1.clone()).unwrap();
817        let numbered = xml.replacen(
818            "<p:presentation ",
819            &format!("<p:presentation firstSlideNum=\"{value}\" "),
820            1,
821        );
822        assert_ne!(numbered, xml);
823        slot.1 = numbered.into_bytes();
824        ooxml_opc::rezip_parts(&parts).unwrap()
825    }
826
827    #[test]
828    fn untouched_save_preserves_every_part_byte_and_order() {
829        let package = parse_pptx(FIXTURE).unwrap();
830        let written = write_pptx(&package).unwrap();
831        let before = ooxml_opc::unzip_parts(FIXTURE).unwrap();
832        let after = ooxml_opc::unzip_parts(&written).unwrap();
833        assert_eq!(after, before);
834    }
835
836    #[test]
837    fn loads_every_referenced_chart_part_against_the_deck_theme() {
838        let package = parse_pptx(CHART_FIXTURE).unwrap();
839        assert_eq!(
840            package
841                .charts
842                .iter()
843                .map(|part| part.part_path.as_str())
844                .collect::<Vec<_>>(),
845            ["ppt/charts/chart1.xml", "ppt/charts/chart2.xml"]
846        );
847
848        let column = &package.charts[0].chart;
849        assert_eq!(column.chart_type, "column");
850        assert_eq!(column.title.as_deref(), Some("Revenue"));
851        assert_eq!(
852            column.legend.as_ref().unwrap().position.as_deref(),
853            Some("right")
854        );
855        assert_eq!(column.series[0].color, "#6254E7");
856        assert_eq!(column.series[1].color, "#1FA97A");
857        assert_eq!(column.series[0].values, [12.0, 19.0, 7.0]);
858        assert_eq!(column.plot_groups[0].gap_width, Some(150.0));
859        assert_eq!(
860            column.plot_groups[0].series[0]
861                .data_labels
862                .as_ref()
863                .and_then(|labels| labels.show_value),
864            Some(true)
865        );
866        assert!(column.plot_groups[0].data_labels.is_none());
867        assert!(column.plot_groups[0].show_data_labels);
868        let axes = column.axis_list.as_ref().unwrap();
869        assert_eq!(axes[0].title.as_deref(), Some("Quarter"));
870        assert_eq!(axes[1].title.as_deref(), Some("Millions"));
871        assert_eq!(axes[1].max, Some(25.0));
872
873        // The grouped chart proves the reference walk descends into groups.
874        let pie = &package.charts[1].chart;
875        assert_eq!(pie.chart_type, "pie");
876        let points = pie.plot_groups[0].series[0].points.as_ref().unwrap();
877        assert_eq!(points[0].color, "#E7A954");
878        assert_eq!(points[1].color, "#112233");
879        assert_eq!(points[1].explosion, Some(10.0));
880    }
881
882    /// Three slides sharing `ppt/charts/chart1.xml`, resolving to two themes.
883    struct SharedChartDeck {
884        slides: Vec<Slide>,
885        layouts: Vec<SlideLayout>,
886        masters: Vec<SlideMaster>,
887        themes: Vec<ThemePart>,
888        relationships: BTreeMap<String, Vec<Relationship>>,
889    }
890
891    impl SharedChartDeck {
892        fn sources(&self) -> ChartSources<'_> {
893            ChartSources {
894                slides: &self.slides,
895                layouts: &self.layouts,
896                masters: &self.masters,
897                themes: &self.themes,
898                relationships: &self.relationships,
899            }
900        }
901    }
902
903    fn shared_chart_deck() -> SharedChartDeck {
904        fn chart_shape(id: u32) -> ShapeNode {
905            ShapeNode::GraphicFrame(GraphicFrame {
906                base: ShapeBase {
907                    id,
908                    name: format!("Chart {id}"),
909                    description: None,
910                    hidden: false,
911                    placeholder: None,
912                    transform: ShapeTransform::default(),
913                },
914                data: GraphicFrameData::Chart {
915                    relationship_id: "rIdChart".to_owned(),
916                    part_path: Some("ppt/charts/chart1.xml".to_owned()),
917                },
918            })
919        }
920
921        fn slide(path: &str, layout: &str, id: u32) -> Slide {
922            Slide {
923                part_path: path.to_owned(),
924                name: None,
925                layout_part_path: Some(layout.to_owned()),
926                show_master_shapes: true,
927                background: None,
928                shapes: vec![chart_shape(id)],
929                notes: String::new(),
930            }
931        }
932
933        fn chart_relationships() -> Vec<Relationship> {
934            vec![Relationship {
935                id: "rIdChart".to_owned(),
936                relationship_type: relationship_types::CHART.to_owned(),
937                target: "../charts/chart1.xml".to_owned(),
938                target_mode: Default::default(),
939                resolved_target: Some("ppt/charts/chart1.xml".to_owned()),
940            }]
941        }
942
943        let slides = vec![
944            slide("ppt/slides/slide1.xml", "ppt/slideLayouts/layout1.xml", 1),
945            slide("ppt/slides/slide2.xml", "ppt/slideLayouts/layout2.xml", 2),
946            slide("ppt/slides/slide3.xml", "ppt/slideLayouts/layout1.xml", 3),
947        ];
948        let layouts = vec![
949            SlideLayout {
950                part_path: "ppt/slideLayouts/layout1.xml".to_owned(),
951                name: None,
952                layout_type: None,
953                master_part_path: Some("ppt/slideMasters/master1.xml".to_owned()),
954                show_master_shapes: true,
955                background: None,
956                shapes: Vec::new(),
957            },
958            SlideLayout {
959                part_path: "ppt/slideLayouts/layout2.xml".to_owned(),
960                name: None,
961                layout_type: None,
962                master_part_path: Some("ppt/slideMasters/master2.xml".to_owned()),
963                show_master_shapes: true,
964                background: None,
965                shapes: Vec::new(),
966            },
967        ];
968        let masters = vec![
969            SlideMaster {
970                part_path: "ppt/slideMasters/master1.xml".to_owned(),
971                name: None,
972                theme_part_path: Some("ppt/theme/theme1.xml".to_owned()),
973                layout_part_paths: vec!["ppt/slideLayouts/layout1.xml".to_owned()],
974                background: None,
975                shapes: Vec::new(),
976                text_styles: TextStyleSet::default(),
977            },
978            SlideMaster {
979                part_path: "ppt/slideMasters/master2.xml".to_owned(),
980                name: None,
981                theme_part_path: Some("ppt/theme/theme2.xml".to_owned()),
982                layout_part_paths: vec!["ppt/slideLayouts/layout2.xml".to_owned()],
983                background: None,
984                shapes: Vec::new(),
985                text_styles: TextStyleSet::default(),
986            },
987        ];
988        let mut first_theme = Theme::default();
989        first_theme.color_scheme.accent1 = "112233".to_owned();
990        let mut second_theme = Theme::default();
991        second_theme.color_scheme.accent1 = "AABBCC".to_owned();
992        let themes = vec![
993            ThemePart {
994                part_path: "ppt/theme/theme1.xml".to_owned(),
995                theme: first_theme,
996                format_scheme: Default::default(),
997            },
998            ThemePart {
999                part_path: "ppt/theme/theme2.xml".to_owned(),
1000                theme: second_theme,
1001                format_scheme: Default::default(),
1002            },
1003        ];
1004        let relationships = BTreeMap::from([
1005            ("ppt/slides/slide1.xml".to_owned(), chart_relationships()),
1006            ("ppt/slides/slide2.xml".to_owned(), chart_relationships()),
1007            ("ppt/slides/slide3.xml".to_owned(), chart_relationships()),
1008        ]);
1009        SharedChartDeck {
1010            slides,
1011            layouts,
1012            masters,
1013            themes,
1014            relationships,
1015        }
1016    }
1017
1018    #[test]
1019    fn a_shared_chart_is_resolved_once_per_referencing_theme() {
1020        let deck = shared_chart_deck();
1021        let chart_xml = br#"<c:chartSpace xmlns:c="c" xmlns:a="a"><c:chart><c:plotArea><c:barChart><c:barDir val="col"/><c:ser><c:spPr><a:solidFill><a:schemeClr val="accent1"/></a:solidFill></c:spPr></c:ser></c:barChart></c:plotArea></c:chart></c:chartSpace>"#;
1022        let parts: HashMap<&str, &[u8]> =
1023            HashMap::from([("ppt/charts/chart1.xml", chart_xml.as_slice())]);
1024
1025        let charts = parse_chart_parts(&parts, &deck.sources(), &ParseLimits::default());
1026
1027        assert_eq!(charts.len(), 2);
1028        assert_eq!(
1029            charts
1030                .iter()
1031                .map(|chart| chart.theme_part_path.as_deref())
1032                .collect::<Vec<_>>(),
1033            [Some("ppt/theme/theme1.xml"), Some("ppt/theme/theme2.xml")]
1034        );
1035        assert_eq!(charts[0].chart.series[0].color, "#112233");
1036        assert_eq!(charts[1].chart.series[0].color, "#AABBCC");
1037    }
1038
1039    /// A chart part parses to the same tree whatever theme it is coloured
1040    /// against, so it is read once: an event pool only one read fits still
1041    /// resolves the chart against both themes.
1042    #[test]
1043    fn a_shared_chart_part_is_read_once_however_many_themes_reference_it() {
1044        let deck = shared_chart_deck();
1045        let chart_xml = cached_chart(1_000);
1046        let parts: HashMap<&str, &[u8]> =
1047            HashMap::from([("ppt/charts/chart1.xml", chart_xml.as_slice())]);
1048        let limits = ParseLimits {
1049            max_xml_events: 6_000,
1050            ..ParseLimits::default()
1051        };
1052
1053        let charts = parse_chart_parts(&parts, &deck.sources(), &limits);
1054
1055        assert_eq!(charts.len(), 2);
1056        assert!(
1057            charts
1058                .iter()
1059                .all(|part| part.chart.series[0].values.len() == 1_000)
1060        );
1061    }
1062
1063    #[test]
1064    fn a_deck_without_charts_loads_no_chart_parts() {
1065        assert!(parse_pptx(FIXTURE).unwrap().charts.is_empty());
1066    }
1067
1068    /// `chart-deck.pptx` with `part`'s bytes replaced.
1069    fn chart_deck_with(part: &str, bytes: &[u8]) -> Vec<u8> {
1070        let mut parts = ooxml_opc::unzip_parts(CHART_FIXTURE).unwrap();
1071        let slot = parts
1072            .iter_mut()
1073            .find(|(path, _)| path == part)
1074            .unwrap_or_else(|| panic!("{part} is not in the fixture"));
1075        slot.1 = bytes.to_vec();
1076        ooxml_opc::rezip_parts(&parts).unwrap()
1077    }
1078
1079    /// Opens a deck whose first chart part carries `bytes` and asserts the deck
1080    /// is whole apart from that one chart.
1081    fn assert_damaged_chart_is_dropped(bytes: &[u8]) {
1082        let package = parse_pptx(&chart_deck_with(CHART_PART, bytes)).unwrap();
1083        assert_eq!(package.slides.len(), 2);
1084        assert_eq!(package.layouts.len(), 1);
1085        assert_eq!(package.masters.len(), 1);
1086        assert_eq!(package.themes.len(), 1);
1087        assert_eq!(
1088            package
1089                .slides
1090                .iter()
1091                .map(|slide| slide.name.as_deref())
1092                .collect::<Vec<_>>(),
1093            [Some("Charts"), Some("Grouped")]
1094        );
1095        assert_eq!(
1096            package.slides[0]
1097                .shapes
1098                .iter()
1099                .filter(|shape| matches!(shape, ShapeNode::GraphicFrame(_)))
1100                .count(),
1101            2
1102        );
1103        assert_eq!(
1104            package
1105                .charts
1106                .iter()
1107                .map(|part| part.part_path.as_str())
1108                .collect::<Vec<_>>(),
1109            ["ppt/charts/chart2.xml"]
1110        );
1111    }
1112
1113    #[test]
1114    fn a_malformed_chart_part_drops_only_that_chart() {
1115        assert_damaged_chart_is_dropped(b"<c:chartSpace><c:chart></c:chartSpace>");
1116    }
1117
1118    #[test]
1119    fn an_empty_chart_part_drops_only_that_chart() {
1120        assert_damaged_chart_is_dropped(b"");
1121    }
1122
1123    #[test]
1124    fn a_doctype_bearing_chart_part_drops_only_that_chart() {
1125        assert_damaged_chart_is_dropped(
1126            br#"<?xml version="1.0"?><!DOCTYPE c:chartSpace [<!ENTITY x "y">]><c:chartSpace xmlns:c="c"/>"#,
1127        );
1128    }
1129
1130    #[test]
1131    fn a_truncated_chart_part_drops_only_that_chart() {
1132        let source = ooxml_opc::unzip_parts(CHART_FIXTURE)
1133            .unwrap()
1134            .into_iter()
1135            .find(|(path, _)| path == CHART_PART)
1136            .map(|(_, bytes)| bytes)
1137            .unwrap();
1138        assert_damaged_chart_is_dropped(&source[..source.len() / 2]);
1139    }
1140
1141    #[test]
1142    fn a_binary_chart_part_drops_only_that_chart() {
1143        assert_damaged_chart_is_dropped(&[0x00, 0xff, 0xfe, 0x01, 0x80, 0x7f]);
1144    }
1145
1146    #[test]
1147    fn an_undeclared_entity_in_a_chart_part_drops_only_that_chart() {
1148        assert_damaged_chart_is_dropped(
1149            br#"<c:chartSpace xmlns:c="c"><c:chart><c:title>&nbsp;</c:title></c:chart></c:chartSpace>"#,
1150        );
1151    }
1152
1153    /// Declining to read a chart part must not stop the package from carrying
1154    /// it: an untouched save still writes the source bytes back.
1155    #[test]
1156    fn an_unreadable_chart_part_survives_a_save_byte_for_byte() {
1157        const DAMAGED: &[u8] = b"<c:chartSpace><c:chart></c:chartSpace>";
1158        let deck = chart_deck_with(CHART_PART, DAMAGED);
1159        let written = write_pptx(&parse_pptx(&deck).unwrap()).unwrap();
1160        assert_eq!(
1161            ooxml_opc::unzip_parts(&written).unwrap(),
1162            ooxml_opc::unzip_parts(&deck).unwrap()
1163        );
1164        assert_eq!(
1165            ooxml_opc::unzip_parts(&written)
1166                .unwrap()
1167                .into_iter()
1168                .find(|(path, _)| path == CHART_PART)
1169                .map(|(_, bytes)| bytes),
1170            Some(DAMAGED.to_vec())
1171        );
1172    }
1173
1174    /// A chart part whose only fault is a large cache. Every point costs the
1175    /// reader five events.
1176    fn cached_chart(points: usize) -> Vec<u8> {
1177        let mut xml = String::from(
1178            r#"<c:chartSpace xmlns:c="c" xmlns:a="a"><c:chart><c:plotArea><c:barChart><c:barDir val="col"/><c:ser><c:val><c:numRef><c:numCache>"#,
1179        );
1180        for point in 0..points {
1181            xml.push_str(&format!("<c:pt><c:v>{point}</c:v></c:pt>"));
1182        }
1183        xml.push_str("</c:numCache></c:numRef></c:val></c:ser></c:barChart></c:plotArea></c:chart></c:chartSpace>");
1184        xml.into_bytes()
1185    }
1186
1187    /// `chart-deck.pptx` with charts of `points` cached values and `shapes`
1188    /// more shapes on the second slide, so charts and slides both cost real
1189    /// events.
1190    fn cached_chart_deck(points: usize, shapes: usize) -> Vec<u8> {
1191        let crowd = (0..shapes)
1192            .map(|shape| {
1193                format!(
1194                    r#"<p:sp><p:nvSpPr><p:cNvPr id="{}" name=""/><p:cNvSpPr/><p:nvPr/></p:nvSpPr><p:spPr/></p:sp>"#,
1195                    shape + 100
1196                )
1197            })
1198            .collect::<String>();
1199        let mut parts = ooxml_opc::unzip_parts(CHART_FIXTURE).unwrap();
1200        for (path, bytes) in &mut parts {
1201            if path.starts_with("ppt/charts/") {
1202                *bytes = cached_chart(points);
1203            }
1204            if path == "ppt/slides/slide2.xml" {
1205                *bytes = String::from_utf8(bytes.clone())
1206                    .unwrap()
1207                    .replace("</p:spTree>", &format!("{crowd}</p:spTree>"))
1208                    .into_bytes();
1209            }
1210        }
1211        ooxml_opc::rezip_parts(&parts).unwrap()
1212    }
1213
1214    /// The undamaged deck that used to be refused: spec-valid charts whose
1215    /// caches, added to what the slides cost, outgrow one budget. The charts
1216    /// draw from a pool of their own, so slides and charts both come through.
1217    #[test]
1218    fn a_deck_whose_charts_outgrow_the_slides_budget_opens_with_every_chart() {
1219        let deck = cached_chart_deck(2_000, 1_500);
1220        let limits = ParseLimits {
1221            max_xml_events: 30_000,
1222            ..ParseLimits::default()
1223        };
1224
1225        let package = parse_pptx_with_limits(&deck, &limits).unwrap();
1226
1227        assert_eq!(package.slides.len(), 2);
1228        assert_eq!(
1229            package
1230                .slides
1231                .iter()
1232                .map(|slide| slide.name.as_deref())
1233                .collect::<Vec<_>>(),
1234            [Some("Charts"), Some("Grouped")]
1235        );
1236        assert_eq!(package.slides[1].shapes.len(), 1_501);
1237        assert_eq!(package.charts.len(), 2);
1238        assert!(
1239            package
1240                .charts
1241                .iter()
1242                .all(|part| part.chart.series[0].values.len() == 2_000)
1243        );
1244    }
1245
1246    /// The pool the charts share is one budget for the whole deck, so a chart
1247    /// that empties it leaves the next one declined like a damaged part — and
1248    /// the deck still opens.
1249    #[test]
1250    fn a_deck_whose_charts_outgrow_the_chart_budget_opens_with_the_charts_that_fit() {
1251        let deck = cached_chart_deck(4_000, 1_500);
1252        let limits = ParseLimits {
1253            max_xml_events: 30_000,
1254            ..ParseLimits::default()
1255        };
1256
1257        let package = parse_pptx_with_limits(&deck, &limits).unwrap();
1258
1259        assert_eq!(package.slides.len(), 2);
1260        assert_eq!(package.slides[1].shapes.len(), 1_501);
1261        assert_eq!(
1262            package.slides[0]
1263                .shapes
1264                .iter()
1265                .filter(|shape| matches!(shape, ShapeNode::GraphicFrame(_)))
1266                .count(),
1267            2
1268        );
1269        assert_eq!(
1270            package
1271                .charts
1272                .iter()
1273                .map(|part| part.part_path.as_str())
1274                .collect::<Vec<_>>(),
1275            ["ppt/charts/chart1.xml"]
1276        );
1277        assert_eq!(package.charts[0].chart.series[0].values.len(), 4_000);
1278    }
1279
1280    #[test]
1281    fn package_limits_apply_across_xml_parts() {
1282        let limits = ParseLimits {
1283            max_xml_bytes: 100,
1284            ..ParseLimits::default()
1285        };
1286        assert!(matches!(
1287            parse_pptx_with_limits(FIXTURE, &limits),
1288            Err(PptxError::ResourceLimit {
1289                kind: "xmlBytes",
1290                ..
1291            })
1292        ));
1293    }
1294}