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 +89 -0
- pptlive/_anchors.py +484 -0
- pptlive/_app.py +93 -0
- pptlive/_charts.py +243 -0
- pptlive/_com.py +127 -0
- pptlive/_edit.py +101 -0
- pptlive/_guide.py +58 -0
- pptlive/_presentation.py +414 -0
- pptlive/_selection.py +249 -0
- pptlive/_shapes.py +775 -0
- pptlive/_show.py +232 -0
- pptlive/_skill/pptlive-cli/SKILL.md +110 -0
- pptlive/_skill/pptlive-python/SKILL.md +163 -0
- pptlive/_slides.py +356 -0
- pptlive/_smartart.py +213 -0
- pptlive/_tables.py +227 -0
- pptlive/_theme.py +296 -0
- pptlive/cli/__init__.py +1 -0
- pptlive/cli/__main__.py +4 -0
- pptlive/cli/commands.py +2129 -0
- pptlive/cli/main.py +96 -0
- pptlive/constants.py +1072 -0
- pptlive/exceptions.py +219 -0
- pptlive/mcp/__init__.py +19 -0
- pptlive/mcp/__main__.py +19 -0
- pptlive/mcp/server.py +848 -0
- pptlive/py.typed +0 -0
- pptlive/units.py +37 -0
- pptlive-0.1.0.dist-info/METADATA +382 -0
- pptlive-0.1.0.dist-info/RECORD +32 -0
- pptlive-0.1.0.dist-info/WHEEL +4 -0
- pptlive-0.1.0.dist-info/entry_points.txt +3 -0
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
|