| 1 | #!/usr/bin/env python3 |
| 2 | """Shared, dependency-light OPC package relationship validation.""" |
| 3 | |
| 4 | from __future__ import annotations |
| 5 | |
| 6 | import posixpath |
| 7 | import re |
| 8 | from pathlib import Path |
| 9 | from urllib.parse import urlsplit |
| 10 | from xml.etree import ElementTree as ET |
| 11 | |
| 12 | from hyperlink_contract import ( |
| 13 | HYPERLINK_REL_TYPE, |
| 14 | SLIDE_JUMP_ACTION, |
| 15 | SLIDE_REL_TYPE, |
| 16 | ) |
| 17 | |
| 18 | |
| 19 | PACKAGE_REL_NS = ( |
| 20 | "http://schemas.openxmlformats.org/package/2006/relationships" |
| 21 | ) |
| 22 | _RELATIONSHIPS_TAG = f"{{{PACKAGE_REL_NS}}}Relationships" |
| 23 | _RELATIONSHIP_TAG = f"{{{PACKAGE_REL_NS}}}Relationship" |
| 24 | _DRAWINGML_NS = ( |
| 25 | "http://schemas.openxmlformats.org/drawingml/2006/main" |
| 26 | ) |
| 27 | _PRESENTATIONML_NS = ( |
| 28 | "http://schemas.openxmlformats.org/presentationml/2006/main" |
| 29 | ) |
| 30 | _OFFICE_REL_NS = ( |
| 31 | "http://schemas.openxmlformats.org/officeDocument/2006/relationships" |
| 32 | ) |
| 33 | _OPC_UNRESERVED = frozenset( |
| 34 | "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~" |
| 35 | ) |
| 36 | _ASCII_LOWER_TRANSLATION = str.maketrans( |
| 37 | "ABCDEFGHIJKLMNOPQRSTUVWXYZ", |
| 38 | "abcdefghijklmnopqrstuvwxyz", |
| 39 | ) |
| 40 | |
| 41 | |
| 42 | def canonical_opc_part_path(path: str) -> str | None: |
| 43 | """Return an OPC-equivalent package path key, or None when invalid.""" |
| 44 | if ( |
| 45 | not path |
| 46 | or "\\" in path |
| 47 | or "?" in path |
| 48 | or "#" in path |
| 49 | or path.endswith("/") |
| 50 | or "//" in path |
| 51 | or any(ord(char) <= 0x20 for char in path) |
| 52 | ): |
| 53 | return None |
| 54 | output: list[str] = [] |
| 55 | index = 0 |
| 56 | while index < len(path): |
| 57 | char = path[index] |
| 58 | if char != "%": |
| 59 | output.append(char) |
| 60 | index += 1 |
| 61 | continue |
| 62 | if ( |
| 63 | index + 2 >= len(path) |
| 64 | or re.fullmatch( |
| 65 | r"[0-9A-Fa-f]{2}", |
| 66 | path[index + 1:index + 3], |
| 67 | ) |
| 68 | is None |
| 69 | ): |
| 70 | return None |
| 71 | value = int(path[index + 1:index + 3], 16) |
| 72 | decoded = chr(value) |
| 73 | if value in {0, ord("/"), ord("\\")}: |
| 74 | return None |
| 75 | output.append( |
| 76 | decoded |
| 77 | if decoded in _OPC_UNRESERVED |
| 78 | else f"%{value:02X}" |
| 79 | ) |
| 80 | index += 3 |
| 81 | |
| 82 | decoded_path = "".join(output) |
| 83 | if decoded_path.rsplit("/", 1)[-1] in {".", ".."}: |
| 84 | return None |
| 85 | normalized = posixpath.normpath(decoded_path) |
| 86 | if ( |
| 87 | not normalized |
| 88 | or normalized in {".", ".."} |
| 89 | or normalized.startswith("/") |
| 90 | or normalized.startswith("../") |
| 91 | ): |
| 92 | return None |
| 93 | return normalized.translate(_ASCII_LOWER_TRANSLATION) |
| 94 | |
| 95 | |
| 96 | def _source_part_for_rels(rels_path: str) -> str | None: |
| 97 | filename = posixpath.basename(rels_path) |
| 98 | if filename == ".rels" or not filename.endswith(".rels"): |
| 99 | return None |
| 100 | source_dir = posixpath.dirname(posixpath.dirname(rels_path)) |
| 101 | source_name = filename.removesuffix(".rels") |
| 102 | return ( |
| 103 | posixpath.join(source_dir, source_name) |
| 104 | if source_dir |
| 105 | else source_name |
| 106 | ) |
| 107 | |
| 108 | |
| 109 | def resolve_internal_opc_target( |
| 110 | rels_path: str, |
| 111 | target: str, |
| 112 | ) -> str | None: |
| 113 | """Resolve one valid internal OPC Target to its canonical package key.""" |
| 114 | target_path_query = target.split("#", 1)[0] |
| 115 | if ( |
| 116 | "\\" in target |
| 117 | or "?" in target_path_query |
| 118 | or any(ord(char) <= 0x20 for char in target) |
| 119 | ): |
| 120 | return None |
| 121 | try: |
| 122 | parsed = urlsplit(target) |
| 123 | except ValueError: |
| 124 | return None |
| 125 | if parsed.scheme or parsed.netloc or parsed.query: |
| 126 | return None |
| 127 | |
| 128 | source_part = _source_part_for_rels(rels_path) |
| 129 | if parsed.path.startswith("/"): |
| 130 | resolved = parsed.path[1:] |
| 131 | elif parsed.path: |
| 132 | base_dir = posixpath.dirname(source_part) if source_part else "" |
| 133 | resolved = ( |
| 134 | posixpath.join(base_dir, parsed.path) |
| 135 | if base_dir |
| 136 | else parsed.path |
| 137 | ) |
| 138 | elif source_part and "#" in target: |
| 139 | resolved = source_part |
| 140 | else: |
| 141 | return None |
| 142 | return canonical_opc_part_path(resolved) |
| 143 | |
| 144 | |
| 145 | def _relationships_path_for_part(part_path: Path) -> Path: |
| 146 | return part_path.parent / "_rels" / f"{part_path.name}.rels" |
| 147 | |
| 148 | |
| 149 | def _relationship_attrs_by_id(rels_path: Path) -> dict[str, dict[str, str]]: |
| 150 | if not rels_path.is_file(): |
| 151 | return {} |
| 152 | root = ET.parse(rels_path).getroot() |
| 153 | return { |
| 154 | elem.attrib["Id"]: dict(elem.attrib) |
| 155 | for elem in root.findall(_RELATIONSHIP_TAG) |
| 156 | if elem.attrib.get("Id") |
| 157 | } |
| 158 | |
| 159 | |
| 160 | def _presentation_slide_roster(extract_dir: Path) -> set[str]: |
| 161 | """Return canonical slide parts reachable from ``p:sldIdLst``.""" |
| 162 | presentation = extract_dir / "ppt" / "presentation.xml" |
| 163 | presentation_rels = _relationships_path_for_part(presentation) |
| 164 | if not presentation.is_file() or not presentation_rels.is_file(): |
| 165 | return set() |
| 166 | rels = _relationship_attrs_by_id(presentation_rels) |
| 167 | root = ET.parse(presentation).getroot() |
| 168 | roster: set[str] = set() |
| 169 | rels_rel = presentation_rels.relative_to(extract_dir).as_posix() |
| 170 | for slide_id in root.findall( |
| 171 | f"{{{_PRESENTATIONML_NS}}}sldIdLst/" |
| 172 | f"{{{_PRESENTATIONML_NS}}}sldId" |
| 173 | ): |
| 174 | relationship_id = slide_id.attrib.get(f"{{{_OFFICE_REL_NS}}}id") |
| 175 | relationship = rels.get(relationship_id or "") |
| 176 | if relationship is None or relationship.get("Type") != SLIDE_REL_TYPE: |
| 177 | continue |
| 178 | target = relationship.get("Target", "") |
| 179 | resolved = resolve_internal_opc_target(rels_rel, target) |
| 180 | if resolved is not None: |
| 181 | roster.add(resolved) |
| 182 | return roster |
| 183 | |
| 184 | |
| 185 | def verify_hyperlink_relationships(extract_dir: Path) -> list[str]: |
| 186 | """Return hyperlink/action mismatches and slide jumps outside the roster.""" |
| 187 | roster = _presentation_slide_roster(extract_dir) |
| 188 | problems: list[str] = [] |
| 189 | source_patterns = ( |
| 190 | "ppt/slides/slide*.xml", |
| 191 | "ppt/slideLayouts/slideLayout*.xml", |
| 192 | "ppt/slideMasters/slideMaster*.xml", |
| 193 | ) |
| 194 | for pattern in source_patterns: |
| 195 | for part_path in sorted(extract_dir.glob(pattern)): |
| 196 | rels_path = _relationships_path_for_part(part_path) |
| 197 | rels = _relationship_attrs_by_id(rels_path) |
| 198 | part_rel = part_path.relative_to(extract_dir).as_posix() |
| 199 | rels_rel = rels_path.relative_to(extract_dir).as_posix() |
| 200 | try: |
| 201 | root = ET.parse(part_path).getroot() |
| 202 | except ET.ParseError: |
| 203 | continue |
| 204 | for link in root.iter(f"{{{_DRAWINGML_NS}}}hlinkClick"): |
| 205 | action = (link.attrib.get("action") or "").strip() |
| 206 | if action == "ppaction://media": |
| 207 | continue |
| 208 | relationship_id = ( |
| 209 | link.attrib.get(f"{{{_OFFICE_REL_NS}}}id") or "" |
| 210 | ).strip() |
| 211 | if not relationship_id: |
| 212 | if action == SLIDE_JUMP_ACTION: |
| 213 | problems.append( |
| 214 | f"{part_rel} -> <slide jump without relationship id>" |
| 215 | ) |
| 216 | continue |
| 217 | relationship = rels.get(relationship_id) |
| 218 | if relationship is None: |
| 219 | problems.append( |
| 220 | f"{part_rel} -> <missing hyperlink relationship " |
| 221 | f"{relationship_id!r}>" |
| 222 | ) |
| 223 | continue |
| 224 | rel_type = relationship.get("Type", "") |
| 225 | target_mode = relationship.get("TargetMode", "") |
| 226 | target = relationship.get("Target", "") |
| 227 | if rel_type == HYPERLINK_REL_TYPE: |
| 228 | if target_mode.lower() != "external": |
| 229 | problems.append( |
| 230 | f"{part_rel} -> <hyperlink relationship " |
| 231 | f"{relationship_id!r} is not External>" |
| 232 | ) |
| 233 | if action == SLIDE_JUMP_ACTION: |
| 234 | problems.append( |
| 235 | f"{part_rel} -> <slide jump {relationship_id!r} " |
| 236 | "uses an external hyperlink relationship>" |
| 237 | ) |
| 238 | continue |
| 239 | if rel_type == SLIDE_REL_TYPE: |
| 240 | if target_mode: |
| 241 | problems.append( |
| 242 | f"{part_rel} -> <slide relationship " |
| 243 | f"{relationship_id!r} cannot be External>" |
| 244 | ) |
| 245 | if action != SLIDE_JUMP_ACTION: |
| 246 | problems.append( |
| 247 | f"{part_rel} -> <slide relationship " |
| 248 | f"{relationship_id!r} lacks hlinksldjump action>" |
| 249 | ) |
| 250 | resolved = resolve_internal_opc_target(rels_rel, target) |
| 251 | if resolved is not None and resolved not in roster: |
| 252 | problems.append( |
| 253 | f"{part_rel} -> <slide jump {relationship_id!r} " |
| 254 | f"targets non-roster part {resolved}>" |
| 255 | ) |
| 256 | continue |
| 257 | if action == SLIDE_JUMP_ACTION: |
| 258 | problems.append( |
| 259 | f"{part_rel} -> <slide jump {relationship_id!r} uses " |
| 260 | f"relationship type {rel_type!r}>" |
| 261 | ) |
| 262 | return problems |
| 263 | |
| 264 | |
| 265 | def verify_internal_relationships(extract_dir: Path) -> list[str]: |
| 266 | """Return invalid or dangling internal relationships in an OPC package.""" |
| 267 | package_parts: set[str] = set() |
| 268 | for path in extract_dir.rglob("*"): |
| 269 | if not path.is_file(): |
| 270 | continue |
| 271 | key = canonical_opc_part_path( |
| 272 | path.relative_to(extract_dir).as_posix() |
| 273 | ) |
| 274 | if key is not None: |
| 275 | package_parts.add(key) |
| 276 | |
| 277 | problems: list[str] = [] |
| 278 | for rels_path in sorted(extract_dir.rglob("*.rels")): |
| 279 | rels_rel = rels_path.relative_to(extract_dir).as_posix() |
| 280 | try: |
| 281 | root = ET.parse(rels_path).getroot() |
| 282 | except ET.ParseError as exc: |
| 283 | problems.append( |
| 284 | f"{rels_rel} -> <invalid relationships XML: {exc}>" |
| 285 | ) |
| 286 | continue |
| 287 | if root.tag != _RELATIONSHIPS_TAG: |
| 288 | problems.append( |
| 289 | f"{rels_rel} -> <invalid Relationships namespace>" |
| 290 | ) |
| 291 | continue |
| 292 | |
| 293 | seen_ids: set[str] = set() |
| 294 | for element in root: |
| 295 | if element.tag != _RELATIONSHIP_TAG: |
| 296 | problems.append( |
| 297 | f"{rels_rel} -> <invalid relationships child " |
| 298 | f"{element.tag!r}>" |
| 299 | ) |
| 300 | continue |
| 301 | relationship_id = (element.attrib.get("Id") or "").strip() |
| 302 | relationship_type = ( |
| 303 | element.attrib.get("Type") or "" |
| 304 | ).strip() |
| 305 | target = (element.attrib.get("Target") or "").strip() |
| 306 | target_mode = ( |
| 307 | element.attrib.get("TargetMode") or "" |
| 308 | ).strip() |
| 309 | |
| 310 | if not relationship_id: |
| 311 | problems.append(f"{rels_rel} -> <missing relationship Id>") |
| 312 | elif relationship_id in seen_ids: |
| 313 | problems.append( |
| 314 | f"{rels_rel} -> <duplicate relationship Id " |
| 315 | f"{relationship_id!r}>" |
| 316 | ) |
| 317 | else: |
| 318 | seen_ids.add(relationship_id) |
| 319 | if not relationship_type: |
| 320 | problems.append( |
| 321 | f"{rels_rel} -> <missing relationship Type>" |
| 322 | ) |
| 323 | if not target: |
| 324 | problems.append(f"{rels_rel} -> <missing Target>") |
| 325 | continue |
| 326 | if target_mode and target_mode.lower() not in { |
| 327 | "internal", |
| 328 | "external", |
| 329 | }: |
| 330 | problems.append( |
| 331 | f"{rels_rel} -> <invalid TargetMode " |
| 332 | f"{target_mode!r}>" |
| 333 | ) |
| 334 | continue |
| 335 | if target_mode.lower() == "external": |
| 336 | continue |
| 337 | |
| 338 | resolved = resolve_internal_opc_target(rels_rel, target) |
| 339 | if resolved is None: |
| 340 | problems.append( |
| 341 | f"{rels_rel} -> <invalid Target {target!r}>" |
| 342 | ) |
| 343 | elif resolved not in package_parts: |
| 344 | problems.append(f"{rels_rel} -> {resolved}") |
| 345 | problems.extend(verify_hyperlink_relationships(extract_dir)) |
| 346 | return problems |
| 347 |