Python API
Every entry on this page is generated from the docstrings in the
pptlive
package, so it stays in sync with the code. If something looks thin, the fix
is in the source docstring, not here.
The public surface is small on purpose. Three rough layers:
See Concepts for the why behind these shapes.
Connecting to PowerPoint
pptlive.attach
attach() -> Iterator[PowerPoint]
Attach to an already-running PowerPoint instance.
Raises PowerPointNotRunningError if no instance is available. Does not
launch PowerPoint and does not close it on exit.
Source code in src/pptlive/_app.py
| @contextmanager
def attach() -> Iterator[PowerPoint]:
"""Attach to an already-running PowerPoint instance.
Raises `PowerPointNotRunningError` if no instance is available. Does not
launch PowerPoint and does not close it on exit.
"""
with _com.com_apartment():
app = _com.get_active_powerpoint()
try:
yield PowerPoint(app)
finally:
del app
|
pptlive.connect
connect(launch_if_missing: bool = True) -> Iterator[PowerPoint]
Attach to a running PowerPoint, or launch a new one if missing.
With launch_if_missing=False this behaves like attach(). There is no
visible parameter — PowerPoint is always visible (see module docstring).
pptlive never closes PowerPoint on exit, even when it launched the instance:
the user owns its lifecycle.
Source code in src/pptlive/_app.py
| @contextmanager
def connect(launch_if_missing: bool = True) -> Iterator[PowerPoint]:
"""Attach to a running PowerPoint, or launch a new one if missing.
With `launch_if_missing=False` this behaves like `attach()`. There is no
`visible` parameter — PowerPoint is always visible (see module docstring).
pptlive never closes PowerPoint on exit, even when it launched the instance:
the user owns its lifecycle.
"""
with _com.com_apartment():
try:
app = _com.get_active_powerpoint()
except PowerPointNotRunningError:
if not launch_if_missing:
raise
app = _com.launch_powerpoint()
try:
yield PowerPoint(app)
finally:
del app
|
pptlive.PowerPoint
Handle to a running PowerPoint.Application COM object.
Source code in src/pptlive/_app.py
| def __init__(self, app: Any) -> None:
self._app = app
|
com
property
Raw Application COM object — escape hatch when pptlive doesn't cover something.
viewed_slide_index
viewed_slide_index() -> int | None
1-based index of the slide the user is currently looking at, or None.
None when there's no active window or the active view isn't one where a
slide is shown (e.g. slide sorter, or a slide show running).
Source code in src/pptlive/_app.py
| def viewed_slide_index(self) -> int | None:
"""1-based index of the slide the user is currently looking at, or None.
None when there's no active window or the active view isn't one where a
slide is shown (e.g. slide sorter, or a slide show running).
"""
try:
return int(self._app.ActiveWindow.View.Slide.SlideIndex)
except Exception:
return None
|
Presentations
pptlive.Presentation
Presentation(ppt: PowerPoint, pres_com: Any)
Wraps a PowerPoint Presentation COM object.
Source code in src/pptlive/_presentation.py
| def __init__(self, ppt: PowerPoint, pres_com: Any) -> None:
self._ppt = ppt
self._pres = pres_com
|
path
property
Full path, or just the name for a never-saved deck.
saved
property
Whether the deck has no unsaved changes (PowerPoint's Presentation.Saved).
True right after a save; False once an edit dirties it, and False
for a brand-new never-saved deck. The same flag pptlive status reports
per open deck — how an agent sees there's unsaved work before deciding to
save(). (PowerPoint's COM value is an MsoTriState, -1/0; both
coerce to the right bool.)
sections
property
sections: SectionCollection
The deck's sections — named spans of slides (list/add/rename/
delete/move), addressed by 1-based section index. See
_sections.SectionCollection. Structural; wrap mutations in deck.edit(...).
show
property
Live slide-show control (start/next/goto/black/state/…).
Unlike the editing verbs, these deliberately drive what the user sees, so
they are not wrapped in edit(). See _show.SlideShow.
theme
property
Deck-wide theme styling — palette + typefaces (set_color/set_font).
A global, anti-polite surface: one call recolors/re-fonts every slide
that inherits the theme. Reaches SlideMaster.Theme. See _theme.Theme.
master
property
Deck-wide master styling — text styles + background.
The counterpart to per-anchor format_text, applied to the primary
SlideMaster so every inheriting slide re-renders. See _theme.Master.
save
Save the deck to its existing file; return the absolute path written.
The explicit, never-implicit persist verb (pptlive never auto-saves).
Raises UnsavedPresentationError if
the deck has never been saved — it has no path yet, so call
save_as with a destination first.
The never-saved guard is in Python on purpose: the 2026-06-09 spike found
PowerPoint's Save() does not raise on a path-less deck — on a
OneDrive/SharePoint build it silently uploads to the user's default cloud
folder — so relying on COM to refuse would let the deck escape somewhere
the caller didn't choose.
Source code in src/pptlive/_presentation.py
| def save(self) -> str:
"""Save the deck to its existing file; return the absolute path written.
The **explicit, never-implicit** persist verb (pptlive never auto-saves).
Raises [`UnsavedPresentationError`][pptlive.UnsavedPresentationError] if
the deck has never been saved — it has no path yet, so call
[`save_as`][pptlive.Presentation.save_as] with a destination first.
The never-saved guard is in Python on purpose: the 2026-06-09 spike found
PowerPoint's `Save()` does *not* raise on a path-less deck — on a
OneDrive/SharePoint build it silently uploads to the user's default cloud
folder — so relying on COM to refuse would let the deck escape somewhere
the caller didn't choose.
"""
with _com.translate_com_errors():
folder = str(self._pres.Path)
if not folder:
raise UnsavedPresentationError(self.name)
self._pres.Save()
return str(self._pres.FullName)
|
save_as
save_as(path: str | PathLike[str], *, fmt: str = 'pptx', overwrite: bool = False) -> str
Save the deck to path, returning the absolute path written.
fmt is "pptx" (the modern Open XML format). For PDF use
export_pdf — same COM call, but a read
(it doesn't rebind the working file). Rebinds the working file: after
this, the open deck is the new file (its name/path follow), matching
PowerPoint's own Save-As. By default refuses to clobber an existing file —
pass overwrite=True to allow it. Explicit-only, like
save.
Source code in src/pptlive/_presentation.py
| def save_as(
self, path: str | os.PathLike[str], *, fmt: str = "pptx", overwrite: bool = False
) -> str:
"""Save the deck to `path`, returning the absolute path written.
`fmt` is `"pptx"` (the modern Open XML format). For PDF use
[`export_pdf`][pptlive.Presentation.export_pdf] — same COM call, but a read
(it doesn't rebind the working file). **Rebinds** the working file: after
this, the open deck *is* the new file (its `name`/`path` follow), matching
PowerPoint's own Save-As. By default refuses to clobber an existing file —
pass `overwrite=True` to allow it. Explicit-only, like
[`save`][pptlive.Presentation.save].
"""
file_format, _ext = save_format_for(fmt) # validates; rejects "pdf"
target = Path(os.fspath(path)).expanduser()
if not overwrite and target.exists():
raise FileExistsError(
f"refusing to overwrite existing file {str(target)!r}; pass overwrite=True"
)
abspath = str(target.resolve())
with _com.translate_com_errors():
self._pres.SaveAs(abspath, file_format)
return abspath
|
export_pdf
export_pdf(path: str | PathLike[str]) -> str
Export the deck to a PDF at path; return the absolute path written.
The recommended "hand back a deliverable" path — a pixel-faithful render
of the deck's current (unsaved) state via PowerPoint's PDF engine. A
read: unlike save_as it neither
rebinds the working file nor clears its dirty flag (verified 2026-06-09),
so the user's .pptx is untouched and no edit() fence is needed.
Overwrites an existing PDF.
Goes through Presentation.SaveAs(path, ppSaveAsPDF=32):
ExportAsFixedFormat is the nominal PDF API but won't marshal under
pptlive's late-bound COM dispatch, and SaveAs-to-PDF produces the same
faithful PDF while behaving as a pure export.
Source code in src/pptlive/_presentation.py
| def export_pdf(self, path: str | os.PathLike[str]) -> str:
"""Export the deck to a PDF at `path`; return the absolute path written.
The recommended "hand back a deliverable" path — a pixel-faithful render
of the deck's current (unsaved) state via PowerPoint's PDF engine. A
**read**: unlike [`save_as`][pptlive.Presentation.save_as] it neither
rebinds the working file nor clears its dirty flag (verified 2026-06-09),
so the user's `.pptx` is untouched and no `edit()` fence is needed.
Overwrites an existing PDF.
Goes through `Presentation.SaveAs(path, ppSaveAsPDF=32)`:
`ExportAsFixedFormat` is the nominal PDF API but won't marshal under
pptlive's late-bound COM dispatch, and `SaveAs`-to-PDF produces the same
faithful PDF while behaving as a pure export.
"""
abspath = str(Path(os.fspath(path)).expanduser().resolve())
with _com.translate_com_errors():
self._pres.SaveAs(abspath, int(PpSaveAsFileType.PDF))
return abspath
|
export_video
export_video(path: str | PathLike[str], *, use_timings: bool = True, default_slide_duration: float = 5.0, resolution: int = 720, fps: int = 30, quality: int = 85, wait: bool = True, timeout: float = 600.0) -> VideoExportResult
Export the deck to an MP4 at path (Presentation.CreateVideo).
The narrated-video deliverable — renders the deck's current (unsaved) state
to video. A read: like export_pdf
it neither rebinds the working file nor clears its dirty flag, so no edit()
fence is needed. Overwrites an existing file.
use_timings honors per-slide auto-advance timings + narration (set
pace_slide=True on add_audio/add_video); default_slide_duration (s)
paces any slide without a timing. resolution is the vertical pixel
height (e.g. 720, 1080), fps frames/second, quality 0–100.
CreateVideo is async. By default (wait=True) this blocks, polling
CreateVideoStatus until the encode finishes (or timeout seconds elapse,
raising VideoExportError), and returns a
VideoExportResult with ok=True and the written path. With wait=False
it returns immediately with the in-flight status (ok=False); poll
video_status until it reports done.
A failed encode raises VideoExportError.
Source code in src/pptlive/_presentation.py
| def export_video(
self,
path: str | os.PathLike[str],
*,
use_timings: bool = True,
default_slide_duration: float = 5.0,
resolution: int = 720,
fps: int = 30,
quality: int = 85,
wait: bool = True,
timeout: float = 600.0,
) -> VideoExportResult:
"""Export the deck to an MP4 at `path` (`Presentation.CreateVideo`).
The narrated-video deliverable — renders the deck's current (unsaved) state
to video. A **read**: like [`export_pdf`][pptlive.Presentation.export_pdf]
it neither rebinds the working file nor clears its dirty flag, so no `edit()`
fence is needed. Overwrites an existing file.
`use_timings` honors per-slide auto-advance timings + narration (set
`pace_slide=True` on `add_audio`/`add_video`); `default_slide_duration` (s)
paces any slide *without* a timing. `resolution` is the vertical pixel
height (e.g. 720, 1080), `fps` frames/second, `quality` 0–100.
`CreateVideo` is **async**. By default (`wait=True`) this blocks, polling
`CreateVideoStatus` until the encode finishes (or `timeout` seconds elapse,
raising [`VideoExportError`][pptlive.VideoExportError]), and returns a
`VideoExportResult` with `ok=True` and the written `path`. With `wait=False`
it returns immediately with the in-flight status (`ok=False`); poll
[`video_status`][pptlive.Presentation.video_status] until it reports `done`.
A failed encode raises `VideoExportError`.
"""
abspath = str(Path(os.fspath(path)).expanduser().resolve())
# Validate before any COM so a bad value is a clean ValueError, not a
# confusing raw CreateVideo COM error (the project's validate-first pattern).
if int(resolution) <= 0:
raise ValueError(f"resolution must be a positive pixel height, got {resolution!r}")
if int(fps) <= 0:
raise ValueError(f"fps must be positive, got {fps!r}")
if not 0 <= int(quality) <= 100:
raise ValueError(f"quality must be between 0 and 100, got {quality!r}")
with _com.translate_com_errors():
self._pres.CreateVideo(
abspath,
bool(use_timings),
int(default_slide_duration),
int(resolution),
int(fps),
int(quality),
)
if not wait:
code = self._video_status_code()
return VideoExportResult(
path=abspath,
status=media_task_status_name(code),
status_code=code,
ok=False,
)
deadline = time.monotonic() + float(timeout)
while True:
code = self._video_status_code()
if code == int(PpMediaTaskStatus.FAILED):
raise VideoExportError(
f"video export failed for {abspath!r}",
status=media_task_status_name(code),
)
if code == int(PpMediaTaskStatus.DONE):
# The encoder flushes the file a beat after status flips to Done.
for _ in range(_VIDEO_FILE_WAIT_TICKS):
if os.path.exists(abspath) and os.path.getsize(abspath) > 0:
break
time.sleep(_VIDEO_POLL_INTERVAL)
ok = os.path.exists(abspath) and os.path.getsize(abspath) > 0
return VideoExportResult(
path=abspath,
status=media_task_status_name(code),
status_code=code,
ok=ok,
)
if time.monotonic() >= deadline:
raise VideoExportError(
f"video export timed out after {timeout:g}s "
f"(last status: {media_task_status_name(code)}); "
"the encode may still be running — poll video_status()",
status=media_task_status_name(code),
)
time.sleep(_VIDEO_POLL_INTERVAL)
|
video_status
video_status() -> VideoExportResult
Poll the async video-export status (Presentation.CreateVideoStatus).
The non-blocking companion to export_video(wait=False): returns a
VideoExportResult whose status is none/queued/in_progress/done/
failed and ok is True once the encode is done. path is "" here (a
bare status poll doesn't know the target). none means no export has been
requested in this session.
Source code in src/pptlive/_presentation.py
| def video_status(self) -> VideoExportResult:
"""Poll the async video-export status (`Presentation.CreateVideoStatus`).
The non-blocking companion to `export_video(wait=False)`: returns a
`VideoExportResult` whose `status` is `none`/`queued`/`in_progress`/`done`/
`failed` and `ok` is True once the encode is `done`. `path` is `""` here (a
bare status poll doesn't know the target). `none` means no export has been
requested in this session.
"""
code = self._video_status_code()
return VideoExportResult(
path="",
status=media_task_status_name(code),
status_code=code,
ok=code == int(PpMediaTaskStatus.DONE),
)
|
page_setup
page_setup() -> dict[str, float]
Slide canvas dimensions in points: {width, height}.
From Presentation.PageSetup.SlideWidth/SlideHeight, so an agent can
place shapes relative to the canvas. Points, never EMUs.
Source code in src/pptlive/_presentation.py
| def page_setup(self) -> dict[str, float]:
"""Slide canvas dimensions in points: `{width, height}`.
From `Presentation.PageSetup.SlideWidth`/`SlideHeight`, so an agent can
place shapes relative to the canvas. Points, never EMUs.
"""
with _com.translate_com_errors():
ps = self._pres.PageSetup
return {"width": float(ps.SlideWidth), "height": float(ps.SlideHeight)}
|
export_images
export_images(directory: str | PathLike[str], *, fmt: str = 'png', width: int | None = None, height: int | None = None) -> list[Path]
Render every slide into directory; return the image paths, in order.
Files are named slide-001.<ext>, slide-002.<ext>, …. A per-slide wrap
of Slide.export_image (same fmt/width/height semantics) — the
whole-deck "show me what I built" read.
Source code in src/pptlive/_presentation.py
| def export_images(
self,
directory: str | os.PathLike[str],
*,
fmt: str = "png",
width: int | None = None,
height: int | None = None,
) -> list[Path]:
"""Render every slide into `directory`; return the image paths, in order.
Files are named `slide-001.<ext>`, `slide-002.<ext>`, …. A per-slide wrap
of `Slide.export_image` (same `fmt`/`width`/`height` semantics) — the
whole-deck "show me what I built" read.
"""
_filter_name, ext = image_filter_for(fmt) # validate before any work
out_dir = os.path.abspath(os.fspath(directory))
os.makedirs(out_dir, exist_ok=True)
paths: list[Path] = []
for slide in self.slides:
target = os.path.join(out_dir, f"slide-{slide.index:03d}.{ext}")
paths.append(slide.export_image(target, width=width, height=height, fmt=fmt))
return paths
|
snapshot
snapshot(out: str | PathLike[str] | None = None, *, slides: int | tuple[int, int] | None = None, fmt: str = 'png', max_dim: int | None = None, width: int | None = None, height: int | None = None) -> list[Snapshot]
Render slides to PNG so a vision model can see the whole deck cheaply.
The token-cost-aware read: max_dim caps each slide's long edge in
pixels (only ever lowering resolution), so the per-slide cost is a
predictable budget — and because every slide shares one geometry, that
budget is uniform across the deck. The lever for "render the whole deck
and check my styling landed" without full-resolution token bloat
(~1000 stays legible). max_dim=None renders at native size.
For an exact per-slide pixel size instead of the cap, pass width/
height (one is enough — the other follows the slide aspect ratio); this
overrides the cap, and passing it together with max_dim is a ValueError
(two conflicting size levers). Note PowerPoint exposes no JPEG-quality
knob on Slide.Export, so pixel dimensions — max_dim or width/height
— are the only render-cost levers (which is fine: a vision model bills on
pixel area, not encoder quality).
slides selects what to render: None (default) every slide, an int
a single 1-based slide, a (start, end) tuple an inclusive span. Returns
one Snapshot per slide (so a single slide is a
one-element list); read .image for the bytes.
If out is given the image is also written there: a single slide to out
itself, multiple slides alongside it as <stem>-s<N><suffix>. fmt is a
friendly image token (png/jpg/…). A read — the export renders the
current unsaved state and leaves the viewed slide and Selection untouched
(no edit() fence needed).
Source code in src/pptlive/_presentation.py
| def snapshot(
self,
out: str | os.PathLike[str] | None = None,
*,
slides: int | tuple[int, int] | None = None,
fmt: str = "png",
max_dim: int | None = None,
width: int | None = None,
height: int | None = None,
) -> list[Snapshot]:
"""Render slides to PNG so a vision model can *see* the whole deck cheaply.
The token-cost-aware read: `max_dim` caps each slide's **long edge** in
pixels (only ever lowering resolution), so the per-slide cost is a
predictable budget — and because every slide shares one geometry, that
budget is uniform across the deck. The lever for "render the whole deck
and check my styling landed" without full-resolution token bloat
(~1000 stays legible). `max_dim=None` renders at native size.
For an *exact* per-slide pixel size instead of the cap, pass `width`/
`height` (one is enough — the other follows the slide aspect ratio); this
overrides the cap, and passing it together with `max_dim` is a `ValueError`
(two conflicting size levers). Note PowerPoint exposes **no** JPEG-quality
knob on `Slide.Export`, so pixel dimensions — `max_dim` or `width`/`height`
— are the only render-cost levers (which is fine: a vision model bills on
pixel area, not encoder quality).
`slides` selects what to render: `None` (default) every slide, an `int`
a single 1-based slide, a `(start, end)` tuple an inclusive span. Returns
one [`Snapshot`][pptlive.Snapshot] per slide (so a single slide is a
one-element list); read `.image` for the bytes.
If `out` is given the image is also written there: a single slide to `out`
itself, multiple slides alongside it as `<stem>-s<N><suffix>`. `fmt` is a
friendly image token (`png`/`jpg`/…). A read — the export renders the
current unsaved state and leaves the viewed slide and Selection untouched
(no `edit()` fence needed).
"""
return _snapshot.snapshot(
self, out, slides=slides, fmt=fmt, max_dim=max_dim, width=width, height=height
)
|
selection
selection() -> SelectionInfo
The user's current selection, resolved to anchors — a polite read.
Snapshots ActiveWindow.Selection without changing it (the complement to
status, which reports the viewed slide): the selected shapes as
shape:S:N, or a text caret as para:S:N:P. To act on the selection,
resolve anchor_by_id("here:") — the explicit opt-in (pptlive never
targets the live Selection unless asked).
Source code in src/pptlive/_presentation.py
| def selection(self) -> SelectionInfo:
"""The user's current selection, resolved to anchors — a polite read.
Snapshots `ActiveWindow.Selection` without changing it (the complement to
`status`, which reports the viewed slide): the selected shapes as
`shape:S:N`, or a text caret as `para:S:N:P`. To *act on* the selection,
resolve `anchor_by_id("here:")` — the explicit opt-in (pptlive never
targets the live Selection unless asked).
"""
return read_selection(self._ppt)
|
layouts
layouts() -> list[dict[str, Any]]
The deck's slide layouts: [{index, name}, ...] (1-based index).
Sourced from SlideMaster.CustomLayouts. Lists the exact names that
slides.add(layout=…) / Slide.set_layout(…) accept on this template —
useful when a theme has renamed the standard Office layouts.
Source code in src/pptlive/_presentation.py
| def layouts(self) -> list[dict[str, Any]]:
"""The deck's slide layouts: `[{index, name}, ...]` (1-based index).
Sourced from `SlideMaster.CustomLayouts`. Lists the exact names that
`slides.add(layout=…)` / `Slide.set_layout(…)` accept on this template —
useful when a theme has renamed the standard Office layouts.
"""
out: list[dict[str, Any]] = []
with _com.translate_com_errors():
for idx, layout in enumerate(self._custom_layouts(), start=1):
out.append({"index": idx, "name": str(layout.Name)})
return out
|
outline
outline() -> list[dict[str, Any]]
The Outline-view analog: [{slide, title, bullets:[...]}, ...].
bullets are the non-empty paragraphs of the slide's body placeholder
(when it has one); slides without a body — or whose body placeholder
holds a chart/table/picture rather than text — just carry their title.
Source code in src/pptlive/_presentation.py
| def outline(self) -> list[dict[str, Any]]:
"""The Outline-view analog: `[{slide, title, bullets:[...]}, ...]`.
`bullets` are the non-empty paragraphs of the slide's body placeholder
(when it has one); slides without a body — or whose body placeholder
holds a chart/table/picture rather than text — just carry their title.
"""
out: list[dict[str, Any]] = []
for slide in self.slides:
bullets: list[str] = []
try:
body = slide.placeholder("body")
bullets = _paragraphs(body.text)
except (AnchorNotFoundError, NoTextFrameError):
# No body placeholder, or it's been filled with a chart/table/
# picture (no text frame): the slide simply contributes no bullets.
bullets = []
out.append({"slide": slide.index, "title": slide.title, "bullets": bullets})
return out
|
comments() -> dict[str, Any]
Every review comment across the deck — the deck-wide roll-up.
{total, slides: [{slide, comments:[{index, author, initials, text,
datetime, left, top, replies:[...]}, ...]}, ...]}. Only slides that carry
at least one comment appear; total counts top-level comments (not
replies). A read — side-effect-free and polite (no view move). For one
slide, use deck.slides[S].comments.list() (the comments:S read).
Source code in src/pptlive/_presentation.py
| def comments(self) -> dict[str, Any]:
"""Every review comment across the deck — the deck-wide roll-up.
`{total, slides: [{slide, comments:[{index, author, initials, text,
datetime, left, top, replies:[...]}, ...]}, ...]}`. Only slides that carry
at least one comment appear; `total` counts top-level comments (not
replies). A read — side-effect-free and polite (no view move). For one
slide, use `deck.slides[S].comments.list()` (the `comments:S` read).
"""
slides_out: list[dict[str, Any]] = []
total = 0
for slide in self.slides:
items = slide.comments.list()
if items:
slides_out.append({"slide": slide.index, "comments": items})
total += len(items)
return {"total": total, "slides": slides_out}
|
anchor_by_id
anchor_by_id(anchor_id: str) -> Anchor
Resolve an anchor_id string into an Anchor.
Recognised
shape:S:N — Nth shape (1-based z-order) on slide S
shapeid:S:ID— shape with stable Shape.Id ID on slide S — the
delete-proof handle (the id in every shape listing)
ph:S:KIND — placeholder of semantic KIND on slide S
(title/ctrtitle/subtitle/body/footer/date/slidenum)
para:S:N:P — paragraph P of shape N on slide S (v0.3)
cell:S:N:R:C— cell (row R, col C) of the table in shape N on slide S (v0.5)
notes:S — speaker-notes body of slide S
here: — whatever the user has selected right now (v0.4): the
selected shape, or the paragraph holding the text caret
slide:S is a container, not a text anchor — use deck.slides[S].
Raises AnchorNotFoundError for unknown schemes or missing anchors
(SlideNotFoundError, a subclass, for an out-of-range slide).
Source code in src/pptlive/_presentation.py
| def anchor_by_id(self, anchor_id: str) -> Anchor:
"""Resolve an `anchor_id` string into an `Anchor`.
Recognised:
- `shape:S:N` — Nth shape (1-based z-order) on slide S
- `shapeid:S:ID`— shape with stable `Shape.Id` ID on slide S — the
delete-proof handle (the `id` in every shape listing)
- `ph:S:KIND` — placeholder of semantic KIND on slide S
(title/ctrtitle/subtitle/body/footer/date/slidenum)
- `para:S:N:P` — paragraph P of shape N on slide S (v0.3)
- `cell:S:N:R:C`— cell (row R, col C) of the table in shape N on slide S (v0.5)
- `notes:S` — speaker-notes body of slide S
- `here:` — whatever the user has selected right now (v0.4): the
selected shape, or the paragraph holding the text caret
`slide:S` is a *container*, not a text anchor — use `deck.slides[S]`.
Raises `AnchorNotFoundError` for unknown schemes or missing anchors
(`SlideNotFoundError`, a subclass, for an out-of-range slide).
"""
if not isinstance(anchor_id, str) or ":" not in anchor_id:
raise AnchorNotFoundError("anchor", str(anchor_id))
kind, _, rest = anchor_id.partition(":")
if kind == "shape":
parts = rest.split(":")
if len(parts) != 2:
raise AnchorNotFoundError("shape", anchor_id)
try:
s, n = int(parts[0]), int(parts[1])
except ValueError as e:
raise AnchorNotFoundError("shape", anchor_id) from e
return self.slides[s].shapes[n]
if kind == "shapeid":
parts = rest.split(":")
if len(parts) != 2:
raise AnchorNotFoundError("shape", anchor_id)
try:
s, sid = int(parts[0]), int(parts[1])
except ValueError as e:
raise AnchorNotFoundError("shape", anchor_id) from e
return self.slides[s].shapes.by_id(sid)
if kind == "para":
parts = rest.split(":")
if len(parts) != 3:
raise AnchorNotFoundError("paragraph", anchor_id)
try:
s, n, p = int(parts[0]), int(parts[1]), int(parts[2])
except ValueError as e:
raise AnchorNotFoundError("paragraph", anchor_id) from e
return self.slides[s].shapes[n].paragraph(p)
if kind == "cell":
parts = rest.split(":")
if len(parts) != 4:
raise AnchorNotFoundError("table cell", anchor_id)
try:
s, n, r, c = (int(x) for x in parts)
except ValueError as e:
raise AnchorNotFoundError("table cell", anchor_id) from e
return self.slides[s].shapes[n].table.cell(r, c)
if kind == "ph":
s_str, sep, ph_kind = rest.partition(":")
if not sep or not ph_kind:
raise AnchorNotFoundError("placeholder", anchor_id)
try:
s = int(s_str)
except ValueError as e:
raise AnchorNotFoundError("placeholder", anchor_id) from e
try:
return self.slides[s].placeholder(ph_kind)
except ValueError as e:
# Unknown KIND — surface as a missing anchor (exit 2), not a crash.
raise AnchorNotFoundError("placeholder", anchor_id) from e
if kind == "notes":
try:
s = int(rest)
except ValueError as e:
raise AnchorNotFoundError("notes", anchor_id) from e
return self.slides[s].notes
if kind == "here":
# The explicit opt-in to target the live Selection (politeness:
# never otherwise). Resolves live to a Shape or Paragraph.
info = read_selection(self._ppt)
if (
info.type == "text"
and info.slide is not None
and info.shape_index is not None
and info.paragraph is not None
):
return self.slides[info.slide].shapes[info.shape_index].paragraph(info.paragraph)
if info.type == "shapes" and info.slide is not None and info.shape_index is not None:
return self.slides[info.slide].shapes[info.shape_index]
raise AnchorNotFoundError("selection", anchor_id)
raise AnchorNotFoundError("anchor", anchor_id)
|
find
find(text: str, *, scope: str | Slide | Anchor | None = None) -> list[dict[str, Any]]
Locate every fuzzy occurrence of text across the deck (or scope).
Search is a traversal of slides × shapes → text frames, table cells, and
speaker notes (there is no deck-wide character stream). Matching is
whitespace- and Unicode-normalized (NFKC, smart quotes, dashes, NBSP), so
text an LLM re-typed off a slide still matches the original glyphs.
Returns {anchor_id, start, length, text, context} per hit, in document
order: anchor_id is a resolvable text anchor (shape:S:N,
cell:S:N:R:C, or notes:S), start is the 0-based char offset of the
match within that anchor's text, text is the actual original substring,
and context is a short surrounding snippet. The offsets are live — use
them before further edits shift the text.
A read — polite (no view move). scope restricts the search: a slide:S
string (or a Slide) limits it to one slide; any text-anchor id (or an
Anchor) limits it to that shape / cell / notes frame; None (default)
searches the whole deck.
Source code in src/pptlive/_presentation.py
| def find(self, text: str, *, scope: str | Slide | Anchor | None = None) -> list[dict[str, Any]]:
"""Locate every fuzzy occurrence of `text` across the deck (or `scope`).
Search is a traversal of slides × shapes → text frames, table cells, and
speaker notes (there is no deck-wide character stream). Matching is
whitespace- and Unicode-normalized (NFKC, smart quotes, dashes, NBSP), so
text an LLM re-typed off a slide still matches the original glyphs.
Returns `{anchor_id, start, length, text, context}` per hit, in document
order: `anchor_id` is a resolvable text anchor (`shape:S:N`,
`cell:S:N:R:C`, or `notes:S`), `start` is the 0-based char offset of the
match within that anchor's text, `text` is the actual original substring,
and `context` is a short surrounding snippet. The offsets are live — use
them before further edits shift the text.
A read — polite (no view move). `scope` restricts the search: a `slide:S`
string (or a `Slide`) limits it to one slide; any text-anchor id (or an
`Anchor`) limits it to that shape / cell / notes frame; `None` (default)
searches the whole deck.
"""
units = self._search_units(scope)
results: list[dict[str, Any]] = []
with _com.translate_com_errors():
for anchor_id, tr in units:
haystack = str(tr.Text or "")
for m in _findreplace.find_matches(haystack, text):
results.append(
{
"anchor_id": anchor_id,
"start": m.start,
"length": m.end - m.start,
"text": m.text,
"context": _match_context(haystack, m.start, m.end),
}
)
return results
|
find_replace
find_replace(find: str, replace: str, *, scope: str | Slide | Anchor | None = None, all: bool = False, occurrence: int | None = None) -> list[dict[str, Any]]
Fuzzy plain-text replace across the deck (or scope). See find.
Parameters:
| Name |
Type |
Description |
Default |
find
|
str
|
the text to look for (fuzzy-matched, same semantics as find).
|
required
|
replace
|
str
|
|
required
|
scope
|
str | Slide | Anchor | None
|
restrict the search — a slide:S / anchor-id string, a Slide
or Anchor, or None for the whole deck.
|
None
|
all
|
bool
|
|
False
|
occurrence
|
int | None
|
1-based index — replace only the Nth match (document order).
|
None
|
Raises:
| Type |
Description |
AnchorNotFoundError
|
zero matches (kind='find', exit 2), or an
out-of-range occurrence.
|
AmbiguousMatchError
|
more than one match and neither all nor
occurrence was given (exit 5).
|
Returns the replacements applied, each {anchor_id, start, length, text}
in their pre-replacement coordinates. Only the matched span is rewritten
(via TextRange.Characters), so the rest of each frame keeps its run
formatting. Wrap the call in deck.edit(...) for view preservation and a
one-Ctrl-Z fence.
Source code in src/pptlive/_presentation.py
| def find_replace(
self,
find: str,
replace: str,
*,
scope: str | Slide | Anchor | None = None,
all: bool = False,
occurrence: int | None = None,
) -> list[dict[str, Any]]:
"""Fuzzy plain-text replace across the deck (or `scope`). See `find`.
Args:
find: the text to look for (fuzzy-matched, same semantics as `find`).
replace: the replacement text.
scope: restrict the search — a `slide:S` / anchor-id string, a `Slide`
or `Anchor`, or `None` for the whole deck.
all: replace every match.
occurrence: 1-based index — replace only the Nth match (document order).
Raises:
AnchorNotFoundError: zero matches (`kind='find'`, exit 2), or an
out-of-range `occurrence`.
AmbiguousMatchError: more than one match and neither `all` nor
`occurrence` was given (exit 5).
Returns the replacements applied, each `{anchor_id, start, length, text}`
in their pre-replacement coordinates. Only the matched span is rewritten
(via `TextRange.Characters`), so the rest of each frame keeps its run
formatting. Wrap the call in `deck.edit(...)` for view preservation and a
one-Ctrl-Z fence.
"""
units = self._search_units(scope)
# (anchor_id, COM TextRange, start, end, original_text) in document order.
matches: list[tuple[str, Any, int, int, str]] = []
with _com.translate_com_errors():
for anchor_id, tr in units:
haystack = str(tr.Text or "")
for m in _findreplace.find_matches(haystack, find):
matches.append((anchor_id, tr, m.start, m.end, m.text))
if not matches:
raise AnchorNotFoundError("find", find)
if occurrence is not None:
if occurrence < 1 or occurrence > len(matches):
raise AnchorNotFoundError("find", f"{find} (occurrence {occurrence})")
to_apply = [matches[occurrence - 1]]
elif all:
to_apply = list(matches)
elif len(matches) == 1:
to_apply = matches
else:
raise AmbiguousMatchError(
find,
[
{"anchor_id": a, "start": s, "length": e - s, "text": t}
for (a, _tr, s, e, t) in matches
],
)
# Apply in reverse document order so an earlier match's offsets stay valid
# after a later match in the *same* frame is rewritten to a different
# length (matches in different frames are independent).
applied: list[dict[str, Any]] = []
with _com.translate_com_errors():
for anchor_id, tr, start, end, text in reversed(to_apply):
span = tr.Characters(start + 1, end - start)
# Re-verify the span still holds the located text before
# overwriting. An empty resolved text can't be checked (the fake
# COM, or a genuinely empty range) — proceed; a non-empty mismatch
# means the frame drifted between locate and apply, so refuse
# rather than corrupt the wrong characters.
resolved = str(span.Text or "")
if resolved and not _findreplace.normalized_equal(resolved, text):
raise ReplaceVerificationError(find, text, resolved, anchor_id=anchor_id)
span.Text = replace
applied.append(
{"anchor_id": anchor_id, "start": start, "length": end - start, "text": text}
)
applied.reverse() # report in document order
return applied
|
edit
edit(label: str) -> Iterator[EditScope]
Open an atomic-undo + view/Selection-preserving edit scope.
Mutations inside the block collapse into a single Ctrl-Z: the scope
fences a fresh undo entry with Application.StartNewUndoEntry() on entry
and PowerPoint groups the rest. On clean exit the user is returned to the
slide and selection they had. See EditScope for the mechanism and its
caveats (no explicit "end" fence; always wrap mutations in edit).
with deck.edit("Revise agenda slide"):
deck.anchor_by_id("ph:2:title").set_text("Agenda")
deck.anchor_by_id("ph:2:body").set_text("Intro\nDemo\nQ&A")
Source code in src/pptlive/_presentation.py
| @contextmanager
def edit(self, label: str) -> Iterator[EditScope]:
"""Open an atomic-undo + view/Selection-preserving edit scope.
Mutations inside the block collapse into a **single Ctrl-Z**: the scope
fences a fresh undo entry with `Application.StartNewUndoEntry()` on entry
and PowerPoint groups the rest. On clean exit the user is returned to the
slide and selection they had. See `EditScope` for the mechanism and its
caveats (no explicit "end" fence; always wrap mutations in `edit`).
```
with deck.edit("Revise agenda slide"):
deck.anchor_by_id("ph:2:title").set_text("Agenda")
deck.anchor_by_id("ph:2:body").set_text("Intro\\nDemo\\nQ&A")
```
"""
scope = EditScope(self._ppt, label)
with scope:
yield scope
|
go_to
go_to(target: Anchor | Slide, *, select: bool = True) -> None
Move the user's view to a slide or shape (deliberate, opt-in jump).
Rare — most operations preserve the view. target is an Anchor
(resolved via anchor_by_id) or a Slide. Jumps the active window to
that slide and, when select is True and the target is a shape, selects
it. This intentionally moves the user, so inside a deck.edit(...) block
call scope.allow_view_move() first or it'll be snapped back on exit.
Source code in src/pptlive/_presentation.py
| def go_to(self, target: Anchor | Slide, *, select: bool = True) -> None:
"""Move the user's view to a slide or shape (deliberate, opt-in jump).
Rare — most operations preserve the view. `target` is an `Anchor`
(resolved via `anchor_by_id`) or a `Slide`. Jumps the active window to
that slide and, when `select` is True and the target is a shape, selects
it. This intentionally moves the user, so inside a `deck.edit(...)` block
call `scope.allow_view_move()` first or it'll be snapped back on exit.
"""
slide: Slide | None
shape: Shape | None
if isinstance(target, Slide):
slide, shape = target, None
else:
slide = getattr(target, "slide", None)
shape = target if isinstance(target, Shape) else None
if slide is None:
raise TypeError(f"cannot go_to {type(target).__name__}: no slide context")
assert slide is not None # narrowed by the branches above
slide_index = slide.index
with _com.translate_com_errors():
win = self._ppt.com.ActiveWindow
win.View.GotoSlide(int(slide_index))
if select and shape is not None:
try:
with _com.translate_com_errors():
shape.com.Select()
except PowerPointBusyError:
# The goto landed; only the shape-select failed. A busy here
# is still retryable — surface it rather than silently
# leaving nothing selected.
raise
except Exception as exc: # noqa: BLE001
# The goto succeeded; the select is best-effort (e.g. the
# shape can't be selected in the current view). Don't fail the
# whole jump, but trace it so a swallowed select isn't fully
# invisible under PPTLIVE_VIEW_DEBUG.
_dbg(f"go_to: shape.Select() failed (goto still landed): {exc!r}")
|
pptlive.PresentationCollection
PresentationCollection(ppt: PowerPoint)
Indexable view over open presentations.
Source code in src/pptlive/_presentation.py
| def __init__(self, ppt: PowerPoint) -> None:
self._ppt = ppt
|
list
list() -> list[dict[str, Any]]
[{name, path, saved, is_active}, ...] — used by pptlive status.
saved is Presentation.Saved (False = unsaved changes / never saved),
so an agent sees dirty state before deciding to save(); path is the
full path (just the name for a never-saved deck).
Source code in src/pptlive/_presentation.py
| def list(self) -> list[dict[str, Any]]:
"""`[{name, path, saved, is_active}, ...]` — used by `pptlive status`.
`saved` is `Presentation.Saved` (False = unsaved changes / never saved),
so an agent sees dirty state before deciding to `save()`; `path` is the
full path (just the name for a never-saved deck).
"""
out: list[dict[str, Any]] = []
with _com.translate_com_errors():
active_name: str | None
try:
with _com.translate_com_errors():
active_name = str(self._ppt.com.ActivePresentation.Name)
except PowerPointBusyError:
raise # busy is exit 3; don't mask it as "no active deck"
except Exception:
active_name = None
for pres in self._com_collection:
name = str(pres.Name)
out.append(
{
"name": name,
"path": str(pres.FullName),
"saved": bool(pres.Saved),
"is_active": name == active_name,
}
)
return out
|
Slides
Presentation.slides is a SlideCollection. Index
a slide by 1-based position (deck.slides[3]), iterate it, or use the
lifecycle verbs (add / delete / duplicate / move_to / set_layout). A
Slide exposes shapes, placeholder(kind), notes,
read(), and export_image(...).
pptlive.SlideCollection
SlideCollection(deck: Presentation)
Indexable, iterable view over a presentation's slides (1-based).
Source code in src/pptlive/_slides.py
| def __init__(self, deck: Presentation) -> None:
self._deck = deck
|
add
add(layout: str | int | None = None, index: int | None = None, *, placeholders: dict[str, dict[str, float]] | None = None) -> Slide
Insert a new slide and return it (v0.1; wrap in deck.edit(...)).
layout is a friendly name or 1-based layout index (default
title_and_content); index is the 1-based insertion position
(default: appended to the end). Prefers the modern
Slides.AddSlide(Index, CustomLayout), falling back to legacy
Slides.Add only on a deck that exposes no custom layouts. Raises
LayoutNotFoundError for an unknown layout and SlideNotFoundError
for an out-of-range insertion position (1..count+1).
placeholders repositions the layout's placeholders right after creation
— {KIND: {left, top, width, height}} in points, any subset of the four
keys per KIND — so "a content slide with the body on the left half" is one
op instead of an add-then-resize fix-up. KIND is the same semantic name
ph:S:KIND uses (title/body/…); an unknown-on-this-layout or ambiguous
KIND raises (AnchorNotFoundError / AmbiguousMatchError) the same way
addressing it would. Pair with Slide.geometry_report() to size the boxes.
Source code in src/pptlive/_slides.py
| def add(
self,
layout: str | int | None = None,
index: int | None = None,
*,
placeholders: dict[str, dict[str, float]] | None = None,
) -> Slide:
"""Insert a new slide and return it (v0.1; wrap in `deck.edit(...)`).
`layout` is a friendly name or 1-based layout index (default
`title_and_content`); `index` is the 1-based insertion position
(default: appended to the end). Prefers the modern
`Slides.AddSlide(Index, CustomLayout)`, falling back to legacy
`Slides.Add` only on a deck that exposes no custom layouts. Raises
`LayoutNotFoundError` for an unknown layout and `SlideNotFoundError`
for an out-of-range insertion position (1..count+1).
`placeholders` repositions the layout's placeholders right after creation
— `{KIND: {left, top, width, height}}` in points, any subset of the four
keys per KIND — so "a content slide with the body on the left half" is one
op instead of an add-then-resize fix-up. KIND is the same semantic name
`ph:S:KIND` uses (`title`/`body`/…); an unknown-on-this-layout or ambiguous
KIND raises (`AnchorNotFoundError` / `AmbiguousMatchError`) the same way
addressing it would. Pair with `Slide.geometry_report()` to size the boxes.
"""
_validate_placeholders_arg(placeholders)
count = len(self)
if index is None:
target = count + 1
elif isinstance(index, bool) or not isinstance(index, int):
raise TypeError(f"index must be int, got {type(index).__name__}")
elif index < 1 or index > count + 1:
raise SlideNotFoundError(index)
else:
target = index
with _com.translate_com_errors():
custom = self._deck._resolve_layout(layout)
if custom is not None:
new_com = self._com_collection.AddSlide(target, custom)
else:
new_com = self._com_collection.Add(target, int(DEFAULT_LEGACY_LAYOUT))
new_slide = Slide(self._deck, new_com)
if placeholders:
self._apply_placeholder_geometry(new_slide.index, placeholders)
return new_slide
|
list
list() -> list[dict[str, Any]]
[{index, id, layout, title, shape_count, has_notes}, ...].
Source code in src/pptlive/_slides.py
| def list(self) -> list[dict[str, Any]]:
"""`[{index, id, layout, title, shape_count, has_notes}, ...]`."""
out: list[dict[str, Any]] = []
for slide in self:
out.append(
{
"index": slide.index,
"id": slide.id,
"layout": slide.layout_name,
"title": slide.title,
"shape_count": len(slide.shapes),
"has_notes": slide.has_notes(),
}
)
return out
|
pptlive.Slide
Slide(deck: Presentation, slide_com: Any)
Wraps a PowerPoint Slide COM object.
Source code in src/pptlive/_slides.py
| def __init__(self, deck: Presentation, slide_com: Any) -> None:
self._deck = deck
self._slide = slide_com
|
index
property
1-based position in the deck (SlideIndex). Shifts when slides move.
id
property
Stable SlideID — survives reordering, unlike index.
deck
property
The owning Presentation (e.g. for resolving sibling slides).
notes
property
The speaker-notes anchor (notes:S).
comments: CommentCollection
The slide's review comments (comments:S) — read + add/reply/delete.
headers_footers: HeadersFooters
This slide's footer / slide-number / date placeholders (a per-slide
override of the master default). See _headersfooters.HeadersFooters.
layout_name
property
The slide's custom-layout name (e.g. "Title and Content"), or None.
title
property
Text of the slide's title placeholder, or None if it has no title.
add_audio
add_audio(path: str | PathLike[str], **kwargs: Any) -> Shape
Insert an audio clip on this slide — self.shapes.add_audio(...).
The "narrate this slide" convenience; see
ShapeCollection.add_audio
for the keyword options (autoplay/hide_icon/pace_slide/link/…).
Source code in src/pptlive/_slides.py
| def add_audio(self, path: str | os.PathLike[str], **kwargs: Any) -> Shape:
"""Insert an audio clip on this slide — `self.shapes.add_audio(...)`.
The "narrate this slide" convenience; see
[`ShapeCollection.add_audio`][pptlive._shapes.ShapeCollection.add_audio]
for the keyword options (`autoplay`/`hide_icon`/`pace_slide`/`link`/…).
"""
return self.shapes.add_audio(path, **kwargs)
|
add_video
add_video(path: str | PathLike[str], **kwargs: Any) -> Shape
Insert a video clip on this slide — self.shapes.add_video(...).
See ShapeCollection.add_video
for the keyword options.
Source code in src/pptlive/_slides.py
| def add_video(self, path: str | os.PathLike[str], **kwargs: Any) -> Shape:
"""Insert a video clip on this slide — `self.shapes.add_video(...)`.
See [`ShapeCollection.add_video`][pptlive._shapes.ShapeCollection.add_video]
for the keyword options.
"""
return self.shapes.add_video(path, **kwargs)
|
placeholder
placeholder(kind: str) -> PlaceholderShape
Return the ph:S:KIND placeholder anchor (resolved live by kind).
KIND ∈ title, ctrtitle, subtitle, body, footer, date, slidenum. Raises
AnchorNotFoundError if the slide has no such placeholder.
Source code in src/pptlive/_slides.py
| def placeholder(self, kind: str) -> PlaceholderShape:
"""Return the `ph:S:KIND` placeholder anchor (resolved live by kind).
KIND ∈ title, ctrtitle, subtitle, body, footer, date, slidenum. Raises
`AnchorNotFoundError` if the slide has no such placeholder.
"""
# Resolve once now so a missing placeholder fails fast with a clean error;
# the returned anchor still re-resolves live on each use.
with _com.translate_com_errors():
self._find_placeholder(kind)
return PlaceholderShape(self, kind)
|
has_notes
Whether the slide has non-empty speaker notes.
Source code in src/pptlive/_slides.py
| def has_notes(self) -> bool:
"""Whether the slide has non-empty speaker notes."""
try:
return bool(self.notes.text.strip())
except PowerPointBusyError:
# A transient busy is retryable (exit 3); don't bury it as "no notes".
raise
except Exception:
# A missing notes body (AnchorNotFoundError) or any COM hiccup just
# means "no notes" for the purpose of a listing.
return False
|
read
Every shape on the slide plus its metadata — the slide read S payload.
Source code in src/pptlive/_slides.py
| def read(self) -> dict[str, Any]:
"""Every shape on the slide plus its metadata — the `slide read S` payload."""
return {
"index": self.index,
"id": self.id,
"layout": self.layout_name,
"title": self.title,
"transition": self.transition(),
"background": self.background(),
"animations": self.animations(),
"shapes": self.shapes.list(),
}
|
transition
transition() -> dict[str, Any]
The slide's entrance transition — {effect, duration, advance_on_click,
advance_on_time, advance_time}.
effect is the friendly PpEntryEffect name ("fade", "none", …);
duration is the transition animation length in seconds; the advance_*
fields describe auto-advance (advance_on_time + advance_time seconds)
vs. click-to-advance (advance_on_click). A read — no view move.
Source code in src/pptlive/_slides.py
| def transition(self) -> dict[str, Any]:
"""The slide's entrance transition — `{effect, duration, advance_on_click,
advance_on_time, advance_time}`.
`effect` is the friendly `PpEntryEffect` name (`"fade"`, `"none"`, …);
`duration` is the transition animation length in seconds; the `advance_*`
fields describe auto-advance (`advance_on_time` + `advance_time` seconds)
vs. click-to-advance (`advance_on_click`). A read — no view move.
"""
with _com.translate_com_errors():
t = self._slide.SlideShowTransition
return {
"effect": entry_effect_name(int(t.EntryEffect)),
"duration": float(t.Duration),
"advance_on_click": is_true(t.AdvanceOnClick),
"advance_on_time": is_true(t.AdvanceOnTime),
"advance_time": float(t.AdvanceTime),
}
|
animations
animations() -> list[dict[str, Any]]
The slide's shape animations, in play order — one row per effect.
Reads Slide.TimeLine.MainSequence: each row is {seq_index, shapeid,
shape, effect, exit, trigger, duration, delay} (see effect_to_dict),
ordered by seq_index (the 1-based position the effect plays in). The
shapeid maps each effect back to its target shape (drift-proof), so an
agent can tell what animates how without a render. Empty when the slide
has no animations. A read — no view move.
Source code in src/pptlive/_slides.py
| def animations(self) -> list[dict[str, Any]]:
"""The slide's shape animations, in play order — one row per effect.
Reads `Slide.TimeLine.MainSequence`: each row is `{seq_index, shapeid,
shape, effect, exit, trigger, duration, delay}` (see `effect_to_dict`),
ordered by `seq_index` (the 1-based position the effect plays in). The
`shapeid` maps each effect back to its target shape (drift-proof), so an
agent can tell *what* animates *how* without a render. Empty when the slide
has no animations. A read — no view move.
"""
idx = self.index
with _com.translate_com_errors():
seq = self._slide.TimeLine.MainSequence
count = int(seq.Count)
return [{"seq_index": i, **effect_to_dict(seq(i), idx)} for i in range(1, count + 1)]
|
clear_animations
clear_animations(anchor: Shape | None = None) -> int
Remove animation effects from the slide; return how many were deleted.
With anchor=None (the default) wipes the whole slide's animation
sequence; pass a Shape to remove only the effects targeting that shape
(matched by stable Shape.Id, so a restack is irrelevant). Deletes from the
end of the sequence so the live indices don't shift mid-loop. A no-op
(returns 0) when there's nothing to remove. A mutation: wrap in
deck.edit(...).
Source code in src/pptlive/_slides.py
| def clear_animations(self, anchor: Shape | None = None) -> int:
"""Remove animation effects from the slide; return how many were deleted.
With `anchor=None` (the default) wipes the **whole** slide's animation
sequence; pass a `Shape` to remove only the effects targeting that shape
(matched by stable `Shape.Id`, so a restack is irrelevant). Deletes from the
end of the sequence so the live indices don't shift mid-loop. A no-op
(returns 0) when there's nothing to remove. A mutation: wrap in
`deck.edit(...)`.
"""
target_id = None if anchor is None else anchor.shape_id # COM read before the loop
with _com.translate_com_errors():
seq = self._slide.TimeLine.MainSequence
removed = 0
for i in range(int(seq.Count), 0, -1):
eff = seq(i)
if target_id is None or int(eff.Shape.Id) == target_id:
eff.Delete()
removed += 1
return removed
|
background
background() -> dict[str, Any]
The slide's background — {follows_master, type, color}.
follows_master is True when the slide inherits the master/layout
background (the default); when False it carries its own {type, color}
fill (set via set_background). A read — no view move.
Source code in src/pptlive/_slides.py
| def background(self) -> dict[str, Any]:
"""The slide's background — `{follows_master, type, color}`.
`follows_master` is True when the slide inherits the master/layout
background (the default); when False it carries its own `{type, color}`
fill (set via `set_background`). A read — no view move.
"""
with _com.translate_com_errors():
follows = is_true(self._slide.FollowMasterBackground)
bg = background_to_dict(self._slide)
return {"follows_master": follows, **bg}
|
geometry_report
geometry_report() -> dict[str, Any]
A geometry-only spatial map of the slide — catch overlaps and off-slide
shapes before rendering.
Returns the slide size (points) and, per shape, its bounding box
(left/top/right/bottom/width/height) plus an off_slide flag,
then the list of overlaps (shape pairs whose boxes intersect, largest
area first) and the off_slide anchor ids. The point of it is the feedback
loop the snapshot can't give cheaply: an agent that just placed an arrow or
a card can see "shape:5:3 overlaps shape:5:4" or "the arrow runs off the
right edge" without a render round-trip or float math.
Pure axis-aligned geometry on the boxes PowerPoint reports — shape
rotation is not accounted for (the box is the unrotated extent), so a
rotated shape's overlap / bounds are approximate (each shape carries its
rotation so the caller can judge). A read — no view move.
Source code in src/pptlive/_slides.py
| def geometry_report(self) -> dict[str, Any]:
"""A geometry-only spatial map of the slide — catch overlaps and off-slide
shapes *before* rendering.
Returns the slide size (points) and, per shape, its bounding `box`
(`left`/`top`/`right`/`bottom`/`width`/`height`) plus an `off_slide` flag,
then the list of `overlaps` (shape pairs whose boxes intersect, largest
area first) and the `off_slide` anchor ids. The point of it is the feedback
loop the snapshot can't give cheaply: an agent that just placed an arrow or
a card can see "shape:5:3 overlaps shape:5:4" or "the arrow runs off the
right edge" without a render round-trip or float math.
Pure axis-aligned geometry on the boxes PowerPoint reports — shape
**rotation is not accounted for** (the box is the unrotated extent), so a
rotated shape's overlap / bounds are approximate (each shape carries its
`rotation` so the caller can judge). A read — no view move.
"""
with _com.translate_com_errors():
ps = self._deck.com.PageSetup
width, height = float(ps.SlideWidth), float(ps.SlideHeight)
boxes: list[tuple[dict[str, Any], float, float, float, float]] = []
shapes_out: list[dict[str, Any]] = []
for s in self.shapes.list():
geo = s.get("geometry")
if not geo:
continue
left, top = float(geo["left"]), float(geo["top"])
w, h = float(geo["width"]), float(geo["height"])
right, bottom = left + w, top + h
off = left < 0 or top < 0 or right > width or bottom > height
entry = {
"anchor_id": s["anchor_id"],
"shapeid": s.get("shapeid"),
"name": s["name"],
"id": s["id"],
"box": {
"left": left,
"top": top,
"right": right,
"bottom": bottom,
"width": w,
"height": h,
},
"rotation": float(geo.get("rotation", 0.0)),
"off_slide": off,
}
shapes_out.append(entry)
boxes.append((entry, left, top, right, bottom))
overlaps: list[dict[str, Any]] = []
for i in range(len(boxes)):
ei, l1, t1, r1, b1 = boxes[i]
for j in range(i + 1, len(boxes)):
ej, l2, t2, r2, b2 = boxes[j]
ix = min(r1, r2) - max(l1, l2)
iy = min(b1, b2) - max(t1, t2)
if ix > 0 and iy > 0:
overlaps.append(
{
"a": ei["anchor_id"],
"b": ej["anchor_id"],
"a_name": ei["name"],
"b_name": ej["name"],
"area": ix * iy,
}
)
overlaps.sort(key=lambda o: o["area"], reverse=True)
return {
"slide": self.index,
"slide_size": {"width": width, "height": height},
"shapes": shapes_out,
"overlaps": overlaps,
"off_slide": [s["anchor_id"] for s in shapes_out if s["off_slide"]],
}
|
export_image
export_image(path: str | PathLike[str] | None = None, *, width: int | None = None, height: int | None = None, fmt: str = 'png') -> Path
Render the slide to an image file and return its absolute path.
Wraps Slide.Export(FileName, FilterName, ScaleWidth, ScaleHeight). The
export renders the slide's current in-memory state — unsaved edits
included — so an agent can edit over COM and immediately see the result;
and it's polite (it doesn't move the viewed slide or change the Selection).
fmt is a friendly token (png/jpg/gif/bmp/tiff; see
constants.IMAGE_FORMAT_CHOICES). When path is None a temp file is
created (so export-then-read is one step). width/height are output
pixels; pass one and the other is filled from the slide's aspect
ratio, pass neither for the slide's native pixel size. A relative path
is resolved to absolute first — PowerPoint otherwise drops the file in
its own working directory, not the caller's.
Source code in src/pptlive/_slides.py
| def export_image(
self,
path: str | os.PathLike[str] | None = None,
*,
width: int | None = None,
height: int | None = None,
fmt: str = "png",
) -> Path:
"""Render the slide to an image file and return its absolute path.
Wraps `Slide.Export(FileName, FilterName, ScaleWidth, ScaleHeight)`. The
export renders the slide's **current in-memory state** — unsaved edits
included — so an agent can edit over COM and immediately *see* the result;
and it's polite (it doesn't move the viewed slide or change the Selection).
`fmt` is a friendly token (`png`/`jpg`/`gif`/`bmp`/`tiff`; see
`constants.IMAGE_FORMAT_CHOICES`). When `path` is None a temp file is
created (so export-then-read is one step). `width`/`height` are output
**pixels**; pass one and the other is filled from the slide's aspect
ratio, pass neither for the slide's native pixel size. A relative `path`
is resolved to absolute first — PowerPoint otherwise drops the file in
its own working directory, not the caller's.
"""
filter_name, ext = image_filter_for(fmt) # ValueError before any COM
if path is None:
fd, tmp = tempfile.mkstemp(prefix="pptlive_slide_", suffix=f".{ext}")
os.close(fd)
os.remove(tmp) # hand PowerPoint a clean path to write
abs_path = tmp
else:
abs_path = os.path.abspath(os.fspath(path))
with _com.translate_com_errors():
w, h = self._export_dims(width, height)
if w is not None and h is not None:
self._slide.Export(abs_path, filter_name, int(round(w)), int(round(h)))
else:
self._slide.Export(abs_path, filter_name)
return Path(abs_path)
|
delete
Delete this slide from the deck (Slide.Delete). The wrapper is spent.
Source code in src/pptlive/_slides.py
| def delete(self) -> None:
"""Delete this slide from the deck (`Slide.Delete`). The wrapper is spent."""
with _com.translate_com_errors():
self._slide.Delete()
|
duplicate
Duplicate this slide; return the copy (inserted immediately after).
Wraps Slide.Duplicate, which yields a one-item SlideRange. The copy
gets a fresh SlideID; everything after the original shifts down by one.
Source code in src/pptlive/_slides.py
| def duplicate(self) -> Slide:
"""Duplicate this slide; return the copy (inserted immediately after).
Wraps `Slide.Duplicate`, which yields a one-item `SlideRange`. The copy
gets a fresh `SlideID`; everything after the original shifts down by one.
"""
with _com.translate_com_errors():
new_range = self._slide.Duplicate()
new_com = new_range(1)
return Slide(self._deck, new_com)
|
move_to
move_to(index: int) -> Slide
Move this slide to 1-based position index (Slide.MoveTo); return self.
The wrapper keeps pointing at the same slide, which now reports the new
index. Raises SlideNotFoundError if index is out of range (1..count).
Source code in src/pptlive/_slides.py
| def move_to(self, index: int) -> Slide:
"""Move this slide to 1-based position `index` (`Slide.MoveTo`); return self.
The wrapper keeps pointing at the same slide, which now reports the new
`index`. Raises `SlideNotFoundError` if `index` is out of range (1..count).
"""
if isinstance(index, bool) or not isinstance(index, int):
raise TypeError(f"index must be int, got {type(index).__name__}")
count = len(self._deck.slides)
if index < 1 or index > count:
raise SlideNotFoundError(index)
with _com.translate_com_errors():
self._slide.MoveTo(index)
return self
|
set_layout
set_layout(layout: str | int) -> Slide
Re-apply a slide layout by friendly name or 1-based index; return self.
Resolves layout to a CustomLayout (see Presentation._resolve_layout)
and assigns Slide.CustomLayout. Raises LayoutNotFoundError (listing
the deck's layout names) for an unknown layout.
Source code in src/pptlive/_slides.py
| def set_layout(self, layout: str | int) -> Slide:
"""Re-apply a slide layout by friendly name or 1-based index; return self.
Resolves `layout` to a `CustomLayout` (see `Presentation._resolve_layout`)
and assigns `Slide.CustomLayout`. Raises `LayoutNotFoundError` (listing
the deck's layout names) for an unknown layout.
"""
custom = self._deck._resolve_layout(layout)
if custom is None:
raise LayoutNotFoundError(str(layout), [])
with _com.translate_com_errors():
self._slide.CustomLayout = custom
return self
|
set_transition
set_transition(effect: str | int | None = None, *, duration: float | None = None, advance_after: float | None = None, advance_on_click: bool | None = None) -> dict[str, Any]
Set the slide's entrance transition; return the resulting transition dict.
effect is a friendly PpEntryEffect name ("fade", "cut",
"cover_left", … — see constants.ENTRY_EFFECT_CHOICES) or a raw int.
duration is the transition animation length in seconds. advance_after
is the auto-advance delay in seconds — passing it sets both
AdvanceOnTime=msoTrue and AdvanceTime (the spike confirmed both are
needed); pass 0 to keep the timing but require a click. advance_on_click
toggles click-to-advance independently. Only the kwargs passed are written.
Raises ValueError (before any COM) for an unknown effect name or if
nothing is passed. A mutation: wrap in deck.edit(...).
Source code in src/pptlive/_slides.py
| def set_transition(
self,
effect: str | int | None = None,
*,
duration: float | None = None,
advance_after: float | None = None,
advance_on_click: bool | None = None,
) -> dict[str, Any]:
"""Set the slide's entrance transition; return the resulting transition dict.
`effect` is a friendly `PpEntryEffect` name (`"fade"`, `"cut"`,
`"cover_left"`, … — see `constants.ENTRY_EFFECT_CHOICES`) or a raw int.
`duration` is the transition animation length in seconds. `advance_after`
is the auto-advance delay in seconds — passing it sets **both**
`AdvanceOnTime=msoTrue` and `AdvanceTime` (the spike confirmed both are
needed); pass `0` to keep the timing but require a click. `advance_on_click`
toggles click-to-advance independently. Only the kwargs passed are written.
Raises `ValueError` (before any COM) for an unknown effect name or if
nothing is passed. A mutation: wrap in `deck.edit(...)`.
"""
if (
effect is None
and duration is None
and advance_after is None
and advance_on_click is None
):
raise ValueError(
"set_transition() requires at least one of effect, duration, "
"advance_after, or advance_on_click"
)
effect_int = entry_effect_for(effect) if effect is not None else None # ValueError first
# Durations are seconds; reject negatives before any COM (PowerPoint would
# either clamp silently or emit an opaque COM error). advance_after=0 is
# valid — it keeps the timing but requires a click.
if duration is not None and float(duration) < 0:
raise ValueError(f"duration must be non-negative seconds, got {duration!r}")
if advance_after is not None and float(advance_after) < 0:
raise ValueError(f"advance_after must be non-negative seconds, got {advance_after!r}")
with _com.translate_com_errors():
t = self._slide.SlideShowTransition
if effect_int is not None:
t.EntryEffect = effect_int
if duration is not None:
t.Duration = float(duration)
if advance_after is not None:
# Auto-advance needs the flag AND the seconds (spike finding).
t.AdvanceOnTime = int(MsoTriState.TRUE)
t.AdvanceTime = float(advance_after)
if advance_on_click is not None:
t.AdvanceOnClick = (
int(MsoTriState.TRUE) if advance_on_click else int(MsoTriState.FALSE)
)
return self.transition()
|
set_background
set_background(color: str | int | tuple[int, int, int]) -> dict[str, Any]
Give the slide its own solid background color; return the background dict.
The per-slide override of the deck-wide master background (deck.master.
set_background). color is "#RRGGBB", an (r, g, b) tuple, or a raw RGB
int. Sets FollowMasterBackground=msoFalse then a solid fill of that color.
Raises ValueError for a bad color (before any COM). Revert with
follow_master_background(). A mutation: wrap in deck.edit(...).
Source code in src/pptlive/_slides.py
| def set_background(self, color: str | int | tuple[int, int, int]) -> dict[str, Any]:
"""Give the slide its own solid background color; return the background dict.
The per-slide override of the deck-wide master background (`deck.master.
set_background`). `color` is `"#RRGGBB"`, an `(r, g, b)` tuple, or a raw RGB
int. Sets `FollowMasterBackground=msoFalse` then a solid fill of that color.
Raises `ValueError` for a bad color (before any COM). Revert with
`follow_master_background()`. A mutation: wrap in `deck.edit(...)`.
"""
rgb = parse_color(color) # ValueError before any COM
with _com.translate_com_errors():
self._slide.FollowMasterBackground = int(MsoTriState.FALSE)
fill = self._slide.Background.Fill
fill.Solid()
fill.ForeColor.RGB = rgb
return self.background()
|
follow_master_background
follow_master_background() -> dict[str, Any]
Drop any per-slide background override and inherit the master's again.
Sets FollowMasterBackground=msoTrue (the spike-verified revert). A
mutation: wrap in deck.edit(...).
Source code in src/pptlive/_slides.py
| def follow_master_background(self) -> dict[str, Any]:
"""Drop any per-slide background override and inherit the master's again.
Sets `FollowMasterBackground=msoTrue` (the spike-verified revert). A
mutation: wrap in `deck.edit(...)`.
"""
with _com.translate_com_errors():
self._slide.FollowMasterBackground = int(MsoTriState.TRUE)
return self.background()
|
Shapes & geometry
Slide.shapes is a ShapeCollection — index by
1-based z-order (shapes[2]) or by name (shapes["Title 1"]), and create with
add_textbox / add_shape / add_picture / add_table / add_chart. A
Shape is an Anchor when it has a
text frame (so it inherits text / set_text / format_text / the list and
paragraph verbs), and always carries geometry (move, resize, geometry())
in points, plus alt_text / set_alt_text and per-shape
export_image(...). Every shape also carries a stable shapeid (shapeid:S:ID,
the delete-proof handle) alongside its z-order anchor_id.
A shape can also animate: Shape.animate(effect="fade", *, trigger="on_click",
duration=None, delay=None, exit=False) appends a whole-shape entrance (or, with
exit=True, exit) effect to the slide's main sequence, and
Shape.clear_animations() removes just that shape's effects. Read them back per
slide with Slide.animations() (ordered rows, each mapped to its
target by shapeid) and wipe a whole slide with Slide.clear_animations(). A
slide's spatial layout is available without a render via
Slide.geometry_report() (slide size + per-shape boxes +
overlaps + off-slide flags).
pptlive.ShapeCollection
ShapeCollection(slide: Slide)
Indexable, iterable view over a slide's shapes.
Index by 1-based z-order (slide.shapes[2]) or by name
(slide.shapes["Title 1"]). Iteration yields a Shape per shape in
z-order. list() emits the structured dict used by slide read.
Source code in src/pptlive/_shapes.py
| def __init__(self, slide: Slide) -> None:
self._slide = slide
|
by_id
by_id(shape_id: int) -> ShapeById
A shape addressed by its stable Shape.Id (shapeid:S:ID) — delete-proof.
Unlike slide.shapes[N] (z-order index) or slide.shapes["Name"], the id
survives a delete/restack that renumbers indices (PPTLIVE-010). Verifies
the id exists now (raising AnchorNotFoundError if not), then returns a
ShapeById that re-resolves live on each use.
Source code in src/pptlive/_shapes.py
| def by_id(self, shape_id: int) -> ShapeById:
"""A shape addressed by its stable `Shape.Id` (`shapeid:S:ID`) — delete-proof.
Unlike `slide.shapes[N]` (z-order index) or `slide.shapes["Name"]`, the id
survives a delete/restack that renumbers indices (PPTLIVE-010). Verifies
the id exists now (raising `AnchorNotFoundError` if not), then returns a
`ShapeById` that re-resolves live on each use.
"""
handle = ShapeById(self._slide, shape_id)
with _com.translate_com_errors():
handle._com_shape() # eager existence check (clean exit-2 if absent)
return handle
|
group
group(shapes: Sequence[Shape]) -> ShapeById
Group two or more shapes into a single group shape; return its handle.
Shapes.Range([...]).Group() combines the shapes; the new group gets a
fresh Shape.Id (so this returns a ShapeById for it), while the
members keep their own ids inside group.GroupItems (read back in the
group's group_item_ids) — verified in scripts/arrangement_spike.py.
Reverse with Shape.ungroup.
Raises ValueError (before any COM) for fewer than two shapes; an unknown
member raises AnchorNotFoundError. A mutation: wrap in deck.edit(...).
Source code in src/pptlive/_shapes.py
| def group(self, shapes: Sequence[Shape]) -> ShapeById:
"""Group two or more shapes into a single group shape; return its handle.
`Shapes.Range([...]).Group()` combines the shapes; the new group gets a
**fresh `Shape.Id`** (so this returns a `ShapeById` for it), while the
members keep their own ids inside `group.GroupItems` (read back in the
group's `group_item_ids`) — verified in `scripts/arrangement_spike.py`.
Reverse with `Shape.ungroup`.
Raises `ValueError` (before any COM) for fewer than two shapes; an unknown
member raises `AnchorNotFoundError`. A mutation: wrap in `deck.edit(...)`.
"""
members = list(shapes)
if len(members) < 2:
raise ValueError(f"group() needs at least 2 shapes, got {len(members)}")
with _com.translate_com_errors():
indices = self._range_indices(members)
group = self._com_collection.Range(indices).Group()
group_id = int(group.Id)
return ShapeById(self._slide, group_id)
|
align
align(shapes: Sequence[Shape], how: str | int, *, relative_to: str | int | bool = 'slide') -> None
Align a set of shapes to a common edge / center.
how is "left"/"center"/"right" (horizontal) or "top"/"middle"/
"bottom" (vertical) — see constants.ALIGN_CHOICES. relative_to is
"slide" (align against the slide, the default) or "selection" (align
the shapes to one another). Shapes.Range([...]).Align(cmd, RelativeTo).
Raises ValueError (before any COM) for an unknown how/relative_to, an
empty set, or a selection-relative align of fewer than two shapes. A
mutation: wrap in deck.edit(...).
Source code in src/pptlive/_shapes.py
| def align(
self, shapes: Sequence[Shape], how: str | int, *, relative_to: str | int | bool = "slide"
) -> None:
"""Align a set of shapes to a common edge / center.
`how` is `"left"`/`"center"`/`"right"` (horizontal) or `"top"`/`"middle"`/
`"bottom"` (vertical) — see `constants.ALIGN_CHOICES`. `relative_to` is
`"slide"` (align against the slide, the default) or `"selection"` (align
the shapes to one another). `Shapes.Range([...]).Align(cmd, RelativeTo)`.
Raises `ValueError` (before any COM) for an unknown `how`/`relative_to`, an
empty set, or a selection-relative align of fewer than two shapes. A
mutation: wrap in `deck.edit(...)`.
"""
cmd = align_cmd_for(how) # ValueError before any COM
rel = relative_to_for(relative_to)
members = list(shapes)
if not members:
raise ValueError("align() needs at least one shape")
if rel == int(MsoTriState.FALSE) and len(members) < 2:
raise ValueError("aligning relative to the selection needs at least 2 shapes")
with _com.translate_com_errors():
indices = self._range_indices(members)
self._com_collection.Range(indices).Align(cmd, rel)
|
distribute
distribute(shapes: Sequence[Shape], how: str | int, *, relative_to: str | int | bool = 'slide') -> None
Space a set of shapes evenly on one axis.
how is "horizontal" or "vertical" (constants.DISTRIBUTE_CHOICES);
relative_to is "slide" (default) or "selection".
Shapes.Range([...]).Distribute(cmd, RelativeTo). Distributing is only
meaningful for three or more shapes (the two outermost pin the span and the
rest are evenly spaced between them).
Raises ValueError (before any COM) for an unknown how/relative_to or
fewer than three shapes. A mutation: wrap in deck.edit(...).
Source code in src/pptlive/_shapes.py
| def distribute(
self, shapes: Sequence[Shape], how: str | int, *, relative_to: str | int | bool = "slide"
) -> None:
"""Space a set of shapes evenly on one axis.
`how` is `"horizontal"` or `"vertical"` (`constants.DISTRIBUTE_CHOICES`);
`relative_to` is `"slide"` (default) or `"selection"`.
`Shapes.Range([...]).Distribute(cmd, RelativeTo)`. Distributing is only
meaningful for three or more shapes (the two outermost pin the span and the
rest are evenly spaced between them).
Raises `ValueError` (before any COM) for an unknown `how`/`relative_to` or
fewer than three shapes. A mutation: wrap in `deck.edit(...)`.
"""
cmd = distribute_cmd_for(how) # ValueError before any COM
rel = relative_to_for(relative_to)
members = list(shapes)
if len(members) < 3:
raise ValueError(f"distribute() needs at least 3 shapes, got {len(members)}")
with _com.translate_com_errors():
indices = self._range_indices(members)
self._com_collection.Range(indices).Distribute(cmd, rel)
|
add_connector
add_connector(connector_type: str | int = 'straight', *, begin: Shape | None = None, end: Shape | None = None, begin_site: int = 1, end_site: int = 1, left: float | None = None, top: float | None = None, width: float | None = None, height: float | None = None) -> ShapeById
Add a connector line and return its handle (Shapes.AddConnector).
Two forms:
- Attached (primary): pass
begin= and end= shape handles to glue the
two ends to those shapes (via ConnectorFormat.BeginConnect /
EndConnect + RerouteConnections), so the line follows them when they
move. begin_site / end_site request a 1-based connection site on each
shape, but are advisory — RerouteConnections() re-chooses the
shortest sites (spike finding), and the resulting glue reads back under
the shape's connector field.
- Geometry: omit both shapes and pass explicit
left/top/width/
height (points) to draw a free-floating connector across that box.
connector_type is "straight"/"elbow"/"curved"
(constants.CONNECTOR_CHOICES). Raises ValueError (before any COM) for a
bad type, an out-of-range site, or an incomplete spec (need both begin and
end, or full explicit geometry). A mutation: wrap in deck.edit(...).
Source code in src/pptlive/_shapes.py
| def add_connector(
self,
connector_type: str | int = "straight",
*,
begin: Shape | None = None,
end: Shape | None = None,
begin_site: int = 1,
end_site: int = 1,
left: float | None = None,
top: float | None = None,
width: float | None = None,
height: float | None = None,
) -> ShapeById:
"""Add a connector line and return its handle (`Shapes.AddConnector`).
Two forms:
- **Attached (primary):** pass `begin=` and `end=` shape handles to glue the
two ends to those shapes (via `ConnectorFormat.BeginConnect` /
`EndConnect` + `RerouteConnections`), so the line follows them when they
move. `begin_site` / `end_site` request a 1-based connection site on each
shape, but are **advisory** — `RerouteConnections()` re-chooses the
shortest sites (spike finding), and the resulting glue reads back under
the shape's `connector` field.
- **Geometry:** omit both shapes and pass explicit `left`/`top`/`width`/
`height` (points) to draw a free-floating connector across that box.
`connector_type` is `"straight"`/`"elbow"`/`"curved"`
(`constants.CONNECTOR_CHOICES`). Raises `ValueError` (before any COM) for a
bad type, an out-of-range site, or an incomplete spec (need both `begin` and
`end`, or full explicit geometry). A mutation: wrap in `deck.edit(...)`.
"""
type_int = connector_type_for(connector_type) # ValueError before any COM
if (begin is None) != (end is None):
raise ValueError("add_connector() needs BOTH begin= and end= (or neither)")
attaching = begin is not None and end is not None
if not attaching and None in (left, top, width, height):
raise ValueError(
"add_connector() needs begin= and end= shapes, or explicit "
"left/top/width/height geometry"
)
with _com.translate_com_errors():
if attaching:
assert begin is not None and end is not None # narrowed above
begin_com = begin._com_shape()
end_com = end._com_shape()
_check_site(begin_com, begin_site, "begin_site")
_check_site(end_com, end_site, "end_site")
x1, y1 = _center_of(begin_com)
x2, y2 = _center_of(end_com)
else:
x1, y1 = float(left), float(top) # type: ignore[arg-type]
x2 = float(left) + float(width) # type: ignore[arg-type]
y2 = float(top) + float(height) # type: ignore[arg-type]
conn = self._com_collection.AddConnector(type_int, x1, y1, x2, y2)
conn_id = int(conn.Id)
if attaching:
cf = conn.ConnectorFormat
cf.BeginConnect(begin_com, int(begin_site))
cf.EndConnect(end_com, int(end_site))
conn.RerouteConnections()
return ShapeById(self._slide, conn_id)
|
add_textbox
add_textbox(text: str = '', *, left: float | None = None, top: float | None = None, width: float | None = None, height: float | None = None, fill: str | int | tuple[int, int, int] | None = None, line: str | int | tuple[int, int, int] | None = None, line_width: float | None = None) -> Shape
Add a horizontal text box and return it (Shapes.AddTextbox).
Geometry is in points; omitted values default to a 4×1 in box near the
top-left. text, if given, is written into the new frame. fill/line
set a solid fill / border color (or "none" for transparent / no border)
and line_width the border weight in points — see Shape.set_fill. A
text box defaults to no fill and no line. Raises ValueError for a bad
color before any COM.
Source code in src/pptlive/_shapes.py
| def add_textbox(
self,
text: str = "",
*,
left: float | None = None,
top: float | None = None,
width: float | None = None,
height: float | None = None,
fill: str | int | tuple[int, int, int] | None = None,
line: str | int | tuple[int, int, int] | None = None,
line_width: float | None = None,
) -> Shape:
"""Add a horizontal text box and return it (`Shapes.AddTextbox`).
Geometry is in points; omitted values default to a 4×1 in box near the
top-left. `text`, if given, is written into the new frame. `fill`/`line`
set a solid fill / border color (or `"none"` for transparent / no border)
and `line_width` the border weight in points — see `Shape.set_fill`. A
text box defaults to no fill and no line. Raises `ValueError` for a bad
color before any COM.
"""
_precheck_fill(fill, line) # ValueError before any COM
left = _DEFAULT_LEFT if left is None else float(left)
top = _DEFAULT_TOP if top is None else float(top)
width = _DEFAULT_TEXTBOX_WIDTH if width is None else float(width)
height = _DEFAULT_TEXTBOX_HEIGHT if height is None else float(height)
with _com.translate_com_errors():
com_shape = self._com_collection.AddTextbox(
int(MsoTextOrientation.HORIZONTAL), left, top, width, height
)
if text:
com_shape.TextFrame.TextRange.Text = text
if fill is not None or line is not None or line_width is not None:
apply_shape_fill(com_shape, fill=fill, line=line, line_width=line_width)
return self._added()
|
add_shape
add_shape(shape_type: str | int, *, left: float | None = None, top: float | None = None, width: float | None = None, height: float | None = None, fill: str | int | tuple[int, int, int] | None = None, line: str | int | tuple[int, int, int] | None = None, line_width: float | None = None) -> Shape
Add an autoshape and return it (Shapes.AddShape).
shape_type is a friendly name ("rectangle", "oval", "arrow", …;
see constants.AUTOSHAPE_CHOICES) or a raw MsoAutoShapeType int.
Geometry is in points; omitted values default to a 2×2 in box near the
top-left. fill/line set a solid fill / border color (or "none" for
transparent / no border) and line_width the border weight in points —
see Shape.set_fill; omitted, the shape takes the theme's default accent
fill. Raises ValueError for an unknown shape name or bad color (before
any COM).
Source code in src/pptlive/_shapes.py
| def add_shape(
self,
shape_type: str | int,
*,
left: float | None = None,
top: float | None = None,
width: float | None = None,
height: float | None = None,
fill: str | int | tuple[int, int, int] | None = None,
line: str | int | tuple[int, int, int] | None = None,
line_width: float | None = None,
) -> Shape:
"""Add an autoshape and return it (`Shapes.AddShape`).
`shape_type` is a friendly name (`"rectangle"`, `"oval"`, `"arrow"`, …;
see `constants.AUTOSHAPE_CHOICES`) or a raw `MsoAutoShapeType` int.
Geometry is in points; omitted values default to a 2×2 in box near the
top-left. `fill`/`line` set a solid fill / border color (or `"none"` for
transparent / no border) and `line_width` the border weight in points —
see `Shape.set_fill`; omitted, the shape takes the theme's default accent
fill. Raises `ValueError` for an unknown shape name or bad color (before
any COM).
"""
type_int = autoshape_type_for(shape_type) # ValueError before COM
_precheck_fill(fill, line) # ValueError before COM
left = _DEFAULT_LEFT if left is None else float(left)
top = _DEFAULT_TOP if top is None else float(top)
width = _DEFAULT_SHAPE_WIDTH if width is None else float(width)
height = _DEFAULT_SHAPE_HEIGHT if height is None else float(height)
with _com.translate_com_errors():
com_shape = self._com_collection.AddShape(type_int, left, top, width, height)
if fill is not None or line is not None or line_width is not None:
apply_shape_fill(com_shape, fill=fill, line=line, line_width=line_width)
return self._added()
|
add_picture
add_picture(path: str | PathLike[str], *, left: float | None = None, top: float | None = None, width: float | None = None, height: float | None = None, alt_text: str | None = None) -> Shape
Embed a picture from a local file and return it (Shapes.AddPicture).
The image is embedded, never linked (so the deck stays portable).
left/top default to the top-left; omitted width/height keep the
image's native size. alt_text, if given, sets the picture's
alternative text — a drift-proof, LLM-readable re-identification handle
(see Shape.alt_text). Raises FileNotFoundError if path doesn't exist.
Source code in src/pptlive/_shapes.py
| def add_picture(
self,
path: str | os.PathLike[str],
*,
left: float | None = None,
top: float | None = None,
width: float | None = None,
height: float | None = None,
alt_text: str | None = None,
) -> Shape:
"""Embed a picture from a local file and return it (`Shapes.AddPicture`).
The image is **embedded**, never linked (so the deck stays portable).
`left`/`top` default to the top-left; omitted `width`/`height` keep the
image's native size. `alt_text`, if given, sets the picture's
alternative text — a drift-proof, LLM-readable re-identification handle
(see `Shape.alt_text`). Raises `FileNotFoundError` if `path` doesn't exist.
"""
fs_path = os.fspath(path)
if not os.path.isfile(fs_path):
raise FileNotFoundError(f"picture not found: {fs_path}")
abs_path = os.path.abspath(fs_path)
left = _DEFAULT_LEFT if left is None else float(left)
top = _DEFAULT_TOP if top is None else float(top)
with _com.translate_com_errors():
com_shape = self._com_collection.AddPicture(
abs_path,
int(MsoTriState.FALSE), # LinkToFile: no
int(MsoTriState.TRUE), # SaveWithDocument: yes (embed)
left,
top,
-1.0 if width is None else float(width), # -1 = native size
-1.0 if height is None else float(height),
)
if alt_text is not None:
com_shape.AlternativeText = str(alt_text)
return self._added()
|
add_audio
add_audio(path: str | PathLike[str], *, left: float | None = None, top: float | None = None, width: float | None = None, height: float | None = None, link: bool = False, autoplay: bool = True, hide_icon: bool = True, pace_slide: bool = True, alt_text: str | None = None) -> Shape
Insert an audio clip and return its Shape (Shapes.AddMediaObject2).
The narration path: the clip is embedded (set link=True to keep it on
disk and shrink the deck). autoplay plays it on slide entry, hide_icon
hides the speaker icon while it isn't playing, and pace_slide sets the
slide to auto-advance to the clip's length (so an exported video paces
itself to the narration). Geometry is in points; omitted values default to a
small icon box near the top-left. The shape's .has_media is True. Raises
FileNotFoundError if path doesn't exist (before any COM). Wrap in
deck.edit(...) for the one-Ctrl-Z fence.
Source code in src/pptlive/_shapes.py
| def add_audio(
self,
path: str | os.PathLike[str],
*,
left: float | None = None,
top: float | None = None,
width: float | None = None,
height: float | None = None,
link: bool = False,
autoplay: bool = True,
hide_icon: bool = True,
pace_slide: bool = True,
alt_text: str | None = None,
) -> Shape:
"""Insert an audio clip and return its `Shape` (`Shapes.AddMediaObject2`).
The narration path: the clip is **embedded** (set `link=True` to keep it on
disk and shrink the deck). `autoplay` plays it on slide entry, `hide_icon`
hides the speaker icon while it isn't playing, and `pace_slide` sets the
slide to auto-advance to the clip's length (so an exported video paces
itself to the narration). Geometry is in points; omitted values default to a
small icon box near the top-left. The shape's `.has_media` is True. Raises
`FileNotFoundError` if `path` doesn't exist (before any COM). Wrap in
`deck.edit(...)` for the one-Ctrl-Z fence.
"""
return self._add_media(
path,
left=left,
top=top,
width=width,
height=height,
default_width=_DEFAULT_AUDIO_WIDTH,
default_height=_DEFAULT_AUDIO_HEIGHT,
link=link,
autoplay=autoplay,
hide_icon=hide_icon,
pace_slide=pace_slide,
alt_text=alt_text,
)
|
add_video
add_video(path: str | PathLike[str], *, left: float | None = None, top: float | None = None, width: float | None = None, height: float | None = None, link: bool = False, autoplay: bool = True, pace_slide: bool = True, alt_text: str | None = None) -> Shape
Insert a video clip and return its Shape (Shapes.AddMediaObject2).
Like add_audio, but the clip stays visible (there is no hide_icon — a
video frame is meant to be seen). Geometry is in points; omitted values
default to a 16:9 frame near the top-left. autoplay plays it on slide
entry; pace_slide auto-advances the slide to the clip length. The shape's
.has_media is True. Raises FileNotFoundError if path doesn't exist
(before any COM). Wrap in deck.edit(...) for the one-Ctrl-Z fence.
Source code in src/pptlive/_shapes.py
| def add_video(
self,
path: str | os.PathLike[str],
*,
left: float | None = None,
top: float | None = None,
width: float | None = None,
height: float | None = None,
link: bool = False,
autoplay: bool = True,
pace_slide: bool = True,
alt_text: str | None = None,
) -> Shape:
"""Insert a video clip and return its `Shape` (`Shapes.AddMediaObject2`).
Like `add_audio`, but the clip stays visible (there is no `hide_icon` — a
video frame is meant to be seen). Geometry is in points; omitted values
default to a 16:9 frame near the top-left. `autoplay` plays it on slide
entry; `pace_slide` auto-advances the slide to the clip length. The shape's
`.has_media` is True. Raises `FileNotFoundError` if `path` doesn't exist
(before any COM). Wrap in `deck.edit(...)` for the one-Ctrl-Z fence.
"""
return self._add_media(
path,
left=left,
top=top,
width=width,
height=height,
default_width=_DEFAULT_VIDEO_WIDTH,
default_height=_DEFAULT_VIDEO_HEIGHT,
link=link,
autoplay=autoplay,
hide_icon=False,
pace_slide=pace_slide,
alt_text=alt_text,
)
|
add_table
add_table(rows: int, columns: int, *, left: float | None = None, top: float | None = None, width: float | None = None, height: float | None = None) -> Shape
Add a rows×columns table and return its Shape (Shapes.AddTable).
Geometry is in points; omitted values default to a wide grid near the
top-left (height is advisory — PowerPoint auto-fits rows to content).
Address cells through the returned shape's .table or the cell:S:N:R:C
anchor; the shape's .has_table is True. Raises ValueError for
non-positive rows/columns (before any COM).
Source code in src/pptlive/_shapes.py
| def add_table(
self,
rows: int,
columns: int,
*,
left: float | None = None,
top: float | None = None,
width: float | None = None,
height: float | None = None,
) -> Shape:
"""Add a `rows`×`columns` table and return its `Shape` (`Shapes.AddTable`).
Geometry is in points; omitted values default to a wide grid near the
top-left (height is advisory — PowerPoint auto-fits rows to content).
Address cells through the returned shape's `.table` or the `cell:S:N:R:C`
anchor; the shape's `.has_table` is True. Raises `ValueError` for
non-positive `rows`/`columns` (before any COM).
"""
if int(rows) < 1 or int(columns) < 1:
raise ValueError(f"table needs >=1 row and >=1 column, got {rows}x{columns}")
left = _DEFAULT_LEFT if left is None else float(left)
top = _DEFAULT_TOP if top is None else float(top)
width = _DEFAULT_TABLE_WIDTH if width is None else float(width)
height = _DEFAULT_ROW_HEIGHT * int(rows) if height is None else float(height)
with _com.translate_com_errors():
self._com_collection.AddTable(int(rows), int(columns), left, top, width, height)
return self._added()
|
add_chart
add_chart(chart_type: str | int = 'column', categories: Sequence[str] | None = None, series: SeriesInput | None = None, *, left: float | None = None, top: float | None = None, width: float | None = None, height: float | None = None) -> Shape
Add a chart and return its Shape (Shapes.AddChart2).
chart_type is a friendly name ("column", "bar", "line", "pie",
…; see constants.CHART_TYPE_CHOICES) or a raw XlChartType int.
Geometry is in points; omitted values default to a ~6.7×4.2 in chart near
the top-left. Address the chart's data through the returned shape's
.chart (or anchor_id); the shape's .has_chart is True.
If both categories and series are given, the chart's embedded-Excel
data is replaced with them (via Chart.set_data); otherwise the chart
keeps PowerPoint's default placeholder data. Pass one without the other
and it's a ValueError. Raises ValueError for an unknown chart_type
(before any COM). Wrap in deck.edit(...) for the one-Ctrl-Z fence.
Source code in src/pptlive/_shapes.py
| def add_chart(
self,
chart_type: str | int = "column",
categories: Sequence[str] | None = None,
series: SeriesInput | None = None,
*,
left: float | None = None,
top: float | None = None,
width: float | None = None,
height: float | None = None,
) -> Shape:
"""Add a chart and return its `Shape` (`Shapes.AddChart2`).
`chart_type` is a friendly name (`"column"`, `"bar"`, `"line"`, `"pie"`,
…; see `constants.CHART_TYPE_CHOICES`) or a raw `XlChartType` int.
Geometry is in points; omitted values default to a ~6.7×4.2 in chart near
the top-left. Address the chart's data through the returned shape's
`.chart` (or `anchor_id`); the shape's `.has_chart` is True.
If both `categories` and `series` are given, the chart's embedded-Excel
data is replaced with them (via `Chart.set_data`); otherwise the chart
keeps PowerPoint's default placeholder data. Pass one without the other
and it's a `ValueError`. Raises `ValueError` for an unknown `chart_type`
(before any COM). Wrap in `deck.edit(...)` for the one-Ctrl-Z fence.
"""
type_int = chart_type_for(chart_type) # ValueError before any COM
if (categories is None) != (series is None):
raise ValueError("pass both categories and series, or neither")
left = _DEFAULT_LEFT if left is None else float(left)
top = _DEFAULT_TOP if top is None else float(top)
width = _DEFAULT_CHART_WIDTH if width is None else float(width)
height = _DEFAULT_CHART_HEIGHT if height is None else float(height)
with _com.translate_com_errors():
self._com_collection.AddChart2(_CHART_DEFAULT_STYLE, type_int, left, top, width, height)
shape = self._added()
if categories is not None and series is not None:
shape.chart.set_data(categories, series)
return shape
|
add_smartart
add_smartart(kind: str, nodes: Sequence[NodeInput] | None = None, *, left: float | None = None, top: float | None = None, width: float | None = None, height: float | None = None) -> Shape
Add a SmartArt diagram and return its Shape (Shapes.AddSmartArt).
kind is a friendly layout name ("process", "cycle", "orgchart",
…; see constants.SMARTART_CHOICES). Geometry is in points; omitted values
default to a ~6.7×4.2 in diagram near the top-left. Address the diagram's
nodes through the returned shape's .smartart (or anchor_id); the
shape's .has_smartart is True.
If nodes is given, the diagram's nodes are replaced with it (via
SmartArt.set_nodes) — a list of strings (flat) and/or {text, children}
mappings (nested); otherwise the layout keeps its default placeholder
nodes. Raises ValueError for an unknown kind (before any COM) and
AnchorNotFoundError if the layout isn't installed. Wrap in
deck.edit(...) for the one-Ctrl-Z fence.
Source code in src/pptlive/_shapes.py
| def add_smartart(
self,
kind: str,
nodes: Sequence[NodeInput] | None = None,
*,
left: float | None = None,
top: float | None = None,
width: float | None = None,
height: float | None = None,
) -> Shape:
"""Add a SmartArt diagram and return its `Shape` (`Shapes.AddSmartArt`).
`kind` is a friendly layout name (`"process"`, `"cycle"`, `"orgchart"`,
…; see `constants.SMARTART_CHOICES`). Geometry is in points; omitted values
default to a ~6.7×4.2 in diagram near the top-left. Address the diagram's
nodes through the returned shape's `.smartart` (or `anchor_id`); the
shape's `.has_smartart` is True.
If `nodes` is given, the diagram's nodes are replaced with it (via
`SmartArt.set_nodes`) — a list of strings (flat) and/or `{text, children}`
mappings (nested); otherwise the layout keeps its default placeholder
nodes. Raises `ValueError` for an unknown `kind` (before any COM) and
`AnchorNotFoundError` if the layout isn't installed. Wrap in
`deck.edit(...)` for the one-Ctrl-Z fence.
"""
left = _DEFAULT_LEFT if left is None else float(left)
top = _DEFAULT_TOP if top is None else float(top)
width = _DEFAULT_SMARTART_WIDTH if width is None else float(width)
height = _DEFAULT_SMARTART_HEIGHT if height is None else float(height)
with _com.translate_com_errors():
layout = self._resolve_smartart_layout(kind)
self._com_collection.AddSmartArt(layout, left, top, width, height)
shape = self._added()
if nodes is not None:
shape.smartart.set_nodes(nodes)
return shape
|
list
list() -> list[dict[str, Any]]
Every shape as a structured dict, in z-order.
Source code in src/pptlive/_shapes.py
| def list(self) -> list[dict[str, Any]]:
"""Every shape as a structured dict, in z-order."""
out: list[dict[str, Any]] = []
with _com.translate_com_errors():
for idx, sh in enumerate(self._com_collection, start=1):
out.append(shape_to_dict(sh, self._slide.index, idx))
return out
|
pptlive.Shape
Shape(slide: Slide, index: int)
Bases: Anchor
A shape on a slide, addressed by 1-based z-order index — shape:S:N.
Resolves its COM object live on every access (z-order drifts). Inherits
text / set_text from Anchor (raising NoTextFrameError if the shape
has no text frame), and adds geometry verbs.
Source code in src/pptlive/_shapes.py
| def __init__(self, slide: Slide, index: int) -> None:
self._slide = slide
self._index = int(index)
|
index
property
1-based z-order index this shape was addressed by.
com
property
Raw COM Shape (overrides Anchor.com, which would give a text range).
name
property
The shape's .Name (e.g. "Title 1") — drift-proof, unique per slide.
Propagates a missing-shape / busy error like shape_id and shape_type
do — it must never fabricate an anchor_id-shaped string, which would
collide with the shape:S:N anchor format and mislead a caller into
treating a failed lookup as a real shape name.
shape_id
property
Shape.Id — stable across z-order reordering.
shapeid
property
The restack-proof anchor for this shape — shapeid:S:ID.
Built live from Shape.Id, so it survives the z-order drift that shifts
shape:S:N when shapes are added / deleted / reordered. Every shape read
and mutation echoes this alongside anchor_id, so an agent can chain
edits on a shape across a restack without re-reading the slide.
shape_type
property
Friendly shape-type name (e.g. "placeholder", "textbox", "picture").
alt_text
property
The shape's alternative (accessibility) text — Shape.AlternativeText.
Doubles as a stable, LLM-readable re-identification handle: tag a
picture/diagram with a descriptive alt text and find it again after
z-order drift (it shows up in every shape listing) without relying on the
volatile shape:S:N index. Empty string when unset. Set it with
set_alt_text.
has_table
property
Whether this shape holds a table (Shape.HasTable).
table
property
The shape's Table (cells are cell:S:N:R:C anchors).
Raises AnchorNotFoundError (kind "table", exit 2) if the shape holds
no table.
has_chart
property
Whether this shape holds a chart (Shape.HasChart).
chart
property
The shape's Chart (data lives in an embedded Excel workbook).
Raises AnchorNotFoundError (kind "chart", exit 2) if the shape holds
no chart.
has_smartart
property
Whether this shape holds a SmartArt diagram (Shape.HasSmartArt).
smartart
property
The shape's SmartArt diagram (its node tree).
Raises AnchorNotFoundError (kind "smartart", exit 2) if the shape
holds no SmartArt.
Whether this shape is an audio/video clip (Shape.Type == msoMedia).
The media clip's {type, length_s, muted, volume, autoplay} read.
Raises AnchorNotFoundError (kind "media", exit 2) if the shape holds
no media.
paragraphs
property
paragraphs: ParagraphCollection
The shape's paragraphs (para:S:N:P); raises NoTextFrameError if none.
text_frame_status
text_frame_status() -> TextFrameStatus
Autofit / wrap / margin diagnostics for this shape's text frame.
A read (no view move, no edit fence) that exposes the state behind a
"formatting spiral": the autofit mode, word-wrap, inner margins (points),
and a coarse overflow_risk (see TextFrameStatus). Raises
NoTextFrameError if the shape holds no text frame.
Source code in src/pptlive/_shapes.py
| def text_frame_status(self) -> TextFrameStatus:
"""Autofit / wrap / margin diagnostics for this shape's text frame.
A **read** (no view move, no edit fence) that exposes the state behind a
"formatting spiral": the autofit mode, word-wrap, inner margins (points),
and a coarse `overflow_risk` (see `TextFrameStatus`). Raises
`NoTextFrameError` if the shape holds no text frame.
"""
with _com.translate_com_errors():
sh = self._com_shape()
if not has_text_frame(sh):
raise NoTextFrameError(self.anchor_id)
tf = sh.TextFrame
autosize = _autosize_of(sh)
margins = {
"left": _safe(lambda: float(tf.MarginLeft), 0.0),
"right": _safe(lambda: float(tf.MarginRight), 0.0),
"top": _safe(lambda: float(tf.MarginTop), 0.0),
"bottom": _safe(lambda: float(tf.MarginBottom), 0.0),
}
word_wrap = _safe(lambda: is_true(tf.WordWrap), True)
return TextFrameStatus(
autosize=autosize,
word_wrap=word_wrap,
margins=margins,
overflow_risk=_overflow_risk(autosize),
)
|
paragraph
paragraph(index: int) -> Paragraph
The index-th paragraph (1-based) of this shape's text frame.
Source code in src/pptlive/_shapes.py
| def paragraph(self, index: int) -> Paragraph:
"""The `index`-th paragraph (1-based) of this shape's text frame."""
return ParagraphCollection(self)[index]
|
geometry
geometry() -> dict[str, float]
{left, top, width, height, rotation} in points.
Source code in src/pptlive/_shapes.py
| def geometry(self) -> dict[str, float]:
"""`{left, top, width, height, rotation}` in points."""
with _com.translate_com_errors():
geo = _geometry_of(self._com_shape())
if geo is None:
raise AnchorNotFoundError("shape", self.anchor_id)
return geo
|
move
move(*, left: float | None = None, top: float | None = None) -> None
Set the shape's absolute position in points. Pass left and/or top.
Source code in src/pptlive/_shapes.py
| def move(self, *, left: float | None = None, top: float | None = None) -> None:
"""Set the shape's absolute position in points. Pass `left` and/or `top`."""
if left is None and top is None:
raise ValueError("move() requires at least one of left= or top=")
with _com.translate_com_errors():
sh = self._com_shape()
if left is not None:
sh.Left = float(left)
if top is not None:
sh.Top = float(top)
|
resize
resize(*, width: float | None = None, height: float | None = None) -> None
Set the shape's size in points. Pass width and/or height.
Source code in src/pptlive/_shapes.py
| def resize(self, *, width: float | None = None, height: float | None = None) -> None:
"""Set the shape's size in points. Pass `width` and/or `height`."""
if width is None and height is None:
raise ValueError("resize() requires at least one of width= or height=")
with _com.translate_com_errors():
sh = self._com_shape()
if width is not None:
sh.Width = float(width)
if height is not None:
sh.Height = float(height)
|
reset_to_layout
reset_to_layout() -> dict[str, float]
Restore this placeholder's geometry (+ default font size) from its layout.
The recovery verb for a placeholder that's been manually moved/resized or
shrunk to an unreadable font (the gpt-5.4 review's "5 pt font, overflow off
the slide" case). Matches this shape to its slide's CustomLayout
placeholder by PlaceholderFormat.Type, then copies that placeholder's
Left/Top/Width/Height (and, best-effort, its default font size onto
the live text) — the layout's reading is the source of truth (verified in
scripts/text_model_spike.py). Returns the restored {left, top, width,
height[, font_size]} in points.
Raises ValueError if this shape isn't a placeholder, or
AnchorNotFoundError if the layout has no placeholder of the same kind.
Pairs with reset_format (which clears the paragraph-spacing spiral). A
mutation: wrap in deck.edit(...).
Source code in src/pptlive/_shapes.py
| def reset_to_layout(self) -> dict[str, float]:
"""Restore this *placeholder's* geometry (+ default font size) from its layout.
The recovery verb for a placeholder that's been manually moved/resized or
shrunk to an unreadable font (the gpt-5.4 review's "5 pt font, overflow off
the slide" case). Matches this shape to its slide's `CustomLayout`
placeholder by `PlaceholderFormat.Type`, then copies that placeholder's
`Left`/`Top`/`Width`/`Height` (and, best-effort, its default font size onto
the live text) — the layout's reading is the source of truth (verified in
`scripts/text_model_spike.py`). Returns the restored `{left, top, width,
height[, font_size]}` in points.
Raises `ValueError` if this shape isn't a placeholder, or
`AnchorNotFoundError` if the layout has no placeholder of the same kind.
Pairs with `reset_format` (which clears the paragraph-spacing spiral). A
mutation: wrap in `deck.edit(...)`.
"""
with _com.translate_com_errors():
sh = self._com_shape()
try:
want_type = int(sh.PlaceholderFormat.Type)
except Exception as exc: # not a placeholder -> no PlaceholderFormat
raise ValueError(
f"reset_to_layout() needs a placeholder shape, got {self.anchor_id}"
) from exc
match = self._layout_placeholder(want_type)
sh.Left = float(match.Left)
sh.Top = float(match.Top)
sh.Width = float(match.Width)
sh.Height = float(match.Height)
restored: dict[str, float] = {
"left": float(sh.Left),
"top": float(sh.Top),
"width": float(sh.Width),
"height": float(sh.Height),
}
size = _layout_default_size(match)
if size is not None and has_text_frame(sh):
sh.TextFrame.TextRange.Font.Size = size
restored["font_size"] = size
return restored
|
set_alt_text
set_alt_text(value: str) -> None
Set the shape's alternative (accessibility) text (Shape.AlternativeText).
The drift-proof re-identification handle (see alt_text). A mutation:
wrap in deck.edit(...) for view preservation + a one-Ctrl-Z fence.
Source code in src/pptlive/_shapes.py
| def set_alt_text(self, value: str) -> None:
"""Set the shape's alternative (accessibility) text (`Shape.AlternativeText`).
The drift-proof re-identification handle (see `alt_text`). A mutation:
wrap in `deck.edit(...)` for view preservation + a one-Ctrl-Z fence.
"""
with _com.translate_com_errors():
self._com_shape().AlternativeText = str(value)
|
set_fill
set_fill(*, fill: str | int | tuple[int, int, int] | None = None, line: str | int | tuple[int, int, int] | None = None, line_width: float | None = None, fill_transparency: float | None = None, line_transparency: float | None = None) -> None
Set the shape's fill and/or line (border). Only the kwargs passed.
fill/line take a color ("#RRGGBB", an (r, g, b) tuple, or a raw RGB
int) for a solid fill / line of that color, or the string "none" to make
the fill transparent / remove the border. line_width is the border weight
in points. fill_transparency/line_transparency are 0.0..1.0 alpha
fractions (0 opaque, 1 fully transparent) — the partial-alpha knob, distinct
from "none" (which hides the fill/line entirely). Distinct from
format_text's color, which is font color. Raises ValueError for a bad
color / out-of-range transparency (before any COM) or if nothing is passed. A
mutation: wrap in deck.edit(...).
Source code in src/pptlive/_shapes.py
| def set_fill(
self,
*,
fill: str | int | tuple[int, int, int] | None = None,
line: str | int | tuple[int, int, int] | None = None,
line_width: float | None = None,
fill_transparency: float | None = None,
line_transparency: float | None = None,
) -> None:
"""Set the shape's **fill** and/or **line** (border). Only the kwargs passed.
`fill`/`line` take a color (`"#RRGGBB"`, an `(r, g, b)` tuple, or a raw RGB
int) for a solid fill / line of that color, or the string `"none"` to make
the fill transparent / remove the border. `line_width` is the border weight
in points. `fill_transparency`/`line_transparency` are `0.0..1.0` alpha
fractions (0 opaque, 1 fully transparent) — the partial-alpha knob, distinct
from `"none"` (which hides the fill/line entirely). Distinct from
`format_text`'s `color`, which is *font* color. Raises `ValueError` for a bad
color / out-of-range transparency (before any COM) or if nothing is passed. A
mutation: wrap in `deck.edit(...)`.
"""
if (
fill is None
and line is None
and line_width is None
and fill_transparency is None
and line_transparency is None
):
raise ValueError(
"set_fill() requires at least one of fill=, line=, line_width=, "
"fill_transparency=, or line_transparency="
)
with _com.translate_com_errors():
apply_shape_fill(
self._com_shape(),
fill=fill,
line=line,
line_width=line_width,
fill_transparency=fill_transparency,
line_transparency=line_transparency,
)
|
set_line_style
set_line_style(*, dash: str | int | None = None, begin_arrow: str | int | None = None, end_arrow: str | int | None = None, begin_arrow_size: str | int | None = None, end_arrow_size: str | int | None = None) -> None
Set the shape's line dash pattern and/or arrowheads. Only the kwargs passed.
dash is a friendly MsoLineDashStyle ("solid"/"dash"/"round_dot"/
"dash_dot"/"long_dash"/…) or raw int. begin_arrow/end_arrow are
MsoArrowheadStyle names ("none"/"triangle"/"open"/"stealth"/
"diamond"/"oval") or raw ints; begin_arrow_size/end_arrow_size are
"small"/"medium"/"large" (set both arrowhead length + width). Names are
validated up front (ValueError before any COM). Arrowheads apply to
lines/connectors only — PowerPoint raises on a closed shape (use dash for
those). Border color/weight stay on set_fill. A mutation: wrap in
deck.edit(...).
Source code in src/pptlive/_shapes.py
| def set_line_style(
self,
*,
dash: str | int | None = None,
begin_arrow: str | int | None = None,
end_arrow: str | int | None = None,
begin_arrow_size: str | int | None = None,
end_arrow_size: str | int | None = None,
) -> None:
"""Set the shape's line **dash** pattern and/or **arrowheads**. Only the kwargs passed.
`dash` is a friendly `MsoLineDashStyle` (`"solid"`/`"dash"`/`"round_dot"`/
`"dash_dot"`/`"long_dash"`/…) or raw int. `begin_arrow`/`end_arrow` are
`MsoArrowheadStyle` names (`"none"`/`"triangle"`/`"open"`/`"stealth"`/
`"diamond"`/`"oval"`) or raw ints; `begin_arrow_size`/`end_arrow_size` are
`"small"`/`"medium"`/`"large"` (set both arrowhead length + width). Names are
validated up front (`ValueError` before any COM). **Arrowheads apply to
lines/connectors only** — PowerPoint raises on a closed shape (use `dash` for
those). Border color/weight stay on `set_fill`. A mutation: wrap in
`deck.edit(...)`.
"""
with _com.translate_com_errors():
apply_line_style(
self._com_shape(),
dash=dash,
begin_arrow=begin_arrow,
end_arrow=end_arrow,
begin_arrow_size=begin_arrow_size,
end_arrow_size=end_arrow_size,
)
|
set_gradient_fill
set_gradient_fill(colors: Sequence[Any] | None = None, *, positions: Sequence[float] | None = None, style: str | int = 'horizontal', variant: int = 1, degree: float | None = None, preset: str | int | None = None) -> None
Give this shape a gradient fill. A mutation: wrap in deck.edit(...).
Pass colors (a list of "#RRGGBB" / (r,g,b) / int colors) or preset
(a named ramp like "ocean" / "fire" / "rainbow"):
- one color → a one-color gradient (degree is the 0..1 brightness, default 0.5);
- two colors → a two-color gradient (first at stop 0.0, second at 1.0);
- three+ colors → a multi-stop gradient; positions (floats 0..1, same length as
colors) places the interior stops, which otherwise space evenly (the
endpoints stay at 0.0/1.0).
style is a friendly MsoGradientStyle ("horizontal"/"vertical"/
"diagonal_up"/…) and variant (1-4) picks the shading variant. Raises
ValueError (before any COM) for a bad color / style / preset, or if neither
colors nor preset is given.
Source code in src/pptlive/_shapes.py
| def set_gradient_fill(
self,
colors: Sequence[Any] | None = None,
*,
positions: Sequence[float] | None = None,
style: str | int = "horizontal",
variant: int = 1,
degree: float | None = None,
preset: str | int | None = None,
) -> None:
"""Give this shape a **gradient** fill. A mutation: wrap in `deck.edit(...)`.
Pass `colors` (a list of `"#RRGGBB"` / `(r,g,b)` / int colors) **or** `preset`
(a named ramp like `"ocean"` / `"fire"` / `"rainbow"`):
- one color → a one-color gradient (`degree` is the 0..1 brightness, default 0.5);
- two colors → a two-color gradient (first at stop 0.0, second at 1.0);
- three+ colors → a multi-stop gradient; `positions` (floats 0..1, same length as
`colors`) places the *interior* stops, which otherwise space evenly (the
endpoints stay at 0.0/1.0).
`style` is a friendly `MsoGradientStyle` (`"horizontal"`/`"vertical"`/
`"diagonal_up"`/…) and `variant` (1-4) picks the shading variant. Raises
`ValueError` (before any COM) for a bad color / style / preset, or if neither
`colors` nor `preset` is given.
"""
with _com.translate_com_errors():
apply_gradient_fill(
self._com_shape(),
colors=colors,
positions=positions,
style=style,
variant=variant,
degree=degree,
preset=preset,
)
|
set_picture_fill
set_picture_fill(path: str | Path) -> None
Fill this shape with an image (Fill.UserPicture). A mutation: deck.edit(...).
path is resolved to an absolute path (a relative path raises
ERROR_FILE_NOT_FOUND in PowerPoint); a missing file raises FileNotFoundError
(before any COM).
For an actual picture shape this is the wrong verb — it sets a fill
behind the unchanged picture raster (so the image doesn't visibly change);
use set_picture to re-source a picture in place.
Source code in src/pptlive/_shapes.py
| def set_picture_fill(self, path: str | Path) -> None:
"""Fill this shape with an **image** (`Fill.UserPicture`). A mutation: `deck.edit(...)`.
`path` is resolved to an absolute path (a relative path raises
`ERROR_FILE_NOT_FOUND` in PowerPoint); a missing file raises `FileNotFoundError`
(before any COM).
For an actual **picture** shape this is the wrong verb — it sets a fill
*behind* the unchanged picture raster (so the image doesn't visibly change);
use `set_picture` to re-source a picture in place.
"""
with _com.translate_com_errors():
apply_picture_fill(self._com_shape(), path)
|
set_picture
set_picture(path: str | PathLike[str], *, alt_text: str | None = None) -> Shape
Re-source this picture in place — swap its image without delete-and-recreate.
The post-creation edit for a picture: replaces the displayed image while
preserving the picture's position, size, rotation, name, alt text, and
z-order slot, so an agent can update a logo / screenshot / chart export
without re-deriving geometry (the wordlive delete-then-recreate habit). The
new image is embedded (never linked); alt_text, if given, overrides
the carried-over alt text.
Under the hood this is a delete + re-insert — PowerPoint's COM exposes no
in-place image swap for a picture shape (Fill.UserPicture only sets a
fill behind the unchanged raster, confirmed in
scripts/set_picture_spike.py). Two honest consequences: the picture gets
a new Shape.Id (so this returns a fresh handle — the old wrapper is
spent, like after delete()), and anything bound to the old picture object
— animations, hyperlinks, crop, and picture adjustments
(brightness / contrast / recolor) — is not carried over. Position, size,
rotation, name, alt text, and z-order are.
Raises FileNotFoundError if path is missing, or ValueError if this
shape isn't a picture (both before any COM mutation — use set_picture_fill
to put an image into a non-picture shape's fill). Returns a shapeid:S:ID
handle to the new picture. A mutation: wrap in deck.edit(...).
Source code in src/pptlive/_shapes.py
| def set_picture(
self,
path: str | os.PathLike[str],
*,
alt_text: str | None = None,
) -> Shape:
"""Re-source this **picture** in place — swap its image without delete-and-recreate.
The post-creation edit for a picture: replaces the displayed image while
**preserving the picture's position, size, rotation, name, alt text, and
z-order slot**, so an agent can update a logo / screenshot / chart export
without re-deriving geometry (the wordlive delete-then-recreate habit). The
new image is **embedded** (never linked); `alt_text`, if given, overrides
the carried-over alt text.
Under the hood this *is* a delete + re-insert — PowerPoint's COM exposes no
in-place image swap for a picture shape (`Fill.UserPicture` only sets a
fill *behind* the unchanged raster, confirmed in
`scripts/set_picture_spike.py`). Two honest consequences: the picture gets
a **new `Shape.Id`** (so this returns a fresh handle — the old wrapper is
spent, like after `delete()`), and anything bound to the old picture object
— **animations, hyperlinks, crop, and picture adjustments**
(brightness / contrast / recolor) — is **not** carried over. Position, size,
rotation, name, alt text, and z-order are.
Raises `FileNotFoundError` if `path` is missing, or `ValueError` if this
shape isn't a picture (both before any COM mutation — use `set_picture_fill`
to put an image into a non-picture shape's fill). Returns a `shapeid:S:ID`
handle to the new picture. A mutation: wrap in `deck.edit(...)`.
"""
fs_path = os.fspath(path)
if not os.path.isfile(fs_path):
raise FileNotFoundError(f"picture not found: {fs_path}")
abs_path = os.path.abspath(fs_path)
with _com.translate_com_errors():
com_old = self._com_shape()
if not is_picture(com_old):
raise ValueError(
f"set_picture() needs a picture shape, got "
f"{shape_type_name(com_old.Type)} ({self.anchor_id}); use "
f"set_picture_fill() to put an image into a non-picture shape's fill"
)
new_id = replace_picture(
self._slide.com, com_old, abs_path, self._slide.index, alt_text=alt_text
)
return ShapeById(self._slide, new_id)
|
set_pattern_fill
set_pattern_fill(pattern: str | int, *, fore: Any, back: Any | None = None) -> None
Give this shape a two-color pattern fill. A mutation: wrap in deck.edit(...).
pattern is a friendly MsoPatternType name ("percent_50", "trellis",
"dark_horizontal", …) or a raw int; fore is the pattern color and back
the (optional) background color. Raises ValueError (before any COM) for a bad
pattern name or color.
Source code in src/pptlive/_shapes.py
| def set_pattern_fill(
self,
pattern: str | int,
*,
fore: Any,
back: Any | None = None,
) -> None:
"""Give this shape a two-color **pattern** fill. A mutation: wrap in `deck.edit(...)`.
`pattern` is a friendly `MsoPatternType` name (`"percent_50"`, `"trellis"`,
`"dark_horizontal"`, …) or a raw int; `fore` is the pattern color and `back`
the (optional) background color. Raises `ValueError` (before any COM) for a bad
pattern name or color.
"""
with _com.translate_com_errors():
apply_pattern_fill(self._com_shape(), pattern=pattern, fore=fore, back=back)
|
set_effect
set_effect(*, shadow: Any | None = None, glow: Any | None = None, soft_edge: Any | None = None, reflection: Any | None = None) -> None
Set shape effects — shadow / glow / soft-edge / reflection. Only kwargs passed.
Each takes its friendly form or the string "none" to turn it off:
- shadow — a dict {color?, transparency?, blur?, size?, offset_x?, offset_y?}
(an outer drop shadow);
- glow — a dict {color?, radius?, transparency?} (radius 0 = off);
- soft_edge — an int preset 0-6 (0 = off);
- reflection — an int preset 0-9 (0 = off).
Raises ValueError (before any COM) for a bad color or a non-dict shadow/glow.
A mutation: wrap in deck.edit(...).
Source code in src/pptlive/_shapes.py
| def set_effect(
self,
*,
shadow: Any | None = None,
glow: Any | None = None,
soft_edge: Any | None = None,
reflection: Any | None = None,
) -> None:
"""Set shape **effects** — shadow / glow / soft-edge / reflection. Only kwargs passed.
Each takes its friendly form or the string `"none"` to turn it off:
- `shadow` — a dict `{color?, transparency?, blur?, size?, offset_x?, offset_y?}`
(an outer drop shadow);
- `glow` — a dict `{color?, radius?, transparency?}` (radius 0 = off);
- `soft_edge` — an int preset `0`-`6` (0 = off);
- `reflection` — an int preset `0`-`9` (0 = off).
Raises `ValueError` (before any COM) for a bad color or a non-dict shadow/glow.
A mutation: wrap in `deck.edit(...)`.
"""
if shadow is None and glow is None and soft_edge is None and reflection is None:
raise ValueError(
"set_effect() requires at least one of shadow=, glow=, soft_edge=, or reflection="
)
with _com.translate_com_errors():
apply_effect(
self._com_shape(),
shadow=shadow,
glow=glow,
soft_edge=soft_edge,
reflection=reflection,
)
|
reorder
reorder(to: str | int) -> int
Restack this shape in the slide's z-order; return its new 1-based position.
to is "front" / "back" / "forward" / "backward" (or a raw
MsoZOrderCmd int) — bring to front, send to back, or step one level
(constants.ZORDER_CHOICES). Lets a freshly added background panel slide
behind existing content (otherwise it always lands on top). Note this
shifts the shape:S:N indices of the shapes it passes — re-read after, or
address by shapeid:S:ID / .Name. Raises ValueError for an unknown
command (before any COM). A mutation: wrap in deck.edit(...).
Source code in src/pptlive/_shapes.py
| def reorder(self, to: str | int) -> int:
"""Restack this shape in the slide's z-order; return its new 1-based position.
`to` is `"front"` / `"back"` / `"forward"` / `"backward"` (or a raw
`MsoZOrderCmd` int) — bring to front, send to back, or step one level
(`constants.ZORDER_CHOICES`). Lets a freshly added background panel slide
*behind* existing content (otherwise it always lands on top). Note this
shifts the `shape:S:N` indices of the shapes it passes — re-read after, or
address by `shapeid:S:ID` / `.Name`. Raises `ValueError` for an unknown
command (before any COM). A mutation: wrap in `deck.edit(...)`.
"""
cmd = zorder_cmd_for(to) # ValueError before any COM
with _com.translate_com_errors():
sh = self._com_shape() # raw ref tracks the shape across the restack
shape_id = int(sh.Id)
sh.ZOrder(cmd)
# Report the Shapes-collection index (the basis shape:S:N resolves by),
# not ZOrderPosition — they coincide on a flat slide but can diverge,
# and the returned position should be usable as shape:S:N.
found = find_shape_by_id(self._slide.com, shape_id)
return found[0] if found is not None else int(sh.ZOrderPosition)
|
ungroup
ungroup() -> list[ShapeById]
Ungroup this group shape; return drift-proof handles to the freed members.
Reverses ShapeCollection.group — GroupShape.Ungroup() dissolves the
group and frees its members back onto the slide. Per the spike
(scripts/arrangement_spike.py), the freed children keep their original
Shape.Ids, so this returns a ShapeById per member (resolvable as
shapeid:S:ID). The group's own wrapper is spent afterwards (its shape no
longer exists), like after delete().
Raises ValueError (before any COM) if this shape is not a group. A
mutation: wrap in deck.edit(...) for the one-Ctrl-Z fence.
Source code in src/pptlive/_shapes.py
| def ungroup(self) -> list[ShapeById]:
"""Ungroup this group shape; return drift-proof handles to the freed members.
Reverses `ShapeCollection.group` — `GroupShape.Ungroup()` dissolves the
group and frees its members back onto the slide. Per the spike
(`scripts/arrangement_spike.py`), the freed children **keep their original
`Shape.Id`s**, so this returns a `ShapeById` per member (resolvable as
`shapeid:S:ID`). The group's own wrapper is spent afterwards (its shape no
longer exists), like after `delete()`.
Raises `ValueError` (before any COM) if this shape is not a group. A
mutation: wrap in `deck.edit(...)` for the one-Ctrl-Z fence.
"""
with _com.translate_com_errors():
com = self._com_shape()
if not is_group(com):
raise ValueError(
f"ungroup() needs a group shape, got "
f"{shape_type_name(com.Type)} ({self.anchor_id})"
)
freed = com.Ungroup()
child_ids = [int(freed(i).Id) for i in range(1, int(freed.Count) + 1)]
return [ShapeById(self._slide, cid) for cid in child_ids]
|
animate
animate(effect: str | int = 'fade', *, trigger: str | int = 'on_click', duration: float | None = None, delay: float | None = None, exit: bool = False) -> dict[str, Any]
Give this shape an entrance (or exit) animation; return the effect dict.
Appends an effect to the slide's main animation sequence
(Slide.TimeLine.MainSequence.AddEffect). effect is a friendly
MsoAnimEffect name ("fade"/"appear"/"fly_in"/… — see
constants.ANIM_EFFECT_CHOICES) or a raw int. trigger is when it fires —
"on_click" (default), "with_previous", or "after_previous".
duration is the animation length in seconds and delay the start delay in
seconds (both optional; PowerPoint's per-effect default applies when None).
Pass exit=True to make the shape animate out instead of in (the
"disappear" case) — the same effect ids serve both.
A shape can carry several effects; each animate() call adds one (clear
them with clear_animations()). Raises ValueError (before any COM) for an
unknown effect/trigger name. A mutation: wrap in deck.edit(...).
Source code in src/pptlive/_shapes.py
| def animate(
self,
effect: str | int = "fade",
*,
trigger: str | int = "on_click",
duration: float | None = None,
delay: float | None = None,
exit: bool = False,
) -> dict[str, Any]:
"""Give this shape an entrance (or exit) animation; return the effect dict.
Appends an effect to the slide's main animation sequence
(`Slide.TimeLine.MainSequence.AddEffect`). `effect` is a friendly
`MsoAnimEffect` name (`"fade"`/`"appear"`/`"fly_in"`/… — see
`constants.ANIM_EFFECT_CHOICES`) or a raw int. `trigger` is when it fires —
`"on_click"` (default), `"with_previous"`, or `"after_previous"`.
`duration` is the animation length in seconds and `delay` the start delay in
seconds (both optional; PowerPoint's per-effect default applies when None).
Pass `exit=True` to make the shape animate **out** instead of in (the
"disappear" case) — the same effect ids serve both.
A shape can carry several effects; each `animate()` call adds one (clear
them with `clear_animations()`). Raises `ValueError` (before any COM) for an
unknown effect/trigger name. A mutation: wrap in `deck.edit(...)`.
"""
effect_int = anim_effect_for(effect) # ValueError before any COM
trigger_int = anim_trigger_for(trigger)
with _com.translate_com_errors():
seq = self._slide.com.TimeLine.MainSequence
eff = seq.AddEffect(self._com_shape(), effect_int, 0, trigger_int)
if exit:
eff.Exit = int(MsoTriState.TRUE)
if duration is not None:
eff.Timing.Duration = float(duration)
if delay is not None:
eff.Timing.TriggerDelayTime = float(delay)
return effect_to_dict(eff, self.slide.index)
|
clear_animations
clear_animations() -> int
Remove every animation effect targeting this shape; return the count.
Walks the slide's main sequence and deletes each effect whose target is this
shape (matched by stable Shape.Id, so a restack doesn't matter), leaving
other shapes' animations intact. Use Slide.clear_animations() to wipe the
whole slide. A no-op (returns 0) if the shape has no animations. A mutation:
wrap in deck.edit(...).
Source code in src/pptlive/_shapes.py
| def clear_animations(self) -> int:
"""Remove every animation effect targeting **this shape**; return the count.
Walks the slide's main sequence and deletes each effect whose target is this
shape (matched by stable `Shape.Id`, so a restack doesn't matter), leaving
other shapes' animations intact. Use `Slide.clear_animations()` to wipe the
whole slide. A no-op (returns 0) if the shape has no animations. A mutation:
wrap in `deck.edit(...)`.
"""
return self._slide.clear_animations(anchor=self)
|
set_hyperlink
set_hyperlink(*, url: str | None = None, slide: int | None = None, screen_tip: str | None = None) -> dict[str, Any]
Make this shape a clickable hyperlink; return the resulting link dict.
Pass exactly one destination: url for an external link (a URL,
mailto:, or file path) or slide for an in-deck jump to a 1-based slide
index ("back to TOC" / agenda navigation). screen_tip is the optional
hover tooltip. The link fires on mouse click (ppMouseClick); setting it
implicitly flips the shape's action to ppActionHyperlink. A shape needs
no text frame to carry a link (it's a shape-level action).
Raises ValueError (before any COM) if neither or both destinations are
given, url is blank, or slide is out of range. A mutation: wrap in
deck.edit(...) for the one-Ctrl-Z fence.
Source code in src/pptlive/_shapes.py
| def set_hyperlink(
self,
*,
url: str | None = None,
slide: int | None = None,
screen_tip: str | None = None,
) -> dict[str, Any]:
"""Make this shape a clickable hyperlink; return the resulting link dict.
Pass **exactly one** destination: `url` for an external link (a URL,
`mailto:`, or file path) or `slide` for an in-deck jump to a 1-based slide
index ("back to TOC" / agenda navigation). `screen_tip` is the optional
hover tooltip. The link fires on mouse click (`ppMouseClick`); setting it
implicitly flips the shape's action to `ppActionHyperlink`. A shape needs
no text frame to carry a link (it's a shape-level action).
Raises `ValueError` (before any COM) if neither or both destinations are
given, `url` is blank, or `slide` is out of range. A mutation: wrap in
`deck.edit(...)` for the one-Ctrl-Z fence.
"""
if (url is None) == (slide is None):
raise ValueError("set_hyperlink() requires exactly one of url= or slide=")
sub_address: str | None = None
if url is not None:
if not str(url).strip():
raise ValueError("set_hyperlink(url=) must be a non-empty string")
url = str(url)
else:
assert slide is not None # narrowed by the exactly-one check above
sub_address = self._slide_jump_subaddress(int(slide)) # ValueError if out of range
with _com.translate_com_errors():
sh = self._com_shape() # resolve once; reuse for mutation + readback
acts = sh.ActionSettings(int(PpMouseActivation.MOUSE_CLICK))
link = acts.Hyperlink
if url is not None:
link.Address = url
else:
# SubAddress alone makes it an in-deck jump; clear any stale Address.
link.Address = ""
link.SubAddress = sub_address
if screen_tip is not None:
link.ScreenTip = str(screen_tip)
return _hyperlink_to_dict(sh) or {}
|
remove_hyperlink
remove_hyperlink() -> None
Remove this shape's mouse-click hyperlink (Hyperlink.Delete).
Reverts the shape's action to ppActionNone and clears the address. A
no-op if the shape has no link. A mutation: wrap in deck.edit(...).
Source code in src/pptlive/_shapes.py
| def remove_hyperlink(self) -> None:
"""Remove this shape's mouse-click hyperlink (`Hyperlink.Delete`).
Reverts the shape's action to `ppActionNone` and clears the address. A
no-op if the shape has no link. A mutation: wrap in `deck.edit(...)`.
"""
with _com.translate_com_errors():
acts = self._com_shape().ActionSettings(int(PpMouseActivation.MOUSE_CLICK))
acts.Hyperlink.Delete()
|
delete
Delete this shape from its slide (Shape.Delete).
The wrapper is spent afterwards; later shapes' z-order indices shift down
by one. Wrap in deck.edit(...) for the one-Ctrl-Z fence.
Source code in src/pptlive/_shapes.py
| def delete(self) -> None:
"""Delete this shape from its slide (`Shape.Delete`).
The wrapper is spent afterwards; later shapes' z-order indices shift down
by one. Wrap in `deck.edit(...)` for the one-Ctrl-Z fence.
"""
with _com.translate_com_errors():
self._com_shape().Delete()
|
export_image
export_image(path: str | PathLike[str] | None = None, *, fmt: str = 'png') -> Path
Render just this shape to an image file and return its absolute path.
The per-shape complement to Slide.export_image (v0.4): lets a vision
model see one picture / chart / diagram in isolation, cropped to the
shape's (rendered) bounds. Wraps Shape.Export(PathName, Filter) —
Filter is the PpShapeFormat int enum, not Slide.Export's string
FilterName.
fmt is a friendly token (png/jpg/gif/bmp; see
constants.SHAPE_IMAGE_FORMAT_CHOICES — narrower than the slide set, no
TIFF). When path is None a temp file is created (export-then-Read in
one step). A relative path is resolved to absolute first (PowerPoint
otherwise writes to its own working directory, not the caller's). It's a
read — no mutation, and polite (it doesn't move the viewed slide or the
Selection).
The image is the shape's native pixel size (the slide's 96-DPI scale,
e.g. a 720 pt-wide shape on a 960 pt slide → 960 px). Unlike
Slide.export_image, there is no output-size override: the 2026-05-28
live spike found Shape.Export's ScaleWidth/ScaleHeight do not map to
output pixels the way Slide.Export's do (requesting 400×300 gave
399×241 — width roughly tracked, height didn't, aspect wasn't preserved),
so pptlive only exposes the reliable native export. Reach for the .com
escape hatch (shape.com.Export(path, filter, w, h, mode)) if you need to
experiment with scaling.
Source code in src/pptlive/_shapes.py
| def export_image(
self,
path: str | os.PathLike[str] | None = None,
*,
fmt: str = "png",
) -> Path:
"""Render *just this shape* to an image file and return its absolute path.
The per-shape complement to `Slide.export_image` (v0.4): lets a vision
model see one picture / chart / diagram in isolation, cropped to the
shape's (rendered) bounds. Wraps `Shape.Export(PathName, Filter)` —
`Filter` is the `PpShapeFormat` int enum, **not** `Slide.Export`'s string
FilterName.
`fmt` is a friendly token (`png`/`jpg`/`gif`/`bmp`; see
`constants.SHAPE_IMAGE_FORMAT_CHOICES` — narrower than the slide set, no
TIFF). When `path` is None a temp file is created (export-then-`Read` in
one step). A relative `path` is resolved to absolute first (PowerPoint
otherwise writes to its own working directory, not the caller's). It's a
read — no mutation, and polite (it doesn't move the viewed slide or the
Selection).
The image is the shape's **native pixel size** (the slide's 96-DPI scale,
e.g. a 720 pt-wide shape on a 960 pt slide → 960 px). Unlike
`Slide.export_image`, there is **no** output-size override: the 2026-05-28
live spike found `Shape.Export`'s ScaleWidth/ScaleHeight do *not* map to
output pixels the way `Slide.Export`'s do (requesting 400×300 gave
399×241 — width roughly tracked, height didn't, aspect wasn't preserved),
so pptlive only exposes the reliable native export. Reach for the `.com`
escape hatch (`shape.com.Export(path, filter, w, h, mode)`) if you need to
experiment with scaling.
"""
filter_int, ext = shape_image_filter_for(fmt) # ValueError before any COM
if path is None:
fd, tmp = tempfile.mkstemp(prefix="pptlive_shape_", suffix=f".{ext}")
os.close(fd)
os.remove(tmp) # hand PowerPoint a clean path to write
abs_path = tmp
else:
abs_path = os.path.abspath(os.fspath(path))
with _com.translate_com_errors():
self._com_shape().Export(abs_path, int(filter_int))
return Path(abs_path)
|
pptlive.PlaceholderShape
PlaceholderShape(slide: Slide, ph_kind: str)
Bases: Shape
A placeholder addressed by semantic kind — ph:S:KIND.
The LLM-preferred, drift-proof form: "the title of slide 3" without caring
about z-order. Re-resolves its COM shape by PlaceholderFormat.Type on every
access (via Slide._find_placeholder), so it survives shape reordering. It
is a Shape, so all geometry and text verbs work; only the resolution
strategy and anchor_id differ.
Source code in src/pptlive/_shapes.py
| def __init__(self, slide: Slide, ph_kind: str) -> None:
# Validate the kind eagerly so a typo fails before any COM work.
placeholder_types_for(ph_kind)
super().__init__(slide, index=0)
self._ph_kind = ph_kind.lower()
|
index
property
Current 1-based z-order index of the resolved placeholder.
Anchors
Every text-bearing handle subclasses Anchor and shares the
same verbs — text, set_text, insert_paragraph_before/after,
format_text, format_paragraph, apply_list / remove_list — so the same
calls work uniformly on a whole shape, one paragraph, a table cell, or a
slide's notes. PowerPoint has no named paragraph styles, so "styling" is direct
font formatting via format_text (bold / italic / underline / size / font /
color). A paragraph read's font block also reports color_source
("direct" / "theme" / "mixed") and theme_color (the inherited slot when
themed), so you can tell a run color set on the run from one cascaded from the
theme — the one place PowerPoint exposes that direct-vs-inherited distinction.
pptlive.Anchor
Bases: ABC
Abstract base for text-bearing handles.
Concrete subclasses implement _text_range() (the COM TextRange to read
and write) and anchor_id. text / set_text are derived and inherited.
com
property
Raw COM object for this anchor — the TextRange it targets.
Shape overrides this to return the raw Shape instead (the more useful
escape hatch for a shape), exposing its text range via text/set_text.
anchor_id
abstractmethod
property
Stable string identifier (e.g. notes:3, shape:2:1, ph:2:body).
slide
abstractmethod
property
The slide this anchor lives on (used to resolve in-deck link jumps).
name
property
A display name for this anchor. Defaults to its anchor_id.
text
property
The anchor's plain text. PowerPoint separates paragraphs with \r.
set_text
set_text(text: str) -> None
Replace the anchor's text in place.
Embed \n (or \r) to start a new paragraph — each line becomes its
own addressable para:S:N:P. For a soft line break that stays within
one paragraph, embed \v (SOFT_BREAK). Targets the text range
directly, never the Selection, so it doesn't move the user's view. Wrap
in deck.edit(...) to preserve the viewed slide and collapse the block
to one Ctrl-Z (see EditScope).
Source code in src/pptlive/_anchors.py
| def set_text(self, text: str) -> None:
"""Replace the anchor's text in place.
Embed `\\n` (or `\\r`) to start a new paragraph — each line becomes its
own addressable `para:S:N:P`. For a *soft* line break that stays within
one paragraph, embed `\\v` (`SOFT_BREAK`). Targets the text range
directly, never the Selection, so it doesn't move the user's view. Wrap
in `deck.edit(...)` to preserve the viewed slide and collapse the block
to one Ctrl-Z (see `EditScope`).
"""
with _com.translate_com_errors():
self._text_range().Text = normalize_paragraph_breaks(text)
|
set_paragraphs
set_paragraphs(paragraphs: list[Any]) -> list[str]
Replace this anchor's text with a clean, per-paragraph list.
The safe alternative to newline inference for list authoring (the gpt-5.4
review's ask): each item is a plain string or a dict
{"text": ..., "list_type"?, "indent_level"?, "alignment"?, "line_spacing"?,
"size"?, ...} and becomes exactly one addressable para: — a newline
inside an item is folded to a soft break, never a paragraph split. Per-item
keys are forwarded to format_paragraph (spacing/alignment/indent) and
format_text (font); list_type applies/removes the bullet
("none" strips it), resetting list state cleanly. Returns the new
paragraphs' anchor_ids (empty for a text anchor with no paragraph view,
e.g. notes). Wrap in deck.edit(...).
Source code in src/pptlive/_anchors.py
| def set_paragraphs(self, paragraphs: list[Any]) -> list[str]:
"""Replace this anchor's text with a clean, per-paragraph list.
The safe alternative to newline inference for list authoring (the gpt-5.4
review's ask): each item is a plain string **or** a dict
`{"text": ..., "list_type"?, "indent_level"?, "alignment"?, "line_spacing"?,
"size"?, ...}` and becomes exactly one addressable `para:` — a newline
inside an item is folded to a soft break, never a paragraph split. Per-item
keys are forwarded to `format_paragraph` (spacing/alignment/indent) and
`format_text` (font); `list_type` applies/removes the bullet
(`"none"` strips it), resetting list state cleanly. Returns the new
paragraphs' `anchor_id`s (empty for a text anchor with no paragraph view,
e.g. notes). Wrap in `deck.edit(...)`.
"""
items = [_coerce_paragraph_item(p) for p in paragraphs]
if not items:
raise ValueError("set_paragraphs needs at least one paragraph")
joined = "\r".join(_as_single_paragraph(it["text"]) for it in items)
with _com.translate_com_errors():
self._text_range().Text = joined
para_coll = getattr(self, "paragraphs", None)
if para_coll is None:
return [] # a text anchor without a paragraph view (notes) — text only
new_ids: list[str] = []
for idx, item in enumerate(items, start=1):
para = para_coll[idx]
new_ids.append(para.anchor_id)
self._apply_paragraph_item(para, item)
return new_ids
|
paragraph_count
Number of paragraphs in this anchor's text range.
Source code in src/pptlive/_anchors.py
| def paragraph_count(self) -> int:
"""Number of paragraphs in this anchor's text range."""
with _com.translate_com_errors():
return int(self._text_range().Paragraphs().Count)
|
format_text
format_text(*, bold: bool | None = None, italic: bool | None = None, underline: bool | None = None, size: float | None = None, font: str | None = None, color: str | int | tuple[int, int, int] | None = None) -> None
Set font formatting on this anchor's text (PowerPoint's apply_style).
PowerPoint has no named paragraph styles, so styling is direct font
formatting. Only the kwargs you pass are written. size is in points;
color is "#RRGGBB", an (r, g, b) tuple, or a raw RGB int.
A bad color raises ValueError before any font property is written
(apply_font validates it first), so the font is never left half-styled.
Source code in src/pptlive/_anchors.py
| def format_text(
self,
*,
bold: bool | None = None,
italic: bool | None = None,
underline: bool | None = None,
size: float | None = None,
font: str | None = None,
color: str | int | tuple[int, int, int] | None = None,
) -> None:
"""Set font formatting on this anchor's text (PowerPoint's `apply_style`).
PowerPoint has no named paragraph styles, so styling is direct font
formatting. Only the kwargs you pass are written. `size` is in points;
`color` is `"#RRGGBB"`, an `(r, g, b)` tuple, or a raw RGB int.
A bad `color` raises `ValueError` before any font property is written
(`apply_font` validates it first), so the font is never left half-styled.
"""
with _com.translate_com_errors():
apply_font(
self._text_range().Font,
bold=bold,
italic=italic,
underline=underline,
size=size,
font=font,
color=color,
)
|
format_paragraph(*, alignment: str | int | None = None, space_before: float | None = None, space_after: float | None = None, line_spacing: float | None = None, line_spacing_points: float | None = None, space_before_lines: float | None = None, space_after_lines: float | None = None, indent_level: int | None = None, force: bool = False) -> None
Set paragraph formatting on this anchor's paragraphs.
Only the kwargs you pass are written. alignment is a name
("left"/"center"/"right"/"justify"/"distribute") or int.
Spacing is unit-explicit (PowerPoint stores a bare number whose unit a
LineRule* flag selects, so the same number means wildly different things):
line_spacing — a multiple (1.0 single, 1.5, 2.0).
line_spacing_points — an exact point height (e.g. 24 = 24 pt).
space_before / space_after — points before/after the paragraph.
space_before_lines / space_after_lines — the same as a multiple.
Pass at most one of each line-spacing pair (line_spacing xor
line_spacing_points, etc.) — passing both raises ValueError. A
line_spacing multiple above 5× is rejected unless force=True (it's
almost always a points-vs-multiple mix-up — pass line_spacing_points
instead). indent_level is PowerPoint's outline/bullet level, 1-5 (its only
notion of paragraph indent — there is no points-based left indent).
Source code in src/pptlive/_anchors.py
| def format_paragraph(
self,
*,
alignment: str | int | None = None,
space_before: float | None = None,
space_after: float | None = None,
line_spacing: float | None = None,
line_spacing_points: float | None = None,
space_before_lines: float | None = None,
space_after_lines: float | None = None,
indent_level: int | None = None,
force: bool = False,
) -> None:
"""Set paragraph formatting on this anchor's paragraphs.
Only the kwargs you pass are written. `alignment` is a name
(`"left"`/`"center"`/`"right"`/`"justify"`/`"distribute"`) or int.
**Spacing is unit-explicit** (PowerPoint stores a bare number whose unit a
`LineRule*` flag selects, so the same number means wildly different things):
* `line_spacing` — a **multiple** (`1.0` single, `1.5`, `2.0`).
* `line_spacing_points` — an **exact point** height (e.g. `24` = 24 pt).
* `space_before` / `space_after` — **points** before/after the paragraph.
* `space_before_lines` / `space_after_lines` — the same as a **multiple**.
Pass at most one of each line-spacing pair (`line_spacing` xor
`line_spacing_points`, etc.) — passing both raises `ValueError`. A
`line_spacing` multiple above 5× is rejected unless `force=True` (it's
almost always a points-vs-multiple mix-up — pass `line_spacing_points`
instead). `indent_level` is PowerPoint's outline/bullet level, 1-5 (its only
notion of paragraph indent — there is no points-based left indent).
"""
align_int = alignment_for(alignment) if alignment is not None else None
if indent_level is not None and not (1 <= int(indent_level) <= 5):
raise ValueError(f"indent_level must be between 1 and 5, got {indent_level}")
if line_spacing is not None and line_spacing_points is not None:
raise ValueError(
"pass line_spacing (a multiple) or line_spacing_points (exact pt), not both"
)
if space_before is not None and space_before_lines is not None:
raise ValueError("pass space_before (pt) or space_before_lines (a multiple), not both")
if space_after is not None and space_after_lines is not None:
raise ValueError("pass space_after (pt) or space_after_lines (a multiple), not both")
if (
line_spacing is not None
and float(line_spacing) > LINE_SPACING_MULTIPLE_MAX
and not force
):
raise ValueError(
f"line_spacing={line_spacing} is a *multiple* — "
f"{line_spacing}× line height pushes text off the slide. "
f"Did you mean line_spacing_points={line_spacing} (exact pt)? "
"Pass force=True to set the multiple anyway."
)
with _com.translate_com_errors():
tr = self._text_range()
apply_paragraph_format(
tr.ParagraphFormat,
alignment=align_int,
space_before=space_before,
space_after=space_after,
line_spacing=line_spacing,
line_spacing_points=line_spacing_points,
space_before_lines=space_before_lines,
space_after_lines=space_after_lines,
)
if indent_level is not None:
tr.IndentLevel = int(indent_level)
|
apply_list
apply_list(list_type: str = 'bulleted', *, character: str | int | None = None) -> None
Turn this anchor's paragraphs into a bulleted or numbered list.
list_type is "bulleted" (default) or "numbered". character (a
single char or int code point) sets a custom bullet glyph — only
meaningful for a bulleted list. Raises ValueError for an unknown
list_type.
Source code in src/pptlive/_anchors.py
| def apply_list(
self, list_type: str = "bulleted", *, character: str | int | None = None
) -> None:
"""Turn this anchor's paragraphs into a bulleted or numbered list.
`list_type` is `"bulleted"` (default) or `"numbered"`. `character` (a
single char or int code point) sets a custom bullet glyph — only
meaningful for a bulleted list. Raises `ValueError` for an unknown
`list_type`.
"""
bt = bullet_type_for(list_type) # ValueError before any COM
char_int = _bullet_char(character) if character is not None else None
with _com.translate_com_errors():
bullet = self._text_range().ParagraphFormat.Bullet
bullet.Visible = _tristate(True)
bullet.Type = int(bt)
if char_int is not None:
bullet.Character = char_int
|
remove_list
Strip bullets / numbering from this anchor's paragraphs.
Source code in src/pptlive/_anchors.py
| def remove_list(self) -> None:
"""Strip bullets / numbering from this anchor's paragraphs."""
with _com.translate_com_errors():
self._text_range().ParagraphFormat.Bullet.Visible = _tristate(False)
|
Reset this anchor's paragraph spacing to clean single-spaced defaults.
The recovery verb for the line-spacing / space-before spiral — the gpt-5.4
review's "giant spacing, text off the slide, unrecoverable without a
rewrite" case. Sets single line spacing (LineRuleWithin=msoTrue,
SpaceWithin=1.0), zero space before/after (in points), and indent level 1.
Honest scope (verified in scripts/text_model_spike.py): PowerPoint
exposes no "clear direct formatting" primitive — re-setting the text does
not drop run overrides, and a run's size / typeface / colour / emphasis have
no readable "inherited" value to fall back to, so this resets only the
unambiguous paragraph-spacing knobs (where "single, 0, 0" is the clean
default). To restore a placeholder's geometry + default font size from
its layout, use Shape.reset_to_layout(); to set a specific font, use
format_text. A mutation: wrap in deck.edit(...).
Source code in src/pptlive/_anchors.py
| def reset_format(self) -> None:
"""Reset this anchor's paragraph *spacing* to clean single-spaced defaults.
The recovery verb for the line-spacing / space-before spiral — the gpt-5.4
review's "giant spacing, text off the slide, unrecoverable without a
rewrite" case. Sets single line spacing (`LineRuleWithin=msoTrue`,
`SpaceWithin=1.0`), zero space before/after (in points), and indent level 1.
**Honest scope (verified in `scripts/text_model_spike.py`):** PowerPoint
exposes *no* "clear direct formatting" primitive — re-setting the text does
not drop run overrides, and a run's size / typeface / colour / emphasis have
no readable "inherited" value to fall back to, so this resets only the
unambiguous paragraph-spacing knobs (where "single, 0, 0" *is* the clean
default). To restore a **placeholder's** geometry + default font size from
its layout, use `Shape.reset_to_layout()`; to set a specific font, use
`format_text`. A mutation: wrap in `deck.edit(...)`.
"""
with _com.translate_com_errors():
tr = self._text_range()
apply_paragraph_format(
tr.ParagraphFormat,
line_spacing=1.0,
space_before=0.0,
space_after=0.0,
)
tr.IndentLevel = 1
|
insert_paragraph_before
insert_paragraph_before(text: str) -> None
Insert text as a new paragraph immediately before this anchor's range.
On a whole-shape anchor this prepends a first paragraph; on a Paragraph
it inserts just above that paragraph. Embedded \n/\r in text add
further paragraphs; \v (SOFT_BREAK) adds a soft line break.
Source code in src/pptlive/_anchors.py
| def insert_paragraph_before(self, text: str) -> None:
"""Insert `text` as a new paragraph immediately before this anchor's range.
On a whole-shape anchor this prepends a first paragraph; on a `Paragraph`
it inserts just above that paragraph. Embedded `\\n`/`\\r` in `text` add
further paragraphs; `\\v` (`SOFT_BREAK`) adds a soft line break.
"""
text = normalize_paragraph_breaks(text)
with _com.translate_com_errors():
tr = self._text_range()
if str(tr.Text or "") == "":
tr.Text = text
else:
tr.InsertBefore(text + "\r")
|
insert_paragraph_after
insert_paragraph_after(text: str) -> None
Insert text as a new paragraph immediately after this anchor's range.
On a whole-shape anchor this appends a paragraph (the common "add a
bullet to the body" case); on a Paragraph it inserts just below it. The
range includes its trailing break for a non-final paragraph, so we detect
that to land a clean new paragraph either way (verified in the spike).
Embedded \n/\r in text add further paragraphs; \v (SOFT_BREAK)
adds a soft line break.
Source code in src/pptlive/_anchors.py
| def insert_paragraph_after(self, text: str) -> None:
"""Insert `text` as a new paragraph immediately after this anchor's range.
On a whole-shape anchor this appends a paragraph (the common "add a
bullet to the body" case); on a `Paragraph` it inserts just below it. The
range includes its trailing break for a non-final paragraph, so we detect
that to land a clean new paragraph either way (verified in the spike).
Embedded `\\n`/`\\r` in `text` add further paragraphs; `\\v` (`SOFT_BREAK`)
adds a soft line break.
"""
text = normalize_paragraph_breaks(text)
with _com.translate_com_errors():
tr = self._text_range()
raw = str(tr.Text or "")
if raw == "":
tr.InsertAfter(text)
elif raw.endswith("\r"):
tr.InsertAfter(text + "\r")
else:
tr.InsertAfter("\r" + text)
|
set_link
set_link(text: str | None = None, *, start: int | None = None, length: int | None = None, url: str | None = None, slide: int | None = None, screen_tip: str | None = None) -> dict[str, Any]
Link a span of this anchor's text; return the resulting link dict.
Address the span by literal substring text= (the LLM-friendly
default — located fuzzily, an ambiguous multi-match raises
AmbiguousMatchError, no match AnchorNotFoundError) or by explicit 0-based
start/length. Destination: exactly one of url= (an external URL /
mailto: / file path) or slide= (a 1-based in-deck slide jump). Optional
screen_tip hover tooltip.
Returns {text, start, length, address, sub_address}. Raises ValueError
(before any COM) for a bad span/destination combination or a blank url. A
mutation: wrap in deck.edit(...) for the one-Ctrl-Z fence.
Source code in src/pptlive/_anchors.py
| def set_link(
self,
text: str | None = None,
*,
start: int | None = None,
length: int | None = None,
url: str | None = None,
slide: int | None = None,
screen_tip: str | None = None,
) -> dict[str, Any]:
"""Link a span of this anchor's text; return the resulting link dict.
**Address the span** by literal substring `text=` (the LLM-friendly
default — located fuzzily, an ambiguous multi-match raises
`AmbiguousMatchError`, no match `AnchorNotFoundError`) or by explicit 0-based
`start`/`length`. **Destination:** exactly one of `url=` (an external URL /
`mailto:` / file path) or `slide=` (a 1-based in-deck slide jump). Optional
`screen_tip` hover tooltip.
Returns `{text, start, length, address, sub_address}`. Raises `ValueError`
(before any COM) for a bad span/destination combination or a blank `url`. A
mutation: wrap in `deck.edit(...)` for the one-Ctrl-Z fence.
"""
if (url is None) == (slide is None):
raise ValueError("set_link() requires exactly one of url= or slide=")
if url is not None and not str(url).strip():
raise ValueError("set_link(url=) must be a non-empty string")
sub_address: str | None = None
if slide is not None:
# Resolve the jump target before any mutation (SlideNotFoundError, exit 2).
sub_address = slide_jump_subaddress(self.slide, int(slide))
with _com.translate_com_errors():
tr = self._text_range()
span_start, span_len, span_text = _resolve_span(tr, text, start, length)
link = tr.Characters(span_start + 1, span_len).ActionSettings(_MOUSE_CLICK).Hyperlink
if url is not None:
link.Address = str(url)
else:
link.Address = "" # SubAddress alone makes it an in-deck jump
link.SubAddress = sub_address
if screen_tip is not None:
link.ScreenTip = str(screen_tip)
return {
"text": span_text,
"start": span_start,
"length": span_len,
"address": str(url) if url is not None else None,
"sub_address": sub_address,
}
|
remove_link
remove_link(text: str | None = None, *, start: int | None = None, length: int | None = None) -> int
Remove text-run hyperlinks from this anchor; return how many were cleared.
With no span (text/start/length all omitted) clears all links in
the anchor; otherwise clears just the addressed span (same addressing as
set_link). Hyperlink.Delete() per span. A mutation: wrap in
deck.edit(...).
Source code in src/pptlive/_anchors.py
| def remove_link(
self, text: str | None = None, *, start: int | None = None, length: int | None = None
) -> int:
"""Remove text-run hyperlinks from this anchor; return how many were cleared.
With no span (`text`/`start`/`length` all omitted) clears **all** links in
the anchor; otherwise clears just the addressed span (same addressing as
`set_link`). `Hyperlink.Delete()` per span. A mutation: wrap in
`deck.edit(...)`.
"""
with _com.translate_com_errors():
tr = self._text_range()
if text is None and start is None and length is None:
rows = links_in_range(tr)
# Delete back-to-front: a cleared link re-merges its run, shifting
# later runs — reverse order keeps each span's offsets valid.
for row in reversed(rows):
tr.Characters(int(row["start"]) + 1, int(row["length"])).ActionSettings(
_MOUSE_CLICK
).Hyperlink.Delete()
return len(rows)
span_start, span_len, _ = _resolve_span(tr, text, start, length)
tr.Characters(span_start + 1, span_len).ActionSettings(_MOUSE_CLICK).Hyperlink.Delete()
return 1
|
links
links() -> list[dict[str, Any]]
Every text-run hyperlink in this anchor — [{text, start, length, address,
sub_address}] in document order (a read; see links_in_range).
Source code in src/pptlive/_anchors.py
| def links(self) -> list[dict[str, Any]]:
"""Every text-run hyperlink in this anchor — `[{text, start, length, address,
sub_address}]` in document order (a read; see `links_in_range`)."""
with _com.translate_com_errors():
return links_in_range(self._text_range())
|
pptlive.Paragraph
Paragraph(shape: Shape, index: int)
Bases: Anchor
One paragraph of a shape's text frame — anchor id para:S:N:P.
Located by 1-based paragraph index P within shape N (z-order) on slide
S. Inherits every text verb (set_text, format_text, format_paragraph,
apply_list, insert_paragraph_before/after); _text_range() is
TextFrame.TextRange.Paragraphs(P, 1), so those verbs scope to just this
paragraph. Resolves live on each access (the paragraph count drifts as text
is inserted/deleted), raising AnchorNotFoundError if P is out of range or
NoTextFrameError (via the shape) if the shape holds no text.
Source code in src/pptlive/_anchors.py
| def __init__(self, shape: Shape, index: int) -> None:
self._shape = shape
self._index = int(index)
|
index
property
1-based paragraph index within the shape's text frame.
text
property
The paragraph's text, without the trailing paragraph break.
indent_level
property
PowerPoint outline/bullet level, 1-5.
delete
Delete this paragraph (text + its break). The wrapper is spent.
Source code in src/pptlive/_anchors.py
| def delete(self) -> None:
"""Delete this paragraph (text + its break). The wrapper is spent."""
with _com.translate_com_errors():
self._text_range().Delete()
|
set_paragraphs
set_paragraphs(paragraphs: list[Any]) -> list[str]
Not supported on a single paragraph — call it on the shape/cell anchor.
The base set_paragraphs replaces a whole text frame's paragraph list.
A Paragraph's _text_range() is a single paragraph, so the inherited
implementation would write a multi-paragraph block into one paragraph's
range (corrupting it) and silently drop per-item formatting (no
paragraphs view to address). Reject it explicitly instead.
Source code in src/pptlive/_anchors.py
| def set_paragraphs(self, paragraphs: list[Any]) -> list[str]:
"""Not supported on a single paragraph — call it on the shape/cell anchor.
The base `set_paragraphs` replaces a whole text frame's paragraph list.
A `Paragraph`'s `_text_range()` is a single paragraph, so the inherited
implementation would write a multi-paragraph block into one paragraph's
range (corrupting it) and silently drop per-item formatting (no
`paragraphs` view to address). Reject it explicitly instead.
"""
raise ValueError(
"set_paragraphs replaces a whole text frame's paragraph list and "
f"cannot target a single paragraph anchor ({self.anchor_id!r}); call "
"it on the shape or cell anchor that owns this paragraph."
)
|
pptlive.ParagraphCollection
ParagraphCollection(shape: Shape)
Indexable, iterable view over the paragraphs of a shape's text frame.
shape.paragraphs[2] is the 2nd paragraph (1-based); iteration yields a
Paragraph each; list() emits the structured dict used by the
paragraphs CLI command. Raises NoTextFrameError (via the shape) if the
shape holds no text.
Source code in src/pptlive/_anchors.py
| def __init__(self, shape: Shape) -> None:
self._shape = shape
|
list
list() -> list[dict[str, Any]]
Every paragraph as a structured dict, in order.
Source code in src/pptlive/_anchors.py
| def list(self) -> list[dict[str, Any]]:
"""Every paragraph as a structured dict, in order."""
out: list[dict[str, Any]] = []
with _com.translate_com_errors():
tr = self._shape._text_range()
count = int(tr.Paragraphs().Count)
for idx in range(1, count + 1):
out.append(paragraph_to_dict(tr.Paragraphs(idx, 1), self._anchor_id(idx), idx))
return out
|
pptlive.Notes
Bases: Anchor
The speaker-notes body of a slide — anchor id notes:S.
Resolves the notes-page body placeholder by
PlaceholderFormat.Type == ppPlaceholderBody, not by a hard index, because
the index varies across templates (spec.md / IMPLEMENTATION.md spike item).
Reads return "" for an empty notes body; set_text replaces it.
Source code in src/pptlive/_anchors.py
| def __init__(self, slide: Slide) -> None:
self._slide = slide
|
Tables
A table is a shape on a slide (Shape.has_table / Shape.table), not a
deck-scoped collection. Reach a table through its shape
(slide.shapes[N].table) and address its cells as cell:S:N:R:C. A
Cell is an Anchor, so
doc.anchor_by_id("cell:4:5:1:1") returns a handle that works with set_text,
format_text, and format_paragraph like any other anchor.
pptlive.Table
A table on a slide, bound to its Shape (over Shape.Table).
Re-resolves the COM table live through the shape, so it survives z-order
drift. A table has no anchor of its own — shape:S:N addresses the table
shape (geometry/delete) and cell:S:N:R:C addresses a cell — so Table
exposes structural reads + row edits, while text edits go through Cell.
Source code in src/pptlive/_tables.py
| def __init__(self, shape: Shape) -> None:
self._shape = shape
|
com
property
Raw COM Table (Shape.Table), resolved live.
cell
cell(row: int, col: int) -> Cell
Return the Cell at 1-based (row, col).
Raises AnchorNotFoundError (kind "table cell") if the coordinates fall
outside the table's grid.
Source code in src/pptlive/_tables.py
| def cell(self, row: int, col: int) -> Cell:
"""Return the `Cell` at 1-based (row, col).
Raises `AnchorNotFoundError` (kind `"table cell"`) if the coordinates fall
outside the table's grid.
"""
rows, cols = self.row_count, self.column_count
if not (1 <= int(row) <= rows and 1 <= int(col) <= cols):
raise AnchorNotFoundError(
"table cell", f"cell:{self._shape.slide.index}:{self._shape.index}:{row}:{col}"
)
return Cell(self, int(row), int(col))
|
grid
grid() -> list[list[str]]
All cell text as a row-major list[list[str]].
Source code in src/pptlive/_tables.py
| def grid(self) -> list[list[str]]:
"""All cell text as a row-major `list[list[str]]`."""
rows, cols = self.row_count, self.column_count
return [[self.cell(r, c).text for c in range(1, cols + 1)] for r in range(1, rows + 1)]
|
read
Structured dump: dimensions plus every cell with its addressable id.
Each cell carries its anchor_id (cell:S:N:R:C) so a caller can feed it
straight back into write / format-text / format-paragraph.
Merged cells are not flagged: PowerPoint's COM Cell exposes no
merge-state read property (it lives only in the OOXML, off the automation
surface), so the grid is reported by its raw row×column geometry. A merged
region still occupies every covered coordinate, and writing to a covered
cell lands on the merge origin's text — address merged regions by their
top-left (origin) cell to avoid surprises.
Source code in src/pptlive/_tables.py
| def read(self) -> dict[str, Any]:
"""Structured dump: dimensions plus every cell with its addressable id.
Each cell carries its `anchor_id` (`cell:S:N:R:C`) so a caller can feed it
straight back into `write` / `format-text` / `format-paragraph`.
Merged cells are **not** flagged: PowerPoint's COM `Cell` exposes no
merge-state read property (it lives only in the OOXML, off the automation
surface), so the grid is reported by its raw row×column geometry. A merged
region still occupies every covered coordinate, and writing to a covered
cell lands on the merge origin's text — address merged regions by their
top-left (origin) cell to avoid surprises.
"""
rows, cols = self.row_count, self.column_count
cells = [
[self.cell(r, c).to_dict() for c in range(1, cols + 1)] for r in range(1, rows + 1)
]
return {
"slide": self._shape.slide.index,
"shape": self._shape.index,
"anchor_id": self._shape.anchor_id,
"rows": rows,
"columns": cols,
"cells": cells,
}
|
add_row
add_row(values: list[Any] | None = None) -> None
Append a row at the end of the table, optionally filling its cells.
values are matched to columns left-to-right; extras past the column
count are ignored, short lists leave trailing cells empty. Wrap in
deck.edit(...) for view preservation + a one-Ctrl-Z fence.
Source code in src/pptlive/_tables.py
| def add_row(self, values: list[Any] | None = None) -> None:
"""Append a row at the end of the table, optionally filling its cells.
`values` are matched to columns left-to-right; extras past the column
count are ignored, short lists leave trailing cells empty. Wrap in
`deck.edit(...)` for view preservation + a one-Ctrl-Z fence.
"""
with _com.translate_com_errors():
com_table = self._shape.com.Table
com_table.Rows.Add()
if values:
last = int(com_table.Rows.Count)
cols = int(com_table.Columns.Count)
for c, val in enumerate(values, start=1):
if c > cols:
break
com_table.Cell(last, c).Shape.TextFrame.TextRange.Text = str(val)
|
delete_row
delete_row(index: int) -> None
Delete the 1-based row index.
Raises AnchorNotFoundError (kind "table row") if out of range, and
ValueError if it would empty the table — PowerPoint has no zero-row
table, and Rows(1).Delete() on a one-row grid corrupts the shape rather
than failing cleanly. Delete the whole table shape (shape:S:N) instead.
Source code in src/pptlive/_tables.py
| def delete_row(self, index: int) -> None:
"""Delete the 1-based row `index`.
Raises `AnchorNotFoundError` (kind `"table row"`) if out of range, and
`ValueError` if it would empty the table — PowerPoint has no zero-row
table, and `Rows(1).Delete()` on a one-row grid corrupts the shape rather
than failing cleanly. Delete the whole table shape (`shape:S:N`) instead.
"""
rows = self.row_count
if not (1 <= int(index) <= rows):
raise AnchorNotFoundError(
"table row", f"cell:{self._shape.slide.index}:{self._shape.index}:row:{index}"
)
if rows <= 1:
raise ValueError(
"cannot delete the last row of a table; delete the table shape "
f"({self._shape.anchor_id}) instead"
)
with _com.translate_com_errors():
self._shape.com.Table.Rows(int(index)).Delete()
|
add_column
add_column(values: list[Any] | None = None, *, before: int | None = None) -> None
Add a column, optionally filling its cells — appended by default.
before (1-based) inserts the new column before that existing column;
omitting it appends at the right edge (verified live: Columns.Add()
appends, Columns.Add(n) inserts before column n). values are matched
to rows top-to-bottom; extras past the row count are ignored, short lists
leave trailing cells empty. Raises AnchorNotFoundError (kind
"table column") for an out-of-range before. Wrap in deck.edit(...)
for view preservation + a one-Ctrl-Z fence.
Source code in src/pptlive/_tables.py
| def add_column(self, values: list[Any] | None = None, *, before: int | None = None) -> None:
"""Add a column, optionally filling its cells — appended by default.
`before` (1-based) inserts the new column *before* that existing column;
omitting it appends at the right edge (verified live: `Columns.Add()`
appends, `Columns.Add(n)` inserts before column `n`). `values` are matched
to rows top-to-bottom; extras past the row count are ignored, short lists
leave trailing cells empty. Raises `AnchorNotFoundError` (kind
`"table column"`) for an out-of-range `before`. Wrap in `deck.edit(...)`
for view preservation + a one-Ctrl-Z fence.
"""
if before is not None:
cols = self.column_count
if not (1 <= int(before) <= cols):
raise AnchorNotFoundError(
"table column",
f"cell:{self._shape.slide.index}:{self._shape.index}:column:{before}",
)
with _com.translate_com_errors():
com_table = self._shape.com.Table
if before is None:
com_table.Columns.Add()
target = int(com_table.Columns.Count)
else:
com_table.Columns.Add(int(before))
target = int(before)
if values:
rows = int(com_table.Rows.Count)
for r, val in enumerate(values, start=1):
if r > rows:
break
com_table.Cell(r, target).Shape.TextFrame.TextRange.Text = str(val)
|
delete_column
delete_column(index: int) -> None
Delete the 1-based column index.
Raises AnchorNotFoundError (kind "table column") if out of range, and
ValueError if it would empty the table — PowerPoint has no zero-column
table, and deleting the only column corrupts the shape rather than failing
cleanly. Delete the whole table shape (shape:S:N) instead.
Source code in src/pptlive/_tables.py
| def delete_column(self, index: int) -> None:
"""Delete the 1-based column `index`.
Raises `AnchorNotFoundError` (kind `"table column"`) if out of range, and
`ValueError` if it would empty the table — PowerPoint has no zero-column
table, and deleting the only column corrupts the shape rather than failing
cleanly. Delete the whole table shape (`shape:S:N`) instead.
"""
cols = self.column_count
if not (1 <= int(index) <= cols):
raise AnchorNotFoundError(
"table column",
f"cell:{self._shape.slide.index}:{self._shape.index}:column:{index}",
)
if cols <= 1:
raise ValueError(
"cannot delete the last column of a table; delete the table shape "
f"({self._shape.anchor_id}) instead"
)
with _com.translate_com_errors():
self._shape.com.Table.Columns(int(index)).Delete()
|
set_fill
set_fill(fill: str | int | tuple[int, int, int], *, rows: AxisSelector = None, cols: AxisSelector = None, transparency: float | None = None) -> int
Solid-fill (cell shading) a region of cells — or clear it with fill="none".
rows/cols select the region: None is the whole axis, an int one
index, a list several. Their intersection is filled — so rows=1 shades
the header row, cols=2 a column, rows=1, cols=1 a single cell, and both
None the whole table. fill is a color ("#RRGGBB" / (r, g, b) / raw
RGB int) or "none" (transparent — falls back to the table style's shading).
transparency is a 0.0..1.0 alpha fraction. Colors / indices are validated
before any COM mutation. A mutation: wrap in deck.edit(...). Returns the
number of cells filled.
Source code in src/pptlive/_tables.py
| def set_fill(
self,
fill: str | int | tuple[int, int, int],
*,
rows: AxisSelector = None,
cols: AxisSelector = None,
transparency: float | None = None,
) -> int:
"""Solid-fill (cell shading) a region of cells — or clear it with `fill="none"`.
`rows`/`cols` select the region: `None` is the whole axis, an int one
index, a list several. Their **intersection** is filled — so `rows=1` shades
the header row, `cols=2` a column, `rows=1, cols=1` a single cell, and both
`None` the whole table. `fill` is a color (`"#RRGGBB"` / `(r, g, b)` / raw
RGB int) or `"none"` (transparent — falls back to the table style's shading).
`transparency` is a `0.0..1.0` alpha fraction. Colors / indices are validated
before any COM mutation. A mutation: wrap in `deck.edit(...)`. Returns the
number of cells filled.
"""
row_idx = self._resolve_axis(rows, self.row_count, "row")
col_idx = self._resolve_axis(cols, self.column_count, "column")
if not _is_none_token(fill):
parse_color(fill) # validate the color once, before any COM (like set_border)
with _com.translate_com_errors():
com_table = self._shape.com.Table
count = 0
for r in row_idx:
for c in col_idx:
apply_shape_fill(
com_table.Cell(r, c).Shape, fill=fill, fill_transparency=transparency
)
count += 1
return count
|
set_border
set_border(*, color: str | int | tuple[int, int, int] | None = None, weight: float | None = None, dash: str | int | None = None, edges: str | int | Sequence[str | int] = 'all', rows: AxisSelector = None, cols: AxisSelector = None, visible: bool | None = None) -> int
Style cell border(s) across a region — color / weight / dash / visibility.
rows/cols select the region exactly like set_fill (intersection;
None = whole axis). edges picks which edges of each cell: "all" (the
four sides) or one/several of "top"/"bottom"/"left"/"right"/
"diagonal_down"/"diagonal_up". color is a color (turns the edge on) or
"none" (hides it); weight is points; dash a friendly MsoLineDashStyle
name; visible forces the edge on/off. At least one of
color/weight/dash/visible is required. All names / colors / indices
are validated before any COM mutation. A mutation: wrap in deck.edit(...).
Returns the number of cells touched.
Source code in src/pptlive/_tables.py
| def set_border(
self,
*,
color: str | int | tuple[int, int, int] | None = None,
weight: float | None = None,
dash: str | int | None = None,
edges: str | int | Sequence[str | int] = "all",
rows: AxisSelector = None,
cols: AxisSelector = None,
visible: bool | None = None,
) -> int:
"""Style cell border(s) across a region — color / weight / dash / visibility.
`rows`/`cols` select the region exactly like `set_fill` (intersection;
`None` = whole axis). `edges` picks which edges of each cell: `"all"` (the
four sides) or one/several of `"top"`/`"bottom"`/`"left"`/`"right"`/
`"diagonal_down"`/`"diagonal_up"`. `color` is a color (turns the edge on) or
`"none"` (hides it); `weight` is points; `dash` a friendly `MsoLineDashStyle`
name; `visible` forces the edge on/off. At least one of
`color`/`weight`/`dash`/`visible` is required. All names / colors / indices
are validated before any COM mutation. A mutation: wrap in `deck.edit(...)`.
Returns the number of cells touched.
"""
if color is None and weight is None and dash is None and visible is None:
raise ValueError(
"set_border() requires at least one of color=, weight=, dash=, visible="
)
if weight is not None and weight < 0: # validate before any COM
raise ValueError(f"weight must be >= 0 points, got {weight}")
edge_idx = border_edges_for(edges) # ValueError before any COM
hide = color is not None and _is_none_token(color)
rgb = None if (color is None or hide) else parse_color(color) # ValueError before COM
dash_int = dash_style_for(dash) if dash is not None else None
row_idx = self._resolve_axis(rows, self.row_count, "row")
col_idx = self._resolve_axis(cols, self.column_count, "column")
with _com.translate_com_errors():
com_table = self._shape.com.Table
count = 0
for r in row_idx:
for c in col_idx:
com_cell = com_table.Cell(r, c)
for idx in edge_idx:
_apply_cell_border(
com_cell.Borders(idx),
rgb=rgb,
hide=hide,
weight=weight,
dash_int=dash_int,
visible=visible,
)
count += 1
return count
|
pptlive.Cell
Cell(table: Table, row: int, col: int)
Bases: Anchor
A single table cell, addressed by 1-based (row, column) — cell:S:N:R:C.
Subclasses Anchor, so it inherits every text verb (set_text,
format_text, format_paragraph, apply_list, insert_paragraph_*)
unchanged. _text_range() is the cell's own text frame, re-resolved live (the
grid can drift as rows are added/deleted), raising AnchorNotFoundError if
(row, col) falls outside the current grid.
Source code in src/pptlive/_tables.py
| def __init__(self, table: Table, row: int, col: int) -> None:
self._table = table
self._row = int(row)
self._col = int(col)
|
set_fill
set_fill(fill: str | int | tuple[int, int, int], *, transparency: float | None = None) -> int
Solid-fill this cell (cell shading) — or clear it with fill="none".
Thin per-cell wrapper over Table.set_fill (which is where the row/column
bulk form lives). fill is a color ("#RRGGBB" / (r, g, b) / raw RGB int)
or the string "none" (transparent — inherits the table style's shading);
transparency is a 0.0..1.0 alpha fraction. A mutation: wrap in
deck.edit(...). Returns the number of cells filled (always 1).
Source code in src/pptlive/_tables.py
| def set_fill(
self,
fill: str | int | tuple[int, int, int],
*,
transparency: float | None = None,
) -> int:
"""Solid-fill this cell (cell shading) — or clear it with `fill="none"`.
Thin per-cell wrapper over `Table.set_fill` (which is where the row/column
bulk form lives). `fill` is a color (`"#RRGGBB"` / `(r, g, b)` / raw RGB int)
or the string `"none"` (transparent — inherits the table style's shading);
`transparency` is a `0.0..1.0` alpha fraction. A mutation: wrap in
`deck.edit(...)`. Returns the number of cells filled (always 1).
"""
return self._table.set_fill(fill, rows=self._row, cols=self._col, transparency=transparency)
|
set_border
set_border(*, color: str | int | tuple[int, int, int] | None = None, weight: float | None = None, dash: str | int | None = None, edges: str | int | Sequence[str | int] = 'all', visible: bool | None = None) -> int
Style this cell's border(s) — color / weight / dash / visibility.
Thin per-cell wrapper over Table.set_border. edges selects which edges
("all" = the four sides, or "top"/"bottom"/"left"/"right"/
"diagonal_down"/"diagonal_up", one or a list); color is a color or
"none" (hide that edge). A mutation: wrap in deck.edit(...). Returns the
number of cells touched (always 1).
Source code in src/pptlive/_tables.py
| def set_border(
self,
*,
color: str | int | tuple[int, int, int] | None = None,
weight: float | None = None,
dash: str | int | None = None,
edges: str | int | Sequence[str | int] = "all",
visible: bool | None = None,
) -> int:
"""Style this cell's border(s) — color / weight / dash / visibility.
Thin per-cell wrapper over `Table.set_border`. `edges` selects which edges
(`"all"` = the four sides, or `"top"`/`"bottom"`/`"left"`/`"right"`/
`"diagonal_down"`/`"diagonal_up"`, one or a list); `color` is a color or
`"none"` (hide that edge). A mutation: wrap in `deck.edit(...)`. Returns the
number of cells touched (always 1).
"""
return self._table.set_border(
rows=self._row,
cols=self._col,
color=color,
weight=weight,
dash=dash,
edges=edges,
visible=visible,
)
|
Charts
A chart is also a shape (Shape.has_chart / Shape.chart); its data lives in
an embedded Excel workbook. Chart reads the chart type,
categories, and series, and writes them back with set_type / set_data.
pptlive.Chart
A chart on a slide, bound to its Shape — reached via shape.chart.
chart = deck.slides[2].shapes[3].chart
chart.read() # {chart_type, categories, series:[...]}
chart.set_type("line") # change the chart kind
chart.set_data(["Q1", "Q2"], {"Revenue": [10, 20]}) # rewrite the data
read() is side-effect-free. set_type / set_data mutate; wrap them in
deck.edit(...) (as the CLI/MCP do) for view preservation + a one-Ctrl-Z
fence. The COM chart is resolved live every call, so z-order drift on the
host shape is handled.
Source code in src/pptlive/_charts.py
| def __init__(self, shape: Shape) -> None:
self._shape = shape
|
com
property
Raw COM Chart (Shape.Chart), resolved live.
chart_type
property
Friendly chart-type name (e.g. "column_clustered", "line", "pie").
set_type
set_type(chart_type: str | int) -> None
Change the chart kind (Chart.ChartType).
chart_type is a friendly name ("column", "bar", "line", "pie",
…; see constants.CHART_TYPE_CHOICES) or a raw XlChartType int. Raises
ValueError for an unknown name (before any COM). Wrap in deck.edit(...).
Source code in src/pptlive/_charts.py
| def set_type(self, chart_type: str | int) -> None:
"""Change the chart kind (`Chart.ChartType`).
`chart_type` is a friendly name (`"column"`, `"bar"`, `"line"`, `"pie"`,
…; see `constants.CHART_TYPE_CHOICES`) or a raw `XlChartType` int. Raises
`ValueError` for an unknown name (before any COM). Wrap in `deck.edit(...)`.
"""
type_int = chart_type_for(chart_type) # ValueError before COM
with _com.translate_com_errors():
self.com.ChartType = type_int
|
categories
categories() -> list[str]
The category (X-axis) labels, as strings.
Source code in src/pptlive/_charts.py
| def categories(self) -> list[str]:
"""The category (X-axis) labels, as strings."""
with _com.translate_com_errors():
sc = self.com.SeriesCollection()
if int(sc.Count) < 1:
return []
return [_as_label(v) for v in sc(1).XValues]
|
series
series() -> list[dict[str, Any]]
Each series as {"name": str, "values": [float, ...]}, in plot order.
Source code in src/pptlive/_charts.py
| def series(self) -> list[dict[str, Any]]:
"""Each series as `{"name": str, "values": [float, ...]}`, in plot order."""
with _com.translate_com_errors():
sc = self.com.SeriesCollection()
out: list[dict[str, Any]] = []
for i in range(1, int(sc.Count) + 1):
s = sc(i)
out.append({"name": str(s.Name), "values": [_as_number(v) for v in s.Values]})
return out
|
read
Structured dump: chart kind + categories + series.
{slide, shape, anchor_id, chart_type, chart_type_code, categories,
series:[{name, values}]}. Side-effect-free.
Source code in src/pptlive/_charts.py
| def read(self) -> dict[str, Any]:
"""Structured dump: chart kind + categories + series.
`{slide, shape, anchor_id, chart_type, chart_type_code, categories,
series:[{name, values}]}`. Side-effect-free.
"""
with _com.translate_com_errors():
chart = self.com
type_code = int(chart.ChartType)
sc = chart.SeriesCollection()
count = int(sc.Count)
cats = [_as_label(v) for v in sc(1).XValues] if count >= 1 else []
series = [
{"name": str(sc(i).Name), "values": [_as_number(v) for v in sc(i).Values]}
for i in range(1, count + 1)
]
return {
"slide": self._shape.slide.index,
"shape": self._shape.index,
"anchor_id": self._shape.anchor_id,
"chart_type": chart_type_name(type_code),
"chart_type_code": type_code,
"categories": cats,
"series": series,
}
|
set_data
set_data(categories: Sequence[str], series: SeriesInput) -> None
Replace the chart's data with categories × series.
categories are the X-axis labels; series is a name->values mapping
(e.g. {"Revenue": [10, 20, 30]}) or an ordered sequence of
(name, values) pairs. Series are written — and plotted — in insertion
order (note bar charts render series bottom-to-top by Excel/PowerPoint
convention, so the first series sits at the bottom; this is a render
order, not a reorder of the data). Every series must have exactly
len(categories) values. Raises ValueError for empty inputs or a length
mismatch (before any COM). Wrap in deck.edit(...) for the one-Ctrl-Z fence.
Drives the chart's embedded Excel workbook: activate it, clear stale
values, write the corner / series names (row 1) / categories (column A) /
values, point SetSourceData at the exact range (string form), and close
the workbook. See the module docstring for why no Excel Table is used.
Source code in src/pptlive/_charts.py
| def set_data(self, categories: Sequence[str], series: SeriesInput) -> None:
"""Replace the chart's data with `categories` × `series`.
`categories` are the X-axis labels; `series` is a name->values mapping
(e.g. `{"Revenue": [10, 20, 30]}`) or an ordered sequence of
`(name, values)` pairs. Series are written — and plotted — in insertion
order (note bar charts render series bottom-to-top by Excel/PowerPoint
convention, so the first series sits at the bottom; this is a render
order, not a reorder of the data). Every series must have exactly
`len(categories)` values. Raises `ValueError` for empty inputs or a length
mismatch (before any COM). Wrap in `deck.edit(...)` for the one-Ctrl-Z fence.
Drives the chart's embedded Excel workbook: activate it, clear stale
values, write the corner / series names (row 1) / categories (column A) /
values, point `SetSourceData` at the exact range (string form), and close
the workbook. See the module docstring for why no Excel Table is used.
"""
cats = [str(c) for c in categories]
norm = _normalize_series(series)
if not cats:
raise ValueError("set_data requires at least one category")
if not norm:
raise ValueError("set_data requires at least one series")
for name, values in norm:
if len(values) != len(cats):
raise ValueError(
f"series {name!r} has {len(values)} values but there are {len(cats)} categories"
)
ncols = 1 + len(norm)
nrows = 1 + len(cats)
def _write() -> None:
with _com.translate_com_errors():
chart = self.com
cdata = chart.ChartData
cdata.Activate()
wb = cdata.Workbook
try:
ws = wb.Worksheets(1)
ws.UsedRange.ClearContents()
ws.Cells(1, 1).Value = "" # corner
for c, (name, _values) in enumerate(norm, start=2):
ws.Cells(1, c).Value = name
for r, cat in enumerate(cats, start=2):
# Force the category column to Text, else Excel coerces a
# numeric-looking label ("2026") to a number and it reads
# back as "2026.0". Categories are labels, never values.
cell = ws.Cells(r, 1)
cell.NumberFormat = "@"
cell.Value = cat
for c, (_name, values) in enumerate(norm, start=2):
for r, v in enumerate(values, start=2):
ws.Cells(r, c).Value = v
target = f"{_sheet_ref(ws.Name)}!$A$1:${_col_letter(ncols)}${nrows}"
chart.SetSourceData(target)
finally:
wb.Close()
# The embedded-Excel commit can SILENTLY race: a first-chance
# RPC_S_CALL_FAILED (0x800706BE) during the workbook teardown leaves
# SetSourceData bound to an empty range with *no* Python exception, so
# the chart reads back blank (SeriesCollection empty). Verify the write
# actually landed; if not, raise a retryable busy so retry_on_busy
# re-runs the whole idempotent sequence (a fresh ChartData.Activate
# re-establishes the workbook). Observed live ~50% of the time on the
# chart smoke test before this guard.
if not self._reflects_data(len(norm), len(cats)):
raise PowerPointBusyError(
"chart data did not commit (embedded-Excel teardown raced); retrying"
)
# The embedded workbook can be momentarily unavailable right after the
# chart is created (RPC_S_UNKNOWN_IF), and the commit above can race; the
# write is a clean rewrite, so retrying the whole sequence is safe. See
# `_com.retry_on_busy`.
_com.retry_on_busy(_write, attempts=5)
|
recolor_text
recolor_text(color: str | int | tuple[int, int, int]) -> dict[str, Any]
Set the font color of every shown chart text element (one Ctrl-Z).
A chart has no addressable text frame of its own — its text is split across
the legend, axis tick labels, title, and data labels, each its own COM
element — so there is no per-anchor format_text path. This is the coarse
fix PPTLIVE-009 asked for: recolor all chart text at once, the move a dark-
(or any custom-background) theme needs when the inherited black axis/legend
text goes invisible.
color is a "#RRGGBB" / "RRGGBB" hex string, an (r, g, b) tuple, or a
raw RGB int (same forms as format_text's color). Raises ValueError for
a malformed color (before any COM). Recolors only the elements that are
actually present: the legend/title are guarded by HasLegend /
HasTitle; the axis tick labels and per-series data labels are best-effort
(an absent axis — e.g. a pie chart has none — is simply skipped, not an
error), so it never adds a legend, title, or labels the deck didn't show.
Always sets the ChartArea global text default (the modern
Format.TextFrame2 path) so any text shown later inherits the color too.
Wrap in deck.edit(...) for view preservation + the one-Ctrl-Z fence.
Returns {ok, slide, shape, anchor_id, color, recolored, series_data_labels}
where recolored lists the element kinds that were touched.
The chart Font model differs from a text frame's: a chart element's
Font.Color is itself the RGB long (set/read directly), not a
ColorFormat with .RGB; and Series.DataLabels is a method.
Chart.HasAxis is an Excel-ism PowerPoint's chart COM rejects, so axes are
probed by attempting the set rather than asking first.
The core recolor (chart area + legend/title/data labels) runs under
retry_on_busy: every set is idempotent (recoloring to the same RGB twice
is a no-op), so a transient busy mid-sequence retries the whole block rather
than leaving a half-recolored chart — the same safety set_data uses. The
axes stay best-effort outside that fence (they're already probe-and-skip).
Source code in src/pptlive/_charts.py
| def recolor_text(self, color: str | int | tuple[int, int, int]) -> dict[str, Any]:
"""Set the font color of **every shown** chart text element (one Ctrl-Z).
A chart has no addressable text frame of its own — its text is split across
the legend, axis tick labels, title, and data labels, each its own COM
element — so there is no per-anchor `format_text` path. This is the coarse
fix PPTLIVE-009 asked for: recolor all chart text at once, the move a dark-
(or any custom-background) theme needs when the inherited black axis/legend
text goes invisible.
`color` is a `"#RRGGBB"` / `"RRGGBB"` hex string, an `(r, g, b)` tuple, or a
raw RGB int (same forms as `format_text`'s `color`). Raises `ValueError` for
a malformed color (before any COM). Recolors only the elements that are
actually **present**: the legend/title are guarded by `HasLegend` /
`HasTitle`; the axis tick labels and per-series data labels are best-effort
(an absent axis — e.g. a pie chart has none — is simply skipped, not an
error), so it never adds a legend, title, or labels the deck didn't show.
Always sets the `ChartArea` global text default (the modern
`Format.TextFrame2` path) so any text shown later inherits the color too.
Wrap in `deck.edit(...)` for view preservation + the one-Ctrl-Z fence.
Returns `{ok, slide, shape, anchor_id, color, recolored, series_data_labels}`
where `recolored` lists the element kinds that were touched.
The chart `Font` model differs from a text frame's: a chart element's
`Font.Color` is *itself* the RGB long (set/read directly), not a
`ColorFormat` with `.RGB`; and `Series.DataLabels` is a **method**.
`Chart.HasAxis` is an Excel-ism PowerPoint's chart COM rejects, so axes are
probed by attempting the set rather than asking first.
The core recolor (chart area + legend/title/data labels) runs under
`retry_on_busy`: every set is idempotent (recoloring to the same RGB twice
is a no-op), so a transient busy mid-sequence retries the whole block rather
than leaving a half-recolored chart — the same safety `set_data` uses. The
axes stay best-effort outside that fence (they're already probe-and-skip).
"""
rgb = parse_color(color) # ValueError before any COM
def _recolor_core() -> tuple[list[str], int]:
recolored: list[str] = []
labeled = 0
with _com.translate_com_errors():
chart = self.com
# Global default for all chart text (modern TextFrame2 path);
# reliably present, and covers text elements shown later.
chart.ChartArea.Format.TextFrame2.TextRange.Font.Fill.ForeColor.RGB = rgb
recolored.append("chart_area")
if bool(chart.HasLegend):
chart.Legend.Font.Color = rgb
recolored.append("legend")
if bool(chart.HasTitle):
chart.ChartTitle.Font.Color = rgb
recolored.append("title")
sc = chart.SeriesCollection()
for i in range(1, int(sc.Count) + 1):
s = sc(i)
if bool(s.HasDataLabels):
s.DataLabels().Font.Color = rgb
labeled += 1
if labeled:
recolored.append("data_labels")
return recolored, labeled
recolored, labeled = _com.retry_on_busy(_recolor_core)
def _attempt_axis(axis_type: int) -> bool:
"""Set an axis's tick-label color; skip (False) if that axis is absent.
`Chart.HasAxis` is unreliable on PowerPoint's chart COM, so we attempt
the set and treat a COM failure (e.g. a pie chart has no axes) as
"not present" rather than an error.
"""
try:
with _com.translate_com_errors():
self.com.Axes(axis_type).TickLabels.Font.Color = rgb
return True
except PowerPointBusyError:
raise # a transient busy is retryable — don't mask it as "axis absent"
except PptliveError:
return False
for axis_type, name in (
(XlAxisType.CATEGORY, "category_axis"),
(XlAxisType.VALUE, "value_axis"),
):
if _attempt_axis(axis_type):
recolored.append(name)
return {
"ok": True,
"slide": self._shape.slide.index,
"shape": self._shape.index,
"anchor_id": self._shape.anchor_id,
"color": color_hex(rgb),
"recolored": recolored,
"series_data_labels": labeled,
}
|
SmartArt
A SmartArt diagram is a shape too (Shape.has_smartart / Shape.smartart); its
content is a tree of nodes. SmartArt reads the layout kind
and the nested node tree, and replaces it with set_nodes — a flat list of
strings, or {text, children} mappings that nest. Create one via
shapes.add_smartart(kind, nodes).
pptlive.SmartArt
A SmartArt diagram on a slide, bound to its Shape — reached via shape.smartart.
sa = deck.slides[2].shapes[3].smartart
sa.read() # {layout, nodes:[{text, level, children}]}
sa.set_nodes(["Discover", "Design", "Build", "Ship"]) # flat
sa.set_nodes([{"text": "CEO", "children": ["VP Eng", "VP Sales"]}]) # tree
read() is side-effect-free. set_nodes mutates; wrap it in deck.edit(...)
(as the CLI/MCP do) for view preservation + a one-Ctrl-Z fence. The COM
diagram is resolved live every call, so z-order drift on the host shape is
handled.
Source code in src/pptlive/_smartart.py
| def __init__(self, shape: Shape) -> None:
self._shape = shape
|
com
property
Raw COM SmartArt (Shape.SmartArt), resolved live.
layout_id
property
The diagram's layout URN (SmartArt.Layout.Id), stable across installs.
kind
property
Friendly layout name (e.g. "process", "orgchart"); falls back to the URN.
read
Structured dump: layout kind + the nested node tree.
{slide, shape, anchor_id, layout, layout_id, node_count,
nodes:[{node_index, text, level, children}]}. Each node carries a
1-based node_index (its position in a depth-first walk, which equals its
AllNodes index — spike-verified) so it can be fed straight into
format_node. Side-effect-free.
Source code in src/pptlive/_smartart.py
| def read(self) -> dict[str, Any]:
"""Structured dump: layout kind + the nested node tree.
`{slide, shape, anchor_id, layout, layout_id, node_count,
nodes:[{node_index, text, level, children}]}`. Each node carries a
1-based `node_index` (its position in a depth-first walk, which equals its
`AllNodes` index — spike-verified) so it can be fed straight into
`format_node`. Side-effect-free.
"""
with _com.translate_com_errors():
sa = self.com
layout_id = str(sa.Layout.Id)
counter = [0]
nodes = self._dump(sa.Nodes, counter)
total = int(sa.AllNodes.Count)
if counter[0] != total:
# The recursive Nodes walk and AllNodes must enumerate the same set in
# the same order (the spike-verified depth-first assumption) for a
# read()'s node_index to address the right node in format_node. A
# mismatch (e.g. a layout with assistant/hidden nodes AllNodes counts
# but Nodes doesn't reach) means node_index is unreliable — surface it.
warnings.warn(
f"SmartArt node walk ({counter[0]}) != AllNodes.Count ({total}); "
"node_index values may not line up with format_node",
stacklevel=2,
)
return {
"slide": self._shape.slide.index,
"shape": self._shape.index,
"anchor_id": self._shape.anchor_id,
"layout": smartart_layout_name(layout_id),
"layout_id": layout_id,
"node_count": total,
"nodes": nodes,
}
|
set_nodes
set_nodes(nodes: Sequence[NodeInput]) -> None
Replace the diagram's nodes with nodes.
nodes is a list of leaves (plain strings) and/or {text, children}
mappings, where children nests recursively. Flat layouts
(process/list/cycle/pyramid/venn) take any number of top-level nodes; tree
layouts (hierarchy/orgchart) take a single root with nested children —
passing more than one top-level node to a tree layout is a ValueError
(raised before any COM). Also raises ValueError for an empty list. Wrap
in deck.edit(...) for the one-Ctrl-Z fence.
Clears the layout's pre-built skeleton to one empty root, sizes the
top-level list, sets each node's text (on TextFrame2), and builds
children via AddNode(BELOW). See the module docstring for why.
Source code in src/pptlive/_smartart.py
| def set_nodes(self, nodes: Sequence[NodeInput]) -> None:
"""Replace the diagram's nodes with `nodes`.
`nodes` is a list of leaves (plain strings) and/or `{text, children}`
mappings, where `children` nests recursively. Flat layouts
(process/list/cycle/pyramid/venn) take any number of top-level nodes; tree
layouts (hierarchy/orgchart) take a **single root** with nested children —
passing more than one top-level node to a tree layout is a `ValueError`
(raised before any COM). Also raises `ValueError` for an empty list. Wrap
in `deck.edit(...)` for the one-Ctrl-Z fence.
Clears the layout's pre-built skeleton to one empty root, sizes the
top-level list, sets each node's text (on `TextFrame2`), and builds
children via `AddNode(BELOW)`. See the module docstring for why.
"""
norm = _normalize_nodes(nodes)
if not norm:
raise ValueError("set_nodes requires at least one node")
if self.kind in SMARTART_TREE_KINDS and len(norm) > 1:
raise ValueError(
f"the {self.kind!r} SmartArt layout takes a single root node; got "
f"{len(norm)} top-level nodes. Pass one {{text, children}} root."
)
with _com.translate_com_errors():
sa = self.com
# reset to a single empty top-level node
while int(sa.Nodes.Count) > 1:
sa.Nodes.Item(int(sa.Nodes.Count)).Delete()
if int(sa.Nodes.Count) == 0:
# A blank layout (or one left empty by a prior edit) has no root
# to seed from; create one before sizing the top level.
sa.Nodes.Add()
root = sa.Nodes.Item(1)
while int(root.Nodes.Count):
root.Nodes.Item(int(root.Nodes.Count)).Delete()
# grow top-level to len(norm); a tree layout caps here (Add is a no-op)
target = len(norm)
while int(sa.Nodes.Count) < target:
before = int(sa.Nodes.Count)
sa.Nodes.Add()
if int(sa.Nodes.Count) == before:
raise ValueError(
f"this SmartArt layout accepts {before} top-level node(s); got "
f"{target}. Tree layouts take one root with nested children."
)
while int(sa.Nodes.Count) > target:
sa.Nodes.Item(int(sa.Nodes.Count)).Delete()
# fill text + children
for i, node in enumerate(norm, start=1):
com_node = sa.Nodes.Item(i)
com_node.TextFrame2.TextRange.Text = node["text"]
self._add_children(com_node, node["children"])
|
recolor_text
recolor_text(color: str | int | tuple[int, int, int]) -> dict[str, Any]
Set the font color of every node's text to color (one Ctrl-Z).
A SmartArt diagram has no addressable text frame of its own — its labels
live on each node's TextFrame2 — so there is no per-anchor format_text
path the way a textbox has. This is the coarse fix PPTLIVE-009 asked for:
recolor all node text at once, the move a dark- (or any custom-background)
theme needs when the inherited black node text goes invisible.
color is a "#RRGGBB" / "RRGGBB" hex string, an (r, g, b) tuple, or a
raw RGB int (same forms as format_text's color). Raises ValueError for
a malformed color (before any COM). Walks SmartArt.AllNodes and sets each
node's TextFrame2.TextRange.Font.Fill.ForeColor.RGB (TextFrame2 colors
live on Font.Fill.ForeColor, not Font.Color). Wrap in deck.edit(...)
for view preservation + the one-Ctrl-Z fence. Returns
{ok, slide, shape, anchor_id, color, nodes_recolored}.
Source code in src/pptlive/_smartart.py
| def recolor_text(self, color: str | int | tuple[int, int, int]) -> dict[str, Any]:
"""Set the font color of **every** node's text to `color` (one Ctrl-Z).
A SmartArt diagram has no addressable text frame of its own — its labels
live on each node's `TextFrame2` — so there is no per-anchor `format_text`
path the way a textbox has. This is the coarse fix PPTLIVE-009 asked for:
recolor all node text at once, the move a dark- (or any custom-background)
theme needs when the inherited black node text goes invisible.
`color` is a `"#RRGGBB"` / `"RRGGBB"` hex string, an `(r, g, b)` tuple, or a
raw RGB int (same forms as `format_text`'s `color`). Raises `ValueError` for
a malformed color (before any COM). Walks `SmartArt.AllNodes` and sets each
node's `TextFrame2.TextRange.Font.Fill.ForeColor.RGB` (TextFrame2 colors
live on `Font.Fill.ForeColor`, not `Font.Color`). Wrap in `deck.edit(...)`
for view preservation + the one-Ctrl-Z fence. Returns
`{ok, slide, shape, anchor_id, color, nodes_recolored}`.
"""
rgb = parse_color(color) # ValueError before any COM
with _com.translate_com_errors():
allnodes = self.com.AllNodes
total = int(allnodes.Count)
for i in range(1, total + 1):
allnodes.Item(i).TextFrame2.TextRange.Font.Fill.ForeColor.RGB = rgb
return {
"ok": True,
"slide": self._shape.slide.index,
"shape": self._shape.index,
"anchor_id": self._shape.anchor_id,
"color": color_hex(rgb),
"nodes_recolored": total,
}
|
format_node(index: int, *, bold: bool | None = None, italic: bool | None = None, underline: bool | None = None, size: float | None = None, font: str | None = None, color: str | int | tuple[int, int, int] | None = None) -> dict[str, Any]
Format one node's text — the per-node companion to recolor_text.
index is the 1-based node_index from read() (its depth-first /
AllNodes position — spike-verified equal). Only the kwargs you pass are
written, mirroring Anchor.format_text: size is points; color is
"#RRGGBB" / (r, g, b) / a raw RGB int. The knobs land on the node's
TextFrame2 Font2 (color on Font.Fill.ForeColor, underline as the
UnderlineStyle enum), so this reaches the diagram's internal labels that
no format_text anchor can.
Raises AnchorNotFoundError (kind "smartart node") for an out-of-range
index and ValueError for a malformed color — both before any COM
mutation. Wrap in deck.edit(...) for view preservation + the one-Ctrl-Z
fence. Returns {ok, slide, shape, anchor_id, node_index, text}.
Source code in src/pptlive/_smartart.py
| def format_node(
self,
index: int,
*,
bold: bool | None = None,
italic: bool | None = None,
underline: bool | None = None,
size: float | None = None,
font: str | None = None,
color: str | int | tuple[int, int, int] | None = None,
) -> dict[str, Any]:
"""Format **one** node's text — the per-node companion to `recolor_text`.
`index` is the 1-based `node_index` from `read()` (its depth-first /
`AllNodes` position — spike-verified equal). Only the kwargs you pass are
written, mirroring `Anchor.format_text`: `size` is points; `color` is
`"#RRGGBB"` / `(r, g, b)` / a raw RGB int. The knobs land on the node's
`TextFrame2` `Font2` (color on `Font.Fill.ForeColor`, underline as the
`UnderlineStyle` enum), so this reaches the diagram's internal labels that
no `format_text` anchor can.
Raises `AnchorNotFoundError` (kind `"smartart node"`) for an out-of-range
`index` and `ValueError` for a malformed `color` — both before any COM
mutation. Wrap in `deck.edit(...)` for view preservation + the one-Ctrl-Z
fence. Returns `{ok, slide, shape, anchor_id, node_index, text}`.
"""
rgb = parse_color(color) if color is not None else None # validate before any COM
idx = int(index)
with _com.translate_com_errors():
allnodes = self.com.AllNodes
total = int(allnodes.Count)
if not (1 <= idx <= total):
raise AnchorNotFoundError(
"smartart node",
f"{self._shape.anchor_id}:node:{idx}",
)
node = allnodes.Item(idx)
_apply_node_font(
node.TextFrame2.TextRange.Font,
bold=bold,
italic=italic,
underline=underline,
size=size,
font=font,
color=rgb, # already validated above; parse_color is idempotent on an int
)
text = str(node.TextFrame2.TextRange.Text or "")
return {
"ok": True,
"slide": self._shape.slide.index,
"shape": self._shape.index,
"anchor_id": self._shape.anchor_id,
"node_index": idx,
"text": text,
}
|
Theme & master — deck-wide styling
Where format_text styles one anchor, deck.theme and
deck.master restyle the whole deck by editing what every
slide inherits. Theme is the 12-slot palette plus the heading/body typefaces;
Master is the primary slide master's text styles (title / body /
default, 5 levels each) and background. These are deliberately global and
anti-polite — one call recolors or re-fonts every inheriting slide — so wrap them
in deck.edit() for the one-Ctrl-Z fence (the user's view doesn't move).
pptlive.Theme
Theme(deck: Presentation)
The deck's theme — palette + typefaces — bound to the Presentation.
deck.theme.read() # {colors:{...}, fonts:{...}}
deck.theme.set_color("accent1", "#C00000") # recolor every inheriting slide
deck.theme.set_font("major", "Georgia") # swap the headings typeface
read() is side-effect-free. The setters mutate the whole deck; wrap them in
deck.edit(...). Reaches SlideMaster.Theme live every call.
Source code in src/pptlive/_theme.py
| def __init__(self, deck: Presentation) -> None:
self._deck = deck
|
com
property
Raw COM Theme (SlideMaster.Theme), resolved live.
read
The full palette + the major/minor (Latin) typefaces.
{"colors": {slot: "#RRGGBB", ...}, "fonts": {"major": name, "minor": name}}.
Slots are the 12 canonical names in palette order. Side-effect-free.
Source code in src/pptlive/_theme.py
| def read(self) -> dict[str, Any]:
"""The full palette + the major/minor (Latin) typefaces.
`{"colors": {slot: "#RRGGBB", ...}, "fonts": {"major": name, "minor": name}}`.
Slots are the 12 canonical names in palette order. Side-effect-free.
"""
with _com.translate_com_errors():
theme = self.com
scheme = theme.ThemeColorScheme
colors = {
slot: _safe(lambda i=idx: color_hex_or_none(scheme.Colors(i).RGB), None)
for idx, slot in enumerate(THEME_COLOR_CHOICES, start=1)
}
fonts_com = theme.ThemeFontScheme
fonts = {
"major": _safe(lambda: str(fonts_com.MajorFont.Item(1).Name), None),
"minor": _safe(lambda: str(fonts_com.MinorFont.Item(1).Name), None),
}
return {"colors": colors, "fonts": fonts}
|
set_color
set_color(slot: str, color: str | int | tuple[int, int, int]) -> None
Set one palette slot ("accent1", "dark1", "hyperlink", …).
color is "#RRGGBB", an (r, g, b) tuple, or a raw RGB int. Unknown
slot -> ValueError (before any COM). Recolors every slide that inherits
the theme; wrap in deck.edit(...).
Source code in src/pptlive/_theme.py
| def set_color(self, slot: str, color: str | int | tuple[int, int, int]) -> None:
"""Set one palette slot (`"accent1"`, `"dark1"`, `"hyperlink"`, …).
`color` is `"#RRGGBB"`, an `(r, g, b)` tuple, or a raw RGB int. Unknown
slot -> `ValueError` (before any COM). Recolors every slide that inherits
the theme; wrap in `deck.edit(...)`.
"""
idx = theme_color_for(slot) # ValueError before any COM
rgb = parse_color(color)
with _com.translate_com_errors():
self.com.ThemeColorScheme.Colors(idx).RGB = rgb
|
set_font
set_font(which: str, name: str, *, script: str = 'latin') -> None
Set the major (headings) or minor (body) typeface.
which is "major"/"minor" ("heading"/"body" accepted). script
selects the sub-typeface — "latin" (default), "east_asian", or
"complex_script" (the .Latin accessor is broken; we go through
.Item(n)). Unknown which/script -> ValueError before any COM.
Source code in src/pptlive/_theme.py
| def set_font(self, which: str, name: str, *, script: str = "latin") -> None:
"""Set the major (headings) or minor (body) typeface.
`which` is `"major"`/`"minor"` (`"heading"`/`"body"` accepted). `script`
selects the sub-typeface — `"latin"` (default), `"east_asian"`, or
`"complex_script"` (the `.Latin` accessor is broken; we go through
`.Item(n)`). Unknown `which`/`script` -> `ValueError` before any COM.
"""
slot = theme_font_slot_for(which) # ValueError before any COM
script_idx = theme_font_script_for(script)
with _com.translate_com_errors():
scheme = self.com.ThemeFontScheme
font = scheme.MajorFont if slot == "major" else scheme.MinorFont
font.Item(script_idx).Name = str(name)
|
pptlive.Master
Master(deck: Presentation)
The deck's primary slide master — text styles + background.
deck.master.read() # {text_styles, background}
deck.master.format_text_style("body", 1, font="Georgia", size=32)
deck.master.format_paragraph_style("title", 1, alignment="center")
deck.master.set_background("#1F1F1F")
The text styles drive the same COM Font / ParagraphFormat objects as
Anchor.format_text / format_paragraph (so formatting is identical), but
deck-wide. Setters mutate; wrap in deck.edit(...). Reaches
SlideMaster live every call.
Source code in src/pptlive/_theme.py
| def __init__(self, deck: Presentation) -> None:
self._deck = deck
|
com
property
Raw COM SlideMaster, resolved live.
headers_footers: HeadersFooters
The deck-wide footer / slide-number / date defaults (the master scope —
the per-slide override is Slide.headers_footers). See
_headersfooters.HeadersFooters.
read
The three text styles (5 levels each) + the background fill.
{"text_styles": {style: {"levels": [{level, font, size, bold, italic,
underline, color, alignment}, ...]}}, "background": {"type", "color"}}.
Reads are defensive — a property the master can't supply degrades to
None rather than failing the whole dump. Side-effect-free.
Source code in src/pptlive/_theme.py
| def read(self) -> dict[str, Any]:
"""The three text styles (5 levels each) + the background fill.
`{"text_styles": {style: {"levels": [{level, font, size, bold, italic,
underline, color, alignment}, ...]}}, "background": {"type", "color"}}`.
Reads are defensive — a property the master can't supply degrades to
`None` rather than failing the whole dump. Side-effect-free.
"""
with _com.translate_com_errors():
master = self.com
text_styles: dict[str, Any] = {}
for style in self._STYLES:
ts = master.TextStyles(text_style_for(style))
levels = [self._level_dict(ts.Levels(lvl), lvl) for lvl in range(1, 6)]
text_styles[style] = {"levels": levels}
background = background_to_dict(master)
return {"text_styles": text_styles, "background": background}
|
format_text_style
format_text_style(style: str, level: int = 1, *, bold: bool | None = None, italic: bool | None = None, underline: bool | None = None, size: float | None = None, font: str | None = None, color: str | int | tuple[int, int, int] | None = None) -> None
Set font formatting on a master text style + outline level (deck-wide).
style is "title"/"body"/"default"; level is 1-5 and defaults to
1 (the natural choice for title, which has a single level). Only the
kwargs you pass are written (size in points; color "#RRGGBB" / tuple
/ int). Re-renders every slide that inherits the style; wrap in
deck.edit(...). Unknown style / out-of-range level -> ValueError.
Source code in src/pptlive/_theme.py
| def format_text_style(
self,
style: str,
level: int = 1,
*,
bold: bool | None = None,
italic: bool | None = None,
underline: bool | None = None,
size: float | None = None,
font: str | None = None,
color: str | int | tuple[int, int, int] | None = None,
) -> None:
"""Set font formatting on a master text style + outline level (deck-wide).
`style` is `"title"`/`"body"`/`"default"`; `level` is 1-5 and defaults to
`1` (the natural choice for `title`, which has a single level). Only the
kwargs you pass are written (`size` in points; `color` `"#RRGGBB"` / tuple
/ int). Re-renders every slide that inherits the style; wrap in
`deck.edit(...)`. Unknown style / out-of-range level -> `ValueError`.
"""
if color is not None:
parse_color(color) # validate before any COM
with _com.translate_com_errors():
apply_font(
self._level(style, level).Font,
bold=bold,
italic=italic,
underline=underline,
size=size,
font=font,
color=color,
)
|
format_paragraph_style(style: str, level: int = 1, *, alignment: str | int | None = None, space_before: float | None = None, space_after: float | None = None, line_spacing: float | None = None) -> None
Set paragraph formatting on a master text style + outline level.
alignment is a name ("left"/"center"/"right"/"justify"/
"distribute") or int; space_before/space_after are points;
line_spacing is the multiple. Only the kwargs you pass are written. Wrap
in deck.edit(...). Unknown style/alignment / bad level -> ValueError.
Source code in src/pptlive/_theme.py
| def format_paragraph_style(
self,
style: str,
level: int = 1,
*,
alignment: str | int | None = None,
space_before: float | None = None,
space_after: float | None = None,
line_spacing: float | None = None,
) -> None:
"""Set paragraph formatting on a master text style + outline level.
`alignment` is a name (`"left"`/`"center"`/`"right"`/`"justify"`/
`"distribute"`) or int; `space_before`/`space_after` are points;
`line_spacing` is the multiple. Only the kwargs you pass are written. Wrap
in `deck.edit(...)`. Unknown style/alignment / bad level -> `ValueError`.
"""
align_int = alignment_for(alignment) if alignment is not None else None
with _com.translate_com_errors():
apply_paragraph_format(
self._level(style, level).ParagraphFormat,
alignment=align_int,
space_before=space_before,
space_after=space_after,
line_spacing=line_spacing,
)
|
set_background
set_background(color: str | int | tuple[int, int, int]) -> None
Set the master background to a solid color (deck-wide).
color is "#RRGGBB", an (r, g, b) tuple, or a raw RGB int. Applies a
solid fill to SlideMaster.Background; wrap in deck.edit(...). (v0.9
ships solid fills only — gradient/picture backgrounds are deferred.)
Source code in src/pptlive/_theme.py
| def set_background(self, color: str | int | tuple[int, int, int]) -> None:
"""Set the master background to a solid color (deck-wide).
`color` is `"#RRGGBB"`, an `(r, g, b)` tuple, or a raw RGB int. Applies a
solid fill to `SlideMaster.Background`; wrap in `deck.edit(...)`. (v0.9
ships solid fills only — gradient/picture backgrounds are deferred.)
"""
rgb = parse_color(color)
with _com.translate_com_errors():
fill = self.com.Background.Fill
fill.Solid()
fill.ForeColor.RGB = rgb
|
deck.sections is the deck's named slide spans —
list() returns {index, name, first_slide, slide_count} rows, and
add(name, *, before_slide=None) / rename / delete(*, delete_slides=False) /
move edit them by 1-based section index. HeadersFooters
is a shared wrapper mounted at two scopes — slide.headers_footers (a per-slide
override) and deck.master.headers_footers (the deck-wide default every slide
inherits) — with read() plus set_footer / set_slide_number / set_date.
A footer / date text reads back as None while that element is hidden (PowerPoint
only exposes the text on a visible element), and setting text auto-shows it.
pptlive.SectionCollection
SectionCollection(deck: Presentation)
Indexable, mutable view over a deck's sections (1-based section index).
Source code in src/pptlive/_sections.py
| def __init__(self, deck: Presentation) -> None:
self._deck = deck
|
list
list() -> list[dict[str, Any]]
Every section in order — [{index, name, first_slide, slide_count}, ...].
first_slide is the 1-based index of the section's first slide (or None
for an empty trailing section); slide_count is how many slides it spans.
A read — no view move.
Source code in src/pptlive/_sections.py
| def list(self) -> list[dict[str, Any]]:
"""Every section in order — `[{index, name, first_slide, slide_count}, ...]`.
`first_slide` is the 1-based index of the section's first slide (or `None`
for an empty trailing section); `slide_count` is how many slides it spans.
A read — no view move.
"""
with _com.translate_com_errors():
props = self._props
return [self._row(props, i) for i in range(1, int(props.Count) + 1)]
|
add
add(name: str, *, before_slide: int | None = None) -> dict[str, Any]
Create a section named name; return its new row.
before_slide (1-based) is the slide the section starts at — the
natural form ("start a 'Results' section at slide 5"); everything from there
to the next section joins it. Adding the first section in front of slide
N>1 makes PowerPoint also create a leading "Default Section" for the earlier
slides. Omit before_slide to append an empty trailing section (a
boundary slides can later move into).
Raises SlideNotFoundError for an out-of-range before_slide. A mutation:
wrap in deck.edit(...).
Source code in src/pptlive/_sections.py
| def add(self, name: str, *, before_slide: int | None = None) -> dict[str, Any]:
"""Create a section named `name`; return its new row.
`before_slide` (1-based) is the slide the section **starts at** — the
natural form ("start a 'Results' section at slide 5"); everything from there
to the next section joins it. Adding the first section in front of slide
N>1 makes PowerPoint also create a leading "Default Section" for the earlier
slides. Omit `before_slide` to append an **empty** trailing section (a
boundary slides can later move into).
Raises `SlideNotFoundError` for an out-of-range `before_slide`. A mutation:
wrap in `deck.edit(...)`.
"""
# Validate before the COM mutation block (mirrors rename/delete/move).
if before_slide is not None:
if isinstance(before_slide, bool) or not isinstance(before_slide, int):
raise TypeError(f"before_slide must be int, got {type(before_slide).__name__}")
if before_slide < 1 or before_slide > len(self._deck.slides):
raise SlideNotFoundError(before_slide)
with _com.translate_com_errors():
props = self._props
if before_slide is None:
new_index = int(props.AddSection(int(props.Count) + 1, str(name)))
else:
new_index = int(props.AddBeforeSlide(before_slide, str(name)))
return self._row(props, new_index)
|
rename
rename(index: int, name: str) -> dict[str, Any]
Rename the section at 1-based index; return its updated row.
Raises AnchorNotFoundError (exit 2) for an unknown index. A mutation.
Source code in src/pptlive/_sections.py
| def rename(self, index: int, name: str) -> dict[str, Any]:
"""Rename the section at 1-based `index`; return its updated row.
Raises `AnchorNotFoundError` (exit 2) for an unknown index. A mutation.
"""
self._check_index(index)
with _com.translate_com_errors():
props = self._props
props.Rename(index, str(name))
return self._row(props, index)
|
delete
delete(index: int, *, delete_slides: bool = False) -> dict[str, Any]
Delete the section at 1-based index; return {deleted, name, ...}.
By default only the section boundary is removed and its slides stay
(they merge into the previous section); pass delete_slides=True to delete
the slides too. Raises AnchorNotFoundError for an unknown index. A
mutation: wrap in deck.edit(...).
Source code in src/pptlive/_sections.py
| def delete(self, index: int, *, delete_slides: bool = False) -> dict[str, Any]:
"""Delete the section at 1-based `index`; return `{deleted, name, ...}`.
By default only the section **boundary** is removed and its slides stay
(they merge into the previous section); pass `delete_slides=True` to delete
the slides too. Raises `AnchorNotFoundError` for an unknown index. A
mutation: wrap in `deck.edit(...)`.
"""
self._check_index(index)
with _com.translate_com_errors():
props = self._props
name = str(props.Name(index))
props.Delete(index, int(MsoTriState.TRUE if delete_slides else MsoTriState.FALSE))
return {"deleted": True, "index": index, "name": name, "slides_deleted": delete_slides}
|
move
move(index: int, to: int) -> dict[str, Any]
Move the section at 1-based index to position to; return its new row.
Reorders the section (and the slides it spans) within the deck. Raises
AnchorNotFoundError for an out-of-range index or to. A mutation.
Source code in src/pptlive/_sections.py
| def move(self, index: int, to: int) -> dict[str, Any]:
"""Move the section at 1-based `index` to position `to`; return its new row.
Reorders the section (and the slides it spans) within the deck. Raises
`AnchorNotFoundError` for an out-of-range `index` or `to`. A mutation.
"""
self._check_index(index)
count = len(self)
if isinstance(to, bool) or not isinstance(to, int):
raise TypeError(f"to must be int, got {type(to).__name__}")
if to < 1 or to > count:
raise AnchorNotFoundError("section", f"section:{to}")
with _com.translate_com_errors():
props = self._props
props.Move(index, to)
return self._row(props, to)
|
HeadersFooters(com_hf: Any)
Wraps a COM HeadersFooters (a slide's or the master's).
Source code in src/pptlive/_headersfooters.py
| def __init__(self, com_hf: Any) -> None:
self._hf = com_hf
|
{footer, slide_number, date, display_on_title_slide} — guarded.
footer is {visible, text}; slide_number is {visible}; date is
{visible, text, format, use_format}. Per the spike, text/use_format
are only readable while their element is visible, so they degrade to None
when hidden. display_on_title_slide is master-scoped (None on a slide).
A read — no view move.
Source code in src/pptlive/_headersfooters.py
| def read(self) -> dict[str, Any]:
"""`{footer, slide_number, date, display_on_title_slide}` — guarded.
`footer` is `{visible, text}`; `slide_number` is `{visible}`; `date` is
`{visible, text, format, use_format}`. Per the spike, `text`/`use_format`
are only readable while their element is visible, so they degrade to `None`
when hidden. `display_on_title_slide` is master-scoped (None on a slide).
A read — no view move.
"""
hf = self._hf
footer = hf.Footer
date = hf.DateAndTime
return {
"footer": {
"visible": _safe(lambda: is_true(footer.Visible), False),
"text": _safe(lambda: str(footer.Text), None),
},
"slide_number": {
"visible": _safe(lambda: is_true(hf.SlideNumber.Visible), False),
},
"date": {
"visible": _safe(lambda: is_true(date.Visible), False),
"text": _safe(lambda: str(date.Text), None),
"format": _safe(lambda: int(date.Format), None),
"use_format": _safe(lambda: is_true(date.UseFormat), None),
},
"display_on_title_slide": _safe(lambda: is_true(hf.DisplayOnTitleSlide), None),
}
|
set_footer(*, text: str | None = None, visible: bool | None = None) -> dict[str, Any]
Set the footer text and/or visibility; return the resulting read.
Passing text auto-shows the footer (unless visible=False is explicit) —
a hidden footer's text doesn't render, so setting text implies showing it.
Pass visible=False to hide the footer. A mutation: wrap in deck.edit(...).
Source code in src/pptlive/_headersfooters.py
| def set_footer(self, *, text: str | None = None, visible: bool | None = None) -> dict[str, Any]:
"""Set the footer text and/or visibility; return the resulting read.
Passing `text` auto-shows the footer (unless `visible=False` is explicit) —
a hidden footer's text doesn't render, so setting text implies showing it.
Pass `visible=False` to hide the footer. A mutation: wrap in `deck.edit(...)`.
"""
with _com.translate_com_errors():
footer = self._hf.Footer
show = visible if visible is not None else (True if text is not None else None)
if show is not None:
footer.Visible = _tri(show)
if text is not None:
footer.Text = str(text)
return self.read()
|
set_slide_number(visible: bool) -> dict[str, Any]
Show or hide the auto slide-number placeholder; return the resulting read.
A mutation: wrap in deck.edit(...).
Source code in src/pptlive/_headersfooters.py
| def set_slide_number(self, visible: bool) -> dict[str, Any]:
"""Show or hide the auto slide-number placeholder; return the resulting read.
A mutation: wrap in `deck.edit(...)`.
"""
with _com.translate_com_errors():
self._hf.SlideNumber.Visible = _tri(visible)
return self.read()
|
set_date(*, visible: bool | None = None, text: str | None = None, fmt: int | None = None) -> dict[str, Any]
Set the date/time placeholder; return the resulting read.
Pass text for a fixed date string (sets UseFormat=msoFalse), or
fmt (a raw PpDateTimeFormat int) for an auto-updating date (sets
UseFormat=msoTrue); they're mutually exclusive. Passing either auto-shows
the element unless visible=False is explicit. A mutation: wrap in
deck.edit(...).
Source code in src/pptlive/_headersfooters.py
| def set_date(
self,
*,
visible: bool | None = None,
text: str | None = None,
fmt: int | None = None,
) -> dict[str, Any]:
"""Set the date/time placeholder; return the resulting read.
Pass `text` for a **fixed** date string (sets `UseFormat=msoFalse`), or
`fmt` (a raw `PpDateTimeFormat` int) for an **auto-updating** date (sets
`UseFormat=msoTrue`); they're mutually exclusive. Passing either auto-shows
the element unless `visible=False` is explicit. A mutation: wrap in
`deck.edit(...)`.
"""
if text is not None and fmt is not None:
raise ValueError("set_date() takes either text (fixed) or fmt (auto), not both")
with _com.translate_com_errors():
date = self._hf.DateAndTime
given = text is not None or fmt is not None
show = visible if visible is not None else (True if given else None)
if show is not None:
date.Visible = _tri(show)
if text is not None:
date.UseFormat = _tri(False)
date.Text = str(text)
if fmt is not None:
date.UseFormat = _tri(True)
date.Format = int(fmt)
return self.read()
|
Rendering
slide.export_image renders one slide to an image;
deck.snapshot renders the whole deck (or a slide
selection) to one PNG per slide so a vision model can see every slide cheaply.
Its max_dim long-edge pixel cap gives a predictable, uniform per-slide token
budget (a model is billed on pixel area, not DPI); pass exact width / height
instead for a fixed per-slide size (they override max_dim, and passing both
forms is a ValueError). Both are reads — they reflect the current unsaved state
but leave the viewed slide and Selection untouched. Each rendered slide comes back
as a Snapshot.
pptlive.Snapshot
dataclass
Snapshot(slide: int, image: bytes, path: Path | None = None)
One rendered slide of a deck.
slide is the 1-based slide index; image is the encoded image bytes in the
chosen fmt (PNG by default, JPEG when fmt="jpg") — feed it straight to a
vision model, or write it yourself. path is where the image was written when
a snapshot(out=...) call saved it to disk, otherwise None.
Saving & export
Three explicit, never-implicit verbs on Presentation
(pptlive never auto-saves): deck.save() persists to the existing file;
deck.save_as(path, *, fmt="pptx", overwrite=False) writes a .pptx and rebinds
the working file to it (the open deck becomes that file, like PowerPoint's Save-As),
refusing to clobber unless overwrite=True; and deck.export_pdf(path) writes a
pixel-faithful PDF as a read — unlike save_as it neither rebinds the working
file nor clears its dirty flag, so your .pptx is untouched. deck.saved (the
Presentation.Saved dirty flag) and deck.path ride on every status deck row so
an agent can see unsaved state. save() on a never-saved deck raises
UnsavedPresentationError rather than letting
PowerPoint silently route the file to a default cloud folder.
with pl.attach() as ppt:
deck = ppt.presentations.active
if not deck.saved:
deck.save() # persist in place (must already have a path)
deck.save_as("C:/out/v2.pptx") # write + rebind the working file
deck.export_pdf("C:/out/deck.pdf") # a read — working file untouched
The "build a deck, narrate it, export a video" path.
slide.add_audio(path) / slide.add_video(path)
insert an audio/video clip (embedded by default; link=True keeps the file on disk).
autoplay plays the clip on slide entry, hide_icon hides the audio icon while idle
(audio only), and pace_slide auto-advances the slide to the clip's length — so an
exported video paces itself to the narration. Each shape read carries a media dict
({type, length_s, muted, volume, autoplay}) and has_media.
deck.export_video(path) exports the deck to an MP4 via
PowerPoint's async CreateVideo. Like export_pdf it is a read (no rebind, dirty
flag preserved). It blocks by default, polling to completion and returning a
VideoExportResult; pass wait=False to return the
in-flight status immediately and poll deck.video_status()
until it reports done. A failed or timed-out encode raises
VideoExportError.
with pl.attach() as ppt:
deck = ppt.presentations.active
with deck.edit("Narrate the deck"):
deck.slides[1].add_audio("intro.mp3") # autoplay + pace the slide (defaults)
deck.slides[2].add_video("demo.mp4") # stays visible; same knobs
result = deck.export_video("C:/out/deck.mp4", resolution=1080)
assert result.ok and result.status == "done" # result.path is the written MP4
pptlive.VideoExportResult
dataclass
VideoExportResult(path: str, status: str, status_code: int, ok: bool)
The outcome of a deck.export_video(...) request or a video_status() poll.
status is the friendly CreateVideoStatus token (queued/in_progress/
done/failed/none); ok is True only on a finished, non-empty file.
path is the absolute MP4 path for an export, or "" for a bare status poll.
Slide show
deck.show drives a running slide show like a presenter's
clicker — start, end, next, previous, goto(n), black() / white()
/ resume(), and the read-only state(). Unlike the polite edit verbs, these
deliberately drive what's on screen, so show is not wrapped in edit().
pptlive.SlideShow
SlideShow(deck: Presentation)
Live slide-show control for one deck (deck.show).
deck.show.start() # run from the top
deck.show.goto(5) # jump to slide 5
deck.show.next() # advance
deck.show.black() # blank the screen (B); resume() to come back
deck.show.state() # {"running": True, "state": "running", ...}
deck.show.end() # exit the show
Every control verb returns the post-action state() dict, so a caller never
has to follow up with a separate read. state() itself is the only
side-effect-free verb and the only one that never raises when no show is
running (it reports running: false).
Source code in src/pptlive/_show.py
| def __init__(self, deck: Presentation) -> None:
self._deck = deck
|
com
property
Raw SlideShowSettings COM object — the always-available handle.
For the running show, use window / view (both None when no show is
running). Escape hatch for show knobs pptlive doesn't wrap (loop,
narration, advance mode, …).
window
property
The live SlideShowWindow COM object, or None if no show is running.
view
property
The live SlideShowView COM object, or None if no show is running.
is_running
True iff a slide show is currently running for this deck.
Source code in src/pptlive/_show.py
| def is_running(self) -> bool:
"""True iff a slide show is currently running for this deck."""
return self._window() is not None
|
start
start(*, from_slide: int | None = None) -> dict[str, Any]
Start the slide show (or, if one is already running, keep it).
With from_slide (1-based) the show begins on that slide; otherwise it
runs the whole deck from the top. If a show is already running this is a
no-op except that from_slide, when given, jumps to that slide — so
start() is safe to call idempotently. Returns the show state().
Raises SlideNotFoundError for an out-of-range from_slide.
Source code in src/pptlive/_show.py
| def start(self, *, from_slide: int | None = None) -> dict[str, Any]:
"""Start the slide show (or, if one is already running, keep it).
With `from_slide` (1-based) the show begins on that slide; otherwise it
runs the whole deck from the top. If a show is already running this is a
no-op except that `from_slide`, when given, jumps to that slide — so
`start()` is safe to call idempotently. Returns the show `state()`.
Raises `SlideNotFoundError` for an out-of-range `from_slide`.
"""
if from_slide is not None:
self._check_slide(from_slide)
with _com.translate_com_errors():
existing = self._window()
if existing is not None:
if from_slide is not None:
existing.View.GotoSlide(int(from_slide))
return self.state()
settings = self._deck.com.SlideShowSettings
if from_slide is not None:
settings.RangeType = int(PpSlideShowRangeType.SLIDE_RANGE)
settings.StartingSlide = int(from_slide)
settings.EndingSlide = len(self._deck.slides)
else:
# Reset to whole-deck: SlideShowSettings persists on the
# presentation, so a prior `start(from_slide=…)` would otherwise
# leave a stale SLIDE_RANGE and silently replay that range.
settings.RangeType = int(PpSlideShowRangeType.ALL)
settings.Run()
return self.state()
|
end
End the slide show. A no-op (not an error) if none is running.
Source code in src/pptlive/_show.py
| def end(self) -> dict[str, Any]:
"""End the slide show. A no-op (not an error) if none is running."""
win = self._window()
if win is not None:
with _com.translate_com_errors():
win.View.Exit()
return self.state()
|
next
Advance to the next build/slide (the clicker's forward press).
Source code in src/pptlive/_show.py
| def next(self) -> dict[str, Any]:
"""Advance to the next build/slide (the clicker's forward press)."""
with _com.translate_com_errors():
self._require_window().View.Next()
return self.state()
|
previous
previous() -> dict[str, Any]
Step back to the previous build/slide.
Source code in src/pptlive/_show.py
| def previous(self) -> dict[str, Any]:
"""Step back to the previous build/slide."""
with _com.translate_com_errors():
self._require_window().View.Previous()
return self.state()
|
goto
goto(slide: int) -> dict[str, Any]
Jump the running show to slide slide (1-based).
Raises SlideNotFoundError for an out-of-range index, and
SlideShowNotRunningError if no show is running.
Source code in src/pptlive/_show.py
| def goto(self, slide: int) -> dict[str, Any]:
"""Jump the running show to slide `slide` (1-based).
Raises `SlideNotFoundError` for an out-of-range index, and
`SlideShowNotRunningError` if no show is running.
"""
self._check_slide(slide)
with _com.translate_com_errors():
self._require_window().View.GotoSlide(int(slide))
return self.state()
|
black
black() -> dict[str, Any]
Blank the screen to black (the B key). resume() returns to the slide.
Source code in src/pptlive/_show.py
| def black(self) -> dict[str, Any]:
"""Blank the screen to black (the B key). `resume()` returns to the slide."""
return self._set_state(PpSlideShowState.BLACK_SCREEN)
|
white
white() -> dict[str, Any]
Blank the screen to white (the W key). resume() returns to the slide.
Source code in src/pptlive/_show.py
| def white(self) -> dict[str, Any]:
"""Blank the screen to white (the W key). `resume()` returns to the slide."""
return self._set_state(PpSlideShowState.WHITE_SCREEN)
|
resume
resume() -> dict[str, Any]
Resume from a black/white blank screen back to the running slide.
Source code in src/pptlive/_show.py
| def resume(self) -> dict[str, Any]:
"""Resume from a black/white blank screen back to the running slide."""
return self._set_state(PpSlideShowState.RUNNING)
|
state
state() -> dict[str, Any]
Report the show's status without changing it — the polite read.
Always returns a dict: {running, state, state_code, current_slide,
position, slide_count}. When no show is running, running is False,
state is "done", and the slide fields are None. current_slide is
the 1-based deck index of the slide on screen; position is its place in
the show sequence (differs from current_slide with hidden/custom shows).
Source code in src/pptlive/_show.py
| def state(self) -> dict[str, Any]:
"""Report the show's status without changing it — the polite read.
Always returns a dict: `{running, state, state_code, current_slide,
position, slide_count}`. When no show is running, `running` is False,
`state` is `"done"`, and the slide fields are None. `current_slide` is
the 1-based deck index of the slide on screen; `position` is its place in
the show sequence (differs from `current_slide` with hidden/custom shows).
"""
slide_count = len(self._deck.slides)
win = self._window()
if win is None:
return {
"running": False,
"state": slide_show_state_name(PpSlideShowState.DONE),
"state_code": int(PpSlideShowState.DONE),
"current_slide": None,
"position": None,
"slide_count": slide_count,
}
with _com.translate_com_errors():
view = win.View
state_code = int(view.State)
current_slide = self._safe_int(lambda: view.Slide.SlideIndex)
position = self._safe_int(lambda: view.CurrentShowPosition)
return {
"running": True,
"state": slide_show_state_name(state_code),
"state_code": state_code,
"current_slide": current_slide,
"position": position,
"slide_count": slide_count,
}
|
Editing & selection
deck.edit(label) returns an EditScope — the
view/selection-preservation and atomic-undo scope. deck.selection() reads the
user's current SelectionInfo (resolved to anchors)
without perturbing it; act on it by targeting the opt-in here: anchor.
pptlive.EditScope
EditScope(ppt: PowerPoint, label: str)
Snapshots the viewed slide + Selection on enter; restores them on exit.
with deck.edit("Revise agenda slide"):
deck.anchor_by_id("ph:2:title").set_text("Agenda")
deck.anchor_by_id("ph:2:body").set_text("Intro\nDemo\nQ&A")
On enter it fences a fresh undo entry (StartNewUndoEntry), so the whole
block is a single Ctrl-Z (see the module docstring). On clean exit the
user is returned to the slide they were looking at, with their shape
selection re-selected — unless code inside the scope called
allow_view_move() (the analog of wordlive's allow_cursor_move()), which
opts out so a deliberate go_to/jump survives. If the block raises, the view
is left wherever the failing op put it, so the user can see what happened.
Source code in src/pptlive/_edit.py
| def __init__(self, ppt: PowerPoint, label: str) -> None:
self._ppt = ppt
self._label = label
self._snapshot: SelectionSnapshot | None = None
self._move_allowed = False
|
allow_view_move
allow_view_move() -> None
Opt out of restoring the viewed slide + Selection on scope exit.
Source code in src/pptlive/_edit.py
| def allow_view_move(self) -> None:
"""Opt out of restoring the viewed slide + Selection on scope exit."""
self._move_allowed = True
|
pptlive.SelectionInfo
dataclass
SelectionInfo(type: str, slide: int | None, shapes: tuple[dict[str, Any], ...] = (), shape_index: int | None = None, paragraph: int | None = None, text: str | None = None)
What the user currently has selected, resolved to pptlive anchors.
type is "none" / "slides" / "shapes" / "text". For a shape
selection, shapes lists each selected shape (anchor_id/name/id/
index); for a text selection, paragraph and text describe the caret's
paragraph. anchor_id is the single targetable anchor (here: resolves to
it) — the first selected shape, or the text paragraph — or None when nothing
is targetable (empty/slide selection).
pptlive.SelectionSnapshot
dataclass
SelectionSnapshot(slide_index: int | None, selection_type: int = int(PpSelectionType.NONE), shape_names: tuple[str, ...] = ())
A point-in-time capture of what the user is looking at and has selected.
slide_index
instance-attribute
The viewed slide (1-based), or None if no Normal/Slide-view window exists.
selection_type
class-attribute
instance-attribute
selection_type: int = int(PpSelectionType.NONE)
The PpSelectionType at snapshot time.
shape_names
class-attribute
instance-attribute
shape_names: tuple[str, ...] = ()
Names of the selected shapes, when selection_type is SHAPES.
Caveat: PowerPoint allows duplicate shape names, and Shapes.Range (used by
restore) resolves by name, not the stable Shape.Id. If two selected
shapes share a name, restore may re-select the wrong shape (or fall back to
clearing the selection). The stable Id can't be passed to Range, so this
is a known COM limitation of the politeness restore, not a correctness bug in
an edit — the user's selection is cosmetic state, restored best-effort.
Units
Geometry is in points throughout (1 inch = 72 pt). These helpers convert so you
needn't hardcode multiplications; EMUs never surface.
pptlive.units
Unit helpers — points throughout, never EMUs.
PowerPoint's COM layer measures geometry in points (1 inch = 72 pt), so
pptlive does too: Shape.Left/Top/Width/Height, slide dimensions, indents —
all points. EMUs are an OOXML / python-pptx concern and never surface here.
These helpers exist so an agent can write pl.units.inches(1.5) instead of
hardcoding 108. points() is the identity, included for symmetry and to make
"this number is already in points" explicit at a call site.
shape.move(left=inches(1), top=cm(4))
points
points(value: float) -> float
Return value unchanged — it is already in points (PowerPoint's unit).
Source code in src/pptlive/units.py
| def points(value: float) -> float:
"""Return `value` unchanged — it is already in points (PowerPoint's unit)."""
return float(value)
|
inches
inches(value: float) -> float
Convert inches to points (1 in = 72 pt).
Source code in src/pptlive/units.py
| def inches(value: float) -> float:
"""Convert inches to points (1 in = 72 pt)."""
return float(value) * _POINTS_PER_INCH
|
cm
cm(value: float) -> float
Convert centimetres to points (1 in = 2.54 cm = 72 pt).
Source code in src/pptlive/units.py
| def cm(value: float) -> float:
"""Convert centimetres to points (1 in = 2.54 cm = 72 pt)."""
return float(value) * _POINTS_PER_INCH / _CM_PER_INCH
|
mm
mm(value: float) -> float
Convert millimetres to points.
Source code in src/pptlive/units.py
| def mm(value: float) -> float:
"""Convert millimetres to points."""
return cm(float(value) / 10.0)
|
Constants
Typed IntEnums for the Mso* / Pp* / Xl* magic constants, plus
friendly-string coercers ("title", "two_content", "star", "column")
that map names to the right int the way an LLM would phrase them.
pptlive.constants
Typed enums for the PowerPoint magic constants pptlive uses.
Values mirror the official Mso* / Pp* enumerations exactly. Resist the urge
to pre-populate — add entries only as a feature needs them (the wordlive rule).
Friendly string aliases ("title", "textbox") coerce to the right int the way
wordlive's alignment names do.
MsoTriState
Bases: IntEnum
Office's tri-state boolean — Shape.HasTextFrame, HasTable, etc.
COM returns TRUE as -1. MIXED (-2) only appears for multi-shape
selections, which pptlive's anchors never hold.
MsoShapeType
Bases: IntEnum
Shape.Type values — what kind of object a shape is.
The subset pptlive reports in slide.read(). Emitted as lowercase strings
via shape_type_name() so JSON consumers match "placeholder" /
"picture" without importing the enum.
MsoAutoSize
Bases: IntEnum
TextFrame2.AutoSize — how a text frame resizes to fit its text.
Read off the modern TextFrame2 (the classic TextFrame.AutoSize returns the
mixed sentinel on current builds — see scripts/text_model_spike.py).
PpPlaceholderType
Bases: IntEnum
PlaceholderFormat.Type values — the semantic role of a placeholder.
ph:S:KIND resolves a friendly KIND to one of these (see
placeholder_types_for). Reported by placeholder_kind_name() as a
friendly string in slide.read().
PpSelectionType
Bases: IntEnum
Selection.Type — what the user currently has selected in the window.
Used by the politeness snapshot/restore: a SHAPES selection is re-selected
by name on scope exit; TEXT and NONE fall back to restoring just the
viewed slide. (Spike: confirm a shape-range selection round-trips cleanly.)
PpViewType
Bases: IntEnum
DocumentWindow.ViewType — the subset pptlive checks.
NORMAL and SLIDE are the views where View.Slide (the slide the user is
looking at) is meaningful; we snapshot/restore it for politeness.
PpSlideLayout
Bases: IntEnum
Legacy Slides.Add(Index, Layout) layout codes (the deprecated path).
Only reached as a fallback when a deck exposes no CustomLayouts for the
modern AddSlide(Index, CustomLayout). Friendly layout names resolve to a
real CustomLayout instead (see match_layout_name), so this enum stays
deliberately tiny — TEXT (a title-and-content slide) is the fallback default.
MsoTextOrientation
Bases: IntEnum
Shapes.AddTextbox orientation. pptlive creates horizontal text boxes.
MsoAutoShapeType
Bases: IntEnum
The Shapes.AddShape(Type, …) autoshape geometries pptlive names.
A curated common subset of the (large) MsoAutoShapeType enumeration —
added as a feature needs them (the wordlive rule), not pre-populated.
Friendly names ("rectangle", "oval", "arrow") resolve to these via
autoshape_type_for; a raw int still passes through for the long tail.
MsoZOrderCmd
Bases: IntEnum
Shape.ZOrder(cmd) — how to restack a shape relative to its siblings.
Only the four that move a shape within the slide's z-stack; the
in-front-of/behind-text variants aren't named (added as a feature needs them).
MsoAlignCmd
Bases: IntEnum
ShapeRange.Align(cmd, RelativeTo) — how to align a set of shapes.
MsoDistributeCmd
Bases: IntEnum
ShapeRange.Distribute(cmd, RelativeTo) — even spacing on one axis.
MsoConnectorType
Bases: IntEnum
Shapes.AddConnector(type, …) — the connector line geometry.
PpParagraphAlignment
Bases: IntEnum
ParagraphFormat.Alignment — horizontal alignment of a paragraph.
PpBulletType
Bases: IntEnum
ParagraphFormat.Bullet.Type — what kind of bullet a paragraph carries.
MsoColorType
Bases: IntEnum
ColorFormat.Type — how a font/fill color is sourced.
The spike-verified (scripts/batch2_spike.py) signal that finally answers the
"directly set vs theme-cascaded" question the Claude Desktop session raised: a
freshly-inherited run reads SCHEME, an explicit RGB reads RGB.
MsoFillType
Bases: IntEnum
Fill.Type — what kind of fill a shape / background carries (read-back).
MsoGradientStyle
Bases: IntEnum
Fill.GradientStyle — the direction a gradient sweeps.
MsoShadowStyle
Bases: IntEnum
Shadow.Style — inner vs outer shadow (the read-back the spike pinned).
Setting individual shadow props pushes Shadow.Type to the mixed (-2)
sentinel, so .Style (not .Type) is the reliable read-back.
PpSlideShowState
Bases: IntEnum
SlideShowView.State — what a running slide show is currently doing.
BLACK_SCREEN/WHITE_SCREEN are the presenter "blank the screen" states
(the B / W keys); setting State back to RUNNING resumes. pptlive reports
DONE for a deck with no running show (the show window is gone), so the
state read has a value to return without raising.
PpSlideShowRangeType
Bases: IntEnum
SlideShowSettings.RangeType — which slides a show runs.
pptlive sets SLIDE_RANGE only to honor show.start(from_slide=...); the
default ALL runs the whole deck.
Bases: IntEnum
Shape.Export filter — the per-shape image-format enum.
Distinct from Slide.Export, whose FilterName is a string ("PNG"): a
shape export takes this int enum instead. pptlive exposes only the common
raster set; the vector types (WMF/EMF) stay reachable via the .com escape
hatch.
PpSaveAsFileType
Bases: IntEnum
Presentation.SaveAs(FileFormat=...) values the save/export verbs expose.
A deliberately narrow slice of PowerPoint's full PpSaveAsFileType: the
modern Open XML .pptx (OPEN_XML_PRESENTATION, what save_as(fmt="pptx")
writes) and PDF (what export_pdf writes). The 2026-06-09 spike found
Presentation.ExportAsFixedFormat won't marshal under pptlive's late-bound
dispatch (a trailing object-typed param raises TypeError), but
SaveAs(path, ppSaveAsPDF=32) produces a faithful PDF without rebinding the
working file or touching its dirty flag — so PDF export rides SaveAs too.
Legacy .ppt, image, and slide-show formats are deferred until a use case
needs them (the wordlive "add only as needed" rule).
XlChartType
Bases: IntEnum
Shapes.AddChart2 / Chart.ChartType — the chart kind.
A small, common subset of Excel's XlChartType (the values are shared with
PowerPoint's chart object model). Added only as needed (the wordlive rule);
reach for the .com escape hatch + a raw int for anything exotic. Note the
negative members are how Office encodes these specific constants.
XlAxisType
Bases: IntEnum
Chart.Axes(type) — the two axes whose tick labels carry text.
Excel's XlAxisType, shared with PowerPoint's chart object model. Only the
category and value axes are surfaced (the ones recolor_text walks); the
series axis (3-D charts) isn't needed yet.
MsoSmartArtNodePosition
Bases: IntEnum
SmartArtNode.AddNode(Position, Type) — where to add a node.
The one that matters is BELOW (add a child): plain
SmartArtNodes.Add() adds a sibling, so child nesting must go through
AddNode(BELOW, ...) (verified live).
MsoTextUnderlineType
Bases: IntEnum
Font2.UnderlineStyle (TextFrame2) — a SmartArt node's underline.
A node's text lives on TextFrame2, whose Font2 has no classic
Font.Underline tristate; underline is this enum instead. Only the two ends
SmartArt.format_node needs are populated — no underline vs. a plain single
line (widen on demand, the "add only as needed" rule).
PpTextStyleType
Bases: IntEnum
SlideMaster.TextStyles(type) — the master's three named text styles.
PowerPoint's nearest analog to Word's named paragraph styles: each style has
5 outline Levels, and editing one re-renders every slide that inherits it.
MsoThemeColorSchemeIndex
Bases: IntEnum
Theme.ThemeColorScheme.Colors(index) — the 12 theme palette slots.
The slot ints PowerPoint uses; .RGB on each is the same R-low-byte long as
Font.Color.RGB (so parse_color / color_hex apply unchanged).
PpMouseActivation
Bases: IntEnum
Shape.ActionSettings(activation) — which mouse event the action fires on.
Only MOUSE_CLICK is used (the common "click to follow the link"); MOUSE_OVER
is named for the .com escape hatch but not wired into a verb yet.
PpActionType
Bases: IntEnum
ActionSetting.Action — what the click does.
Only the two pptlive sets/reads: NONE (no action — what Hyperlink.Delete()
leaves behind) and HYPERLINK (follow .Hyperlink.Address/.SubAddress, which
PowerPoint sets implicitly when an address is assigned). Widen on demand.
PpEntryEffect
Bases: IntEnum
Slide.SlideShowTransition.EntryEffect — the slide's entrance transition.
A curated subset of the documented PpEntryEffect enum (the families this
build accepts): cut, blinds, checkerboard, cover, dissolve, fade, uncover.
NONE is no transition. Pass a raw int to reach any value pptlive hasn't named.
Bases: IntEnum
Shape.MediaType — the kind of media a media shape holds.
Bases: IntEnum
Presentation.CreateVideoStatus — the async MP4-encode task state.
NONE is "no export has been requested" (the idle state). QUEUED /
IN_PROGRESS are mid-encode; DONE / FAILED are terminal. export_video
polls this to completion.
MsoAnimEffect
Bases: IntEnum
Sequence.AddEffect EffectId — the curated common animation effects.
A small, well-documented subset of the large MsoAnimEffect enum; pass a raw
int to reach any effect pptlive hasn't named. An effect animates a shape in
by default and out when applied as an exit effect (Effect.Exit).
MsoAnimTriggerType
Bases: IntEnum
Effect.Timing.TriggerType — when an animation fires.
is_true
is_true(tristate: Any) -> bool
True iff an MsoTriState-valued COM property is msoTrue (-1).
bool(shape.HasTextFrame) already works for the TRUE/FALSE pair, but this
spells the intent out and ignores the MIXED/TOGGLE sentinels.
Source code in src/pptlive/constants.py
| def is_true(tristate: Any) -> bool:
"""True iff an MsoTriState-valued COM property is `msoTrue` (-1).
`bool(shape.HasTextFrame)` already works for the TRUE/FALSE pair, but this
spells the intent out and ignores the MIXED/TOGGLE sentinels.
"""
try:
return int(tristate) == int(MsoTriState.TRUE)
except (TypeError, ValueError):
return bool(tristate)
|
tristate_value
tristate_value(tristate: Any) -> bool | str
An MsoTriState font property -> True / False / "mixed".
Unlike is_true (which collapses MIXED to False), this preserves the
msoTriStateMixed (-2) signal a font property like Font.Bold returns when
a range spans both bold and non-bold runs — so a reader can tell "this
paragraph is uniformly not-bold" apart from "this paragraph mixes bold runs".
Source code in src/pptlive/constants.py
| def tristate_value(tristate: Any) -> bool | str:
"""An MsoTriState font property -> `True` / `False` / `"mixed"`.
Unlike `is_true` (which collapses MIXED to False), this preserves the
`msoTriStateMixed` (-2) signal a font property like `Font.Bold` returns when
a *range* spans both bold and non-bold runs — so a reader can tell "this
paragraph is uniformly not-bold" apart from "this paragraph mixes bold runs".
"""
try:
v = int(tristate)
except (TypeError, ValueError):
return bool(tristate)
if v == int(MsoTriState.MIXED):
return "mixed"
return v == int(MsoTriState.TRUE)
|
shape_type_name
shape_type_name(value: Any) -> str
Friendly lowercase name for a Shape.Type int (e.g. 14 -> "placeholder").
Unknown values render as "type:<n>" rather than raising — a read should
never fail because PowerPoint grew a shape kind we haven't enumerated.
Source code in src/pptlive/constants.py
| def shape_type_name(value: Any) -> str:
"""Friendly lowercase name for a `Shape.Type` int (e.g. 14 -> "placeholder").
Unknown values render as `"type:<n>"` rather than raising — a read should
never fail because PowerPoint grew a shape kind we haven't enumerated.
"""
try:
return _SHAPE_TYPE_NAMES.get(int(value), f"type:{int(value)}")
except (TypeError, ValueError):
return "unknown"
|
autosize_name
autosize_name(value: Any) -> str
Friendly name for a TextFrame2.AutoSize int (e.g. 2 -> "shape_to_fit_text").
Source code in src/pptlive/constants.py
| def autosize_name(value: Any) -> str:
"""Friendly name for a `TextFrame2.AutoSize` int (e.g. 2 -> "shape_to_fit_text")."""
try:
return _AUTOSIZE_NAMES.get(int(value), f"autosize:{int(value)}")
except (TypeError, ValueError):
return "unknown"
|
placeholder_types_for
placeholder_types_for(kind: str) -> tuple[PpPlaceholderType, ...]
Accepted PpPlaceholderTypes for a friendly placeholder KIND.
Raises ValueError for an unknown KIND (with the valid set), so callers can
surface a clean message before touching COM.
Source code in src/pptlive/constants.py
| def placeholder_types_for(kind: str) -> tuple[PpPlaceholderType, ...]:
"""Accepted `PpPlaceholderType`s for a friendly placeholder KIND.
Raises `ValueError` for an unknown KIND (with the valid set), so callers can
surface a clean message before touching COM.
"""
try:
return _PLACEHOLDER_KINDS[kind.lower()]
except KeyError:
raise ValueError(
f"unknown placeholder kind {kind!r}; expected one of {sorted(_PLACEHOLDER_KINDS)}"
) from None
|
placeholder_kind_name
placeholder_kind_name(value: Any) -> str
Friendly name for a PlaceholderFormat.Type int (e.g. 1 -> "title").
Source code in src/pptlive/constants.py
| def placeholder_kind_name(value: Any) -> str:
"""Friendly name for a `PlaceholderFormat.Type` int (e.g. 1 -> "title")."""
try:
return _PLACEHOLDER_TYPE_NAMES.get(int(value), f"placeholder:{int(value)}")
except (TypeError, ValueError):
return "placeholder"
|
match_layout_name
match_layout_name(available: Sequence[str], requested: str) -> int | None
1-based index into available of the layout matching requested, else None.
Matches case/separator-insensitively against the deck's actual layout names
first — so any template, including one whose layouts were renamed, resolves
by its real name — then falls back to a small friendly-alias table for the
standard Office layouts. Returns None when nothing matches; callers raise
LayoutNotFoundError carrying available so an agent can pick a valid name.
Source code in src/pptlive/constants.py
| def match_layout_name(available: Sequence[str], requested: str) -> int | None:
"""1-based index into `available` of the layout matching `requested`, else None.
Matches case/separator-insensitively against the deck's *actual* layout names
first — so any template, including one whose layouts were renamed, resolves
by its real name — then falls back to a small friendly-alias table for the
standard Office layouts. Returns None when nothing matches; callers raise
`LayoutNotFoundError` carrying `available` so an agent can pick a valid name.
"""
norm_available = [_normalize_name(name) for name in available]
want = _normalize_name(requested)
if not want:
return None
if want in norm_available:
return norm_available.index(want) + 1
canonical = _LAYOUT_ALIASES.get(want)
if canonical is not None and canonical in norm_available:
return norm_available.index(canonical) + 1
return None
|
autoshape_type_for
autoshape_type_for(name: str | int) -> int
Friendly autoshape name (or a raw MsoAutoShapeType int) -> the int.
Names match case/separator-insensitively ("Rounded Rectangle",
"rounded_rectangle", and "roundrect" all resolve). A raw int passes
through unchanged — the escape hatch for autoshapes pptlive hasn't named.
Raises ValueError (listing the friendly names) for an unknown name.
Source code in src/pptlive/constants.py
| def autoshape_type_for(name: str | int) -> int:
"""Friendly autoshape name (or a raw `MsoAutoShapeType` int) -> the int.
Names match case/separator-insensitively (`"Rounded Rectangle"`,
`"rounded_rectangle"`, and `"roundrect"` all resolve). A raw int passes
through unchanged — the escape hatch for autoshapes pptlive hasn't named.
Raises `ValueError` (listing the friendly names) for an unknown name.
"""
if isinstance(name, bool):
raise ValueError(f"invalid autoshape type: {name!r}")
if isinstance(name, int):
return int(name)
found = _AUTOSHAPE_NAMES.get(_normalize_name(name))
if found is None:
choices = ", ".join(AUTOSHAPE_CHOICES)
raise ValueError(f"unknown autoshape {name!r}; expected one of: {choices}")
return int(found)
|
zorder_cmd_for
zorder_cmd_for(name: str | int) -> int
Friendly z-order name (or a raw MsoZOrderCmd int) -> the int.
"front"/"back"/"forward"/"backward" (and the verbose "bring_to_front"
etc.) match case/separator-insensitively. A raw int passes through. Raises
ValueError (listing the friendly names) for an unknown name — symmetric with
autoshape_type_for.
Source code in src/pptlive/constants.py
| def zorder_cmd_for(name: str | int) -> int:
"""Friendly z-order name (or a raw `MsoZOrderCmd` int) -> the int.
`"front"`/`"back"`/`"forward"`/`"backward"` (and the verbose `"bring_to_front"`
etc.) match case/separator-insensitively. A raw int passes through. Raises
`ValueError` (listing the friendly names) for an unknown name — symmetric with
`autoshape_type_for`.
"""
if isinstance(name, bool):
raise ValueError(f"invalid z-order command: {name!r}")
if isinstance(name, int):
return int(name)
found = _ZORDER_NAMES.get(_normalize_name(name))
if found is None:
choices = ", ".join(ZORDER_CHOICES)
raise ValueError(f"unknown z-order command {name!r}; expected one of: {choices}")
return int(found)
|
align_cmd_for
align_cmd_for(name: str | int) -> int
Friendly align name (or a raw MsoAlignCmd int) -> the int.
"left"/"center"/"right" (horizontal edges) and "top"/"middle"/
"bottom" (vertical edges) match case/separator-insensitively; a raw int
passes through. Raises ValueError (listing the names) for an unknown name.
Source code in src/pptlive/constants.py
| def align_cmd_for(name: str | int) -> int:
"""Friendly align name (or a raw `MsoAlignCmd` int) -> the int.
`"left"`/`"center"`/`"right"` (horizontal edges) and `"top"`/`"middle"`/
`"bottom"` (vertical edges) match case/separator-insensitively; a raw int
passes through. Raises `ValueError` (listing the names) for an unknown name.
"""
if isinstance(name, bool):
raise ValueError(f"invalid align command: {name!r}")
if isinstance(name, int):
return int(name)
found = _ALIGN_NAMES.get(_normalize_name(name))
if found is None:
choices = ", ".join(ALIGN_CHOICES)
raise ValueError(f"unknown align command {name!r}; expected one of: {choices}")
return int(found)
|
distribute_cmd_for
distribute_cmd_for(name: str | int) -> int
Friendly distribute name (or a raw MsoDistributeCmd int) -> the int.
"horizontal" / "vertical" match case-insensitively; a raw int passes
through. Raises ValueError (listing the names) for an unknown name.
Source code in src/pptlive/constants.py
| def distribute_cmd_for(name: str | int) -> int:
"""Friendly distribute name (or a raw `MsoDistributeCmd` int) -> the int.
`"horizontal"` / `"vertical"` match case-insensitively; a raw int passes
through. Raises `ValueError` (listing the names) for an unknown name.
"""
if isinstance(name, bool):
raise ValueError(f"invalid distribute command: {name!r}")
if isinstance(name, int):
return int(name)
found = _DISTRIBUTE_NAMES.get(_normalize_name(name))
if found is None:
choices = ", ".join(DISTRIBUTE_CHOICES)
raise ValueError(f"unknown distribute command {name!r}; expected one of: {choices}")
return int(found)
|
relative_to_for
relative_to_for(value: str | int | bool) -> int
Coerce a relative-to choice to the RelativeTo MsoTriState int.
"slide" -> msoTrue (align/distribute against the slide); "selection" ->
msoFalse (against the selection's own bounding box). A bool or raw int
passes through (True/non-zero -> msoTrue). Raises ValueError for an
unknown name.
Source code in src/pptlive/constants.py
| def relative_to_for(value: str | int | bool) -> int:
"""Coerce a relative-to choice to the `RelativeTo` `MsoTriState` int.
`"slide"` -> `msoTrue` (align/distribute against the slide); `"selection"` ->
`msoFalse` (against the selection's own bounding box). A bool or raw int
passes through (`True`/non-zero -> msoTrue). Raises `ValueError` for an
unknown name.
"""
if isinstance(value, bool):
return int(MsoTriState.TRUE if value else MsoTriState.FALSE)
if isinstance(value, int):
return int(MsoTriState.TRUE if value else MsoTriState.FALSE)
found = _RELATIVE_TO_NAMES.get(_normalize_name(value))
if found is None:
choices = ", ".join(RELATIVE_TO_CHOICES)
raise ValueError(f"unknown relative-to {value!r}; expected one of: {choices}")
return int(MsoTriState.TRUE if found else MsoTriState.FALSE)
|
connector_type_for
connector_type_for(name: str | int) -> int
Friendly connector type (or a raw MsoConnectorType int) -> the int.
"straight" / "elbow" / "curved" match case-insensitively; a raw int
passes through. Raises ValueError (listing the names) for an unknown name.
Source code in src/pptlive/constants.py
| def connector_type_for(name: str | int) -> int:
"""Friendly connector type (or a raw `MsoConnectorType` int) -> the int.
`"straight"` / `"elbow"` / `"curved"` match case-insensitively; a raw int
passes through. Raises `ValueError` (listing the names) for an unknown name.
"""
if isinstance(name, bool):
raise ValueError(f"invalid connector type: {name!r}")
if isinstance(name, int):
return int(name)
found = _CONNECTOR_NAMES.get(_normalize_name(name))
if found is None:
choices = ", ".join(CONNECTOR_CHOICES)
raise ValueError(f"unknown connector type {name!r}; expected one of: {choices}")
return int(found)
|
connector_type_name
connector_type_name(value: int) -> str
MsoConnectorType int -> a friendly name (for connector reads).
Source code in src/pptlive/constants.py
| def connector_type_name(value: int) -> str:
"""`MsoConnectorType` int -> a friendly name (for connector reads)."""
try:
return MsoConnectorType(int(value)).name.lower()
except ValueError:
return str(int(value))
|
alignment_for
alignment_for(value: str | int) -> int
Coerce an alignment name/int to a PpParagraphAlignment int.
Accepts "left"/"center"/"right"/"justify"/"distribute" (case-
insensitive, "centre" too) or a raw int. Raises ValueError for an
unknown name — symmetric with autoshape_type_for.
Source code in src/pptlive/constants.py
| def alignment_for(value: str | int) -> int:
"""Coerce an alignment name/int to a `PpParagraphAlignment` int.
Accepts `"left"`/`"center"`/`"right"`/`"justify"`/`"distribute"` (case-
insensitive, `"centre"` too) or a raw int. Raises `ValueError` for an
unknown name — symmetric with `autoshape_type_for`.
"""
if isinstance(value, bool):
raise ValueError(f"invalid alignment: {value!r}")
if isinstance(value, int):
return int(value)
found = _ALIGNMENT_NAMES.get(_normalize_name(value))
if found is None:
choices = ", ".join(ALIGNMENT_CHOICES)
raise ValueError(f"unknown alignment {value!r}; expected one of: {choices}")
return int(found)
|
bullet_type_for
bullet_type_for(list_type: str) -> PpBulletType
Resolve a list_type string to its PpBulletType.
"bulleted" -> unnumbered, "numbered" -> numbered. Raises ValueError
for an unknown name.
Source code in src/pptlive/constants.py
| def bullet_type_for(list_type: str) -> PpBulletType:
"""Resolve a `list_type` string to its `PpBulletType`.
`"bulleted"` -> unnumbered, `"numbered"` -> numbered. Raises `ValueError`
for an unknown name.
"""
found = _BULLET_TYPE_FOR.get(_normalize_name(list_type))
if found is None:
choices = ", ".join(LIST_TYPE_CHOICES)
raise ValueError(f"unknown list type {list_type!r}; expected one of: {choices}")
return found
|
bullet_type_name
bullet_type_name(value: Any) -> str
Friendly name for a Bullet.Type int (e.g. 1 -> "bulleted").
Source code in src/pptlive/constants.py
| def bullet_type_name(value: Any) -> str:
"""Friendly name for a `Bullet.Type` int (e.g. 1 -> "bulleted")."""
try:
return _BULLET_TYPE_NAMES.get(int(value), f"bullet:{int(value)}")
except (TypeError, ValueError):
return "none"
|
parse_color
parse_color(value: str | int | tuple[int, int, int]) -> int
Coerce a color to the long PowerPoint's Font.Color.RGB expects.
Accepts "#RRGGBB" / "RRGGBB" hex, an (r, g, b) tuple (0-255 each), or a
raw int (passed through — the escape hatch). PowerPoint stores the long in
R-low-byte order (red == 0x0000FF), so "#FF0000" -> 255. Raises
ValueError for a malformed hex string or out-of-range channel.
Source code in src/pptlive/constants.py
| def parse_color(value: str | int | tuple[int, int, int]) -> int:
"""Coerce a color to the long PowerPoint's `Font.Color.RGB` expects.
Accepts `"#RRGGBB"` / `"RRGGBB"` hex, an `(r, g, b)` tuple (0-255 each), or a
raw int (passed through — the escape hatch). PowerPoint stores the long in
R-low-byte order (`red == 0x0000FF`), so `"#FF0000"` -> 255. Raises
`ValueError` for a malformed hex string or out-of-range channel.
"""
if isinstance(value, bool):
raise ValueError(f"invalid color: {value!r}")
if isinstance(value, int):
return int(value)
if isinstance(value, tuple):
if len(value) != 3 or any(not (0 <= int(c) <= 255) for c in value):
raise ValueError(f"color tuple must be three 0-255 channels, got {value!r}")
r, g, b = (int(c) for c in value)
return r | (g << 8) | (b << 16)
text = str(value).strip().lstrip("#")
if len(text) != 6:
raise ValueError(f"color must be '#RRGGBB' hex, an (r,g,b) tuple, or an int; got {value!r}")
try:
r = int(text[0:2], 16)
g = int(text[2:4], 16)
b = int(text[4:6], 16)
except ValueError:
raise ValueError(f"invalid hex color {value!r}") from None
return r | (g << 8) | (b << 16)
|
color_hex
color_hex(value: Any) -> str
Render a PowerPoint Font.Color.RGB long as "#RRGGBB".
Source code in src/pptlive/constants.py
| def color_hex(value: Any) -> str:
"""Render a PowerPoint `Font.Color.RGB` long as `"#RRGGBB"`."""
n = int(value)
r, g, b = n & 0xFF, (n >> 8) & 0xFF, (n >> 16) & 0xFF
return f"#{r:02X}{g:02X}{b:02X}"
|
color_hex_or_none
color_hex_or_none(value: Any) -> str | None
color_hex, but None for a non-literal (theme/automatic) color.
A theme or automatic color isn't a literal RGB: COM returns the 0x80000000
"automatic" sentinel (which color_hex would mis-render as #000000), and
anything outside 0..0xFFFFFF is likewise not a real RGB. Shared by the font
(_anchors._font_color_hex) and shape fill/line readbacks so a theme-driven
color reports honestly as None rather than a wrong black.
Source code in src/pptlive/constants.py
| def color_hex_or_none(value: Any) -> str | None:
"""`color_hex`, but `None` for a non-literal (theme/automatic) color.
A theme or automatic color isn't a literal RGB: COM returns the `0x80000000`
"automatic" sentinel (which `color_hex` would mis-render as `#000000`), and
anything outside `0..0xFFFFFF` is likewise not a real RGB. Shared by the font
(`_anchors._font_color_hex`) and shape fill/line readbacks so a theme-driven
color reports honestly as `None` rather than a wrong black.
"""
try:
rgb = int(value)
except (TypeError, ValueError):
return None
if rgb < 0 or rgb > 0xFFFFFF:
return None
return color_hex(rgb)
|
color_source_name
color_source_name(type_value: Any) -> str | None
Friendly source for a ColorFormat.Type int: "direct" (a literal RGB/CMYK
set on the run) / "theme" (a scheme color cascaded from the theme/master) /
"mixed" (a range spanning both), or None if PowerPoint can't report it.
This is the "is it green because I set it, or because the master pulled it?"
discriminator — text_frame_status / the font read surface it as color_source.
Source code in src/pptlive/constants.py
| def color_source_name(type_value: Any) -> str | None:
"""Friendly source for a `ColorFormat.Type` int: `"direct"` (a literal RGB/CMYK
set on the run) / `"theme"` (a scheme color cascaded from the theme/master) /
`"mixed"` (a range spanning both), or `None` if PowerPoint can't report it.
This is the "is it green because I set it, or because the master pulled it?"
discriminator — `text_frame_status` / the font read surface it as `color_source`.
"""
try:
t = int(type_value)
except (TypeError, ValueError):
return None
if t == int(MsoColorType.SCHEME):
return "theme"
if t in (int(MsoColorType.RGB), int(MsoColorType.CMYK), int(MsoColorType.CMS)):
return "direct"
if t == int(MsoColorType.MIXED):
return "mixed"
return None
|
theme_color_name
theme_color_name(value: Any) -> str | None
Friendly theme-slot name for a ColorFormat.ObjectThemeColor int, or None.
Source code in src/pptlive/constants.py
| def theme_color_name(value: Any) -> str | None:
"""Friendly theme-slot name for a `ColorFormat.ObjectThemeColor` int, or None."""
try:
return _THEME_COLOR_NAMES.get(int(value))
except (TypeError, ValueError):
return None
|
fill_type_name
fill_type_name(value: Any) -> str | int | None
Friendly name for a Fill.Type int (1 -> "solid"); the int if unknown.
Source code in src/pptlive/constants.py
| def fill_type_name(value: Any) -> str | int | None:
"""Friendly name for a `Fill.Type` int (`1 -> "solid"`); the int if unknown."""
try:
n = int(value)
except (TypeError, ValueError):
return None
return FILL_TYPE_NAMES.get(n, n)
|
gradient_style_for
gradient_style_for(style: str | int) -> int
Friendly gradient-style name (or raw MsoGradientStyle int) -> the int.
"horizontal"/"vertical"/"diagonal_up"/… (case- and separator-insensitive)
or a raw int (passed through). Raises ValueError for an unknown name —
symmetric with entry_effect_for.
Source code in src/pptlive/constants.py
| def gradient_style_for(style: str | int) -> int:
"""Friendly gradient-style name (or raw `MsoGradientStyle` int) -> the int.
`"horizontal"`/`"vertical"`/`"diagonal_up"`/… (case- and separator-insensitive)
or a raw int (passed through). Raises `ValueError` for an unknown name —
symmetric with `entry_effect_for`.
"""
if isinstance(style, bool):
raise ValueError(f"invalid gradient style: {style!r}")
if isinstance(style, int):
return int(style)
found = _GRADIENT_STYLES.get(str(style).strip().lower().replace(" ", "_").replace("-", "_"))
if found is None:
choices = ", ".join(GRADIENT_STYLE_CHOICES)
raise ValueError(f"unknown gradient style {style!r}; expected one of: {choices}")
return found
|
gradient_style_name
gradient_style_name(value: Any) -> str | int | None
Friendly name for a Fill.GradientStyle int (1 -> "horizontal").
Source code in src/pptlive/constants.py
| def gradient_style_name(value: Any) -> str | int | None:
"""Friendly name for a `Fill.GradientStyle` int (`1 -> "horizontal"`)."""
try:
n = int(value)
except (TypeError, ValueError):
return None
return _GRADIENT_STYLE_NAMES.get(n, n)
|
preset_gradient_for
preset_gradient_for(preset: str | int) -> int
Friendly preset-gradient name (or raw MsoPresetGradientType int) -> the int.
"ocean"/"fire"/"rainbow"/… (case-/separator-insensitive) or a raw int.
Raises ValueError for an unknown name.
Source code in src/pptlive/constants.py
| def preset_gradient_for(preset: str | int) -> int:
"""Friendly preset-gradient name (or raw `MsoPresetGradientType` int) -> the int.
`"ocean"`/`"fire"`/`"rainbow"`/… (case-/separator-insensitive) or a raw int.
Raises `ValueError` for an unknown name.
"""
if isinstance(preset, bool):
raise ValueError(f"invalid preset gradient: {preset!r}")
if isinstance(preset, int):
return int(preset)
found = _PRESET_GRADIENTS.get(str(preset).strip().lower().replace(" ", "_").replace("-", "_"))
if found is None:
choices = ", ".join(PRESET_GRADIENT_CHOICES)
raise ValueError(f"unknown preset gradient {preset!r}; expected one of: {choices}")
return found
|
preset_gradient_name
preset_gradient_name(value: Any) -> str | int | None
Friendly name for a MsoPresetGradientType int (7 -> "ocean").
Source code in src/pptlive/constants.py
| def preset_gradient_name(value: Any) -> str | int | None:
"""Friendly name for a `MsoPresetGradientType` int (`7 -> "ocean"`)."""
try:
n = int(value)
except (TypeError, ValueError):
return None
return _PRESET_GRADIENT_NAMES.get(n, n)
|
pattern_for
pattern_for(pattern: str | int) -> int
Friendly pattern name (or raw MsoPatternType int) -> the int.
"percent_50"/"dark_horizontal"/"trellis"/… (case-/separator-insensitive)
or a raw int. Raises ValueError for an unknown name.
Source code in src/pptlive/constants.py
| def pattern_for(pattern: str | int) -> int:
"""Friendly pattern name (or raw `MsoPatternType` int) -> the int.
`"percent_50"`/`"dark_horizontal"`/`"trellis"`/… (case-/separator-insensitive)
or a raw int. Raises `ValueError` for an unknown name.
"""
if isinstance(pattern, bool):
raise ValueError(f"invalid pattern: {pattern!r}")
if isinstance(pattern, int):
return int(pattern)
found = _PATTERNS.get(str(pattern).strip().lower().replace(" ", "_").replace("-", "_"))
if found is None:
choices = ", ".join(PATTERN_CHOICES)
raise ValueError(f"unknown pattern {pattern!r}; expected one of: {choices}")
return found
|
pattern_name
pattern_name(value: Any) -> str | int | None
Friendly name for a MsoPatternType int (7 -> "percent_50").
Source code in src/pptlive/constants.py
| def pattern_name(value: Any) -> str | int | None:
"""Friendly name for a `MsoPatternType` int (`7 -> "percent_50"`)."""
try:
n = int(value)
except (TypeError, ValueError):
return None
return _PATTERN_NAMES.get(n, n)
|
dash_style_for
dash_style_for(dash: str | int) -> int
Friendly dash name (or raw MsoLineDashStyle int) -> the int.
"solid"/"dash"/"round_dot"/"long_dash_dot"/… (case-/separator-insensitive)
or a raw int. Raises ValueError for an unknown name.
Source code in src/pptlive/constants.py
| def dash_style_for(dash: str | int) -> int:
"""Friendly dash name (or raw `MsoLineDashStyle` int) -> the int.
`"solid"`/`"dash"`/`"round_dot"`/`"long_dash_dot"`/… (case-/separator-insensitive)
or a raw int. Raises `ValueError` for an unknown name.
"""
if isinstance(dash, bool):
raise ValueError(f"invalid dash style: {dash!r}")
if isinstance(dash, int):
return int(dash)
found = _DASH_STYLES.get(str(dash).strip().lower().replace(" ", "_").replace("-", "_"))
if found is None:
choices = ", ".join(DASH_STYLE_CHOICES)
raise ValueError(f"unknown dash style {dash!r}; expected one of: {choices}")
return found
|
dash_style_name
dash_style_name(value: Any) -> str | int | None
Friendly name for a MsoLineDashStyle int (4 -> "dash").
Source code in src/pptlive/constants.py
| def dash_style_name(value: Any) -> str | int | None:
"""Friendly name for a `MsoLineDashStyle` int (`4 -> "dash"`)."""
try:
n = int(value)
except (TypeError, ValueError):
return None
if n <= 0: # msoLineDashStyleMixed (-2) / unset
return None
return _DASH_STYLE_NAMES.get(n, n)
|
arrowhead_style_for
arrowhead_style_for(style: str | int) -> int
Friendly arrowhead name (or raw MsoArrowheadStyle int) -> the int.
"none"/"triangle"/"open"/"stealth"/"diamond"/"oval" or a raw int.
Raises ValueError for an unknown name.
Source code in src/pptlive/constants.py
| def arrowhead_style_for(style: str | int) -> int:
"""Friendly arrowhead name (or raw `MsoArrowheadStyle` int) -> the int.
`"none"`/`"triangle"`/`"open"`/`"stealth"`/`"diamond"`/`"oval"` or a raw int.
Raises `ValueError` for an unknown name.
"""
if isinstance(style, bool):
raise ValueError(f"invalid arrowhead style: {style!r}")
if isinstance(style, int):
return int(style)
found = _ARROWHEAD_STYLES.get(str(style).strip().lower().replace(" ", "_").replace("-", "_"))
if found is None:
choices = ", ".join(ARROWHEAD_STYLE_CHOICES)
raise ValueError(f"unknown arrowhead style {style!r}; expected one of: {choices}")
return found
|
arrowhead_style_name
arrowhead_style_name(value: Any) -> str | int | None
Friendly name for a MsoArrowheadStyle int (2 -> "triangle").
Source code in src/pptlive/constants.py
| def arrowhead_style_name(value: Any) -> str | int | None:
"""Friendly name for a `MsoArrowheadStyle` int (`2 -> "triangle"`)."""
try:
n = int(value)
except (TypeError, ValueError):
return None
if n <= 0: # msoArrowheadStyleMixed (-2) / unset
return None
return _ARROWHEAD_STYLE_NAMES.get(n, n)
|
arrowhead_size_for
arrowhead_size_for(size: str | int) -> int
Friendly arrowhead size (small/medium/large, or raw 1-3) -> the int.
Drives both MsoArrowheadLength and MsoArrowheadWidth together. Raises
ValueError for an unknown name.
Source code in src/pptlive/constants.py
| def arrowhead_size_for(size: str | int) -> int:
"""Friendly arrowhead size (`small`/`medium`/`large`, or raw 1-3) -> the int.
Drives both `MsoArrowheadLength` and `MsoArrowheadWidth` together. Raises
`ValueError` for an unknown name.
"""
if isinstance(size, bool):
raise ValueError(f"invalid arrowhead size: {size!r}")
if isinstance(size, int):
return int(size)
found = _ARROWHEAD_SIZES.get(str(size).strip().lower())
if found is None:
choices = ", ".join(ARROWHEAD_SIZE_CHOICES)
raise ValueError(f"unknown arrowhead size {size!r}; expected one of: {choices}")
return found
|
border_edges_for
border_edges_for(edges: str | int | Sequence[str | int]) -> list[int]
Friendly edge selector -> a deduplicated, ordered list of Borders() indices.
Accepts "all" (the four sides 1-4, diagonals excluded), a single edge name
or raw int ("bottom", 3), or a sequence of those (["top", "bottom"]).
Names are case-/separator-insensitive. Raises ValueError for an unknown edge.
Source code in src/pptlive/constants.py
| def border_edges_for(edges: str | int | Sequence[str | int]) -> list[int]:
"""Friendly edge selector -> a deduplicated, ordered list of `Borders()` indices.
Accepts `"all"` (the four sides 1-4, diagonals excluded), a single edge name
or raw int (`"bottom"`, `3`), or a sequence of those (`["top", "bottom"]`).
Names are case-/separator-insensitive. Raises `ValueError` for an unknown edge.
"""
def one(edge: str | int) -> list[int]:
if isinstance(edge, bool):
raise ValueError(f"invalid border edge: {edge!r}")
if isinstance(edge, int):
return [int(edge)]
token = str(edge).strip().lower().replace(" ", "_").replace("-", "_")
if token == "all":
return [_BORDER_EDGES[s] for s in _BORDER_SIDES]
found = _BORDER_EDGES.get(token)
if found is None:
choices = ", ".join(BORDER_EDGE_CHOICES)
raise ValueError(f"unknown border edge {edge!r}; expected one of: {choices}")
return [found]
items: Sequence[str | int]
if isinstance(edges, (str, int)) and not isinstance(edges, bool):
items = [edges]
else:
items = list(edges) # type: ignore[arg-type]
seen: dict[int, None] = {}
for item in items:
for idx in one(item):
seen[idx] = None
if not seen:
raise ValueError("border edge selector resolved to no edges")
return list(seen)
|
image_filter_for
image_filter_for(fmt: str) -> tuple[str, str]
Resolve an image-format token to its (FilterName, extension) for Slide.Export.
Accepts "png"/"jpg"/"jpeg"/"gif"/"bmp"/"tif"/"tiff" (case-
insensitive, a leading dot tolerated). Raises ValueError for an unknown
format — symmetric with autoshape_type_for / alignment_for.
Source code in src/pptlive/constants.py
| def image_filter_for(fmt: str) -> tuple[str, str]:
"""Resolve an image-format token to its `(FilterName, extension)` for `Slide.Export`.
Accepts `"png"`/`"jpg"`/`"jpeg"`/`"gif"`/`"bmp"`/`"tif"`/`"tiff"` (case-
insensitive, a leading dot tolerated). Raises `ValueError` for an unknown
format — symmetric with `autoshape_type_for` / `alignment_for`.
"""
key = str(fmt).strip().lower().lstrip(".")
found = _IMAGE_FILTERS.get(key)
if found is None:
choices = ", ".join(IMAGE_FORMAT_CHOICES)
raise ValueError(f"unknown image format {fmt!r}; expected one of: {choices}")
return found
|
slide_show_state_name
slide_show_state_name(value: Any) -> str
Friendly name for a SlideShowView.State int (e.g. 3 -> "black").
Source code in src/pptlive/constants.py
| def slide_show_state_name(value: Any) -> str:
"""Friendly name for a `SlideShowView.State` int (e.g. 3 -> "black")."""
try:
return _SLIDE_SHOW_STATE_NAMES.get(int(value), f"state:{int(value)}")
except (TypeError, ValueError):
return "unknown"
|
shape_image_filter_for
shape_image_filter_for(fmt: str) -> tuple[int, str]
Resolve an image-format token to its (PpShapeFormat, extension) for Shape.Export.
Accepts "png"/"jpg"/"jpeg"/"gif"/"bmp" (case-insensitive, a
leading dot tolerated). Raises ValueError for an unknown format —
symmetric with image_filter_for (the Slide.Export resolver). Note the
raster set is narrower than Slide.Export's (no TIFF — PpShapeFormat has
no TIFF member).
Source code in src/pptlive/constants.py
| def shape_image_filter_for(fmt: str) -> tuple[int, str]:
"""Resolve an image-format token to its `(PpShapeFormat, extension)` for `Shape.Export`.
Accepts `"png"`/`"jpg"`/`"jpeg"`/`"gif"`/`"bmp"` (case-insensitive, a
leading dot tolerated). Raises `ValueError` for an unknown format —
symmetric with `image_filter_for` (the `Slide.Export` resolver). Note the
raster set is narrower than `Slide.Export`'s (no TIFF — `PpShapeFormat` has
no TIFF member).
"""
key = str(fmt).strip().lower().lstrip(".")
found = _SHAPE_IMAGE_FILTERS.get(key)
if found is None:
choices = ", ".join(SHAPE_IMAGE_FORMAT_CHOICES)
raise ValueError(f"unknown image format {fmt!r}; expected one of: {choices}")
return found
|
save_format_for(fmt: str) -> tuple[int, str]
Resolve a save_as format token to its (PpSaveAsFileType, extension).
Accepts "pptx" (case-insensitive, a leading dot tolerated). "pdf" is
rejected with a pointer to export_pdf — PDF goes through the same SaveAs
COM call but is a read (it neither rebinds the working file nor clears the
dirty flag), so it's a separate verb. Raises ValueError for anything else —
symmetric with image_filter_for / shape_image_filter_for.
Source code in src/pptlive/constants.py
| def save_format_for(fmt: str) -> tuple[int, str]:
"""Resolve a `save_as` format token to its `(PpSaveAsFileType, extension)`.
Accepts `"pptx"` (case-insensitive, a leading dot tolerated). `"pdf"` is
rejected with a pointer to `export_pdf` — PDF goes through the same `SaveAs`
COM call but is a *read* (it neither rebinds the working file nor clears the
dirty flag), so it's a separate verb. Raises `ValueError` for anything else —
symmetric with `image_filter_for` / `shape_image_filter_for`.
"""
key = str(fmt).strip().lower().lstrip(".")
if key == "pdf":
raise ValueError("save_as does not write PDF; use export_pdf(path) instead")
found = _SAVE_FILE_FORMATS.get(key)
if found is None:
choices = ", ".join(SAVE_FORMAT_CHOICES)
raise ValueError(
f"unsupported save format {fmt!r}; supported: {choices} (PDF via export_pdf)"
)
return found
|
chart_type_for
chart_type_for(chart_type: str | int) -> int
Resolve a friendly chart-type name (or raw int) to its XlChartType int.
Accepts "column"/"bar"/"line"/"pie"/… (case- and separator-
insensitive: "Line Markers" -> line_markers) or a raw int (passed through, so
exotic XlChartType values still work). Raises ValueError for an unknown
name — symmetric with autoshape_type_for.
Source code in src/pptlive/constants.py
| def chart_type_for(chart_type: str | int) -> int:
"""Resolve a friendly chart-type name (or raw int) to its `XlChartType` int.
Accepts `"column"`/`"bar"`/`"line"`/`"pie"`/… (case- and separator-
insensitive: "Line Markers" -> line_markers) or a raw int (passed through, so
exotic `XlChartType` values still work). Raises `ValueError` for an unknown
name — symmetric with `autoshape_type_for`.
"""
if isinstance(chart_type, bool): # guard: bool is an int subclass
raise ValueError(f"invalid chart type: {chart_type!r}")
if isinstance(chart_type, int):
return int(chart_type)
key = str(chart_type).strip().lower().replace(" ", "_").replace("-", "_")
found = _CHART_TYPES.get(key)
if found is None:
choices = ", ".join(CHART_TYPE_CHOICES)
raise ValueError(f"unknown chart type {chart_type!r}; expected one of: {choices}")
return found
|
chart_type_name
chart_type_name(value: Any) -> str
Friendly name for an XlChartType int (e.g. 51 -> "column_clustered").
Source code in src/pptlive/constants.py
| def chart_type_name(value: Any) -> str:
"""Friendly name for an `XlChartType` int (e.g. 51 -> "column_clustered")."""
try:
return _CHART_TYPE_NAMES.get(int(value), f"type:{int(value)}")
except (TypeError, ValueError):
return "unknown"
|
smartart_layout_for
smartart_layout_for(kind: str) -> str
Resolve a friendly SmartArt name to its layout URN segment.
Accepts "process"/"cycle"/"orgchart"/… (case- and separator-
insensitive). Raises ValueError for an unknown name (before any COM) —
symmetric with chart_type_for. Unlike charts there is no raw-int form: a
layout is a COM object, not an int, so the wrapper resolves the segment
against Application.SmartArtLayouts live.
Source code in src/pptlive/constants.py
| def smartart_layout_for(kind: str) -> str:
"""Resolve a friendly SmartArt name to its layout URN segment.
Accepts `"process"`/`"cycle"`/`"orgchart"`/… (case- and separator-
insensitive). Raises `ValueError` for an unknown name (before any COM) —
symmetric with `chart_type_for`. Unlike charts there is no raw-int form: a
layout is a COM object, not an int, so the wrapper resolves the segment
against `Application.SmartArtLayouts` live.
"""
key = str(kind).strip().lower().replace(" ", "_").replace("-", "_")
seg = _SMARTART_LAYOUTS.get(key)
if seg is None:
choices = ", ".join(SMARTART_CHOICES)
raise ValueError(f"unknown SmartArt layout {kind!r}; expected one of: {choices}")
return seg
|
smartart_layout_name
smartart_layout_name(urn: Any) -> str
Friendly name for a SmartArt layout .Id URN (e.g. ".../process1" -> "process").
Falls back to the trailing URN segment (then the raw value) when the layout
isn't one of the known cores, so a read-back never raises.
Source code in src/pptlive/constants.py
| def smartart_layout_name(urn: Any) -> str:
"""Friendly name for a SmartArt layout `.Id` URN (e.g. ".../process1" -> "process").
Falls back to the trailing URN segment (then the raw value) when the layout
isn't one of the known cores, so a read-back never raises.
"""
text = str(urn or "")
seg = text.rsplit("/", 1)[-1] if text else text
return _SMARTART_NAMES.get(seg, seg or "unknown")
|
text_style_for
text_style_for(style: str) -> int
Resolve a friendly master text-style name to its PpTextStyleType int.
Accepts "title"/"body"/"default" (case-insensitive). Raises
ValueError for an unknown name (before any COM) — symmetric with
smartart_layout_for.
Source code in src/pptlive/constants.py
| def text_style_for(style: str) -> int:
"""Resolve a friendly master text-style name to its `PpTextStyleType` int.
Accepts `"title"`/`"body"`/`"default"` (case-insensitive). Raises
`ValueError` for an unknown name (before any COM) — symmetric with
`smartart_layout_for`.
"""
key = str(style).strip().lower()
val = _TEXT_STYLES.get(key)
if val is None:
choices = ", ".join(TEXT_STYLE_CHOICES)
raise ValueError(f"unknown text style {style!r}; expected one of: {choices}")
return val
|
text_style_name
text_style_name(value: Any) -> str
Friendly name for a PpTextStyleType int (e.g. 3 -> "body").
Source code in src/pptlive/constants.py
| def text_style_name(value: Any) -> str:
"""Friendly name for a `PpTextStyleType` int (e.g. 3 -> "body")."""
try:
return _TEXT_STYLE_NAMES.get(int(value), f"style:{int(value)}")
except (TypeError, ValueError):
return "default"
|
theme_color_for
theme_color_for(slot: str) -> int
Resolve a friendly theme-color slot name to its palette index (1-12).
Accepts "accent1"/"dark1"/"hyperlink"/… (case- and separator-
insensitive; "hlink"/"folhlink" aliases too). Raises ValueError for an
unknown name (before any COM).
Source code in src/pptlive/constants.py
| def theme_color_for(slot: str) -> int:
"""Resolve a friendly theme-color slot name to its palette index (1-12).
Accepts `"accent1"`/`"dark1"`/`"hyperlink"`/… (case- and separator-
insensitive; `"hlink"`/`"folhlink"` aliases too). Raises `ValueError` for an
unknown name (before any COM).
"""
key = str(slot).strip().lower().replace(" ", "").replace("-", "")
# Match against keys with their own separators stripped, so "accent 6",
# "accent6", and "followed-hyperlink" all resolve. Explicit `is None` (not
# `or`) so a hypothetical slot index of 0 wouldn't be treated as a miss.
idx = _THEME_COLORS.get(key)
if idx is None:
idx = _THEME_COLORS_NOSEP.get(key)
if idx is None:
choices = ", ".join(THEME_COLOR_CHOICES)
raise ValueError(f"unknown theme color slot {slot!r}; expected one of: {choices}")
return idx
|
theme_font_slot_for
theme_font_slot_for(which: str) -> str
Normalize the typeface role to "major" or "minor".
"major" is the headings font, "minor" the body font; "heading"/"body"
are accepted aliases. Raises ValueError for anything else (before any COM).
Source code in src/pptlive/constants.py
| def theme_font_slot_for(which: str) -> str:
"""Normalize the typeface role to `"major"` or `"minor"`.
`"major"` is the headings font, `"minor"` the body font; `"heading"`/`"body"`
are accepted aliases. Raises `ValueError` for anything else (before any COM).
"""
key = str(which).strip().lower()
if key in ("major", "heading", "headings"):
return "major"
if key in ("minor", "body"):
return "minor"
choices = ", ".join(THEME_FONT_SLOTS)
raise ValueError(f"unknown theme font {which!r}; expected one of: {choices}")
|
theme_font_script_for
theme_font_script_for(script: str) -> int
Resolve a font script name to its .Item(n) index (latin=1/…).
Raises ValueError for an unknown name (before any COM).
Source code in src/pptlive/constants.py
| def theme_font_script_for(script: str) -> int:
"""Resolve a font script name to its `.Item(n)` index (latin=1/…).
Raises `ValueError` for an unknown name (before any COM).
"""
key = str(script).strip().lower().replace(" ", "_").replace("-", "_")
idx = _THEME_FONT_SCRIPTS.get(key)
if idx is None:
choices = ", ".join(THEME_FONT_SCRIPT_CHOICES)
raise ValueError(f"unknown font script {script!r}; expected one of: {choices}")
return idx
|
entry_effect_for
entry_effect_for(effect: str | int) -> int
Resolve a friendly transition name (or raw int) to its PpEntryEffect int.
Accepts "fade"/"cut"/"cover_left"/… (case- and separator-insensitive) or
a raw int (passed through, so exotic PpEntryEffect values still work). Raises
ValueError for an unknown name — symmetric with chart_type_for.
Source code in src/pptlive/constants.py
| def entry_effect_for(effect: str | int) -> int:
"""Resolve a friendly transition name (or raw int) to its `PpEntryEffect` int.
Accepts `"fade"`/`"cut"`/`"cover_left"`/… (case- and separator-insensitive) or
a raw int (passed through, so exotic `PpEntryEffect` values still work). Raises
`ValueError` for an unknown name — symmetric with `chart_type_for`.
"""
if isinstance(effect, bool): # guard: bool is an int subclass
raise ValueError(f"invalid transition effect: {effect!r}")
if isinstance(effect, int):
return int(effect)
key = str(effect).strip().lower().replace(" ", "_").replace("-", "_")
found = _ENTRY_EFFECTS.get(key)
if found is None:
choices = ", ".join(ENTRY_EFFECT_CHOICES)
raise ValueError(f"unknown transition effect {effect!r}; expected one of: {choices}")
return found
|
entry_effect_name
entry_effect_name(value: Any) -> str
Friendly name for a PpEntryEffect int (e.g. 1793 -> "fade").
Source code in src/pptlive/constants.py
| def entry_effect_name(value: Any) -> str:
"""Friendly name for a `PpEntryEffect` int (e.g. 1793 -> "fade")."""
try:
return _ENTRY_EFFECT_NAMES.get(int(value), f"effect:{int(value)}")
except (TypeError, ValueError):
return "unknown"
|
media_type_name(value: Any) -> str
Friendly name for a Shape.MediaType int (2 -> "sound", 3 -> "movie").
Source code in src/pptlive/constants.py
| def media_type_name(value: Any) -> str:
"""Friendly name for a `Shape.MediaType` int (2 -> "sound", 3 -> "movie")."""
try:
return _MEDIA_TYPE_NAMES.get(int(value), f"media:{int(value)}")
except (TypeError, ValueError):
return "unknown"
|
media_task_status_name(value: Any) -> str
Friendly name for a CreateVideoStatus int (3 -> "done", 4 -> "failed").
Source code in src/pptlive/constants.py
| def media_task_status_name(value: Any) -> str:
"""Friendly name for a `CreateVideoStatus` int (3 -> "done", 4 -> "failed")."""
try:
return _MEDIA_TASK_STATUS_NAMES.get(int(value), f"status:{int(value)}")
except (TypeError, ValueError):
return "unknown"
|
anim_effect_for
anim_effect_for(effect: str | int) -> int
Resolve a friendly animation name (or raw int) to its MsoAnimEffect int.
Accepts "fade"/"appear"/"fly_in"/… (case- and separator-insensitive) or a
raw int (passed through, so exotic MsoAnimEffect values still work). Raises
ValueError for an unknown name — symmetric with entry_effect_for.
Source code in src/pptlive/constants.py
| def anim_effect_for(effect: str | int) -> int:
"""Resolve a friendly animation name (or raw int) to its `MsoAnimEffect` int.
Accepts `"fade"`/`"appear"`/`"fly_in"`/… (case- and separator-insensitive) or a
raw int (passed through, so exotic `MsoAnimEffect` values still work). Raises
`ValueError` for an unknown name — symmetric with `entry_effect_for`.
"""
if isinstance(effect, bool): # guard: bool is an int subclass
raise ValueError(f"invalid animation effect: {effect!r}")
if isinstance(effect, int):
return int(effect)
key = str(effect).strip().lower().replace(" ", "_").replace("-", "_")
found = _ANIM_EFFECTS.get(key)
if found is None:
choices = ", ".join(ANIM_EFFECT_CHOICES)
raise ValueError(f"unknown animation effect {effect!r}; expected one of: {choices}")
return found
|
anim_effect_name
anim_effect_name(value: Any) -> str
Friendly name for a MsoAnimEffect int (e.g. 10 -> "fade").
Source code in src/pptlive/constants.py
| def anim_effect_name(value: Any) -> str:
"""Friendly name for a `MsoAnimEffect` int (e.g. 10 -> "fade")."""
try:
return _ANIM_EFFECT_NAMES.get(int(value), f"effect:{int(value)}")
except (TypeError, ValueError):
return "unknown"
|
anim_trigger_for
anim_trigger_for(trigger: str | int) -> int
Resolve a friendly trigger name (or raw int) to its MsoAnimTriggerType int.
"on_click" / "with_previous" / "after_previous" (the start-timing the
PowerPoint animation pane offers), or a raw int. Raises ValueError for an
unknown name.
Source code in src/pptlive/constants.py
| def anim_trigger_for(trigger: str | int) -> int:
"""Resolve a friendly trigger name (or raw int) to its `MsoAnimTriggerType` int.
`"on_click"` / `"with_previous"` / `"after_previous"` (the start-timing the
PowerPoint animation pane offers), or a raw int. Raises `ValueError` for an
unknown name.
"""
if isinstance(trigger, bool):
raise ValueError(f"invalid animation trigger: {trigger!r}")
if isinstance(trigger, int):
return int(trigger)
key = str(trigger).strip().lower().replace(" ", "_").replace("-", "_")
found = _ANIM_TRIGGERS.get(key)
if found is None:
choices = ", ".join(ANIM_TRIGGER_CHOICES)
raise ValueError(f"unknown animation trigger {trigger!r}; expected one of: {choices}")
return found
|
anim_trigger_name
anim_trigger_name(value: Any) -> str
Friendly name for a MsoAnimTriggerType int (e.g. 1 -> "on_click").
Source code in src/pptlive/constants.py
| def anim_trigger_name(value: Any) -> str:
"""Friendly name for a `MsoAnimTriggerType` int (e.g. 1 -> "on_click")."""
try:
return _ANIM_TRIGGER_NAMES.get(int(value), f"trigger:{int(value)}")
except (TypeError, ValueError):
return "unknown"
|
Exceptions
pptlive.PptliveError
Bases: Exception
Base class for all pptlive errors.
pptlive.PowerPointNotRunningError
Bases: PptliveError
No running PowerPoint instance is available.
pptlive.PresentationNotFoundError
PresentationNotFoundError(name: str)
Bases: PptliveError
The requested presentation is not open in PowerPoint.
Source code in src/pptlive/exceptions.py
| def __init__(self, name: str) -> None:
super().__init__(f"presentation not found: {name!r}")
self.name = name
|
pptlive.AnchorNotFoundError
AnchorNotFoundError(kind: str, name: str)
Bases: PptliveError
The requested anchor (shape / placeholder / cell / notes) does not exist.
Covers a missing slide too, via SlideNotFoundError, and a zero-match
find (raised with kind='find'). Maps to exit code 2.
Source code in src/pptlive/exceptions.py
| def __init__(self, kind: str, name: str) -> None:
super().__init__(f"{kind} not found: {name!r}")
self.kind = kind
self.name = name
|
pptlive.SlideNotFoundError
SlideNotFoundError(index: int)
Bases: AnchorNotFoundError
A slide index is out of range.
Subclass of AnchorNotFoundError so it shares the same exit code (2) and
so except AnchorNotFoundError catches both missing-slide and
missing-shape errors. Retryable after re-reading deck.slides.list().
Source code in src/pptlive/exceptions.py
| def __init__(self, index: int) -> None:
super().__init__("slide", f"slide:{index}")
self.index = index
|
pptlive.LayoutNotFoundError
LayoutNotFoundError(requested: str, available: list[str])
Bases: AnchorNotFoundError
A requested slide layout name/index doesn't exist in the deck.
Subclass of AnchorNotFoundError so it shares exit code 2. Layout names are
template-dependent (a theme can rename them), so the message lists the deck's
actual layout names and available carries them structured — an agent can
read them off stderr (or slide layouts) and retry with a real name.
Source code in src/pptlive/exceptions.py
| def __init__(self, requested: str, available: list[str]) -> None:
names = ", ".join(repr(n) for n in available) if available else "(none)"
# Build the full message first, then hand AnchorNotFoundError the bare
# name; overwrite args so the available list survives in str(exc).
super().__init__("layout", requested)
self.args = (f"layout not found: {requested!r}; available: {names}",)
self.requested = requested
self.available = available
|
pptlive.NoTextFrameError
NoTextFrameError(anchor_id: str | None = None)
Bases: PptliveError
A text operation targeted a shape with no text frame (picture, line, …).
The one genuinely new code versus wordlive (exit 6). It's common enough —
an LLM tries to set text on a decorative shape — to deserve a deterministic
exit code instead of a bare COM failure. Not retryable on the same shape:
pick a text-bearing anchor (a placeholder or text box) instead.
Source code in src/pptlive/exceptions.py
| def __init__(self, anchor_id: str | None = None) -> None:
target = f": {anchor_id}" if anchor_id else ""
super().__init__(f"shape has no text frame{target}")
self.anchor_id = anchor_id
|
pptlive.UnsavedPresentationError
UnsavedPresentationError(name: str | None = None)
Bases: PptliveError
deck.save() was called on a deck that has never been saved (no path yet).
A precondition failure, not a missing anchor, so it maps to the general exit
code (1). The 2026-06-09 spike found PowerPoint's Presentation.Save() does
not raise on a never-saved deck — on a OneDrive/SharePoint-backed build it
silently uploads to the user's default cloud location. So save() guards in
Python on an empty Presentation.Path and raises this instead of letting the
deck escape somewhere the caller didn't choose. Fix: call
save_as(path) (or export_pdf(path)) with an explicit destination first.
Source code in src/pptlive/exceptions.py
| def __init__(self, name: str | None = None) -> None:
target = f": {name}" if name else ""
super().__init__(
f"presentation has never been saved{target}; use save_as(path) to choose a destination"
)
self.name = name
|
pptlive.VideoExportError
VideoExportError(message: str, *, status: str | None = None)
Bases: PptliveError
deck.export_video() failed or timed out.
A failed encode (CreateVideoStatus == ppMediaTaskStatusFailed) or a blocking
wait=True that exceeded its timeout before PowerPoint reported Done. Not a
missing anchor or a transient busy state, so it maps to the general exit code
(1) / MCP error. Carries the last-seen status token for diagnosis; on
timeout the encode may still be running — poll deck.video_status() to check.
Source code in src/pptlive/exceptions.py
| def __init__(self, message: str, *, status: str | None = None) -> None:
super().__init__(message)
self.status = status
|
pptlive.SlideShowNotRunningError
SlideShowNotRunningError()
Bases: PptliveError
A slide-show control verb was called with no slide show running.
deck.show.next() / previous() / goto() / black() / white() /
resume() all need a running show — start one with deck.show.start()
first. This is a precondition failure, not a missing anchor, so it maps to
the general exit code (1). deck.show.state() never raises it (it reports
running: false instead), and end() on an already-stopped show is a no-op.
Source code in src/pptlive/exceptions.py
| def __init__(self) -> None:
super().__init__("no slide show is running; start one with show.start() first")
|
pptlive.AmbiguousMatchError
AmbiguousMatchError(find: str, matches: list[dict[str, Any]], *, message: str | None = None)
Bases: PptliveError
A query matched more than one candidate without a disambiguator.
Raised by find_replace (more than one fuzzy match and neither occurrence
nor replace_all given) and by ph:S:KIND placeholder resolution (a kind
that matches two equally-preferred placeholders, e.g. the two bodies of a
Two Content layout). Carries matches so callers (notably LLM drivers) can
pick a candidate and retry. Maps to exit 5 / the MCP ambiguous token.
Source code in src/pptlive/exceptions.py
| def __init__(
self, find: str, matches: list[dict[str, Any]], *, message: str | None = None
) -> None:
if message is None:
# Surface-neutral: name the MCP params AND the CLI flags, so the hint
# is actionable whether the caller drives pptlive over MCP or the CLI.
message = (
f"{len(matches)} matches for {find!r}; set occurrence=N or "
"replace_all=true (CLI: --occurrence N / --all) to disambiguate"
)
super().__init__(message)
self.find = find
self.matches = matches
|
for_placeholder
classmethod
for_placeholder(anchor_id: str, candidates: list[dict[str, Any]]) -> AmbiguousMatchError
Build the placeholder-ambiguity variant, listing the candidate shapes.
Source code in src/pptlive/exceptions.py
| @classmethod
def for_placeholder(
cls, anchor_id: str, candidates: list[dict[str, Any]]
) -> AmbiguousMatchError:
"""Build the placeholder-ambiguity variant, listing the candidate shapes."""
anchors = ", ".join(str(c["anchor_id"]) for c in candidates)
message = (
f"{anchor_id!r} matches {len(candidates)} placeholders ({anchors}); "
"target one by its shape anchor (shape:S:N) or .Name"
)
return cls(anchor_id, candidates, message=message)
|
pptlive.PowerPointBusyError
PowerPointBusyError(message: str = 'PowerPoint is busy or in a modal dialog', *, hresult: int | None = None)
Bases: PptliveError
PowerPoint rejected the RPC — typically a modal dialog has focus.
Retryable in principle; caller decides. Raised when a COM call comes back with
a known busy RPC_E_* HRESULT (see _BUSY_HRESULTS). Note: a running slide
show does not itself block edits — the 2026-05-28 spike found a text edit
succeeds mid-show — so this is no longer claimed as a slide-show symptom;
drive a live show through deck.show regardless.
Source code in src/pptlive/exceptions.py
| def __init__(
self,
message: str = "PowerPoint is busy or in a modal dialog",
*,
hresult: int | None = None,
) -> None:
super().__init__(message)
self.hresult = hresult
self.retryable = True
|
pptlive.ComError
ComError(message: str, *, hresult: int | None = None, description: str | None = None)
Bases: PptliveError
Generic wrapper for an unclassified pywintypes.com_error.
Source code in src/pptlive/exceptions.py
| def __init__(
self, message: str, *, hresult: int | None = None, description: str | None = None
) -> None:
super().__init__(message)
self.hresult = hresult
self.description = description
|