Skip to main content

ppt_rs/core/package_validation/
rules.rs

1//! Composable validation rules for generated PPTX packages.
2
3use std::collections::{HashMap, HashSet};
4use std::io::{Read, Seek};
5
6use zip::ZipArchive;
7
8use crate::core::validation::validate_well_formed_xml;
9use crate::generator::package_xml::{first_slide_rel_id, slide_id_value};
10
11use super::context::PackageContext;
12use super::report::{
13    PackageValidationIssue, PackageValidationReport, ValidationCategory,
14};
15
16/// Parts PowerPoint expects in every generated deck.
17pub const REQUIRED_PACKAGE_PARTS: &[&str] = &[
18    "[Content_Types].xml",
19    "_rels/.rels",
20    "ppt/presentation.xml",
21    "ppt/_rels/presentation.xml.rels",
22    "ppt/presProps.xml",
23    "ppt/viewProps.xml",
24    "ppt/tableStyles.xml",
25    "ppt/theme/theme1.xml",
26    "ppt/slideMasters/slideMaster1.xml",
27    "ppt/slideLayouts/slideLayout1.xml",
28    "docProps/core.xml",
29    "docProps/app.xml",
30];
31
32/// Validate a PPTX archive using all structural rules.
33pub fn validate_package<R: Read + Seek>(archive: &mut ZipArchive<R>) -> PackageValidationReport {
34    let mut ctx = PackageContext::from_archive(archive);
35    let mut report = PackageValidationReport::default();
36
37    check_required_parts(&ctx, &mut report);
38    check_xml_parts(&mut ctx, archive, &mut report);
39    check_relationship_targets(&ctx, &mut report);
40    check_content_types(&mut ctx, archive, &mut report);
41    check_presentation_structure(&mut ctx, archive, &mut report);
42    check_slide_master(&mut ctx, archive, &mut report);
43    check_theme(&mut ctx, archive, &mut report);
44    check_chart_packages(&mut ctx, archive, &mut report);
45    check_slide_relationships(&mut ctx, archive, &mut report);
46    check_notes_slides(&ctx, &mut report);
47    check_handout_package(&mut ctx, archive, &mut report);
48    check_notes_master(&mut ctx, archive, &mut report);
49
50    report
51}
52
53/// Validate PPTX bytes (opens a ZIP archive internally).
54pub fn validate_package_bytes(bytes: &[u8]) -> PackageValidationReport {
55    let cursor = std::io::Cursor::new(bytes);
56    let mut archive = match ZipArchive::new(cursor) {
57        Ok(a) => a,
58        Err(e) => {
59            let mut report = PackageValidationReport::default();
60            report.push(PackageValidationIssue::error(
61                ValidationCategory::Xml,
62                format!("Invalid ZIP archive: {e}"),
63                None,
64            ));
65            return report;
66        }
67    };
68    validate_package(&mut archive)
69}
70
71fn check_required_parts(ctx: &PackageContext, report: &mut PackageValidationReport) {
72    for part in REQUIRED_PACKAGE_PARTS {
73        if !ctx.has_part(part) {
74            report.push(PackageValidationIssue::error(
75                ValidationCategory::MissingPart,
76                format!("Missing required part: {part}"),
77                Some(part),
78            ));
79        }
80    }
81}
82
83fn check_xml_parts<R: Read + Seek>(
84    ctx: &mut PackageContext,
85    archive: &mut ZipArchive<R>,
86    report: &mut PackageValidationReport,
87) {
88    let xml_parts: Vec<String> = ctx
89        .names
90        .iter()
91        .filter(|n| n.ends_with(".xml") || n.ends_with(".rels"))
92        .cloned()
93        .collect();
94
95    for path in xml_parts {
96        if let Some(content) = ctx.read_part(archive, &path)
97            && let Err(e) = validate_well_formed_xml(&content) {
98                report.push(PackageValidationIssue::error(
99                    ValidationCategory::Xml,
100                    e.to_string(),
101                    Some(&path),
102                ));
103            }
104    }
105}
106
107fn check_relationship_targets(ctx: &PackageContext, report: &mut PackageValidationReport) {
108    for (rels_path, relationships) in ctx.relationship_parts() {
109        for rel in relationships {
110            // External relationships (e.g. hyperlinks to URLs) point outside the
111            // package and therefore have no resolvable part to validate.
112            if rel.target_mode == "External" {
113                continue;
114            }
115            if !ctx.has_part(&rel.resolved) {
116                report.push(PackageValidationIssue::error(
117                    ValidationCategory::Relationship,
118                    format!(
119                        "{} Id=\"{}\" Target=\"{}\" resolves to missing part \"{}\"",
120                        rels_path, rel.id, rel.target, rel.resolved
121                    ),
122                    Some(rels_path),
123                ));
124            }
125        }
126    }
127}
128
129fn check_content_types<R: Read + Seek>(
130    ctx: &mut PackageContext,
131    archive: &mut ZipArchive<R>,
132    report: &mut PackageValidationReport,
133) {
134    let Some(ct) = ctx.read_part(archive, "[Content_Types].xml") else {
135        return;
136    };
137
138    for path in &ctx.names {
139        if path.ends_with(".rels") || path == "[Content_Types].xml" {
140            continue;
141        }
142        let override_path = format!("/{path}");
143        if ct.contains(&override_path) {
144            continue;
145        }
146        let ext = path.rsplit('.').next().unwrap_or("");
147        let default_needle = format!("Extension=\"{ext}\"");
148        if ext.is_empty() || !ct.contains(&default_needle) {
149            report.push(PackageValidationIssue::error(
150                ValidationCategory::ContentType,
151                format!("Part {path} has no Override or Default in [Content_Types].xml"),
152                Some(path),
153            ));
154        }
155    }
156}
157
158fn check_presentation_structure<R: Read + Seek>(
159    ctx: &mut PackageContext,
160    archive: &mut ZipArchive<R>,
161    report: &mut PackageValidationReport,
162) {
163    let Some(pres) = ctx.read_part(archive, "ppt/presentation.xml") else {
164        return;
165    };
166    let pres_rels = ctx.relationships("ppt/_rels/presentation.xml.rels");
167    let rel_by_id: HashMap<&str, &str> = pres_rels
168        .iter()
169        .map(|r| (r.id.as_str(), r.resolved.as_str()))
170        .collect();
171
172    let sld_id_count = pres.matches("<p:sldId ").count();
173
174    if sld_id_count > 0 {
175        let first_id = format!("id=\"{}\"", slide_id_value(1));
176        if !pres.contains(&first_id) {
177            report.push(PackageValidationIssue::error(
178                ValidationCategory::Presentation,
179                format!(
180                    "First slide id should be {} (PowerPoint convention)",
181                    slide_id_value(1)
182                ),
183                Some("ppt/presentation.xml"),
184            ));
185        }
186
187        let has_notes = pres.contains("<p:notesMasterIdLst>");
188        let has_handout = pres.contains("<p:handoutMasterIdLst>");
189        let expected_first_rid = first_slide_rel_id(has_notes, has_handout);
190        let first_rid = format!(r#"r:id="rId{expected_first_rid}""#);
191        if pres.contains("<p:sldIdLst>") && !pres.contains(&first_rid) {
192            report.push(PackageValidationIssue::error(
193                ValidationCategory::Presentation,
194                format!(
195                    "First slide r:id should be rId{expected_first_rid} for this master's optional parts"
196                ),
197                Some("ppt/presentation.xml"),
198            ));
199        }
200    }
201
202    validate_presentation_rel_order(pres_rels, report);
203
204    for sld_rid in extract_attr_values(&pres, "p:sldId", "r:id") {
205        let target = rel_by_id.get(sld_rid.as_str());
206        match target {
207            Some(t) if t.contains("/slides/slide") => {}
208            _ => report.push(PackageValidationIssue::error(
209                ValidationCategory::Presentation,
210                format!("Slide r:id=\"{sld_rid}\" does not resolve to a slide part"),
211                Some("ppt/presentation.xml"),
212            )),
213        }
214    }
215
216    let slide_parts: usize = ctx
217        .names
218        .iter()
219        .filter(|n| is_slide_part(n))
220        .count();
221    if slide_parts != sld_id_count {
222        report.push(PackageValidationIssue::error(
223            ValidationCategory::Presentation,
224            format!(
225                "Slide count mismatch: {slide_parts} slide parts vs {sld_id_count} p:sldId entries"
226            ),
227            Some("ppt/presentation.xml"),
228        ));
229    }
230}
231
232fn validate_presentation_rel_order(
233    pres_rels: &[super::context::Relationship],
234    report: &mut PackageValidationReport,
235) {
236    let rels_xml = pres_rels
237        .iter()
238        .map(|r| format!("Id=\"{}\" Target=\"{}\"", r.id, r.target))
239        .collect::<Vec<_>>()
240        .join(" ");
241
242    if !rels_xml.contains("Id=\"rId1\"") || !rels_xml.contains("slideMaster") {
243        report.push(PackageValidationIssue::error(
244            ValidationCategory::Relationship,
245            "presentation.xml.rels missing rId1 (slideMaster)",
246            Some("ppt/_rels/presentation.xml.rels"),
247        ));
248    }
249
250    let slide_pos = rels_xml.find("slides/slide");
251    let pres_props_pos = rels_xml.find("presProps");
252    if let (Some(s), Some(p)) = (slide_pos, pres_props_pos)
253        && s > p {
254            report.push(PackageValidationIssue::error(
255                ValidationCategory::Relationship,
256                "Slide relationships should appear before presProps in presentation.xml.rels",
257                Some("ppt/_rels/presentation.xml.rels"),
258            ));
259        }
260
261    let table_pos = rels_xml.find("tableStyles");
262    let theme_pos = rels_xml.find("theme/theme1");
263    if let (Some(t), Some(tb)) = (theme_pos, table_pos)
264        && t > tb {
265            report.push(PackageValidationIssue::error(
266                ValidationCategory::Relationship,
267                "theme should appear before tableStyles in presentation.xml.rels",
268                Some("ppt/_rels/presentation.xml.rels"),
269            ));
270        }
271}
272
273fn check_slide_master<R: Read + Seek>(
274    ctx: &mut PackageContext,
275    archive: &mut ZipArchive<R>,
276    report: &mut PackageValidationReport,
277) {
278    let Some(master) = ctx.read_part(archive, "ppt/slideMasters/slideMaster1.xml") else {
279        return;
280    };
281
282    if !master.contains("<p:txStyles>") {
283        report.push(PackageValidationIssue::error(
284            ValidationCategory::SlideMaster,
285            "Slide master missing p:txStyles",
286            Some("ppt/slideMasters/slideMaster1.xml"),
287        ));
288    }
289
290    let Some(pres) = ctx.read_part(archive, "ppt/presentation.xml") else {
291        return;
292    };
293    check_master_id_uniqueness(&pres, &master, report);
294
295    let master_rels = ctx.relationships("ppt/slideMasters/_rels/slideMaster1.xml.rels");
296    let rel_by_id: HashMap<&str, &str> = master_rels
297        .iter()
298        .map(|r| (r.id.as_str(), r.resolved.as_str()))
299        .collect();
300
301    for layout_rid in extract_attr_values(&master, "p:sldLayoutId", "r:id") {
302        let target = rel_by_id.get(layout_rid.as_str());
303        match target {
304            Some(t) if t.contains("slideLayout") => {}
305            _ => report.push(PackageValidationIssue::error(
306                ValidationCategory::SlideMaster,
307                format!("Layout r:id=\"{layout_rid}\" does not resolve to a slideLayout part"),
308                Some("ppt/slideMasters/slideMaster1.xml"),
309            )),
310        }
311    }
312
313    // PowerPoint omits id on notes/handout master entries; flag collisions if present.
314}
315
316fn check_master_id_uniqueness(
317    pres: &str,
318    master: &str,
319    report: &mut PackageValidationReport,
320) {
321    let layout_ids: HashSet<String> = extract_attr_values(master, "p:sldLayoutId", "id")
322        .into_iter()
323        .collect();
324
325    for (tag, label) in [
326        ("notesMasterId", "notes master"),
327        ("handoutMasterId", "handout master"),
328    ] {
329        for id in extract_attr_values(pres, tag, "id") {
330            if layout_ids.contains(&id) {
331                report.push(PackageValidationIssue::error(
332                    ValidationCategory::SlideMaster,
333                    format!("{label} id {id} collides with a slide layout id on the slide master"),
334                    Some("ppt/presentation.xml"),
335                ));
336            }
337        }
338    }
339}
340
341fn check_theme<R: Read + Seek>(
342    ctx: &mut PackageContext,
343    archive: &mut ZipArchive<R>,
344    report: &mut PackageValidationReport,
345) {
346    let mut buf = Vec::new();
347    if let Ok(mut file) = archive.by_name("ppt/theme/theme1.xml")
348        && file.read_to_end(&mut buf).is_ok() && buf.len() < 7000 {
349            report.push(PackageValidationIssue::error(
350                ValidationCategory::Theme,
351                format!(
352                    "Theme part too small ({} bytes); PowerPoint expects full Office theme",
353                    buf.len()
354                ),
355                Some("ppt/theme/theme1.xml"),
356            ));
357        }
358    let _ = ctx;
359}
360
361fn check_chart_packages<R: Read + Seek>(
362    ctx: &mut PackageContext,
363    archive: &mut ZipArchive<R>,
364    report: &mut PackageValidationReport,
365) {
366    let chart_parts: Vec<String> = ctx
367        .names
368        .iter()
369        .filter(|n| n.starts_with("ppt/charts/chart") && n.ends_with(".xml"))
370        .cloned()
371        .collect();
372
373    for chart_path in chart_parts {
374        let rels_path = chart_path.replace("ppt/charts/", "ppt/charts/_rels/") + ".rels";
375        if !ctx.has_part(&rels_path) {
376            report.push(PackageValidationIssue::error(
377                ValidationCategory::Chart,
378                format!("Chart missing rels: {rels_path}"),
379                Some(&chart_path),
380            ));
381            continue;
382        }
383
384        let rels = ctx.relationships(&rels_path);
385        let has_embedding = rels.iter().any(|r| r.target.contains("embeddings/"));
386        if !has_embedding {
387            report.push(PackageValidationIssue::error(
388                ValidationCategory::Chart,
389                format!("{rels_path} missing package relationship to embedding"),
390                Some(&rels_path),
391            ));
392        }
393
394        if let Some(chart_xml) = ctx.read_part(archive, &chart_path)
395            && !chart_xml.contains("<c:externalData") {
396                report.push(PackageValidationIssue::error(
397                    ValidationCategory::Chart,
398                    format!("{chart_path} missing c:externalData"),
399                    Some(&chart_path),
400                ));
401            }
402
403        let idx = chart_path
404            .trim_start_matches("ppt/charts/chart")
405            .trim_end_matches(".xml");
406        let embedding = format!("ppt/embeddings/Microsoft_Excel_Sheet{idx}.xlsx");
407        if !ctx.has_part(&embedding) {
408            report.push(PackageValidationIssue::error(
409                ValidationCategory::Chart,
410                format!("Chart missing embedding: {embedding}"),
411                Some(&chart_path),
412            ));
413        }
414    }
415}
416
417fn check_slide_relationships<R: Read + Seek>(
418    ctx: &mut PackageContext,
419    archive: &mut ZipArchive<R>,
420    report: &mut PackageValidationReport,
421) {
422    let slide_paths: Vec<String> = ctx
423        .names
424        .iter()
425        .filter(|n| is_slide_part(n))
426        .cloned()
427        .collect();
428
429    for slide_path in slide_paths {
430        let rels_path = slide_path.replace("ppt/slides/", "ppt/slides/_rels/") + ".rels";
431        let rels = ctx.relationships(&rels_path);
432        let rel_map: HashMap<String, (String, String)> = rels
433            .iter()
434            .map(|r| (r.id.clone(), (r.resolved.clone(), r.target_mode.clone())))
435            .collect();
436
437        let Some(slide_xml) = ctx.read_part(archive, &slide_path) else {
438            continue;
439        };
440
441        for rid in extract_rel_reference_ids(&slide_xml) {
442            let Some((target, target_mode)) = rel_map.get(&rid) else {
443                report.push(PackageValidationIssue::error(
444                    ValidationCategory::Slide,
445                    format!("{slide_path} references {rid} but {rels_path} has no such relationship"),
446                    Some(&slide_path),
447                ));
448                continue;
449            };
450            // External relationships (e.g. URL hyperlinks) have no in-package target.
451            if target_mode == "External" {
452                continue;
453            }
454            if !ctx.has_part(target) {
455                report.push(PackageValidationIssue::error(
456                    ValidationCategory::Slide,
457                    format!("{slide_path} {rid} target \"{target}\" is missing from the package"),
458                    Some(&slide_path),
459                ));
460            }
461        }
462
463        let mut shape_ids: HashSet<String> = HashSet::new();
464        for id in extract_attr_values(&slide_xml, "p:cNvPr", "id") {
465            if !shape_ids.insert(id.clone()) {
466                report.push(PackageValidationIssue::error(
467                    ValidationCategory::Slide,
468                    format!("{slide_path} has duplicate shape id \"{id}\""),
469                    Some(&slide_path),
470                ));
471            }
472        }
473    }
474}
475
476fn check_handout_package<R: Read + Seek>(
477    ctx: &mut PackageContext,
478    archive: &mut ZipArchive<R>,
479    report: &mut PackageValidationReport,
480) {
481    if !ctx.has_part("ppt/handoutMasters/handoutMaster1.xml") {
482        return;
483    }
484
485    if let Some(pres_props) = ctx.read_part(archive, "ppt/presProps.xml")
486        && pres_props.contains("<p:prnPr") {
487            report.push(PackageValidationIssue::error(
488                ValidationCategory::Presentation,
489                "presProps.xml must not contain p:prnPr when a handout master is packaged",
490                Some("ppt/presProps.xml"),
491            ));
492        }
493
494    let handout_rels = ctx.relationships("ppt/handoutMasters/_rels/handoutMaster1.xml.rels");
495    let has_theme3 = handout_rels
496        .iter()
497        .any(|r| r.resolved.contains("theme/theme3.xml"));
498    if !has_theme3 {
499        report.push(PackageValidationIssue::error(
500            ValidationCategory::Relationship,
501            "handout master should reference theme/theme3.xml",
502            Some("ppt/handoutMasters/_rels/handoutMaster1.xml.rels"),
503        ));
504    }
505
506    if let Some(handout) = ctx.read_part(archive, "ppt/handoutMasters/handoutMaster1.xml")
507        && !handout.contains("<p:bg>") {
508            report.push(PackageValidationIssue::error(
509                ValidationCategory::SlideMaster,
510                "handout master missing slide background",
511                Some("ppt/handoutMasters/handoutMaster1.xml"),
512            ));
513        }
514}
515
516fn check_notes_master<R: Read + Seek>(
517    ctx: &mut PackageContext,
518    archive: &mut ZipArchive<R>,
519    report: &mut PackageValidationReport,
520) {
521    if !ctx.has_part("ppt/notesMasters/notesMaster1.xml") {
522        return;
523    }
524
525    let notes_rels = ctx.relationships("ppt/notesMasters/_rels/notesMaster1.xml.rels");
526    let has_theme2 = notes_rels
527        .iter()
528        .any(|r| r.resolved.contains("theme/theme2.xml"));
529    if !has_theme2 {
530        report.push(PackageValidationIssue::error(
531            ValidationCategory::Relationship,
532            "notes master should reference theme/theme2.xml",
533            Some("ppt/notesMasters/_rels/notesMaster1.xml.rels"),
534        ));
535    }
536
537    if let Some(notes) = ctx.read_part(archive, "ppt/notesMasters/notesMaster1.xml")
538        && !notes.contains("<p:bg>") {
539            report.push(PackageValidationIssue::error(
540                ValidationCategory::SlideMaster,
541                "notes master missing slide background",
542                Some("ppt/notesMasters/notesMaster1.xml"),
543            ));
544        }
545}
546
547fn check_notes_slides(ctx: &PackageContext, report: &mut PackageValidationReport) {
548    for (rels_path, rels) in ctx.relationship_parts() {
549        if !rels_path.starts_with("ppt/slides/_rels/") {
550            continue;
551        }
552        for rel in rels {
553            if rel.resolved.contains("notesSlides/notesSlide") && !ctx.has_part(&rel.resolved) {
554                report.push(PackageValidationIssue::error(
555                    ValidationCategory::Slide,
556                    format!(
557                        "{rels_path} references notes slide \"{}\" which is missing",
558                        rel.resolved
559                    ),
560                    Some(rels_path),
561                ));
562            }
563        }
564    }
565}
566
567fn is_slide_part(path: &str) -> bool {
568    path.starts_with("ppt/slides/slide")
569        && path.ends_with(".xml")
570        && !path.contains("/_rels/")
571}
572
573/// Extract `attr="value"` from elements like `<p:sldId id="256" r:id="rId6"/>`.
574fn extract_attr_values(xml: &str, element: &str, attr: &str) -> Vec<String> {
575    let mut values = Vec::new();
576    let needle = format!("<{element}");
577    let attr_needle = format!("{attr}=\"");
578    let mut search_from = 0;
579
580    while let Some(start) = xml[search_from..].find(&needle) {
581        let abs = search_from + start;
582        let end = xml[abs..]
583            .find("/>")
584            .or_else(|| xml[abs..].find("</"))
585            .map(|i| abs + i)
586            .unwrap_or(xml.len());
587        let slice = &xml[abs..end];
588        if let Some(attr_start) = slice.find(&attr_needle) {
589            let val_start = attr_start + attr_needle.len();
590            if let Some(val_end) = slice[val_start..].find('"') {
591                values.push(slice[val_start..val_start + val_end].to_string());
592            }
593        }
594        search_from = end;
595    }
596
597    values
598}
599
600/// Collect `r:embed` and chart-style `r:id` references from slide XML.
601fn extract_rel_reference_ids(slide_xml: &str) -> Vec<String> {
602    let mut ids = Vec::new();
603    for prefix in ["r:embed=\"", "r:id=\""] {
604        let mut search_from = 0;
605        while let Some(start) = slide_xml[search_from..].find(prefix) {
606            let abs = search_from + start + prefix.len();
607            if let Some(end) = slide_xml[abs..].find('"') {
608                let id = &slide_xml[abs..abs + end];
609                if id.starts_with("rId") {
610                    ids.push(id.to_string());
611                }
612            }
613            search_from = abs;
614        }
615    }
616    ids.sort();
617    ids.dedup();
618    ids
619}
620
621#[cfg(test)]
622mod tests {
623    use super::*;
624    use crate::generator::create_pptx;
625
626    #[test]
627    fn minimal_generated_deck_passes_all_rules() {
628        let bytes = create_pptx("Rules", 2).unwrap();
629        let report = validate_package_bytes(&bytes);
630        assert!(
631            report.is_valid(),
632            "expected valid package, got: {:?}",
633            report.issues
634        );
635    }
636
637    #[test]
638    fn extract_rel_reference_ids_dedupes() {
639        let xml = r#"<p:sld><p:pic><a:blip r:embed="rId2"/></p:pic><c:chart r:id="rId3"/></p:sld>"#;
640        assert_eq!(
641            extract_rel_reference_ids(xml),
642            vec!["rId2".to_string(), "rId3".to_string()]
643        );
644    }
645}