Skip to main content

pptx_parse/
package.rs

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