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