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 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 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 }
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 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
586fn 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
613fn 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}