ppt2pptx 0.3.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
ppt2pptx/__init__.py ADDED
@@ -0,0 +1,6 @@
1
+ """Public API for ppt2pptx."""
2
+
3
+ from .converter import ConversionResult, convert, inspect_ppt
4
+
5
+ __all__ = ["ConversionResult", "convert", "inspect_ppt"]
6
+ __version__ = "0.3.1"
ppt2pptx/__main__.py ADDED
@@ -0,0 +1,2 @@
1
+ from .cli import main
2
+ raise SystemExit(main())
ppt2pptx/cfb.py ADDED
@@ -0,0 +1,115 @@
1
+ """Small bounded reader for CFB/OLE compound files used by legacy PPT."""
2
+ from __future__ import annotations
3
+
4
+ from dataclasses import dataclass
5
+ from pathlib import Path
6
+ import struct
7
+
8
+ from .errors import InvalidPpt
9
+
10
+ FREE, END, FAT, DIF = 0xFFFFFFFF, 0xFFFFFFFE, 0xFFFFFFFD, 0xFFFFFFFC
11
+
12
+ @dataclass(frozen=True, slots=True)
13
+ class Limits:
14
+ max_input_bytes: int = 512 * 1024 * 1024
15
+ max_stream_bytes: int = 256 * 1024 * 1024
16
+
17
+ class CompoundFile:
18
+ def __init__(self, data: bytes, limits: Limits | None = None) -> None:
19
+ self.limits = limits or Limits()
20
+ if len(data) > self.limits.max_input_bytes or len(data) < 512:
21
+ raise InvalidPpt("invalid or oversized compound file")
22
+ if data[:8] != b"\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1":
23
+ raise InvalidPpt("not a Compound File Binary container")
24
+ self.data = data
25
+ major = struct.unpack_from("<H", data, 26)[0]
26
+ self.sector_size = 512 if major == 3 else 4096 if major == 4 else 0
27
+ if not self.sector_size or len(data) < self.sector_size:
28
+ raise InvalidPpt("unsupported compound file version")
29
+ self.mini_sector_size = 64
30
+ self.mini_cutoff = struct.unpack_from("<I", data, 56)[0]
31
+ self.first_dir = struct.unpack_from("<I", data, 48)[0]
32
+ self.first_mini_fat = struct.unpack_from("<I", data, 60)[0]
33
+ self.num_mini_fat = struct.unpack_from("<I", data, 64)[0]
34
+ self._sectors = (len(data) - self.sector_size) // self.sector_size
35
+ self.fat = self._load_fat()
36
+ self.entries = self._read_directory()
37
+ self.by_name = {name.casefold(): entry for name, entry in self.entries.items()}
38
+ self.mini_fat = self._read_chain_values(self.first_mini_fat, self.num_mini_fat)
39
+ self._mini_stream: bytes | None = None
40
+
41
+ @classmethod
42
+ def from_path(cls, path: str | Path, limits: Limits | None = None) -> "CompoundFile":
43
+ p = Path(path)
44
+ actual = limits or Limits()
45
+ if p.stat().st_size > actual.max_input_bytes:
46
+ raise InvalidPpt("input exceeds configured size limit")
47
+ return cls(p.read_bytes(), actual)
48
+
49
+ def _sector(self, index: int) -> bytes:
50
+ if index >= self._sectors or index in (FREE, END, FAT, DIF):
51
+ raise InvalidPpt("compound file sector reference is invalid")
52
+ start = (index + 1) * self.sector_size
53
+ return self.data[start:start + self.sector_size]
54
+
55
+ def _load_fat(self) -> tuple[int, ...]:
56
+ count = struct.unpack_from("<I", self.data, 44)[0]
57
+ ids = [x for x in struct.unpack_from("<109I", self.data, 76) if x != FREE]
58
+ next_difat, difat_count = struct.unpack_from("<II", self.data, 68)
59
+ for _ in range(difat_count):
60
+ block = self._sector(next_difat)
61
+ values = struct.unpack("<%dI" % (self.sector_size // 4), block)
62
+ ids.extend(x for x in values[:-1] if x != FREE)
63
+ next_difat = values[-1]
64
+ if len(ids) < count:
65
+ raise InvalidPpt("compound file FAT is truncated")
66
+ values: list[int] = []
67
+ for index in ids[:count]: values.extend(struct.unpack("<%dI" % (self.sector_size // 4), self._sector(index)))
68
+ return tuple(values)
69
+
70
+ def _chain(self, start: int, table: tuple[int, ...], limit: int | None = None) -> list[int]:
71
+ result, seen, current = [], set(), start
72
+ while current != END:
73
+ if current in seen or current >= len(table) or current in (FREE, FAT, DIF):
74
+ raise InvalidPpt("compound file sector chain is invalid")
75
+ seen.add(current); result.append(current)
76
+ if len(result) > (limit or len(table) + 1): raise InvalidPpt("compound file sector chain is unbounded")
77
+ current = table[current]
78
+ return result
79
+
80
+ def _read_chain_values(self, start: int, declared: int) -> tuple[int, ...]:
81
+ if not declared: return ()
82
+ raw = b"".join(self._sector(i) for i in self._chain(start, self.fat, declared))
83
+ return struct.unpack("<%dI" % (len(raw) // 4), raw)
84
+
85
+ def _read_directory(self) -> dict[str, tuple[int, int, int]]:
86
+ raw = b"".join(self._sector(i) for i in self._chain(self.first_dir, self.fat))
87
+ entries: list[tuple[str, int, int, int]] = []
88
+ for offset in range(0, len(raw) - 127, 128):
89
+ name_len = struct.unpack_from("<H", raw, offset + 64)[0]
90
+ kind = raw[offset + 66]
91
+ if kind not in (2, 5) or name_len < 2 or name_len > 64: continue
92
+ name = raw[offset:offset + name_len - 2].decode("utf-16le", "replace")
93
+ start, size = struct.unpack_from("<IQ", raw, offset + 116)
94
+ entries.append((name, kind, start, size))
95
+ return {name: (kind, start, size) for name, kind, start, size in entries}
96
+
97
+ def open_stream(self, name: str) -> bytes:
98
+ entry = self.by_name.get(name.casefold())
99
+ if entry is None: raise InvalidPpt(f"required stream is missing: {name}")
100
+ kind, start, size = entry
101
+ if kind != 2 or size > self.limits.max_stream_bytes: raise InvalidPpt(f"invalid stream: {name}")
102
+ if not size: return b""
103
+ if size < self.mini_cutoff:
104
+ if self._mini_stream is None:
105
+ root = next((x for x in self.entries.values() if x[0] == 5), None)
106
+ if root is None: raise InvalidPpt("compound file root storage is missing")
107
+ self._mini_stream = self._read_regular(root[1], root[2])
108
+ chunks = []
109
+ for idx in self._chain(start, self.mini_fat):
110
+ pos = idx * self.mini_sector_size; chunks.append(self._mini_stream[pos:pos+self.mini_sector_size])
111
+ return b"".join(chunks)[:size]
112
+ return self._read_regular(start, size)
113
+
114
+ def _read_regular(self, start: int, size: int) -> bytes:
115
+ return b"".join(self._sector(i) for i in self._chain(start, self.fat))[:size]
ppt2pptx/cli.py ADDED
@@ -0,0 +1,146 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import json
5
+ from pathlib import Path
6
+ import sys
7
+
8
+ from . import __version__
9
+ from .cfb import Limits
10
+ from .converter import convert, inspect_ppt
11
+ from .diagnostics import write_json_file
12
+ from .errors import Ppt2PptxError
13
+
14
+ def _positive_int(value: str) -> int:
15
+ parsed = int(value)
16
+ if parsed <= 0:
17
+ raise argparse.ArgumentTypeError("value must be positive")
18
+ return parsed
19
+
20
+ def _common(parser: argparse.ArgumentParser) -> None:
21
+ parser.add_argument("--max-input-bytes", type=_positive_int, default=Limits().max_input_bytes)
22
+ group = parser.add_mutually_exclusive_group()
23
+ group.add_argument("--password", help="password for an encrypted presentation")
24
+ group.add_argument("--password-file", type=Path, help="UTF-8 file containing the password")
25
+
26
+ def _password(args: argparse.Namespace) -> str | None:
27
+ if args.password is not None:
28
+ return args.password
29
+ if args.password_file is None:
30
+ return None
31
+ if args.password_file.stat().st_size > 4096:
32
+ raise OSError("password file exceeds 4096 bytes")
33
+ return args.password_file.read_text(encoding="utf-8").removesuffix("\n").removesuffix("\r")
34
+
35
+ def _conversion_parser() -> argparse.ArgumentParser:
36
+ parser = argparse.ArgumentParser(prog="ppt2pptx", description="Convert PowerPoint 97–2003 .ppt files to .pptx")
37
+ parser.add_argument("input", type=Path)
38
+ parser.add_argument("-o", "--output", type=Path)
39
+ parser.add_argument("--report", type=Path)
40
+ parser.add_argument("--version", action="version", version=__version__)
41
+ _common(parser)
42
+ return parser
43
+
44
+ def _inspection_parser() -> argparse.ArgumentParser:
45
+ parser = argparse.ArgumentParser(prog="ppt2pptx inspect", description="Inspect slides and editable text without converting")
46
+ parser.add_argument("input", type=Path)
47
+ parser.add_argument("--json", action="store_true", dest="as_json")
48
+ _common(parser)
49
+ return parser
50
+
51
+ def _batch_parser() -> argparse.ArgumentParser:
52
+ parser = argparse.ArgumentParser(prog="ppt2pptx batch", description="Convert a directory of PowerPoint 97–2003 .ppt files")
53
+ parser.add_argument("input", type=Path)
54
+ parser.add_argument("-o", "--output", type=Path, required=True)
55
+ parser.add_argument("--recursive", action="store_true")
56
+ parser.add_argument("--report", type=Path)
57
+ _common(parser)
58
+ return parser
59
+
60
+ def _same_path(left: Path, right: Path) -> bool:
61
+ return left.resolve().as_posix().casefold() == right.resolve().as_posix().casefold()
62
+
63
+ def _batch_sources(root: Path, recursive: bool) -> list[Path]:
64
+ iterator = root.rglob("*") if recursive else root.iterdir()
65
+ return sorted(
66
+ (path for path in iterator if path.is_file() and path.suffix.casefold() == ".ppt" and not path.name.startswith("~$")),
67
+ key=lambda path: path.relative_to(root).as_posix().casefold(),
68
+ )
69
+
70
+ def _run_batch(args: argparse.Namespace) -> int:
71
+ source_root, output_root = args.input, args.output
72
+ if not source_root.is_dir():
73
+ print(f"ppt2pptx: error: batch input is not a directory: {source_root}", file=sys.stderr)
74
+ return 2
75
+ sources = _batch_sources(source_root, args.recursive)
76
+ password = _password(args)
77
+ if not sources:
78
+ print(f"ppt2pptx: error: no .ppt files found in {source_root}", file=sys.stderr)
79
+ return 2
80
+ destinations = [output_root / source.relative_to(source_root).with_suffix(".pptx") for source in sources]
81
+ if args.report and any(_same_path(args.report, path) for path in (*sources, *destinations)):
82
+ print("ppt2pptx: error: report path would overwrite an input or output", file=sys.stderr)
83
+ return 2
84
+ results: list[dict[str, object]] = []
85
+ succeeded = failed = warning_count = 0
86
+ seen_destinations: set[str] = set()
87
+ for source, destination in zip(sources, destinations):
88
+ destination_key = destination.resolve().as_posix().casefold()
89
+ if destination_key in seen_destinations:
90
+ error = f"multiple inputs map to the same output {destination}"
91
+ print(f"Failed {source}: {error}", file=sys.stderr)
92
+ results.append({"source": str(source), "destination": str(destination), "status": "failed", "error": error})
93
+ failed += 1
94
+ continue
95
+ seen_destinations.add(destination_key)
96
+ try:
97
+ result = convert(source, destination, limits=Limits(max_input_bytes=args.max_input_bytes), password=password)
98
+ except (Ppt2PptxError, OSError) as exc:
99
+ print(f"Failed {source}: {exc}", file=sys.stderr)
100
+ results.append({"source": str(source), "destination": str(destination), "status": "failed", "error": str(exc)})
101
+ failed += 1
102
+ continue
103
+ warnings = len(result.report.warnings)
104
+ warning_count += warnings
105
+ succeeded += 1
106
+ print(f"Converted {source} -> {destination}")
107
+ results.append({"source": str(source), "destination": str(destination), "status": "converted", "slide_count": result.slide_count, "warning_count": warnings, "report": result.report.to_dict()})
108
+ summary = {"input": str(source_root), "output": str(output_root), "recursive": bool(args.recursive), "file_count": len(sources), "succeeded": succeeded, "failed": failed, "warning_count": warning_count, "results": results}
109
+ if args.report:
110
+ write_json_file(args.report, summary)
111
+ print(f"Batch complete: {succeeded} converted, {failed} failed")
112
+ return 1 if failed else 0
113
+
114
+ def main(argv: list[str] | None = None) -> int:
115
+ raw = list(sys.argv[1:] if argv is None else argv)
116
+ mode = raw.pop(0) if raw and raw[0] in ("inspect", "batch") else "convert"
117
+ parser = {"inspect": _inspection_parser, "batch": _batch_parser}.get(mode, _conversion_parser)()
118
+ args = parser.parse_args(raw)
119
+ if mode == "batch":
120
+ try:
121
+ return _run_batch(args)
122
+ except (Ppt2PptxError, OSError) as exc:
123
+ print(f"ppt2pptx: error: {exc}", file=sys.stderr)
124
+ return 2
125
+ try:
126
+ limits = Limits(max_input_bytes=args.max_input_bytes)
127
+ password = _password(args)
128
+ if mode == "inspect":
129
+ result = inspect_ppt(args.input, limits=limits, password=password)
130
+ print(json.dumps(result, ensure_ascii=False, indent=2) if args.as_json else f"{result['slide_count']} slides; {result['text_box_count']} text boxes")
131
+ return 0
132
+ output = args.output or args.input.with_suffix(".pptx")
133
+ if args.report and (_same_path(args.report, args.input) or _same_path(args.report, output)):
134
+ print("ppt2pptx: error: report path would overwrite an input or output", file=sys.stderr)
135
+ return 2
136
+ result = convert(args.input, args.output, limits=limits, password=password)
137
+ if args.report:
138
+ write_json_file(args.report, result.report.to_dict())
139
+ print(f"Converted {args.input} -> {result.output_path} ({result.slide_count} slides)")
140
+ return 0
141
+ except (Ppt2PptxError, OSError) as exc:
142
+ print(f"ppt2pptx: error: {exc}", file=sys.stderr)
143
+ return 2
144
+
145
+ if __name__ == "__main__":
146
+ raise SystemExit(main())
ppt2pptx/converter.py ADDED
@@ -0,0 +1,129 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, replace
4
+ from pathlib import Path
5
+
6
+ from .cfb import CompoundFile, Limits
7
+ from .diagnostics import ConversionReport
8
+ from .encryption import decrypt_powerpoint_document
9
+ from .errors import EncryptedPresentationError, InvalidPpt, UnsupportedPptVersionError, UnsafeOutputPathError
10
+ from .ooxml import write_pptx
11
+ from .oleps import read_summary_information
12
+ from .ppt import Presentation, extract_presentation
13
+
14
+ @dataclass(frozen=True, slots=True)
15
+ class ConversionResult:
16
+ output_path: Path
17
+ report: ConversionReport
18
+ slide_count: int
19
+ presentation: Presentation
20
+
21
+ def _load(source: str | Path, limits: Limits | None = None, report: ConversionReport | None = None, password: str | None = None) -> Presentation:
22
+ compound = CompoundFile.from_path(source, limits)
23
+ current_user = compound.open_stream("Current User")
24
+ document_stream = compound.open_stream("PowerPoint Document")
25
+ if len(current_user) >= 16 and current_user[12:16] == b"\xdf\xc4\xd1\xf3":
26
+ document_stream = decrypt_powerpoint_document(document_stream, current_user, password)
27
+ if report is not None and "encryptedsummary" in compound.by_name:
28
+ report.warning("ENCRYPTED_METADATA_OMITTED", "encrypted document properties were not copied")
29
+ if len(current_user) < 8 or current_user[2:4] != b"\xf6\x0f":
30
+ raise UnsupportedPptVersionError("PowerPoint 95 and earlier presentations are outside the PowerPoint 97–2003 format")
31
+ pictures = compound.open_stream("Pictures") if "pictures" in compound.by_name else None
32
+ presentation = extract_presentation(document_stream, pictures)
33
+ if "\x05summaryinformation" in compound.by_name:
34
+ try:
35
+ properties = read_summary_information(compound.open_stream("\x05SummaryInformation"))
36
+ presentation = replace(presentation, core_properties=properties)
37
+ except InvalidPpt as exc:
38
+ if report is not None:
39
+ report.warning("SUMMARY_INFORMATION_MALFORMED", "malformed optional presentation metadata was omitted", reason=str(exc))
40
+ return presentation
41
+
42
+ def inspect_ppt(source: str | Path, *, limits: Limits | None = None, password: str | None = None) -> dict[str, object]:
43
+ presentation = _load(source, limits, password=password)
44
+ return {
45
+ "source": str(source),
46
+ "slide_count": len(presentation.slides),
47
+ "slide_size": {"width": presentation.width, "height": presentation.height},
48
+ "text_box_count": sum(len(slide.text_boxes) for slide in presentation.slides),
49
+ "picture_count": sum(len(slide.pictures) for slide in presentation.slides),
50
+ "shape_count": sum(len(slide.shapes) for slide in presentation.slides),
51
+ "comment_count": sum(len(slide.comments) for slide in presentation.slides),
52
+ "note_count": sum(len(slide.notes) for slide in presentation.slides),
53
+ "core_properties": {
54
+ name: value for name in presentation.core_properties.__dataclass_fields__
55
+ if (value := getattr(presentation.core_properties, name)) is not None
56
+ },
57
+ "slides": [
58
+ {
59
+ "index": i + 1,
60
+ "text_boxes": [
61
+ {"text": box.text, "left": box.left, "top": box.top,
62
+ "width": box.width, "height": box.height,
63
+ "paragraph_alignments": box.paragraph_alignments,
64
+ "paragraph_bullets": box.paragraph_bullets,
65
+ "rotation": box.rotation, "flip_horizontal": box.flip_horizontal,
66
+ "flip_vertical": box.flip_vertical,
67
+ "runs": [
68
+ {"text": run.text, "bold": run.bold, "italic": run.italic,
69
+ "underline": run.underline, "font_size": run.font_size,
70
+ "color": run.color, "typeface": run.typeface,
71
+ "hyperlink": run.hyperlink}
72
+ for run in box.runs
73
+ ]}
74
+ for box in slide.text_boxes
75
+ ],
76
+ "pictures": [
77
+ {"extension": picture.extension, "content_type": picture.content_type,
78
+ "byte_count": len(picture.data), "left": picture.left,
79
+ "top": picture.top, "width": picture.width, "height": picture.height,
80
+ "crop_left": picture.crop_left, "crop_top": picture.crop_top,
81
+ "crop_right": picture.crop_right, "crop_bottom": picture.crop_bottom,
82
+ "rotation": picture.rotation, "flip_horizontal": picture.flip_horizontal,
83
+ "flip_vertical": picture.flip_vertical}
84
+ for picture in slide.pictures
85
+ ],
86
+ "shapes": [
87
+ {"preset": shape.preset, "left": shape.left, "top": shape.top,
88
+ "width": shape.width, "height": shape.height,
89
+ "fill_color": shape.fill_color, "line_color": shape.line_color,
90
+ "rotation": shape.rotation, "flip_horizontal": shape.flip_horizontal,
91
+ "flip_vertical": shape.flip_vertical}
92
+ for shape in slide.shapes
93
+ ],
94
+ "background_color": slide.background_color,
95
+ "background_color_end": slide.background_color_end,
96
+ "hidden": slide.hidden,
97
+ "notes": list(slide.notes),
98
+ "header_footer": ({
99
+ "date_text": slide.header_footer.date_text,
100
+ "date_is_auto": slide.header_footer.date_is_auto,
101
+ "header_text": slide.header_footer.header_text,
102
+ "footer_text": slide.header_footer.footer_text,
103
+ "show_slide_number": slide.header_footer.show_slide_number,
104
+ } if slide.header_footer else None),
105
+ "comments": [
106
+ {"author": comment.author, "initials": comment.initials,
107
+ "text": comment.text, "left": comment.left,
108
+ "top": comment.top, "created": comment.created}
109
+ for comment in slide.comments
110
+ ],
111
+ }
112
+ for i, slide in enumerate(presentation.slides)
113
+ ],
114
+ }
115
+
116
+ def convert(source: str | Path, destination: str | Path | None = None, *, limits: Limits | None = None, password: str | None = None) -> ConversionResult:
117
+ source_path = Path(source)
118
+ output = Path(destination) if destination is not None else source_path.with_suffix(".pptx")
119
+ if output.suffix.casefold() != ".pptx": raise UnsafeOutputPathError("destination must use the .pptx extension")
120
+ if source_path.resolve() == output.resolve(): raise UnsafeOutputPathError("destination must not overwrite the source presentation")
121
+ report = ConversionReport(str(source_path), str(output))
122
+ presentation = _load(source_path, limits, report, password)
123
+ report.warning("ADVANCED_FEATURES_APPROXIMATED", "charts, animations, audio/video, and complex freeform master geometry may be omitted or approximated")
124
+ if not presentation.slides: report.warning("NO_SLIDES_FOUND", "no normal slide records could be recovered from the presentation")
125
+ elif any(not (slide.text_boxes or slide.pictures or slide.shapes or slide.header_footer or slide.notes)
126
+ for slide in presentation.slides):
127
+ report.warning("EMPTY_SLIDE_CONTENT", "one or more slides had no recoverable editable content")
128
+ write_pptx(output, presentation)
129
+ return ConversionResult(output, report, len(presentation.slides), presentation)
@@ -0,0 +1,27 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ import json
5
+ from pathlib import Path
6
+
7
+
8
+ @dataclass(slots=True)
9
+ class ConversionReport:
10
+ source: str
11
+ destination: str | None = None
12
+ warnings: list[dict[str, object]] = field(default_factory=list)
13
+
14
+ def warning(self, code: str, message: str, **details: object) -> None:
15
+ self.warnings.append({"code": code, "message": message, **details})
16
+
17
+ def to_dict(self) -> dict[str, object]:
18
+ return {"source": self.source, "destination": self.destination,
19
+ "warning_count": len(self.warnings), "warnings": self.warnings}
20
+
21
+
22
+ def write_json_file(path: str | Path, value: object) -> None:
23
+ target = Path(path)
24
+ target.parent.mkdir(parents=True, exist_ok=True)
25
+ temporary = target.with_name(f".{target.name}.tmp")
26
+ temporary.write_text(json.dumps(value, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
27
+ temporary.replace(target)
ppt2pptx/encryption.py ADDED
@@ -0,0 +1,153 @@
1
+ """RC4 CryptoAPI decryption for password-protected PowerPoint streams."""
2
+ from __future__ import annotations
3
+
4
+ import hashlib
5
+ import hmac
6
+ import struct
7
+
8
+ from .errors import EncryptedPresentationError, InvalidPpt
9
+
10
+ RT_USER_EDIT_ATOM = 4085
11
+ RT_PERSIST_DIRECTORY_ATOM = 6002
12
+ RT_DOCUMENT_ENCRYPTION_ATOM = 12052
13
+
14
+
15
+ def _rc4(key: bytes, data: bytes) -> bytes:
16
+ if not key:
17
+ raise InvalidPpt("PowerPoint encryption key is empty")
18
+ state = list(range(256))
19
+ j = 0
20
+ for index in range(256):
21
+ j = (j + state[index] + key[index % len(key)]) & 0xFF
22
+ state[index], state[j] = state[j], state[index]
23
+ output = bytearray(len(data))
24
+ i = j = 0
25
+ for position, value in enumerate(data):
26
+ i = (i + 1) & 0xFF
27
+ j = (j + state[i]) & 0xFF
28
+ state[i], state[j] = state[j], state[i]
29
+ output[position] = value ^ state[(state[i] + state[j]) & 0xFF]
30
+ return bytes(output)
31
+
32
+
33
+ def _cryptoapi_key(secret: bytes, block: int, key_bits: int) -> bytes:
34
+ digest = hashlib.sha1(secret + struct.pack("<I", block)).digest()
35
+ if key_bits == 40:
36
+ return digest[:5] + bytes(11)
37
+ return digest[: key_bits // 8]
38
+
39
+
40
+ def _record(data: bytes, offset: int, expected_type: int | None = None) -> tuple[int, bytes]:
41
+ if offset < 0 or offset + 8 > len(data):
42
+ raise InvalidPpt("PowerPoint record offset is outside the document stream")
43
+ _, record_type, length = struct.unpack_from("<HHI", data, offset)
44
+ end = offset + 8 + length
45
+ if end > len(data):
46
+ raise InvalidPpt("PowerPoint record extends beyond the document stream")
47
+ if expected_type is not None and record_type != expected_type:
48
+ raise InvalidPpt(f"expected PowerPoint record {expected_type} at offset {offset}, found {record_type}")
49
+ return record_type, data[offset + 8:end]
50
+
51
+
52
+ def _persist_mappings(data: bytes, current_user: bytes) -> tuple[dict[int, int], int]:
53
+ if len(current_user) < 20:
54
+ raise InvalidPpt("encrypted Current User stream is truncated")
55
+ user_edit_offset = struct.unpack_from("<I", current_user, 16)[0]
56
+ mappings: dict[int, int] = {}
57
+ encryption_persist_id: int | None = None
58
+ seen: set[int] = set()
59
+ while user_edit_offset:
60
+ if user_edit_offset in seen or len(seen) >= 4096:
61
+ raise InvalidPpt("PowerPoint user edit chain is cyclic or unbounded")
62
+ seen.add(user_edit_offset)
63
+ _, edit = _record(data, user_edit_offset, RT_USER_EDIT_ATOM)
64
+ if len(edit) not in (28, 32):
65
+ raise InvalidPpt("encrypted PowerPoint UserEditAtom has an invalid size")
66
+ previous_edit, persist_offset = struct.unpack_from("<II", edit, 8)
67
+ if encryption_persist_id is None and len(edit) == 32:
68
+ candidate = struct.unpack_from("<I", edit, 28)[0]
69
+ if candidate != 0xFFFFFFFF:
70
+ encryption_persist_id = candidate
71
+ _, persist = _record(data, persist_offset, RT_PERSIST_DIRECTORY_ATOM)
72
+ cursor = 0
73
+ while cursor < len(persist):
74
+ if cursor + 4 > len(persist):
75
+ raise InvalidPpt("PowerPoint persist directory is truncated")
76
+ info = struct.unpack_from("<I", persist, cursor)[0]
77
+ cursor += 4
78
+ count, first = info >> 20, info & 0xFFFFF
79
+ if not count or cursor + count * 4 > len(persist):
80
+ raise InvalidPpt("PowerPoint persist directory entry is invalid")
81
+ for index in range(count):
82
+ offset = struct.unpack_from("<I", persist, cursor)[0]
83
+ cursor += 4
84
+ mappings.setdefault(first + index, offset)
85
+ user_edit_offset = previous_edit
86
+ if encryption_persist_id is None:
87
+ raise InvalidPpt("encrypted PowerPoint file has no encryption session reference")
88
+ return mappings, encryption_persist_id
89
+
90
+
91
+ def _encryption_parameters(payload: bytes, password: str) -> tuple[bytes, int]:
92
+ if len(payload) < 72:
93
+ raise InvalidPpt("PowerPoint encryption information is truncated")
94
+ major, minor, outer_flags, header_size = struct.unpack_from("<HHII", payload)
95
+ if (major, minor) not in ((2, 2), (3, 2), (4, 2)):
96
+ raise EncryptedPresentationError(f"unsupported PowerPoint encryption version {major}.{minor}")
97
+ header_end = 12 + header_size
98
+ if header_size < 32 or header_end + 60 > len(payload):
99
+ raise InvalidPpt("PowerPoint CryptoAPI encryption header is truncated")
100
+ flags, size_extra, algorithm_id, hash_id, key_bits, provider_type, _, reserved2 = struct.unpack_from(
101
+ "<8I", payload, 12
102
+ )
103
+ if flags != outer_flags or not flags & 0x04 or flags & 0x30:
104
+ raise InvalidPpt("PowerPoint CryptoAPI flags are inconsistent")
105
+ if size_extra != 0 or algorithm_id not in (0, 0x6801) or hash_id not in (0, 0x8004):
106
+ raise EncryptedPresentationError("unsupported PowerPoint CryptoAPI cipher or hash algorithm")
107
+ key_bits = key_bits or 40
108
+ if key_bits < 40 or key_bits > 128 or key_bits % 8 or provider_type not in (0, 1) or reserved2:
109
+ raise InvalidPpt("PowerPoint CryptoAPI key or provider fields are invalid")
110
+ salt_size = struct.unpack_from("<I", payload, header_end)[0]
111
+ if salt_size != 16:
112
+ raise InvalidPpt("PowerPoint CryptoAPI salt must contain 16 bytes")
113
+ salt = payload[header_end + 4:header_end + 20]
114
+ encrypted_verifier = payload[header_end + 20:header_end + 36]
115
+ hash_size = struct.unpack_from("<I", payload, header_end + 36)[0]
116
+ encrypted_hash = payload[header_end + 40:header_end + 60]
117
+ if hash_size != 20 or len(encrypted_hash) != 20:
118
+ raise InvalidPpt("PowerPoint CryptoAPI verifier hash is invalid")
119
+ secret = hashlib.sha1(salt + password[:255].encode("utf-16le")).digest()
120
+ verifier_data = _rc4(_cryptoapi_key(secret, 0, key_bits), encrypted_verifier + encrypted_hash)
121
+ if not hmac.compare_digest(hashlib.sha1(verifier_data[:16]).digest(), verifier_data[16:]):
122
+ raise EncryptedPresentationError("incorrect password for encrypted PowerPoint presentation")
123
+ return secret, key_bits
124
+
125
+
126
+ def decrypt_powerpoint_document(data: bytes, current_user: bytes, password: str | None) -> bytes:
127
+ """Decrypt persisted records while leaving edit and directory records intact."""
128
+ if password is None:
129
+ raise EncryptedPresentationError("password-protected PowerPoint presentation; provide --password")
130
+ mappings, encryption_persist_id = _persist_mappings(data, current_user)
131
+ encryption_offset = mappings.get(encryption_persist_id)
132
+ if encryption_offset is None:
133
+ raise InvalidPpt("PowerPoint encryption record is missing from the persist directory")
134
+ _, encryption_payload = _record(data, encryption_offset, RT_DOCUMENT_ENCRYPTION_ATOM)
135
+ secret, key_bits = _encryption_parameters(encryption_payload, password)
136
+ output = bytearray(data)
137
+ occupied_until = 0
138
+ for persist_id, offset in sorted(mappings.items(), key=lambda item: item[1]):
139
+ if persist_id == encryption_persist_id:
140
+ continue
141
+ if offset < occupied_until:
142
+ continue
143
+ key = _cryptoapi_key(secret, persist_id, key_bits)
144
+ header = _rc4(key, data[offset:offset + 8])
145
+ if len(header) != 8:
146
+ raise InvalidPpt("encrypted PowerPoint record header is truncated")
147
+ length = struct.unpack_from("<I", header, 4)[0]
148
+ end = offset + 8 + length
149
+ if length > len(data) or end > len(data):
150
+ raise InvalidPpt("decrypted PowerPoint record has an invalid size")
151
+ output[offset:end] = _rc4(key, data[offset:end])
152
+ occupied_until = end
153
+ return bytes(output)
ppt2pptx/errors.py ADDED
@@ -0,0 +1,18 @@
1
+ class Ppt2PptxError(Exception):
2
+ """Base error for expected conversion failures."""
3
+
4
+
5
+ class InvalidPpt(Ppt2PptxError):
6
+ """The input is not a supported PowerPoint binary presentation."""
7
+
8
+
9
+ class UnsafeOutputPathError(Ppt2PptxError):
10
+ """An output path could overwrite an input or has the wrong extension."""
11
+
12
+
13
+ class EncryptedPresentationError(Ppt2PptxError):
14
+ """The presentation is encrypted and needs password support."""
15
+
16
+
17
+ class UnsupportedPptVersionError(Ppt2PptxError):
18
+ """The file predates the PowerPoint 97 binary record format."""