Files

1134 lines
39 KiB
Python

#!/usr/bin/env python3
"""
PPT Master - PPTX Delivery Check
Inspect a finished PPTX without modifying it and report package integrity,
delivery portability, media footprint, hidden slides, and motion presence.
Usage:
python3 scripts/pptx_delivery_check.py <presentation.pptx>
Examples:
python3 scripts/pptx_delivery_check.py projects/demo/exports/demo.pptx
Dependencies:
Same repository dependencies as beautify_identity.py and svg_to_pptx.py.
"""
from __future__ import annotations
import argparse
import json
import mimetypes
import re
import sys
import tempfile
import unicodedata
import zipfile
from collections import Counter, defaultdict
from pathlib import Path, PurePosixPath
from xml.etree import ElementTree as ET
_SCRIPTS_DIR = Path(__file__).resolve().parent
if str(_SCRIPTS_DIR) not in sys.path:
sys.path.insert(0, str(_SCRIPTS_DIR))
from console_encoding import configure_utf8_stdio # noqa: E402
configure_utf8_stdio()
from beautify_identity import extract_identity # noqa: E402
from pptx_opc_validation import ( # noqa: E402
canonical_opc_part_path,
resolve_internal_opc_target,
verify_internal_relationships,
)
from pptx_animations import ( # noqa: E402
object_animation_fingerprint,
read_slide_animation_sequence,
validate_pptx_animation_package,
)
from pptx_to_svg.ooxml_loader import ( # noqa: E402
OoxmlPackage,
parse_ooxml_boolean,
)
from pptx_transitions import ( # noqa: E402
read_slide_transition_xml,
validate_slide_transition_xml,
)
from svg_to_pptx.drawingml.utils import PPT_SAFE_FONTS # noqa: E402
REPORT_SCHEMA = "ppt-master.pptx-delivery-check.v1"
PACKAGE_REL_NS = "http://schemas.openxmlformats.org/package/2006/relationships"
CONTENT_TYPES_NS = "http://schemas.openxmlformats.org/package/2006/content-types"
DRAWINGML_NS = "http://schemas.openxmlformats.org/drawingml/2006/main"
PRESENTATION_NS = (
"http://schemas.openxmlformats.org/presentationml/2006/main"
)
_SLIDE_PART_RE = re.compile(r"ppt/slides/slide[1-9]\d*\.xml")
_NOTES_PART_RE = re.compile(r"ppt/notesSlides/notesSlide[1-9]\d*\.xml")
_MASTER_PART_RE = re.compile(r"ppt/slideMasters/slideMaster[1-9]\d*\.xml")
_LAYOUT_PART_RE = re.compile(r"ppt/slideLayouts/slideLayout[1-9]\d*\.xml")
_MEDIA_REL_KINDS = frozenset({"audio", "image", "media", "video"})
_PPT_SAFE_FONT_ALIASES = {
"等线": "DengXian",
"等线 light": "DengXian Light",
"宋体": "SimSun",
"新細明體": "PMingLiU",
"맑은 고딕": "Malgun Gothic",
"ms pゴシック": "MS PGothic",
}
_PPT_DELIVERY_SAFE_FONTS = PPT_SAFE_FONTS | frozenset(
{"dengxian light", "pmingliu"}
)
def _issue(code: str, message: str, **details: object) -> dict[str, object]:
issue: dict[str, object] = {"code": code, "message": message}
issue.update(details)
return issue
def _empty_report(path: Path) -> dict[str, object]:
return {
"schema": REPORT_SCHEMA,
"status": "failed",
"file": {
"path": str(path),
"bytes": path.stat().st_size if path.is_file() else None,
},
"package": {
"zip_integrity": "not-checked",
"corrupt_member": None,
"duplicate_parts": [],
"canonical_part_collisions": [],
"parts": {},
"relationships": {"problems": []},
},
"slides": {
"count": 0,
"hidden_count": 0,
"hidden": [],
},
"fonts": {
"theme": {},
"declared": {},
"embedded_parts": [],
"portability_advisory_faces": [],
},
"media": {
"embedded_count": 0,
"external_count": 0,
"embedded": [],
"external": [],
"uncompressed_bytes": 0,
"archive_compressed_bytes": 0,
"share_of_archive": 0.0,
"share_of_uncompressed_parts": 0.0,
"top_contributors": [],
},
"motion": {
"transitions": {
"carrier_slide_count": 0,
"visual_effect_slide_count": 0,
"timed_advance_slide_count": 0,
"carrier_slides": [],
"visual_effect_slides": [],
"timed_advance_slides": [],
"effects": {},
},
"object_animations": {
"timing_slide_count": 0,
"object_animation_slide_count": 0,
"audio_timing_slide_count": 0,
"timing_slides": [],
"object_animation_slides": [],
"audio_timing_slides": [],
},
},
"errors": [],
"advisories": [],
}
def _content_type_maps(
archive: zipfile.ZipFile,
errors: list[dict[str, object]],
) -> tuple[dict[str, str], dict[str, str]]:
try:
root = ET.fromstring(archive.read("[Content_Types].xml"))
except KeyError:
errors.append(
_issue(
"missing_content_types",
"PPTX package is missing [Content_Types].xml.",
)
)
return {}, {}
except ET.ParseError as exc:
errors.append(
_issue(
"invalid_content_types",
f"PPTX content-types XML is invalid: {exc}.",
)
)
return {}, {}
expected_root = f"{{{CONTENT_TYPES_NS}}}Types"
if root.tag != expected_root:
errors.append(
_issue(
"invalid_content_types",
"PPTX content-types XML uses an invalid Types namespace.",
)
)
return {}, {}
defaults: dict[str, str] = {}
overrides: dict[str, str] = {}
default_tag = f"{{{CONTENT_TYPES_NS}}}Default"
override_tag = f"{{{CONTENT_TYPES_NS}}}Override"
for node in root:
if node.tag == default_tag:
raw_extension = (node.attrib.get("Extension") or "").strip()
content_type = (node.attrib.get("ContentType") or "").strip()
extension = raw_extension.lower()
if not extension or extension.startswith(".") or not content_type:
errors.append(
_issue(
"invalid_content_types",
"PPTX content-types Default entry is incomplete.",
)
)
continue
if extension in defaults:
errors.append(
_issue(
"invalid_content_types",
"PPTX content-types XML has a duplicate Default extension.",
extension=raw_extension,
)
)
continue
defaults[extension] = content_type
continue
if node.tag == override_tag:
raw_part_name = (node.attrib.get("PartName") or "").strip()
content_type = (node.attrib.get("ContentType") or "").strip()
part_name = raw_part_name.lstrip("/")
if (
not raw_part_name.startswith("/")
or not part_name
or ".." in PurePosixPath(part_name).parts
or not content_type
):
errors.append(
_issue(
"invalid_content_types",
"PPTX content-types Override entry is incomplete or invalid.",
part=raw_part_name or None,
)
)
continue
if part_name in overrides:
errors.append(
_issue(
"invalid_content_types",
"PPTX content-types XML has a duplicate Override part.",
part=raw_part_name,
)
)
continue
overrides[part_name] = content_type
continue
errors.append(
_issue(
"invalid_content_types",
"PPTX content-types XML contains an unexpected child.",
child=node.tag,
)
)
return defaults, overrides
def _content_type_for_part(
part_name: str,
defaults: dict[str, str],
overrides: dict[str, str],
) -> str:
declared = _declared_content_type_for_part(
part_name,
defaults,
overrides,
)
if declared is not None:
return declared
guessed, _encoding = mimetypes.guess_type(part_name)
return guessed or "application/octet-stream"
def _declared_content_type_for_part(
part_name: str,
defaults: dict[str, str],
overrides: dict[str, str],
) -> str | None:
if part_name in overrides:
return overrides[part_name]
filename = PurePosixPath(part_name).name
extension = (
"rels"
if filename == ".rels" or filename.endswith(".rels")
else PurePosixPath(part_name).suffix.lstrip(".").lower()
)
return defaults.get(extension)
def _xml_and_font_inventory(
archive: zipfile.ZipFile,
part_names: set[str],
errors: list[dict[str, object]],
) -> dict[str, list[dict[str, object]]]:
font_counts: dict[str, Counter[str]] = {
"latin": Counter(),
"ea": Counter(),
"cs": Counter(),
}
for part_name in sorted(
name
for name in part_names
if name.endswith(".xml") and name != "[Content_Types].xml"
):
try:
root = ET.fromstring(archive.read(part_name))
except (KeyError, ET.ParseError) as exc:
errors.append(
_issue(
"invalid_xml_part",
f"Cannot parse XML part {part_name}: {exc}.",
part=part_name,
)
)
continue
for role in font_counts:
for element in root.iter(
f"{{{DRAWINGML_NS}}}{role}"
):
face = (element.attrib.get("typeface") or "").strip()
if face:
font_counts[role][face] += 1
return {
role: [
{"value": face, "count": count}
for face, count in sorted(
counts.items(),
key=lambda item: (-item[1], item[0].casefold()),
)
]
for role, counts in font_counts.items()
}
def _relationship_kind(relationship_type: str) -> str | None:
kind = relationship_type.rstrip("/").rsplit("/", 1)[-1].lower()
return kind if kind in _MEDIA_REL_KINDS else None
def _relationship_inventory(
archive: zipfile.ZipFile,
part_names: set[str],
errors: list[dict[str, object]],
) -> tuple[
Counter[str],
dict[str, set[str]],
list[dict[str, object]],
]:
reference_counts: Counter[str] = Counter()
relationship_types: dict[str, set[str]] = defaultdict(set)
external_counts: Counter[tuple[str, str, str]] = Counter()
for rels_name in sorted(
name for name in part_names if name.endswith(".rels")
):
try:
root = ET.fromstring(archive.read(rels_name))
except (KeyError, ET.ParseError) as exc:
errors.append(
_issue(
"invalid_relationship_part",
f"Cannot parse relationship part {rels_name}: {exc}.",
part=rels_name,
)
)
continue
for relationship in root.findall(
f"{{{PACKAGE_REL_NS}}}Relationship"
):
relationship_type = (
relationship.attrib.get("Type") or ""
).strip()
target = (relationship.attrib.get("Target") or "").strip()
target_mode = (
relationship.attrib.get("TargetMode") or ""
).strip().lower()
kind = _relationship_kind(relationship_type)
if target_mode == "external":
if kind and target:
external_counts[(kind, target, relationship_type)] += 1
continue
resolved = resolve_internal_opc_target(rels_name, target)
if resolved is None:
continue
reference_counts[resolved] += 1
if relationship_type:
relationship_types[resolved].add(relationship_type)
external = [
{
"target": target,
"kind": kind,
"relationship_type": relationship_type,
"bytes": None,
"compressed_bytes": None,
"reference_count": count,
}
for (kind, target, relationship_type), count in sorted(
external_counts.items()
)
]
return reference_counts, relationship_types, external
def _embedded_part_record(
info: zipfile.ZipInfo,
*,
content_type: str,
reference_counts: Counter[str],
relationship_types: dict[str, set[str]],
) -> dict[str, object]:
kind = content_type.split("/", 1)[0]
if kind not in {"audio", "image", "video"}:
kind = "media"
part_key = canonical_opc_part_path(info.filename)
return {
"part": info.filename,
"kind": kind,
"content_type": content_type,
"bytes": info.file_size,
"compressed_bytes": info.compress_size,
"reference_count": (
reference_counts.get(part_key, 0)
if part_key is not None
else 0
),
"relationship_types": sorted(
relationship_types.get(part_key, set())
if part_key is not None
else set()
),
}
def _archive_member_problems(
infos: list[zipfile.ZipInfo],
) -> list[str]:
problems: list[str] = []
for info in infos:
name = info.filename
path = PurePosixPath(name)
if (
name.startswith("/")
or "\\" in name
or ".." in path.parts
):
problems.append(name)
return sorted(set(problems))
def _relationship_problem_code(problem: str) -> str:
if "<invalid relationships XML:" in problem:
return "invalid_relationship_xml"
if " -> <" in problem:
return "invalid_internal_relationship"
return "dangling_internal_relationship"
def _font_faces_for_advisory(
theme_fonts: object,
declared_fonts: object,
) -> list[str]:
faces: set[str] = set()
if isinstance(theme_fonts, dict):
for role in ("title", "body"):
value = theme_fonts.get(role)
if not isinstance(value, dict):
continue
for field in ("latin", "ea", "cs"):
face = value.get(field)
if isinstance(face, str) and face.strip():
faces.add(face.strip())
scripts = value.get("scripts")
if isinstance(scripts, dict):
for face in scripts.values():
if isinstance(face, str) and face.strip():
faces.add(face.strip())
if isinstance(declared_fonts, dict):
for role in ("latin", "ea", "cs"):
values = declared_fonts.get(role)
if not isinstance(values, list):
continue
for value in values:
if not isinstance(value, dict):
continue
face = value.get("value")
if isinstance(face, str) and face.strip():
faces.add(face.strip())
return sorted(faces, key=str.casefold)
def _unsafe_font_faces(faces: list[str]) -> list[str]:
unsafe: list[str] = []
for face in faces:
normalized = unicodedata.normalize("NFKC", face).strip().casefold()
canonical = _PPT_SAFE_FONT_ALIASES.get(normalized, normalized)
canonical = unicodedata.normalize(
"NFKC",
canonical,
).strip().casefold()
if canonical.startswith("+") or canonical in _PPT_DELIVERY_SAFE_FONTS:
continue
unsafe.append(face)
return unsafe
def _motion_and_hidden_summary(
path: Path,
errors: list[dict[str, object]],
) -> tuple[int, list[dict[str, object]], dict[str, object]]:
hidden: list[dict[str, object]] = []
transition_carriers: list[int] = []
visual_transitions: list[int] = []
timed_advances: list[int] = []
transition_effects: Counter[str] = Counter()
timing_slides: list[int] = []
object_animation_slides: list[int] = []
audio_timing_slides: list[int] = []
slide_count = 0
try:
validate_pptx_animation_package(
path,
require_supported_effects=False,
)
except ValueError as exc:
errors.append(
_issue(
"invalid_animation_structure",
str(exc),
)
)
try:
with OoxmlPackage(path) as package:
if package.zip is None:
raise RuntimeError("PPTX package closed during slide audit")
if package.presentation is None:
raise RuntimeError("PPTX package has no presentation part")
slide_roster = package.presentation.xml.find(
f"{{{PRESENTATION_NS}}}sldIdLst"
)
roster_count = (
len(
slide_roster.findall(
f"{{{PRESENTATION_NS}}}sldId"
)
)
if slide_roster is not None
else 0
)
loaded_count = package.slide_count
inspected_count = 0
slide_count = roster_count
for slide in package.iter_slides():
inspected_count += 1
try:
visible = parse_ooxml_boolean(
slide.part.xml.attrib.get("show"),
default=True,
context=f"{slide.part.path} show",
)
except RuntimeError as exc:
errors.append(
_issue(
"invalid_slide_visibility",
str(exc),
slide_index=slide.index,
part=slide.part.path,
)
)
visible = True
if not visible:
hidden.append(
{
"index": slide.index,
"part": slide.part.path,
}
)
slide_xml = package.zip.read(slide.part.path)
transition_problems = validate_slide_transition_xml(slide_xml)
for problem in transition_problems:
errors.append(
_issue(
"invalid_transition_structure",
f"{slide.part.path}: {problem}",
slide_index=slide.index,
part=slide.part.path,
)
)
try:
transition = read_slide_transition_xml(slide_xml)
except (ET.ParseError, ValueError) as exc:
if not transition_problems:
errors.append(
_issue(
"transition_readback_failed",
f"{slide.part.path}: {exc}",
slide_index=slide.index,
part=slide.part.path,
)
)
else:
if transition.logical_count:
transition_carriers.append(slide.index)
effect = (
transition.canonical_effect
or transition.effect
or "timing-only"
)
transition_effects[effect] += 1
if transition.effect is not None:
visual_transitions.append(slide.index)
if transition.advance_after_ms is not None:
timed_advances.append(slide.index)
try:
animation = read_slide_animation_sequence(
slide_xml,
require_supported_effects=False,
)
except (ET.ParseError, ValueError):
animation = None
try:
has_object_animation = (
object_animation_fingerprint(slide_xml) is not None
)
except ValueError as exc:
errors.append(
_issue(
"animation_presence_readback_failed",
f"{slide.part.path}: {exc}",
slide_index=slide.index,
part=slide.part.path,
)
)
has_object_animation = False
root = ET.fromstring(slide_xml)
has_timing = any(
node.tag == f"{{{PRESENTATION_NS}}}timing"
for node in root
)
has_audio_timing = any(
node.tag == f"{{{PRESENTATION_NS}}}audio"
for node in root.iter()
)
if animation is not None:
has_timing = has_timing or bool(animation.timing_count)
has_object_animation = (
has_object_animation or bool(animation.rows)
)
has_audio_timing = (
has_audio_timing or bool(animation.audio_target_ids)
)
if has_timing:
timing_slides.append(slide.index)
if has_object_animation:
object_animation_slides.append(slide.index)
if has_audio_timing:
audio_timing_slides.append(slide.index)
if (
roster_count != loaded_count
or loaded_count != inspected_count
):
errors.append(
_issue(
"slide_inventory_failed",
(
f"Presentation declares {roster_count} slides, "
f"but the loader resolved {loaded_count} and the "
f"delivery audit inspected {inspected_count}."
),
roster_count=roster_count,
loaded_count=loaded_count,
inspected_count=inspected_count,
)
)
except (KeyError, OSError, RuntimeError, ValueError, zipfile.BadZipFile) as exc:
errors.append(
_issue(
"slide_inventory_failed",
f"Cannot inspect presentation slides: {exc}.",
)
)
return slide_count, hidden, {
"transitions": {
"carrier_slide_count": len(transition_carriers),
"visual_effect_slide_count": len(visual_transitions),
"timed_advance_slide_count": len(timed_advances),
"carrier_slides": transition_carriers,
"visual_effect_slides": visual_transitions,
"timed_advance_slides": timed_advances,
"effects": dict(sorted(transition_effects.items())),
},
"object_animations": {
"timing_slide_count": len(timing_slides),
"object_animation_slide_count": len(object_animation_slides),
"audio_timing_slide_count": len(audio_timing_slides),
"timing_slides": timing_slides,
"object_animation_slides": object_animation_slides,
"audio_timing_slides": audio_timing_slides,
},
}
def _deduplicate_issues(
issues: list[dict[str, object]],
) -> list[dict[str, object]]:
seen: set[str] = set()
output: list[dict[str, object]] = []
for issue in issues:
key = json.dumps(issue, ensure_ascii=False, sort_keys=True)
if key in seen:
continue
seen.add(key)
output.append(issue)
return output
def audit_pptx_delivery(path: str | Path) -> dict[str, object]:
"""Return a JSON-safe, read-only delivery audit for one PPTX file."""
pptx_path = Path(path).expanduser().resolve()
report = _empty_report(pptx_path)
errors = report["errors"]
advisories = report["advisories"]
if not isinstance(errors, list) or not isinstance(advisories, list):
raise AssertionError("delivery report issue containers are invalid")
if not pptx_path.is_file():
errors.append(
_issue(
"file_not_found",
f"PPTX file not found: {pptx_path}.",
)
)
return report
try:
with zipfile.ZipFile(pptx_path) as archive:
infos = archive.infolist()
file_infos = [info for info in infos if not info.is_dir()]
names = [info.filename for info in file_infos]
name_counts = Counter(names)
duplicate_parts = sorted(
name for name, count in name_counts.items() if count > 1
)
info_by_name = {info.filename: info for info in file_infos}
part_names = set(info_by_name)
canonical_names: dict[str, set[str]] = defaultdict(set)
invalid_part_names: list[str] = []
for name in part_names:
canonical = canonical_opc_part_path(name)
if canonical is None:
invalid_part_names.append(name)
else:
canonical_names[canonical].add(name)
canonical_collisions = sorted(
sorted(raw_names)
for raw_names in canonical_names.values()
if len(raw_names) > 1
)
package = report["package"]
if not isinstance(package, dict):
raise AssertionError("delivery report package container is invalid")
try:
corrupt_member = archive.testzip()
except (NotImplementedError, RuntimeError) as exc:
package["zip_integrity"] = "failed"
errors.append(
_issue(
"unsupported_zip_compression",
f"PPTX ZIP members cannot be decoded: {exc}.",
)
)
report["errors"] = _deduplicate_issues(errors)
return report
package["zip_integrity"] = (
"passed" if corrupt_member is None else "failed"
)
package["corrupt_member"] = corrupt_member
package["duplicate_parts"] = duplicate_parts
package["canonical_part_collisions"] = canonical_collisions
package["parts"] = {
"entries": len(file_infos),
"unique": len(part_names),
"slides": sum(bool(_SLIDE_PART_RE.fullmatch(name)) for name in part_names),
"notes": sum(bool(_NOTES_PART_RE.fullmatch(name)) for name in part_names),
"masters": sum(bool(_MASTER_PART_RE.fullmatch(name)) for name in part_names),
"layouts": sum(bool(_LAYOUT_PART_RE.fullmatch(name)) for name in part_names),
"media": sum(
(canonical_opc_part_path(name) or "").startswith(
"ppt/media/"
)
for name in part_names
),
}
if corrupt_member is not None:
errors.append(
_issue(
"zip_integrity_failed",
f"PPTX ZIP integrity failed at {corrupt_member}.",
part=corrupt_member,
)
)
if duplicate_parts:
errors.append(
_issue(
"duplicate_package_parts",
"PPTX package contains duplicate part names.",
parts=duplicate_parts,
)
)
if canonical_collisions:
errors.append(
_issue(
"duplicate_opc_part_names",
(
"PPTX package contains case- or encoding-equivalent "
"part names."
),
parts=canonical_collisions,
)
)
if invalid_part_names:
errors.append(
_issue(
"invalid_opc_part_name",
"PPTX package contains invalid OPC part names.",
parts=sorted(invalid_part_names),
)
)
defaults, overrides = _content_type_maps(archive, errors)
content_type_registry_valid = not any(
issue.get("code") in {
"missing_content_types",
"invalid_content_types",
}
for issue in errors
if isinstance(issue, dict)
)
if content_type_registry_valid:
missing_content_types = sorted(
name
for name in part_names
if name != "[Content_Types].xml"
and _declared_content_type_for_part(
name,
defaults,
overrides,
)
is None
)
if missing_content_types:
errors.append(
_issue(
"missing_part_content_type",
(
"PPTX package parts are missing a declared "
"Default or Override content type."
),
parts=missing_content_types,
)
)
declared_fonts = _xml_and_font_inventory(
archive,
part_names,
errors,
)
reference_counts, relationship_types, external_media = (
_relationship_inventory(archive, part_names, errors)
)
media_infos = [
info_by_name[name]
for name in sorted(part_names)
if (
canonical_opc_part_path(name) or ""
).startswith("ppt/media/")
]
embedded_media = [
_embedded_part_record(
info,
content_type=_content_type_for_part(
info.filename,
defaults,
overrides,
),
reference_counts=reference_counts,
relationship_types=relationship_types,
)
for info in media_infos
]
embedded_media.sort(
key=lambda item: (
-int(item["bytes"]),
str(item["part"]),
)
)
embedded_font_infos = [
info_by_name[name]
for name in sorted(part_names)
if name.startswith("ppt/fonts/")
or name.lower().endswith(".fntdata")
]
embedded_fonts = [
{
"part": info.filename,
"content_type": _content_type_for_part(
info.filename,
defaults,
overrides,
),
"bytes": info.file_size,
"compressed_bytes": info.compress_size,
"reference_count": reference_counts.get(
canonical_opc_part_path(info.filename) or "",
0,
),
}
for info in embedded_font_infos
]
media_bytes = sum(info.file_size for info in media_infos)
media_compressed_bytes = sum(
info.compress_size for info in media_infos
)
total_uncompressed_bytes = sum(
info.file_size for info in info_by_name.values()
)
file_bytes = pptx_path.stat().st_size
media = report["media"]
if not isinstance(media, dict):
raise AssertionError("delivery report media container is invalid")
media.update(
{
"embedded_count": len(embedded_media),
"external_count": len(external_media),
"embedded": embedded_media,
"external": external_media,
"uncompressed_bytes": media_bytes,
"archive_compressed_bytes": media_compressed_bytes,
"share_of_archive": (
round(media_compressed_bytes / file_bytes, 6)
if file_bytes
else 0.0
),
"share_of_uncompressed_parts": (
round(media_bytes / total_uncompressed_bytes, 6)
if total_uncompressed_bytes
else 0.0
),
"top_contributors": embedded_media[:10],
}
)
fonts = report["fonts"]
if not isinstance(fonts, dict):
raise AssertionError("delivery report fonts container is invalid")
fonts["embedded_parts"] = embedded_fonts
fonts["declared"] = declared_fonts
unsafe_members = _archive_member_problems(infos)
if unsafe_members:
errors.append(
_issue(
"unsafe_package_member",
"PPTX package contains unsafe member paths.",
parts=unsafe_members,
)
)
relationship_problems: list[str] = []
else:
with tempfile.TemporaryDirectory(
prefix="pptx-delivery-check-"
) as tmp:
extract_dir = Path(tmp) / "pptx"
archive.extractall(extract_dir)
relationship_problems = verify_internal_relationships(
extract_dir
)
relationships = package["relationships"]
if not isinstance(relationships, dict):
raise AssertionError(
"delivery report relationships container is invalid"
)
relationships["problems"] = relationship_problems
for problem in relationship_problems:
errors.append(
_issue(
_relationship_problem_code(problem),
problem,
)
)
except zipfile.BadZipFile as exc:
errors.append(
_issue(
"invalid_zip_package",
f"File is not a readable PPTX ZIP package: {exc}.",
)
)
return report
except (NotImplementedError, RuntimeError) as exc:
errors.append(
_issue(
"unsupported_zip_compression",
f"PPTX ZIP members cannot be decoded: {exc}.",
)
)
return report
except OSError as exc:
errors.append(
_issue(
"package_read_failed",
f"Cannot read PPTX package: {exc}.",
)
)
return report
try:
identity = extract_identity(pptx_path)
except (KeyError, OSError, RuntimeError, ValueError, zipfile.BadZipFile) as exc:
advisories.append(
_issue(
"font_inventory_unavailable",
f"Could not resolve theme and declared font facts: {exc}.",
)
)
else:
identity_theme = identity.get("theme")
theme_fonts = (
identity_theme.get("fonts", {})
if isinstance(identity_theme, dict)
else {}
)
fonts = report["fonts"]
if not isinstance(fonts, dict):
raise AssertionError("delivery report fonts container is invalid")
declared_fonts = fonts.get("declared", {})
if not isinstance(declared_fonts, dict):
declared_fonts = {}
fonts["theme"] = theme_fonts
unsafe_faces = _unsafe_font_faces(
_font_faces_for_advisory(theme_fonts, declared_fonts)
)
fonts["portability_advisory_faces"] = unsafe_faces
if unsafe_faces:
advisories.append(
_issue(
"font_portability",
(
"Some fonts declared or referenced by the presentation "
"are outside the common Office/OS portability set; "
"target-system availability was not verified."
),
faces=unsafe_faces,
)
)
slide_count, hidden_slides, motion = _motion_and_hidden_summary(
pptx_path,
errors,
)
slides = report["slides"]
if not isinstance(slides, dict):
raise AssertionError("delivery report slides container is invalid")
slides.update(
{
"count": slide_count,
"hidden_count": len(hidden_slides),
"hidden": hidden_slides,
}
)
report["motion"] = motion
if hidden_slides:
advisories.append(
_issue(
"hidden_slides",
"The presentation contains hidden slides; their state was not changed.",
slides=[item["index"] for item in hidden_slides],
)
)
media = report["media"]
if isinstance(media, dict) and media.get("external_count"):
advisories.append(
_issue(
"external_media",
(
"The presentation contains externally linked media; "
"offline delivery may depend on those targets."
),
targets=[
item["target"]
for item in media.get("external", [])
if isinstance(item, dict) and "target" in item
],
)
)
report["errors"] = _deduplicate_issues(errors)
report["advisories"] = _deduplicate_issues(advisories)
report["status"] = (
"failed"
if report["errors"]
else (
"passed-with-advisories"
if report["advisories"]
else "passed"
)
)
return report
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description="Inspect a finished PPTX for delivery risks without modifying it.",
formatter_class=argparse.RawDescriptionHelpFormatter,
)
parser.add_argument("pptx", help="Finished .pptx file to inspect")
return parser
def main(argv: list[str] | None = None) -> int:
args = build_parser().parse_args(argv)
report = audit_pptx_delivery(args.pptx)
print(json.dumps(report, ensure_ascii=False, indent=2))
return 1 if report["status"] == "failed" else 0
if __name__ == "__main__":
raise SystemExit(main())