#!/usr/bin/env python3 """ Validate a .pptx file for PowerPoint compatibility. Checks for issues that LibreOffice tolerates but PowerPoint rejects: - Negative shape dimensions (cx, cy ≤ 0) in elements - Negative shape positions (x, y < 0) in elements - Missing required OOXML parts ([Content_Types].xml, presentation.xml, etc.) - Malformed XML in any part - Broken relationship references (rId in slide XML not found in .rels) - Missing Content_Type overrides for slides Usage: python3 validate_pptx_compat.py Exit codes: 0 = all checks passed 1 = issues found (printed to stderr) """ import sys import zipfile import xml.etree.ElementTree as ET NS_A = "http://schemas.openxmlformats.org/drawingml/2006/main" NS_P = "http://schemas.openxmlformats.org/presentationml/2006/main" NS_R = "http://schemas.openxmlformats.org/officeDocument/2006/relationships" NS_REL = "{http://schemas.openxmlformats.org/package/2006/relationships}" def validate(pptx_path: str) -> list[str]: """Return a list of issue strings; empty list means clean.""" issues = [] with zipfile.ZipFile(pptx_path, "r") as z: # 1. Zip integrity bad = z.testzip() if bad: issues.append(f"BAD ZIP ENTRY: {bad}") # 2. XML parse check on all XML/rels parts for name in z.namelist(): if name.endswith(".xml") or name.endswith(".rels"): try: ET.parse(z.open(name)) except ET.ParseError as e: issues.append(f"XML PARSE ERROR in {name}: {e}") # 3. Required parts required = [ "[Content_Types].xml", "_rels/.rels", "ppt/presentation.xml", "ppt/_rels/presentation.xml.rels", "ppt/theme/theme1.xml", ] for req in required: if req not in z.namelist(): issues.append(f"MISSING REQUIRED PART: {req}") # 4. Slide rels existence slides = [ f for f in z.namelist() if f.startswith("ppt/slides/slide") and f.endswith(".xml") ] for s in sorted(slides): rels = s.replace("ppt/slides/", "ppt/slides/_rels/") + ".rels" if rels not in z.namelist(): issues.append(f"MISSING RELS for {s}") # 5. Content_Type overrides for slides ct = ET.parse(z.open("[Content_Types].xml")) ct_ns = {"ct": "http://schemas.openxmlformats.org/package/2006/content-types"} overrides = [ o.get("PartName") for o in ct.findall(".//ct:Override", ct_ns) ] for s in sorted(slides): part_name = "/" + s if part_name not in overrides: issues.append(f"Missing Content_Type override for {s}") # 6. Negative dimensions and positions (THE key check) for i in range(1, len(slides) + 1): slide_path = f"ppt/slides/slide{i}.xml" if slide_path not in z.namelist(): continue root = ET.parse(z.open(slide_path)).getroot() for sp in root.iter(f"{{{NS_P}}}sp"): spPr = sp.find(f".//{{{NS_P}}}spPr") if spPr is None: continue # Get shape name for reporting cNvPr = sp.find(f".//{{{NS_P}}}cNvPr") name = cNvPr.get("name", "?") if cNvPr is not None else "?" xfrm = spPr.find(f"{{{NS_A}}}xfrm") if xfrm is None: continue off = xfrm.find(f"{{{NS_A}}}off") ext = xfrm.find(f"{{{NS_A}}}ext") if ext is not None: cx = int(ext.get("cx", "0")) cy = int(ext.get("cy", "0")) if cx <= 0 or cy <= 0: issues.append( f"slide{i}: shape \"{name}\" has zero/negative extent: " f"cx={cx} cy={cy}" ) if off is not None: x = int(off.get("x", "0")) y = int(off.get("y", "0")) if x < 0 or y < 0: issues.append( f"slide{i}: shape \"{name}\" has negative position: " f"x={x} y={y}" ) # 7. Broken image embed references for i in range(1, len(slides) + 1): slide_path = f"ppt/slides/slide{i}.xml" if slide_path not in z.namelist(): continue root = ET.parse(z.open(slide_path)).getroot() rels_path = f"ppt/slides/_rels/slide{i}.xml.rels" if rels_path not in z.namelist(): continue rels_tree = ET.parse(z.open(rels_path)) rels_root = rels_tree.getroot() valid_rids = { rel.get("Id") for rel in rels_root.findall(f"{NS_REL}Relationship") } for blip in root.iter(f"{{{NS_A}}}blip"): embed = blip.get(f"{{{NS_R}}}embed") if embed is not None and embed not in valid_rids: issues.append( f"slide{i}: blipFill embed '{embed}' not found in rels" ) return issues def main(): if len(sys.argv) != 2: print("Usage: validate_pptx_compat.py ", file=sys.stderr) sys.exit(2) pptx_path = sys.argv[1] try: issues = validate(pptx_path) except FileNotFoundError: print(f"File not found: {pptx_path}", file=sys.stderr) sys.exit(2) except zipfile.BadZipFile: print(f"Not a valid ZIP/PPTX file: {pptx_path}", file=sys.stderr) sys.exit(2) if issues: print(f"❌ {len(issues)} issue(s) found:", file=sys.stderr) for iss in issues: print(f" • {iss}", file=sys.stderr) sys.exit(1) else: print(f"✅ {pptx_path}: all checks passed") sys.exit(0) if __name__ == "__main__": main()