clean.py 9.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286
  1. """Remove unreferenced files from an unpacked PPTX directory.
  2. Usage: python clean.py <unpacked_dir>
  3. Example:
  4. python clean.py unpacked/
  5. This script removes:
  6. - Orphaned slides (not in sldIdLst) and their relationships
  7. - [trash] directory (unreferenced files)
  8. - Orphaned .rels files for deleted resources
  9. - Unreferenced media, embeddings, charts, diagrams, drawings, ink files
  10. - Unreferenced theme files
  11. - Unreferenced notes slides
  12. - Content-Type overrides for deleted files
  13. """
  14. import sys
  15. from pathlib import Path
  16. import defusedxml.minidom
  17. import re
  18. def get_slides_in_sldidlst(unpacked_dir: Path) -> set[str]:
  19. pres_path = unpacked_dir / "ppt" / "presentation.xml"
  20. pres_rels_path = unpacked_dir / "ppt" / "_rels" / "presentation.xml.rels"
  21. if not pres_path.exists() or not pres_rels_path.exists():
  22. return set()
  23. rels_dom = defusedxml.minidom.parse(str(pres_rels_path))
  24. rid_to_slide = {}
  25. for rel in rels_dom.getElementsByTagName("Relationship"):
  26. rid = rel.getAttribute("Id")
  27. target = rel.getAttribute("Target")
  28. rel_type = rel.getAttribute("Type")
  29. if "slide" in rel_type and target.startswith("slides/"):
  30. rid_to_slide[rid] = target.replace("slides/", "")
  31. pres_content = pres_path.read_text(encoding="utf-8")
  32. referenced_rids = set(re.findall(r'<p:sldId[^>]*r:id="([^"]+)"', pres_content))
  33. return {rid_to_slide[rid] for rid in referenced_rids if rid in rid_to_slide}
  34. def remove_orphaned_slides(unpacked_dir: Path) -> list[str]:
  35. slides_dir = unpacked_dir / "ppt" / "slides"
  36. slides_rels_dir = slides_dir / "_rels"
  37. pres_rels_path = unpacked_dir / "ppt" / "_rels" / "presentation.xml.rels"
  38. if not slides_dir.exists():
  39. return []
  40. referenced_slides = get_slides_in_sldidlst(unpacked_dir)
  41. removed = []
  42. for slide_file in slides_dir.glob("slide*.xml"):
  43. if slide_file.name not in referenced_slides:
  44. rel_path = slide_file.relative_to(unpacked_dir)
  45. slide_file.unlink()
  46. removed.append(str(rel_path))
  47. rels_file = slides_rels_dir / f"{slide_file.name}.rels"
  48. if rels_file.exists():
  49. rels_file.unlink()
  50. removed.append(str(rels_file.relative_to(unpacked_dir)))
  51. if removed and pres_rels_path.exists():
  52. rels_dom = defusedxml.minidom.parse(str(pres_rels_path))
  53. changed = False
  54. for rel in list(rels_dom.getElementsByTagName("Relationship")):
  55. target = rel.getAttribute("Target")
  56. if target.startswith("slides/"):
  57. slide_name = target.replace("slides/", "")
  58. if slide_name not in referenced_slides:
  59. if rel.parentNode:
  60. rel.parentNode.removeChild(rel)
  61. changed = True
  62. if changed:
  63. with open(pres_rels_path, "wb") as f:
  64. f.write(rels_dom.toxml(encoding="utf-8"))
  65. return removed
  66. def remove_trash_directory(unpacked_dir: Path) -> list[str]:
  67. trash_dir = unpacked_dir / "[trash]"
  68. removed = []
  69. if trash_dir.exists() and trash_dir.is_dir():
  70. for file_path in trash_dir.iterdir():
  71. if file_path.is_file():
  72. rel_path = file_path.relative_to(unpacked_dir)
  73. removed.append(str(rel_path))
  74. file_path.unlink()
  75. trash_dir.rmdir()
  76. return removed
  77. def get_slide_referenced_files(unpacked_dir: Path) -> set:
  78. referenced = set()
  79. slides_rels_dir = unpacked_dir / "ppt" / "slides" / "_rels"
  80. if not slides_rels_dir.exists():
  81. return referenced
  82. for rels_file in slides_rels_dir.glob("*.rels"):
  83. dom = defusedxml.minidom.parse(str(rels_file))
  84. for rel in dom.getElementsByTagName("Relationship"):
  85. target = rel.getAttribute("Target")
  86. if not target:
  87. continue
  88. target_path = (rels_file.parent.parent / target).resolve()
  89. try:
  90. referenced.add(target_path.relative_to(unpacked_dir.resolve()))
  91. except ValueError:
  92. pass
  93. return referenced
  94. def remove_orphaned_rels_files(unpacked_dir: Path) -> list[str]:
  95. resource_dirs = ["charts", "diagrams", "drawings"]
  96. removed = []
  97. slide_referenced = get_slide_referenced_files(unpacked_dir)
  98. for dir_name in resource_dirs:
  99. rels_dir = unpacked_dir / "ppt" / dir_name / "_rels"
  100. if not rels_dir.exists():
  101. continue
  102. for rels_file in rels_dir.glob("*.rels"):
  103. resource_file = rels_dir.parent / rels_file.name.replace(".rels", "")
  104. try:
  105. resource_rel_path = resource_file.resolve().relative_to(unpacked_dir.resolve())
  106. except ValueError:
  107. continue
  108. if not resource_file.exists() or resource_rel_path not in slide_referenced:
  109. rels_file.unlink()
  110. rel_path = rels_file.relative_to(unpacked_dir)
  111. removed.append(str(rel_path))
  112. return removed
  113. def get_referenced_files(unpacked_dir: Path) -> set:
  114. referenced = set()
  115. for rels_file in unpacked_dir.rglob("*.rels"):
  116. dom = defusedxml.minidom.parse(str(rels_file))
  117. for rel in dom.getElementsByTagName("Relationship"):
  118. target = rel.getAttribute("Target")
  119. if not target:
  120. continue
  121. target_path = (rels_file.parent.parent / target).resolve()
  122. try:
  123. referenced.add(target_path.relative_to(unpacked_dir.resolve()))
  124. except ValueError:
  125. pass
  126. return referenced
  127. def remove_orphaned_files(unpacked_dir: Path, referenced: set) -> list[str]:
  128. resource_dirs = ["media", "embeddings", "charts", "diagrams", "tags", "drawings", "ink"]
  129. removed = []
  130. for dir_name in resource_dirs:
  131. dir_path = unpacked_dir / "ppt" / dir_name
  132. if not dir_path.exists():
  133. continue
  134. for file_path in dir_path.glob("*"):
  135. if not file_path.is_file():
  136. continue
  137. rel_path = file_path.relative_to(unpacked_dir)
  138. if rel_path not in referenced:
  139. file_path.unlink()
  140. removed.append(str(rel_path))
  141. theme_dir = unpacked_dir / "ppt" / "theme"
  142. if theme_dir.exists():
  143. for file_path in theme_dir.glob("theme*.xml"):
  144. rel_path = file_path.relative_to(unpacked_dir)
  145. if rel_path not in referenced:
  146. file_path.unlink()
  147. removed.append(str(rel_path))
  148. theme_rels = theme_dir / "_rels" / f"{file_path.name}.rels"
  149. if theme_rels.exists():
  150. theme_rels.unlink()
  151. removed.append(str(theme_rels.relative_to(unpacked_dir)))
  152. notes_dir = unpacked_dir / "ppt" / "notesSlides"
  153. if notes_dir.exists():
  154. for file_path in notes_dir.glob("*.xml"):
  155. if not file_path.is_file():
  156. continue
  157. rel_path = file_path.relative_to(unpacked_dir)
  158. if rel_path not in referenced:
  159. file_path.unlink()
  160. removed.append(str(rel_path))
  161. notes_rels_dir = notes_dir / "_rels"
  162. if notes_rels_dir.exists():
  163. for file_path in notes_rels_dir.glob("*.rels"):
  164. notes_file = notes_dir / file_path.name.replace(".rels", "")
  165. if not notes_file.exists():
  166. file_path.unlink()
  167. removed.append(str(file_path.relative_to(unpacked_dir)))
  168. return removed
  169. def update_content_types(unpacked_dir: Path, removed_files: list[str]) -> None:
  170. ct_path = unpacked_dir / "[Content_Types].xml"
  171. if not ct_path.exists():
  172. return
  173. dom = defusedxml.minidom.parse(str(ct_path))
  174. changed = False
  175. for override in list(dom.getElementsByTagName("Override")):
  176. part_name = override.getAttribute("PartName").lstrip("/")
  177. if part_name in removed_files:
  178. if override.parentNode:
  179. override.parentNode.removeChild(override)
  180. changed = True
  181. if changed:
  182. with open(ct_path, "wb") as f:
  183. f.write(dom.toxml(encoding="utf-8"))
  184. def clean_unused_files(unpacked_dir: Path) -> list[str]:
  185. all_removed = []
  186. slides_removed = remove_orphaned_slides(unpacked_dir)
  187. all_removed.extend(slides_removed)
  188. trash_removed = remove_trash_directory(unpacked_dir)
  189. all_removed.extend(trash_removed)
  190. while True:
  191. removed_rels = remove_orphaned_rels_files(unpacked_dir)
  192. referenced = get_referenced_files(unpacked_dir)
  193. removed_files = remove_orphaned_files(unpacked_dir, referenced)
  194. total_removed = removed_rels + removed_files
  195. if not total_removed:
  196. break
  197. all_removed.extend(total_removed)
  198. if all_removed:
  199. update_content_types(unpacked_dir, all_removed)
  200. return all_removed
  201. if __name__ == "__main__":
  202. if len(sys.argv) != 2:
  203. print("Usage: python clean.py <unpacked_dir>", file=sys.stderr)
  204. print("Example: python clean.py unpacked/", file=sys.stderr)
  205. sys.exit(1)
  206. unpacked_dir = Path(sys.argv[1])
  207. if not unpacked_dir.exists():
  208. print(f"Error: {unpacked_dir} not found", file=sys.stderr)
  209. sys.exit(1)
  210. removed = clean_unused_files(unpacked_dir)
  211. if removed:
  212. print(f"Removed {len(removed)} unreferenced files:")
  213. for f in removed:
  214. print(f" {f}")
  215. else:
  216. print("No unreferenced files found")