""" Validator for PowerPoint presentation XML files against XSD schemas. """ import re from pathlib import Path from helpers import opc_target, rels_source_part, safe_extract from .base import BaseSchemaValidator class PPTXSchemaValidator(BaseSchemaValidator): PRESENTATIONML_NAMESPACE = ( "http://schemas.openxmlformats.org/presentationml/2006/main" ) ELEMENT_RELATIONSHIP_TYPES = { "sldid": "slide", "sldmasterid": "slidemaster", "notesmasterid": "notesmaster", "sldlayoutid": "slidelayout", "themeid": "theme", "tablestyleid": "tablestyles", } def validate(self): if not self.validate_xml(): return False all_valid = True if not self.validate_namespaces(): all_valid = False if not self.validate_unique_ids(): all_valid = False if not self.validate_uuid_ids(): all_valid = False if not self.validate_file_references(): all_valid = False if not self.validate_slide_layout_ids(): all_valid = False if not self.validate_content_types(): all_valid = False if not self.validate_against_xsd(): all_valid = False if not self.validate_notes_slide_references(): all_valid = False if not self.validate_all_relationship_ids(): all_valid = False if not self.validate_no_duplicate_slide_layouts(): all_valid = False if not self.validate_master_theme_uniqueness(): all_valid = False if not self.validate_charts(): all_valid = False if not self.validate_slides(): all_valid = False return all_valid def _package_map(self) -> dict: wanted = [] wanted += list(self.unpacked_dir.glob("[[]Content_Types[]].xml")) wanted += list(self.unpacked_dir.glob("ppt/presentation.xml")) wanted += list(self.unpacked_dir.glob("ppt/theme/*.xml")) wanted += list(self.unpacked_dir.glob("ppt/theme/_rels/*.rels")) wanted += list(self.unpacked_dir.glob("ppt/charts/chart*.xml")) for group in ("slideMasters", "notesMasters", "handoutMasters"): wanted += list(self.unpacked_dir.glob(f"ppt/{group}/*.xml")) wanted += list(self.unpacked_dir.glob(f"ppt/{group}/_rels/*.rels")) return { p.relative_to(self.unpacked_dir).as_posix(): p.read_bytes() for p in wanted if p.is_file() } def validate_master_theme_uniqueness(self): from helpers.pptx_theme import _NOTES_MASTERS, live_shared_master_themes shared = live_shared_master_themes(self._package_map()) if shared: print(f"FAILED - Found {len(shared)} master(s) sharing a theme part:") for message in shared: print(f" {message}") if any(m.startswith(_NOTES_MASTERS) for m in shared): print(" Fix: in ppt/presentation.xml, move back to " "directly after . PowerPoint reads that happily.") else: print(" Fix: give each master its own theme part.") return False if self.verbose: print("PASSED - No master shares a theme part in a way PowerPoint refuses") return True def validate_charts(self): from helpers.pptx_chart import find_chart_problems problems = find_chart_problems(self._package_map()) if problems: print(f"FAILED - Found {len(problems)} chart problem(s) PowerPoint rejects:") for message in problems: print(f" {message}") return False if self.verbose: print("PASSED - Charts satisfy the constraints PowerPoint enforces") return True def _original_slide_defects(self, schema) -> set[str]: import tempfile import zipfile from helpers.pptx_slide import SLIDE_PART_RE, fatal_slide_errors if self.original_file is None: return set() found: set[str] = set() with tempfile.TemporaryDirectory() as temp_dir: temp_path = Path(temp_dir) try: with zipfile.ZipFile(self.original_file, "r") as zf: safe_extract(zf, temp_path) except (zipfile.BadZipFile, ValueError, OSError): return set() for part in sorted(temp_path.rglob("*.xml")): relative = part.relative_to(temp_path).as_posix() if not SLIDE_PART_RE.fullmatch(relative): continue ok, errors = self._validate_single_file_xsd( part.resolve(), temp_path.resolve(), schema_path=schema ) if ok is None or ok or not errors: continue found |= set(fatal_slide_errors(set(errors))) return found def validate_slides(self): from helpers.pptx_slide import ( SLIDE_PART_RE, fatal_slide_errors, is_schema_verdict, ) schema = self.schemas_dir / self.SCHEMA_MAPPINGS["ppt"] inherited = self._original_slide_defects(schema) problems: list[str] = [] broken: list[str] = [] for xml_file in self.xml_files: relative = xml_file.relative_to(self.unpacked_dir).as_posix() if not SLIDE_PART_RE.fullmatch(relative): continue ok, errors = self._validate_single_file_xsd( xml_file.resolve(), self.unpacked_dir.resolve(), schema_path=schema ) if ok is None or not errors: continue unreadable = [f"{relative}: {e}" for e in errors if not is_schema_verdict(e)] if unreadable: broken.extend(unreadable) continue if ok: continue for message in fatal_slide_errors(set(errors)): if message in inherited: continue problems.append(f"{relative}: {message}") if broken: print(f"FAILED - Could not check {len(broken)} slide part(s):") for message in sorted(broken): print(f" {message[:240]}") if problems: print(f"FAILED - Found {len(problems)} slide problem(s) PowerPoint rejects:") for message in sorted(problems): print(f" {message[:240]}") if broken or problems: return False if self.verbose: print("PASSED - Slide XML has none of the defects PowerPoint refuses") return True def _get_schema_path(self, xml_file): if xml_file.parent.name == "charts" and xml_file.name.startswith("chart"): return None return super()._get_schema_path(xml_file) def _preprocess_for_schema(self, xml_doc, relative_path): if relative_path.as_posix() != "ppt/presentation.xml": return xml_doc root = xml_doc.getroot() ns = f"{{{self.PRESENTATIONML_NAMESPACE}}}" notes = root.find(f"{ns}notesMasterIdLst") slides = root.find(f"{ns}sldIdLst") if notes is None or slides is None: return xml_doc children = list(root) if children.index(notes) < children.index(slides): return xml_doc root.remove(notes) root.insert(list(root).index(slides), notes) return xml_doc def validate_uuid_ids(self): import lxml.etree errors = [] uuid_pattern = re.compile( r"^[\{\(]?[0-9A-Fa-f]{8}-?[0-9A-Fa-f]{4}-?[0-9A-Fa-f]{4}-?[0-9A-Fa-f]{4}-?[0-9A-Fa-f]{12}[\}\)]?$" ) for xml_file in self.xml_files: try: root = lxml.etree.parse(str(xml_file)).getroot() for elem in root.iter(): for attr, value in elem.attrib.items(): attr_name = attr.split("}")[-1].lower() if attr_name == "id" or attr_name.endswith("id"): if self._looks_like_uuid(value): if not uuid_pattern.match(value): errors.append( f" {xml_file.relative_to(self.unpacked_dir)}: " f"Line {elem.sourceline}: ID '{value}' appears to be a UUID but contains invalid hex characters" ) except (lxml.etree.XMLSyntaxError, Exception) as e: errors.append( f" {xml_file.relative_to(self.unpacked_dir)}: Error: {e}" ) if errors: print(f"FAILED - Found {len(errors)} UUID ID validation errors:") for error in errors: print(error) return False else: if self.verbose: print("PASSED - All UUID-like IDs contain valid hex values") return True def _looks_like_uuid(self, value): clean_value = value.strip("{}()").replace("-", "") return len(clean_value) == 32 and all(c.isalnum() for c in clean_value) def validate_slide_layout_ids(self): import lxml.etree errors = [] slide_masters = list(self.unpacked_dir.glob("ppt/slideMasters/*.xml")) if not slide_masters: if self.verbose: print("PASSED - No slide masters found") return True for slide_master in slide_masters: try: root = lxml.etree.parse(str(slide_master)).getroot() rels_file = slide_master.parent / "_rels" / f"{slide_master.name}.rels" if not rels_file.exists(): errors.append( f" {slide_master.relative_to(self.unpacked_dir)}: " f"Missing relationships file: {rels_file.relative_to(self.unpacked_dir)}" ) continue rels_root = lxml.etree.parse(str(rels_file)).getroot() valid_layout_rids = set() for rel in rels_root.findall( f".//{{{self.PACKAGE_RELATIONSHIPS_NAMESPACE}}}Relationship" ): rel_type = rel.get("Type", "") if "slideLayout" in rel_type: valid_layout_rids.add(rel.get("Id")) for sld_layout_id in root.findall( f".//{{{self.PRESENTATIONML_NAMESPACE}}}sldLayoutId" ): r_id = sld_layout_id.get( f"{{{self.OFFICE_RELATIONSHIPS_NAMESPACE}}}id" ) layout_id = sld_layout_id.get("id") if r_id and r_id not in valid_layout_rids: errors.append( f" {slide_master.relative_to(self.unpacked_dir)}: " f"Line {sld_layout_id.sourceline}: sldLayoutId with id='{layout_id}' " f"references r:id='{r_id}' which is not found in slide layout relationships" ) except (lxml.etree.XMLSyntaxError, Exception) as e: errors.append( f" {slide_master.relative_to(self.unpacked_dir)}: Error: {e}" ) if errors: print(f"FAILED - Found {len(errors)} slide layout ID validation errors:") for error in errors: print(error) print( "Remove invalid references or add missing slide layouts to the relationships file." ) return False else: if self.verbose: print("PASSED - All slide layout IDs reference valid slide layouts") return True def validate_no_duplicate_slide_layouts(self): import lxml.etree errors = [] slide_rels_files = list(self.unpacked_dir.glob("ppt/slides/_rels/*.xml.rels")) for rels_file in slide_rels_files: try: root = lxml.etree.parse(str(rels_file)).getroot() layout_rels = [ rel for rel in root.findall( f".//{{{self.PACKAGE_RELATIONSHIPS_NAMESPACE}}}Relationship" ) if "slideLayout" in rel.get("Type", "") ] if len(layout_rels) > 1: errors.append( f" {rels_file.relative_to(self.unpacked_dir)}: has {len(layout_rels)} slideLayout references" ) except Exception as e: errors.append( f" {rels_file.relative_to(self.unpacked_dir)}: Error: {e}" ) if errors: print("FAILED - Found slides with duplicate slideLayout references:") for error in errors: print(error) return False else: if self.verbose: print("PASSED - All slides have exactly one slideLayout reference") return True def validate_notes_slide_references(self): import lxml.etree errors = [] notes_slide_references = {} slide_rels_files = list(self.unpacked_dir.glob("ppt/slides/_rels/*.xml.rels")) if not slide_rels_files: if self.verbose: print("PASSED - No slide relationship files found") return True for rels_file in slide_rels_files: try: root = lxml.etree.parse(str(rels_file)).getroot() for rel in root.findall( f".//{{{self.PACKAGE_RELATIONSHIPS_NAMESPACE}}}Relationship" ): rel_type = rel.get("Type", "") if "notesSlide" in rel_type: part = opc_target( rel.get("Target", ""), rels_source_part(rels_file, self.unpacked_dir), rel.get("TargetMode", ""), ) if part: slide_name = rels_file.stem.replace( ".xml", "" ) notes_slide_references.setdefault(part, []).append( (slide_name, rels_file) ) except (lxml.etree.XMLSyntaxError, Exception) as e: errors.append( f" {rels_file.relative_to(self.unpacked_dir)}: Error: {e}" ) for target, references in notes_slide_references.items(): if len(references) > 1: slide_names = [ref[0] for ref in references] errors.append( f" Notes slide '{target}' is referenced by multiple slides: {', '.join(slide_names)}" ) for slide_name, rels_file in references: errors.append(f" - {rels_file.relative_to(self.unpacked_dir)}") if errors: print( f"FAILED - Found {len([e for e in errors if not e.startswith(' ')])} notes slide reference validation errors:" ) for error in errors: print(error) print("Each slide may optionally have its own slide file.") return False else: if self.verbose: print("PASSED - All notes slide references are unique") return True if __name__ == "__main__": raise RuntimeError("This module should not be run directly.")