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