Skip to main content

pptx_parse/
package.rs

1use std::collections::{BTreeMap, HashMap, HashSet};
2
3use ooxml_drawingml::{ColorMap, TableStyleList, Theme};
4
5use crate::chart::parse_chart_part;
6use crate::comments::{
7    Comment, CommentAuthor, CommentFlavor, authors_part, parse_comment_authors, parse_comments,
8    slide_comment_parts,
9};
10use crate::drawing::{
11    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
93/// Preserves pre-connector source ordinals.
94pub 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, &notes_path, &mut budget)?;
173            notes = crate::notes::notes_text(&notes_root);
174            notes_sources.insert(
175                reference.part_path.clone(),
176                crate::inventory::notes_source(&notes_root, &notes_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
685/// The parts a chart may be referenced from, plus the theme cascade that
686/// decides which colours it resolves against.
687struct 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
762/// Every chart the deck references, read against the theme its source part
763/// resolves to.
764///
765/// A chart part is a decorative leaf: nothing structural is read from it and
766/// nothing structural is read after it. So a part `parse_xml` refuses is
767/// skipped, leaving the deck to open with that chart missing exactly as it opens
768/// when the part is absent. `parse_xml` reports only `MalformedXml`, `UnsafeXml`
769/// and `ResourceLimit`, each naming this part; a hostile package is refused
770/// earlier, by `ooxml_opc::unzip_parts`.
771///
772/// What bounds the reading: each part gets its own [`ParseBudget`], so no cached
773/// series can starve a slide or spend another part's depth, byte and text
774/// limits; all of them draw their XML events from one pool the size of
775/// `max_xml_events` ([`read_chart_root`]); and each part is read once however
776/// many themes reference it, the tree being the same for all of them and only
777/// the colours it resolves against differing.
778fn 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
842/// Reads one chart part against its own budget, drawing its XML events from
843/// `remaining_events` — a pool the size of the package's `max_xml_events` that
844/// every chart part shares. A part that empties the pool is declined like a
845/// malformed one, so the deck's charts together cost no more than the one budget
846/// they used to be read against.
847fn 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
869/// Parses every `ppt/diagrams/drawing#.xml` a slide points at, once each,
870/// against what the package budget has left, or synthesizes a basic layout
871/// from `ppt/diagrams/data#.xml` when the cached drawing is missing or empty.
872fn 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
1069/// `(referencing part, relationship id)` for every chart in `shapes`, groups
1070/// included, in document order.
1071fn 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
1192/// The `p:clrMap` in effect for a slide: its own `p:clrMapOvr`, else its
1193/// layout's, else the master's. Every projection resolves scheme colours
1194/// through this one predicate.
1195pub 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
1207/// The theme a slide's colours resolve against: its master's theme part
1208/// carrying [`effective_color_map`].
1209pub 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
1236/// The master a layout belongs to, by relationship then by back-reference.
1237pub 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| &paragraph.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        // The grouped chart proves the reference walk descends into groups.
1612        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    /// Three slides sharing `ppt/charts/chart1.xml`, resolving to two themes.
1621    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    /// A chart part parses to the same tree whatever theme it is coloured
1797    /// against, so it is read once: an event pool only one read fits still
1798    /// resolves the chart against both themes.
1799    #[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    /// `chart-deck.pptx` with `part`'s bytes replaced.
1826    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    /// Opens a deck whose first chart part carries `bytes` and asserts the deck
1837    /// is whole apart from that one chart.
1838    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>&nbsp;</c:title></c:chart></c:chartSpace>"#,
1907        );
1908    }
1909
1910    /// Declining to read a chart part must not stop the package from carrying
1911    /// it: an untouched save still writes the source bytes back.
1912    #[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    /// A chart part whose only fault is a large cache. Every point costs the
1932    /// reader five events.
1933    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    /// `chart-deck.pptx` with charts of `points` cached values and `shapes`
1945    /// more shapes on the second slide, so charts and slides both cost real
1946    /// events.
1947    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    /// The undamaged deck that used to be refused: spec-valid charts whose
1972    /// caches, added to what the slides cost, outgrow one budget. The charts
1973    /// draw from a pool of their own, so slides and charts both come through.
1974    #[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    /// The pool the charts share is one budget for the whole deck, so a chart
2004    /// that empties it leaves the next one declined like a damaged part — and
2005    /// the deck still opens.
2006    #[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    /// A deck of SmartArt whose drawings each hold `shapes` shapes, `drawings` of them.
2038    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    /// Every SmartArt drawing draws from the one package budget, so a deck of
2083    /// many cannot parse past it; the drawings that would are declined and the
2084    /// deck still opens.
2085    #[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 &paragraph.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}