python-pptx2 2.13.0__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.
- pptx2/__init__.py +152 -0
- pptx2/_color.py +75 -0
- pptx2/_slide_importer.py +597 -0
- pptx2/_svg.py +155 -0
- pptx2/_template_applier.py +292 -0
- pptx2/_textstyle.py +187 -0
- pptx2/accessibility.py +365 -0
- pptx2/action.py +270 -0
- pptx2/animation.py +2237 -0
- pptx2/api.py +49 -0
- pptx2/audit.py +258 -0
- pptx2/chart/__init__.py +0 -0
- pptx2/chart/analytics.py +381 -0
- pptx2/chart/axis.py +543 -0
- pptx2/chart/category.py +200 -0
- pptx2/chart/chart.py +670 -0
- pptx2/chart/data.py +864 -0
- pptx2/chart/datalabel.py +406 -0
- pptx2/chart/legend.py +86 -0
- pptx2/chart/marker.py +70 -0
- pptx2/chart/palettes.py +129 -0
- pptx2/chart/plot.py +462 -0
- pptx2/chart/point.py +101 -0
- pptx2/chart/quick_layouts.py +325 -0
- pptx2/chart/series.py +334 -0
- pptx2/chart/xlsx.py +272 -0
- pptx2/chart/xmlwriter.py +1845 -0
- pptx2/compose/__init__.py +28 -0
- pptx2/compose/from_spec.py +1094 -0
- pptx2/design/__init__.py +8 -0
- pptx2/design/components.py +607 -0
- pptx2/design/figures.py +389 -0
- pptx2/design/layout.py +370 -0
- pptx2/design/recipes.py +1967 -0
- pptx2/design/style.py +209 -0
- pptx2/design/tokens.py +915 -0
- pptx2/diagrams.py +754 -0
- pptx2/dml/__init__.py +0 -0
- pptx2/dml/chtfmt.py +40 -0
- pptx2/dml/color.py +496 -0
- pptx2/dml/effect.py +909 -0
- pptx2/dml/fill.py +691 -0
- pptx2/dml/line.py +287 -0
- pptx2/dml/picture.py +212 -0
- pptx2/dml/three_d.py +381 -0
- pptx2/enum/__init__.py +0 -0
- pptx2/enum/action.py +71 -0
- pptx2/enum/animation.py +31 -0
- pptx2/enum/base.py +218 -0
- pptx2/enum/chart.py +574 -0
- pptx2/enum/dml.py +740 -0
- pptx2/enum/lang.py +685 -0
- pptx2/enum/presentation.py +133 -0
- pptx2/enum/shapes.py +1029 -0
- pptx2/enum/text.py +230 -0
- pptx2/exc.py +42 -0
- pptx2/formats.py +139 -0
- pptx2/geometry.py +420 -0
- pptx2/inherit.py +109 -0
- pptx2/lint.py +2256 -0
- pptx2/math.py +177 -0
- pptx2/media.py +197 -0
- pptx2/opc/__init__.py +0 -0
- pptx2/opc/constants.py +332 -0
- pptx2/opc/oxml.py +188 -0
- pptx2/opc/package.py +762 -0
- pptx2/opc/packuri.py +109 -0
- pptx2/opc/serialized.py +296 -0
- pptx2/opc/shared.py +20 -0
- pptx2/opc/spec.py +45 -0
- pptx2/oxml/__init__.py +555 -0
- pptx2/oxml/action.py +53 -0
- pptx2/oxml/chart/__init__.py +0 -0
- pptx2/oxml/chart/axis.py +337 -0
- pptx2/oxml/chart/chart.py +481 -0
- pptx2/oxml/chart/datalabel.py +253 -0
- pptx2/oxml/chart/legend.py +72 -0
- pptx2/oxml/chart/marker.py +61 -0
- pptx2/oxml/chart/plot.py +365 -0
- pptx2/oxml/chart/series.py +425 -0
- pptx2/oxml/chart/shared.py +220 -0
- pptx2/oxml/coreprops.py +288 -0
- pptx2/oxml/dml/__init__.py +0 -0
- pptx2/oxml/dml/color.py +135 -0
- pptx2/oxml/dml/effect.py +213 -0
- pptx2/oxml/dml/fill.py +316 -0
- pptx2/oxml/dml/line.py +12 -0
- pptx2/oxml/dml/three_d.py +110 -0
- pptx2/oxml/ns.py +135 -0
- pptx2/oxml/presentation.py +313 -0
- pptx2/oxml/shapes/__init__.py +19 -0
- pptx2/oxml/shapes/autoshape.py +467 -0
- pptx2/oxml/shapes/connector.py +107 -0
- pptx2/oxml/shapes/graphfrm.py +347 -0
- pptx2/oxml/shapes/groupshape.py +329 -0
- pptx2/oxml/shapes/picture.py +270 -0
- pptx2/oxml/shapes/shared.py +577 -0
- pptx2/oxml/simpletypes.py +1027 -0
- pptx2/oxml/slide.py +563 -0
- pptx2/oxml/table.py +650 -0
- pptx2/oxml/text.py +815 -0
- pptx2/oxml/theme.py +36 -0
- pptx2/oxml/xmlchemy.py +717 -0
- pptx2/package.py +222 -0
- pptx2/parts/__init__.py +0 -0
- pptx2/parts/chart.py +95 -0
- pptx2/parts/coreprops.py +167 -0
- pptx2/parts/diagram.py +37 -0
- pptx2/parts/embeddedpackage.py +93 -0
- pptx2/parts/image.py +275 -0
- pptx2/parts/media.py +37 -0
- pptx2/parts/presentation.py +136 -0
- pptx2/parts/slide.py +371 -0
- pptx2/presentation.py +408 -0
- pptx2/py.typed +0 -0
- pptx2/render.py +586 -0
- pptx2/section.py +272 -0
- pptx2/shapes/__init__.py +26 -0
- pptx2/shapes/autoshape.py +442 -0
- pptx2/shapes/base.py +1078 -0
- pptx2/shapes/connector.py +297 -0
- pptx2/shapes/freeform.py +337 -0
- pptx2/shapes/graphfrm.py +316 -0
- pptx2/shapes/group.py +264 -0
- pptx2/shapes/picture.py +422 -0
- pptx2/shapes/placeholder.py +468 -0
- pptx2/shapes/shapetree.py +2027 -0
- pptx2/shared.py +82 -0
- pptx2/skill/SKILL.md +450 -0
- pptx2/skill/__init__.py +78 -0
- pptx2/skill/__main__.py +64 -0
- pptx2/skill/references/animations.md +189 -0
- pptx2/skill/references/basics.md +421 -0
- pptx2/skill/references/charts.md +254 -0
- pptx2/skill/references/compose.md +234 -0
- pptx2/skill/references/design.md +366 -0
- pptx2/skill/references/effects.md +249 -0
- pptx2/skill/references/end-to-end-deck.md +231 -0
- pptx2/skill/references/geometry-and-arrows.md +334 -0
- pptx2/skill/references/lint.md +275 -0
- pptx2/skill/references/math.md +86 -0
- pptx2/skill/references/picture-effects.md +129 -0
- pptx2/skill/references/render.md +151 -0
- pptx2/skill/references/smart-art.md +75 -0
- pptx2/skill/references/space-aware-authoring.md +249 -0
- pptx2/skill/references/tables.md +244 -0
- pptx2/skill/references/theme.md +127 -0
- pptx2/skill/references/three-d.md +109 -0
- pptx2/skill/references/transitions.md +100 -0
- pptx2/slide.py +1244 -0
- pptx2/smart_art.py +220 -0
- pptx2/spec.py +633 -0
- pptx2/table.py +1181 -0
- pptx2/table_styles.py +184 -0
- pptx2/templates/default.pptx +0 -0
- pptx2/templates/docx-icon.emf +0 -0
- pptx2/templates/generic-icon.emf +0 -0
- pptx2/templates/notes.xml +23 -0
- pptx2/templates/notesMaster.xml +352 -0
- pptx2/templates/pptx-icon.emf +0 -0
- pptx2/templates/theme.xml +321 -0
- pptx2/templates/xlsx-icon.emf +0 -0
- pptx2/text/__init__.py +0 -0
- pptx2/text/fonts.py +482 -0
- pptx2/text/layout.py +374 -0
- pptx2/text/text.py +1272 -0
- pptx2/theme.py +721 -0
- pptx2/types.py +36 -0
- pptx2/util.py +263 -0
- python_pptx2-2.13.0.dist-info/METADATA +351 -0
- python_pptx2-2.13.0.dist-info/RECORD +175 -0
- python_pptx2-2.13.0.dist-info/WHEEL +5 -0
- python_pptx2-2.13.0.dist-info/entry_points.txt +3 -0
- python_pptx2-2.13.0.dist-info/licenses/LICENSE +22 -0
- python_pptx2-2.13.0.dist-info/top_level.txt +1 -0
pptx2/render.py
ADDED
|
@@ -0,0 +1,586 @@
|
|
|
1
|
+
"""Slide thumbnail rendering via a headless LibreOffice/soffice shell-out.
|
|
2
|
+
|
|
3
|
+
PowerPoint's own renderer is the only pixel-perfect option for a deck;
|
|
4
|
+
since this library deliberately runs without PowerPoint, the next-best
|
|
5
|
+
practical option is to drive LibreOffice in headless mode. That's what
|
|
6
|
+
:func:`render_slide_thumbnails` (and the convenience methods on
|
|
7
|
+
:class:`~pptx2.api.Presentation` and ``Slide``) do: save the deck to a
|
|
8
|
+
temporary file, ask ``soffice --headless --convert-to png`` to render
|
|
9
|
+
each slide, and return the resulting paths (or PNG bytes).
|
|
10
|
+
|
|
11
|
+
This is an *optional* feature with no hard dependency: callers must have
|
|
12
|
+
``soffice`` (LibreOffice) on ``PATH``. When it isn't available the
|
|
13
|
+
functions raise :class:`ThumbnailRendererUnavailable` with an actionable
|
|
14
|
+
hint so the failure mode is obvious.
|
|
15
|
+
|
|
16
|
+
Two rendering strategies are supported, tried in order:
|
|
17
|
+
|
|
18
|
+
1. ``soffice --convert-to png`` — fast, single subprocess, but stock
|
|
19
|
+
LibreOffice 7+ only emits the *first* slide of a multi-slide deck
|
|
20
|
+
when targeting PNG directly. We accept this output only when the
|
|
21
|
+
number of PNGs produced matches the slide count.
|
|
22
|
+
|
|
23
|
+
2. ``soffice --convert-to pdf`` followed by per-page PDF→PNG split —
|
|
24
|
+
reliable across LibreOffice versions because the PDF export always
|
|
25
|
+
includes every slide. The split prefers ``pdftoppm`` (Poppler,
|
|
26
|
+
ubiquitous on Linux/macOS), then ``pypdfium2`` if installed.
|
|
27
|
+
|
|
28
|
+
Callers can force a specific strategy with ``strategy="png"`` or
|
|
29
|
+
``strategy="pdf"``; the default ``"auto"`` tries PNG first and falls
|
|
30
|
+
back to PDF on a slide-count mismatch.
|
|
31
|
+
|
|
32
|
+
The shell-out is deliberately quarantined to a single small module so
|
|
33
|
+
the rest of the library never depends on subprocess or LibreOffice.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
from __future__ import annotations
|
|
37
|
+
|
|
38
|
+
import os
|
|
39
|
+
import re
|
|
40
|
+
import shutil
|
|
41
|
+
import subprocess
|
|
42
|
+
import tempfile
|
|
43
|
+
from pathlib import Path
|
|
44
|
+
from typing import IO, TYPE_CHECKING, Iterable, List, Optional, Sequence, Union
|
|
45
|
+
|
|
46
|
+
if TYPE_CHECKING:
|
|
47
|
+
from pptx2.api import Presentation as _Presentation
|
|
48
|
+
from pptx2.slide import Slide as _Slide
|
|
49
|
+
|
|
50
|
+
DEFAULT_SOFFICE_BIN = "soffice"
|
|
51
|
+
DEFAULT_TIMEOUT_SECONDS = 120
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class ThumbnailRendererUnavailable(RuntimeError):
|
|
55
|
+
"""Raised when LibreOffice/soffice is not available on PATH.
|
|
56
|
+
|
|
57
|
+
The message includes an install hint so callers can route users to
|
|
58
|
+
a working configuration without grepping documentation.
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class ThumbnailRendererError(RuntimeError):
|
|
63
|
+
"""Raised when LibreOffice runs but produces no output (or errors)."""
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _resolve_binary(binary: Optional[str]) -> str:
|
|
67
|
+
candidate = binary or os.environ.get("POWER_PPTX_SOFFICE") or DEFAULT_SOFFICE_BIN
|
|
68
|
+
resolved = shutil.which(candidate)
|
|
69
|
+
if resolved is None:
|
|
70
|
+
raise ThumbnailRendererUnavailable(
|
|
71
|
+
"could not locate %r on PATH; install LibreOffice (provides the "
|
|
72
|
+
"`soffice` binary) or set POWER_PPTX_SOFFICE to the absolute path "
|
|
73
|
+
"of a compatible binary." % candidate
|
|
74
|
+
)
|
|
75
|
+
return resolved
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _save_to_path(prs, path: Path) -> None:
|
|
79
|
+
prs.save(str(path))
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _run_soffice(
|
|
83
|
+
soffice_bin: str,
|
|
84
|
+
deck_path: Path,
|
|
85
|
+
out_dir: Path,
|
|
86
|
+
timeout: int,
|
|
87
|
+
) -> subprocess.CompletedProcess:
|
|
88
|
+
"""Convert *deck_path* to PNG using ``soffice --convert-to png``.
|
|
89
|
+
|
|
90
|
+
Stock LibreOffice 7+ writes only the first slide for a multi-slide
|
|
91
|
+
deck through this filter; older builds (and a handful of forks) write
|
|
92
|
+
one PNG per slide. Callers are responsible for verifying the output
|
|
93
|
+
count matches the slide count and falling back to the PDF path when
|
|
94
|
+
it doesn't.
|
|
95
|
+
"""
|
|
96
|
+
cmd = [
|
|
97
|
+
soffice_bin,
|
|
98
|
+
"--headless",
|
|
99
|
+
"--norestore",
|
|
100
|
+
"--nologo",
|
|
101
|
+
"--nofirststartwizard",
|
|
102
|
+
"--convert-to",
|
|
103
|
+
"png",
|
|
104
|
+
"--outdir",
|
|
105
|
+
str(out_dir),
|
|
106
|
+
str(deck_path),
|
|
107
|
+
]
|
|
108
|
+
return subprocess.run(
|
|
109
|
+
cmd,
|
|
110
|
+
capture_output=True,
|
|
111
|
+
check=False,
|
|
112
|
+
timeout=timeout,
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _run_soffice_pdf(
|
|
117
|
+
soffice_bin: str,
|
|
118
|
+
deck_path: Path,
|
|
119
|
+
out_dir: Path,
|
|
120
|
+
timeout: int,
|
|
121
|
+
) -> subprocess.CompletedProcess:
|
|
122
|
+
"""Convert *deck_path* to PDF using ``soffice --convert-to pdf``.
|
|
123
|
+
|
|
124
|
+
Unlike the PNG filter, the PDF export reliably contains every slide
|
|
125
|
+
on every LibreOffice version we've tested, which makes it the
|
|
126
|
+
authoritative source for per-slide thumbnails.
|
|
127
|
+
"""
|
|
128
|
+
cmd = [
|
|
129
|
+
soffice_bin,
|
|
130
|
+
"--headless",
|
|
131
|
+
"--norestore",
|
|
132
|
+
"--nologo",
|
|
133
|
+
"--nofirststartwizard",
|
|
134
|
+
"--convert-to",
|
|
135
|
+
"pdf",
|
|
136
|
+
"--outdir",
|
|
137
|
+
str(out_dir),
|
|
138
|
+
str(deck_path),
|
|
139
|
+
]
|
|
140
|
+
return subprocess.run(
|
|
141
|
+
cmd,
|
|
142
|
+
capture_output=True,
|
|
143
|
+
check=False,
|
|
144
|
+
timeout=timeout,
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def render_slide_thumbnails(
|
|
149
|
+
prs: "_Presentation",
|
|
150
|
+
*,
|
|
151
|
+
out_dir: Optional[Union[str, os.PathLike[str]]] = None,
|
|
152
|
+
slide_indexes: Optional[Sequence[int]] = None,
|
|
153
|
+
soffice_bin: Optional[str] = None,
|
|
154
|
+
timeout: int = DEFAULT_TIMEOUT_SECONDS,
|
|
155
|
+
return_bytes: bool = False,
|
|
156
|
+
strategy: str = "auto",
|
|
157
|
+
dpi: int = 150,
|
|
158
|
+
) -> Union[List[Path], List[bytes]]:
|
|
159
|
+
"""Render slide thumbnails for `prs` via headless LibreOffice.
|
|
160
|
+
|
|
161
|
+
`out_dir` is the directory to write PNGs into; if ``None``, a
|
|
162
|
+
temporary directory is used and the returned paths point inside it
|
|
163
|
+
(the caller is responsible for cleanup). When ``return_bytes=True``
|
|
164
|
+
the function reads each PNG into memory and returns ``bytes``
|
|
165
|
+
objects instead, deleting the temporary directory before returning.
|
|
166
|
+
|
|
167
|
+
`slide_indexes` is a 0-based list of slide indexes to return; when
|
|
168
|
+
``None``, all slides are returned in deck order.
|
|
169
|
+
|
|
170
|
+
`strategy` controls which LibreOffice export pipeline is used:
|
|
171
|
+
|
|
172
|
+
* ``"auto"`` (default) — try ``--convert-to png`` first; if it emits
|
|
173
|
+
fewer PNGs than the slide count (typical of stock LibreOffice 7+,
|
|
174
|
+
which only writes the first slide), fall back to PDF + per-page
|
|
175
|
+
split.
|
|
176
|
+
* ``"png"`` — only the PNG path; raises :class:`ThumbnailRendererError`
|
|
177
|
+
when LibreOffice produces fewer than one PNG per slide.
|
|
178
|
+
* ``"pdf"`` — skip the PNG path entirely and always go through PDF +
|
|
179
|
+
per-page split.
|
|
180
|
+
|
|
181
|
+
`dpi` controls the PDF→PNG raster resolution (150 DPI by default —
|
|
182
|
+
a reasonable balance between fidelity and file size). Ignored on
|
|
183
|
+
the PNG-only path.
|
|
184
|
+
|
|
185
|
+
Raises :class:`ThumbnailRendererUnavailable` when ``soffice`` cannot
|
|
186
|
+
be located, and :class:`ThumbnailRendererError` when the conversion
|
|
187
|
+
completes with no PNG output (typically a corrupted deck or a
|
|
188
|
+
LibreOffice version that doesn't ship the PNG filter).
|
|
189
|
+
"""
|
|
190
|
+
if strategy not in ("auto", "png", "pdf"):
|
|
191
|
+
raise ValueError(
|
|
192
|
+
f"strategy must be 'auto', 'png', or 'pdf'; got {strategy!r}"
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
bin_path = _resolve_binary(soffice_bin)
|
|
196
|
+
|
|
197
|
+
cleanup_tmp = out_dir is None
|
|
198
|
+
work_dir = Path(out_dir) if out_dir is not None else Path(tempfile.mkdtemp(prefix="pptx-thumbs-"))
|
|
199
|
+
work_dir.mkdir(parents=True, exist_ok=True)
|
|
200
|
+
|
|
201
|
+
expected_slide_count = len(list(prs.slides))
|
|
202
|
+
|
|
203
|
+
try:
|
|
204
|
+
deck_path = work_dir / "_render_input.pptx"
|
|
205
|
+
_save_to_path(prs, deck_path)
|
|
206
|
+
|
|
207
|
+
png_paths: List[Path] = []
|
|
208
|
+
|
|
209
|
+
if strategy in ("auto", "png"):
|
|
210
|
+
png_paths = _render_via_png(
|
|
211
|
+
bin_path, deck_path, work_dir, timeout
|
|
212
|
+
)
|
|
213
|
+
if strategy == "png" and len(png_paths) < expected_slide_count:
|
|
214
|
+
raise ThumbnailRendererError(
|
|
215
|
+
"soffice --convert-to png emitted %d PNG(s) for a "
|
|
216
|
+
"%d-slide deck. Most LibreOffice 7+ builds only write "
|
|
217
|
+
"the first slide via the PNG filter; pass "
|
|
218
|
+
"strategy='auto' or 'pdf' to use the PDF-split fallback."
|
|
219
|
+
% (len(png_paths), expected_slide_count)
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
# Auto-fallback: PNG path didn't produce one image per slide.
|
|
223
|
+
if strategy == "pdf" or (
|
|
224
|
+
strategy == "auto" and len(png_paths) < expected_slide_count
|
|
225
|
+
):
|
|
226
|
+
# Clean up partial PNG output from the auto-mode first attempt
|
|
227
|
+
# so we don't confuse the slide-index lookup.
|
|
228
|
+
for stale in png_paths:
|
|
229
|
+
try:
|
|
230
|
+
stale.unlink()
|
|
231
|
+
except OSError:
|
|
232
|
+
pass
|
|
233
|
+
png_paths = _render_via_pdf(
|
|
234
|
+
bin_path, deck_path, work_dir, timeout, dpi=dpi
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
if not png_paths:
|
|
238
|
+
raise ThumbnailRendererError(
|
|
239
|
+
"no PNG output produced by either the PNG or PDF rendering "
|
|
240
|
+
"pipeline. Verify LibreOffice can convert this deck "
|
|
241
|
+
"(`soffice --convert-to pdf <deck>.pptx`); the PDF-split "
|
|
242
|
+
"fallback also requires `pdftoppm` (Poppler) or `pypdfium2`."
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
if slide_indexes is not None:
|
|
246
|
+
wanted = list(slide_indexes)
|
|
247
|
+
png_paths = _select_indexes(png_paths, wanted)
|
|
248
|
+
|
|
249
|
+
if return_bytes:
|
|
250
|
+
data = [p.read_bytes() for p in png_paths]
|
|
251
|
+
return data
|
|
252
|
+
return list(png_paths)
|
|
253
|
+
finally:
|
|
254
|
+
if cleanup_tmp and return_bytes:
|
|
255
|
+
shutil.rmtree(work_dir, ignore_errors=True)
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def _render_via_png(
|
|
259
|
+
bin_path: str, deck_path: Path, work_dir: Path, timeout: int
|
|
260
|
+
) -> List[Path]:
|
|
261
|
+
"""Run the soffice PNG filter and return the produced PNG paths."""
|
|
262
|
+
# Snapshot any PNGs already in `work_dir` so we can later subtract
|
|
263
|
+
# them from the result set. Otherwise, when a caller points
|
|
264
|
+
# `out_dir=` at a non-empty directory (a shared artifacts folder, a
|
|
265
|
+
# cache, …) stray PNGs get treated as slide renders and corrupt
|
|
266
|
+
# `slide_indexes` lookups / out-of-range errors.
|
|
267
|
+
preexisting_pngs = {p.name for p in work_dir.glob("*.png")}
|
|
268
|
+
|
|
269
|
+
result = _run_soffice(bin_path, deck_path, work_dir, timeout)
|
|
270
|
+
if result.returncode != 0:
|
|
271
|
+
raise ThumbnailRendererError(
|
|
272
|
+
"soffice exited with status %d: %s"
|
|
273
|
+
% (result.returncode, (result.stderr or b"").decode("utf-8", "replace"))
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
png_paths = sorted(
|
|
277
|
+
(
|
|
278
|
+
p
|
|
279
|
+
for p in work_dir.glob("*.png")
|
|
280
|
+
if p.name != deck_path.name and p.name not in preexisting_pngs
|
|
281
|
+
),
|
|
282
|
+
key=_natural_sort_key,
|
|
283
|
+
)
|
|
284
|
+
return png_paths
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def _render_via_pdf(
|
|
288
|
+
bin_path: str, deck_path: Path, work_dir: Path, timeout: int, *, dpi: int
|
|
289
|
+
) -> List[Path]:
|
|
290
|
+
"""Run the soffice PDF filter, then split each PDF page into a PNG.
|
|
291
|
+
|
|
292
|
+
Splits prefer the ``pdftoppm`` binary (Poppler) since it's a single
|
|
293
|
+
subprocess and ubiquitous on Linux/macOS; ``pypdfium2`` is the
|
|
294
|
+
pure-Python fallback when callers can't depend on Poppler.
|
|
295
|
+
"""
|
|
296
|
+
# Snapshot existing PDFs so a stale one in a shared work_dir doesn't
|
|
297
|
+
# get treated as our output.
|
|
298
|
+
preexisting_pdfs = {p.name for p in work_dir.glob("*.pdf")}
|
|
299
|
+
|
|
300
|
+
result = _run_soffice_pdf(bin_path, deck_path, work_dir, timeout)
|
|
301
|
+
if result.returncode != 0:
|
|
302
|
+
raise ThumbnailRendererError(
|
|
303
|
+
"soffice --convert-to pdf exited with status %d: %s"
|
|
304
|
+
% (result.returncode, (result.stderr or b"").decode("utf-8", "replace"))
|
|
305
|
+
)
|
|
306
|
+
|
|
307
|
+
pdfs = [
|
|
308
|
+
p for p in work_dir.glob("*.pdf")
|
|
309
|
+
if p.name not in preexisting_pdfs
|
|
310
|
+
]
|
|
311
|
+
if not pdfs:
|
|
312
|
+
raise ThumbnailRendererError(
|
|
313
|
+
"soffice --convert-to pdf produced no PDF output; "
|
|
314
|
+
"ensure your LibreOffice build includes the PDF export filter."
|
|
315
|
+
)
|
|
316
|
+
|
|
317
|
+
pdf_path = pdfs[0]
|
|
318
|
+
try:
|
|
319
|
+
return _pdf_to_pngs(pdf_path, work_dir, dpi=dpi)
|
|
320
|
+
finally:
|
|
321
|
+
try:
|
|
322
|
+
pdf_path.unlink()
|
|
323
|
+
except OSError:
|
|
324
|
+
pass
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def _pdf_to_pngs(pdf_path: Path, out_dir: Path, *, dpi: int) -> List[Path]:
|
|
328
|
+
"""Split a PDF into one PNG per page in *out_dir* and return the paths.
|
|
329
|
+
|
|
330
|
+
Tries ``pdftoppm`` first (Poppler), then ``pypdfium2``. Raises
|
|
331
|
+
:class:`ThumbnailRendererError` with an install hint when neither is
|
|
332
|
+
available — the message names both options so the user can pick
|
|
333
|
+
whichever fits their environment.
|
|
334
|
+
"""
|
|
335
|
+
pdftoppm = shutil.which("pdftoppm")
|
|
336
|
+
if pdftoppm is not None:
|
|
337
|
+
return _pdf_to_pngs_via_pdftoppm(pdftoppm, pdf_path, out_dir, dpi=dpi)
|
|
338
|
+
|
|
339
|
+
try:
|
|
340
|
+
import pypdfium2 # type: ignore[import-not-found] # noqa: F401
|
|
341
|
+
except ImportError:
|
|
342
|
+
raise ThumbnailRendererError(
|
|
343
|
+
"PDF-split fallback needs either `pdftoppm` (install Poppler: "
|
|
344
|
+
"`apt install poppler-utils` / `brew install poppler`) or the "
|
|
345
|
+
"`pypdfium2` Python package (`pip install pypdfium2`); neither "
|
|
346
|
+
"is available."
|
|
347
|
+
)
|
|
348
|
+
return _pdf_to_pngs_via_pypdfium2(pdf_path, out_dir, dpi=dpi)
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def _pdf_to_pngs_via_pdftoppm(
|
|
352
|
+
pdftoppm: str, pdf_path: Path, out_dir: Path, *, dpi: int
|
|
353
|
+
) -> List[Path]:
|
|
354
|
+
prefix = pdf_path.stem + "-page"
|
|
355
|
+
cmd = [
|
|
356
|
+
pdftoppm,
|
|
357
|
+
"-png",
|
|
358
|
+
"-r",
|
|
359
|
+
str(int(dpi)),
|
|
360
|
+
str(pdf_path),
|
|
361
|
+
str(out_dir / prefix),
|
|
362
|
+
]
|
|
363
|
+
result = subprocess.run(cmd, capture_output=True, check=False)
|
|
364
|
+
if result.returncode != 0:
|
|
365
|
+
raise ThumbnailRendererError(
|
|
366
|
+
"pdftoppm exited with status %d: %s"
|
|
367
|
+
% (result.returncode, (result.stderr or b"").decode("utf-8", "replace"))
|
|
368
|
+
)
|
|
369
|
+
pages = sorted(
|
|
370
|
+
out_dir.glob(prefix + "-*.png"), key=_natural_sort_key
|
|
371
|
+
)
|
|
372
|
+
return pages
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def _pdf_to_pngs_via_pypdfium2(
|
|
376
|
+
pdf_path: Path, out_dir: Path, *, dpi: int
|
|
377
|
+
) -> List[Path]:
|
|
378
|
+
import pypdfium2 as pdfium # type: ignore[import-not-found]
|
|
379
|
+
|
|
380
|
+
scale = float(dpi) / 72.0 # pypdfium2 uses 1.0 == 72 DPI
|
|
381
|
+
pdf = pdfium.PdfDocument(str(pdf_path))
|
|
382
|
+
try:
|
|
383
|
+
pages: List[Path] = []
|
|
384
|
+
prefix = pdf_path.stem + "-page"
|
|
385
|
+
for i in range(len(pdf)):
|
|
386
|
+
page = pdf[i]
|
|
387
|
+
try:
|
|
388
|
+
bitmap = page.render(scale=scale)
|
|
389
|
+
pil_image = bitmap.to_pil()
|
|
390
|
+
# 1-based numbering matches pdftoppm's convention so the
|
|
391
|
+
# natural-sort key produces the same order.
|
|
392
|
+
target = out_dir / f"{prefix}-{i + 1}.png"
|
|
393
|
+
pil_image.save(str(target), format="PNG")
|
|
394
|
+
pages.append(target)
|
|
395
|
+
finally:
|
|
396
|
+
# pypdfium2 page handles need explicit close to avoid
|
|
397
|
+
# holding the underlying PDF mapping open.
|
|
398
|
+
page.close()
|
|
399
|
+
return pages
|
|
400
|
+
finally:
|
|
401
|
+
pdf.close()
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
_NATURAL_SORT_RE = re.compile(r"(\d+)")
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def _natural_sort_key(path: Path):
|
|
408
|
+
"""Return a sort key that treats embedded digit runs as integers.
|
|
409
|
+
|
|
410
|
+
LibreOffice writes one PNG per slide with the slide index appended to
|
|
411
|
+
the basename — e.g. ``deck-1.png``, ``deck-2.png``, …, ``deck-10.png``.
|
|
412
|
+
Plain lexicographic sorting puts ``deck-10.png`` before ``deck-2.png``,
|
|
413
|
+
which silently scrambles ``slide_indexes=`` lookups for any deck with
|
|
414
|
+
ten or more slides. Splitting the name into alternating
|
|
415
|
+
text / int chunks gives the human-intuitive ordering.
|
|
416
|
+
"""
|
|
417
|
+
parts = _NATURAL_SORT_RE.split(path.name)
|
|
418
|
+
return tuple((int(p) if p.isdigit() else p) for p in parts)
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def _select_indexes(paths: Sequence[Path], indexes: Iterable[int]) -> List[Path]:
|
|
422
|
+
selected = []
|
|
423
|
+
for i in indexes:
|
|
424
|
+
if i < 0 or i >= len(paths):
|
|
425
|
+
raise IndexError(
|
|
426
|
+
"slide index %d out of range for deck with %d slides"
|
|
427
|
+
% (i, len(paths))
|
|
428
|
+
)
|
|
429
|
+
selected.append(paths[i])
|
|
430
|
+
return selected
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def render_slide_thumbnail(
|
|
434
|
+
slide: "_Slide",
|
|
435
|
+
*,
|
|
436
|
+
out_path: Optional[Union[str, os.PathLike[str]]] = None,
|
|
437
|
+
soffice_bin: Optional[str] = None,
|
|
438
|
+
timeout: int = DEFAULT_TIMEOUT_SECONDS,
|
|
439
|
+
return_bytes: bool = False,
|
|
440
|
+
strategy: str = "auto",
|
|
441
|
+
dpi: int = 150,
|
|
442
|
+
) -> Union[Path, bytes]:
|
|
443
|
+
"""Render a single slide to PNG.
|
|
444
|
+
|
|
445
|
+
The slide must belong to a :class:`Presentation` whose ``save()``
|
|
446
|
+
will produce a complete deck on disk. Internally this calls
|
|
447
|
+
:func:`render_slide_thumbnails` against a private temporary
|
|
448
|
+
directory; that directory is always cleaned up before this
|
|
449
|
+
function returns, regardless of which return mode is selected:
|
|
450
|
+
|
|
451
|
+
* ``return_bytes=True`` — returns PNG ``bytes``; temp dir removed.
|
|
452
|
+
* ``out_path=...`` — returns the destination ``Path``; temp dir removed.
|
|
453
|
+
* neither — returns a stable ``Path`` to a small
|
|
454
|
+
``NamedTemporaryFile`` PNG (``delete=False``). The bigger temp
|
|
455
|
+
directory holding the saved deck is cleaned up; the caller owns
|
|
456
|
+
cleanup of the returned PNG file.
|
|
457
|
+
|
|
458
|
+
*strategy* and *dpi* mirror the same arguments on
|
|
459
|
+
:func:`render_slide_thumbnails`.
|
|
460
|
+
"""
|
|
461
|
+
prs = _presentation_for(slide)
|
|
462
|
+
idx = list(prs.slides).index(slide)
|
|
463
|
+
|
|
464
|
+
if return_bytes:
|
|
465
|
+
# `render_slide_thumbnails` cleans up its own temp dir when
|
|
466
|
+
# `return_bytes=True`, so no extra wrapping is needed here.
|
|
467
|
+
data = render_slide_thumbnails(
|
|
468
|
+
prs,
|
|
469
|
+
slide_indexes=[idx],
|
|
470
|
+
soffice_bin=soffice_bin,
|
|
471
|
+
timeout=timeout,
|
|
472
|
+
return_bytes=True,
|
|
473
|
+
strategy=strategy,
|
|
474
|
+
dpi=dpi,
|
|
475
|
+
)
|
|
476
|
+
return data[0]
|
|
477
|
+
|
|
478
|
+
# Otherwise, control the temp dir ourselves so we can copy the PNG
|
|
479
|
+
# out and remove the directory (which also holds the saved deck).
|
|
480
|
+
with tempfile.TemporaryDirectory(prefix="pptx-thumb-") as tmp:
|
|
481
|
+
paths = render_slide_thumbnails(
|
|
482
|
+
prs,
|
|
483
|
+
slide_indexes=[idx],
|
|
484
|
+
out_dir=tmp,
|
|
485
|
+
soffice_bin=soffice_bin,
|
|
486
|
+
timeout=timeout,
|
|
487
|
+
strategy=strategy,
|
|
488
|
+
dpi=dpi,
|
|
489
|
+
)
|
|
490
|
+
src = paths[0]
|
|
491
|
+
if out_path is not None:
|
|
492
|
+
target = Path(out_path)
|
|
493
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
494
|
+
shutil.copyfile(src, target)
|
|
495
|
+
return target
|
|
496
|
+
# No destination given: persist the single PNG to a stable
|
|
497
|
+
# tempfile so the returned path remains valid after the
|
|
498
|
+
# `TemporaryDirectory` context exits.
|
|
499
|
+
fd, persistent = tempfile.mkstemp(prefix="pptx-thumb-", suffix=".png")
|
|
500
|
+
os.close(fd)
|
|
501
|
+
shutil.copyfile(src, persistent)
|
|
502
|
+
return Path(persistent)
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
def render_slides(
|
|
506
|
+
prs: "_Presentation",
|
|
507
|
+
*,
|
|
508
|
+
out_dir: Optional[Union[str, os.PathLike[str]]] = None,
|
|
509
|
+
slides: Optional[Sequence[int]] = None,
|
|
510
|
+
name_template: Optional[str] = None,
|
|
511
|
+
soffice_bin: Optional[str] = None,
|
|
512
|
+
timeout: int = DEFAULT_TIMEOUT_SECONDS,
|
|
513
|
+
return_bytes: bool = False,
|
|
514
|
+
strategy: str = "auto",
|
|
515
|
+
dpi: int = 150,
|
|
516
|
+
scale: Optional[float] = None,
|
|
517
|
+
) -> Union[List[Path], List[bytes]]:
|
|
518
|
+
"""Render slide thumbnails — friendlier wrapper around :func:`render_slide_thumbnails`.
|
|
519
|
+
|
|
520
|
+
Same semantics as :func:`render_slide_thumbnails` but with two
|
|
521
|
+
quality-of-life additions:
|
|
522
|
+
|
|
523
|
+
* ``slides=`` (cleaner name than ``slide_indexes=``).
|
|
524
|
+
* ``name_template=`` — a ``str.format``-able template like
|
|
525
|
+
``"slide-{:02d}.png"`` applied to the rendered PNGs. Index is
|
|
526
|
+
0-based. Defaults to LibreOffice's own ``"_render_input-page-NN"``
|
|
527
|
+
output if omitted.
|
|
528
|
+
* ``scale=`` is accepted and translated to ``dpi=`` (``scale=0.5``
|
|
529
|
+
≙ ``dpi=72``).
|
|
530
|
+
"""
|
|
531
|
+
if scale is not None:
|
|
532
|
+
dpi = max(int(72 * float(scale)), 36)
|
|
533
|
+
# Validate name_template *before* rendering — a template without a
|
|
534
|
+
# format placeholder would rename every slide to the same filename
|
|
535
|
+
# and silently overwrite all but the last PNG.
|
|
536
|
+
if name_template is not None and not return_bytes:
|
|
537
|
+
try:
|
|
538
|
+
sample_a = name_template.format(0)
|
|
539
|
+
sample_b = name_template.format(1)
|
|
540
|
+
except (IndexError, ValueError, KeyError) as exc:
|
|
541
|
+
raise ValueError(
|
|
542
|
+
f"name_template {name_template!r} is not a valid str.format "
|
|
543
|
+
"template (expected one positional placeholder like "
|
|
544
|
+
"'slide-{:02d}.png'): " + str(exc)
|
|
545
|
+
) from exc
|
|
546
|
+
if sample_a == sample_b:
|
|
547
|
+
raise ValueError(
|
|
548
|
+
f"name_template {name_template!r} produces the same filename "
|
|
549
|
+
"for every slide — include a positional placeholder such as "
|
|
550
|
+
"'slide-{:02d}.png' so each PNG gets a unique name."
|
|
551
|
+
)
|
|
552
|
+
paths = render_slide_thumbnails(
|
|
553
|
+
prs,
|
|
554
|
+
out_dir=out_dir,
|
|
555
|
+
slide_indexes=slides,
|
|
556
|
+
soffice_bin=soffice_bin,
|
|
557
|
+
timeout=timeout,
|
|
558
|
+
return_bytes=return_bytes,
|
|
559
|
+
strategy=strategy,
|
|
560
|
+
dpi=dpi,
|
|
561
|
+
)
|
|
562
|
+
if return_bytes or name_template is None:
|
|
563
|
+
return paths
|
|
564
|
+
# ``paths`` here is List[Path].
|
|
565
|
+
renamed: list[Path] = []
|
|
566
|
+
if slides is None:
|
|
567
|
+
index_iter: list[int] = list(range(len(paths)))
|
|
568
|
+
else:
|
|
569
|
+
index_iter = list(slides)
|
|
570
|
+
for idx, p in zip(index_iter, paths):
|
|
571
|
+
new_name = name_template.format(idx)
|
|
572
|
+
target = p.parent / new_name
|
|
573
|
+
if p != target:
|
|
574
|
+
shutil.move(str(p), str(target))
|
|
575
|
+
renamed.append(target)
|
|
576
|
+
return renamed
|
|
577
|
+
|
|
578
|
+
|
|
579
|
+
def _presentation_for(slide: "_Slide") -> "_Presentation":
|
|
580
|
+
"""Walk back from a Slide to its owning Presentation.
|
|
581
|
+
|
|
582
|
+
``Slide.part.package.presentation_part.presentation`` is the canonical
|
|
583
|
+
accessor; we go through ``part`` to avoid importing Presentation here
|
|
584
|
+
(would cause a circular import on `pptx2.api`).
|
|
585
|
+
"""
|
|
586
|
+
return slide.part.package.presentation_part.presentation
|