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.
Files changed (175) hide show
  1. pptx2/__init__.py +152 -0
  2. pptx2/_color.py +75 -0
  3. pptx2/_slide_importer.py +597 -0
  4. pptx2/_svg.py +155 -0
  5. pptx2/_template_applier.py +292 -0
  6. pptx2/_textstyle.py +187 -0
  7. pptx2/accessibility.py +365 -0
  8. pptx2/action.py +270 -0
  9. pptx2/animation.py +2237 -0
  10. pptx2/api.py +49 -0
  11. pptx2/audit.py +258 -0
  12. pptx2/chart/__init__.py +0 -0
  13. pptx2/chart/analytics.py +381 -0
  14. pptx2/chart/axis.py +543 -0
  15. pptx2/chart/category.py +200 -0
  16. pptx2/chart/chart.py +670 -0
  17. pptx2/chart/data.py +864 -0
  18. pptx2/chart/datalabel.py +406 -0
  19. pptx2/chart/legend.py +86 -0
  20. pptx2/chart/marker.py +70 -0
  21. pptx2/chart/palettes.py +129 -0
  22. pptx2/chart/plot.py +462 -0
  23. pptx2/chart/point.py +101 -0
  24. pptx2/chart/quick_layouts.py +325 -0
  25. pptx2/chart/series.py +334 -0
  26. pptx2/chart/xlsx.py +272 -0
  27. pptx2/chart/xmlwriter.py +1845 -0
  28. pptx2/compose/__init__.py +28 -0
  29. pptx2/compose/from_spec.py +1094 -0
  30. pptx2/design/__init__.py +8 -0
  31. pptx2/design/components.py +607 -0
  32. pptx2/design/figures.py +389 -0
  33. pptx2/design/layout.py +370 -0
  34. pptx2/design/recipes.py +1967 -0
  35. pptx2/design/style.py +209 -0
  36. pptx2/design/tokens.py +915 -0
  37. pptx2/diagrams.py +754 -0
  38. pptx2/dml/__init__.py +0 -0
  39. pptx2/dml/chtfmt.py +40 -0
  40. pptx2/dml/color.py +496 -0
  41. pptx2/dml/effect.py +909 -0
  42. pptx2/dml/fill.py +691 -0
  43. pptx2/dml/line.py +287 -0
  44. pptx2/dml/picture.py +212 -0
  45. pptx2/dml/three_d.py +381 -0
  46. pptx2/enum/__init__.py +0 -0
  47. pptx2/enum/action.py +71 -0
  48. pptx2/enum/animation.py +31 -0
  49. pptx2/enum/base.py +218 -0
  50. pptx2/enum/chart.py +574 -0
  51. pptx2/enum/dml.py +740 -0
  52. pptx2/enum/lang.py +685 -0
  53. pptx2/enum/presentation.py +133 -0
  54. pptx2/enum/shapes.py +1029 -0
  55. pptx2/enum/text.py +230 -0
  56. pptx2/exc.py +42 -0
  57. pptx2/formats.py +139 -0
  58. pptx2/geometry.py +420 -0
  59. pptx2/inherit.py +109 -0
  60. pptx2/lint.py +2256 -0
  61. pptx2/math.py +177 -0
  62. pptx2/media.py +197 -0
  63. pptx2/opc/__init__.py +0 -0
  64. pptx2/opc/constants.py +332 -0
  65. pptx2/opc/oxml.py +188 -0
  66. pptx2/opc/package.py +762 -0
  67. pptx2/opc/packuri.py +109 -0
  68. pptx2/opc/serialized.py +296 -0
  69. pptx2/opc/shared.py +20 -0
  70. pptx2/opc/spec.py +45 -0
  71. pptx2/oxml/__init__.py +555 -0
  72. pptx2/oxml/action.py +53 -0
  73. pptx2/oxml/chart/__init__.py +0 -0
  74. pptx2/oxml/chart/axis.py +337 -0
  75. pptx2/oxml/chart/chart.py +481 -0
  76. pptx2/oxml/chart/datalabel.py +253 -0
  77. pptx2/oxml/chart/legend.py +72 -0
  78. pptx2/oxml/chart/marker.py +61 -0
  79. pptx2/oxml/chart/plot.py +365 -0
  80. pptx2/oxml/chart/series.py +425 -0
  81. pptx2/oxml/chart/shared.py +220 -0
  82. pptx2/oxml/coreprops.py +288 -0
  83. pptx2/oxml/dml/__init__.py +0 -0
  84. pptx2/oxml/dml/color.py +135 -0
  85. pptx2/oxml/dml/effect.py +213 -0
  86. pptx2/oxml/dml/fill.py +316 -0
  87. pptx2/oxml/dml/line.py +12 -0
  88. pptx2/oxml/dml/three_d.py +110 -0
  89. pptx2/oxml/ns.py +135 -0
  90. pptx2/oxml/presentation.py +313 -0
  91. pptx2/oxml/shapes/__init__.py +19 -0
  92. pptx2/oxml/shapes/autoshape.py +467 -0
  93. pptx2/oxml/shapes/connector.py +107 -0
  94. pptx2/oxml/shapes/graphfrm.py +347 -0
  95. pptx2/oxml/shapes/groupshape.py +329 -0
  96. pptx2/oxml/shapes/picture.py +270 -0
  97. pptx2/oxml/shapes/shared.py +577 -0
  98. pptx2/oxml/simpletypes.py +1027 -0
  99. pptx2/oxml/slide.py +563 -0
  100. pptx2/oxml/table.py +650 -0
  101. pptx2/oxml/text.py +815 -0
  102. pptx2/oxml/theme.py +36 -0
  103. pptx2/oxml/xmlchemy.py +717 -0
  104. pptx2/package.py +222 -0
  105. pptx2/parts/__init__.py +0 -0
  106. pptx2/parts/chart.py +95 -0
  107. pptx2/parts/coreprops.py +167 -0
  108. pptx2/parts/diagram.py +37 -0
  109. pptx2/parts/embeddedpackage.py +93 -0
  110. pptx2/parts/image.py +275 -0
  111. pptx2/parts/media.py +37 -0
  112. pptx2/parts/presentation.py +136 -0
  113. pptx2/parts/slide.py +371 -0
  114. pptx2/presentation.py +408 -0
  115. pptx2/py.typed +0 -0
  116. pptx2/render.py +586 -0
  117. pptx2/section.py +272 -0
  118. pptx2/shapes/__init__.py +26 -0
  119. pptx2/shapes/autoshape.py +442 -0
  120. pptx2/shapes/base.py +1078 -0
  121. pptx2/shapes/connector.py +297 -0
  122. pptx2/shapes/freeform.py +337 -0
  123. pptx2/shapes/graphfrm.py +316 -0
  124. pptx2/shapes/group.py +264 -0
  125. pptx2/shapes/picture.py +422 -0
  126. pptx2/shapes/placeholder.py +468 -0
  127. pptx2/shapes/shapetree.py +2027 -0
  128. pptx2/shared.py +82 -0
  129. pptx2/skill/SKILL.md +450 -0
  130. pptx2/skill/__init__.py +78 -0
  131. pptx2/skill/__main__.py +64 -0
  132. pptx2/skill/references/animations.md +189 -0
  133. pptx2/skill/references/basics.md +421 -0
  134. pptx2/skill/references/charts.md +254 -0
  135. pptx2/skill/references/compose.md +234 -0
  136. pptx2/skill/references/design.md +366 -0
  137. pptx2/skill/references/effects.md +249 -0
  138. pptx2/skill/references/end-to-end-deck.md +231 -0
  139. pptx2/skill/references/geometry-and-arrows.md +334 -0
  140. pptx2/skill/references/lint.md +275 -0
  141. pptx2/skill/references/math.md +86 -0
  142. pptx2/skill/references/picture-effects.md +129 -0
  143. pptx2/skill/references/render.md +151 -0
  144. pptx2/skill/references/smart-art.md +75 -0
  145. pptx2/skill/references/space-aware-authoring.md +249 -0
  146. pptx2/skill/references/tables.md +244 -0
  147. pptx2/skill/references/theme.md +127 -0
  148. pptx2/skill/references/three-d.md +109 -0
  149. pptx2/skill/references/transitions.md +100 -0
  150. pptx2/slide.py +1244 -0
  151. pptx2/smart_art.py +220 -0
  152. pptx2/spec.py +633 -0
  153. pptx2/table.py +1181 -0
  154. pptx2/table_styles.py +184 -0
  155. pptx2/templates/default.pptx +0 -0
  156. pptx2/templates/docx-icon.emf +0 -0
  157. pptx2/templates/generic-icon.emf +0 -0
  158. pptx2/templates/notes.xml +23 -0
  159. pptx2/templates/notesMaster.xml +352 -0
  160. pptx2/templates/pptx-icon.emf +0 -0
  161. pptx2/templates/theme.xml +321 -0
  162. pptx2/templates/xlsx-icon.emf +0 -0
  163. pptx2/text/__init__.py +0 -0
  164. pptx2/text/fonts.py +482 -0
  165. pptx2/text/layout.py +374 -0
  166. pptx2/text/text.py +1272 -0
  167. pptx2/theme.py +721 -0
  168. pptx2/types.py +36 -0
  169. pptx2/util.py +263 -0
  170. python_pptx2-2.13.0.dist-info/METADATA +351 -0
  171. python_pptx2-2.13.0.dist-info/RECORD +175 -0
  172. python_pptx2-2.13.0.dist-info/WHEEL +5 -0
  173. python_pptx2-2.13.0.dist-info/entry_points.txt +3 -0
  174. python_pptx2-2.13.0.dist-info/licenses/LICENSE +22 -0
  175. 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