Skip to content

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

PowerPoint(app: Any)

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

com: Any

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

path: str

Full path, or just the name for a never-saved deck.

saved property

saved: bool

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

show: SlideShow

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

theme: Theme

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

master: Master

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() -> 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 if the deck has never been saved — it has no path yet, so call save_as with a destination first.

The never-saved guard is in Python on purpose: the 2026-06-09 spike found PowerPoint's Save() does not raise on a path-less deck — on a OneDrive/SharePoint build it silently uploads to the user's default cloud folder — so relying on COM to refuse would let the deck escape somewhere the caller didn't choose.

Source code in src/pptlive/_presentation.py
def save(self) -> str:
    """Save the deck to its existing file; return the absolute path written.

    The **explicit, never-implicit** persist verb (pptlive never auto-saves).
    Raises [`UnsavedPresentationError`][pptlive.UnsavedPresentationError] if
    the deck has never been saved — it has no path yet, so call
    [`save_as`][pptlive.Presentation.save_as] with a destination first.

    The never-saved guard is in Python on purpose: the 2026-06-09 spike found
    PowerPoint's `Save()` does *not* raise on a path-less deck — on a
    OneDrive/SharePoint build it silently uploads to the user's default cloud
    folder — so relying on COM to refuse would let the deck escape somewhere
    the caller didn't choose.
    """
    with _com.translate_com_errors():
        folder = str(self._pres.Path)
        if not folder:
            raise UnsavedPresentationError(self.name)
        self._pres.Save()
        return str(self._pres.FullName)

save_as

save_as(path: str | PathLike[str], *, fmt: str = 'pptx', overwrite: bool = False) -> str

Save the deck to path, returning the absolute path written.

fmt is "pptx" (the modern Open XML format). For PDF use export_pdf — same COM call, but a read (it doesn't rebind the working file). Rebinds the working file: after this, the open deck is the new file (its name/path follow), matching PowerPoint's own Save-As. By default refuses to clobber an existing file — pass overwrite=True to allow it. Explicit-only, like save.

Source code in src/pptlive/_presentation.py
def save_as(
    self, path: str | os.PathLike[str], *, fmt: str = "pptx", overwrite: bool = False
) -> str:
    """Save the deck to `path`, returning the absolute path written.

    `fmt` is `"pptx"` (the modern Open XML format). For PDF use
    [`export_pdf`][pptlive.Presentation.export_pdf] — same COM call, but a read
    (it doesn't rebind the working file). **Rebinds** the working file: after
    this, the open deck *is* the new file (its `name`/`path` follow), matching
    PowerPoint's own Save-As. By default refuses to clobber an existing file —
    pass `overwrite=True` to allow it. Explicit-only, like
    [`save`][pptlive.Presentation.save].
    """
    file_format, _ext = save_format_for(fmt)  # validates; rejects "pdf"
    target = Path(os.fspath(path)).expanduser()
    if not overwrite and target.exists():
        raise FileExistsError(
            f"refusing to overwrite existing file {str(target)!r}; pass overwrite=True"
        )
    abspath = str(target.resolve())
    with _com.translate_com_errors():
        self._pres.SaveAs(abspath, file_format)
    return abspath

export_pdf

export_pdf(path: str | PathLike[str]) -> str

Export the deck to a PDF at path; return the absolute path written.

The recommended "hand back a deliverable" path — a pixel-faithful render of the deck's current (unsaved) state via PowerPoint's PDF engine. A read: unlike save_as it neither rebinds the working file nor clears its dirty flag (verified 2026-06-09), so the user's .pptx is untouched and no edit() fence is needed. Overwrites an existing PDF.

Goes through Presentation.SaveAs(path, ppSaveAsPDF=32): ExportAsFixedFormat is the nominal PDF API but won't marshal under pptlive's late-bound COM dispatch, and SaveAs-to-PDF produces the same faithful PDF while behaving as a pure export.

Source code in src/pptlive/_presentation.py
def export_pdf(self, path: str | os.PathLike[str]) -> str:
    """Export the deck to a PDF at `path`; return the absolute path written.

    The recommended "hand back a deliverable" path — a pixel-faithful render
    of the deck's current (unsaved) state via PowerPoint's PDF engine. A
    **read**: unlike [`save_as`][pptlive.Presentation.save_as] it neither
    rebinds the working file nor clears its dirty flag (verified 2026-06-09),
    so the user's `.pptx` is untouched and no `edit()` fence is needed.
    Overwrites an existing PDF.

    Goes through `Presentation.SaveAs(path, ppSaveAsPDF=32)`:
    `ExportAsFixedFormat` is the nominal PDF API but won't marshal under
    pptlive's late-bound COM dispatch, and `SaveAs`-to-PDF produces the same
    faithful PDF while behaving as a pure export.
    """
    abspath = str(Path(os.fspath(path)).expanduser().resolve())
    with _com.translate_com_errors():
        self._pres.SaveAs(abspath, int(PpSaveAsFileType.PDF))
    return abspath

export_video

export_video(path: str | PathLike[str], *, use_timings: bool = True, default_slide_duration: float = 5.0, resolution: int = 720, fps: int = 30, quality: int = 85, wait: bool = True, timeout: float = 600.0) -> VideoExportResult

Export the deck to an MP4 at path (Presentation.CreateVideo).

The narrated-video deliverable — renders the deck's current (unsaved) state to video. A read: like export_pdf it neither rebinds the working file nor clears its dirty flag, so no edit() fence is needed. Overwrites an existing file.

use_timings honors per-slide auto-advance timings + narration (set pace_slide=True on add_audio/add_video); default_slide_duration (s) paces any slide without a timing. resolution is the vertical pixel height (e.g. 720, 1080), fps frames/second, quality 0–100.

CreateVideo is async. By default (wait=True) this blocks, polling CreateVideoStatus until the encode finishes (or timeout seconds elapse, raising VideoExportError), and returns a VideoExportResult with ok=True and the written path. With wait=False it returns immediately with the in-flight status (ok=False); poll video_status until it reports done. A failed encode raises VideoExportError.

Source code in src/pptlive/_presentation.py
def export_video(
    self,
    path: str | os.PathLike[str],
    *,
    use_timings: bool = True,
    default_slide_duration: float = 5.0,
    resolution: int = 720,
    fps: int = 30,
    quality: int = 85,
    wait: bool = True,
    timeout: float = 600.0,
) -> VideoExportResult:
    """Export the deck to an MP4 at `path` (`Presentation.CreateVideo`).

    The narrated-video deliverable — renders the deck's current (unsaved) state
    to video. A **read**: like [`export_pdf`][pptlive.Presentation.export_pdf]
    it neither rebinds the working file nor clears its dirty flag, so no `edit()`
    fence is needed. Overwrites an existing file.

    `use_timings` honors per-slide auto-advance timings + narration (set
    `pace_slide=True` on `add_audio`/`add_video`); `default_slide_duration` (s)
    paces any slide *without* a timing. `resolution` is the vertical pixel
    height (e.g. 720, 1080), `fps` frames/second, `quality` 0–100.

    `CreateVideo` is **async**. By default (`wait=True`) this blocks, polling
    `CreateVideoStatus` until the encode finishes (or `timeout` seconds elapse,
    raising [`VideoExportError`][pptlive.VideoExportError]), and returns a
    `VideoExportResult` with `ok=True` and the written `path`. With `wait=False`
    it returns immediately with the in-flight status (`ok=False`); poll
    [`video_status`][pptlive.Presentation.video_status] until it reports `done`.
    A failed encode raises `VideoExportError`.
    """
    abspath = str(Path(os.fspath(path)).expanduser().resolve())
    # Validate before any COM so a bad value is a clean ValueError, not a
    # confusing raw CreateVideo COM error (the project's validate-first pattern).
    if int(resolution) <= 0:
        raise ValueError(f"resolution must be a positive pixel height, got {resolution!r}")
    if int(fps) <= 0:
        raise ValueError(f"fps must be positive, got {fps!r}")
    if not 0 <= int(quality) <= 100:
        raise ValueError(f"quality must be between 0 and 100, got {quality!r}")
    with _com.translate_com_errors():
        self._pres.CreateVideo(
            abspath,
            bool(use_timings),
            int(default_slide_duration),
            int(resolution),
            int(fps),
            int(quality),
        )
    if not wait:
        code = self._video_status_code()
        return VideoExportResult(
            path=abspath,
            status=media_task_status_name(code),
            status_code=code,
            ok=False,
        )
    deadline = time.monotonic() + float(timeout)
    while True:
        code = self._video_status_code()
        if code == int(PpMediaTaskStatus.FAILED):
            raise VideoExportError(
                f"video export failed for {abspath!r}",
                status=media_task_status_name(code),
            )
        if code == int(PpMediaTaskStatus.DONE):
            # The encoder flushes the file a beat after status flips to Done.
            for _ in range(_VIDEO_FILE_WAIT_TICKS):
                if os.path.exists(abspath) and os.path.getsize(abspath) > 0:
                    break
                time.sleep(_VIDEO_POLL_INTERVAL)
            ok = os.path.exists(abspath) and os.path.getsize(abspath) > 0
            return VideoExportResult(
                path=abspath,
                status=media_task_status_name(code),
                status_code=code,
                ok=ok,
            )
        if time.monotonic() >= deadline:
            raise VideoExportError(
                f"video export timed out after {timeout:g}s "
                f"(last status: {media_task_status_name(code)}); "
                "the encode may still be running — poll video_status()",
                status=media_task_status_name(code),
            )
        time.sleep(_VIDEO_POLL_INTERVAL)

video_status

video_status() -> VideoExportResult

Poll the async video-export status (Presentation.CreateVideoStatus).

The non-blocking companion to export_video(wait=False): returns a VideoExportResult whose status is none/queued/in_progress/done/ failed and ok is True once the encode is done. path is "" here (a bare status poll doesn't know the target). none means no export has been requested in this session.

Source code in src/pptlive/_presentation.py
def video_status(self) -> VideoExportResult:
    """Poll the async video-export status (`Presentation.CreateVideoStatus`).

    The non-blocking companion to `export_video(wait=False)`: returns a
    `VideoExportResult` whose `status` is `none`/`queued`/`in_progress`/`done`/
    `failed` and `ok` is True once the encode is `done`. `path` is `""` here (a
    bare status poll doesn't know the target). `none` means no export has been
    requested in this session.
    """
    code = self._video_status_code()
    return VideoExportResult(
        path="",
        status=media_task_status_name(code),
        status_code=code,
        ok=code == int(PpMediaTaskStatus.DONE),
    )

page_setup

page_setup() -> dict[str, float]

Slide canvas dimensions in points: {width, height}.

From Presentation.PageSetup.SlideWidth/SlideHeight, so an agent can place shapes relative to the canvas. Points, never EMUs.

Source code in src/pptlive/_presentation.py
def page_setup(self) -> dict[str, float]:
    """Slide canvas dimensions in points: `{width, height}`.

    From `Presentation.PageSetup.SlideWidth`/`SlideHeight`, so an agent can
    place shapes relative to the canvas. Points, never EMUs.
    """
    with _com.translate_com_errors():
        ps = self._pres.PageSetup
        return {"width": float(ps.SlideWidth), "height": float(ps.SlideHeight)}

export_images

export_images(directory: str | PathLike[str], *, fmt: str = 'png', width: int | None = None, height: int | None = None) -> list[Path]

Render every slide into directory; return the image paths, in order.

Files are named slide-001.<ext>, slide-002.<ext>, …. A per-slide wrap of Slide.export_image (same fmt/width/height semantics) — the whole-deck "show me what I built" read.

Source code in src/pptlive/_presentation.py
def export_images(
    self,
    directory: str | os.PathLike[str],
    *,
    fmt: str = "png",
    width: int | None = None,
    height: int | None = None,
) -> list[Path]:
    """Render every slide into `directory`; return the image paths, in order.

    Files are named `slide-001.<ext>`, `slide-002.<ext>`, …. A per-slide wrap
    of `Slide.export_image` (same `fmt`/`width`/`height` semantics) — the
    whole-deck "show me what I built" read.
    """
    _filter_name, ext = image_filter_for(fmt)  # validate before any work
    out_dir = os.path.abspath(os.fspath(directory))
    os.makedirs(out_dir, exist_ok=True)
    paths: list[Path] = []
    for slide in self.slides:
        target = os.path.join(out_dir, f"slide-{slide.index:03d}.{ext}")
        paths.append(slide.export_image(target, width=width, height=height, fmt=fmt))
    return paths

snapshot

snapshot(out: str | PathLike[str] | None = None, *, slides: int | tuple[int, int] | None = None, fmt: str = 'png', max_dim: int | None = None, width: int | None = None, height: int | None = None) -> list[Snapshot]

Render slides to PNG so a vision model can see the whole deck cheaply.

The token-cost-aware read: max_dim caps each slide's long edge in pixels (only ever lowering resolution), so the per-slide cost is a predictable budget — and because every slide shares one geometry, that budget is uniform across the deck. The lever for "render the whole deck and check my styling landed" without full-resolution token bloat (~1000 stays legible). max_dim=None renders at native size.

For an exact per-slide pixel size instead of the cap, pass width/ height (one is enough — the other follows the slide aspect ratio); this overrides the cap, and passing it together with max_dim is a ValueError (two conflicting size levers). Note PowerPoint exposes no JPEG-quality knob on Slide.Export, so pixel dimensions — max_dim or width/height — are the only render-cost levers (which is fine: a vision model bills on pixel area, not encoder quality).

slides selects what to render: None (default) every slide, an int a single 1-based slide, a (start, end) tuple an inclusive span. Returns one Snapshot per slide (so a single slide is a one-element list); read .image for the bytes.

If out is given the image is also written there: a single slide to out itself, multiple slides alongside it as <stem>-s<N><suffix>. fmt is a friendly image token (png/jpg/…). A read — the export renders the current unsaved state and leaves the viewed slide and Selection untouched (no edit() fence needed).

Source code in src/pptlive/_presentation.py
def snapshot(
    self,
    out: str | os.PathLike[str] | None = None,
    *,
    slides: int | tuple[int, int] | None = None,
    fmt: str = "png",
    max_dim: int | None = None,
    width: int | None = None,
    height: int | None = None,
) -> list[Snapshot]:
    """Render slides to PNG so a vision model can *see* the whole deck cheaply.

    The token-cost-aware read: `max_dim` caps each slide's **long edge** in
    pixels (only ever lowering resolution), so the per-slide cost is a
    predictable budget — and because every slide shares one geometry, that
    budget is uniform across the deck. The lever for "render the whole deck
    and check my styling landed" without full-resolution token bloat
    (~1000 stays legible). `max_dim=None` renders at native size.

    For an *exact* per-slide pixel size instead of the cap, pass `width`/
    `height` (one is enough — the other follows the slide aspect ratio); this
    overrides the cap, and passing it together with `max_dim` is a `ValueError`
    (two conflicting size levers). Note PowerPoint exposes **no** JPEG-quality
    knob on `Slide.Export`, so pixel dimensions — `max_dim` or `width`/`height`
    — are the only render-cost levers (which is fine: a vision model bills on
    pixel area, not encoder quality).

    `slides` selects what to render: `None` (default) every slide, an `int`
    a single 1-based slide, a `(start, end)` tuple an inclusive span. Returns
    one [`Snapshot`][pptlive.Snapshot] per slide (so a single slide is a
    one-element list); read `.image` for the bytes.

    If `out` is given the image is also written there: a single slide to `out`
    itself, multiple slides alongside it as `<stem>-s<N><suffix>`. `fmt` is a
    friendly image token (`png`/`jpg`/…). A read — the export renders the
    current unsaved state and leaves the viewed slide and Selection untouched
    (no `edit()` fence needed).
    """
    return _snapshot.snapshot(
        self, out, slides=slides, fmt=fmt, max_dim=max_dim, width=width, height=height
    )

selection

selection() -> SelectionInfo

The user's current selection, resolved to anchors — a polite read.

Snapshots ActiveWindow.Selection without changing it (the complement to status, which reports the viewed slide): the selected shapes as shape:S:N, or a text caret as para:S:N:P. To act on the selection, resolve anchor_by_id("here:") — the explicit opt-in (pptlive never targets the live Selection unless asked).

Source code in src/pptlive/_presentation.py
def selection(self) -> SelectionInfo:
    """The user's current selection, resolved to anchors — a polite read.

    Snapshots `ActiveWindow.Selection` without changing it (the complement to
    `status`, which reports the viewed slide): the selected shapes as
    `shape:S:N`, or a text caret as `para:S:N:P`. To *act on* the selection,
    resolve `anchor_by_id("here:")` — the explicit opt-in (pptlive never
    targets the live Selection unless asked).
    """
    return read_selection(self._ppt)

layouts

layouts() -> list[dict[str, Any]]

The deck's slide layouts: [{index, name}, ...] (1-based index).

Sourced from SlideMaster.CustomLayouts. Lists the exact names that slides.add(layout=…) / Slide.set_layout(…) accept on this template — useful when a theme has renamed the standard Office layouts.

Source code in src/pptlive/_presentation.py
def layouts(self) -> list[dict[str, Any]]:
    """The deck's slide layouts: `[{index, name}, ...]` (1-based index).

    Sourced from `SlideMaster.CustomLayouts`. Lists the exact names that
    `slides.add(layout=…)` / `Slide.set_layout(…)` accept on this template —
    useful when a theme has renamed the standard Office layouts.
    """
    out: list[dict[str, Any]] = []
    with _com.translate_com_errors():
        for idx, layout in enumerate(self._custom_layouts(), start=1):
            out.append({"index": idx, "name": str(layout.Name)})
    return out

outline

outline() -> list[dict[str, Any]]

The Outline-view analog: [{slide, title, bullets:[...]}, ...].

bullets are the non-empty paragraphs of the slide's body placeholder (when it has one); slides without a body — or whose body placeholder holds a chart/table/picture rather than text — just carry their title.

Source code in src/pptlive/_presentation.py
def outline(self) -> list[dict[str, Any]]:
    """The Outline-view analog: `[{slide, title, bullets:[...]}, ...]`.

    `bullets` are the non-empty paragraphs of the slide's body placeholder
    (when it has one); slides without a body — or whose body placeholder
    holds a chart/table/picture rather than text — just carry their title.
    """
    out: list[dict[str, Any]] = []
    for slide in self.slides:
        bullets: list[str] = []
        try:
            body = slide.placeholder("body")
            bullets = _paragraphs(body.text)
        except (AnchorNotFoundError, NoTextFrameError):
            # No body placeholder, or it's been filled with a chart/table/
            # picture (no text frame): the slide simply contributes no bullets.
            bullets = []
        out.append({"slide": slide.index, "title": slide.title, "bullets": bullets})
    return out

comments

comments() -> dict[str, Any]

Every review comment across the deck — the deck-wide roll-up.

{total, slides: [{slide, comments:[{index, author, initials, text, datetime, left, top, replies:[...]}, ...]}, ...]}. Only slides that carry at least one comment appear; total counts top-level comments (not replies). A read — side-effect-free and polite (no view move). For one slide, use deck.slides[S].comments.list() (the comments:S read).

Source code in src/pptlive/_presentation.py
def comments(self) -> dict[str, Any]:
    """Every review comment across the deck — the deck-wide roll-up.

    `{total, slides: [{slide, comments:[{index, author, initials, text,
    datetime, left, top, replies:[...]}, ...]}, ...]}`. Only slides that carry
    at least one comment appear; `total` counts top-level comments (not
    replies). A read — side-effect-free and polite (no view move). For one
    slide, use `deck.slides[S].comments.list()` (the `comments:S` read).
    """
    slides_out: list[dict[str, Any]] = []
    total = 0
    for slide in self.slides:
        items = slide.comments.list()
        if items:
            slides_out.append({"slide": slide.index, "comments": items})
            total += len(items)
    return {"total": total, "slides": slides_out}

anchor_by_id

anchor_by_id(anchor_id: str) -> Anchor

Resolve an anchor_id string into an Anchor.

Recognised
  • shape:S:N — Nth shape (1-based z-order) on slide S
  • shapeid:S:ID— shape with stable Shape.Id ID on slide S — the delete-proof handle (the id in every shape listing)
  • ph:S:KIND — placeholder of semantic KIND on slide S (title/ctrtitle/subtitle/body/footer/date/slidenum)
  • para:S:N:P — paragraph P of shape N on slide S (v0.3)
  • cell:S:N:R:C— cell (row R, col C) of the table in shape N on slide S (v0.5)
  • notes:S — speaker-notes body of slide S
  • here: — whatever the user has selected right now (v0.4): the selected shape, or the paragraph holding the text caret

slide:S is a container, not a text anchor — use deck.slides[S].

Raises AnchorNotFoundError for unknown schemes or missing anchors (SlideNotFoundError, a subclass, for an out-of-range slide).

Source code in src/pptlive/_presentation.py
def anchor_by_id(self, anchor_id: str) -> Anchor:
    """Resolve an `anchor_id` string into an `Anchor`.

    Recognised:
      - `shape:S:N`   — Nth shape (1-based z-order) on slide S
      - `shapeid:S:ID`— shape with stable `Shape.Id` ID on slide S — the
                        delete-proof handle (the `id` in every shape listing)
      - `ph:S:KIND`   — placeholder of semantic KIND on slide S
                        (title/ctrtitle/subtitle/body/footer/date/slidenum)
      - `para:S:N:P`  — paragraph P of shape N on slide S (v0.3)
      - `cell:S:N:R:C`— cell (row R, col C) of the table in shape N on slide S (v0.5)
      - `notes:S`     — speaker-notes body of slide S
      - `here:`       — whatever the user has selected right now (v0.4): the
                        selected shape, or the paragraph holding the text caret

    `slide:S` is a *container*, not a text anchor — use `deck.slides[S]`.

    Raises `AnchorNotFoundError` for unknown schemes or missing anchors
    (`SlideNotFoundError`, a subclass, for an out-of-range slide).
    """
    if not isinstance(anchor_id, str) or ":" not in anchor_id:
        raise AnchorNotFoundError("anchor", str(anchor_id))
    kind, _, rest = anchor_id.partition(":")

    if kind == "shape":
        parts = rest.split(":")
        if len(parts) != 2:
            raise AnchorNotFoundError("shape", anchor_id)
        try:
            s, n = int(parts[0]), int(parts[1])
        except ValueError as e:
            raise AnchorNotFoundError("shape", anchor_id) from e
        return self.slides[s].shapes[n]

    if kind == "shapeid":
        parts = rest.split(":")
        if len(parts) != 2:
            raise AnchorNotFoundError("shape", anchor_id)
        try:
            s, sid = int(parts[0]), int(parts[1])
        except ValueError as e:
            raise AnchorNotFoundError("shape", anchor_id) from e
        return self.slides[s].shapes.by_id(sid)

    if kind == "para":
        parts = rest.split(":")
        if len(parts) != 3:
            raise AnchorNotFoundError("paragraph", anchor_id)
        try:
            s, n, p = int(parts[0]), int(parts[1]), int(parts[2])
        except ValueError as e:
            raise AnchorNotFoundError("paragraph", anchor_id) from e
        return self.slides[s].shapes[n].paragraph(p)

    if kind == "cell":
        parts = rest.split(":")
        if len(parts) != 4:
            raise AnchorNotFoundError("table cell", anchor_id)
        try:
            s, n, r, c = (int(x) for x in parts)
        except ValueError as e:
            raise AnchorNotFoundError("table cell", anchor_id) from e
        return self.slides[s].shapes[n].table.cell(r, c)

    if kind == "ph":
        s_str, sep, ph_kind = rest.partition(":")
        if not sep or not ph_kind:
            raise AnchorNotFoundError("placeholder", anchor_id)
        try:
            s = int(s_str)
        except ValueError as e:
            raise AnchorNotFoundError("placeholder", anchor_id) from e
        try:
            return self.slides[s].placeholder(ph_kind)
        except ValueError as e:
            # Unknown KIND — surface as a missing anchor (exit 2), not a crash.
            raise AnchorNotFoundError("placeholder", anchor_id) from e

    if kind == "notes":
        try:
            s = int(rest)
        except ValueError as e:
            raise AnchorNotFoundError("notes", anchor_id) from e
        return self.slides[s].notes

    if kind == "here":
        # The explicit opt-in to target the live Selection (politeness:
        # never otherwise). Resolves live to a Shape or Paragraph.
        info = read_selection(self._ppt)
        if (
            info.type == "text"
            and info.slide is not None
            and info.shape_index is not None
            and info.paragraph is not None
        ):
            return self.slides[info.slide].shapes[info.shape_index].paragraph(info.paragraph)
        if info.type == "shapes" and info.slide is not None and info.shape_index is not None:
            return self.slides[info.slide].shapes[info.shape_index]
        raise AnchorNotFoundError("selection", anchor_id)

    raise AnchorNotFoundError("anchor", anchor_id)

find

find(text: str, *, scope: str | Slide | Anchor | None = None) -> list[dict[str, Any]]

Locate every fuzzy occurrence of text across the deck (or scope).

Search is a traversal of slides × shapes → text frames, table cells, and speaker notes (there is no deck-wide character stream). Matching is whitespace- and Unicode-normalized (NFKC, smart quotes, dashes, NBSP), so text an LLM re-typed off a slide still matches the original glyphs.

Returns {anchor_id, start, length, text, context} per hit, in document order: anchor_id is a resolvable text anchor (shape:S:N, cell:S:N:R:C, or notes:S), start is the 0-based char offset of the match within that anchor's text, text is the actual original substring, and context is a short surrounding snippet. The offsets are live — use them before further edits shift the text.

A read — polite (no view move). scope restricts the search: a slide:S string (or a Slide) limits it to one slide; any text-anchor id (or an Anchor) limits it to that shape / cell / notes frame; None (default) searches the whole deck.

Source code in src/pptlive/_presentation.py
def find(self, text: str, *, scope: str | Slide | Anchor | None = None) -> list[dict[str, Any]]:
    """Locate every fuzzy occurrence of `text` across the deck (or `scope`).

    Search is a traversal of slides × shapes → text frames, table cells, and
    speaker notes (there is no deck-wide character stream). Matching is
    whitespace- and Unicode-normalized (NFKC, smart quotes, dashes, NBSP), so
    text an LLM re-typed off a slide still matches the original glyphs.

    Returns `{anchor_id, start, length, text, context}` per hit, in document
    order: `anchor_id` is a resolvable text anchor (`shape:S:N`,
    `cell:S:N:R:C`, or `notes:S`), `start` is the 0-based char offset of the
    match within that anchor's text, `text` is the actual original substring,
    and `context` is a short surrounding snippet. The offsets are live — use
    them before further edits shift the text.

    A read — polite (no view move). `scope` restricts the search: a `slide:S`
    string (or a `Slide`) limits it to one slide; any text-anchor id (or an
    `Anchor`) limits it to that shape / cell / notes frame; `None` (default)
    searches the whole deck.
    """
    units = self._search_units(scope)
    results: list[dict[str, Any]] = []
    with _com.translate_com_errors():
        for anchor_id, tr in units:
            haystack = str(tr.Text or "")
            for m in _findreplace.find_matches(haystack, text):
                results.append(
                    {
                        "anchor_id": anchor_id,
                        "start": m.start,
                        "length": m.end - m.start,
                        "text": m.text,
                        "context": _match_context(haystack, m.start, m.end),
                    }
                )
    return results

find_replace

find_replace(find: str, replace: str, *, scope: str | Slide | Anchor | None = None, all: bool = False, occurrence: int | None = None) -> list[dict[str, Any]]

Fuzzy plain-text replace across the deck (or scope). See find.

Parameters:

Name Type Description Default
find str

the text to look for (fuzzy-matched, same semantics as find).

required
replace str

the replacement text.

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

replace every match.

False
occurrence int | None

1-based index — replace only the Nth match (document order).

None

Raises:

Type Description
AnchorNotFoundError

zero matches (kind='find', exit 2), or an out-of-range occurrence.

AmbiguousMatchError

more than one match and neither all nor occurrence was given (exit 5).

Returns the replacements applied, each {anchor_id, start, length, text} in their pre-replacement coordinates. Only the matched span is rewritten (via TextRange.Characters), so the rest of each frame keeps its run formatting. Wrap the call in deck.edit(...) for view preservation and a one-Ctrl-Z fence.

Source code in src/pptlive/_presentation.py
def find_replace(
    self,
    find: str,
    replace: str,
    *,
    scope: str | Slide | Anchor | None = None,
    all: bool = False,
    occurrence: int | None = None,
) -> list[dict[str, Any]]:
    """Fuzzy plain-text replace across the deck (or `scope`). See `find`.

    Args:
        find: the text to look for (fuzzy-matched, same semantics as `find`).
        replace: the replacement text.
        scope: restrict the search — a `slide:S` / anchor-id string, a `Slide`
            or `Anchor`, or `None` for the whole deck.
        all: replace every match.
        occurrence: 1-based index — replace only the Nth match (document order).

    Raises:
        AnchorNotFoundError: zero matches (`kind='find'`, exit 2), or an
            out-of-range `occurrence`.
        AmbiguousMatchError: more than one match and neither `all` nor
            `occurrence` was given (exit 5).

    Returns the replacements applied, each `{anchor_id, start, length, text}`
    in their pre-replacement coordinates. Only the matched span is rewritten
    (via `TextRange.Characters`), so the rest of each frame keeps its run
    formatting. Wrap the call in `deck.edit(...)` for view preservation and a
    one-Ctrl-Z fence.
    """
    units = self._search_units(scope)
    # (anchor_id, COM TextRange, start, end, original_text) in document order.
    matches: list[tuple[str, Any, int, int, str]] = []
    with _com.translate_com_errors():
        for anchor_id, tr in units:
            haystack = str(tr.Text or "")
            for m in _findreplace.find_matches(haystack, find):
                matches.append((anchor_id, tr, m.start, m.end, m.text))

    if not matches:
        raise AnchorNotFoundError("find", find)

    if occurrence is not None:
        if occurrence < 1 or occurrence > len(matches):
            raise AnchorNotFoundError("find", f"{find} (occurrence {occurrence})")
        to_apply = [matches[occurrence - 1]]
    elif all:
        to_apply = list(matches)
    elif len(matches) == 1:
        to_apply = matches
    else:
        raise AmbiguousMatchError(
            find,
            [
                {"anchor_id": a, "start": s, "length": e - s, "text": t}
                for (a, _tr, s, e, t) in matches
            ],
        )

    # Apply in reverse document order so an earlier match's offsets stay valid
    # after a later match in the *same* frame is rewritten to a different
    # length (matches in different frames are independent).
    applied: list[dict[str, Any]] = []
    with _com.translate_com_errors():
        for anchor_id, tr, start, end, text in reversed(to_apply):
            span = tr.Characters(start + 1, end - start)
            # Re-verify the span still holds the located text before
            # overwriting. An empty resolved text can't be checked (the fake
            # COM, or a genuinely empty range) — proceed; a non-empty mismatch
            # means the frame drifted between locate and apply, so refuse
            # rather than corrupt the wrong characters.
            resolved = str(span.Text or "")
            if resolved and not _findreplace.normalized_equal(resolved, text):
                raise ReplaceVerificationError(find, text, resolved, anchor_id=anchor_id)
            span.Text = replace
            applied.append(
                {"anchor_id": anchor_id, "start": start, "length": end - start, "text": text}
            )
    applied.reverse()  # report in document order
    return applied

edit

edit(label: str) -> Iterator[EditScope]

Open an atomic-undo + view/Selection-preserving edit scope.

Mutations inside the block collapse into a single Ctrl-Z: the scope fences a fresh undo entry with Application.StartNewUndoEntry() on entry and PowerPoint groups the rest. On clean exit the user is returned to the slide and selection they had. See EditScope for the mechanism and its caveats (no explicit "end" fence; always wrap mutations in edit).

with deck.edit("Revise agenda slide"):
    deck.anchor_by_id("ph:2:title").set_text("Agenda")
    deck.anchor_by_id("ph:2:body").set_text("Intro\nDemo\nQ&A")
Source code in src/pptlive/_presentation.py
@contextmanager
def edit(self, label: str) -> Iterator[EditScope]:
    """Open an atomic-undo + view/Selection-preserving edit scope.

    Mutations inside the block collapse into a **single Ctrl-Z**: the scope
    fences a fresh undo entry with `Application.StartNewUndoEntry()` on entry
    and PowerPoint groups the rest. On clean exit the user is returned to the
    slide and selection they had. See `EditScope` for the mechanism and its
    caveats (no explicit "end" fence; always wrap mutations in `edit`).

    ```
    with deck.edit("Revise agenda slide"):
        deck.anchor_by_id("ph:2:title").set_text("Agenda")
        deck.anchor_by_id("ph:2:body").set_text("Intro\\nDemo\\nQ&A")
    ```
    """
    scope = EditScope(self._ppt, label)
    with scope:
        yield scope

go_to

go_to(target: Anchor | Slide, *, select: bool = True) -> None

Move the user's view to a slide or shape (deliberate, opt-in jump).

Rare — most operations preserve the view. target is an Anchor (resolved via anchor_by_id) or a Slide. Jumps the active window to that slide and, when select is True and the target is a shape, selects it. This intentionally moves the user, so inside a deck.edit(...) block call scope.allow_view_move() first or it'll be snapped back on exit.

Source code in src/pptlive/_presentation.py
def go_to(self, target: Anchor | Slide, *, select: bool = True) -> None:
    """Move the user's view to a slide or shape (deliberate, opt-in jump).

    Rare — most operations preserve the view. `target` is an `Anchor`
    (resolved via `anchor_by_id`) or a `Slide`. Jumps the active window to
    that slide and, when `select` is True and the target is a shape, selects
    it. This intentionally moves the user, so inside a `deck.edit(...)` block
    call `scope.allow_view_move()` first or it'll be snapped back on exit.
    """
    slide: Slide | None
    shape: Shape | None
    if isinstance(target, Slide):
        slide, shape = target, None
    else:
        slide = getattr(target, "slide", None)
        shape = target if isinstance(target, Shape) else None
        if slide is None:
            raise TypeError(f"cannot go_to {type(target).__name__}: no slide context")

    assert slide is not None  # narrowed by the branches above
    slide_index = slide.index
    with _com.translate_com_errors():
        win = self._ppt.com.ActiveWindow
        win.View.GotoSlide(int(slide_index))
        if select and shape is not None:
            try:
                with _com.translate_com_errors():
                    shape.com.Select()
            except PowerPointBusyError:
                # The goto landed; only the shape-select failed. A busy here
                # is still retryable — surface it rather than silently
                # leaving nothing selected.
                raise
            except Exception as exc:  # noqa: BLE001
                # The goto succeeded; the select is best-effort (e.g. the
                # shape can't be selected in the current view). Don't fail the
                # whole jump, but trace it so a swallowed select isn't fully
                # invisible under PPTLIVE_VIEW_DEBUG.
                _dbg(f"go_to: shape.Select() failed (goto still landed): {exc!r}")

pptlive.PresentationCollection

PresentationCollection(ppt: PowerPoint)

Indexable view over open presentations.

Source code in src/pptlive/_presentation.py
def __init__(self, ppt: PowerPoint) -> None:
    self._ppt = ppt

list

list() -> list[dict[str, Any]]

[{name, path, saved, is_active}, ...] — used by pptlive status.

saved is Presentation.Saved (False = unsaved changes / never saved), so an agent sees dirty state before deciding to save(); path is the full path (just the name for a never-saved deck).

Source code in src/pptlive/_presentation.py
def list(self) -> list[dict[str, Any]]:
    """`[{name, path, saved, is_active}, ...]` — used by `pptlive status`.

    `saved` is `Presentation.Saved` (False = unsaved changes / never saved),
    so an agent sees dirty state before deciding to `save()`; `path` is the
    full path (just the name for a never-saved deck).
    """
    out: list[dict[str, Any]] = []
    with _com.translate_com_errors():
        active_name: str | None
        try:
            with _com.translate_com_errors():
                active_name = str(self._ppt.com.ActivePresentation.Name)
        except PowerPointBusyError:
            raise  # busy is exit 3; don't mask it as "no active deck"
        except Exception:
            active_name = None
        for pres in self._com_collection:
            name = str(pres.Name)
            out.append(
                {
                    "name": name,
                    "path": str(pres.FullName),
                    "saved": bool(pres.Saved),
                    "is_active": name == active_name,
                }
            )
    return out

Slides

Presentation.slides is a SlideCollection. Index a slide by 1-based position (deck.slides[3]), iterate it, or use the lifecycle verbs (add / delete / duplicate / move_to / set_layout). A Slide exposes shapes, placeholder(kind), notes, read(), and export_image(...).

pptlive.SlideCollection

SlideCollection(deck: Presentation)

Indexable, iterable view over a presentation's slides (1-based).

Source code in src/pptlive/_slides.py
def __init__(self, deck: Presentation) -> None:
    self._deck = deck

add

add(layout: str | int | None = None, index: int | None = None, *, placeholders: dict[str, dict[str, float]] | None = None) -> Slide

Insert a new slide and return it (v0.1; wrap in deck.edit(...)).

layout is a friendly name or 1-based layout index (default title_and_content); index is the 1-based insertion position (default: appended to the end). Prefers the modern Slides.AddSlide(Index, CustomLayout), falling back to legacy Slides.Add only on a deck that exposes no custom layouts. Raises LayoutNotFoundError for an unknown layout and SlideNotFoundError for an out-of-range insertion position (1..count+1).

placeholders repositions the layout's placeholders right after creation — {KIND: {left, top, width, height}} in points, any subset of the four keys per KIND — so "a content slide with the body on the left half" is one op instead of an add-then-resize fix-up. KIND is the same semantic name ph:S:KIND uses (title/body/…); an unknown-on-this-layout or ambiguous KIND raises (AnchorNotFoundError / AmbiguousMatchError) the same way addressing it would. Pair with Slide.geometry_report() to size the boxes.

Source code in src/pptlive/_slides.py
def add(
    self,
    layout: str | int | None = None,
    index: int | None = None,
    *,
    placeholders: dict[str, dict[str, float]] | None = None,
) -> Slide:
    """Insert a new slide and return it (v0.1; wrap in `deck.edit(...)`).

    `layout` is a friendly name or 1-based layout index (default
    `title_and_content`); `index` is the 1-based insertion position
    (default: appended to the end). Prefers the modern
    `Slides.AddSlide(Index, CustomLayout)`, falling back to legacy
    `Slides.Add` only on a deck that exposes no custom layouts. Raises
    `LayoutNotFoundError` for an unknown layout and `SlideNotFoundError`
    for an out-of-range insertion position (1..count+1).

    `placeholders` repositions the layout's placeholders right after creation
    — `{KIND: {left, top, width, height}}` in points, any subset of the four
    keys per KIND — so "a content slide with the body on the left half" is one
    op instead of an add-then-resize fix-up. KIND is the same semantic name
    `ph:S:KIND` uses (`title`/`body`/…); an unknown-on-this-layout or ambiguous
    KIND raises (`AnchorNotFoundError` / `AmbiguousMatchError`) the same way
    addressing it would. Pair with `Slide.geometry_report()` to size the boxes.
    """
    _validate_placeholders_arg(placeholders)
    count = len(self)
    if index is None:
        target = count + 1
    elif isinstance(index, bool) or not isinstance(index, int):
        raise TypeError(f"index must be int, got {type(index).__name__}")
    elif index < 1 or index > count + 1:
        raise SlideNotFoundError(index)
    else:
        target = index
    with _com.translate_com_errors():
        custom = self._deck._resolve_layout(layout)
        if custom is not None:
            new_com = self._com_collection.AddSlide(target, custom)
        else:
            new_com = self._com_collection.Add(target, int(DEFAULT_LEGACY_LAYOUT))
    new_slide = Slide(self._deck, new_com)
    if placeholders:
        self._apply_placeholder_geometry(new_slide.index, placeholders)
    return new_slide

list

list() -> list[dict[str, Any]]

[{index, id, layout, title, shape_count, has_notes}, ...].

Source code in src/pptlive/_slides.py
def list(self) -> list[dict[str, Any]]:
    """`[{index, id, layout, title, shape_count, has_notes}, ...]`."""
    out: list[dict[str, Any]] = []
    for slide in self:
        out.append(
            {
                "index": slide.index,
                "id": slide.id,
                "layout": slide.layout_name,
                "title": slide.title,
                "shape_count": len(slide.shapes),
                "has_notes": slide.has_notes(),
            }
        )
    return out

pptlive.Slide

Slide(deck: Presentation, slide_com: Any)

Wraps a PowerPoint Slide COM object.

Source code in src/pptlive/_slides.py
def __init__(self, deck: Presentation, slide_com: Any) -> None:
    self._deck = deck
    self._slide = slide_com

index property

index: int

1-based position in the deck (SlideIndex). Shifts when slides move.

id property

id: int

Stable SlideID — survives reordering, unlike index.

deck property

deck: Presentation

The owning Presentation (e.g. for resolving sibling slides).

notes property

notes: Notes

The speaker-notes anchor (notes:S).

comments property

comments: CommentCollection

The slide's review comments (comments:S) — read + add/reply/delete.

headers_footers property

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

layout_name: str | None

The slide's custom-layout name (e.g. "Title and Content"), or None.

title property

title: str | None

Text of the slide's title placeholder, or None if it has no title.

add_audio

add_audio(path: str | PathLike[str], **kwargs: Any) -> Shape

Insert an audio clip on this slide — self.shapes.add_audio(...).

The "narrate this slide" convenience; see ShapeCollection.add_audio for the keyword options (autoplay/hide_icon/pace_slide/link/…).

Source code in src/pptlive/_slides.py
def add_audio(self, path: str | os.PathLike[str], **kwargs: Any) -> Shape:
    """Insert an audio clip on this slide — `self.shapes.add_audio(...)`.

    The "narrate this slide" convenience; see
    [`ShapeCollection.add_audio`][pptlive._shapes.ShapeCollection.add_audio]
    for the keyword options (`autoplay`/`hide_icon`/`pace_slide`/`link`/…).
    """
    return self.shapes.add_audio(path, **kwargs)

add_video

add_video(path: str | PathLike[str], **kwargs: Any) -> Shape

Insert a video clip on this slide — self.shapes.add_video(...).

See ShapeCollection.add_video for the keyword options.

Source code in src/pptlive/_slides.py
def add_video(self, path: str | os.PathLike[str], **kwargs: Any) -> Shape:
    """Insert a video clip on this slide — `self.shapes.add_video(...)`.

    See [`ShapeCollection.add_video`][pptlive._shapes.ShapeCollection.add_video]
    for the keyword options.
    """
    return self.shapes.add_video(path, **kwargs)

placeholder

placeholder(kind: str) -> PlaceholderShape

Return the ph:S:KIND placeholder anchor (resolved live by kind).

KIND ∈ title, ctrtitle, subtitle, body, footer, date, slidenum. Raises AnchorNotFoundError if the slide has no such placeholder.

Source code in src/pptlive/_slides.py
def placeholder(self, kind: str) -> PlaceholderShape:
    """Return the `ph:S:KIND` placeholder anchor (resolved live by kind).

    KIND ∈ title, ctrtitle, subtitle, body, footer, date, slidenum. Raises
    `AnchorNotFoundError` if the slide has no such placeholder.
    """
    # Resolve once now so a missing placeholder fails fast with a clean error;
    # the returned anchor still re-resolves live on each use.
    with _com.translate_com_errors():
        self._find_placeholder(kind)
    return PlaceholderShape(self, kind)

has_notes

has_notes() -> bool

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

read() -> dict[str, Any]

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() -> None

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() -> 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.

Source code in src/pptlive/_slides.py
def duplicate(self) -> Slide:
    """Duplicate this slide; return the copy (inserted immediately after).

    Wraps `Slide.Duplicate`, which yields a one-item `SlideRange`. The copy
    gets a fresh `SlideID`; everything after the original shifts down by one.
    """
    with _com.translate_com_errors():
        new_range = self._slide.Duplicate()
        new_com = new_range(1)
    return Slide(self._deck, new_com)

move_to

move_to(index: int) -> Slide

Move this slide to 1-based position index (Slide.MoveTo); return self.

The wrapper keeps pointing at the same slide, which now reports the new index. Raises SlideNotFoundError if index is out of range (1..count).

Source code in src/pptlive/_slides.py
def move_to(self, index: int) -> Slide:
    """Move this slide to 1-based position `index` (`Slide.MoveTo`); return self.

    The wrapper keeps pointing at the same slide, which now reports the new
    `index`. Raises `SlideNotFoundError` if `index` is out of range (1..count).
    """
    if isinstance(index, bool) or not isinstance(index, int):
        raise TypeError(f"index must be int, got {type(index).__name__}")
    count = len(self._deck.slides)
    if index < 1 or index > count:
        raise SlideNotFoundError(index)
    with _com.translate_com_errors():
        self._slide.MoveTo(index)
    return self

set_layout

set_layout(layout: str | int) -> Slide

Re-apply a slide layout by friendly name or 1-based index; return self.

Resolves layout to a CustomLayout (see Presentation._resolve_layout) and assigns Slide.CustomLayout. Raises LayoutNotFoundError (listing the deck's layout names) for an unknown layout.

Source code in src/pptlive/_slides.py
def set_layout(self, layout: str | int) -> Slide:
    """Re-apply a slide layout by friendly name or 1-based index; return self.

    Resolves `layout` to a `CustomLayout` (see `Presentation._resolve_layout`)
    and assigns `Slide.CustomLayout`. Raises `LayoutNotFoundError` (listing
    the deck's layout names) for an unknown layout.
    """
    custom = self._deck._resolve_layout(layout)
    if custom is None:
        raise LayoutNotFoundError(str(layout), [])
    with _com.translate_com_errors():
        self._slide.CustomLayout = custom
    return self

set_transition

set_transition(effect: str | int | None = None, *, duration: float | None = None, advance_after: float | None = None, advance_on_click: bool | None = None) -> dict[str, Any]

Set the slide's entrance transition; return the resulting transition dict.

effect is a friendly PpEntryEffect name ("fade", "cut", "cover_left", … — see constants.ENTRY_EFFECT_CHOICES) or a raw int. duration is the transition animation length in seconds. advance_after is the auto-advance delay in seconds — passing it sets both AdvanceOnTime=msoTrue and AdvanceTime (the spike confirmed both are needed); pass 0 to keep the timing but require a click. advance_on_click toggles click-to-advance independently. Only the kwargs passed are written.

Raises ValueError (before any COM) for an unknown effect name or if nothing is passed. A mutation: wrap in deck.edit(...).

Source code in src/pptlive/_slides.py
def set_transition(
    self,
    effect: str | int | None = None,
    *,
    duration: float | None = None,
    advance_after: float | None = None,
    advance_on_click: bool | None = None,
) -> dict[str, Any]:
    """Set the slide's entrance transition; return the resulting transition dict.

    `effect` is a friendly `PpEntryEffect` name (`"fade"`, `"cut"`,
    `"cover_left"`, … — see `constants.ENTRY_EFFECT_CHOICES`) or a raw int.
    `duration` is the transition animation length in seconds. `advance_after`
    is the auto-advance delay in seconds — passing it sets **both**
    `AdvanceOnTime=msoTrue` and `AdvanceTime` (the spike confirmed both are
    needed); pass `0` to keep the timing but require a click. `advance_on_click`
    toggles click-to-advance independently. Only the kwargs passed are written.

    Raises `ValueError` (before any COM) for an unknown effect name or if
    nothing is passed. A mutation: wrap in `deck.edit(...)`.
    """
    if (
        effect is None
        and duration is None
        and advance_after is None
        and advance_on_click is None
    ):
        raise ValueError(
            "set_transition() requires at least one of effect, duration, "
            "advance_after, or advance_on_click"
        )
    effect_int = entry_effect_for(effect) if effect is not None else None  # ValueError first
    # Durations are seconds; reject negatives before any COM (PowerPoint would
    # either clamp silently or emit an opaque COM error). advance_after=0 is
    # valid — it keeps the timing but requires a click.
    if duration is not None and float(duration) < 0:
        raise ValueError(f"duration must be non-negative seconds, got {duration!r}")
    if advance_after is not None and float(advance_after) < 0:
        raise ValueError(f"advance_after must be non-negative seconds, got {advance_after!r}")
    with _com.translate_com_errors():
        t = self._slide.SlideShowTransition
        if effect_int is not None:
            t.EntryEffect = effect_int
        if duration is not None:
            t.Duration = float(duration)
        if advance_after is not None:
            # Auto-advance needs the flag AND the seconds (spike finding).
            t.AdvanceOnTime = int(MsoTriState.TRUE)
            t.AdvanceTime = float(advance_after)
        if advance_on_click is not None:
            t.AdvanceOnClick = (
                int(MsoTriState.TRUE) if advance_on_click else int(MsoTriState.FALSE)
            )
    return self.transition()

set_background

set_background(color: str | int | tuple[int, int, int]) -> dict[str, Any]

Give the slide its own solid background color; return the background dict.

The per-slide override of the deck-wide master background (deck.master. set_background). color is "#RRGGBB", an (r, g, b) tuple, or a raw RGB int. Sets FollowMasterBackground=msoFalse then a solid fill of that color. Raises ValueError for a bad color (before any COM). Revert with follow_master_background(). A mutation: wrap in deck.edit(...).

Source code in src/pptlive/_slides.py
def set_background(self, color: str | int | tuple[int, int, int]) -> dict[str, Any]:
    """Give the slide its own solid background color; return the background dict.

    The per-slide override of the deck-wide master background (`deck.master.
    set_background`). `color` is `"#RRGGBB"`, an `(r, g, b)` tuple, or a raw RGB
    int. Sets `FollowMasterBackground=msoFalse` then a solid fill of that color.
    Raises `ValueError` for a bad color (before any COM). Revert with
    `follow_master_background()`. A mutation: wrap in `deck.edit(...)`.
    """
    rgb = parse_color(color)  # ValueError before any COM
    with _com.translate_com_errors():
        self._slide.FollowMasterBackground = int(MsoTriState.FALSE)
        fill = self._slide.Background.Fill
        fill.Solid()
        fill.ForeColor.RGB = rgb
    return self.background()

follow_master_background

follow_master_background() -> dict[str, Any]

Drop any per-slide background override and inherit the master's again.

Sets FollowMasterBackground=msoTrue (the spike-verified revert). A mutation: wrap in deck.edit(...).

Source code in src/pptlive/_slides.py
def follow_master_background(self) -> dict[str, Any]:
    """Drop any per-slide background override and inherit the master's again.

    Sets `FollowMasterBackground=msoTrue` (the spike-verified revert). A
    mutation: wrap in `deck.edit(...)`.
    """
    with _com.translate_com_errors():
        self._slide.FollowMasterBackground = int(MsoTriState.TRUE)
    return self.background()

Shapes & geometry

Slide.shapes is a ShapeCollection — index by 1-based z-order (shapes[2]) or by name (shapes["Title 1"]), and create with add_textbox / add_shape / add_picture / add_table / add_chart. A Shape is an Anchor when it has a text frame (so it inherits text / set_text / format_text / the list and paragraph verbs), and always carries geometry (move, resize, geometry()) in points, plus alt_text / set_alt_text and per-shape export_image(...). Every shape also carries a stable shapeid (shapeid:S:ID, the delete-proof handle) alongside its z-order anchor_id.

A shape can also animate: Shape.animate(effect="fade", *, trigger="on_click", duration=None, delay=None, exit=False) appends a whole-shape entrance (or, with exit=True, exit) effect to the slide's main sequence, and Shape.clear_animations() removes just that shape's effects. Read them back per slide with Slide.animations() (ordered rows, each mapped to its target by shapeid) and wipe a whole slide with Slide.clear_animations(). A slide's spatial layout is available without a render via Slide.geometry_report() (slide size + per-shape boxes + overlaps + off-slide flags).

pptlive.ShapeCollection

ShapeCollection(slide: Slide)

Indexable, iterable view over a slide's shapes.

Index by 1-based z-order (slide.shapes[2]) or by name (slide.shapes["Title 1"]). Iteration yields a Shape per shape in z-order. list() emits the structured dict used by slide read.

Source code in src/pptlive/_shapes.py
def __init__(self, slide: Slide) -> None:
    self._slide = slide

by_id

by_id(shape_id: int) -> ShapeById

A shape addressed by its stable Shape.Id (shapeid:S:ID) — delete-proof.

Unlike slide.shapes[N] (z-order index) or slide.shapes["Name"], the id survives a delete/restack that renumbers indices (PPTLIVE-010). Verifies the id exists now (raising AnchorNotFoundError if not), then returns a ShapeById that re-resolves live on each use.

Source code in src/pptlive/_shapes.py
def by_id(self, shape_id: int) -> ShapeById:
    """A shape addressed by its stable `Shape.Id` (`shapeid:S:ID`) — delete-proof.

    Unlike `slide.shapes[N]` (z-order index) or `slide.shapes["Name"]`, the id
    survives a delete/restack that renumbers indices (PPTLIVE-010). Verifies
    the id exists now (raising `AnchorNotFoundError` if not), then returns a
    `ShapeById` that re-resolves live on each use.
    """
    handle = ShapeById(self._slide, shape_id)
    with _com.translate_com_errors():
        handle._com_shape()  # eager existence check (clean exit-2 if absent)
    return handle

group

group(shapes: Sequence[Shape]) -> ShapeById

Group two or more shapes into a single group shape; return its handle.

Shapes.Range([...]).Group() combines the shapes; the new group gets a fresh Shape.Id (so this returns a ShapeById for it), while the members keep their own ids inside group.GroupItems (read back in the group's group_item_ids) — verified in scripts/arrangement_spike.py. Reverse with Shape.ungroup.

Raises ValueError (before any COM) for fewer than two shapes; an unknown member raises AnchorNotFoundError. A mutation: wrap in deck.edit(...).

Source code in src/pptlive/_shapes.py
def group(self, shapes: Sequence[Shape]) -> ShapeById:
    """Group two or more shapes into a single group shape; return its handle.

    `Shapes.Range([...]).Group()` combines the shapes; the new group gets a
    **fresh `Shape.Id`** (so this returns a `ShapeById` for it), while the
    members keep their own ids inside `group.GroupItems` (read back in the
    group's `group_item_ids`) — verified in `scripts/arrangement_spike.py`.
    Reverse with `Shape.ungroup`.

    Raises `ValueError` (before any COM) for fewer than two shapes; an unknown
    member raises `AnchorNotFoundError`. A mutation: wrap in `deck.edit(...)`.
    """
    members = list(shapes)
    if len(members) < 2:
        raise ValueError(f"group() needs at least 2 shapes, got {len(members)}")
    with _com.translate_com_errors():
        indices = self._range_indices(members)
        group = self._com_collection.Range(indices).Group()
        group_id = int(group.Id)
    return ShapeById(self._slide, group_id)

align

align(shapes: Sequence[Shape], how: str | int, *, relative_to: str | int | bool = 'slide') -> None

Align a set of shapes to a common edge / center.

how is "left"/"center"/"right" (horizontal) or "top"/"middle"/ "bottom" (vertical) — see constants.ALIGN_CHOICES. relative_to is "slide" (align against the slide, the default) or "selection" (align the shapes to one another). Shapes.Range([...]).Align(cmd, RelativeTo).

Raises ValueError (before any COM) for an unknown how/relative_to, an empty set, or a selection-relative align of fewer than two shapes. A mutation: wrap in deck.edit(...).

Source code in src/pptlive/_shapes.py
def align(
    self, shapes: Sequence[Shape], how: str | int, *, relative_to: str | int | bool = "slide"
) -> None:
    """Align a set of shapes to a common edge / center.

    `how` is `"left"`/`"center"`/`"right"` (horizontal) or `"top"`/`"middle"`/
    `"bottom"` (vertical) — see `constants.ALIGN_CHOICES`. `relative_to` is
    `"slide"` (align against the slide, the default) or `"selection"` (align
    the shapes to one another). `Shapes.Range([...]).Align(cmd, RelativeTo)`.

    Raises `ValueError` (before any COM) for an unknown `how`/`relative_to`, an
    empty set, or a selection-relative align of fewer than two shapes. A
    mutation: wrap in `deck.edit(...)`.
    """
    cmd = align_cmd_for(how)  # ValueError before any COM
    rel = relative_to_for(relative_to)
    members = list(shapes)
    if not members:
        raise ValueError("align() needs at least one shape")
    if rel == int(MsoTriState.FALSE) and len(members) < 2:
        raise ValueError("aligning relative to the selection needs at least 2 shapes")
    with _com.translate_com_errors():
        indices = self._range_indices(members)
        self._com_collection.Range(indices).Align(cmd, rel)

distribute

distribute(shapes: Sequence[Shape], how: str | int, *, relative_to: str | int | bool = 'slide') -> None

Space a set of shapes evenly on one axis.

how is "horizontal" or "vertical" (constants.DISTRIBUTE_CHOICES); relative_to is "slide" (default) or "selection". Shapes.Range([...]).Distribute(cmd, RelativeTo). Distributing is only meaningful for three or more shapes (the two outermost pin the span and the rest are evenly spaced between them).

Raises ValueError (before any COM) for an unknown how/relative_to or fewer than three shapes. A mutation: wrap in deck.edit(...).

Source code in src/pptlive/_shapes.py
def distribute(
    self, shapes: Sequence[Shape], how: str | int, *, relative_to: str | int | bool = "slide"
) -> None:
    """Space a set of shapes evenly on one axis.

    `how` is `"horizontal"` or `"vertical"` (`constants.DISTRIBUTE_CHOICES`);
    `relative_to` is `"slide"` (default) or `"selection"`.
    `Shapes.Range([...]).Distribute(cmd, RelativeTo)`. Distributing is only
    meaningful for three or more shapes (the two outermost pin the span and the
    rest are evenly spaced between them).

    Raises `ValueError` (before any COM) for an unknown `how`/`relative_to` or
    fewer than three shapes. A mutation: wrap in `deck.edit(...)`.
    """
    cmd = distribute_cmd_for(how)  # ValueError before any COM
    rel = relative_to_for(relative_to)
    members = list(shapes)
    if len(members) < 3:
        raise ValueError(f"distribute() needs at least 3 shapes, got {len(members)}")
    with _com.translate_com_errors():
        indices = self._range_indices(members)
        self._com_collection.Range(indices).Distribute(cmd, rel)

add_connector

add_connector(connector_type: str | int = 'straight', *, begin: Shape | None = None, end: Shape | None = None, begin_site: int = 1, end_site: int = 1, left: float | None = None, top: float | None = None, width: float | None = None, height: float | None = None) -> ShapeById

Add a connector line and return its handle (Shapes.AddConnector).

Two forms:

  • Attached (primary): pass begin= and end= shape handles to glue the two ends to those shapes (via ConnectorFormat.BeginConnect / EndConnect + RerouteConnections), so the line follows them when they move. begin_site / end_site request a 1-based connection site on each shape, but are advisoryRerouteConnections() re-chooses the shortest sites (spike finding), and the resulting glue reads back under the shape's connector field.
  • Geometry: omit both shapes and pass explicit left/top/width/ height (points) to draw a free-floating connector across that box.

connector_type is "straight"/"elbow"/"curved" (constants.CONNECTOR_CHOICES). Raises ValueError (before any COM) for a bad type, an out-of-range site, or an incomplete spec (need both begin and end, or full explicit geometry). A mutation: wrap in deck.edit(...).

Source code in src/pptlive/_shapes.py
def add_connector(
    self,
    connector_type: str | int = "straight",
    *,
    begin: Shape | None = None,
    end: Shape | None = None,
    begin_site: int = 1,
    end_site: int = 1,
    left: float | None = None,
    top: float | None = None,
    width: float | None = None,
    height: float | None = None,
) -> ShapeById:
    """Add a connector line and return its handle (`Shapes.AddConnector`).

    Two forms:

    - **Attached (primary):** pass `begin=` and `end=` shape handles to glue the
      two ends to those shapes (via `ConnectorFormat.BeginConnect` /
      `EndConnect` + `RerouteConnections`), so the line follows them when they
      move. `begin_site` / `end_site` request a 1-based connection site on each
      shape, but are **advisory** — `RerouteConnections()` re-chooses the
      shortest sites (spike finding), and the resulting glue reads back under
      the shape's `connector` field.
    - **Geometry:** omit both shapes and pass explicit `left`/`top`/`width`/
      `height` (points) to draw a free-floating connector across that box.

    `connector_type` is `"straight"`/`"elbow"`/`"curved"`
    (`constants.CONNECTOR_CHOICES`). Raises `ValueError` (before any COM) for a
    bad type, an out-of-range site, or an incomplete spec (need both `begin` and
    `end`, or full explicit geometry). A mutation: wrap in `deck.edit(...)`.
    """
    type_int = connector_type_for(connector_type)  # ValueError before any COM
    if (begin is None) != (end is None):
        raise ValueError("add_connector() needs BOTH begin= and end= (or neither)")
    attaching = begin is not None and end is not None
    if not attaching and None in (left, top, width, height):
        raise ValueError(
            "add_connector() needs begin= and end= shapes, or explicit "
            "left/top/width/height geometry"
        )
    with _com.translate_com_errors():
        if attaching:
            assert begin is not None and end is not None  # narrowed above
            begin_com = begin._com_shape()
            end_com = end._com_shape()
            _check_site(begin_com, begin_site, "begin_site")
            _check_site(end_com, end_site, "end_site")
            x1, y1 = _center_of(begin_com)
            x2, y2 = _center_of(end_com)
        else:
            x1, y1 = float(left), float(top)  # type: ignore[arg-type]
            x2 = float(left) + float(width)  # type: ignore[arg-type]
            y2 = float(top) + float(height)  # type: ignore[arg-type]
        conn = self._com_collection.AddConnector(type_int, x1, y1, x2, y2)
        conn_id = int(conn.Id)
        if attaching:
            cf = conn.ConnectorFormat
            cf.BeginConnect(begin_com, int(begin_site))
            cf.EndConnect(end_com, int(end_site))
            conn.RerouteConnections()
    return ShapeById(self._slide, conn_id)

add_textbox

add_textbox(text: str = '', *, left: float | None = None, top: float | None = None, width: float | None = None, height: float | None = None, fill: str | int | tuple[int, int, int] | None = None, line: str | int | tuple[int, int, int] | None = None, line_width: float | None = None) -> Shape

Add a horizontal text box and return it (Shapes.AddTextbox).

Geometry is in points; omitted values default to a 4×1 in box near the top-left. text, if given, is written into the new frame. fill/line set a solid fill / border color (or "none" for transparent / no border) and line_width the border weight in points — see Shape.set_fill. A text box defaults to no fill and no line. Raises ValueError for a bad color before any COM.

Source code in src/pptlive/_shapes.py
def add_textbox(
    self,
    text: str = "",
    *,
    left: float | None = None,
    top: float | None = None,
    width: float | None = None,
    height: float | None = None,
    fill: str | int | tuple[int, int, int] | None = None,
    line: str | int | tuple[int, int, int] | None = None,
    line_width: float | None = None,
) -> Shape:
    """Add a horizontal text box and return it (`Shapes.AddTextbox`).

    Geometry is in points; omitted values default to a 4×1 in box near the
    top-left. `text`, if given, is written into the new frame. `fill`/`line`
    set a solid fill / border color (or `"none"` for transparent / no border)
    and `line_width` the border weight in points — see `Shape.set_fill`. A
    text box defaults to no fill and no line. Raises `ValueError` for a bad
    color before any COM.
    """
    _precheck_fill(fill, line)  # ValueError before any COM
    left = _DEFAULT_LEFT if left is None else float(left)
    top = _DEFAULT_TOP if top is None else float(top)
    width = _DEFAULT_TEXTBOX_WIDTH if width is None else float(width)
    height = _DEFAULT_TEXTBOX_HEIGHT if height is None else float(height)
    with _com.translate_com_errors():
        com_shape = self._com_collection.AddTextbox(
            int(MsoTextOrientation.HORIZONTAL), left, top, width, height
        )
        if text:
            com_shape.TextFrame.TextRange.Text = text
        if fill is not None or line is not None or line_width is not None:
            apply_shape_fill(com_shape, fill=fill, line=line, line_width=line_width)
        return self._added()

add_shape

add_shape(shape_type: str | int, *, left: float | None = None, top: float | None = None, width: float | None = None, height: float | None = None, fill: str | int | tuple[int, int, int] | None = None, line: str | int | tuple[int, int, int] | None = None, line_width: float | None = None) -> Shape

Add an autoshape and return it (Shapes.AddShape).

shape_type is a friendly name ("rectangle", "oval", "arrow", …; see constants.AUTOSHAPE_CHOICES) or a raw MsoAutoShapeType int. Geometry is in points; omitted values default to a 2×2 in box near the top-left. fill/line set a solid fill / border color (or "none" for transparent / no border) and line_width the border weight in points — see Shape.set_fill; omitted, the shape takes the theme's default accent fill. Raises ValueError for an unknown shape name or bad color (before any COM).

Source code in src/pptlive/_shapes.py
def add_shape(
    self,
    shape_type: str | int,
    *,
    left: float | None = None,
    top: float | None = None,
    width: float | None = None,
    height: float | None = None,
    fill: str | int | tuple[int, int, int] | None = None,
    line: str | int | tuple[int, int, int] | None = None,
    line_width: float | None = None,
) -> Shape:
    """Add an autoshape and return it (`Shapes.AddShape`).

    `shape_type` is a friendly name (`"rectangle"`, `"oval"`, `"arrow"`, …;
    see `constants.AUTOSHAPE_CHOICES`) or a raw `MsoAutoShapeType` int.
    Geometry is in points; omitted values default to a 2×2 in box near the
    top-left. `fill`/`line` set a solid fill / border color (or `"none"` for
    transparent / no border) and `line_width` the border weight in points —
    see `Shape.set_fill`; omitted, the shape takes the theme's default accent
    fill. Raises `ValueError` for an unknown shape name or bad color (before
    any COM).
    """
    type_int = autoshape_type_for(shape_type)  # ValueError before COM
    _precheck_fill(fill, line)  # ValueError before COM
    left = _DEFAULT_LEFT if left is None else float(left)
    top = _DEFAULT_TOP if top is None else float(top)
    width = _DEFAULT_SHAPE_WIDTH if width is None else float(width)
    height = _DEFAULT_SHAPE_HEIGHT if height is None else float(height)
    with _com.translate_com_errors():
        com_shape = self._com_collection.AddShape(type_int, left, top, width, height)
        if fill is not None or line is not None or line_width is not None:
            apply_shape_fill(com_shape, fill=fill, line=line, line_width=line_width)
        return self._added()

add_picture

add_picture(path: str | PathLike[str], *, left: float | None = None, top: float | None = None, width: float | None = None, height: float | None = None, alt_text: str | None = None) -> Shape

Embed a picture from a local file and return it (Shapes.AddPicture).

The image is embedded, never linked (so the deck stays portable). left/top default to the top-left; omitted width/height keep the image's native size. alt_text, if given, sets the picture's alternative text — a drift-proof, LLM-readable re-identification handle (see Shape.alt_text). Raises FileNotFoundError if path doesn't exist.

Source code in src/pptlive/_shapes.py
def add_picture(
    self,
    path: str | os.PathLike[str],
    *,
    left: float | None = None,
    top: float | None = None,
    width: float | None = None,
    height: float | None = None,
    alt_text: str | None = None,
) -> Shape:
    """Embed a picture from a local file and return it (`Shapes.AddPicture`).

    The image is **embedded**, never linked (so the deck stays portable).
    `left`/`top` default to the top-left; omitted `width`/`height` keep the
    image's native size. `alt_text`, if given, sets the picture's
    alternative text — a drift-proof, LLM-readable re-identification handle
    (see `Shape.alt_text`). Raises `FileNotFoundError` if `path` doesn't exist.
    """
    fs_path = os.fspath(path)
    if not os.path.isfile(fs_path):
        raise FileNotFoundError(f"picture not found: {fs_path}")
    abs_path = os.path.abspath(fs_path)
    left = _DEFAULT_LEFT if left is None else float(left)
    top = _DEFAULT_TOP if top is None else float(top)
    with _com.translate_com_errors():
        com_shape = self._com_collection.AddPicture(
            abs_path,
            int(MsoTriState.FALSE),  # LinkToFile: no
            int(MsoTriState.TRUE),  # SaveWithDocument: yes (embed)
            left,
            top,
            -1.0 if width is None else float(width),  # -1 = native size
            -1.0 if height is None else float(height),
        )
        if alt_text is not None:
            com_shape.AlternativeText = str(alt_text)
        return self._added()

add_audio

add_audio(path: str | PathLike[str], *, left: float | None = None, top: float | None = None, width: float | None = None, height: float | None = None, link: bool = False, autoplay: bool = True, hide_icon: bool = True, pace_slide: bool = True, alt_text: str | None = None) -> Shape

Insert an audio clip and return its Shape (Shapes.AddMediaObject2).

The narration path: the clip is embedded (set link=True to keep it on disk and shrink the deck). autoplay plays it on slide entry, hide_icon hides the speaker icon while it isn't playing, and pace_slide sets the slide to auto-advance to the clip's length (so an exported video paces itself to the narration). Geometry is in points; omitted values default to a small icon box near the top-left. The shape's .has_media is True. Raises FileNotFoundError if path doesn't exist (before any COM). Wrap in deck.edit(...) for the one-Ctrl-Z fence.

Source code in src/pptlive/_shapes.py
def add_audio(
    self,
    path: str | os.PathLike[str],
    *,
    left: float | None = None,
    top: float | None = None,
    width: float | None = None,
    height: float | None = None,
    link: bool = False,
    autoplay: bool = True,
    hide_icon: bool = True,
    pace_slide: bool = True,
    alt_text: str | None = None,
) -> Shape:
    """Insert an audio clip and return its `Shape` (`Shapes.AddMediaObject2`).

    The narration path: the clip is **embedded** (set `link=True` to keep it on
    disk and shrink the deck). `autoplay` plays it on slide entry, `hide_icon`
    hides the speaker icon while it isn't playing, and `pace_slide` sets the
    slide to auto-advance to the clip's length (so an exported video paces
    itself to the narration). Geometry is in points; omitted values default to a
    small icon box near the top-left. The shape's `.has_media` is True. Raises
    `FileNotFoundError` if `path` doesn't exist (before any COM). Wrap in
    `deck.edit(...)` for the one-Ctrl-Z fence.
    """
    return self._add_media(
        path,
        left=left,
        top=top,
        width=width,
        height=height,
        default_width=_DEFAULT_AUDIO_WIDTH,
        default_height=_DEFAULT_AUDIO_HEIGHT,
        link=link,
        autoplay=autoplay,
        hide_icon=hide_icon,
        pace_slide=pace_slide,
        alt_text=alt_text,
    )

add_video

add_video(path: str | PathLike[str], *, left: float | None = None, top: float | None = None, width: float | None = None, height: float | None = None, link: bool = False, autoplay: bool = True, pace_slide: bool = True, alt_text: str | None = None) -> Shape

Insert a video clip and return its Shape (Shapes.AddMediaObject2).

Like add_audio, but the clip stays visible (there is no hide_icon — a video frame is meant to be seen). Geometry is in points; omitted values default to a 16:9 frame near the top-left. autoplay plays it on slide entry; pace_slide auto-advances the slide to the clip length. The shape's .has_media is True. Raises FileNotFoundError if path doesn't exist (before any COM). Wrap in deck.edit(...) for the one-Ctrl-Z fence.

Source code in src/pptlive/_shapes.py
def add_video(
    self,
    path: str | os.PathLike[str],
    *,
    left: float | None = None,
    top: float | None = None,
    width: float | None = None,
    height: float | None = None,
    link: bool = False,
    autoplay: bool = True,
    pace_slide: bool = True,
    alt_text: str | None = None,
) -> Shape:
    """Insert a video clip and return its `Shape` (`Shapes.AddMediaObject2`).

    Like `add_audio`, but the clip stays visible (there is no `hide_icon` — a
    video frame is meant to be seen). Geometry is in points; omitted values
    default to a 16:9 frame near the top-left. `autoplay` plays it on slide
    entry; `pace_slide` auto-advances the slide to the clip length. The shape's
    `.has_media` is True. Raises `FileNotFoundError` if `path` doesn't exist
    (before any COM). Wrap in `deck.edit(...)` for the one-Ctrl-Z fence.
    """
    return self._add_media(
        path,
        left=left,
        top=top,
        width=width,
        height=height,
        default_width=_DEFAULT_VIDEO_WIDTH,
        default_height=_DEFAULT_VIDEO_HEIGHT,
        link=link,
        autoplay=autoplay,
        hide_icon=False,
        pace_slide=pace_slide,
        alt_text=alt_text,
    )

add_table

add_table(rows: int, columns: int, *, left: float | None = None, top: float | None = None, width: float | None = None, height: float | None = None) -> Shape

Add a rows×columns table and return its Shape (Shapes.AddTable).

Geometry is in points; omitted values default to a wide grid near the top-left (height is advisory — PowerPoint auto-fits rows to content). Address cells through the returned shape's .table or the cell:S:N:R:C anchor; the shape's .has_table is True. Raises ValueError for non-positive rows/columns (before any COM).

Source code in src/pptlive/_shapes.py
def add_table(
    self,
    rows: int,
    columns: int,
    *,
    left: float | None = None,
    top: float | None = None,
    width: float | None = None,
    height: float | None = None,
) -> Shape:
    """Add a `rows`×`columns` table and return its `Shape` (`Shapes.AddTable`).

    Geometry is in points; omitted values default to a wide grid near the
    top-left (height is advisory — PowerPoint auto-fits rows to content).
    Address cells through the returned shape's `.table` or the `cell:S:N:R:C`
    anchor; the shape's `.has_table` is True. Raises `ValueError` for
    non-positive `rows`/`columns` (before any COM).
    """
    if int(rows) < 1 or int(columns) < 1:
        raise ValueError(f"table needs >=1 row and >=1 column, got {rows}x{columns}")
    left = _DEFAULT_LEFT if left is None else float(left)
    top = _DEFAULT_TOP if top is None else float(top)
    width = _DEFAULT_TABLE_WIDTH if width is None else float(width)
    height = _DEFAULT_ROW_HEIGHT * int(rows) if height is None else float(height)
    with _com.translate_com_errors():
        self._com_collection.AddTable(int(rows), int(columns), left, top, width, height)
        return self._added()

add_chart

add_chart(chart_type: str | int = 'column', categories: Sequence[str] | None = None, series: SeriesInput | None = None, *, left: float | None = None, top: float | None = None, width: float | None = None, height: float | None = None) -> Shape

Add a chart and return its Shape (Shapes.AddChart2).

chart_type is a friendly name ("column", "bar", "line", "pie", …; see constants.CHART_TYPE_CHOICES) or a raw XlChartType int. Geometry is in points; omitted values default to a ~6.7×4.2 in chart near the top-left. Address the chart's data through the returned shape's .chart (or anchor_id); the shape's .has_chart is True.

If both categories and series are given, the chart's embedded-Excel data is replaced with them (via Chart.set_data); otherwise the chart keeps PowerPoint's default placeholder data. Pass one without the other and it's a ValueError. Raises ValueError for an unknown chart_type (before any COM). Wrap in deck.edit(...) for the one-Ctrl-Z fence.

Source code in src/pptlive/_shapes.py
def add_chart(
    self,
    chart_type: str | int = "column",
    categories: Sequence[str] | None = None,
    series: SeriesInput | None = None,
    *,
    left: float | None = None,
    top: float | None = None,
    width: float | None = None,
    height: float | None = None,
) -> Shape:
    """Add a chart and return its `Shape` (`Shapes.AddChart2`).

    `chart_type` is a friendly name (`"column"`, `"bar"`, `"line"`, `"pie"`,
    …; see `constants.CHART_TYPE_CHOICES`) or a raw `XlChartType` int.
    Geometry is in points; omitted values default to a ~6.7×4.2 in chart near
    the top-left. Address the chart's data through the returned shape's
    `.chart` (or `anchor_id`); the shape's `.has_chart` is True.

    If both `categories` and `series` are given, the chart's embedded-Excel
    data is replaced with them (via `Chart.set_data`); otherwise the chart
    keeps PowerPoint's default placeholder data. Pass one without the other
    and it's a `ValueError`. Raises `ValueError` for an unknown `chart_type`
    (before any COM). Wrap in `deck.edit(...)` for the one-Ctrl-Z fence.
    """
    type_int = chart_type_for(chart_type)  # ValueError before any COM
    if (categories is None) != (series is None):
        raise ValueError("pass both categories and series, or neither")
    left = _DEFAULT_LEFT if left is None else float(left)
    top = _DEFAULT_TOP if top is None else float(top)
    width = _DEFAULT_CHART_WIDTH if width is None else float(width)
    height = _DEFAULT_CHART_HEIGHT if height is None else float(height)
    with _com.translate_com_errors():
        self._com_collection.AddChart2(_CHART_DEFAULT_STYLE, type_int, left, top, width, height)
        shape = self._added()
    if categories is not None and series is not None:
        shape.chart.set_data(categories, series)
    return shape

add_smartart

add_smartart(kind: str, nodes: Sequence[NodeInput] | None = None, *, left: float | None = None, top: float | None = None, width: float | None = None, height: float | None = None) -> Shape

Add a SmartArt diagram and return its Shape (Shapes.AddSmartArt).

kind is a friendly layout name ("process", "cycle", "orgchart", …; see constants.SMARTART_CHOICES). Geometry is in points; omitted values default to a ~6.7×4.2 in diagram near the top-left. Address the diagram's nodes through the returned shape's .smartart (or anchor_id); the shape's .has_smartart is True.

If nodes is given, the diagram's nodes are replaced with it (via SmartArt.set_nodes) — a list of strings (flat) and/or {text, children} mappings (nested); otherwise the layout keeps its default placeholder nodes. Raises ValueError for an unknown kind (before any COM) and AnchorNotFoundError if the layout isn't installed. Wrap in deck.edit(...) for the one-Ctrl-Z fence.

Source code in src/pptlive/_shapes.py
def add_smartart(
    self,
    kind: str,
    nodes: Sequence[NodeInput] | None = None,
    *,
    left: float | None = None,
    top: float | None = None,
    width: float | None = None,
    height: float | None = None,
) -> Shape:
    """Add a SmartArt diagram and return its `Shape` (`Shapes.AddSmartArt`).

    `kind` is a friendly layout name (`"process"`, `"cycle"`, `"orgchart"`,
    …; see `constants.SMARTART_CHOICES`). Geometry is in points; omitted values
    default to a ~6.7×4.2 in diagram near the top-left. Address the diagram's
    nodes through the returned shape's `.smartart` (or `anchor_id`); the
    shape's `.has_smartart` is True.

    If `nodes` is given, the diagram's nodes are replaced with it (via
    `SmartArt.set_nodes`) — a list of strings (flat) and/or `{text, children}`
    mappings (nested); otherwise the layout keeps its default placeholder
    nodes. Raises `ValueError` for an unknown `kind` (before any COM) and
    `AnchorNotFoundError` if the layout isn't installed. Wrap in
    `deck.edit(...)` for the one-Ctrl-Z fence.
    """
    left = _DEFAULT_LEFT if left is None else float(left)
    top = _DEFAULT_TOP if top is None else float(top)
    width = _DEFAULT_SMARTART_WIDTH if width is None else float(width)
    height = _DEFAULT_SMARTART_HEIGHT if height is None else float(height)
    with _com.translate_com_errors():
        layout = self._resolve_smartart_layout(kind)
        self._com_collection.AddSmartArt(layout, left, top, width, height)
        shape = self._added()
    if nodes is not None:
        shape.smartart.set_nodes(nodes)
    return shape

list

list() -> list[dict[str, Any]]

Every shape as a structured dict, in z-order.

Source code in src/pptlive/_shapes.py
def list(self) -> list[dict[str, Any]]:
    """Every shape as a structured dict, in z-order."""
    out: list[dict[str, Any]] = []
    with _com.translate_com_errors():
        for idx, sh in enumerate(self._com_collection, start=1):
            out.append(shape_to_dict(sh, self._slide.index, idx))
    return out

pptlive.Shape

Shape(slide: Slide, index: int)

Bases: Anchor

A shape on a slide, addressed by 1-based z-order index — shape:S:N.

Resolves its COM object live on every access (z-order drifts). Inherits text / set_text from Anchor (raising NoTextFrameError if the shape has no text frame), and adds geometry verbs.

Source code in src/pptlive/_shapes.py
def __init__(self, slide: Slide, index: int) -> None:
    self._slide = slide
    self._index = int(index)

index property

index: int

1-based z-order index this shape was addressed by.

com property

com: Any

Raw COM Shape (overrides Anchor.com, which would give a text range).

name property

name: str

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: int

Shape.Id — stable across z-order reordering.

shapeid property

shapeid: str

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

shape_type: str

Friendly shape-type name (e.g. "placeholder", "textbox", "picture").

alt_text property

alt_text: str

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

has_table: bool

Whether this shape holds a table (Shape.HasTable).

table property

table: Table

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

has_chart: bool

Whether this shape holds a chart (Shape.HasChart).

chart property

chart: Chart

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

has_smartart: bool

Whether this shape holds a SmartArt diagram (Shape.HasSmartArt).

smartart property

smartart: SmartArt

The shape's SmartArt diagram (its node tree).

Raises AnchorNotFoundError (kind "smartart", exit 2) if the shape holds no SmartArt.

has_media property

has_media: bool

Whether this shape is an audio/video clip (Shape.Type == msoMedia).

media property

media: dict[str, Any]

The media clip's {type, length_s, muted, volume, autoplay} read.

Raises AnchorNotFoundError (kind "media", exit 2) if the shape holds no media.

paragraphs property

paragraphs: ParagraphCollection

The shape's paragraphs (para:S:N:P); raises NoTextFrameError if none.

text_frame_status

text_frame_status() -> TextFrameStatus

Autofit / wrap / margin diagnostics for this shape's text frame.

A read (no view move, no edit fence) that exposes the state behind a "formatting spiral": the autofit mode, word-wrap, inner margins (points), and a coarse overflow_risk (see TextFrameStatus). Raises NoTextFrameError if the shape holds no text frame.

Source code in src/pptlive/_shapes.py
def text_frame_status(self) -> TextFrameStatus:
    """Autofit / wrap / margin diagnostics for this shape's text frame.

    A **read** (no view move, no edit fence) that exposes the state behind a
    "formatting spiral": the autofit mode, word-wrap, inner margins (points),
    and a coarse `overflow_risk` (see `TextFrameStatus`). Raises
    `NoTextFrameError` if the shape holds no text frame.
    """
    with _com.translate_com_errors():
        sh = self._com_shape()
        if not has_text_frame(sh):
            raise NoTextFrameError(self.anchor_id)
        tf = sh.TextFrame
        autosize = _autosize_of(sh)
        margins = {
            "left": _safe(lambda: float(tf.MarginLeft), 0.0),
            "right": _safe(lambda: float(tf.MarginRight), 0.0),
            "top": _safe(lambda: float(tf.MarginTop), 0.0),
            "bottom": _safe(lambda: float(tf.MarginBottom), 0.0),
        }
        word_wrap = _safe(lambda: is_true(tf.WordWrap), True)
    return TextFrameStatus(
        autosize=autosize,
        word_wrap=word_wrap,
        margins=margins,
        overflow_risk=_overflow_risk(autosize),
    )

paragraph

paragraph(index: int) -> Paragraph

The index-th paragraph (1-based) of this shape's text frame.

Source code in src/pptlive/_shapes.py
def paragraph(self, index: int) -> Paragraph:
    """The `index`-th paragraph (1-based) of this shape's text frame."""
    return ParagraphCollection(self)[index]

geometry

geometry() -> dict[str, float]

{left, top, width, height, rotation} in points.

Source code in src/pptlive/_shapes.py
def geometry(self) -> dict[str, float]:
    """`{left, top, width, height, rotation}` in points."""
    with _com.translate_com_errors():
        geo = _geometry_of(self._com_shape())
    if geo is None:
        raise AnchorNotFoundError("shape", self.anchor_id)
    return geo

move

move(*, left: float | None = None, top: float | None = None) -> None

Set the shape's absolute position in points. Pass left and/or top.

Source code in src/pptlive/_shapes.py
def move(self, *, left: float | None = None, top: float | None = None) -> None:
    """Set the shape's absolute position in points. Pass `left` and/or `top`."""
    if left is None and top is None:
        raise ValueError("move() requires at least one of left= or top=")
    with _com.translate_com_errors():
        sh = self._com_shape()
        if left is not None:
            sh.Left = float(left)
        if top is not None:
            sh.Top = float(top)

resize

resize(*, width: float | None = None, height: float | None = None) -> None

Set the shape's size in points. Pass width and/or height.

Source code in src/pptlive/_shapes.py
def resize(self, *, width: float | None = None, height: float | None = None) -> None:
    """Set the shape's size in points. Pass `width` and/or `height`."""
    if width is None and height is None:
        raise ValueError("resize() requires at least one of width= or height=")
    with _com.translate_com_errors():
        sh = self._com_shape()
        if width is not None:
            sh.Width = float(width)
        if height is not None:
            sh.Height = float(height)

reset_to_layout

reset_to_layout() -> dict[str, float]

Restore this placeholder's geometry (+ default font size) from its layout.

The recovery verb for a placeholder that's been manually moved/resized or shrunk to an unreadable font (the gpt-5.4 review's "5 pt font, overflow off the slide" case). Matches this shape to its slide's CustomLayout placeholder by PlaceholderFormat.Type, then copies that placeholder's Left/Top/Width/Height (and, best-effort, its default font size onto the live text) — the layout's reading is the source of truth (verified in scripts/text_model_spike.py). Returns the restored {left, top, width, height[, font_size]} in points.

Raises ValueError if this shape isn't a placeholder, or AnchorNotFoundError if the layout has no placeholder of the same kind. Pairs with reset_format (which clears the paragraph-spacing spiral). A mutation: wrap in deck.edit(...).

Source code in src/pptlive/_shapes.py
def reset_to_layout(self) -> dict[str, float]:
    """Restore this *placeholder's* geometry (+ default font size) from its layout.

    The recovery verb for a placeholder that's been manually moved/resized or
    shrunk to an unreadable font (the gpt-5.4 review's "5 pt font, overflow off
    the slide" case). Matches this shape to its slide's `CustomLayout`
    placeholder by `PlaceholderFormat.Type`, then copies that placeholder's
    `Left`/`Top`/`Width`/`Height` (and, best-effort, its default font size onto
    the live text) — the layout's reading is the source of truth (verified in
    `scripts/text_model_spike.py`). Returns the restored `{left, top, width,
    height[, font_size]}` in points.

    Raises `ValueError` if this shape isn't a placeholder, or
    `AnchorNotFoundError` if the layout has no placeholder of the same kind.
    Pairs with `reset_format` (which clears the paragraph-spacing spiral). A
    mutation: wrap in `deck.edit(...)`.
    """
    with _com.translate_com_errors():
        sh = self._com_shape()
        try:
            want_type = int(sh.PlaceholderFormat.Type)
        except Exception as exc:  # not a placeholder -> no PlaceholderFormat
            raise ValueError(
                f"reset_to_layout() needs a placeholder shape, got {self.anchor_id}"
            ) from exc
        match = self._layout_placeholder(want_type)
        sh.Left = float(match.Left)
        sh.Top = float(match.Top)
        sh.Width = float(match.Width)
        sh.Height = float(match.Height)
        restored: dict[str, float] = {
            "left": float(sh.Left),
            "top": float(sh.Top),
            "width": float(sh.Width),
            "height": float(sh.Height),
        }
        size = _layout_default_size(match)
        if size is not None and has_text_frame(sh):
            sh.TextFrame.TextRange.Font.Size = size
            restored["font_size"] = size
    return restored

set_alt_text

set_alt_text(value: str) -> None

Set the shape's alternative (accessibility) text (Shape.AlternativeText).

The drift-proof re-identification handle (see alt_text). A mutation: wrap in deck.edit(...) for view preservation + a one-Ctrl-Z fence.

Source code in src/pptlive/_shapes.py
def set_alt_text(self, value: str) -> None:
    """Set the shape's alternative (accessibility) text (`Shape.AlternativeText`).

    The drift-proof re-identification handle (see `alt_text`). A mutation:
    wrap in `deck.edit(...)` for view preservation + a one-Ctrl-Z fence.
    """
    with _com.translate_com_errors():
        self._com_shape().AlternativeText = str(value)

set_fill

set_fill(*, fill: str | int | tuple[int, int, int] | None = None, line: str | int | tuple[int, int, int] | None = None, line_width: float | None = None, fill_transparency: float | None = None, line_transparency: float | None = None) -> None

Set the shape's fill and/or line (border). Only the kwargs passed.

fill/line take a color ("#RRGGBB", an (r, g, b) tuple, or a raw RGB int) for a solid fill / line of that color, or the string "none" to make the fill transparent / remove the border. line_width is the border weight in points. fill_transparency/line_transparency are 0.0..1.0 alpha fractions (0 opaque, 1 fully transparent) — the partial-alpha knob, distinct from "none" (which hides the fill/line entirely). Distinct from format_text's color, which is font color. Raises ValueError for a bad color / out-of-range transparency (before any COM) or if nothing is passed. A mutation: wrap in deck.edit(...).

Source code in src/pptlive/_shapes.py
def set_fill(
    self,
    *,
    fill: str | int | tuple[int, int, int] | None = None,
    line: str | int | tuple[int, int, int] | None = None,
    line_width: float | None = None,
    fill_transparency: float | None = None,
    line_transparency: float | None = None,
) -> None:
    """Set the shape's **fill** and/or **line** (border). Only the kwargs passed.

    `fill`/`line` take a color (`"#RRGGBB"`, an `(r, g, b)` tuple, or a raw RGB
    int) for a solid fill / line of that color, or the string `"none"` to make
    the fill transparent / remove the border. `line_width` is the border weight
    in points. `fill_transparency`/`line_transparency` are `0.0..1.0` alpha
    fractions (0 opaque, 1 fully transparent) — the partial-alpha knob, distinct
    from `"none"` (which hides the fill/line entirely). Distinct from
    `format_text`'s `color`, which is *font* color. Raises `ValueError` for a bad
    color / out-of-range transparency (before any COM) or if nothing is passed. A
    mutation: wrap in `deck.edit(...)`.
    """
    if (
        fill is None
        and line is None
        and line_width is None
        and fill_transparency is None
        and line_transparency is None
    ):
        raise ValueError(
            "set_fill() requires at least one of fill=, line=, line_width=, "
            "fill_transparency=, or line_transparency="
        )
    with _com.translate_com_errors():
        apply_shape_fill(
            self._com_shape(),
            fill=fill,
            line=line,
            line_width=line_width,
            fill_transparency=fill_transparency,
            line_transparency=line_transparency,
        )

set_line_style

set_line_style(*, dash: str | int | None = None, begin_arrow: str | int | None = None, end_arrow: str | int | None = None, begin_arrow_size: str | int | None = None, end_arrow_size: str | int | None = None) -> None

Set the shape's line dash pattern and/or arrowheads. Only the kwargs passed.

dash is a friendly MsoLineDashStyle ("solid"/"dash"/"round_dot"/ "dash_dot"/"long_dash"/…) or raw int. begin_arrow/end_arrow are MsoArrowheadStyle names ("none"/"triangle"/"open"/"stealth"/ "diamond"/"oval") or raw ints; begin_arrow_size/end_arrow_size are "small"/"medium"/"large" (set both arrowhead length + width). Names are validated up front (ValueError before any COM). Arrowheads apply to lines/connectors only — PowerPoint raises on a closed shape (use dash for those). Border color/weight stay on set_fill. A mutation: wrap in deck.edit(...).

Source code in src/pptlive/_shapes.py
def set_line_style(
    self,
    *,
    dash: str | int | None = None,
    begin_arrow: str | int | None = None,
    end_arrow: str | int | None = None,
    begin_arrow_size: str | int | None = None,
    end_arrow_size: str | int | None = None,
) -> None:
    """Set the shape's line **dash** pattern and/or **arrowheads**. Only the kwargs passed.

    `dash` is a friendly `MsoLineDashStyle` (`"solid"`/`"dash"`/`"round_dot"`/
    `"dash_dot"`/`"long_dash"`/…) or raw int. `begin_arrow`/`end_arrow` are
    `MsoArrowheadStyle` names (`"none"`/`"triangle"`/`"open"`/`"stealth"`/
    `"diamond"`/`"oval"`) or raw ints; `begin_arrow_size`/`end_arrow_size` are
    `"small"`/`"medium"`/`"large"` (set both arrowhead length + width). Names are
    validated up front (`ValueError` before any COM). **Arrowheads apply to
    lines/connectors only** — PowerPoint raises on a closed shape (use `dash` for
    those). Border color/weight stay on `set_fill`. A mutation: wrap in
    `deck.edit(...)`.
    """
    with _com.translate_com_errors():
        apply_line_style(
            self._com_shape(),
            dash=dash,
            begin_arrow=begin_arrow,
            end_arrow=end_arrow,
            begin_arrow_size=begin_arrow_size,
            end_arrow_size=end_arrow_size,
        )

set_gradient_fill

set_gradient_fill(colors: Sequence[Any] | None = None, *, positions: Sequence[float] | None = None, style: str | int = 'horizontal', variant: int = 1, degree: float | None = None, preset: str | int | None = None) -> None

Give this shape a gradient fill. A mutation: wrap in deck.edit(...).

Pass colors (a list of "#RRGGBB" / (r,g,b) / int colors) or preset (a named ramp like "ocean" / "fire" / "rainbow"): - one color → a one-color gradient (degree is the 0..1 brightness, default 0.5); - two colors → a two-color gradient (first at stop 0.0, second at 1.0); - three+ colors → a multi-stop gradient; positions (floats 0..1, same length as colors) places the interior stops, which otherwise space evenly (the endpoints stay at 0.0/1.0).

style is a friendly MsoGradientStyle ("horizontal"/"vertical"/ "diagonal_up"/…) and variant (1-4) picks the shading variant. Raises ValueError (before any COM) for a bad color / style / preset, or if neither colors nor preset is given.

Source code in src/pptlive/_shapes.py
def set_gradient_fill(
    self,
    colors: Sequence[Any] | None = None,
    *,
    positions: Sequence[float] | None = None,
    style: str | int = "horizontal",
    variant: int = 1,
    degree: float | None = None,
    preset: str | int | None = None,
) -> None:
    """Give this shape a **gradient** fill. A mutation: wrap in `deck.edit(...)`.

    Pass `colors` (a list of `"#RRGGBB"` / `(r,g,b)` / int colors) **or** `preset`
    (a named ramp like `"ocean"` / `"fire"` / `"rainbow"`):
    - one color → a one-color gradient (`degree` is the 0..1 brightness, default 0.5);
    - two colors → a two-color gradient (first at stop 0.0, second at 1.0);
    - three+ colors → a multi-stop gradient; `positions` (floats 0..1, same length as
      `colors`) places the *interior* stops, which otherwise space evenly (the
      endpoints stay at 0.0/1.0).

    `style` is a friendly `MsoGradientStyle` (`"horizontal"`/`"vertical"`/
    `"diagonal_up"`/…) and `variant` (1-4) picks the shading variant. Raises
    `ValueError` (before any COM) for a bad color / style / preset, or if neither
    `colors` nor `preset` is given.
    """
    with _com.translate_com_errors():
        apply_gradient_fill(
            self._com_shape(),
            colors=colors,
            positions=positions,
            style=style,
            variant=variant,
            degree=degree,
            preset=preset,
        )

set_picture_fill

set_picture_fill(path: str | Path) -> None

Fill this shape with an image (Fill.UserPicture). A mutation: deck.edit(...).

path is resolved to an absolute path (a relative path raises ERROR_FILE_NOT_FOUND in PowerPoint); a missing file raises FileNotFoundError (before any COM).

For an actual picture shape this is the wrong verb — it sets a fill behind the unchanged picture raster (so the image doesn't visibly change); use set_picture to re-source a picture in place.

Source code in src/pptlive/_shapes.py
def set_picture_fill(self, path: str | Path) -> None:
    """Fill this shape with an **image** (`Fill.UserPicture`). A mutation: `deck.edit(...)`.

    `path` is resolved to an absolute path (a relative path raises
    `ERROR_FILE_NOT_FOUND` in PowerPoint); a missing file raises `FileNotFoundError`
    (before any COM).

    For an actual **picture** shape this is the wrong verb — it sets a fill
    *behind* the unchanged picture raster (so the image doesn't visibly change);
    use `set_picture` to re-source a picture in place.
    """
    with _com.translate_com_errors():
        apply_picture_fill(self._com_shape(), path)

set_picture

set_picture(path: str | PathLike[str], *, alt_text: str | None = None) -> Shape

Re-source this picture in place — swap its image without delete-and-recreate.

The post-creation edit for a picture: replaces the displayed image while preserving the picture's position, size, rotation, name, alt text, and z-order slot, so an agent can update a logo / screenshot / chart export without re-deriving geometry (the wordlive delete-then-recreate habit). The new image is embedded (never linked); alt_text, if given, overrides the carried-over alt text.

Under the hood this is a delete + re-insert — PowerPoint's COM exposes no in-place image swap for a picture shape (Fill.UserPicture only sets a fill behind the unchanged raster, confirmed in scripts/set_picture_spike.py). Two honest consequences: the picture gets a new Shape.Id (so this returns a fresh handle — the old wrapper is spent, like after delete()), and anything bound to the old picture object — animations, hyperlinks, crop, and picture adjustments (brightness / contrast / recolor) — is not carried over. Position, size, rotation, name, alt text, and z-order are.

Raises FileNotFoundError if path is missing, or ValueError if this shape isn't a picture (both before any COM mutation — use set_picture_fill to put an image into a non-picture shape's fill). Returns a shapeid:S:ID handle to the new picture. A mutation: wrap in deck.edit(...).

Source code in src/pptlive/_shapes.py
def set_picture(
    self,
    path: str | os.PathLike[str],
    *,
    alt_text: str | None = None,
) -> Shape:
    """Re-source this **picture** in place — swap its image without delete-and-recreate.

    The post-creation edit for a picture: replaces the displayed image while
    **preserving the picture's position, size, rotation, name, alt text, and
    z-order slot**, so an agent can update a logo / screenshot / chart export
    without re-deriving geometry (the wordlive delete-then-recreate habit). The
    new image is **embedded** (never linked); `alt_text`, if given, overrides
    the carried-over alt text.

    Under the hood this *is* a delete + re-insert — PowerPoint's COM exposes no
    in-place image swap for a picture shape (`Fill.UserPicture` only sets a
    fill *behind* the unchanged raster, confirmed in
    `scripts/set_picture_spike.py`). Two honest consequences: the picture gets
    a **new `Shape.Id`** (so this returns a fresh handle — the old wrapper is
    spent, like after `delete()`), and anything bound to the old picture object
    — **animations, hyperlinks, crop, and picture adjustments**
    (brightness / contrast / recolor) — is **not** carried over. Position, size,
    rotation, name, alt text, and z-order are.

    Raises `FileNotFoundError` if `path` is missing, or `ValueError` if this
    shape isn't a picture (both before any COM mutation — use `set_picture_fill`
    to put an image into a non-picture shape's fill). Returns a `shapeid:S:ID`
    handle to the new picture. A mutation: wrap in `deck.edit(...)`.
    """
    fs_path = os.fspath(path)
    if not os.path.isfile(fs_path):
        raise FileNotFoundError(f"picture not found: {fs_path}")
    abs_path = os.path.abspath(fs_path)
    with _com.translate_com_errors():
        com_old = self._com_shape()
        if not is_picture(com_old):
            raise ValueError(
                f"set_picture() needs a picture shape, got "
                f"{shape_type_name(com_old.Type)} ({self.anchor_id}); use "
                f"set_picture_fill() to put an image into a non-picture shape's fill"
            )
        new_id = replace_picture(
            self._slide.com, com_old, abs_path, self._slide.index, alt_text=alt_text
        )
    return ShapeById(self._slide, new_id)

set_pattern_fill

set_pattern_fill(pattern: str | int, *, fore: Any, back: Any | None = None) -> None

Give this shape a two-color pattern fill. A mutation: wrap in deck.edit(...).

pattern is a friendly MsoPatternType name ("percent_50", "trellis", "dark_horizontal", …) or a raw int; fore is the pattern color and back the (optional) background color. Raises ValueError (before any COM) for a bad pattern name or color.

Source code in src/pptlive/_shapes.py
def set_pattern_fill(
    self,
    pattern: str | int,
    *,
    fore: Any,
    back: Any | None = None,
) -> None:
    """Give this shape a two-color **pattern** fill. A mutation: wrap in `deck.edit(...)`.

    `pattern` is a friendly `MsoPatternType` name (`"percent_50"`, `"trellis"`,
    `"dark_horizontal"`, …) or a raw int; `fore` is the pattern color and `back`
    the (optional) background color. Raises `ValueError` (before any COM) for a bad
    pattern name or color.
    """
    with _com.translate_com_errors():
        apply_pattern_fill(self._com_shape(), pattern=pattern, fore=fore, back=back)

set_effect

set_effect(*, shadow: Any | None = None, glow: Any | None = None, soft_edge: Any | None = None, reflection: Any | None = None) -> None

Set shape effects — shadow / glow / soft-edge / reflection. Only kwargs passed.

Each takes its friendly form or the string "none" to turn it off: - shadow — a dict {color?, transparency?, blur?, size?, offset_x?, offset_y?} (an outer drop shadow); - glow — a dict {color?, radius?, transparency?} (radius 0 = off); - soft_edge — an int preset 0-6 (0 = off); - reflection — an int preset 0-9 (0 = off).

Raises ValueError (before any COM) for a bad color or a non-dict shadow/glow. A mutation: wrap in deck.edit(...).

Source code in src/pptlive/_shapes.py
def set_effect(
    self,
    *,
    shadow: Any | None = None,
    glow: Any | None = None,
    soft_edge: Any | None = None,
    reflection: Any | None = None,
) -> None:
    """Set shape **effects** — shadow / glow / soft-edge / reflection. Only kwargs passed.

    Each takes its friendly form or the string `"none"` to turn it off:
    - `shadow` — a dict `{color?, transparency?, blur?, size?, offset_x?, offset_y?}`
      (an outer drop shadow);
    - `glow` — a dict `{color?, radius?, transparency?}` (radius 0 = off);
    - `soft_edge` — an int preset `0`-`6` (0 = off);
    - `reflection` — an int preset `0`-`9` (0 = off).

    Raises `ValueError` (before any COM) for a bad color or a non-dict shadow/glow.
    A mutation: wrap in `deck.edit(...)`.
    """
    if shadow is None and glow is None and soft_edge is None and reflection is None:
        raise ValueError(
            "set_effect() requires at least one of shadow=, glow=, soft_edge=, or reflection="
        )
    with _com.translate_com_errors():
        apply_effect(
            self._com_shape(),
            shadow=shadow,
            glow=glow,
            soft_edge=soft_edge,
            reflection=reflection,
        )

reorder

reorder(to: str | int) -> int

Restack this shape in the slide's z-order; return its new 1-based position.

to is "front" / "back" / "forward" / "backward" (or a raw MsoZOrderCmd int) — bring to front, send to back, or step one level (constants.ZORDER_CHOICES). Lets a freshly added background panel slide behind existing content (otherwise it always lands on top). Note this shifts the shape:S:N indices of the shapes it passes — re-read after, or address by shapeid:S:ID / .Name. Raises ValueError for an unknown command (before any COM). A mutation: wrap in deck.edit(...).

Source code in src/pptlive/_shapes.py
def reorder(self, to: str | int) -> int:
    """Restack this shape in the slide's z-order; return its new 1-based position.

    `to` is `"front"` / `"back"` / `"forward"` / `"backward"` (or a raw
    `MsoZOrderCmd` int) — bring to front, send to back, or step one level
    (`constants.ZORDER_CHOICES`). Lets a freshly added background panel slide
    *behind* existing content (otherwise it always lands on top). Note this
    shifts the `shape:S:N` indices of the shapes it passes — re-read after, or
    address by `shapeid:S:ID` / `.Name`. Raises `ValueError` for an unknown
    command (before any COM). A mutation: wrap in `deck.edit(...)`.
    """
    cmd = zorder_cmd_for(to)  # ValueError before any COM
    with _com.translate_com_errors():
        sh = self._com_shape()  # raw ref tracks the shape across the restack
        shape_id = int(sh.Id)
        sh.ZOrder(cmd)
        # Report the Shapes-collection index (the basis shape:S:N resolves by),
        # not ZOrderPosition — they coincide on a flat slide but can diverge,
        # and the returned position should be usable as shape:S:N.
        found = find_shape_by_id(self._slide.com, shape_id)
        return found[0] if found is not None else int(sh.ZOrderPosition)

ungroup

ungroup() -> list[ShapeById]

Ungroup this group shape; return drift-proof handles to the freed members.

Reverses ShapeCollection.groupGroupShape.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(*, 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() -> 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() -> 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.

Source code in src/pptlive/_shapes.py
def delete(self) -> None:
    """Delete this shape from its slide (`Shape.Delete`).

    The wrapper is spent afterwards; later shapes' z-order indices shift down
    by one. Wrap in `deck.edit(...)` for the one-Ctrl-Z fence.
    """
    with _com.translate_com_errors():
        self._com_shape().Delete()

export_image

export_image(path: str | PathLike[str] | None = None, *, fmt: str = 'png') -> Path

Render just this shape to an image file and return its absolute path.

The per-shape complement to Slide.export_image (v0.4): lets a vision model see one picture / chart / diagram in isolation, cropped to the shape's (rendered) bounds. Wraps Shape.Export(PathName, Filter)Filter is the PpShapeFormat int enum, not Slide.Export's string FilterName.

fmt is a friendly token (png/jpg/gif/bmp; see constants.SHAPE_IMAGE_FORMAT_CHOICES — narrower than the slide set, no TIFF). When path is None a temp file is created (export-then-Read in one step). A relative path is resolved to absolute first (PowerPoint otherwise writes to its own working directory, not the caller's). It's a read — no mutation, and polite (it doesn't move the viewed slide or the Selection).

The image is the shape's native pixel size (the slide's 96-DPI scale, e.g. a 720 pt-wide shape on a 960 pt slide → 960 px). Unlike Slide.export_image, there is no output-size override: the 2026-05-28 live spike found Shape.Export's ScaleWidth/ScaleHeight do not map to output pixels the way Slide.Export's do (requesting 400×300 gave 399×241 — width roughly tracked, height didn't, aspect wasn't preserved), so pptlive only exposes the reliable native export. Reach for the .com escape hatch (shape.com.Export(path, filter, w, h, mode)) if you need to experiment with scaling.

Source code in src/pptlive/_shapes.py
def export_image(
    self,
    path: str | os.PathLike[str] | None = None,
    *,
    fmt: str = "png",
) -> Path:
    """Render *just this shape* to an image file and return its absolute path.

    The per-shape complement to `Slide.export_image` (v0.4): lets a vision
    model see one picture / chart / diagram in isolation, cropped to the
    shape's (rendered) bounds. Wraps `Shape.Export(PathName, Filter)` —
    `Filter` is the `PpShapeFormat` int enum, **not** `Slide.Export`'s string
    FilterName.

    `fmt` is a friendly token (`png`/`jpg`/`gif`/`bmp`; see
    `constants.SHAPE_IMAGE_FORMAT_CHOICES` — narrower than the slide set, no
    TIFF). When `path` is None a temp file is created (export-then-`Read` in
    one step). A relative `path` is resolved to absolute first (PowerPoint
    otherwise writes to its own working directory, not the caller's). It's a
    read — no mutation, and polite (it doesn't move the viewed slide or the
    Selection).

    The image is the shape's **native pixel size** (the slide's 96-DPI scale,
    e.g. a 720 pt-wide shape on a 960 pt slide → 960 px). Unlike
    `Slide.export_image`, there is **no** output-size override: the 2026-05-28
    live spike found `Shape.Export`'s ScaleWidth/ScaleHeight do *not* map to
    output pixels the way `Slide.Export`'s do (requesting 400×300 gave
    399×241 — width roughly tracked, height didn't, aspect wasn't preserved),
    so pptlive only exposes the reliable native export. Reach for the `.com`
    escape hatch (`shape.com.Export(path, filter, w, h, mode)`) if you need to
    experiment with scaling.
    """
    filter_int, ext = shape_image_filter_for(fmt)  # ValueError before any COM
    if path is None:
        fd, tmp = tempfile.mkstemp(prefix="pptlive_shape_", suffix=f".{ext}")
        os.close(fd)
        os.remove(tmp)  # hand PowerPoint a clean path to write
        abs_path = tmp
    else:
        abs_path = os.path.abspath(os.fspath(path))
    with _com.translate_com_errors():
        self._com_shape().Export(abs_path, int(filter_int))
    return Path(abs_path)

pptlive.PlaceholderShape

PlaceholderShape(slide: Slide, ph_kind: str)

Bases: Shape

A placeholder addressed by semantic kind — ph:S:KIND.

The LLM-preferred, drift-proof form: "the title of slide 3" without caring about z-order. Re-resolves its COM shape by PlaceholderFormat.Type on every access (via Slide._find_placeholder), so it survives shape reordering. It is a Shape, so all geometry and text verbs work; only the resolution strategy and anchor_id differ.

Source code in src/pptlive/_shapes.py
def __init__(self, slide: Slide, ph_kind: str) -> None:
    # Validate the kind eagerly so a typo fails before any COM work.
    placeholder_types_for(ph_kind)
    super().__init__(slide, index=0)
    self._ph_kind = ph_kind.lower()

index property

index: int

Current 1-based z-order index of the resolved placeholder.

Anchors

Every text-bearing handle subclasses Anchor and shares the same verbs — text, set_text, insert_paragraph_before/after, format_text, format_paragraph, apply_list / remove_list — so the same calls work uniformly on a whole shape, one paragraph, a table cell, or a slide's notes. PowerPoint has no named paragraph styles, so "styling" is direct font formatting via format_text (bold / italic / underline / size / font / color). A paragraph read's font block also reports color_source ("direct" / "theme" / "mixed") and theme_color (the inherited slot when themed), so you can tell a run color set on the run from one cascaded from the theme — the one place PowerPoint exposes that direct-vs-inherited distinction.

pptlive.Anchor

Bases: ABC

Abstract base for text-bearing handles.

Concrete subclasses implement _text_range() (the COM TextRange to read and write) and anchor_id. text / set_text are derived and inherited.

com property

com: Any

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

anchor_id: str

Stable string identifier (e.g. notes:3, shape:2:1, ph:2:body).

slide abstractmethod property

slide: Slide

The slide this anchor lives on (used to resolve in-deck link jumps).

name property

name: str

A display name for this anchor. Defaults to its anchor_id.

text property

text: str

The anchor's plain text. PowerPoint separates paragraphs with \r.

set_text

set_text(text: str) -> None

Replace the anchor's text in place.

Embed \n (or \r) to start a new paragraph — each line becomes its own addressable para:S:N:P. For a soft line break that stays within one paragraph, embed \v (SOFT_BREAK). Targets the text range directly, never the Selection, so it doesn't move the user's view. Wrap in deck.edit(...) to preserve the viewed slide and collapse the block to one Ctrl-Z (see EditScope).

Source code in src/pptlive/_anchors.py
def set_text(self, text: str) -> None:
    """Replace the anchor's text in place.

    Embed `\\n` (or `\\r`) to start a new paragraph — each line becomes its
    own addressable `para:S:N:P`. For a *soft* line break that stays within
    one paragraph, embed `\\v` (`SOFT_BREAK`). Targets the text range
    directly, never the Selection, so it doesn't move the user's view. Wrap
    in `deck.edit(...)` to preserve the viewed slide and collapse the block
    to one Ctrl-Z (see `EditScope`).
    """
    with _com.translate_com_errors():
        self._text_range().Text = normalize_paragraph_breaks(text)

set_paragraphs

set_paragraphs(paragraphs: list[Any]) -> list[str]

Replace this anchor's text with a clean, per-paragraph list.

The safe alternative to newline inference for list authoring (the gpt-5.4 review's ask): each item is a plain string or a dict {"text": ..., "list_type"?, "indent_level"?, "alignment"?, "line_spacing"?, "size"?, ...} and becomes exactly one addressable para: — a newline inside an item is folded to a soft break, never a paragraph split. Per-item keys are forwarded to format_paragraph (spacing/alignment/indent) and format_text (font); list_type applies/removes the bullet ("none" strips it), resetting list state cleanly. Returns the new paragraphs' anchor_ids (empty for a text anchor with no paragraph view, e.g. notes). Wrap in deck.edit(...).

Source code in src/pptlive/_anchors.py
def set_paragraphs(self, paragraphs: list[Any]) -> list[str]:
    """Replace this anchor's text with a clean, per-paragraph list.

    The safe alternative to newline inference for list authoring (the gpt-5.4
    review's ask): each item is a plain string **or** a dict
    `{"text": ..., "list_type"?, "indent_level"?, "alignment"?, "line_spacing"?,
    "size"?, ...}` and becomes exactly one addressable `para:` — a newline
    inside an item is folded to a soft break, never a paragraph split. Per-item
    keys are forwarded to `format_paragraph` (spacing/alignment/indent) and
    `format_text` (font); `list_type` applies/removes the bullet
    (`"none"` strips it), resetting list state cleanly. Returns the new
    paragraphs' `anchor_id`s (empty for a text anchor with no paragraph view,
    e.g. notes). Wrap in `deck.edit(...)`.
    """
    items = [_coerce_paragraph_item(p) for p in paragraphs]
    if not items:
        raise ValueError("set_paragraphs needs at least one paragraph")
    joined = "\r".join(_as_single_paragraph(it["text"]) for it in items)
    with _com.translate_com_errors():
        self._text_range().Text = joined
    para_coll = getattr(self, "paragraphs", None)
    if para_coll is None:
        return []  # a text anchor without a paragraph view (notes) — text only
    new_ids: list[str] = []
    for idx, item in enumerate(items, start=1):
        para = para_coll[idx]
        new_ids.append(para.anchor_id)
        self._apply_paragraph_item(para, item)
    return new_ids

paragraph_count

paragraph_count() -> int

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

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_afterpoints 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

remove_list() -> None

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_format

reset_format() -> 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(...).

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(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(text: str | None = None, *, start: int | None = None, length: int | None = None) -> int

Remove text-run hyperlinks from this anchor; return how many were cleared.

With no span (text/start/length all omitted) clears all links in the anchor; otherwise clears just the addressed span (same addressing as set_link). Hyperlink.Delete() per span. A mutation: wrap in deck.edit(...).

Source code in src/pptlive/_anchors.py
def remove_link(
    self, text: str | None = None, *, start: int | None = None, length: int | None = None
) -> int:
    """Remove text-run hyperlinks from this anchor; return how many were cleared.

    With no span (`text`/`start`/`length` all omitted) clears **all** links in
    the anchor; otherwise clears just the addressed span (same addressing as
    `set_link`). `Hyperlink.Delete()` per span. A mutation: wrap in
    `deck.edit(...)`.
    """
    with _com.translate_com_errors():
        tr = self._text_range()
        if text is None and start is None and length is None:
            rows = links_in_range(tr)
            # Delete back-to-front: a cleared link re-merges its run, shifting
            # later runs — reverse order keeps each span's offsets valid.
            for row in reversed(rows):
                tr.Characters(int(row["start"]) + 1, int(row["length"])).ActionSettings(
                    _MOUSE_CLICK
                ).Hyperlink.Delete()
            return len(rows)
        span_start, span_len, _ = _resolve_span(tr, text, start, length)
        tr.Characters(span_start + 1, span_len).ActionSettings(_MOUSE_CLICK).Hyperlink.Delete()
        return 1
links() -> list[dict[str, Any]]

Every text-run hyperlink in this anchor — [{text, start, length, address, sub_address}] in document order (a read; see links_in_range).

Source code in src/pptlive/_anchors.py
def links(self) -> list[dict[str, Any]]:
    """Every text-run hyperlink in this anchor — `[{text, start, length, address,
    sub_address}]` in document order (a read; see `links_in_range`)."""
    with _com.translate_com_errors():
        return links_in_range(self._text_range())

pptlive.Paragraph

Paragraph(shape: Shape, index: int)

Bases: Anchor

One paragraph of a shape's text frame — anchor id para:S:N:P.

Located by 1-based paragraph index P within shape N (z-order) on slide S. Inherits every text verb (set_text, format_text, format_paragraph, apply_list, insert_paragraph_before/after); _text_range() is TextFrame.TextRange.Paragraphs(P, 1), so those verbs scope to just this paragraph. Resolves live on each access (the paragraph count drifts as text is inserted/deleted), raising AnchorNotFoundError if P is out of range or NoTextFrameError (via the shape) if the shape holds no text.

Source code in src/pptlive/_anchors.py
def __init__(self, shape: Shape, index: int) -> None:
    self._shape = shape
    self._index = int(index)

index property

index: int

1-based paragraph index within the shape's text frame.

text property

text: str

The paragraph's text, without the trailing paragraph break.

indent_level property

indent_level: int

PowerPoint outline/bullet level, 1-5.

delete

delete() -> None

Delete this paragraph (text + its break). The wrapper is spent.

Source code in src/pptlive/_anchors.py
def delete(self) -> None:
    """Delete this paragraph (text + its break). The wrapper is spent."""
    with _com.translate_com_errors():
        self._text_range().Delete()

set_paragraphs

set_paragraphs(paragraphs: list[Any]) -> list[str]

Not supported on a single paragraph — call it on the shape/cell anchor.

The base set_paragraphs replaces a whole text frame's paragraph list. A Paragraph's _text_range() is a single paragraph, so the inherited implementation would write a multi-paragraph block into one paragraph's range (corrupting it) and silently drop per-item formatting (no paragraphs view to address). Reject it explicitly instead.

Source code in src/pptlive/_anchors.py
def set_paragraphs(self, paragraphs: list[Any]) -> list[str]:
    """Not supported on a single paragraph — call it on the shape/cell anchor.

    The base `set_paragraphs` replaces a whole text frame's paragraph list.
    A `Paragraph`'s `_text_range()` is a single paragraph, so the inherited
    implementation would write a multi-paragraph block into one paragraph's
    range (corrupting it) and silently drop per-item formatting (no
    `paragraphs` view to address). Reject it explicitly instead.
    """
    raise ValueError(
        "set_paragraphs replaces a whole text frame's paragraph list and "
        f"cannot target a single paragraph anchor ({self.anchor_id!r}); call "
        "it on the shape or cell anchor that owns this paragraph."
    )

pptlive.ParagraphCollection

ParagraphCollection(shape: Shape)

Indexable, iterable view over the paragraphs of a shape's text frame.

shape.paragraphs[2] is the 2nd paragraph (1-based); iteration yields a Paragraph each; list() emits the structured dict used by the paragraphs CLI command. Raises NoTextFrameError (via the shape) if the shape holds no text.

Source code in src/pptlive/_anchors.py
def __init__(self, shape: Shape) -> None:
    self._shape = shape

list

list() -> list[dict[str, Any]]

Every paragraph as a structured dict, in order.

Source code in src/pptlive/_anchors.py
def list(self) -> list[dict[str, Any]]:
    """Every paragraph as a structured dict, in order."""
    out: list[dict[str, Any]] = []
    with _com.translate_com_errors():
        tr = self._shape._text_range()
        count = int(tr.Paragraphs().Count)
        for idx in range(1, count + 1):
            out.append(paragraph_to_dict(tr.Paragraphs(idx, 1), self._anchor_id(idx), idx))
    return out

pptlive.Notes

Notes(slide: Slide)

Bases: Anchor

The speaker-notes body of a slide — anchor id notes:S.

Resolves the notes-page body placeholder by PlaceholderFormat.Type == ppPlaceholderBody, not by a hard index, because the index varies across templates (spec.md / IMPLEMENTATION.md spike item). Reads return "" for an empty notes body; set_text replaces it.

Source code in src/pptlive/_anchors.py
def __init__(self, slide: Slide) -> None:
    self._slide = slide

Tables

A table is a shape on a slide (Shape.has_table / Shape.table), not a deck-scoped collection. Reach a table through its shape (slide.shapes[N].table) and address its cells as cell:S:N:R:C. A Cell is an Anchor, so doc.anchor_by_id("cell:4:5:1:1") returns a handle that works with set_text, format_text, and format_paragraph like any other anchor.

pptlive.Table

Table(shape: Shape)

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

com: Any

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

read() -> 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.

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

Chart(shape: Shape)

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

com: Any

Raw COM Chart (Shape.Chart), resolved live.

chart_type property

chart_type: str

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

read() -> 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.

Source code in src/pptlive/_charts.py
def read(self) -> dict[str, Any]:
    """Structured dump: chart kind + categories + series.

    `{slide, shape, anchor_id, chart_type, chart_type_code, categories,
    series:[{name, values}]}`. Side-effect-free.
    """
    with _com.translate_com_errors():
        chart = self.com
        type_code = int(chart.ChartType)
        sc = chart.SeriesCollection()
        count = int(sc.Count)
        cats = [_as_label(v) for v in sc(1).XValues] if count >= 1 else []
        series = [
            {"name": str(sc(i).Name), "values": [_as_number(v) for v in sc(i).Values]}
            for i in range(1, count + 1)
        ]
    return {
        "slide": self._shape.slide.index,
        "shape": self._shape.index,
        "anchor_id": self._shape.anchor_id,
        "chart_type": chart_type_name(type_code),
        "chart_type_code": type_code,
        "categories": cats,
        "series": series,
    }

set_data

set_data(categories: Sequence[str], series: SeriesInput) -> None

Replace the chart's data with categories × series.

categories are the X-axis labels; series is a name->values mapping (e.g. {"Revenue": [10, 20, 30]}) or an ordered sequence of (name, values) pairs. Series are written — and plotted — in insertion order (note bar charts render series bottom-to-top by Excel/PowerPoint convention, so the first series sits at the bottom; this is a render order, not a reorder of the data). Every series must have exactly len(categories) values. Raises ValueError for empty inputs or a length mismatch (before any COM). Wrap in deck.edit(...) for the one-Ctrl-Z fence.

Drives the chart's embedded Excel workbook: activate it, clear stale values, write the corner / series names (row 1) / categories (column A) / values, point SetSourceData at the exact range (string form), and close the workbook. See the module docstring for why no Excel Table is used.

Source code in src/pptlive/_charts.py
def set_data(self, categories: Sequence[str], series: SeriesInput) -> None:
    """Replace the chart's data with `categories` × `series`.

    `categories` are the X-axis labels; `series` is a name->values mapping
    (e.g. `{"Revenue": [10, 20, 30]}`) or an ordered sequence of
    `(name, values)` pairs. Series are written — and plotted — in insertion
    order (note bar charts render series bottom-to-top by Excel/PowerPoint
    convention, so the first series sits at the bottom; this is a render
    order, not a reorder of the data). Every series must have exactly
    `len(categories)` values. Raises `ValueError` for empty inputs or a length
    mismatch (before any COM). Wrap in `deck.edit(...)` for the one-Ctrl-Z fence.

    Drives the chart's embedded Excel workbook: activate it, clear stale
    values, write the corner / series names (row 1) / categories (column A) /
    values, point `SetSourceData` at the exact range (string form), and close
    the workbook. See the module docstring for why no Excel Table is used.
    """
    cats = [str(c) for c in categories]
    norm = _normalize_series(series)
    if not cats:
        raise ValueError("set_data requires at least one category")
    if not norm:
        raise ValueError("set_data requires at least one series")
    for name, values in norm:
        if len(values) != len(cats):
            raise ValueError(
                f"series {name!r} has {len(values)} values but there are {len(cats)} categories"
            )

    ncols = 1 + len(norm)
    nrows = 1 + len(cats)

    def _write() -> None:
        with _com.translate_com_errors():
            chart = self.com
            cdata = chart.ChartData
            cdata.Activate()
            wb = cdata.Workbook
            try:
                ws = wb.Worksheets(1)
                ws.UsedRange.ClearContents()
                ws.Cells(1, 1).Value = ""  # corner
                for c, (name, _values) in enumerate(norm, start=2):
                    ws.Cells(1, c).Value = name
                for r, cat in enumerate(cats, start=2):
                    # Force the category column to Text, else Excel coerces a
                    # numeric-looking label ("2026") to a number and it reads
                    # back as "2026.0". Categories are labels, never values.
                    cell = ws.Cells(r, 1)
                    cell.NumberFormat = "@"
                    cell.Value = cat
                for c, (_name, values) in enumerate(norm, start=2):
                    for r, v in enumerate(values, start=2):
                        ws.Cells(r, c).Value = v
                target = f"{_sheet_ref(ws.Name)}!$A$1:${_col_letter(ncols)}${nrows}"
                chart.SetSourceData(target)
            finally:
                wb.Close()
        # The embedded-Excel commit can SILENTLY race: a first-chance
        # RPC_S_CALL_FAILED (0x800706BE) during the workbook teardown leaves
        # SetSourceData bound to an empty range with *no* Python exception, so
        # the chart reads back blank (SeriesCollection empty). Verify the write
        # actually landed; if not, raise a retryable busy so retry_on_busy
        # re-runs the whole idempotent sequence (a fresh ChartData.Activate
        # re-establishes the workbook). Observed live ~50% of the time on the
        # chart smoke test before this guard.
        if not self._reflects_data(len(norm), len(cats)):
            raise PowerPointBusyError(
                "chart data did not commit (embedded-Excel teardown raced); retrying"
            )

    # The embedded workbook can be momentarily unavailable right after the
    # chart is created (RPC_S_UNKNOWN_IF), and the commit above can race; the
    # write is a clean rewrite, so retrying the whole sequence is safe. See
    # `_com.retry_on_busy`.
    _com.retry_on_busy(_write, attempts=5)

recolor_text

recolor_text(color: str | int | tuple[int, int, int]) -> dict[str, Any]

Set the font color of every shown chart text element (one Ctrl-Z).

A chart has no addressable text frame of its own — its text is split across the legend, axis tick labels, title, and data labels, each its own COM element — so there is no per-anchor format_text path. This is the coarse fix PPTLIVE-009 asked for: recolor all chart text at once, the move a dark- (or any custom-background) theme needs when the inherited black axis/legend text goes invisible.

color is a "#RRGGBB" / "RRGGBB" hex string, an (r, g, b) tuple, or a raw RGB int (same forms as format_text's color). Raises ValueError for a malformed color (before any COM). Recolors only the elements that are actually present: the legend/title are guarded by HasLegend / HasTitle; the axis tick labels and per-series data labels are best-effort (an absent axis — e.g. a pie chart has none — is simply skipped, not an error), so it never adds a legend, title, or labels the deck didn't show. Always sets the ChartArea global text default (the modern Format.TextFrame2 path) so any text shown later inherits the color too. Wrap in deck.edit(...) for view preservation + the one-Ctrl-Z fence. Returns {ok, slide, shape, anchor_id, color, recolored, series_data_labels} where recolored lists the element kinds that were touched.

The chart Font model differs from a text frame's: a chart element's Font.Color is itself the RGB long (set/read directly), not a ColorFormat with .RGB; and Series.DataLabels is a method. Chart.HasAxis is an Excel-ism PowerPoint's chart COM rejects, so axes are probed by attempting the set rather than asking first.

The core recolor (chart area + legend/title/data labels) runs under retry_on_busy: every set is idempotent (recoloring to the same RGB twice is a no-op), so a transient busy mid-sequence retries the whole block rather than leaving a half-recolored chart — the same safety set_data uses. The axes stay best-effort outside that fence (they're already probe-and-skip).

Source code in src/pptlive/_charts.py
def recolor_text(self, color: str | int | tuple[int, int, int]) -> dict[str, Any]:
    """Set the font color of **every shown** chart text element (one Ctrl-Z).

    A chart has no addressable text frame of its own — its text is split across
    the legend, axis tick labels, title, and data labels, each its own COM
    element — so there is no per-anchor `format_text` path. This is the coarse
    fix PPTLIVE-009 asked for: recolor all chart text at once, the move a dark-
    (or any custom-background) theme needs when the inherited black axis/legend
    text goes invisible.

    `color` is a `"#RRGGBB"` / `"RRGGBB"` hex string, an `(r, g, b)` tuple, or a
    raw RGB int (same forms as `format_text`'s `color`). Raises `ValueError` for
    a malformed color (before any COM). Recolors only the elements that are
    actually **present**: the legend/title are guarded by `HasLegend` /
    `HasTitle`; the axis tick labels and per-series data labels are best-effort
    (an absent axis — e.g. a pie chart has none — is simply skipped, not an
    error), so it never adds a legend, title, or labels the deck didn't show.
    Always sets the `ChartArea` global text default (the modern
    `Format.TextFrame2` path) so any text shown later inherits the color too.
    Wrap in `deck.edit(...)` for view preservation + the one-Ctrl-Z fence.
    Returns `{ok, slide, shape, anchor_id, color, recolored, series_data_labels}`
    where `recolored` lists the element kinds that were touched.

    The chart `Font` model differs from a text frame's: a chart element's
    `Font.Color` is *itself* the RGB long (set/read directly), not a
    `ColorFormat` with `.RGB`; and `Series.DataLabels` is a **method**.
    `Chart.HasAxis` is an Excel-ism PowerPoint's chart COM rejects, so axes are
    probed by attempting the set rather than asking first.

    The core recolor (chart area + legend/title/data labels) runs under
    `retry_on_busy`: every set is idempotent (recoloring to the same RGB twice
    is a no-op), so a transient busy mid-sequence retries the whole block rather
    than leaving a half-recolored chart — the same safety `set_data` uses. The
    axes stay best-effort outside that fence (they're already probe-and-skip).
    """
    rgb = parse_color(color)  # ValueError before any COM

    def _recolor_core() -> tuple[list[str], int]:
        recolored: list[str] = []
        labeled = 0
        with _com.translate_com_errors():
            chart = self.com
            # Global default for all chart text (modern TextFrame2 path);
            # reliably present, and covers text elements shown later.
            chart.ChartArea.Format.TextFrame2.TextRange.Font.Fill.ForeColor.RGB = rgb
            recolored.append("chart_area")
            if bool(chart.HasLegend):
                chart.Legend.Font.Color = rgb
                recolored.append("legend")
            if bool(chart.HasTitle):
                chart.ChartTitle.Font.Color = rgb
                recolored.append("title")
            sc = chart.SeriesCollection()
            for i in range(1, int(sc.Count) + 1):
                s = sc(i)
                if bool(s.HasDataLabels):
                    s.DataLabels().Font.Color = rgb
                    labeled += 1
            if labeled:
                recolored.append("data_labels")
        return recolored, labeled

    recolored, labeled = _com.retry_on_busy(_recolor_core)

    def _attempt_axis(axis_type: int) -> bool:
        """Set an axis's tick-label color; skip (False) if that axis is absent.

        `Chart.HasAxis` is unreliable on PowerPoint's chart COM, so we attempt
        the set and treat a COM failure (e.g. a pie chart has no axes) as
        "not present" rather than an error.
        """
        try:
            with _com.translate_com_errors():
                self.com.Axes(axis_type).TickLabels.Font.Color = rgb
            return True
        except PowerPointBusyError:
            raise  # a transient busy is retryable — don't mask it as "axis absent"
        except PptliveError:
            return False

    for axis_type, name in (
        (XlAxisType.CATEGORY, "category_axis"),
        (XlAxisType.VALUE, "value_axis"),
    ):
        if _attempt_axis(axis_type):
            recolored.append(name)
    return {
        "ok": True,
        "slide": self._shape.slide.index,
        "shape": self._shape.index,
        "anchor_id": self._shape.anchor_id,
        "color": color_hex(rgb),
        "recolored": recolored,
        "series_data_labels": labeled,
    }

SmartArt

A SmartArt diagram is a shape too (Shape.has_smartart / Shape.smartart); its content is a tree of nodes. SmartArt reads the layout kind and the nested node tree, and replaces it with set_nodes — a flat list of strings, or {text, children} mappings that nest. Create one via shapes.add_smartart(kind, nodes).

pptlive.SmartArt

SmartArt(shape: Shape)

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

com: Any

Raw COM SmartArt (Shape.SmartArt), resolved live.

layout_id property

layout_id: str

The diagram's layout URN (SmartArt.Layout.Id), stable across installs.

kind property

kind: str

Friendly layout name (e.g. "process", "orgchart"); falls back to the URN.

read

read() -> 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.

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

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

com: Any

Raw COM Theme (SlideMaster.Theme), resolved live.

read

read() -> 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.

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

com: Any

Raw COM SlideMaster, resolved live.

headers_footers property

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

read() -> 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.

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

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 structure — sections & headers/footers

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)

pptlive.HeadersFooters

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

read

read() -> 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.

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

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

set_date(*, visible: bool | None = None, text: str | None = None, fmt: int | None = None) -> dict[str, Any]

Set the date/time placeholder; return the resulting read.

Pass text for a fixed date string (sets UseFormat=msoFalse), or fmt (a raw PpDateTimeFormat int) for an auto-updating date (sets UseFormat=msoTrue); they're mutually exclusive. Passing either auto-shows the element unless visible=False is explicit. A mutation: wrap in deck.edit(...).

Source code in src/pptlive/_headersfooters.py
def set_date(
    self,
    *,
    visible: bool | None = None,
    text: str | None = None,
    fmt: int | None = None,
) -> dict[str, Any]:
    """Set the date/time placeholder; return the resulting read.

    Pass `text` for a **fixed** date string (sets `UseFormat=msoFalse`), or
    `fmt` (a raw `PpDateTimeFormat` int) for an **auto-updating** date (sets
    `UseFormat=msoTrue`); they're mutually exclusive. Passing either auto-shows
    the element unless `visible=False` is explicit. A mutation: wrap in
    `deck.edit(...)`.
    """
    if text is not None and fmt is not None:
        raise ValueError("set_date() takes either text (fixed) or fmt (auto), not both")
    with _com.translate_com_errors():
        date = self._hf.DateAndTime
        given = text is not None or fmt is not None
        show = visible if visible is not None else (True if given else None)
        if show is not None:
            date.Visible = _tri(show)
        if text is not None:
            date.UseFormat = _tri(False)
            date.Text = str(text)
        if fmt is not None:
            date.UseFormat = _tri(True)
            date.Format = int(fmt)
    return self.read()

Rendering

slide.export_image renders one slide to an image; deck.snapshot renders the whole deck (or a slide selection) to one PNG per slide so a vision model can see every slide cheaply. Its max_dim long-edge pixel cap gives a predictable, uniform per-slide token budget (a model is billed on pixel area, not DPI); pass exact width / height instead for a fixed per-slide size (they override max_dim, and passing both forms is a ValueError). Both are reads — they reflect the current unsaved state but leave the viewed slide and Selection untouched. Each rendered slide comes back as a Snapshot.

pptlive.Snapshot dataclass

Snapshot(slide: int, image: bytes, path: Path | None = None)

One rendered slide of a deck.

slide is the 1-based slide index; image is the encoded image bytes in the chosen fmt (PNG by default, JPEG when fmt="jpg") — feed it straight to a vision model, or write it yourself. path is where the image was written when a snapshot(out=...) call saved it to disk, otherwise None.

Saving & export

Three explicit, never-implicit verbs on Presentation (pptlive never auto-saves): deck.save() persists to the existing file; deck.save_as(path, *, fmt="pptx", overwrite=False) writes a .pptx and rebinds the working file to it (the open deck becomes that file, like PowerPoint's Save-As), refusing to clobber unless overwrite=True; and deck.export_pdf(path) writes a pixel-faithful PDF as a read — unlike save_as it neither rebinds the working file nor clears its dirty flag, so your .pptx is untouched. deck.saved (the Presentation.Saved dirty flag) and deck.path ride on every status deck row so an agent can see unsaved state. save() on a never-saved deck raises UnsavedPresentationError rather than letting PowerPoint silently route the file to a default cloud folder.

with pl.attach() as ppt:
    deck = ppt.presentations.active
    if not deck.saved:
        deck.save()                       # persist in place (must already have a path)
    deck.save_as("C:/out/v2.pptx")        # write + rebind the working file
    deck.export_pdf("C:/out/deck.pdf")    # a read — working file untouched

Media & narrated-video export

The "build a deck, narrate it, export a video" path. slide.add_audio(path) / slide.add_video(path) insert an audio/video clip (embedded by default; link=True keeps the file on disk). autoplay plays the clip on slide entry, hide_icon hides the audio icon while idle (audio only), and pace_slide auto-advances the slide to the clip's length — so an exported video paces itself to the narration. Each shape read carries a media dict ({type, length_s, muted, volume, autoplay}) and has_media.

deck.export_video(path) exports the deck to an MP4 via PowerPoint's async CreateVideo. Like export_pdf it is a read (no rebind, dirty flag preserved). It blocks by default, polling to completion and returning a VideoExportResult; pass wait=False to return the in-flight status immediately and poll deck.video_status() until it reports done. A failed or timed-out encode raises VideoExportError.

with pl.attach() as ppt:
    deck = ppt.presentations.active
    with deck.edit("Narrate the deck"):
        deck.slides[1].add_audio("intro.mp3")     # autoplay + pace the slide (defaults)
        deck.slides[2].add_video("demo.mp4")      # stays visible; same knobs
    result = deck.export_video("C:/out/deck.mp4", resolution=1080)
    assert result.ok and result.status == "done"  # result.path is the written MP4

pptlive.VideoExportResult dataclass

VideoExportResult(path: str, status: str, status_code: int, ok: bool)

The outcome of a deck.export_video(...) request or a video_status() poll.

status is the friendly CreateVideoStatus token (queued/in_progress/ done/failed/none); ok is True only on a finished, non-empty file. path is the absolute MP4 path for an export, or "" for a bare status poll.

Slide show

deck.show drives a running slide show like a presenter's clicker — start, end, next, previous, goto(n), black() / white() / resume(), and the read-only state(). Unlike the polite edit verbs, these deliberately drive what's on screen, so show is not wrapped in edit().

pptlive.SlideShow

SlideShow(deck: Presentation)

Live slide-show control for one deck (deck.show).

deck.show.start()            # run from the top
deck.show.goto(5)            # jump to slide 5
deck.show.next()             # advance
deck.show.black()            # blank the screen (B); resume() to come back
deck.show.state()            # {"running": True, "state": "running", ...}
deck.show.end()              # exit the show

Every control verb returns the post-action state() dict, so a caller never has to follow up with a separate read. state() itself is the only side-effect-free verb and the only one that never raises when no show is running (it reports running: false).

Source code in src/pptlive/_show.py
def __init__(self, deck: Presentation) -> None:
    self._deck = deck

com property

com: Any

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

window: Any | None

The live SlideShowWindow COM object, or None if no show is running.

view property

view: Any | None

The live SlideShowView COM object, or None if no show is running.

is_running

is_running() -> bool

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() -> dict[str, Any]

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

next() -> dict[str, Any]

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

slide_index: int | None

The viewed slide (1-based), or None if no Normal/Slide-view window exists.

selection_type class-attribute instance-attribute

selection_type: int = int(PpSelectionType.NONE)

The PpSelectionType at snapshot time.

shape_names class-attribute instance-attribute

shape_names: tuple[str, ...] = ()

Names of the selected shapes, when selection_type is SHAPES.

Caveat: PowerPoint allows duplicate shape names, and Shapes.Range (used by restore) resolves by name, not the stable Shape.Id. If two selected shapes share a name, restore may re-select the wrong shape (or fall back to clearing the selection). The stable Id can't be passed to Range, so this is a known COM limitation of the politeness restore, not a correctness bug in an edit — the user's selection is cosmetic state, restored best-effort.

Units

Geometry is in points throughout (1 inch = 72 pt). These helpers convert so you needn't hardcode multiplications; EMUs never surface.

pptlive.units

Unit helpers — points throughout, never EMUs.

PowerPoint's COM layer measures geometry in points (1 inch = 72 pt), so pptlive does too: Shape.Left/Top/Width/Height, slide dimensions, indents — all points. EMUs are an OOXML / python-pptx concern and never surface here.

These helpers exist so an agent can write pl.units.inches(1.5) instead of hardcoding 108. points() is the identity, included for symmetry and to make "this number is already in points" explicit at a call site.

shape.move(left=inches(1), top=cm(4))

points

points(value: float) -> float

Return value unchanged — it is already in points (PowerPoint's unit).

Source code in src/pptlive/units.py
def points(value: float) -> float:
    """Return `value` unchanged — it is already in points (PowerPoint's unit)."""
    return float(value)

inches

inches(value: float) -> float

Convert inches to points (1 in = 72 pt).

Source code in src/pptlive/units.py
def inches(value: float) -> float:
    """Convert inches to points (1 in = 72 pt)."""
    return float(value) * _POINTS_PER_INCH

cm

cm(value: float) -> float

Convert centimetres to points (1 in = 2.54 cm = 72 pt).

Source code in src/pptlive/units.py
def cm(value: float) -> float:
    """Convert centimetres to points (1 in = 2.54 cm = 72 pt)."""
    return float(value) * _POINTS_PER_INCH / _CM_PER_INCH

mm

mm(value: float) -> float

Convert millimetres to points.

Source code in src/pptlive/units.py
def mm(value: float) -> float:
    """Convert millimetres to points."""
    return cm(float(value) / 10.0)

Constants

Typed IntEnums for the Mso* / Pp* / Xl* magic constants, plus friendly-string coercers ("title", "two_content", "star", "column") that map names to the right int the way an LLM would phrase them.

pptlive.constants

Typed enums for the PowerPoint magic constants pptlive uses.

Values mirror the official Mso* / Pp* enumerations exactly. Resist the urge to pre-populate — add entries only as a feature needs them (the wordlive rule). Friendly string aliases ("title", "textbox") coerce to the right int the way wordlive's alignment names do.

MsoTriState

Bases: IntEnum

Office's tri-state boolean — Shape.HasTextFrame, HasTable, etc.

COM returns TRUE as -1. MIXED (-2) only appears for multi-shape selections, which pptlive's anchors never hold.

MsoShapeType

Bases: IntEnum

Shape.Type values — what kind of object a shape is.

The subset pptlive reports in slide.read(). Emitted as lowercase strings via shape_type_name() so JSON consumers match "placeholder" / "picture" without importing the enum.

MsoAutoSize

Bases: IntEnum

TextFrame2.AutoSize — how a text frame resizes to fit its text.

Read off the modern TextFrame2 (the classic TextFrame.AutoSize returns the mixed sentinel on current builds — see scripts/text_model_spike.py).

PpPlaceholderType

Bases: IntEnum

PlaceholderFormat.Type values — the semantic role of a placeholder.

ph:S:KIND resolves a friendly KIND to one of these (see placeholder_types_for). Reported by placeholder_kind_name() as a friendly string in slide.read().

PpSelectionType

Bases: IntEnum

Selection.Type — what the user currently has selected in the window.

Used by the politeness snapshot/restore: a SHAPES selection is re-selected by name on scope exit; TEXT and NONE fall back to restoring just the viewed slide. (Spike: confirm a shape-range selection round-trips cleanly.)

PpViewType

Bases: IntEnum

DocumentWindow.ViewType — the subset pptlive checks.

NORMAL and SLIDE are the views where View.Slide (the slide the user is looking at) is meaningful; we snapshot/restore it for politeness.

PpSlideLayout

Bases: IntEnum

Legacy Slides.Add(Index, Layout) layout codes (the deprecated path).

Only reached as a fallback when a deck exposes no CustomLayouts for the modern AddSlide(Index, CustomLayout). Friendly layout names resolve to a real CustomLayout instead (see match_layout_name), so this enum stays deliberately tiny — TEXT (a title-and-content slide) is the fallback default.

MsoTextOrientation

Bases: IntEnum

Shapes.AddTextbox orientation. pptlive creates horizontal text boxes.

MsoAutoShapeType

Bases: IntEnum

The Shapes.AddShape(Type, …) autoshape geometries pptlive names.

A curated common subset of the (large) MsoAutoShapeType enumeration — added as a feature needs them (the wordlive rule), not pre-populated. Friendly names ("rectangle", "oval", "arrow") resolve to these via autoshape_type_for; a raw int still passes through for the long tail.

MsoZOrderCmd

Bases: IntEnum

Shape.ZOrder(cmd) — how to restack a shape relative to its siblings.

Only the four that move a shape within the slide's z-stack; the in-front-of/behind-text variants aren't named (added as a feature needs them).

MsoAlignCmd

Bases: IntEnum

ShapeRange.Align(cmd, RelativeTo) — how to align a set of shapes.

MsoDistributeCmd

Bases: IntEnum

ShapeRange.Distribute(cmd, RelativeTo) — even spacing on one axis.

MsoConnectorType

Bases: IntEnum

Shapes.AddConnector(type, …) — the connector line geometry.

PpParagraphAlignment

Bases: IntEnum

ParagraphFormat.Alignment — horizontal alignment of a paragraph.

PpBulletType

Bases: IntEnum

ParagraphFormat.Bullet.Type — what kind of bullet a paragraph carries.

MsoColorType

Bases: IntEnum

ColorFormat.Type — how a font/fill color is sourced.

The spike-verified (scripts/batch2_spike.py) signal that finally answers the "directly set vs theme-cascaded" question the Claude Desktop session raised: a freshly-inherited run reads SCHEME, an explicit RGB reads RGB.

MsoFillType

Bases: IntEnum

Fill.Type — what kind of fill a shape / background carries (read-back).

MsoGradientStyle

Bases: IntEnum

Fill.GradientStyle — the direction a gradient sweeps.

MsoShadowStyle

Bases: IntEnum

Shadow.Style — inner vs outer shadow (the read-back the spike pinned).

Setting individual shadow props pushes Shadow.Type to the mixed (-2) sentinel, so .Style (not .Type) is the reliable read-back.

PpSlideShowState

Bases: IntEnum

SlideShowView.State — what a running slide show is currently doing.

BLACK_SCREEN/WHITE_SCREEN are the presenter "blank the screen" states (the B / W keys); setting State back to RUNNING resumes. pptlive reports DONE for a deck with no running show (the show window is gone), so the state read has a value to return without raising.

PpSlideShowRangeType

Bases: IntEnum

SlideShowSettings.RangeType — which slides a show runs.

pptlive sets SLIDE_RANGE only to honor show.start(from_slide=...); the default ALL runs the whole deck.

PpShapeFormat

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.

PpMediaType

Bases: IntEnum

Shape.MediaType — the kind of media a media shape holds.

PpMediaTaskStatus

Bases: IntEnum

Presentation.CreateVideoStatus — the async MP4-encode task state.

NONE is "no export has been requested" (the idle state). QUEUED / IN_PROGRESS are mid-encode; DONE / FAILED are terminal. export_video polls this to completion.

MsoAnimEffect

Bases: IntEnum

Sequence.AddEffect EffectId — the curated common animation effects.

A small, well-documented subset of the large MsoAnimEffect enum; pass a raw int to reach any effect pptlive hasn't named. An effect animates a shape in by default and out when applied as an exit effect (Effect.Exit).

MsoAnimTriggerType

Bases: IntEnum

Effect.Timing.TriggerType — when an animation fires.

is_true

is_true(tristate: Any) -> bool

True iff an MsoTriState-valued COM property is msoTrue (-1).

bool(shape.HasTextFrame) already works for the TRUE/FALSE pair, but this spells the intent out and ignores the MIXED/TOGGLE sentinels.

Source code in src/pptlive/constants.py
def is_true(tristate: Any) -> bool:
    """True iff an MsoTriState-valued COM property is `msoTrue` (-1).

    `bool(shape.HasTextFrame)` already works for the TRUE/FALSE pair, but this
    spells the intent out and ignores the MIXED/TOGGLE sentinels.
    """
    try:
        return int(tristate) == int(MsoTriState.TRUE)
    except (TypeError, ValueError):
        return bool(tristate)

tristate_value

tristate_value(tristate: Any) -> bool | str

An MsoTriState font property -> True / False / "mixed".

Unlike is_true (which collapses MIXED to False), this preserves the msoTriStateMixed (-2) signal a font property like Font.Bold returns when a range spans both bold and non-bold runs — so a reader can tell "this paragraph is uniformly not-bold" apart from "this paragraph mixes bold runs".

Source code in src/pptlive/constants.py
def tristate_value(tristate: Any) -> bool | str:
    """An MsoTriState font property -> `True` / `False` / `"mixed"`.

    Unlike `is_true` (which collapses MIXED to False), this preserves the
    `msoTriStateMixed` (-2) signal a font property like `Font.Bold` returns when
    a *range* spans both bold and non-bold runs — so a reader can tell "this
    paragraph is uniformly not-bold" apart from "this paragraph mixes bold runs".
    """
    try:
        v = int(tristate)
    except (TypeError, ValueError):
        return bool(tristate)
    if v == int(MsoTriState.MIXED):
        return "mixed"
    return v == int(MsoTriState.TRUE)

shape_type_name

shape_type_name(value: Any) -> str

Friendly lowercase name for a Shape.Type int (e.g. 14 -> "placeholder").

Unknown values render as "type:<n>" rather than raising — a read should never fail because PowerPoint grew a shape kind we haven't enumerated.

Source code in src/pptlive/constants.py
def shape_type_name(value: Any) -> str:
    """Friendly lowercase name for a `Shape.Type` int (e.g. 14 -> "placeholder").

    Unknown values render as `"type:<n>"` rather than raising — a read should
    never fail because PowerPoint grew a shape kind we haven't enumerated.
    """
    try:
        return _SHAPE_TYPE_NAMES.get(int(value), f"type:{int(value)}")
    except (TypeError, ValueError):
        return "unknown"

autosize_name

autosize_name(value: Any) -> str

Friendly name for a TextFrame2.AutoSize int (e.g. 2 -> "shape_to_fit_text").

Source code in src/pptlive/constants.py
def autosize_name(value: Any) -> str:
    """Friendly name for a `TextFrame2.AutoSize` int (e.g. 2 -> "shape_to_fit_text")."""
    try:
        return _AUTOSIZE_NAMES.get(int(value), f"autosize:{int(value)}")
    except (TypeError, ValueError):
        return "unknown"

placeholder_types_for

placeholder_types_for(kind: str) -> tuple[PpPlaceholderType, ...]

Accepted PpPlaceholderTypes for a friendly placeholder KIND.

Raises ValueError for an unknown KIND (with the valid set), so callers can surface a clean message before touching COM.

Source code in src/pptlive/constants.py
def placeholder_types_for(kind: str) -> tuple[PpPlaceholderType, ...]:
    """Accepted `PpPlaceholderType`s for a friendly placeholder KIND.

    Raises `ValueError` for an unknown KIND (with the valid set), so callers can
    surface a clean message before touching COM.
    """
    try:
        return _PLACEHOLDER_KINDS[kind.lower()]
    except KeyError:
        raise ValueError(
            f"unknown placeholder kind {kind!r}; expected one of {sorted(_PLACEHOLDER_KINDS)}"
        ) from None

placeholder_kind_name

placeholder_kind_name(value: Any) -> str

Friendly name for a PlaceholderFormat.Type int (e.g. 1 -> "title").

Source code in src/pptlive/constants.py
def placeholder_kind_name(value: Any) -> str:
    """Friendly name for a `PlaceholderFormat.Type` int (e.g. 1 -> "title")."""
    try:
        return _PLACEHOLDER_TYPE_NAMES.get(int(value), f"placeholder:{int(value)}")
    except (TypeError, ValueError):
        return "placeholder"

match_layout_name

match_layout_name(available: Sequence[str], requested: str) -> int | None

1-based index into available of the layout matching requested, else None.

Matches case/separator-insensitively against the deck's actual layout names first — so any template, including one whose layouts were renamed, resolves by its real name — then falls back to a small friendly-alias table for the standard Office layouts. Returns None when nothing matches; callers raise LayoutNotFoundError carrying available so an agent can pick a valid name.

Source code in src/pptlive/constants.py
def match_layout_name(available: Sequence[str], requested: str) -> int | None:
    """1-based index into `available` of the layout matching `requested`, else None.

    Matches case/separator-insensitively against the deck's *actual* layout names
    first — so any template, including one whose layouts were renamed, resolves
    by its real name — then falls back to a small friendly-alias table for the
    standard Office layouts. Returns None when nothing matches; callers raise
    `LayoutNotFoundError` carrying `available` so an agent can pick a valid name.
    """
    norm_available = [_normalize_name(name) for name in available]
    want = _normalize_name(requested)
    if not want:
        return None
    if want in norm_available:
        return norm_available.index(want) + 1
    canonical = _LAYOUT_ALIASES.get(want)
    if canonical is not None and canonical in norm_available:
        return norm_available.index(canonical) + 1
    return None

autoshape_type_for

autoshape_type_for(name: str | int) -> int

Friendly autoshape name (or a raw MsoAutoShapeType int) -> the int.

Names match case/separator-insensitively ("Rounded Rectangle", "rounded_rectangle", and "roundrect" all resolve). A raw int passes through unchanged — the escape hatch for autoshapes pptlive hasn't named. Raises ValueError (listing the friendly names) for an unknown name.

Source code in src/pptlive/constants.py
def autoshape_type_for(name: str | int) -> int:
    """Friendly autoshape name (or a raw `MsoAutoShapeType` int) -> the int.

    Names match case/separator-insensitively (`"Rounded Rectangle"`,
    `"rounded_rectangle"`, and `"roundrect"` all resolve). A raw int passes
    through unchanged — the escape hatch for autoshapes pptlive hasn't named.
    Raises `ValueError` (listing the friendly names) for an unknown name.
    """
    if isinstance(name, bool):
        raise ValueError(f"invalid autoshape type: {name!r}")
    if isinstance(name, int):
        return int(name)
    found = _AUTOSHAPE_NAMES.get(_normalize_name(name))
    if found is None:
        choices = ", ".join(AUTOSHAPE_CHOICES)
        raise ValueError(f"unknown autoshape {name!r}; expected one of: {choices}")
    return int(found)

zorder_cmd_for

zorder_cmd_for(name: str | int) -> int

Friendly z-order name (or a raw MsoZOrderCmd int) -> the int.

"front"/"back"/"forward"/"backward" (and the verbose "bring_to_front" etc.) match case/separator-insensitively. A raw int passes through. Raises ValueError (listing the friendly names) for an unknown name — symmetric with autoshape_type_for.

Source code in src/pptlive/constants.py
def zorder_cmd_for(name: str | int) -> int:
    """Friendly z-order name (or a raw `MsoZOrderCmd` int) -> the int.

    `"front"`/`"back"`/`"forward"`/`"backward"` (and the verbose `"bring_to_front"`
    etc.) match case/separator-insensitively. A raw int passes through. Raises
    `ValueError` (listing the friendly names) for an unknown name — symmetric with
    `autoshape_type_for`.
    """
    if isinstance(name, bool):
        raise ValueError(f"invalid z-order command: {name!r}")
    if isinstance(name, int):
        return int(name)
    found = _ZORDER_NAMES.get(_normalize_name(name))
    if found is None:
        choices = ", ".join(ZORDER_CHOICES)
        raise ValueError(f"unknown z-order command {name!r}; expected one of: {choices}")
    return int(found)

align_cmd_for

align_cmd_for(name: str | int) -> int

Friendly align name (or a raw MsoAlignCmd int) -> the int.

"left"/"center"/"right" (horizontal edges) and "top"/"middle"/ "bottom" (vertical edges) match case/separator-insensitively; a raw int passes through. Raises ValueError (listing the names) for an unknown name.

Source code in src/pptlive/constants.py
def align_cmd_for(name: str | int) -> int:
    """Friendly align name (or a raw `MsoAlignCmd` int) -> the int.

    `"left"`/`"center"`/`"right"` (horizontal edges) and `"top"`/`"middle"`/
    `"bottom"` (vertical edges) match case/separator-insensitively; a raw int
    passes through. Raises `ValueError` (listing the names) for an unknown name.
    """
    if isinstance(name, bool):
        raise ValueError(f"invalid align command: {name!r}")
    if isinstance(name, int):
        return int(name)
    found = _ALIGN_NAMES.get(_normalize_name(name))
    if found is None:
        choices = ", ".join(ALIGN_CHOICES)
        raise ValueError(f"unknown align command {name!r}; expected one of: {choices}")
    return int(found)

distribute_cmd_for

distribute_cmd_for(name: str | int) -> int

Friendly distribute name (or a raw MsoDistributeCmd int) -> the int.

"horizontal" / "vertical" match case-insensitively; a raw int passes through. Raises ValueError (listing the names) for an unknown name.

Source code in src/pptlive/constants.py
def distribute_cmd_for(name: str | int) -> int:
    """Friendly distribute name (or a raw `MsoDistributeCmd` int) -> the int.

    `"horizontal"` / `"vertical"` match case-insensitively; a raw int passes
    through. Raises `ValueError` (listing the names) for an unknown name.
    """
    if isinstance(name, bool):
        raise ValueError(f"invalid distribute command: {name!r}")
    if isinstance(name, int):
        return int(name)
    found = _DISTRIBUTE_NAMES.get(_normalize_name(name))
    if found is None:
        choices = ", ".join(DISTRIBUTE_CHOICES)
        raise ValueError(f"unknown distribute command {name!r}; expected one of: {choices}")
    return int(found)

relative_to_for

relative_to_for(value: str | int | bool) -> int

Coerce a relative-to choice to the RelativeTo MsoTriState int.

"slide" -> msoTrue (align/distribute against the slide); "selection" -> msoFalse (against the selection's own bounding box). A bool or raw int passes through (True/non-zero -> msoTrue). Raises ValueError for an unknown name.

Source code in src/pptlive/constants.py
def relative_to_for(value: str | int | bool) -> int:
    """Coerce a relative-to choice to the `RelativeTo` `MsoTriState` int.

    `"slide"` -> `msoTrue` (align/distribute against the slide); `"selection"` ->
    `msoFalse` (against the selection's own bounding box). A bool or raw int
    passes through (`True`/non-zero -> msoTrue). Raises `ValueError` for an
    unknown name.
    """
    if isinstance(value, bool):
        return int(MsoTriState.TRUE if value else MsoTriState.FALSE)
    if isinstance(value, int):
        return int(MsoTriState.TRUE if value else MsoTriState.FALSE)
    found = _RELATIVE_TO_NAMES.get(_normalize_name(value))
    if found is None:
        choices = ", ".join(RELATIVE_TO_CHOICES)
        raise ValueError(f"unknown relative-to {value!r}; expected one of: {choices}")
    return int(MsoTriState.TRUE if found else MsoTriState.FALSE)

connector_type_for

connector_type_for(name: str | int) -> int

Friendly connector type (or a raw MsoConnectorType int) -> the int.

"straight" / "elbow" / "curved" match case-insensitively; a raw int passes through. Raises ValueError (listing the names) for an unknown name.

Source code in src/pptlive/constants.py
def connector_type_for(name: str | int) -> int:
    """Friendly connector type (or a raw `MsoConnectorType` int) -> the int.

    `"straight"` / `"elbow"` / `"curved"` match case-insensitively; a raw int
    passes through. Raises `ValueError` (listing the names) for an unknown name.
    """
    if isinstance(name, bool):
        raise ValueError(f"invalid connector type: {name!r}")
    if isinstance(name, int):
        return int(name)
    found = _CONNECTOR_NAMES.get(_normalize_name(name))
    if found is None:
        choices = ", ".join(CONNECTOR_CHOICES)
        raise ValueError(f"unknown connector type {name!r}; expected one of: {choices}")
    return int(found)

connector_type_name

connector_type_name(value: int) -> str

MsoConnectorType int -> a friendly name (for connector reads).

Source code in src/pptlive/constants.py
def connector_type_name(value: int) -> str:
    """`MsoConnectorType` int -> a friendly name (for connector reads)."""
    try:
        return MsoConnectorType(int(value)).name.lower()
    except ValueError:
        return str(int(value))

alignment_for

alignment_for(value: str | int) -> int

Coerce an alignment name/int to a PpParagraphAlignment int.

Accepts "left"/"center"/"right"/"justify"/"distribute" (case- insensitive, "centre" too) or a raw int. Raises ValueError for an unknown name — symmetric with autoshape_type_for.

Source code in src/pptlive/constants.py
def alignment_for(value: str | int) -> int:
    """Coerce an alignment name/int to a `PpParagraphAlignment` int.

    Accepts `"left"`/`"center"`/`"right"`/`"justify"`/`"distribute"` (case-
    insensitive, `"centre"` too) or a raw int. Raises `ValueError` for an
    unknown name — symmetric with `autoshape_type_for`.
    """
    if isinstance(value, bool):
        raise ValueError(f"invalid alignment: {value!r}")
    if isinstance(value, int):
        return int(value)
    found = _ALIGNMENT_NAMES.get(_normalize_name(value))
    if found is None:
        choices = ", ".join(ALIGNMENT_CHOICES)
        raise ValueError(f"unknown alignment {value!r}; expected one of: {choices}")
    return int(found)

bullet_type_for

bullet_type_for(list_type: str) -> PpBulletType

Resolve a list_type string to its PpBulletType.

"bulleted" -> unnumbered, "numbered" -> numbered. Raises ValueError for an unknown name.

Source code in src/pptlive/constants.py
def bullet_type_for(list_type: str) -> PpBulletType:
    """Resolve a `list_type` string to its `PpBulletType`.

    `"bulleted"` -> unnumbered, `"numbered"` -> numbered. Raises `ValueError`
    for an unknown name.
    """
    found = _BULLET_TYPE_FOR.get(_normalize_name(list_type))
    if found is None:
        choices = ", ".join(LIST_TYPE_CHOICES)
        raise ValueError(f"unknown list type {list_type!r}; expected one of: {choices}")
    return found

bullet_type_name

bullet_type_name(value: Any) -> str

Friendly name for a Bullet.Type int (e.g. 1 -> "bulleted").

Source code in src/pptlive/constants.py
def bullet_type_name(value: Any) -> str:
    """Friendly name for a `Bullet.Type` int (e.g. 1 -> "bulleted")."""
    try:
        return _BULLET_TYPE_NAMES.get(int(value), f"bullet:{int(value)}")
    except (TypeError, ValueError):
        return "none"

parse_color

parse_color(value: str | int | tuple[int, int, int]) -> int

Coerce a color to the long PowerPoint's Font.Color.RGB expects.

Accepts "#RRGGBB" / "RRGGBB" hex, an (r, g, b) tuple (0-255 each), or a raw int (passed through — the escape hatch). PowerPoint stores the long in R-low-byte order (red == 0x0000FF), so "#FF0000" -> 255. Raises ValueError for a malformed hex string or out-of-range channel.

Source code in src/pptlive/constants.py
def parse_color(value: str | int | tuple[int, int, int]) -> int:
    """Coerce a color to the long PowerPoint's `Font.Color.RGB` expects.

    Accepts `"#RRGGBB"` / `"RRGGBB"` hex, an `(r, g, b)` tuple (0-255 each), or a
    raw int (passed through — the escape hatch). PowerPoint stores the long in
    R-low-byte order (`red == 0x0000FF`), so `"#FF0000"` -> 255. Raises
    `ValueError` for a malformed hex string or out-of-range channel.
    """
    if isinstance(value, bool):
        raise ValueError(f"invalid color: {value!r}")
    if isinstance(value, int):
        return int(value)
    if isinstance(value, tuple):
        if len(value) != 3 or any(not (0 <= int(c) <= 255) for c in value):
            raise ValueError(f"color tuple must be three 0-255 channels, got {value!r}")
        r, g, b = (int(c) for c in value)
        return r | (g << 8) | (b << 16)
    text = str(value).strip().lstrip("#")
    if len(text) != 6:
        raise ValueError(f"color must be '#RRGGBB' hex, an (r,g,b) tuple, or an int; got {value!r}")
    try:
        r = int(text[0:2], 16)
        g = int(text[2:4], 16)
        b = int(text[4:6], 16)
    except ValueError:
        raise ValueError(f"invalid hex color {value!r}") from None
    return r | (g << 8) | (b << 16)

color_hex

color_hex(value: Any) -> str

Render a PowerPoint Font.Color.RGB long as "#RRGGBB".

Source code in src/pptlive/constants.py
def color_hex(value: Any) -> str:
    """Render a PowerPoint `Font.Color.RGB` long as `"#RRGGBB"`."""
    n = int(value)
    r, g, b = n & 0xFF, (n >> 8) & 0xFF, (n >> 16) & 0xFF
    return f"#{r:02X}{g:02X}{b:02X}"

color_hex_or_none

color_hex_or_none(value: Any) -> str | None

color_hex, but None for a non-literal (theme/automatic) color.

A theme or automatic color isn't a literal RGB: COM returns the 0x80000000 "automatic" sentinel (which color_hex would mis-render as #000000), and anything outside 0..0xFFFFFF is likewise not a real RGB. Shared by the font (_anchors._font_color_hex) and shape fill/line readbacks so a theme-driven color reports honestly as None rather than a wrong black.

Source code in src/pptlive/constants.py
def color_hex_or_none(value: Any) -> str | None:
    """`color_hex`, but `None` for a non-literal (theme/automatic) color.

    A theme or automatic color isn't a literal RGB: COM returns the `0x80000000`
    "automatic" sentinel (which `color_hex` would mis-render as `#000000`), and
    anything outside `0..0xFFFFFF` is likewise not a real RGB. Shared by the font
    (`_anchors._font_color_hex`) and shape fill/line readbacks so a theme-driven
    color reports honestly as `None` rather than a wrong black.
    """
    try:
        rgb = int(value)
    except (TypeError, ValueError):
        return None
    if rgb < 0 or rgb > 0xFFFFFF:
        return None
    return color_hex(rgb)

color_source_name

color_source_name(type_value: Any) -> str | None

Friendly source for a ColorFormat.Type int: "direct" (a literal RGB/CMYK set on the run) / "theme" (a scheme color cascaded from the theme/master) / "mixed" (a range spanning both), or None if PowerPoint can't report it.

This is the "is it green because I set it, or because the master pulled it?" discriminator — text_frame_status / the font read surface it as color_source.

Source code in src/pptlive/constants.py
def color_source_name(type_value: Any) -> str | None:
    """Friendly source for a `ColorFormat.Type` int: `"direct"` (a literal RGB/CMYK
    set on the run) / `"theme"` (a scheme color cascaded from the theme/master) /
    `"mixed"` (a range spanning both), or `None` if PowerPoint can't report it.

    This is the "is it green because I set it, or because the master pulled it?"
    discriminator — `text_frame_status` / the font read surface it as `color_source`.
    """
    try:
        t = int(type_value)
    except (TypeError, ValueError):
        return None
    if t == int(MsoColorType.SCHEME):
        return "theme"
    if t in (int(MsoColorType.RGB), int(MsoColorType.CMYK), int(MsoColorType.CMS)):
        return "direct"
    if t == int(MsoColorType.MIXED):
        return "mixed"
    return None

theme_color_name

theme_color_name(value: Any) -> str | None

Friendly theme-slot name for a ColorFormat.ObjectThemeColor int, or None.

Source code in src/pptlive/constants.py
def theme_color_name(value: Any) -> str | None:
    """Friendly theme-slot name for a `ColorFormat.ObjectThemeColor` int, or None."""
    try:
        return _THEME_COLOR_NAMES.get(int(value))
    except (TypeError, ValueError):
        return None

fill_type_name

fill_type_name(value: Any) -> str | int | None

Friendly name for a Fill.Type int (1 -> "solid"); the int if unknown.

Source code in src/pptlive/constants.py
def fill_type_name(value: Any) -> str | int | None:
    """Friendly name for a `Fill.Type` int (`1 -> "solid"`); the int if unknown."""
    try:
        n = int(value)
    except (TypeError, ValueError):
        return None
    return FILL_TYPE_NAMES.get(n, n)

gradient_style_for

gradient_style_for(style: str | int) -> int

Friendly gradient-style name (or raw MsoGradientStyle int) -> the int.

"horizontal"/"vertical"/"diagonal_up"/… (case- and separator-insensitive) or a raw int (passed through). Raises ValueError for an unknown name — symmetric with entry_effect_for.

Source code in src/pptlive/constants.py
def gradient_style_for(style: str | int) -> int:
    """Friendly gradient-style name (or raw `MsoGradientStyle` int) -> the int.

    `"horizontal"`/`"vertical"`/`"diagonal_up"`/… (case- and separator-insensitive)
    or a raw int (passed through). Raises `ValueError` for an unknown name —
    symmetric with `entry_effect_for`.
    """
    if isinstance(style, bool):
        raise ValueError(f"invalid gradient style: {style!r}")
    if isinstance(style, int):
        return int(style)
    found = _GRADIENT_STYLES.get(str(style).strip().lower().replace(" ", "_").replace("-", "_"))
    if found is None:
        choices = ", ".join(GRADIENT_STYLE_CHOICES)
        raise ValueError(f"unknown gradient style {style!r}; expected one of: {choices}")
    return found

gradient_style_name

gradient_style_name(value: Any) -> str | int | None

Friendly name for a Fill.GradientStyle int (1 -> "horizontal").

Source code in src/pptlive/constants.py
def gradient_style_name(value: Any) -> str | int | None:
    """Friendly name for a `Fill.GradientStyle` int (`1 -> "horizontal"`)."""
    try:
        n = int(value)
    except (TypeError, ValueError):
        return None
    return _GRADIENT_STYLE_NAMES.get(n, n)

preset_gradient_for

preset_gradient_for(preset: str | int) -> int

Friendly preset-gradient name (or raw MsoPresetGradientType int) -> the int.

"ocean"/"fire"/"rainbow"/… (case-/separator-insensitive) or a raw int. Raises ValueError for an unknown name.

Source code in src/pptlive/constants.py
def preset_gradient_for(preset: str | int) -> int:
    """Friendly preset-gradient name (or raw `MsoPresetGradientType` int) -> the int.

    `"ocean"`/`"fire"`/`"rainbow"`/… (case-/separator-insensitive) or a raw int.
    Raises `ValueError` for an unknown name.
    """
    if isinstance(preset, bool):
        raise ValueError(f"invalid preset gradient: {preset!r}")
    if isinstance(preset, int):
        return int(preset)
    found = _PRESET_GRADIENTS.get(str(preset).strip().lower().replace(" ", "_").replace("-", "_"))
    if found is None:
        choices = ", ".join(PRESET_GRADIENT_CHOICES)
        raise ValueError(f"unknown preset gradient {preset!r}; expected one of: {choices}")
    return found

preset_gradient_name

preset_gradient_name(value: Any) -> str | int | None

Friendly name for a MsoPresetGradientType int (7 -> "ocean").

Source code in src/pptlive/constants.py
def preset_gradient_name(value: Any) -> str | int | None:
    """Friendly name for a `MsoPresetGradientType` int (`7 -> "ocean"`)."""
    try:
        n = int(value)
    except (TypeError, ValueError):
        return None
    return _PRESET_GRADIENT_NAMES.get(n, n)

pattern_for

pattern_for(pattern: str | int) -> int

Friendly pattern name (or raw MsoPatternType int) -> the int.

"percent_50"/"dark_horizontal"/"trellis"/… (case-/separator-insensitive) or a raw int. Raises ValueError for an unknown name.

Source code in src/pptlive/constants.py
def pattern_for(pattern: str | int) -> int:
    """Friendly pattern name (or raw `MsoPatternType` int) -> the int.

    `"percent_50"`/`"dark_horizontal"`/`"trellis"`/… (case-/separator-insensitive)
    or a raw int. Raises `ValueError` for an unknown name.
    """
    if isinstance(pattern, bool):
        raise ValueError(f"invalid pattern: {pattern!r}")
    if isinstance(pattern, int):
        return int(pattern)
    found = _PATTERNS.get(str(pattern).strip().lower().replace(" ", "_").replace("-", "_"))
    if found is None:
        choices = ", ".join(PATTERN_CHOICES)
        raise ValueError(f"unknown pattern {pattern!r}; expected one of: {choices}")
    return found

pattern_name

pattern_name(value: Any) -> str | int | None

Friendly name for a MsoPatternType int (7 -> "percent_50").

Source code in src/pptlive/constants.py
def pattern_name(value: Any) -> str | int | None:
    """Friendly name for a `MsoPatternType` int (`7 -> "percent_50"`)."""
    try:
        n = int(value)
    except (TypeError, ValueError):
        return None
    return _PATTERN_NAMES.get(n, n)

dash_style_for

dash_style_for(dash: str | int) -> int

Friendly dash name (or raw MsoLineDashStyle int) -> the int.

"solid"/"dash"/"round_dot"/"long_dash_dot"/… (case-/separator-insensitive) or a raw int. Raises ValueError for an unknown name.

Source code in src/pptlive/constants.py
def dash_style_for(dash: str | int) -> int:
    """Friendly dash name (or raw `MsoLineDashStyle` int) -> the int.

    `"solid"`/`"dash"`/`"round_dot"`/`"long_dash_dot"`/… (case-/separator-insensitive)
    or a raw int. Raises `ValueError` for an unknown name.
    """
    if isinstance(dash, bool):
        raise ValueError(f"invalid dash style: {dash!r}")
    if isinstance(dash, int):
        return int(dash)
    found = _DASH_STYLES.get(str(dash).strip().lower().replace(" ", "_").replace("-", "_"))
    if found is None:
        choices = ", ".join(DASH_STYLE_CHOICES)
        raise ValueError(f"unknown dash style {dash!r}; expected one of: {choices}")
    return found

dash_style_name

dash_style_name(value: Any) -> str | int | None

Friendly name for a MsoLineDashStyle int (4 -> "dash").

Source code in src/pptlive/constants.py
def dash_style_name(value: Any) -> str | int | None:
    """Friendly name for a `MsoLineDashStyle` int (`4 -> "dash"`)."""
    try:
        n = int(value)
    except (TypeError, ValueError):
        return None
    if n <= 0:  # msoLineDashStyleMixed (-2) / unset
        return None
    return _DASH_STYLE_NAMES.get(n, n)

arrowhead_style_for

arrowhead_style_for(style: str | int) -> int

Friendly arrowhead name (or raw MsoArrowheadStyle int) -> the int.

"none"/"triangle"/"open"/"stealth"/"diamond"/"oval" or a raw int. Raises ValueError for an unknown name.

Source code in src/pptlive/constants.py
def arrowhead_style_for(style: str | int) -> int:
    """Friendly arrowhead name (or raw `MsoArrowheadStyle` int) -> the int.

    `"none"`/`"triangle"`/`"open"`/`"stealth"`/`"diamond"`/`"oval"` or a raw int.
    Raises `ValueError` for an unknown name.
    """
    if isinstance(style, bool):
        raise ValueError(f"invalid arrowhead style: {style!r}")
    if isinstance(style, int):
        return int(style)
    found = _ARROWHEAD_STYLES.get(str(style).strip().lower().replace(" ", "_").replace("-", "_"))
    if found is None:
        choices = ", ".join(ARROWHEAD_STYLE_CHOICES)
        raise ValueError(f"unknown arrowhead style {style!r}; expected one of: {choices}")
    return found

arrowhead_style_name

arrowhead_style_name(value: Any) -> str | int | None

Friendly name for a MsoArrowheadStyle int (2 -> "triangle").

Source code in src/pptlive/constants.py
def arrowhead_style_name(value: Any) -> str | int | None:
    """Friendly name for a `MsoArrowheadStyle` int (`2 -> "triangle"`)."""
    try:
        n = int(value)
    except (TypeError, ValueError):
        return None
    if n <= 0:  # msoArrowheadStyleMixed (-2) / unset
        return None
    return _ARROWHEAD_STYLE_NAMES.get(n, n)

arrowhead_size_for

arrowhead_size_for(size: str | int) -> int

Friendly arrowhead size (small/medium/large, or raw 1-3) -> the int.

Drives both MsoArrowheadLength and MsoArrowheadWidth together. Raises ValueError for an unknown name.

Source code in src/pptlive/constants.py
def arrowhead_size_for(size: str | int) -> int:
    """Friendly arrowhead size (`small`/`medium`/`large`, or raw 1-3) -> the int.

    Drives both `MsoArrowheadLength` and `MsoArrowheadWidth` together. Raises
    `ValueError` for an unknown name.
    """
    if isinstance(size, bool):
        raise ValueError(f"invalid arrowhead size: {size!r}")
    if isinstance(size, int):
        return int(size)
    found = _ARROWHEAD_SIZES.get(str(size).strip().lower())
    if found is None:
        choices = ", ".join(ARROWHEAD_SIZE_CHOICES)
        raise ValueError(f"unknown arrowhead size {size!r}; expected one of: {choices}")
    return found

border_edges_for

border_edges_for(edges: str | int | Sequence[str | int]) -> list[int]

Friendly edge selector -> a deduplicated, ordered list of Borders() indices.

Accepts "all" (the four sides 1-4, diagonals excluded), a single edge name or raw int ("bottom", 3), or a sequence of those (["top", "bottom"]). Names are case-/separator-insensitive. Raises ValueError for an unknown edge.

Source code in src/pptlive/constants.py
def border_edges_for(edges: str | int | Sequence[str | int]) -> list[int]:
    """Friendly edge selector -> a deduplicated, ordered list of `Borders()` indices.

    Accepts `"all"` (the four sides 1-4, diagonals excluded), a single edge name
    or raw int (`"bottom"`, `3`), or a sequence of those (`["top", "bottom"]`).
    Names are case-/separator-insensitive. Raises `ValueError` for an unknown edge.
    """

    def one(edge: str | int) -> list[int]:
        if isinstance(edge, bool):
            raise ValueError(f"invalid border edge: {edge!r}")
        if isinstance(edge, int):
            return [int(edge)]
        token = str(edge).strip().lower().replace(" ", "_").replace("-", "_")
        if token == "all":
            return [_BORDER_EDGES[s] for s in _BORDER_SIDES]
        found = _BORDER_EDGES.get(token)
        if found is None:
            choices = ", ".join(BORDER_EDGE_CHOICES)
            raise ValueError(f"unknown border edge {edge!r}; expected one of: {choices}")
        return [found]

    items: Sequence[str | int]
    if isinstance(edges, (str, int)) and not isinstance(edges, bool):
        items = [edges]
    else:
        items = list(edges)  # type: ignore[arg-type]
    seen: dict[int, None] = {}
    for item in items:
        for idx in one(item):
            seen[idx] = None
    if not seen:
        raise ValueError("border edge selector resolved to no edges")
    return list(seen)

image_filter_for

image_filter_for(fmt: str) -> tuple[str, str]

Resolve an image-format token to its (FilterName, extension) for Slide.Export.

Accepts "png"/"jpg"/"jpeg"/"gif"/"bmp"/"tif"/"tiff" (case- insensitive, a leading dot tolerated). Raises ValueError for an unknown format — symmetric with autoshape_type_for / alignment_for.

Source code in src/pptlive/constants.py
def image_filter_for(fmt: str) -> tuple[str, str]:
    """Resolve an image-format token to its `(FilterName, extension)` for `Slide.Export`.

    Accepts `"png"`/`"jpg"`/`"jpeg"`/`"gif"`/`"bmp"`/`"tif"`/`"tiff"` (case-
    insensitive, a leading dot tolerated). Raises `ValueError` for an unknown
    format — symmetric with `autoshape_type_for` / `alignment_for`.
    """
    key = str(fmt).strip().lower().lstrip(".")
    found = _IMAGE_FILTERS.get(key)
    if found is None:
        choices = ", ".join(IMAGE_FORMAT_CHOICES)
        raise ValueError(f"unknown image format {fmt!r}; expected one of: {choices}")
    return found

slide_show_state_name

slide_show_state_name(value: Any) -> str

Friendly name for a SlideShowView.State int (e.g. 3 -> "black").

Source code in src/pptlive/constants.py
def slide_show_state_name(value: Any) -> str:
    """Friendly name for a `SlideShowView.State` int (e.g. 3 -> "black")."""
    try:
        return _SLIDE_SHOW_STATE_NAMES.get(int(value), f"state:{int(value)}")
    except (TypeError, ValueError):
        return "unknown"

shape_image_filter_for

shape_image_filter_for(fmt: str) -> tuple[int, str]

Resolve an image-format token to its (PpShapeFormat, extension) for Shape.Export.

Accepts "png"/"jpg"/"jpeg"/"gif"/"bmp" (case-insensitive, a leading dot tolerated). Raises ValueError for an unknown format — symmetric with image_filter_for (the Slide.Export resolver). Note the raster set is narrower than Slide.Export's (no TIFF — PpShapeFormat has no TIFF member).

Source code in src/pptlive/constants.py
def shape_image_filter_for(fmt: str) -> tuple[int, str]:
    """Resolve an image-format token to its `(PpShapeFormat, extension)` for `Shape.Export`.

    Accepts `"png"`/`"jpg"`/`"jpeg"`/`"gif"`/`"bmp"` (case-insensitive, a
    leading dot tolerated). Raises `ValueError` for an unknown format —
    symmetric with `image_filter_for` (the `Slide.Export` resolver). Note the
    raster set is narrower than `Slide.Export`'s (no TIFF — `PpShapeFormat` has
    no TIFF member).
    """
    key = str(fmt).strip().lower().lstrip(".")
    found = _SHAPE_IMAGE_FILTERS.get(key)
    if found is None:
        choices = ", ".join(SHAPE_IMAGE_FORMAT_CHOICES)
        raise ValueError(f"unknown image format {fmt!r}; expected one of: {choices}")
    return found

save_format_for

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

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

media_task_status_name(value: Any) -> str

Friendly name for a CreateVideoStatus int (3 -> "done", 4 -> "failed").

Source code in src/pptlive/constants.py
def media_task_status_name(value: Any) -> str:
    """Friendly name for a `CreateVideoStatus` int (3 -> "done", 4 -> "failed")."""
    try:
        return _MEDIA_TASK_STATUS_NAMES.get(int(value), f"status:{int(value)}")
    except (TypeError, ValueError):
        return "unknown"

anim_effect_for

anim_effect_for(effect: str | int) -> int

Resolve a friendly animation name (or raw int) to its MsoAnimEffect int.

Accepts "fade"/"appear"/"fly_in"/… (case- and separator-insensitive) or a raw int (passed through, so exotic MsoAnimEffect values still work). Raises ValueError for an unknown name — symmetric with entry_effect_for.

Source code in src/pptlive/constants.py
def anim_effect_for(effect: str | int) -> int:
    """Resolve a friendly animation name (or raw int) to its `MsoAnimEffect` int.

    Accepts `"fade"`/`"appear"`/`"fly_in"`/… (case- and separator-insensitive) or a
    raw int (passed through, so exotic `MsoAnimEffect` values still work). Raises
    `ValueError` for an unknown name — symmetric with `entry_effect_for`.
    """
    if isinstance(effect, bool):  # guard: bool is an int subclass
        raise ValueError(f"invalid animation effect: {effect!r}")
    if isinstance(effect, int):
        return int(effect)
    key = str(effect).strip().lower().replace(" ", "_").replace("-", "_")
    found = _ANIM_EFFECTS.get(key)
    if found is None:
        choices = ", ".join(ANIM_EFFECT_CHOICES)
        raise ValueError(f"unknown animation effect {effect!r}; expected one of: {choices}")
    return found

anim_effect_name

anim_effect_name(value: Any) -> str

Friendly name for a MsoAnimEffect int (e.g. 10 -> "fade").

Source code in src/pptlive/constants.py
def anim_effect_name(value: Any) -> str:
    """Friendly name for a `MsoAnimEffect` int (e.g. 10 -> "fade")."""
    try:
        return _ANIM_EFFECT_NAMES.get(int(value), f"effect:{int(value)}")
    except (TypeError, ValueError):
        return "unknown"

anim_trigger_for

anim_trigger_for(trigger: str | int) -> int

Resolve a friendly trigger name (or raw int) to its MsoAnimTriggerType int.

"on_click" / "with_previous" / "after_previous" (the start-timing the PowerPoint animation pane offers), or a raw int. Raises ValueError for an unknown name.

Source code in src/pptlive/constants.py
def anim_trigger_for(trigger: str | int) -> int:
    """Resolve a friendly trigger name (or raw int) to its `MsoAnimTriggerType` int.

    `"on_click"` / `"with_previous"` / `"after_previous"` (the start-timing the
    PowerPoint animation pane offers), or a raw int. Raises `ValueError` for an
    unknown name.
    """
    if isinstance(trigger, bool):
        raise ValueError(f"invalid animation trigger: {trigger!r}")
    if isinstance(trigger, int):
        return int(trigger)
    key = str(trigger).strip().lower().replace(" ", "_").replace("-", "_")
    found = _ANIM_TRIGGERS.get(key)
    if found is None:
        choices = ", ".join(ANIM_TRIGGER_CHOICES)
        raise ValueError(f"unknown animation trigger {trigger!r}; expected one of: {choices}")
    return found

anim_trigger_name

anim_trigger_name(value: Any) -> str

Friendly name for a MsoAnimTriggerType int (e.g. 1 -> "on_click").

Source code in src/pptlive/constants.py
def anim_trigger_name(value: Any) -> str:
    """Friendly name for a `MsoAnimTriggerType` int (e.g. 1 -> "on_click")."""
    try:
        return _ANIM_TRIGGER_NAMES.get(int(value), f"trigger:{int(value)}")
    except (TypeError, ValueError):
        return "unknown"

Exceptions

pptlive.PptliveError

Bases: Exception

Base class for all pptlive errors.

pptlive.PowerPointNotRunningError

Bases: PptliveError

No running PowerPoint instance is available.

pptlive.PresentationNotFoundError

PresentationNotFoundError(name: str)

Bases: PptliveError

The requested presentation is not open in PowerPoint.

Source code in src/pptlive/exceptions.py
def __init__(self, name: str) -> None:
    super().__init__(f"presentation not found: {name!r}")
    self.name = name

pptlive.AnchorNotFoundError

AnchorNotFoundError(kind: str, name: str)

Bases: PptliveError

The requested anchor (shape / placeholder / cell / notes) does not exist.

Covers a missing slide too, via SlideNotFoundError, and a zero-match find (raised with kind='find'). Maps to exit code 2.

Source code in src/pptlive/exceptions.py
def __init__(self, kind: str, name: str) -> None:
    super().__init__(f"{kind} not found: {name!r}")
    self.kind = kind
    self.name = name

pptlive.SlideNotFoundError

SlideNotFoundError(index: int)

Bases: AnchorNotFoundError

A slide index is out of range.

Subclass of AnchorNotFoundError so it shares the same exit code (2) and so except AnchorNotFoundError catches both missing-slide and missing-shape errors. Retryable after re-reading deck.slides.list().

Source code in src/pptlive/exceptions.py
def __init__(self, index: int) -> None:
    super().__init__("slide", f"slide:{index}")
    self.index = index

pptlive.LayoutNotFoundError

LayoutNotFoundError(requested: str, available: list[str])

Bases: AnchorNotFoundError

A requested slide layout name/index doesn't exist in the deck.

Subclass of AnchorNotFoundError so it shares exit code 2. Layout names are template-dependent (a theme can rename them), so the message lists the deck's actual layout names and available carries them structured — an agent can read them off stderr (or slide layouts) and retry with a real name.

Source code in src/pptlive/exceptions.py
def __init__(self, requested: str, available: list[str]) -> None:
    names = ", ".join(repr(n) for n in available) if available else "(none)"
    # Build the full message first, then hand AnchorNotFoundError the bare
    # name; overwrite args so the available list survives in str(exc).
    super().__init__("layout", requested)
    self.args = (f"layout not found: {requested!r}; available: {names}",)
    self.requested = requested
    self.available = available

pptlive.NoTextFrameError

NoTextFrameError(anchor_id: str | None = None)

Bases: PptliveError

A text operation targeted a shape with no text frame (picture, line, …).

The one genuinely new code versus wordlive (exit 6). It's common enough — an LLM tries to set text on a decorative shape — to deserve a deterministic exit code instead of a bare COM failure. Not retryable on the same shape: pick a text-bearing anchor (a placeholder or text box) instead.

Source code in src/pptlive/exceptions.py
def __init__(self, anchor_id: str | None = None) -> None:
    target = f": {anchor_id}" if anchor_id else ""
    super().__init__(f"shape has no text frame{target}")
    self.anchor_id = anchor_id

pptlive.UnsavedPresentationError

UnsavedPresentationError(name: str | None = None)

Bases: PptliveError

deck.save() was called on a deck that has never been saved (no path yet).

A precondition failure, not a missing anchor, so it maps to the general exit code (1). The 2026-06-09 spike found PowerPoint's Presentation.Save() does not raise on a never-saved deck — on a OneDrive/SharePoint-backed build it silently uploads to the user's default cloud location. So save() guards in Python on an empty Presentation.Path and raises this instead of letting the deck escape somewhere the caller didn't choose. Fix: call save_as(path) (or export_pdf(path)) with an explicit destination first.

Source code in src/pptlive/exceptions.py
def __init__(self, name: str | None = None) -> None:
    target = f": {name}" if name else ""
    super().__init__(
        f"presentation has never been saved{target}; use save_as(path) to choose a destination"
    )
    self.name = name

pptlive.VideoExportError

VideoExportError(message: str, *, status: str | None = None)

Bases: PptliveError

deck.export_video() failed or timed out.

A failed encode (CreateVideoStatus == ppMediaTaskStatusFailed) or a blocking wait=True that exceeded its timeout before PowerPoint reported Done. Not a missing anchor or a transient busy state, so it maps to the general exit code (1) / MCP error. Carries the last-seen status token for diagnosis; on timeout the encode may still be running — poll deck.video_status() to check.

Source code in src/pptlive/exceptions.py
def __init__(self, message: str, *, status: str | None = None) -> None:
    super().__init__(message)
    self.status = status

pptlive.SlideShowNotRunningError

SlideShowNotRunningError()

Bases: PptliveError

A slide-show control verb was called with no slide show running.

deck.show.next() / previous() / goto() / black() / white() / resume() all need a running show — start one with deck.show.start() first. This is a precondition failure, not a missing anchor, so it maps to the general exit code (1). deck.show.state() never raises it (it reports running: false instead), and end() on an already-stopped show is a no-op.

Source code in src/pptlive/exceptions.py
def __init__(self) -> None:
    super().__init__("no slide show is running; start one with show.start() first")

pptlive.AmbiguousMatchError

AmbiguousMatchError(find: str, matches: list[dict[str, Any]], *, message: str | None = None)

Bases: PptliveError

A query matched more than one candidate without a disambiguator.

Raised by find_replace (more than one fuzzy match and neither occurrence nor replace_all given) and by ph:S:KIND placeholder resolution (a kind that matches two equally-preferred placeholders, e.g. the two bodies of a Two Content layout). Carries matches so callers (notably LLM drivers) can pick a candidate and retry. Maps to exit 5 / the MCP ambiguous token.

Source code in src/pptlive/exceptions.py
def __init__(
    self, find: str, matches: list[dict[str, Any]], *, message: str | None = None
) -> None:
    if message is None:
        # Surface-neutral: name the MCP params AND the CLI flags, so the hint
        # is actionable whether the caller drives pptlive over MCP or the CLI.
        message = (
            f"{len(matches)} matches for {find!r}; set occurrence=N or "
            "replace_all=true (CLI: --occurrence N / --all) to disambiguate"
        )
    super().__init__(message)
    self.find = find
    self.matches = matches

for_placeholder classmethod

for_placeholder(anchor_id: str, candidates: list[dict[str, Any]]) -> AmbiguousMatchError

Build the placeholder-ambiguity variant, listing the candidate shapes.

Source code in src/pptlive/exceptions.py
@classmethod
def for_placeholder(
    cls, anchor_id: str, candidates: list[dict[str, Any]]
) -> AmbiguousMatchError:
    """Build the placeholder-ambiguity variant, listing the candidate shapes."""
    anchors = ", ".join(str(c["anchor_id"]) for c in candidates)
    message = (
        f"{anchor_id!r} matches {len(candidates)} placeholders ({anchors}); "
        "target one by its shape anchor (shape:S:N) or .Name"
    )
    return cls(anchor_id, candidates, message=message)

pptlive.PowerPointBusyError

PowerPointBusyError(message: str = 'PowerPoint is busy or in a modal dialog', *, hresult: int | None = None)

Bases: PptliveError

PowerPoint rejected the RPC — typically a modal dialog has focus.

Retryable in principle; caller decides. Raised when a COM call comes back with a known busy RPC_E_* HRESULT (see _BUSY_HRESULTS). Note: a running slide show does not itself block edits — the 2026-05-28 spike found a text edit succeeds mid-show — so this is no longer claimed as a slide-show symptom; drive a live show through deck.show regardless.

Source code in src/pptlive/exceptions.py
def __init__(
    self,
    message: str = "PowerPoint is busy or in a modal dialog",
    *,
    hresult: int | None = None,
) -> None:
    super().__init__(message)
    self.hresult = hresult
    self.retryable = True

pptlive.ComError

ComError(message: str, *, hresult: int | None = None, description: str | None = None)

Bases: PptliveError

Generic wrapper for an unclassified pywintypes.com_error.

Source code in src/pptlive/exceptions.py
def __init__(
    self, message: str, *, hresult: int | None = None, description: str | None = None
) -> None:
    super().__init__(message)
    self.hresult = hresult
    self.description = description