pptx.py 9.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275
  1. """
  2. Validator for PowerPoint presentation XML files against XSD schemas.
  3. """
  4. import re
  5. from .base import BaseSchemaValidator
  6. class PPTXSchemaValidator(BaseSchemaValidator):
  7. PRESENTATIONML_NAMESPACE = (
  8. "http://schemas.openxmlformats.org/presentationml/2006/main"
  9. )
  10. ELEMENT_RELATIONSHIP_TYPES = {
  11. "sldid": "slide",
  12. "sldmasterid": "slidemaster",
  13. "notesmasterid": "notesmaster",
  14. "sldlayoutid": "slidelayout",
  15. "themeid": "theme",
  16. "tablestyleid": "tablestyles",
  17. }
  18. def validate(self):
  19. if not self.validate_xml():
  20. return False
  21. all_valid = True
  22. if not self.validate_namespaces():
  23. all_valid = False
  24. if not self.validate_unique_ids():
  25. all_valid = False
  26. if not self.validate_uuid_ids():
  27. all_valid = False
  28. if not self.validate_file_references():
  29. all_valid = False
  30. if not self.validate_slide_layout_ids():
  31. all_valid = False
  32. if not self.validate_content_types():
  33. all_valid = False
  34. if not self.validate_against_xsd():
  35. all_valid = False
  36. if not self.validate_notes_slide_references():
  37. all_valid = False
  38. if not self.validate_all_relationship_ids():
  39. all_valid = False
  40. if not self.validate_no_duplicate_slide_layouts():
  41. all_valid = False
  42. return all_valid
  43. def validate_uuid_ids(self):
  44. import lxml.etree
  45. errors = []
  46. uuid_pattern = re.compile(
  47. 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}[\}\)]?$"
  48. )
  49. for xml_file in self.xml_files:
  50. try:
  51. root = lxml.etree.parse(str(xml_file)).getroot()
  52. for elem in root.iter():
  53. for attr, value in elem.attrib.items():
  54. attr_name = attr.split("}")[-1].lower()
  55. if attr_name == "id" or attr_name.endswith("id"):
  56. if self._looks_like_uuid(value):
  57. if not uuid_pattern.match(value):
  58. errors.append(
  59. f" {xml_file.relative_to(self.unpacked_dir)}: "
  60. f"Line {elem.sourceline}: ID '{value}' appears to be a UUID but contains invalid hex characters"
  61. )
  62. except (lxml.etree.XMLSyntaxError, Exception) as e:
  63. errors.append(
  64. f" {xml_file.relative_to(self.unpacked_dir)}: Error: {e}"
  65. )
  66. if errors:
  67. print(f"FAILED - Found {len(errors)} UUID ID validation errors:")
  68. for error in errors:
  69. print(error)
  70. return False
  71. else:
  72. if self.verbose:
  73. print("PASSED - All UUID-like IDs contain valid hex values")
  74. return True
  75. def _looks_like_uuid(self, value):
  76. clean_value = value.strip("{}()").replace("-", "")
  77. return len(clean_value) == 32 and all(c.isalnum() for c in clean_value)
  78. def validate_slide_layout_ids(self):
  79. import lxml.etree
  80. errors = []
  81. slide_masters = list(self.unpacked_dir.glob("ppt/slideMasters/*.xml"))
  82. if not slide_masters:
  83. if self.verbose:
  84. print("PASSED - No slide masters found")
  85. return True
  86. for slide_master in slide_masters:
  87. try:
  88. root = lxml.etree.parse(str(slide_master)).getroot()
  89. rels_file = slide_master.parent / "_rels" / f"{slide_master.name}.rels"
  90. if not rels_file.exists():
  91. errors.append(
  92. f" {slide_master.relative_to(self.unpacked_dir)}: "
  93. f"Missing relationships file: {rels_file.relative_to(self.unpacked_dir)}"
  94. )
  95. continue
  96. rels_root = lxml.etree.parse(str(rels_file)).getroot()
  97. valid_layout_rids = set()
  98. for rel in rels_root.findall(
  99. f".//{{{self.PACKAGE_RELATIONSHIPS_NAMESPACE}}}Relationship"
  100. ):
  101. rel_type = rel.get("Type", "")
  102. if "slideLayout" in rel_type:
  103. valid_layout_rids.add(rel.get("Id"))
  104. for sld_layout_id in root.findall(
  105. f".//{{{self.PRESENTATIONML_NAMESPACE}}}sldLayoutId"
  106. ):
  107. r_id = sld_layout_id.get(
  108. f"{{{self.OFFICE_RELATIONSHIPS_NAMESPACE}}}id"
  109. )
  110. layout_id = sld_layout_id.get("id")
  111. if r_id and r_id not in valid_layout_rids:
  112. errors.append(
  113. f" {slide_master.relative_to(self.unpacked_dir)}: "
  114. f"Line {sld_layout_id.sourceline}: sldLayoutId with id='{layout_id}' "
  115. f"references r:id='{r_id}' which is not found in slide layout relationships"
  116. )
  117. except (lxml.etree.XMLSyntaxError, Exception) as e:
  118. errors.append(
  119. f" {slide_master.relative_to(self.unpacked_dir)}: Error: {e}"
  120. )
  121. if errors:
  122. print(f"FAILED - Found {len(errors)} slide layout ID validation errors:")
  123. for error in errors:
  124. print(error)
  125. print(
  126. "Remove invalid references or add missing slide layouts to the relationships file."
  127. )
  128. return False
  129. else:
  130. if self.verbose:
  131. print("PASSED - All slide layout IDs reference valid slide layouts")
  132. return True
  133. def validate_no_duplicate_slide_layouts(self):
  134. import lxml.etree
  135. errors = []
  136. slide_rels_files = list(self.unpacked_dir.glob("ppt/slides/_rels/*.xml.rels"))
  137. for rels_file in slide_rels_files:
  138. try:
  139. root = lxml.etree.parse(str(rels_file)).getroot()
  140. layout_rels = [
  141. rel
  142. for rel in root.findall(
  143. f".//{{{self.PACKAGE_RELATIONSHIPS_NAMESPACE}}}Relationship"
  144. )
  145. if "slideLayout" in rel.get("Type", "")
  146. ]
  147. if len(layout_rels) > 1:
  148. errors.append(
  149. f" {rels_file.relative_to(self.unpacked_dir)}: has {len(layout_rels)} slideLayout references"
  150. )
  151. except Exception as e:
  152. errors.append(
  153. f" {rels_file.relative_to(self.unpacked_dir)}: Error: {e}"
  154. )
  155. if errors:
  156. print("FAILED - Found slides with duplicate slideLayout references:")
  157. for error in errors:
  158. print(error)
  159. return False
  160. else:
  161. if self.verbose:
  162. print("PASSED - All slides have exactly one slideLayout reference")
  163. return True
  164. def validate_notes_slide_references(self):
  165. import lxml.etree
  166. errors = []
  167. notes_slide_references = {}
  168. slide_rels_files = list(self.unpacked_dir.glob("ppt/slides/_rels/*.xml.rels"))
  169. if not slide_rels_files:
  170. if self.verbose:
  171. print("PASSED - No slide relationship files found")
  172. return True
  173. for rels_file in slide_rels_files:
  174. try:
  175. root = lxml.etree.parse(str(rels_file)).getroot()
  176. for rel in root.findall(
  177. f".//{{{self.PACKAGE_RELATIONSHIPS_NAMESPACE}}}Relationship"
  178. ):
  179. rel_type = rel.get("Type", "")
  180. if "notesSlide" in rel_type:
  181. target = rel.get("Target", "")
  182. if target:
  183. normalized_target = target.replace("../", "")
  184. slide_name = rels_file.stem.replace(
  185. ".xml", ""
  186. )
  187. if normalized_target not in notes_slide_references:
  188. notes_slide_references[normalized_target] = []
  189. notes_slide_references[normalized_target].append(
  190. (slide_name, rels_file)
  191. )
  192. except (lxml.etree.XMLSyntaxError, Exception) as e:
  193. errors.append(
  194. f" {rels_file.relative_to(self.unpacked_dir)}: Error: {e}"
  195. )
  196. for target, references in notes_slide_references.items():
  197. if len(references) > 1:
  198. slide_names = [ref[0] for ref in references]
  199. errors.append(
  200. f" Notes slide '{target}' is referenced by multiple slides: {', '.join(slide_names)}"
  201. )
  202. for slide_name, rels_file in references:
  203. errors.append(f" - {rels_file.relative_to(self.unpacked_dir)}")
  204. if errors:
  205. print(
  206. f"FAILED - Found {len([e for e in errors if not e.startswith(' ')])} notes slide reference validation errors:"
  207. )
  208. for error in errors:
  209. print(error)
  210. print("Each slide may optionally have its own slide file.")
  211. return False
  212. else:
  213. if self.verbose:
  214. print("PASSED - All notes slide references are unique")
  215. return True
  216. if __name__ == "__main__":
  217. raise RuntimeError("This module should not be run directly.")