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.
442 lines
16 KiB
Python
442 lines
16 KiB
Python
"""
|
|
Validator for PowerPoint presentation XML files against XSD schemas.
|
|
"""
|
|
|
|
import re
|
|
from pathlib import Path
|
|
|
|
from helpers import opc_target, rels_source_part, safe_extract
|
|
|
|
from .base import BaseSchemaValidator
|
|
|
|
|
|
class PPTXSchemaValidator(BaseSchemaValidator):
|
|
|
|
PRESENTATIONML_NAMESPACE = (
|
|
"http://schemas.openxmlformats.org/presentationml/2006/main"
|
|
)
|
|
|
|
ELEMENT_RELATIONSHIP_TYPES = {
|
|
"sldid": "slide",
|
|
"sldmasterid": "slidemaster",
|
|
"notesmasterid": "notesmaster",
|
|
"sldlayoutid": "slidelayout",
|
|
"themeid": "theme",
|
|
"tablestyleid": "tablestyles",
|
|
}
|
|
|
|
def validate(self):
|
|
if not self.validate_xml():
|
|
return False
|
|
|
|
all_valid = True
|
|
if not self.validate_namespaces():
|
|
all_valid = False
|
|
|
|
if not self.validate_unique_ids():
|
|
all_valid = False
|
|
|
|
if not self.validate_uuid_ids():
|
|
all_valid = False
|
|
|
|
if not self.validate_file_references():
|
|
all_valid = False
|
|
|
|
if not self.validate_slide_layout_ids():
|
|
all_valid = False
|
|
|
|
if not self.validate_content_types():
|
|
all_valid = False
|
|
|
|
if not self.validate_against_xsd():
|
|
all_valid = False
|
|
|
|
if not self.validate_notes_slide_references():
|
|
all_valid = False
|
|
|
|
if not self.validate_all_relationship_ids():
|
|
all_valid = False
|
|
|
|
if not self.validate_no_duplicate_slide_layouts():
|
|
all_valid = False
|
|
|
|
if not self.validate_master_theme_uniqueness():
|
|
all_valid = False
|
|
|
|
if not self.validate_charts():
|
|
all_valid = False
|
|
|
|
if not self.validate_slides():
|
|
all_valid = False
|
|
|
|
return all_valid
|
|
|
|
def _package_map(self) -> dict:
|
|
wanted = []
|
|
wanted += list(self.unpacked_dir.glob("[[]Content_Types[]].xml"))
|
|
wanted += list(self.unpacked_dir.glob("ppt/presentation.xml"))
|
|
wanted += list(self.unpacked_dir.glob("ppt/theme/*.xml"))
|
|
wanted += list(self.unpacked_dir.glob("ppt/theme/_rels/*.rels"))
|
|
wanted += list(self.unpacked_dir.glob("ppt/charts/chart*.xml"))
|
|
for group in ("slideMasters", "notesMasters", "handoutMasters"):
|
|
wanted += list(self.unpacked_dir.glob(f"ppt/{group}/*.xml"))
|
|
wanted += list(self.unpacked_dir.glob(f"ppt/{group}/_rels/*.rels"))
|
|
return {
|
|
p.relative_to(self.unpacked_dir).as_posix(): p.read_bytes()
|
|
for p in wanted
|
|
if p.is_file()
|
|
}
|
|
|
|
def validate_master_theme_uniqueness(self):
|
|
from helpers.pptx_theme import _NOTES_MASTERS, live_shared_master_themes
|
|
|
|
shared = live_shared_master_themes(self._package_map())
|
|
if shared:
|
|
print(f"FAILED - Found {len(shared)} master(s) sharing a theme part:")
|
|
for message in shared:
|
|
print(f" {message}")
|
|
if any(m.startswith(_NOTES_MASTERS) for m in shared):
|
|
print(" Fix: in ppt/presentation.xml, move <p:notesMasterIdLst> back to "
|
|
"directly after <p:sldIdLst>. PowerPoint reads that happily.")
|
|
else:
|
|
print(" Fix: give each master its own theme part.")
|
|
return False
|
|
|
|
if self.verbose:
|
|
print("PASSED - No master shares a theme part in a way PowerPoint refuses")
|
|
return True
|
|
|
|
def validate_charts(self):
|
|
from helpers.pptx_chart import find_chart_problems
|
|
|
|
problems = find_chart_problems(self._package_map())
|
|
if problems:
|
|
print(f"FAILED - Found {len(problems)} chart problem(s) PowerPoint rejects:")
|
|
for message in problems:
|
|
print(f" {message}")
|
|
return False
|
|
|
|
if self.verbose:
|
|
print("PASSED - Charts satisfy the constraints PowerPoint enforces")
|
|
return True
|
|
|
|
def _original_slide_defects(self, schema) -> set[str]:
|
|
import tempfile
|
|
import zipfile
|
|
|
|
from helpers.pptx_slide import SLIDE_PART_RE, fatal_slide_errors
|
|
|
|
if self.original_file is None:
|
|
return set()
|
|
|
|
found: set[str] = set()
|
|
with tempfile.TemporaryDirectory() as temp_dir:
|
|
temp_path = Path(temp_dir)
|
|
try:
|
|
with zipfile.ZipFile(self.original_file, "r") as zf:
|
|
safe_extract(zf, temp_path)
|
|
except (zipfile.BadZipFile, ValueError, OSError):
|
|
return set()
|
|
|
|
for part in sorted(temp_path.rglob("*.xml")):
|
|
relative = part.relative_to(temp_path).as_posix()
|
|
if not SLIDE_PART_RE.fullmatch(relative):
|
|
continue
|
|
ok, errors = self._validate_single_file_xsd(
|
|
part.resolve(), temp_path.resolve(), schema_path=schema
|
|
)
|
|
if ok is None or ok or not errors:
|
|
continue
|
|
found |= set(fatal_slide_errors(set(errors)))
|
|
return found
|
|
|
|
def validate_slides(self):
|
|
from helpers.pptx_slide import (
|
|
SLIDE_PART_RE,
|
|
fatal_slide_errors,
|
|
is_schema_verdict,
|
|
)
|
|
|
|
schema = self.schemas_dir / self.SCHEMA_MAPPINGS["ppt"]
|
|
inherited = self._original_slide_defects(schema)
|
|
problems: list[str] = []
|
|
broken: list[str] = []
|
|
|
|
for xml_file in self.xml_files:
|
|
relative = xml_file.relative_to(self.unpacked_dir).as_posix()
|
|
if not SLIDE_PART_RE.fullmatch(relative):
|
|
continue
|
|
ok, errors = self._validate_single_file_xsd(
|
|
xml_file.resolve(), self.unpacked_dir.resolve(), schema_path=schema
|
|
)
|
|
if ok is None or not errors:
|
|
continue
|
|
|
|
unreadable = [f"{relative}: {e}" for e in errors if not is_schema_verdict(e)]
|
|
if unreadable:
|
|
broken.extend(unreadable)
|
|
continue
|
|
if ok:
|
|
continue
|
|
|
|
for message in fatal_slide_errors(set(errors)):
|
|
if message in inherited:
|
|
continue
|
|
problems.append(f"{relative}: {message}")
|
|
|
|
if broken:
|
|
print(f"FAILED - Could not check {len(broken)} slide part(s):")
|
|
for message in sorted(broken):
|
|
print(f" {message[:240]}")
|
|
|
|
if problems:
|
|
print(f"FAILED - Found {len(problems)} slide problem(s) PowerPoint rejects:")
|
|
for message in sorted(problems):
|
|
print(f" {message[:240]}")
|
|
|
|
if broken or problems:
|
|
return False
|
|
|
|
if self.verbose:
|
|
print("PASSED - Slide XML has none of the defects PowerPoint refuses")
|
|
return True
|
|
|
|
def _get_schema_path(self, xml_file):
|
|
if xml_file.parent.name == "charts" and xml_file.name.startswith("chart"):
|
|
return None
|
|
return super()._get_schema_path(xml_file)
|
|
|
|
def _preprocess_for_schema(self, xml_doc, relative_path):
|
|
if relative_path.as_posix() != "ppt/presentation.xml":
|
|
return xml_doc
|
|
|
|
root = xml_doc.getroot()
|
|
ns = f"{{{self.PRESENTATIONML_NAMESPACE}}}"
|
|
notes = root.find(f"{ns}notesMasterIdLst")
|
|
slides = root.find(f"{ns}sldIdLst")
|
|
if notes is None or slides is None:
|
|
return xml_doc
|
|
|
|
children = list(root)
|
|
if children.index(notes) < children.index(slides):
|
|
return xml_doc
|
|
|
|
root.remove(notes)
|
|
root.insert(list(root).index(slides), notes)
|
|
return xml_doc
|
|
|
|
def validate_uuid_ids(self):
|
|
import lxml.etree
|
|
|
|
errors = []
|
|
uuid_pattern = re.compile(
|
|
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}[\}\)]?$"
|
|
)
|
|
|
|
for xml_file in self.xml_files:
|
|
try:
|
|
root = lxml.etree.parse(str(xml_file)).getroot()
|
|
|
|
for elem in root.iter():
|
|
for attr, value in elem.attrib.items():
|
|
attr_name = attr.split("}")[-1].lower()
|
|
if attr_name == "id" or attr_name.endswith("id"):
|
|
if self._looks_like_uuid(value):
|
|
if not uuid_pattern.match(value):
|
|
errors.append(
|
|
f" {xml_file.relative_to(self.unpacked_dir)}: "
|
|
f"Line {elem.sourceline}: ID '{value}' appears to be a UUID but contains invalid hex characters"
|
|
)
|
|
|
|
except (lxml.etree.XMLSyntaxError, Exception) as e:
|
|
errors.append(
|
|
f" {xml_file.relative_to(self.unpacked_dir)}: Error: {e}"
|
|
)
|
|
|
|
if errors:
|
|
print(f"FAILED - Found {len(errors)} UUID ID validation errors:")
|
|
for error in errors:
|
|
print(error)
|
|
return False
|
|
else:
|
|
if self.verbose:
|
|
print("PASSED - All UUID-like IDs contain valid hex values")
|
|
return True
|
|
|
|
def _looks_like_uuid(self, value):
|
|
clean_value = value.strip("{}()").replace("-", "")
|
|
return len(clean_value) == 32 and all(c.isalnum() for c in clean_value)
|
|
|
|
def validate_slide_layout_ids(self):
|
|
import lxml.etree
|
|
|
|
errors = []
|
|
|
|
slide_masters = list(self.unpacked_dir.glob("ppt/slideMasters/*.xml"))
|
|
|
|
if not slide_masters:
|
|
if self.verbose:
|
|
print("PASSED - No slide masters found")
|
|
return True
|
|
|
|
for slide_master in slide_masters:
|
|
try:
|
|
root = lxml.etree.parse(str(slide_master)).getroot()
|
|
|
|
rels_file = slide_master.parent / "_rels" / f"{slide_master.name}.rels"
|
|
|
|
if not rels_file.exists():
|
|
errors.append(
|
|
f" {slide_master.relative_to(self.unpacked_dir)}: "
|
|
f"Missing relationships file: {rels_file.relative_to(self.unpacked_dir)}"
|
|
)
|
|
continue
|
|
|
|
rels_root = lxml.etree.parse(str(rels_file)).getroot()
|
|
|
|
valid_layout_rids = set()
|
|
for rel in rels_root.findall(
|
|
f".//{{{self.PACKAGE_RELATIONSHIPS_NAMESPACE}}}Relationship"
|
|
):
|
|
rel_type = rel.get("Type", "")
|
|
if "slideLayout" in rel_type:
|
|
valid_layout_rids.add(rel.get("Id"))
|
|
|
|
for sld_layout_id in root.findall(
|
|
f".//{{{self.PRESENTATIONML_NAMESPACE}}}sldLayoutId"
|
|
):
|
|
r_id = sld_layout_id.get(
|
|
f"{{{self.OFFICE_RELATIONSHIPS_NAMESPACE}}}id"
|
|
)
|
|
layout_id = sld_layout_id.get("id")
|
|
|
|
if r_id and r_id not in valid_layout_rids:
|
|
errors.append(
|
|
f" {slide_master.relative_to(self.unpacked_dir)}: "
|
|
f"Line {sld_layout_id.sourceline}: sldLayoutId with id='{layout_id}' "
|
|
f"references r:id='{r_id}' which is not found in slide layout relationships"
|
|
)
|
|
|
|
except (lxml.etree.XMLSyntaxError, Exception) as e:
|
|
errors.append(
|
|
f" {slide_master.relative_to(self.unpacked_dir)}: Error: {e}"
|
|
)
|
|
|
|
if errors:
|
|
print(f"FAILED - Found {len(errors)} slide layout ID validation errors:")
|
|
for error in errors:
|
|
print(error)
|
|
print(
|
|
"Remove invalid references or add missing slide layouts to the relationships file."
|
|
)
|
|
return False
|
|
else:
|
|
if self.verbose:
|
|
print("PASSED - All slide layout IDs reference valid slide layouts")
|
|
return True
|
|
|
|
def validate_no_duplicate_slide_layouts(self):
|
|
import lxml.etree
|
|
|
|
errors = []
|
|
slide_rels_files = list(self.unpacked_dir.glob("ppt/slides/_rels/*.xml.rels"))
|
|
|
|
for rels_file in slide_rels_files:
|
|
try:
|
|
root = lxml.etree.parse(str(rels_file)).getroot()
|
|
|
|
layout_rels = [
|
|
rel
|
|
for rel in root.findall(
|
|
f".//{{{self.PACKAGE_RELATIONSHIPS_NAMESPACE}}}Relationship"
|
|
)
|
|
if "slideLayout" in rel.get("Type", "")
|
|
]
|
|
|
|
if len(layout_rels) > 1:
|
|
errors.append(
|
|
f" {rels_file.relative_to(self.unpacked_dir)}: has {len(layout_rels)} slideLayout references"
|
|
)
|
|
|
|
except Exception as e:
|
|
errors.append(
|
|
f" {rels_file.relative_to(self.unpacked_dir)}: Error: {e}"
|
|
)
|
|
|
|
if errors:
|
|
print("FAILED - Found slides with duplicate slideLayout references:")
|
|
for error in errors:
|
|
print(error)
|
|
return False
|
|
else:
|
|
if self.verbose:
|
|
print("PASSED - All slides have exactly one slideLayout reference")
|
|
return True
|
|
|
|
def validate_notes_slide_references(self):
|
|
import lxml.etree
|
|
|
|
errors = []
|
|
notes_slide_references = {}
|
|
|
|
slide_rels_files = list(self.unpacked_dir.glob("ppt/slides/_rels/*.xml.rels"))
|
|
|
|
if not slide_rels_files:
|
|
if self.verbose:
|
|
print("PASSED - No slide relationship files found")
|
|
return True
|
|
|
|
for rels_file in slide_rels_files:
|
|
try:
|
|
root = lxml.etree.parse(str(rels_file)).getroot()
|
|
|
|
for rel in root.findall(
|
|
f".//{{{self.PACKAGE_RELATIONSHIPS_NAMESPACE}}}Relationship"
|
|
):
|
|
rel_type = rel.get("Type", "")
|
|
if "notesSlide" in rel_type:
|
|
part = opc_target(
|
|
rel.get("Target", ""),
|
|
rels_source_part(rels_file, self.unpacked_dir),
|
|
rel.get("TargetMode", ""),
|
|
)
|
|
if part:
|
|
slide_name = rels_file.stem.replace(
|
|
".xml", ""
|
|
)
|
|
|
|
notes_slide_references.setdefault(part, []).append(
|
|
(slide_name, rels_file)
|
|
)
|
|
|
|
except (lxml.etree.XMLSyntaxError, Exception) as e:
|
|
errors.append(
|
|
f" {rels_file.relative_to(self.unpacked_dir)}: Error: {e}"
|
|
)
|
|
|
|
for target, references in notes_slide_references.items():
|
|
if len(references) > 1:
|
|
slide_names = [ref[0] for ref in references]
|
|
errors.append(
|
|
f" Notes slide '{target}' is referenced by multiple slides: {', '.join(slide_names)}"
|
|
)
|
|
for slide_name, rels_file in references:
|
|
errors.append(f" - {rels_file.relative_to(self.unpacked_dir)}")
|
|
|
|
if errors:
|
|
print(
|
|
f"FAILED - Found {len([e for e in errors if not e.startswith(' ')])} notes slide reference validation errors:"
|
|
)
|
|
for error in errors:
|
|
print(error)
|
|
print("Each slide may optionally have its own slide file.")
|
|
return False
|
|
else:
|
|
if self.verbose:
|
|
print("PASSED - All notes slide references are unique")
|
|
return True
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise RuntimeError("This module should not be run directly.")
|