mirror of
https://github.com/anthropics/skills.git
synced 2026-08-02 13:05:28 +08:00
fa0fa64bdc
Add support for template formats (.dotx, .potx, .xltx) across validation and the helper scripts. Consolidate the shared office helpers into a single module, replacing the pack/unpack pipeline with explicit zip/unzip steps. Extraction now rejects symlink and path-traversal archive entries. Move run merging to a standalone docx script, since it only ever applied to Word documents. Fix the redlining validator so it compares against the original even when a document has no tracked changes, which is when an untracked edit would otherwise go unreported. Provision a LibreOffice user profile per invocation so conversions work in sandboxed environments. Trim the skill docs to the guidance that earns its place.
310 lines
10 KiB
Python
Executable File
310 lines
10 KiB
Python
Executable File
"""Remove unreferenced files from an unpacked PPTX directory.
|
|
|
|
Usage: python clean.py <unpacked_dir>
|
|
|
|
Example:
|
|
python clean.py unpacked/
|
|
|
|
This script removes:
|
|
- Orphaned slides (not in sldIdLst) and their relationships
|
|
- [trash] directory (unreferenced files)
|
|
- Orphaned .rels files for deleted resources
|
|
- Unreferenced media, embeddings, charts, diagrams, drawings, ink files
|
|
- Unreferenced theme files
|
|
- Unreferenced notes slides
|
|
- Content-Type overrides for deleted files
|
|
"""
|
|
|
|
import posixpath
|
|
import re
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
import defusedxml.minidom
|
|
|
|
from office.helpers import SLIDE_REL_TYPE, opc_target, rels_source_part
|
|
|
|
|
|
def _slide_rids(pres_rels_path: Path, unpacked_dir: Path) -> dict[str, str]:
|
|
source_part = rels_source_part(pres_rels_path, unpacked_dir)
|
|
rels_dom = defusedxml.minidom.parse(str(pres_rels_path))
|
|
|
|
rids: dict[str, str] = {}
|
|
for rel in rels_dom.getElementsByTagName("Relationship"):
|
|
if rel.getAttribute("Type") != SLIDE_REL_TYPE:
|
|
continue
|
|
part = opc_target(
|
|
rel.getAttribute("Target"), source_part, rel.getAttribute("TargetMode")
|
|
)
|
|
if part is not None:
|
|
rids[rel.getAttribute("Id")] = part
|
|
return rids
|
|
|
|
|
|
def get_slides_in_sldidlst(unpacked_dir: Path) -> set[str]:
|
|
pres_path = unpacked_dir / "ppt" / "presentation.xml"
|
|
pres_rels_path = unpacked_dir / "ppt" / "_rels" / "presentation.xml.rels"
|
|
|
|
if not pres_path.exists() or not pres_rels_path.exists():
|
|
return set()
|
|
|
|
rid_to_slide = _slide_rids(pres_rels_path, unpacked_dir)
|
|
|
|
pres_content = pres_path.read_text(encoding="utf-8")
|
|
referenced_rids = set(re.findall(r'<p:sldId[^>]*r:id="([^"]+)"', pres_content))
|
|
|
|
return {
|
|
posixpath.basename(rid_to_slide[rid])
|
|
for rid in referenced_rids
|
|
if rid in rid_to_slide
|
|
}
|
|
|
|
|
|
class RefusedToClean(Exception):
|
|
"""The package does not look the way a readable package should."""
|
|
|
|
|
|
def remove_orphaned_slides(unpacked_dir: Path) -> list[str]:
|
|
slides_dir = unpacked_dir / "ppt" / "slides"
|
|
slides_rels_dir = slides_dir / "_rels"
|
|
pres_rels_path = unpacked_dir / "ppt" / "_rels" / "presentation.xml.rels"
|
|
|
|
if not slides_dir.exists():
|
|
return []
|
|
|
|
referenced_slides = get_slides_in_sldidlst(unpacked_dir)
|
|
on_disk = sorted(slides_dir.glob("slide*.xml"))
|
|
|
|
if on_disk and not any(s.name in referenced_slides for s in on_disk):
|
|
listed = re.findall(
|
|
r'<p:sldId[^>]*r:id="([^"]+)"',
|
|
(unpacked_dir / "ppt" / "presentation.xml").read_text(encoding="utf-8")
|
|
if (unpacked_dir / "ppt" / "presentation.xml").exists()
|
|
else "",
|
|
)
|
|
if listed:
|
|
raise RefusedToClean(
|
|
f"<p:sldIdLst> lists {len(listed)} slide(s) and none of the "
|
|
f"{len(on_disk)} slide(s) on disk match any of them. Refusing to "
|
|
f"delete them all — this is a parse failure, not an empty deck."
|
|
)
|
|
|
|
removed = []
|
|
|
|
for slide_file in on_disk:
|
|
if slide_file.name not in referenced_slides:
|
|
rel_path = slide_file.relative_to(unpacked_dir)
|
|
slide_file.unlink()
|
|
removed.append(str(rel_path))
|
|
|
|
rels_file = slides_rels_dir / f"{slide_file.name}.rels"
|
|
if rels_file.exists():
|
|
rels_file.unlink()
|
|
removed.append(str(rels_file.relative_to(unpacked_dir)))
|
|
|
|
if removed and pres_rels_path.exists():
|
|
rels_dom = defusedxml.minidom.parse(str(pres_rels_path))
|
|
source_part = rels_source_part(pres_rels_path, unpacked_dir)
|
|
changed = False
|
|
|
|
for rel in list(rels_dom.getElementsByTagName("Relationship")):
|
|
if rel.getAttribute("Type") != SLIDE_REL_TYPE:
|
|
continue
|
|
part = opc_target(
|
|
rel.getAttribute("Target"), source_part, rel.getAttribute("TargetMode")
|
|
)
|
|
if part is None:
|
|
continue
|
|
if posixpath.basename(part) not in referenced_slides:
|
|
if rel.parentNode:
|
|
rel.parentNode.removeChild(rel)
|
|
changed = True
|
|
|
|
if changed:
|
|
with open(pres_rels_path, "wb") as f:
|
|
f.write(rels_dom.toxml(encoding="utf-8"))
|
|
|
|
return removed
|
|
|
|
|
|
def remove_trash_directory(unpacked_dir: Path) -> list[str]:
|
|
trash_dir = unpacked_dir / "[trash]"
|
|
removed = []
|
|
|
|
if trash_dir.exists() and trash_dir.is_dir():
|
|
for file_path in trash_dir.iterdir():
|
|
if file_path.is_file():
|
|
rel_path = file_path.relative_to(unpacked_dir)
|
|
removed.append(str(rel_path))
|
|
file_path.unlink()
|
|
trash_dir.rmdir()
|
|
|
|
return removed
|
|
|
|
|
|
def _referenced_by(rels_files, unpacked_dir: Path) -> set:
|
|
referenced = set()
|
|
|
|
for rels_file in rels_files:
|
|
source_part = rels_source_part(rels_file, unpacked_dir)
|
|
dom = defusedxml.minidom.parse(str(rels_file))
|
|
for rel in dom.getElementsByTagName("Relationship"):
|
|
part = opc_target(
|
|
rel.getAttribute("Target"), source_part, rel.getAttribute("TargetMode")
|
|
)
|
|
if part is not None:
|
|
referenced.add(Path(part))
|
|
|
|
return referenced
|
|
|
|
|
|
def remove_orphaned_rels_files(unpacked_dir: Path) -> list[str]:
|
|
resource_dirs = ["charts", "diagrams", "drawings"]
|
|
removed = []
|
|
|
|
for dir_name in resource_dirs:
|
|
rels_dir = unpacked_dir / "ppt" / dir_name / "_rels"
|
|
if not rels_dir.exists():
|
|
continue
|
|
|
|
for rels_file in rels_dir.glob("*.rels"):
|
|
resource_file = rels_dir.parent / rels_file.name.replace(".rels", "")
|
|
if not resource_file.exists():
|
|
rels_file.unlink()
|
|
removed.append(str(rels_file.relative_to(unpacked_dir)))
|
|
|
|
return removed
|
|
|
|
|
|
def get_referenced_files(unpacked_dir: Path) -> set:
|
|
return _referenced_by(sorted(unpacked_dir.rglob("*.rels")), unpacked_dir)
|
|
|
|
|
|
def remove_orphaned_files(unpacked_dir: Path, referenced: set) -> list[str]:
|
|
resource_dirs = ["media", "embeddings", "charts", "diagrams", "tags", "drawings", "ink"]
|
|
removed = []
|
|
|
|
for dir_name in resource_dirs:
|
|
dir_path = unpacked_dir / "ppt" / dir_name
|
|
if not dir_path.exists():
|
|
continue
|
|
|
|
for file_path in dir_path.glob("*"):
|
|
if not file_path.is_file():
|
|
continue
|
|
rel_path = file_path.relative_to(unpacked_dir)
|
|
if rel_path not in referenced:
|
|
file_path.unlink()
|
|
removed.append(str(rel_path))
|
|
|
|
theme_dir = unpacked_dir / "ppt" / "theme"
|
|
if theme_dir.exists():
|
|
for file_path in theme_dir.glob("theme*.xml"):
|
|
rel_path = file_path.relative_to(unpacked_dir)
|
|
if rel_path not in referenced:
|
|
file_path.unlink()
|
|
removed.append(str(rel_path))
|
|
theme_rels = theme_dir / "_rels" / f"{file_path.name}.rels"
|
|
if theme_rels.exists():
|
|
theme_rels.unlink()
|
|
removed.append(str(theme_rels.relative_to(unpacked_dir)))
|
|
|
|
notes_dir = unpacked_dir / "ppt" / "notesSlides"
|
|
if notes_dir.exists():
|
|
for file_path in notes_dir.glob("*.xml"):
|
|
if not file_path.is_file():
|
|
continue
|
|
rel_path = file_path.relative_to(unpacked_dir)
|
|
if rel_path not in referenced:
|
|
file_path.unlink()
|
|
removed.append(str(rel_path))
|
|
|
|
notes_rels_dir = notes_dir / "_rels"
|
|
if notes_rels_dir.exists():
|
|
for file_path in notes_rels_dir.glob("*.rels"):
|
|
notes_file = notes_dir / file_path.name.replace(".rels", "")
|
|
if not notes_file.exists():
|
|
file_path.unlink()
|
|
removed.append(str(file_path.relative_to(unpacked_dir)))
|
|
|
|
return removed
|
|
|
|
|
|
def update_content_types(unpacked_dir: Path, removed_files: list[str]) -> None:
|
|
ct_path = unpacked_dir / "[Content_Types].xml"
|
|
if not ct_path.exists():
|
|
return
|
|
|
|
dom = defusedxml.minidom.parse(str(ct_path))
|
|
changed = False
|
|
|
|
for override in list(dom.getElementsByTagName("Override")):
|
|
part_name = override.getAttribute("PartName").lstrip("/")
|
|
if part_name in removed_files:
|
|
if override.parentNode:
|
|
override.parentNode.removeChild(override)
|
|
changed = True
|
|
|
|
if changed:
|
|
with open(ct_path, "wb") as f:
|
|
f.write(dom.toxml(encoding="utf-8"))
|
|
|
|
|
|
def clean_unused_files(unpacked_dir: Path) -> list[str]:
|
|
all_removed = []
|
|
|
|
if list(unpacked_dir.rglob("*.rels")) and not get_referenced_files(unpacked_dir):
|
|
raise RefusedToClean(
|
|
"no relationship in this package names a part we can resolve. "
|
|
"Refusing to treat every file as unreferenced."
|
|
)
|
|
|
|
slides_removed = remove_orphaned_slides(unpacked_dir)
|
|
all_removed.extend(slides_removed)
|
|
|
|
trash_removed = remove_trash_directory(unpacked_dir)
|
|
all_removed.extend(trash_removed)
|
|
|
|
while True:
|
|
removed_rels = remove_orphaned_rels_files(unpacked_dir)
|
|
referenced = get_referenced_files(unpacked_dir)
|
|
removed_files = remove_orphaned_files(unpacked_dir, referenced)
|
|
|
|
total_removed = removed_rels + removed_files
|
|
if not total_removed:
|
|
break
|
|
|
|
all_removed.extend(total_removed)
|
|
|
|
if all_removed:
|
|
update_content_types(unpacked_dir, all_removed)
|
|
|
|
return all_removed
|
|
|
|
|
|
if __name__ == "__main__":
|
|
if len(sys.argv) != 2:
|
|
print("Usage: python clean.py <unpacked_dir>", file=sys.stderr)
|
|
print("Example: python clean.py unpacked/", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
unpacked_dir = Path(sys.argv[1])
|
|
|
|
if not unpacked_dir.exists():
|
|
print(f"Error: {unpacked_dir} not found", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
try:
|
|
removed = clean_unused_files(unpacked_dir)
|
|
except (RefusedToClean, ValueError) as e:
|
|
print(f"Error: {e}", file=sys.stderr)
|
|
print("Nothing was deleted.", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
if removed:
|
|
print(f"Removed {len(removed)} unreferenced files:")
|
|
for f in removed:
|
|
print(f" {f}")
|
|
else:
|
|
print("No unreferenced files found")
|