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
282struct 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
359fn 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
439fn 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
460fn 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| ¶graph.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 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 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 #[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 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 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> </c:title></c:chart></c:chartSpace>"#,
949 );
950 }
951
952 #[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 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 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 #[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 #[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}