ppt2pptx 0.3.1__tar.gz
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-0.3.1/LICENSE +21 -0
- ppt2pptx-0.3.1/PKG-INFO +90 -0
- ppt2pptx-0.3.1/README.md +79 -0
- ppt2pptx-0.3.1/pyproject.toml +19 -0
- ppt2pptx-0.3.1/setup.cfg +4 -0
- ppt2pptx-0.3.1/src/ppt2pptx/__init__.py +6 -0
- ppt2pptx-0.3.1/src/ppt2pptx/__main__.py +2 -0
- ppt2pptx-0.3.1/src/ppt2pptx/cfb.py +115 -0
- ppt2pptx-0.3.1/src/ppt2pptx/cli.py +146 -0
- ppt2pptx-0.3.1/src/ppt2pptx/converter.py +129 -0
- ppt2pptx-0.3.1/src/ppt2pptx/diagnostics.py +27 -0
- ppt2pptx-0.3.1/src/ppt2pptx/encryption.py +153 -0
- ppt2pptx-0.3.1/src/ppt2pptx/errors.py +18 -0
- ppt2pptx-0.3.1/src/ppt2pptx/oleps.py +104 -0
- ppt2pptx-0.3.1/src/ppt2pptx/ooxml.py +236 -0
- ppt2pptx-0.3.1/src/ppt2pptx/ppt.py +850 -0
- ppt2pptx-0.3.1/src/ppt2pptx.egg-info/PKG-INFO +90 -0
- ppt2pptx-0.3.1/src/ppt2pptx.egg-info/SOURCES.txt +23 -0
- ppt2pptx-0.3.1/src/ppt2pptx.egg-info/dependency_links.txt +1 -0
- ppt2pptx-0.3.1/src/ppt2pptx.egg-info/entry_points.txt +2 -0
- ppt2pptx-0.3.1/src/ppt2pptx.egg-info/top_level.txt +1 -0
- ppt2pptx-0.3.1/tests/test_encryption.py +52 -0
- ppt2pptx-0.3.1/tests/test_oleps.py +41 -0
- ppt2pptx-0.3.1/tests/test_ooxml.py +71 -0
- ppt2pptx-0.3.1/tests/test_ppt.py +120 -0
ppt2pptx-0.3.1/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 ppt2pptx contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
ppt2pptx-0.3.1/PKG-INFO
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: ppt2pptx
|
|
3
|
+
Version: 0.3.1
|
|
4
|
+
Summary: A pure-Python converter from legacy PowerPoint .ppt files to .pptx
|
|
5
|
+
Author: ppt2pptx contributors
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Requires-Python: >=3.11
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Dynamic: license-file
|
|
11
|
+
|
|
12
|
+
# ppt2pptx
|
|
13
|
+
|
|
14
|
+
`ppt2pptx` converts Microsoft PowerPoint 97–2003 binary `.ppt` presentations
|
|
15
|
+
to Office Open XML `.pptx` using only the Python standard library. It reads
|
|
16
|
+
the CFB/OLE container and PowerPoint record stream directly; it never launches
|
|
17
|
+
PowerPoint, LibreOffice, COM, or a conversion service.
|
|
18
|
+
|
|
19
|
+
The project is useful for a broad set of real presentations, but it is not a
|
|
20
|
+
complete implementation of every legacy PowerPoint feature. Unsupported or
|
|
21
|
+
lossy content is diagnosed instead of being silently presented as fully
|
|
22
|
+
faithful.
|
|
23
|
+
|
|
24
|
+
## Highlights
|
|
25
|
+
|
|
26
|
+
- Opens RC4 CryptoAPI password-protected presentations when a password is supplied.
|
|
27
|
+
- Preserves normal slide order and dimensions without mistaking masters or notes for slides.
|
|
28
|
+
- Preserves hidden-slide state so excluded slides remain hidden during slide shows.
|
|
29
|
+
- Restores positioned editable text, fonts, sizes, colors, bold/italic/underline,
|
|
30
|
+
paragraph alignment, bullets, rotation, flips, and safe external hyperlinks.
|
|
31
|
+
- Preserves PNG, JPEG, GIF, TIFF, EMF, WMF, and PICT media, including cropping,
|
|
32
|
+
position, rotation, and flips when present.
|
|
33
|
+
- Recreates common editable shapes, solid/gradient backgrounds, comments, speaker
|
|
34
|
+
notes, slide numbers, dates, headers, and footers.
|
|
35
|
+
- Copies common core properties such as title, author, keywords, and timestamps.
|
|
36
|
+
- Supports atomic single-file and recursive batch conversion with JSON diagnostics.
|
|
37
|
+
|
|
38
|
+
## Usage
|
|
39
|
+
|
|
40
|
+
```console
|
|
41
|
+
python -m pip install ppt2pptx
|
|
42
|
+
ppt2pptx presentation.ppt
|
|
43
|
+
ppt2pptx presentation.ppt -o presentation.pptx --report report.json
|
|
44
|
+
ppt2pptx protected.ppt --password-file password.txt
|
|
45
|
+
ppt2pptx inspect presentation.ppt --json
|
|
46
|
+
ppt2pptx batch input-directory -o output-directory --recursive --report batch.json
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
The input is always read-only and output files are written atomically. The
|
|
50
|
+
converter refuses to overwrite its input.
|
|
51
|
+
|
|
52
|
+
The Python API accepts the password directly:
|
|
53
|
+
|
|
54
|
+
```python
|
|
55
|
+
from ppt2pptx import convert
|
|
56
|
+
|
|
57
|
+
result = convert("protected.ppt", "protected.pptx", password="secret")
|
|
58
|
+
print(result.report.to_dict())
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## Current limitations
|
|
62
|
+
|
|
63
|
+
Charts, SmartArt, animation timelines, audio/video playback, embedded OLE
|
|
64
|
+
objects, and complex freeform or grouped master geometry remain incomplete.
|
|
65
|
+
Solid and common gradient backgrounds are retained, while advanced fills and
|
|
66
|
+
effects may be approximated. PICT data is preserved, but rendering depends on
|
|
67
|
+
the PPTX consumer. PowerPoint 95 and earlier files use a different record
|
|
68
|
+
format and are deliberately rejected with a clear error.
|
|
69
|
+
|
|
70
|
+
## Development
|
|
71
|
+
|
|
72
|
+
```console
|
|
73
|
+
python -m pip install -e .
|
|
74
|
+
PYTHONPATH=src python -m unittest discover -s tests -v
|
|
75
|
+
python -m build
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
The converter itself does not depend on LibreOffice. The real-file regression
|
|
79
|
+
script may use LibreOffice only to verify that generated packages render:
|
|
80
|
+
|
|
81
|
+
```console
|
|
82
|
+
PYTHONPATH=src python scripts/validate_real_files.py tests/real_samples \
|
|
83
|
+
-o tests/real_output --render --password hello
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
## Specification
|
|
87
|
+
|
|
88
|
+
- [MS-PPT: PowerPoint Binary File Format](https://learn.microsoft.com/openspecs/office_file_formats/ms-ppt/)
|
|
89
|
+
- [MS-CFB: Compound File Binary File Format](https://learn.microsoft.com/openspecs/windows_protocols/ms-cfb/)
|
|
90
|
+
- [MS-ODRAW: Office Drawing Binary File Format](https://learn.microsoft.com/openspecs/office_file_formats/ms-odraw/)
|
ppt2pptx-0.3.1/README.md
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# ppt2pptx
|
|
2
|
+
|
|
3
|
+
`ppt2pptx` converts Microsoft PowerPoint 97–2003 binary `.ppt` presentations
|
|
4
|
+
to Office Open XML `.pptx` using only the Python standard library. It reads
|
|
5
|
+
the CFB/OLE container and PowerPoint record stream directly; it never launches
|
|
6
|
+
PowerPoint, LibreOffice, COM, or a conversion service.
|
|
7
|
+
|
|
8
|
+
The project is useful for a broad set of real presentations, but it is not a
|
|
9
|
+
complete implementation of every legacy PowerPoint feature. Unsupported or
|
|
10
|
+
lossy content is diagnosed instead of being silently presented as fully
|
|
11
|
+
faithful.
|
|
12
|
+
|
|
13
|
+
## Highlights
|
|
14
|
+
|
|
15
|
+
- Opens RC4 CryptoAPI password-protected presentations when a password is supplied.
|
|
16
|
+
- Preserves normal slide order and dimensions without mistaking masters or notes for slides.
|
|
17
|
+
- Preserves hidden-slide state so excluded slides remain hidden during slide shows.
|
|
18
|
+
- Restores positioned editable text, fonts, sizes, colors, bold/italic/underline,
|
|
19
|
+
paragraph alignment, bullets, rotation, flips, and safe external hyperlinks.
|
|
20
|
+
- Preserves PNG, JPEG, GIF, TIFF, EMF, WMF, and PICT media, including cropping,
|
|
21
|
+
position, rotation, and flips when present.
|
|
22
|
+
- Recreates common editable shapes, solid/gradient backgrounds, comments, speaker
|
|
23
|
+
notes, slide numbers, dates, headers, and footers.
|
|
24
|
+
- Copies common core properties such as title, author, keywords, and timestamps.
|
|
25
|
+
- Supports atomic single-file and recursive batch conversion with JSON diagnostics.
|
|
26
|
+
|
|
27
|
+
## Usage
|
|
28
|
+
|
|
29
|
+
```console
|
|
30
|
+
python -m pip install ppt2pptx
|
|
31
|
+
ppt2pptx presentation.ppt
|
|
32
|
+
ppt2pptx presentation.ppt -o presentation.pptx --report report.json
|
|
33
|
+
ppt2pptx protected.ppt --password-file password.txt
|
|
34
|
+
ppt2pptx inspect presentation.ppt --json
|
|
35
|
+
ppt2pptx batch input-directory -o output-directory --recursive --report batch.json
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
The input is always read-only and output files are written atomically. The
|
|
39
|
+
converter refuses to overwrite its input.
|
|
40
|
+
|
|
41
|
+
The Python API accepts the password directly:
|
|
42
|
+
|
|
43
|
+
```python
|
|
44
|
+
from ppt2pptx import convert
|
|
45
|
+
|
|
46
|
+
result = convert("protected.ppt", "protected.pptx", password="secret")
|
|
47
|
+
print(result.report.to_dict())
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## Current limitations
|
|
51
|
+
|
|
52
|
+
Charts, SmartArt, animation timelines, audio/video playback, embedded OLE
|
|
53
|
+
objects, and complex freeform or grouped master geometry remain incomplete.
|
|
54
|
+
Solid and common gradient backgrounds are retained, while advanced fills and
|
|
55
|
+
effects may be approximated. PICT data is preserved, but rendering depends on
|
|
56
|
+
the PPTX consumer. PowerPoint 95 and earlier files use a different record
|
|
57
|
+
format and are deliberately rejected with a clear error.
|
|
58
|
+
|
|
59
|
+
## Development
|
|
60
|
+
|
|
61
|
+
```console
|
|
62
|
+
python -m pip install -e .
|
|
63
|
+
PYTHONPATH=src python -m unittest discover -s tests -v
|
|
64
|
+
python -m build
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
The converter itself does not depend on LibreOffice. The real-file regression
|
|
68
|
+
script may use LibreOffice only to verify that generated packages render:
|
|
69
|
+
|
|
70
|
+
```console
|
|
71
|
+
PYTHONPATH=src python scripts/validate_real_files.py tests/real_samples \
|
|
72
|
+
-o tests/real_output --render --password hello
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
## Specification
|
|
76
|
+
|
|
77
|
+
- [MS-PPT: PowerPoint Binary File Format](https://learn.microsoft.com/openspecs/office_file_formats/ms-ppt/)
|
|
78
|
+
- [MS-CFB: Compound File Binary File Format](https://learn.microsoft.com/openspecs/windows_protocols/ms-cfb/)
|
|
79
|
+
- [MS-ODRAW: Office Drawing Binary File Format](https://learn.microsoft.com/openspecs/office_file_formats/ms-odraw/)
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "ppt2pptx"
|
|
7
|
+
version = "0.3.1"
|
|
8
|
+
description = "A pure-Python converter from legacy PowerPoint .ppt files to .pptx"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.11"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
authors = [{name = "ppt2pptx contributors"}]
|
|
13
|
+
dependencies = []
|
|
14
|
+
|
|
15
|
+
[project.scripts]
|
|
16
|
+
ppt2pptx = "ppt2pptx.cli:main"
|
|
17
|
+
|
|
18
|
+
[tool.setuptools.packages.find]
|
|
19
|
+
where = ["src"]
|
ppt2pptx-0.3.1/setup.cfg
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]
|
|
@@ -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())
|
|
@@ -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)
|