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 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.
@@ -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/)
@@ -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"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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"
@@ -0,0 +1,2 @@
1
+ from .cli import main
2
+ raise SystemExit(main())
@@ -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)