176 lines
6.0 KiB
Python
176 lines
6.0 KiB
Python
#!/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 <a:ext> elements
|
|
- Negative shape positions (x, y < 0) in <a:off> 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 <file.pptx>
|
|
|
|
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.pptx>", 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()
|