pptlive 0.1.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.
pptlive/__init__.py ADDED
@@ -0,0 +1,89 @@
1
+ """pptlive — drive a running Microsoft PowerPoint instance from Python.
2
+
3
+ xlwings, but for PowerPoint, and built for LLM agents. The live-app sibling of
4
+ `python-pptx` (which works the file on disk) and the PowerPoint sibling of
5
+ `wordlive`.
6
+
7
+ Quick start:
8
+
9
+ import pptlive as pl
10
+
11
+ with pl.attach() as ppt:
12
+ deck = ppt.presentations.active
13
+ with deck.edit("Set the agenda"): # preserves the viewed slide
14
+ deck.anchor_by_id("ph:2:title").set_text("Agenda")
15
+ deck.anchor_by_id("ph:2:body").set_text("Intro\\nDemo\\nQ&A")
16
+
17
+ Note: `edit()` preserves the user's view and selection *and* is an atomic-undo
18
+ scope — PowerPoint groups a block's edits into a single Ctrl-Z (fenced with
19
+ `StartNewUndoEntry`). See `_edit.EditScope` for the mechanism and caveats.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ from . import constants, units
25
+ from ._anchors import Anchor, Notes, Paragraph, ParagraphCollection
26
+ from ._app import PowerPoint, attach, connect
27
+ from ._charts import Chart
28
+ from ._edit import EditScope
29
+ from ._presentation import Presentation, PresentationCollection
30
+ from ._selection import SelectionInfo, SelectionSnapshot
31
+ from ._shapes import PlaceholderShape, Shape, ShapeCollection
32
+ from ._show import SlideShow
33
+ from ._slides import Slide, SlideCollection
34
+ from ._smartart import SmartArt
35
+ from ._tables import Cell, Table
36
+ from ._theme import Master, Theme
37
+ from .exceptions import (
38
+ AmbiguousMatchError,
39
+ AnchorNotFoundError,
40
+ ComError,
41
+ LayoutNotFoundError,
42
+ NoTextFrameError,
43
+ PowerPointBusyError,
44
+ PowerPointNotRunningError,
45
+ PptliveError,
46
+ PresentationNotFoundError,
47
+ SlideNotFoundError,
48
+ SlideShowNotRunningError,
49
+ )
50
+
51
+ __all__ = [
52
+ "AmbiguousMatchError",
53
+ "Anchor",
54
+ "AnchorNotFoundError",
55
+ "Cell",
56
+ "Chart",
57
+ "ComError",
58
+ "EditScope",
59
+ "LayoutNotFoundError",
60
+ "Master",
61
+ "NoTextFrameError",
62
+ "Notes",
63
+ "Paragraph",
64
+ "ParagraphCollection",
65
+ "PlaceholderShape",
66
+ "PowerPoint",
67
+ "PowerPointBusyError",
68
+ "PowerPointNotRunningError",
69
+ "Presentation",
70
+ "PresentationCollection",
71
+ "PresentationNotFoundError",
72
+ "PptliveError",
73
+ "SelectionInfo",
74
+ "SelectionSnapshot",
75
+ "Shape",
76
+ "ShapeCollection",
77
+ "Slide",
78
+ "SlideCollection",
79
+ "SlideNotFoundError",
80
+ "SlideShow",
81
+ "SlideShowNotRunningError",
82
+ "SmartArt",
83
+ "Table",
84
+ "Theme",
85
+ "attach",
86
+ "connect",
87
+ "constants",
88
+ "units",
89
+ ]
pptlive/_anchors.py ADDED
@@ -0,0 +1,484 @@
1
+ """Anchor types — semantic, text-bearing handles inside a presentation.
2
+
3
+ The PowerPoint anchor model is *hierarchical* (slide → shape → paragraph), not a
4
+ global character stream, so there is no deck-wide `range:` and offsets are only
5
+ meaningful within one shape's text frame (see spec.md §"The anchor model"). An
6
+ anchor targets a COM `TextRange`, never the live `Selection`: text is set through
7
+ `TextFrame.TextRange.Text` directly, so no edit needs to select anything.
8
+
9
+ This module holds the abstract `Anchor` base, the `Notes` anchor, and (v0.3) the
10
+ `Paragraph` anchor (`para:S:N:P`) over one paragraph of a shape's text frame.
11
+ `Shape` — which *is* an `Anchor` when it has a text frame — lives in `_shapes.py`
12
+ because it also carries geometry. `Cell` arrives in v0.4.
13
+
14
+ The text-structure verbs (`format_text`, `format_paragraph`, `apply_list`,
15
+ `remove_list`, `insert_paragraph_before/after`) live on the base `Anchor` and act
16
+ on `self._text_range()`, so they work on a whole shape's text *and* on a single
17
+ `Paragraph` — PowerPoint has no named paragraph styles (the Word `apply_style`
18
+ analog), so styling is direct font formatting via `format_text`.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ from abc import ABC, abstractmethod
24
+ from collections.abc import Iterator
25
+ from typing import TYPE_CHECKING, Any
26
+
27
+ from . import _com
28
+ from .constants import (
29
+ MsoTriState,
30
+ PpPlaceholderType,
31
+ alignment_for,
32
+ bullet_type_for,
33
+ bullet_type_name,
34
+ is_true,
35
+ parse_color,
36
+ )
37
+ from .exceptions import AnchorNotFoundError
38
+
39
+ if TYPE_CHECKING:
40
+ from ._shapes import Shape
41
+ from ._slides import Slide
42
+
43
+
44
+ def _tristate(value: bool) -> int:
45
+ """Python bool -> `MsoTriState` int (`msoTrue` / `msoFalse`)."""
46
+ return int(MsoTriState.TRUE) if value else int(MsoTriState.FALSE)
47
+
48
+
49
+ def _bullet_char(character: str | int) -> int:
50
+ """A single-char string or an int code point -> the int `Bullet.Character`."""
51
+ if isinstance(character, str):
52
+ if len(character) != 1:
53
+ raise ValueError(f"bullet character must be a single character, got {character!r}")
54
+ return ord(character)
55
+ if isinstance(character, bool) or not isinstance(character, int):
56
+ raise TypeError("bullet character must be a single-char str or an int code point")
57
+ return int(character)
58
+
59
+
60
+ def apply_font(
61
+ f: Any,
62
+ *,
63
+ bold: bool | None = None,
64
+ italic: bool | None = None,
65
+ underline: bool | None = None,
66
+ size: float | None = None,
67
+ font: str | None = None,
68
+ color: str | int | tuple[int, int, int] | None = None,
69
+ ) -> None:
70
+ """Write font properties onto a COM `Font` object — only the kwargs passed.
71
+
72
+ Shared by `Anchor.format_text` (a text range's `.Font`) and the master text
73
+ styles (`Master.format_text_style`, a `TextStyles(t).Levels(n).Font`), so both
74
+ surfaces format fonts identically. `size` is points; `color` is `"#RRGGBB"`,
75
+ an `(r, g, b)` tuple, or a raw RGB int. Caller wraps this in
76
+ `translate_com_errors()`.
77
+ """
78
+ if bold is not None:
79
+ f.Bold = _tristate(bold)
80
+ if italic is not None:
81
+ f.Italic = _tristate(italic)
82
+ if underline is not None:
83
+ f.Underline = _tristate(underline)
84
+ if size is not None:
85
+ f.Size = float(size)
86
+ if font is not None:
87
+ f.Name = str(font)
88
+ if color is not None:
89
+ f.Color.RGB = parse_color(color)
90
+
91
+
92
+ def apply_paragraph_format(
93
+ pf: Any,
94
+ *,
95
+ alignment: int | None = None,
96
+ space_before: float | None = None,
97
+ space_after: float | None = None,
98
+ line_spacing: float | None = None,
99
+ ) -> None:
100
+ """Write paragraph properties onto a COM `ParagraphFormat` object.
101
+
102
+ Shared by `Anchor.format_paragraph` and `Master.format_paragraph_style`.
103
+ `alignment` is the resolved int (caller coerces a name first);
104
+ `space_before`/`space_after` are points; `line_spacing` is the line-spacing
105
+ multiple (`SpaceWithin`). Indent level is *not* handled here — it lives on the
106
+ `TextRange`, not `ParagraphFormat`, so `Anchor.format_paragraph` sets it
107
+ separately. Caller wraps this in `translate_com_errors()`.
108
+ """
109
+ if alignment is not None:
110
+ pf.Alignment = alignment
111
+ if space_before is not None:
112
+ pf.SpaceBefore = float(space_before)
113
+ if space_after is not None:
114
+ pf.SpaceAfter = float(space_after)
115
+ if line_spacing is not None:
116
+ pf.SpaceWithin = float(line_spacing)
117
+
118
+
119
+ class Anchor(ABC):
120
+ """Abstract base for text-bearing handles.
121
+
122
+ Concrete subclasses implement `_text_range()` (the COM `TextRange` to read
123
+ and write) and `anchor_id`. `text` / `set_text` are derived and inherited.
124
+ """
125
+
126
+ kind: str = "anchor"
127
+
128
+ @property
129
+ def com(self) -> Any:
130
+ """Raw COM object for this anchor — the `TextRange` it targets.
131
+
132
+ `Shape` overrides this to return the raw `Shape` instead (the more useful
133
+ escape hatch for a shape), exposing its text range via `text`/`set_text`.
134
+ """
135
+ return self._text_range()
136
+
137
+ @abstractmethod
138
+ def _text_range(self) -> Any:
139
+ """Return the COM `TextRange` this anchor reads/writes. Must be overridden."""
140
+
141
+ @property
142
+ @abstractmethod
143
+ def anchor_id(self) -> str:
144
+ """Stable string identifier (e.g. `notes:3`, `shape:2:1`, `ph:2:body`)."""
145
+
146
+ @property
147
+ def name(self) -> str:
148
+ """A display name for this anchor. Defaults to its `anchor_id`."""
149
+ return self.anchor_id
150
+
151
+ @property
152
+ def text(self) -> str:
153
+ """The anchor's plain text. PowerPoint separates paragraphs with `\\r`."""
154
+ with _com.translate_com_errors():
155
+ return str(self._text_range().Text or "")
156
+
157
+ def set_text(self, text: str) -> None:
158
+ """Replace the anchor's text in place.
159
+
160
+ Embed `\\n` (or `\\r`) for multiple paragraphs — PowerPoint treats them
161
+ as paragraph breaks. Targets the text range directly, never the
162
+ Selection, so it doesn't move the user's view. Wrap in `deck.edit(...)`
163
+ to preserve the viewed slide and collapse the block to one Ctrl-Z
164
+ (see `EditScope`).
165
+ """
166
+ with _com.translate_com_errors():
167
+ self._text_range().Text = text
168
+
169
+ # -- text structure (v0.3) -------------------------------------------------
170
+ #
171
+ # These act on `self._text_range()`, so on a whole-shape anchor they apply to
172
+ # all its paragraphs and on a `Paragraph` to just that one. Wrap in
173
+ # `deck.edit(...)` for view preservation + a one-Ctrl-Z fence.
174
+
175
+ def paragraph_count(self) -> int:
176
+ """Number of paragraphs in this anchor's text range."""
177
+ with _com.translate_com_errors():
178
+ return int(self._text_range().Paragraphs().Count)
179
+
180
+ def format_text(
181
+ self,
182
+ *,
183
+ bold: bool | None = None,
184
+ italic: bool | None = None,
185
+ underline: bool | None = None,
186
+ size: float | None = None,
187
+ font: str | None = None,
188
+ color: str | int | tuple[int, int, int] | None = None,
189
+ ) -> None:
190
+ """Set font formatting on this anchor's text (PowerPoint's `apply_style`).
191
+
192
+ PowerPoint has no named paragraph styles, so styling is direct font
193
+ formatting. Only the kwargs you pass are written. `size` is in points;
194
+ `color` is `"#RRGGBB"`, an `(r, g, b)` tuple, or a raw RGB int.
195
+ """
196
+ if color is not None:
197
+ parse_color(color) # validate before any COM
198
+ with _com.translate_com_errors():
199
+ apply_font(
200
+ self._text_range().Font,
201
+ bold=bold,
202
+ italic=italic,
203
+ underline=underline,
204
+ size=size,
205
+ font=font,
206
+ color=color,
207
+ )
208
+
209
+ def format_paragraph(
210
+ self,
211
+ *,
212
+ alignment: str | int | None = None,
213
+ space_before: float | None = None,
214
+ space_after: float | None = None,
215
+ line_spacing: float | None = None,
216
+ indent_level: int | None = None,
217
+ ) -> None:
218
+ """Set paragraph formatting on this anchor's paragraphs.
219
+
220
+ Only the kwargs you pass are written. `alignment` is a name
221
+ (`"left"`/`"center"`/`"right"`/`"justify"`/`"distribute"`) or int.
222
+ `space_before`/`space_after` are in points; `line_spacing` is a multiple
223
+ (`1.0` single, `1.5`, …). `indent_level` is PowerPoint's outline/bullet
224
+ level, 1-5 (its only notion of paragraph indent — there is no points-based
225
+ left indent on `ParagraphFormat`).
226
+ """
227
+ align_int = alignment_for(alignment) if alignment is not None else None
228
+ if indent_level is not None and not (1 <= int(indent_level) <= 5):
229
+ raise ValueError(f"indent_level must be between 1 and 5, got {indent_level}")
230
+ with _com.translate_com_errors():
231
+ tr = self._text_range()
232
+ apply_paragraph_format(
233
+ tr.ParagraphFormat,
234
+ alignment=align_int,
235
+ space_before=space_before,
236
+ space_after=space_after,
237
+ line_spacing=line_spacing,
238
+ )
239
+ if indent_level is not None:
240
+ tr.IndentLevel = int(indent_level)
241
+
242
+ def apply_list(
243
+ self, list_type: str = "bulleted", *, character: str | int | None = None
244
+ ) -> None:
245
+ """Turn this anchor's paragraphs into a bulleted or numbered list.
246
+
247
+ `list_type` is `"bulleted"` (default) or `"numbered"`. `character` (a
248
+ single char or int code point) sets a custom bullet glyph — only
249
+ meaningful for a bulleted list. Raises `ValueError` for an unknown
250
+ `list_type`.
251
+ """
252
+ bt = bullet_type_for(list_type) # ValueError before any COM
253
+ char_int = _bullet_char(character) if character is not None else None
254
+ with _com.translate_com_errors():
255
+ bullet = self._text_range().ParagraphFormat.Bullet
256
+ bullet.Visible = _tristate(True)
257
+ bullet.Type = int(bt)
258
+ if char_int is not None:
259
+ bullet.Character = char_int
260
+
261
+ def remove_list(self) -> None:
262
+ """Strip bullets / numbering from this anchor's paragraphs."""
263
+ with _com.translate_com_errors():
264
+ self._text_range().ParagraphFormat.Bullet.Visible = _tristate(False)
265
+
266
+ def insert_paragraph_before(self, text: str) -> None:
267
+ """Insert `text` as a new paragraph immediately before this anchor's range.
268
+
269
+ On a whole-shape anchor this prepends a first paragraph; on a `Paragraph`
270
+ it inserts just above that paragraph.
271
+ """
272
+ with _com.translate_com_errors():
273
+ tr = self._text_range()
274
+ if str(tr.Text or "") == "":
275
+ tr.Text = text
276
+ else:
277
+ tr.InsertBefore(text + "\r")
278
+
279
+ def insert_paragraph_after(self, text: str) -> None:
280
+ """Insert `text` as a new paragraph immediately after this anchor's range.
281
+
282
+ On a whole-shape anchor this appends a paragraph (the common "add a
283
+ bullet to the body" case); on a `Paragraph` it inserts just below it. The
284
+ range includes its trailing break for a non-final paragraph, so we detect
285
+ that to land a clean new paragraph either way (verified in the spike).
286
+ """
287
+ with _com.translate_com_errors():
288
+ tr = self._text_range()
289
+ raw = str(tr.Text or "")
290
+ if raw == "":
291
+ tr.InsertAfter(text)
292
+ elif raw.endswith("\r"):
293
+ tr.InsertAfter(text + "\r")
294
+ else:
295
+ tr.InsertAfter("\r" + text)
296
+
297
+ def __repr__(self) -> str:
298
+ return f"<{type(self).__name__} {self.anchor_id!r}>"
299
+
300
+
301
+ class Notes(Anchor):
302
+ """The speaker-notes body of a slide — anchor id `notes:S`.
303
+
304
+ Resolves the notes-page **body** placeholder by
305
+ `PlaceholderFormat.Type == ppPlaceholderBody`, not by a hard index, because
306
+ the index varies across templates (spec.md / IMPLEMENTATION.md spike item).
307
+ Reads return `""` for an empty notes body; `set_text` replaces it.
308
+ """
309
+
310
+ kind = "notes"
311
+
312
+ def __init__(self, slide: Slide) -> None:
313
+ self._slide = slide
314
+
315
+ @property
316
+ def slide(self) -> Slide:
317
+ return self._slide
318
+
319
+ @property
320
+ def anchor_id(self) -> str:
321
+ return f"notes:{self._slide.index}"
322
+
323
+ def _body_placeholder(self) -> Any:
324
+ notes_page = self._slide.com.NotesPage
325
+ for ph in notes_page.Shapes.Placeholders:
326
+ try:
327
+ if int(ph.PlaceholderFormat.Type) == int(PpPlaceholderType.BODY) and is_true(
328
+ ph.HasTextFrame
329
+ ):
330
+ return ph
331
+ except Exception:
332
+ continue
333
+ raise AnchorNotFoundError("notes", self.anchor_id)
334
+
335
+ def _text_range(self) -> Any:
336
+ return self._body_placeholder().TextFrame.TextRange
337
+
338
+
339
+ # ---------------------------------------------------------------------------
340
+ # Paragraphs — para:S:N:P
341
+ # ---------------------------------------------------------------------------
342
+
343
+
344
+ def _strip_break(text: str) -> str:
345
+ """Drop the trailing paragraph/line break PowerPoint includes in a range."""
346
+ return text.rstrip("\r\v\n")
347
+
348
+
349
+ def paragraph_to_dict(para_range: Any, anchor_id: str, index: int) -> dict[str, Any]:
350
+ """Structured snapshot of one paragraph for `shape.paragraphs.list()`.
351
+
352
+ Reads are defensive — a property PowerPoint can't supply for this range
353
+ degrades to a sensible default rather than failing the whole listing.
354
+ """
355
+
356
+ def _safe(fn: Any, default: Any) -> Any:
357
+ try:
358
+ return fn()
359
+ except Exception:
360
+ return default
361
+
362
+ pf = para_range.ParagraphFormat
363
+ return {
364
+ "index": index,
365
+ "anchor_id": anchor_id,
366
+ "text": _strip_break(str(para_range.Text or "")),
367
+ "indent_level": _safe(lambda: int(para_range.IndentLevel), 1),
368
+ "alignment": _safe(lambda: int(pf.Alignment), None),
369
+ "bullet": _safe(
370
+ lambda: bullet_type_name(pf.Bullet.Type) if is_true(pf.Bullet.Visible) else "none",
371
+ "none",
372
+ ),
373
+ "bold": _safe(lambda: is_true(para_range.Font.Bold), False),
374
+ "size": _safe(lambda: float(para_range.Font.Size), None),
375
+ }
376
+
377
+
378
+ class Paragraph(Anchor):
379
+ """One paragraph of a shape's text frame — anchor id `para:S:N:P`.
380
+
381
+ Located by 1-based paragraph index `P` within shape `N` (z-order) on slide
382
+ `S`. Inherits every text verb (`set_text`, `format_text`, `format_paragraph`,
383
+ `apply_list`, `insert_paragraph_before/after`); `_text_range()` is
384
+ `TextFrame.TextRange.Paragraphs(P, 1)`, so those verbs scope to just this
385
+ paragraph. Resolves live on each access (the paragraph count drifts as text
386
+ is inserted/deleted), raising `AnchorNotFoundError` if `P` is out of range or
387
+ `NoTextFrameError` (via the shape) if the shape holds no text.
388
+ """
389
+
390
+ kind = "paragraph"
391
+
392
+ def __init__(self, shape: Shape, index: int) -> None:
393
+ self._shape = shape
394
+ self._index = int(index)
395
+
396
+ @property
397
+ def shape(self) -> Shape:
398
+ return self._shape
399
+
400
+ @property
401
+ def slide(self) -> Slide:
402
+ return self._shape.slide
403
+
404
+ @property
405
+ def index(self) -> int:
406
+ """1-based paragraph index within the shape's text frame."""
407
+ return self._index
408
+
409
+ @property
410
+ def anchor_id(self) -> str:
411
+ return f"para:{self._shape.slide.index}:{self._shape.index}:{self._index}"
412
+
413
+ def _text_range(self) -> Any:
414
+ tr = self._shape._text_range() # NoTextFrameError if the shape has no frame
415
+ count = int(tr.Paragraphs().Count)
416
+ if self._index < 1 or self._index > count:
417
+ raise AnchorNotFoundError("paragraph", self.anchor_id)
418
+ return tr.Paragraphs(self._index, 1)
419
+
420
+ @property
421
+ def text(self) -> str:
422
+ """The paragraph's text, without the trailing paragraph break."""
423
+ with _com.translate_com_errors():
424
+ return _strip_break(str(self._text_range().Text or ""))
425
+
426
+ @property
427
+ def indent_level(self) -> int:
428
+ """PowerPoint outline/bullet level, 1-5."""
429
+ with _com.translate_com_errors():
430
+ return int(self._text_range().IndentLevel)
431
+
432
+ def delete(self) -> None:
433
+ """Delete this paragraph (text + its break). The wrapper is spent."""
434
+ with _com.translate_com_errors():
435
+ self._text_range().Delete()
436
+
437
+ def to_dict(self) -> dict[str, Any]:
438
+ with _com.translate_com_errors():
439
+ return paragraph_to_dict(self._text_range(), self.anchor_id, self._index)
440
+
441
+
442
+ class ParagraphCollection:
443
+ """Indexable, iterable view over the paragraphs of a shape's text frame.
444
+
445
+ `shape.paragraphs[2]` is the 2nd paragraph (1-based); iteration yields a
446
+ `Paragraph` each; `list()` emits the structured dict used by the
447
+ `paragraphs` CLI command. Raises `NoTextFrameError` (via the shape) if the
448
+ shape holds no text.
449
+ """
450
+
451
+ def __init__(self, shape: Shape) -> None:
452
+ self._shape = shape
453
+
454
+ def _count(self) -> int:
455
+ with _com.translate_com_errors():
456
+ return int(self._shape._text_range().Paragraphs().Count)
457
+
458
+ def __len__(self) -> int:
459
+ return self._count()
460
+
461
+ def _anchor_id(self, index: int) -> str:
462
+ return f"para:{self._shape.slide.index}:{self._shape.index}:{index}"
463
+
464
+ def __getitem__(self, index: int) -> Paragraph:
465
+ if isinstance(index, bool) or not isinstance(index, int):
466
+ raise TypeError(f"paragraph index must be int, got {type(index).__name__}")
467
+ count = self._count()
468
+ if index < 1 or index > count:
469
+ raise AnchorNotFoundError("paragraph", self._anchor_id(index))
470
+ return Paragraph(self._shape, index)
471
+
472
+ def __iter__(self) -> Iterator[Paragraph]:
473
+ for idx in range(1, self._count() + 1):
474
+ yield Paragraph(self._shape, idx)
475
+
476
+ def list(self) -> list[dict[str, Any]]:
477
+ """Every paragraph as a structured dict, in order."""
478
+ out: list[dict[str, Any]] = []
479
+ with _com.translate_com_errors():
480
+ tr = self._shape._text_range()
481
+ count = int(tr.Paragraphs().Count)
482
+ for idx in range(1, count + 1):
483
+ out.append(paragraph_to_dict(tr.Paragraphs(idx, 1), self._anchor_id(idx), idx))
484
+ return out
pptlive/_app.py ADDED
@@ -0,0 +1,93 @@
1
+ """PowerPoint application wrapper + attach()/connect() context managers.
2
+
3
+ Note the PowerPoint diff from wordlive: `connect()` has **no `visible=False`
4
+ mode**. PowerPoint historically refuses to run invisibly, so the app is always
5
+ shown; politeness is about not *moving* the user's view, not about working
6
+ hidden. Like wordlive, pptlive never closes PowerPoint on exit — it's the user's
7
+ app, even when we launched it.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from collections.abc import Iterator
13
+ from contextlib import contextmanager
14
+ from typing import TYPE_CHECKING, Any
15
+
16
+ from . import _com
17
+ from .exceptions import PowerPointNotRunningError
18
+
19
+ if TYPE_CHECKING:
20
+ from ._presentation import PresentationCollection
21
+
22
+
23
+ class PowerPoint:
24
+ """Handle to a running PowerPoint.Application COM object."""
25
+
26
+ def __init__(self, app: Any) -> None:
27
+ self._app = app
28
+
29
+ @property
30
+ def com(self) -> Any:
31
+ """Raw Application COM object — escape hatch when pptlive doesn't cover something."""
32
+ return self._app
33
+
34
+ @property
35
+ def visible(self) -> bool:
36
+ return bool(self._app.Visible)
37
+
38
+ @property
39
+ def presentations(self) -> PresentationCollection:
40
+ from ._presentation import PresentationCollection
41
+
42
+ return PresentationCollection(self)
43
+
44
+ def viewed_slide_index(self) -> int | None:
45
+ """1-based index of the slide the user is currently looking at, or None.
46
+
47
+ None when there's no active window or the active view isn't one where a
48
+ slide is shown (e.g. slide sorter, or a slide show running).
49
+ """
50
+ try:
51
+ return int(self._app.ActiveWindow.View.Slide.SlideIndex)
52
+ except Exception:
53
+ return None
54
+
55
+ def __repr__(self) -> str:
56
+ return "<PowerPoint>"
57
+
58
+
59
+ @contextmanager
60
+ def attach() -> Iterator[PowerPoint]:
61
+ """Attach to an already-running PowerPoint instance.
62
+
63
+ Raises `PowerPointNotRunningError` if no instance is available. Does not
64
+ launch PowerPoint and does not close it on exit.
65
+ """
66
+ with _com.com_apartment():
67
+ app = _com.get_active_powerpoint()
68
+ try:
69
+ yield PowerPoint(app)
70
+ finally:
71
+ del app
72
+
73
+
74
+ @contextmanager
75
+ def connect(launch_if_missing: bool = True) -> Iterator[PowerPoint]:
76
+ """Attach to a running PowerPoint, or launch a new one if missing.
77
+
78
+ With `launch_if_missing=False` this behaves like `attach()`. There is no
79
+ `visible` parameter — PowerPoint is always visible (see module docstring).
80
+ pptlive never closes PowerPoint on exit, even when it launched the instance:
81
+ the user owns its lifecycle.
82
+ """
83
+ with _com.com_apartment():
84
+ try:
85
+ app = _com.get_active_powerpoint()
86
+ except PowerPointNotRunningError:
87
+ if not launch_if_missing:
88
+ raise
89
+ app = _com.launch_powerpoint()
90
+ try:
91
+ yield PowerPoint(app)
92
+ finally:
93
+ del app