1use std::collections::{BTreeMap, HashMap, HashSet};
2
3use ooxml_drawingml::{ColorMap, TableStyleList, Theme};
4
5use crate::chart::parse_chart_part;
6use crate::comments::{
7 Comment, CommentAuthor, CommentFlavor, authors_part, parse_comment_authors, parse_comments,
8 slide_comment_parts,
9};
10use crate::drawing::{
11 BasicLayout, DIAGRAM_EXTENT_EMU, classify_layout, common_slide_data,
12 common_slide_data_with_vml, layout_basic_diagram, normalize_text_columns,
13 parse_diagram_drawing, parse_paragraph_properties, parse_style_levels, parse_text_styles,
14};
15use crate::model::*;
16use crate::relationships::{Relationship, parse_relationships, relationship_types};
17use crate::table_style::parse_table_styles;
18use crate::theme::{parse_background_pictures, parse_format_scheme, parse_theme};
19use crate::xml::{PRESENTATIONML_NS, ParseBudget, XmlElement, parse_xml};
20use crate::{ParseLimits, PptxError};
21
22fn column_master<'a>(
23 masters: &'a [SlideMaster],
24 layout: Option<&SlideLayout>,
25) -> Option<&'a SlideMaster> {
26 layout
27 .and_then(|layout| layout.master_part_path.as_deref())
28 .and_then(|path| masters.iter().find(|master| master.part_path == path))
29 .or_else(|| {
30 layout.and_then(|layout| {
31 masters
32 .iter()
33 .find(|master| master.layout_part_paths.contains(&layout.part_path))
34 })
35 })
36 .or_else(|| masters.first())
37}
38
39fn placeholder_columns(nodes: &[ShapeNode], target: &Placeholder) -> Option<u8> {
40 for node in nodes {
41 match node {
42 ShapeNode::Shape(shape)
43 if shape
44 .base
45 .placeholder
46 .as_ref()
47 .is_some_and(|value| value.matches(target)) =>
48 {
49 return shape.text.as_ref().and_then(|body| body.num_col);
50 }
51 ShapeNode::Group(group) => {
52 if let Some(columns) = placeholder_columns(&group.children, target) {
53 return Some(columns);
54 }
55 }
56 _ => {}
57 }
58 }
59 None
60}
61
62fn normalize_shape_columns(nodes: &mut [ShapeNode], layout: &[ShapeNode], master: &[ShapeNode]) {
63 for node in nodes {
64 match node {
65 ShapeNode::Shape(shape) => {
66 let inherited_columns = shape
67 .base
68 .placeholder
69 .as_ref()
70 .and_then(|placeholder| {
71 placeholder_columns(layout, placeholder)
72 .or_else(|| placeholder_columns(master, placeholder))
73 })
74 .unwrap_or(1);
75 if let Some(body) = &mut shape.text {
76 normalize_text_columns(body, inherited_columns);
77 }
78 }
79 ShapeNode::Group(group) => normalize_shape_columns(&mut group.children, layout, master),
80 _ => {}
81 }
82 }
83}
84
85pub fn parse_pptx(data: &[u8]) -> Result<PptxPackage, PptxError> {
86 parse_pptx_with_limits(data, &ParseLimits::default())
87}
88
89pub fn parse_pptx_with_limits(data: &[u8], limits: &ParseLimits) -> Result<PptxPackage, PptxError> {
90 parse_package(data, limits, ShapeElements::WithConnectors)
91}
92
93pub fn parse_pptx_without_connectors(data: &[u8]) -> Result<PptxPackage, PptxError> {
95 parse_package(
96 data,
97 &ParseLimits::default(),
98 ShapeElements::WithoutConnectors,
99 )
100}
101
102fn parse_package(
103 data: &[u8],
104 limits: &ParseLimits,
105 shape_elements: ShapeElements,
106) -> Result<PptxPackage, PptxError> {
107 let source_parts = ooxml_opc::unzip_parts(data).map_err(PptxError::Container)?;
108 let parts: HashMap<&str, &[u8]> = source_parts
109 .iter()
110 .map(|(path, bytes)| (path.as_str(), bytes.as_slice()))
111 .collect();
112 let mut budget = ParseBudget::new(limits);
113 let relationships = parse_package_relationships(&source_parts, &mut budget)?;
114 let presentation_path = relationships
115 .get("")
116 .and_then(|entries| {
117 entries
118 .iter()
119 .find(|relationship| relationship.has_type("/officeDocument"))
120 })
121 .and_then(|relationship| relationship.resolved_target.clone())
122 .unwrap_or_else(|| "ppt/presentation.xml".to_owned());
123 let presentation_bytes =
124 parts
125 .get(presentation_path.as_str())
126 .ok_or_else(|| PptxError::NotAPresentation {
127 message: format!("main presentation part is missing: {presentation_path}"),
128 })?;
129 let presentation_root = parse_xml(presentation_bytes, &presentation_path, &mut budget)?;
130 check_presentation_root(&presentation_root, &presentation_path)?;
131 let presentation_relationships = relationships
132 .get(&presentation_path)
133 .map(Vec::as_slice)
134 .unwrap_or_default();
135 let presentation = parse_presentation(
136 &presentation_root,
137 &presentation_path,
138 presentation_relationships,
139 )?;
140
141 let mut has_connectors = false;
142 let mut vml_budget = ParseBudget::new(limits);
143 let mut slides = Vec::with_capacity(presentation.slides.len());
144 let mut sources = BTreeMap::new();
145 let mut notes_sources = BTreeMap::new();
146 for reference in &presentation.slides {
147 let root = parse_part(&parts, &reference.part_path, &mut budget)?;
148 let slide_relationships = relationships
149 .get(&reference.part_path)
150 .map(Vec::as_slice)
151 .unwrap_or_default();
152 has_connectors |= !root.descendants_named("cxnSp").is_empty();
153 let previews = parse_vml_previews(
154 &root,
155 slide_relationships,
156 &parts,
157 &relationships,
158 &mut vml_budget,
159 );
160 let data = common_slide_data_with_vml(
161 &root,
162 slide_relationships,
163 &reference.part_path,
164 &mut budget,
165 shape_elements,
166 &previews,
167 )?;
168 let mut notes = String::new();
169 if let Some(notes_path) = crate::notes::slide_notes_part(slide_relationships)
170 && let Some(bytes) = parts.get(notes_path.as_str())
171 {
172 let notes_root = parse_xml(bytes, ¬es_path, &mut budget)?;
173 notes = crate::notes::notes_text(¬es_root);
174 notes_sources.insert(
175 reference.part_path.clone(),
176 crate::inventory::notes_source(¬es_root, ¬es_path),
177 );
178 }
179 sources.insert(
180 reference.part_path.clone(),
181 crate::inventory::slide_source(&root, slide_relationships, shape_elements),
182 );
183 slides.push(Slide {
184 part_path: reference.part_path.clone(),
185 name: data.name,
186 layout_part_path: relationship_by_type(
187 slide_relationships,
188 relationship_types::SLIDE_LAYOUT,
189 ),
190 show_master_shapes: bool_attribute(&root, "showMasterSp", true),
191 background: data.background,
192 background_picture: data.background_picture,
193 background_reference: data.background_reference,
194 declares_background: root
195 .child("cSld")
196 .is_some_and(|common| common.child("bg").is_some()),
197 shapes: data.shapes,
198 color_map_override: parse_color_map_override(&root),
199 notes,
200 hidden: Some(!bool_attribute(&root, "show", true)),
201 });
202 }
203
204 let master_paths = ordered_part_paths(
205 presentation.master_part_paths.clone(),
206 &source_parts,
207 "ppt/slideMasters/",
208 );
209 let mut masters = Vec::with_capacity(master_paths.len());
210 for part_path in &master_paths {
211 let root = parse_part(&parts, part_path, &mut budget)?;
212 let master_relationships = relationships
213 .get(part_path)
214 .map(Vec::as_slice)
215 .unwrap_or_default();
216 has_connectors |= !root.descendants_named("cxnSp").is_empty();
217 let data = common_slide_data(
218 &root,
219 master_relationships,
220 part_path,
221 &mut budget,
222 shape_elements,
223 )?;
224 masters.push(SlideMaster {
225 part_path: part_path.clone(),
226 name: data.name,
227 theme_part_path: relationship_by_type(master_relationships, relationship_types::THEME),
228 layout_part_paths: master_relationships
229 .iter()
230 .filter(|relationship| relationship.has_type(relationship_types::SLIDE_LAYOUT))
231 .filter_map(|relationship| relationship.resolved_target.clone())
232 .collect(),
233 background: data.background,
234 background_picture: data.background_picture,
235 background_reference: data.background_reference,
236 shapes: data.shapes,
237 text_styles: parse_text_styles(&root),
238 color_map: parse_color_map(&root),
239 });
240 }
241
242 let layout_paths = ordered_part_paths(
243 masters
244 .iter()
245 .flat_map(|master| master.layout_part_paths.iter().cloned())
246 .collect(),
247 &source_parts,
248 "ppt/slideLayouts/",
249 );
250 let mut layouts = Vec::with_capacity(layout_paths.len());
251 for part_path in &layout_paths {
252 let root = parse_part(&parts, part_path, &mut budget)?;
253 let layout_relationships = relationships
254 .get(part_path)
255 .map(Vec::as_slice)
256 .unwrap_or_default();
257 has_connectors |= !root.descendants_named("cxnSp").is_empty();
258 let data = common_slide_data(
259 &root,
260 layout_relationships,
261 part_path,
262 &mut budget,
263 shape_elements,
264 )?;
265 layouts.push(SlideLayout {
266 part_path: part_path.clone(),
267 name: root
268 .attribute("matchingName")
269 .map(str::to_owned)
270 .or(data.name),
271 layout_type: root.attribute("type").map(str::to_owned),
272 master_part_path: relationship_by_type(
273 layout_relationships,
274 relationship_types::SLIDE_MASTER,
275 ),
276 show_master_shapes: bool_attribute(&root, "showMasterSp", true),
277 background: data.background,
278 background_picture: data.background_picture,
279 background_reference: data.background_reference,
280 shapes: data.shapes,
281 color_map_override: parse_color_map_override(&root),
282 });
283 }
284
285 let theme_paths = ordered_part_paths(
286 masters
287 .iter()
288 .filter_map(|master| master.theme_part_path.clone())
289 .collect(),
290 &source_parts,
291 "ppt/theme/",
292 );
293 let mut themes = Vec::with_capacity(theme_paths.len());
294 for part_path in theme_paths {
295 let root = parse_part(&parts, &part_path, &mut budget)?;
296 let theme_relationships = relationships
297 .get(&part_path)
298 .map(Vec::as_slice)
299 .unwrap_or_default();
300 themes.push(ThemePart {
301 theme: parse_theme(&root),
302 format_scheme: parse_format_scheme(&root),
303 background_pictures: parse_background_pictures(&root, theme_relationships),
304 part_path,
305 });
306 }
307
308 let table_styles = parse_table_style_part(&parts, presentation_relationships, &mut budget)?;
309
310 let charts = parse_chart_parts(
311 &parts,
312 &ChartSources {
313 slides: &slides,
314 layouts: &layouts,
315 masters: &masters,
316 themes: &themes,
317 relationships: &relationships,
318 },
319 limits,
320 );
321
322 let deck_comments = parse_package_comments(
323 &parts,
324 &presentation,
325 presentation_relationships,
326 &relationships,
327 &mut budget,
328 )?;
329
330 let content_types = parse_content_types(&parts, &mut budget)?;
331 let mut diagram_drawings =
332 parse_diagram_drawings(&parts, &slides, &relationships, &mut budget, shape_elements);
333 let media = source_parts
334 .iter()
335 .filter(|(path, _)| path.starts_with("ppt/media/"))
336 .map(|(path, bytes)| MediaPart {
337 part_path: path.clone(),
338 content_type: content_type_for(path, &content_types),
339 bytes: bytes.clone(),
340 })
341 .collect();
342 for slide in &mut slides {
343 let layout = slide
344 .layout_part_path
345 .as_deref()
346 .and_then(|path| layouts.iter().find(|layout| layout.part_path == path))
347 .or_else(|| layouts.first());
348 let master = column_master(&masters, layout);
349 normalize_shape_columns(
350 &mut slide.shapes,
351 layout.map_or(&[], |layout| layout.shapes.as_slice()),
352 master.map_or(&[], |master| master.shapes.as_slice()),
353 );
354 }
355 for layout in &mut layouts {
356 let master = column_master(&masters, Some(layout));
357 normalize_shape_columns(
358 &mut layout.shapes,
359 &[],
360 master.map_or(&[], |master| master.shapes.as_slice()),
361 );
362 }
363 for master in &mut masters {
364 normalize_shape_columns(&mut master.shapes, &[], &[]);
365 }
366 for drawing in &mut diagram_drawings {
367 normalize_shape_columns(&mut drawing.shapes, &[], &[]);
368 }
369 let parts = source_parts
370 .into_iter()
371 .map(|(path, bytes)| PackagePart { path, bytes })
372 .collect();
373 Ok(PptxPackage {
374 presentation,
375 slides,
376 layouts,
377 masters,
378 themes,
379 charts,
380 diagram_drawings,
381 media,
382 table_styles,
383 comment_authors: deck_comments.authors,
384 comments: deck_comments.comments,
385 comment_flavor: deck_comments.flavor,
386 relationships,
387 parts,
388 source_container: ooxml_opc::SourceContainer::new(data.to_vec()),
389 shape_elements: if has_connectors {
390 shape_elements
391 } else {
392 ShapeElements::WithoutConnectors
393 },
394 sources,
395 notes_sources,
396 })
397}
398
399fn parse_package_comments(
400 parts: &HashMap<&str, &[u8]>,
401 presentation: &Presentation,
402 presentation_relationships: &[Relationship],
403 relationships: &BTreeMap<String, Vec<Relationship>>,
404 budget: &mut ParseBudget<'_>,
405) -> Result<PackageComments, PptxError> {
406 let mut found: Vec<(CommentFlavor, String, String)> = Vec::new();
407 for reference in &presentation.slides {
408 let slide_relationships = relationships
409 .get(&reference.part_path)
410 .map(Vec::as_slice)
411 .unwrap_or_default();
412 let slide_parts = slide_comment_parts(slide_relationships);
413 if let Some(part) = slide_parts.legacy {
414 found.push((CommentFlavor::Legacy, reference.part_path.clone(), part));
415 }
416 if let Some(part) = slide_parts.modern {
417 found.push((CommentFlavor::Modern, reference.part_path.clone(), part));
418 }
419 }
420 let Some(flavor) = found
421 .iter()
422 .any(|(flavor, _, _)| *flavor == CommentFlavor::Legacy)
423 .then_some(CommentFlavor::Legacy)
424 .or_else(|| (!found.is_empty()).then_some(CommentFlavor::Modern))
425 else {
426 return Ok(PackageComments::default());
427 };
428
429 let mut comments = Vec::new();
430 for (_, slide_part_path, part) in found
431 .iter()
432 .filter(|(candidate, _, _)| *candidate == flavor)
433 {
434 let Some(bytes) = parts.get(part.as_str()) else {
435 continue;
436 };
437 comments.extend(parse_comments(
438 bytes,
439 part,
440 slide_part_path,
441 flavor,
442 budget,
443 )?);
444 }
445
446 let comment_authors = match authors_part(presentation_relationships, flavor)
447 .and_then(|part| parts.get(part.as_str()).map(|bytes| (part, *bytes)))
448 {
449 Some((part, bytes)) => parse_comment_authors(bytes, &part, flavor, budget)?,
450 None => Vec::new(),
451 };
452 Ok(PackageComments {
453 authors: comment_authors,
454 comments,
455 flavor: Some(flavor),
456 })
457}
458
459#[derive(Default)]
460struct PackageComments {
461 authors: Vec<CommentAuthor>,
462 comments: Vec<Comment>,
463 flavor: Option<CommentFlavor>,
464}
465
466pub fn write_pptx(package: &PptxPackage) -> Result<Vec<u8>, PptxError> {
467 let parts = package
468 .parts
469 .iter()
470 .map(|part| (part.path.clone(), part.bytes.clone()))
471 .collect::<Vec<_>>();
472 ooxml_opc::rezip_parts_preserving(&parts, package.source_container.as_bytes())
473 .map_err(PptxError::Container)
474}
475
476fn parse_package_relationships(
477 parts: &[(String, Vec<u8>)],
478 budget: &mut ParseBudget<'_>,
479) -> Result<BTreeMap<String, Vec<Relationship>>, PptxError> {
480 let mut output = BTreeMap::new();
481 for (path, bytes) in parts {
482 let Some(source) = relationship_source(path) else {
483 continue;
484 };
485 let parsed = parse_relationships(bytes, path, &source, budget)?;
486 output.insert(source, parsed);
487 }
488 Ok(output)
489}
490
491fn relationship_source(path: &str) -> Option<String> {
492 if path == "_rels/.rels" {
493 return Some(String::new());
494 }
495 let (directory, filename) = path.rsplit_once("/_rels/")?;
496 let source_filename = filename.strip_suffix(".rels")?;
497 Some(format!("{directory}/{source_filename}"))
498}
499
500const STRICT_PRESENTATIONML_NS: &str = "http://purl.oclc.org/ooxml/presentationml/main";
501
502fn check_presentation_root(root: &XmlElement, path: &str) -> Result<(), PptxError> {
503 if root.local_name() != "presentation" {
504 return Err(PptxError::NotAPresentation {
505 message: format!("invalid main root in {path}: {}", root.name),
506 });
507 }
508 let declaration = match root.name.split_once(':') {
509 Some((prefix, _)) => format!("xmlns:{prefix}"),
510 None => "xmlns".to_owned(),
511 };
512 let namespace = root.attribute(&declaration);
513 if !matches!(
514 namespace,
515 Some(PRESENTATIONML_NS | STRICT_PRESENTATIONML_NS)
516 ) {
517 return Err(PptxError::NotAPresentation {
518 message: format!(
519 "unsupported main namespace in {path}: {}",
520 namespace.unwrap_or("(missing)")
521 ),
522 });
523 }
524 Ok(())
525}
526
527fn parse_part(
528 parts: &HashMap<&str, &[u8]>,
529 path: &str,
530 budget: &mut ParseBudget<'_>,
531) -> Result<XmlElement, PptxError> {
532 let bytes = parts
533 .get(path)
534 .ok_or_else(|| PptxError::MissingPart(path.to_owned()))?;
535 parse_xml(bytes, path, budget)
536}
537
538fn parse_vml_previews(
539 slide: &XmlElement,
540 slide_relationships: &[Relationship],
541 parts: &HashMap<&str, &[u8]>,
542 relationships: &BTreeMap<String, Vec<Relationship>>,
543 budget: &mut ParseBudget<'_>,
544) -> BTreeMap<String, PictureFill> {
545 let wanted: HashSet<_> = slide
546 .descendants_named("oleObj")
547 .into_iter()
548 .filter(|object| object.child("pic").is_none())
549 .filter_map(|object| object.attribute("spid"))
550 .filter(|spid| !spid.is_empty())
551 .collect();
552 let mut previews = BTreeMap::new();
553 if wanted.is_empty() {
554 return previews;
555 }
556 for relationship in slide_relationships
557 .iter()
558 .filter(|relationship| relationship.has_type("/vmlDrawing"))
559 {
560 let Some(path) = relationship.resolved_target.as_deref() else {
561 continue;
562 };
563 let Some(bytes) = parts.get(path) else {
564 continue;
565 };
566 let Ok(root) = parse_xml(bytes, path, budget) else {
567 continue;
568 };
569 let vml_relationships = relationships
570 .get(path)
571 .map(Vec::as_slice)
572 .unwrap_or_default();
573 for shape in root.descendants_named("shape") {
574 let spids = [shape.attribute("id"), shape.attribute("o:spid")];
575 if !spids.iter().flatten().any(|spid| wanted.contains(spid)) {
576 continue;
577 }
578 let Some(image) = shape.child("imagedata") else {
579 continue;
580 };
581 let Some(id) = image
582 .attribute("o:relid")
583 .or_else(|| image.attribute("r:id"))
584 else {
585 continue;
586 };
587 let Some(media_path) = vml_relationships
588 .iter()
589 .find(|relationship| relationship.id == id)
590 .and_then(|relationship| relationship.resolved_target.as_deref())
591 .filter(|path| path.starts_with("ppt/media/") && parts.contains_key(path))
592 else {
593 continue;
594 };
595 for spid in spids
596 .into_iter()
597 .flatten()
598 .filter(|spid| wanted.contains(spid))
599 {
600 previews
601 .entry(spid.to_owned())
602 .or_insert_with(|| PictureFill {
603 media_part_path: Some(media_path.to_owned()),
604 ..PictureFill::default()
605 });
606 }
607 }
608 }
609 previews
610}
611
612fn parse_table_style_part(
613 parts: &HashMap<&str, &[u8]>,
614 presentation_relationships: &[Relationship],
615 budget: &mut ParseBudget<'_>,
616) -> Result<TableStyleList, PptxError> {
617 let path = relationship_by_type(presentation_relationships, relationship_types::TABLE_STYLES)
618 .unwrap_or_else(|| "ppt/tableStyles.xml".to_owned());
619 let Some(bytes) = parts.get(path.as_str()) else {
620 return Ok(TableStyleList::default());
621 };
622 Ok(parse_table_styles(&parse_xml(bytes, &path, budget)?))
623}
624
625fn parse_presentation(
626 root: &XmlElement,
627 part_path: &str,
628 relationships: &[Relationship],
629) -> Result<Presentation, PptxError> {
630 let slide_size = root.child("sldSz");
631 let width_emu = positive_integer_attribute(slide_size, "cx").unwrap_or(12_192_000);
632 let height_emu = positive_integer_attribute(slide_size, "cy").unwrap_or(6_858_000);
633 let first_slide_num = root
634 .attribute("firstSlideNum")
635 .and_then(|value| value.parse().ok())
636 .unwrap_or(1);
637 let mut slides = Vec::new();
638 if let Some(list) = root.child("sldIdLst") {
639 for slide in list.children_named("sldId") {
640 let relationship_id = slide
641 .attribute("r:id")
642 .or_else(|| slide.attribute_local("id"))
643 .unwrap_or_default()
644 .to_owned();
645 let part_path =
646 relationship_target(relationships, &relationship_id).ok_or_else(|| {
647 PptxError::MissingPart(format!("slide relationship {relationship_id}"))
648 })?;
649 slides.push(SlideReference {
650 id: slide
651 .attribute("id")
652 .and_then(|value| value.parse().ok())
653 .unwrap_or_default(),
654 relationship_id,
655 part_path,
656 });
657 }
658 }
659 let master_part_paths = root
660 .child("sldMasterIdLst")
661 .into_iter()
662 .flat_map(|list| list.children_named("sldMasterId"))
663 .filter_map(|master| {
664 master
665 .attribute("r:id")
666 .or_else(|| master.attribute_local("id"))
667 .and_then(|id| relationship_target(relationships, id))
668 })
669 .collect();
670 Ok(Presentation {
671 part_path: part_path.to_owned(),
672 width_emu,
673 height_emu,
674 first_slide_num,
675 slides,
676 master_part_paths,
677 default_text_style: parse_style_levels(root.child("defaultTextStyle")),
678 default_text_paragraph: root
679 .child("defaultTextStyle")
680 .and_then(|style| style.child("defPPr"))
681 .map(|properties| Box::new(parse_paragraph_properties(Some(properties)))),
682 })
683}
684
685struct ChartSources<'a> {
688 slides: &'a [Slide],
689 layouts: &'a [SlideLayout],
690 masters: &'a [SlideMaster],
691 themes: &'a [ThemePart],
692 relationships: &'a BTreeMap<String, Vec<Relationship>>,
693}
694
695impl ChartSources<'_> {
696 fn chart_target(&self, source_part: &str, relationship_id: &str) -> Option<String> {
697 self.relationships
698 .get(source_part)?
699 .iter()
700 .find(|relationship| relationship.id == relationship_id)
701 .filter(|relationship| relationship.has_type(relationship_types::CHART))
702 .and_then(|relationship| relationship.resolved_target.clone())
703 }
704
705 fn theme_for(&self, source_part: &str) -> Option<&ThemePart> {
706 if let Some(slide) = self
707 .slides
708 .iter()
709 .find(|slide| slide.part_path == source_part)
710 {
711 return self.theme_via_layout(slide.layout_part_path.as_deref());
712 }
713 if self
714 .layouts
715 .iter()
716 .any(|layout| layout.part_path == source_part)
717 {
718 return self.theme_via_layout(Some(source_part));
719 }
720 if self
721 .masters
722 .iter()
723 .any(|master| master.part_path == source_part)
724 {
725 return self.theme_via_master(Some(source_part));
726 }
727 self.themes.first()
728 }
729
730 fn theme_via_layout(&self, layout_part_path: Option<&str>) -> Option<&ThemePart> {
731 let layout = layout_part_path
732 .and_then(|path| self.layouts.iter().find(|layout| layout.part_path == path))
733 .or_else(|| self.layouts.first());
734 let master_part_path = layout
735 .and_then(|layout| layout.master_part_path.as_deref())
736 .or_else(|| {
737 let layout = layout?;
738 self.masters
739 .iter()
740 .find(|master| {
741 master
742 .layout_part_paths
743 .iter()
744 .any(|path| path == &layout.part_path)
745 })
746 .map(|master| master.part_path.as_str())
747 });
748 self.theme_via_master(master_part_path)
749 }
750
751 fn theme_via_master(&self, master_part_path: Option<&str>) -> Option<&ThemePart> {
752 let master = master_part_path
753 .and_then(|path| self.masters.iter().find(|master| master.part_path == path))
754 .or_else(|| self.masters.first());
755 master
756 .and_then(|master| master.theme_part_path.as_deref())
757 .and_then(|path| self.themes.iter().find(|theme| theme.part_path == path))
758 .or_else(|| self.themes.first())
759 }
760}
761
762fn parse_chart_parts(
779 parts: &HashMap<&str, &[u8]>,
780 sources: &ChartSources<'_>,
781 limits: &ParseLimits,
782) -> Vec<ChartPart> {
783 let mut references = Vec::new();
784 for slide in sources.slides {
785 collect_chart_references(&slide.shapes, &slide.part_path, &mut references);
786 }
787 for layout in sources.layouts {
788 collect_chart_references(&layout.shapes, &layout.part_path, &mut references);
789 }
790 for master in sources.masters {
791 collect_chart_references(&master.shapes, &master.part_path, &mut references);
792 }
793 let default_theme = Theme::default();
794 let mut reads = Vec::new();
795 let mut loaded = HashSet::new();
796 let mut pending_reads: HashMap<String, usize> = HashMap::new();
797 for (source_part, relationship_id) in references {
798 let Some(part_path) = sources.chart_target(&source_part, &relationship_id) else {
799 continue;
800 };
801 let theme_part = sources.theme_for(&source_part);
802 let theme_part_path = theme_part.map(|part| part.part_path.clone());
803 if !loaded.insert((part_path.clone(), theme_part_path.clone())) {
804 continue;
805 }
806 *pending_reads.entry(part_path.clone()).or_default() += 1;
807 let theme = theme_part.map(|part| &part.theme).unwrap_or(&default_theme);
808 reads.push((part_path, theme_part_path, theme));
809 }
810
811 let mut charts = Vec::new();
812 let mut roots: HashMap<String, Option<XmlElement>> = HashMap::new();
813 let mut remaining_events = limits.max_xml_events;
814 for (part_path, theme_part_path, theme) in reads {
815 if !roots.contains_key(&part_path) {
816 let root = parts.get(part_path.as_str()).and_then(|bytes| {
817 read_chart_root(bytes, &part_path, limits, &mut remaining_events)
818 });
819 roots.insert(part_path.clone(), root);
820 }
821 let chart = roots
822 .get(&part_path)
823 .and_then(Option::as_ref)
824 .and_then(|root| parse_chart_part(root, theme));
825 if let Some(chart) = chart {
826 charts.push(ChartPart {
827 part_path: part_path.clone(),
828 theme_part_path,
829 chart,
830 });
831 }
832 if let Some(pending) = pending_reads.get_mut(&part_path) {
833 *pending -= 1;
834 if *pending == 0 {
835 roots.remove(&part_path);
836 }
837 }
838 }
839 charts
840}
841
842fn read_chart_root(
848 bytes: &[u8],
849 part_path: &str,
850 limits: &ParseLimits,
851 remaining_events: &mut usize,
852) -> Option<XmlElement> {
853 let part_limits = ParseLimits {
854 max_xml_events: *remaining_events,
855 ..limits.clone()
856 };
857 let mut budget = ParseBudget::new(&part_limits);
858 let root = parse_xml(bytes, part_path, &mut budget).ok();
859 *remaining_events = remaining_events.saturating_sub(budget.xml_events_spent());
860 root
861}
862
863struct DiagramReference {
864 part_path: String,
865 slide_part_path: String,
866 relationship_ids: Vec<String>,
867}
868
869fn parse_diagram_drawings(
873 parts: &HashMap<&str, &[u8]>,
874 slides: &[Slide],
875 relationships: &BTreeMap<String, Vec<Relationship>>,
876 budget: &mut ParseBudget<'_>,
877 elements: ShapeElements,
878) -> Vec<DiagramDrawing> {
879 let mut wanted: Vec<DiagramReference> = Vec::new();
880 let mut diagram_counts = HashMap::new();
881 for slide in slides {
882 let start = wanted.len();
883 collect_diagram_drawings(&slide.shapes, &slide.part_path, &mut wanted);
884 diagram_counts.insert(slide.part_path.as_str(), wanted.len() - start);
885 }
886 wanted.sort_by(|left, right| left.part_path.cmp(&right.part_path));
887 wanted.dedup_by(|left, right| left.part_path == right.part_path);
888 let mut drawings = Vec::new();
889 let mut layouts = HashMap::new();
890 'references: for reference in wanted {
891 let slide_relationships = relationships
892 .get(&reference.slide_part_path)
893 .map(Vec::as_slice)
894 .unwrap_or_default();
895 let data_path = diagram_relationship_target(
896 slide_relationships,
897 &reference.relationship_ids,
898 "/diagramData",
899 )
900 .or_else(|| sibling_diagram_part(&reference.part_path, "drawing", "data"));
901 let mut data_budget = budget.scratch();
902 let data_root = if let Some(path) = data_path.as_deref()
903 && let Some(bytes) = parts.get(path)
904 {
905 parse_xml(bytes, path, &mut data_budget).ok()
906 } else {
907 None
908 };
909 let mut drawing_relationships = slide_relationships
910 .iter()
911 .filter(|relationship| relationship.has_type("/diagramDrawing"));
912 let related_drawing = drawing_relationships.next().and_then(|first| {
913 if drawing_relationships.next().is_none()
914 && diagram_counts.get(reference.slide_part_path.as_str()) == Some(&1)
915 {
916 return first.resolved_target.clone();
917 }
918 slide_relationships
919 .iter()
920 .find(|relationship| {
921 relationship.has_type("/diagramDrawing")
922 && relationship.resolved_target.as_deref()
923 == Some(reference.part_path.as_str())
924 })
925 .and_then(|relationship| relationship.resolved_target.clone())
926 });
927 let drawing_paths = [
928 data_root
929 .as_ref()
930 .and_then(|root| data_model_drawing_target(root, slide_relationships)),
931 related_drawing,
932 if data_path.as_deref() == Some(reference.part_path.as_str()) {
933 sibling_diagram_part(&reference.part_path, "data", "drawing")
934 } else {
935 Some(reference.part_path.clone())
936 },
937 ];
938 let mut seen = HashSet::new();
939 for path in drawing_paths.into_iter().flatten() {
940 if !seen.insert(path.clone()) {
941 continue;
942 }
943 let Some(bytes) = parts.get(path.as_str()) else {
944 continue;
945 };
946 let Ok(root) = parse_xml(bytes, &path, budget) else {
947 continue;
948 };
949 let part_relationships = relationships
950 .get(&path)
951 .map(Vec::as_slice)
952 .unwrap_or_default();
953 let Ok(shapes) =
954 parse_diagram_drawing(&root, part_relationships, &path, budget, elements)
955 else {
956 continue;
957 };
958 if !shapes.is_empty() {
959 drawings.push(DiagramDrawing {
960 part_path: reference.part_path,
961 synthesis_extent: None,
962 shapes,
963 });
964 continue 'references;
965 }
966 }
967 let (Some(data_path), Some(data_root)) = (data_path, data_root) else {
968 continue;
969 };
970 if budget.charge_xml_from(&data_budget, &data_path).is_err() {
971 continue;
972 }
973 let layout_path = diagram_relationship_target(
974 slide_relationships,
975 &reference.relationship_ids,
976 "/diagramLayout",
977 )
978 .or_else(|| sibling_diagram_part(&data_path, "data", "layout"));
979 let kind = if let Some(path) = layout_path.as_deref()
980 && let Some(bytes) = parts.get(path)
981 {
982 let Some(kind) = *layouts.entry(path.to_owned()).or_insert_with(|| {
983 parse_xml(bytes, path, budget)
984 .ok()
985 .map(|root| classify_layout(&root))
986 }) else {
987 continue;
988 };
989 kind
990 } else {
991 BasicLayout::List
992 };
993 if let Ok(shapes) = layout_basic_diagram(&data_root, kind, &data_path, budget) {
994 drawings.push(DiagramDrawing {
995 part_path: reference.part_path,
996 synthesis_extent: Some([DIAGRAM_EXTENT_EMU; 2]),
997 shapes,
998 });
999 }
1000 }
1001 drawings
1002}
1003
1004fn collect_diagram_drawings(
1005 shapes: &[ShapeNode],
1006 slide_part_path: &str,
1007 output: &mut Vec<DiagramReference>,
1008) {
1009 for shape in shapes {
1010 match shape {
1011 ShapeNode::GraphicFrame(frame) => {
1012 if let GraphicFrameData::Diagram {
1013 drawing_part_path: Some(path),
1014 relationship_ids,
1015 } = &frame.data
1016 {
1017 output.push(DiagramReference {
1018 part_path: path.clone(),
1019 slide_part_path: slide_part_path.to_owned(),
1020 relationship_ids: relationship_ids.clone(),
1021 });
1022 }
1023 }
1024 ShapeNode::Group(group) => {
1025 collect_diagram_drawings(&group.children, slide_part_path, output);
1026 }
1027 ShapeNode::Shape(_) | ShapeNode::Picture(_) => {}
1028 }
1029 }
1030}
1031
1032fn diagram_relationship_target(
1033 relationships: &[Relationship],
1034 relationship_ids: &[String],
1035 type_suffix: &str,
1036) -> Option<String> {
1037 relationship_ids.iter().find_map(|id| {
1038 relationships
1039 .iter()
1040 .find(|relationship| relationship.id == *id && relationship.has_type(type_suffix))
1041 .and_then(|relationship| relationship.resolved_target.clone())
1042 })
1043}
1044
1045fn sibling_diagram_part(path: &str, from_prefix: &str, to_prefix: &str) -> Option<String> {
1046 let (directory, name) = path.rsplit_once('/')?;
1047 let stem = name.strip_prefix(from_prefix)?;
1048 Some(format!("{directory}/{to_prefix}{stem}"))
1049}
1050
1051fn data_model_drawing_target(
1052 data_root: &XmlElement,
1053 relationships: &[Relationship],
1054) -> Option<String> {
1055 let rel_id = data_root
1056 .descendants_named("dataModelExt")
1057 .first()
1058 .and_then(|element| {
1059 element
1060 .attribute("relId")
1061 .or_else(|| element.attribute_local("relId"))
1062 })?;
1063 relationships
1064 .iter()
1065 .find(|relationship| relationship.id == rel_id && relationship.has_type("/diagramDrawing"))
1066 .and_then(|relationship| relationship.resolved_target.clone())
1067}
1068
1069fn collect_chart_references(
1072 shapes: &[ShapeNode],
1073 part_path: &str,
1074 output: &mut Vec<(String, String)>,
1075) {
1076 for shape in shapes {
1077 match shape {
1078 ShapeNode::GraphicFrame(frame) => {
1079 if let GraphicFrameData::Chart {
1080 relationship_id, ..
1081 } = &frame.data
1082 {
1083 output.push((part_path.to_owned(), relationship_id.clone()));
1084 }
1085 }
1086 ShapeNode::Group(group) => collect_chart_references(&group.children, part_path, output),
1087 ShapeNode::Shape(_) | ShapeNode::Picture(_) => {}
1088 }
1089 }
1090}
1091
1092#[derive(Default)]
1093struct ContentTypes {
1094 defaults: HashMap<String, String>,
1095 overrides: HashMap<String, String>,
1096}
1097
1098fn parse_content_types(
1099 parts: &HashMap<&str, &[u8]>,
1100 budget: &mut ParseBudget<'_>,
1101) -> Result<ContentTypes, PptxError> {
1102 let root = parse_part(parts, "[Content_Types].xml", budget)?;
1103 let mut content_types = ContentTypes::default();
1104 for child in root.child_elements() {
1105 match child.local_name() {
1106 "Default" => {
1107 if let (Some(extension), Some(content_type)) =
1108 (child.attribute("Extension"), child.attribute("ContentType"))
1109 {
1110 content_types
1111 .defaults
1112 .insert(extension.to_ascii_lowercase(), content_type.to_owned());
1113 }
1114 }
1115 "Override" => {
1116 if let (Some(part_name), Some(content_type)) =
1117 (child.attribute("PartName"), child.attribute("ContentType"))
1118 {
1119 content_types.overrides.insert(
1120 part_name.trim_start_matches('/').to_owned(),
1121 content_type.to_owned(),
1122 );
1123 }
1124 }
1125 _ => {}
1126 }
1127 }
1128 Ok(content_types)
1129}
1130
1131fn content_type_for(path: &str, content_types: &ContentTypes) -> String {
1132 content_types
1133 .overrides
1134 .get(path)
1135 .cloned()
1136 .or_else(|| {
1137 path.rsplit_once('.').and_then(|(_, extension)| {
1138 content_types
1139 .defaults
1140 .get(&extension.to_ascii_lowercase())
1141 .cloned()
1142 })
1143 })
1144 .unwrap_or_else(|| "application/octet-stream".to_owned())
1145}
1146
1147fn ordered_part_paths(
1148 preferred: Vec<String>,
1149 parts: &[(String, Vec<u8>)],
1150 prefix: &str,
1151) -> Vec<String> {
1152 let mut seen = HashSet::new();
1153 preferred
1154 .into_iter()
1155 .chain(
1156 parts
1157 .iter()
1158 .map(|(path, _)| path.clone())
1159 .filter(|path| path.starts_with(prefix) && path.ends_with(".xml")),
1160 )
1161 .filter(|path| seen.insert(path.clone()))
1162 .collect()
1163}
1164
1165fn relationship_by_type(relationships: &[Relationship], suffix: &str) -> Option<String> {
1166 relationships
1167 .iter()
1168 .find(|relationship| relationship.has_type(suffix))
1169 .and_then(|relationship| relationship.resolved_target.clone())
1170}
1171
1172fn relationship_target(relationships: &[Relationship], id: &str) -> Option<String> {
1173 relationships
1174 .iter()
1175 .find(|relationship| relationship.id == id)
1176 .and_then(|relationship| relationship.resolved_target.clone())
1177}
1178
1179fn positive_integer_attribute(element: Option<&XmlElement>, name: &str) -> Option<i64> {
1180 let value = element?.attribute(name)?.parse::<i64>().ok()?;
1181 (value > 0 && value <= 1_000_000_000_000_000).then_some(value)
1182}
1183
1184fn bool_attribute(element: &XmlElement, name: &str, default: bool) -> bool {
1185 match element.attribute(name) {
1186 Some("1" | "true" | "on") => true,
1187 Some("0" | "false" | "off") => false,
1188 _ => default,
1189 }
1190}
1191
1192pub fn effective_color_map(
1196 slide: Option<&Slide>,
1197 layout: Option<&SlideLayout>,
1198 master: Option<&SlideMaster>,
1199) -> ColorMap {
1200 slide
1201 .and_then(|slide| slide.color_map_override.clone())
1202 .or_else(|| layout.and_then(|layout| layout.color_map_override.clone()))
1203 .or_else(|| master.map(|master| master.color_map.clone()))
1204 .unwrap_or_default()
1205}
1206
1207pub fn slide_theme(
1210 package: &PptxPackage,
1211 slide_part_path: Option<&str>,
1212 layout_part_path: Option<&str>,
1213) -> Theme {
1214 let slide = slide_part_path
1215 .and_then(|path| package.slides.iter().find(|slide| slide.part_path == path));
1216 let layout = layout_part_path
1217 .or_else(|| slide.and_then(|slide| slide.layout_part_path.as_deref()))
1218 .and_then(|path| {
1219 package
1220 .layouts
1221 .iter()
1222 .find(|layout| layout.part_path == path)
1223 })
1224 .or_else(|| package.layouts.first());
1225 let master = master_for_layout(package, layout);
1226 let base = master
1227 .and_then(|master| master.theme_part_path.as_deref())
1228 .and_then(|path| package.themes.iter().find(|theme| theme.part_path == path))
1229 .or_else(|| package.themes.first());
1230 Theme {
1231 color_map: effective_color_map(slide, layout, master),
1232 ..base.map(|part| part.theme.clone()).unwrap_or_default()
1233 }
1234}
1235
1236pub fn master_for_layout<'a>(
1238 package: &'a PptxPackage,
1239 layout: Option<&SlideLayout>,
1240) -> Option<&'a SlideMaster> {
1241 column_master(&package.masters, layout)
1242}
1243
1244fn parse_color_map(root: &XmlElement) -> ColorMap {
1245 root.child("clrMap").map(color_map).unwrap_or_default()
1246}
1247
1248fn parse_color_map_override(root: &XmlElement) -> Option<ColorMap> {
1249 root.child("clrMapOvr")
1250 .and_then(|element| element.child("overrideClrMapping"))
1251 .map(color_map)
1252}
1253
1254fn color_map(element: &XmlElement) -> ColorMap {
1255 let mut map = ColorMap::default();
1256 for (name, slot) in &element.attributes {
1257 map.set(name, slot);
1258 }
1259 map
1260}
1261
1262#[cfg(test)]
1263mod tests {
1264 use super::*;
1265
1266 const FIXTURE: &[u8] = include_bytes!("../../../apps/demo/public/betteroffice-demo.pptx");
1267 const CHART_FIXTURE: &[u8] = include_bytes!("../tests/fixtures/chart-deck.pptx");
1268 const NUMBERED_FIXTURE: &[u8] = include_bytes!("../tests/fixtures/slide-number-fields.pptx");
1269 const CHART_PART: &str = "ppt/charts/chart1.xml";
1270
1271 fn rezip_with(edit: impl FnOnce(&mut Vec<(String, Vec<u8>)>)) -> Vec<u8> {
1272 let mut parts = ooxml_opc::unzip_parts(FIXTURE).unwrap();
1273 edit(&mut parts);
1274 ooxml_opc::rezip_parts(&parts).unwrap()
1275 }
1276
1277 fn set_part(parts: &mut Vec<(String, Vec<u8>)>, path: &str, xml: &str) {
1278 parts.retain(|(name, _)| name != path);
1279 parts.push((path.to_owned(), xml.as_bytes().to_vec()));
1280 }
1281
1282 #[test]
1283 fn refuses_packages_that_are_not_presentations() {
1284 let presentation = |parts: &Vec<(String, Vec<u8>)>| {
1285 String::from_utf8(
1286 parts
1287 .iter()
1288 .find(|(path, _)| path == "ppt/presentation.xml")
1289 .unwrap()
1290 .1
1291 .clone(),
1292 )
1293 .unwrap()
1294 };
1295 let cases = [
1296 (
1297 "missing main part",
1298 rezip_with(|parts| parts.retain(|(path, _)| path != "ppt/presentation.xml")),
1299 ),
1300 (
1301 "unsupported namespace",
1302 rezip_with(|parts| {
1303 let xml = presentation(parts).replace(PRESENTATIONML_NS, "urn:unsupported-ooxml:main");
1304 set_part(parts, "ppt/presentation.xml", &xml);
1305 }),
1306 ),
1307 (
1308 "missing namespace",
1309 rezip_with(|parts| {
1310 set_part(parts, "ppt/presentation.xml", "<p:presentation><p:sldIdLst/></p:presentation>");
1311 }),
1312 ),
1313 (
1314 "word document",
1315 ooxml_opc::rezip_parts(&[
1316 (
1317 "[Content_Types].xml".to_owned(),
1318 br#"<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/></Types>"#.to_vec(),
1319 ),
1320 (
1321 "_rels/.rels".to_owned(),
1322 br#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/></Relationships>"#.to_vec(),
1323 ),
1324 (
1325 "word/document.xml".to_owned(),
1326 br#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body><w:p/></w:body></w:document>"#.to_vec(),
1327 ),
1328 ])
1329 .unwrap(),
1330 ),
1331 (
1332 "ordinary archive",
1333 ooxml_opc::rezip_parts(&[("readme.txt".to_owned(), b"Archive contents".to_vec())])
1334 .unwrap(),
1335 ),
1336 ];
1337 for (title, bytes) in cases {
1338 for result in [parse_pptx(&bytes), parse_pptx_without_connectors(&bytes)] {
1339 assert!(
1340 matches!(result, Err(PptxError::NotAPresentation { .. })),
1341 "{title}: {result:?}"
1342 );
1343 }
1344 }
1345 }
1346
1347 #[test]
1348 fn opens_strict_presentation_roots() {
1349 let strict = rezip_with(|parts| {
1350 let xml = String::from_utf8(
1351 parts
1352 .iter()
1353 .find(|(path, _)| path == "ppt/presentation.xml")
1354 .unwrap()
1355 .1
1356 .clone(),
1357 )
1358 .unwrap()
1359 .replace(PRESENTATIONML_NS, STRICT_PRESENTATIONML_NS);
1360 set_part(parts, "ppt/presentation.xml", &xml);
1361 });
1362 assert_eq!(parse_pptx(&strict).unwrap().slides.len(), 3);
1363 }
1364
1365 #[test]
1366 fn parses_betteroffice_demo_deck_surface() {
1367 let package = parse_pptx(FIXTURE).unwrap();
1368 assert_eq!(package.presentation.width_emu, 12_192_000);
1369 assert_eq!(package.presentation.height_emu, 6_858_000);
1370 assert_eq!(package.slides.len(), 3);
1371 assert_eq!(package.layouts.len(), 1);
1372 assert_eq!(package.masters.len(), 1);
1373 assert_eq!(package.themes.len(), 1);
1374 assert_eq!(package.media.len(), 1);
1375 assert_eq!(package.themes[0].theme.name, "BetterOffice");
1376
1377 let kinds = package
1378 .slides
1379 .iter()
1380 .flat_map(|slide| slide.shapes.iter())
1381 .map(|shape| match shape {
1382 ShapeNode::Shape(_) => "shape",
1383 ShapeNode::Picture(_) => "picture",
1384 ShapeNode::GraphicFrame(_) => "graphicFrame",
1385 ShapeNode::Group(_) => "group",
1386 })
1387 .collect::<HashSet<_>>();
1388 assert_eq!(
1389 kinds,
1390 HashSet::from(["shape", "picture", "graphicFrame", "group"])
1391 );
1392 assert!(package.slides.iter().flat_map(|slide| &slide.shapes).any(|shape| {
1393 matches!(
1394 shape,
1395 ShapeNode::Shape(Shape {
1396 text: Some(TextBody { paragraphs, .. }),
1397 ..
1398 }) if paragraphs.iter().flat_map(|paragraph| ¶graph.runs).any(|run| run.text.contains("Rust"))
1399 )
1400 }));
1401 }
1402
1403 #[test]
1404 fn text_columns_non_column_decks_keep_the_json_without_default_attributes() {
1405 for fixture in [
1406 include_bytes!("../tests/fixtures/master-style-deck.pptx").as_slice(),
1407 include_bytes!("../tests/fixtures/style-matrix-deck.pptx").as_slice(),
1408 ] {
1409 let package = parse_pptx(fixture).unwrap();
1410 let mut parts = ooxml_opc::unzip_parts(fixture).unwrap();
1411 for (path, bytes) in &mut parts {
1412 if path.ends_with(".xml") {
1413 *bytes = String::from_utf8(bytes.clone())
1414 .unwrap()
1415 .replace(r#" rtlCol="0""#, "")
1416 .replace(r#" spcCol="0""#, "")
1417 .replace(r#" numCol="1""#, "")
1418 .into_bytes();
1419 }
1420 }
1421 let without_defaults = parse_pptx(&ooxml_opc::rezip_parts(&parts).unwrap()).unwrap();
1422 assert_eq!(
1423 serde_json::to_vec(&package).unwrap(),
1424 serde_json::to_vec(&without_defaults).unwrap()
1425 );
1426 }
1427 }
1428
1429 #[test]
1430 fn text_columns_serialize_single_column_only_when_overriding_inheritance() {
1431 let deck = |master_columns: u8| {
1432 let mut parts = ooxml_opc::unzip_parts(FIXTURE).unwrap();
1433 for (path, bytes) in &mut parts {
1434 let attributes = if path.starts_with("ppt/slides/slide") && path.ends_with(".xml") {
1435 Some(r#"numCol="1" rtlCol="0" spcCol="0""#.to_owned())
1436 } else if path.starts_with("ppt/slideMasters/slideMaster") && path.ends_with(".xml")
1437 {
1438 Some(format!(r#"numCol="{master_columns}""#))
1439 } else {
1440 None
1441 };
1442 if let Some(attributes) = attributes {
1443 let xml = String::from_utf8(bytes.clone()).unwrap().replace(
1444 r#"<p:cNvSpPr txBox="1"/><p:nvPr/>"#,
1445 r#"<p:cNvSpPr txBox="1"/><p:nvPr><p:ph type="body" idx="1"/></p:nvPr>"#,
1446 );
1447 *bytes = xml
1448 .replace("<a:bodyPr", &format!("<a:bodyPr {attributes}"))
1449 .into_bytes();
1450 }
1451 }
1452 parse_pptx(&ooxml_opc::rezip_parts(&parts).unwrap()).unwrap()
1453 };
1454 let body = |package: &PptxPackage| {
1455 package.slides[0]
1456 .shapes
1457 .iter()
1458 .filter_map(|node| match node {
1459 ShapeNode::Shape(shape) if shape.base.placeholder.is_some() => {
1460 shape.text.as_ref()
1461 }
1462 _ => None,
1463 })
1464 .map(|body| serde_json::to_value(body).unwrap())
1465 .collect::<Vec<_>>()
1466 };
1467 let defaults = body(&deck(1));
1468 assert!(!defaults.is_empty());
1469 assert!(defaults.iter().all(|body| body.get("numCol").is_none()));
1470 let overrides = body(&deck(2));
1471 assert!(overrides.iter().any(|body| body["numCol"] == 1));
1472 let package = deck(2);
1473 assert_eq!(
1474 ooxml_opc::unzip_parts(&crate::write_pptx(&package).unwrap()).unwrap(),
1475 package
1476 .parts
1477 .iter()
1478 .map(|part| (part.path.clone(), part.bytes.clone()))
1479 .collect::<Vec<_>>()
1480 );
1481 }
1482
1483 #[test]
1484 fn text_columns_preserve_layout_defaults_for_descendant_columns() {
1485 let mut parts = ooxml_opc::unzip_parts(FIXTURE).unwrap();
1486 for (path, bytes) in &mut parts {
1487 let (root, attributes) = match path.as_str() {
1488 "ppt/slides/slide1.xml" => ("sld", r#"numCol="2""#),
1489 "ppt/slideLayouts/slideLayout1.xml" => {
1490 ("sldLayout", r#"numCol="1" spcCol="0" rtlCol="0""#)
1491 }
1492 "ppt/slideMasters/slideMaster1.xml" => {
1493 ("sldMaster", r#"numCol="2" spcCol="457200" rtlCol="1""#)
1494 }
1495 _ => continue,
1496 };
1497 *bytes = format!(
1498 r#"<p:{root} xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main" xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"><p:cSld><p:spTree><p:sp><p:nvSpPr><p:cNvPr id="2" name="Text"/><p:cNvSpPr/><p:nvPr><p:ph type="body" idx="1"/></p:nvPr></p:nvSpPr><p:spPr/><p:txBody><a:bodyPr {attributes}/><a:p/></p:txBody></p:sp></p:spTree></p:cSld></p:{root}>"#
1499 )
1500 .into_bytes();
1501 }
1502 let package = parse_pptx(&ooxml_opc::rezip_parts(&parts).unwrap()).unwrap();
1503 let ShapeNode::Shape(slide) = &package.slides[0].shapes[0] else {
1504 panic!("expected slide placeholder");
1505 };
1506 let ShapeNode::Shape(layout) = &package.layouts[0].shapes[0] else {
1507 panic!("expected layout placeholder");
1508 };
1509 assert_eq!(slide.text.as_ref().unwrap().num_col, Some(2));
1510 let body = layout.text.as_ref().unwrap();
1511 assert_eq!(body.num_col, Some(1));
1512 assert_eq!(body.spc_col, Some(0));
1513 assert_eq!(body.rtl_col, Some(false));
1514 }
1515
1516 #[test]
1517 fn first_slide_num_is_read_signed_and_defaults_to_one() {
1518 assert_eq!(parse_pptx(FIXTURE).unwrap().presentation.first_slide_num, 1);
1519 assert_eq!(
1520 parse_pptx(NUMBERED_FIXTURE)
1521 .unwrap()
1522 .presentation
1523 .first_slide_num,
1524 10
1525 );
1526 for (value, expected) in [
1527 ("0", 0),
1528 ("-3", -3),
1529 ("-2147483648", i32::MIN),
1530 ("2147483647", i32::MAX),
1531 ("2147483648", 1),
1532 ("-2147483649", 1),
1533 ("ten", 1),
1534 ("", 1),
1535 ] {
1536 let package = parse_pptx(&demo_deck_numbered_from(value)).unwrap();
1537 assert_eq!(
1538 package.presentation.first_slide_num, expected,
1539 "firstSlideNum={value:?}"
1540 );
1541 }
1542 }
1543
1544 fn demo_deck_numbered_from(value: &str) -> Vec<u8> {
1545 let mut parts = ooxml_opc::unzip_parts(FIXTURE).unwrap();
1546 let slot = parts
1547 .iter_mut()
1548 .find(|(path, _)| path == "ppt/presentation.xml")
1549 .unwrap();
1550 let xml = String::from_utf8(slot.1.clone()).unwrap();
1551 let numbered = xml.replacen(
1552 "<p:presentation ",
1553 &format!("<p:presentation firstSlideNum=\"{value}\" "),
1554 1,
1555 );
1556 assert_ne!(numbered, xml);
1557 slot.1 = numbered.into_bytes();
1558 ooxml_opc::rezip_parts(&parts).unwrap()
1559 }
1560
1561 fn assert_untouched_save_round_trip(deck: &[u8]) {
1562 let package = parse_pptx(deck).unwrap();
1563 let written = write_pptx(&package).unwrap();
1564 let before = ooxml_opc::unzip_parts(deck).unwrap();
1565 let after = ooxml_opc::unzip_parts(&written).unwrap();
1566 assert_eq!(after, before);
1567 }
1568
1569 #[test]
1570 fn untouched_save_preserves_every_part_byte_and_order() {
1571 assert_untouched_save_round_trip(FIXTURE);
1572 }
1573
1574 #[test]
1575 fn loads_every_referenced_chart_part_against_the_deck_theme() {
1576 let package = parse_pptx(CHART_FIXTURE).unwrap();
1577 assert_eq!(
1578 package
1579 .charts
1580 .iter()
1581 .map(|part| part.part_path.as_str())
1582 .collect::<Vec<_>>(),
1583 ["ppt/charts/chart1.xml", "ppt/charts/chart2.xml"]
1584 );
1585
1586 let column = &package.charts[0].chart;
1587 assert_eq!(column.chart_type, "column");
1588 assert_eq!(column.title.as_deref(), Some("Revenue"));
1589 assert_eq!(
1590 column.legend.as_ref().unwrap().position.as_deref(),
1591 Some("right")
1592 );
1593 assert_eq!(column.series[0].color, "#6254E7");
1594 assert_eq!(column.series[1].color, "#1FA97A");
1595 assert_eq!(column.series[0].values, [12.0, 19.0, 7.0]);
1596 assert_eq!(column.plot_groups[0].gap_width, Some(150.0));
1597 assert_eq!(
1598 column.plot_groups[0].series[0]
1599 .data_labels
1600 .as_ref()
1601 .and_then(|labels| labels.show_value),
1602 Some(true)
1603 );
1604 assert!(column.plot_groups[0].data_labels.is_none());
1605 assert!(column.plot_groups[0].show_data_labels);
1606 let axes = column.axis_list.as_ref().unwrap();
1607 assert_eq!(axes[0].title.as_deref(), Some("Quarter"));
1608 assert_eq!(axes[1].title.as_deref(), Some("Millions"));
1609 assert_eq!(axes[1].max, Some(25.0));
1610
1611 let pie = &package.charts[1].chart;
1613 assert_eq!(pie.chart_type, "pie");
1614 let points = pie.plot_groups[0].series[0].points.as_ref().unwrap();
1615 assert_eq!(points[0].color, "#E7A954");
1616 assert_eq!(points[1].color, "#112233");
1617 assert_eq!(points[1].explosion, Some(10.0));
1618 }
1619
1620 struct SharedChartDeck {
1622 slides: Vec<Slide>,
1623 layouts: Vec<SlideLayout>,
1624 masters: Vec<SlideMaster>,
1625 themes: Vec<ThemePart>,
1626 relationships: BTreeMap<String, Vec<Relationship>>,
1627 }
1628
1629 impl SharedChartDeck {
1630 fn sources(&self) -> ChartSources<'_> {
1631 ChartSources {
1632 slides: &self.slides,
1633 layouts: &self.layouts,
1634 masters: &self.masters,
1635 themes: &self.themes,
1636 relationships: &self.relationships,
1637 }
1638 }
1639 }
1640
1641 fn shared_chart_deck() -> SharedChartDeck {
1642 fn chart_shape(id: u32) -> ShapeNode {
1643 ShapeNode::GraphicFrame(GraphicFrame {
1644 base: ShapeBase {
1645 id,
1646 name: format!("Chart {id}"),
1647 description: None,
1648 hidden: false,
1649 placeholder: None,
1650 transform: ShapeTransform::default(),
1651 },
1652 data: GraphicFrameData::Chart {
1653 relationship_id: "rIdChart".to_owned(),
1654 part_path: Some("ppt/charts/chart1.xml".to_owned()),
1655 },
1656 })
1657 }
1658
1659 fn slide(path: &str, layout: &str, id: u32) -> Slide {
1660 Slide {
1661 part_path: path.to_owned(),
1662 name: None,
1663 layout_part_path: Some(layout.to_owned()),
1664 show_master_shapes: true,
1665 background: None,
1666 background_picture: None,
1667 background_reference: None,
1668 declares_background: false,
1669 shapes: vec![chart_shape(id)],
1670 color_map_override: None,
1671 notes: String::new(),
1672 hidden: Some(false),
1673 }
1674 }
1675
1676 fn chart_relationships() -> Vec<Relationship> {
1677 vec![Relationship {
1678 id: "rIdChart".to_owned(),
1679 relationship_type: relationship_types::CHART.to_owned(),
1680 target: "../charts/chart1.xml".to_owned(),
1681 target_mode: Default::default(),
1682 resolved_target: Some("ppt/charts/chart1.xml".to_owned()),
1683 }]
1684 }
1685
1686 let slides = vec![
1687 slide("ppt/slides/slide1.xml", "ppt/slideLayouts/layout1.xml", 1),
1688 slide("ppt/slides/slide2.xml", "ppt/slideLayouts/layout2.xml", 2),
1689 slide("ppt/slides/slide3.xml", "ppt/slideLayouts/layout1.xml", 3),
1690 ];
1691 let layouts = vec![
1692 SlideLayout {
1693 part_path: "ppt/slideLayouts/layout1.xml".to_owned(),
1694 name: None,
1695 layout_type: None,
1696 master_part_path: Some("ppt/slideMasters/master1.xml".to_owned()),
1697 show_master_shapes: true,
1698 background: None,
1699 background_picture: None,
1700 background_reference: None,
1701 shapes: Vec::new(),
1702 color_map_override: None,
1703 },
1704 SlideLayout {
1705 part_path: "ppt/slideLayouts/layout2.xml".to_owned(),
1706 name: None,
1707 layout_type: None,
1708 master_part_path: Some("ppt/slideMasters/master2.xml".to_owned()),
1709 show_master_shapes: true,
1710 background: None,
1711 background_picture: None,
1712 background_reference: None,
1713 shapes: Vec::new(),
1714 color_map_override: None,
1715 },
1716 ];
1717 let masters = vec![
1718 SlideMaster {
1719 part_path: "ppt/slideMasters/master1.xml".to_owned(),
1720 name: None,
1721 theme_part_path: Some("ppt/theme/theme1.xml".to_owned()),
1722 layout_part_paths: vec!["ppt/slideLayouts/layout1.xml".to_owned()],
1723 background: None,
1724 background_picture: None,
1725 background_reference: None,
1726 shapes: Vec::new(),
1727 text_styles: TextStyleSet::default(),
1728 color_map: ColorMap::default(),
1729 },
1730 SlideMaster {
1731 part_path: "ppt/slideMasters/master2.xml".to_owned(),
1732 name: None,
1733 theme_part_path: Some("ppt/theme/theme2.xml".to_owned()),
1734 layout_part_paths: vec!["ppt/slideLayouts/layout2.xml".to_owned()],
1735 background: None,
1736 background_picture: None,
1737 background_reference: None,
1738 shapes: Vec::new(),
1739 text_styles: TextStyleSet::default(),
1740 color_map: ColorMap::default(),
1741 },
1742 ];
1743 let mut first_theme = Theme::default();
1744 first_theme.color_scheme.accent1 = "112233".to_owned();
1745 let mut second_theme = Theme::default();
1746 second_theme.color_scheme.accent1 = "AABBCC".to_owned();
1747 let themes = vec![
1748 ThemePart {
1749 part_path: "ppt/theme/theme1.xml".to_owned(),
1750 theme: first_theme,
1751 format_scheme: Default::default(),
1752 background_pictures: Vec::new(),
1753 },
1754 ThemePart {
1755 part_path: "ppt/theme/theme2.xml".to_owned(),
1756 theme: second_theme,
1757 format_scheme: Default::default(),
1758 background_pictures: Vec::new(),
1759 },
1760 ];
1761 let relationships = BTreeMap::from([
1762 ("ppt/slides/slide1.xml".to_owned(), chart_relationships()),
1763 ("ppt/slides/slide2.xml".to_owned(), chart_relationships()),
1764 ("ppt/slides/slide3.xml".to_owned(), chart_relationships()),
1765 ]);
1766 SharedChartDeck {
1767 slides,
1768 layouts,
1769 masters,
1770 themes,
1771 relationships,
1772 }
1773 }
1774
1775 #[test]
1776 fn a_shared_chart_is_resolved_once_per_referencing_theme() {
1777 let deck = shared_chart_deck();
1778 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>"#;
1779 let parts: HashMap<&str, &[u8]> =
1780 HashMap::from([("ppt/charts/chart1.xml", chart_xml.as_slice())]);
1781
1782 let charts = parse_chart_parts(&parts, &deck.sources(), &ParseLimits::default());
1783
1784 assert_eq!(charts.len(), 2);
1785 assert_eq!(
1786 charts
1787 .iter()
1788 .map(|chart| chart.theme_part_path.as_deref())
1789 .collect::<Vec<_>>(),
1790 [Some("ppt/theme/theme1.xml"), Some("ppt/theme/theme2.xml")]
1791 );
1792 assert_eq!(charts[0].chart.series[0].color, "#112233");
1793 assert_eq!(charts[1].chart.series[0].color, "#AABBCC");
1794 }
1795
1796 #[test]
1800 fn a_shared_chart_part_is_read_once_however_many_themes_reference_it() {
1801 let deck = shared_chart_deck();
1802 let chart_xml = cached_chart(1_000);
1803 let parts: HashMap<&str, &[u8]> =
1804 HashMap::from([("ppt/charts/chart1.xml", chart_xml.as_slice())]);
1805 let limits = ParseLimits {
1806 max_xml_events: 6_000,
1807 ..ParseLimits::default()
1808 };
1809
1810 let charts = parse_chart_parts(&parts, &deck.sources(), &limits);
1811
1812 assert_eq!(charts.len(), 2);
1813 assert!(
1814 charts
1815 .iter()
1816 .all(|part| part.chart.series[0].values.len() == 1_000)
1817 );
1818 }
1819
1820 #[test]
1821 fn a_deck_without_charts_loads_no_chart_parts() {
1822 assert!(parse_pptx(FIXTURE).unwrap().charts.is_empty());
1823 }
1824
1825 fn chart_deck_with(part: &str, bytes: &[u8]) -> Vec<u8> {
1827 let mut parts = ooxml_opc::unzip_parts(CHART_FIXTURE).unwrap();
1828 let slot = parts
1829 .iter_mut()
1830 .find(|(path, _)| path == part)
1831 .unwrap_or_else(|| panic!("{part} is not in the fixture"));
1832 slot.1 = bytes.to_vec();
1833 ooxml_opc::rezip_parts(&parts).unwrap()
1834 }
1835
1836 fn assert_damaged_chart_is_dropped(bytes: &[u8]) {
1839 let package = parse_pptx(&chart_deck_with(CHART_PART, bytes)).unwrap();
1840 assert_eq!(package.slides.len(), 2);
1841 assert_eq!(package.layouts.len(), 1);
1842 assert_eq!(package.masters.len(), 1);
1843 assert_eq!(package.themes.len(), 1);
1844 assert_eq!(
1845 package
1846 .slides
1847 .iter()
1848 .map(|slide| slide.name.as_deref())
1849 .collect::<Vec<_>>(),
1850 [Some("Charts"), Some("Grouped")]
1851 );
1852 assert_eq!(
1853 package.slides[0]
1854 .shapes
1855 .iter()
1856 .filter(|shape| matches!(shape, ShapeNode::GraphicFrame(_)))
1857 .count(),
1858 2
1859 );
1860 assert_eq!(
1861 package
1862 .charts
1863 .iter()
1864 .map(|part| part.part_path.as_str())
1865 .collect::<Vec<_>>(),
1866 ["ppt/charts/chart2.xml"]
1867 );
1868 }
1869
1870 #[test]
1871 fn a_malformed_chart_part_drops_only_that_chart() {
1872 assert_damaged_chart_is_dropped(b"<c:chartSpace><c:chart></c:chartSpace>");
1873 }
1874
1875 #[test]
1876 fn an_empty_chart_part_drops_only_that_chart() {
1877 assert_damaged_chart_is_dropped(b"");
1878 }
1879
1880 #[test]
1881 fn a_doctype_bearing_chart_part_drops_only_that_chart() {
1882 assert_damaged_chart_is_dropped(
1883 br#"<?xml version="1.0"?><!DOCTYPE c:chartSpace [<!ENTITY x "y">]><c:chartSpace xmlns:c="c"/>"#,
1884 );
1885 }
1886
1887 #[test]
1888 fn a_truncated_chart_part_drops_only_that_chart() {
1889 let source = ooxml_opc::unzip_parts(CHART_FIXTURE)
1890 .unwrap()
1891 .into_iter()
1892 .find(|(path, _)| path == CHART_PART)
1893 .map(|(_, bytes)| bytes)
1894 .unwrap();
1895 assert_damaged_chart_is_dropped(&source[..source.len() / 2]);
1896 }
1897
1898 #[test]
1899 fn a_binary_chart_part_drops_only_that_chart() {
1900 assert_damaged_chart_is_dropped(&[0x00, 0xff, 0xfe, 0x01, 0x80, 0x7f]);
1901 }
1902
1903 #[test]
1904 fn an_undeclared_entity_in_a_chart_part_drops_only_that_chart() {
1905 assert_damaged_chart_is_dropped(
1906 br#"<c:chartSpace xmlns:c="c"><c:chart><c:title> </c:title></c:chart></c:chartSpace>"#,
1907 );
1908 }
1909
1910 #[test]
1913 fn an_unreadable_chart_part_survives_a_save_byte_for_byte() {
1914 const DAMAGED: &[u8] = b"<c:chartSpace><c:chart></c:chartSpace>";
1915 let deck = chart_deck_with(CHART_PART, DAMAGED);
1916 let written = write_pptx(&parse_pptx(&deck).unwrap()).unwrap();
1917 assert_eq!(
1918 ooxml_opc::unzip_parts(&written).unwrap(),
1919 ooxml_opc::unzip_parts(&deck).unwrap()
1920 );
1921 assert_eq!(
1922 ooxml_opc::unzip_parts(&written)
1923 .unwrap()
1924 .into_iter()
1925 .find(|(path, _)| path == CHART_PART)
1926 .map(|(_, bytes)| bytes),
1927 Some(DAMAGED.to_vec())
1928 );
1929 }
1930
1931 fn cached_chart(points: usize) -> Vec<u8> {
1934 let mut xml = String::from(
1935 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>"#,
1936 );
1937 for point in 0..points {
1938 xml.push_str(&format!("<c:pt><c:v>{point}</c:v></c:pt>"));
1939 }
1940 xml.push_str("</c:numCache></c:numRef></c:val></c:ser></c:barChart></c:plotArea></c:chart></c:chartSpace>");
1941 xml.into_bytes()
1942 }
1943
1944 fn cached_chart_deck(points: usize, shapes: usize) -> Vec<u8> {
1948 let crowd = (0..shapes)
1949 .map(|shape| {
1950 format!(
1951 r#"<p:sp><p:nvSpPr><p:cNvPr id="{}" name=""/><p:cNvSpPr/><p:nvPr/></p:nvSpPr><p:spPr/></p:sp>"#,
1952 shape + 100
1953 )
1954 })
1955 .collect::<String>();
1956 let mut parts = ooxml_opc::unzip_parts(CHART_FIXTURE).unwrap();
1957 for (path, bytes) in &mut parts {
1958 if path.starts_with("ppt/charts/") {
1959 *bytes = cached_chart(points);
1960 }
1961 if path == "ppt/slides/slide2.xml" {
1962 *bytes = String::from_utf8(bytes.clone())
1963 .unwrap()
1964 .replace("</p:spTree>", &format!("{crowd}</p:spTree>"))
1965 .into_bytes();
1966 }
1967 }
1968 ooxml_opc::rezip_parts(&parts).unwrap()
1969 }
1970
1971 #[test]
1975 fn a_deck_whose_charts_outgrow_the_slides_budget_opens_with_every_chart() {
1976 let deck = cached_chart_deck(2_000, 1_500);
1977 let limits = ParseLimits {
1978 max_xml_events: 30_000,
1979 ..ParseLimits::default()
1980 };
1981
1982 let package = parse_pptx_with_limits(&deck, &limits).unwrap();
1983
1984 assert_eq!(package.slides.len(), 2);
1985 assert_eq!(
1986 package
1987 .slides
1988 .iter()
1989 .map(|slide| slide.name.as_deref())
1990 .collect::<Vec<_>>(),
1991 [Some("Charts"), Some("Grouped")]
1992 );
1993 assert_eq!(package.slides[1].shapes.len(), 1_501);
1994 assert_eq!(package.charts.len(), 2);
1995 assert!(
1996 package
1997 .charts
1998 .iter()
1999 .all(|part| part.chart.series[0].values.len() == 2_000)
2000 );
2001 }
2002
2003 #[test]
2007 fn a_deck_whose_charts_outgrow_the_chart_budget_opens_with_the_charts_that_fit() {
2008 let deck = cached_chart_deck(4_000, 1_500);
2009 let limits = ParseLimits {
2010 max_xml_events: 30_000,
2011 ..ParseLimits::default()
2012 };
2013
2014 let package = parse_pptx_with_limits(&deck, &limits).unwrap();
2015
2016 assert_eq!(package.slides.len(), 2);
2017 assert_eq!(package.slides[1].shapes.len(), 1_501);
2018 assert_eq!(
2019 package.slides[0]
2020 .shapes
2021 .iter()
2022 .filter(|shape| matches!(shape, ShapeNode::GraphicFrame(_)))
2023 .count(),
2024 2
2025 );
2026 assert_eq!(
2027 package
2028 .charts
2029 .iter()
2030 .map(|part| part.part_path.as_str())
2031 .collect::<Vec<_>>(),
2032 ["ppt/charts/chart1.xml"]
2033 );
2034 assert_eq!(package.charts[0].chart.series[0].values.len(), 4_000);
2035 }
2036
2037 fn smartart_deck(drawings: usize, shapes: usize) -> Vec<u8> {
2039 let mut parts = ooxml_opc::unzip_parts(FIXTURE).unwrap();
2040 let frames = (0..drawings)
2041 .map(|index| {
2042 format!(
2043 r#"<p:graphicFrame><p:nvGraphicFramePr><p:cNvPr id="{}" name="SmartArt {index}"/><p:cNvGraphicFramePr/><p:nvPr/></p:nvGraphicFramePr><p:xfrm><a:off x="0" y="0"/><a:ext cx="914400" cy="914400"/></p:xfrm><a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/diagram"><dgm:relIds xmlns:dgm="http://schemas.openxmlformats.org/drawingml/2006/diagram" r:dm="rIdData{index}" r:lo="" r:qs="" r:cs=""/></a:graphicData></a:graphic></p:graphicFrame>"#,
2044 index + 900
2045 )
2046 })
2047 .collect::<String>();
2048 let links = (0..drawings)
2049 .map(|index| {
2050 format!(
2051 r#"<Relationship Id="rIdData{index}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/diagramData" Target="../diagrams/data{index}.xml"/><Relationship Id="rIdDrawing{index}" Type="http://schemas.microsoft.com/office/2007/relationships/diagramDrawing" Target="../diagrams/drawing{index}.xml"/>"#
2052 )
2053 })
2054 .collect::<String>();
2055 let crowd = (0..shapes)
2056 .map(|_| r#"<dsp:sp modelId="{0}"><dsp:nvSpPr><dsp:cNvPr id="0" name=""/><dsp:cNvSpPr/></dsp:nvSpPr><dsp:spPr/></dsp:sp>"#)
2057 .collect::<String>();
2058 let drawing = format!(
2059 r#"<dsp:drawing xmlns:dsp="http://schemas.microsoft.com/office/drawing/2008/diagram" xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"><dsp:spTree><dsp:nvGrpSpPr><dsp:cNvPr id="0" name=""/><dsp:cNvGrpSpPr/></dsp:nvGrpSpPr><dsp:grpSpPr/>{crowd}</dsp:spTree></dsp:drawing>"#
2060 );
2061 for (path, bytes) in &mut parts {
2062 let edited = match path.as_str() {
2063 "ppt/slides/slide1.xml" => String::from_utf8(bytes.clone())
2064 .unwrap()
2065 .replace("</p:spTree>", &format!("{frames}</p:spTree>")),
2066 "ppt/slides/_rels/slide1.xml.rels" => String::from_utf8(bytes.clone())
2067 .unwrap()
2068 .replace("</Relationships>", &format!("{links}</Relationships>")),
2069 _ => continue,
2070 };
2071 *bytes = edited.into_bytes();
2072 }
2073 for index in 0..drawings {
2074 parts.push((
2075 format!("ppt/diagrams/drawing{index}.xml"),
2076 drawing.clone().into_bytes(),
2077 ));
2078 }
2079 ooxml_opc::rezip_parts(&parts).unwrap()
2080 }
2081
2082 #[test]
2086 fn smartart_drawings_share_the_package_budget() {
2087 let whole = parse_pptx(&smartart_deck(3, 40)).unwrap();
2088 assert_eq!(whole.diagram_drawings.len(), 3);
2089 let spent = whole
2090 .slides
2091 .iter()
2092 .map(|slide| count_shapes(&slide.shapes))
2093 .sum::<usize>()
2094 + whole
2095 .layouts
2096 .iter()
2097 .map(|layout| count_shapes(&layout.shapes))
2098 .sum::<usize>()
2099 + whole
2100 .masters
2101 .iter()
2102 .map(|master| count_shapes(&master.shapes))
2103 .sum::<usize>();
2104 let limits = ParseLimits {
2105 max_shapes: spent + 100,
2106 ..ParseLimits::default()
2107 };
2108
2109 let package = parse_pptx_with_limits(&smartart_deck(3, 40), &limits).unwrap();
2110
2111 assert_eq!(package.slides.len(), whole.slides.len());
2112 assert_eq!(
2113 package
2114 .diagram_drawings
2115 .iter()
2116 .map(|drawing| drawing.shapes.len())
2117 .collect::<Vec<_>>(),
2118 [40, 40]
2119 );
2120 }
2121
2122 fn count_shapes(shapes: &[ShapeNode]) -> usize {
2123 shapes
2124 .iter()
2125 .map(|shape| match shape {
2126 ShapeNode::Group(group) => 1 + count_shapes(&group.children),
2127 _ => 1,
2128 })
2129 .sum()
2130 }
2131
2132 #[test]
2133 fn package_limits_apply_across_xml_parts() {
2134 let limits = ParseLimits {
2135 max_xml_bytes: 100,
2136 ..ParseLimits::default()
2137 };
2138 assert!(matches!(
2139 parse_pptx_with_limits(FIXTURE, &limits),
2140 Err(PptxError::ResourceLimit {
2141 kind: "xmlBytes",
2142 ..
2143 })
2144 ));
2145 }
2146
2147 fn diagram_deck_without_drawing(
2148 data_xml: &[u8],
2149 layout_xml: Option<&[u8]>,
2150 include_drawing_rel: bool,
2151 ) -> Vec<u8> {
2152 let mut parts = ooxml_opc::unzip_parts(FIXTURE).unwrap();
2153 let frame = r#"<p:graphicFrame><p:nvGraphicFramePr><p:cNvPr id="901" name="Diagram 1"/><p:cNvGraphicFramePr/><p:nvPr/></p:nvGraphicFramePr><p:xfrm><a:off x="914400" y="914400"/><a:ext cx="3657600" cy="1828800"/></p:xfrm><a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/diagram"><dgm:relIds xmlns:dgm="http://schemas.openxmlformats.org/drawingml/2006/diagram" r:dm="rIdData1" r:lo="rIdLayout1" r:qs="" r:cs=""/></a:graphicData></a:graphic></p:graphicFrame>"#;
2154 let mut rels = String::from(
2155 r#"<Relationship Id="rIdData1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/diagramData" Target="../diagrams/data1.xml"/>"#,
2156 );
2157 if layout_xml.is_some() {
2158 rels.push_str(
2159 r#"<Relationship Id="rIdLayout1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/diagramLayout" Target="../diagrams/layout1.xml"/>"#,
2160 );
2161 }
2162 if include_drawing_rel {
2163 rels.push_str(
2164 r#"<Relationship Id="rIdDrawing1" Type="http://schemas.microsoft.com/office/2007/relationships/diagramDrawing" Target="../diagrams/drawing1.xml"/>"#,
2165 );
2166 }
2167 for (path, bytes) in &mut parts {
2168 let edited = match path.as_str() {
2169 "ppt/slides/slide1.xml" => String::from_utf8(bytes.clone())
2170 .unwrap()
2171 .replace("</p:spTree>", &format!("{frame}</p:spTree>")),
2172 "ppt/slides/_rels/slide1.xml.rels" => String::from_utf8(bytes.clone())
2173 .unwrap()
2174 .replace("</Relationships>", &format!("{rels}</Relationships>")),
2175 _ => continue,
2176 };
2177 *bytes = edited.into_bytes();
2178 }
2179 parts.push(("ppt/diagrams/data1.xml".to_owned(), data_xml.to_vec()));
2180 parts.push((
2181 "ppt/diagrams/drawing1.xml".to_owned(),
2182 br#"<dsp:drawing xmlns:dsp="http://schemas.microsoft.com/office/drawing/2008/diagram" xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"><dsp:spTree><dsp:nvGrpSpPr><dsp:cNvPr id="0" name=""/><dsp:cNvGrpSpPr/></dsp:nvGrpSpPr><dsp:grpSpPr/></dsp:spTree></dsp:drawing>"#.to_vec(),
2183 ));
2184 if let Some(layout) = layout_xml {
2185 parts.push(("ppt/diagrams/layout1.xml".to_owned(), layout.to_vec()));
2186 }
2187 ooxml_opc::rezip_parts(&parts).unwrap()
2188 }
2189
2190 const BASIC_DIAGRAM_DATA: &[u8] = br#"<dgm:dataModel xmlns:dgm="http://schemas.openxmlformats.org/drawingml/2006/diagram" xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"><dgm:ptLst><dgm:pt modelId="p1"><dgm:t><a:p><a:r><a:t>First</a:t></a:r></a:p></dgm:t></dgm:pt><dgm:pt modelId="p2"><dgm:t><a:p><a:r><a:t>Second</a:t></a:r></a:p></dgm:t></dgm:pt></dgm:ptLst></dgm:dataModel>"#;
2191
2192 #[test]
2193 fn uncached_diagram_does_not_reuse_another_frames_cached_drawing() {
2194 let mut parts = ooxml_opc::unzip_parts(&smartart_deck(2, 1)).unwrap();
2195 parts.retain(|(path, _)| path != "ppt/diagrams/drawing1.xml");
2196 let (_, rels) = parts
2197 .iter_mut()
2198 .find(|(path, _)| path == "ppt/slides/_rels/slide1.xml.rels")
2199 .unwrap();
2200 *rels = String::from_utf8(rels.clone())
2201 .unwrap()
2202 .replace(
2203 r#"<Relationship Id="rIdDrawing1" Type="http://schemas.microsoft.com/office/2007/relationships/diagramDrawing" Target="../diagrams/drawing1.xml"/>"#,
2204 "",
2205 )
2206 .into_bytes();
2207 parts.push((
2208 "ppt/diagrams/data1.xml".to_owned(),
2209 BASIC_DIAGRAM_DATA.to_vec(),
2210 ));
2211 let deck = ooxml_opc::rezip_parts(&parts).unwrap();
2212 let package = parse_pptx(&deck).unwrap();
2213 assert_eq!(package.diagram_drawings.len(), 2);
2214 let cached = package
2215 .diagram_drawings
2216 .iter()
2217 .find(|drawing| drawing.part_path == "ppt/diagrams/drawing0.xml")
2218 .unwrap();
2219 assert_eq!(cached.synthesis_extent, None);
2220 assert_eq!(cached.shapes.len(), 1);
2221 let fallback = package
2222 .diagram_drawings
2223 .iter()
2224 .find(|drawing| drawing.part_path == "ppt/diagrams/data1.xml")
2225 .unwrap();
2226 assert_eq!(fallback.synthesis_extent, Some([DIAGRAM_EXTENT_EMU; 2]));
2227 let texts: Vec<_> = fallback
2228 .shapes
2229 .iter()
2230 .map(|node| {
2231 let ShapeNode::Shape(shape) = node else {
2232 panic!("expected a synthesized shape");
2233 };
2234 shape.text.as_ref().unwrap().paragraphs[0].runs[0]
2235 .text
2236 .as_str()
2237 })
2238 .collect();
2239 assert_eq!(texts, ["First", "Second"]);
2240 assert_untouched_save_round_trip(&deck);
2241 }
2242
2243 fn assert_crowded_diagram_box_widths(layout: &str, child_count: usize) {
2244 let hierarchy = layout == "hierarchy";
2245 let count = child_count + usize::from(hierarchy);
2246 let points: String = (0..count)
2247 .map(|index| format!(r#"<dgm:pt modelId="p{index}"><dgm:t><a:p><a:r><a:t>Item {index}</a:t></a:r></a:p></dgm:t></dgm:pt>"#))
2248 .collect();
2249 let data = format!(
2250 r#"<dgm:dataModel xmlns:dgm="http://schemas.openxmlformats.org/drawingml/2006/diagram" xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"><dgm:ptLst>{points}</dgm:ptLst></dgm:dataModel>"#
2251 );
2252 let layout = format!(
2253 r#"<dgm:layoutDef xmlns:dgm="http://schemas.openxmlformats.org/drawingml/2006/diagram" uniqueId="{layout}"/>"#
2254 );
2255 let deck = diagram_deck_without_drawing(data.as_bytes(), Some(layout.as_bytes()), false);
2256 let package = parse_pptx(&deck).unwrap();
2257 assert_eq!(package.diagram_drawings.len(), 1);
2258 let shapes = &package.diagram_drawings[0].shapes;
2259 assert_eq!(shapes.len(), count);
2260 let min_width = DIAGRAM_EXTENT_EMU / (2 * child_count as i64);
2261 let mut previous_right = 0;
2262 for (index, node) in shapes.iter().enumerate() {
2263 let ShapeNode::Shape(shape) = node else {
2264 panic!("expected a synthesized shape");
2265 };
2266 assert_eq!(
2267 shape.text.as_ref().unwrap().paragraphs[0].runs[0].text,
2268 format!("Item {index}")
2269 );
2270 let t = &shape.base.transform;
2271 assert!(
2272 t.width >= min_width,
2273 "box {index} width {} < {min_width}",
2274 t.width
2275 );
2276 assert!(t.x >= 0 && t.y >= 0);
2277 assert!(t.x + t.width <= DIAGRAM_EXTENT_EMU);
2278 assert!(t.y + t.height <= DIAGRAM_EXTENT_EMU);
2279 if hierarchy && index == 0 {
2280 continue;
2281 }
2282 assert!(t.x >= previous_right);
2283 previous_right = t.x + t.width;
2284 }
2285 }
2286
2287 #[test]
2288 fn crowded_linear_diagrams_reserve_width_for_boxes() {
2289 for layout in ["list", "process"] {
2290 for count in [24, 25, 64] {
2291 assert_crowded_diagram_box_widths(layout, count);
2292 }
2293 }
2294 }
2295
2296 #[test]
2297 fn crowded_hierarchy_diagrams_reserve_width_for_children() {
2298 for count in [24, 25, 64] {
2299 assert_crowded_diagram_box_widths("hierarchy", count);
2300 }
2301 }
2302
2303 #[test]
2304 fn cached_diagram_ignores_malformed_data() {
2305 let mut parts = ooxml_opc::unzip_parts(&smartart_deck(1, 1)).unwrap();
2306 parts.push(("ppt/diagrams/data0.xml".to_owned(), b"<broken>".to_vec()));
2307 let package = parse_pptx(&ooxml_opc::rezip_parts(&parts).unwrap()).unwrap();
2308 assert_eq!(package.diagram_drawings.len(), 1);
2309 assert_eq!(package.diagram_drawings[0].shapes.len(), 1);
2310 assert_eq!(package.diagram_drawings[0].synthesis_extent, None);
2311 }
2312
2313 #[test]
2314 fn cached_diagram_does_not_charge_data_against_remaining_budget() {
2315 let deck = smartart_deck(1, 1);
2316 let package = parse_pptx(&deck).unwrap();
2317 let mut source = ooxml_opc::unzip_parts(&deck).unwrap();
2318 let drawing = source
2319 .iter()
2320 .find(|(path, _)| path == "ppt/diagrams/drawing0.xml")
2321 .unwrap()
2322 .1
2323 .clone();
2324 for data in [
2325 b"<dataModel/>".to_vec(),
2326 format!("<dataModel>{}</dataModel>", "x".repeat(4_096)).into_bytes(),
2327 ] {
2328 source.retain(|(path, _)| path != "ppt/diagrams/data0.xml");
2329 source.push(("ppt/diagrams/data0.xml".to_owned(), data));
2330 let parts: HashMap<_, _> = source
2331 .iter()
2332 .map(|(path, bytes)| (path.as_str(), bytes.as_slice()))
2333 .collect();
2334 let spent = b"<spent/>";
2335 let limits = ParseLimits {
2336 max_xml_bytes: spent.len() + drawing.len(),
2337 ..ParseLimits::default()
2338 };
2339 let mut budget = ParseBudget::new(&limits);
2340 parse_xml(spent, "spent.xml", &mut budget).unwrap();
2341 let drawings = parse_diagram_drawings(
2342 &parts,
2343 &package.slides,
2344 &package.relationships,
2345 &mut budget,
2346 ShapeElements::WithConnectors,
2347 );
2348 assert_eq!(drawings.len(), 1);
2349 assert_eq!(drawings[0].shapes.len(), 1);
2350 assert_eq!(drawings[0].synthesis_extent, None);
2351 assert!(parse_xml(b"<extra/>", "extra.xml", &mut budget).is_err());
2352 }
2353 }
2354
2355 #[test]
2356 fn cached_diagram_falls_back_from_unusable_explicit_drawing() {
2357 let data = String::from_utf8(BASIC_DIAGRAM_DATA.to_vec()).unwrap().replace(
2358 "</dgm:dataModel>",
2359 r#"<dgm:extLst><dsp:dataModelExt xmlns:dsp="http://schemas.microsoft.com/office/drawing/2008/diagram" relId="rIdDrawing1"/></dgm:extLst></dgm:dataModel>"#,
2360 );
2361 for candidate in [
2362 None,
2363 Some(b"<drawing/>".as_slice()),
2364 Some(b"<broken>".as_slice()),
2365 ] {
2366 let mut parts = ooxml_opc::unzip_parts(&smartart_deck(1, 1)).unwrap();
2367 parts.push((
2368 "ppt/diagrams/data0.xml".to_owned(),
2369 data.as_bytes().to_vec(),
2370 ));
2371 let rels = &mut parts
2372 .iter_mut()
2373 .find(|(path, _)| path == "ppt/slides/_rels/slide1.xml.rels")
2374 .unwrap()
2375 .1;
2376 *rels = String::from_utf8(rels.clone()).unwrap().replace(
2377 "</Relationships>",
2378 r#"<Relationship Id="rIdDrawing1" Type="http://schemas.microsoft.com/office/2007/relationships/diagramDrawing" Target="../diagrams/drawing1.xml"/></Relationships>"#,
2379 ).into_bytes();
2380 if let Some(bytes) = candidate {
2381 parts.push(("ppt/diagrams/drawing1.xml".to_owned(), bytes.to_vec()));
2382 }
2383 let package = parse_pptx(&ooxml_opc::rezip_parts(&parts).unwrap()).unwrap();
2384 assert_eq!(package.diagram_drawings.len(), 1);
2385 assert_eq!(package.diagram_drawings[0].shapes.len(), 1);
2386 assert_eq!(package.diagram_drawings[0].synthesis_extent, None);
2387 }
2388 }
2389
2390 #[test]
2391 fn synthesized_diagram_layout_obeys_xml_limits() {
2392 let layout = format!(
2393 r#"<dgm:layoutDef xmlns:dgm="http://schemas.openxmlformats.org/drawingml/2006/diagram" uniqueId="process">{}<dgm:title>{}</dgm:title>{}</dgm:layoutDef>"#,
2394 "<dgm:layoutNode>".repeat(7),
2395 "x".repeat(4_096),
2396 "</dgm:layoutNode>".repeat(7)
2397 );
2398 let deck = diagram_deck_without_drawing(BASIC_DIAGRAM_DATA, Some(layout.as_bytes()), false);
2399 let package = parse_pptx(&deck).unwrap();
2400 let source = ooxml_opc::unzip_parts(&deck).unwrap();
2401 let parts: HashMap<_, _> = source
2402 .iter()
2403 .map(|(path, bytes)| (path.as_str(), bytes.as_slice()))
2404 .collect();
2405 for limits in [
2406 ParseLimits {
2407 max_xml_bytes: 2_048,
2408 ..ParseLimits::default()
2409 },
2410 ParseLimits {
2411 max_xml_text_bytes: 100,
2412 ..ParseLimits::default()
2413 },
2414 ParseLimits {
2415 max_xml_depth: 7,
2416 ..ParseLimits::default()
2417 },
2418 ] {
2419 let mut budget = ParseBudget::new(&limits);
2420 let drawings = parse_diagram_drawings(
2421 &parts,
2422 &package.slides,
2423 &package.relationships,
2424 &mut budget,
2425 ShapeElements::WithConnectors,
2426 );
2427 assert!(
2428 drawings.is_empty(),
2429 "layout exceeding XML limits must be declined"
2430 );
2431 }
2432 }
2433
2434 #[test]
2435 fn synthesized_diagrams_parse_a_shared_layout_once() {
2436 let layout = br#"<dgm:layoutDef xmlns:dgm="http://schemas.openxmlformats.org/drawingml/2006/diagram" uniqueId="process"/>"#;
2437 let deck = diagram_deck_without_drawing(BASIC_DIAGRAM_DATA, Some(layout), false);
2438 let mut package = parse_pptx(&deck).unwrap();
2439 let mut frame = package.slides[0]
2440 .shapes
2441 .iter()
2442 .find(|shape| matches!(shape, ShapeNode::GraphicFrame(_)))
2443 .unwrap()
2444 .clone();
2445 let ShapeNode::GraphicFrame(value) = &mut frame else {
2446 unreachable!()
2447 };
2448 let GraphicFrameData::Diagram {
2449 drawing_part_path,
2450 relationship_ids,
2451 ..
2452 } = &mut value.data
2453 else {
2454 unreachable!()
2455 };
2456 *drawing_part_path = Some("ppt/diagrams/data2.xml".to_owned());
2457 relationship_ids[0] = "rIdData2".to_owned();
2458 package.slides[0].shapes.push(frame);
2459 let rels = package
2460 .relationships
2461 .get_mut("ppt/slides/slide1.xml")
2462 .unwrap();
2463 let mut data_rel = rels
2464 .iter()
2465 .find(|rel| rel.id == "rIdData1")
2466 .unwrap()
2467 .clone();
2468 data_rel.id = "rIdData2".to_owned();
2469 data_rel.resolved_target = Some("ppt/diagrams/data2.xml".to_owned());
2470 rels.push(data_rel);
2471 let mut source = ooxml_opc::unzip_parts(&deck).unwrap();
2472 source.push((
2473 "ppt/diagrams/data2.xml".to_owned(),
2474 BASIC_DIAGRAM_DATA.to_vec(),
2475 ));
2476 let parts: HashMap<_, _> = source
2477 .iter()
2478 .map(|(path, bytes)| (path.as_str(), bytes.as_slice()))
2479 .collect();
2480 let limits = ParseLimits::default();
2481 let mut expected = ParseBudget::new(&limits);
2482 for path in [
2483 "ppt/diagrams/data1.xml",
2484 "ppt/diagrams/drawing1.xml",
2485 "ppt/diagrams/layout1.xml",
2486 "ppt/diagrams/data2.xml",
2487 ] {
2488 parse_xml(parts[path], path, &mut expected).unwrap();
2489 }
2490 let limits = ParseLimits {
2491 max_xml_events: expected.xml_events_spent(),
2492 ..limits
2493 };
2494 let mut budget = ParseBudget::new(&limits);
2495 let drawings = parse_diagram_drawings(
2496 &parts,
2497 &package.slides,
2498 &package.relationships,
2499 &mut budget,
2500 ShapeElements::WithConnectors,
2501 );
2502 assert_eq!(drawings.len(), 2);
2503 assert!(drawings.iter().all(|drawing| drawing.shapes.len() == 2));
2504 assert_eq!(budget.xml_events_spent(), expected.xml_events_spent());
2505 }
2506
2507 #[test]
2508 fn explicit_diagram_drawing_wins_over_filename_guess() {
2509 for extension in [false, true] {
2510 let data = String::from_utf8(BASIC_DIAGRAM_DATA.to_vec()).unwrap().replace(
2511 "</dgm:dataModel>",
2512 if extension { r#"<dgm:extLst><dsp:dataModelExt xmlns:dsp="http://schemas.microsoft.com/office/drawing/2008/diagram" relId="rIdDrawing2"/></dgm:extLst></dgm:dataModel>"# } else { "</dgm:dataModel>" },
2513 );
2514 let deck = diagram_deck_without_drawing(data.as_bytes(), None, extension);
2515 let mut parts = ooxml_opc::unzip_parts(&deck).unwrap();
2516 let drawing = |name: &str| {
2517 format!(r#"<dsp:drawing xmlns:dsp="http://schemas.microsoft.com/office/drawing/2008/diagram"><dsp:spTree><dsp:sp><dsp:nvSpPr><dsp:cNvPr id="1" name="{name}"/></dsp:nvSpPr><dsp:spPr/></dsp:sp></dsp:spTree></dsp:drawing>"#).into_bytes()
2518 };
2519 parts
2520 .iter_mut()
2521 .find(|(path, _)| path == "ppt/diagrams/drawing1.xml")
2522 .unwrap()
2523 .1 = drawing("Unrelated");
2524 parts.push(("ppt/diagrams/drawing2.xml".to_owned(), drawing("Explicit")));
2525 let rels = &mut parts
2526 .iter_mut()
2527 .find(|(path, _)| path == "ppt/slides/_rels/slide1.xml.rels")
2528 .unwrap()
2529 .1;
2530 *rels = String::from_utf8(rels.clone()).unwrap().replace("</Relationships>", r#"<Relationship Id="rIdDrawing2" Type="http://schemas.microsoft.com/office/2007/relationships/diagramDrawing" Target="../diagrams/drawing2.xml"/></Relationships>"#).into_bytes();
2531 let package = parse_pptx(&ooxml_opc::rezip_parts(&parts).unwrap()).unwrap();
2532 let ShapeNode::Shape(shape) = &package.diagram_drawings[0].shapes[0] else {
2533 panic!("expected drawing shape")
2534 };
2535 assert_eq!(shape.base.name, "Explicit");
2536 }
2537 }
2538
2539 #[test]
2540 fn text_free_diagram_with_empty_drawing_records_an_empty_diagram_drawing() {
2541 let data = br#"<dgm:dataModel xmlns:dgm="http://schemas.openxmlformats.org/drawingml/2006/diagram" xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"><dgm:ptLst><dgm:pt modelId="{0}" type="doc"/><dgm:pt modelId="{1}" type="pres"/><dgm:pt modelId="{2}" type="parTrans"><dgm:t><a:bodyPr/><a:lstStyle/><a:p><a:r><a:t>ignored</a:t></a:r></a:p></dgm:t></dgm:pt></dgm:ptLst><dgm:cxnLst/></dgm:dataModel>"#;
2542 for include_drawing_rel in [true, false] {
2543 let deck = diagram_deck_without_drawing(data, None, include_drawing_rel);
2544 let package = parse_pptx(&deck).unwrap();
2545 assert_eq!(package.diagram_drawings.len(), 1);
2546 assert!(package.diagram_drawings[0].shapes.is_empty());
2547 }
2548 }
2549
2550 #[test]
2551 fn diagram_without_cached_drawing_synthesizes_boxes_from_data_model_and_round_trips() {
2552 let data = br#"<dgm:dataModel xmlns:dgm="http://schemas.openxmlformats.org/drawingml/2006/diagram" xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"><dgm:ptLst><dgm:pt modelId="doc" type="doc"/><dgm:pt modelId="p1"><dgm:t><a:bodyPr/><a:lstStyle/><a:p><a:r><a:t>First header</a:t></a:r></a:p></dgm:t></dgm:pt><dgm:pt modelId="c1"><dgm:t><a:bodyPr/><a:lstStyle/><a:p><a:r><a:t>First child</a:t></a:r></a:p></dgm:t></dgm:pt><dgm:pt modelId="p2"><dgm:t><a:bodyPr/><a:lstStyle/><a:p><a:r><a:t>Second header</a:t></a:r></a:p></dgm:t></dgm:pt><dgm:pt modelId="c2"><dgm:t><a:bodyPr/><a:lstStyle/><a:p><a:r><a:t>Second child</a:t></a:r></a:p></dgm:t></dgm:pt></dgm:ptLst><dgm:cxnLst><dgm:cxn modelId="x1" srcId="doc" destId="p1" srcOrd="0"/><dgm:cxn modelId="x2" srcId="doc" destId="p2" srcOrd="1"/><dgm:cxn modelId="x3" srcId="p1" destId="c1" srcOrd="0"/><dgm:cxn modelId="x4" srcId="p2" destId="c2" srcOrd="0"/></dgm:cxnLst></dgm:dataModel>"#;
2553 let list_layout = br#"<dgm:layoutDef xmlns:dgm="http://schemas.openxmlformats.org/drawingml/2006/diagram" uniqueId="urn:microsoft.com/office/officeart/2005/8/layout/hList1"/>"#;
2554 let deck = diagram_deck_without_drawing(data, Some(list_layout), false);
2555 let package = parse_pptx(&deck).unwrap();
2556 assert_eq!(package.diagram_drawings.len(), 1);
2557 let shapes = &package.diagram_drawings[0].shapes;
2558 assert_eq!(shapes.len(), 2);
2559 let mut texts = Vec::new();
2560 for node in shapes {
2561 let ShapeNode::Shape(shape) = node else {
2562 panic!("expected a synthesized shape");
2563 };
2564 let t = &shape.base.transform;
2565 assert!(t.x >= 0 && t.y >= 0);
2566 assert!(t.width > 0 && t.height > 0);
2567 assert!(t.x + t.width <= 3_657_600);
2568 assert!(t.y + t.height <= 1_828_800);
2569 let body = shape.text.as_ref().expect("shape has text body");
2570 for paragraph in &body.paragraphs {
2571 for run in ¶graph.runs {
2572 texts.push(run.text.clone());
2573 }
2574 }
2575 }
2576 assert_eq!(
2577 texts,
2578 [
2579 "First header",
2580 "First child",
2581 "Second header",
2582 "Second child"
2583 ]
2584 );
2585 assert_untouched_save_round_trip(&deck);
2586
2587 let process_layout = br#"<dgm:layoutDef xmlns:dgm="http://schemas.openxmlformats.org/drawingml/2006/diagram" uniqueId="urn:microsoft.com/office/officeart/2005/8/layout/process5"/>"#;
2588 let process_deck = diagram_deck_without_drawing(data, Some(process_layout), true);
2589 let process_package = parse_pptx(&process_deck).unwrap();
2590 assert_eq!(process_package.diagram_drawings.len(), 1);
2591 assert_eq!(process_package.diagram_drawings[0].shapes.len(), 4);
2592 assert_untouched_save_round_trip(&process_deck);
2593 }
2594}