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 +15 -0
- pykeynote/_osa.py +56 -0
- pykeynote/document.py +420 -0
- pykeynote/markdown.py +213 -0
- pykeynote/package.py +275 -0
- pykeynote/scripting.py +491 -0
- pykeynote/slides.py +188 -0
- pykeynote/templates/basic-white.key +0 -0
- pykeynote/text.py +232 -0
- pykeynote/transitions.py +78 -0
- pykeynote-0.2.0.dist-info/METADATA +134 -0
- pykeynote-0.2.0.dist-info/RECORD +14 -0
- pykeynote-0.2.0.dist-info/WHEEL +4 -0
- pykeynote-0.2.0.dist-info/licenses/LICENSE +21 -0
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))
|