Update docx, pptx, and xlsx skills (#1447)

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.
This commit is contained in:
Peter Lai
2026-07-16 19:47:37 -07:00
committed by GitHub
parent 9d2f1ae187
commit fa0fa64bdc
53 changed files with 4147 additions and 4442 deletions
+80 -57
View File
@@ -15,13 +15,30 @@ This script removes:
- 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
import re
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]:
@@ -31,19 +48,20 @@ def get_slides_in_sldidlst(unpacked_dir: Path) -> set[str]:
if not pres_path.exists() or not pres_rels_path.exists():
return set()
rels_dom = defusedxml.minidom.parse(str(pres_rels_path))
rid_to_slide = {}
for rel in rels_dom.getElementsByTagName("Relationship"):
rid = rel.getAttribute("Id")
target = rel.getAttribute("Target")
rel_type = rel.getAttribute("Type")
if "slide" in rel_type and target.startswith("slides/"):
rid_to_slide[rid] = target.replace("slides/", "")
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 {rid_to_slide[rid] for rid in referenced_rids if rid in rid_to_slide}
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]:
@@ -55,9 +73,25 @@ def remove_orphaned_slides(unpacked_dir: Path) -> list[str]:
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 slides_dir.glob("slide*.xml"):
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()
@@ -70,16 +104,21 @@ def remove_orphaned_slides(unpacked_dir: Path) -> list[str]:
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")):
target = rel.getAttribute("Target")
if target.startswith("slides/"):
slide_name = target.replace("slides/", "")
if slide_name not in referenced_slides:
if rel.parentNode:
rel.parentNode.removeChild(rel)
changed = True
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:
@@ -103,24 +142,18 @@ def remove_trash_directory(unpacked_dir: Path) -> list[str]:
return removed
def get_slide_referenced_files(unpacked_dir: Path) -> set:
def _referenced_by(rels_files, unpacked_dir: Path) -> set:
referenced = set()
slides_rels_dir = unpacked_dir / "ppt" / "slides" / "_rels"
if not slides_rels_dir.exists():
return referenced
for rels_file in slides_rels_dir.glob("*.rels"):
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"):
target = rel.getAttribute("Target")
if not target:
continue
target_path = (rels_file.parent.parent / target).resolve()
try:
referenced.add(target_path.relative_to(unpacked_dir.resolve()))
except ValueError:
pass
part = opc_target(
rel.getAttribute("Target"), source_part, rel.getAttribute("TargetMode")
)
if part is not None:
referenced.add(Path(part))
return referenced
@@ -128,7 +161,6 @@ def get_slide_referenced_files(unpacked_dir: Path) -> set:
def remove_orphaned_rels_files(unpacked_dir: Path) -> list[str]:
resource_dirs = ["charts", "diagrams", "drawings"]
removed = []
slide_referenced = get_slide_referenced_files(unpacked_dir)
for dir_name in resource_dirs:
rels_dir = unpacked_dir / "ppt" / dir_name / "_rels"
@@ -137,35 +169,15 @@ def remove_orphaned_rels_files(unpacked_dir: Path) -> list[str]:
for rels_file in rels_dir.glob("*.rels"):
resource_file = rels_dir.parent / rels_file.name.replace(".rels", "")
try:
resource_rel_path = resource_file.resolve().relative_to(unpacked_dir.resolve())
except ValueError:
continue
if not resource_file.exists() or resource_rel_path not in slide_referenced:
if not resource_file.exists():
rels_file.unlink()
rel_path = rels_file.relative_to(unpacked_dir)
removed.append(str(rel_path))
removed.append(str(rels_file.relative_to(unpacked_dir)))
return removed
def get_referenced_files(unpacked_dir: Path) -> set:
referenced = set()
for rels_file in unpacked_dir.rglob("*.rels"):
dom = defusedxml.minidom.parse(str(rels_file))
for rel in dom.getElementsByTagName("Relationship"):
target = rel.getAttribute("Target")
if not target:
continue
target_path = (rels_file.parent.parent / target).resolve()
try:
referenced.add(target_path.relative_to(unpacked_dir.resolve()))
except ValueError:
pass
return referenced
return _referenced_by(sorted(unpacked_dir.rglob("*.rels")), unpacked_dir)
def remove_orphaned_files(unpacked_dir: Path, referenced: set) -> list[str]:
@@ -241,6 +253,12 @@ def update_content_types(unpacked_dir: Path, removed_files: list[str]) -> None:
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)
@@ -276,7 +294,12 @@ if __name__ == "__main__":
print(f"Error: {unpacked_dir} not found", file=sys.stderr)
sys.exit(1)
removed = clean_unused_files(unpacked_dir)
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:")