Skills › Scientific Agent Skills › pptx-posters › _pptx.py
_pptx.py
1484 lines · skills/pptx-posters/scripts/_pptx.py at 5f2bc03
1#!/usr/bin/env python3
2"""Bounded, non-executing PPTX package inspection and layout analysis."""
4from __future__ import annotations
6import posixpath
7import re
8import shutil
9import stat
10import struct
11import xml.etree.ElementTree as ET
12import zipfile
13from pathlib import Path, PurePosixPath
14from typing import Any
16from _common import (
17 EMU_PER_INCH,
18 MAX_INPUT_BYTES,
19 CliError,
20 checked_input_file,
21)
23REPORT_SCHEMA_VERSION = "1.0"
24MAX_MEMBERS = 4_096
25MAX_TOTAL_UNCOMPRESSED = 1024 * 1024 * 1024
26MAX_MEMBER_UNCOMPRESSED = 128 * 1024 * 1024
27MAX_XML_BYTES = 8 * 1024 * 1024
28MAX_COMPRESSION_RATIO = 100.0
29MAX_CENTRAL_DIRECTORY_BYTES = 64 * 1024 * 1024
30DETERMINISTIC_ZIP_DATETIME = (1980, 1, 1, 0, 0, 0)
32P_NS = "http://schemas.openxmlformats.org/presentationml/2006/main"
33A_NS = "http://schemas.openxmlformats.org/drawingml/2006/main"
34R_NS = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"
35PKG_REL_NS = "http://schemas.openxmlformats.org/package/2006/relationships"
36CT_NS = "http://schemas.openxmlformats.org/package/2006/content-types"
38STANDARD_PRESENTATION_CONTENT_TYPE = (
39 "application/vnd.openxmlformats-officedocument.presentationml."
40 "presentation.main+xml"
41)
42REQUIRED_PARTS = {
43 "[Content_Types].xml",
44 "_rels/.rels",
45 "ppt/_rels/presentation.xml.rels",
46 "ppt/presentation.xml",
47}
48FORBIDDEN_PREFIXES = (
49 "_xmlsignatures/",
50 "customxml/",
51 "customui/",
52 "ppt/activex/",
53 "ppt/comments/",
54 "ppt/ctrlprops/",
55 "ppt/embeddings/",
56 "ppt/externallinks/",
57 "ppt/fonts/",
58 "ppt/model3d/",
59 "ppt/notesmasters/",
60 "ppt/notesslides/",
61 "ppt/persons/",
62 "ppt/vba/",
63 "ppt/webextensions/",
64 "webextensions/",
65)
66FORBIDDEN_RELATIONSHIP_MARKERS = (
67 "activex",
68 "attachedtemplate",
69 "control",
70 "customui",
71 "externallink",
72 "relationships/hyperlink",
73 "notesmaster",
74 "notesslide",
75 "oleobject",
76 "person",
77 "relationships/package",
78 "relationships/audio",
79 "relationships/media",
80 "relationships/video",
81 "webextension",
82 "vbaproject",
83)
84FORBIDDEN_CONTENT_TYPE_MARKERS = (
85 "activex",
86 "macroenabled",
87 "oleobject",
88 "vba",
89 "vnd.ms-package",
90)
91FORBIDDEN_BINARY_SUFFIXES = {
92 ".7z",
93 ".app",
94 ".applescript",
95 ".bat",
96 ".bin",
97 ".class",
98 ".cmd",
99 ".com",
100 ".doc",
101 ".docm",
102 ".docx",
103 ".dll",
104 ".dylib",
105 ".exe",
106 ".hta",
107 ".htm",
108 ".html",
109 ".jar",
110 ".js",
111 ".lnk",
112 ".msi",
113 ".pdf",
114 ".ppt",
115 ".pptm",
116 ".pptx",
117 ".ps1",
118 ".py",
119 ".rar",
120 ".rb",
121 ".sh",
122 ".so",
123 ".svg",
124 ".vbs",
125 ".xls",
126 ".xlsm",
127 ".xlsx",
128 ".zip",
129}
130ALLOWED_CONTENT_TYPES = {
131 "application/vnd.openxmlformats-package.core-properties+xml",
132 "application/vnd.openxmlformats-package.relationships+xml",
133 "application/vnd.openxmlformats-officedocument.extended-properties+xml",
134 "application/vnd.openxmlformats-officedocument.presentationml.presProps+xml",
135 "application/vnd.openxmlformats-officedocument.presentationml.presentation.main+xml",
136 "application/vnd.openxmlformats-officedocument.presentationml.slide+xml",
137 "application/vnd.openxmlformats-officedocument.presentationml.slideLayout+xml",
138 "application/vnd.openxmlformats-officedocument.presentationml.slideMaster+xml",
139 "application/vnd.openxmlformats-officedocument.presentationml.tableStyles+xml",
140 "application/vnd.openxmlformats-officedocument.presentationml.viewProps+xml",
141 "application/vnd.openxmlformats-officedocument.theme+xml",
142 "application/xml",
143 "image/jpeg",
144 "image/png",
145}
146ALLOWED_PART_PATTERNS = tuple(
147 re.compile(pattern)
148 for pattern in (
149 r"\[Content_Types\]\.xml",
150 r"_rels/\.rels",
151 r"docProps/(?:app|core)\.xml",
152 r"docProps/thumbnail\.jpeg",
153 r"ppt/(?:presentation|presProps|tableStyles|viewProps)\.xml",
154 r"ppt/_rels/presentation\.xml\.rels",
155 r"ppt/theme/theme[1-9][0-9]*\.xml",
156 r"ppt/slideMasters/slideMaster[1-9][0-9]*\.xml",
157 r"ppt/slideMasters/_rels/slideMaster[1-9][0-9]*\.xml\.rels",
158 r"ppt/slideLayouts/slideLayout[1-9][0-9]*\.xml",
159 r"ppt/slideLayouts/_rels/slideLayout[1-9][0-9]*\.xml\.rels",
160 r"ppt/slides/slide[1-9][0-9]*\.xml",
161 r"ppt/slides/_rels/slide[1-9][0-9]*\.xml\.rels",
162 r"ppt/media/image[1-9][0-9]*\.(?:jpeg|jpg|png)",
163 r"ppt/printerSettings/printerSettings[1-9][0-9]*\.bin",
164 )
165)
168def _finding(code: str, message: str, *, location: str | None = None) -> dict[str, Any]:
169 record: dict[str, Any] = {"code": code, "message": message}
170 if location is not None:
171 record["location"] = location
172 return record
175def _allowed_generated_part(name: str) -> bool:
176 """Return whether a part belongs to the strict generated-poster profile."""
177 return any(pattern.fullmatch(name) for pattern in ALLOWED_PART_PATTERNS)
180def _preflight_zip_directory(path: Path) -> list[dict[str, Any]]:
181 """Bound the central directory before ZipFile materializes all members."""
182 findings: list[dict[str, Any]] = []
183 file_size = path.stat().st_size
184 if file_size < 22:
185 return [_finding("NOT_ZIP", "file is too short to contain a ZIP directory")]
186 with path.open("rb") as handle:
187 if handle.read(4) != b"PK\x03\x04":
188 return [_finding("NOT_ZIP", "file does not begin with a ZIP local header")]
189 tail_size = min(file_size, 65_557)
190 handle.seek(file_size - tail_size)
191 tail = handle.read(tail_size)
192 marker = b"PK\x05\x06"
193 index = tail.rfind(marker)
194 if index < 0 or index + 22 > len(tail):
195 return [_finding("ZIP_DIRECTORY_INVALID", "end-of-central-directory not found")]
196 try:
197 (
198 signature,
199 disk_number,
200 central_disk,
201 entries_on_disk,
202 total_entries,
203 central_size,
204 central_offset,
205 comment_length,
206 ) = struct.unpack_from("<4s4H2LH", tail, index)
207 except struct.error as exc:
208 return [_finding("ZIP_DIRECTORY_INVALID", str(exc))]
209 if signature != marker:
210 findings.append(
211 _finding("ZIP_DIRECTORY_INVALID", "invalid central-directory signature")
212 )
213 eocd_offset = file_size - tail_size + index
214 if eocd_offset + 22 + comment_length != file_size:
215 findings.append(
216 _finding(
217 "ZIP_DIRECTORY_INVALID",
218 "central-directory comment length or trailing bytes are invalid",
219 )
220 )
221 if (
222 disk_number != 0
223 or central_disk != 0
224 or entries_on_disk != total_entries
225 ):
226 findings.append(
227 _finding("ZIP_MULTIDISK", "multi-disk ZIP packages are forbidden")
228 )
229 if (
230 total_entries == 0xFFFF
231 or central_size == 0xFFFFFFFF
232 or central_offset == 0xFFFFFFFF
233 ):
234 findings.append(
235 _finding(
236 "ZIP64_DIRECTORY",
237 "ZIP64 central directories are outside the strict PPTX profile",
238 )
239 )
240 return findings
241 if total_entries > MAX_MEMBERS:
242 findings.append(
243 _finding(
244 "ZIP_MEMBER_LIMIT",
245 f"package declares {total_entries} members; limit is {MAX_MEMBERS}",
246 )
247 )
248 if central_size > MAX_CENTRAL_DIRECTORY_BYTES:
249 findings.append(
250 _finding(
251 "ZIP_DIRECTORY_SIZE",
252 f"central directory is {central_size} bytes; limit is "
253 f"{MAX_CENTRAL_DIRECTORY_BYTES}",
254 )
255 )
256 if central_offset + central_size != eocd_offset:
257 findings.append(
258 _finding(
259 "ZIP_DIRECTORY_INVALID",
260 "central-directory offset/size does not end at the ZIP footer",
261 )
262 )
263 return findings
266def _unsafe_xml(text: str) -> bool:
267 upper = text.upper()
268 return "<!DOCTYPE" in upper or "<!ENTITY" in upper
271def _parse_xml(raw: bytes, *, location: str) -> ET.Element:
272 if len(raw) > MAX_XML_BYTES:
273 raise CliError(
274 f"XML part {location} is {len(raw)} bytes; limit is {MAX_XML_BYTES}"
275 )
276 try:
277 text = raw.decode("utf-8-sig")
278 except UnicodeDecodeError as exc:
279 raise CliError(
280 f"XML part {location} must be UTF-8 in the strict generated profile"
281 ) from exc
282 if "\x00" in text:
283 raise CliError(f"NUL is forbidden in XML part {location}")
284 if _unsafe_xml(text):
285 raise CliError(f"DTD or entity declaration is forbidden in {location}")
286 try:
287 return ET.fromstring(text)
288 except ET.ParseError as exc:
289 raise CliError(f"malformed XML in {location}: {exc}") from exc
292def _relationship_source_directory(rels_name: str) -> str:
293 if rels_name == "_rels/.rels":
294 return ""
295 marker = "/_rels/"
296 if marker not in rels_name or not rels_name.endswith(".rels"):
297 raise CliError(f"invalid relationship part name: {rels_name}")
298 prefix, leaf = rels_name.split(marker, 1)
299 source_leaf = leaf[: -len(".rels")]
300 return posixpath.dirname(f"{prefix}/{source_leaf}")
303def _resolve_internal_target(rels_name: str, target: str) -> str:
304 if not target or "\x00" in target or "\\" in target:
305 raise CliError(f"unsafe internal relationship target in {rels_name}: {target!r}")
306 if target.startswith("/") or "%" in target or "?" in target or "#" in target:
307 raise CliError(
308 f"unsupported internal relationship target in {rels_name}: {target!r}"
309 )
310 base = _relationship_source_directory(rels_name)
311 normalized = posixpath.normpath(posixpath.join(base, target))
312 if normalized in {"", ".", ".."} or normalized.startswith("../"):
313 raise CliError(
314 f"relationship target escapes the package in {rels_name}: {target!r}"
315 )
316 return normalized
319def _preflight_members(
320 archive: zipfile.ZipFile,
321) -> tuple[list[zipfile.ZipInfo], list[dict[str, Any]], set[str], int]:
322 findings: list[dict[str, Any]] = []
323 members = archive.infolist()
324 if len(members) > MAX_MEMBERS:
325 return (
326 members,
327 [
328 _finding(
329 "ZIP_MEMBER_LIMIT",
330 f"package has {len(members)} members; limit is {MAX_MEMBERS}",
331 )
332 ],
333 set(),
334 0,
335 )
336 names: set[str] = set()
337 casefold_names: set[str] = set()
338 total_uncompressed = 0
340 for info in members:
341 name = info.filename
342 location = name or "<empty>"
343 if (
344 not name
345 or "\x00" in name
346 or "\\" in name
347 or ":" in name
348 or "%" in name
349 ):
350 findings.append(
351 _finding(
352 "ZIP_UNSAFE_NAME",
353 "empty, NUL, backslash, colon, or percent-encoded member name",
354 location=location,
355 )
356 )
357 continue
358 pure = PurePosixPath(name)
359 if pure.is_absolute() or any(part in {"", ".", ".."} for part in pure.parts):
360 findings.append(
361 _finding("ZIP_TRAVERSAL", "absolute or traversing member path", location=name)
362 )
363 if name in names or name.casefold() in casefold_names:
364 findings.append(
365 _finding("ZIP_DUPLICATE_NAME", "duplicate or case-colliding member", location=name)
366 )
367 names.add(name)
368 casefold_names.add(name.casefold())
369 unix_mode = (info.external_attr >> 16) & 0o170000
370 if unix_mode == stat.S_IFLNK:
371 findings.append(
372 _finding("ZIP_SYMLINK", "symbolic-link member is forbidden", location=name)
373 )
374 if info.flag_bits & 0x1:
375 findings.append(
376 _finding("ZIP_ENCRYPTED", "encrypted member is forbidden", location=name)
377 )
378 if info.compress_type not in {zipfile.ZIP_STORED, zipfile.ZIP_DEFLATED}:
379 findings.append(
380 _finding(
381 "ZIP_COMPRESSION_METHOD",
382 f"unsupported compression method {info.compress_type}",
383 location=name,
384 )
385 )
386 total_uncompressed += info.file_size
387 if total_uncompressed > MAX_TOTAL_UNCOMPRESSED:
388 findings.append(
389 _finding(
390 "ZIP_TOTAL_SIZE",
391 f"package expands beyond {MAX_TOTAL_UNCOMPRESSED} bytes",
392 location=name,
393 )
394 )
395 return members, findings, names, total_uncompressed
396 if info.file_size > MAX_MEMBER_UNCOMPRESSED:
397 findings.append(
398 _finding(
399 "ZIP_MEMBER_SIZE",
400 f"member expands to {info.file_size} bytes; "
401 f"limit is {MAX_MEMBER_UNCOMPRESSED}",
402 location=name,
403 )
404 )
405 return members, findings, names, total_uncompressed
406 if info.file_size:
407 if info.compress_size == 0:
408 ratio = float("inf")
409 else:
410 ratio = info.file_size / info.compress_size
411 if ratio > MAX_COMPRESSION_RATIO:
412 findings.append(
413 _finding(
414 "ZIP_COMPRESSION_RATIO",
415 f"compression ratio {ratio:.1f}:1 exceeds "
416 f"{MAX_COMPRESSION_RATIO:.1f}:1",
417 location=name,
418 )
419 )
420 return members, findings, names, total_uncompressed
421 lowered = name.lower()
422 if lowered.startswith(FORBIDDEN_PREFIXES):
423 findings.append(
424 _finding(
425 "FORBIDDEN_PACKAGE_PART",
426 "active, external, or embedded package area is forbidden",
427 location=name,
428 )
429 )
430 if lowered == "docprops/custom.xml":
431 findings.append(
432 _finding(
433 "FORBIDDEN_CUSTOM_PROPERTIES",
434 "custom document properties are outside the strict poster profile",
435 location=name,
436 )
437 )
438 if PurePosixPath(lowered).suffix in FORBIDDEN_BINARY_SUFFIXES:
439 findings.append(
440 _finding(
441 "FORBIDDEN_BINARY_PART",
442 "binary or executable package part is forbidden",
443 location=name,
444 )
445 )
446 if not _allowed_generated_part(name):
447 findings.append(
448 _finding(
449 "UNKNOWN_PACKAGE_PART",
450 "part is outside the strict one-slide generated-poster profile",
451 location=name,
452 )
453 )
454 if lowered.startswith("ppt/media/") and PurePosixPath(lowered).suffix not in {
455 ".jpeg",
456 ".jpg",
457 ".png",
458 }:
459 findings.append(
460 _finding(
461 "FORBIDDEN_MEDIA_PART",
462 "non-image media part is forbidden",
463 location=name,
464 )
465 )
467 missing = sorted(REQUIRED_PARTS - names)
468 if missing:
469 findings.append(
470 _finding(
471 "MISSING_REQUIRED_PART",
472 f"missing required package part(s): {', '.join(missing)}",
473 )
474 )
475 return members, findings, names, total_uncompressed
478def _inspect_content_types(
479 archive: zipfile.ZipFile,
480 names: set[str],
481) -> list[dict[str, Any]]:
482 findings: list[dict[str, Any]] = []
483 if "[Content_Types].xml" not in names:
484 return findings
485 try:
486 root = _parse_xml(
487 archive.read("[Content_Types].xml"),
488 location="[Content_Types].xml",
489 )
490 except (KeyError, OSError, RuntimeError, zipfile.BadZipFile, CliError) as exc:
491 return [
492 _finding(
493 "CONTENT_TYPES_INVALID",
494 str(exc),
495 location="[Content_Types].xml",
496 )
497 ]
498 if root.tag != f"{{{CT_NS}}}Types":
499 findings.append(
500 _finding(
501 "CONTENT_TYPES_INVALID",
502 "root element is not the OPC content-types element",
503 location="[Content_Types].xml",
504 )
505 )
506 return findings
507 main_type = None
508 seen_defaults: set[str] = set()
509 seen_overrides: set[str] = set()
510 for node in root:
511 content_type = node.attrib.get("ContentType", "")
512 lowered = content_type.lower()
513 if node.tag not in {f"{{{CT_NS}}}Default", f"{{{CT_NS}}}Override"}:
514 findings.append(
515 _finding(
516 "CONTENT_TYPES_INVALID",
517 "unexpected element in content-types part",
518 location="[Content_Types].xml",
519 )
520 )
521 continue
522 if content_type not in ALLOWED_CONTENT_TYPES:
523 findings.append(
524 _finding(
525 "UNEXPECTED_CONTENT_TYPE",
526 f"content type is outside the strict profile: {content_type!r}",
527 location="[Content_Types].xml",
528 )
529 )
530 if any(marker in lowered for marker in FORBIDDEN_CONTENT_TYPE_MARKERS):
531 findings.append(
532 _finding(
533 "FORBIDDEN_CONTENT_TYPE",
534 f"forbidden content type {content_type!r}",
535 location="[Content_Types].xml",
536 )
537 )
538 if node.tag == f"{{{CT_NS}}}Default":
539 extension = node.attrib.get("Extension", "")
540 if (
541 not extension
542 or extension in seen_defaults
543 or "/" in extension
544 or "\\" in extension
545 or "." in extension
546 ):
547 findings.append(
548 _finding(
549 "CONTENT_TYPES_INVALID",
550 f"invalid or duplicate default extension {extension!r}",
551 location="[Content_Types].xml",
552 )
553 )
554 seen_defaults.add(extension)
555 continue
556 part_name = node.attrib.get("PartName", "")
557 normalized_part = part_name[1:] if part_name.startswith("/") else ""
558 if (
559 not normalized_part
560 or normalized_part in seen_overrides
561 or normalized_part not in names
562 ):
563 findings.append(
564 _finding(
565 "CONTENT_TYPES_INVALID",
566 f"invalid, duplicate, or missing override part {part_name!r}",
567 location="[Content_Types].xml",
568 )
569 )
570 seen_overrides.add(normalized_part)
571 if (
572 node.tag == f"{{{CT_NS}}}Override"
573 and part_name == "/ppt/presentation.xml"
574 ):
575 main_type = content_type
576 if main_type != STANDARD_PRESENTATION_CONTENT_TYPE:
577 findings.append(
578 _finding(
579 "PRESENTATION_CONTENT_TYPE",
580 "ppt/presentation.xml is not a standard macro-free PPTX main part",
581 location="[Content_Types].xml",
582 )
583 )
584 return findings
587def _inspect_relationships(
588 archive: zipfile.ZipFile,
589 names: set[str],
590) -> tuple[list[dict[str, Any]], int]:
591 findings: list[dict[str, Any]] = []
592 relationship_count = 0
593 for rels_name in sorted(name for name in names if name.endswith(".rels")):
594 try:
595 root = _parse_xml(archive.read(rels_name), location=rels_name)
596 except (KeyError, OSError, RuntimeError, zipfile.BadZipFile, CliError) as exc:
597 findings.append(
598 _finding("RELATIONSHIPS_INVALID", str(exc), location=rels_name)
599 )
600 continue
601 if root.tag != f"{{{PKG_REL_NS}}}Relationships":
602 findings.append(
603 _finding(
604 "RELATIONSHIPS_INVALID",
605 "root element is not the OPC relationships element",
606 location=rels_name,
607 )
608 )
609 continue
610 seen_ids: set[str] = set()
611 for relationship in root:
612 if relationship.tag != f"{{{PKG_REL_NS}}}Relationship":
613 findings.append(
614 _finding(
615 "RELATIONSHIPS_INVALID",
616 "unexpected element in relationship part",
617 location=rels_name,
618 )
619 )
620 continue
621 relationship_count += 1
622 relationship_id = relationship.attrib.get("Id", "")
623 relationship_type = relationship.attrib.get("Type", "")
624 target = relationship.attrib.get("Target", "")
625 target_mode = relationship.attrib.get("TargetMode", "")
626 if not relationship_id or relationship_id in seen_ids:
627 findings.append(
628 _finding(
629 "RELATIONSHIPS_INVALID",
630 f"missing or duplicate relationship Id {relationship_id!r}",
631 location=rels_name,
632 )
633 )
634 seen_ids.add(relationship_id)
635 if not relationship_type:
636 findings.append(
637 _finding(
638 "RELATIONSHIPS_INVALID",
639 "relationship Type is required",
640 location=rels_name,
641 )
642 )
643 if target_mode == "External":
644 code = (
645 "REMOTE_LINKED_IMAGE"
646 if relationship_type.lower().endswith("/image")
647 else "EXTERNAL_RELATIONSHIP"
648 )
649 findings.append(
650 _finding(
651 code,
652 f"external target is forbidden: {target!r}",
653 location=rels_name,
654 )
655 )
656 continue
657 if target_mode not in {"", "Internal"}:
658 findings.append(
659 _finding(
660 "RELATIONSHIP_TARGET_MODE_INVALID",
661 f"unsupported TargetMode {target_mode!r}",
662 location=rels_name,
663 )
664 )
665 continue
666 type_lower = relationship_type.lower()
667 if any(marker in type_lower for marker in FORBIDDEN_RELATIONSHIP_MARKERS):
668 findings.append(
669 _finding(
670 "FORBIDDEN_RELATIONSHIP_TYPE",
671 f"forbidden relationship type {relationship_type!r}",
672 location=rels_name,
673 )
674 )
675 try:
676 resolved = _resolve_internal_target(rels_name, target)
677 except CliError as exc:
678 findings.append(
679 _finding(
680 "RELATIONSHIP_TARGET_INVALID",
681 str(exc),
682 location=rels_name,
683 )
684 )
685 continue
686 if resolved not in names:
687 findings.append(
688 _finding(
689 "RELATIONSHIP_TARGET_MISSING",
690 f"internal target does not exist: {resolved!r}",
691 location=rels_name,
692 )
693 )
694 return findings, relationship_count
697def _inspect_all_xml_parts(
698 archive: zipfile.ZipFile,
699 names: set[str],
700) -> list[dict[str, Any]]:
701 """Reject malformed or entity-bearing XML anywhere in the strict package."""
702 findings: list[dict[str, Any]] = []
703 for part_name in sorted(name for name in names if name.endswith(".xml")):
704 try:
705 _parse_xml(archive.read(part_name), location=part_name)
706 except (KeyError, OSError, RuntimeError, zipfile.BadZipFile, CliError) as exc:
707 findings.append(
708 _finding("XML_PART_INVALID", str(exc), location=part_name)
709 )
710 return findings
713def _inspect_one_slide_profile(
714 archive: zipfile.ZipFile,
715 names: set[str],
716) -> list[dict[str, Any]]:
717 """Require exactly one slide and one matching presentation relationship."""
718 findings: list[dict[str, Any]] = []
719 slide_names = sorted(
720 name
721 for name in names
722 if name.startswith("ppt/slides/slide")
723 and name.endswith(".xml")
724 and "/_rels/" not in name
725 )
726 if len(slide_names) != 1:
727 return [
728 _finding(
729 "SLIDE_COUNT",
730 f"strict poster profile requires exactly one slide; found {len(slide_names)}",
731 location="ppt/presentation.xml",
732 )
733 ]
734 try:
735 presentation = _parse_xml(
736 archive.read("ppt/presentation.xml"),
737 location="ppt/presentation.xml",
738 )
739 presentation_rels = _parse_xml(
740 archive.read("ppt/_rels/presentation.xml.rels"),
741 location="ppt/_rels/presentation.xml.rels",
742 )
743 except (KeyError, OSError, RuntimeError, zipfile.BadZipFile, CliError) as exc:
744 return [
745 _finding(
746 "PRESENTATION_PROFILE_INVALID",
747 str(exc),
748 location="ppt/presentation.xml",
749 )
750 ]
751 slide_ids = presentation.findall(
752 f"./{{{P_NS}}}sldIdLst/{{{P_NS}}}sldId"
753 )
754 if len(slide_ids) != 1:
755 findings.append(
756 _finding(
757 "SLIDE_ID_COUNT",
758 f"presentation must declare exactly one slide Id; found {len(slide_ids)}",
759 location="ppt/presentation.xml",
760 )
761 )
762 return findings
763 relationship_id = slide_ids[0].attrib.get(f"{{{R_NS}}}id", "")
764 targets: dict[str, str] = {}
765 for relationship in presentation_rels:
766 if relationship.attrib.get("TargetMode", "") == "External":
767 continue
768 try:
769 target = _resolve_internal_target(
770 "ppt/_rels/presentation.xml.rels",
771 relationship.attrib.get("Target", ""),
772 )
773 except CliError:
774 continue
775 targets[relationship.attrib.get("Id", "")] = target
776 if targets.get(relationship_id) != slide_names[0]:
777 findings.append(
778 _finding(
779 "SLIDE_RELATIONSHIP",
780 "the declared slide Id does not resolve to the sole slide part",
781 location="ppt/presentation.xml",
782 )
783 )
784 return findings
787def _inspect_image_payloads(
788 archive: zipfile.ZipFile,
789 names: set[str],
790) -> list[dict[str, Any]]:
791 """Check strict-profile image signatures and stream through CRC validation."""
792 findings: list[dict[str, Any]] = []
793 image_names = sorted(
794 name
795 for name in names
796 if name.startswith("ppt/media/") or name == "docProps/thumbnail.jpeg"
797 )
798 for part_name in image_names:
799 try:
800 with archive.open(part_name, "r") as handle:
801 prefix = handle.read(16)
802 while handle.read(1024 * 1024):
803 pass
804 except (OSError, RuntimeError, zipfile.BadZipFile) as exc:
805 findings.append(
806 _finding("IMAGE_PART_INVALID", str(exc), location=part_name)
807 )
808 continue
809 suffix = PurePosixPath(part_name).suffix.lower()
810 signature_ok = (
811 prefix.startswith(b"\x89PNG\r\n\x1a\n")
812 if suffix == ".png"
813 else prefix.startswith(b"\xff\xd8\xff")
814 )
815 if not signature_ok:
816 findings.append(
817 _finding(
818 "IMAGE_SIGNATURE",
819 "image bytes do not match the declared PNG/JPEG suffix",
820 location=part_name,
821 )
822 )
823 return findings
826def _inspect_slide_accessibility(
827 archive: zipfile.ZipFile,
828 names: set[str],
829) -> dict[str, Any]:
830 picture_count = 0
831 pictures_with_alt_text = 0
832 missing_alt: list[dict[str, str]] = []
833 slide_title_count = 0
834 text_run_count = 0
835 text_runs_with_language = 0
836 missing_language: list[dict[str, str]] = []
837 slide_names = sorted(
838 name
839 for name in names
840 if name.startswith("ppt/slides/slide")
841 and name.endswith(".xml")
842 and "/_rels/" not in name
843 )
844 for slide_name in slide_names:
845 try:
846 root = _parse_xml(archive.read(slide_name), location=slide_name)
847 except (KeyError, OSError, RuntimeError, zipfile.BadZipFile, CliError):
848 continue
849 for shape in root.findall(f".//{{{P_NS}}}sp"):
850 placeholder = shape.find(
851 f"./{{{P_NS}}}nvSpPr/{{{P_NS}}}nvPr/{{{P_NS}}}ph"
852 )
853 if (
854 placeholder is not None
855 and placeholder.attrib.get("type") in {"title", "ctrTitle"}
856 and "".join(
857 node.text or ""
858 for node in shape.findall(f".//{{{A_NS}}}t")
859 ).strip()
860 ):
861 slide_title_count += 1
862 for run_index, run in enumerate(root.findall(f".//{{{A_NS}}}r"), 1):
863 text = run.find(f"./{{{A_NS}}}t")
864 if text is None or not (text.text or ""):
865 continue
866 text_run_count += 1
867 properties = run.find(f"./{{{A_NS}}}rPr")
868 language = "" if properties is None else properties.attrib.get("lang", "")
869 if language:
870 text_runs_with_language += 1
871 else:
872 missing_language.append(
873 {"slide": slide_name, "run": str(run_index)}
874 )
875 for picture in root.findall(f".//{{{P_NS}}}pic"):
876 picture_count += 1
877 properties = picture.find(f"./{{{P_NS}}}nvPicPr/{{{P_NS}}}cNvPr")
878 name = ""
879 description = ""
880 if properties is not None:
881 name = properties.attrib.get("name", "")
882 description = properties.attrib.get("descr", "").strip()
883 if description:
884 pictures_with_alt_text += 1
885 else:
886 missing_alt.append({"slide": slide_name, "name": name})
887 return {
888 "picture_count": picture_count,
889 "pictures_with_alt_text": pictures_with_alt_text,
890 "pictures_missing_alt_text": missing_alt,
891 "slide_title_count": slide_title_count,
892 "text_run_count": text_run_count,
893 "text_runs_with_language": text_runs_with_language,
894 "text_runs_missing_language": missing_language,
895 "manual_check_required": (
896 "PowerPoint Accessibility Checker, Reading Order pane, and a screen-reader "
897 "test remain required; package inspection cannot establish accessibility."
898 ),
899 }
902def _inspect_forbidden_markup(
903 archive: zipfile.ZipFile,
904 names: set[str],
905) -> list[dict[str, Any]]:
906 findings: list[dict[str, Any]] = []
907 for part_name in sorted(
908 name
909 for name in names
910 if name.startswith("ppt/")
911 and name.endswith(".xml")
912 and "/_rels/" not in name
913 ):
914 try:
915 root = _parse_xml(archive.read(part_name), location=part_name)
916 except (KeyError, OSError, RuntimeError, zipfile.BadZipFile, CliError) as exc:
917 findings.append(
918 _finding("PRESENTATION_XML_INVALID", str(exc), location=part_name)
919 )
920 continue
921 for node in root.iter():
922 local = _local_name(node.tag).lower()
923 namespace = node.tag.split("}", 1)[0].lower()
924 if local in {
925 "audio",
926 "cmd",
927 "control",
928 "hlinkclick",
929 "hlinkhover",
930 "oleobj",
931 "snd",
932 "timing",
933 "transition",
934 "video",
935 } or any(
936 marker in namespace
937 for marker in ("activex", "model3d", "webextension")
938 ):
939 findings.append(
940 _finding(
941 "FORBIDDEN_PRESENTATION_MARKUP",
942 f"active or embedded markup element {local!r} is forbidden",
943 location=part_name,
944 )
945 )
946 break
947 return findings
950def inspect_pptx(value: str | Path) -> dict[str, Any]:
951 """Inspect a PPTX as ZIP/XML without opening it in PowerPoint or python-pptx."""
952 path = checked_input_file(value, max_bytes=MAX_INPUT_BYTES)
953 findings: list[dict[str, Any]] = []
954 if path.suffix.lower() != ".pptx":
955 findings.append(
956 _finding(
957 "FILE_EXTENSION",
958 "only the macro-free .pptx extension is accepted; .pptm is rejected",
959 location=str(path),
960 )
961 )
962 return {
963 "schema_version": REPORT_SCHEMA_VERSION,
964 "path": str(path),
965 "safe": False,
966 "findings": findings,
967 }
968 findings.extend(_preflight_zip_directory(path))
969 if findings:
970 return {
971 "schema_version": REPORT_SCHEMA_VERSION,
972 "path": str(path),
973 "safe": False,
974 "findings": findings,
975 }
977 member_count = 0
978 total_uncompressed = 0
979 relationship_count = 0
980 accessibility: dict[str, Any] = {
981 "picture_count": 0,
982 "pictures_with_alt_text": 0,
983 "pictures_missing_alt_text": [],
984 "slide_title_count": 0,
985 "text_run_count": 0,
986 "text_runs_with_language": 0,
987 "text_runs_missing_language": [],
988 "manual_check_required": True,
989 }
990 try:
991 with zipfile.ZipFile(path, "r") as archive:
992 members, member_findings, names, total_uncompressed = _preflight_members(
993 archive
994 )
995 member_count = len(members)
996 findings.extend(member_findings)
997 if not member_findings:
998 findings.extend(_inspect_content_types(archive, names))
999 findings.extend(_inspect_all_xml_parts(archive, names))
1000 rel_findings, relationship_count = _inspect_relationships(
1001 archive, names
1002 )
1003 findings.extend(rel_findings)
1004 findings.extend(_inspect_one_slide_profile(archive, names))
1005 findings.extend(_inspect_forbidden_markup(archive, names))
1006 findings.extend(_inspect_image_payloads(archive, names))
1007 accessibility = _inspect_slide_accessibility(archive, names)
1008 except (OSError, RuntimeError, zipfile.BadZipFile, zipfile.LargeZipFile) as exc:
1009 findings.append(_finding("ZIP_READ_ERROR", str(exc)))
1011 return {
1012 "schema_version": REPORT_SCHEMA_VERSION,
1013 "path": str(path),
1014 "safe": not findings,
1015 "limits": {
1016 "max_archive_bytes": MAX_INPUT_BYTES,
1017 "max_members": MAX_MEMBERS,
1018 "max_central_directory_bytes": MAX_CENTRAL_DIRECTORY_BYTES,
1019 "max_member_uncompressed_bytes": MAX_MEMBER_UNCOMPRESSED,
1020 "max_total_uncompressed_bytes": MAX_TOTAL_UNCOMPRESSED,
1021 "max_compression_ratio": MAX_COMPRESSION_RATIO,
1022 },
1023 "package": {
1024 "member_count": member_count,
1025 "total_uncompressed_bytes": total_uncompressed,
1026 "relationship_count": relationship_count,
1027 },
1028 "accessibility": accessibility,
1029 "findings": findings,
1030 "inspection_method": (
1031 "Bounded ZIP central-directory and selected XML-part inspection only; "
1032 "members were not extracted and the presentation was not opened or executed."
1033 ),
1034 }
1037def require_safe_pptx(value: str | Path) -> dict[str, Any]:
1038 report = inspect_pptx(value)
1039 if not report["safe"]:
1040 codes = ", ".join(item["code"] for item in report["findings"])
1041 raise CliError(f"unsafe PPTX package ({codes})")
1042 return report
1045def _local_name(tag: str) -> str:
1046 return tag.rsplit("}", 1)[-1]
1049def _shape_transform(shape: ET.Element) -> tuple[int, int, int, int] | None:
1050 kind = _local_name(shape.tag)
1051 if kind == "graphicFrame":
1052 transform = shape.find(f"./{{{P_NS}}}xfrm")
1053 else:
1054 transform = shape.find(f"./{{{P_NS}}}spPr/{{{A_NS}}}xfrm")
1055 if transform is None:
1056 return None
1057 offset = transform.find(f"./{{{A_NS}}}off")
1058 extent = transform.find(f"./{{{A_NS}}}ext")
1059 if offset is None or extent is None:
1060 return None
1061 try:
1062 return (
1063 int(offset.attrib["x"]),
1064 int(offset.attrib["y"]),
1065 int(extent.attrib["cx"]),
1066 int(extent.attrib["cy"]),
1067 )
1068 except (KeyError, ValueError):
1069 return None
1072def _shape_name(shape: ET.Element) -> str:
1073 properties = shape.find(f".//{{{P_NS}}}cNvPr")
1074 return "" if properties is None else properties.attrib.get("name", "")
1077def _shape_text(shape: ET.Element) -> str:
1078 return "".join(
1079 node.text or "" for node in shape.findall(f".//{{{A_NS}}}t")
1080 ).strip()
1083def _font_sizes_pt(shape: ET.Element) -> list[float]:
1084 sizes: list[float] = []
1085 for tag in ("rPr", "defRPr", "endParaRPr"):
1086 for node in shape.findall(f".//{{{A_NS}}}{tag}"):
1087 raw = node.attrib.get("sz")
1088 if raw is None:
1089 continue
1090 try:
1091 sizes.append(int(raw) / 100.0)
1092 except ValueError:
1093 continue
1094 return sizes
1097def _intersection(
1098 first: tuple[int, int, int, int],
1099 second: tuple[int, int, int, int],
1100) -> tuple[int, int]:
1101 first_x, first_y, first_w, first_h = first
1102 second_x, second_y, second_w, second_h = second
1103 overlap_w = min(first_x + first_w, second_x + second_w) - max(
1104 first_x, second_x
1105 )
1106 overlap_h = min(first_y + first_h, second_y + second_h) - max(
1107 first_y, second_y
1108 )
1109 return max(0, overlap_w), max(0, overlap_h)
1112def analyze_layout(
1113 value: str | Path,
1114 *,
1115 print_scale: float = 1.0,
1116 minimum_font_pt_final: float = 18.0,
1117) -> dict[str, Any]:
1118 """Check direct slide shapes for bounds, overlap, and explicit font sizes."""
1119 if print_scale <= 0:
1120 raise CliError("print_scale must be greater than zero")
1121 if minimum_font_pt_final <= 0:
1122 raise CliError("minimum_font_pt_final must be greater than zero")
1123 package_report = require_safe_pptx(value)
1124 path = Path(package_report["path"])
1125 issues: list[dict[str, Any]] = []
1126 slides: list[dict[str, Any]] = []
1128 with zipfile.ZipFile(path, "r") as archive:
1129 presentation = _parse_xml(
1130 archive.read("ppt/presentation.xml"),
1131 location="ppt/presentation.xml",
1132 )
1133 slide_size = presentation.find(f"./{{{P_NS}}}sldSz")
1134 if slide_size is None:
1135 raise CliError("ppt/presentation.xml does not declare slide dimensions")
1136 try:
1137 slide_width = int(slide_size.attrib["cx"])
1138 slide_height = int(slide_size.attrib["cy"])
1139 except (KeyError, ValueError) as exc:
1140 raise CliError("invalid slide dimensions in ppt/presentation.xml") from exc
1142 slide_names = sorted(
1143 (
1144 name
1145 for name in archive.namelist()
1146 if name.startswith("ppt/slides/slide")
1147 and name.endswith(".xml")
1148 and "/_rels/" not in name
1149 ),
1150 key=lambda name: (
1151 len(PurePosixPath(name).stem),
1152 PurePosixPath(name).stem,
1153 ),
1154 )
1155 for slide_index, slide_name in enumerate(slide_names, 1):
1156 root = _parse_xml(archive.read(slide_name), location=slide_name)
1157 tree = root.find(f"./{{{P_NS}}}cSld/{{{P_NS}}}spTree")
1158 if tree is None:
1159 issues.append(
1160 {
1161 "code": "MISSING_SHAPE_TREE",
1162 "slide": slide_index,
1163 "message": "slide has no shape tree",
1164 }
1165 )
1166 continue
1167 shapes: list[dict[str, Any]] = []
1168 for child in list(tree):
1169 kind = _local_name(child.tag)
1170 if kind not in {"sp", "pic", "graphicFrame", "cxnSp", "grpSp"}:
1171 continue
1172 name = _shape_name(child)
1173 if kind == "grpSp":
1174 issues.append(
1175 {
1176 "code": "GROUP_REQUIRES_MANUAL_REVIEW",
1177 "slide": slide_index,
1178 "shape": name,
1179 "message": (
1180 "group transforms are not flattened by this checker"
1181 ),
1182 }
1183 )
1184 continue
1185 box = _shape_transform(child)
1186 if box is None:
1187 issues.append(
1188 {
1189 "code": "MISSING_TRANSFORM",
1190 "slide": slide_index,
1191 "shape": name,
1192 "message": "shape position or size could not be read",
1193 }
1194 )
1195 continue
1196 x, y, width, height = box
1197 text = _shape_text(child)
1198 sizes = _font_sizes_pt(child)
1199 shape_record = {
1200 "reading_order": len(shapes) + 1,
1201 "name": name,
1202 "kind": kind,
1203 "x_in": x / EMU_PER_INCH,
1204 "y_in": y / EMU_PER_INCH,
1205 "width_in": width / EMU_PER_INCH,
1206 "height_in": height / EMU_PER_INCH,
1207 "has_text": bool(text),
1208 "_box": box,
1209 }
1210 shapes.append(shape_record)
1211 if x < 0 or y < 0 or x + width > slide_width or y + height > slide_height:
1212 issues.append(
1213 {
1214 "code": "OUT_OF_BOUNDS",
1215 "slide": slide_index,
1216 "shape": name,
1217 "message": "shape extends outside the slide canvas",
1218 }
1219 )
1220 if text:
1221 if not sizes:
1222 issues.append(
1223 {
1224 "code": "FONT_SIZE_UNSPECIFIED",
1225 "slide": slide_index,
1226 "shape": name,
1227 "message": (
1228 "text has no explicit run/default font size; "
1229 "theme inheritance requires manual review"
1230 ),
1231 }
1232 )
1233 else:
1234 minimum_design = min(sizes)
1235 minimum_final = minimum_design * print_scale
1236 shape_record["minimum_font_pt_design"] = minimum_design
1237 shape_record["minimum_font_pt_final"] = minimum_final
1238 if minimum_final + 1e-6 < minimum_font_pt_final:
1239 issues.append(
1240 {
1241 "code": "FONT_TOO_SMALL",
1242 "slide": slide_index,
1243 "shape": name,
1244 "message": (
1245 f"minimum final font {minimum_final:.2f} pt "
1246 f"is below {minimum_font_pt_final:.2f} pt"
1247 ),
1248 }
1249 )
1251 for first_index, first in enumerate(shapes):
1252 first_box = first["_box"]
1253 if first_box[2] <= 0 or first_box[3] <= 0:
1254 continue
1255 for second in shapes[first_index + 1 :]:
1256 second_box = second["_box"]
1257 if second_box[2] <= 0 or second_box[3] <= 0:
1258 continue
1259 overlap_width, overlap_height = _intersection(
1260 first_box, second_box
1261 )
1262 if overlap_width > 0 and overlap_height > 0:
1263 overlap_area = overlap_width * overlap_height
1264 smaller_area = min(
1265 first_box[2] * first_box[3],
1266 second_box[2] * second_box[3],
1267 )
1268 issues.append(
1269 {
1270 "code": "SHAPE_OVERLAP",
1271 "slide": slide_index,
1272 "shapes": [first["name"], second["name"]],
1273 "overlap_fraction_of_smaller": (
1274 overlap_area / smaller_area
1275 ),
1276 "message": (
1277 "direct shape bounding boxes overlap; determine "
1278 "whether this is intentional"
1279 ),
1280 }
1281 )
1282 for shape in shapes:
1283 shape.pop("_box", None)
1284 slides.append(
1285 {
1286 "slide": slide_index,
1287 "part": slide_name,
1288 "shape_count": len(shapes),
1289 "reading_order": [
1290 {"order": shape["reading_order"], "name": shape["name"]}
1291 for shape in shapes
1292 ],
1293 "shapes": shapes,
1294 }
1295 )
1297 return {
1298 "schema_version": REPORT_SCHEMA_VERSION,
1299 "path": str(path),
1300 "pass": not issues,
1301 "slide_size": {
1302 "width_in": slide_width / EMU_PER_INCH,
1303 "height_in": slide_height / EMU_PER_INCH,
1304 },
1305 "print_scale": print_scale,
1306 "minimum_font_pt_final": minimum_font_pt_final,
1307 "minimum_font_basis": (
1308 "Caller-supplied requirement or project heuristic; this value is not "
1309 "a universal poster standard."
1310 ),
1311 "slides": slides,
1312 "issues": issues,
1313 "manual_checks": [
1314 "Review text overflow in PowerPoint; XML bounds do not reveal rendered overflow.",
1315 "Confirm Reading Order pane order and test with a screen reader.",
1316 "Inspect intentional overlays, rotated objects, groups, charts, and SmartArt manually.",
1317 ],
1318 }
1321def strip_generated_printer_settings(source: Path, destination: Path) -> None:
1322 """Remove python-pptx's inert default printer-settings binary from new output."""
1323 with zipfile.ZipFile(source, "r") as input_archive:
1324 _, preflight_findings, _, _ = _preflight_members(input_archive)
1325 unexpected = [
1326 finding
1327 for finding in preflight_findings
1328 if not (
1329 finding["code"] == "FORBIDDEN_BINARY_PART"
1330 and str(finding.get("location", "")).startswith(
1331 "ppt/printerSettings/"
1332 )
1333 )
1334 ]
1335 if unexpected:
1336 codes = ", ".join(finding["code"] for finding in unexpected)
1337 raise CliError(
1338 f"generated package has unexpected ZIP findings before cleanup: {codes}"
1339 )
1340 with zipfile.ZipFile(
1341 destination,
1342 "w",
1343 compression=zipfile.ZIP_DEFLATED,
1344 compresslevel=6,
1345 allowZip64=True,
1346 ) as output_archive:
1347 for info in input_archive.infolist():
1348 if info.filename.startswith("ppt/printerSettings/"):
1349 continue
1350 info.date_time = DETERMINISTIC_ZIP_DATETIME
1351 if info.filename == "[Content_Types].xml":
1352 root = _parse_xml(
1353 input_archive.read(info.filename),
1354 location=info.filename,
1355 )
1356 for node in list(root):
1357 if (
1358 node.tag == f"{{{CT_NS}}}Default"
1359 and node.attrib.get("Extension", "").lower() == "bin"
1360 and node.attrib.get("ContentType", "").endswith(
1361 ".printerSettings"
1362 )
1363 ):
1364 root.remove(node)
1365 output_archive.writestr(
1366 info,
1367 ET.tostring(
1368 root,
1369 encoding="utf-8",
1370 xml_declaration=True,
1371 ),
1372 )
1373 continue
1374 if info.filename.endswith(".rels"):
1375 root = _parse_xml(
1376 input_archive.read(info.filename),
1377 location=info.filename,
1378 )
1379 changed = False
1380 for relationship in list(root):
1381 if relationship.attrib.get("Type", "").endswith(
1382 "/printerSettings"
1383 ):
1384 root.remove(relationship)
1385 changed = True
1386 if changed:
1387 output_archive.writestr(
1388 info,
1389 ET.tostring(
1390 root,
1391 encoding="utf-8",
1392 xml_declaration=True,
1393 ),
1394 )
1395 continue
1396 with input_archive.open(info, "r") as source_handle:
1397 with output_archive.open(info, "w") as destination_handle:
1398 shutil.copyfileobj(
1399 source_handle,
1400 destination_handle,
1401 length=1024 * 1024,
1402 )
1405def patch_accessibility_metadata(
1406 source: Path,
1407 destination: Path,
1408 *,
1409 alt_text_by_shape_name: dict[str, str],
1410 language: str,
1411) -> None:
1412 """Add picture descriptions and explicit run language to a generated PPTX."""
1413 require_safe_pptx(source)
1414 found: dict[str, int] = {name: 0 for name in alt_text_by_shape_name}
1415 with zipfile.ZipFile(source, "r") as input_archive:
1416 with zipfile.ZipFile(
1417 destination,
1418 "w",
1419 compression=zipfile.ZIP_DEFLATED,
1420 compresslevel=6,
1421 allowZip64=True,
1422 ) as output_archive:
1423 for info in input_archive.infolist():
1424 info.date_time = DETERMINISTIC_ZIP_DATETIME
1425 if (
1426 info.filename.startswith("ppt/slides/slide")
1427 and info.filename.endswith(".xml")
1428 and "/_rels/" not in info.filename
1429 ):
1430 root = _parse_xml(
1431 input_archive.read(info.filename),
1432 location=info.filename,
1433 )
1434 changed = False
1435 for picture in root.findall(f".//{{{P_NS}}}pic"):
1436 properties = picture.find(
1437 f"./{{{P_NS}}}nvPicPr/{{{P_NS}}}cNvPr"
1438 )
1439 if properties is None:
1440 continue
1441 name = properties.attrib.get("name", "")
1442 if name not in alt_text_by_shape_name:
1443 continue
1444 properties.set("descr", alt_text_by_shape_name[name])
1445 properties.set("title", name)
1446 found[name] += 1
1447 changed = True
1448 for run in root.findall(f".//{{{A_NS}}}r"):
1449 properties = run.find(f"./{{{A_NS}}}rPr")
1450 if properties is None:
1451 properties = ET.Element(f"{{{A_NS}}}rPr")
1452 run.insert(0, properties)
1453 if properties.attrib.get("lang") != language:
1454 properties.set("lang", language)
1455 changed = True
1456 for tag in ("defRPr", "endParaRPr"):
1457 for properties in root.findall(f".//{{{A_NS}}}{tag}"):
1458 if properties.attrib.get("lang") != language:
1459 properties.set("lang", language)
1460 changed = True
1461 payload = (
1462 ET.tostring(
1463 root,
1464 encoding="utf-8",
1465 xml_declaration=True,
1466 )
1467 if changed
1468 else input_archive.read(info.filename)
1469 )
1470 output_archive.writestr(info, payload)
1471 continue
1472 with input_archive.open(info, "r") as source_handle:
1473 with output_archive.open(info, "w") as destination_handle:
1474 shutil.copyfileobj(
1475 source_handle,
1476 destination_handle,
1477 length=1024 * 1024,
1478 )
1479 missing = [name for name, count in found.items() if count != 1]
1480 if missing:
1481 raise CliError(
1482 "could not apply alt text exactly once for shape(s): "
1483 + ", ".join(sorted(missing))
1484 )