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 +6 -0
- ppt2pptx/__main__.py +2 -0
- ppt2pptx/cfb.py +115 -0
- ppt2pptx/cli.py +146 -0
- ppt2pptx/converter.py +129 -0
- ppt2pptx/diagnostics.py +27 -0
- ppt2pptx/encryption.py +153 -0
- ppt2pptx/errors.py +18 -0
- ppt2pptx/oleps.py +104 -0
- ppt2pptx/ooxml.py +236 -0
- ppt2pptx/ppt.py +850 -0
- ppt2pptx-0.3.1.dist-info/METADATA +90 -0
- ppt2pptx-0.3.1.dist-info/RECORD +17 -0
- ppt2pptx-0.3.1.dist-info/WHEEL +5 -0
- ppt2pptx-0.3.1.dist-info/entry_points.txt +2 -0
- ppt2pptx-0.3.1.dist-info/licenses/LICENSE +21 -0
- ppt2pptx-0.3.1.dist-info/top_level.txt +1 -0
ppt2pptx/__init__.py
ADDED
ppt2pptx/__main__.py
ADDED
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)
|
ppt2pptx/diagnostics.py
ADDED
|
@@ -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."""
|