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: int = 5, 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
paces any slide without a timing. resolution is the vertical pixel
height (e.g. 720, 1080), fps frames/second, quality 0–100.
default_slide_duration is whole seconds — CreateVideo's parameter is
an integer, so a fractional value was silently truncated (2.5 → 2); it is
typed int and rejected outright rather than quietly rounded.
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: int = 5,
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`
paces any slide *without* a timing. `resolution` is the vertical pixel
height (e.g. 720, 1080), `fps` frames/second, `quality` 0–100.
`default_slide_duration` is **whole seconds** — `CreateVideo`'s parameter is
an integer, so a fractional value was silently truncated (`2.5` → `2`); it is
typed `int` and rejected outright rather than quietly rounded.
`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}")
# Annotated `int`, but the CLI/MCP/batch layers hand through whatever JSON
# carried, so check at runtime rather than trusting the annotation.
if float(default_slide_duration) != int(default_slide_duration):
raise ValueError(
"default_slide_duration must be whole seconds (CreateVideo takes an "
f"integer), got {default_slide_duration!r}"
)
if int(default_slide_duration) <= 0:
raise ValueError(
f"default_slide_duration must be positive, got {default_slide_duration!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":
# ph:S:KIND or ph:S:KIND:N (N = Nth matching placeholder in z-order)
parts = rest.split(":")
if len(parts) not in (2, 3) or not parts[1]:
raise AnchorNotFoundError("placeholder", anchor_id)
ph_kind = parts[1]
try:
s = int(parts[0])
ordinal = int(parts[2]) if len(parts) == 3 else None
except ValueError as e:
raise AnchorNotFoundError("placeholder", anchor_id) from e
if ordinal is not None and ordinal < 1:
raise AnchorNotFoundError("placeholder", anchor_id)
try:
return self.slides[s].placeholder(ph_kind, ordinal)
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
|
lint
lint(*, rules: Any = None, within: str | Slide | Anchor | None = None, profile: Any = None) -> list[dict[str, Any]]
Audit the deck for presentation-quality defects — a pure read.
Returns a severity-ranked list of findings, each {rule, kind, severity,
slide, anchor_id, shapeid, message, fixable, fix, adds_content, observed,
expected}. kind is consistency (a minority peer fighting the deck's
dominant value — one title at 36 pt when the rest are 44, a title box that
jumps between slides), structural (an objective defect — a shape hanging
off the slide), or policy (a house-style target — off unless a profile
enables it). A fixable finding carries an op-shaped fix — literally the
exec op(s) regularize would run. Findings anchor by the drift-proof
shapeid:S:ID (or para:S:N:P for one paragraph).
rules selects which rules run: None is the default set (the on-by-
default consistency + structural rules); a list of ids / tags
(["titles", "alignment"]) includes only those — and lights up off-by-
default rules like edge-alignment; {"exclude": [...]} runs the default
set minus the listed ids/tags. within scopes the emitted findings by
containment — "slide:S" / a Slide for one slide, a shape anchor for one
shape (peer rules still compare against the whole deck). profile is a
house-style config (a path to a pptlive.lint.json, an inline dict, or
None) that enables rules, supplies targets/tolerances, and can override
a severity — spec-linter.md §6.
Source code in src/pptlive/_presentation.py
| def lint(
self,
*,
rules: Any = None,
within: str | Slide | Anchor | None = None,
profile: Any = None,
) -> list[dict[str, Any]]:
"""Audit the deck for presentation-quality defects — a pure read.
Returns a severity-ranked list of findings, each `{rule, kind, severity,
slide, anchor_id, shapeid, message, fixable, fix, adds_content, observed,
expected}`. `kind` is `consistency` (a minority peer fighting the deck's
dominant value — one title at 36 pt when the rest are 44, a title box that
jumps between slides), `structural` (an objective defect — a shape hanging
off the slide), or `policy` (a house-style target — off unless a `profile`
enables it). A `fixable` finding carries an op-shaped `fix` — literally the
`exec` op(s) `regularize` would run. Findings anchor by the drift-proof
`shapeid:S:ID` (or `para:S:N:P` for one paragraph).
`rules` selects which rules run: `None` is the default set (the on-by-
default consistency + structural rules); a list of ids / tags
(`["titles", "alignment"]`) includes only those — and lights up off-by-
default rules like `edge-alignment`; `{"exclude": [...]}` runs the default
set minus the listed ids/tags. `within` scopes the *emitted* findings by
containment — `"slide:S"` / a `Slide` for one slide, a shape anchor for one
shape (peer rules still compare against the whole deck). `profile` is a
house-style config (a path to a `pptlive.lint.json`, an inline dict, or
`None`) that enables rules, supplies targets/tolerances, and can override
a severity — `spec-linter.md` §6.
"""
from . import _linting # noqa: PLC0415
return [
f.to_dict()
for f in _linting.run_lint(self, rules=rules, within=within, profile=profile)
]
|
regularize
regularize(*, rules: Any = None, within: str | Slide | Anchor | None = None, profile: Any = None, dry_run: bool = False, allow_content: bool = False) -> dict[str, Any]
Apply the fixable lint findings in one atomic-undo step. Returns
{applied, skipped, deferred, findings, dry_run[, ops_run]}.
Each fixable finding's fix op(s) run through the batch op loop inside a
single deck.edit("Regularize formatting"), so one Ctrl-Z reverts the whole
pass and the viewed slide / Selection are preserved. The fixes are
targeted and idempotent — they write the dominant peer value (or the
layout position) back as direct formatting, so running regularize twice
applies nothing the second time. rules / within / profile are as for
lint. dry_run=True plans the fixes (in findings) without writing.
Formatting/geometry fixes apply by default; a fix that adds or destroys
content is flagged adds_content and withheld into deferred unless
allow_content=True. A failing fix raises BatchOpError naming it (the
earlier fixes in the pass stay applied — one Ctrl-Z reverts them).
Source code in src/pptlive/_presentation.py
| def regularize(
self,
*,
rules: Any = None,
within: str | Slide | Anchor | None = None,
profile: Any = None,
dry_run: bool = False,
allow_content: bool = False,
) -> dict[str, Any]:
"""Apply the fixable `lint` findings in one atomic-undo step. Returns
`{applied, skipped, deferred, findings, dry_run[, ops_run]}`.
Each fixable finding's `fix` op(s) run through the batch op loop inside a
single `deck.edit("Regularize formatting")`, so one Ctrl-Z reverts the whole
pass and the viewed slide / Selection are preserved. The fixes are
**targeted and idempotent** — they write the dominant peer value (or the
layout position) back as direct formatting, so running `regularize` twice
applies nothing the second time. `rules` / `within` / `profile` are as for
`lint`. `dry_run=True` plans the fixes (in `findings`) without writing.
Formatting/geometry fixes apply by default; a fix that adds or destroys
content is flagged `adds_content` and **withheld** into `deferred` unless
`allow_content=True`. A failing fix raises `BatchOpError` naming it (the
earlier fixes in the pass stay applied — one Ctrl-Z reverts them).
"""
from . import _linting # noqa: PLC0415
return _linting.regularize(
self,
rules=rules,
within=within,
profile=profile,
dry_run=dry_run,
allow_content=allow_content,
)
|
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, title: str | None = None, body: str | list[Any] | None = None, content: dict[str, str | list[Any]] | None = None, notes: str | 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.
**The one-op slide (the authoring macro).** `title=` / `body=` / `content=`
/ `notes=` fill the new slide's placeholders in the same call, so "add a
slide, then write its title, then its bullets, then its notes" collapses
from four round-trips (and an index lookup between them) to one. A value
is a string (written like `set_text` — embed `
to start a paragraph)
or a list ofset_paragraphsitems (strings, or dicts withtextplus
any of its formatting keys — the safe bullet path).contenttakes any
placeholder address without theph:S:prefix, so the two columns of a
Two Content slide are;title=andbody=are shorthand forcontent={"title": …, "body": …}(giving the
same key both ways is aValueError).notes=sets the speaker notes.
Every key is resolved **before** anything is written, so an unknown-on-
this-layout or ambiguous KIND (ph:S:bodyon a Two Content slide — usebody:1/body:2) raises the way addressing it would, and the slide is
still added but untouched. Geometry fromplaceholders=` is applied
first, then the text (so autofit sees the final box).
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,
title: str | None = None,
body: str | list[Any] | None = None,
content: dict[str, str | list[Any]] | None = None,
notes: str | 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.
**The one-op slide (the authoring macro).** `title=` / `body=` / `content=`
/ `notes=` fill the new slide's placeholders in the same call, so "add a
slide, then write its title, then its bullets, then its notes" collapses
from four round-trips (and an index lookup between them) to one. A value
is a string (written like `set_text` — embed `\n` to start a paragraph)
or a list of `set_paragraphs` items (strings, or dicts with `text` plus
any of its formatting keys — the safe bullet path). `content` takes any
placeholder address without the `ph:S:` prefix, so the two columns of a
Two Content slide are `{"body:1": [...], "body:2": [...]}`; `title=` and
`body=` are shorthand for `content={"title": …, "body": …}` (giving the
same key both ways is a `ValueError`). `notes=` sets the speaker notes.
Every key is resolved **before** anything is written, so an unknown-on-
this-layout or ambiguous KIND (`ph:S:body` on a Two Content slide — use
`body:1`/`body:2`) raises the way addressing it would, and the slide is
still added but untouched. Geometry from `placeholders=` is applied
first, then the text (so autofit sees the final box).
"""
_validate_placeholders_arg(placeholders)
fills = _merge_content_arg(title, body, content, notes)
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)
if fills or notes is not None:
self._apply_content(new_slide, fills, notes)
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, ordinal: int | None = None) -> PlaceholderShape
Return the ph:S:KIND placeholder anchor (resolved live by kind).
KIND ∈ title, ctrtitle, subtitle, body, footer, date, slidenum. ordinal
picks the Nth matching placeholder in z-order (ph:S:KIND:N) — the way to
address one column of a Two Content / Comparison slide. Raises
AnchorNotFoundError if the slide has no such placeholder.
Source code in src/pptlive/_slides.py
| def placeholder(self, kind: str, ordinal: int | None = None) -> PlaceholderShape:
"""Return the `ph:S:KIND` placeholder anchor (resolved live by kind).
KIND ∈ title, ctrtitle, subtitle, body, footer, date, slidenum. `ordinal`
picks the Nth matching placeholder in z-order (`ph:S:KIND:N`) — the way to
address one column of a Two Content / Comparison slide. 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, ordinal=ordinal)
return PlaceholderShape(self, kind, ordinal)
|
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.
ShapeById is the handle you get back from that
shapeid — it resolves by Shape.Id on every access, so it keeps pointing at
the same shape across a delete/restack that would shift a shape:S:N index.
One capture rule to know: an empty content placeholder captures an
add_picture — the picture lands inside the placeholder (the returned shape
reports placeholder, picture within) rather than as a free shape. add_picture
still returns the right handle and re-applies any geometry you passed explicitly
(deriving a missing width/height from the image aspect), and
crop/crop_to_fit/set_picture all accept the captured picture — but if you
want a free picture, fill (or reposition) the content placeholders first, and
if you want to fill the layout slot, pass no geometry and take the frame.
A picture can be cropped. Shape.crop(*, left=, right=, top=, bottom=) is
the raw primitive — points off each named edge, 1:1 with PowerPoint's
PictureFormat.Crop* — but note that cropping shrinks the shape box rather
than letterboxing inside it. Shape.crop_to_fit(*, left=, top=, width=, height=,
fit="cover") is the verb that reconciles an aspect mismatch for you:
fit="cover" fills the box exactly and centre-crops the overflow (the
full-bleed move — and it keeps the picture on the slide, so
Slide.geometry_report()'s off_slide flag stays trustworthy), while
fit="contain" shrinks the whole picture to fit and centres it, letterboxed.
Each box argument defaults to the picture's current geometry, and any existing
crop is cleared first, so re-fitting never compounds. Both return
{crop, geometry} (plus fit), and a cropped picture carries its crop in
shape reads.
Arranging several shapes at once lives on the collection:
ShapeCollection.group(shapes) returns a new group handle (members keep their
own ids, echoed as group_item_ids) and Shape.ungroup() frees them again with
those same ids — no identity churn either way. align(shapes, how, *,
relative_to="slide") and distribute(shapes, how, *, relative_to=) do the
mechanical tidying, and add_connector(connector_type, *, begin=, end=, ...)
draws a line that glues to two shapes (so it follows them when they move);
omit begin/end and pass geometry instead for a free-floating line.
A text-bearing shape's frame is readable and settable —
text_frame_status() / set_text_frame(...), with the same knobs available on
add_textbox / add_shape at creation time. See
Anchors for what those do and the autofit footgun they exist to
solve.
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, autosize: str | int | None = None, word_wrap: bool | None = None, vertical_anchor: str | int | None = None, margins: float | None = None, margin_left: float | None = None, margin_right: float | None = None, margin_top: float | None = None, margin_bottom: 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.
autosize / word_wrap / vertical_anchor / margins (and the per-edge
margin_*) configure the text frame in the same call — see
Shape.set_text_frame, which takes the same arguments. A new text box
autosizes to its text, so a height you pass here is advisory until you
also pass autosize="none"; that plus margins=0 is the precise-layout
opener. Raises ValueError for a bad color, autosize/anchor name, or
negative margin 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,
autosize: str | int | None = None,
word_wrap: bool | None = None,
vertical_anchor: str | int | None = None,
margins: float | None = None,
margin_left: float | None = None,
margin_right: float | None = None,
margin_top: float | None = None,
margin_bottom: 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.
`autosize` / `word_wrap` / `vertical_anchor` / `margins` (and the per-edge
`margin_*`) configure the text frame in the same call — see
`Shape.set_text_frame`, which takes the same arguments. **A new text box
autosizes to its text**, so a `height` you pass here is advisory until you
also pass `autosize="none"`; that plus `margins=0` is the precise-layout
opener. Raises `ValueError` for a bad color, autosize/anchor name, or
negative margin before any COM.
"""
_precheck_fill(fill, line) # ValueError before any COM
frame_kwargs = resolve_text_frame_kwargs( # ValueError before any COM
autosize=autosize,
word_wrap=word_wrap,
vertical_anchor=vertical_anchor,
margins=margins,
margin_left=margin_left,
margin_right=margin_right,
margin_top=margin_top,
margin_bottom=margin_bottom,
)
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
)
# Configure the frame BEFORE writing the text. A new text box autofits,
# so text written first resizes the box, and a later autosize="none"
# then pins the already-wrong height — the live spike caught exactly
# that (a 40pt box arrived at 29.1pt). Frame first, text second.
if frame_kwargs:
apply_text_frame(com_shape, **frame_kwargs)
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, *, 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, autosize: str | int | None = None, word_wrap: bool | None = None, vertical_anchor: str | int | None = None, margins: float | None = None, margin_left: float | None = None, margin_right: float | None = None, margin_top: float | None = None, margin_bottom: 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.
text, if given, is written into the new shape's frame — the same
one-call convenience add_textbox has (the CLI's shape add --kind shape
--text used to be the only surface offering it).
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. autosize / word_wrap / vertical_anchor / margins (and the
per-edge margin_*) configure the shape's text frame in the same call —
see Shape.set_text_frame, which takes the same arguments. Raises
ValueError for an unknown shape name, bad color, unknown autosize/anchor
name, or negative margin (all before any COM).
Source code in src/pptlive/_shapes.py
| def add_shape(
self,
shape_type: str | int,
*,
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,
autosize: str | int | None = None,
word_wrap: bool | None = None,
vertical_anchor: str | int | None = None,
margins: float | None = None,
margin_left: float | None = None,
margin_right: float | None = None,
margin_top: float | None = None,
margin_bottom: 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.
`text`, if given, is written into the new shape's frame — the same
one-call convenience `add_textbox` has (the CLI's `shape add --kind shape
--text` used to be the only surface offering it).
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. `autosize` / `word_wrap` / `vertical_anchor` / `margins` (and the
per-edge `margin_*`) configure the shape's text frame in the same call —
see `Shape.set_text_frame`, which takes the same arguments. Raises
`ValueError` for an unknown shape name, bad color, unknown autosize/anchor
name, or negative margin (all before any COM).
"""
type_int = autoshape_type_for(shape_type) # ValueError before COM
_precheck_fill(fill, line) # ValueError before COM
frame_kwargs = resolve_text_frame_kwargs( # ValueError before COM
autosize=autosize,
word_wrap=word_wrap,
vertical_anchor=vertical_anchor,
margins=margins,
margin_left=margin_left,
margin_right=margin_right,
margin_top=margin_top,
margin_bottom=margin_bottom,
)
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)
# Frame before text — see the note in `add_textbox`.
if frame_kwargs:
apply_text_frame(com_shape, **frame_kwargs)
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_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.
Placeholder capture (issue #49): when the slide has an empty
content placeholder, PowerPoint routes the picture into it — the
returned shape is that placeholder (Type == placeholder, picture
inside), not a free picture, and PowerPoint sizes it to the placeholder
frame, discarding the requested geometry. This wrapper (a) returns the
right shape either way (resolved by stable Shape.Id), and (b)
re-applies any geometry you passed explicitly, deriving a missing
width/height from the image's aspect — so the call means what it
says even when captured. Pass no geometry to accept the placeholder
frame (the "fill the layout slot" reading). crop/crop_to_fit/
set_picture all accept a captured picture.
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.
**Placeholder capture** (issue #49): when the slide has an **empty
content placeholder**, PowerPoint routes the picture *into* it — the
returned shape is that placeholder (`Type == placeholder`, picture
inside), not a free picture, and PowerPoint sizes it to the placeholder
frame, discarding the requested geometry. This wrapper (a) returns the
**right** shape either way (resolved by stable `Shape.Id`), and (b)
re-applies any geometry you passed explicitly, deriving a missing
`width`/`height` from the image's aspect — so the call means what it
says even when captured. Pass no geometry to accept the placeholder
frame (the "fill the layout slot" reading). `crop`/`crop_to_fit`/
`set_picture` all accept a captured picture.
"""
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)
requested = {"left": left, "top": top, "width": width, "height": height}
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)
if is_placeholder(com_shape) and any(v is not None for v in requested.values()):
_apply_requested_box(com_shape, requested)
return self._resolved(com_shape)
|
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 | None = None, *, cols: int | None = None, 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).
cols= is an accepted alias for columns= (it is the CLI --cols / MCP
cols spelling, so an agent moving between front-ends never guesses
wrong); passing both is a ValueError. 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 | None = None,
*,
cols: int | None = None,
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`).
`cols=` is an accepted alias for `columns=` (it is the CLI `--cols` / MCP
`cols` spelling, so an agent moving between front-ends never guesses
wrong); passing both is a `ValueError`. 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 columns is not None and cols is not None:
raise ValueError("pass columns= or its alias cols=, not both")
if columns is None:
columns = cols
if columns is None:
raise ValueError("add_table needs a column count: columns= (alias cols=)")
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, start_s, end_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.
set_media_playback(*, muted: bool | None = None, volume: float | None = None, start: float | None = None, end: float | None = None) -> dict[str, Any]
Set playback options on this audio/video clip: mute, volume, and trim.
muted silences the clip; volume is a 0.0..1.0 fraction; start/end
are the trim window in seconds (omit an edge to keep it — e.g.
start=2 trims the lead-in but keeps the tail). At least one option must be
given. Applies to both audio and video.
Raises AnchorNotFoundError (kind "media", exit 2) if the shape holds no
media, or ValueError (before any COM) for a volume outside [0, 1] or a
trim window that isn't 0 <= start < end <= length. A mutation — wrap in
deck.edit(...) for view preservation + a one-Ctrl-Z fence. Returns the
refreshed media read.
Source code in src/pptlive/_shapes.py
| def set_media_playback(
self,
*,
muted: bool | None = None,
volume: float | None = None,
start: float | None = None,
end: float | None = None,
) -> dict[str, Any]:
"""Set playback options on this audio/video clip: mute, volume, and trim.
`muted` silences the clip; `volume` is a `0.0..1.0` fraction; `start`/`end`
are the **trim** window in *seconds* (omit an edge to keep it — e.g.
`start=2` trims the lead-in but keeps the tail). At least one option must be
given. Applies to both audio and video.
Raises `AnchorNotFoundError` (kind `"media"`, exit 2) if the shape holds no
media, or `ValueError` (before any COM) for a volume outside `[0, 1]` or a
trim window that isn't `0 <= start < end <= length`. A mutation — wrap in
`deck.edit(...)` for view preservation + a one-Ctrl-Z fence. Returns the
refreshed media read.
"""
if muted is None and volume is None and start is None and end is None:
raise ValueError("set_media_playback needs at least one of muted/volume/start/end")
with _com.translate_com_errors():
sh = self._com_shape()
if not is_media(sh):
raise AnchorNotFoundError("media", self.anchor_id)
apply_media_playback(sh, muted=muted, volume=volume, start=start, end=end)
return _media_to_dict(sh)
|
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)
anchor = _safe(lambda: vertical_anchor_name(tf.VerticalAnchor), "unknown")
return TextFrameStatus(
autosize=autosize,
word_wrap=word_wrap,
margins=margins,
overflow_risk=_overflow_risk(autosize),
vertical_anchor=anchor,
)
|
set_text_frame
set_text_frame(*, autosize: str | int | None = None, word_wrap: bool | None = None, vertical_anchor: str | int | None = None, margins: float | None = None, margin_left: float | None = None, margin_right: float | None = None, margin_top: float | None = None, margin_bottom: float | None = None) -> TextFrameStatus
Set this shape's text-frame container properties; returns the new status.
The setter half of text_frame_status() — until this existed the
diagnostic reported overflow_risk: "possible" with no supported way to
act on it, and precise layout meant dropping to the .com escape hatch
(the Claude Code review's one real gap).
autosize — "none" (or "off") pins the frame so a height you set
is honored; "shape_to_fit_text" grows the shape; "text_to_fit_shape"
shrinks the text. A new text box defaults to growing, which is why a
passed height otherwise drifts.
word_wrap — True/False.
vertical_anchor — "top" / "middle" / "bottom" (named in full to
stay distinct from this library's anchor_id sense of "anchor").
margins — all four inner margins in points at once; margin_left /
margin_right / margin_top / margin_bottom set or override one edge.
PowerPoint's 0.1 in (7.2 pt) defaults silently eat padding math, so
margins=0 is the usual precise-layout opener.
One honest caveat, pinned live by scripts/text_frame_setter_spike.py:
turning autofit back on does not retroactively re-fit an existing
frame. PowerPoint applies autofit as text is laid out, so a frame that
was written while autofit was off keeps its size even after you set
autosize="shape_to_fit_text" and rewrite the text. Set the mode you want
before the text lands (which is why add_textbox/add_shape take these
arguments and apply them first). The same spike measured the drift this
prevents: a 40 pt box asked to hold a long paragraph grew to 312.6 pt
with the default autofit, and stayed at 40 pt with autosize="none".
Raises ValueError if nothing was passed, for an unknown autosize/anchor
name, or for a negative margin — all before any COM call, so a bad
request never half-applies. Raises NoTextFrameError if the shape holds no
text frame. Wrap in deck.edit(...).
Source code in src/pptlive/_shapes.py
| def set_text_frame(
self,
*,
autosize: str | int | None = None,
word_wrap: bool | None = None,
vertical_anchor: str | int | None = None,
margins: float | None = None,
margin_left: float | None = None,
margin_right: float | None = None,
margin_top: float | None = None,
margin_bottom: float | None = None,
) -> TextFrameStatus:
"""Set this shape's text-frame container properties; returns the new status.
The setter half of `text_frame_status()` — until this existed the
diagnostic reported `overflow_risk: "possible"` with no supported way to
act on it, and precise layout meant dropping to the `.com` escape hatch
(the Claude Code review's one real gap).
- `autosize` — `"none"` (or `"off"`) pins the frame so a `height` you set
is **honored**; `"shape_to_fit_text"` grows the shape; `"text_to_fit_shape"`
shrinks the text. A new text box defaults to growing, which is why a
passed height otherwise drifts.
- `word_wrap` — `True`/`False`.
- `vertical_anchor` — `"top"` / `"middle"` / `"bottom"` (named in full to
stay distinct from this library's `anchor_id` sense of "anchor").
- `margins` — all four inner margins in points at once; `margin_left` /
`margin_right` / `margin_top` / `margin_bottom` set or override one edge.
PowerPoint's 0.1 in (7.2 pt) defaults silently eat padding math, so
`margins=0` is the usual precise-layout opener.
One honest caveat, pinned live by `scripts/text_frame_setter_spike.py`:
**turning autofit back *on* does not retroactively re-fit an existing
frame.** PowerPoint applies autofit as text is laid out, so a frame that
was written while autofit was off keeps its size even after you set
`autosize="shape_to_fit_text"` and rewrite the text. Set the mode you want
*before* the text lands (which is why `add_textbox`/`add_shape` take these
arguments and apply them first). The same spike measured the drift this
prevents: a 40 pt box asked to hold a long paragraph grew to **312.6 pt**
with the default autofit, and stayed at 40 pt with `autosize="none"`.
Raises `ValueError` if nothing was passed, for an unknown autosize/anchor
name, or for a negative margin — all **before any COM call**, so a bad
request never half-applies. Raises `NoTextFrameError` if the shape holds no
text frame. Wrap in `deck.edit(...)`.
"""
resolved = resolve_text_frame_kwargs(
autosize=autosize,
word_wrap=word_wrap,
vertical_anchor=vertical_anchor,
margins=margins,
margin_left=margin_left,
margin_right=margin_right,
margin_top=margin_top,
margin_bottom=margin_bottom,
)
if not resolved:
raise ValueError(
"set_text_frame() requires at least one of autosize=, word_wrap=, "
"vertical_anchor=, margins=, or margin_left/right/top/bottom="
)
with _com.translate_com_errors():
sh = self._com_shape()
if not has_text_frame(sh):
raise NoTextFrameError(self.anchor_id)
apply_text_frame(sh, **resolved)
return self.text_frame_status()
|
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)
|
crop
crop(*, left: float | None = None, right: float | None = None, top: float | None = None, bottom: float | None = None) -> dict[str, Any]
Trim points off this picture's edges. Returns {crop, geometry}.
The raw primitive, 1:1 with PowerPoint's PictureFormat.CropLeft/Right/
Top/Bottom — each argument is points trimmed off that edge of the
picture, and only the edges you pass are touched (so crop(left=20)
leaves an existing top crop alone). Pass 0 to un-crop an edge.
Cropping shrinks the shape box (pinned live in
scripts/crop_spike.py: a 300 pt picture cropped 75 pt became 225 pt) —
it does not keep the box and letterbox inside it. So if the picture has to
hold a layout slot, follow with resize(...), or use crop_to_fit(),
which does the arithmetic and forces the box for you.
Raises ValueError if this shape isn't a picture, for a negative crop, or
for a crop that would consume the whole picture — all before any COM
mutation. A mutation: wrap in deck.edit(...).
Source code in src/pptlive/_shapes.py
| def crop(
self,
*,
left: float | None = None,
right: float | None = None,
top: float | None = None,
bottom: float | None = None,
) -> dict[str, Any]:
"""Trim points off this **picture's** edges. Returns `{crop, geometry}`.
The raw primitive, 1:1 with PowerPoint's `PictureFormat.CropLeft/Right/
Top/Bottom` — each argument is points trimmed off that edge of the
picture, and only the edges you pass are touched (so `crop(left=20)`
leaves an existing top crop alone). Pass `0` to un-crop an edge.
**Cropping shrinks the shape box** (pinned live in
`scripts/crop_spike.py`: a 300 pt picture cropped 75 pt became 225 pt) —
it does not keep the box and letterbox inside it. So if the picture has to
hold a layout slot, follow with `resize(...)`, or use `crop_to_fit()`,
which does the arithmetic and forces the box for you.
Raises `ValueError` if this shape isn't a picture, for a negative crop, or
for a crop that would consume the whole picture — all **before any COM
mutation**. A mutation: wrap in `deck.edit(...)`.
"""
passed = {"left": left, "right": right, "top": top, "bottom": bottom}
given = {k: v for k, v in passed.items() if v is not None}
if not given:
raise ValueError("crop() requires at least one of left=, right=, top=, or bottom=")
negative = sorted(k for k, v in given.items() if float(v) < 0.0)
if negative:
raise ValueError(
f"crop() values must be >= 0 points; got a negative "
f"{', '.join(negative)} (a crop trims inward from the edge)"
)
with _com.translate_com_errors():
com_shape = self._picture_com("crop")
extent = _picture_extent(com_shape)
if extent is not None:
check_crop_extent(given, extent)
apply_crop(com_shape, **given)
return {"crop": read_crop(com_shape), "geometry": _geometry_of(com_shape)}
|
crop_to_fit
crop_to_fit(*, left: float | None = None, top: float | None = None, width: float | None = None, height: float | None = None, fit: str = 'cover') -> dict[str, Any]
Fit this picture to a box, reconciling the aspect mismatch for you.
The verb the 2026-08-03 Claude Code review asked for: a 2.08:1 panorama
into a 16:9 panel used to mean oversizing the picture and letting it hang
off the slide, which permanently poisons geometry_report's off_slide
flag — the cheapest pre-render lint stops being trustworthy. This lands the
picture inside the box instead, so that signal stays clean.
left/top/width/height are points; each defaults to the picture's
current geometry, so pic.crop_to_fit(width=480, height=270) re-fits in
place. fit picks how the aspect mismatch is resolved:
"cover" (default) — fill the box completely, centre-cropping the
overflow. The box is honored exactly; some of the picture is hidden.
"contain" — show the whole picture, shrunk to fit and centred
inside the box. Nothing is cropped; the box is honored on one axis and
the other falls short (that shortfall is the letterbox — PowerPoint has
no matte bars, so the slide background shows through).
Existing crops are cleared first, so this is idempotent: re-fitting to
a new box starts from the whole picture instead of compounding. The
picture's aspect ratio is never stretched — a picture that was previously
cropped and manually resized is fitted at the aspect it currently
displays, not its original one.
Returns {fit, crop, geometry}. Raises ValueError if this shape isn't a
picture, for an unknown fit, or for a non-positive width/height —
all before any COM mutation. A mutation: wrap in deck.edit(...).
Source code in src/pptlive/_shapes.py
| def crop_to_fit(
self,
*,
left: float | None = None,
top: float | None = None,
width: float | None = None,
height: float | None = None,
fit: str = "cover",
) -> dict[str, Any]:
"""Fit this **picture** to a box, reconciling the aspect mismatch for you.
The verb the 2026-08-03 Claude Code review asked for: a 2.08:1 panorama
into a 16:9 panel used to mean oversizing the picture and letting it hang
off the slide, which permanently poisons `geometry_report`'s `off_slide`
flag — the cheapest pre-render lint stops being trustworthy. This lands the
picture *inside* the box instead, so that signal stays clean.
`left`/`top`/`width`/`height` are points; each defaults to the picture's
current geometry, so `pic.crop_to_fit(width=480, height=270)` re-fits in
place. `fit` picks how the aspect mismatch is resolved:
- `"cover"` (default) — **fill the box completely**, centre-cropping the
overflow. The box is honored exactly; some of the picture is hidden.
- `"contain"` — **show the whole picture**, shrunk to fit and centred
inside the box. Nothing is cropped; the box is honored on one axis and
the other falls short (that shortfall is the letterbox — PowerPoint has
no matte bars, so the slide background shows through).
Existing crops are cleared first, so this is **idempotent**: re-fitting to
a new box starts from the whole picture instead of compounding. The
picture's aspect ratio is never stretched — a picture that was previously
cropped *and* manually resized is fitted at the aspect it currently
displays, not its original one.
Returns `{fit, crop, geometry}`. Raises `ValueError` if this shape isn't a
picture, for an unknown `fit`, or for a non-positive `width`/`height` —
all **before any COM mutation**. A mutation: wrap in `deck.edit(...)`.
"""
mode = fit_mode_for(fit)
for name, value in (("width", width), ("height", height)):
if value is not None and float(value) <= 0.0:
raise ValueError(f"crop_to_fit() {name} must be > 0 points, got {value!r}")
with _com.translate_com_errors():
com_shape = self._picture_com("crop_to_fit")
box_left = float(com_shape.Left) if left is None else float(left)
box_top = float(com_shape.Top) if top is None else float(top)
box_w = float(com_shape.Width) if width is None else float(width)
box_h = float(com_shape.Height) if height is None else float(height)
fit_picture(
com_shape,
left=box_left,
top=box_top,
width=box_w,
height=box_h,
fit=mode,
)
return {
"fit": mode,
"crop": read_crop(com_shape),
"geometry": _geometry_of(com_shape),
}
|
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.ShapeById
ShapeById(slide: Slide, shape_id: int)
Bases: Shape
A shape addressed by its stable Shape.Id — shapeid:S:ID.
The delete-proof handle (PPTLIVE-010): Shape.Id is assigned once and is
never renumbered or reused, so unlike shape:S:N — a z-order index that
shifts down when a lower shape is deleted or restacked — a shapeid keeps
pointing at the same shape across structural edits. Resolves by scanning the
slide's shapes for the matching .Id on every access (so z-order drift on the
host slide is handled). The id emitted in every shape listing is this
value, so an agent can build shapeid:S:ID straight from a read.
Source code in src/pptlive/_shapes.py
| def __init__(self, slide: Slide, shape_id: int) -> None:
super().__init__(slide, index=0)
self._shape_id = int(shape_id)
|
target_id
property
The Shape.Id this handle resolves by.
index
property
Current 1-based Shapes-collection index of the resolved shape.
pptlive.PlaceholderShape
PlaceholderShape(slide: Slide, ph_kind: str, ordinal: int | None = None)
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, ordinal: int | None = None) -> None:
# Validate the kind (and ordinal) eagerly so a typo fails before any COM work.
placeholder_types_for(ph_kind)
if ordinal is not None and int(ordinal) < 1:
raise ValueError(f"placeholder ordinal must be >= 1, got {ordinal}")
super().__init__(slide, index=0)
self._ph_kind = ph_kind.lower()
self._ordinal = None if ordinal is None else int(ordinal)
|
ordinal
property
The N of a ph:S:KIND:N address (None for the plain ph:S:KIND).
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.
Anchor.set_paragraphs([...]) is the one-pass authoring path: each item is a
string or a {"text", ...} dict that becomes exactly one addressable para:.
Its item keys cover both paragraph and font formatting — the full table is in
the set_paragraphs docstring below — so
following it with a per-paragraph format_text loop is pure extra COM
round-trips. An unknown key raises ValueError naming the valid ones rather than
being silently ignored. set_paragraphs(..., autosize="none") pins the frame's
autofit mode before the text lands — a content placeholder defaults to
shrink-on-overflow autofit, which rewrites explicit size values unevenly as the
paragraphs land, and turning autofit off afterwards does not restore them — so
the one kwarg replaces the disable-then-reapply dance whenever the items carry a
deliberate size hierarchy.
Shape.text_frame_status() returns a TextFrameStatus
— autosize mode / word-wrap / vertical anchor / margins / a coarse overflow_risk
flag — so an agent can see a text box heading for a "formatting spiral" before it
clips, without a render. Shape.set_text_frame(autosize=, word_wrap=,
vertical_anchor=, margins=, margin_left=, …) is the setter half: it takes the
same knobs and returns the resulting status. The two that matter most for precise
layout are autosize="none" — a new text box grows to fit its text, so a height
you set is advisory until autofit is off — and margins=0, since PowerPoint's
0.1 in (7.2 pt) inner margins silently eat padding math. add_textbox and
add_shape accept the same arguments, so a box can be created already pinned.
Hyperlinks come at two scales. Shape.set_hyperlink(...) makes the whole
shape clickable; Anchor.set_link(text=|start=/length=, url=|slide=,
screen_tip=) links a span of text inside it — and because it lives on the
Anchor base, it works identically on a shape, one Paragraph, a table Cell,
or a slide's Notes. Address the span by literal substring (an ambiguous match
raises AmbiguousMatchError rather than guessing) or by explicit 0-based
offsets. remove_link(...) clears one span or, with no span, every link in the
anchor, returning how many actually existed; links() lists them.
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], *, autosize: str | int | None = None) -> 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 with a required
"text" plus any of the keys below, and becomes exactly one addressable
para: — a newline inside an item is folded to a soft break, never a
paragraph split. Returns the new paragraphs' anchor_ids (empty for a text
anchor with no paragraph view, e.g. notes). Wrap in deck.edit(...).
autosize sets the frame's autofit mode ("none" / "shape_to_fit_text"
/ "text_to_fit_shape", like set_text_frame) before the text lands
— the ordering matters: a content placeholder defaults to shrink-on-
overflow autofit, which rewrites explicit size= values unevenly as
the paragraphs land, and turning autofit off afterwards does not
restore them (scripts/gh_feedback_spike.py, issue #53). So
autosize="none" is how a deliberate 20 pt/16 pt hierarchy survives in
one call. Only a whole-frame anchor (shape / cell / notes) can take it —
on a Paragraph it is rejected with the rest of the call.
The key list is exhaustive — anything here can be set in one pass, so
there is never a reason to follow up with a per-paragraph format_text
loop. An unknown key raises ValueError rather than being ignored.
| key |
forwarded to |
notes |
text |
— |
required; the paragraph's text |
list_type |
apply_list / remove_list |
"bulleted" / "numbered" / "none" (strips) |
bullet_char |
apply_list |
single-char str or int code point |
alignment |
format_paragraph |
"left" / "center" / "right" / "justify" |
indent_level |
format_paragraph |
1-5, the outline depth |
space_before / space_after |
format_paragraph |
points |
space_before_lines / space_after_lines |
format_paragraph |
multiples |
line_spacing |
format_paragraph |
a multiple (1.5), not points |
line_spacing_points |
format_paragraph |
exact points (24) |
force |
format_paragraph |
allow a line_spacing multiple > 5 |
bold / italic / underline |
format_text |
bool |
size |
format_text |
points |
font |
format_text |
typeface name |
color |
format_text |
font color — "#RRGGBB", (r, g, b), or an RGB int |
Source code in src/pptlive/_anchors.py
| def set_paragraphs(
self, paragraphs: list[Any], *, autosize: str | int | None = None
) -> 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 with a required
`"text"` plus any of the keys below, and becomes exactly one addressable
`para:` — a newline inside an item is folded to a soft break, never a
paragraph split. Returns the new paragraphs' `anchor_id`s (empty for a text
anchor with no paragraph view, e.g. notes). Wrap in `deck.edit(...)`.
`autosize` sets the frame's autofit mode (`"none"` / `"shape_to_fit_text"`
/ `"text_to_fit_shape"`, like `set_text_frame`) **before** the text lands
— the ordering matters: a content placeholder defaults to shrink-on-
overflow autofit, which **rewrites explicit `size=` values unevenly** as
the paragraphs land, and turning autofit off afterwards does *not*
restore them (`scripts/gh_feedback_spike.py`, issue #53). So
`autosize="none"` is how a deliberate 20 pt/16 pt hierarchy survives in
one call. Only a whole-frame anchor (shape / cell / notes) can take it —
on a `Paragraph` it is rejected with the rest of the call.
The key list is **exhaustive** — anything here can be set in one pass, so
there is never a reason to follow up with a per-paragraph `format_text`
loop. An unknown key raises `ValueError` rather than being ignored.
| key | forwarded to | notes |
| --- | --- | --- |
| `text` | — | **required**; the paragraph's text |
| `list_type` | `apply_list` / `remove_list` | `"bulleted"` / `"numbered"` / `"none"` (strips) |
| `bullet_char` | `apply_list` | single-char str or int code point |
| `alignment` | `format_paragraph` | `"left"` / `"center"` / `"right"` / `"justify"` |
| `indent_level` | `format_paragraph` | 1-5, the outline depth |
| `space_before` / `space_after` | `format_paragraph` | **points** |
| `space_before_lines` / `space_after_lines` | `format_paragraph` | **multiples** |
| `line_spacing` | `format_paragraph` | a **multiple** (1.5), not points |
| `line_spacing_points` | `format_paragraph` | **exact points** (24) |
| `force` | `format_paragraph` | allow a `line_spacing` multiple > 5 |
| `bold` / `italic` / `underline` | `format_text` | bool |
| `size` | `format_text` | points |
| `font` | `format_text` | typeface name |
| `color` | `format_text` | **font** color — `"#RRGGBB"`, `(r, g, b)`, or an RGB int |
"""
items = [_coerce_paragraph_item(p) for p in paragraphs]
if not items:
raise ValueError("set_paragraphs needs at least one paragraph")
autofit_target = None
if autosize is not None:
autosize_int = autosize_for(autosize) # ValueError before any COM
autofit_target = self._autofit_com_shape()
if autofit_target is None:
raise ValueError(
f"autosize= needs an anchor that owns a whole text frame "
f"(a shape, cell, or notes anchor), not {self.anchor_id!r}"
)
joined = "\r".join(_as_single_paragraph(it["text"]) for it in items)
with _com.translate_com_errors():
if autofit_target is not None:
# Before the text: autofit applies as text lands and is not
# undone retroactively (spike C1/C3).
apply_autosize(autofit_target, autosize_int)
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 every link overlapping the addressed span
(same addressing as set_link). A mutation: wrap in deck.edit(...).
The count is how many links were actually there — not a fixed 1 per
call. An addressed span carrying no link returns 0 and issues no
Delete, so a caller can tell "removed it" from "nothing was there"
(a typo'd text= used to report success). A link is removed whole even
when the span covers only part of it: a hyperlink is a property of a text
run, so there is no half-link to leave behind.
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 every link **overlapping** the addressed span
(same addressing as `set_link`). A mutation: wrap in `deck.edit(...)`.
The count is how many links were actually **there** — not a fixed `1` per
call. An addressed span carrying no link returns `0` and issues no
`Delete`, so a caller can tell "removed it" from "nothing was there"
(a typo'd `text=` used to report success). A link is removed whole even
when the span covers only part of it: a hyperlink is a property of a text
run, so there is no half-link to leave behind.
"""
with _com.translate_com_errors():
tr = self._text_range()
rows = links_in_range(tr)
if not (text is None and start is None and length is None):
span_start, span_len, _ = _resolve_span(tr, text, start, length)
span_end = span_start + span_len
rows = [
row
for row in rows
if int(row["start"]) < span_end
and int(row["start"]) + int(row["length"]) > span_start
]
# Delete back-to-front: a cleared link re-merges its run, shifting
# later runs — reverse order keeps each span's offsets valid. Deleting
# per *link* (rather than one Delete over the caller's whole span)
# makes the returned count provably equal to what was removed.
for row in reversed(rows):
tr.Characters(int(row["start"]) + 1, int(row["length"])).ActionSettings(
_MOUSE_CLICK
).Hyperlink.Delete()
return len(rows)
|
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())
|
format_info() -> dict[str, Any]
The format-probe read — this anchor's effective formatting, each field
a {value, baseline, override} cell (the read mirror of format_text /
format_paragraph, and the linter's substrate).
font carries name/size/bold/italic/underline/color (color
also has source/theme_color, as in font_to_dict); paragraph carries
alignment/indent_level/space_before/space_after. For a placeholder
the baseline is what the slide's CustomLayout placeholder (falling back
to the master text style at that indent level) would render, cascade names
which rung supplied it, and override is value ≠ baseline. A free textbox,
table cell, or notes body has no cascade: baseline/override are None.
mixed lists the fields that vary across the anchor's runs (their
override is None too). A read — no view move.
Source code in src/pptlive/_anchors.py
| def format_info(self) -> dict[str, Any]:
"""The format-probe read — this anchor's *effective* formatting, each field
a `{value, baseline, override}` cell (the read mirror of `format_text` /
`format_paragraph`, and the linter's substrate).
`font` carries `name`/`size`/`bold`/`italic`/`underline`/`color` (`color`
also has `source`/`theme_color`, as in `font_to_dict`); `paragraph` carries
`alignment`/`indent_level`/`space_before`/`space_after`. For a **placeholder**
the `baseline` is what the slide's `CustomLayout` placeholder (falling back
to the master text style at that indent level) would render, `cascade` names
which rung supplied it, and `override` is `value ≠ baseline`. A free textbox,
table cell, or notes body has no cascade: `baseline`/`override` are `None`.
`mixed` lists the fields that vary across the anchor's runs (their
`override` is `None` too). A read — no view move.
"""
from ._format_info import format_info # noqa: PLC0415 — avoids an import cycle
return format_info(self)
|
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], *, autosize: str | int | None = None) -> 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], *, autosize: str | int | None = None
) -> 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
|
pptlive.TextFrameStatus
dataclass
TextFrameStatus(autosize: str, word_wrap: bool, margins: dict[str, float], overflow_risk: str, vertical_anchor: str = 'unknown')
A shape's text-frame container state — the autofit/overflow diagnostics read.
Surfaces the state that makes a "formatting spiral" visible before it bites
(the gpt-5.4 review's ask). autosize is the friendly TextFrame2.AutoSize
mode; word_wrap is on/off; vertical_anchor is where text sits in the frame
("top"/"middle"/"bottom"); margins is the four inner margins in points;
overflow_risk is a coarse, mode-derived flag (PowerPoint exposes no
shrink-% on this build, so it reads the autofit mode, not a measured extent):
"possible" (autosize off — text can clip), "low" (an autofit mode is
active), or "unknown".
Every field except overflow_risk is writable via Shape.set_text_frame().
Linter / regularizer
deck.lint() (a pure read) audits the deck for presentation-quality defects —
mode-across-peers consistency (titles, body bullets, the title box) and geometry
(off-slide shapes, near-aligned edges) — and deck.regularize() applies the
fixable findings as one atomic-undo step; both are documented on
Presentation. anchor.format_info() (on
Anchor) is the probe they read. The finding shape and the
profile loader:
pptlive.Finding
dataclass
Finding(rule: str, kind: str, severity: str, slide: int | None, anchor_id: str, message: str, shapeid: str | None = None, fixable: bool = False, fix: FixOps | None = None, adds_content: bool = False, observed: str | None = None, expected: str | None = None)
One linter result. fix is present iff fixable — an op-shaped dict (or
list of them) regularize runs verbatim through the batch op loop.
slide is the 1-based slide; anchor_id the anchor the finding is about
(drift-proof shapeid:S:ID, or para:S:N:P for one paragraph); shapeid
always names the host shape's stable handle. adds_content marks a fix that
adds or destroys content (withheld by regularize unless allow_content).
pptlive.LintProfile
dataclass
LintProfile(rules: dict[str, dict[str, Any]] = dict(), house_style: dict[str, Any] = dict(), extends: str | None = None)
A resolved lint profile. rules maps a rule id to its per-rule config dict
(enabled / severity / tolerance / text / …); house_style is the
optional pinned-target block; extends the base profile name, if any. An
empty profile (Profile()) is the no-config default — every policy rule
stays off and no severity is overridden.
load
classmethod
load(source: Any) -> Profile
Resolve source into a Profile.
source may be None (→ empty profile), an existing Profile (returned
as-is), an inline mapping, or a path (str/Path) to a JSON file. A
missing/empty file is an empty profile; malformed JSON or a non-object
payload raises ValueError (bad input).
Source code in src/pptlive/_lint_profile.py
| @classmethod
def load(cls, source: Any) -> Profile:
"""Resolve `source` into a `Profile`.
`source` may be `None` (→ empty profile), an existing `Profile` (returned
as-is), an inline mapping, or a path (`str`/`Path`) to a JSON file. A
missing/empty file is an empty profile; malformed JSON or a non-object
payload raises `ValueError` (bad input)."""
if source is None:
return cls()
if isinstance(source, Profile):
return source
if isinstance(source, dict):
return cls._from_mapping(source, where="profile")
path = Path(source)
try:
raw = path.read_text(encoding="utf-8").strip()
except OSError as e:
raise ValueError(f"could not read lint profile {str(source)!r}: {e}") from e
try:
data = json.loads(raw) if raw else {}
except json.JSONDecodeError as e:
raise ValueError(f"lint profile {str(source)!r} is not valid JSON: {e}") from e
if not isinstance(data, dict):
raise ValueError(f"lint profile {str(source)!r} must be a JSON object")
return cls._from_mapping(data, where=str(source))
|
is_enabled
is_enabled(rule_id: str) -> bool | None
True/False if the profile mentions rule_id (a bare mention enables
it — enabled defaults to True), else None (the profile is silent, so
the rule's own default stands).
Source code in src/pptlive/_lint_profile.py
| def is_enabled(self, rule_id: str) -> bool | None:
"""`True`/`False` if the profile mentions `rule_id` (a bare mention enables
it — `enabled` defaults to `True`), else `None` (the profile is silent, so
the rule's own default stands)."""
cfg = self.rules.get(rule_id)
if cfg is None:
return None
return bool(cfg.get("enabled", True))
|
severity_for
severity_for(rule_id: str) -> str | None
The profile's severity override for rule_id, or None if unset.
Source code in src/pptlive/_lint_profile.py
| def severity_for(self, rule_id: str) -> str | None:
"""The profile's severity override for `rule_id`, or `None` if unset."""
cfg = self.rules.get(rule_id)
if cfg is None:
return None
sev = cfg.get("severity")
return sev if isinstance(sev, str) else None
|
config_for
config_for(rule_id: str) -> dict[str, Any]
The raw per-rule config dict (so a rule reads its own tolerance /
text / threshold), or an empty dict if the profile doesn't mention it.
Source code in src/pptlive/_lint_profile.py
| def config_for(self, rule_id: str) -> dict[str, Any]:
"""The raw per-rule config dict (so a rule reads its own `tolerance` /
`text` / `threshold`), or an empty dict if the profile doesn't mention it."""
return self.rules.get(rule_id) or {}
|
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:
# Best-effort: by this point the cells are written and
# SetSourceData has run, so a Close that fails because the
# embedded-Excel server already went away (0x800706BA
# RPC_S_SERVER_UNAVAILABLE — deliberately NOT a busy HRESULT,
# so it would surface as a hard ComError) has nothing left to
# close. Swallowing it here does not hide a failed write: the
# _reflects_data check below is the authority on whether the
# data actually landed, and raises a retryable busy if it
# didn't. Observed as an intermittent red on the chart smoke
# test; the write itself had succeeded every time.
_safe_close(wb)
# 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()
|
slide.comments is a per-slide CommentCollection
(1-based, add / list / iterate / index); deck.comments() is the deck-wide
roll-up ({total, slides: [...]}). Comments attach to a slide at an
(x, y) point (not a text range) and are threaded —
Comment.replies / Comment.reply(text) walk and extend a
thread. Adding a comment needs the signed-in Office-account identity: add
lifts it off any existing comment via the modern Comments.Add2, falling back
to the legacy identity-free Comments.Add on a comment-less deck; a reply
lifts identity off its parent. Two honest caveats: Add2 binds to the
signed-in account (a passed author / initials is best-effort — even the
legacy Add may ignore them on a modern build), and there is no
resolve/reopen verb (.Status / .Resolved aren't COM-readable on current
builds) — delete a thread once it's addressed instead.
with pl.attach() as ppt:
deck = ppt.presentations.active
with deck.edit("Leave and answer review notes"):
note = deck.slides[2].comments.add("Tighten this headline", left=100, top=80)
note.reply("Done — shortened to five words")
deck.slides[2].comments[1].delete() # resolve by deleting (takes its replies)
Comment(slide: Slide, com: Any, index: int, *, is_reply: bool = False)
A single review comment on a slide, located by its 1-based slide index.
A comment carries its author/text/timestamp/anchor and, when modern, a thread
of replies. reply(text) appends to the thread; delete() removes the
comment (and its replies). All reads are side-effect-free; reply/delete
mutate, so wrap them in deck.edit(...) (as the CLI/MCP do).
Source code in src/pptlive/_comments.py
| def __init__(self, slide: Slide, com: Any, index: int, *, is_reply: bool = False) -> None:
self._slide = slide
self._com = com
self._index = index
# PowerPoint threads are one level deep, but on a live build a *reply's*
# `.Replies` returns the sibling-reply list (which contains the reply
# itself), so recursing into it never terminates. A reply therefore
# reports no sub-thread — only a top-level comment expands its replies.
self._is_reply = is_reply
|
Raw COM Comment — escape hatch (replies, mentions, …).
1-based position in the slide's Comments collection (or thread).
The comment's timestamp as an ISO-8601 string (tz-aware), or None.
The comment anchor's x position, in points.
The comment anchor's y position, in points.
The thread's replies, in order (empty for a legacy/unthreaded comment).
Replies are themselves Comments (same fields), indexed 1-based within
the thread. PowerPoint threads are one level deep, so a reply reports no
replies of its own — and crucially a live reply's .Replies is
self-referential (it returns the sibling list, which includes the reply),
so we must not read it, or a thread walk would never terminate.
reply(text: str, *, author: str | None = None, initials: str | None = None) -> Comment
Append a threaded reply to this comment; return the new reply Comment.
Uses the modern Replies.Add2(Left, Top, Author, Initials, Text,
ProviderID, UserID), lifting the ProviderID/UserID straight off this
(the parent) comment — so the reply binds to the deck's comment identity.
The reply inherits the parent's anchor position. author/initials are
accepted for symmetry but, like Add2 on a parent, are overridden by the
identity account. Wrap in deck.edit(...) for the one-Ctrl-Z fence.
Source code in src/pptlive/_comments.py
| def reply(
self, text: str, *, author: str | None = None, initials: str | None = None
) -> Comment:
"""Append a threaded reply to this comment; return the new reply `Comment`.
Uses the modern `Replies.Add2(Left, Top, Author, Initials, Text,
ProviderID, UserID)`, lifting the `ProviderID`/`UserID` straight off this
(the parent) comment — so the reply binds to the deck's comment identity.
The reply inherits the parent's anchor position. `author`/`initials` are
accepted for symmetry but, like `Add2` on a parent, are overridden by the
identity account. Wrap in `deck.edit(...)` for the one-Ctrl-Z fence.
"""
identity = _identity_from_comment(self._com)
with _com.translate_com_errors():
left, top = float(self._com.Left), float(self._com.Top)
replies = self._com.Replies
provider, user = identity if identity is not None else ("", "")
rep = replies.Add2(left, top, author or "", initials or "", text, provider, user)
new_index = int(replies.Count)
return Comment(self._slide, rep, new_index, is_reply=True)
|
Delete this comment (and its replies). The wrapper is spent.
Source code in src/pptlive/_comments.py
| def delete(self) -> None:
"""Delete this comment (and its replies). The wrapper is spent."""
with _com.translate_com_errors():
self._com.Delete()
|
to_dict() -> dict[str, Any]
{index, author, initials, text, datetime, left, top, replies:[...]}.
Source code in src/pptlive/_comments.py
| def to_dict(self) -> dict[str, Any]:
"""`{index, author, initials, text, datetime, left, top, replies:[...]}`."""
with _com.translate_com_errors():
d: dict[str, Any] = {
"index": self._index,
"author": str(self._com.Author or ""),
"initials": str(self._com.AuthorInitials or ""),
"text": str(self._com.Text or ""),
"datetime": _dt(self._com.DateTime),
"left": float(self._com.Left),
"top": float(self._com.Top),
}
d["replies"] = [r.to_dict() for r in self.replies]
return d
|
CommentCollection(slide: Slide)
Indexable, iterable view over a slide's review comments (1-based).
Source code in src/pptlive/_comments.py
| def __init__(self, slide: Slide) -> None:
self._slide = slide
|
add(text: str, *, left: float = _DEFAULT_LEFT, top: float = _DEFAULT_TOP, author: str | None = None, initials: str | None = None) -> Comment
Add a new top-level comment to the slide; return the new Comment.
Prefers the modern threaded Comments.Add2(Left, Top, Author, Initials,
Text, ProviderID, UserID), sourcing the ProviderID/UserID from any
comment already in the deck. On a deck with no comment to borrow an
identity from, falls back to the legacy Comments.Add(Left, Top, Author,
Initials, Text) (identity-free; may produce a non-threaded comment).
author/initials are best-effort (they target the legacy path) — Add2
binds to the identity account, and on a modern Office build even the legacy
Add does, so they may be ignored (see the module docstring). left/top
are the anchor point in points. Wrap in deck.edit(...) for the one-Ctrl-Z
fence.
Source code in src/pptlive/_comments.py
| def add(
self,
text: str,
*,
left: float = _DEFAULT_LEFT,
top: float = _DEFAULT_TOP,
author: str | None = None,
initials: str | None = None,
) -> Comment:
"""Add a new top-level comment to the slide; return the new `Comment`.
Prefers the modern threaded `Comments.Add2(Left, Top, Author, Initials,
Text, ProviderID, UserID)`, sourcing the `ProviderID`/`UserID` from any
comment already in the deck. On a deck with **no** comment to borrow an
identity from, falls back to the legacy `Comments.Add(Left, Top, Author,
Initials, Text)` (identity-free; may produce a non-threaded comment).
`author`/`initials` are best-effort (they target the legacy path) — `Add2`
binds to the identity account, and on a modern Office build even the legacy
`Add` does, so they may be ignored (see the module docstring). `left`/`top`
are the anchor point in points. Wrap in `deck.edit(...)` for the one-Ctrl-Z
fence.
"""
identity = _discover_identity(self._slide._deck)
with _com.translate_com_errors():
comments = self._com_collection
if identity is not None:
provider, user = identity
com = comments.Add2(
float(left), float(top), author or "", initials or "", text, provider, user
)
else:
com = comments.Add(float(left), float(top), author or "", initials or "", text)
index = int(comments.Count)
return Comment(self._slide, com, index)
|
list() -> list[dict[str, Any]]
All top-level comments (with their reply threads) as dicts.
Source code in src/pptlive/_comments.py
| def list(self) -> list[dict[str, Any]]:
"""All top-level comments (with their reply threads) as dicts."""
return [c.to_dict() for c in self]
|
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, start_s, end_s, muted, volume, autoplay} — start_s/end_s are
the trim window in seconds) and has_media. Shape.set_media_playback(muted=,
volume=, start=, end=) sets those playback options on an existing clip. The
left/top you pass are honored even when negative (PowerPoint's raw insert
silently re-centers a negative position; the wrapper re-applies it), so parking
a hidden audio icon just off-canvas works.
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).
MsoVerticalAnchor
Bases: IntEnum
TextFrame.VerticalAnchor — where text sits vertically within its frame.
Read off the Office typelib's MsoVerticalAnchor record, not the merged
constant namespace: there msoAnchorNone (1) and msoAnchorCenter (2) — which
belong to a different Office enum entirely — collide with msoAnchorTop (1)
and msoAnchorTopBaseline (2). Transcribing from the flat namespace would have
silently aimed "top" at the wrong enum's member.
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.
0-based, unlike most Mso* enums — verified against the Office typelib
(msoAlignLefts is 0, not 1). The horizontal cmds are 0-2 and the vertical
3-5; anything that branches on the axis must split there.
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).
Values are pinned against the PowerPoint typelib
({91493440-5A91-11CF-8700-00AA0060263B}), not the Office one — msoAnimEffect*
lives there despite the mso prefix. Seven of these shipped wrong in v0.10 (e.g.
ZOOM sent 31, which is PowerPoint's GrowAndTurn); a round-trip check cannot
catch that, since the wrong id round-trips perfectly. See
test_anim_effect_matches_the_office_typelib.
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"
|
autosize_for
autosize_for(name: str | int) -> int
Friendly autosize name (or a raw MsoAutoSize int) -> the int.
"none"/"off" disables autofit (the precise-layout setting — a height you
pass is then honored), "shape_to_fit_text"/"grow" grows the shape, and
"text_to_fit_shape"/"shrink" shrinks the text. Raises ValueError
(listing the names) for an unknown name.
Source code in src/pptlive/constants.py
| def autosize_for(name: str | int) -> int:
"""Friendly autosize name (or a raw `MsoAutoSize` int) -> the int.
`"none"`/`"off"` disables autofit (the precise-layout setting — a height you
pass is then honored), `"shape_to_fit_text"`/`"grow"` grows the shape, and
`"text_to_fit_shape"`/`"shrink"` shrinks the text. Raises `ValueError`
(listing the names) for an unknown name.
"""
if isinstance(name, bool):
raise ValueError(f"invalid autosize: {name!r}")
if isinstance(name, int):
return int(name)
found = _AUTOSIZE_VALUES.get(_normalize_name(name))
if found is None:
choices = ", ".join(AUTOSIZE_CHOICES)
raise ValueError(f"unknown autosize {name!r}; expected one of: {choices}")
return int(found)
|
vertical_anchor_for
vertical_anchor_for(name: str | int) -> int
Friendly vertical-anchor name (or a raw MsoVerticalAnchor int) -> the int.
"middle" also answers to "center"/"centre"; the two baseline modes are
reachable by their full names. Raises ValueError for an unknown name.
Source code in src/pptlive/constants.py
| def vertical_anchor_for(name: str | int) -> int:
"""Friendly vertical-anchor name (or a raw `MsoVerticalAnchor` int) -> the int.
`"middle"` also answers to `"center"`/`"centre"`; the two baseline modes are
reachable by their full names. Raises `ValueError` for an unknown name.
"""
if isinstance(name, bool):
raise ValueError(f"invalid vertical anchor: {name!r}")
if isinstance(name, int):
return int(name)
found = _VERTICAL_ANCHOR_NAMES.get(_normalize_name(name))
if found is None:
choices = ", ".join(VERTICAL_ANCHOR_CHOICES)
raise ValueError(f"unknown vertical anchor {name!r}; expected one of: {choices}")
return int(found)
|
vertical_anchor_name
vertical_anchor_name(value: Any) -> str
Friendly name for a TextFrame.VerticalAnchor int (e.g. 3 -> "middle").
Source code in src/pptlive/constants.py
| def vertical_anchor_name(value: Any) -> str:
"""Friendly name for a `TextFrame.VerticalAnchor` int (e.g. 3 -> "middle")."""
try:
return _VERTICAL_ANCHOR_LABELS.get(int(value), f"anchor:{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}); "
f"pick one by z-order ordinal ({anchor_id}:1 … {anchor_id}:{len(candidates)}), "
"or target it by its shape anchor (shape:S:N) or .Name"
)
return cls(anchor_id, candidates, message=message)
|
pptlive.ReplaceVerificationError
ReplaceVerificationError(find: str, expected: str, found: str, *, anchor_id: str | None = None)
Bases: PptliveError
A find_replace target span no longer matched the located text.
The fuzzy matcher locates a span, then find_replace re-reads that span just
before overwriting it and confirms it still equals the located text (under the
same fuzzy normalization). A non-empty mismatch means the frame text shifted
between the locate and apply passes (or an offset map drifted), so we refuse
rather than overwrite the wrong characters. Maps to exit 1 / MCP error.
Source code in src/pptlive/exceptions.py
| def __init__(
self, find: str, expected: str, found: str, *, anchor_id: str | None = None
) -> None:
where = f" at {anchor_id}" if anchor_id else ""
super().__init__(
f"refusing to replace {find!r}{where}: the target span changed "
f"(expected {expected!r}, found {found!r})"
)
self.find = find
self.expected = expected
self.found = found
self.anchor_id = anchor_id
|
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
|