| 1 | #!/usr/bin/env python3 |
| 2 | """ |
| 3 | PPT Master - PPTX Delivery Check |
| 4 | |
| 5 | Inspect a finished PPTX without modifying it and report package integrity, |
| 6 | delivery portability, media footprint, hidden slides, and motion presence. |
| 7 | |
| 8 | Usage: |
| 9 | python3 scripts/pptx_delivery_check.py <presentation.pptx> |
| 10 | |
| 11 | Examples: |
| 12 | python3 scripts/pptx_delivery_check.py projects/demo/exports/demo.pptx |
| 13 | |
| 14 | Dependencies: |
| 15 | Same repository dependencies as beautify_identity.py and svg_to_pptx.py. |
| 16 | """ |
| 17 | |
| 18 | from __future__ import annotations |
| 19 | |
| 20 | import argparse |
| 21 | import json |
| 22 | import mimetypes |
| 23 | import re |
| 24 | import sys |
| 25 | import tempfile |
| 26 | import unicodedata |
| 27 | import zipfile |
| 28 | from collections import Counter, defaultdict |
| 29 | from pathlib import Path, PurePosixPath |
| 30 | from xml.etree import ElementTree as ET |
| 31 | |
| 32 | _SCRIPTS_DIR = Path(__file__).resolve().parent |
| 33 | if str(_SCRIPTS_DIR) not in sys.path: |
| 34 | sys.path.insert(0, str(_SCRIPTS_DIR)) |
| 35 | |
| 36 | from console_encoding import configure_utf8_stdio # noqa: E402 |
| 37 | |
| 38 | configure_utf8_stdio() |
| 39 | |
| 40 | from beautify_identity import extract_identity # noqa: E402 |
| 41 | from pptx_opc_validation import ( # noqa: E402 |
| 42 | canonical_opc_part_path, |
| 43 | resolve_internal_opc_target, |
| 44 | verify_internal_relationships, |
| 45 | ) |
| 46 | from pptx_animations import ( # noqa: E402 |
| 47 | object_animation_fingerprint, |
| 48 | read_slide_animation_sequence, |
| 49 | validate_pptx_animation_package, |
| 50 | ) |
| 51 | from pptx_to_svg.ooxml_loader import ( # noqa: E402 |
| 52 | OoxmlPackage, |
| 53 | parse_ooxml_boolean, |
| 54 | ) |
| 55 | from pptx_transitions import ( # noqa: E402 |
| 56 | read_slide_transition_xml, |
| 57 | validate_slide_transition_xml, |
| 58 | ) |
| 59 | from svg_to_pptx.drawingml.utils import PPT_SAFE_FONTS # noqa: E402 |
| 60 | |
| 61 | |
| 62 | REPORT_SCHEMA = "ppt-master.pptx-delivery-check.v1" |
| 63 | PACKAGE_REL_NS = "http://schemas.openxmlformats.org/package/2006/relationships" |
| 64 | CONTENT_TYPES_NS = "http://schemas.openxmlformats.org/package/2006/content-types" |
| 65 | DRAWINGML_NS = "http://schemas.openxmlformats.org/drawingml/2006/main" |
| 66 | PRESENTATION_NS = ( |
| 67 | "http://schemas.openxmlformats.org/presentationml/2006/main" |
| 68 | ) |
| 69 | _SLIDE_PART_RE = re.compile(r"ppt/slides/slide[1-9]\d*\.xml") |
| 70 | _NOTES_PART_RE = re.compile(r"ppt/notesSlides/notesSlide[1-9]\d*\.xml") |
| 71 | _MASTER_PART_RE = re.compile(r"ppt/slideMasters/slideMaster[1-9]\d*\.xml") |
| 72 | _LAYOUT_PART_RE = re.compile(r"ppt/slideLayouts/slideLayout[1-9]\d*\.xml") |
| 73 | _MEDIA_REL_KINDS = frozenset({"audio", "image", "media", "video"}) |
| 74 | _PPT_SAFE_FONT_ALIASES = { |
| 75 | "等线": "DengXian", |
| 76 | "等线 light": "DengXian Light", |
| 77 | "宋体": "SimSun", |
| 78 | "新細明體": "PMingLiU", |
| 79 | "맑은 고딕": "Malgun Gothic", |
| 80 | "ms pゴシック": "MS PGothic", |
| 81 | } |
| 82 | _PPT_DELIVERY_SAFE_FONTS = PPT_SAFE_FONTS | frozenset( |
| 83 | {"dengxian light", "pmingliu"} |
| 84 | ) |
| 85 | |
| 86 | |
| 87 | def _issue(code: str, message: str, **details: object) -> dict[str, object]: |
| 88 | issue: dict[str, object] = {"code": code, "message": message} |
| 89 | issue.update(details) |
| 90 | return issue |
| 91 | |
| 92 | |
| 93 | def _empty_report(path: Path) -> dict[str, object]: |
| 94 | return { |
| 95 | "schema": REPORT_SCHEMA, |
| 96 | "status": "failed", |
| 97 | "file": { |
| 98 | "path": str(path), |
| 99 | "bytes": path.stat().st_size if path.is_file() else None, |
| 100 | }, |
| 101 | "package": { |
| 102 | "zip_integrity": "not-checked", |
| 103 | "corrupt_member": None, |
| 104 | "duplicate_parts": [], |
| 105 | "canonical_part_collisions": [], |
| 106 | "parts": {}, |
| 107 | "relationships": {"problems": []}, |
| 108 | }, |
| 109 | "slides": { |
| 110 | "count": 0, |
| 111 | "hidden_count": 0, |
| 112 | "hidden": [], |
| 113 | }, |
| 114 | "fonts": { |
| 115 | "theme": {}, |
| 116 | "declared": {}, |
| 117 | "embedded_parts": [], |
| 118 | "portability_advisory_faces": [], |
| 119 | }, |
| 120 | "media": { |
| 121 | "embedded_count": 0, |
| 122 | "external_count": 0, |
| 123 | "embedded": [], |
| 124 | "external": [], |
| 125 | "uncompressed_bytes": 0, |
| 126 | "archive_compressed_bytes": 0, |
| 127 | "share_of_archive": 0.0, |
| 128 | "share_of_uncompressed_parts": 0.0, |
| 129 | "top_contributors": [], |
| 130 | }, |
| 131 | "motion": { |
| 132 | "transitions": { |
| 133 | "carrier_slide_count": 0, |
| 134 | "visual_effect_slide_count": 0, |
| 135 | "timed_advance_slide_count": 0, |
| 136 | "carrier_slides": [], |
| 137 | "visual_effect_slides": [], |
| 138 | "timed_advance_slides": [], |
| 139 | "effects": {}, |
| 140 | }, |
| 141 | "object_animations": { |
| 142 | "timing_slide_count": 0, |
| 143 | "object_animation_slide_count": 0, |
| 144 | "audio_timing_slide_count": 0, |
| 145 | "timing_slides": [], |
| 146 | "object_animation_slides": [], |
| 147 | "audio_timing_slides": [], |
| 148 | }, |
| 149 | }, |
| 150 | "errors": [], |
| 151 | "advisories": [], |
| 152 | } |
| 153 | |
| 154 | |
| 155 | def _content_type_maps( |
| 156 | archive: zipfile.ZipFile, |
| 157 | errors: list[dict[str, object]], |
| 158 | ) -> tuple[dict[str, str], dict[str, str]]: |
| 159 | try: |
| 160 | root = ET.fromstring(archive.read("[Content_Types].xml")) |
| 161 | except KeyError: |
| 162 | errors.append( |
| 163 | _issue( |
| 164 | "missing_content_types", |
| 165 | "PPTX package is missing [Content_Types].xml.", |
| 166 | ) |
| 167 | ) |
| 168 | return {}, {} |
| 169 | except ET.ParseError as exc: |
| 170 | errors.append( |
| 171 | _issue( |
| 172 | "invalid_content_types", |
| 173 | f"PPTX content-types XML is invalid: {exc}.", |
| 174 | ) |
| 175 | ) |
| 176 | return {}, {} |
| 177 | expected_root = f"{{{CONTENT_TYPES_NS}}}Types" |
| 178 | if root.tag != expected_root: |
| 179 | errors.append( |
| 180 | _issue( |
| 181 | "invalid_content_types", |
| 182 | "PPTX content-types XML uses an invalid Types namespace.", |
| 183 | ) |
| 184 | ) |
| 185 | return {}, {} |
| 186 | |
| 187 | defaults: dict[str, str] = {} |
| 188 | overrides: dict[str, str] = {} |
| 189 | default_tag = f"{{{CONTENT_TYPES_NS}}}Default" |
| 190 | override_tag = f"{{{CONTENT_TYPES_NS}}}Override" |
| 191 | for node in root: |
| 192 | if node.tag == default_tag: |
| 193 | raw_extension = (node.attrib.get("Extension") or "").strip() |
| 194 | content_type = (node.attrib.get("ContentType") or "").strip() |
| 195 | extension = raw_extension.lower() |
| 196 | if not extension or extension.startswith(".") or not content_type: |
| 197 | errors.append( |
| 198 | _issue( |
| 199 | "invalid_content_types", |
| 200 | "PPTX content-types Default entry is incomplete.", |
| 201 | ) |
| 202 | ) |
| 203 | continue |
| 204 | if extension in defaults: |
| 205 | errors.append( |
| 206 | _issue( |
| 207 | "invalid_content_types", |
| 208 | "PPTX content-types XML has a duplicate Default extension.", |
| 209 | extension=raw_extension, |
| 210 | ) |
| 211 | ) |
| 212 | continue |
| 213 | defaults[extension] = content_type |
| 214 | continue |
| 215 | if node.tag == override_tag: |
| 216 | raw_part_name = (node.attrib.get("PartName") or "").strip() |
| 217 | content_type = (node.attrib.get("ContentType") or "").strip() |
| 218 | part_name = raw_part_name.lstrip("/") |
| 219 | if ( |
| 220 | not raw_part_name.startswith("/") |
| 221 | or not part_name |
| 222 | or ".." in PurePosixPath(part_name).parts |
| 223 | or not content_type |
| 224 | ): |
| 225 | errors.append( |
| 226 | _issue( |
| 227 | "invalid_content_types", |
| 228 | "PPTX content-types Override entry is incomplete or invalid.", |
| 229 | part=raw_part_name or None, |
| 230 | ) |
| 231 | ) |
| 232 | continue |
| 233 | if part_name in overrides: |
| 234 | errors.append( |
| 235 | _issue( |
| 236 | "invalid_content_types", |
| 237 | "PPTX content-types XML has a duplicate Override part.", |
| 238 | part=raw_part_name, |
| 239 | ) |
| 240 | ) |
| 241 | continue |
| 242 | overrides[part_name] = content_type |
| 243 | continue |
| 244 | errors.append( |
| 245 | _issue( |
| 246 | "invalid_content_types", |
| 247 | "PPTX content-types XML contains an unexpected child.", |
| 248 | child=node.tag, |
| 249 | ) |
| 250 | ) |
| 251 | return defaults, overrides |
| 252 | |
| 253 | |
| 254 | def _content_type_for_part( |
| 255 | part_name: str, |
| 256 | defaults: dict[str, str], |
| 257 | overrides: dict[str, str], |
| 258 | ) -> str: |
| 259 | declared = _declared_content_type_for_part( |
| 260 | part_name, |
| 261 | defaults, |
| 262 | overrides, |
| 263 | ) |
| 264 | if declared is not None: |
| 265 | return declared |
| 266 | guessed, _encoding = mimetypes.guess_type(part_name) |
| 267 | return guessed or "application/octet-stream" |
| 268 | |
| 269 | |
| 270 | def _declared_content_type_for_part( |
| 271 | part_name: str, |
| 272 | defaults: dict[str, str], |
| 273 | overrides: dict[str, str], |
| 274 | ) -> str | None: |
| 275 | if part_name in overrides: |
| 276 | return overrides[part_name] |
| 277 | filename = PurePosixPath(part_name).name |
| 278 | extension = ( |
| 279 | "rels" |
| 280 | if filename == ".rels" or filename.endswith(".rels") |
| 281 | else PurePosixPath(part_name).suffix.lstrip(".").lower() |
| 282 | ) |
| 283 | return defaults.get(extension) |
| 284 | |
| 285 | |
| 286 | def _xml_and_font_inventory( |
| 287 | archive: zipfile.ZipFile, |
| 288 | part_names: set[str], |
| 289 | errors: list[dict[str, object]], |
| 290 | ) -> dict[str, list[dict[str, object]]]: |
| 291 | font_counts: dict[str, Counter[str]] = { |
| 292 | "latin": Counter(), |
| 293 | "ea": Counter(), |
| 294 | "cs": Counter(), |
| 295 | } |
| 296 | for part_name in sorted( |
| 297 | name |
| 298 | for name in part_names |
| 299 | if name.endswith(".xml") and name != "[Content_Types].xml" |
| 300 | ): |
| 301 | try: |
| 302 | root = ET.fromstring(archive.read(part_name)) |
| 303 | except (KeyError, ET.ParseError) as exc: |
| 304 | errors.append( |
| 305 | _issue( |
| 306 | "invalid_xml_part", |
| 307 | f"Cannot parse XML part {part_name}: {exc}.", |
| 308 | part=part_name, |
| 309 | ) |
| 310 | ) |
| 311 | continue |
| 312 | for role in font_counts: |
| 313 | for element in root.iter( |
| 314 | f"{{{DRAWINGML_NS}}}{role}" |
| 315 | ): |
| 316 | face = (element.attrib.get("typeface") or "").strip() |
| 317 | if face: |
| 318 | font_counts[role][face] += 1 |
| 319 | return { |
| 320 | role: [ |
| 321 | {"value": face, "count": count} |
| 322 | for face, count in sorted( |
| 323 | counts.items(), |
| 324 | key=lambda item: (-item[1], item[0].casefold()), |
| 325 | ) |
| 326 | ] |
| 327 | for role, counts in font_counts.items() |
| 328 | } |
| 329 | |
| 330 | |
| 331 | def _relationship_kind(relationship_type: str) -> str | None: |
| 332 | kind = relationship_type.rstrip("/").rsplit("/", 1)[-1].lower() |
| 333 | return kind if kind in _MEDIA_REL_KINDS else None |
| 334 | |
| 335 | |
| 336 | def _relationship_inventory( |
| 337 | archive: zipfile.ZipFile, |
| 338 | part_names: set[str], |
| 339 | errors: list[dict[str, object]], |
| 340 | ) -> tuple[ |
| 341 | Counter[str], |
| 342 | dict[str, set[str]], |
| 343 | list[dict[str, object]], |
| 344 | ]: |
| 345 | reference_counts: Counter[str] = Counter() |
| 346 | relationship_types: dict[str, set[str]] = defaultdict(set) |
| 347 | external_counts: Counter[tuple[str, str, str]] = Counter() |
| 348 | |
| 349 | for rels_name in sorted( |
| 350 | name for name in part_names if name.endswith(".rels") |
| 351 | ): |
| 352 | try: |
| 353 | root = ET.fromstring(archive.read(rels_name)) |
| 354 | except (KeyError, ET.ParseError) as exc: |
| 355 | errors.append( |
| 356 | _issue( |
| 357 | "invalid_relationship_part", |
| 358 | f"Cannot parse relationship part {rels_name}: {exc}.", |
| 359 | part=rels_name, |
| 360 | ) |
| 361 | ) |
| 362 | continue |
| 363 | for relationship in root.findall( |
| 364 | f"{{{PACKAGE_REL_NS}}}Relationship" |
| 365 | ): |
| 366 | relationship_type = ( |
| 367 | relationship.attrib.get("Type") or "" |
| 368 | ).strip() |
| 369 | target = (relationship.attrib.get("Target") or "").strip() |
| 370 | target_mode = ( |
| 371 | relationship.attrib.get("TargetMode") or "" |
| 372 | ).strip().lower() |
| 373 | kind = _relationship_kind(relationship_type) |
| 374 | if target_mode == "external": |
| 375 | if kind and target: |
| 376 | external_counts[(kind, target, relationship_type)] += 1 |
| 377 | continue |
| 378 | resolved = resolve_internal_opc_target(rels_name, target) |
| 379 | if resolved is None: |
| 380 | continue |
| 381 | reference_counts[resolved] += 1 |
| 382 | if relationship_type: |
| 383 | relationship_types[resolved].add(relationship_type) |
| 384 | |
| 385 | external = [ |
| 386 | { |
| 387 | "target": target, |
| 388 | "kind": kind, |
| 389 | "relationship_type": relationship_type, |
| 390 | "bytes": None, |
| 391 | "compressed_bytes": None, |
| 392 | "reference_count": count, |
| 393 | } |
| 394 | for (kind, target, relationship_type), count in sorted( |
| 395 | external_counts.items() |
| 396 | ) |
| 397 | ] |
| 398 | return reference_counts, relationship_types, external |
| 399 | |
| 400 | |
| 401 | def _embedded_part_record( |
| 402 | info: zipfile.ZipInfo, |
| 403 | *, |
| 404 | content_type: str, |
| 405 | reference_counts: Counter[str], |
| 406 | relationship_types: dict[str, set[str]], |
| 407 | ) -> dict[str, object]: |
| 408 | kind = content_type.split("/", 1)[0] |
| 409 | if kind not in {"audio", "image", "video"}: |
| 410 | kind = "media" |
| 411 | part_key = canonical_opc_part_path(info.filename) |
| 412 | return { |
| 413 | "part": info.filename, |
| 414 | "kind": kind, |
| 415 | "content_type": content_type, |
| 416 | "bytes": info.file_size, |
| 417 | "compressed_bytes": info.compress_size, |
| 418 | "reference_count": ( |
| 419 | reference_counts.get(part_key, 0) |
| 420 | if part_key is not None |
| 421 | else 0 |
| 422 | ), |
| 423 | "relationship_types": sorted( |
| 424 | relationship_types.get(part_key, set()) |
| 425 | if part_key is not None |
| 426 | else set() |
| 427 | ), |
| 428 | } |
| 429 | |
| 430 | |
| 431 | def _archive_member_problems( |
| 432 | infos: list[zipfile.ZipInfo], |
| 433 | ) -> list[str]: |
| 434 | problems: list[str] = [] |
| 435 | for info in infos: |
| 436 | name = info.filename |
| 437 | path = PurePosixPath(name) |
| 438 | if ( |
| 439 | name.startswith("/") |
| 440 | or "\\" in name |
| 441 | or ".." in path.parts |
| 442 | ): |
| 443 | problems.append(name) |
| 444 | return sorted(set(problems)) |
| 445 | |
| 446 | |
| 447 | def _relationship_problem_code(problem: str) -> str: |
| 448 | if "<invalid relationships XML:" in problem: |
| 449 | return "invalid_relationship_xml" |
| 450 | if " -> <" in problem: |
| 451 | return "invalid_internal_relationship" |
| 452 | return "dangling_internal_relationship" |
| 453 | |
| 454 | |
| 455 | def _font_faces_for_advisory( |
| 456 | theme_fonts: object, |
| 457 | declared_fonts: object, |
| 458 | ) -> list[str]: |
| 459 | faces: set[str] = set() |
| 460 | if isinstance(theme_fonts, dict): |
| 461 | for role in ("title", "body"): |
| 462 | value = theme_fonts.get(role) |
| 463 | if not isinstance(value, dict): |
| 464 | continue |
| 465 | for field in ("latin", "ea", "cs"): |
| 466 | face = value.get(field) |
| 467 | if isinstance(face, str) and face.strip(): |
| 468 | faces.add(face.strip()) |
| 469 | scripts = value.get("scripts") |
| 470 | if isinstance(scripts, dict): |
| 471 | for face in scripts.values(): |
| 472 | if isinstance(face, str) and face.strip(): |
| 473 | faces.add(face.strip()) |
| 474 | if isinstance(declared_fonts, dict): |
| 475 | for role in ("latin", "ea", "cs"): |
| 476 | values = declared_fonts.get(role) |
| 477 | if not isinstance(values, list): |
| 478 | continue |
| 479 | for value in values: |
| 480 | if not isinstance(value, dict): |
| 481 | continue |
| 482 | face = value.get("value") |
| 483 | if isinstance(face, str) and face.strip(): |
| 484 | faces.add(face.strip()) |
| 485 | return sorted(faces, key=str.casefold) |
| 486 | |
| 487 | |
| 488 | def _unsafe_font_faces(faces: list[str]) -> list[str]: |
| 489 | unsafe: list[str] = [] |
| 490 | for face in faces: |
| 491 | normalized = unicodedata.normalize("NFKC", face).strip().casefold() |
| 492 | canonical = _PPT_SAFE_FONT_ALIASES.get(normalized, normalized) |
| 493 | canonical = unicodedata.normalize( |
| 494 | "NFKC", |
| 495 | canonical, |
| 496 | ).strip().casefold() |
| 497 | if canonical.startswith("+") or canonical in _PPT_DELIVERY_SAFE_FONTS: |
| 498 | continue |
| 499 | unsafe.append(face) |
| 500 | return unsafe |
| 501 | |
| 502 | |
| 503 | def _motion_and_hidden_summary( |
| 504 | path: Path, |
| 505 | errors: list[dict[str, object]], |
| 506 | ) -> tuple[int, list[dict[str, object]], dict[str, object]]: |
| 507 | hidden: list[dict[str, object]] = [] |
| 508 | transition_carriers: list[int] = [] |
| 509 | visual_transitions: list[int] = [] |
| 510 | timed_advances: list[int] = [] |
| 511 | transition_effects: Counter[str] = Counter() |
| 512 | timing_slides: list[int] = [] |
| 513 | object_animation_slides: list[int] = [] |
| 514 | audio_timing_slides: list[int] = [] |
| 515 | slide_count = 0 |
| 516 | |
| 517 | try: |
| 518 | validate_pptx_animation_package( |
| 519 | path, |
| 520 | require_supported_effects=False, |
| 521 | ) |
| 522 | except ValueError as exc: |
| 523 | errors.append( |
| 524 | _issue( |
| 525 | "invalid_animation_structure", |
| 526 | str(exc), |
| 527 | ) |
| 528 | ) |
| 529 | |
| 530 | try: |
| 531 | with OoxmlPackage(path) as package: |
| 532 | if package.zip is None: |
| 533 | raise RuntimeError("PPTX package closed during slide audit") |
| 534 | if package.presentation is None: |
| 535 | raise RuntimeError("PPTX package has no presentation part") |
| 536 | slide_roster = package.presentation.xml.find( |
| 537 | f"{{{PRESENTATION_NS}}}sldIdLst" |
| 538 | ) |
| 539 | roster_count = ( |
| 540 | len( |
| 541 | slide_roster.findall( |
| 542 | f"{{{PRESENTATION_NS}}}sldId" |
| 543 | ) |
| 544 | ) |
| 545 | if slide_roster is not None |
| 546 | else 0 |
| 547 | ) |
| 548 | loaded_count = package.slide_count |
| 549 | inspected_count = 0 |
| 550 | slide_count = roster_count |
| 551 | for slide in package.iter_slides(): |
| 552 | inspected_count += 1 |
| 553 | try: |
| 554 | visible = parse_ooxml_boolean( |
| 555 | slide.part.xml.attrib.get("show"), |
| 556 | default=True, |
| 557 | context=f"{slide.part.path} show", |
| 558 | ) |
| 559 | except RuntimeError as exc: |
| 560 | errors.append( |
| 561 | _issue( |
| 562 | "invalid_slide_visibility", |
| 563 | str(exc), |
| 564 | slide_index=slide.index, |
| 565 | part=slide.part.path, |
| 566 | ) |
| 567 | ) |
| 568 | visible = True |
| 569 | if not visible: |
| 570 | hidden.append( |
| 571 | { |
| 572 | "index": slide.index, |
| 573 | "part": slide.part.path, |
| 574 | } |
| 575 | ) |
| 576 | |
| 577 | slide_xml = package.zip.read(slide.part.path) |
| 578 | transition_problems = validate_slide_transition_xml(slide_xml) |
| 579 | for problem in transition_problems: |
| 580 | errors.append( |
| 581 | _issue( |
| 582 | "invalid_transition_structure", |
| 583 | f"{slide.part.path}: {problem}", |
| 584 | slide_index=slide.index, |
| 585 | part=slide.part.path, |
| 586 | ) |
| 587 | ) |
| 588 | try: |
| 589 | transition = read_slide_transition_xml(slide_xml) |
| 590 | except (ET.ParseError, ValueError) as exc: |
| 591 | if not transition_problems: |
| 592 | errors.append( |
| 593 | _issue( |
| 594 | "transition_readback_failed", |
| 595 | f"{slide.part.path}: {exc}", |
| 596 | slide_index=slide.index, |
| 597 | part=slide.part.path, |
| 598 | ) |
| 599 | ) |
| 600 | else: |
| 601 | if transition.logical_count: |
| 602 | transition_carriers.append(slide.index) |
| 603 | effect = ( |
| 604 | transition.canonical_effect |
| 605 | or transition.effect |
| 606 | or "timing-only" |
| 607 | ) |
| 608 | transition_effects[effect] += 1 |
| 609 | if transition.effect is not None: |
| 610 | visual_transitions.append(slide.index) |
| 611 | if transition.advance_after_ms is not None: |
| 612 | timed_advances.append(slide.index) |
| 613 | |
| 614 | try: |
| 615 | animation = read_slide_animation_sequence( |
| 616 | slide_xml, |
| 617 | require_supported_effects=False, |
| 618 | ) |
| 619 | except (ET.ParseError, ValueError): |
| 620 | animation = None |
| 621 | |
| 622 | try: |
| 623 | has_object_animation = ( |
| 624 | object_animation_fingerprint(slide_xml) is not None |
| 625 | ) |
| 626 | except ValueError as exc: |
| 627 | errors.append( |
| 628 | _issue( |
| 629 | "animation_presence_readback_failed", |
| 630 | f"{slide.part.path}: {exc}", |
| 631 | slide_index=slide.index, |
| 632 | part=slide.part.path, |
| 633 | ) |
| 634 | ) |
| 635 | has_object_animation = False |
| 636 | |
| 637 | root = ET.fromstring(slide_xml) |
| 638 | has_timing = any( |
| 639 | node.tag == f"{{{PRESENTATION_NS}}}timing" |
| 640 | for node in root |
| 641 | ) |
| 642 | has_audio_timing = any( |
| 643 | node.tag == f"{{{PRESENTATION_NS}}}audio" |
| 644 | for node in root.iter() |
| 645 | ) |
| 646 | if animation is not None: |
| 647 | has_timing = has_timing or bool(animation.timing_count) |
| 648 | has_object_animation = ( |
| 649 | has_object_animation or bool(animation.rows) |
| 650 | ) |
| 651 | has_audio_timing = ( |
| 652 | has_audio_timing or bool(animation.audio_target_ids) |
| 653 | ) |
| 654 | if has_timing: |
| 655 | timing_slides.append(slide.index) |
| 656 | if has_object_animation: |
| 657 | object_animation_slides.append(slide.index) |
| 658 | if has_audio_timing: |
| 659 | audio_timing_slides.append(slide.index) |
| 660 | if ( |
| 661 | roster_count != loaded_count |
| 662 | or loaded_count != inspected_count |
| 663 | ): |
| 664 | errors.append( |
| 665 | _issue( |
| 666 | "slide_inventory_failed", |
| 667 | ( |
| 668 | f"Presentation declares {roster_count} slides, " |
| 669 | f"but the loader resolved {loaded_count} and the " |
| 670 | f"delivery audit inspected {inspected_count}." |
| 671 | ), |
| 672 | roster_count=roster_count, |
| 673 | loaded_count=loaded_count, |
| 674 | inspected_count=inspected_count, |
| 675 | ) |
| 676 | ) |
| 677 | except (KeyError, OSError, RuntimeError, ValueError, zipfile.BadZipFile) as exc: |
| 678 | errors.append( |
| 679 | _issue( |
| 680 | "slide_inventory_failed", |
| 681 | f"Cannot inspect presentation slides: {exc}.", |
| 682 | ) |
| 683 | ) |
| 684 | |
| 685 | return slide_count, hidden, { |
| 686 | "transitions": { |
| 687 | "carrier_slide_count": len(transition_carriers), |
| 688 | "visual_effect_slide_count": len(visual_transitions), |
| 689 | "timed_advance_slide_count": len(timed_advances), |
| 690 | "carrier_slides": transition_carriers, |
| 691 | "visual_effect_slides": visual_transitions, |
| 692 | "timed_advance_slides": timed_advances, |
| 693 | "effects": dict(sorted(transition_effects.items())), |
| 694 | }, |
| 695 | "object_animations": { |
| 696 | "timing_slide_count": len(timing_slides), |
| 697 | "object_animation_slide_count": len(object_animation_slides), |
| 698 | "audio_timing_slide_count": len(audio_timing_slides), |
| 699 | "timing_slides": timing_slides, |
| 700 | "object_animation_slides": object_animation_slides, |
| 701 | "audio_timing_slides": audio_timing_slides, |
| 702 | }, |
| 703 | } |
| 704 | |
| 705 | |
| 706 | def _deduplicate_issues( |
| 707 | issues: list[dict[str, object]], |
| 708 | ) -> list[dict[str, object]]: |
| 709 | seen: set[str] = set() |
| 710 | output: list[dict[str, object]] = [] |
| 711 | for issue in issues: |
| 712 | key = json.dumps(issue, ensure_ascii=False, sort_keys=True) |
| 713 | if key in seen: |
| 714 | continue |
| 715 | seen.add(key) |
| 716 | output.append(issue) |
| 717 | return output |
| 718 | |
| 719 | |
| 720 | def audit_pptx_delivery(path: str | Path) -> dict[str, object]: |
| 721 | """Return a JSON-safe, read-only delivery audit for one PPTX file.""" |
| 722 | pptx_path = Path(path).expanduser().resolve() |
| 723 | report = _empty_report(pptx_path) |
| 724 | errors = report["errors"] |
| 725 | advisories = report["advisories"] |
| 726 | if not isinstance(errors, list) or not isinstance(advisories, list): |
| 727 | raise AssertionError("delivery report issue containers are invalid") |
| 728 | |
| 729 | if not pptx_path.is_file(): |
| 730 | errors.append( |
| 731 | _issue( |
| 732 | "file_not_found", |
| 733 | f"PPTX file not found: {pptx_path}.", |
| 734 | ) |
| 735 | ) |
| 736 | return report |
| 737 | |
| 738 | try: |
| 739 | with zipfile.ZipFile(pptx_path) as archive: |
| 740 | infos = archive.infolist() |
| 741 | file_infos = [info for info in infos if not info.is_dir()] |
| 742 | names = [info.filename for info in file_infos] |
| 743 | name_counts = Counter(names) |
| 744 | duplicate_parts = sorted( |
| 745 | name for name, count in name_counts.items() if count > 1 |
| 746 | ) |
| 747 | info_by_name = {info.filename: info for info in file_infos} |
| 748 | part_names = set(info_by_name) |
| 749 | canonical_names: dict[str, set[str]] = defaultdict(set) |
| 750 | invalid_part_names: list[str] = [] |
| 751 | for name in part_names: |
| 752 | canonical = canonical_opc_part_path(name) |
| 753 | if canonical is None: |
| 754 | invalid_part_names.append(name) |
| 755 | else: |
| 756 | canonical_names[canonical].add(name) |
| 757 | canonical_collisions = sorted( |
| 758 | sorted(raw_names) |
| 759 | for raw_names in canonical_names.values() |
| 760 | if len(raw_names) > 1 |
| 761 | ) |
| 762 | |
| 763 | package = report["package"] |
| 764 | if not isinstance(package, dict): |
| 765 | raise AssertionError("delivery report package container is invalid") |
| 766 | try: |
| 767 | corrupt_member = archive.testzip() |
| 768 | except (NotImplementedError, RuntimeError) as exc: |
| 769 | package["zip_integrity"] = "failed" |
| 770 | errors.append( |
| 771 | _issue( |
| 772 | "unsupported_zip_compression", |
| 773 | f"PPTX ZIP members cannot be decoded: {exc}.", |
| 774 | ) |
| 775 | ) |
| 776 | report["errors"] = _deduplicate_issues(errors) |
| 777 | return report |
| 778 | package["zip_integrity"] = ( |
| 779 | "passed" if corrupt_member is None else "failed" |
| 780 | ) |
| 781 | package["corrupt_member"] = corrupt_member |
| 782 | package["duplicate_parts"] = duplicate_parts |
| 783 | package["canonical_part_collisions"] = canonical_collisions |
| 784 | package["parts"] = { |
| 785 | "entries": len(file_infos), |
| 786 | "unique": len(part_names), |
| 787 | "slides": sum(bool(_SLIDE_PART_RE.fullmatch(name)) for name in part_names), |
| 788 | "notes": sum(bool(_NOTES_PART_RE.fullmatch(name)) for name in part_names), |
| 789 | "masters": sum(bool(_MASTER_PART_RE.fullmatch(name)) for name in part_names), |
| 790 | "layouts": sum(bool(_LAYOUT_PART_RE.fullmatch(name)) for name in part_names), |
| 791 | "media": sum( |
| 792 | (canonical_opc_part_path(name) or "").startswith( |
| 793 | "ppt/media/" |
| 794 | ) |
| 795 | for name in part_names |
| 796 | ), |
| 797 | } |
| 798 | if corrupt_member is not None: |
| 799 | errors.append( |
| 800 | _issue( |
| 801 | "zip_integrity_failed", |
| 802 | f"PPTX ZIP integrity failed at {corrupt_member}.", |
| 803 | part=corrupt_member, |
| 804 | ) |
| 805 | ) |
| 806 | if duplicate_parts: |
| 807 | errors.append( |
| 808 | _issue( |
| 809 | "duplicate_package_parts", |
| 810 | "PPTX package contains duplicate part names.", |
| 811 | parts=duplicate_parts, |
| 812 | ) |
| 813 | ) |
| 814 | if canonical_collisions: |
| 815 | errors.append( |
| 816 | _issue( |
| 817 | "duplicate_opc_part_names", |
| 818 | ( |
| 819 | "PPTX package contains case- or encoding-equivalent " |
| 820 | "part names." |
| 821 | ), |
| 822 | parts=canonical_collisions, |
| 823 | ) |
| 824 | ) |
| 825 | if invalid_part_names: |
| 826 | errors.append( |
| 827 | _issue( |
| 828 | "invalid_opc_part_name", |
| 829 | "PPTX package contains invalid OPC part names.", |
| 830 | parts=sorted(invalid_part_names), |
| 831 | ) |
| 832 | ) |
| 833 | |
| 834 | defaults, overrides = _content_type_maps(archive, errors) |
| 835 | content_type_registry_valid = not any( |
| 836 | issue.get("code") in { |
| 837 | "missing_content_types", |
| 838 | "invalid_content_types", |
| 839 | } |
| 840 | for issue in errors |
| 841 | if isinstance(issue, dict) |
| 842 | ) |
| 843 | if content_type_registry_valid: |
| 844 | missing_content_types = sorted( |
| 845 | name |
| 846 | for name in part_names |
| 847 | if name != "[Content_Types].xml" |
| 848 | and _declared_content_type_for_part( |
| 849 | name, |
| 850 | defaults, |
| 851 | overrides, |
| 852 | ) |
| 853 | is None |
| 854 | ) |
| 855 | if missing_content_types: |
| 856 | errors.append( |
| 857 | _issue( |
| 858 | "missing_part_content_type", |
| 859 | ( |
| 860 | "PPTX package parts are missing a declared " |
| 861 | "Default or Override content type." |
| 862 | ), |
| 863 | parts=missing_content_types, |
| 864 | ) |
| 865 | ) |
| 866 | declared_fonts = _xml_and_font_inventory( |
| 867 | archive, |
| 868 | part_names, |
| 869 | errors, |
| 870 | ) |
| 871 | reference_counts, relationship_types, external_media = ( |
| 872 | _relationship_inventory(archive, part_names, errors) |
| 873 | ) |
| 874 | |
| 875 | media_infos = [ |
| 876 | info_by_name[name] |
| 877 | for name in sorted(part_names) |
| 878 | if ( |
| 879 | canonical_opc_part_path(name) or "" |
| 880 | ).startswith("ppt/media/") |
| 881 | ] |
| 882 | embedded_media = [ |
| 883 | _embedded_part_record( |
| 884 | info, |
| 885 | content_type=_content_type_for_part( |
| 886 | info.filename, |
| 887 | defaults, |
| 888 | overrides, |
| 889 | ), |
| 890 | reference_counts=reference_counts, |
| 891 | relationship_types=relationship_types, |
| 892 | ) |
| 893 | for info in media_infos |
| 894 | ] |
| 895 | embedded_media.sort( |
| 896 | key=lambda item: ( |
| 897 | -int(item["bytes"]), |
| 898 | str(item["part"]), |
| 899 | ) |
| 900 | ) |
| 901 | |
| 902 | embedded_font_infos = [ |
| 903 | info_by_name[name] |
| 904 | for name in sorted(part_names) |
| 905 | if name.startswith("ppt/fonts/") |
| 906 | or name.lower().endswith(".fntdata") |
| 907 | ] |
| 908 | embedded_fonts = [ |
| 909 | { |
| 910 | "part": info.filename, |
| 911 | "content_type": _content_type_for_part( |
| 912 | info.filename, |
| 913 | defaults, |
| 914 | overrides, |
| 915 | ), |
| 916 | "bytes": info.file_size, |
| 917 | "compressed_bytes": info.compress_size, |
| 918 | "reference_count": reference_counts.get( |
| 919 | canonical_opc_part_path(info.filename) or "", |
| 920 | 0, |
| 921 | ), |
| 922 | } |
| 923 | for info in embedded_font_infos |
| 924 | ] |
| 925 | |
| 926 | media_bytes = sum(info.file_size for info in media_infos) |
| 927 | media_compressed_bytes = sum( |
| 928 | info.compress_size for info in media_infos |
| 929 | ) |
| 930 | total_uncompressed_bytes = sum( |
| 931 | info.file_size for info in info_by_name.values() |
| 932 | ) |
| 933 | file_bytes = pptx_path.stat().st_size |
| 934 | media = report["media"] |
| 935 | if not isinstance(media, dict): |
| 936 | raise AssertionError("delivery report media container is invalid") |
| 937 | media.update( |
| 938 | { |
| 939 | "embedded_count": len(embedded_media), |
| 940 | "external_count": len(external_media), |
| 941 | "embedded": embedded_media, |
| 942 | "external": external_media, |
| 943 | "uncompressed_bytes": media_bytes, |
| 944 | "archive_compressed_bytes": media_compressed_bytes, |
| 945 | "share_of_archive": ( |
| 946 | round(media_compressed_bytes / file_bytes, 6) |
| 947 | if file_bytes |
| 948 | else 0.0 |
| 949 | ), |
| 950 | "share_of_uncompressed_parts": ( |
| 951 | round(media_bytes / total_uncompressed_bytes, 6) |
| 952 | if total_uncompressed_bytes |
| 953 | else 0.0 |
| 954 | ), |
| 955 | "top_contributors": embedded_media[:10], |
| 956 | } |
| 957 | ) |
| 958 | fonts = report["fonts"] |
| 959 | if not isinstance(fonts, dict): |
| 960 | raise AssertionError("delivery report fonts container is invalid") |
| 961 | fonts["embedded_parts"] = embedded_fonts |
| 962 | fonts["declared"] = declared_fonts |
| 963 | |
| 964 | unsafe_members = _archive_member_problems(infos) |
| 965 | if unsafe_members: |
| 966 | errors.append( |
| 967 | _issue( |
| 968 | "unsafe_package_member", |
| 969 | "PPTX package contains unsafe member paths.", |
| 970 | parts=unsafe_members, |
| 971 | ) |
| 972 | ) |
| 973 | relationship_problems: list[str] = [] |
| 974 | else: |
| 975 | with tempfile.TemporaryDirectory( |
| 976 | prefix="pptx-delivery-check-" |
| 977 | ) as tmp: |
| 978 | extract_dir = Path(tmp) / "pptx" |
| 979 | archive.extractall(extract_dir) |
| 980 | relationship_problems = verify_internal_relationships( |
| 981 | extract_dir |
| 982 | ) |
| 983 | relationships = package["relationships"] |
| 984 | if not isinstance(relationships, dict): |
| 985 | raise AssertionError( |
| 986 | "delivery report relationships container is invalid" |
| 987 | ) |
| 988 | relationships["problems"] = relationship_problems |
| 989 | for problem in relationship_problems: |
| 990 | errors.append( |
| 991 | _issue( |
| 992 | _relationship_problem_code(problem), |
| 993 | problem, |
| 994 | ) |
| 995 | ) |
| 996 | except zipfile.BadZipFile as exc: |
| 997 | errors.append( |
| 998 | _issue( |
| 999 | "invalid_zip_package", |
| 1000 | f"File is not a readable PPTX ZIP package: {exc}.", |
| 1001 | ) |
| 1002 | ) |
| 1003 | return report |
| 1004 | except (NotImplementedError, RuntimeError) as exc: |
| 1005 | errors.append( |
| 1006 | _issue( |
| 1007 | "unsupported_zip_compression", |
| 1008 | f"PPTX ZIP members cannot be decoded: {exc}.", |
| 1009 | ) |
| 1010 | ) |
| 1011 | return report |
| 1012 | except OSError as exc: |
| 1013 | errors.append( |
| 1014 | _issue( |
| 1015 | "package_read_failed", |
| 1016 | f"Cannot read PPTX package: {exc}.", |
| 1017 | ) |
| 1018 | ) |
| 1019 | return report |
| 1020 | |
| 1021 | try: |
| 1022 | identity = extract_identity(pptx_path) |
| 1023 | except (KeyError, OSError, RuntimeError, ValueError, zipfile.BadZipFile) as exc: |
| 1024 | advisories.append( |
| 1025 | _issue( |
| 1026 | "font_inventory_unavailable", |
| 1027 | f"Could not resolve theme and declared font facts: {exc}.", |
| 1028 | ) |
| 1029 | ) |
| 1030 | else: |
| 1031 | identity_theme = identity.get("theme") |
| 1032 | theme_fonts = ( |
| 1033 | identity_theme.get("fonts", {}) |
| 1034 | if isinstance(identity_theme, dict) |
| 1035 | else {} |
| 1036 | ) |
| 1037 | fonts = report["fonts"] |
| 1038 | if not isinstance(fonts, dict): |
| 1039 | raise AssertionError("delivery report fonts container is invalid") |
| 1040 | declared_fonts = fonts.get("declared", {}) |
| 1041 | if not isinstance(declared_fonts, dict): |
| 1042 | declared_fonts = {} |
| 1043 | fonts["theme"] = theme_fonts |
| 1044 | unsafe_faces = _unsafe_font_faces( |
| 1045 | _font_faces_for_advisory(theme_fonts, declared_fonts) |
| 1046 | ) |
| 1047 | fonts["portability_advisory_faces"] = unsafe_faces |
| 1048 | if unsafe_faces: |
| 1049 | advisories.append( |
| 1050 | _issue( |
| 1051 | "font_portability", |
| 1052 | ( |
| 1053 | "Some fonts declared or referenced by the presentation " |
| 1054 | "are outside the common Office/OS portability set; " |
| 1055 | "target-system availability was not verified." |
| 1056 | ), |
| 1057 | faces=unsafe_faces, |
| 1058 | ) |
| 1059 | ) |
| 1060 | |
| 1061 | slide_count, hidden_slides, motion = _motion_and_hidden_summary( |
| 1062 | pptx_path, |
| 1063 | errors, |
| 1064 | ) |
| 1065 | slides = report["slides"] |
| 1066 | if not isinstance(slides, dict): |
| 1067 | raise AssertionError("delivery report slides container is invalid") |
| 1068 | slides.update( |
| 1069 | { |
| 1070 | "count": slide_count, |
| 1071 | "hidden_count": len(hidden_slides), |
| 1072 | "hidden": hidden_slides, |
| 1073 | } |
| 1074 | ) |
| 1075 | report["motion"] = motion |
| 1076 | if hidden_slides: |
| 1077 | advisories.append( |
| 1078 | _issue( |
| 1079 | "hidden_slides", |
| 1080 | "The presentation contains hidden slides; their state was not changed.", |
| 1081 | slides=[item["index"] for item in hidden_slides], |
| 1082 | ) |
| 1083 | ) |
| 1084 | |
| 1085 | media = report["media"] |
| 1086 | if isinstance(media, dict) and media.get("external_count"): |
| 1087 | advisories.append( |
| 1088 | _issue( |
| 1089 | "external_media", |
| 1090 | ( |
| 1091 | "The presentation contains externally linked media; " |
| 1092 | "offline delivery may depend on those targets." |
| 1093 | ), |
| 1094 | targets=[ |
| 1095 | item["target"] |
| 1096 | for item in media.get("external", []) |
| 1097 | if isinstance(item, dict) and "target" in item |
| 1098 | ], |
| 1099 | ) |
| 1100 | ) |
| 1101 | |
| 1102 | report["errors"] = _deduplicate_issues(errors) |
| 1103 | report["advisories"] = _deduplicate_issues(advisories) |
| 1104 | report["status"] = ( |
| 1105 | "failed" |
| 1106 | if report["errors"] |
| 1107 | else ( |
| 1108 | "passed-with-advisories" |
| 1109 | if report["advisories"] |
| 1110 | else "passed" |
| 1111 | ) |
| 1112 | ) |
| 1113 | return report |
| 1114 | |
| 1115 | |
| 1116 | def build_parser() -> argparse.ArgumentParser: |
| 1117 | parser = argparse.ArgumentParser( |
| 1118 | description="Inspect a finished PPTX for delivery risks without modifying it.", |
| 1119 | formatter_class=argparse.RawDescriptionHelpFormatter, |
| 1120 | ) |
| 1121 | parser.add_argument("pptx", help="Finished .pptx file to inspect") |
| 1122 | return parser |
| 1123 | |
| 1124 | |
| 1125 | def main(argv: list[str] | None = None) -> int: |
| 1126 | args = build_parser().parse_args(argv) |
| 1127 | report = audit_pptx_delivery(args.pptx) |
| 1128 | print(json.dumps(report, ensure_ascii=False, indent=2)) |
| 1129 | return 1 if report["status"] == "failed" else 0 |
| 1130 | |
| 1131 | |
| 1132 | if __name__ == "__main__": |
| 1133 | raise SystemExit(main()) |
| 1134 |