1use 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
16pub 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
32pub 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
53pub 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 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 }
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 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
573fn 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
600fn 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}