Skip to content

Python API

Every entry on this page is generated from the docstrings in the wordlive 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 Word

wordlive.attach

attach() -> Iterator[Word]

Attach to an already-running Word instance.

Raises WordNotRunningError if no instance is available. Does not launch Word and does not close it on exit.

Source code in src/wordlive/_app.py
@contextmanager
def attach() -> Iterator[Word]:
    """Attach to an already-running Word instance.

    Raises `WordNotRunningError` if no instance is available. Does not launch
    Word and does not close it on exit.
    """
    with _com.com_apartment():
        app = _com.get_active_word()
        try:
            yield Word(app)
        finally:
            del app

wordlive.connect

connect(launch_if_missing: bool = True, visible: bool = True) -> Iterator[Word]

Attach to a running Word, or launch a new one if missing.

With launch_if_missing=False this behaves like attach(). Wordlive never closes Word on exit — even when it launched the instance itself, the user is expected to own its lifecycle.

Source code in src/wordlive/_app.py
@contextmanager
def connect(launch_if_missing: bool = True, visible: bool = True) -> Iterator[Word]:
    """Attach to a running Word, or launch a new one if missing.

    With `launch_if_missing=False` this behaves like `attach()`. Wordlive never
    closes Word on exit — even when it launched the instance itself, the user
    is expected to own its lifecycle.
    """
    with _com.com_apartment():
        try:
            app = _com.get_active_word()
        except WordNotRunningError:
            if not launch_if_missing:
                raise
            app = _com.launch_word(visible=visible)
        try:
            yield Word(app)
        finally:
            del app

wordlive.Word

Word(app: Any)

Handle to a running Word.Application COM object.

Source code in src/wordlive/_app.py
def __init__(self, app: Any) -> None:
    self._app = app

com property

com: Any

Raw Application COM object — escape hatch when wordlive doesn't cover something.

Documents

wordlive.Document

Document(word: Word, doc: Any)

Wraps a Word Document COM object.

Source code in src/wordlive/_document.py
def __init__(self, word: Word, doc: Any) -> None:
    self._word = word
    self._doc = doc

tables property

tables: TableCollection

Iterable, indexable view over the document's tables.

Index by 1-based position (doc.tables[1]) or Title (doc.tables["Budget"]). Cells are anchors: doc.tables[1].cell(2, 3) — or doc.anchor_by_id("table:1:2:3") — returns a Cell that works with set_text, apply_style, and format_paragraph.

headings property

headings: HeadingCollection

Iterable view over the document's headings.

Symmetric with bookmarks, content_controls, and styles. Index by visible text (doc.headings["Risks"]) or 1-based paragraph position (doc.headings[3]). Document.heading(name) remains as sugar for self.headings[name].

paragraphs property

paragraphs: ParagraphCollection

Indexable, iterable view over every paragraph (not just headings).

Index by 1-based position (doc.paragraphs[2]) to get a Paragraph anchor (para:N) that works with set_text, apply_style, format_paragraph, and the list verbs. doc.paragraphs.list() emits offsets, so a body paragraph can be turned into a range:START-END target for a mid-paragraph insertion. para:N shares its index space with heading:N.

lists property

lists: ListCollection

Read-only, iterable view over the document's bullet / numbered lists.

Index a list by 1-based position (doc.lists[2]) to get a RangeAnchor over its range, so every list verb (apply_list, restart_numbering, …) is available on it. List formatting itself is applied through any anchor's apply_list(...).

sections property

sections: SectionCollection

Indexable view over the document's sections, headers, and footers.

doc.sections[1].header() / .footer() return HeaderFooter anchors (addressed header:S:WHICH / footer:S:WHICH) that work with set_text / apply_style like any other anchor.

comments property

comments: CommentCollection

Iterable, indexable view over the document's review comments.

doc.comments.add(anchor, text, author=...) attaches a comment to any anchor's range without changing the text — the polite, side-channel way to flag something. Index existing comments by 1-based position (doc.comments[2]) to resolve() or delete() them.

start property

start: StartAnchor

An anchor at the very start of the document — the prepend target.

The mirror of end. doc.start (anchor id start, also anchor_by_id("start")) names the position before the first paragraph; its insert verbs all prepend — doc.start.insert_paragraph_after(text) adds a new first paragraph (delegating to prepend_paragraph) and insert_after(text) prepends inline (delegating to prepend). The CLI reaches it too: wordlive insert --anchor-id start --text "…".

end property

end: EndAnchor

An anchor at the very end of the document — the append target.

doc.end (anchor id end, also anchor_by_id("end")) names the one position no content names: past the last paragraph. Its insert verbs all append — doc.end.insert_paragraph_after(text) adds a new final paragraph (delegating to append_paragraph), insert_after(text) appends inline (delegating to append), and insert_image(...) drops a picture at the end. Because it resolves through anchor_by_id, the CLI reaches it too: wordlive insert --anchor-id end --text "…".

track_changes property writable

track_changes: bool

Whether Word's Track Changes is currently on for this document.

tracked_changes

tracked_changes() -> Iterator[None]

Turn on Track Changes for the duration of the block, then restore it.

Every mutation made inside the scope is recorded as a tracked revision the user can accept or reject — "make this edit visibly." The prior TrackRevisions setting is restored on exit, so the scope stays polite even when the user had tracking off.

Pairs with edit() for an atomic, visibly-tracked batch:

with doc.tracked_changes(), doc.edit("Suggest rewordings"):
    doc.find_replace("utilise", "use", all=True)
Source code in src/wordlive/_document.py
@contextmanager
def tracked_changes(self) -> Iterator[None]:
    """Turn on Track Changes for the duration of the block, then restore it.

    Every mutation made inside the scope is recorded as a tracked revision
    the user can accept or reject — "make this edit *visibly*." The prior
    `TrackRevisions` setting is restored on exit, so the scope stays polite
    even when the user had tracking off.

    Pairs with `edit()` for an atomic, visibly-tracked batch:

        with doc.tracked_changes(), doc.edit("Suggest rewordings"):
            doc.find_replace("utilise", "use", all=True)
    """
    with _com.translate_com_errors():
        previous = bool(self._doc.TrackRevisions)
        self._doc.TrackRevisions = True
    try:
        yield
    finally:
        with _com.translate_com_errors():
            self._doc.TrackRevisions = previous

add_table

add_table(rows: int, cols: int, *, style: str | None = None, data: list[list[Any]] | None = None, header: bool = False) -> Table

Append a rows × cols table at the end of the document and return it.

The "build a document from the bottom up" helper for tables — the counterpart to append_paragraph. Sugar for self.end.insert_table(...); see Anchor.insert_table for the full semantics of style (defaults to the built-in "Table Grid"), data (row-major fill, validated up front), and header. To place a table somewhere other than the end, resolve a position anchor and call insert_table on it directly (e.g. doc.headings["Pricing"].insert_table(3, 2, ...)). Wrap in doc.edit(...) for atomic undo.

Source code in src/wordlive/_document.py
def add_table(
    self,
    rows: int,
    cols: int,
    *,
    style: str | None = None,
    data: list[list[Any]] | None = None,
    header: bool = False,
) -> Table:
    """Append a `rows` × `cols` table at the end of the document and return it.

    The "build a document from the bottom up" helper for tables — the
    counterpart to [`append_paragraph`][wordlive.Document.append_paragraph].
    Sugar for `self.end.insert_table(...)`; see
    [`Anchor.insert_table`][wordlive.Anchor.insert_table] for the full
    semantics of `style` (defaults to the built-in ``"Table Grid"``), `data`
    (row-major fill, validated up front), and `header`. To place a table
    somewhere other than the end, resolve a position anchor and call
    `insert_table` on it directly (e.g.
    `doc.headings["Pricing"].insert_table(3, 2, ...)`). Wrap in
    `doc.edit(...)` for atomic undo.
    """
    return self.end.insert_table(
        rows, cols, where="after", style=style, data=data, header=header
    )

range

range(start: int, end: int) -> RangeAnchor

Return a RangeAnchor over the absolute offsets [start, end).

Offsets are UTF-16 code units — the coordinates Word uses and that find() emits as range:START-END. Lazy: the offsets aren't validated against the document until the anchor is used.

Source code in src/wordlive/_document.py
def range(self, start: int, end: int) -> RangeAnchor:
    """Return a `RangeAnchor` over the absolute offsets `[start, end)`.

    Offsets are UTF-16 code units — the coordinates Word uses and that
    `find()` emits as `range:START-END`. Lazy: the offsets aren't validated
    against the document until the anchor is used.
    """
    return RangeAnchor(self, start, end)

anchor_by_id

anchor_by_id(anchor_id: str) -> Anchor

Resolve an anchor_id string into an Anchor.

Recognised forms
  • start — the position before the first paragraph (the prepend target)
  • end — the position past the last paragraph (the append target)
  • heading:N — Nth paragraph in the document (1-based, must be a heading)
  • para:N — Nth paragraph (1-based, any paragraph; same index space as heading:N)
  • bookmark:NAME — bookmark by name
  • cc:NAME — content control by Title (or Tag)
  • table:N:R:C — cell at 1-based (row, column) of the Nth table
  • range:START-END — arbitrary character span (the form find() emits)
  • header:S:WHICH — the WHICH header of section S (WHICH = primary/first/even)
  • footer:S:WHICH — the WHICH footer of section S

The bare table:N form is not an anchor (a whole table is a collection, not a single range) — use doc.tables[N] instead.

Raises AnchorNotFoundError for unknown schemes or missing anchors.

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

    Recognised forms:
      - `start`            — the position before the first paragraph (the prepend target)
      - `end`              — the position past the last paragraph (the append target)
      - `heading:N`        — Nth paragraph in the document (1-based, must be a heading)
      - `para:N`           — Nth paragraph (1-based, any paragraph; same index space as `heading:N`)
      - `bookmark:NAME`    — bookmark by name
      - `cc:NAME`          — content control by Title (or Tag)
      - `table:N:R:C`      — cell at 1-based (row, column) of the Nth table
      - `range:START-END`  — arbitrary character span (the form `find()` emits)
      - `header:S:WHICH`   — the WHICH header of section S (WHICH = primary/first/even)
      - `footer:S:WHICH`   — the WHICH footer of section S

    The bare `table:N` form is not an anchor (a whole table is a collection,
    not a single range) — use `doc.tables[N]` instead.

    Raises `AnchorNotFoundError` for unknown schemes or missing anchors.
    """
    if anchor_id == "start":
        # Bare keyword (no `kind:value` form) for the document-start
        # position. See `Document.start`.
        return self.start
    if anchor_id == "end":
        # Bare keyword for the document-end position. See `Document.end`.
        return self.end
    if not isinstance(anchor_id, str) or ":" not in anchor_id:
        raise AnchorNotFoundError("anchor", anchor_id)
    kind, _, value = anchor_id.partition(":")
    if kind == "heading":
        try:
            idx = int(value)
        except ValueError as e:
            raise AnchorNotFoundError("heading", anchor_id) from e
        return _IndexedHeading(self, idx)
    if kind == "para":
        try:
            idx = int(value)
        except ValueError as e:
            raise AnchorNotFoundError("paragraph", anchor_id) from e
        # Lazy, like heading:N — a bad index raises AnchorNotFoundError on use.
        return Paragraph(self, idx)
    if kind == "bookmark":
        return self.bookmarks[value]
    if kind == "cc":
        return self.content_controls[value]
    if kind == "table":
        parts = value.split(":")
        if len(parts) != 3:
            # `table:N` (whole table) isn't a single-range anchor.
            raise AnchorNotFoundError("table cell", anchor_id)
        try:
            t, r, c = (int(p) for p in parts)
        except ValueError as e:
            raise AnchorNotFoundError("table cell", anchor_id) from e
        return self.tables[t].cell(r, c)
    if kind == "range":
        start_str, sep, end_str = value.partition("-")
        if not sep:
            raise AnchorNotFoundError("range", anchor_id)
        try:
            start, end = int(start_str), int(end_str)
        except ValueError as e:
            raise AnchorNotFoundError("range", anchor_id) from e
        try:
            return self.range(start, end)
        except ValueError as e:
            raise AnchorNotFoundError("range", anchor_id) from e
    if kind in ("header", "footer"):
        parts = value.split(":")
        if len(parts) != 2:
            raise AnchorNotFoundError(kind, anchor_id)
        section_str, which = parts
        try:
            section_index = int(section_str)
        except ValueError as e:
            raise AnchorNotFoundError(kind, anchor_id) from e
        try:
            section = self.sections[section_index]
        except AnchorNotFoundError as e:
            raise AnchorNotFoundError(kind, anchor_id) from e
        try:
            if kind == "footer":
                return section.footer(which)
            return section.header(which)
        except ValueError as e:
            # Unknown WHICH (primary/first/even) — surface as a missing anchor.
            raise AnchorNotFoundError(kind, anchor_id) from e
    raise AnchorNotFoundError(
        "anchor",
        anchor_id,
        hint=(
            f"unknown anchor type {kind!r}; expected one of "
            "start/end/heading/para/bookmark/cc/table/range/header/footer"
        ),
    )

find

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

Locate every fuzzy occurrence of text within scope (or the whole doc).

Matching is whitespace- and Unicode-normalized (NFKC, smart quotes, dashes, NBSP). Returns a list of {anchor_id, start, end, text} where offsets are absolute document positions and text is the actual original substring (not the normalized form).

anchor_id for each match is range:START-END, which resolves through anchor_by_id to a RangeAnchor — so a hit can be fed straight back into replace --anchor-id or comments.add. The offsets are live, though, so use them before further edits shift the document.

Matches are located per segment (contiguous body text or a single table cell) so the returned offsets stay exact even inside tables; see _scope_segments.

Source code in src/wordlive/_document.py
def find(
    self,
    text: str,
    *,
    scope: Anchor | None = None,
) -> list[dict[str, Any]]:
    """Locate every fuzzy occurrence of `text` within `scope` (or the whole doc).

    Matching is whitespace- and Unicode-normalized (NFKC, smart quotes,
    dashes, NBSP). Returns a list of `{anchor_id, start, end, text}` where
    offsets are absolute document positions and `text` is the actual
    original substring (not the normalized form).

    `anchor_id` for each match is `range:START-END`, which resolves through
    `anchor_by_id` to a `RangeAnchor` — so a hit can be fed straight back
    into `replace --anchor-id` or `comments.add`. The offsets are live,
    though, so use them before further edits shift the document.

    Matches are located per *segment* (contiguous body text or a single table
    cell) so the returned offsets stay exact even inside tables; see
    `_scope_segments`.
    """
    segments = self._scope_segments(scope)
    results: list[dict[str, Any]] = []
    for base, haystack in segments:
        for m in _findreplace.find_matches(haystack, text):
            results.append(
                {
                    "anchor_id": f"range:{base + m.start}-{base + m.end}",
                    "start": base + m.start,
                    "end": base + m.end,
                    "text": m.text,
                }
            )
    return results

find_replace

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

Fuzzy plain-text replace. See find() for matching semantics.

Parameters:

Name Type Description Default
find str

the text to look for (fuzzy-matched).

required
replace str

the replacement text.

required
scope Anchor | None

optional anchor to restrict the search to. Headings expand to their body section.

None
all bool

replace every match.

False
occurrence int | None

1-based index — replace only the Nth match.

None

Raises:

Type Description
AnchorNotFoundError

zero matches (uses kind='find').

AmbiguousMatchError

more than one match and neither all nor occurrence was given.

Returns the list of replacements actually applied, each {anchor_id, start, end, text} in their pre-replacement coordinates.

Matching is segment-aware (see _scope_segments), so a match inside a table cell resolves to the right cell rather than drifting into its neighbour. As a backstop, each write is verified against the located text and raises ReplaceVerificationError rather than overwriting the wrong span.

Source code in src/wordlive/_document.py
def find_replace(
    self,
    find: str,
    replace: str,
    *,
    scope: Anchor | None = None,
    all: bool = False,
    occurrence: int | None = None,
) -> list[dict[str, Any]]:
    """Fuzzy plain-text replace. See `find()` for matching semantics.

    Args:
        find: the text to look for (fuzzy-matched).
        replace: the replacement text.
        scope: optional anchor to restrict the search to. Headings expand
            to their body section.
        all: replace every match.
        occurrence: 1-based index — replace only the Nth match.

    Raises:
        AnchorNotFoundError: zero matches (uses `kind='find'`).
        AmbiguousMatchError: more than one match and neither `all` nor
            `occurrence` was given.

    Returns the list of replacements actually applied, each
    `{anchor_id, start, end, text}` in their pre-replacement coordinates.

    Matching is segment-aware (see `_scope_segments`), so a match inside a
    table cell resolves to the right cell rather than drifting into its
    neighbour. As a backstop, each write is verified against the located text
    and raises `ReplaceVerificationError` rather than overwriting the wrong
    span.
    """
    segments = self._scope_segments(scope)
    match_payloads: list[dict[str, Any]] = [
        {
            "anchor_id": f"range:{base + m.start}-{base + m.end}",
            "start": base + m.start,
            "end": base + m.end,
            "text": m.text,
        }
        for base, haystack in segments
        for m in _findreplace.find_matches(haystack, find)
    ]
    if not match_payloads:
        raise AnchorNotFoundError("find", find)

    if occurrence is not None:
        if occurrence < 1 or occurrence > len(match_payloads):
            raise AnchorNotFoundError("find", f"{find} (occurrence {occurrence})")
        to_apply = [match_payloads[occurrence - 1]]
    elif all:
        to_apply = match_payloads
    elif len(match_payloads) == 1:
        to_apply = match_payloads
    else:
        raise AmbiguousMatchError(find, match_payloads)

    with _com.translate_com_errors():
        # Word's final paragraph mark is undeletable; a range whose End reaches
        # Content.End straddles it and raises COM 0x80020009. Clamp the write
        # target (not the returned payload, which promises pre-edit offsets).
        doc_end = int(self._doc.Content.End)
        # Apply in reverse so earlier offsets don't shift.
        for m in reversed(to_apply):
            start, end = m["start"], min(m["end"], doc_end - 1)
            if end <= start:
                # Clamped away to nothing (match was only the trailing mark).
                continue
            target = self._doc.Range(start, end)
            # Verify the resolved span before writing. An empty resolved text
            # means we can't check (the fake COM, or a genuinely empty range)
            # — proceed. A non-empty mismatch means the offset map drifted
            # (table position divergence): refuse rather than corrupt.
            resolved = str(target.Text or "")
            if resolved and not _findreplace.normalized_equal(resolved, m["text"]):
                raise ReplaceVerificationError(
                    find, m["text"], resolved, anchor_id=m["anchor_id"]
                )
            target.Text = replace
    return to_apply

prepend

prepend(text: str) -> None

Prepend text to the very start of the document, inline (no new paragraph).

The mirror of append: text lands before the document's first character, joining the opening paragraph. Embed \r / \n for your own paragraph breaks; reach for prepend_paragraph when you want text to become a new first paragraph. Wrap in doc.edit(...) for atomic undo. Not idempotent — each call adds more text.

Source code in src/wordlive/_document.py
def prepend(self, text: str) -> None:
    """Prepend `text` to the very start of the document, inline (no new paragraph).

    The mirror of [`append`][wordlive.Document.append]: `text` lands before
    the document's first character, joining the opening paragraph. Embed
    `\\r` / `\\n` for your own paragraph breaks; reach for
    [`prepend_paragraph`][wordlive.Document.prepend_paragraph] when you want
    `text` to *become* a new first paragraph. Wrap in `doc.edit(...)` for
    atomic undo. Not idempotent — each call adds more text.
    """
    with _com.translate_com_errors():
        self._doc.Content.InsertBefore(text)

prepend_paragraph

prepend_paragraph(text: str, *, style: str | None = None) -> None

Prepend text as a new paragraph at the very start of the document.

The mirror of append_paragraph — for a title, a banner, or a disclaimer above everything else. text may contain \r / \n to prepend several paragraphs at once. If style is given it must name a style defined in the document, otherwise StyleNotFoundError is raised before any text is inserted. Wrap in doc.edit(...) for atomic undo. Not idempotent.

Equivalent to insert_paragraph_before(text, style=style) on the document's first paragraph.

Source code in src/wordlive/_document.py
def prepend_paragraph(self, text: str, *, style: str | None = None) -> None:
    """Prepend `text` as a new paragraph at the very start of the document.

    The mirror of [`append_paragraph`][wordlive.Document.append_paragraph]
    — for a title, a banner, or a disclaimer above everything else. `text`
    may contain `\\r` / `\\n` to prepend several paragraphs at once. If
    `style` is given it must name a style defined in the document, otherwise
    `StyleNotFoundError` is raised before any text is inserted. Wrap in
    `doc.edit(...)` for atomic undo. Not idempotent.

    Equivalent to `insert_paragraph_before(text, style=style)` on the
    document's first paragraph.
    """
    style_obj = self.styles[style] if style is not None else None  # validate early
    with _com.translate_com_errors():
        doc_com = self._doc
        # The start has no terminal-mark complication: write "<text><break>"
        # at offset 0 so `text` becomes a new first paragraph.
        insert_rng = doc_com.Range(0, 0)
        insert_rng.Text = text + "\r"
        if style_obj is not None:
            # Word counts UTF-16 code units; len() under-counts surrogates.
            styled = doc_com.Range(0, _utf16_len(text))
            styled.Style = style_obj.com

append

append(text: str) -> None

Append text to the very end of the document, inline (no new paragraph).

The high-level form of the old doc.com.Content.InsertAfter(...) escape hatch: text lands immediately after the document's last character, continuing the final paragraph. Embed \r / \n to introduce your own paragraph breaks; reach for append_paragraph when you want text to become a new paragraph. Wrap in doc.edit(...) for atomic undo. Not idempotent — each call adds more text.

Source code in src/wordlive/_document.py
def append(self, text: str) -> None:
    """Append `text` to the very end of the document, inline (no new paragraph).

    The high-level form of the old `doc.com.Content.InsertAfter(...)` escape
    hatch: `text` lands immediately after the document's last character,
    continuing the final paragraph. Embed `\\r` / `\\n` to introduce your
    own paragraph breaks; reach for
    [`append_paragraph`][wordlive.Document.append_paragraph] when you want
    `text` to *become* a new paragraph. Wrap in `doc.edit(...)` for atomic
    undo. Not idempotent — each call adds more text.
    """
    with _com.translate_com_errors():
        self._doc.Content.InsertAfter(text)

append_paragraph

append_paragraph(text: str, *, style: str | None = None) -> None

Append text as a new paragraph at the very end of the document.

The polite, high-level "end of doc" helper — there is no named anchor for the position past the last paragraph, so this is how you add a closing note, drop in a generated summary, or build a document from the bottom up. text may contain \r / \n to append several paragraphs at once. If style is given it must name a style defined in the document, otherwise StyleNotFoundError is raised before any text is inserted. Wrap in doc.edit(...) for atomic undo. Not idempotent — each call adds another paragraph.

Equivalent to calling insert_paragraph_after(text, style=style) on the document's last paragraph, without having to locate it first.

Source code in src/wordlive/_document.py
def append_paragraph(self, text: str, *, style: str | None = None) -> None:
    """Append `text` as a new paragraph at the very end of the document.

    The polite, high-level "end of doc" helper — there is no named anchor
    for the position past the last paragraph, so this is how you add a
    closing note, drop in a generated summary, or build a document from the
    bottom up. `text` may contain `\\r` / `\\n` to append several paragraphs
    at once. If `style` is given it must name a style defined in the
    document, otherwise `StyleNotFoundError` is raised before any text is
    inserted. Wrap in `doc.edit(...)` for atomic undo. Not idempotent —
    each call adds another paragraph.

    Equivalent to calling `insert_paragraph_after(text, style=style)` on the
    document's last paragraph, without having to locate it first.
    """
    style_obj = self.styles[style] if style is not None else None  # validate early
    with _com.translate_com_errors():
        doc_com = self._doc
        doc_end = int(doc_com.Content.End)
        # Same trick as Anchor.insert_paragraph_after's terminal branch:
        # write "<break><text>" just before the final paragraph mark so
        # `text` becomes a new final paragraph (the original mark closes
        # it). Writing at Range(doc_end, doc_end) — past the final mark —
        # is a "value out of range" COM error.
        anchor_pos = max(0, doc_end - 1)
        insert_rng = doc_com.Range(anchor_pos, anchor_pos)
        insert_rng.Text = "\r" + text
        if style_obj is not None:
            # Word counts UTF-16 code units; len() under-counts surrogate
            # pairs and would leave the tail of astral text unstyled.
            text_start = anchor_pos + 1
            styled = doc_com.Range(text_start, text_start + _utf16_len(text))
            styled.Style = style_obj.com

outline

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

Return all heading paragraphs as [{level, text, anchor_id}, ...].

Source code in src/wordlive/_document.py
def outline(self) -> list[dict[str, Any]]:
    """Return all heading paragraphs as `[{level, text, anchor_id}, ...]`."""
    out: list[dict[str, Any]] = []
    with _com.translate_com_errors():
        for idx, para in enumerate(self._doc.Paragraphs, start=1):
            try:
                level = int(para.OutlineLevel)
            except Exception:
                continue
            if level >= 10:
                continue
            out.append(
                {
                    "level": level,
                    "text": paragraph_text(para),
                    "anchor_id": f"heading:{idx}",
                }
            )
    return out

snapshot

snapshot(out: str | Path | None = None, *, pages: int | tuple[int, int] | None = None, dpi: int = 150) -> list[Snapshot]

Render document page(s) to PNG so a vision model can see the layout.

Word exports a pixel-faithful PDF of the live document and wordlive rasterises the requested pages — a true WYSIWYG image (real fonts, spacing, page geometry), ideal for iterating on style and formatting.

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

If out is given the image is also written there: a single page to out itself, multiple pages alongside it as <stem>-p<N><suffix>.

dpi controls resolution; ~150 reads well for a vision model without bloating the image. Read-only — the document and the user's cursor are untouched. Requires the snapshot extra (PyMuPDF), else SnapshotError.

Source code in src/wordlive/_document.py
def snapshot(
    self,
    out: str | Path | None = None,
    *,
    pages: int | tuple[int, int] | None = None,
    dpi: int = 150,
) -> list[Snapshot]:
    """Render document page(s) to PNG so a vision model can *see* the layout.

    Word exports a pixel-faithful PDF of the live document and wordlive
    rasterises the requested pages — a true WYSIWYG image (real fonts,
    spacing, page geometry), ideal for iterating on style and formatting.

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

    If `out` is given the image is also written there: a single page to `out`
    itself, multiple pages alongside it as `<stem>-p<N><suffix>`.

    `dpi` controls resolution; ~150 reads well for a vision model without
    bloating the image. Read-only — the document and the user's cursor are
    untouched. Requires the `snapshot` extra (PyMuPDF), else
    [`SnapshotError`][wordlive.SnapshotError].
    """
    from_page, to_page = self._resolve_page_arg(pages)
    rendered = _snapshot.render(self._doc, from_page=from_page, to_page=to_page, dpi=dpi)
    return _snapshot.build_snapshots(rendered, out)

snapshot_anchor

snapshot_anchor(anchor: Anchor, out: str | Path | None = None, *, dpi: int = 150) -> list[Snapshot]

Render the page(s) an anchor sits on. Backs Anchor.snapshot.

A heading: anchor expands to its whole section (the heading plus the body beneath it, up to the next same-or-higher heading); any other anchor renders the page(s) its range spans. See snapshot for out/dpi semantics and the return shape.

Source code in src/wordlive/_document.py
def snapshot_anchor(
    self, anchor: Anchor, out: str | Path | None = None, *, dpi: int = 150
) -> list[Snapshot]:
    """Render the page(s) an anchor sits on. Backs [`Anchor.snapshot`][wordlive.Anchor.snapshot].

    A `heading:` anchor expands to its whole section (the heading plus the
    body beneath it, up to the next same-or-higher heading); any other
    anchor renders the page(s) its range spans. See
    [`snapshot`][wordlive.Document.snapshot] for `out`/`dpi` semantics and
    the return shape.
    """
    from_page, to_page = self._anchor_page_span(anchor)
    rendered = _snapshot.render(self._doc, from_page=from_page, to_page=to_page, dpi=dpi)
    return _snapshot.build_snapshots(rendered, out)

edit

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

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

with doc.edit("Update address"):
    doc.bookmarks["Address"].set_text("…")
Source code in src/wordlive/_document.py
@contextmanager
def edit(self, label: str) -> Iterator[EditScope]:
    """Open an atomic-undo / Selection-preserving edit scope.

    ```
    with doc.edit("Update address"):
        doc.bookmarks["Address"].set_text("…")
    ```
    """
    scope = EditScope(self._word, label)
    with scope:
        yield scope

go_to

go_to(anchor: Anchor, scroll: bool = True) -> None

Move the user's Selection to the given anchor (rare — most ops preserve it).

Does NOT open an UndoRecord — cursor moves don't belong on the user's undo stack. If you want the move to ride along with a batch of edits, call this inside a doc.edit(...) scope and the surrounding UndoRecord will still group everything together.

Source code in src/wordlive/_document.py
def go_to(self, anchor: Anchor, scroll: bool = True) -> None:
    """Move the user's Selection to the given anchor (rare — most ops preserve it).

    Does NOT open an `UndoRecord` — cursor moves don't belong on the user's
    undo stack. If you want the move to ride along with a batch of edits,
    call this inside a `doc.edit(...)` scope and the surrounding
    `UndoRecord` will still group everything together.
    """
    with _com.translate_com_errors():
        rng = anchor.com
        collapsed = self._doc.Range(int(rng.Start), int(rng.Start))
        collapsed.Select()
        if scroll:
            try:
                self._word.com.ActiveWindow.ScrollIntoView(collapsed)
            except Exception:
                pass

wordlive.DocumentCollection

DocumentCollection(word: Word)

Indexable view over open documents.

Source code in src/wordlive/_document.py
def __init__(self, word: Word) -> None:
    self._word = word

list

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

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

name is the document's window name (e.g. Report.docx, or Document1 for one never saved) and is always non-empty so a caller can confirm which document it is about to edit. saved is whether the document has an on-disk location yet; path is that full path, or empty for an unsaved document. The active document is matched by full path (falling back to name), which is robust when several unsaved documents share a blank path.

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

    `name` is the document's window name (e.g. ``Report.docx``, or
    ``Document1`` for one never saved) and is always non-empty so a caller
    can confirm which document it is about to edit. `saved` is whether the
    document has an on-disk location yet; `path` is that full path, or empty
    for an unsaved document. The active document is matched by full path
    (falling back to name), which is robust when several unsaved documents
    share a blank path.
    """
    out: list[dict[str, Any]] = []
    with _com.translate_com_errors():
        active_name: str | None
        active_full: str | None
        try:
            active = self._word.com.ActiveDocument
            active_name = str(active.Name)
            active_full = str(active.FullName)
        except Exception:
            active_name = active_full = None
        for doc in self._com_collection:
            name = str(doc.Name or "")
            full = str(doc.FullName or "")
            try:
                on_disk = bool(str(doc.Path or ""))
            except Exception:
                on_disk = False
            is_active = (full == active_full) if full and active_full else (name == active_name)
            out.append(
                {
                    "name": name or full or "Document",
                    "path": full if on_disk else "",
                    "saved": on_disk,
                    "is_active": bool(is_active),
                }
            )
    return out

Anchors

Every anchor type inherits apply_style(name), format_paragraph(...), insert_paragraph_before/after(...), insert_image(...), insert_table(...), insert_break(...), and the list verbs (apply_list, remove_list, list_info, restart_numbering, indent_list, outdent_list) from Anchor, so the same calls work uniformly on bookmarks, content controls, headings, paragraphs, table cells, header/footer ranges, and arbitrary range anchors. insert_image accepts a file path, raw bytes, or a base64 string and embeds the picture; wrap is required ("inline", "auto", or a float wrap like "square"/"top-bottom"), and block=True places the image on its own new line rather than in the anchor's text run. insert_table(rows, cols, …) creates a new table at the anchor and returns its Table (append at the end with Document.add_table). insert_break(kind="page"|"column"|"section_next"|"section_continuous") drops an explicit break; for a reflow-safe page break tied to a paragraph (e.g. every Heading 1), pass page_break_before=True to format_paragraph instead. Every anchor also has snapshot(...), which renders the page(s) it sits on to PNG (a heading expands to its whole section) — see Snapshots.

wordlive.Anchor

Anchor(doc: Document, name: str)

Bases: ABC

Abstract base — subclasses know how to materialise their COM Range.

Concrete subclasses must implement _range() and set_text(). Other operations (text, insert_before, insert_after, delete, apply_style, format_paragraph) are derived and inherited as-is.

Source code in src/wordlive/_anchors.py
def __init__(self, doc: Document, name: str) -> None:
    self._doc = doc
    self.name = name

com property

com: Any

Raw COM range. Subclasses override.

anchor_id abstractmethod property

anchor_id: str

Stable string identifier for this anchor (e.g. bookmark:Address).

Each anchor kind has its own scheme (bookmark:, cc:, heading:), so subclasses must declare theirs explicitly — no useful default exists at this level.

set_text abstractmethod

set_text(text: str) -> None

Replace the anchor's text in place. Must be overridden.

Source code in src/wordlive/_anchors.py
@abstractmethod
def set_text(self, text: str) -> None:
    """Replace the anchor's text in place. Must be overridden."""

insert_paragraph_before

insert_paragraph_before(text: str, style: str | None = None) -> None

Insert a new paragraph immediately before this anchor's range.

If style is given it must name a style defined in the document; otherwise StyleNotFoundError is raised before any text is inserted.

Source code in src/wordlive/_anchors.py
def insert_paragraph_before(self, text: str, style: str | None = None) -> None:
    """Insert a new paragraph immediately before this anchor's range.

    If `style` is given it must name a style defined in the document;
    otherwise `StyleNotFoundError` is raised before any text is inserted.
    """
    style_obj = self._doc.styles[style] if style is not None else None
    with _com.translate_com_errors():
        doc_com = self._doc.com
        start = int(self._range().Start)
        insert_rng = doc_com.Range(start, start)
        insert_rng.Text = text + "\r"
        if style_obj is not None:
            # Word measures Range offsets in UTF-16 code units; Python's
            # len() under-counts surrogate pairs and leaves the tail unstyled.
            styled = doc_com.Range(start, start + _utf16_len(text))
            styled.Style = style_obj.com

insert_paragraph_after

insert_paragraph_after(text: str, style: str | None = None) -> None

Insert a new paragraph immediately after this anchor's range.

If style is given it must name a style defined in the document; otherwise StyleNotFoundError is raised before any text is inserted.

When the anchor is (or ends at) the document's final paragraph there is no position after the terminal paragraph mark to write to — Word rejects Range(end, end) there with a "value out of range" COM error. In that case the new paragraph is split in just before the final mark instead, so appending to the end of a document — the common "build from scratch" case, where the only paragraph is the last one — just works.

Source code in src/wordlive/_anchors.py
def insert_paragraph_after(self, text: str, style: str | None = None) -> None:
    """Insert a new paragraph immediately after this anchor's range.

    If `style` is given it must name a style defined in the document;
    otherwise `StyleNotFoundError` is raised before any text is inserted.

    When the anchor is (or ends at) the document's final paragraph there is
    no position *after* the terminal paragraph mark to write to — Word
    rejects `Range(end, end)` there with a "value out of range" COM error.
    In that case the new paragraph is split in just before the final mark
    instead, so appending to the end of a document — the common
    "build from scratch" case, where the only paragraph *is* the last one —
    just works.
    """
    style_obj = self._doc.styles[style] if style is not None else None
    with _com.translate_com_errors():
        doc_com = self._doc.com
        end = int(self._range().End)
        doc_end = int(doc_com.Content.End)
        if end >= doc_end:
            # Anchor ends at the final paragraph mark. Insert "<break><text>"
            # just before that mark: the leading break terminates the
            # anchor's paragraph and `text` becomes a new final paragraph
            # (the original final mark now closes it).
            anchor_pos = max(0, doc_end - 1)
            insert_rng = doc_com.Range(anchor_pos, anchor_pos)
            insert_rng.Text = "\r" + text
            text_start = anchor_pos + 1
        else:
            insert_rng = doc_com.Range(end, end)
            insert_rng.Text = text + "\r"
            text_start = end
        if style_obj is not None:
            # Word measures Range offsets in UTF-16 code units; Python's
            # len() under-counts surrogate pairs and leaves the tail unstyled.
            styled = doc_com.Range(text_start, text_start + _utf16_len(text))
            styled.Style = style_obj.com

insert_image

insert_image(image: str | Path | bytes, *, wrap: str, where: str = 'after', block: bool = False, width: float | None = None, height: float | None = None, alt_text: str | None = None, lock_aspect: bool = True) -> None

Insert an image at this anchor (atomic-undo when inside doc.edit()).

image is a file path, raw image bytes, or a base64 string — a str is treated as a path when it names an existing file, otherwise as base64. Word embeds the picture (SaveWithDocument=True) and auto-detects its natural size, so width/height (points) are optional overrides. alt_text sets the image's accessibility text.

wrap is required — there is no default — so layout intent is always explicit:

  • "inline" keeps the image in the text flow (an InlineShape).
  • "auto" floats it: Square when its width is at most half the section's usable text width, else top-and-bottom.
  • "square" | "tight" | "through" | "top-bottom" | "front" | "behind" floats it with that wrap type.

where is "after" (default) or "before" the anchor's range.

block places the image in its own new paragraph (reset to Normal) rather than embedding it in the anchor's text run — so heading.insert_image(..., wrap="inline", where="before", block=True) drops the image on its own line above the heading instead of joining the heading text. Without it, an inline image anchored at a heading lands mid-run and the heading text trails it on the same line.

Raises ImageSourceError for a missing/unreadable/invalid image and ValueError for an unknown wrap or where.

Source code in src/wordlive/_anchors.py
def insert_image(
    self,
    image: str | Path | bytes,
    *,
    wrap: str,
    where: str = "after",
    block: bool = False,
    width: float | None = None,
    height: float | None = None,
    alt_text: str | None = None,
    lock_aspect: bool = True,
) -> None:
    """Insert an image at this anchor (atomic-undo when inside `doc.edit()`).

    `image` is a file path, raw image bytes, or a base64 string — a `str`
    is treated as a path when it names an existing file, otherwise as
    base64. Word embeds the picture (`SaveWithDocument=True`) and
    auto-detects its natural size, so `width`/`height` (points) are optional
    overrides. `alt_text` sets the image's accessibility text.

    `wrap` is required — there is no default — so layout intent is always
    explicit:

    - ``"inline"`` keeps the image in the text flow (an `InlineShape`).
    - ``"auto"`` floats it: Square when its width is at most half the
      section's usable text width, else top-and-bottom.
    - ``"square" | "tight" | "through" | "top-bottom" | "front" | "behind"``
      floats it with that wrap type.

    `where` is ``"after"`` (default) or ``"before"`` the anchor's range.

    `block` places the image in its own new paragraph (reset to ``Normal``)
    rather than embedding it in the anchor's text run — so
    ``heading.insert_image(..., wrap="inline", where="before", block=True)``
    drops the image on its own line *above* the heading instead of joining
    the heading text. Without it, an inline image anchored at a heading lands
    mid-run and the heading text trails it on the same line.

    Raises `ImageSourceError` for a missing/unreadable/invalid image and
    `ValueError` for an unknown `wrap` or `where`.
    """
    if wrap not in _WRAP_VALUES:
        raise ValueError(f"unknown wrap {wrap!r}; expected one of {sorted(_WRAP_VALUES)}")
    if where not in ("before", "after"):
        raise ValueError(f"where must be 'before' or 'after'; got {where!r}")
    # New paragraphs inherit the anchor's style — a block image above a
    # heading would otherwise become a heading-styled (and outline-polluting)
    # paragraph. Reset it to the body default, like insert_table does.
    normal_obj = self._doc.styles["Normal"] if block and "Normal" in self._doc.styles else None
    with _images.image_on_disk(image) as disk_path:
        with _com.translate_com_errors():
            doc_com = self._doc.com
            rng = self._range()
            pos = int(rng.Start) if where == "before" else int(rng.End)
            if block:
                # Open a fresh paragraph at the insertion point and target it,
                # so the image sits on its own line instead of in the run.
                doc_com.Range(pos, pos).Text = "\r"
                if normal_obj is not None:
                    doc_com.Range(pos, pos).Paragraphs(1).Range.Style = normal_obj.com
            insert_rng = doc_com.Range(pos, pos)
            ish = insert_rng.InlineShapes.AddPicture(
                FileName=disk_path,
                LinkToFile=False,
                SaveWithDocument=True,
                Range=insert_rng,
            )
            ish.LockAspectRatio = int(MsoTriState.TRUE if lock_aspect else MsoTriState.FALSE)
            if width is not None:
                ish.Width = float(width)
            if height is not None:
                ish.Height = float(height)
            if alt_text is not None:
                ish.AlternativeText = alt_text
            if wrap == "inline":
                return
            wrap_type = _resolve_wrap(wrap, ish, insert_rng)
            shape = ish.ConvertToShape()
            shape.WrapFormat.Type = int(wrap_type)
            if alt_text is not None:
                # AlternativeText doesn't always survive the conversion.
                shape.AlternativeText = alt_text

insert_table

insert_table(rows: int, cols: int, *, where: str = 'after', style: str | None = None, data: list[list[Any]] | None = None, header: bool = False) -> Any

Create a rows × cols table at this anchor and return it.

The structural counterpart to insert_image — it creates new document structure rather than editing existing structure. Returns the new Table wrapper so create → fill → read closes on one object; the table's 1-based document index is on .index.

where is "after" (default) or "before" this anchor's range — so doc.headings["Pricing"].insert_table(...) drops a table just under a heading, and doc.end.insert_table(...) (i.e. Document.add_table) appends one.

style names a table style defined in the document (e.g. "Table Grid"); an unknown name raises StyleNotFoundError before anything is inserted. style=None applies the built-in "Table Grid" when it's available, so a table has visible borders by default rather than the invisible cell gridlines of a styleless table.

data populates the cells at creation: a row-major 2-D list ([[r1c1, r1c2], …]), validated against rows × cols up front (OpError on overflow). A short or partial data leaves the remaining cells empty. Filling at creation keeps the whole grid in one atomic undo and beats a set_cell storm.

header=True bolds the first row as a header. Wrap in doc.edit(...) for atomic undo. Raises ValueError for an unknown where and OpError for a non-positive rows/cols or a bad data shape.

Source code in src/wordlive/_anchors.py
def insert_table(
    self,
    rows: int,
    cols: int,
    *,
    where: str = "after",
    style: str | None = None,
    data: list[list[Any]] | None = None,
    header: bool = False,
) -> Any:
    """Create a `rows` × `cols` table at this anchor and return it.

    The structural counterpart to `insert_image` — it *creates* new
    document structure rather than editing existing structure. Returns the
    new [`Table`][wordlive.Table] wrapper so create → fill → read closes on
    one object; the table's 1-based document index is on `.index`.

    `where` is ``"after"`` (default) or ``"before"`` this anchor's range —
    so `doc.headings["Pricing"].insert_table(...)` drops a table just under
    a heading, and `doc.end.insert_table(...)` (i.e.
    [`Document.add_table`][wordlive.Document.add_table]) appends one.

    `style` names a table style defined in the document (e.g. ``"Table
    Grid"``); an unknown name raises `StyleNotFoundError` before anything is
    inserted. `style=None` applies the built-in ``"Table Grid"`` when it's
    available, so a table has visible borders by default rather than the
    invisible cell gridlines of a styleless table.

    `data` populates the cells at creation: a row-major 2-D list
    (``[[r1c1, r1c2], …]``), validated against `rows` × `cols` up front
    (`OpError` on overflow). A short or partial `data` leaves the remaining
    cells empty. Filling at creation keeps the whole grid in one atomic
    undo and beats a `set_cell` storm.

    `header=True` bolds the first row as a header. Wrap in `doc.edit(...)`
    for atomic undo. Raises `ValueError` for an unknown `where` and
    `OpError` for a non-positive `rows`/`cols` or a bad `data` shape.
    """
    from ._tables import Table, index_of

    if where not in ("before", "after"):
        raise ValueError(f"where must be 'before' or 'after'; got {where!r}")
    if isinstance(rows, bool) or not isinstance(rows, int) or rows < 1:
        raise OpError(f"table rows must be a positive integer; got {rows!r}")
    if isinstance(cols, bool) or not isinstance(cols, int) or cols < 1:
        raise OpError(f"table cols must be a positive integer; got {cols!r}")
    if data is not None:
        _validate_table_data(data, rows, cols)
    # Resolve the style up-front so a bad name fails before any mutation.
    if style is not None:
        style_obj = self._doc.styles[style]  # StyleNotFoundError (exit 2) if missing
    elif "Table Grid" in self._doc.styles:
        style_obj = self._doc.styles["Table Grid"]
    else:
        style_obj = None
    # New cells inherit the *paragraph* style at the insertion point — drop a
    # table right after a `Heading 2` and Word makes every cell Heading 2,
    # which renders as large heading text and pollutes the navigation
    # outline. Reset the cells to the body default (`Normal`) so a table
    # looks like a table regardless of where it was anchored. The table
    # `style` above (borders etc.) and `header` bolding still apply on top.
    normal_obj = self._doc.styles["Normal"] if "Normal" in self._doc.styles else None
    with _com.translate_com_errors():
        doc_com = self._doc.com
        rng = self._range()
        pos = int(rng.Start) if where == "before" else int(rng.End)
        # Word's final paragraph mark is undeletable and Tables.Add needs a
        # paragraph *after* the insertion point to anchor the table; at/after
        # that mark there is none, so the add raises COM 0x80020009. Push a
        # trailing paragraph first so the table lands before it (a document
        # can't end with a table anyway — Word keeps a paragraph after one).
        doc_end = int(doc_com.Content.End)
        if pos >= doc_end - 1:
            pos = max(0, doc_end - 1)
            doc_com.Range(pos, pos).Text = "\r"
        # Word merges two tables that touch with no paragraph mark between
        # them, so a table appended at the end (or dropped next to another)
        # would silently fuse into its neighbour. Push a separator paragraph
        # onto whichever side abuts an existing table; untouched insertions
        # into ordinary text get no stray paragraph.
        if _within_table(doc_com, pos - 1, pos):
            doc_com.Range(pos, pos).Text = "\r"
            pos += 1
        if _within_table(doc_com, pos, pos + 1):
            doc_com.Range(pos, pos).Text = "\r"
        insert_rng = doc_com.Range(pos, pos)
        table_com = doc_com.Tables.Add(insert_rng, rows, cols)
        if style_obj is not None:
            table_com.Style = style_obj.com
        if normal_obj is not None:
            # Per-cell rather than table_com.Range.Style: a paragraph style
            # set on the whole table range can bleed onto the paragraph that
            # follows the table; the cell loop is contained and explicit.
            normal_com = normal_obj.com
            for r in range(1, rows + 1):
                for c in range(1, cols + 1):
                    table_com.Cell(r, c).Range.Style = normal_com
        if data:
            for r, row in enumerate(data, start=1):
                for c, val in enumerate(row, start=1):
                    table_com.Cell(r, c).Range.Text = str(val)
        if header:
            table_com.Rows(1).Range.Bold = True
        index = index_of(self._doc.com, table_com)
    return Table(self._doc, table_com, index)

insert_break

insert_break(kind: str = 'page', *, where: str = 'after') -> None

Insert a page, column, or section break at this anchor.

The explicit one-off break — the clean alternative to appending a paragraph whose text is a literal form-feed. kind is one of:

  • "page" (default) — a manual page break (the 90% case).
  • "column" — a column break (multi-column layouts).
  • "section_next" — a section break that starts the new section on the next page.
  • "section_continuous" — a section break with no page break, so the new section flows on the same page.

Section breaks pair with Document.sections: each new section gets its own headers/footers and page setup. To make a style (e.g. every Heading 1) open a new page without a stray break character, prefer format_paragraph(page_break_before=True) instead — it survives reflow.

where is "after" (default) or "before" this anchor's range. Wrap in doc.edit(...) for atomic undo. Raises ValueError for an unknown kind or where.

Source code in src/wordlive/_anchors.py
def insert_break(self, kind: str = "page", *, where: str = "after") -> None:
    """Insert a page, column, or section break at this anchor.

    The explicit one-off break — the clean alternative to appending a
    paragraph whose text is a literal form-feed. `kind` is one of:

    - ``"page"`` (default) — a manual page break (the 90% case).
    - ``"column"`` — a column break (multi-column layouts).
    - ``"section_next"`` — a section break that starts the new section on
      the next page.
    - ``"section_continuous"`` — a section break with no page break, so the
      new section flows on the same page.

    Section breaks pair with [`Document.sections`][wordlive.Document.sections]:
    each new section gets its own headers/footers and page setup. To make a
    *style* (e.g. every `Heading 1`) open a new page without a stray break
    character, prefer
    [`format_paragraph(page_break_before=True)`][wordlive.Anchor.format_paragraph]
    instead — it survives reflow.

    `where` is ``"after"`` (default) or ``"before"`` this anchor's range.
    Wrap in `doc.edit(...)` for atomic undo. Raises `ValueError` for an
    unknown `kind` or `where`.
    """
    if kind not in _BREAK_TYPES:
        raise ValueError(f"unknown break kind {kind!r}; expected one of {sorted(_BREAK_TYPES)}")
    if where not in ("before", "after"):
        raise ValueError(f"where must be 'before' or 'after'; got {where!r}")
    break_type = _BREAK_TYPES[kind]
    # A section break creates a *new* paragraph to carry the break, and that
    # paragraph inherits the anchor's style — drop one before a `Heading 1`
    # and Word makes the break paragraph a heading, leaving a spurious empty
    # entry in the navigation outline / TOC. Reset it to `Normal` so the break
    # is invisible to the outline. (Page/column breaks are an in-paragraph
    # character and create no such paragraph, so they need no reset.)
    is_section = kind in ("section_next", "section_continuous")
    normal_obj = (
        self._doc.styles["Normal"] if is_section and "Normal" in self._doc.styles else None
    )
    with _com.translate_com_errors():
        rng = self._range()
        pos = int(rng.Start) if where == "before" else int(rng.End)
        insert_rng = self._doc.com.Range(pos, pos)
        insert_rng.InsertBreak(Type=int(break_type))
        if normal_obj is not None:
            # The break now occupies the position we inserted at; the
            # paragraph containing `pos` is the break paragraph.
            break_para = self._doc.com.Range(pos, pos).Paragraphs(1)
            break_para.Range.Style = normal_obj.com

snapshot

snapshot(out: str | Path | None = None, *, dpi: int = 150) -> list[Snapshot]

Render the page(s) this anchor sits on to PNG — let a model see it.

A heading expands to its whole section; any other anchor renders the page(s) its range spans. Returns a list of Snapshot (one per page); pass out to also write the image(s) to disk. Sugar for Document.snapshot_anchor; see it for the full semantics. Requires the snapshot extra (PyMuPDF).

Source code in src/wordlive/_anchors.py
def snapshot(self, out: str | Path | None = None, *, dpi: int = 150) -> list[Snapshot]:
    """Render the page(s) this anchor sits on to PNG — let a model *see* it.

    A heading expands to its whole section; any other anchor renders the
    page(s) its range spans. Returns a list of
    [`Snapshot`][wordlive.Snapshot] (one per page); pass `out` to also write
    the image(s) to disk. Sugar for
    [`Document.snapshot_anchor`][wordlive.Document.snapshot_anchor]; see it
    for the full semantics. Requires the `snapshot` extra (PyMuPDF).
    """
    return self._doc.snapshot_anchor(self, out, dpi=dpi)

apply_style

apply_style(name: str) -> None

Apply the named paragraph or character style to this anchor's range.

Word selects paragraph- vs. character-style behaviour from the style's own Type; we don't model that distinction. Raises StyleNotFoundError if the style isn't defined in the document.

Source code in src/wordlive/_anchors.py
def apply_style(self, name: str) -> None:
    """Apply the named paragraph or character style to this anchor's range.

    Word selects paragraph- vs. character-style behaviour from the style's
    own `Type`; we don't model that distinction. Raises `StyleNotFoundError`
    if the style isn't defined in the document.
    """
    style = self._doc.styles[name]  # raises StyleNotFoundError if missing
    with _com.translate_com_errors():
        self._range().Style = style.com

format_paragraph

format_paragraph(*, alignment: Any = None, left_indent: float | None = None, right_indent: float | None = None, first_line_indent: float | None = None, space_before: float | None = None, space_after: float | None = None, page_break_before: bool | None = None) -> None

Set paragraph-formatting properties on this anchor's range.

All kwargs are optional; only the ones explicitly passed are written. Indent and spacing values are in points (Word's native unit for ParagraphFormat.LeftIndent etc.). alignment accepts a WdParagraphAlignment enum, its int value, or a string ("left"/"center"/"right"/"justify").

page_break_before=True forces the paragraph to begin on a new page — the clean way to page-break (e.g. apply it to every Heading 1): it's a paragraph property that survives reflow and leaves no stray break character, unlike insert_break. False clears the property.

Source code in src/wordlive/_anchors.py
def format_paragraph(
    self,
    *,
    alignment: Any = None,
    left_indent: float | None = None,
    right_indent: float | None = None,
    first_line_indent: float | None = None,
    space_before: float | None = None,
    space_after: float | None = None,
    page_break_before: bool | None = None,
) -> None:
    """Set paragraph-formatting properties on this anchor's range.

    All kwargs are optional; only the ones explicitly passed are written.
    Indent and spacing values are in points (Word's native unit for
    `ParagraphFormat.LeftIndent` etc.). `alignment` accepts a
    `WdParagraphAlignment` enum, its int value, or a string
    (`"left"`/`"center"`/`"right"`/`"justify"`).

    `page_break_before=True` forces the paragraph to begin on a new page —
    the *clean* way to page-break (e.g. apply it to every `Heading 1`): it's
    a paragraph property that survives reflow and leaves no stray break
    character, unlike [`insert_break`][wordlive.Anchor.insert_break].
    `False` clears the property.
    """
    with _com.translate_com_errors():
        pf = self._range().ParagraphFormat
        if alignment is not None:
            pf.Alignment = _coerce_alignment(alignment)
        if left_indent is not None:
            pf.LeftIndent = float(left_indent)
        if right_indent is not None:
            pf.RightIndent = float(right_indent)
        if first_line_indent is not None:
            pf.FirstLineIndent = float(first_line_indent)
        if space_before is not None:
            pf.SpaceBefore = float(space_before)
        if space_after is not None:
            pf.SpaceAfter = float(space_after)
        if page_break_before is not None:
            pf.PageBreakBefore = bool(page_break_before)

apply_list

apply_list(list_type: str = 'bulleted', *, continue_previous: bool = False) -> None

Turn this anchor's paragraphs into a list.

list_type is "bulleted", "numbered", or "outline" (the three ListGalleries). By default numbering starts fresh at 1; pass continue_previous=True to continue from a list immediately above. Raises ValueError for an unknown list_type.

Source code in src/wordlive/_anchors.py
def apply_list(self, list_type: str = "bulleted", *, continue_previous: bool = False) -> None:
    """Turn this anchor's paragraphs into a list.

    `list_type` is `"bulleted"`, `"numbered"`, or `"outline"` (the three
    `ListGalleries`). By default numbering starts fresh at 1; pass
    `continue_previous=True` to continue from a list immediately above.
    Raises `ValueError` for an unknown `list_type`.
    """
    gallery_type = _lists.gallery_for(list_type)  # ValueError before any mutation
    with _com.translate_com_errors():
        _lists.apply_list_template(
            self._range(), gallery_type, continue_previous=continue_previous
        )

remove_list

remove_list() -> None

Strip list formatting (bullets / numbers) from this anchor's paragraphs.

Source code in src/wordlive/_anchors.py
def remove_list(self) -> None:
    """Strip list formatting (bullets / numbers) from this anchor's paragraphs."""
    with _com.translate_com_errors():
        self._range().ListFormat.RemoveNumbers(NumberType=int(WdNumberType.ALL_NUMBERS))

list_info

list_info() -> dict[str, Any]

Describe the list this anchor sits in: {type, level, number, string}.

type is "none" when there's no list formatting, otherwise one of "bulleted", "numbered", "outline", "number-only", or "mixed". number is the first paragraph's value, string its rendered marker.

Source code in src/wordlive/_anchors.py
def list_info(self) -> dict[str, Any]:
    """Describe the list this anchor sits in: `{type, level, number, string}`.

    `type` is `"none"` when there's no list formatting, otherwise one of
    `"bulleted"`, `"numbered"`, `"outline"`, `"number-only"`, or `"mixed"`.
    `number` is the first paragraph's value, `string` its rendered marker.
    """
    with _com.translate_com_errors():
        return _lists.read_list_info(self._range())

restart_numbering

restart_numbering() -> None

Restart this list's numbering at 1.

Re-applies the range's current list template with "continue previous" off. Raises ValueError if the range isn't part of a list.

Source code in src/wordlive/_anchors.py
def restart_numbering(self) -> None:
    """Restart this list's numbering at 1.

    Re-applies the range's current list template with "continue previous"
    off. Raises `ValueError` if the range isn't part of a list.
    """
    with _com.translate_com_errors():
        _lists.restart_numbering(self._range())

indent_list

indent_list() -> None

Demote this list item one level (e.g. level 1 -> 2).

Source code in src/wordlive/_anchors.py
def indent_list(self) -> None:
    """Demote this list item one level (e.g. level 1 -> 2)."""
    with _com.translate_com_errors():
        self._range().ListFormat.ListIndent()

outdent_list

outdent_list() -> None

Promote this list item one level (e.g. level 2 -> 1).

Source code in src/wordlive/_anchors.py
def outdent_list(self) -> None:
    """Promote this list item one level (e.g. level 2 -> 1)."""
    with _com.translate_com_errors():
        self._range().ListFormat.ListOutdent()

wordlive.Bookmark

Bookmark(doc: Document, name: str)

Bases: Anchor

Source code in src/wordlive/_anchors.py
def __init__(self, doc: Document, name: str) -> None:
    self._doc = doc
    self.name = name

wordlive.ContentControl

ContentControl(doc: Document, name: str)

Bases: Anchor

Source code in src/wordlive/_anchors.py
def __init__(self, doc: Document, name: str) -> None:
    self._doc = doc
    self.name = name

wordlive.Heading

Heading(doc: Document, name: str)

Bases: Anchor

Source code in src/wordlive/_anchors.py
def __init__(self, doc: Document, name: str) -> None:
    self._doc = doc
    self.name = name

section_range

section_range() -> Any

COM Range covering the body under this heading.

Spans from the end of the heading paragraph to the start of the next heading whose level is <= this one's (or to the end of the document if no such heading exists). Excludes the heading paragraph itself.

Source code in src/wordlive/_anchors.py
def section_range(self) -> Any:
    """COM Range covering the body under this heading.

    Spans from the end of the heading paragraph to the start of the next
    heading whose level is `<=` this one's (or to the end of the document
    if no such heading exists). Excludes the heading paragraph itself.
    """
    with _com.translate_com_errors():
        para = self._paragraph()
        level = int(para.OutlineLevel)
        return _section_range(self._doc.com, para, level)

section_text

section_text() -> str

Plain text of the body under this heading.

Source code in src/wordlive/_anchors.py
def section_text(self) -> str:
    """Plain text of the body under this heading."""
    with _com.translate_com_errors():
        return str(self.section_range().Text or "")

wordlive.HeadingCollection

HeadingCollection(doc: Document)

Iterable, indexable view over a document's headings.

Symmetric with BookmarkCollection and ContentControlCollection:

for h in doc.headings:           # iteration → Heading per heading paragraph
    ...
doc.headings["Risks"]            # by visible text
doc.headings[3]                  # by 1-based paragraph index
"Risks" in doc.headings          # membership
doc.headings.list()              # same shape as doc.outline()
Source code in src/wordlive/_anchors.py
def __init__(self, doc: Document) -> None:
    self._doc = doc

list

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

Same shape as Document.outline()[{level, text, anchor_id}, ...].

Source code in src/wordlive/_anchors.py
def list(self) -> list[dict[str, Any]]:
    """Same shape as `Document.outline()` — `[{level, text, anchor_id}, ...]`."""
    out: list[dict[str, Any]] = []
    with _com.translate_com_errors():
        for idx, para in enumerate(self._doc.com.Paragraphs, start=1):
            try:
                level = int(para.OutlineLevel)
            except Exception:
                continue
            if level >= 10:
                continue
            out.append(
                {
                    "level": level,
                    "text": paragraph_text(para),
                    "anchor_id": f"heading:{idx}",
                }
            )
    return out

wordlive.Paragraph

Paragraph(doc: Document, index: int)

Bases: Anchor

A paragraph located by 1-based index over doc.Paragraphs.

para:N addresses any paragraph — body text, headings, list items alike. heading:N is the same index space narrowed to heading paragraphs, so para:5 and heading:5 resolve to the same paragraph when paragraph 5 is a heading. A Paragraph inherits every anchor verb (set_text, apply_style, format_paragraph, apply_list, insert_paragraph_before/after, …).

Source code in src/wordlive/_anchors.py
def __init__(self, doc: Document, index: int) -> None:
    super().__init__(doc, name=f"para:{index}")
    self._index = index

wordlive.ParagraphCollection

ParagraphCollection(doc: Document)

Indexable, iterable view over every paragraph in the document.

Unlike headings, this includes body paragraphs and list items, not just heading paragraphs. Index by 1-based position (doc.paragraphs[2]); iterate for a Paragraph per paragraph. list() emits each paragraph's start / end offsets, so a body paragraph can be turned into a range:START-END insertion point for mid-paragraph edits.

Source code in src/wordlive/_anchors.py
def __init__(self, doc: Document) -> None:
    self._doc = doc

at

at(offset: int) -> Paragraph | None

Return the paragraph whose range contains offset, or None.

Used to map a character offset (e.g. the cursor position) back to a para:N anchor.

Source code in src/wordlive/_anchors.py
def at(self, offset: int) -> Paragraph | None:
    """Return the paragraph whose range contains `offset`, or None.

    Used to map a character offset (e.g. the cursor position) back to a
    `para:N` anchor.
    """
    with _com.translate_com_errors():
        for idx, para in enumerate(self._doc.com.Paragraphs, start=1):
            rng = para.Range
            if int(rng.Start) <= offset < int(rng.End):
                return Paragraph(self._doc, idx)
    return None

list

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

Every paragraph as [{index, anchor_id, level, is_heading, start, end, text}, ...].

Source code in src/wordlive/_anchors.py
def list(self) -> list[dict[str, Any]]:
    """Every paragraph as `[{index, anchor_id, level, is_heading, start, end, text}, ...]`."""
    out: list[dict[str, Any]] = []
    with _com.translate_com_errors():
        for idx, para in enumerate(self._doc.com.Paragraphs, start=1):
            try:
                level = int(para.OutlineLevel)
            except Exception:
                level = 10
            rng = para.Range
            out.append(
                {
                    "index": idx,
                    "anchor_id": f"para:{idx}",
                    "level": level,
                    "is_heading": level < 10,
                    "start": int(rng.Start),
                    "end": int(rng.End),
                    "text": paragraph_text(para),
                }
            )
    return out

wordlive.RangeAnchor

RangeAnchor(doc: Document, start: int, end: int)

Bases: Anchor

An anchor over an arbitrary character range — doc.range(start, end).

Unlike bookmarks/headings/cells, a range anchor names nothing in the document: it's a pair of absolute character offsets (UTF-16 code units, the same coordinates Word's Document.Range(start, end) uses and that Document.find() emits as range:START-END). It's the generic target when no named anchor exists — feed a find() hit straight into a replace, or drop a comment on an offset span.

The anchor is ephemeral: offsets resolve live against the document on each access, so an edit elsewhere that shifts the text can leave it pointing at the wrong span. Resolve, act, discard. set_text keeps the anchor's own end in sync with the replacement so chained ops on the same instance stay consistent.

Source code in src/wordlive/_anchors.py
def __init__(self, doc: Document, start: int, end: int) -> None:
    start = int(start)
    end = int(end)
    if start < 0 or end < start:
        raise ValueError(f"invalid range offsets: start={start}, end={end}")
    super().__init__(doc, name=f"range:{start}-{end}")
    self._start = start
    self._end = end

wordlive.StartAnchor

StartAnchor(doc: Document)

Bases: Anchor

A zero-width anchor at the very start of the document body — doc.start.

The mirror of EndAnchor: the insertion point before the first paragraph. doc.start returns it and anchor_by_id("start") resolves it, so "prepend to the document" composes with the usual verbs and the CLI --anchor-id plumbing.

Only the prepend direction is meaningful at a single start-point, so every insert verb lands text at the start: insert_paragraph_before / insert_paragraph_after add a new first paragraph (delegating to Document.prepend_paragraph), and insert_before / insert_after / set_text prepend inline (delegating to Document.prepend). text is always empty and delete() is a no-op. insert_image and apply_style are inherited: they resolve to the collapsed start position.

Source code in src/wordlive/_anchors.py
def __init__(self, doc: Document) -> None:
    super().__init__(doc, name="start")

wordlive.EndAnchor

EndAnchor(doc: Document)

Bases: Anchor

A zero-width anchor at the very end of the document body — doc.end.

The one position no content names: the insertion point past the last paragraph. doc.end returns it and anchor_by_id("end") resolves it, so "append to the document" composes with the same verbs and the same CLI --anchor-id plumbing as every other anchor — no .com drop needed.

Only the append direction is meaningful at a single end-point, so every insert verb lands text at the end: insert_paragraph_after / insert_paragraph_before add a new final paragraph (delegating to Document.append_paragraph), and insert_after / insert_before / set_text append inline (delegating to Document.append). text is always empty and delete() is a no-op — there is no content here to read or remove. insert_image and apply_style are inherited: they resolve to the collapsed end position, so an image lands at the end and a style falls on the final paragraph.

Source code in src/wordlive/_anchors.py
def __init__(self, doc: Document) -> None:
    super().__init__(doc, name="end")

Styles

Styles are document-scoped, read-only handles. Document.styles is a StyleCollection; apply styles to anchors via Anchor.apply_style.

wordlive.Style

Style(doc: Document, name: str)

A read-only view onto a single Word style.

Properties access the COM object lazily; nothing is cached so renames or deletions during the session don't return stale data.

Source code in src/wordlive/_styles.py
def __init__(self, doc: Document, name: str) -> None:
    self._doc = doc
    self._name = name

com property

com: Any

Raw COM Style object. Raises StyleNotFoundError if the style is gone.

Tries direct lookup (Styles(name)) first — O(1) on Word's side — and falls back to iteration only if that raises. Membership checking still iterates (Word doesn't reserve an HRESULT for "missing style" and a generic com_error would be indistinguishable from a real failure), but once the caller has a Style instance the name is presumed valid and the direct path is safe.

wordlive.StyleCollection

StyleCollection(doc: Document)

Indexable, iterable view over a document's styles.

Source code in src/wordlive/_styles.py
def __init__(self, doc: Document) -> None:
    self._doc = doc

list

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

All styles as {name, type, builtin, in_use} dicts.

Source code in src/wordlive/_styles.py
def list(self) -> list[dict[str, Any]]:
    """All styles as `{name, type, builtin, in_use}` dicts."""
    with _com.translate_com_errors():
        return [
            {
                "name": str(s.NameLocal),
                "type": _style_type_name(s.Type),
                "builtin": bool(s.BuiltIn),
                "in_use": bool(s.InUse),
            }
            for s in self._doc.com.Styles
        ]

Tables

Document.tables is a TableCollection. Index a table by 1-based position or Title, then read or edit it. A Cell is an Anchor — its id is table:N:R:C, so doc.anchor_by_id("table:1:2:3") returns a cell that works with set_text, apply_style, and format_paragraph like any other anchor.

Create tables with Document.add_table(rows, cols, …) (append at the end) or Anchor.insert_table(...) (at any position anchor); both return the new Table, populate cells from a row-major data grid, default to the Table Grid style, and keep appended tables from merging into an adjacent one. Table.delete() removes a whole table — the structural mirror of add_row / delete_row.

wordlive.TableCollection

TableCollection(doc: Document)

Indexable, iterable view over a document's tables.

Index by 1-based position (doc.tables[1]) or by the table's Title (doc.tables["Budget"]). Positions match Word's own Tables(n) ordering — document order, top to bottom.

Source code in src/wordlive/_tables.py
def __init__(self, doc: Document) -> None:
    self._doc = doc

list

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

All tables as {index, title, rows, columns} dicts.

Source code in src/wordlive/_tables.py
def list(self) -> list[dict[str, Any]]:
    """All tables as `{index, title, rows, columns}` dicts."""
    return [t.to_dict() for t in self]

wordlive.Table

Table(doc: Document, com: Any, index: int)

Wraps a Word Table COM object, located by its 1-based document position.

The index is stored at construction (the collection knows it without a COM round-trip), so anchor_id and cell ids never have to re-scan the document.

Source code in src/wordlive/_tables.py
def __init__(self, doc: Document, com: Any, index: int) -> None:
    self._doc = doc
    self._com = com
    self._index = index

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/wordlive/_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 <= row <= rows and 1 <= col <= cols):
        raise AnchorNotFoundError("table cell", f"table:{self._index}:{row}:{col}")
    return Cell(self, row, col)

grid

grid() -> list[list[str]]

All cell text as a row-major list[list[str]].

Source code in src/wordlive/_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: metadata plus every cell with its addressable id.

Each cell carries its anchor_id (table:N:R:C) so a caller can feed it straight back into replace / style apply / format-paragraph.

Source code in src/wordlive/_tables.py
def read(self) -> dict[str, Any]:
    """Structured dump: metadata plus every cell with its addressable id.

    Each cell carries its `anchor_id` (`table:N:R:C`) so a caller can feed
    it straight back into `replace` / `style apply` / `format-paragraph`.
    """
    rows, cols = self.row_count, self.column_count
    cells = [
        [
            {
                "row": r,
                "col": c,
                "text": self.cell(r, c).text,
                "anchor_id": f"table:{self._index}:{r}:{c}",
            }
            for c in range(1, cols + 1)
        ]
        for r in range(1, rows + 1)
    ]
    return {
        "index": self._index,
        "title": self.title,
        "rows": rows,
        "columns": cols,
        "cells": cells,
    }

to_dict

to_dict() -> dict[str, Any]

Metadata only — {index, title, rows, columns}. Used by table list.

Source code in src/wordlive/_tables.py
def to_dict(self) -> dict[str, Any]:
    """Metadata only — `{index, title, rows, columns}`. Used by `table list`."""
    return {
        "index": self._index,
        "title": self.title,
        "rows": self.row_count,
        "columns": self.column_count,
    }

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.

Source code in src/wordlive/_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.
    """
    with _com.translate_com_errors():
        self._com.Rows.Add()
        if values:
            last = int(self._com.Rows.Count)
            cols = int(self._com.Columns.Count)
            for c, val in enumerate(values, start=1):
                if c > cols:
                    break
                self._com.Cell(last, c).Range.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.

Source code in src/wordlive/_tables.py
def delete_row(self, index: int) -> None:
    """Delete the 1-based row `index`.

    Raises `AnchorNotFoundError` (kind `"table row"`) if out of range.
    """
    rows = self.row_count
    if not (1 <= index <= rows):
        raise AnchorNotFoundError("table row", f"table:{self._index}:row:{index}")
    with _com.translate_com_errors():
        self._com.Rows(index).Delete()

delete

delete() -> None

Delete this entire table — the structural mirror of add_row.

Removes the table and all its cells from the document. Afterwards this Table (and any Cell anchors derived from it) is stale; the indices of any tables that followed it shift down by one, so re-resolve through doc.tables before addressing another.

Source code in src/wordlive/_tables.py
def delete(self) -> None:
    """Delete this entire table — the structural mirror of `add_row`.

    Removes the table and all its cells from the document. Afterwards this
    `Table` (and any `Cell` anchors derived from it) is stale; the indices
    of any tables that followed it shift down by one, so re-resolve through
    `doc.tables` before addressing another.
    """
    with _com.translate_com_errors():
        self._com.Delete()

wordlive.Cell

Cell(table: Table, row: int, col: int)

Bases: Anchor

A single table cell, addressed by 1-based (row, column).

Subclasses Anchor, so it inherits insert_before / insert_after / delete / apply_style / format_paragraph unchanged. Only the bits that differ for cells — the COM range, text read/write, and the anchor id — are overridden here.

Source code in src/wordlive/_tables.py
def __init__(self, table: Table, row: int, col: int) -> None:
    super().__init__(table._doc, name=f"table:{table.index}:{row}:{col}")
    self._table = table
    self._row = row
    self._col = col

Comments

Document.comments is a CommentCollection. comments.add(anchor, text, author=...) attaches a review comment to any anchor's range without changing the text — the polite, side-channel way for an agent to flag something. Existing comments are addressed by 1-based index (doc.comments[2]) to resolve() or delete().

wordlive.CommentCollection

CommentCollection(doc: Document)

Indexable, iterable view over a document's review comments.

Source code in src/wordlive/_comments.py
def __init__(self, doc: Document) -> None:
    self._doc = doc

add

add(anchor: Anchor, text: str, *, author: str | None = None) -> Comment

Attach a new comment to anchor's range.

anchor is any wordlive anchor (bookmark, heading, cell, range, …); its COM range becomes the comment's scope and the document text is left untouched — only an annotation is added. Returns the new Comment.

Source code in src/wordlive/_comments.py
def add(self, anchor: Anchor, text: str, *, author: str | None = None) -> Comment:
    """Attach a new comment to `anchor`'s range.

    `anchor` is any wordlive anchor (bookmark, heading, cell, range, …); its
    COM range becomes the comment's scope and the document text is left
    untouched — only an annotation is added. Returns the new `Comment`.
    """
    with _com.translate_com_errors():
        rng = anchor.com
        comments = self._doc.com.Comments
        com = comments.Add(rng, text)
        if author:
            try:
                com.Author = author
            except Exception:
                # Some COM builds reject a per-comment Author write; the
                # comment still lands with the app's default author.
                pass
        index = int(comments.Count)
    return Comment(self._doc, com, index)

list

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

All comments as {index, author, text, scope, done} dicts.

Source code in src/wordlive/_comments.py
def list(self) -> list[dict[str, Any]]:
    """All comments as `{index, author, text, scope, done}` dicts."""
    return [c.to_dict() for c in self]

wordlive.Comment

Comment(doc: Document, com: Any, index: int)

A single review comment, located by its 1-based document index.

Source code in src/wordlive/_comments.py
def __init__(self, doc: Document, com: Any, index: int) -> None:
    self._doc = doc
    self._com = com
    self._index = index

com property

com: Any

Raw COM Comment object — escape hatch (replies, ranges, etc.).

text property

text: str

The comment body.

scope_text property

scope_text: str

The document text the comment is attached to (its anchored range).

done property

done: bool

Whether the comment is marked resolved/done. False on Word <2013.

resolve

resolve() -> None

Mark the comment as done/resolved (Word 2013+).

Source code in src/wordlive/_comments.py
def resolve(self) -> None:
    """Mark the comment as done/resolved (Word 2013+)."""
    with _com.translate_com_errors():
        self._com.Done = True

reopen

reopen() -> None

Clear the done/resolved flag (Word 2013+).

Source code in src/wordlive/_comments.py
def reopen(self) -> None:
    """Clear the done/resolved flag (Word 2013+)."""
    with _com.translate_com_errors():
        self._com.Done = False

delete

delete() -> None

Remove the comment from the document.

Source code in src/wordlive/_comments.py
def delete(self) -> None:
    """Remove the comment from the document."""
    with _com.translate_com_errors():
        self._com.Delete()

to_dict

to_dict() -> dict[str, Any]

{index, author, text, scope, done} — the JSON shape list() emits.

Source code in src/wordlive/_comments.py
def to_dict(self) -> dict[str, Any]:
    """`{index, author, text, scope, done}` — the JSON shape `list()` emits."""
    with _com.translate_com_errors():
        return {
            "index": self._index,
            "author": str(self._com.Author or ""),
            "text": _clean(self._com.Range.Text),
            "scope": _clean(self._com.Scope.Text),
            "done": self.done,
        }

Track Changes

Document.tracked_changes() is a context manager that turns Word's Track Changes on for the scope and restores the prior setting on exit — pair it with edit() to make a batch of edits visibly, as revisions the user can accept or reject. Document.track_changes is the underlying read/write property for the persistent flag. Both are documented on Document.

Lists & numbering

List operations apply to a range's paragraphs, so the verbs live on Anchorapply_list("numbered"), remove_list(), list_info(), restart_numbering(), and indent_list() / outdent_list() work on any anchor. Document.lists is a read-only ListCollection for discovering the lists already in the document; index it (doc.lists[2]) to get a RangeAnchor over a list's range.

wordlive.ListCollection

ListCollection(doc: Document)

Read-only, iterable view over the document's lists (doc.lists).

Index a list by 1-based position (doc.lists[2]) to get a RangeAnchor over its whole range — so every list verb (apply_list, restart_numbering, …) is immediately available on it. list() returns a summary per list; positions match Word's own Document.Lists(n) ordering.

Source code in src/wordlive/_lists.py
def __init__(self, doc: Document) -> None:
    self._doc = doc

list

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

All lists as {index, type, count, anchor_id} dicts.

Source code in src/wordlive/_lists.py
def list(self) -> list[dict[str, Any]]:
    """All lists as `{index, type, count, anchor_id}` dicts."""
    out: list[dict[str, Any]] = []
    with _com.translate_com_errors():
        count = int(self._doc.com.Lists.Count)
        for i in range(1, count + 1):
            lst = self._doc.com.Lists(i)
            rng = lst.Range
            start, end = int(rng.Start), int(rng.End)
            info = read_list_info(rng)
            try:
                n_items = int(lst.ListParagraphs.Count)
            except Exception:
                n_items = 0
            out.append(
                {
                    "index": i,
                    "type": info["type"],
                    "count": n_items,
                    "anchor_id": f"range:{start}-{end}",
                }
            )
    return out

Sections, headers & footers

Document.sections is a SectionCollection. Each Section reaches its headers and footers as HeaderFooter anchors — doc.sections[1].header() / .footer("first") — addressed header:S:WHICH / footer:S:WHICH (WHICH is primary / first / even). A HeaderFooter is an Anchor, so set_text, apply_style, and format_paragraph work on it like any other.

wordlive.SectionCollection

SectionCollection(doc: Document)

Indexable, iterable view over a document's sections (doc.sections).

Index by 1-based position (doc.sections[1]). Every document has at least one section; doc.sections[1].header() is the common entry point.

Source code in src/wordlive/_sections.py
def __init__(self, doc: Document) -> None:
    self._doc = doc

list

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

All sections as {index, page_setup} dicts.

Source code in src/wordlive/_sections.py
def list(self) -> list[dict[str, Any]]:
    """All sections as `{index, page_setup}` dicts."""
    return [s.to_dict() for s in self]

wordlive.Section

Section(doc: Document, com: Any, index: int)

Wraps a Word Section, located by its 1-based document position.

Source code in src/wordlive/_sections.py
def __init__(self, doc: Document, com: Any, index: int) -> None:
    self._doc = doc
    self._com = com
    self._index = index

header

header(which: str = 'primary') -> HeaderFooter

The section's header for which (primary / first / even).

Source code in src/wordlive/_sections.py
def header(self, which: str = "primary") -> HeaderFooter:
    """The section's header for `which` (`primary` / `first` / `even`)."""
    return HeaderFooter(self._doc, self._index, which, is_footer=False)

footer

footer(which: str = 'primary') -> HeaderFooter

The section's footer for which (primary / first / even).

Source code in src/wordlive/_sections.py
def footer(self, which: str = "primary") -> HeaderFooter:
    """The section's footer for `which` (`primary` / `first` / `even`)."""
    return HeaderFooter(self._doc, self._index, which, is_footer=True)

page_setup

page_setup() -> dict[str, Any]

Read-only {orientation, *_margin, page_width, page_height} in points.

Source code in src/wordlive/_sections.py
def page_setup(self) -> dict[str, Any]:
    """Read-only `{orientation, *_margin, page_width, page_height}` in points."""
    with _com.translate_com_errors():
        ps = self._com.PageSetup
        try:
            orientation = int(_safe(ps, "Orientation", 0))
        except (TypeError, ValueError):
            orientation = 0
        return {
            "orientation": "landscape"
            if orientation == int(WdOrientation.LANDSCAPE)
            else "portrait",
            "top_margin": float(_safe(ps, "TopMargin", 0.0)),
            "bottom_margin": float(_safe(ps, "BottomMargin", 0.0)),
            "left_margin": float(_safe(ps, "LeftMargin", 0.0)),
            "right_margin": float(_safe(ps, "RightMargin", 0.0)),
            "page_width": float(_safe(ps, "PageWidth", 0.0)),
            "page_height": float(_safe(ps, "PageHeight", 0.0)),
        }

to_dict

to_dict() -> dict[str, Any]

{index, page_setup} — the JSON shape sections.list() emits.

Source code in src/wordlive/_sections.py
def to_dict(self) -> dict[str, Any]:
    """`{index, page_setup}` — the JSON shape `sections.list()` emits."""
    return {"index": self._index, "page_setup": self.page_setup()}

wordlive.HeaderFooter

HeaderFooter(doc: Document, section_index: int, which: str, *, is_footer: bool)

Bases: Anchor

A section's header or footer, addressed as header:S:WHICH / footer:S:WHICH.

Subclasses Anchor, so text, set_text, insert_before/after, apply_style, and format_paragraph all work unchanged — only the COM range and anchor id are overridden here. WHICH is primary, first, or even.

Source code in src/wordlive/_sections.py
def __init__(self, doc: Document, section_index: int, which: str, *, is_footer: bool) -> None:
    self._section_index = int(section_index)
    self._which = _CANONICAL_WHICH[int(which_index(which))]
    self._is_footer = bool(is_footer)
    self.kind = "footer" if is_footer else "header"
    super().__init__(doc, name=f"{self.kind}:{self._section_index}:{self._which}")

exists property

exists: bool

Whether this header/footer actually has content defined for the section.

linked_to_previous property

linked_to_previous: bool

Whether this header/footer inherits from the previous section's.

Editing

Selection is the explicit cursor surface: doc.selection.info() reads where the cursor is, and doc.selection.write(text, replace=...) types at it. write deliberately moves the cursor, so wrap it in doc.edit() and call scope.allow_cursor_move() for atomic undo without snapping the cursor back. Everywhere else, prefer anchors over the cursor.

wordlive.EditScope

EditScope(word: Word, label: str)

Wraps a Word UndoRecord + a Selection snapshot.

One Ctrl-Z reverts every mutation made inside the with block. The user's cursor and scroll position are restored on exit unless code inside the scope calls allow_cursor_move().

Source code in src/wordlive/_edit.py
def __init__(self, word: Word, label: str) -> None:
    self._word = word
    self._label = label
    self._snapshot: SelectionSnapshot | None = None
    self._undo: Any | None = None
    self._move_allowed: bool = False
    self._undo_started: bool = False

allow_cursor_move

allow_cursor_move() -> None

Opt out of restoring the user's Selection on scope exit.

Source code in src/wordlive/_edit.py
def allow_cursor_move(self) -> None:
    """Opt out of restoring the user's Selection on scope exit."""
    self._move_allowed = True

wordlive.Selection

Selection(word: Word)

Wrapper around Application.Selection. Mostly used for reads.

Source code in src/wordlive/_selection.py
def __init__(self, word: Word) -> None:
    self._word = word

info

info() -> dict[str, Any]

Structured snapshot of the current selection for wordlive reads.

collapsed is true when there's an insertion point but no selected text (start == end). The CLI's cursor read enriches this with the containing para:N anchor.

Source code in src/wordlive/_selection.py
def info(self) -> dict[str, Any]:
    """Structured snapshot of the current selection for `wordlive` reads.

    `collapsed` is true when there's an insertion point but no selected
    text (`start == end`). The CLI's `cursor read` enriches this with the
    containing `para:N` anchor.
    """
    with _com.translate_com_errors():
        sel = self.com
        start = int(sel.Start)
        end = int(sel.End)
        return {
            "start": start,
            "end": end,
            "collapsed": start == end,
            "text": str(sel.Text or ""),
        }

write

write(text: str, *, replace: bool = True) -> None

Insert text at the user's cursor — the deliberate cursor write.

Unlike every anchor write, this targets the live Selection. With a spanning selection and replace=True (the default) the selected text is overwritten; with replace=False, or a collapsed cursor, the text is inserted at the selection start. Either way the cursor is left after the inserted text.

This intentionally moves the cursor, so it fights EditScope's cursor-preservation. To get atomic undo without snapping the cursor back, wrap it: ::

with doc.edit("type at cursor") as scope:
    scope.allow_cursor_move()
    doc.selection.write("…")
Source code in src/wordlive/_selection.py
def write(self, text: str, *, replace: bool = True) -> None:
    """Insert `text` at the user's cursor — the deliberate cursor write.

    Unlike every anchor write, this targets the live `Selection`. With a
    spanning selection and `replace=True` (the default) the selected text is
    overwritten; with `replace=False`, or a collapsed cursor, the text is
    inserted at the selection start. Either way the cursor is left *after*
    the inserted text.

    This intentionally moves the cursor, so it fights `EditScope`'s
    cursor-preservation. To get atomic undo without snapping the cursor
    back, wrap it: ::

        with doc.edit("type at cursor") as scope:
            scope.allow_cursor_move()
            doc.selection.write("…")
    """
    with _com.translate_com_errors():
        sel = self.com
        start = int(sel.Start)
        end = int(sel.End)
        doc = self._word.com.ActiveDocument
        target = doc.Range(start, end if replace else start)
        target.Text = text
        # Collapse the cursor to just after the inserted text. Word counts
        # UTF-16 code units, so encode rather than using len().
        n = len(text.encode("utf-16-le")) // 2
        try:
            doc.Range(start + n, start + n).Select()
        except Exception:
            pass

wordlive.SelectionSnapshot dataclass

SelectionSnapshot(start: int, end: int, vertical_percent: int | None = None)

A point-in-time capture of where the user's cursor and view are.

vertical_percent class-attribute instance-attribute

vertical_percent: int | None = None

ActiveWindow.VerticalPercentScrolled at snapshot time, or None if unavailable.

Snapshots

Document.snapshot(...) and Anchor.snapshot(...) render page(s) of the live document to PNG so a vision model can see the layout — Word exports a pixel-faithful PDF and wordlive rasterises the requested pages. Document.snapshot selects pages (all, one, or a span); Anchor.snapshot (and Document.snapshot_anchor) renders the page(s) an anchor occupies, expanding a heading to its whole section. Both return a list of Snapshot (one per page) and optionally write the image(s) to out. This needs the optional snapshot extra (PyMuPDF); a missing backend raises SnapshotError.

import wordlive as wl

with wl.attach() as word:
    doc = word.documents.active
    png = doc.heading("Introduction").snapshot()[0].png   # bytes for a model
    doc.snapshot("report.png", pages=(1, 3))              # write pages 1-3

wordlive.Snapshot dataclass

Snapshot(page: int, png: bytes, path: Path | None = None)

One rendered page of a document.

page is the 1-based document page number; png is the PNG-encoded image bytes — 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.

Constants

wordlive.constants re-exports the typed IntEnum mirrors of the Word Wd* magic numbers wordlive uses internally (alignment, break types, wrap types, …). You rarely need these directly — the high-level API takes plain strings ("center", "page", "square") and maps them — but they're available for .com escape-hatch code that talks to the raw object model.

from wordlive import constants

constants.WdParagraphAlignment.CENTER   # 1

Exceptions

wordlive.WordliveError

Bases: Exception

Base class for all wordlive errors.

wordlive.WordNotRunningError

Bases: WordliveError

No running Word instance is available.

wordlive.DocumentNotFoundError

DocumentNotFoundError(name: str)

Bases: WordliveError

The requested document is not open in Word.

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

wordlive.AnchorNotFoundError

AnchorNotFoundError(kind: str, name: str, *, hint: str | None = None)

Bases: WordliveError

The requested anchor (bookmark / content control / heading) does not exist.

Source code in src/wordlive/exceptions.py
def __init__(self, kind: str, name: str, *, hint: str | None = None) -> None:
    message = f"{kind} not found: {name!r}"
    if hint:
        message += f"; {hint}"
    super().__init__(message)
    self.kind = kind
    self.name = name
    self.hint = hint

wordlive.StyleNotFoundError

StyleNotFoundError(name: str)

Bases: AnchorNotFoundError

The requested paragraph or character style is not defined in the document.

Subclass of AnchorNotFoundError so it shares the same exit code (2) and so except AnchorNotFoundError catches both bookmark-misses and style-misses. Retryable after re-reading doc.styles.list().

Source code in src/wordlive/exceptions.py
def __init__(self, name: str) -> None:
    super().__init__("style", name)

wordlive.AmbiguousMatchError

AmbiguousMatchError(find: str, matches: list[dict[str, Any]])

Bases: WordliveError

A find/replace pattern matched more than one occurrence without disambiguation.

Carries the list of matches so callers (notably LLM drivers) can pick an occurrence index and retry.

Source code in src/wordlive/exceptions.py
def __init__(self, find: str, matches: list[dict[str, Any]]) -> None:
    super().__init__(
        f"{len(matches)} matches for {find!r}; pass --all or --occurrence N to disambiguate"
    )
    self.find = find
    self.matches = matches

wordlive.ReplaceVerificationError

ReplaceVerificationError(find: str, expected: str, resolved: str, *, anchor_id: str | None = None)

Bases: WordliveError

A resolved replacement target didn't match the located text — refused to write.

Word's table position model diverges from rendered Range.Text offsets, so a find/replace whose match resolves to the wrong span (historically inside a table) could silently overwrite a neighbouring cell while returning success. wordlive verifies each target against the located match before writing and raises this instead of corrupting the document. If you hit it, re-scope the replace to the cell anchor (scope=doc.anchor_by_id("table:N:R:C")). Maps to the generic exit code (1). Not retryable as-is — the same call drifts again.

Source code in src/wordlive/exceptions.py
def __init__(
    self, find: str, expected: str, resolved: str, *, anchor_id: str | None = None
) -> None:
    super().__init__(
        f"replacement target for {find!r} resolved to {resolved!r}, expected "
        f"{expected!r}; refusing to overwrite (re-scope to the cell anchor)"
    )
    self.find = find
    self.expected = expected
    self.resolved = resolved
    self.anchor_id = anchor_id

wordlive.ImageSourceError

ImageSourceError(message: str)

Bases: WordliveError

An image given to insert_image couldn't be turned into an embeddable file.

Raised for a missing or unreadable path, malformed base64, or bytes whose format isn't a recognised raster image (PNG/JPEG/GIF/BMP/TIFF). It's a bad-input error — not a "named thing is missing" — so it maps to the generic exit code (1) rather than reusing the anchor-not-found code. Not retryable: fix the input.

Source code in src/wordlive/exceptions.py
def __init__(self, message: str) -> None:
    super().__init__(message)

wordlive.SnapshotError

SnapshotError(message: str)

Bases: WordliveError

A page/section snapshot couldn't be rendered.

Raised when the optional PDF-rendering backend (PyMuPDF) isn't installed, or when rasterising the exported PDF fails. The PDF export itself goes through Word's COM, so a busy/modal Word surfaces as WordBusyError, not this. It's an environment/dependency problem rather than a "named thing is missing", so it maps to the generic exit code (1). Fix by installing the extra: pip install "wordlive[snapshot]" (or uv add "wordlive[snapshot]").

Source code in src/wordlive/exceptions.py
def __init__(self, message: str) -> None:
    super().__init__(message)

wordlive.WordBusyError

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

Bases: WordliveError

Word rejected the RPC — typically a modal dialog or a transient busy state.

Retryable in principle; caller decides.

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

wordlive.ComError

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

Bases: WordliveError

Generic wrapper for an unclassified pywintypes.com_error.

Source code in src/wordlive/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