返回 ppt-master
pptx_delivery_check.py
根目录 / skills / ppt-master / scripts / pptx_delivery_check.py
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
1134 lines PYTHON