Skip to main content

pptx_parse/
package.rs

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