pykeynote 0.2.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.
pykeynote/__init__.py ADDED
@@ -0,0 +1,15 @@
1
+ """Create, read and edit Apple Keynote presentations from Python.
2
+
3
+ :class:`Presentation` reads ``.key`` files directly, so Keynote isn't needed.
4
+ The older :mod:`pykeynote.scripting` module drives a running copy of Keynote
5
+ on a Mac instead, and will go once the file-based engine can do everything
6
+ it does.
7
+ """
8
+
9
+ from ._osa import KeynoteError
10
+ from .document import Presentation, Slide, Transition, themes
11
+ from .markdown import from_markdown, parse_markdown
12
+ from .text import Paragraph, UnsupportedTextError
13
+
14
+ __all__ = ["KeynoteError", "Paragraph", "Presentation", "Slide", "Transition", "UnsupportedTextError", "from_markdown", "parse_markdown", "themes"]
15
+ __version__ = "0.2.0"
pykeynote/_osa.py ADDED
@@ -0,0 +1,56 @@
1
+ """Low-level bridge to Keynote through ``osascript``.
2
+
3
+ Values are never pasted into script source. JXA scripts get their arguments
4
+ as one JSON string in ``argv`` and return JSON. AppleScript scripts get plain
5
+ string arguments through ``on run argv``.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import subprocess
12
+ from typing import Any, Sequence
13
+
14
+
15
+ class KeynoteError(RuntimeError):
16
+ """Raised when Keynote or osascript reports an error."""
17
+
18
+
19
+ _JXA_WRAPPER = """
20
+ function run(argv) {
21
+ const Keynote = Application("Keynote");
22
+ const args = JSON.parse(argv[0]);
23
+ const result = main(Keynote, args);
24
+ return JSON.stringify(result === undefined ? null : result);
25
+ }
26
+ """
27
+
28
+
29
+ def _execute(language: str, script: str, argv: Sequence[str]) -> str:
30
+ """Run a script with osascript and return its stdout. Tests replace this."""
31
+ try:
32
+ proc = subprocess.run(
33
+ ["osascript", "-l", language, "-", *argv],
34
+ input=script,
35
+ capture_output=True,
36
+ text=True,
37
+ )
38
+ except FileNotFoundError as exc:
39
+ raise KeynoteError(
40
+ "osascript was not found. pykeynote needs macOS with Keynote installed."
41
+ ) from exc
42
+ if proc.returncode != 0:
43
+ raise KeynoteError(proc.stderr.strip() or f"osascript exited with {proc.returncode}")
44
+ return proc.stdout
45
+
46
+
47
+ def jxa(script: str, **args: Any) -> Any:
48
+ """Run a JXA script that defines ``function main(Keynote, args)``."""
49
+ out = _execute("JavaScript", script + _JXA_WRAPPER, [json.dumps(args)])
50
+ out = out.strip()
51
+ return json.loads(out) if out else None
52
+
53
+
54
+ def applescript(script: str, *args: str) -> str:
55
+ """Run an AppleScript with an ``on run argv`` handler and return its output."""
56
+ return _execute("AppleScript", script, [str(a) for a in args]).strip()
pykeynote/document.py ADDED
@@ -0,0 +1,420 @@
1
+ """Read Keynote presentations straight from the .key file, without Keynote."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import re
7
+ from dataclasses import dataclass
8
+ from typing import Any, Iterable, List, Optional, Tuple, Union
9
+
10
+ from . import slides
11
+ from .package import Package, PathLike
12
+ from .text import Paragraph, ParagraphInput, Text
13
+
14
+ @dataclass(frozen=True)
15
+ class Transition:
16
+ """A slide's transition. ``effect`` is ``None`` when there is no transition."""
17
+
18
+ effect: Optional[str]
19
+ duration: float
20
+ delay: float
21
+ automatic: bool
22
+ effect_id: str # Keynote's own name for the effect, e.g. "apple:dissolve"
23
+
24
+
25
+ def _effect_name(effect_id: str) -> Optional[str]:
26
+ if effect_id in ("", "none"):
27
+ return None
28
+ name = effect_id.split(":", 1)[-1]
29
+ if name.startswith("magic-move"):
30
+ return "magic move"
31
+ return name.replace("-", " ")
32
+
33
+
34
+ def _effect_id(name: Optional[str]) -> str:
35
+ if name is None or name.strip().lower() in ("", "none", "no transition"):
36
+ return "none"
37
+ if ":" in name:
38
+ return name
39
+ key = name.strip().lower().replace("_", " ").replace("-", " ")
40
+ if key == "magic move":
41
+ return "apple:magic-move-implied-motion-path"
42
+ return "apple:" + key.replace(" ", "-")
43
+
44
+
45
+ def _theme_name(identifier: str) -> str:
46
+ """Turn an identifier such as ``"21_BasicWhite"`` into ``"Basic White"``."""
47
+ name = re.sub(r"^\d+_", "", identifier)
48
+ return re.sub(r"(?<=[a-z])(?=[A-Z])", " ", name)
49
+
50
+
51
+ class Slide:
52
+ """One slide, read from the file."""
53
+
54
+ def __init__(self, presentation: "Presentation", node_ref: Any, index: int):
55
+ self.presentation = presentation
56
+ self.index = index
57
+ self._package = presentation.package
58
+ self._node_id = node_ref.identifier
59
+ self._node = self._package.deref(node_ref)
60
+ self._slide_id = self._node.slide.identifier
61
+ self._slide = self._package.get(self._slide_id)
62
+
63
+ def __repr__(self) -> str:
64
+ return f"<Slide {self.index} {self.layout!r} title={self.title!r}>"
65
+
66
+ def _placeholder_text(self, field: str) -> Optional[Text]:
67
+ if not self._slide.HasField(field):
68
+ return None
69
+ ref = getattr(self._slide, field)
70
+ # A placeholder is shown only when it is part of the slide's drawables.
71
+ if ref.identifier not in {r.identifier for r in self._slide.drawables_z_order}:
72
+ return None
73
+ placeholder = self._package.deref(ref)
74
+ return Text(self._package, placeholder.super.owned_storage.identifier)
75
+
76
+ def _set_placeholder_text(self, field: str, value: Union[str, Iterable[ParagraphInput]]) -> None:
77
+ text = self._placeholder_text(field)
78
+ if text is None:
79
+ what = "title" if field == "titlePlaceholder" else "body"
80
+ raise ValueError(f"the {self.layout!r} layout doesn't show a {what} on slide {self.index}")
81
+ text.set_paragraphs(value.split("\n") if isinstance(value, str) else value)
82
+ self._touched()
83
+
84
+ def _touched(self) -> None:
85
+ # Keynote redraws thumbnails flagged as dirty when it opens the file.
86
+ if not self._node.thumbnailsAreDirty:
87
+ self._node.thumbnailsAreDirty = True
88
+ self._package.mark_changed(self._node_id)
89
+
90
+ @property
91
+ def title_text(self) -> Optional[Text]:
92
+ return self._placeholder_text("titlePlaceholder")
93
+
94
+ @property
95
+ def body_text(self) -> Optional[Text]:
96
+ return self._placeholder_text("bodyPlaceholder")
97
+
98
+ @property
99
+ def title(self) -> Optional[str]:
100
+ """Title text, or ``None`` if this slide doesn't show a title."""
101
+ text = self.title_text
102
+ return None if text is None else str(text)
103
+
104
+ @title.setter
105
+ def title(self, value: str) -> None:
106
+ self._set_placeholder_text("titlePlaceholder", value)
107
+
108
+ @property
109
+ def body(self) -> Optional[str]:
110
+ """Body text with one line per paragraph, or ``None`` if this slide doesn't show a body.
111
+
112
+ Assign a string with one line per paragraph, or a list of strings or
113
+ :class:`Paragraph` objects to set indent levels too.
114
+ """
115
+ text = self.body_text
116
+ return None if text is None else str(text)
117
+
118
+ @body.setter
119
+ def body(self, value: Union[str, Iterable[ParagraphInput]]) -> None:
120
+ self._set_placeholder_text("bodyPlaceholder", value)
121
+
122
+ @property
123
+ def paragraphs(self) -> List[Paragraph]:
124
+ """Body paragraphs with their indent level and whether they have a bullet."""
125
+ text = self.body_text
126
+ return [] if text is None else text.paragraphs
127
+
128
+ def _notes_text(self) -> Optional[Text]:
129
+ if not self._slide.HasField("note"):
130
+ return None
131
+ note = self._package.deref(self._slide.note)
132
+ return Text(self._package, note.containedStorage.identifier)
133
+
134
+ @property
135
+ def notes(self) -> str:
136
+ text = self._notes_text()
137
+ return "" if text is None else str(text)
138
+
139
+ @notes.setter
140
+ def notes(self, value: str) -> None:
141
+ text = self._notes_text()
142
+ if text is None:
143
+ raise ValueError(f"slide {self.index} has no notes storage")
144
+ text.set_paragraphs(value.split("\n"))
145
+
146
+ @property
147
+ def layout(self) -> str:
148
+ """Name of the master slide this slide is based on."""
149
+ return self._package.deref(self._slide.template_slide).name
150
+
151
+ @property
152
+ def skipped(self) -> bool:
153
+ return bool(self._node.isSkipped)
154
+
155
+ @property
156
+ def transition(self) -> Transition:
157
+ a = self._slide.transition.attributes.animationAttributes
158
+ return Transition(
159
+ effect=_effect_name(a.effect),
160
+ duration=a.duration,
161
+ delay=a.delay,
162
+ automatic=a.is_automatic,
163
+ effect_id=a.effect,
164
+ )
165
+
166
+ @skipped.setter
167
+ def skipped(self, value: bool) -> None:
168
+ self._node.isSkipped = bool(value)
169
+ self._package.mark_changed(self._node_id)
170
+
171
+ def set_transition(
172
+ self,
173
+ effect: Optional[str],
174
+ duration: Optional[float] = None,
175
+ delay: Optional[float] = None,
176
+ automatic: Optional[bool] = None,
177
+ ) -> None:
178
+ """Set the transition into the next slide, e.g. ``set_transition("dissolve", 1.5)``.
179
+
180
+ ``effect`` is a name such as ``"dissolve"``, ``"push"`` or ``"magic
181
+ move"``, Keynote's own id such as ``"apple:dissolve"``, or ``None`` to
182
+ remove the transition. Dissolve, push and magic move are checked
183
+ against real Keynote files; other names follow the same pattern.
184
+ """
185
+ attrs = self._slide.transition.attributes
186
+ a = attrs.animationAttributes
187
+ effect_id = _effect_id(effect)
188
+ a.effect = effect_id
189
+ for field in ("custom_magic_move_fade_unmatched_objects", "custom_timing_curve", "custom_text_delivery_type"):
190
+ attrs.ClearField(field)
191
+ if effect_id.startswith("apple:magic-move"):
192
+ # What Keynote 14.5 writes for a default Magic Move.
193
+ attrs.custom_magic_move_fade_unmatched_objects = True
194
+ attrs.custom_timing_curve = attrs.TransitionCustomAttributesTimingCurveTypeEaseInEaseOut
195
+ attrs.custom_text_delivery_type = attrs.TransitionCustomAttributesTextDeliveryTypeByObject
196
+ if duration is not None:
197
+ a.duration = float(duration)
198
+ if delay is not None:
199
+ a.delay = float(delay)
200
+ if automatic is not None:
201
+ a.is_automatic = bool(automatic)
202
+ self._package.mark_changed(self._slide_id)
203
+ self._node.hasTransition = effect_id != "none"
204
+ self._package.mark_changed(self._node_id)
205
+
206
+ @property
207
+ def has_builds(self) -> bool:
208
+ return len(self._slide.builds) > 0
209
+
210
+ def to_dict(self) -> dict:
211
+ t = self.transition
212
+ return {
213
+ "index": self.index,
214
+ "layout": self.layout,
215
+ "title": self.title,
216
+ "body": self.body,
217
+ "notes": self.notes,
218
+ "skipped": self.skipped,
219
+ "transition": {"effect": t.effect, "duration": t.duration, "delay": t.delay, "automatic": t.automatic},
220
+ }
221
+
222
+
223
+ TEMPLATES_DIR = os.path.join(os.path.dirname(__file__), "templates")
224
+
225
+
226
+ def _template_file(theme: str) -> str:
227
+ return theme.strip().lower().replace(" ", "-") + ".key"
228
+
229
+
230
+ def themes() -> List[str]:
231
+ """Names of the themes bundled with pykeynote, for :meth:`Presentation.new`."""
232
+ names = sorted(f[:-4] for f in os.listdir(TEMPLATES_DIR) if f.endswith(".key"))
233
+ return [n.replace("-", " ").title() for n in names]
234
+
235
+
236
+ def _reparse(package: Package) -> Package:
237
+ import io
238
+ import zipfile
239
+
240
+ with zipfile.ZipFile(io.BytesIO(package.to_bytes())) as z:
241
+ return Package({n: z.read(n) for n in z.namelist() if not n.endswith("/")})
242
+
243
+
244
+ class Presentation:
245
+ """A Keynote presentation read from a ``.key`` file. Keynote isn't needed."""
246
+
247
+ def __init__(self, package: Package, path: Optional[str] = None):
248
+ self.package = package
249
+ self.path = path
250
+ self._show_id = package.get(1).show.identifier
251
+ self._show = package.get(self._show_id)
252
+ # Slides kept only as models for add_slide(); hidden, and left out on save.
253
+ self._prototypes: set = set()
254
+
255
+ @classmethod
256
+ def open(cls, path: PathLike) -> "Presentation":
257
+ full = os.path.abspath(os.fspath(path))
258
+ return cls(Package.open(full), full)
259
+
260
+ @classmethod
261
+ def new(cls, theme: str = "Basic White") -> "Presentation":
262
+ """A new, empty presentation in one of the bundled themes (see :func:`themes`)."""
263
+ path = os.path.join(TEMPLATES_DIR, _template_file(theme))
264
+ if not os.path.exists(path):
265
+ raise ValueError(f"no bundled theme {theme!r}; choose from {', '.join(themes())}")
266
+ pres = cls(Package.open(path))
267
+ pres._prototypes = set(pres._node_ids(include_prototypes=True))
268
+ return pres
269
+
270
+ def _node_ids(self, include_prototypes: bool = False) -> List[int]:
271
+ ids = [ref.identifier for ref in self._show.slideTree.slides]
272
+ return ids if include_prototypes else [i for i in ids if i not in self._prototypes]
273
+
274
+ def _tree_index(self, index: int) -> int:
275
+ """Position in the full slide tree of the visible slide at ``index``."""
276
+ visible = self._node_ids()
277
+ return self._node_ids(include_prototypes=True).index(visible[index])
278
+
279
+ def __repr__(self) -> str:
280
+ return f"<Presentation {os.path.basename(self.path or '')!r} slides={len(self)}>"
281
+
282
+ @property
283
+ def slides(self) -> List[Slide]:
284
+ refs = [r for r in self._show.slideTree.slides if r.identifier not in self._prototypes]
285
+ return [Slide(self, ref, i) for i, ref in enumerate(refs)]
286
+
287
+ def __len__(self) -> int:
288
+ return len(self._node_ids())
289
+
290
+ # --- changing the slide list ---------------------------------------------
291
+
292
+ def _check_index(self, index: int, allow_end: bool = False) -> int:
293
+ count = len(self)
294
+ if index < 0:
295
+ index += count + (1 if allow_end else 0)
296
+ if not 0 <= index < count + (1 if allow_end else 0):
297
+ raise IndexError(f"slide index {index} out of range for {count} slides")
298
+ return index
299
+
300
+ def move_slide(self, old: int, new: int) -> None:
301
+ """Move the slide at ``old`` so it ends up at ``new``."""
302
+ old, new = self._check_index(old), self._check_index(new)
303
+ visible = self._node_ids()
304
+ node = visible.pop(old)
305
+ visible.insert(new, node)
306
+ full = self._node_ids(include_prototypes=True)
307
+ prototypes = [i for i in full if i in self._prototypes]
308
+ slides.set_tree(self.package, self._show_id, prototypes + visible)
309
+
310
+ def delete_slide(self, index: int) -> None:
311
+ index = self._check_index(index)
312
+ slides.delete_slide(self.package, self._show_id, self._tree_index(index))
313
+
314
+ def duplicate_slide(self, index: int, to: Optional[int] = None) -> Slide:
315
+ """Copy the slide at ``index``. The copy goes right after it unless ``to`` is given."""
316
+ index = self._check_index(index)
317
+ to = index + 1 if to is None else self._check_index(to, allow_end=True)
318
+ return self._copy_node(self._node_ids()[index], to)
319
+
320
+ def _copy_node(self, node_id: int, to: int) -> Slide:
321
+ full = self._node_ids(include_prototypes=True)
322
+ visible = self._node_ids()
323
+ tree_to = len(full) if to >= len(visible) else full.index(visible[to])
324
+ slides.duplicate_slide(self.package, self._show_id, full.index(node_id), tree_to)
325
+ return self.slides[to]
326
+
327
+ def add_slide(
328
+ self,
329
+ layout: str = "Title & Bullets",
330
+ title: Optional[str] = None,
331
+ body: Union[None, str, Iterable[ParagraphInput]] = None,
332
+ notes: Optional[str] = None,
333
+ index: Optional[int] = None,
334
+ ) -> Slide:
335
+ """Add a slide with the given layout, at the end unless ``index`` is given.
336
+
337
+ The new slide is copied from an existing slide with that layout, so
338
+ it keeps that slide's look. Presentations made with :meth:`new` have
339
+ one of every layout to copy from. For other presentations, at least
340
+ one slide must already use the layout.
341
+ """
342
+ to = len(self) if index is None else self._check_index(index, allow_end=True)
343
+ source = self._find_layout(layout)
344
+ slide = self._copy_node(source, to)
345
+ if slide.title is not None:
346
+ slide.title = title or ""
347
+ if slide.body is not None:
348
+ slide.body = body if body is not None else ""
349
+ if notes is not None or slide.notes:
350
+ slide.notes = notes or ""
351
+ if title is not None and slide.title is None:
352
+ raise ValueError(f"the {layout!r} layout doesn't show a title")
353
+ if body is not None and slide.body is None:
354
+ raise ValueError(f"the {layout!r} layout doesn't show a body")
355
+ return slide
356
+
357
+ def _find_layout(self, layout: str) -> int:
358
+ p = self.package
359
+
360
+ def layout_of(node_id: int) -> str:
361
+ slide = p.deref(p.get(node_id).slide)
362
+ return p.deref(slide.template_slide).name
363
+
364
+ candidates = [n for n in self._prototypes if layout_of(n) == layout]
365
+ candidates += [n for n in self._node_ids() if layout_of(n) == layout]
366
+ if not candidates:
367
+ raise ValueError(
368
+ f"no slide uses the {layout!r} layout to copy from; layouts here: {', '.join(self.layouts)}"
369
+ )
370
+ return candidates[0]
371
+
372
+ def __getitem__(self, index: int) -> Slide:
373
+ return self.slides[index]
374
+
375
+ def __iter__(self):
376
+ return iter(self.slides)
377
+
378
+ @property
379
+ def size(self) -> Tuple[int, int]:
380
+ """Slide size in points, ``(width, height)``."""
381
+ return int(self._show.size.width), int(self._show.size.height)
382
+
383
+ @property
384
+ def theme(self) -> str:
385
+ """Theme name, e.g. ``"Basic White"``."""
386
+ return _theme_name(self._show_theme.super.theme_identifier)
387
+
388
+ @property
389
+ def _show_theme(self) -> Any:
390
+ return self.package.deref(self._show.theme)
391
+
392
+ @property
393
+ def layouts(self) -> List[str]:
394
+ """Names of the master slides in this presentation's theme, in order."""
395
+ p = self.package
396
+ return [p.deref(p.deref(ref).slide).name for ref in self._show_theme.templates]
397
+
398
+ def to_markdown(self) -> str:
399
+ from .markdown import to_markdown
400
+
401
+ return to_markdown(self)
402
+
403
+ def save(self, path: Optional[PathLike] = None) -> None:
404
+ """Write the presentation as a ``.key`` file, to ``path`` or back where it came from."""
405
+ target = path if path is not None else self.path
406
+ if target is None:
407
+ raise ValueError("no path to save to")
408
+ if not self._node_ids():
409
+ raise ValueError("a presentation needs at least one slide")
410
+ package = self.package
411
+ if self._prototypes:
412
+ # Save a copy without the model slides, so this object can keep adding slides.
413
+ package = _reparse(package)
414
+ show_id = package.get(1).show.identifier
415
+ for node_id in [r.identifier for r in package.get(show_id).slideTree.slides]:
416
+ if node_id in self._prototypes:
417
+ tree = [r.identifier for r in package.get(show_id).slideTree.slides]
418
+ slides.delete_slide(package, show_id, tree.index(node_id))
419
+ package.save(target)
420
+ self.path = os.path.abspath(os.fspath(target))