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:

The package version is available as wordlive.__version__ (resolved from the installed package metadata).

See Concepts for the why behind these shapes.


Connecting & documents

Get a Word handle and reach the open documents.

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/_core.py
def __init__(self, word: Word, doc: Any) -> None:
    self._word = word
    self._doc = doc

saved property

saved: bool

Whether the document has no unsaved changes (Word's Document.Saved).

True right after a save; False once an edit dirties it. A brand-new, never-saved document reads False until its first save. This is the same flag wordlive status reports per open document.

sources property

sources: SourceCollection

The document's bibliography sources β€” add and look up by tag.

See SourceCollection: doc.sources.add(...) registers a source, doc.sources["Smith2020"] looks one up, and the collection is iterable and in-testable by tag. Cite a source with Anchor.insert_citation and list the cited ones with Document.add_bibliography.

theme property

theme: DocumentTheme

The document's theme β€” the document-wide brand primitive.

See DocumentTheme: doc.theme.apply("Facet") swaps the whole theme, doc.theme.set_colors(accent1="#1A73E8") / doc.theme.set_fonts(major="Arial") set brand colours/fonts, and doc.theme.colors / doc.theme.to_dict() read it back. Wrap mutations in doc.edit(...) for atomic undo.

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

images property

images: ImageCollection

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

Index an image by 1-based position (doc.images[2]) to get an ImageAnchor (image:N), then read_image() for its raw bytes + MIME type β€” the path for handing an embedded picture to a vision model. list() summarises each image (MIME, size, alt text, and the para:N it's anchored in). The write mirror is any anchor's insert_image.

equations property

equations: EquationCollection

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

Index an equation by 1-based position (doc.equations[2]) to get an EquationAnchor (equation:N), then mathml / linear to read it. list() summarises each equation (type, a linear preview, and the para:N it sits in). The write mirror is any anchor's insert_equation.

charts property

charts: ChartCollection

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

Index a chart by 1-based position (doc.charts[2]) to get a ChartAnchor (chart:N); chart_type / title read its metadata. list() summarises each chart (kind, title, and the para:N it sits in). Charts are inserted with their data link broken (static data), so reading the series back is deferred β€” this view is metadata only. The write mirror is any anchor's insert_chart.

shapes property

shapes: ShapeCollection

Iterable view over the document's floating shapes (doc.shapes).

Index a shape by 1-based position (doc.shapes[2]) to get a ShapeAnchor (shape:N) β€” a text box, a floating image, or WordArt β€” then restyle it in place (set_wrap / set_position / set_size / format / replace_image). list() summarises each shape (kind, size, wrap, the para:N it's anchored in). Header-story watermarks are excluded; positions follow document order and renumber as shapes come and go. The write mirror is any anchor's insert_text_box / a floating insert_image.

text_boxes property

text_boxes: TextBoxCollection

The text boxes among doc.shapes β€” the shape_type == "text_box" subset.

A discovery filter, not a second id space: doc.text_boxes[1] returns a ShapeAnchor that keeps its canonical shape:N id (its position among all floating shapes). Created by any anchor's insert_text_box.

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.

revisions property

revisions: RevisionCollection

Iterable view over the document's tracked changes (doc.revisions).

When Track Changes is on, every edit is a Revision the user can accept or reject. doc.revisions.list() reports each as {index, type, author, text, anchor_id, start, end, date} β€” the structured way to see what tracked edits a batch recorded (the visual way is snapshot(markup="all")). Index by 1-based position (doc.revisions[2]); type is "insert" / "delete" / "format" / … .

Resolve them too: doc.revisions[2].accept() / .reject() for one, or accept_all / reject_all (within=anchor to scope to one section/range) for many. For a read that separates the inserted from the deleted runs of a just-edited range, use Anchor.text_final / text_original / revision_segments. Writing tracked changes is tracked_changes().

footnotes property

footnotes: FootnoteCollection

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

Index a footnote by 1-based position (doc.footnotes[2]) to get a Footnote anchor (footnote:N) whose set_text / delete edit the note. list() summarises each note (number, body text, and the para:N it's anchored at). Create one with Anchor.insert_footnote.

endnotes property

endnotes: EndnoteCollection

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

The endnote mirror of footnotes; notes are addressed endnote:N. Create one with Anchor.insert_endnote.

hyperlinks: HyperlinkCollection

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

The read mirror of Anchor.link_to: index a link by 1-based position (doc.hyperlinks[2]) to get a Hyperlink, or list() to summarise each β€” visible text, external address or internal sub_address bookmark, screen tip, and the range:START-END / para:N it sits in.

fields property

fields: FieldCollection

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

The read mirror of Anchor.insert_field: index a field by 1-based position (doc.fields[2]) to get a Field, or list() to summarise each β€” its kind (the code's leading keyword, PAGE / REF / TOC / …), raw code, rendered result, and the range:START-END / para:N it sits in. Refresh stale results with update_fields.

properties property

properties: PropertyCollection

Read/write view over the document's built-in and custom properties (metadata).

doc.properties.read() returns {"builtin": {…}, "custom": {…}} β€” the Title / Author / Keywords / … bag plus any custom name/value pairs. doc.properties.set("Title", "…") writes a built-in property; set(name, value, custom=True) writes (creating if needed) a custom one. Wrap writes in doc.edit(...) for atomic undo.

variables property

variables: VariableCollection

Read/write view over the document's variables (doc.variables).

Document variables are invisible named string storage β€” the backing store for { DOCVARIABLE name } fields. doc.variables.list() returns {name: value}; set(name, value) / delete(name) manage them. Wrap writes in doc.edit(...) for atomic undo.

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 "…".

bibliography_style property writable

bibliography_style: str

The citation/bibliography style (e.g. "APA", "MLA", "Chicago").

Read/write. Setting it changes how every citation and the bibliography render (refresh them with update_fields). Word accepts a build-dependent set of identifiers; an unsupported value raises OpError.

track_changes property writable

track_changes: bool

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

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/_core.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._as_document, 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
  • pin:CODE β€” a durable handle minted by pin / stamp / pin_outline
  • cc:NAME β€” content control by Title (or Tag)
  • footnote:N β€” Nth footnote (1-based), resolving to its note body
  • endnote:N β€” Nth endnote (1-based), resolving to its note body
  • image:N β€” Nth embedded image (1-based, Word's InlineShapes order)
  • equation:N β€” Nth equation (1-based, Word's OMaths order)
  • chart:N β€” Nth chart (1-based, document order over chart inline shapes)
  • shape:N β€” Nth floating shape (1-based, document order: text box / image / WordArt)
  • textbox:N β€” Nth text box (alias onto its canonical shape:N)
  • 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/_core.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
      - `pin:CODE`         β€” a durable handle minted by `pin` / `stamp` / `pin_outline`
      - `cc:NAME`          β€” content control by Title (or Tag)
      - `footnote:N`       β€” Nth footnote (1-based), resolving to its note body
      - `endnote:N`        β€” Nth endnote (1-based), resolving to its note body
      - `image:N`          β€” Nth embedded image (1-based, Word's InlineShapes order)
      - `equation:N`       β€” Nth equation (1-based, Word's OMaths order)
      - `chart:N`          β€” Nth chart (1-based, document order over chart inline shapes)
      - `shape:N`          β€” Nth floating shape (1-based, document order: text box / image / WordArt)
      - `textbox:N`        β€” Nth text box (alias onto its canonical `shape:N`)
      - `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._as_document, 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._as_document, idx)
    if kind == "bookmark":
        return self.bookmarks[value]
    if kind == "pin":
        name = _pin_name_for(value)
        with _com.translate_com_errors():
            if not self._doc.Bookmarks.Exists(name):
                # A vanished pin (its content was deleted) correctly misses.
                raise AnchorNotFoundError("pin", anchor_id)
        return Bookmark.pin(self._as_document, value)
    if kind == "cc":
        return self.content_controls[value]
    if kind in ("footnote", "endnote"):
        try:
            idx = int(value)
        except ValueError as e:
            raise AnchorNotFoundError(kind, anchor_id) from e
        coll = self.footnotes if kind == "footnote" else self.endnotes
        try:
            return coll[idx]
        except AnchorNotFoundError as e:
            raise AnchorNotFoundError(kind, anchor_id) from e
    if kind == "image":
        try:
            idx = int(value)
        except ValueError as e:
            raise AnchorNotFoundError("image", anchor_id) from e
        try:
            return self.images[idx]
        except AnchorNotFoundError as e:
            raise AnchorNotFoundError("image", anchor_id) from e
    if kind == "equation":
        try:
            idx = int(value)
        except ValueError as e:
            raise AnchorNotFoundError("equation", anchor_id) from e
        try:
            return self.equations[idx]
        except AnchorNotFoundError as e:
            raise AnchorNotFoundError("equation", anchor_id) from e
    if kind == "chart":
        try:
            idx = int(value)
        except ValueError as e:
            raise AnchorNotFoundError("chart", anchor_id) from e
        try:
            return self.charts[idx]
        except AnchorNotFoundError as e:
            raise AnchorNotFoundError("chart", anchor_id) from e
    if kind == "shape":
        try:
            idx = int(value)
        except ValueError as e:
            raise AnchorNotFoundError("shape", anchor_id) from e
        try:
            return self.shapes[idx]
        except AnchorNotFoundError as e:
            raise AnchorNotFoundError("shape", anchor_id) from e
    if kind == "textbox":
        # A thin alias onto the text-box subset of shape:N β€” the returned
        # ShapeAnchor reports its canonical shape:N id, not textbox:N.
        try:
            idx = int(value)
        except ValueError as e:
            raise AnchorNotFoundError("text box", anchor_id) from e
        try:
            return self.text_boxes[idx]
        except AnchorNotFoundError as e:
            raise AnchorNotFoundError("text box", anchor_id) from e
    if kind == "table":
        parts = value.split(":")
        if len(parts) != 3:
            # `table:N` (whole table) isn't a single-range anchor. It's the most
            # common table miss by far, so name the three forms that do resolve
            # rather than echoing the id back β€” the guidance used to live only in
            # this method's docstring, where a failing caller never sees it.
            raise AnchorNotFoundError("table cell", anchor_id, hint=_table_anchor_hint(value))
        # `table:N:row:R` / `table:N:col:C` address a whole row / column;
        # `table:N:R:C` (all numeric) addresses a single cell.
        selector = parts[1].lower()
        if selector in ("row", "col"):
            try:
                t, idx = int(parts[0]), int(parts[2])
            except ValueError as e:
                kind_label = "table row" if selector == "row" else "table column"
                raise AnchorNotFoundError(kind_label, anchor_id) from e
            if selector == "row":
                return self.tables[t].row(idx)
            return self.tables[t].column(idx)
        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
    near = did_you_mean(kind, _ANCHOR_KINDS, limit=2)
    raise AnchorNotFoundError(
        "anchor",
        anchor_id,
        hint=(
            f"unknown anchor type {kind!r}"
            + (f"; did you mean {or_list(near)}?" if near else "")
            + "; expected one of "
            + "/".join(_ANCHOR_KINDS)
        ),
    )

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/_core.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/_core.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

save

save() -> str

Save the document to its existing file, returning the absolute path.

Raises OpError if the document has never been saved (it has no path yet) β€” call save_as first. This is the ungated Python-API surface: it writes wherever the document already lives. The CLI / MCP save verb additionally checks that path against the configured save-directory whitelist before calling this.

Source code in src/wordlive/_document/_persistence.py
def save(self) -> str:
    """Save the document to its existing file, returning the absolute path.

    Raises [`OpError`][wordlive.OpError] if the document has never
    been saved (it has no path yet) β€” call [`save_as`][wordlive.Document.save_as]
    first. This is the **ungated** Python-API surface: it writes wherever the
    document already lives. The CLI / MCP `save` verb additionally checks that
    path against the configured save-directory whitelist before calling this.
    """
    with _com.translate_com_errors():
        folder = str(self._doc.Path)
        if not folder:
            raise OpError("document has never been saved; use save_as(path) first")
        self._doc.Save()
        return str(self._doc.FullName)

save_as

save_as(path: str | Path, *, fmt: str = 'docx', overwrite: bool = False) -> str

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

fmt is "docx" (the modern Open XML format). For PDF, use export_pdf (it goes through a different COM call and takes a page range). By default refuses to clobber an existing file β€” pass overwrite=True to allow it. Ungated like save; the CLI / MCP surface whitelists the target first.

Source code in src/wordlive/_document/_persistence.py
def save_as(self, path: str | Path, *, fmt: str = "docx", overwrite: bool = False) -> str:
    """Save the document to `path`, returning the absolute path written.

    `fmt` is `"docx"` (the modern Open XML format). For PDF, use
    [`export_pdf`][wordlive.Document.export_pdf] (it goes through a different
    COM call and takes a page range). By default refuses to clobber an
    existing file β€” pass `overwrite=True` to allow it. **Ungated** like
    [`save`][wordlive.Document.save]; the CLI / MCP surface whitelists the
    target first.
    """
    target = Path(path).expanduser()
    fmt_norm = str(fmt).lower().lstrip(".")
    if fmt_norm == "pdf":
        raise OpError("save_as does not write PDF; use export_pdf(path) instead")
    if fmt_norm not in ("docx",):
        raise OpError(f"unsupported save format {fmt!r}; supported: docx (PDF via export_pdf)")
    if not overwrite and target.exists():
        raise OpError(
            f"refusing to overwrite existing file {str(target)!r}; pass overwrite=True"
        )
    abspath = str(target.resolve())
    with _com.translate_com_errors():
        self._doc.SaveAs2(FileName=abspath, FileFormat=int(WdSaveFormat.DOCUMENT_DEFAULT))
    return abspath

export_pdf

export_pdf(path: str | Path, *, from_page: int | None = None, to_page: int | None = None) -> str

Export the document (or a page span) to a PDF at path; return the path.

from_page / to_page are 1-based and inclusive; omit both to export the whole document, or give from_page alone to export a single page. Goes through Document.ExportAsFixedFormat (the same engine snapshot uses), so the PDF is a pixel-faithful render β€” the recommended "hand back a deliverable" path. Overwrites an existing file. Ungated like save.

Source code in src/wordlive/_document/_persistence.py
def export_pdf(
    self, path: str | Path, *, from_page: int | None = None, to_page: int | None = None
) -> str:
    """Export the document (or a page span) to a PDF at `path`; return the path.

    `from_page` / `to_page` are 1-based and inclusive; omit both to export the
    whole document, or give `from_page` alone to export a single page. Goes
    through `Document.ExportAsFixedFormat` (the same engine
    [`snapshot`][wordlive.Document.snapshot] uses), so the PDF is a
    pixel-faithful render β€” the recommended "hand back a deliverable" path.
    Overwrites an existing file. **Ungated** like [`save`][wordlive.Document.save].
    """
    abspath = str(Path(path).expanduser().resolve())
    _snapshot._export_pdf(self._doc, abspath, from_page=from_page, to_page=to_page)
    return abspath

pin

pin(anchor: Anchor | str, name: str | None = None) -> dict[str, Any]

Plant a durable handle on anchor's range and return its pin: id.

The fix for fragile positional ids: pin("para:7") mints a hidden bookmark over that paragraph's range and hands back a pin:<code> anchor id that keeps pointing at the same content across later inserts / deletes / edits (Word maintains the association natively β€” that's the durability). Resolve it like any anchor β€” doc.anchor_by_id("pin:a3f9c2") β€” or feed it straight into another op. If the pinned content is later deleted the handle correctly vanishes (resolving it raises AnchorNotFoundError).

anchor is an Anchor or an anchor id string. name optionally gives a readable slug (budget-intro -> pin:budget-intro; lowercase words joined by single hyphens); omit it for a random code. Re-using a slug moves the handle to the new range (Word's Bookmarks.Add semantics). Editing through the pin (set_text) keeps it; rewriting the same span via a different anchor's Range.Text drops it.

Returns {"anchor_id": "pin:…", "pin": "pin:…", "target": <resolved id>}. stamp is an alias. Wrap in doc.edit(...) for atomic undo β€” but do not call it inside an already-open edit scope (custom undo records don't nest; the exec batch already owns one). The CLI verb is wordlive pin ANCHOR_ID [--name SLUG]; the exec op is pin.

Source code in src/wordlive/_document/_persistence.py
def pin(self, anchor: Anchor | str, name: str | None = None) -> dict[str, Any]:
    """Plant a durable handle on `anchor`'s range and return its `pin:` id.

    The fix for fragile positional ids: `pin("para:7")` mints a hidden
    bookmark over that paragraph's range and hands back a `pin:<code>` anchor
    id that keeps pointing at the same content across later inserts / deletes
    / edits (Word maintains the association natively β€” that's the durability).
    Resolve it like any anchor β€” `doc.anchor_by_id("pin:a3f9c2")` β€” or feed it
    straight into another op. If the pinned content is later deleted the handle
    correctly vanishes (resolving it raises `AnchorNotFoundError`).

    `anchor` is an [`Anchor`][wordlive.Anchor] or an anchor id string. `name`
    optionally gives a readable slug (``budget-intro`` -> ``pin:budget-intro``;
    lowercase words joined by single hyphens); omit it for a random code.
    Re-using a slug moves the handle to the new range (Word's `Bookmarks.Add`
    semantics). Editing *through* the pin (`set_text`) keeps it; rewriting the
    same span via a different anchor's `Range.Text` drops it.

    Returns `{"anchor_id": "pin:…", "pin": "pin:…", "target": <resolved id>}`.
    `stamp` is an alias. Wrap in `doc.edit(...)` for atomic undo β€” but do not
    call it inside an already-open edit scope (custom undo records don't nest;
    the `exec` batch already owns one). The CLI verb is
    `wordlive pin ANCHOR_ID [--name SLUG]`; the exec op is `pin`.
    """
    resolved = self.anchor_by_id(anchor) if isinstance(anchor, str) else anchor
    target = anchor if isinstance(anchor, str) else resolved.anchor_id
    if name is not None:
        code = _validate_pin_slug(name)
        with _com.translate_com_errors():
            _mint_wl_bookmark(self._doc, resolved.com, code)
    else:
        with _com.translate_com_errors():
            code = _new_pin_code()
            while self._doc.Bookmarks.Exists(_pin_name_for(code)):
                code = _new_pin_code()
            _mint_wl_bookmark(self._doc, resolved.com, code)
    return {"anchor_id": f"pin:{code}", "pin": f"pin:{code}", "target": target}

pin_outline

pin_outline(*, levels: int | tuple[int, int] | None = None) -> dict[str, str]

Pin every heading at once and return the {heading_id: pin_id} map.

A durable navigation scaffold up front: stamp a handle on each heading so an agent can address sections by pin: ids that survive the inserts / deletes it is about to make, instead of re-reading outline after every edit. Idempotent β€” a heading already carrying a wordlive handle reuses it, so calling this twice returns the same map (run it once on a stable document; the reuse keys on each heading's range start).

levels filters which headings get pinned: None (default) pins every heading, an int n pins levels 1..n, and a (lo, hi) tuple pins the inclusive band. Returns an ordered {"heading:3": "pin:a3f9c2", …}. Wrap in doc.edit(...) for atomic undo. See pin for the single-anchor form.

Source code in src/wordlive/_document/_persistence.py
def pin_outline(self, *, levels: int | tuple[int, int] | None = None) -> dict[str, str]:
    """Pin every heading at once and return the `{heading_id: pin_id}` map.

    A durable navigation scaffold up front: stamp a handle on each heading so
    an agent can address sections by `pin:` ids that survive the inserts /
    deletes it is about to make, instead of re-reading `outline` after every
    edit. Idempotent β€” a heading already carrying a wordlive handle reuses it,
    so calling this twice returns the same map (run it once on a stable
    document; the reuse keys on each heading's range start).

    `levels` filters which headings get pinned: `None` (default) pins every
    heading, an `int` n pins levels ``1..n``, and a ``(lo, hi)`` tuple pins
    the inclusive band. Returns an ordered ``{"heading:3": "pin:a3f9c2", …}``.
    Wrap in `doc.edit(...)` for atomic undo. See
    [`pin`][wordlive.Document.pin] for the single-anchor form.
    """
    lo, hi = _resolve_level_band(levels)
    existing = self._existing_pin_starts()
    out: dict[str, str] = {}
    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 or not (lo <= level <= hi):
                continue
            rng = para.Range
            start = int(rng.Start)
            code = existing.get(start)
            if code is None:
                code = _new_pin_code()
                while self._doc.Bookmarks.Exists(_pin_name_for(code)):
                    code = _new_pin_code()
                _mint_wl_bookmark(self._doc, rng, code)
                existing[start] = code
            out[f"heading:{idx}"] = f"pin:{code}"
    return out

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/_persistence.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

set_watermark

set_watermark(text: str, *, font: str = 'Calibri', color: str = '#C0C0C0', layout: str = 'diagonal', semitransparent: bool = True) -> int

Stamp a text watermark (DRAFT / CONFIDENTIAL / …) behind every page.

Adds a WordArt shape to each section's primary header story β€” the same mechanism (and shape name) as Word's Design β†’ Watermark β†’ Custom, so it shows behind the body text on every page and replaces any existing text watermark. layout is "diagonal" (default, rotated 45Β°) or "horizontal"; color is the fill colour ("#C0C0C0" / "red"); semitransparent washes it out (50% transparency) so body text stays readable. Returns the number of sections stamped.

Any prior watermark is cleared first, so calling it twice doesn't stack. Remove one with remove_watermark. Wrap in doc.edit(...) for atomic undo. Raises OpError for a bad layout or color.

Source code in src/wordlive/_document/_persistence.py
def set_watermark(
    self,
    text: str,
    *,
    font: str = "Calibri",
    color: str = "#C0C0C0",
    layout: str = "diagonal",
    semitransparent: bool = True,
) -> int:
    """Stamp a text watermark (DRAFT / CONFIDENTIAL / …) behind every page.

    Adds a WordArt shape to each section's primary header story β€” the same
    mechanism (and shape name) as Word's *Design β†’ Watermark β†’ Custom*, so it
    shows behind the body text on every page and replaces any existing text
    watermark. `layout` is ``"diagonal"`` (default, rotated 45Β°) or
    ``"horizontal"``; `color` is the fill colour (``"#C0C0C0"`` / ``"red"``);
    `semitransparent` washes it out (50% transparency) so body text stays
    readable. Returns the number of sections stamped.

    Any prior watermark is cleared first, so calling it twice doesn't stack.
    Remove one with [`remove_watermark`][wordlive.Document.remove_watermark].
    Wrap in `doc.edit(...)` for atomic undo. Raises `OpError` for a bad
    `layout` or `color`.
    """
    if layout not in ("diagonal", "horizontal"):
        raise OpError(f"watermark layout must be 'diagonal' or 'horizontal'; got {layout!r}")
    try:
        fill_bgr = to_bgr(color)
    except (ValueError, TypeError) as e:
        raise OpError(str(e)) from e
    rotation = 315.0 if layout == "diagonal" else 0.0
    self.remove_watermark()
    with _com.translate_com_errors():
        sections = self._doc.Sections
        count = int(sections.Count)
        for s in range(1, count + 1):
            section = sections(s)
            header = section.Headers(int(WdHeaderFooterIndex.PRIMARY))
            ps = section.PageSetup
            usable = float(ps.PageWidth) - float(ps.LeftMargin) - float(ps.RightMargin)
            width = max(72.0, usable)
            # Shapes live on the HeaderFooter itself, not its Range (a Range
            # has no .Shapes) β€” this is the header story Word's own watermark
            # feature draws into.
            shape = header.Shapes.AddTextEffect(
                PresetTextEffect=int(MsoPresetTextEffect.TEXT_EFFECT1),
                Text=text,
                FontName=font,
                FontSize=1.0,  # WordArt scales to the box; explicit size below
                FontBold=int(MsoTriState.FALSE),
                FontItalic=int(MsoTriState.FALSE),
                Left=0.0,
                Top=0.0,
            )
            shape.Name = f"{self._WATERMARK_NAME_PREFIX}{s}"
            shape.TextEffect.NormalizedHeight = False
            shape.Line.Visible = int(MsoTriState.FALSE)
            shape.Fill.Visible = int(MsoTriState.TRUE)
            shape.Fill.Solid()
            shape.Fill.ForeColor.RGB = fill_bgr
            shape.Fill.Transparency = 0.5 if semitransparent else 0.0
            shape.Rotation = rotation
            shape.LockAspectRatio = int(MsoTriState.TRUE)
            shape.Width = width
            shape.Height = width / 5.0
            shape.WrapFormat.AllowOverlap = True
            shape.WrapFormat.Side = int(WdWrapSideType.BOTH)
            shape.WrapFormat.Type = int(WdWrapType.BEHIND)
            shape.RelativeHorizontalPosition = int(WdRelativeHorizontalPosition.MARGIN)
            shape.RelativeVerticalPosition = int(WdRelativeVerticalPosition.MARGIN)
            shape.Left = float(WdShapePosition.CENTER)
            shape.Top = float(WdShapePosition.CENTER)
    return count

remove_watermark

remove_watermark() -> int

Remove any text watermark added by set_watermark (or Word's ribbon).

Deletes every WordArt shape named like Word's watermark object across all sections' header stories. Returns the number of shapes removed (0 if there was no watermark). Wrap in doc.edit(...) for atomic undo.

Source code in src/wordlive/_document/_persistence.py
def remove_watermark(self) -> int:
    """Remove any text watermark added by `set_watermark` (or Word's ribbon).

    Deletes every WordArt shape named like Word's watermark object across all
    sections' header stories. Returns the number of shapes removed (0 if there
    was no watermark). Wrap in `doc.edit(...)` for atomic undo.
    """
    removed = 0
    with _com.translate_com_errors():
        sections = self._doc.Sections
        for s in range(1, int(sections.Count) + 1):
            header = sections(s).Headers(int(WdHeaderFooterIndex.PRIMARY))
            shapes = header.Shapes
            # Delete back-to-front: removing a shape renumbers those after it.
            for i in range(int(shapes.Count), 0, -1):
                shape = shapes(i)
                if str(shape.Name or "").startswith(self._WATERMARK_NAME_PREFIX):
                    shape.Delete()
                    removed += 1
    return removed

watermark

watermark() -> WatermarkInfo | None

The text watermark stamped behind the pages, or None if there is none.

The read side of set_watermark / remove_watermark β€” it walks each section's primary header story for the WordArt shape Word's watermark feature draws (named like PowerPlusWaterMarkObject…) and reads its text. Returns a WatermarkInfo (text + the 1-based sections carrying it), or None when the document has no text watermark. Pure read β€” selection, scroll, and Saved are untouched.

Only text watermarks (the set_watermark / Design β†’ Watermark kind) are reported; a picture watermark or an ordinary floating shape is not.

Source code in src/wordlive/_document/_persistence.py
def watermark(self) -> WatermarkInfo | None:
    """The text watermark stamped behind the pages, or `None` if there is none.

    The read side of [`set_watermark`][wordlive.Document.set_watermark] /
    [`remove_watermark`][wordlive.Document.remove_watermark] β€” it walks each
    section's primary header story for the WordArt shape Word's watermark
    feature draws (named like `PowerPlusWaterMarkObject…`) and reads its text.
    Returns a [`WatermarkInfo`][wordlive.WatermarkInfo] (`text` + the 1-based
    `sections` carrying it), or `None` when the document has no text
    watermark. Pure read β€” selection, scroll, and `Saved` are untouched.

    Only text watermarks (the `set_watermark` / *Design β†’ Watermark* kind) are
    reported; a picture watermark or an ordinary floating shape is not.
    """
    found: dict[int, str] = {}
    with _com.translate_com_errors():
        sections = self._doc.Sections
        for s in range(1, int(sections.Count) + 1):
            header = sections(s).Headers(int(WdHeaderFooterIndex.PRIMARY))
            shapes = header.Shapes
            for i in range(1, int(shapes.Count) + 1):
                shape = shapes(i)
                if not str(shape.Name or "").startswith(self._WATERMARK_NAME_PREFIX):
                    continue
                try:
                    text = str(shape.TextEffect.Text or "")
                except Exception:
                    # A non-text (picture) watermark shape has no TextEffect text.
                    text = ""
                found[s] = text
    if not found:
        return None
    # Word stamps the same text into every section; surface the common value
    # (the first non-empty one), falling back to "" if every read came back blank.
    non_empty = [t for t in found.values() if t]
    return WatermarkInfo(text=non_empty[0] if non_empty else "", sections=sorted(found))

group_shapes

group_shapes(*anchor_ids: str) -> ShapeAnchor

Group two or more floating shapes into a single group shape.

Each anchor_id is a shape:N (the members must all be floating shapes). Returns the new group's ShapeAnchor (shape:N, shape_type == "group") β€” move / size / delete it as one unit, or ungroup it to get the members back. Word requires members to allow overlap, so this enables that first. Wrap in doc.edit(...) for atomic undo. Raises OpError for fewer than two shapes or a non-shape anchor.

Source code in src/wordlive/_document/_structure.py
def group_shapes(self, *anchor_ids: str) -> ShapeAnchor:
    """Group two or more floating shapes into a single group shape.

    Each `anchor_id` is a `shape:N` (the members must all be floating shapes).
    Returns the new group's [`ShapeAnchor`][wordlive.ShapeAnchor] (`shape:N`,
    `shape_type == "group"`) β€” move / size / delete it as one unit, or
    [`ungroup`][wordlive.ShapeAnchor.ungroup] it to get the members back. Word
    requires members to allow overlap, so this enables that first. Wrap in
    `doc.edit(...)` for atomic undo. Raises `OpError` for fewer than two
    shapes or a non-shape anchor.
    """
    if len(anchor_ids) < 2:
        raise OpError("group_shapes needs at least two shape anchors")
    with _com.translate_com_errors():
        coms: list[Any] = []
        for aid in anchor_ids:
            anchor = self.anchor_by_id(aid)
            if not isinstance(anchor, ShapeAnchor):
                raise OpError(f"{aid!r} is not a shape; group_shapes needs shape:N anchors")
            coms.append(anchor._shape())
        group = _shapes.group_shapes(self.com, coms)
        # Locate the new group by a unique temp name β€” don't assume "last".
        orig_name = str(group.Name or "")
        probe_name = f"_wl_shape_{secrets.token_hex(8)}"
        group.Name = probe_name
        index = _shapes.index_of_named(self.com, probe_name)
        # Restore unconditionally so an empty original name doesn't leave the
        # `_wl_shape_*` probe lingering in list().
        group.Name = orig_name
    return ShapeAnchor(self._as_document, index)

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/_structure.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
    )

add_toc

add_toc(*, levels: tuple[int, int] = (1, 3), use_heading_styles: bool = True, hyperlinks: bool = True) -> Any

Insert a table of contents at the very start of the document.

The "documents want their TOC at the top" helper β€” sugar for self.start.insert_toc(...). See Anchor.insert_toc for the full semantics of levels (a (upper, lower) heading-level pair), use_heading_styles, and hyperlinks, and for the page-number-repagination caveat. Returns the new Toc. Wrap in doc.edit(...) for atomic undo.

Source code in src/wordlive/_document/_structure.py
def add_toc(
    self,
    *,
    levels: tuple[int, int] = (1, 3),
    use_heading_styles: bool = True,
    hyperlinks: bool = True,
) -> Any:
    """Insert a table of contents at the very start of the document.

    The "documents want their TOC at the top" helper β€” sugar for
    `self.start.insert_toc(...)`. See
    [`Anchor.insert_toc`][wordlive.Anchor.insert_toc] for the full semantics
    of `levels` (a ``(upper, lower)`` heading-level pair), `use_heading_styles`,
    and `hyperlinks`, and for the page-number-repagination caveat. Returns
    the new [`Toc`][wordlive.Toc]. Wrap in `doc.edit(...)` for atomic undo.
    """
    return self.start.insert_toc(
        levels=levels, use_heading_styles=use_heading_styles, hyperlinks=hyperlinks
    )

add_index

add_index(*, columns: int = 2, run_in: bool = False, right_align_page_numbers: bool = False) -> Any

Insert a back-of-book index at the very end of the document.

The "indexes live at the back" helper β€” sugar for self.end.insert_index(...). See Anchor.insert_index for the full semantics of columns, run_in, and right_align_page_numbers, and for the page-number-repagination caveat. Mark entries first with Anchor.mark_index_entry. Returns the new Index. Wrap in doc.edit(...) for atomic undo.

Source code in src/wordlive/_document/_structure.py
def add_index(
    self,
    *,
    columns: int = 2,
    run_in: bool = False,
    right_align_page_numbers: bool = False,
) -> Any:
    """Insert a back-of-book index at the very end of the document.

    The "indexes live at the back" helper β€” sugar for
    `self.end.insert_index(...)`. See
    [`Anchor.insert_index`][wordlive.Anchor.insert_index] for the full
    semantics of `columns`, `run_in`, and `right_align_page_numbers`, and for
    the page-number-repagination caveat. Mark entries first with
    [`Anchor.mark_index_entry`][wordlive.Anchor.mark_index_entry]. Returns the
    new [`Index`][wordlive.Index]. Wrap in `doc.edit(...)` for atomic undo.
    """
    return self.end.insert_index(
        columns=columns, run_in=run_in, right_align_page_numbers=right_align_page_numbers
    )

add_bibliography

add_bibliography() -> Any

Insert a bibliography at the very end of the document.

The "references live at the back" helper β€” sugar for self.end.insert_bibliography(). See Anchor.insert_bibliography for the field-block / repagination caveat. Add sources with doc.sources.add and cite them with Anchor.insert_citation first. Returns the new Bibliography. Wrap in doc.edit(...).

Source code in src/wordlive/_document/_structure.py
def add_bibliography(self) -> Any:
    """Insert a bibliography at the very end of the document.

    The "references live at the back" helper β€” sugar for
    `self.end.insert_bibliography()`. See
    [`Anchor.insert_bibliography`][wordlive.Anchor.insert_bibliography] for the
    field-block / repagination caveat. Add sources with
    `doc.sources.add` and cite them with
    [`Anchor.insert_citation`][wordlive.Anchor.insert_citation] first. Returns
    the new [`Bibliography`][wordlive.Bibliography]. Wrap in `doc.edit(...)`.
    """
    return self.end.insert_bibliography()

add_table_of_authorities

add_table_of_authorities(*, category: str | int = 'all', passim: bool = True, keep_entry_formatting: bool = True, entry_separator: str | None = None, page_range_separator: str | None = None) -> Any

Insert a table of authorities at the very end of the document.

Sugar for self.end.insert_table_of_authorities(...). See Anchor.insert_table_of_authorities for the full semantics of category, passim, keep_entry_formatting, and the separators, and for the page-number-repagination caveat. Mark citations first with Anchor.mark_citation. Returns the new TableOfAuthorities. Wrap in doc.edit(...) for atomic undo.

Source code in src/wordlive/_document/_structure.py
def add_table_of_authorities(
    self,
    *,
    category: str | int = "all",
    passim: bool = True,
    keep_entry_formatting: bool = True,
    entry_separator: str | None = None,
    page_range_separator: str | None = None,
) -> Any:
    """Insert a table of authorities at the very end of the document.

    Sugar for `self.end.insert_table_of_authorities(...)`. See
    [`Anchor.insert_table_of_authorities`][wordlive.Anchor.insert_table_of_authorities]
    for the full semantics of `category`, `passim`, `keep_entry_formatting`,
    and the separators, and for the page-number-repagination caveat. Mark
    citations first with [`Anchor.mark_citation`][wordlive.Anchor.mark_citation].
    Returns the new [`TableOfAuthorities`][wordlive.TableOfAuthorities]. Wrap in
    `doc.edit(...)` for atomic undo.
    """
    return self.end.insert_table_of_authorities(
        category=category,
        passim=passim,
        keep_entry_formatting=keep_entry_formatting,
        entry_separator=entry_separator,
        page_range_separator=page_range_separator,
    )

outline

outline(*, pin: bool = False) -> list[dict[str, Any]]

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

With pin=True each row also carries a durable pin id and the headings are pinned as a side effect (idempotent β€” see pin_outline). This mutates the document, so it is a Python-API-only convenience; the read surfaces (wordlive read outline, MCP word_read outline) stay pure β€” pin in bulk via pin_outline / the pin_outline exec op instead.

Source code in src/wordlive/_document/_reading.py
def outline(self, *, pin: bool = False) -> list[dict[str, Any]]:
    """Return all heading paragraphs as `[{level, text, anchor_id}, ...]`.

    With `pin=True` each row also carries a durable `pin` id and the headings
    are pinned as a side effect (idempotent β€” see
    [`pin_outline`][wordlive.Document.pin_outline]). This **mutates** the
    document, so it is a Python-API-only convenience; the read surfaces
    (`wordlive read outline`, MCP `word_read outline`) stay pure β€” pin in bulk
    via `pin_outline` / the `pin_outline` exec op instead.
    """
    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}",
                }
            )
    if pin:
        pinmap = self._as_document.pin_outline()
        for row in out:
            handle = pinmap.get(row["anchor_id"])
            if handle:
                row["pin"] = handle
    return out

between

between(start: str | Anchor, end: str | Anchor, *, inclusive: bool = False) -> RangeAnchor

Return a RangeAnchor spanning the gap between two anchors.

The "give me the block between these two headings" read. start and end are anchor ids (e.g. "heading:1" / "heading:3") or Anchor objects; the headline use is a pair of heading:N ids, but any anchors work (bookmarks, paragraphs, ranges).

With inclusive=False (default) the span runs from the end of start's range to the start of end's range β€” the content strictly between them, excluding both bounding paragraphs (so two headings yield just the body in between). With inclusive=True it runs from the start of start to the end of end, covering both bounding paragraphs.

Read .text on the result for the spanned text, or feed its range:START-END id into any range-taking op. A pure read (the returned offsets are live β€” use them before further edits shift the document). Raises OpError if end begins before start.

Source code in src/wordlive/_document/_reading.py
def between(
    self,
    start: str | Anchor,
    end: str | Anchor,
    *,
    inclusive: bool = False,
) -> RangeAnchor:
    """Return a `RangeAnchor` spanning the gap between two anchors.

    The "give me the block between these two headings" read. `start` and
    `end` are anchor ids (e.g. ``"heading:1"`` / ``"heading:3"``) or
    `Anchor` objects; the headline use is a pair of `heading:N` ids, but any
    anchors work (bookmarks, paragraphs, ranges).

    With ``inclusive=False`` (default) the span runs from the **end** of
    `start`'s range to the **start** of `end`'s range β€” the content strictly
    between them, excluding both bounding paragraphs (so two headings yield
    just the body in between). With ``inclusive=True`` it runs from the
    start of `start` to the end of `end`, covering both bounding paragraphs.

    Read ``.text`` on the result for the spanned text, or feed its
    `range:START-END` id into any range-taking op. A pure read (the returned
    offsets are live β€” use them before further edits shift the document).
    Raises `OpError` if `end` begins before `start`.
    """
    with _com.translate_com_errors():
        s_anchor = self.anchor_by_id(start) if isinstance(start, str) else start
        e_anchor = self.anchor_by_id(end) if isinstance(end, str) else end
        s_rng, e_rng = s_anchor.com, e_anchor.com
        s_start, s_end = int(s_rng.Start), int(s_rng.End)
        e_start, e_end = int(e_rng.Start), int(e_rng.End)
    if e_start < s_start:
        raise OpError(
            f"'between' end anchor ({e_anchor.anchor_id}) begins before start "
            f"anchor ({s_anchor.anchor_id})"
        )
    if inclusive:
        lo, hi = min(s_start, e_start), max(s_end, e_end)
    else:
        # Strictly between: end of `start` to start of `end`. When the anchors
        # abut with no gap, clamp to an empty span at the boundary.
        lo, hi = s_end, max(s_end, e_start)
    return self.range(lo, hi)

nearest_heading

nearest_heading(where: str | Anchor | int, *, direction: str = 'before') -> dict[str, Any] | None

The heading nearest to a position, scanning before or after it.

where is an anchor id ("para:12"), an Anchor, or a raw character offset (int). direction is "before" (the nearest heading at or above the position β€” i.e. the section the position sits in) or "after" (the next heading past it). Returns an outline()-shaped row {level, text, anchor_id} (anchor_id is heading:N), or None if there is no heading in that direction. A pure read.

Source code in src/wordlive/_document/_reading.py
def nearest_heading(
    self,
    where: str | Anchor | int,
    *,
    direction: str = "before",
) -> dict[str, Any] | None:
    """The heading nearest to a position, scanning ``before`` or ``after`` it.

    `where` is an anchor id (``"para:12"``), an `Anchor`, or a raw character
    offset (int). `direction` is ``"before"`` (the nearest heading at or
    above the position β€” i.e. the section the position sits in) or
    ``"after"`` (the next heading past it). Returns an `outline()`-shaped
    row ``{level, text, anchor_id}`` (``anchor_id`` is ``heading:N``), or
    ``None`` if there is no heading in that direction. A pure read.
    """
    if direction not in ("before", "after"):
        raise OpError(f"direction must be 'before' or 'after', got {direction!r}")
    with _com.translate_com_errors():
        if isinstance(where, str):
            offset = int(self.anchor_by_id(where).com.Start)
        elif isinstance(where, int):  # raw character offset
            offset = int(where)
        else:
            offset = int(where.com.Start)
        best: dict[str, Any] | None = None
        for idx, para in enumerate(self._doc.Paragraphs, start=1):
            try:
                level = int(para.OutlineLevel)
            except Exception:
                continue
            if level >= 10:  # body text, not a heading
                continue
            h_start = int(para.Range.Start)
            row = {"level": level, "text": paragraph_text(para), "anchor_id": f"heading:{idx}"}
            if direction == "before":
                if h_start <= offset:
                    best = row  # paragraphs are in order; keep the last one at/above
                else:
                    break
            elif h_start > offset:  # "after": first heading strictly past the offset
                best = row
                break
    return best

find_paragraphs

find_paragraphs(text: str, *, limit: int = 5, min_score: float = 0.6) -> list[dict[str, Any]]

Fuzzy-rank paragraphs by similarity to text (typo/paraphrase tolerant).

Unlike find() (exact substring on normalized text), this scores every paragraph against text with difflib.SequenceMatcher over the same normalized form (NFKC, smart quotes, dashes, whitespace) β€” so an approximately-remembered paragraph still locates its para:N. Returns up to limit rows, sorted by descending score, keeping only those with score >= min_score: [{anchor_id, index, score, text, level, is_heading}, ...]. An empty or whitespace-only query returns []. A pure read.

Source code in src/wordlive/_document/_reading.py
def find_paragraphs(
    self,
    text: str,
    *,
    limit: int = 5,
    min_score: float = 0.6,
) -> list[dict[str, Any]]:
    """Fuzzy-rank paragraphs by similarity to `text` (typo/paraphrase tolerant).

    Unlike `find()` (exact substring on normalized text), this scores
    **every paragraph** against `text` with `difflib.SequenceMatcher` over
    the same normalized form (NFKC, smart quotes, dashes, whitespace) β€” so
    an approximately-remembered paragraph still locates its `para:N`.
    Returns up to `limit` rows, sorted by descending `score`, keeping only
    those with ``score >= min_score``:
    ``[{anchor_id, index, score, text, level, is_heading}, ...]``. An empty
    or whitespace-only query returns ``[]``. A pure read.
    """
    if limit < 1:
        raise OpError(f"limit must be >= 1, got {limit}")
    if not 0.0 <= min_score <= 1.0:
        raise OpError(f"min_score must be in [0, 1], got {min_score}")
    needle = _findreplace._normalize(text).text
    if not needle:
        return []
    scored: list[dict[str, Any]] = []
    with _com.translate_com_errors():
        for idx, para in enumerate(self._doc.Paragraphs, start=1):
            raw = paragraph_text(para)
            hay = _findreplace._normalize(raw).text
            if not hay:
                continue
            score = difflib.SequenceMatcher(None, needle, hay).ratio()
            if score < min_score:
                continue
            try:
                level = int(para.OutlineLevel)
            except Exception:
                level = 10
            scored.append(
                {
                    "anchor_id": f"para:{idx}",
                    "index": idx,
                    "score": round(score, 4),
                    "text": raw,
                    "level": level,
                    "is_heading": level < 10,
                }
            )
    scored.sort(key=lambda r: r["score"], reverse=True)
    return scored[:limit]

stats

stats() -> dict[str, Any]

A one-call summary of the document β€” the "what am I looking at" read.

Returns {pages, words, characters, paragraphs, lines, sections, headings, tables, images, equations, charts, comments, revisions, saved}. The five text counts come from Word's own ComputeStatistics; the structural counts come from wordlive's discovery collections (so they agree with doc.tables / doc.images / outline etc.); saved is doc.saved.

pages/lines are print-layout truth, so the document is repaginated first (content-neutral β€” selection, scroll, and view are untouched), the same guarantee a snapshot gives. A pure read; nothing is mutated β€” Repaginate flips Word's dirty bit, so the document's Saved state is snapshotted and restored around it.

Source code in src/wordlive/_document/_reading.py
def stats(self) -> dict[str, Any]:
    """A one-call summary of the document β€” the "what am I looking at" read.

    Returns `{pages, words, characters, paragraphs, lines, sections,
    headings, tables, images, equations, charts, comments, revisions, saved}`. The five text
    counts come from Word's own `ComputeStatistics`; the structural counts
    come from wordlive's discovery collections (so they agree with
    `doc.tables` / `doc.images` / `outline` etc.); `saved` is `doc.saved`.

    `pages`/`lines` are print-layout truth, so the document is
    **repaginated first** (content-neutral β€” selection, scroll, and view are
    untouched), the same guarantee a `snapshot` gives. A pure read; nothing
    is mutated β€” `Repaginate` flips Word's dirty bit, so the document's
    `Saved` state is snapshotted and restored around it.
    """
    with _com.translate_com_errors(), _com.preserve_saved(self._doc):
        self._doc.Repaginate()
        text_counts = {
            "pages": int(self._doc.ComputeStatistics(int(WdStatistic.PAGES))),
            "words": int(self._doc.ComputeStatistics(int(WdStatistic.WORDS))),
            "characters": int(self._doc.ComputeStatistics(int(WdStatistic.CHARACTERS))),
            "paragraphs": int(self._doc.ComputeStatistics(int(WdStatistic.PARAGRAPHS))),
            "lines": int(self._doc.ComputeStatistics(int(WdStatistic.LINES))),
        }
    return {
        **text_counts,
        "sections": len(self.sections),
        "headings": len(self.outline()),
        "tables": len(self.tables),
        "images": len(self.images),
        "equations": len(self.equations),
        "charts": len(self.charts),
        "comments": len(self.comments),
        "revisions": len(self.revisions),
        "saved": self.saved,
    }

to_markdown

to_markdown(*, within: str | Anchor | None = None) -> str

Serialise the document (or one anchor's range) to clean Markdown.

The read mirror of insert_markdown: headings (#–######), bullet / numbered lists (with nesting), **bold** / *italic* / ***both***, GFM pipe tables, inline images as ![alt](image:N), and hyperlinks as [text](url). The constrained subset import speaks round-trips; the rest is a richer read (export is lossy by design β€” underline, colours, and merged table cells do not survive).

within scopes the output to an anchor's literal range β€” pass a range:START-END (e.g. from find) or any anchor id / Anchor. A heading:N covers only the heading line, not its section body β€” use doc.between(...) or a range for "the section under X". None (the default) serialises the whole document. A pure read; nothing is mutated.

Source code in src/wordlive/_document/_reading.py
def to_markdown(self, *, within: str | Anchor | None = None) -> str:
    """Serialise the document (or one anchor's range) to clean Markdown.

    The read mirror of [`insert_markdown`][wordlive.Anchor.insert_markdown]:
    headings (``#``–``######``), bullet / numbered lists (with nesting),
    ``**bold**`` / ``*italic*`` / ``***both***``, GFM pipe tables, inline
    images as ``![alt](image:N)``, and hyperlinks as ``[text](url)``. The
    constrained subset import speaks round-trips; the rest is a richer read
    (export is **lossy by design** β€” underline, colours, and merged table
    cells do not survive).

    `within` scopes the output to an anchor's **literal range** β€” pass a
    `range:START-END` (e.g. from `find`) or any anchor id / `Anchor`. A
    `heading:N` covers only the heading line, not its section body β€” use
    `doc.between(...)` or a range for "the section under X". ``None`` (the
    default) serialises the whole document. A pure read; nothing is mutated.
    """
    with _com.translate_com_errors():
        blocks = _export.walk_blocks(self._as_document, within)
    return _export.render_markdown(blocks)

to_html

to_html(*, within: str | Anchor | None = None) -> str

Serialise the document (or one anchor's range) to an HTML fragment.

The HTML counterpart of to_markdown, rendered from the same document walk so the two agree on structure: headings (<h1>–<h6>), <ul>/<ol> lists (nested), <strong>/<em>/<u>, <table>, <img>, and <a>. Unlike the Markdown dialect, HTML keeps underline. Returns a fragment (no <html>/<body> wrapper). within scopes to an anchor's literal range (see to_markdown); None is the whole document. A pure read.

Source code in src/wordlive/_document/_reading.py
def to_html(self, *, within: str | Anchor | None = None) -> str:
    """Serialise the document (or one anchor's range) to an HTML fragment.

    The HTML counterpart of [`to_markdown`][wordlive.Document.to_markdown],
    rendered from the same document walk so the two agree on structure:
    headings (``<h1>``–``<h6>``), ``<ul>``/``<ol>`` lists (nested),
    ``<strong>``/``<em>``/``<u>``, ``<table>``, ``<img>``, and ``<a>``. Unlike
    the Markdown dialect, HTML keeps underline. Returns a fragment (no
    ``<html>``/``<body>`` wrapper). `within` scopes to an anchor's literal
    range (see `to_markdown`); ``None`` is the whole document. A pure read.
    """
    with _com.translate_com_errors():
        blocks = _export.walk_blocks(self._as_document, within)
    return _export.render_html(blocks)

read

read(*, budget: int = 6000, depth: int | None = None) -> str

A token-budgeted, structure-aware digest of the whole document.

Loads a large document into context cheaply while keeping every anchor addressable: headings are emitted verbatim (each tagged with its <!-- heading:N --> anchor β€” the navigation spine), tables become one-line shape stubs (> table:N β€” R rows Γ— C cols: …), and body text is sampled to fit budget (an approximate token count, ~4 chars/token), weighted so shallower sections keep more than deep ones. Overflow is elided to markers that still name the para: range and word count, so an agent can drill in with to_markdown(within=…). depth caps how deep a section keeps any body (deeper sections collapse to a marker).

Returns annotated Markdown. A pure read; the eliding heuristic's knobs live in _export for tuning. For the full text of any region, use to_markdown.

Source code in src/wordlive/_document/_reading.py
def read(self, *, budget: int = 6000, depth: int | None = None) -> str:
    """A token-budgeted, structure-aware digest of the **whole** document.

    Loads a large document into context cheaply while keeping **every anchor
    addressable**: headings are emitted verbatim (each tagged with its
    `<!-- heading:N -->` anchor β€” the navigation spine), tables become one-line
    shape stubs (`> table:N β€” R rows Γ— C cols: …`), and body text is sampled to
    fit `budget` (an approximate token count, ~4 chars/token), weighted so
    shallower sections keep more than deep ones. Overflow is elided to markers
    that still name the `para:` range and word count, so an agent can drill in
    with [`to_markdown(within=…)`][wordlive.Document.to_markdown]. `depth` caps
    how deep a section keeps any body (deeper sections collapse to a marker).

    Returns annotated Markdown. A pure read; the eliding heuristic's knobs live
    in `_export` for tuning. For the full text of any region, use `to_markdown`.
    """
    with _com.translate_com_errors():
        blocks = _export.walk_blocks(self._as_document, None)
    return _export.build_digest(blocks, budget=budget, depth=depth)

proofing

proofing() -> dict[str, Any]

Run Word's proofing tools and report spelling, grammar, and readability.

Returns {spelling, grammar, readability}. spelling and grammar are each {count, errors} β€” the exact error count plus a (capped) list of {text, anchor_id, para} for the flagged runs, so a range:START-END can be fed back into read or comments.add. readability is Word's readability statistics (Flesch Reading Ease, Flesch-Kincaid Grade Level, passive-sentence %, word/sentence averages), snake_cased.

Heavier than stats: it asks Word to (re)check the document. Still a pure read β€” nothing is mutated. If proofing is disabled or the document is protected, the affected section reports a None count / empty readability rather than failing.

Source code in src/wordlive/_document/_reading.py
def proofing(self) -> dict[str, Any]:
    """Run Word's proofing tools and report spelling, grammar, and readability.

    Returns `{spelling, grammar, readability}`. `spelling` and `grammar` are
    each `{count, errors}` β€” the exact error count plus a (capped) list of
    `{text, anchor_id, para}` for the flagged runs, so a `range:START-END`
    can be fed back into `read` or `comments.add`. `readability` is Word's
    readability statistics (Flesch Reading Ease, Flesch-Kincaid Grade Level,
    passive-sentence %, word/sentence averages), snake_cased.

    Heavier than [`stats`][wordlive.Document.stats]: it asks Word to (re)check
    the document. Still a pure read β€” nothing is mutated. If proofing is
    disabled or the document is protected, the affected section reports a
    `None` count / empty readability rather than failing.
    """
    return _proofing.read_proofing(self._as_document)

lint

lint(*, rules: Any = None, within: str | Anchor | None = None, profile: Any = None) -> list[dict[str, Any]]

Audit the document for publishing-quality defects β€” a pure read.

Returns a severity-ranked list of findings, each {rule, kind, severity, anchor_id, message, fixable, fix, observed, expected}. kind is consistency (a direct override fighting the applied style β€” a Heading 1 at 15pt), structural (an objective layout defect β€” a heading that may dangle at a page foot, a multi-page table with no repeating header, a numbered list Word split into independent "1." runs), or policy (a house-style target β€” off unless a profile enables it). A fixable finding carries an op-shaped fix describing exactly what regularize would change.

rules selects which rules run: None is the default set (all consistency + structural); a list of rule ids / tags (["headings", "lists"]) includes only those; {"exclude": [...]} runs the default set minus the listed ids/tags. within=anchor scopes the audit to an anchor's range (a heading's section, a range:, a table).

profile is a house-style config (a path to a wordlive.lint.json file, an inline dict, or None) that opts policy rules in, supplies their targets (body-line-spacing's spacing, table-numeric-right-align's threshold), and can override a rule's severity or disable a default rule β€” spec-linter.md Β§6.

Read-only β€” selection, scroll, and Saved are untouched (the layout rules repaginate content-neutrally, like stats).

Source code in src/wordlive/_document/_reading.py
def lint(
    self,
    *,
    rules: Any = None,
    within: str | Anchor | None = None,
    profile: Any = None,
) -> list[dict[str, Any]]:
    """Audit the document for publishing-quality defects β€” a pure read.

    Returns a severity-ranked list of findings, each
    `{rule, kind, severity, anchor_id, message, fixable, fix, observed,
    expected}`. `kind` is `consistency` (a direct override fighting the
    applied style β€” a `Heading 1` at 15pt), `structural` (an objective layout
    defect β€” a heading that may dangle at a page foot, a multi-page table with
    no repeating header, a numbered list Word split into independent "1."
    runs), or `policy` (a house-style target β€” off unless a `profile` enables
    it). A `fixable` finding carries an op-shaped `fix` describing exactly what
    [`regularize`][wordlive.Document.regularize] would change.

    `rules` selects which rules run: `None` is the default set (all
    consistency + structural); a list of rule ids / tags
    (`["headings", "lists"]`) includes only those; `{"exclude": [...]}` runs
    the default set minus the listed ids/tags. `within=anchor` scopes the
    audit to an anchor's range (a heading's section, a `range:`, a table).

    `profile` is a house-style config (a path to a `wordlive.lint.json` file,
    an inline dict, or `None`) that opts **policy** rules in, supplies their
    targets (`body-line-spacing`'s spacing, `table-numeric-right-align`'s
    threshold), and can override a rule's severity or disable a default rule β€”
    `spec-linter.md` Β§6.

    Read-only β€” selection, scroll, and `Saved` are untouched (the layout
    rules repaginate content-neutrally, like [`stats`][wordlive.Document.stats]).
    """
    return [
        f.to_dict()
        for f in _linting.run_lint(
            self._as_document, rules=rules, within=within, profile=profile
        )
    ]

regularize

regularize(*, rules: Any = None, within: str | Anchor | None = None, profile: Any = None, dry_run: bool = False, allow_content: bool = False) -> dict[str, Any]

Apply the fixable lint findings in one atomic-undo step. Returns {applied, skipped, deferred, findings}.

Each fixable finding's fix op(s) run through the batch op loop inside a single doc.edit("Regularize formatting"), so one Ctrl-Z reverts the whole pass and the user's selection/scroll are preserved. The default fixes are targeted and idempotent β€” they bring a drifted direct override back to its style's value, so running regularize twice applies nothing the second time. rules / within / profile are as for lint (a profile also lets its policy fixes β€” justify, line-spacing, numeric-column alignment β€” participate). dry_run=True plans the fixes (returning them in findings) without writing.

Formatting/structure fixes apply by default; content-changing fixes are opt-in. A fix that adds or destroys content (inserting a caption/notice, deleting a stray paragraph, stripping a watermark) is flagged adds_content and withheld unless allow_content=True. Withheld fixes are listed in deferred so you can see what an opt-in would apply. If Track Changes is on, the edits are tracked like any other for review.

Source code in src/wordlive/_document/_reading.py
def regularize(
    self,
    *,
    rules: Any = None,
    within: str | Anchor | None = None,
    profile: Any = None,
    dry_run: bool = False,
    allow_content: bool = False,
) -> dict[str, Any]:
    """Apply the fixable [`lint`][wordlive.Document.lint] findings in one
    atomic-undo step. Returns `{applied, skipped, deferred, findings}`.

    Each fixable finding's `fix` op(s) run through the batch op loop inside a
    single `doc.edit("Regularize formatting")`, so one Ctrl-Z reverts the
    whole pass and the user's selection/scroll are preserved. The default
    fixes are **targeted and idempotent** β€” they bring a drifted direct
    override back to its style's value, so running `regularize` twice applies
    nothing the second time. `rules` / `within` / `profile` are as for `lint`
    (a `profile` also lets its policy fixes β€” justify, line-spacing,
    numeric-column alignment β€” participate). `dry_run=True` plans the fixes
    (returning them in `findings`) without writing.

    **Formatting/structure fixes apply by default; content-changing fixes are
    opt-in.** A fix that adds or destroys content (inserting a caption/notice,
    deleting a stray paragraph, stripping a watermark) is flagged
    `adds_content` and **withheld** unless `allow_content=True`. Withheld fixes
    are listed in `deferred` so you can see what an opt-in would apply. If
    Track Changes is on, the edits are tracked like any other for review.
    """
    return _linting.regularize(
        self._as_document,
        rules=rules,
        within=within,
        profile=profile,
        dry_run=dry_run,
        allow_content=allow_content,
    )

checkpoint

checkpoint(*, include: str = 'text+style', within: str | Anchor | None = None) -> Checkpoint

Fingerprint the document's structure right now β€” a pure read.

Returns an opaque, serialisable Checkpoint (call .to_json() to store it). Later, feed it to changes_since (checkpoint β†’ now) or diff (two stored checkpoints) for a structured, content-aligned change list β€” the only reliable way to answer "what changed in session" (Word emits no content-change event), and the way an agent verifies its own edits landed without re-reading the whole document.

include sets the fingerprint depth: "text" (cheapest β€” a restyle is invisible), "text+style" (default β€” folds the applied paragraph-style name in, so a restyle surfaces), or "text+format" (also hashes each paragraph's format_info, so a pure direct-formatting edit surfaces as a reformat). within=anchor fingerprints just one section/range.

Read-only β€” walks paragraphs like outline, touching no selection/scroll and leaving Saved untouched.

Source code in src/wordlive/_document/_reading.py
def checkpoint(
    self,
    *,
    include: str = "text+style",
    within: str | Anchor | None = None,
) -> Checkpoint:
    """Fingerprint the document's structure right now β€” a pure read.

    Returns an opaque, serialisable [`Checkpoint`][wordlive.Checkpoint] (call
    `.to_json()` to store it). Later, feed it to
    [`changes_since`][wordlive.Document.changes_since] (checkpoint β†’ now) or
    [`diff`][wordlive.Document.diff] (two stored checkpoints) for a structured,
    content-aligned change list β€” the only reliable way to answer "what
    changed in session" (Word emits no content-change event), and the way an
    agent verifies its own edits landed without re-reading the whole document.

    `include` sets the fingerprint depth: ``"text"`` (cheapest β€” a restyle is
    invisible), ``"text+style"`` (default β€” folds the applied paragraph-style
    name in, so a restyle surfaces), or ``"text+format"`` (also hashes each
    paragraph's `format_info`, so a pure direct-formatting edit surfaces as a
    `reformat`). `within=anchor` fingerprints just one section/range.

    Read-only β€” walks paragraphs like [`outline`][wordlive.Document.outline],
    touching no selection/scroll and leaving `Saved` untouched.
    """
    return _checkpoint.build_checkpoint(self._as_document, include=include, within=within)

changes_since

changes_since(cp: Checkpoint | str | dict[str, Any]) -> list[dict[str, Any]]

Diff a stored checkpoint against the document now β€” a pure read.

cp is a Checkpoint (or its to_json() string / parsed dict, so a token round-tripped through a file works directly). Returns the change list described in diff; the checkpoint's include depth and within scope are re-derived so the two fingerprints are comparable. An unchanged document returns [] via the doc_hash fast-path.

Source code in src/wordlive/_document/_reading.py
def changes_since(self, cp: Checkpoint | str | dict[str, Any]) -> list[dict[str, Any]]:
    """Diff a stored checkpoint against the document **now** β€” a pure read.

    `cp` is a [`Checkpoint`][wordlive.Checkpoint] (or its `to_json()` string /
    parsed dict, so a token round-tripped through a file works directly).
    Returns the change list described in [`diff`][wordlive.Document.diff]; the
    checkpoint's `include` depth and `within` scope are re-derived so the two
    fingerprints are comparable. An unchanged document returns ``[]`` via the
    `doc_hash` fast-path.
    """
    return _checkpoint.changes_since(self._as_document, cp)

diff

diff(cp_a: Checkpoint | str | dict[str, Any], cp_b: Checkpoint | str | dict[str, Any]) -> list[dict[str, Any]]

Diff two stored checkpoints β†’ a structured, content-aligned change list.

Each change is one of: replace (text edit), insert, delete, restyle (same text, paragraph style changed), or reformat (same text+style, direct formatting changed β€” only with include="text+format"). Records carry {op, anchor_id, index_before, index_after, text_before, text_after, style_before, style_after} as applicable; inserts/replaces/ restyles carry the current para:N (anchor_id) so the caller can act on the change immediately, while a delete references only the old index/text (its anchor is gone).

Alignment is by paragraph content, not index (para:N renumbers under inserts/deletes). Both checkpoints must share the same include depth. Move detection is deferred β€” a cut-paste surfaces as delete+insert. A pure read (the tokens carry the data; Word is not touched).

Source code in src/wordlive/_document/_reading.py
def diff(
    self,
    cp_a: Checkpoint | str | dict[str, Any],
    cp_b: Checkpoint | str | dict[str, Any],
) -> list[dict[str, Any]]:
    """Diff two stored checkpoints β†’ a structured, content-aligned change list.

    Each change is one of: ``replace`` (text edit), ``insert``, ``delete``,
    ``restyle`` (same text, paragraph style changed), or ``reformat`` (same
    text+style, direct formatting changed β€” only with ``include="text+format"``).
    Records carry ``{op, anchor_id, index_before, index_after, text_before,
    text_after, style_before, style_after}`` as applicable; inserts/replaces/
    restyles carry the **current** ``para:N`` (`anchor_id`) so the caller can
    act on the change immediately, while a delete references only the old
    index/text (its anchor is gone).

    Alignment is by paragraph **content**, not index (`para:N` renumbers under
    inserts/deletes). Both checkpoints must share the same `include` depth.
    Move detection is deferred β€” a cut-paste surfaces as delete+insert. A pure
    read (the tokens carry the data; Word is not touched).
    """
    return _checkpoint.diff_checkpoints(cp_a, cp_b)

snapshot

snapshot(out: str | Path | None = None, *, pages: int | tuple[int, int] | None = None, dpi: int = 150, max_dim: int | None = None, markup: str = 'none') -> 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>.

markup is "none" (default β€” render the final document) or "all" (render tracked changes and comments as visible revision marks and balloons). The marks come from the export, not a view change, so the user's on-screen markup setting is left untouched. The structured counterpart is revisions.

dpi controls resolution; ~150 reads well for a vision model without bloating the image. max_dim caps each page's long edge in pixels, only ever lowering the resolution β€” the lever for a cheap whole-document layout check (a vision model is billed on pixel area, so a long-edge cap gives a predictable per-page token budget regardless of paper size; ~1000 keeps a page legible for "did my styling land" at a fraction of the tokens). dpi=72 is a coarser alternative. Read-only β€” the document and the user's cursor are untouched. Requires the snapshot extra (PyMuPDF), else SnapshotError.

Source code in src/wordlive/_document/_reading.py
def snapshot(
    self,
    out: str | Path | None = None,
    *,
    pages: int | tuple[int, int] | None = None,
    dpi: int = 150,
    max_dim: int | None = None,
    markup: str = "none",
) -> 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>`.

    `markup` is `"none"` (default β€” render the final document) or `"all"`
    (render tracked changes and comments as visible revision marks and
    balloons). The marks come from the export, not a view change, so the
    user's on-screen markup setting is left untouched. The structured
    counterpart is [`revisions`][wordlive.Document.revisions].

    `dpi` controls resolution; ~150 reads well for a vision model without
    bloating the image. `max_dim` caps each page's **long edge** in pixels,
    only ever lowering the resolution β€” the lever for a cheap *whole-document*
    layout check (a vision model is billed on pixel area, so a long-edge cap
    gives a predictable per-page token budget regardless of paper size; ~1000
    keeps a page legible for "did my styling land" at a fraction of the
    tokens). `dpi=72` is a coarser alternative. Read-only β€” the document and
    the user's cursor are untouched. Requires the `snapshot` extra (PyMuPDF),
    else [`SnapshotError`][wordlive.SnapshotError].
    """
    if max_dim is not None and (isinstance(max_dim, bool) or int(max_dim) < 1):
        raise OpError(f"max_dim must be a positive integer (pixels); got {max_dim!r}")
    from_page, to_page = self._resolve_page_arg(pages)
    rendered = _snapshot.render(
        self._doc,
        from_page=from_page,
        to_page=to_page,
        dpi=dpi,
        max_dim=max_dim,
        markup=_markup_flag(markup),
    )
    return _snapshot.build_snapshots(rendered, out)

snapshot_anchor

snapshot_anchor(anchor: Anchor, out: str | Path | None = None, *, dpi: int = 150, max_dim: int | None = None, markup: str = 'none') -> 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/max_dim/markup semantics and the return shape.

Source code in src/wordlive/_document/_reading.py
def snapshot_anchor(
    self,
    anchor: Anchor,
    out: str | Path | None = None,
    *,
    dpi: int = 150,
    max_dim: int | None = None,
    markup: str = "none",
) -> 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`/`max_dim`/`markup`
    semantics and the return shape.
    """
    if max_dim is not None and (isinstance(max_dim, bool) or int(max_dim) < 1):
        raise OpError(f"max_dim must be a positive integer (pixels); got {max_dim!r}")
    from_page, to_page = self._anchor_page_span(anchor)
    rendered = _snapshot.render(
        self._doc,
        from_page=from_page,
        to_page=to_page,
        dpi=dpi,
        max_dim=max_dim,
        markup=_markup_flag(markup),
    )
    return _snapshot.build_snapshots(rendered, out)

find

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

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

mode selects the matcher:

  • fuzzy (default): whitespace- and Unicode-normalized (NFKC, smart quotes, dashes, NBSP) β€” forgiving of cosmetic drift.
  • literal: exact substring, no folding.
  • regex: text is a Python regular expression.

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/_editing.py
def find(
    self,
    text: str,
    *,
    scope: Anchor | None = None,
    mode: str = "fuzzy",
) -> list[dict[str, Any]]:
    """Locate every occurrence of `text` within `scope` (or the whole doc).

    `mode` selects the matcher:

    - `fuzzy` (default): whitespace- and Unicode-normalized (NFKC, smart
      quotes, dashes, NBSP) β€” forgiving of cosmetic drift.
    - `literal`: exact substring, no folding.
    - `regex`: `text` is a Python regular expression.

    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, mode=mode):
            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, mode: str = 'fuzzy', required: bool = True) -> list[dict[str, Any]]

Plain-text replace. See find() for matching semantics.

Parameters:

Name Type Description Default
find str

the text to look for.

required
replace str

the replacement text. In regex mode it may carry backreferences (\1) that expand per match.

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
mode str

fuzzy (default) / literal / regex β€” see find().

'fuzzy'
required bool

when False, zero matches returns [] instead of raising. Used by idempotent batch autofixes (the linter) where an earlier fix may already have removed an overlapping match in the same pass.

True

Raises:

Type Description
AnchorNotFoundError

zero matches and required (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/_editing.py
def find_replace(
    self,
    find: str,
    replace: str,
    *,
    scope: Anchor | None = None,
    all: bool = False,
    occurrence: int | None = None,
    mode: str = "fuzzy",
    required: bool = True,
) -> list[dict[str, Any]]:
    """Plain-text replace. See `find()` for matching semantics.

    Args:
        find: the text to look for.
        replace: the replacement text. In `regex` mode it may carry
            backreferences (`\\1`) that expand per match.
        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.
        mode: `fuzzy` (default) / `literal` / `regex` β€” see `find()`.
        required: when `False`, zero matches returns `[]` instead of raising.
            Used by idempotent batch autofixes (the linter) where an earlier
            fix may already have removed an overlapping match in the same pass.

    Raises:
        AnchorNotFoundError: zero matches and `required` (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.
    """
    # Only `regex` needs the replacement at match time (per-hit backreference
    # expansion); `fuzzy`/`literal` apply the single `replace` to every match.
    repl_template = replace if mode == "regex" else None
    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,
            # Private to the write loop; stripped from the returned payloads.
            "_replacement": m.replacement if m.replacement is not None else replace,
        }
        for base, haystack in segments
        for m in _findreplace.find_matches(haystack, find, mode=mode, replacement=repl_template)
    ]
    if not match_payloads:
        if not required:
            return []
        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 = m["_replacement"]
    # Drop the internal replacement key; the documented payload is 4 keys.
    return [{k: v for k, v in m.items() if k != "_replacement"} for m in 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/_editing.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/_editing.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/_editing.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/_editing.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

delete_paragraph

delete_paragraph(anchor: str | Anchor) -> None

Delete the paragraph(s) at anchor β€” text and the trailing mark.

anchor is an anchor id (para:N, heading:N) or an Anchor; the whole paragraph is removed, mark included, so the surrounding text closes up with no empty line left behind (the gap set_text("") would leave). A range anchor that spans several paragraphs removes all of them.

Word keeps a mandatory empty paragraph at the very end of the document: deleting the last paragraph clears its content but leaves that final mark (its range otherwise straddles the undeletable terminal mark and raises COM 0x80020009). Wrap in doc.edit(...) for atomic undo.

Source code in src/wordlive/_document/_editing.py
def delete_paragraph(self, anchor: str | Anchor) -> None:
    """Delete the paragraph(s) at `anchor` β€” text *and* the trailing mark.

    `anchor` is an anchor id (`para:N`, `heading:N`) or an `Anchor`; the
    whole paragraph is removed, mark included, so the surrounding text closes
    up with no empty line left behind (the gap `set_text("")` would leave).
    A range anchor that spans several paragraphs removes all of them.

    Word keeps a mandatory empty paragraph at the very end of the document:
    deleting the *last* paragraph clears its content but leaves that final
    mark (its range otherwise straddles the undeletable terminal mark and
    raises COM `0x80020009`). Wrap in `doc.edit(...)` for atomic undo.
    """
    obj = self.anchor_by_id(anchor) if isinstance(anchor, str) else anchor
    with _com.translate_com_errors():
        rng = obj.com
        start, end = int(rng.Start), int(rng.End)
        doc_end = int(self._doc.Content.End)
        # Never let the range reach Word's undeletable final paragraph mark.
        end = min(end, doc_end - 1)
        if end <= start:
            return
        self._doc.Range(start, end).Delete()

update_fields

update_fields() -> None

Refresh the document's fields β€” recompute every { PAGE }, { REF }, etc.

Fields (page numbers, cross-references, dates, a TOC) cache their last rendered value; after edits that change them, this recomputes the document's main-story fields via Fields.Update(). The clean "make the numbers right again" verb β€” pair it with insert_field. A snapshot also forces repagination, so { PAGE }/{ NUMPAGES } in headers and footers settle without this. Wrap in doc.edit(...) for atomic undo.

Scope is the main text story; refreshing fields that live only in headers/footers or other stories is deferred.

Source code in src/wordlive/_document/_editing.py
def update_fields(self) -> None:
    """Refresh the document's fields β€” recompute every `{ PAGE }`, `{ REF }`, etc.

    Fields (page numbers, cross-references, dates, a TOC) cache their last
    rendered value; after edits that change them, this recomputes the
    document's main-story fields via `Fields.Update()`. The clean "make the
    numbers right again" verb β€” pair it with
    [`insert_field`][wordlive.Anchor.insert_field]. A
    [`snapshot`][wordlive.Document.snapshot] also forces repagination, so
    `{ PAGE }`/`{ NUMPAGES }` in headers and footers settle without this.
    Wrap in `doc.edit(...)` for atomic undo.

    Scope is the main text story; refreshing fields that live only in
    headers/footers or other stories is deferred.
    """
    with _com.translate_com_errors():
        self._doc.Fields.Update()

wordlive.DocumentCollection

DocumentCollection(word: Word)

Indexable view over open documents.

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

wordlive.WatermarkInfo dataclass

WatermarkInfo(text: str, sections: list[int])

The text watermark stamped behind a document's pages (doc.watermark()).

text is the watermark's text (Word stamps the same text into every section's header story, so this is the common value); sections lists the 1-based section indices that carry it. The read mirror of set_watermark / remove_watermark.

Anchors, editing & formatting

The anchor model, the edit scope, and the formatting / list / style verbs that run on any anchor.

Anchors

Every anchor type inherits apply_style(name), format_paragraph(...), format_run(...), set_shading(...), set_borders(...), add_tab_stop(...), insert_paragraph_before/after(...), insert_block(...), insert_image(...), insert_text_box(...), insert_table(...), insert_break(...), insert_field(...), insert_footnote(...), insert_endnote(...), insert_toc(...), insert_table_of_figures(...), mark_index_entry(...), insert_index(...), insert_citation(...), insert_bibliography(...), mark_citation(...), insert_table_of_authorities(...), insert_content_control(...), link_to(...), insert_cross_reference(...), insert_caption(...), the revision-aware reads (text_final, text_original, revision_segments() β€” see Track Changes), 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. The read mirror is read_image(), which returns (bytes, mime_type) for the single picture in the anchor's range β€” see Images. insert_block(items, where="after") inserts a contiguous run of styled paragraphs in one op (each item a plain string or {text | runs, style?}, where text carries **bold**/*italic*/`code` markdown and runs is the structured [{text, bold?, italic?, underline?, code?, style?}] form) and returns a RangeAnchor spanning the block β€” feed it straight into apply_list to bullet the section. Two opinionated macros build on it: insert_section(heading, body, *, level=1, where="after") places a Heading {level} paragraph plus its body (the same items shape, or a bare string) in one op, and insert_markdown(md, *, where="after") maps a constrained-Markdown subset β€” #/##/### headings, -/* bullets, 1. numbers, blank-line paragraphs, inline **bold**/*italic*/`code` (a monospace run) β€” to real Word structure (not CommonMark: no code fences, nested lists, or tables in v1). On a blank document these structural inserts reuse the lone empty paragraph; append_paragraph promises a new final paragraph and so leaves it stranded above your content. Headings additionally have replace_section_body(body, *, markdown=False), which clears the body under a heading (up to the next same-or-higher heading) and inserts a replacement, keeping the heading β€” the "rewrite section X" workflow. All three return the new content's RangeAnchor.

a = doc.headings["Methods"]
a.insert_section("Results", ["We saw a **20%** lift.", "Caveats apply."], level=2)
a.insert_markdown("# Plan\n\nKick-off.\n\n- scope it\n- staff it")
a.replace_section_body("Updated findings.\n\n- point one\n- point two", markdown=True)

insert_table(rows, cols, …) creates a new table at the anchor and returns its Table (append at the end with Document.add_table); pass data as a 2-D array or as records (a list of dicts whose keys become a header row), and rows/cols are inferred from data when omitted. 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. format_paragraph also takes line_spacing (the leading within a paragraph: a multiple like 1.5, the keywords "single"/"1.5"/"double", or an exact length such as "14pt") and the pagination controls keep_together, keep_with_next, and widow_control (tri-state booleans) for clean multi-page layout. format_run(...) sets character formatting (bold/italic/underline, font, size, color, highlight, sub/superscript, caps, spacing) β€” the run-level layer, ideal with a range:START-END anchor to style a phrase. format_info() (no args) is the read mirror of format_paragraph / format_run: it returns {anchor_id, style, paragraph, font}, where style is the applied paragraph style's name and paragraph / font each map a field name to {value, style, override} β€” the effective value, the value the applied style contributes (style), and override=True when the two differ (a direct override). font also carries a mixed key listing the font fields that read wdUndefined because they vary across the range's runs (their value is None, never flagged as an override). Lengths are points (floats); color is #RRGGBB or "auto"; alignment is left/center/right/justify; line_spacing is single/1.5/double, "1.15" (a multiple), "14pt" (exactly), or "at_least:14pt". The paragraph fields are alignment, left_indent, right_indent, first_line_indent, space_before, space_after, line_spacing, page_break_before, keep_together, keep_with_next, widow_control; the font fields are name, size, bold, italic, underline, strikethrough, color, subscript, superscript, small_caps, all_caps, spacing, hidden, and highlight (a keyword β€” "yellow", … β€” or "none"; it lives on the range, not the style, so it's effective-only with style: null). It's a pure read β€” diff the override flags to see what a paragraph carries beyond its style (the input doc.regularize() writes back). set_shading, set_borders, and add_tab_stop add range/cell fill, borders, and tab stops; colours accept a name, hex, or (r, g, b) and sizes/positions accept points or a unit string ("12pt", "1in"). drop_cap(lines=3, position="dropped"|"margin"|"none", …) turns the first letter of the anchor's paragraph into a real Word drop cap (the editorial oversized initial; position="none" removes one). insert_field(kind, ...) drops a self-updating field ("page", "numpages", "date", …, or "field" + a raw code) β€” pair it with a footer for page numbers and refresh with Document.update_fields(). insert_footnote(text) / insert_endnote(text) attach a note to the anchor's range and return a Footnote / Endnote (addressed footnote:N / endnote:N); insert_toc(levels=(1, 3), …) inserts a table of contents and returns a Toc, insert_table_of_figures(label= "Figure", …) lists the captions of one label as a TableOfFigures, and mark_index_entry(entry, …) + insert_index(…) mark and build a back-of-book Index. insert_citation(tag, …) cites a registered source and insert_bibliography(…) builds the works-cited block, while mark_citation( long_citation, …) + insert_table_of_authorities(…) mark and build a TableOfAuthorities β€” see Footnotes, endnotes & TOC. insert_content_control(kind="rich_text", …) wraps the anchor's range in a new content control (see Anchoring & linking). link_to(address=… | bookmark=…) makes the anchor a hyperlink, insert_cross_reference(target, …) references another anchor, and insert_caption(label, …) adds a numbered caption β€” see Anchoring & linking. Every anchor also has snapshot(...), which renders the page(s) it sits on to PNG (a heading expands to its whole section) β€” see Snapshots. location() is the non-visual companion: it returns {page, end_page, line, column, in_table} β€” where the anchor sits in the laid-out document (its page span, and its first character's line/column) β€” so an agent can answer "what page is this on" without a snapshot. It repaginates first (content-neutral; selection and view untouched), so page numbers are print-layout truth.

wordlive.Anchor

Anchor(doc: Document, name: str)

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/_anchor_core.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.

text_final property

text_final: str

The anchor's text as if every tracked change in it were accepted.

Inserted runs stay, deleted runs drop β€” the after-the-edits view. Equal to text when nothing tracked touches the range. The mirror is text_original; the per-segment breakdown is revision_segments.

text_original property

text_original: str

The anchor's text as if every tracked change in it were rejected.

Deleted runs stay, inserted runs drop β€” the before-the-edits view. The mirror of text_final.

set_text abstractmethod

set_text(text: str) -> None

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

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

revision_segments

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

The anchor's text split into tracked-change segments (revision-aware read).

Returns [{text, change}, …] in document order, where change is "insert", "delete", or None (unchanged). Word's text read shows the final view (inserted runs present, deleted runs gone); this also surfaces the deleted runs, so you can see both sides of a tracked edit. text_final and text_original are the two flattened views. The structured, whole-document counterpart is doc.revisions.

Source code in src/wordlive/_anchors/_anchor_read.py
def revision_segments(self) -> list[dict[str, Any]]:
    """The anchor's text split into tracked-change segments (revision-aware read).

    Returns `[{text, change}, …]` in document order, where `change` is
    ``"insert"``, ``"delete"``, or ``None`` (unchanged). Word's `text` read
    shows the *final* view (inserted runs present, deleted runs gone); this
    also surfaces the deleted runs, so you can see both sides of a tracked
    edit. [`text_final`][wordlive.Anchor.text_final] and
    [`text_original`][wordlive.Anchor.text_original] are the two flattened
    views. The structured, whole-document counterpart is `doc.revisions`.
    """
    with _com.translate_com_errors():
        rng = self._range()
        start, end = int(rng.Start), int(rng.End)
        final_text = range_text(rng)
    return _revisions.segment_runs(final_text, start, self._revision_runs(start, end))

snapshot

snapshot(out: str | Path | None = None, *, dpi: int = 150, max_dim: int | None = None) -> 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. max_dim caps each page's long edge in pixels (for a cheaper render). Sugar for Document.snapshot_anchor; see it for the full semantics. Requires the snapshot extra (PyMuPDF).

Source code in src/wordlive/_anchors/_anchor_read.py
def snapshot(
    self, out: str | Path | None = None, *, dpi: int = 150, max_dim: int | None = None
) -> 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. `max_dim` caps each page's long edge in pixels (for
    a cheaper render). 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._as_anchor, out, dpi=dpi, max_dim=max_dim)

read_image

read_image() -> tuple[bytes, str]

Extract the image embedded in this anchor's range as (bytes, mime_type).

The read side of the image story β€” pull an embedded picture's original bytes back out (e.g. to hand to a vision model), the counterpart to insert_image. The range must contain exactly one picture: an image:N anchor (or any single-image text anchor) reads cleanly, while a range with no image β€” or more than one β€” raises ImageSourceError. bytes is the picture's raw encoded data (PNG/JPEG/…); mime_type is its content type ("image/png", "image/jpeg", …). Discover what's there first with doc.images. Read-only β€” nothing is mutated.

Source code in src/wordlive/_anchors/_anchor_read.py
def read_image(self) -> tuple[bytes, str]:
    """Extract the image embedded in this anchor's range as `(bytes, mime_type)`.

    The read side of the image story β€” pull an embedded picture's original
    bytes back out (e.g. to hand to a vision model), the counterpart to
    [`insert_image`][wordlive.Anchor.insert_image]. The range must contain
    exactly one picture: an [`image:N`][wordlive.ImageAnchor] anchor (or any
    single-image text anchor) reads cleanly, while a range with no image β€” or
    more than one β€” raises `ImageSourceError`. `bytes` is the picture's raw
    encoded data (PNG/JPEG/…); `mime_type` is its content type
    (``"image/png"``, ``"image/jpeg"``, …). Discover what's there first with
    [`doc.images`][wordlive.Document.images]. Read-only β€” nothing is mutated.
    """
    with _com.translate_com_errors():
        return _images.read_image_from_range(self._range())

location

location() -> dict[str, Any]

Where this anchor sits in the laid-out document β€” a pure read.

Returns {page, end_page, line, column, in_table}:

  • page / end_page β€” the 1-based pages the anchor's first and last characters fall on (equal for a collapsed/single-line anchor); the pair is the anchor's page span, so a section/table/image that straddles a page boundary reports both. page is what answers "what page is this on"; scan paragraphs and watch page step up to find "which paragraph starts page 2".
  • line / column β€” the first character's 1-based line and column in the page's text grid (Range.Information).
  • in_table β€” whether the anchor sits inside a table.

Page/line numbers are only meaningful in print layout, so the document is repaginated first (content-neutral β€” it touches neither the user's selection, scroll, nor view), mirroring the guarantee a snapshot gives. No politeness concern: this mutates nothing β€” the document's Saved state is snapshotted and restored around the repaginate, which would otherwise flip Word's dirty bit.

Source code in src/wordlive/_anchors/_anchor_read.py
def location(self) -> dict[str, Any]:
    """Where this anchor sits in the laid-out document β€” a pure read.

    Returns `{page, end_page, line, column, in_table}`:

    - `page` / `end_page` β€” the 1-based pages the anchor's **first** and
      **last** characters fall on (equal for a collapsed/single-line anchor);
      the pair is the anchor's *page span*, so a section/table/image that
      straddles a page boundary reports both. `page` is what answers "what
      page is this on"; scan `paragraphs` and watch `page` step up to find
      "which paragraph starts page 2".
    - `line` / `column` β€” the first character's 1-based line and column in
      the page's text grid (`Range.Information`).
    - `in_table` β€” whether the anchor sits inside a table.

    Page/line numbers are only meaningful in print layout, so the document
    is **repaginated first** (content-neutral β€” it touches neither the
    user's selection, scroll, nor view), mirroring the guarantee a
    `snapshot` gives. No politeness concern: this mutates nothing β€” the
    document's `Saved` state is snapshotted and restored around the
    repaginate, which would otherwise flip Word's dirty bit.
    """
    with _com.translate_com_errors(), _com.preserve_saved(self._doc.com):
        rng = self._range()
        self._doc.com.Repaginate()
        start, end = int(rng.Start), int(rng.End)
        doc_com = self._doc.com
        head = doc_com.Range(start, start)
        tail = doc_com.Range(end, end)
        return {
            "page": int(head.Information(int(WdInformation.ACTIVE_END_PAGE_NUMBER))),
            "end_page": int(tail.Information(int(WdInformation.ACTIVE_END_PAGE_NUMBER))),
            "line": int(head.Information(int(WdInformation.FIRST_CHARACTER_LINE_NUMBER))),
            "column": int(head.Information(int(WdInformation.FIRST_CHARACTER_COLUMN_NUMBER))),
            "in_table": bool(rng.Information(int(WdInformation.WITH_IN_TABLE))),
        }

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

apply_list_format

apply_list_format(levels: list[dict[str, Any]], *, continue_previous: bool = False) -> None

Author a custom multi-level list template and apply it here.

The richer counterpart to apply_list (which only applies a gallery default): levels is a 1-based list of per-level specs that defines the marker, indentation, and marker font of each list level. Each spec is a dict; all keys are optional except a bullet level's glyph:

  • kind β€” "number" (default) or "bullet".
  • format β€” for a number level, the marker template ("%1.", "%1)", "%1.%2"; %N references level N's number), default "%{level}."; for a bullet level, the glyph (or pass bullet).
  • style β€” a number level's scheme: "arabic", "upper-roman", "lower-roman", "upper-letter", "lower-letter", "ordinal", … .
  • bullet / font β€” a bullet level's glyph and marker font (default "Symbol"); font also sets a number level's marker font.
  • start_at β€” a number level's first value.
  • number_position / text_position β€” the marker and text indents (points or a length string like "0.5in").
  • trailing β€” what follows the marker: "tab" / "space" / "none".
  • alignment β€” the marker's alignment: "left" / "center" / "right".
  • bold / italic / color β€” the marker font's styling.

More than one level mints an outline template (levels beyond those given keep Word's defaults). read_list_levels() is the read mirror. Wrap in doc.edit(...) for atomic undo; a bad spec raises OpError.

Source code in src/wordlive/_anchors/_anchor_lists.py
def apply_list_format(
    self, levels: list[dict[str, Any]], *, continue_previous: bool = False
) -> None:
    """Author a **custom** multi-level list template and apply it here.

    The richer counterpart to `apply_list` (which only applies a gallery
    default): `levels` is a 1-based list of per-level specs that defines the
    marker, indentation, and marker font of each list level. Each spec is a
    dict; all keys are optional except a bullet level's glyph:

    - `kind` β€” `"number"` (default) or `"bullet"`.
    - `format` β€” for a number level, the marker template (`"%1."`, `"%1)"`,
      `"%1.%2"`; `%N` references level N's number), default `"%{level}."`;
      for a bullet level, the glyph (or pass `bullet`).
    - `style` β€” a number level's scheme: `"arabic"`, `"upper-roman"`,
      `"lower-roman"`, `"upper-letter"`, `"lower-letter"`, `"ordinal"`, … .
    - `bullet` / `font` β€” a bullet level's glyph and marker font (default
      `"Symbol"`); `font` also sets a number level's marker font.
    - `start_at` β€” a number level's first value.
    - `number_position` / `text_position` β€” the marker and text indents
      (points or a length string like `"0.5in"`).
    - `trailing` β€” what follows the marker: `"tab"` / `"space"` / `"none"`.
    - `alignment` β€” the marker's alignment: `"left"` / `"center"` / `"right"`.
    - `bold` / `italic` / `color` β€” the marker font's styling.

    More than one level mints an outline template (levels beyond those given
    keep Word's defaults). `read_list_levels()` is the read mirror. Wrap in
    `doc.edit(...)` for atomic undo; a bad spec raises `OpError`.
    """
    with _com.translate_com_errors():
        _lists.apply_list_format(
            self._doc.com, self._range(), levels, continue_previous=continue_previous
        )

read_list_levels

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

The per-level format of the list this anchor sits in β€” a pure read.

Returns one {level, kind, format, number_style, style, trailing, number_position, text_position, font} dict per level of the applied ListTemplate, or [] if the anchor carries no list (number_style is the raw WdListNumberStyle int). The read mirror of apply_list_format.

Source code in src/wordlive/_anchors/_anchor_lists.py
def read_list_levels(self) -> list[dict[str, Any]]:
    """The per-level format of the list this anchor sits in β€” a pure read.

    Returns one `{level, kind, format, number_style, style, trailing,
    number_position, text_position, font}` dict per level of the applied
    `ListTemplate`, or `[]` if the anchor carries no list (`number_style` is
    the raw `WdListNumberStyle` int). The read mirror of `apply_list_format`.
    """
    with _com.translate_com_errors():
        return _lists.read_list_levels(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/_anchor_lists.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/_anchor_lists.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/_anchor_lists.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()

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/_anchor_format.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, line_spacing: Any = None, page_break_before: bool | None = None, keep_together: bool | None = None, keep_with_next: bool | None = None, widow_control: 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").

line_spacing sets the leading between lines within the paragraph (distinct from space_before/space_after, which space paragraphs apart). It accepts a number β€” a multiple of single spacing (1 single, 1.5, 2 double) β€” one of the keywords "single"/"1.5"/ "double", or an exact length string ("14pt", "1.5cm") for a fixed line height.

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. Indents/spacing accept a number (points) or a unit string ("0.5in").

The remaining flags are Word's pagination controls (all tri-state β€” True/False set, None leaves untouched), for clean multi-page layout: keep_together keeps every line of the paragraph on one page; keep_with_next keeps it on the same page as the following paragraph (e.g. a heading with its first body line); widow_control prevents a lone first/last line stranded at the bottom/top of a page (on by default in Word).

Source code in src/wordlive/_anchors/_anchor_format.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,
    line_spacing: Any = None,
    page_break_before: bool | None = None,
    keep_together: bool | None = None,
    keep_with_next: bool | None = None,
    widow_control: 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"`).

    `line_spacing` sets the leading between lines *within* the paragraph
    (distinct from `space_before`/`space_after`, which space paragraphs
    apart). It accepts a **number** β€” a multiple of single spacing (`1`
    single, `1.5`, `2` double) β€” one of the keywords `"single"`/`"1.5"`/
    `"double"`, or an **exact length string** (`"14pt"`, `"1.5cm"`) for a
    fixed line height.

    `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. Indents/spacing accept a number (points) or
    a unit string (`"0.5in"`).

    The remaining flags are Word's *pagination* controls (all tri-state β€”
    `True`/`False` set, `None` leaves untouched), for clean multi-page
    layout: `keep_together` keeps every line of the paragraph on one page;
    `keep_with_next` keeps it on the same page as the following paragraph
    (e.g. a heading with its first body line); `widow_control` prevents a
    lone first/last line stranded at the bottom/top of a page (on by default
    in Word).
    """
    try:
        with _com.translate_com_errors():
            _apply_paragraph_format(
                self._range().ParagraphFormat,
                alignment=alignment,
                left_indent=left_indent,
                right_indent=right_indent,
                first_line_indent=first_line_indent,
                space_before=space_before,
                space_after=space_after,
                line_spacing=line_spacing,
                page_break_before=page_break_before,
                keep_together=keep_together,
                keep_with_next=keep_with_next,
                widow_control=widow_control,
            )
    except (ValueError, TypeError) as e:
        raise OpError(str(e)) from e

drop_cap

drop_cap(lines: int = 3, *, position: str = 'dropped', distance: Any = 0.0, font: str | None = None) -> None

Turn the first letter of this anchor's paragraph into a drop cap.

The editorial oversized initial β€” a real Word DropCap, not a faked big-font run, so it reflows and re-wraps the body text around it natively. Applies to the first paragraph of the anchor's range.

position is "dropped" (the default β€” the letter sits into the text, the common magazine style), "margin" (it hangs out in the left margin), or "none" (remove an existing drop cap; lines/distance/ font are then ignored). lines is how many lines tall the letter is (Word's default is 3). distance is the gap between the letter and the body text, in points (or a unit string like "2pt"). font optionally sets the dropped letter's font family.

Word rejects a drop cap on an empty paragraph (there's no letter to drop) β€” that surfaces as a ComError. Wrap in doc.edit(...) for atomic undo. Raises OpError for an unknown position or a bad distance.

Source code in src/wordlive/_anchors/_anchor_format.py
def drop_cap(
    self,
    lines: int = 3,
    *,
    position: str = "dropped",
    distance: Any = 0.0,
    font: str | None = None,
) -> None:
    """Turn the first letter of this anchor's paragraph into a drop cap.

    The editorial oversized initial β€” a real Word `DropCap`, not a faked
    big-font run, so it reflows and re-wraps the body text around it
    natively. Applies to the **first paragraph** of the anchor's range.

    `position` is ``"dropped"`` (the default β€” the letter sits *into* the
    text, the common magazine style), ``"margin"`` (it hangs out in the left
    margin), or ``"none"`` (remove an existing drop cap; `lines`/`distance`/
    `font` are then ignored). `lines` is how many lines tall the letter is
    (Word's default is 3). `distance` is the gap between the letter and the
    body text, in points (or a unit string like ``"2pt"``). `font` optionally
    sets the dropped letter's font family.

    Word rejects a drop cap on an **empty** paragraph (there's no letter to
    drop) β€” that surfaces as a `ComError`. Wrap in `doc.edit(...)` for atomic
    undo. Raises `OpError` for an unknown `position` or a bad `distance`.
    """
    try:
        pos = _coerce_named(position, _DROP_POSITIONS, "drop-cap position")
        dist = to_points(distance)
        if not isinstance(lines, int) or isinstance(lines, bool) or lines < 1:
            raise ValueError(f"lines must be a positive integer; got {lines!r}")
        with _com.translate_com_errors():
            dc = self._range().Paragraphs(1).DropCap
            # Enable the cap first: Word resets LinesToDrop/DistanceFromText/
            # FontName to its defaults when Position changes, so the geometry
            # must be written *after* the position or it's silently dropped.
            dc.Position = pos
            if pos == int(WdDropPosition.NONE):
                return
            dc.LinesToDrop = lines
            dc.DistanceFromText = dist
            if font is not None:
                dc.FontName = str(font)
    except (ValueError, TypeError) as e:
        raise OpError(str(e)) from e

format_run

format_run(*, bold: bool | None = None, italic: bool | None = None, underline: bool | None = None, strikethrough: bool | None = None, font: str | None = None, size: Any = None, color: Any = None, highlight: Any = None, subscript: bool | None = None, superscript: bool | None = None, small_caps: bool | None = None, all_caps: bool | None = None, spacing: Any = None) -> None

Set character-formatting (run-level) properties on this anchor's range.

Direct formatting β€” the bold this phrase layer, distinct from apply_style (named styles) and format_paragraph (paragraph-scope). Pairs naturally with range:START-END to style a sub-paragraph span.

All kwargs are optional and tri-state; only the ones explicitly passed are written (None leaves the property untouched). bold/italic/ underline/strikethrough/subscript/superscript/small_caps/ all_caps are booleans. font is a family name; size and spacing accept a number (points) or a unit string ("12pt", "1.5mm"). color accepts a named colour, hex ("#FF0000"), or (r, g, b). highlight is a named text-highlight colour ("yellow", "green", …, or "none"/"auto" to clear it) β€” a palette index, not an RGB.

Bad colour/length/highlight input raises OpError (bad-input). Wrap in doc.edit(...) for atomic undo.

Source code in src/wordlive/_anchors/_anchor_format.py
def format_run(
    self,
    *,
    bold: bool | None = None,
    italic: bool | None = None,
    underline: bool | None = None,
    strikethrough: bool | None = None,
    font: str | None = None,
    size: Any = None,
    color: Any = None,
    highlight: Any = None,
    subscript: bool | None = None,
    superscript: bool | None = None,
    small_caps: bool | None = None,
    all_caps: bool | None = None,
    spacing: Any = None,
) -> None:
    """Set character-formatting (run-level) properties on this anchor's range.

    Direct formatting β€” the *bold this phrase* layer, distinct from
    [`apply_style`][wordlive.Anchor.apply_style] (named styles) and
    [`format_paragraph`][wordlive.Anchor.format_paragraph] (paragraph-scope).
    Pairs naturally with `range:START-END` to style a sub-paragraph span.

    All kwargs are optional and tri-state; only the ones explicitly passed
    are written (`None` leaves the property untouched). `bold`/`italic`/
    `underline`/`strikethrough`/`subscript`/`superscript`/`small_caps`/
    `all_caps` are booleans. `font` is a family name; `size` and `spacing`
    accept a number (points) or a unit string (`"12pt"`, `"1.5mm"`).
    `color` accepts a named colour, hex (`"#FF0000"`), or `(r, g, b)`.
    `highlight` is a named text-highlight colour (`"yellow"`, `"green"`, …,
    or `"none"`/`"auto"` to clear it) β€” a palette index, *not* an RGB.

    Bad colour/length/highlight input raises `OpError` (bad-input). Wrap in
    `doc.edit(...)` for atomic undo.
    """
    try:
        with _com.translate_com_errors():
            rng = self._range()
            _apply_font(
                rng.Font,
                bold=bold,
                italic=italic,
                underline=underline,
                strikethrough=strikethrough,
                font_name=font,
                size=size,
                color=color,
                subscript=subscript,
                superscript=superscript,
                small_caps=small_caps,
                all_caps=all_caps,
                spacing=spacing,
            )
            if highlight is not None:
                rng.HighlightColorIndex = _coerce_highlight(highlight)
    except (ValueError, TypeError) as e:
        raise OpError(str(e)) from e

format_info

format_info() -> dict[str, Any]

The effective paragraph + character formatting on this anchor β€” the read mirror of format_paragraph and format_run. Pure read.

Returns {anchor_id, style, paragraph, font}. style is the applied paragraph style's name. paragraph and font carry one entry per field, each {value, style, override}:

  • value β€” the effective value (what's actually rendered);
  • style β€” the value the applied style would give on its own;
  • override β€” True when value != style, i.e. a direct override sits on top of the style (the signal the consistency linter rules act on). A mixed field (value is None) is never flagged as an override.

font.mixed lists the character fields that read wdUndefined because they vary across the range's runs (e.g. a heading with one bold word) β€” those carry value: null rather than a bogus number. Lengths are in points; color is #RRGGBB (or "auto"); alignment/line_spacing use the same keywords the write verbs accept. font.hidden flags Word's hidden-text attribute. font.highlight is a highlight keyword ("yellow", … or "none"); it lives on the range, not the style, so it's effective-only β€” style is always null and override just means a highlight is present.

The field vocabulary is identical to the write side, so a value read here can be written straight back through format_paragraph/format_run.

Source code in src/wordlive/_anchors/_anchor_format.py
def format_info(self) -> dict[str, Any]:
    """The effective paragraph + character formatting on this anchor β€” the
    read mirror of [`format_paragraph`][wordlive.Anchor.format_paragraph] and
    [`format_run`][wordlive.Anchor.format_run]. Pure read.

    Returns `{anchor_id, style, paragraph, font}`. `style` is the applied
    paragraph style's name. `paragraph` and `font` carry one entry per field,
    each `{value, style, override}`:

    - `value` β€” the *effective* value (what's actually rendered);
    - `style` β€” the value the applied **style** would give on its own;
    - `override` β€” `True` when `value != style`, i.e. a **direct override**
      sits on top of the style (the signal the consistency linter rules act
      on). A mixed field (`value is None`) is never flagged as an override.

    `font.mixed` lists the character fields that read `wdUndefined` because
    they vary across the range's runs (e.g. a heading with one bold word) β€”
    those carry `value: null` rather than a bogus number. Lengths are in
    points; `color` is `#RRGGBB` (or `"auto"`); `alignment`/`line_spacing`
    use the same keywords the write verbs accept. `font.hidden` flags Word's
    hidden-text attribute. `font.highlight` is a highlight keyword (`"yellow"`,
    … or `"none"`); it lives on the range, not the style, so it's
    effective-only β€” `style` is always `null` and `override` just means a
    highlight is present.

    The field vocabulary is identical to the write side, so a value read here
    can be written straight back through `format_paragraph`/`format_run`.
    """
    with _com.translate_com_errors():
        rng = self._range()
        style = rng.ParagraphStyle
        style_name = str(style.NameLocal)
        eff_para = _read_paragraph_format(rng.ParagraphFormat)
        eff_font, mixed = _read_font(rng.Font)
        highlight = _read_highlight(rng.HighlightColorIndex)
        sty_para, sty_font = _style_baseline(style, style_name)

    def _annotate(eff: dict[str, Any], sty: dict[str, Any]) -> dict[str, Any]:
        return {
            key: {
                "value": eff[key],
                "style": sty[key],
                "override": eff[key] is not None and eff[key] != sty[key],
            }
            for key in eff
        }

    font = _annotate(eff_font, sty_font)
    # Highlight lives on the Range, not the Font, and a style never carries it
    # (see `_STYLE_RUN_FIELDS`), so it's effective-only: no style baseline, and
    # an "override" simply means a highlight is present. A mixed read (some runs
    # highlighted) surfaces via `mixed`, like the other character fields.
    if highlight is None:
        mixed.append("highlight")
    font["mixed"] = mixed
    font["highlight"] = {
        "value": highlight,
        "style": None,
        "override": highlight is not None and highlight != "none",
    }
    return {
        "anchor_id": self.anchor_id,
        "style": style_name,
        "paragraph": _annotate(eff_para, sty_para),
        "font": font,
    }

set_shading

set_shading(*, fill: Any = None, pattern: Any = None) -> None

Set the background (fill) shading of this anchor's range.

fill is a named colour, hex ("#FFFF00"), or (r, g, b) β€” applied to Range.Shading.BackgroundPatternColor. Because a Cell is an Anchor, this is also how you shade a table cell. pattern (a shading pattern/ texture) is accepted for forward-compatibility but not yet applied β€” deferred. Bad colour input raises OpError. Wrap in doc.edit(...).

Source code in src/wordlive/_anchors/_anchor_format.py
def set_shading(self, *, fill: Any = None, pattern: Any = None) -> None:
    """Set the background (fill) shading of this anchor's range.

    `fill` is a named colour, hex (`"#FFFF00"`), or `(r, g, b)` β€” applied to
    `Range.Shading.BackgroundPatternColor`. Because a `Cell` is an `Anchor`,
    this is also how you shade a table cell. `pattern` (a shading pattern/
    texture) is accepted for forward-compatibility but not yet applied β€”
    deferred. Bad colour input raises `OpError`. Wrap in `doc.edit(...)`.
    """
    try:
        with _com.translate_com_errors():
            if fill is not None:
                self._range().Shading.BackgroundPatternColor = to_bgr(fill)
    except (ValueError, TypeError) as e:
        raise OpError(str(e)) from e

set_borders

set_borders(*, sides: Any = 'all', style: Any = 'single', weight: Any = 0.5, color: Any = None) -> None

Draw borders on this anchor's range (or cell β€” a Cell is an Anchor).

sides is "all"/"box" (the default β€” four outer edges), a single edge ("top"/"bottom"/"left"/"right"), an interior gridline ("horizontal"/"vertical", for multi-cell ranges), or a list of those. style is a line style ("single", "double", "dot", "dash", …, or "none" to remove). weight is the line width in points, snapped to Word's discrete set (0.25/0.5/0.75/1/1.5/2.25/3 pt). color is an optional border colour (name/hex/RGB).

This sets per-range / per-cell borders. Page borders (Section.Borders) are out of scope; whole-table borders (the entire grid in one call, including interior gridlines) go through Table.set_borders / the table set-borders verb. Bad input raises OpError. Wrap in doc.edit(...).

Source code in src/wordlive/_anchors/_anchor_format.py
def set_borders(
    self,
    *,
    sides: Any = "all",
    style: Any = "single",
    weight: Any = 0.5,
    color: Any = None,
) -> None:
    """Draw borders on this anchor's range (or cell β€” a `Cell` is an `Anchor`).

    `sides` is `"all"`/`"box"` (the default β€” four outer edges), a single
    edge (`"top"`/`"bottom"`/`"left"`/`"right"`), an interior gridline
    (`"horizontal"`/`"vertical"`, for multi-cell ranges), or a list of those.
    `style` is a line style (`"single"`, `"double"`, `"dot"`, `"dash"`, …, or
    `"none"` to remove). `weight` is the line width in points, snapped to
    Word's discrete set (0.25/0.5/0.75/1/1.5/2.25/3 pt). `color` is an
    optional border colour (name/hex/RGB).

    This sets per-range / per-cell borders. Page borders
    (`Section.Borders`) are out of scope; whole-table borders (the entire
    grid in one call, including interior gridlines) go through
    [`Table.set_borders`][wordlive.Table.set_borders] / the `table
    set-borders` verb. Bad input raises `OpError`. Wrap in `doc.edit(...)`.
    """
    try:
        with _com.translate_com_errors():
            apply_borders(
                self._range().Borders,
                sides=sides,
                style=style,
                weight=weight,
                color=color,
            )
    except (ValueError, TypeError) as e:
        raise OpError(str(e)) from e

add_tab_stop

add_tab_stop(position: Any, *, align: Any = 'left', leader: Any = None) -> None

Add a tab stop to this anchor's paragraph(s).

position is the distance from the left margin in points (or a unit string like "3in"). align is "left"/"center"/"right"/ "decimal"/"bar". leader is an optional fill drawn up to the stop β€” "dots" (price lists / tables of contents), "dashes", "lines", … β€” defaulting to none. Maps to ParagraphFormat.TabStops.Add. Bad input raises OpError. Wrap in doc.edit(...).

Source code in src/wordlive/_anchors/_anchor_format.py
def add_tab_stop(self, position: Any, *, align: Any = "left", leader: Any = None) -> None:
    """Add a tab stop to this anchor's paragraph(s).

    `position` is the distance from the left margin in points (or a unit
    string like `"3in"`). `align` is `"left"`/`"center"`/`"right"`/
    `"decimal"`/`"bar"`. `leader` is an optional fill drawn up to the stop β€”
    `"dots"` (price lists / tables of contents), `"dashes"`, `"lines"`, … β€”
    defaulting to none. Maps to `ParagraphFormat.TabStops.Add`. Bad input
    raises `OpError`. Wrap in `doc.edit(...)`.
    """
    try:
        pos = to_points(position)
        al = _coerce_named(align, _TAB_ALIGN, "tab alignment")
        ld = (
            _coerce_named(leader, _TAB_LEADERS, "tab leader")
            if leader is not None
            else int(WdTabLeader.SPACES)
        )
        with _com.translate_com_errors():
            # Positional args: the `Leader=` keyword is dropped under pywin32
            # late binding, so pass Position, Alignment, Leader positionally.
            self._range().ParagraphFormat.TabStops.Add(pos, al, ld)
    except (ValueError, TypeError) as e:
        raise OpError(str(e)) from e

insert_toc

insert_toc(*, levels: tuple[int, int] = (1, 3), use_heading_styles: bool = True, hyperlinks: bool = True, where: str = 'after') -> Any

Insert a table of contents at this anchor and return it as a Toc.

Builds a TOC from the document's heading paragraphs over the given levels (a (upper, lower) pair β€” (1, 3) covers Heading 1–3). use_heading_styles=True sources entries from the built-in Heading styles; hyperlinks=True makes each entry a clickable jump (and a real hyperlink in exported PDFs). Returns a Toc.

A TOC's page numbers populate only after repagination β€” call toc.update() (or Document.update_fields, or take a snapshot, which forces print layout) before reading them. Most documents want the TOC at the top: doc.add_toc(...) is the sugar for doc.start.insert_toc(...).

where is "after" (default) or "before" this anchor's range. Wrap in doc.edit(...) for atomic undo. Bad input raises OpError.

Source code in src/wordlive/_anchors/_anchor_references.py
def insert_toc(
    self,
    *,
    levels: tuple[int, int] = (1, 3),
    use_heading_styles: bool = True,
    hyperlinks: bool = True,
    where: str = "after",
) -> Any:
    """Insert a table of contents at this anchor and return it as a `Toc`.

    Builds a TOC from the document's heading paragraphs over the given
    `levels` (a ``(upper, lower)`` pair β€” `(1, 3)` covers Heading 1–3).
    `use_heading_styles=True` sources entries from the built-in Heading
    styles; `hyperlinks=True` makes each entry a clickable jump (and a real
    hyperlink in exported PDFs). Returns a [`Toc`][wordlive.Toc].

    A TOC's page numbers populate only after repagination β€” call
    `toc.update()` (or [`Document.update_fields`][wordlive.Document.update_fields],
    or take a `snapshot`, which forces print layout) before reading them.
    Most documents want the TOC at the top: `doc.add_toc(...)` is the sugar
    for `doc.start.insert_toc(...)`.

    `where` is ``"after"`` (default) or ``"before"`` this anchor's range.
    Wrap in `doc.edit(...)` for atomic undo. Bad input raises `OpError`.
    """
    from .._toc import Toc

    try:
        if where not in ("before", "after"):
            raise ValueError(f"where must be 'before' or 'after'; got {where!r}")
        try:
            upper, lower = int(levels[0]), int(levels[1])
        except (TypeError, IndexError, ValueError, KeyError) as e:
            raise ValueError(
                f"levels must be a (upper, lower) pair of ints; got {levels!r}"
            ) from e
        if not (1 <= upper <= lower <= 9):
            raise ValueError(
                f"levels must satisfy 1 <= upper <= lower <= 9; got {(upper, lower)}"
            )
        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)
            # Positional args (keyword names are dropped under pywin32 late
            # binding). Order: Range, UseHeadingStyles, UpperHeadingLevel,
            # LowerHeadingLevel, UseFields, TableID, RightAlignPageNumbers,
            # IncludePageNumbers, AddedStyles, UseHyperlinks,
            # HidePageNumbersInWeb, UseOutlineLevels.
            toc_com = self._doc.com.TablesOfContents.Add(
                insert_rng,
                bool(use_heading_styles),
                upper,
                lower,
                False,
                "",
                True,
                True,
                "",
                bool(hyperlinks),
                True,
                True,
            )
        return Toc(self._doc, toc_com)
    except (ValueError, TypeError) as e:
        raise OpError(str(e)) from e
link_to(address: str | None = None, *, bookmark: str | None = None, text: str | None = None, screen_tip: str | None = None) -> None

Turn this anchor into a hyperlink (or insert new linked text).

Pass exactly one destination: address for an external link (a URL, mailto:, or file path) or bookmark for an internal jump to a named bookmark in this document. With text=None the anchor's existing range becomes the clickable link; pass text=... to insert new linked text at the end of the anchor's range (so linking a heading or a range: phrase with text=... adds the link rather than overwriting the content). screen_tip is the hover tooltip.

Pair it with doc.bookmarks.add(...) to build internal navigation, or a range:START-END id (from find) to link an existing phrase. Wrap in doc.edit(...) for atomic undo. Bad input (not exactly one destination) raises OpError.

Source code in src/wordlive/_anchors/_anchor_references.py
def link_to(
    self,
    address: str | None = None,
    *,
    bookmark: str | None = None,
    text: str | None = None,
    screen_tip: str | None = None,
) -> None:
    """Turn this anchor into a hyperlink (or insert new linked text).

    Pass exactly one destination: `address` for an external link (a URL,
    `mailto:`, or file path) or `bookmark` for an internal jump to a named
    bookmark in this document. With `text=None` the anchor's existing range
    becomes the clickable link; pass `text=...` to **insert** new linked text
    at the end of the anchor's range (so linking a heading or a `range:`
    phrase with `text=...` adds the link rather than overwriting the content).
    `screen_tip` is the hover tooltip.

    Pair it with [`doc.bookmarks.add(...)`][wordlive.BookmarkCollection.add]
    to build internal navigation, or a `range:START-END` id (from `find`) to
    link an existing phrase. Wrap in `doc.edit(...)` for atomic undo. Bad
    input (not exactly one destination) raises `OpError`.
    """
    try:
        if (address is None) == (bookmark is None):
            raise ValueError("link_to requires exactly one of 'address' or 'bookmark'")
        with _com.translate_com_errors():
            rng = self._range()
            if text is not None:
                # Insert *new* linked text rather than overwriting the
                # anchor's range: collapse to its end so a heading / phrase
                # keeps its content and the link is added after it.
                rng = rng.Duplicate
                rng.Collapse(int(WdCollapseDirection.END))
            addr_arg = address or ""
            sub_arg = bookmark or ""
            tip_arg = screen_tip or ""
            # Positional args (Anchor, Address, SubAddress, ScreenTip,
            # TextToDisplay) β€” keep keywords out for late-binding safety.
            if text is not None:
                self._doc.com.Hyperlinks.Add(rng, addr_arg, sub_arg, tip_arg, text)
            else:
                self._doc.com.Hyperlinks.Add(rng, addr_arg, sub_arg, tip_arg)
    except (ValueError, TypeError) as e:
        raise OpError(str(e)) from e

insert_cross_reference

insert_cross_reference(target: str, *, kind: str = 'text', hyperlink: bool = True, where: str = 'after') -> None

Insert a cross-reference to another anchor at this anchor.

target is the anchor id to point at: bookmark:NAME, heading:N, footnote:N, or endnote:N. kind selects what the reference shows: "text" (the heading/bookmark text β€” the default), "page" ("see page 5"), "number" (the paragraph or note number), or "above_below" ("above"/"below"). hyperlink=True makes the inserted reference a clickable jump.

An unresolvable target raises AnchorNotFoundError (exit 2) before anything is inserted. where is "after" (default) or "before" this anchor's range. Wrap in doc.edit(...) for atomic undo; other bad input raises OpError.

Source code in src/wordlive/_anchors/_anchor_references.py
def insert_cross_reference(
    self,
    target: str,
    *,
    kind: str = "text",
    hyperlink: bool = True,
    where: str = "after",
) -> None:
    """Insert a cross-reference to another anchor at this anchor.

    `target` is the anchor id to point at: `bookmark:NAME`, `heading:N`,
    `footnote:N`, or `endnote:N`. `kind` selects what the reference shows:
    ``"text"`` (the heading/bookmark text β€” the default), ``"page"`` ("see
    page 5"), ``"number"`` (the paragraph or note number), or
    ``"above_below"`` ("above"/"below"). `hyperlink=True` makes the inserted
    reference a clickable jump.

    An unresolvable `target` raises `AnchorNotFoundError` (exit 2) before
    anything is inserted. `where` is ``"after"`` (default) or ``"before"``
    this anchor's range. Wrap in `doc.edit(...)` for atomic undo; other bad
    input raises `OpError`.
    """
    try:
        if where not in ("before", "after"):
            raise ValueError(f"where must be 'before' or 'after'; got {where!r}")
        # Resolve outside translate_com_errors so an AnchorNotFoundError for a
        # bad target propagates as exit 2 rather than being masked.
        ref_type, ref_item = _resolve_cross_ref_target(self._doc, target)
        ref_kind = _cross_ref_kind(kind, ref_type)
        with _com.translate_com_errors():
            insert_rng = self._range().Duplicate
            insert_rng.Collapse(
                int(WdCollapseDirection.START if where == "before" else WdCollapseDirection.END)
            )
            # Positional args: IncludePositionInformation as a keyword raises
            # under pywin32 late binding, so pass only the first four.
            insert_rng.InsertCrossReference(ref_type, ref_kind, ref_item, bool(hyperlink))
    except (ValueError, TypeError) as e:
        raise OpError(str(e)) from e

insert_caption

insert_caption(label: str = 'Figure', *, text: str | None = None, position: str | None = None) -> None

Insert a numbered caption as its own paragraph at this anchor.

label is a caption label β€” built-in "Figure" / "Table" / "Equation" or any custom string; Word auto-numbers per label (Figure 1, Figure 2, …). text is the caption title shown after the label and number. Pairs with insert_cross_reference for "see Figure 2".

position is "above" or "below" the anchor. Left as None it follows convention: a "Table" caption goes above, every other label goes below. The caption always becomes its own Caption-styled paragraph β€” it never fuses into the target paragraph. On a table cell (table:N:R:C) the caption is placed above / below the whole table, not inside the cell.

Wrap in doc.edit(...) for atomic undo. Bad input raises OpError.

Source code in src/wordlive/_anchors/_anchor_references.py
def insert_caption(
    self, label: str = "Figure", *, text: str | None = None, position: str | None = None
) -> None:
    """Insert a numbered caption as its **own paragraph** at this anchor.

    `label` is a caption label β€” built-in ``"Figure"`` / ``"Table"`` /
    ``"Equation"`` or any custom string; Word auto-numbers per label
    (Figure 1, Figure 2, …). `text` is the caption title shown after the
    label and number. Pairs with
    [`insert_cross_reference`][wordlive.Anchor.insert_cross_reference] for
    "see Figure 2".

    `position` is ``"above"`` or ``"below"`` the anchor. Left as `None` it
    follows convention: a ``"Table"`` caption goes **above**, every other
    label goes **below**. The caption always becomes its own
    `Caption`-styled paragraph β€” it never fuses into the target paragraph.
    On a table cell (`table:N:R:C`) the caption is placed above / below the
    **whole table**, not inside the cell.

    Wrap in `doc.edit(...)` for atomic undo. Bad input raises `OpError`.
    """
    try:
        above = _caption_above(label, position)
        title = text if text is not None else ""
        pos = int(WdCaptionPosition.ABOVE if above else WdCaptionPosition.BELOW)
        with _com.translate_com_errors():
            obj_rng = self._caption_object_range()
            if obj_rng is not None:
                # A caption-able object (e.g. a table): let Word place the
                # caption on its own line above/below the object natively.
                obj_rng.InsertCaption(str(label), title, pos, False)
            else:
                # Text/paragraph anchor: carve out a dedicated empty
                # paragraph (before or after the anchor) and drop the
                # caption into it, so it never fuses into the host paragraph.
                insert_rng = self._range().Duplicate
                insert_rng.Collapse(
                    int(WdCollapseDirection.START if above else WdCollapseDirection.END)
                )
                insert_rng.InsertParagraphBefore()
                insert_rng.Collapse(int(WdCollapseDirection.START))
                # Positional args (Label, Title, Position, ExcludeLabel) for
                # late-binding safety; a string Label matches a built-in or
                # defines a custom one.
                insert_rng.InsertCaption(str(label), title, pos, False)
    except (ValueError, TypeError) as e:
        raise OpError(str(e)) from e

mark_index_entry

mark_index_entry(entry: str, *, cross_reference: str | None = None, bold: bool = False, italic: bool = False) -> None

Mark this anchor's range as a back-of-book index entry (an XE field).

entry is the text that appears in the index; use "main:sub" to file it as a subentry under main (Word's colon convention). bold / italic style the entry's page number in the built index. cross_reference replaces the page number with a "see …" pointer (e.g. cross_reference="Widgets" β†’ "see Widgets").

This is the per-term half of indexing; once entries are marked, build the list with insert_index / Document.add_index. The XE field is hidden text and doesn't disturb the visible flow. Wrap in doc.edit(...) for atomic undo. Bad input raises OpError.

Source code in src/wordlive/_anchors/_anchor_references.py
def mark_index_entry(
    self,
    entry: str,
    *,
    cross_reference: str | None = None,
    bold: bool = False,
    italic: bool = False,
) -> None:
    """Mark this anchor's range as a back-of-book index entry (an `XE` field).

    `entry` is the text that appears in the index; use ``"main:sub"`` to file
    it as a subentry under ``main`` (Word's colon convention). `bold` /
    `italic` style the entry's *page number* in the built index.
    `cross_reference` replaces the page number with a "see …" pointer (e.g.
    ``cross_reference="Widgets"`` β†’ *"see Widgets"*).

    This is the per-term half of indexing; once entries are marked, build the
    list with [`insert_index`][wordlive.Anchor.insert_index] /
    [`Document.add_index`][wordlive.Document.add_index]. The `XE` field is
    hidden text and doesn't disturb the visible flow. Wrap in `doc.edit(...)`
    for atomic undo. Bad input raises `OpError`.
    """
    try:
        if not str(entry).strip():
            raise ValueError("entry must be a non-empty string")
        with _com.translate_com_errors():
            rng = self._range()
            # Indexes.MarkEntry(Range, Entry, EntryAutoText, CrossReference,
            # CrossReferenceAutoText, BookmarkName, Bold, Italic) β€” positional
            # for late-binding safety.
            self._doc.com.Indexes.MarkEntry(
                rng,
                str(entry),
                "",
                str(cross_reference) if cross_reference is not None else "",
                "",
                "",
                bool(bold),
                bool(italic),
            )
    except (ValueError, TypeError) as e:
        raise OpError(str(e)) from e

insert_index

insert_index(*, columns: int = 2, run_in: bool = False, right_align_page_numbers: bool = False, where: str = 'after') -> Any

Insert a back-of-book index at this anchor and return it as an Index.

Gathers every XE entry marked with mark_index_entry into an alphabetised, page-numbered list. columns is the number of newspaper columns the index is laid out in (2 is the book default). run_in=True packs subentries into a single paragraph instead of one per line; right_align_page_numbers=True flushes page numbers to the right margin.

Returns an Index; like a TOC it's a field block whose page numbers populate only after repagination β€” call index.update(), Document.update_fields, or take a snapshot. Most documents want the index at the end: doc.add_index(...) is the sugar for doc.end.insert_index(...).

where is "after" (default) or "before" this anchor's range. Wrap in doc.edit(...) for atomic undo. Bad input raises OpError.

Source code in src/wordlive/_anchors/_anchor_references.py
def insert_index(
    self,
    *,
    columns: int = 2,
    run_in: bool = False,
    right_align_page_numbers: bool = False,
    where: str = "after",
) -> Any:
    """Insert a back-of-book index at this anchor and return it as an `Index`.

    Gathers every `XE` entry marked with
    [`mark_index_entry`][wordlive.Anchor.mark_index_entry] into an
    alphabetised, page-numbered list. `columns` is the number of newspaper
    columns the index is laid out in (2 is the book default). `run_in=True`
    packs subentries into a single paragraph instead of one per line;
    `right_align_page_numbers=True` flushes page numbers to the right margin.

    Returns an [`Index`][wordlive.Index]; like a TOC it's a field block whose
    page numbers populate only after repagination β€” call `index.update()`,
    [`Document.update_fields`][wordlive.Document.update_fields], or take a
    `snapshot`. Most documents want the index at the end:
    `doc.add_index(...)` is the sugar for `doc.end.insert_index(...)`.

    `where` is ``"after"`` (default) or ``"before"`` this anchor's range.
    Wrap in `doc.edit(...)` for atomic undo. Bad input raises `OpError`.
    """
    from .._index import Index

    try:
        if where not in ("before", "after"):
            raise ValueError(f"where must be 'before' or 'after'; got {where!r}")
        cols = int(columns)
        if cols < 1:
            raise ValueError(f"columns must be >= 1; got {columns!r}")
        idx_type = int(WdIndexType.RUNIN if run_in else WdIndexType.INDENT)
        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)
            # Indexes.Add(Range, HeadingSeparator, RightAlignPageNumbers, Type,
            # NumberOfColumns, AccentedLetters, SortBy, IndexLanguage). Positional;
            # HeadingSeparator must be a WdHeadingSeparator value (0 = none) β€” an
            # empty string raises a type-mismatch on a makepy-typed Word wrapper.
            idx_com = self._doc.com.Indexes.Add(
                insert_rng, 0, bool(right_align_page_numbers), idx_type, cols
            )
        return Index(self._doc, idx_com)
    except (ValueError, TypeError) as e:
        raise OpError(str(e)) from e

insert_table_of_figures

insert_table_of_figures(*, label: str = 'Figure', include_label: bool = True, hyperlinks: bool = True, right_align_page_numbers: bool = True, where: str = 'after') -> Any

Insert a table of figures at this anchor and return it.

The caption-driven sibling of insert_toc: it lists every caption of one label β€” "Figure" (the default), "Table", "Equation", or any custom label you passed to insert_caption β€” with its page number. include_label=True keeps the "Figure 1" prefix in each entry; hyperlinks=True makes entries clickable jumps; right_align_page_numbers=True flushes page numbers right.

Returns a TableOfFigures; like a TOC its page numbers populate only after repagination β€” call tof.update(), Document.update_fields, or take a snapshot. where is "after" (default) or "before" this anchor's range. Wrap in doc.edit(...) for atomic undo. Bad input raises OpError.

Source code in src/wordlive/_anchors/_anchor_references.py
def insert_table_of_figures(
    self,
    *,
    label: str = "Figure",
    include_label: bool = True,
    hyperlinks: bool = True,
    right_align_page_numbers: bool = True,
    where: str = "after",
) -> Any:
    """Insert a table of figures at this anchor and return it.

    The caption-driven sibling of [`insert_toc`][wordlive.Anchor.insert_toc]:
    it lists every caption of one `label` β€” ``"Figure"`` (the default),
    ``"Table"``, ``"Equation"``, or any custom label you passed to
    [`insert_caption`][wordlive.Anchor.insert_caption] β€” with its page number.
    `include_label=True` keeps the "Figure 1" prefix in each entry;
    `hyperlinks=True` makes entries clickable jumps;
    `right_align_page_numbers=True` flushes page numbers right.

    Returns a [`TableOfFigures`][wordlive.TableOfFigures]; like a TOC its page
    numbers populate only after repagination β€” call `tof.update()`,
    [`Document.update_fields`][wordlive.Document.update_fields], or take a
    `snapshot`. `where` is ``"after"`` (default) or ``"before"`` this anchor's
    range. Wrap in `doc.edit(...)` for atomic undo. Bad input raises `OpError`.
    """
    from .._toc import TableOfFigures

    try:
        if where not in ("before", "after"):
            raise ValueError(f"where must be 'before' or 'after'; got {where!r}")
        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)
            # Keyword args here: the optional string Variants (TableID,
            # Caption2, AddedStyles) raise a type-mismatch when passed
            # positionally as "" on a makepy-typed Word wrapper, so we name
            # only the flags we set and let the rest default (Range + Caption
            # stay positional, matching the Word signature).
            tof_com = self._doc.com.TablesOfFigures.Add(
                insert_rng,
                str(label),
                IncludeLabel=bool(include_label),
                UseHeadingStyles=False,
                RightAlignPageNumbers=bool(right_align_page_numbers),
                UseHyperlinks=bool(hyperlinks),
            )
        return TableOfFigures(self._doc, tof_com)
    except (ValueError, TypeError) as e:
        raise OpError(str(e)) from e

insert_citation

insert_citation(tag: str, *, pages: str | None = None, prefix: str | None = None, suffix: str | None = None, volume: str | None = None, suppress_author: bool = False, suppress_year: bool = False, suppress_title: bool = False, locale: int = 1033, where: str = 'after') -> Any

Insert an in-text citation at this anchor and return it as a Citation.

References a source in the document's store (add one with doc.sources.add) by its tag and renders it in the current bibliography_style β€” e.g. (Smith 2020). pages adds a page locator ((Smith 2020, 15)); prefix / suffix wrap the citation ("see " / ", at 12"); volume adds a volume. suppress_author / suppress_year / suppress_title drop those parts. locale is the LCID the style formats under (1033 = en-US).

Returns a Citation; a citation to an unknown tag renders "Invalid source specified." rather than failing. where is "after" (default) or "before" this anchor's range. Wrap in doc.edit(...) for atomic undo. Bad input raises OpError.

Source code in src/wordlive/_anchors/_anchor_references.py
def insert_citation(
    self,
    tag: str,
    *,
    pages: str | None = None,
    prefix: str | None = None,
    suffix: str | None = None,
    volume: str | None = None,
    suppress_author: bool = False,
    suppress_year: bool = False,
    suppress_title: bool = False,
    locale: int = 1033,
    where: str = "after",
) -> Any:
    """Insert an in-text citation at this anchor and return it as a `Citation`.

    References a source in the document's store (add one with
    `doc.sources.add`) by its `tag` and
    renders it in the current [`bibliography_style`][wordlive.Document.bibliography_style]
    β€” e.g. *(Smith 2020)*. `pages` adds a page locator (*(Smith 2020, 15)*);
    `prefix` / `suffix` wrap the citation (*"see "* / *", at 12"*); `volume`
    adds a volume. `suppress_author` / `suppress_year` / `suppress_title` drop
    those parts. `locale` is the LCID the style formats under (1033 = en-US).

    Returns a [`Citation`][wordlive.Citation]; a citation to an unknown tag
    renders *"Invalid source specified."* rather than failing. `where` is
    ``"after"`` (default) or ``"before"`` this anchor's range. Wrap in
    `doc.edit(...)` for atomic undo. Bad input raises `OpError`.
    """
    from .._citations import Citation

    try:
        if where not in ("before", "after"):
            raise ValueError(f"where must be 'before' or 'after'; got {where!r}")
        if not str(tag).strip():
            raise ValueError("tag must be a non-empty string")
        # CITATION field code (switches confirmed against live Word): \p page,
        # \v volume, \f prefix, \s suffix, \n/\y/\t suppress author/year/title.
        parts = [f"CITATION {tag} \\l {int(locale)}"]
        if pages:
            parts.append(f'\\p "{pages}"')
        if volume:
            parts.append(f"\\v {volume}")
        if prefix:
            parts.append(f'\\f "{prefix}"')
        if suffix:
            parts.append(f'\\s "{suffix}"')
        if suppress_author:
            parts.append("\\n")
        if suppress_year:
            parts.append("\\y")
        if suppress_title:
            parts.append("\\t")
        code = " ".join(parts)
        with _com.translate_com_errors():
            # Collapse a *duplicate* of the anchor's own range so the field
            # lands in the same story (header/footer-safe β€” see insert_field).
            insert_rng = self._range().Duplicate
            insert_rng.Collapse(
                int(WdCollapseDirection.START if where == "before" else WdCollapseDirection.END)
            )
            # EMPTY (-1) raw-code insert β€” positional, the proven path Word
            # parses into a typed CITATION field (its numeric, 96, is fragile
            # to pass directly).
            field = insert_rng.Fields.Add(insert_rng, int(WdFieldType.EMPTY), code, False)
        return Citation(self._doc, field)
    except (ValueError, TypeError) as e:
        raise OpError(str(e)) from e

insert_bibliography

insert_bibliography(*, where: str = 'after') -> Any

Insert a bibliography at this anchor and return it as a Bibliography.

Inserts a BIBLIOGRAPHY field β€” the reference list of every source cited in the document, formatted in the current bibliography_style. Most documents want it at the end: doc.add_bibliography() is the sugar for doc.end.insert_bibliography().

Returns a Bibliography; like a TOC it's a field block β€” call bibliography.update(), Document.update_fields, or take a snapshot after adding citations. where is "after" (default) or "before" this anchor's range. Wrap in doc.edit(...) for atomic undo. Bad input raises OpError.

Source code in src/wordlive/_anchors/_anchor_references.py
def insert_bibliography(self, *, where: str = "after") -> Any:
    """Insert a bibliography at this anchor and return it as a `Bibliography`.

    Inserts a ``BIBLIOGRAPHY`` field β€” the reference list of every source
    *cited* in the document, formatted in the current
    [`bibliography_style`][wordlive.Document.bibliography_style]. Most
    documents want it at the end: `doc.add_bibliography()` is the sugar for
    `doc.end.insert_bibliography()`.

    Returns a [`Bibliography`][wordlive.Bibliography]; like a TOC it's a field
    block β€” call `bibliography.update()`,
    [`Document.update_fields`][wordlive.Document.update_fields], or take a
    `snapshot` after adding citations. `where` is ``"after"`` (default) or
    ``"before"`` this anchor's range. Wrap in `doc.edit(...)` for atomic undo.
    Bad input raises `OpError`.
    """
    from .._citations import Bibliography

    try:
        if where not in ("before", "after"):
            raise ValueError(f"where must be 'before' or 'after'; got {where!r}")
        with _com.translate_com_errors():
            insert_rng = self._range().Duplicate
            insert_rng.Collapse(
                int(WdCollapseDirection.START if where == "before" else WdCollapseDirection.END)
            )
            field = insert_rng.Fields.Add(
                insert_rng, int(WdFieldType.EMPTY), "BIBLIOGRAPHY", False
            )
        return Bibliography(self._doc, field)
    except (ValueError, TypeError) as e:
        raise OpError(str(e)) from e

mark_citation

mark_citation(long_citation: str, *, short_citation: str | None = None, category: str | int = 'cases', where: str = 'after') -> None

Mark this anchor's range as a table-of-authorities citation (a TA field).

The legal analog of mark_index_entry: long_citation is the full citation as it appears in the table (e.g. "Smith v. Jones, 1 U.S. 1 (2020)"), short_citation the abbreviated form Word matches elsewhere in the text (defaults to long_citation), and category the section it files under β€” "cases" (the default), "statutes", "other", "rules", "treatises", "regulations", "constitutional", or a category number (1-16).

This is the per-authority half; build the table with insert_table_of_authorities / Document.add_table_of_authorities. The TA field is hidden and doesn't disturb the visible flow. Wrap in doc.edit(...) for atomic undo. Bad input raises OpError.

Source code in src/wordlive/_anchors/_anchor_references.py
def mark_citation(
    self,
    long_citation: str,
    *,
    short_citation: str | None = None,
    category: str | int = "cases",
    where: str = "after",
) -> None:
    """Mark this anchor's range as a table-of-authorities citation (a `TA` field).

    The legal analog of [`mark_index_entry`][wordlive.Anchor.mark_index_entry]:
    `long_citation` is the full citation as it appears in the table (e.g.
    *"Smith v. Jones, 1 U.S. 1 (2020)"*), `short_citation` the abbreviated
    form Word matches elsewhere in the text (defaults to `long_citation`), and
    `category` the section it files under β€” ``"cases"`` (the default),
    ``"statutes"``, ``"other"``, ``"rules"``, ``"treatises"``,
    ``"regulations"``, ``"constitutional"``, or a category number (1-16).

    This is the per-authority half; build the table with
    [`insert_table_of_authorities`][wordlive.Anchor.insert_table_of_authorities]
    / [`Document.add_table_of_authorities`][wordlive.Document.add_table_of_authorities].
    The `TA` field is hidden and doesn't disturb the visible flow. Wrap in
    `doc.edit(...)` for atomic undo. Bad input raises `OpError`.
    """
    from .._toa import _TOA_CATEGORIES

    try:
        if where not in ("before", "after"):
            raise ValueError(f"where must be 'before' or 'after'; got {where!r}")
        if not str(long_citation).strip():
            raise ValueError("long_citation must be a non-empty string")
        cat = _coerce_named(category, _TOA_CATEGORIES, "TOA category")
        short = short_citation if short_citation is not None else long_citation
        code = f'TA \\l "{long_citation}" \\s "{short}" \\c {cat}'
        with _com.translate_com_errors():
            insert_rng = self._range().Duplicate
            insert_rng.Collapse(
                int(WdCollapseDirection.START if where == "before" else WdCollapseDirection.END)
            )
            insert_rng.Fields.Add(insert_rng, int(WdFieldType.EMPTY), code, False)
    except (ValueError, TypeError) as e:
        raise OpError(str(e)) from e

insert_table_of_authorities

insert_table_of_authorities(*, category: str | int = 'all', passim: bool = True, keep_entry_formatting: bool = True, entry_separator: str | None = None, page_range_separator: str | None = None, where: str = 'after') -> Any

Insert a table of authorities at this anchor and return it.

Gathers the TA citations marked with mark_citation into a page-numbered table. category selects which authorities to include β€” "all" (the default), "cases", "statutes", … or a number (1-16). passim=True replaces five-or-more page references for one authority with "passim"; keep_entry_formatting=True preserves each citation's character formatting. entry_separator / page_range_separator override the defaults between a citation and its first page / between page ranges.

Returns a TableOfAuthorities; like a TOC it's a field block whose page numbers populate only after repagination β€” call toa.update(), Document.update_fields, or take a snapshot. doc.add_table_of_authorities(...) is the sugar for doc.end.insert_table_of_authorities(...). where is "after" (default) or "before" this anchor's range. Wrap in doc.edit(...) for atomic undo. Bad input raises OpError.

Source code in src/wordlive/_anchors/_anchor_references.py
def insert_table_of_authorities(
    self,
    *,
    category: str | int = "all",
    passim: bool = True,
    keep_entry_formatting: bool = True,
    entry_separator: str | None = None,
    page_range_separator: str | None = None,
    where: str = "after",
) -> Any:
    """Insert a table of authorities at this anchor and return it.

    Gathers the `TA` citations marked with
    [`mark_citation`][wordlive.Anchor.mark_citation] into a page-numbered
    table. `category` selects which authorities to include β€” ``"all"`` (the
    default), ``"cases"``, ``"statutes"``, … or a number (1-16). `passim=True`
    replaces five-or-more page references for one authority with *"passim"*;
    `keep_entry_formatting=True` preserves each citation's character
    formatting. `entry_separator` / `page_range_separator` override the
    defaults between a citation and its first page / between page ranges.

    Returns a [`TableOfAuthorities`][wordlive.TableOfAuthorities]; like a TOC
    it's a field block whose page numbers populate only after repagination β€”
    call `toa.update()`, [`Document.update_fields`][wordlive.Document.update_fields],
    or take a `snapshot`. `doc.add_table_of_authorities(...)` is the sugar for
    `doc.end.insert_table_of_authorities(...)`. `where` is ``"after"``
    (default) or ``"before"`` this anchor's range. Wrap in `doc.edit(...)` for
    atomic undo. Bad input raises `OpError`.
    """
    from .._toa import _TOA_CATEGORIES, TableOfAuthorities

    try:
        if where not in ("before", "after"):
            raise ValueError(f"where must be 'before' or 'after'; got {where!r}")
        cat = _coerce_named(category, _TOA_CATEGORIES, "TOA category")
        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)
            # TablesOfAuthorities.Add(Range, Category, ...): Range + int
            # Category positional; the string-Variant optionals need keyword
            # form (same gotcha as TablesOfFigures).
            kwargs: dict[str, Any] = {
                "Passim": bool(passim),
                "KeepEntryFormatting": bool(keep_entry_formatting),
            }
            if entry_separator is not None:
                kwargs["EntrySeparator"] = str(entry_separator)
            if page_range_separator is not None:
                kwargs["PageRangeSeparator"] = str(page_range_separator)
            toa_com = self._doc.com.TablesOfAuthorities.Add(insert_rng, cat, **kwargs)
        return TableOfAuthorities(self._doc, toa_com)
    except (ValueError, TypeError) as e:
        raise OpError(str(e)) from e

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

A floating image (any wrap other than "inline") leaves the inline text flow, so image:N no longer addresses it β€” this returns its floating ShapeAnchor (shape:N) for restyle (re-wrap / reposition / resize / replace_image). An "inline" image stays an InlineShape (addressed as image:N) and returns None.

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

Source code in src/wordlive/_anchors/_anchor_media.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,
) -> ShapeAnchor | 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.

    A floating image (any `wrap` other than ``"inline"``) leaves the inline
    text flow, so `image:N` no longer addresses it β€” this returns its floating
    [`ShapeAnchor`][wordlive.ShapeAnchor] (`shape:N`) for restyle
    (re-wrap / reposition / resize / `replace_image`). An ``"inline"`` image
    stays an `InlineShape` (addressed as `image:N`) and returns ``None``.

    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 None
            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
            # The picture left InlineShapes (image:N no longer addresses it),
            # so hand back its floating shape:N handle for restyle. Locate by a
            # unique temp name β€” don't assume "last" (other floats can reorder).
            orig_name = str(shape.Name or "")
            probe_name = f"_wl_shape_{secrets.token_hex(8)}"
            shape.Name = probe_name
            index = _shapes.index_of_named(doc_com, probe_name)
            # Restore unconditionally β€” leaving the probe name on a shape whose
            # original name was empty would surface `_wl_shape_*` in list().
            shape.Name = orig_name
        from ._shape_anchors import ShapeAnchor  # lazy: _shape_anchors imports Anchor

        return ShapeAnchor(self._doc, index)

insert_text_box

insert_text_box(text: str, *, width: Any = 200, height: Any = 100, wrap: str = 'square', where: str = 'after', font: str | None = None, size: Any = None, bold: bool | None = None, italic: bool | None = None, alignment: str | None = None, fill: str | None = None, border: str | bool | None = None) -> ShapeAnchor

Insert a floating text box (a pull quote / call-out) anchored here.

A Shapes.AddTextbox floating shape is anchored to this anchor's paragraph and seeded with text. width / height are points or a unit string ("3in" / "8cm"). wrap is how body text flows around it β€” "square" (default), "tight", "through", "top-bottom", "front", or "behind" (the same vocabulary as insert_image, minus "inline"). where places the anchor "after" (default) or "before" this anchor's range.

The remaining kwargs style the box and its text, each optional: font / size (points or unit string) / bold / italic set the character format; alignment ("left"/"center"/"right"/ "justify") the paragraph; fill is a background colour ("#eeeeff" / "navy") and border is False for no outline, a colour string for a coloured outline, or True for the default.

Returns the text box's floating ShapeAnchor (shape:N) so it can be restyled in place afterwards (set_text / set_wrap / set_position / set_size / format); discover text boxes later via doc.text_boxes. Wrap in doc.edit(...) for atomic undo; raises ValueError for an unknown wrap / where.

Source code in src/wordlive/_anchors/_anchor_media.py
def insert_text_box(
    self,
    text: str,
    *,
    width: Any = 200,
    height: Any = 100,
    wrap: str = "square",
    where: str = "after",
    font: str | None = None,
    size: Any = None,
    bold: bool | None = None,
    italic: bool | None = None,
    alignment: str | None = None,
    fill: str | None = None,
    border: str | bool | None = None,
) -> ShapeAnchor:
    """Insert a floating text box (a pull quote / call-out) anchored here.

    A `Shapes.AddTextbox` floating shape is anchored to this anchor's
    paragraph and seeded with `text`. `width` / `height` are points or a unit
    string (``"3in"`` / ``"8cm"``). `wrap` is how body text flows around it β€”
    ``"square"`` (default), ``"tight"``, ``"through"``, ``"top-bottom"``,
    ``"front"``, or ``"behind"`` (the same vocabulary as `insert_image`, minus
    ``"inline"``). `where` places the anchor ``"after"`` (default) or
    ``"before"`` this anchor's range.

    The remaining kwargs style the box and its text, each optional:
    `font` / `size` (points or unit string) / `bold` / `italic` set the
    character format; `alignment` (``"left"``/``"center"``/``"right"``/
    ``"justify"``) the paragraph; `fill` is a background colour
    (``"#eeeeff"`` / ``"navy"``) and `border` is ``False`` for no outline, a
    colour string for a coloured outline, or ``True`` for the default.

    Returns the text box's floating [`ShapeAnchor`][wordlive.ShapeAnchor]
    (`shape:N`) so it can be restyled in place afterwards (`set_text` /
    `set_wrap` / `set_position` / `set_size` / `format`); discover text boxes
    later via [`doc.text_boxes`][wordlive.Document.text_boxes]. Wrap in
    `doc.edit(...)` for atomic undo; raises `ValueError` for an unknown
    `wrap` / `where`.
    """
    if wrap not in _WRAP_NAMES:
        raise ValueError(
            f"unknown wrap {wrap!r}; expected one of {sorted(_WRAP_NAMES)} (text boxes float)"
        )
    if where not in ("before", "after"):
        raise ValueError(f"where must be 'before' or 'after'; got {where!r}")
    wd_align = _ALIGNMENT_NAMES[alignment] if alignment is not None else None
    try:
        w = to_points(width)
        h = to_points(height)
        font_size = to_points(size) if size is not None else None
        fill_bgr = to_bgr(fill) if fill is not None else None
        border_bgr = to_bgr(border) if isinstance(border, str) else None
    except (ValueError, TypeError) as e:
        raise OpError(str(e)) from e
    with _com.translate_com_errors():
        doc_com = self._doc.com
        rng = self._range()
        pos = int(rng.Start) if where == "before" else int(rng.End)
        anchor_rng = doc_com.Range(pos, pos)
        shape = doc_com.Shapes.AddTextbox(
            Orientation=int(MsoTextOrientation.HORIZONTAL),
            Left=0.0,
            Top=0.0,
            Width=w,
            Height=h,
            Anchor=anchor_rng,
        )
        text_range = shape.TextFrame.TextRange
        text_range.Text = text
        font_obj = text_range.Font
        if font is not None:
            font_obj.Name = font
        if font_size is not None:
            font_obj.Size = font_size
        if bold is not None:
            font_obj.Bold = int(MsoTriState.TRUE if bold else MsoTriState.FALSE)
        if italic is not None:
            font_obj.Italic = int(MsoTriState.TRUE if italic else MsoTriState.FALSE)
        if wd_align is not None:
            text_range.ParagraphFormat.Alignment = int(wd_align)
        shape.WrapFormat.Type = int(_WRAP_NAMES[wrap])
        if fill_bgr is not None:
            shape.Fill.Visible = int(MsoTriState.TRUE)
            shape.Fill.Solid()
            shape.Fill.ForeColor.RGB = fill_bgr
        if border is False:
            shape.Line.Visible = int(MsoTriState.FALSE)
        elif border_bgr is not None:
            shape.Line.Visible = int(MsoTriState.TRUE)
            shape.Line.ForeColor.RGB = border_bgr
        # Hand back the new text box's shape:N handle (locate by a unique temp
        # name β€” don't assume "last", other floats can reorder).
        orig_name = str(shape.Name or "")
        probe_name = f"_wl_shape_{secrets.token_hex(8)}"
        shape.Name = probe_name
        index = _shapes.index_of_named(doc_com, probe_name)
        # Restore unconditionally so an empty original name doesn't leave the
        # `_wl_shape_*` probe lingering in list().
        shape.Name = orig_name
    from ._shape_anchors import ShapeAnchor  # lazy: _shape_anchors imports Anchor

    return ShapeAnchor(self._doc, index)

insert_chart

insert_chart(kind: str, data: Any, *, title: str | None = None, where: str = 'after') -> ChartAnchor

Insert an Excel-backed chart at this anchor and return it.

kind is one of "bar" (clustered columns), "pie", "line", or "scatter". data is either an object mapping {label: value} (for bar / pie / line) or an array of [x, y] pairs (for scatter β€” both axes numeric, duplicate x preserved β€” and line). title sets the chart title and series name; None leaves it untitled. where places the chart "after" (default) or "before" this anchor's range.

Charts are Excel-backed: this embeds a chart whose data lives in a hidden Excel workbook, then breaks the link so the data is static β€” no live workbook ships in the document and the series data can't be read back (deferred). Requires Excel installed: raises ExcelNotAvailableError (CLI exit 6), checked up front so the document is untouched on a missing Excel. Raises OpError for malformed data and ValueError for an unknown kind / where.

Word's chart API only inserts off the live Selection, so this moves the cursor to the insertion point; wrap in doc.edit(...) (as the CLI / exec / MCP surfaces do) for atomic undo and to restore the user's selection.

Source code in src/wordlive/_anchors/_anchor_media.py
def insert_chart(
    self,
    kind: str,
    data: Any,
    *,
    title: str | None = None,
    where: str = "after",
) -> ChartAnchor:
    """Insert an Excel-backed chart at this anchor and return it.

    `kind` is one of ``"bar"`` (clustered columns), ``"pie"``, ``"line"``, or
    ``"scatter"``. `data` is either an object mapping ``{label: value}`` (for
    bar / pie / line) or an array of ``[x, y]`` pairs (for ``scatter`` β€” both
    axes numeric, duplicate x preserved β€” and ``line``). `title` sets the
    chart title and series name; ``None`` leaves it untitled. `where` places
    the chart ``"after"`` (default) or ``"before"`` this anchor's range.

    Charts are Excel-backed: this embeds a chart whose data lives in a hidden
    Excel workbook, then breaks the link so the data is **static** β€” no live
    workbook ships in the document and the series data can't be read back
    (deferred). Requires Excel installed: raises `ExcelNotAvailableError`
    (CLI exit 6), checked up front so the document is untouched on a missing
    Excel. Raises `OpError` for malformed `data` and `ValueError` for an
    unknown `kind` / `where`.

    Word's chart API only inserts off the live `Selection`, so this moves the
    cursor to the insertion point; wrap in `doc.edit(...)` (as the CLI / exec
    / MCP surfaces do) for atomic undo and to restore the user's selection.
    """
    if kind not in _charts.KIND_TO_XL:
        raise ValueError(
            f"unknown chart kind {kind!r}; expected one of {sorted(_charts.KIND_TO_XL)}"
        )
    if where not in ("before", "after"):
        raise ValueError(f"where must be 'before' or 'after'; got {where!r}")
    xs, ys = _charts.normalize_chart_data(kind, data)
    if not _charts.probe_excel_available():
        raise ExcelNotAvailableError()
    xl_type = int(_charts.KIND_TO_XL[kind])
    with _com.translate_com_errors():
        doc_com = self._doc.com
        rng = self._range()
        pos = int(rng.Start) if where == "before" else int(rng.End)
        # AddChart2 only works off the Selection, never an arbitrary Range
        # (a Range raises "Requested object is not available"). doc.edit()
        # restores the user's selection on exit.
        doc_com.Range(pos, pos).Select()
        shape = doc_com.Application.Selection.InlineShapes.AddChart2(-1, xl_type)
        try:
            _charts.populate_chart(shape.Chart, kind, xs, ys, title)
        except Exception:
            # Don't leave a half-built placeholder chart behind on failure.
            try:
                shape.Delete()
            except Exception:
                pass
            raise
        index = _chart_index_at(doc_com, int(shape.Range.Start))
    from ._chart_anchors import ChartAnchor  # lazy: _chart_anchors imports Anchor

    return ChartAnchor(self._doc, index)

insert_equation

insert_equation(*, unicodemath: str | None = None, latex: str | None = None, mathml: str | None = None, where: str = 'after', display: bool = True) -> EquationAnchor

Insert a mathematical equation at this anchor and return it.

The equation is given in exactly one of three input dialects:

  • unicodemath= β€” Word's native UnicodeMath linear form, e.g. "x=(-b±√(b^2-4ac))/(2a)" or "a^2+b^2=c^2". Zero-dependency: the string is typed into a math zone and built up into the 2-D form by Word itself.
  • latex= β€” a LaTeX math string, e.g. r"\frac{-b\pm\sqrt{b^2-4ac}}{2a}". Converted LaTeXβ†’MathMLβ†’OMML; the LaTeXβ†’MathML hop needs the optional latex extra (pip install "wordlive[latex]") and raises EquationError without it.
  • mathml= β€” a MathML (<math>…</math>) string. Converted MathMLβ†’OMML through Office's own transform (no extra needed).

The equation always lands on its own paragraph, and that paragraph's style is pinned so it never inherits the style of whatever it was inserted next to (an equation dropped before a Heading 2 used to come out styled Heading 2 and land in the outline/TOC). display (default True) gives it the dedicated centred Equation paragraph style (created on first use, based on Normal β€” a stable hook for later equation numbering); display=False resets the paragraph to Normal and left-aligns it (it is still its own paragraph β€” wordlive does not place math mid-sentence β€” but reads as body text, not centred display math). where is "after" (default) or "before" this anchor's range β€” so doc.headings["Derivation"].insert_equation(...) drops an equation under a heading and doc.end.insert_equation(...) appends one.

Returns an EquationAnchor (equation:N); read it back as MathML with equation.mathml, or discover every equation via doc.equations. Wrap in doc.edit(...) for atomic undo. Raises EquationError for malformed input (none, or more than one, of the three dialects; unparseable MathML/LaTeX; a missing LaTeX backend) and ValueError for a bad where.

Source code in src/wordlive/_anchors/_anchor_media.py
def insert_equation(
    self,
    *,
    unicodemath: str | None = None,
    latex: str | None = None,
    mathml: str | None = None,
    where: str = "after",
    display: bool = True,
) -> EquationAnchor:
    """Insert a mathematical equation at this anchor and return it.

    The equation is given in exactly one of three input dialects:

    - ``unicodemath=`` β€” Word's native **UnicodeMath** linear form, e.g.
      ``"x=(-b±√(b^2-4ac))/(2a)"`` or ``"a^2+b^2=c^2"``. Zero-dependency: the
      string is typed into a math zone and *built up* into the 2-D form by
      Word itself.
    - ``latex=`` β€” a **LaTeX** math string, e.g.
      ``r"\\frac{-b\\pm\\sqrt{b^2-4ac}}{2a}"``. Converted LaTeX→MathML→OMML;
      the LaTeX→MathML hop needs the optional ``latex`` extra
      (`pip install "wordlive[latex]"`) and raises `EquationError` without it.
    - ``mathml=`` β€” a **MathML** (``<math>…</math>``) string. Converted
      MathML→OMML through Office's own transform (no extra needed).

    The equation always lands on its **own paragraph**, and that paragraph's
    style is pinned so it never inherits the style of whatever it was
    inserted next to (an equation dropped before a `Heading 2` used to come
    out *styled* `Heading 2` and land in the outline/TOC). `display` (default
    ``True``) gives it the dedicated centred ``Equation`` paragraph style
    (created on first use, based on ``Normal`` β€” a stable hook for later
    equation numbering); ``display=False`` resets the paragraph to ``Normal``
    and left-aligns it (it is still its own paragraph β€” wordlive does not
    place math mid-sentence β€” but reads as body text, not centred display
    math). `where` is ``"after"`` (default) or ``"before"`` this anchor's
    range β€” so ``doc.headings["Derivation"].insert_equation(...)`` drops an
    equation under a heading and ``doc.end.insert_equation(...)`` appends one.

    Returns an [`EquationAnchor`][wordlive.EquationAnchor] (`equation:N`);
    read it back as MathML with `equation.mathml`, or discover every equation
    via [`doc.equations`][wordlive.Document.equations]. Wrap in
    `doc.edit(...)` for atomic undo. Raises `EquationError` for malformed
    input (none, or more than one, of the three dialects; unparseable
    MathML/LaTeX; a missing LaTeX backend) and `ValueError` for a bad `where`.
    """
    given = [
        name
        for name, value in (
            ("unicodemath", unicodemath),
            ("latex", latex),
            ("mathml", mathml),
        )
        if value is not None
    ]
    if len(given) != 1:
        raise EquationError(
            "insert_equation needs exactly one of unicodemath=, latex=, or mathml="
            + (f"; got {', '.join(given)}" if given else "")
        )
    if where not in ("before", "after"):
        raise ValueError(f"where must be 'before' or 'after'; got {where!r}")
    if unicodemath is not None:
        return self._insert_equation_native(unicodemath, where=where, display=display)
    mathml_src = _equations.latex_to_mathml(latex) if latex is not None else (mathml or "")
    omml_inner = _equations.mathml_to_omml(mathml_src)
    return self._insert_equation_omml(omml_inner, where=where, display=display)

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 (StyleNotFoundError otherwise, before any text is inserted); with no style the new paragraph defaults to Normal rather than inheriting the anchor's style.

Source code in src/wordlive/_anchors/_anchor_insert.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
    (`StyleNotFoundError` otherwise, before any text is inserted); with no
    `style` the new paragraph defaults to ``Normal`` rather than inheriting
    the anchor's style.
    """
    style_obj = self._resolve_insert_style(style)
    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 (StyleNotFoundError otherwise, before any text is inserted); with no style the new paragraph defaults to Normal rather than inheriting the anchor's style.

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/_anchor_insert.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
    (`StyleNotFoundError` otherwise, before any text is inserted); with no
    `style` the new paragraph defaults to ``Normal`` rather than inheriting
    the anchor's style.

    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._resolve_insert_style(style)
    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_block

insert_block(items: list[Any], *, where: str = 'after') -> RangeAnchor

Insert a contiguous run of styled paragraphs at this anchor, atomically.

The multi-paragraph counterpart to insert_paragraph_after β€” drop a whole styled section (a feature list, a set of bullets, a heading plus its body) in one op, in natural reading order. Inserting paragraphs one at a time forces a reverse-order dance to dodge positional-anchor renumbering; this places them all at a single point so order is just the order of items.

Each item is one paragraph, given as either a plain string or a dict:

  • "some text" β€” sugar for {"text": "some text"}.
  • {"text": "**Bold lead** β€” rest", "style": "List Bullet"} β€” text carries the tiny inline markdown (**bold**, *italic*, ***both***, and `code` for a monospace run; escape a literal delimiter with a backslash, \* / `\```), andstyle` names the paragraph style.
  • {"runs": [{"text": "Bold lead", "bold": true}, {"text": " β€” rest"}], "style": "List Bullet"} β€” the structured form: each run is {text, bold?, italic?, underline?, code?, style?} (a per-run character style). Use it when markup is ambiguous or you need a run style.

An item that names no style gets Normal β€” not the insertion point's style. Pass style explicitly to match the surroundings (a paragraph's current style is in doc.paragraphs.list()[i]["style"]).

Returns a RangeAnchor spanning the inserted block (range:START-END), so a follow-up op can target the whole run β€” e.g. apply_list it into a bulleted section, or comment on it. where is "after" (default) or "before" this anchor's range. Resolves every paragraph/run style up front, so an unknown style name raises StyleNotFoundError before any text is inserted. Wrap in doc.edit(...) for atomic undo. Raises OpError for a malformed items payload.

Source code in src/wordlive/_anchors/_anchor_insert.py
def insert_block(self, items: list[Any], *, where: str = "after") -> RangeAnchor:
    """Insert a contiguous run of styled paragraphs at this anchor, atomically.

    The multi-paragraph counterpart to `insert_paragraph_after` β€” drop a
    whole styled section (a feature list, a set of bullets, a heading plus
    its body) in **one** op, in natural reading order. Inserting paragraphs
    one at a time forces a reverse-order dance to dodge positional-anchor
    renumbering; this places them all at a single point so order is just the
    order of `items`.

    Each item is one paragraph, given as either a plain string or a dict:

    - ``"some text"`` β€” sugar for ``{"text": "some text"}``.
    - ``{"text": "**Bold lead** β€” rest", "style": "List Bullet"}`` β€” `text`
      carries the tiny inline markdown (`**bold**`, `*italic*`,
      `***both***`, and `` `code` `` for a monospace run; escape a literal
      delimiter with a backslash, ``\\*`` / ``\\```), and `style` names the
      paragraph style.
    - ``{"runs": [{"text": "Bold lead", "bold": true}, {"text": " β€” rest"}],
      "style": "List Bullet"}`` β€” the structured form: each run is
      ``{text, bold?, italic?, underline?, code?, style?}`` (a per-run character
      style). Use it when markup is ambiguous or you need a run `style`.

    An item that names no `style` gets **`Normal`** β€” not the insertion point's
    style. Pass `style` explicitly to match the surroundings (a paragraph's
    current style is in `doc.paragraphs.list()[i]["style"]`).

    Returns a [`RangeAnchor`][wordlive.RangeAnchor] spanning the inserted
    block (`range:START-END`), so a follow-up op can target the whole run β€”
    e.g. `apply_list` it into a bulleted section, or comment on it. `where`
    is ``"after"`` (default) or ``"before"`` this anchor's range. Resolves
    every paragraph/run style up front, so an unknown style name raises
    `StyleNotFoundError` before any text is inserted. Wrap in `doc.edit(...)`
    for atomic undo. Raises `OpError` for a malformed `items` payload.
    """
    from .._runs import CODE_FONT, normalize_block_items, runs_to_text

    if where not in ("before", "after"):
        raise ValueError(f"where must be 'before' or 'after'; got {where!r}")
    norm = normalize_block_items(items)
    # Resolve every paragraph + run style before touching the document, so a
    # bad name fails the whole block cleanly rather than leaving a partial,
    # half-styled run behind. An item with no `style` is pinned to `Normal`
    # rather than left to inherit the insertion point's style: `insert_section`
    # writes its heading first, so an inheriting body paragraph would come out
    # as a *heading* β€” silently corrupting the outline and shifting every
    # `heading:N`. Same reasoning (and the same pin) as `insert_markdown`.
    default_style = self._doc.styles["Normal"]
    para_styles = [self._doc.styles[s] if s else default_style for _, s in norm]
    run_styles: dict[str, Any] = {}
    for runs, _ in norm:
        for r in runs:
            if r.style and r.style not in run_styles:
                run_styles[r.style] = self._doc.styles[r.style]
    para_texts = [runs_to_text(runs) for runs, _ in norm]
    joined = "\r".join(para_texts)
    with _com.translate_com_errors():
        doc_com = self._doc.com
        if where == "before":
            start = int(self._range().Start)
            doc_com.Range(start, start).Text = joined + "\r"
        else:
            end = int(self._range().End)
            doc_end = int(doc_com.Content.End)
            final_mark = max(0, doc_end - 1)
            if end >= final_mark:
                # We're at the document's terminal paragraph mark β€” the one
                # position you can't write *past* (`doc.end` resolves here,
                # as does an anchor ending at the last paragraph). The old
                # `end >= doc_end` guard missed `doc.end` (whose range ends at
                # doc_end - 1), so the block was written *before* the final
                # mark and merged into a non-empty last paragraph β€” stealing
                # its style too. Decide by whether that final paragraph holds
                # text, so the block neither merges nor leaves a stray empty:
                if _final_paragraph_empty(doc_com, final_mark):
                    # Reuse the empty final paragraph as the block's last one:
                    # write without a trailing break so the existing mark
                    # closes it (no leftover empty paragraph).
                    doc_com.Range(final_mark, final_mark).Text = joined
                    start = final_mark
                else:
                    # Open a fresh paragraph after the final one: the leading
                    # break terminates it and the block becomes new trailing
                    # paragraphs (the original final mark closes the last one).
                    doc_com.Range(final_mark, final_mark).Text = "\r" + joined
                    start = final_mark + 1
            else:
                doc_com.Range(end, end).Text = joined + "\r"
                start = end
        span_end = start + _utf16_len(joined)
        # Paragraph styling and run formatting both preserve text length, so
        # offsets stay valid throughout: walk the block by deterministic
        # UTF-16 offset (Word counts code units) rather than re-querying
        # Paragraphs() after each mutation.
        off = start
        for (runs, _), style_obj, ptext in zip(norm, para_styles, para_texts, strict=True):
            plen = _utf16_len(ptext)
            if style_obj is not None:
                doc_com.Range(off, off + plen).Paragraphs(1).Range.Style = style_obj.com
            roff = off
            for run in runs:
                rlen = _utf16_len(run.text)
                if run.formatted():
                    sub = doc_com.Range(roff, roff + rlen)
                    if run.bold is not None:
                        sub.Bold = bool(run.bold)
                    if run.italic is not None:
                        sub.Italic = bool(run.italic)
                    if run.underline is not None:
                        sub.Underline = 1 if run.underline else 0
                    if run.code:
                        # Direct font, not a character style β€” `**bold**`
                        # sets Font.Bold rather than applying `Strong`, and a
                        # code span follows suit. Set before `style` so an
                        # explicit character style still wins.
                        sub.Font.Name = CODE_FONT
                    if run.style:
                        sub.Style = run_styles[run.style].com
                roff += rlen
            off += plen + 1  # + the paragraph mark (CR)
    from ._range import RangeAnchor  # lazy: _range imports Anchor

    return RangeAnchor(self._doc, start, span_end)

insert_section

insert_section(heading: str, body: Any, *, level: int = 1, where: str = 'after') -> RangeAnchor

Insert a heading plus its body in one atomic op.

The opinionated common case over insert_block: a single Heading {level} paragraph followed by body, placed in reading order at one point. heading carries the same inline markdown a block item's text does (**bold**, *italic*); body is the insert_block items shape β€” a list of plain strings or {text|runs, style?} dicts (a bare string is sugar for a one-paragraph body). level is 1–9 and selects the built-in Heading {level} style (validated before any mutation; an absent style raises StyleNotFoundError via insert_block).

A body item that names no style gets Normal (see insert_block) β€” it does not inherit the heading just written above it, nor the anchor's own style. Give an item an explicit style to override that.

Returns the section's spanning RangeAnchor (range:START-END). Wrap in doc.edit(...) for atomic undo.

Source code in src/wordlive/_anchors/_anchor_insert.py
def insert_section(
    self, heading: str, body: Any, *, level: int = 1, where: str = "after"
) -> RangeAnchor:
    """Insert a heading plus its body in one atomic op.

    The opinionated common case over `insert_block`: a single
    ``Heading {level}`` paragraph followed by `body`, placed in reading
    order at one point. `heading` carries the same inline markdown a block
    item's `text` does (`**bold**`, `*italic*`); `body` is the `insert_block`
    items shape β€” a list of plain strings or ``{text|runs, style?}`` dicts
    (a bare string is sugar for a one-paragraph body). `level` is 1–9 and
    selects the built-in ``Heading {level}`` style (validated before any
    mutation; an absent style raises `StyleNotFoundError` via `insert_block`).

    A body item that names no `style` gets **`Normal`** (see `insert_block`) β€”
    it does not inherit the heading just written above it, nor the anchor's own
    style. Give an item an explicit `style` to override that.

    Returns the section's spanning [`RangeAnchor`][wordlive.RangeAnchor]
    (`range:START-END`). Wrap in `doc.edit(...)` for atomic undo.
    """
    if not isinstance(level, int) or isinstance(level, bool) or not 1 <= level <= 9:
        raise ValueError(f"level must be an integer 1–9; got {level!r}")
    if isinstance(body, str):
        body = [body]
    if not isinstance(body, list):
        raise OpError(
            f"insert_section body must be a string or list; got {type(body).__name__}"
        )
    items = [{"text": heading, "style": f"Heading {level}"}, *body]
    return self.insert_block(items, where=where)

insert_markdown

insert_markdown(md: str, *, where: str = 'after') -> RangeAnchor

Insert a constrained-Markdown block as real Word structure, atomically.

Maps a deliberately tiny block dialect (see _markdown) to paragraphs, headings, and lists: #/##/### β†’ Heading 1/2/3, -/* β†’ a bulleted list, 1. β†’ a numbered list, blank-line-separated text β†’ Normal paragraphs, with inline **bold**/*italic* spans honoured. It is a subset, not CommonMark β€” no code fences, nested lists, block quotes, or tables in v1; anything unrecognised is literal paragraph text.

The whole block is one insert_block (one contiguous write); each same-kind list run is then apply_list-ed over its own span, so a numbered list reads 1..N. where is "after" (default) or "before" this anchor's range. Returns the RangeAnchor spanning everything inserted. Raises OpError for empty markdown.

Source code in src/wordlive/_anchors/_anchor_insert.py
def insert_markdown(self, md: str, *, where: str = "after") -> RangeAnchor:
    """Insert a constrained-Markdown block as real Word structure, atomically.

    Maps a deliberately tiny block dialect (see `_markdown`) to paragraphs,
    headings, and lists: ``#``/``##``/``###`` β†’ `Heading 1/2/3`, ``-``/``*``
    β†’ a bulleted list, ``1.`` β†’ a numbered list, blank-line-separated text β†’
    `Normal` paragraphs, with inline ``**bold**``/``*italic*`` spans honoured.
    It is **a subset, not CommonMark** β€” no code fences, nested lists, block
    quotes, or tables in v1; anything unrecognised is literal paragraph text.

    The whole block is one `insert_block` (one contiguous write); each
    same-kind list run is then `apply_list`-ed over its own span, so a
    numbered list reads 1..N. `where` is ``"after"`` (default) or ``"before"``
    this anchor's range. Returns the [`RangeAnchor`][wordlive.RangeAnchor]
    spanning everything inserted. Raises `OpError` for empty markdown.
    """
    from .._markdown import parse_markdown
    from .._runs import normalize_block_items, runs_to_text

    blocks = parse_markdown(md)
    if not blocks:
        raise OpError("insert_markdown requires non-empty markdown")
    # Flatten every block into ONE insert_block (a single contiguous write β€”
    # chaining separate inserts would land each list before the previous
    # block's paragraph mark and merge them). Record which paragraph runs are
    # lists so we can apply_list over their spans afterwards.
    segments = _markdown_segments(blocks)
    items: list[dict[str, Any]] = []
    list_groups: list[tuple[int, int, str]] = []  # (first_para, last_para, list_type)
    for seg_items, list_type in segments:
        start_idx = len(items)
        items.extend(seg_items)
        if list_type is not None:
            list_groups.append((start_idx, len(items) - 1, list_type))
    rng = self.insert_block(items, where=where)
    if not list_groups:
        return rng
    # Recompute each paragraph's offset exactly as insert_block walks them
    # (UTF-16 text length + one CR each, from the block's start), so a list
    # group's span can be addressed without re-querying the document.
    texts = [runs_to_text(runs) for runs, _ in normalize_block_items(items)]
    offsets: list[int] = []
    off = rng.start
    for t in texts:
        offsets.append(off)
        off += _utf16_len(t) + 1
    for first, last, list_type in list_groups:
        from ._range import RangeAnchor  # lazy: _range imports Anchor

        span = RangeAnchor(self._doc, offsets[first], offsets[last] + _utf16_len(texts[last]))
        span.apply_list(list_type)
    return rng

insert_table

insert_table(rows: int | None = None, cols: int | None = None, *, where: str = 'after', style: str | None = None, data: 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 and can be given two ways:

  • a row-major 2-D list ([[r1c1, r1c2], …]); or
  • records β€” a list of dicts ([{"Item": "Travel", "Cost": "$400"}, …]), where the first record's keys become a header row and each dict a body row (so header is forced on). The natural shape for tabular data an LLM already has as rows of objects.

When data is given, rows/cols are optional β€” they're inferred from the data's shape β€” so the common case is just end.insert_table(data=…). Pass them explicitly to pad the grid larger than the data; data is validated against the final rows Γ— cols up front (OpError on overflow) and a short payload leaves the trailing cells empty. Filling at creation keeps the whole grid in one atomic undo and beats a set_cell storm. With no data, both rows and cols are required.

header=True bolds the first row as a header (records imply it). Wrap in doc.edit(...) for atomic undo. Raises ValueError for an unknown where and OpError for a non-positive rows/cols, a missing dimension with no data to infer it from, or a bad data shape.

Source code in src/wordlive/_anchors/_anchor_insert.py
def insert_table(
    self,
    rows: int | None = None,
    cols: int | None = None,
    *,
    where: str = "after",
    style: str | None = None,
    data: 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 and can be given two ways:

    - a **row-major 2-D list** (``[[r1c1, r1c2], …]``); or
    - **records** β€” a list of dicts (``[{"Item": "Travel", "Cost": "$400"},
      …]``), where the first record's keys become a header row and each
      dict a body row (so `header` is forced on). The natural shape for
      tabular data an LLM already has as rows of objects.

    When `data` is given, `rows`/`cols` are **optional** β€” they're inferred
    from the data's shape β€” so the common case is just
    ``end.insert_table(data=…)``. Pass them explicitly to pad the grid
    larger than the data; `data` is validated against the final `rows` Γ—
    `cols` up front (`OpError` on overflow) and a short payload leaves the
    trailing cells empty. Filling at creation keeps the whole grid in one
    atomic undo and beats a `set_cell` storm. With no `data`, both `rows`
    and `cols` are required.

    `header=True` bolds the first row as a header (records imply it). Wrap
    in `doc.edit(...)` for atomic undo. Raises `ValueError` for an unknown
    `where` and `OpError` for a non-positive `rows`/`cols`, a missing
    dimension with no data to infer it from, 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}")
    # Normalise data first so rows/cols can be inferred from its shape.
    grid: list[list[Any]] | None = None
    if data is not None:
        grid, header_from_data = _normalize_table_data(data)
        header = header or header_from_data
        if rows is None:
            rows = len(grid)
        if cols is None:
            cols = max((len(r) for r in grid), default=0)
    if rows is None or cols is None:
        raise OpError("insert_table needs rows and cols, or a data payload to infer them from")
    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 grid is not None:
        _validate_table_data(grid, 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
        normal_com = normal_obj.com if normal_obj is not None else None
        rng = self._range()
        pos = int(rng.Start) if where == "before" else int(rng.End)
        # Track which empty separators we open so their paragraph style can
        # be reset *after* the table exists (addressed relative to the table,
        # since offsets shift as it's built). Like the new cells, an injected
        # mark inherits the paragraph style at the insertion point β€” anchor a
        # table under a `Heading 2` and, left as-is, these become empty
        # heading paragraphs that clutter the navigation outline.
        opened_before = opened_after = False
        # 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"  # lands after the table
            opened_after = True
        # 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"  # lands before the table
            pos += 1
            opened_before = True
        if _within_table(doc_com, pos, pos + 1):
            doc_com.Range(pos, pos).Text = "\r"  # lands after the table
            opened_after = True
        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_com 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.
            for r in range(1, rows + 1):
                for c in range(1, cols + 1):
                    table_com.Cell(r, c).Range.Style = normal_com

            def _reset_empty_run(at: int, forward: bool) -> None:
                # Walk the run of empty, non-table paragraphs abutting the
                # table and reset each to `Normal`, stopping at the first
                # non-empty paragraph, a table, or a document boundary. The
                # terminal guard leaves *two* stray marks (the injected one
                # plus the document's original final mark), so a single reset
                # isn't enough; the small guard count caps any runaway.
                limit = int(doc_com.Content.End)
                for _ in range(8):
                    if not (0 <= at < limit if forward else 0 <= at):
                        break
                    para = doc_com.Range(at, at).Paragraphs(1).Range
                    if para.Information(12):  # wdWithInTable
                        break
                    if str(para.Text or "").strip("\r\x07 \t"):
                        break
                    para.Style = normal_com
                    at = int(para.End) if forward else int(para.Start) - 1

            t_rng = table_com.Range
            if opened_after:
                _reset_empty_run(int(t_rng.End), forward=True)
            if opened_before:
                _reset_empty_run(int(t_rng.Start) - 1, forward=False)
        if grid:
            for r, row in enumerate(grid, 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/_anchor_insert.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

insert_field

insert_field(kind: str, *, text: str | None = None, where: str = 'after') -> None

Insert a Word field at this anchor β€” a self-updating value, not literal text.

A field shows a computed value Word keeps current: a page number, the page count, today's date, the file name, a document property. The named kinds are:

  • "page" β€” the current page number ({ PAGE }).
  • "numpages" β€” the total page count ({ NUMPAGES }); pair with "page" for "Page X of Y".
  • "date" / "time" β€” the current date / time.
  • "filename" β€” the document's file name.
  • "author" / "title" β€” document-property fields.

For anything else, kind="field" is the escape hatch: pass the raw field code as text (e.g. insert_field("field", text="REF myBookmark \\h")) and Word inserts an empty field carrying that code.

Page numbers belong in a header or footer β€” because a HeaderFooter is an anchor, doc.sections[1].footer().insert_field("page") works, and HeaderFooter.insert_page_number() is the sugar for it. Newly inserted fields render once; call Document.update_fields() (or take a snapshot, which repaginates) to refresh them after later edits.

where is "after" (default) or "before" this anchor's range. Bad input raises OpError. Wrap in doc.edit(...) for atomic undo.

Source code in src/wordlive/_anchors/_anchor_insert.py
def insert_field(self, kind: str, *, text: str | None = None, where: str = "after") -> None:
    """Insert a Word field at this anchor β€” a self-updating value, not literal text.

    A field shows a computed value Word keeps current: a page number, the
    page count, today's date, the file name, a document property. The named
    kinds are:

    - ``"page"`` β€” the current page number (`{ PAGE }`).
    - ``"numpages"`` β€” the total page count (`{ NUMPAGES }`); pair with
      ``"page"`` for "Page X of Y".
    - ``"date"`` / ``"time"`` β€” the current date / time.
    - ``"filename"`` β€” the document's file name.
    - ``"author"`` / ``"title"`` β€” document-property fields.

    For anything else, ``kind="field"`` is the escape hatch: pass the raw
    field code as `text` (e.g.
    ``insert_field("field", text="REF myBookmark \\\\h")``) and Word inserts an
    empty field carrying that code.

    Page numbers belong in a header or footer β€” because a `HeaderFooter`
    *is* an anchor, ``doc.sections[1].footer().insert_field("page")`` works,
    and [`HeaderFooter.insert_page_number()`][wordlive.HeaderFooter] is the
    sugar for it. Newly inserted fields render once; call
    [`Document.update_fields()`][wordlive.Document] (or take a `snapshot`,
    which repaginates) to refresh them after later edits.

    `where` is ``"after"`` (default) or ``"before"`` this anchor's range.
    Bad input raises `OpError`. Wrap in `doc.edit(...)` for atomic undo.
    """
    try:
        if where not in ("before", "after"):
            raise ValueError(f"where must be 'before' or 'after'; got {where!r}")
        wd_type = _coerce_named(kind, _FIELD_TYPES, "field kind")
        if wd_type == int(WdFieldType.EMPTY) and not text:
            raise ValueError(
                'field kind "field" requires the raw field code via text= (e.g. text="PAGE")'
            )
        with _com.translate_com_errors():
            # Collapse a *duplicate* of the anchor's own range, so the field
            # lands in the same story β€” critical for header/footer anchors,
            # whose offsets are not main-document positions (a `doc.Range`
            # there would target the body instead).
            insert_rng = self._range().Duplicate
            insert_rng.Collapse(
                int(WdCollapseDirection.START if where == "before" else WdCollapseDirection.END)
            )
            # Positional args: the Type=/Text= keywords are dropped under
            # pywin32 late binding (same gotcha as TabStops.Add / Footnotes).
            if text is not None:
                insert_rng.Fields.Add(insert_rng, wd_type, text)
            else:
                insert_rng.Fields.Add(insert_rng, wd_type)
    except (ValueError, TypeError) as e:
        raise OpError(str(e)) from e

insert_footnote

insert_footnote(text: str, *, where: str = 'after') -> Any

Insert a footnote at this anchor and return it as a Footnote anchor.

A footnote drops a reference mark in the main text and puts text in the note body at the bottom of the page; Word auto-numbers the mark. The returned Footnote is addressed footnote:N, so note.set_text(...) edits the body and note.delete() removes the mark and body together. Discover existing footnotes with doc.footnotes.

where is "after" (default) or "before" this anchor's range β€” the side the reference mark lands on. Wrap in doc.edit(...) for atomic undo. Bad input raises OpError.

Source code in src/wordlive/_anchors/_anchor_insert.py
def insert_footnote(self, text: str, *, where: str = "after") -> Any:
    """Insert a footnote at this anchor and return it as a `Footnote` anchor.

    A footnote drops a reference mark in the main text and puts `text` in the
    note body at the bottom of the page; Word auto-numbers the mark. The
    returned [`Footnote`][wordlive.Footnote] is addressed `footnote:N`, so
    `note.set_text(...)` edits the body and `note.delete()` removes the mark
    and body together. Discover existing footnotes with
    [`doc.footnotes`][wordlive.Document.footnotes].

    `where` is ``"after"`` (default) or ``"before"`` this anchor's range β€”
    the side the reference mark lands on. Wrap in `doc.edit(...)` for atomic
    undo. Bad input raises `OpError`.
    """
    return self._insert_note("Footnotes", "footnote", text, where=where)

insert_endnote

insert_endnote(text: str, *, where: str = 'after') -> Any

Insert an endnote at this anchor and return it as an Endnote anchor.

The endnote mirror of insert_footnote: the reference mark lands in the main text and text collects at the end of the document (or section). The returned Endnote is addressed endnote:N; discover existing endnotes with doc.endnotes.

where is "after" (default) or "before" this anchor's range. Wrap in doc.edit(...) for atomic undo. Bad input raises OpError.

Source code in src/wordlive/_anchors/_anchor_insert.py
def insert_endnote(self, text: str, *, where: str = "after") -> Any:
    """Insert an endnote at this anchor and return it as an `Endnote` anchor.

    The endnote mirror of [`insert_footnote`][wordlive.Anchor.insert_footnote]:
    the reference mark lands in the main text and `text` collects at the end
    of the document (or section). The returned
    [`Endnote`][wordlive.Endnote] is addressed `endnote:N`; discover existing
    endnotes with [`doc.endnotes`][wordlive.Document.endnotes].

    `where` is ``"after"`` (default) or ``"before"`` this anchor's range.
    Wrap in `doc.edit(...)` for atomic undo. Bad input raises `OpError`.
    """
    return self._insert_note("Endnotes", "endnote", text, where=where)

insert_content_control

insert_content_control(kind: str = 'rich_text', *, title: str | None = None, tag: str | None = None, items: list[Any] | None = None, where: str = 'wrap', lock_contents: bool = False, lock_control: bool = False) -> ContentControl

Wrap (or insert) a content control at this anchor and return it.

Content controls are the building blocks of form-like documents β€” a labelled region the user fills in. kind selects the type: "rich_text" (the default β€” formatted text), "text" (plain text), "picture", "combo_box" / "dropdown" (a pick list β€” pass items), "date", "checkbox" (Word 2013+), "building_block", "group", or "repeating_section" (Word 2013+).

where is "wrap" (default β€” the control surrounds this anchor's existing range, so a range:START-END from find wraps that phrase) or "before" / "after" (insert a fresh empty control at the anchor's start / end). Set title and/or tag to give the control a name: a titled control is addressable later as cc:TITLE (falling back to the tag) and shows a label in Word's UI. items populates a combo box or dropdown β€” each is a string, or a {"text": ..., "value": ...} dict. lock_contents stops the user editing the value; lock_control stops them deleting the control.

Returns the new ContentControl (usable even when unnamed β€” it caches the live control). Wrap in doc.edit(...) for atomic undo. Bad input raises OpError.

Source code in src/wordlive/_anchors/_anchor_insert.py
def insert_content_control(
    self,
    kind: str = "rich_text",
    *,
    title: str | None = None,
    tag: str | None = None,
    items: list[Any] | None = None,
    where: str = "wrap",
    lock_contents: bool = False,
    lock_control: bool = False,
) -> ContentControl:
    """Wrap (or insert) a content control at this anchor and return it.

    Content controls are the building blocks of form-like documents β€” a
    labelled region the user fills in. `kind` selects the type:
    ``"rich_text"`` (the default β€” formatted text), ``"text"`` (plain text),
    ``"picture"``, ``"combo_box"`` / ``"dropdown"`` (a pick list β€” pass
    `items`), ``"date"``, ``"checkbox"`` (Word 2013+), ``"building_block"``,
    ``"group"``, or ``"repeating_section"`` (Word 2013+).

    `where` is ``"wrap"`` (default β€” the control surrounds this anchor's
    existing range, so a `range:START-END` from `find` wraps that phrase) or
    ``"before"`` / ``"after"`` (insert a fresh empty control at the anchor's
    start / end). Set `title` and/or `tag` to give the control a name: a
    titled control is addressable later as `cc:TITLE` (falling back to the
    tag) and shows a label in Word's UI. `items` populates a combo box or
    dropdown β€” each is a string, or a `{"text": ..., "value": ...}` dict.
    `lock_contents` stops the user editing the value; `lock_control` stops
    them deleting the control.

    Returns the new [`ContentControl`][wordlive.ContentControl] (usable even
    when unnamed β€” it caches the live control). Wrap in `doc.edit(...)` for
    atomic undo. Bad input raises `OpError`.
    """
    try:
        if where not in ("wrap", "before", "after"):
            raise ValueError(f"where must be 'wrap', 'before', or 'after'; got {where!r}")
        try:
            cc_type = _CC_TYPE_NAMES[str(kind).lower()]
        except (KeyError, AttributeError) as e:
            raise ValueError(
                f"unknown content control kind {kind!r}; "
                f"expected one of {sorted(_CC_TYPE_NAMES)}"
            ) from e
        if items is not None and cc_type not in _CC_LIST_TYPES:
            raise ValueError("items is only valid for a 'combo_box' or 'dropdown' control")
        with _com.translate_com_errors():
            target = self._range().Duplicate
            if where != "wrap":
                target.Collapse(
                    int(
                        WdCollapseDirection.START
                        if where == "before"
                        else WdCollapseDirection.END
                    )
                )
            # Positional args (Type, Range) for late-binding safety.
            cc = self._doc.com.ContentControls.Add(int(cc_type), target)
            if title is not None:
                cc.Title = str(title)
            if tag is not None:
                cc.Tag = str(tag)
            if lock_contents:
                cc.LockContents = True
            if lock_control:
                cc.LockContentControl = True
            if items:
                entries = cc.DropdownListEntries
                for entry_text, value in _normalize_cc_items(items):
                    entries.Add(entry_text, value)  # positional (Text, Value)
        name = title or tag or ""
        from ._content_controls import ContentControl  # lazy: _content_controls imports Anchor

        return ContentControl(self._doc, name, com=cc)
    except (ValueError, TypeError) as e:
        raise OpError(str(e)) from e

wordlive.Bookmark

Bookmark(doc: Document, name: str)

Bases: Anchor

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

pin classmethod

pin(doc: Document, code: str) -> Bookmark

A Bookmark over the hidden _wl_<code> pin, reporting pin:<code>.

Source code in src/wordlive/_anchors/_bookmarks.py
@classmethod
def pin(cls, doc: Document, code: str) -> Bookmark:
    """A `Bookmark` over the hidden `_wl_<code>` pin, reporting `pin:<code>`."""
    bm = cls(doc, _pin_name_for(code))
    bm._pin_code = code
    return bm

wordlive.ContentControl

ContentControl(doc: Document, name: str, *, com: Any | None = None)

Bases: Anchor

Source code in src/wordlive/_anchors/_content_controls.py
def __init__(self, doc: Document, name: str, *, com: Any | None = None) -> None:
    super().__init__(doc, name)
    # A freshly created control caches its live COM object, so the returned
    # wrapper resolves even when unnamed (and survives edits β€” Word maintains
    # the control's identity). Named lookups still go through `_cc_by_name`.
    self._cc_com = com

set_properties

set_properties(*, title: Any = _charts._UNSET, tag: Any = _charts._UNSET, lock_contents: bool | None = None, lock_control: bool | None = None) -> ContentControl

Re-set this control's metadata in place β€” no delete + reinsert.

Tri-state: omit a field to leave it untouched, pass a string to set it, or None (equivalently "") to clear title / tag. lock_contents stops the user editing the value; lock_control stops them deleting the control β€” pass a bool to set either, omit to leave. Renaming the title (or the tag, when untitled) changes the control's cc:NAME anchor id; the returned handle keeps working regardless. Returns self (chainable); wrap in doc.edit(...) for atomic undo. Bad input raises OpError.

Source code in src/wordlive/_anchors/_content_controls.py
def set_properties(
    self,
    *,
    title: Any = _charts._UNSET,
    tag: Any = _charts._UNSET,
    lock_contents: bool | None = None,
    lock_control: bool | None = None,
) -> ContentControl:
    """Re-set this control's metadata in place β€” no delete + reinsert.

    Tri-state: omit a field to leave it untouched, pass a string to set it,
    or `None` (equivalently ``""``) to clear `title` / `tag`. `lock_contents`
    stops the user editing the value; `lock_control` stops them deleting the
    control β€” pass a bool to set either, omit to leave. Renaming the title
    (or the tag, when untitled) changes the control's `cc:NAME` anchor id;
    the returned handle keeps working regardless. Returns `self` (chainable);
    wrap in `doc.edit(...)` for atomic undo. Bad input raises `OpError`.
    """
    try:
        with _com.translate_com_errors():
            cc = self._cc()
            if title is not _charts._UNSET:
                cc.Title = "" if title is None else str(title)
            if tag is not _charts._UNSET:
                cc.Tag = "" if tag is None else str(tag)
            if lock_contents is not None:
                cc.LockContents = bool(lock_contents)
            if lock_control is not None:
                cc.LockContentControl = bool(lock_control)
            if title is not _charts._UNSET or tag is not _charts._UNSET:
                # Keep anchor_id honest after a rename (title beats tag).
                self.name = str(cc.Title or cc.Tag or "")
    except (ValueError, TypeError) as e:
        raise OpError(str(e)) from e
    return self

set_items

set_items(items: list[Any]) -> ContentControl

Replace a combo-box / dropdown's choice list β€” no delete + reinsert.

items is the full new list (it replaces the existing entries, not appends); each is a string or a {"text": ..., "value": ...} dict. Only valid on a combo_box / dropdown control β€” any other kind raises OpError. Returns self (chainable); wrap in doc.edit(...) for atomic undo. Bad input raises OpError.

Source code in src/wordlive/_anchors/_content_controls.py
def set_items(self, items: list[Any]) -> ContentControl:
    """Replace a combo-box / dropdown's choice list β€” no delete + reinsert.

    `items` is the full new list (it replaces the existing entries, not
    appends); each is a string or a `{"text": ..., "value": ...}` dict.
    Only valid on a ``combo_box`` / ``dropdown`` control β€” any other kind
    raises `OpError`. Returns `self` (chainable); wrap in `doc.edit(...)`
    for atomic undo. Bad input raises `OpError`.
    """
    try:
        with _com.translate_com_errors():
            cc = self._cc()
            if cc.Type not in _CC_LIST_TYPES:
                raise ValueError(
                    "set_items is only valid for a 'combo_box' or 'dropdown' control"
                )
            pairs = _normalize_cc_items(items)
            entries = cc.DropdownListEntries
            entries.Clear()
            for entry_text, value in pairs:
                entries.Add(entry_text, value)  # positional (Text, Value)
    except (ValueError, TypeError) as e:
        raise OpError(str(e)) from e
    return self

wordlive.Heading

Heading(doc: Document, name: str)

Bases: Anchor

Source code in src/wordlive/_anchors/_anchor_core.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/_headings.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/_headings.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 "")

replace_section_body

replace_section_body(body: Any, *, markdown: bool = False) -> RangeAnchor

Rewrite this heading's body, leaving the heading paragraph intact.

The "rewrite section X" workflow: clears the span under this heading (section_range, up to the next same-or-higher heading) and inserts body after the heading. With markdown=False (default) body is the insert_block items shape (or a bare string); with markdown=True body is a constrained-Markdown string routed through insert_markdown. Returns the new body's spanning RangeAnchor. Wrap in doc.edit(...) for atomic undo.

Source code in src/wordlive/_anchors/_headings.py
def replace_section_body(self, body: Any, *, markdown: bool = False) -> RangeAnchor:
    """Rewrite this heading's body, leaving the heading paragraph intact.

    The "rewrite section X" workflow: clears the span under this heading
    (`section_range`, up to the next same-or-higher heading) and inserts
    `body` after the heading. With ``markdown=False`` (default) `body` is the
    `insert_block` items shape (or a bare string); with ``markdown=True``
    `body` is a constrained-Markdown string routed through `insert_markdown`.
    Returns the new body's spanning [`RangeAnchor`][wordlive.RangeAnchor].
    Wrap in `doc.edit(...)` for atomic undo.
    """
    with _com.translate_com_errors():
        span = self.section_range()
        doc_com = self._doc.com
        doc_com.Range(int(span.Start), int(span.End)).Delete()
    if markdown:
        if not isinstance(body, str):
            raise OpError("replace_section_body with markdown=True requires a string body")
        return self.insert_markdown(body, where="after")
    if isinstance(body, str):
        body = [body]
    if not isinstance(body, list):
        raise OpError(
            f"replace_section_body body must be a string or list; got {type(body).__name__}"
        )
    return self.insert_block(body, where="after")

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/_headings.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/_headings.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/_paragraphs.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/_paragraphs.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/_paragraphs.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, style, is_heading, start, end, text}, ...].

style is the paragraph's applied Word style name (e.g. "Normal", "List Number", "Heading 2") β€” the handle to feed back into apply_style / a write's style= to mirror existing formatting, since level (Word's OutlineLevel) is 10 for all non-heading paragraphs and so can't distinguish a list item from body text. It's None if the style can't be read.

Source code in src/wordlive/_anchors/_paragraphs.py
def list(self) -> list[dict[str, Any]]:
    """Every paragraph as `[{index, anchor_id, level, style, is_heading, start, end, text}, ...]`.

    `style` is the paragraph's applied Word style name (e.g. ``"Normal"``,
    ``"List Number"``, ``"Heading 2"``) β€” the handle to feed back into
    `apply_style` / a write's `style=` to mirror existing formatting, since
    `level` (Word's `OutlineLevel`) is `10` for *all* non-heading paragraphs
    and so can't distinguish a list item from body text. It's `None` if the
    style can't be read.
    """
    out: list[dict[str, Any]] = []
    with _com.translate_com_errors():
        # Ask the document once whether any inline shape exists. If none does, no
        # paragraph range can hold one, and each row's text read skips fetching
        # (and wrapping) a per-paragraph `InlineShapes` collection. On failure,
        # assume they may exist and let the per-range probe decide.
        try:
            may_have_shapes = int(self._doc.com.InlineShapes.Count) > 0
        except Exception:
            may_have_shapes = True
        for idx, para in enumerate(self._doc.com.Paragraphs, start=1):
            # Fetch `para.Range` once. Each access mints a fresh COM object that
            # pywin32 must wrap (a QueryInterface + type lookup), which dominates
            # this walk β€” three fetches per paragraph made it ~3x its true cost.
            rng = para.Range
            try:
                level = int(para.OutlineLevel)
            except Exception:
                level = 10
            try:
                style: str | None = str(rng.Style.NameLocal)
            except Exception:
                style = None
            out.append(
                {
                    "index": idx,
                    "anchor_id": f"para:{idx}",
                    "level": level,
                    "style": style,
                    "is_heading": level < 10,
                    "start": int(rng.Start),
                    "end": int(rng.End),
                    "text": range_text(rng, may_have_shapes=may_have_shapes).rstrip("\r\n\x07"),
                }
            )
    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/_range.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/_range.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/_range.py
def __init__(self, doc: Document) -> None:
    super().__init__(doc, name="end")

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.

Lists & numbering

List operations apply to a range's paragraphs, so the verbs live on Anchor β€” apply_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.

Custom list formats. Where apply_list only applies a gallery default, anchor.apply_list_format(levels) authors a custom multi-level list template and applies it. levels is a 1-based list of per-level spec dicts β€” each setting the marker format ("%1.", "%1)", "%1.%2"), number style ("arabic", "upper-roman", "lower-letter", …) or bullet glyph + font, plus start_at / number_position / text_position / trailing / alignment / bold / color. More than one level mints an outline template. anchor.read_list_levels() is the read mirror β€” one {level, kind, format, number_style, style, trailing, number_position, text_position, font} dict per template level (number_style is the raw WdListNumberStyle int). A multi-level number level authored without an explicit format keeps Word's built-in outline default; hierarchical numbering (%1.%2.%3.) still needs an explicit format.

with doc.edit("custom numbering"):
    doc.headings["Steps"].apply_list_format([
        {"kind": "number", "format": "%1)", "style": "lower-letter", "trailing": "space"},
        {"kind": "bullet", "bullet": "–", "font": "Symbol"},
    ])

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

Styles

Styles are document-scoped handles. Document.styles is a StyleCollection; apply styles to anchors via Anchor.apply_style. Define a new style with doc.styles.add(name, type="paragraph", based_on=…, next_style=…), which returns a writable Style: set its defaults with style.format_run(…) / style.format_paragraph(…) (the same kwargs as the anchor methods, minus highlight) and chain styles via style.base_style / style.next_paragraph_style. The brand/template workflow: add a house style once, then apply_style it everywhere.

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.

base_style property writable

base_style: str | None

The name of the style this one inherits from (None if unset).

next_paragraph_style property writable

next_paragraph_style: str | None

The name of the style applied to the next paragraph (None if unset).

format_run

format_run(**kwargs: Any) -> None

Set this style's character (font) defaults.

Same kwargs as Anchor.format_run β€” bold/italic/underline/font/size/color/… β€” minus highlight (a style's Font has no highlight property). Tri-state: only the kwargs you pass are written. Bad input raises OpError.

Source code in src/wordlive/_styles.py
def format_run(self, **kwargs: Any) -> None:
    """Set this style's character (font) defaults.

    Same kwargs as [`Anchor.format_run`][wordlive.Anchor.format_run] β€”
    `bold`/`italic`/`underline`/`font`/`size`/`color`/… β€” minus `highlight`
    (a style's `Font` has no highlight property). Tri-state: only the kwargs
    you pass are written. Bad input raises `OpError`.
    """
    from ._anchors import _apply_font

    font_name = kwargs.pop("font", None)
    if "highlight" in kwargs:
        raise OpError("a style's font has no highlight; set highlight on a range instead")
    try:
        with _com.translate_com_errors():
            _apply_font(self.com.Font, font_name=font_name, **kwargs)
    except (ValueError, TypeError) as e:
        raise OpError(str(e)) from e

format_paragraph

format_paragraph(**kwargs: Any) -> None

Set this style's paragraph defaults.

Same kwargs as Anchor.format_paragraph (alignment, indents, spacing, page_break_before). Tri-state. Bad input raises OpError.

Source code in src/wordlive/_styles.py
def format_paragraph(self, **kwargs: Any) -> None:
    """Set this style's paragraph defaults.

    Same kwargs as
    [`Anchor.format_paragraph`][wordlive.Anchor.format_paragraph]
    (`alignment`, indents, spacing, `page_break_before`). Tri-state. Bad
    input raises `OpError`.
    """
    from ._anchors import _apply_paragraph_format

    try:
        with _com.translate_com_errors():
            _apply_paragraph_format(self.com.ParagraphFormat, **kwargs)
    except (ValueError, TypeError) as e:
        raise OpError(str(e)) from e

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
        ]

add

add(name: str, *, type: str = 'paragraph', based_on: str | None = None, next_style: str | None = None) -> Style

Define a new style and return it as a writable Style.

type is "paragraph" (default), "character", "table", or "list". based_on and next_style are names of existing styles (the inheritance parent and the style applied to the following paragraph). The brand / template primitive: define a house style once, then apply_style it everywhere. Style its defaults via the returned object's format_run(...) / format_paragraph(...). Bad type raises OpError; an unknown based_on / next_style raises StyleNotFoundError.

Source code in src/wordlive/_styles.py
def add(
    self,
    name: str,
    *,
    type: str = "paragraph",
    based_on: str | None = None,
    next_style: str | None = None,
) -> Style:
    """Define a new style and return it as a writable `Style`.

    `type` is `"paragraph"` (default), `"character"`, `"table"`, or `"list"`.
    `based_on` and `next_style` are names of existing styles (the inheritance
    parent and the style applied to the following paragraph). The brand /
    template primitive: define a house style once, then `apply_style` it
    everywhere. Style its defaults via the returned object's
    `format_run(...)` / `format_paragraph(...)`. Bad `type` raises `OpError`;
    an unknown `based_on` / `next_style` raises `StyleNotFoundError`.
    """
    style_type = _STYLE_TYPE_FROM_NAME.get(type)
    if style_type is None:
        raise OpError(
            f"unknown style type {type!r}; expected one of {sorted(_STYLE_TYPE_FROM_NAME)}"
        )
    # Resolve referenced styles up front so a miss fails before mutating.
    base_com = self[based_on].com if based_on is not None else None
    next_com = self[next_style].com if next_style is not None else None
    with _com.translate_com_errors():
        # Positional Name, Type β€” keywords drop under pywin32 late binding.
        self._doc.com.Styles.Add(name, int(style_type))
        new = Style(self._doc, name)
        if base_com is not None:
            new.com.BaseStyle = base_com
        if next_com is not None:
            new.com.NextParagraphStyle = next_com
    return new

Tables

Create, read, and restructure tables β€” a cell is itself an anchor.

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. Table.set_heading_row(row=1, heading=True, allow_break=None) marks a row as a repeating header that reprints on every page the table spans.

Treat a table as records keyed by its header row (row 1) β€” the read/update mirror of building one from data=[{...}]. Table.records() returns the body rows as a list of {header: cell_text} dicts; Table.append_record({...}) appends a row from a dict (keys mapped to header columns, missing β†’ empty, extra β†’ ignored); Table.update_row(key, {...}, column=None) sets cells by header name on the first row whose key-column (the first column, or the header named by column) equals key β€” addressing a row by content instead of a fragile 1-based index.

Restyle a table after creation. Table.set_style(name) points an existing table at any built-in or custom table style β€” the post-creation counterpart of insert_table(style=…). Applying a style reapplies its conditional formatting and overwrites direct cell shading, so restyle first, then layer cell-level overrides. Table.set_alignment("left"|"center"|"right") positions the whole table across the page; Table.set_borders(sides=…, style=…, weight=…, color=…) rules the whole grid in one call (the table-wide counterpart of the per-cell set_borders; interior gridlines via "horizontal"/"vertical"); Table.set_banding(first_row=…, last_row=…, first_column=…, last_column=…, banded_rows=…, banded_columns=…) toggles Word's "Table Style Options" (tri-state, None leaves a flag untouched β€” needs a real table style applied to show). Cell.set_vertical_alignment("top"|"center"|"bottom") sets a cell's vertical alignment.

Style a whole row or column in one call. A row is addressable as table:N:row:R (a RowAnchor) and a column as table:N:col:C (a ColumnAnchor); Table.row(R) / Table.column(C) return the same objects. Both are anchors, so the inherited set_shading / set_borders / apply_style / format_run / format_paragraph style the whole strip β€” doc.tables[1].row(1).set_shading(fill="#DDD") shades the header row, table.column(3).format_paragraph(alignment="right") right-aligns a totals column. A row is a contiguous range. A column is not β€” Word has no per-column model on a table with merged or mixed-width cells, so a column op there raises OpError pointing at per-cell table:N:R:C styling (a regular table fans the op across the column's cells transparently).

Add or remove a column; merge or split cells. Table.add_column(values=None) appends a column at the right edge β€” the column mirror of add_row, with values filling top-to-bottom; Table.delete_column(index) removes one. (delete_column raises OpError on a merged / mixed-width table β€” Word can't address an individual column there, so delete its cells via table:N:R:C.) Cell.merge(other) joins two cells (and the rectangle they span) into one, keeping the calling cell's id; Cell.split(rows=1, cols=2) is its inverse. Either makes the table non-uniform: Table.is_uniform then reports False, table:N:R:C indexes physical cells (a merged row has fewer than column_count), and Table.read() walks each row's physical cells so it stays safe on an irregular grid (its uniform field flags the shape).

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

is_uniform property

is_uniform: bool

Whether every row has the same physical cell count β€” a clean grid.

True for a freshly built R Γ— C table; False once a cell has been merged or split. On a non-uniform table table:N:R:C indexes physical cells (so an index can shift after a merge or fall off a short row), delete_column / column anchors raise "mixed cell widths", and row_count Γ— column_count overstates the true cell count. Worth a check before addressing cells in a table you didn't build.

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)

row

row(row: int) -> RowAnchor

Return the RowAnchor for the 1-based row (table:N:row:R).

A styling handle for the whole row β€” table.row(1).set_shading(fill=…) shades it, .format_run(bold=True) bolds it. Same object as doc.anchor_by_id("table:N:row:R"). Raises AnchorNotFoundError (kind "table row") if out of range.

Source code in src/wordlive/_tables.py
def row(self, row: int) -> RowAnchor:
    """Return the `RowAnchor` for the 1-based `row` (`table:N:row:R`).

    A styling handle for the whole row β€” `table.row(1).set_shading(fill=…)`
    shades it, `.format_run(bold=True)` bolds it. Same object as
    `doc.anchor_by_id("table:N:row:R")`. Raises `AnchorNotFoundError` (kind
    `"table row"`) if out of range.
    """
    rows = self.row_count
    if not (1 <= row <= rows):
        raise AnchorNotFoundError("table row", f"table:{self._index}:row:{row}")
    return RowAnchor(self, row)

column

column(col: int) -> ColumnAnchor

Return the ColumnAnchor for the 1-based col (table:N:col:C).

The column counterpart of row() β€” table.column(3).format_paragraph( alignment="right") right-aligns a totals column. Same object as doc.anchor_by_id("table:N:col:C"). Raises AnchorNotFoundError (kind "table column") if out of range. (A column op on a merged / mixed-width table raises OpError when applied β€” see ColumnAnchor.)

Source code in src/wordlive/_tables.py
def column(self, col: int) -> ColumnAnchor:
    """Return the `ColumnAnchor` for the 1-based `col` (`table:N:col:C`).

    The column counterpart of `row()` β€” `table.column(3).format_paragraph(
    alignment="right")` right-aligns a totals column. Same object as
    `doc.anchor_by_id("table:N:col:C")`. Raises `AnchorNotFoundError` (kind
    `"table column"`) if out of range. (A column op on a merged / mixed-width
    table raises `OpError` when applied β€” see `ColumnAnchor`.)
    """
    cols = self.column_count
    if not (1 <= col <= cols):
        raise AnchorNotFoundError("table column", f"table:{self._index}:col:{col}")
    return ColumnAnchor(self, col)

grid

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

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

Iterates each row's physical cells, so it stays safe on a merged / split table (a merged row simply yields fewer columns); on a uniform table it's the plain row_count Γ— column_count grid.

Source code in src/wordlive/_tables.py
def grid(self) -> list[list[str]]:
    """All cell text as a row-major `list[list[str]]`.

    Iterates each row's *physical* cells, so it stays safe on a merged /
    split table (a merged row simply yields fewer columns); on a uniform
    table it's the plain `row_count` Γ— `column_count` grid.
    """
    return [
        [Cell(self, r, c).text for c in range(1, self._row_cell_count(r) + 1)]
        for r in range(1, self.row_count + 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. Cells are walked physically per row (robust to merged / split tables); uniform reports whether rows Γ— columns is the full grid.

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`.
    Cells are walked **physically** per row (robust to merged / split
    tables); `uniform` reports whether `rows` Γ— `columns` is the full grid.
    """
    rows, cols = self.row_count, self.column_count
    cells = [
        [
            {
                "row": r,
                "col": c,
                "text": Cell(self, r, c).text,
                "anchor_id": f"table:{self._index}:{r}:{c}",
            }
            for c in range(1, self._row_cell_count(r) + 1)
        ]
        for r in range(1, rows + 1)
    ]
    return {
        "index": self._index,
        "title": self.title,
        "rows": rows,
        "columns": cols,
        "uniform": self.is_uniform,
        "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,
    }

records

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

Read the body rows as a list of dicts keyed by the header row.

Row 1 is taken as the header (the exact inverse of building a table from data=[{...}] β€” see insert_table); each row below it becomes {header: cell_text}. A pure read β€” no doc.edit() needed.

Edge cases mirror the write path: a duplicate header label collapses (the rightmost column wins), and a blank header cell yields an empty-string key β€” both the caller's responsibility.

Source code in src/wordlive/_tables.py
def records(self) -> list[dict[str, str]]:
    """Read the body rows as a list of dicts keyed by the header row.

    Row 1 is taken as the header (the exact inverse of building a table from
    `data=[{...}]` β€” see `insert_table`); each row below it becomes
    `{header: cell_text}`. A pure read β€” no `doc.edit()` needed.

    Edge cases mirror the write path: a **duplicate** header label collapses
    (the rightmost column wins), and a **blank** header cell yields an
    empty-string key β€” both the caller's responsibility.
    """
    headers = self._header_names()
    out: list[dict[str, str]] = []
    for r in range(2, self.row_count + 1):
        out.append({headers[c - 1]: self.cell(r, c).text for c in range(1, len(headers) + 1)})
    return out

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)

append_record

append_record(record: dict[str, Any]) -> None

Append a row from a dict, mapping its keys to the header columns.

Keys are matched against row 1's headers; a header with no matching key gets an empty cell and an extra key is ignored β€” the same lenient mapping insert_table(data=[{...}]) uses. The new row inherits the table's existing formatting / banding (Word's Rows.Add). Wrap in doc.edit(...) for atomic undo.

Source code in src/wordlive/_tables.py
def append_record(self, record: dict[str, Any]) -> None:
    """Append a row from a dict, mapping its keys to the header columns.

    Keys are matched against row 1's headers; a header with no matching key
    gets an empty cell and an extra key is ignored β€” the same lenient
    mapping `insert_table(data=[{...}])` uses. The new row inherits the
    table's existing formatting / banding (Word's `Rows.Add`). Wrap in
    `doc.edit(...)` for atomic undo.
    """
    headers = self._header_names()
    self.add_row([record.get(name, "") for name in headers])

update_row

update_row(key: Any, values: dict[str, Any], *, column: str | None = None) -> None

Update the first row whose key-column cell equals key, by header name.

The key column is the first column by default, or the header named by column=. Each item in values sets the cell under that header ({header: new_text}). First match wins when several rows share key.

Validates against the header before mutating: an unknown column, or a values key that isn't a header, raises OpError (exit 1). If no row matches key, raises AnchorNotFoundError (exit 2). Wrap in doc.edit(...) for atomic undo.

Source code in src/wordlive/_tables.py
def update_row(self, key: Any, values: dict[str, Any], *, column: str | None = None) -> None:
    """Update the first row whose key-column cell equals `key`, by header name.

    The key column is the **first** column by default, or the header named
    by `column=`. Each item in `values` sets the cell under that header
    (`{header: new_text}`). First match wins when several rows share `key`.

    Validates against the header **before** mutating: an unknown `column`,
    or a `values` key that isn't a header, raises `OpError` (exit 1). If no
    row matches `key`, raises `AnchorNotFoundError` (exit 2). Wrap in
    `doc.edit(...)` for atomic undo.
    """
    headers = self._header_names()
    # Rightmost duplicate header wins, matching `records()`.
    col_of = {name: i + 1 for i, name in enumerate(headers)}
    if column is not None and column not in col_of:
        raise OpError(f"update_row: column {column!r} is not a header; have {headers}")
    unknown = [name for name in values if name not in col_of]
    if unknown:
        raise OpError(f"update_row: {unknown} not in the header row; have {headers}")
    key_col = col_of[column] if column is not None else 1
    target = str(key)
    for r in range(2, self.row_count + 1):
        if self.cell(r, key_col).text == target:
            for name, val in values.items():
                self.cell(r, col_of[name]).set_text(str(val))
            return
    keyed = column if column is not None else (headers[0] if headers else "1")
    raise AnchorNotFoundError("table row", f"table:{self._index}:{keyed}={target!r}")

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

add_column

add_column(values: list[Any] | None = None) -> None

Append a column at the right edge of the table, optionally filling it.

The column mirror of add_row: values are matched to rows top-to-bottom; extras past the row count are ignored, a short list leaves trailing cells empty. (Word's Columns.Add tolerates a merged table, so this works where delete_column can't.) Wrap in doc.edit(...) for atomic undo.

The new column lands at the right edge, so existing table:N:R:C ids are unchanged β€” but any cached column_count is now stale; re-read it.

Source code in src/wordlive/_tables.py
def add_column(self, values: list[Any] | None = None) -> None:
    """Append a column at the right edge of the table, optionally filling it.

    The column mirror of `add_row`: `values` are matched to rows
    top-to-bottom; extras past the row count are ignored, a short list
    leaves trailing cells empty. (Word's `Columns.Add` tolerates a merged
    table, so this works where `delete_column` can't.) Wrap in
    `doc.edit(...)` for atomic undo.

    The new column lands at the right edge, so existing `table:N:R:C` ids are
    unchanged β€” but any cached `column_count` is now stale; re-read it.
    """
    with _com.translate_com_errors():
        self._com.Columns.Add()
        if values:
            last = int(self._com.Columns.Count)
            rows = int(self._com.Rows.Count)
            for r, val in enumerate(values, start=1):
                if r > rows:
                    break
                self._com.Cell(r, last).Range.Text = str(val)

delete_column

delete_column(index: int) -> None

Delete the 1-based column index.

Raises AnchorNotFoundError (kind "table column") if out of range. Word can't address an individual column on a table with merged / mixed-width cells ("mixed cell widths") β€” that ComError is re-raised as an OpError pointing at per-cell deletion via table:N:R:C (the same contract as a column-anchor style op). Wrap in doc.edit(...) for atomic undo.

Every column to the right of index renumbers down by one, so any cached table:N:R:C ids past it are now stale β€” re-resolve through doc.tables before addressing another column.

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

    Raises `AnchorNotFoundError` (kind `"table column"`) if out of range.
    Word can't address an individual column on a table with merged /
    mixed-width cells ("mixed cell widths") β€” that `ComError` is re-raised as
    an `OpError` pointing at per-cell deletion via `table:N:R:C` (the same
    contract as a column-anchor style op). Wrap in `doc.edit(...)` for
    atomic undo.

    Every column to the right of `index` renumbers down by one, so any
    cached `table:N:R:C` ids past it are now stale β€” re-resolve through
    `doc.tables` before addressing another column.
    """
    cols = self.column_count
    if not (1 <= index <= cols):
        raise AnchorNotFoundError("table column", f"table:{self._index}:col:{index}")
    try:
        with _com.translate_com_errors():
            self._com.Columns(index).Delete()
    except ComError as e:
        raise OpError(
            f"cannot delete column {index} of table {self._index} ({e}); the "
            f"table has merged or mixed-width cells β€” delete its cells "
            f"individually via table:{self._index}:R:{index}"
        ) from e

set_heading_row

set_heading_row(row: int = 1, *, heading: bool = True, allow_break: bool | None = None) -> None

Mark a 1-based row as a repeating table heading row.

A heading row (HeadingFormat) repeats at the top of every page the table spans β€” set it on the header row of a multi-page table so the column labels carry over. heading=False clears the flag.

allow_break controls AllowBreakAcrossPages (whether a row's content may split across a page boundary). It defaults to not heading β€” a repeating header shouldn't fracture β€” so the common set_heading_row(1) both repeats row 1 and keeps it intact; pass allow_break explicitly to override.

Raises AnchorNotFoundError (kind "table row") if row is out of range.

Source code in src/wordlive/_tables.py
def set_heading_row(
    self, row: int = 1, *, heading: bool = True, allow_break: bool | None = None
) -> None:
    """Mark a 1-based row as a repeating table heading row.

    A heading row (`HeadingFormat`) repeats at the top of every page the
    table spans β€” set it on the header row of a multi-page table so the
    column labels carry over. `heading=False` clears the flag.

    `allow_break` controls `AllowBreakAcrossPages` (whether a row's content
    may split across a page boundary). It defaults to ``not heading`` β€” a
    repeating header shouldn't fracture β€” so the common
    `set_heading_row(1)` both repeats row 1 *and* keeps it intact; pass
    `allow_break` explicitly to override.

    Raises `AnchorNotFoundError` (kind `"table row"`) if `row` is out of
    range.
    """
    rows = self.row_count
    if not (1 <= row <= rows):
        raise AnchorNotFoundError("table row", f"table:{self._index}:row:{row}")
    keep_intact = (not heading) if allow_break is None else bool(allow_break)
    with _com.translate_com_errors():
        r = self._com.Rows(row)
        r.HeadingFormat = bool(heading)
        r.AllowBreakAcrossPages = keep_intact

autofit

autofit(mode: str = 'content') -> None

Resize the table's columns β€” fit to content, the window, or pin them.

mode is one of:

  • "content" (default) β€” shrink/grow each column to fit its cells.
  • "window" β€” stretch the table to the page (container) width.
  • "fixed" β€” pin the current column widths so Word stops auto-sizing (sets AllowAutoFit = False).

A clean way to tidy a table whose columns drifted after edits. An unknown mode raises OpError. Wrap in doc.edit(...) for atomic undo.

Source code in src/wordlive/_tables.py
def autofit(self, mode: str = "content") -> None:
    """Resize the table's columns β€” fit to content, the window, or pin them.

    `mode` is one of:

    - ``"content"`` (default) β€” shrink/grow each column to fit its cells.
    - ``"window"`` β€” stretch the table to the page (container) width.
    - ``"fixed"`` β€” pin the current column widths so Word stops auto-sizing
      (sets `AllowAutoFit = False`).

    A clean way to tidy a table whose columns drifted after edits. An unknown
    `mode` raises `OpError`. Wrap in `doc.edit(...)` for atomic undo.
    """
    key = str(mode).lower()
    behavior = _AUTOFIT_MODES.get(key)
    if behavior is None:
        allowed = ", ".join(sorted(_AUTOFIT_MODES))
        raise OpError(f"autofit mode must be one of {allowed}; got {mode!r}")
    with _com.translate_com_errors():
        # "fixed" means "stop auto-sizing": AllowAutoFit off, then pin widths.
        # "content"/"window" need AllowAutoFit on for the behavior to take.
        self._com.AllowAutoFit = behavior != WdAutoFitBehavior.FIXED
        self._com.AutoFitBehavior(int(behavior))

set_style

set_style(name: str) -> None

Restyle this existing table with a named table style.

The post-creation counterpart of insert_table(style=…) β€” point a table at any built-in or custom table style ("Grid Table 4 - Accent 1", "Plain Table 3", …; discover them via style list filtered to type=="table"). Raises StyleNotFoundError (exit 2) if the style isn't defined in the document.

Direct cell shading is not preserved. Applying a table style reapplies the style's conditional formatting (banding, header shading), which overwrites explicit per-cell set_shading colours (live-confirmed). So restyle first, then layer cell-level overrides on top β€” not the reverse. Wrap in doc.edit(...) for atomic undo.

Source code in src/wordlive/_tables.py
def set_style(self, name: str) -> None:
    """Restyle this **existing** table with a named table style.

    The post-creation counterpart of `insert_table(style=…)` β€” point a table
    at any built-in or custom table style (`"Grid Table 4 - Accent 1"`,
    `"Plain Table 3"`, …; discover them via `style list` filtered to
    `type=="table"`). Raises `StyleNotFoundError` (exit 2) if the style isn't
    defined in the document.

    **Direct cell shading is not preserved.** Applying a table style reapplies
    the style's conditional formatting (banding, header shading), which
    overwrites explicit per-cell `set_shading` colours (live-confirmed). So
    restyle **first**, then layer cell-level overrides on top β€” not the
    reverse. Wrap in `doc.edit(...)` for atomic undo.
    """
    style_obj = self._doc.styles[name]  # StyleNotFoundError (exit 2) if missing
    with _com.translate_com_errors():
        self._com.Style = style_obj.com

set_alignment

set_alignment(alignment: str) -> None

Align the whole table across the page width β€” left, center, or right.

alignment is "left" / "center" ("centre") / "right", mapped onto Table.Rows.Alignment. This positions the table between the page margins (distinct from the text alignment inside cells, which is format_paragraph). Idempotent. Wrap in doc.edit(...); bad input raises OpError.

Source code in src/wordlive/_tables.py
def set_alignment(self, alignment: str) -> None:
    """Align the whole table across the page width β€” left, center, or right.

    `alignment` is ``"left"`` / ``"center"`` (``"centre"``) / ``"right"``,
    mapped onto `Table.Rows.Alignment`. This positions the *table* between the
    page margins (distinct from the text alignment *inside* cells, which is
    `format_paragraph`). Idempotent. Wrap in `doc.edit(...)`; bad input raises
    `OpError`.
    """
    value = _coerce_align(alignment, _ROW_ALIGN, "table alignment")
    with _com.translate_com_errors():
        self._com.Rows.Alignment = value

set_borders

set_borders(*, sides: Any = 'all', style: Any = 'single', weight: Any = 0.5, color: Any = None) -> None

Draw borders across the whole table grid in one call.

The table-wide counterpart of the per-cell set_borders (a Cell is an Anchor). sides is "all"/"box" (the four outer edges β€” the default), a single outer edge ("top"/"bottom"/"left"/ "right"), the interior gridlines ("horizontal"/"vertical" β€” the lines between cells), or a list (e.g. ["box", "horizontal", "vertical"] to rule every line). style is a line style ("single", "double", "dot", "dash", …, or "none" to clear). weight is the line width in points, snapped to Word's set (0.25/0.5/0.75/1/1.5/ 2.25/3). color is an optional name/hex/RGB. Idempotent. Bad input raises OpError. Wrap in doc.edit(...).

Source code in src/wordlive/_tables.py
def set_borders(
    self,
    *,
    sides: Any = "all",
    style: Any = "single",
    weight: Any = 0.5,
    color: Any = None,
) -> None:
    """Draw borders across the **whole table grid** in one call.

    The table-wide counterpart of the per-cell `set_borders` (a `Cell` is an
    `Anchor`). `sides` is ``"all"``/``"box"`` (the four outer edges β€” the
    default), a single outer edge (``"top"``/``"bottom"``/``"left"``/
    ``"right"``), the interior gridlines (``"horizontal"``/``"vertical"`` β€”
    the lines *between* cells), or a list (e.g. ``["box", "horizontal",
    "vertical"]`` to rule every line). `style` is a line style (``"single"``,
    ``"double"``, ``"dot"``, ``"dash"``, …, or ``"none"`` to clear). `weight`
    is the line width in points, snapped to Word's set (0.25/0.5/0.75/1/1.5/
    2.25/3). `color` is an optional name/hex/RGB. Idempotent. Bad input raises
    `OpError`. Wrap in `doc.edit(...)`.
    """
    try:
        with _com.translate_com_errors():
            apply_borders(
                self._com.Borders, sides=sides, style=style, weight=weight, color=color
            )
    except (ValueError, TypeError) as e:
        raise OpError(str(e)) from e

set_banding

set_banding(*, first_row: bool | None = None, last_row: bool | None = None, first_column: bool | None = None, last_column: bool | None = None, banded_rows: bool | None = None, banded_columns: bool | None = None) -> None

Toggle the table-style options (Word's "Table Style Options" ribbon group).

Each flag turns one conditional-formatting band of the applied table style on or off β€” first_row (a distinct header row), last_row (a total row), first_column / last_column, and banded_rows / banded_columns (the alternating stripes). All are tri-state: True / False set, None (the default) leaves that flag untouched.

These only show once a real table style is applied β€” a styleless table or plain "Table Grid" ignores band conditions. Pair with set_style. Idempotent. Wrap in doc.edit(...) for atomic undo.

Source code in src/wordlive/_tables.py
def set_banding(
    self,
    *,
    first_row: bool | None = None,
    last_row: bool | None = None,
    first_column: bool | None = None,
    last_column: bool | None = None,
    banded_rows: bool | None = None,
    banded_columns: bool | None = None,
) -> None:
    """Toggle the table-style options (Word's "Table Style Options" ribbon group).

    Each flag turns one conditional-formatting band of the **applied table
    style** on or off β€” `first_row` (a distinct header row), `last_row` (a
    total row), `first_column` / `last_column`, and `banded_rows` /
    `banded_columns` (the alternating stripes). All are tri-state: ``True`` /
    ``False`` set, ``None`` (the default) leaves that flag untouched.

    These only show once a **real table style** is applied β€” a styleless table
    or plain `"Table Grid"` ignores band conditions. Pair with `set_style`.
    Idempotent. Wrap in `doc.edit(...)` for atomic undo.
    """
    flags: dict[str, bool | None] = {
        "ApplyStyleHeadingRows": first_row,
        "ApplyStyleLastRow": last_row,
        "ApplyStyleFirstColumn": first_column,
        "ApplyStyleLastColumn": last_column,
        "ApplyStyleRowBands": banded_rows,
        "ApplyStyleColumnBands": banded_columns,
    }
    with _com.translate_com_errors():
        for prop, val in flags.items():
            if val is not None:
                setattr(self._com, prop, bool(val))

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, _com_cell: Any | None = None)

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, _com_cell: Any | None = None) -> None:
    super().__init__(table._doc, name=f"table:{table.index}:{row}:{col}")
    self._table = table
    self._row = row
    self._col = col
    # An already-resolved COM cell (e.g. from `Columns(C).Cells`), so callers
    # iterating a collection don't pay a `Table.Cell(r, c)` round-trip per cell.
    self._com_cell = _com_cell

set_vertical_alignment

set_vertical_alignment(align: str) -> None

Set where this cell's content sits vertically β€” top, center, or bottom.

align is "top" / "center" ("centre") / "bottom", mapped onto Cell.VerticalAlignment. (Word shares this value space with page vertical alignment, whose 2 = justify slot a cell rejects, so only those three are offered.) Idempotent. Wrap in doc.edit(...) for atomic undo; bad input raises OpError.

Source code in src/wordlive/_tables.py
def set_vertical_alignment(self, align: str) -> None:
    """Set where this cell's content sits vertically β€” top, center, or bottom.

    `align` is ``"top"`` / ``"center"`` (``"centre"``) / ``"bottom"``, mapped
    onto `Cell.VerticalAlignment`. (Word shares this value space with page
    vertical alignment, whose ``2`` = *justify* slot a cell rejects, so only
    those three are offered.) Idempotent. Wrap in `doc.edit(...)` for atomic
    undo; bad input raises `OpError`.
    """
    value = _coerce_align(align, _CELL_VALIGN, "cell vertical alignment")
    with _com.translate_com_errors():
        self._cell().VerticalAlignment = value

merge

merge(other: Cell) -> None

Merge this cell with other into one cell spanning their rectangle.

other must belong to the same table. Word joins the cells' text and collapses the rectangle into its upper-left cell, regardless of which corner is self vs other β€” so the merged cell is addressed by the upper-left coordinate of the spanned rectangle (e.g. cell(2, 2).merge(cell(1, 1)) yields a cell at table:N:1:1), and the other spanned coordinates stop resolving. The table becomes non-uniform (Table.is_uniform β†’ False) β€” afterwards table:N:R:C indexes physical cells (a short row has fewer than column_count), so re-read the table to see the new shape. Wrap in doc.edit(...) for atomic undo; cross-table cells raise OpError.

Source code in src/wordlive/_tables.py
def merge(self, other: Cell) -> None:
    """Merge this cell with `other` into one cell spanning their rectangle.

    `other` must belong to the **same table**. Word joins the cells' text and
    collapses the rectangle into its **upper-left** cell, regardless of which
    corner is `self` vs `other` β€” so the merged cell is addressed by the
    upper-left coordinate of the spanned rectangle (e.g.
    ``cell(2, 2).merge(cell(1, 1))`` yields a cell at ``table:N:1:1``), and the
    other spanned coordinates stop resolving. The table becomes
    **non-uniform** (`Table.is_uniform` β†’ `False`) β€” afterwards `table:N:R:C`
    indexes *physical* cells (a short row has fewer than `column_count`), so
    re-read the table to see the new shape. Wrap in `doc.edit(...)` for
    atomic undo; cross-table cells raise `OpError`.
    """
    if other._table.index != self._table.index:
        raise OpError(
            f"cannot merge {self.anchor_id} with {other.anchor_id}: "
            f"cells are in different tables"
        )
    with _com.translate_com_errors():
        # Positional MergeTo β€” the keyword is dropped under late binding.
        self._cell().Merge(other._cell())

split

split(rows: int = 1, cols: int = 2) -> None

Split this cell into a rows Γ— cols grid of cells.

The inverse of merge; defaults to two side-by-side cells (rows=1, cols=2). Makes the table non-uniform (Table.is_uniform β†’ False) β€” see merge. Wrap in doc.edit(...) for atomic undo; a count below 1 raises OpError.

Source code in src/wordlive/_tables.py
def split(self, rows: int = 1, cols: int = 2) -> None:
    """Split this cell into a `rows` Γ— `cols` grid of cells.

    The inverse of `merge`; defaults to two side-by-side cells
    (`rows=1, cols=2`). Makes the table **non-uniform** (`Table.is_uniform`
    β†’ `False`) β€” see `merge`. Wrap in `doc.edit(...)` for atomic undo; a
    count below 1 raises `OpError`.
    """
    if rows < 1 or cols < 1:
        raise OpError(f"split: rows and cols must be >= 1 (got rows={rows}, cols={cols})")
    with _com.translate_com_errors():
        # Positional NumRows, NumColumns β€” keywords are dropped under late binding.
        self._cell().Split(rows, cols)

wordlive.RowAnchor

RowAnchor(table: Table, row: int)

Bases: Anchor

A whole table row, addressed by table:N:row:R (1-based).

Subclasses Anchor over the row's contiguous Rows(R).Range, so the inherited styling verbs β€” set_shading, set_borders, apply_style, format_run, format_paragraph β€” restyle the entire row in one call (shading --anchor-id table:1:row:1 shades the header row; format-run --anchor-id table:1:row:1 --bold bolds it). set_text is refused β€” a row is a styling target, not a text slot; edit its cells via table:N:R:C.

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

wordlive.ColumnAnchor

ColumnAnchor(table: Table, col: int)

Bases: Anchor

A whole table column, addressed by table:N:col:C (1-based).

Unlike a row, a column is not a contiguous Word range β€” Column.Range isn't reachable under late binding β€” so this anchor styles the column by fanning each op out across its cells (Columns(C).Cells). The column-wide styling verbs (set_shading, set_borders, apply_style, format_run, format_paragraph) are overridden to loop the cells; range-only ops that need a single span (set_text, replace, insert_*) raise OpError.

A table with merged or mixed-width cells has no per-column model β€” Word raises "mixed cell widths" β€” so any column op on such a table raises OpError pointing at per-cell table:N:R:C styling. (Rows are unaffected; use table:N:row:R.)

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

Embedded objects

Pictures, floating shapes, equations, and charts as first-class anchors.

Images

The read side of the image story (the write side is Anchor.insert_image). doc.images is a read-only discovery collection over the document's embedded pictures; its list() reports each image's image:N id, MIME type, size (points), alt text, and the para:N it sits in. Index it (doc.images[2]) for an ImageAnchor, then call read_image() for the raw bytes + MIME β€” the path for handing an embedded picture to a vision model. read_image() also works on any anchor whose range contains exactly one picture (e.g. doc.paragraphs[7]); a range with no image, or more than one, raises ImageSourceError. Extraction is non-mutating, so it needs no doc.edit(...).

An ImageAnchor is also lightly writable: set_alt_text(text), set_size(width/height/lock_aspect), and set_crop(left/top/right/bottom) (trim the picture in from its edges β€” lengths in points / "0.2in") restyle an inline picture in place (chainable; wrap in doc.edit(...)). These cover the non-wrap subset β€” re-wrapping an image (floating it) is insert_image(wrap=…), which converts it to a shape:N (see below). To change the picture's bytes, delete and re-insert.

wordlive.ImageAnchor

ImageAnchor(doc: Document, index: int)

Bases: Anchor

An embedded picture located by 1-based index β€” image:N.

Mirrors Word's own InlineShapes(N) ordering (document order). The anchor resolves to the picture's own one-character range, so read_image pulls exactly that image's bytes out. Discover the available images β€” with their MIME, size, and the para:N they sit in β€” via doc.images. An image carries no editable text, so set_text raises; read_image() is the point of it.

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

alt_text property

alt_text: str

The picture's accessibility (alt) text, or "" if unset.

set_alt_text

set_alt_text(text: str) -> ImageAnchor

Set the picture's accessibility (alt) text. Returns self (chainable); wrap in doc.edit(...) for atomic undo.

Source code in src/wordlive/_anchors/_image_anchors.py
def set_alt_text(self, text: str) -> ImageAnchor:
    """Set the picture's accessibility (alt) text. Returns `self` (chainable);
    wrap in `doc.edit(...)` for atomic undo."""
    with _com.translate_com_errors():
        self._shape().AlternativeText = text
    return self

set_size

set_size(*, width: Any = None, height: Any = None, lock_aspect: bool | None = None) -> ImageAnchor

Resize the inline picture. width / height are lengths (points / "3in"); lock_aspect toggles proportional scaling (dropped automatically when both dimensions are given, so both stick). To re-wrap a picture (float it) use insert_image(wrap=…) β€” that crosses it into shape:N. Returns self (chainable). Bad input raises OpError.

Source code in src/wordlive/_anchors/_image_anchors.py
def set_size(
    self, *, width: Any = None, height: Any = None, lock_aspect: bool | None = None
) -> ImageAnchor:
    """Resize the inline picture. `width` / `height` are lengths (points /
    ``"3in"``); `lock_aspect` toggles proportional scaling (dropped
    automatically when both dimensions are given, so both stick). To *re-wrap*
    a picture (float it) use `insert_image(wrap=…)` β€” that crosses it into
    `shape:N`. Returns `self` (chainable). Bad input raises `OpError`."""
    self._apply(_shapes.apply_shape_size, width=width, height=height, lock_aspect=lock_aspect)
    return self

set_crop

set_crop(*, left: Any = None, top: Any = None, right: Any = None, bottom: Any = None) -> ImageAnchor

Crop the inline picture in from its edges. left / top / right / bottom are the amounts trimmed off each edge (lengths in points / "0.2in"); cropping shrinks the displayed size. At least one edge is required. Returns self (chainable). Bad input raises OpError.

Source code in src/wordlive/_anchors/_image_anchors.py
def set_crop(
    self, *, left: Any = None, top: Any = None, right: Any = None, bottom: Any = None
) -> ImageAnchor:
    """Crop the inline picture in from its edges. `left` / `top` / `right` /
    `bottom` are the amounts trimmed off each edge (lengths in points /
    ``"0.2in"``); cropping shrinks the displayed size. At least one edge is
    required. Returns `self` (chainable). Bad input raises `OpError`."""
    self._apply(_shapes.apply_shape_crop, left=left, top=top, right=right, bottom=bottom)
    return self

wordlive.ImageCollection

ImageCollection(doc: Document)

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

Index an image by 1-based position (doc.images[2]) to get an ImageAnchor (image:N), then read_image() for its bytes + MIME. list() summarises every image β€” id, MIME, size, alt text, and the para:N it's anchored in β€” so a model can see what's there before pulling any bytes. Positions match Word's own InlineShapes(n) ordering.

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

list

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

Every image as {index, anchor_id, mime, width, height, crop, alt_text, para}.

mime is the picture's content type (read from its package XML β€” None if the shape isn't a raster image, e.g. an embedded chart or OLE object). width/height are in points; crop the {left, top, right, bottom} insets in points (or None if uncropped). para is the para:N anchor of the paragraph the image sits in (or None if it can't be located). Reads each image's content type but not its (potentially large) bytes β€” call read_image for those.

Source code in src/wordlive/_anchors/_image_anchors.py
def list(self) -> list[dict[str, Any]]:
    """Every image as `{index, anchor_id, mime, width, height, crop, alt_text, para}`.

    `mime` is the picture's content type (read from its package XML β€”
    ``None`` if the shape isn't a raster image, e.g. an embedded chart or OLE
    object). `width`/`height` are in points; `crop` the `{left, top, right,
    bottom}` insets in points (or ``None`` if uncropped). `para` is the
    `para:N` anchor of the paragraph the image sits in (or ``None`` if it
    can't be located). Reads each image's content type but not its
    (potentially large) bytes β€” call [`read_image`][wordlive.Anchor.read_image]
    for those.
    """
    out: list[dict[str, Any]] = []
    with _com.translate_com_errors():
        shapes = self._doc.com.InlineShapes
        count = int(shapes.Count)
        for i in range(1, count + 1):
            shape = shapes.Item(i)
            rng = shape.Range
            try:
                start = int(rng.Start)
            except Exception:
                start = None
            mime: str | None = None
            try:
                parts = _images.image_parts_in_opc(str(rng.WordOpenXML))
                if len(parts) == 1:
                    mime = parts[0][0]
            except Exception:
                mime = None
            para_id: str | None = None
            if start is not None:
                para = self._doc.paragraphs.at(start)
                para_id = para.anchor_id if para is not None else None
            out.append(
                {
                    "index": i,
                    "anchor_id": f"image:{i}",
                    "mime": mime,
                    "width": _safe_float(shape, "Width"),
                    "height": _safe_float(shape, "Height"),
                    "crop": _shapes.crop_values(shape),
                    "alt_text": _safe_str(shape, "AlternativeText"),
                    "para": para_id,
                }
            )
    return out

Watermarks, text boxes & floating shapes

Document.set_watermark(text, …) stamps a WordArt text watermark (DRAFT / CONFIDENTIAL) behind every page via each section's header story β€” layout="diagonal"/"horizontal", color, font, semitransparent; it replaces any prior text watermark rather than stacking, and Document.remove_watermark() clears it (idempotent). Document.watermark() is the read mirror β€” it returns a WatermarkInfo (text + the sections carrying it) or None. Anchor.insert_text_box(text, …) drops a floating text box / pull quote anchored to any anchor's paragraph, with width/height (points or unit strings), wrap (the insert_image vocabulary minus "inline"), where, the text-format kwargs, and fill/border. Both are edits β€” wrap in doc.edit(...) for atomic undo.

Floating shapes β€” text boxes, floating images, and WordArt β€” are on the anchor model, addressed shape:N. Anchor.insert_text_box returns a ShapeAnchor, and a floating insert_image (any wrap other than "inline") returns the picture's ShapeAnchor too β€” an "inline" image stays an InlineShape (image:N) and returns None. Discover them via doc.shapes (all body shapes; header-story watermarks excluded) or doc.text_boxes (the text-box subset, a discovery filter that keeps each box's canonical shape:N id). Restyle in place: set_wrap(wrap, side, distance_top/bottom/left/right) (the wrap style, which sides text flows past β€” both/left/right/largest, honoured by square/tight/through β€” and the standoff gaps; pass any one), set_position(left/top/relative_to), set_size(width/height/lock_aspect), set_crop(left/top/right/bottom) (trim a picture shape in from its edges), format(fill/border/border_weight), set_alt_text; set_text edits a text box's contents and replace_image swaps a floating picture's bits (delete + reinsert at the same anchor, preserving wrap / position / size). shape:N is positional in document order, so adding or removing a shape renumbers the rest β€” re-list rather than caching an id.

Deeper layout knobs round it out: set_rotation(degrees) (absolute angle), set_z_order("front"|"back"|"forward"|"backward") (restack within the floating layer β€” distinct from wrap's in-front-of/behind-text; because Document.Shapes orders by z-order, a restack renumbers shape:N β€” re-list before reusing an id), and set_text_frame(margin_left/right/top/bottom, word_wrap) for a text box's internal insets. Grouping: doc.group_shapes(*shape_ids) collapses two or more floats into one group shape:N (moved / sized / deleted as a unit), and ShapeAnchor.ungroup() dissolves it back into its members' ShapeAnchors. There is no autosize ("resize-to-fit-text") control β€” Word doesn't expose it cleanly over COM. The textbox:N id is an alias onto a text box's canonical shape:N (anchor_by_id("textbox:1") ≑ the first text box).

wordlive.ShapeAnchor

ShapeAnchor(doc: Document, index: int)

Bases: Anchor

A floating shape located by 1-based index β€” shape:N.

Indexes the document's body-story floating shapes (text boxes, floating images, WordArt) in document order β€” the restyle handle that insert_text_box and a floating insert_image return. shape_type reports the kind ("text_box" / "picture" / "wordart" / …). Restyle in place with set_wrap / set_position / set_size / format / set_alt_text; a text box's contents edit via set_text, a floating picture's image swaps via replace_image. Discover shapes via doc.shapes (or just the text boxes via doc.text_boxes).

A floating shape anchors to a paragraph, not a character position, so positions renumber as shapes come and go β€” re-list, don't cache. Watermarks live in the header story and are excluded.

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

shape_type property

shape_type: str

The shape kind β€” "text_box" / "picture" / "wordart" / "group" / ….

alt_text property

alt_text: str

The shape's accessibility (alt) text, or "" if unset.

text property

text: str

A text box's contents ("" for a shape with no text frame).

rotation property

rotation: float

The shape's clockwise rotation in degrees (0.0 if unrotated).

z_order property

z_order: int

The shape's 1-based stacking position (ZOrderPosition; higher = nearer the front).

revision_segments

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

The shape's text as a single unchanged segment (no tracked-change view).

A floating shape's text lives in its own text-frame story, while doc.revisions enumerates the main body β€” the two don't share offsets, so tracked-change views aren't available inside shapes. This mirrors text (rather than reporting the anchoring paragraph's unrelated revision history). text_final / text_original therefore both equal text.

Source code in src/wordlive/_anchors/_shape_anchors.py
def revision_segments(self) -> list[dict[str, Any]]:
    """The shape's text as a single unchanged segment (no tracked-change view).

    A floating shape's text lives in its own text-frame story, while
    `doc.revisions` enumerates the main body β€” the two don't share offsets, so
    tracked-change views aren't available inside shapes. This mirrors
    [`text`][wordlive.ShapeAnchor.text] (rather than reporting the *anchoring
    paragraph's* unrelated revision history). `text_final` / `text_original`
    therefore both equal `text`.
    """
    text = self.text
    return [{"text": text, "change": None}] if text else []

set_wrap

set_wrap(wrap: str | None = None, *, side: str | None = None, distance_top: Any = None, distance_bottom: Any = None, distance_left: Any = None, distance_right: Any = None) -> ShapeAnchor

Set how body text flows around the shape.

wrap is the style β€” "square" / "tight" / "through" / "top-bottom" / "front" / "behind". side is which sides text flows past β€” "both" / "left" / "right" / "largest" (only "square" / "tight" / "through" honour it; Word ignores it for the others). distance_* are the standoff gaps between text and the shape (lengths in points / "0.1in"). At least one argument is required. Returns self (chainable). Bad input raises OpError.

Source code in src/wordlive/_anchors/_shape_anchors.py
def set_wrap(
    self,
    wrap: str | None = None,
    *,
    side: str | None = None,
    distance_top: Any = None,
    distance_bottom: Any = None,
    distance_left: Any = None,
    distance_right: Any = None,
) -> ShapeAnchor:
    """Set how body text flows around the shape.

    `wrap` is the style β€” ``"square"`` / ``"tight"`` / ``"through"`` /
    ``"top-bottom"`` / ``"front"`` / ``"behind"``. `side` is which sides text
    flows past β€” ``"both"`` / ``"left"`` / ``"right"`` / ``"largest"`` (only
    ``"square"`` / ``"tight"`` / ``"through"`` honour it; Word ignores it for
    the others). `distance_*` are the standoff gaps between text and the shape
    (lengths in points / ``"0.1in"``). At least one argument is required.
    Returns `self` (chainable). Bad input raises `OpError`."""
    self._apply(
        _shapes.apply_shape_wrap,
        wrap,
        side=side,
        distance_top=distance_top,
        distance_bottom=distance_bottom,
        distance_left=distance_left,
        distance_right=distance_right,
    )
    return self

set_crop

set_crop(*, left: Any = None, top: Any = None, right: Any = None, bottom: Any = None) -> ShapeAnchor

Crop a floating picture in from its edges. left / top / right / bottom are the amounts trimmed off each edge (lengths in points / "0.2in"); cropping shrinks the displayed size. At least one edge is required. Only valid on a "picture" shape; raises OpError on a text box / WordArt / group. Returns self (chainable).

Source code in src/wordlive/_anchors/_shape_anchors.py
def set_crop(
    self, *, left: Any = None, top: Any = None, right: Any = None, bottom: Any = None
) -> ShapeAnchor:
    """Crop a floating picture in from its edges. `left` / `top` / `right` /
    `bottom` are the amounts trimmed off each edge (lengths in points /
    ``"0.2in"``); cropping shrinks the displayed size. At least one edge is
    required. Only valid on a ``"picture"`` shape; raises `OpError` on a text
    box / WordArt / group. Returns `self` (chainable)."""
    self._apply(
        _shapes.apply_shape_crop,
        left=left,
        top=top,
        right=right,
        bottom=bottom,
        require_picture=True,
    )
    return self

set_position

set_position(*, left: Any = None, top: Any = None, relative_to: str | None = None) -> ShapeAnchor

Reposition the shape. left / top are lengths (points / "2in") or "center"; relative_to is the frame they're measured from ("margin" (default) or "page"). Returns self. Bad input raises OpError.

Source code in src/wordlive/_anchors/_shape_anchors.py
def set_position(
    self, *, left: Any = None, top: Any = None, relative_to: str | None = None
) -> ShapeAnchor:
    """Reposition the shape. `left` / `top` are lengths (points / ``"2in"``) or
    ``"center"``; `relative_to` is the frame they're measured from
    (``"margin"`` (default) or ``"page"``). Returns `self`. Bad input raises
    `OpError`."""
    self._apply(_shapes.apply_shape_position, left=left, top=top, relative_to=relative_to)
    return self

set_size

set_size(*, width: Any = None, height: Any = None, lock_aspect: bool | None = None) -> ShapeAnchor

Resize the shape. width / height are lengths (points / "3in"); lock_aspect toggles proportional scaling (dropped automatically when both dimensions are given, so both stick). Returns self. Bad input raises OpError.

Source code in src/wordlive/_anchors/_shape_anchors.py
def set_size(
    self, *, width: Any = None, height: Any = None, lock_aspect: bool | None = None
) -> ShapeAnchor:
    """Resize the shape. `width` / `height` are lengths (points / ``"3in"``);
    `lock_aspect` toggles proportional scaling (dropped automatically when both
    dimensions are given, so both stick). Returns `self`. Bad input raises
    `OpError`."""
    self._apply(_shapes.apply_shape_size, width=width, height=height, lock_aspect=lock_aspect)
    return self

set_rotation

set_rotation(degrees: Any) -> ShapeAnchor

Rotate the shape clockwise by degrees (absolute angle, e.g. 30 or -15). Returns self (chainable). Bad input raises OpError.

Source code in src/wordlive/_anchors/_shape_anchors.py
def set_rotation(self, degrees: Any) -> ShapeAnchor:
    """Rotate the shape clockwise by `degrees` (absolute angle, e.g. `30` or
    `-15`). Returns `self` (chainable). Bad input raises `OpError`."""
    self._apply(_shapes.apply_shape_rotation, degrees)
    return self

set_z_order

set_z_order(order: str) -> ShapeAnchor

Restack the shape in the floating layer β€” "front" / "back" / "forward" / "backward" (this is the stacking order among floats, distinct from set_wrap's in-front-of / behind-text).

Note: Document.Shapes orders by z-order, so this renumbers shape:N β€” the returned self keeps its old index and may now address a different shape. Re-list (doc.shapes) before using a shape:N id again. Returns self for chaining the call itself; bad input raises OpError.

Source code in src/wordlive/_anchors/_shape_anchors.py
def set_z_order(self, order: str) -> ShapeAnchor:
    """Restack the shape in the floating layer β€” ``"front"`` / ``"back"`` /
    ``"forward"`` / ``"backward"`` (this is the stacking order *among floats*,
    distinct from `set_wrap`'s in-front-of / behind-text).

    Note: `Document.Shapes` orders by z-order, so this **renumbers `shape:N`** β€”
    the returned `self` keeps its old index and may now address a different
    shape. Re-list (`doc.shapes`) before using a `shape:N` id again. Returns
    `self` for chaining the call itself; bad input raises `OpError`."""
    self._apply(_shapes.apply_shape_zorder, order)
    return self

set_text_frame

set_text_frame(*, margin_left: Any = None, margin_right: Any = None, margin_top: Any = None, margin_bottom: Any = None, word_wrap: bool | None = None) -> ShapeAnchor

Set a text box's internal margins and word-wrap. margin_* are lengths (points / "0.1in"); word_wrap toggles whether text wraps to the box width. Only valid on a text box; raises OpError on a picture / WordArt / group. Returns self (chainable).

Source code in src/wordlive/_anchors/_shape_anchors.py
def set_text_frame(
    self,
    *,
    margin_left: Any = None,
    margin_right: Any = None,
    margin_top: Any = None,
    margin_bottom: Any = None,
    word_wrap: bool | None = None,
) -> ShapeAnchor:
    """Set a text box's internal margins and word-wrap. `margin_*` are lengths
    (points / ``"0.1in"``); `word_wrap` toggles whether text wraps to the box
    width. Only valid on a text box; raises `OpError` on a picture / WordArt /
    group. Returns `self` (chainable)."""
    self._apply(
        _shapes.apply_text_frame,
        margin_left=margin_left,
        margin_right=margin_right,
        margin_top=margin_top,
        margin_bottom=margin_bottom,
        word_wrap=word_wrap,
    )
    return self

format

format(*, fill: Any = None, border: str | bool | None = None, border_weight: Any = None) -> ShapeAnchor

Set the shape's fill and outline. fill is any colour; border is False (no outline), True (default), or a colour string; border_weight is the outline thickness (points / "1.5pt"). Returns self. Bad input raises OpError.

Source code in src/wordlive/_anchors/_shape_anchors.py
def format(
    self, *, fill: Any = None, border: str | bool | None = None, border_weight: Any = None
) -> ShapeAnchor:
    """Set the shape's fill and outline. `fill` is any colour; `border` is
    ``False`` (no outline), ``True`` (default), or a colour string;
    `border_weight` is the outline thickness (points / ``"1.5pt"``). Returns
    `self`. Bad input raises `OpError`."""
    self._apply(
        _shapes.apply_shape_format, fill=fill, border=border, border_weight=border_weight
    )
    return self

set_alt_text

set_alt_text(text: str) -> ShapeAnchor

Set the shape's accessibility (alt) text. Returns self.

Source code in src/wordlive/_anchors/_shape_anchors.py
def set_alt_text(self, text: str) -> ShapeAnchor:
    """Set the shape's accessibility (alt) text. Returns `self`."""
    with _com.translate_com_errors():
        self._shape().AlternativeText = text
    return self

replace_image

replace_image(image: str | Path | bytes) -> ShapeAnchor

Swap this floating picture's image in place.

Delete + reinsert at the same anchor, preserving wrap / position / size / alt text β€” image is a path, raw bytes, or a base64 string (like insert_image). Only valid on a "picture" shape; raises OpError otherwise, ImageSourceError for a bad image. Returns self (chainable); wrap in doc.edit(...) for atomic undo.

Source code in src/wordlive/_anchors/_shape_anchors.py
def replace_image(self, image: str | Path | bytes) -> ShapeAnchor:
    """Swap this floating picture's image in place.

    Delete + reinsert at the same anchor, preserving wrap / position / size /
    alt text β€” `image` is a path, raw bytes, or a base64 string (like
    `insert_image`). Only valid on a ``"picture"`` shape; raises `OpError`
    otherwise, `ImageSourceError` for a bad image. Returns `self` (chainable);
    wrap in `doc.edit(...)` for atomic undo."""
    with _images.image_on_disk(image) as disk_path:
        try:
            with _com.translate_com_errors():
                _shapes.replace_shape_image(self._doc.com, self._shape(), disk_path)
        except (ValueError, TypeError) as e:
            raise OpError(str(e)) from e
    return self

set_text

set_text(text: str) -> None

Replace a text box's contents. Raises OpError on a shape with no text frame (a picture / WordArt).

Source code in src/wordlive/_anchors/_shape_anchors.py
def set_text(self, text: str) -> None:
    """Replace a text box's contents. Raises `OpError` on a shape with no text
    frame (a picture / WordArt)."""
    with _com.translate_com_errors():
        shape = self._shape()
        if _shapes.shape_kind(shape) not in ("text_box", "auto_shape"):
            raise OpError("this shape has no text frame to set; set_text needs a text box")
        shape.TextFrame.TextRange.Text = text

ungroup

ungroup() -> list[ShapeAnchor]

Dissolve a group shape into its members, returning their ShapeAnchors.

The children become top-level floating shapes again (each keeps its own shape:N slot β€” re-list, don't cache). Only valid on a "group" shape; raises OpError otherwise. Wrap in doc.edit(...) for atomic undo. The inverse of Document.group_shapes.

Source code in src/wordlive/_anchors/_shape_anchors.py
def ungroup(self) -> list[ShapeAnchor]:
    """Dissolve a group shape into its members, returning their `ShapeAnchor`s.

    The children become top-level floating shapes again (each keeps its own
    `shape:N` slot β€” re-list, don't cache). Only valid on a ``"group"`` shape;
    raises `OpError` otherwise. Wrap in `doc.edit(...)` for atomic undo. The
    inverse of [`Document.group_shapes`][wordlive.Document.group_shapes]."""
    with _com.translate_com_errors():
        shape = self._shape()
        if _shapes.shape_kind(shape) != "group":
            raise OpError("this shape is not a group; ungroup needs a group:N shape")
        names = _shapes.ungroup_shape(shape)
        anchors: list[ShapeAnchor] = []
        for name in names:
            if not name:
                continue
            try:
                idx = _shapes.index_of_named(self._doc.com, name)
            except OpError:
                continue
            anchors.append(ShapeAnchor(self._doc, idx))
    return anchors

delete

delete() -> None

Delete the floating shape itself (not its anchoring paragraph).

Source code in src/wordlive/_anchors/_shape_anchors.py
def delete(self) -> None:
    """Delete the floating shape itself (not its anchoring paragraph)."""
    with _com.translate_com_errors():
        self._shape().Delete()

wordlive.ShapeCollection

ShapeCollection(doc: Document)

Iterable view over the document's floating shapes (doc.shapes).

Index a shape by 1-based position (doc.shapes[2]) to get a ShapeAnchor (shape:N); list() summarises every shape β€” id, kind, size, wrap, and the para:N it's anchored in. Positions follow document order over the body story (header-story watermarks excluded), and renumber as shapes come and go β€” re-list, don't cache. The write mirror is insert_text_box / a floating insert_image.

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

list

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

Every floating shape as {index, anchor_id, shape_type, name, width, height, rotation, z_order, wrap, wrap_side, crop, alt_text, has_text, para}.

shape_type is the kind string; width / height are points; rotation the clockwise angle in degrees; z_order the 1-based stacking position; wrap the text-wrap keyword and wrap_side which sides text flows past; crop the picture's {left, top, right, bottom} insets in points (or None); has_text whether a text frame holds text; para the para:N the shape is anchored in.

Source code in src/wordlive/_anchors/_shape_anchors.py
def list(self) -> list[dict[str, Any]]:
    """Every floating shape as `{index, anchor_id, shape_type, name, width,
    height, rotation, z_order, wrap, wrap_side, crop, alt_text, has_text,
    para}`.

    `shape_type` is the kind string; `width` / `height` are points; `rotation`
    the clockwise angle in degrees; `z_order` the 1-based stacking position;
    `wrap` the text-wrap keyword and `wrap_side` which sides text flows past;
    `crop` the picture's `{left, top, right, bottom}` insets in points (or
    `None`); `has_text` whether a text frame holds text; `para` the `para:N`
    the shape is anchored in.
    """
    out: list[dict[str, Any]] = []
    with _com.translate_com_errors():
        shapes = _shapes.body_shapes(self._doc.com)
        for i, shape in enumerate(shapes, start=1):
            kind = _shapes.shape_kind(shape)
            try:
                wrap: str | None = _shapes.WRAP_TO_NAME.get(int(shape.WrapFormat.Type))
            except Exception:
                wrap = None
            try:
                wrap_side: str | None = _shapes.WRAP_SIDE_TO_NAME.get(
                    int(shape.WrapFormat.Side)
                )
            except Exception:
                wrap_side = None
            crop = _shapes.crop_values(shape) if kind == "picture" else None
            try:
                z_order: int | None = int(shape.ZOrderPosition)
            except Exception:
                z_order = None
            try:
                has_text = kind in ("text_box", "auto_shape") and bool(shape.TextFrame.HasText)
            except Exception:
                has_text = False
            try:
                alt_text = str(shape.AlternativeText or "")
            except Exception:
                alt_text = ""
            try:
                start: int | None = int(shape.Anchor.Start)
            except Exception:
                start = None
            para_id: str | None = None
            if start is not None:
                para = self._doc.paragraphs.at(start)
                para_id = para.anchor_id if para is not None else None
            out.append(
                {
                    "index": i,
                    "anchor_id": f"shape:{i}",
                    "shape_type": kind,
                    "name": str(getattr(shape, "Name", "") or ""),
                    "width": _safe_float(shape, "Width"),
                    "height": _safe_float(shape, "Height"),
                    "rotation": _safe_float(shape, "Rotation"),
                    "z_order": z_order,
                    "wrap": wrap,
                    "wrap_side": wrap_side,
                    "crop": crop,
                    "alt_text": alt_text,
                    "has_text": has_text,
                    "para": para_id,
                }
            )
    return out

wordlive.TextBoxCollection

TextBoxCollection(doc: Document)

Iterable view over the document's text boxes (doc.text_boxes).

The shape_type == "text_box" subset of doc.shapes β€” a discovery filter, not a second id space: each text box keeps its canonical shape:N id (its position among all floating shapes), so doc.text_boxes[1].anchor_id may be e.g. shape:3. Index 1-based over the text boxes; list() is the text-box rows of doc.shapes.list().

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

list

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

The text-box rows of doc.shapes.list() (each keeping its shape:N id).

Source code in src/wordlive/_anchors/_shape_anchors.py
def list(self) -> list[dict[str, Any]]:
    """The text-box rows of `doc.shapes.list()` (each keeping its `shape:N` id)."""
    return [row for row in ShapeCollection(self._doc).list() if row["shape_type"] == "text_box"]

Equations

Mathematical equations as first-class anchors. The write side is Anchor.insert_equation: it takes exactly one of three input dialects — unicodemath= (Word's native linear form, e.g. "a^2+b^2=c^2", zero-dependency), latex= (the optional latex extra does the LaTeX→MathML hop), or mathml= (a <math> string) — converts it to Office Math, and places it on its own paragraph with a pinned style so it never inherits a neighbouring heading's style: display=True gives it the dedicated centred Equation paragraph style (created on first use, based on Normal); display=False resets the paragraph to Normal and left-aligns it (still its own paragraph, not mid-sentence). It returns an EquationAnchor addressed equation:N — a positional id in Word's OMaths order, so inserting another equation before it renumbers it (re-list rather than caching the id across further inserts). LaTeX and MathML travel LaTeX→MathML→OMML→Word through Office's own shipped XSLT (MML2OMML.XSL), so only the LaTeX→MathML step needs a third-party library; malformed input or a missing backend raises EquationError.

doc.equations is the read side: a discovery collection whose list() reports each equation's equation:N id, type (display/inline), a linear preview, and the para:N it sits in. Index it (doc.equations[2]) for an EquationAnchor, then read equation.mathml (a non-mutating round-trip back to MathML via Office's OMML2MML.XSL) or equation.linear. An equation has no plain text, so set_text raises β€” delete and re-insert to change it.

wordlive.EquationAnchor

EquationAnchor(doc: Document, index: int)

Bases: Anchor

A mathematical equation located by 1-based index β€” equation:N.

Mirrors Word's own OMaths(N) ordering (document order). The anchor resolves to the equation's range, so mathml round-trips it back to MathML (via Office's own transform, without mutating the document) and linear reads its UnicodeMath form. type is "display" or "inline". Create equations with Anchor.insert_equation; discover them via doc.equations. An equation isn't plain text, so set_text raises β€” delete and re-insert to change it.

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

type property

type: str

"display" (its own centred line) or "inline" (in the text flow).

mathml property

mathml: str

The equation as MathML — a non-mutating read via Office's OMML→MathML transform.

linear property

linear: str

The equation's text in Word's built-up linear form (a compact preview).

Reads the zone's text with the internal structure markers collapsed β€” a readable approximation of the math, not a precise round-trip. For fidelity use mathml.

wordlive.EquationCollection

EquationCollection(doc: Document)

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

Index an equation by 1-based position (doc.equations[2]) to get an EquationAnchor (equation:N), then mathml / linear to read it. list() summarises every equation β€” id, type, a linear preview, and the para:N it sits in. Positions match Word's own OMaths(n) ordering. The write mirror is any anchor's insert_equation.

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

list

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

Every equation as {index, anchor_id, type, linear, para}.

type is "display" / "inline"; linear is the built-up text as a compact preview (read EquationAnchor.mathml for fidelity); para is the para:N the equation sits in (or None). Reads no XML, so this is cheap to call over a whole document.

Source code in src/wordlive/_anchors/_equation_anchors.py
def list(self) -> list[dict[str, Any]]:
    """Every equation as `{index, anchor_id, type, linear, para}`.

    `type` is ``"display"`` / ``"inline"``; `linear` is the built-up text as
    a compact preview (read [`EquationAnchor.mathml`][wordlive.EquationAnchor]
    for fidelity); `para` is the `para:N` the equation sits in (or ``None``).
    Reads no XML, so this is cheap to call over a whole document.
    """
    out: list[dict[str, Any]] = []
    with _com.translate_com_errors():
        omaths = self._doc.com.OMaths
        count = int(omaths.Count)
        for i in range(1, count + 1):
            zone = omaths.Item(i)
            rng = zone.Range
            try:
                start = int(rng.Start)
            except Exception:
                start = None
            try:
                eq_type = "display" if int(zone.Type) == 1 else "inline"
            except Exception:
                eq_type = "inline"
            linear = str(rng.Text or "").replace("\r", "").replace("\x0b", "").strip()
            para_id: str | None = None
            if start is not None:
                para = self._doc.paragraphs.at(start)
                para_id = para.anchor_id if para is not None else None
            out.append(
                {
                    "index": i,
                    "anchor_id": f"equation:{i}",
                    "type": eq_type,
                    "linear": linear,
                    "para": para_id,
                }
            )
    return out

Charts

Excel-backed charts as first-class anchors. The write side is Anchor.insert_chart: kind is "bar" (clustered columns), "pie", "line", or "scatter", and data is either a {label: value} mapping (for bar/pie/line) or an array of [x, y] pairs (for scatter β€” both axes numeric, with duplicate/clustered x preserved as distinct points; line accepts either). title= sets the chart title and series name. It returns a ChartAnchor addressed chart:N β€” a positional id in document order, so inserting another chart earlier renumbers it.

Charts embed a chart via InlineShapes.AddChart2, whose data lives in a hidden Excel workbook β€” so Excel must be installed. A non-invasive registry probe gates the insert and raises ExcelNotAvailableError (CLI exit 6) before touching the document if Excel is absent. After populating the data wordlive breaks the data link, so the chart's data is static: no embedded workbook ships in the document, and the series data isn't read back (which keeps the hidden Excel from orphaning). The Python API is ungated; the CLI/MCP surfaces add the same Excel probe.

doc.charts is the read side: a discovery collection whose list() reports each chart's chart:N id, kind, title, chart_style, has_legend, and the para:N it sits in (metadata only). Index it (doc.charts[2]) for a ChartAnchor, then read chart.chart_type / chart.title / chart.chart_style / chart.has_legend. A chart has no plain text, so set_text raises β€” delete and re-insert to change the data.

Formatting & design

The chart's appearance β€” Word's "Design" and "Format" tabs β€” is a curated set of methods on ChartAnchor. They operate on the post-insert, static chart, so no Excel is involved (and no ExcelNotAvailableError); every field is tri-state (only what you pass is written), and each method returns self so they chain:

doc.charts[1].format(
    title="Quarterly revenue", legend=True, legend_position="bottom",
    chart_style=242, background="#F4F6F7", data_labels=True,
).set_axis("value", title="USD (M)", minimum=0, maximum=30, scale="log")

scatter = doc.charts[2]
scatter.add_trendline(kind="power", display_equation=True)  # draws the law of best fit
scatter.set_series_color("#2E86C1")          # or point=N for one bar / pie slice
scatter.format_series(marker="circle", marker_size=8, smooth=True)  # markers + smoothed line
doc.charts[1].add_error_bars(kind="percent", amount=5)             # Β± error bars on the value axis
  • format(...) β€” whole-chart/design: title (None clears), legend + legend_position, chart_style (design-gallery int), background / plot_background fills, font / font_size / font_color, data_labels + data_label_format, chart_type to re-type the chart in place, plus gap_width / overlap (bar spacing) and data_table (the grid beneath the plot).
  • set_axis(which, ...) β€” which is "value"/"y" or "category"/"x"; sets title, minimum/maximum, scale ("linear"/"log"), number_format, gridlines.
  • add_trendline(...) β€” kind ∈ linear/exponential/logarithmic/ moving_average/polynomial/power on a 1-based series, with display_equation / display_r_squared, forward/backward forecast, and order (polynomial degree 2–6) / period (moving-average window).
  • set_series_color(color, *, series=1, point=None) β€” recolour a whole series, or one 1-based point (bar / pie slice / marker). color is a name, hex, or (r, g, b).
  • format_series(*, series=1, point=None, ...) β€” markers (marker glyph name or XlMarkerStyle int, marker_size), line smooth, pie explosion, and per-series/point data labels (data_labels, data_label_size, data_label_color). point narrows marker / explosion / label to one point.
  • add_error_bars(*, series=1, kind="fixed", amount=None, include="both", axis="y") β€” draw fixed/percent/stdev/sterror error bars; amount is required for all kinds but sterror (which Word computes).

Bad input (unknown colour / scale / trendline kind / marker, or an error-bar kind missing its amount) raises OpError. Wrap calls in doc.edit(...) for atomic undo.

wordlive.ChartAnchor

ChartAnchor(doc: Document, index: int)

Bases: Anchor

An Excel-backed chart located by 1-based index β€” chart:N.

Indexes the document's chart inline shapes in document order. The anchor resolves to the chart's range, so it inherits apply_style / formatting like any anchor. chart_type reports the kind string ("bar" / "pie" / "line" / "scatter") and title the chart title β€” metadata only: charts are inserted with a broken data link, so the underlying series data is static and isn't read back. Create charts with Anchor.insert_chart; discover them via doc.charts. A chart isn't plain text, so set_text raises.

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

chart_type property

chart_type: str

The chart kind β€” "bar" / "pie" / "line" / "scatter" (or the raw int).

title property

title: str | None

The chart's title, or None if it has none.

chart_style property

chart_style: int

The built-in design-gallery style id (Chart.ChartStyle).

has_legend property

has_legend: bool

Whether the chart currently shows a legend.

format

format(*, title: Any = _charts._UNSET, legend: bool | None = None, legend_position: str | None = None, chart_style: int | None = None, background: Any = None, plot_background: Any = None, font: str | None = None, font_size: Any = None, font_color: Any = None, data_labels: bool | None = None, data_label_format: str | None = None, chart_type: str | None = None, gap_width: int | None = None, overlap: int | None = None, data_table: bool | None = None) -> ChartAnchor

Apply whole-chart / design formatting β€” Word's chart "Design" tab.

All kwargs are optional and tri-state; only the ones you pass are written. title=None clears the chart title (omit it to leave it). legend toggles the legend; legend_position ("right"/"left"/"top"/ "bottom"/"corner") implies it's shown. chart_style is the built-in design-gallery int. background / plot_background fill the chart and plot areas; font / font_size / font_color set the whole-chart font. data_labels toggles point labels on every series, data_label_format is their number format. chart_type ("bar"/"pie"/"line"/ "scatter") re-types the chart in place. gap_width / overlap tune bar spacing (bar/column charts only); data_table toggles the data-table grid beneath the plot. Operates on the static chart β€” no Excel needed. Returns self (chainable); wrap in doc.edit(...) for atomic undo. Bad input raises OpError.

Source code in src/wordlive/_anchors/_chart_anchors.py
def format(
    self,
    *,
    title: Any = _charts._UNSET,
    legend: bool | None = None,
    legend_position: str | None = None,
    chart_style: int | None = None,
    background: Any = None,
    plot_background: Any = None,
    font: str | None = None,
    font_size: Any = None,
    font_color: Any = None,
    data_labels: bool | None = None,
    data_label_format: str | None = None,
    chart_type: str | None = None,
    gap_width: int | None = None,
    overlap: int | None = None,
    data_table: bool | None = None,
) -> ChartAnchor:
    """Apply whole-chart / design formatting β€” Word's chart "Design" tab.

    All kwargs are optional and tri-state; only the ones you pass are written.
    `title=None` clears the chart title (omit it to leave it). `legend`
    toggles the legend; `legend_position` (`"right"`/`"left"`/`"top"`/
    `"bottom"`/`"corner"`) implies it's shown. `chart_style` is the built-in
    design-gallery int. `background` / `plot_background` fill the chart and
    plot areas; `font` / `font_size` / `font_color` set the whole-chart font.
    `data_labels` toggles point labels on every series, `data_label_format`
    is their number format. `chart_type` (`"bar"`/`"pie"`/`"line"`/
    `"scatter"`) re-types the chart in place. `gap_width` / `overlap` tune bar
    spacing (bar/column charts only); `data_table` toggles the data-table grid
    beneath the plot. Operates on the static chart β€” no Excel needed. Returns
    `self` (chainable); wrap in `doc.edit(...)` for atomic undo. Bad input
    raises `OpError`.
    """
    self._apply(
        _charts.apply_chart_format,
        title=title,
        legend=legend,
        legend_position=legend_position,
        chart_style=chart_style,
        background=background,
        plot_background=plot_background,
        font=font,
        font_size=font_size,
        font_color=font_color,
        data_labels=data_labels,
        data_label_format=data_label_format,
        chart_type=chart_type,
        gap_width=gap_width,
        overlap=overlap,
        data_table=data_table,
    )
    return self

set_axis

set_axis(which: str, *, title: Any = _charts._UNSET, minimum: Any = None, maximum: Any = None, scale: str | None = None, number_format: str | None = None, gridlines: bool | None = None) -> ChartAnchor

Format one axis. which is "value"/"y" or "category"/"x".

Tri-state: title=None clears the axis title; minimum/maximum set the scale bounds; scale is "linear" or "log" (log is ideal for order-of-magnitude data); number_format is the tick-label format string; gridlines toggles major gridlines. Returns self. Bad input raises OpError.

Source code in src/wordlive/_anchors/_chart_anchors.py
def set_axis(
    self,
    which: str,
    *,
    title: Any = _charts._UNSET,
    minimum: Any = None,
    maximum: Any = None,
    scale: str | None = None,
    number_format: str | None = None,
    gridlines: bool | None = None,
) -> ChartAnchor:
    """Format one axis. `which` is ``"value"``/``"y"`` or ``"category"``/``"x"``.

    Tri-state: `title=None` clears the axis title; `minimum`/`maximum` set the
    scale bounds; `scale` is ``"linear"`` or ``"log"`` (log is ideal for
    order-of-magnitude data); `number_format` is the tick-label format string;
    `gridlines` toggles major gridlines. Returns `self`. Bad input raises
    `OpError`.
    """
    self._apply(
        _charts.apply_axis_format,
        which,
        title=title,
        minimum=minimum,
        maximum=maximum,
        scale=scale,
        number_format=number_format,
        gridlines=gridlines,
    )
    return self

add_trendline

add_trendline(*, series: int = 1, kind: str = 'linear', display_equation: bool = False, display_r_squared: bool = False, forward: Any = None, backward: Any = None, order: int | None = None, period: int | None = None) -> ChartAnchor

Fit a trendline to a series (1-based series).

kind is "linear", "exponential", "logarithmic", "moving_average", "polynomial", or "power". display_equation / display_r_squared annotate the fit β€” a power/exponential fit with the equation literally draws the law of best fit. forward / backward extend the line that many units past the data. order is the polynomial degree (2–6, with kind="polynomial"); period is the moving-average window (with kind="moving_average"). Returns self. Bad input raises OpError.

Source code in src/wordlive/_anchors/_chart_anchors.py
def add_trendline(
    self,
    *,
    series: int = 1,
    kind: str = "linear",
    display_equation: bool = False,
    display_r_squared: bool = False,
    forward: Any = None,
    backward: Any = None,
    order: int | None = None,
    period: int | None = None,
) -> ChartAnchor:
    """Fit a trendline to a series (1-based `series`).

    `kind` is ``"linear"``, ``"exponential"``, ``"logarithmic"``,
    ``"moving_average"``, ``"polynomial"``, or ``"power"``. `display_equation`
    / `display_r_squared` annotate the fit β€” a power/exponential fit with the
    equation literally draws the law of best fit. `forward` / `backward`
    extend the line that many units past the data. `order` is the polynomial
    degree (2–6, with `kind="polynomial"`); `period` is the moving-average
    window (with `kind="moving_average"`). Returns `self`. Bad input raises
    `OpError`.
    """
    self._apply(
        _charts.add_trendline,
        series=series,
        kind=kind,
        display_equation=display_equation,
        display_r_squared=display_r_squared,
        forward=forward,
        backward=backward,
        order=order,
        period=period,
    )
    return self

set_series_color

set_series_color(color: Any, *, series: int = 1, point: int | None = None) -> ChartAnchor

Recolour a whole series, or a single 1-based point (bar / pie slice).

color is a named colour, hex ("#2E86C1"), or (r, g, b). Omit point to colour the entire series; pass it to vary one bar / slice / marker. Sets the line/marker colour too where the series has one (line/scatter). Returns self. Bad input raises OpError.

Source code in src/wordlive/_anchors/_chart_anchors.py
def set_series_color(
    self, color: Any, *, series: int = 1, point: int | None = None
) -> ChartAnchor:
    """Recolour a whole series, or a single 1-based `point` (bar / pie slice).

    `color` is a named colour, hex (`"#2E86C1"`), or `(r, g, b)`. Omit `point`
    to colour the entire series; pass it to vary one bar / slice / marker.
    Sets the line/marker colour too where the series has one (line/scatter).
    Returns `self`. Bad input raises `OpError`.
    """
    self._apply(_charts.set_series_color, color, series=series, point=point)
    return self

format_series

format_series(*, series: int = 1, point: int | None = None, marker: Any = None, marker_size: int | None = None, smooth: bool | None = None, explosion: int | None = None, data_labels: bool | None = None, data_label_size: Any = None, data_label_color: Any = None) -> ChartAnchor

Format one series, or a single 1-based point within it.

marker is a glyph name ("circle"/"square"/"diamond"/ "triangle"/"x"/"star"/"dot"/"dash"/"plus"/ "none"/"auto") or a raw XlMarkerStyle int, with marker_size (2–72) β€” both for line/scatter. smooth curves a line/scatter through its points. explosion (0–400) pulls a pie slice out. data_labels toggles this series' point labels; data_label_size / data_label_color style their font. With point set, marker / explosion / the data-label font target that one point; marker_size / smooth / the data_labels toggle stay series-wide. Returns self. Bad input raises OpError.

Source code in src/wordlive/_anchors/_chart_anchors.py
def format_series(
    self,
    *,
    series: int = 1,
    point: int | None = None,
    marker: Any = None,
    marker_size: int | None = None,
    smooth: bool | None = None,
    explosion: int | None = None,
    data_labels: bool | None = None,
    data_label_size: Any = None,
    data_label_color: Any = None,
) -> ChartAnchor:
    """Format one series, or a single 1-based `point` within it.

    `marker` is a glyph name (``"circle"``/``"square"``/``"diamond"``/
    ``"triangle"``/``"x"``/``"star"``/``"dot"``/``"dash"``/``"plus"``/
    ``"none"``/``"auto"``) or a raw `XlMarkerStyle` int, with `marker_size`
    (2–72) β€” both for line/scatter. `smooth` curves a line/scatter through its
    points. `explosion` (0–400) pulls a pie slice out. `data_labels` toggles
    this series' point labels; `data_label_size` / `data_label_color` style
    their font. With `point` set, `marker` / `explosion` / the data-label font
    target that one point; `marker_size` / `smooth` / the `data_labels` toggle
    stay series-wide. Returns `self`. Bad input raises `OpError`.
    """
    self._apply(
        _charts.format_series,
        series=series,
        point=point,
        marker=marker,
        marker_size=marker_size,
        smooth=smooth,
        explosion=explosion,
        data_labels=data_labels,
        data_label_size=data_label_size,
        data_label_color=data_label_color,
    )
    return self

add_error_bars

add_error_bars(*, series: int = 1, kind: str = 'fixed', amount: Any = None, include: str = 'both', axis: str = 'y') -> ChartAnchor

Draw error bars on a series (1-based series).

kind is "fixed" (an absolute amount), "percent" (of each value), "stdev" (multiples of the standard deviation), or "sterror" (the standard error β€” Word computes it, so amount is ignored). amount is the magnitude (required for all kinds but "sterror"). include is which side(s) to draw ("both" / "plus" / "minus"); axis is "y"/"value" (the usual) or "x"/"category" for scatter x-uncertainty. Returns self. Bad input raises OpError.

Source code in src/wordlive/_anchors/_chart_anchors.py
def add_error_bars(
    self,
    *,
    series: int = 1,
    kind: str = "fixed",
    amount: Any = None,
    include: str = "both",
    axis: str = "y",
) -> ChartAnchor:
    """Draw error bars on a series (1-based `series`).

    `kind` is ``"fixed"`` (an absolute amount), ``"percent"`` (of each value),
    ``"stdev"`` (multiples of the standard deviation), or ``"sterror"`` (the
    standard error β€” Word computes it, so `amount` is ignored). `amount` is the
    magnitude (required for all kinds but ``"sterror"``). `include` is which
    side(s) to draw (``"both"`` / ``"plus"`` / ``"minus"``); `axis` is
    ``"y"``/``"value"`` (the usual) or ``"x"``/``"category"`` for scatter
    x-uncertainty. Returns `self`. Bad input raises `OpError`.
    """
    self._apply(
        _charts.add_error_bars,
        series=series,
        kind=kind,
        amount=amount,
        include=include,
        axis=axis,
    )
    return self

wordlive.ChartCollection

ChartCollection(doc: Document)

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

Index a chart by 1-based position (doc.charts[2]) to get a ChartAnchor (chart:N); list() summarises every chart β€” id, kind, title, and the para:N it sits in. Positions follow document order. Metadata only β€” charts are inserted with their data link broken (static data), so reading the series back is deferred. The write mirror is any anchor's insert_chart.

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

list

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

Every chart as {index, anchor_id, kind, title, chart_style, has_legend, para}.

kind is the chart-type string; title the chart title (or None); chart_style the design-gallery id; has_legend whether a legend shows; para the para:N the chart sits in. Touches only ChartType / ChartTitle / ChartStyle / HasLegend (never the series data), so it's cheap and Word-stable.

Source code in src/wordlive/_anchors/_chart_anchors.py
def list(self) -> list[dict[str, Any]]:
    """Every chart as `{index, anchor_id, kind, title, chart_style, has_legend, para}`.

    `kind` is the chart-type string; `title` the chart title (or ``None``);
    `chart_style` the design-gallery id; `has_legend` whether a legend shows;
    `para` the `para:N` the chart sits in. Touches only `ChartType` /
    `ChartTitle` / `ChartStyle` / `HasLegend` (never the series data), so it's
    cheap and Word-stable.
    """
    out: list[dict[str, Any]] = []
    with _com.translate_com_errors():
        shapes = _charts.chart_shapes(self._doc.com)
        for i, shape in enumerate(shapes, start=1):
            chart = shape.Chart
            try:
                kind: str | None = _charts.XL_TO_KIND.get(
                    int(chart.ChartType), str(int(chart.ChartType))
                )
            except Exception:
                kind = None
            try:
                title = str(chart.ChartTitle.Text) if bool(chart.HasTitle) else None
            except Exception:
                title = None
            try:
                chart_style: int | None = int(chart.ChartStyle)
            except Exception:
                chart_style = None
            try:
                has_legend: bool | None = bool(chart.HasLegend)
            except Exception:
                has_legend = None
            try:
                start = int(shape.Range.Start)
            except Exception:
                start = None
            para_id: str | None = None
            if start is not None:
                para = self._doc.paragraphs.at(start)
                para_id = para.anchor_id if para is not None else None
            out.append(
                {
                    "index": i,
                    "anchor_id": f"chart:{i}",
                    "kind": kind,
                    "title": title,
                    "chart_style": chart_style,
                    "has_legend": has_legend,
                    "para": para_id,
                }
            )
    return out

References, linking & layout

Notes and the TOC family, cross-references, durable pins, hyperlinks, and section layout.

Footnotes, endnotes & TOC

Notes and the table of contents are reference structures built from anchors. anchor.insert_footnote(text) / insert_endnote(text) drop a reference mark at the anchor and put the body in the note story; they return a Footnote / Endnote addressed footnote:N / endnote:N, so note.set_text(...) edits the body and note.delete() removes the mark and body together. Discover existing notes with doc.footnotes / doc.endnotes (read-only collections whose list() reports each note's number, text, and the para:N it's anchored at).

anchor.insert_toc(levels=(1, 3), use_heading_styles=True, hyperlinks=True) inserts a table of contents over the document's headings and returns a Toc; doc.add_toc(...) is the sugar for placing one at the document start. A TOC's page numbers only populate after repagination β€” call toc.update(), Document.update_fields(), or take a snapshot (which forces print layout) before reading them.

anchor.insert_table_of_figures(label="Figure", include_label=True, hyperlinks=True, right_align_page_numbers=True) is the same field-block pattern over the captions wordlive inserts: it lists every caption of one label ("Figure"/"Table"/ "Equation"/custom) with its page number and returns a TableOfFigures with update() / update_page_numbers().

A back-of-book index is two steps. anchor.mark_index_entry(entry, cross_reference=…, bold=…, italic=…) marks the anchor's range as an XE index field β€” entry uses "main:sub" to nest a subentry β€” then anchor.insert_index(columns=2, run_in=False, right_align_page_numbers=False) builds the index from those marks and returns an Index; doc.add_index(...) is the sugar for one at the document end. Like the TOC, the TableOfFigures and Index are field blocks: their page numbers populate only after repagination (update(), update_fields(), or a snapshot).

Citations and a bibliography are a source-then-cite-then-build workflow. doc.sources is a SourceCollection over the document's master source list: doc.sources.add(source_type="book", author=…, title=…, year=…, …) registers a Source (source_type is "book" / "journal_article" / "web_site" / "case" / … β€” author is "Last, First" or a list, and tag auto-derives from the first author's surname + year when omitted), doc.sources.add_xml("<b:Source>…") is the raw-OOXML escape hatch, and the collection is list/index/in/len-able by tag. doc.bibliography_style is the read/write style property ("APA" / "MLA" / "Chicago" / "IEEE" / "Turabian", build-dependent β€” an unsupported value raises OpError). anchor.insert_citation(tag, pages=…, suppress_author=…, …) inserts an in-text CITATION field rendering per that style (e.g. (Smith 2020, 15)) and returns a Citation β€” a tag with no registered source still inserts but renders "Invalid source specified.". anchor.insert_bibliography() inserts the works-cited block and returns a Bibliography; doc.add_bibliography() is the sugar for one at the document end.

A table of authorities (the legal-citation index) is the same two-step, mark-then-build pattern as the back-of-book index. anchor.mark_citation(long_citation, short_citation=…, category="cases") marks the anchor's range as a TA field (category is "cases" / "statutes" / "other" / … or an int 1–16; short_citation defaults to long_citation), then anchor.insert_table_of_authorities(category="all", passim=True, keep_entry_formatting=True) builds the table from those marks and returns a TableOfAuthorities; doc.add_table_of_authorities(...) is the sugar for one at the document end. Like the TOC, the Bibliography and TableOfAuthorities are field blocks: their entries and page numbers populate only after repagination (update(), update_fields(), or a snapshot). (Word's table of authorities has no per-field page-number refresh, so unlike the TOC and TableOfFigures there's no update_page_numbers() β€” use a full update().)

The document theme is the document-wide brand primitive β€” the colour scheme, font scheme, and effects the Design tab drives. doc.theme is a DocumentTheme: doc.theme.apply("Facet") applies a whole theme by built-in name (see doc.theme.list_available()) or .thmx path; doc.theme.set_colors(scheme="Blue", accent1="#1A73E8", text1="navy") loads a named colour scheme and/or overrides individual brand colours (keys text1 / background1 / text2 / background2 / accent1–accent6 / hyperlink / followed_hyperlink, values a colour name / hex / (r, g, b)); and doc.theme.set_fonts(scheme="Garamond", major="Arial", minor="Calibri") sets the heading/body fonts. doc.theme.colors / .major_font / .minor_font / .to_dict() read the current theme back. Wrap theme mutations in doc.edit(...).

wordlive.Footnote

Footnote(doc: Document, index: int)

Bases: _NoteAnchor

A footnote, addressed footnote:N and resolving to its note-body range.

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

wordlive.Endnote

Endnote(doc: Document, index: int)

Bases: _NoteAnchor

An endnote, addressed endnote:N and resolving to its note-body range.

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

wordlive.FootnoteCollection

FootnoteCollection(doc: Document)

Bases: _NoteCollection

doc.footnotes β€” read-only discovery over the document's footnotes.

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

wordlive.EndnoteCollection

EndnoteCollection(doc: Document)

Bases: _NoteCollection

doc.endnotes β€” read-only discovery over the document's endnotes.

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

wordlive.Toc

Toc(doc: Document, com: Any)

A table of contents created by insert_toc / add_toc.

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

com property

com: Any

Raw COM TableOfContents object β€” the escape hatch.

range property

range: Any

The COM Range the TOC occupies.

text property

text: str

The TOC's rendered text (entries + page numbers, once updated).

update

update() -> None

Rebuild the TOC's entries and page numbers from the current document.

Call this after edits that change headings or pagination β€” or use Document.update_fields to refresh the TOC together with every other field. Wrap in doc.edit(...) for atomic undo.

Source code in src/wordlive/_toc.py
def update(self) -> None:
    """Rebuild the TOC's entries and page numbers from the current document.

    Call this after edits that change headings or pagination β€” or use
    [`Document.update_fields`][wordlive.Document.update_fields] to refresh
    the TOC together with every other field. Wrap in `doc.edit(...)` for
    atomic undo.
    """
    with _com.translate_com_errors():
        self._com.Update()

update_page_numbers

update_page_numbers() -> None

Refresh only the TOC's page numbers (cheaper than a full update()).

Source code in src/wordlive/_toc.py
def update_page_numbers(self) -> None:
    """Refresh only the TOC's page numbers (cheaper than a full `update()`)."""
    with _com.translate_com_errors():
        self._com.UpdatePageNumbers()

wordlive.TableOfFigures

TableOfFigures(doc: Document, com: Any)

A table of figures created by insert_table_of_figures.

The caption-driven sibling of Toc: it lists every caption of one label ("Figure" / "Table" / "Equation" / a custom label) with its page number, built over Word's TablesOfFigures. Like a TOC it is a field block, not a single addressable range β€” refresh it with update() / update_page_numbers(), or with Document.update_fields(). Page numbers populate only after repagination.

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

com property

com: Any

Raw COM TableOfFigures object β€” the escape hatch.

range property

range: Any

The COM Range the table of figures occupies.

text property

text: str

The rendered text (entries + page numbers, once updated).

update

update() -> None

Rebuild entries and page numbers from the current document's captions.

Source code in src/wordlive/_toc.py
def update(self) -> None:
    """Rebuild entries and page numbers from the current document's captions."""
    with _com.translate_com_errors():
        self._com.Update()

update_page_numbers

update_page_numbers() -> None

Refresh only the page numbers (cheaper than a full update()).

Source code in src/wordlive/_toc.py
def update_page_numbers(self) -> None:
    """Refresh only the page numbers (cheaper than a full `update()`)."""
    with _com.translate_com_errors():
        self._com.UpdatePageNumbers()

wordlive.Index

Index(doc: Document, com: Any)

A back-of-book index created by insert_index / add_index.

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

com property

com: Any

Raw COM Index object β€” the escape hatch.

range property

range: Any

The COM Range the index occupies.

text property

text: str

The index's rendered text (entries + page numbers, once updated).

update

update() -> None

Rebuild the index's entries and page numbers from the marked entries.

Call this after adding or moving XE marks β€” or use Document.update_fields to refresh the index together with every other field. Wrap in doc.edit(...) for atomic undo.

Source code in src/wordlive/_index.py
def update(self) -> None:
    """Rebuild the index's entries and page numbers from the marked entries.

    Call this after adding or moving `XE` marks β€” or use
    [`Document.update_fields`][wordlive.Document.update_fields] to refresh the
    index together with every other field. Wrap in `doc.edit(...)` for atomic
    undo.
    """
    with _com.translate_com_errors():
        self._com.Update()

wordlive.Source

Source(doc: Document, tag: str, *, com: Any | None = None)

One bibliography source in doc.sources, addressed by its tag.

Source code in src/wordlive/_sources.py
def __init__(self, doc: Document, tag: str, *, com: Any | None = None) -> None:
    self._doc = doc
    self._tag = tag
    # A freshly added source caches its live COM object; named lookups go
    # through `_source_by_tag` (mirrors ContentControl).
    self._src_com = com

com property

com: Any

Raw COM Source object β€” the escape hatch.

tag property

tag: str

The source's tag β€” the id a citation references.

cited property

cited: bool

Whether a citation in the document currently references this source.

xml property

xml: str

The source's <b:Source> OOXML.

delete

delete() -> None

Remove the source from the document's store. Wrap in doc.edit(...).

Source code in src/wordlive/_sources.py
def delete(self) -> None:
    """Remove the source from the document's store. Wrap in `doc.edit(...)`."""
    with _com.translate_com_errors():
        self._src().Delete()

wordlive.Citation

Citation(doc: Document, com: Any)

An in-text citation field created by insert_citation.

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

com property

com: Any

Raw COM Field object β€” the escape hatch.

range property

range: Any

The COM Range the citation's rendered text occupies.

text property

text: str

The rendered citation (e.g. " (Smith 2020)").

tag property

tag: str

The source tag this citation references (parsed from its field code).

update

update() -> None

Re-render the citation (e.g. after changing the bibliography style).

Source code in src/wordlive/_citations.py
def update(self) -> None:
    """Re-render the citation (e.g. after changing the bibliography style)."""
    with _com.translate_com_errors():
        self._com.Update()

wordlive.Bibliography

Bibliography(doc: Document, com: Any)

A generated bibliography field created by insert_bibliography / add_bibliography.

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

com property

com: Any

Raw COM Field object β€” the escape hatch.

range property

range: Any

The COM Range the bibliography occupies.

text property

text: str

The rendered reference list (once updated).

update

update() -> None

Rebuild the reference list from the cited sources.

Call this after adding citations or changing Document.bibliography_style β€” or use Document.update_fields to refresh it with every other field. Wrap in doc.edit(...) for atomic undo.

Source code in src/wordlive/_citations.py
def update(self) -> None:
    """Rebuild the reference list from the cited sources.

    Call this after adding citations or changing
    [`Document.bibliography_style`][wordlive.Document.bibliography_style] β€” or
    use [`Document.update_fields`][wordlive.Document.update_fields] to refresh
    it with every other field. Wrap in `doc.edit(...)` for atomic undo.
    """
    with _com.translate_com_errors():
        self._com.Update()

wordlive.TableOfAuthorities

TableOfAuthorities(doc: Document, com: Any)

A table of authorities created by insert_table_of_authorities.

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

com property

com: Any

Raw COM TableOfAuthorities object β€” the escape hatch.

range property

range: Any

The COM Range the table occupies.

text property

text: str

The table's rendered text (entries + page numbers, once updated).

update

update() -> None

Rebuild the table from the marked TA citations.

Call this after adding or moving citation marks β€” or use Document.update_fields to refresh it with every other field. Wrap in doc.edit(...) for atomic undo.

Source code in src/wordlive/_toa.py
def update(self) -> None:
    """Rebuild the table from the marked ``TA`` citations.

    Call this after adding or moving citation marks β€” or use
    [`Document.update_fields`][wordlive.Document.update_fields] to refresh it
    with every other field. Wrap in `doc.edit(...)` for atomic undo.
    """
    with _com.translate_com_errors():
        self._com.Update()

wordlive.DocumentTheme

DocumentTheme(doc: Document)

The document's theme β€” doc.theme.

A read/mutate view over Word's OfficeTheme: read the current colours/fonts, apply(...) a whole theme, or set_colors(...)/set_fonts(...) to brand a document. Mutations should be wrapped in doc.edit(...) for atomic undo and to preserve the user's selection/scroll.

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

com property

com: Any

Raw COM OfficeTheme object β€” the escape hatch.

colors property

colors: dict[str, str]

The 12 theme colours as {friendly_name: "#RRGGBB"}.

Keys are text1, background1, text2, background2, accent1– accent6, hyperlink, followed_hyperlink.

major_font property

major_font: str

The theme's major (heading) font name.

minor_font property

minor_font: str

The theme's minor (body) font name.

set_colors

set_colors(scheme: str | None = None, **colors: Any) -> dict[str, str]

Set the theme's colour scheme and/or individual brand colours.

scheme loads a named built-in colour scheme ("Blue", "Orange", …) or a Theme-Colors .xml path; then each keyword override (accent1="#1A73E8", text1="navy", … β€” friendly names from theme.colors) is applied. Colour values take any form to_bgr accepts (a colour name, a hex string, or an (r, g, b) tuple). Wrap in doc.edit(...). Unknown scheme name or colour key/value raises OpError. Returns the resulting colors dict.

Source code in src/wordlive/_themes.py
def set_colors(self, scheme: str | None = None, **colors: Any) -> dict[str, str]:
    """Set the theme's colour scheme and/or individual brand colours.

    `scheme` loads a named built-in colour scheme (``"Blue"``, ``"Orange"``,
    …) or a Theme-Colors ``.xml`` path; then each keyword override
    (`accent1="#1A73E8"`, `text1="navy"`, … β€” friendly names from
    `theme.colors`) is applied. Colour values take any form `to_bgr` accepts
    (a colour name, a hex string, or an ``(r, g, b)`` tuple).
    Wrap in `doc.edit(...)`. Unknown scheme name or colour key/value raises
    `OpError`. Returns the resulting `colors` dict.
    """
    try:
        with _com.translate_com_errors():
            tcs = self._theme().ThemeColorScheme
            if scheme is not None:
                tcs.Load(_resolve_theme_file(self._app(), scheme, "colors"))
            for key, value in colors.items():
                idx = _COLOR_INDEX.get(key.lower())
                if idx is None:
                    raise ValueError(
                        f"unknown theme colour {key!r}; expected one of "
                        f"{sorted(set(_COLOR_NAMES.values()))}"
                    )
                tcs.Colors(idx).RGB = to_bgr(value)
    except (ValueError, TypeError) as e:
        raise OpError(str(e)) from e
    return self.colors

set_fonts

set_fonts(scheme: str | None = None, *, major: str | None = None, minor: str | None = None) -> dict[str, str]

Set the theme's fonts via a named scheme and/or explicit names.

scheme loads a named built-in font scheme ("Garamond", "Arial", …) or a Theme-Fonts .xml path; then major (heading font) and minor (body font) override individual names. Wrap in doc.edit(...). An unknown scheme name raises OpError. Returns {"major_font", "minor_font"}.

Source code in src/wordlive/_themes.py
def set_fonts(
    self, scheme: str | None = None, *, major: str | None = None, minor: str | None = None
) -> dict[str, str]:
    """Set the theme's fonts via a named scheme and/or explicit names.

    `scheme` loads a named built-in font scheme (``"Garamond"``, ``"Arial"``,
    …) or a Theme-Fonts ``.xml`` path; then `major` (heading font) and
    `minor` (body font) override individual names. Wrap in `doc.edit(...)`.
    An unknown scheme name raises `OpError`. Returns
    ``{"major_font", "minor_font"}``.
    """
    with _com.translate_com_errors():
        tfs = self._theme().ThemeFontScheme
        if scheme is not None:
            tfs.Load(_resolve_theme_file(self._app(), scheme, "fonts"))
        if major is not None:
            tfs.MajorFont.Item(1).Name = str(major)
        if minor is not None:
            tfs.MinorFont.Item(1).Name = str(minor)
    return {"major_font": self.major_font, "minor_font": self.minor_font}

apply

apply(theme: str) -> str

Apply a whole document theme (colours + fonts + effects).

theme is a built-in name ("Facet", "Ion", … β€” see doc.theme discovery via the list-themes surfaces) or a .thmx file path. Wrap in doc.edit(...) for atomic undo. An unknown name raises OpError. Returns the resolved theme display name.

Source code in src/wordlive/_themes.py
def apply(self, theme: str) -> str:
    """Apply a whole document theme (colours + fonts + effects).

    `theme` is a built-in name (``"Facet"``, ``"Ion"``, … β€” see
    `doc.theme` discovery via the ``list-themes`` surfaces) or a ``.thmx``
    file path. Wrap in `doc.edit(...)` for atomic undo. An unknown name
    raises `OpError`. Returns the resolved theme display name.
    """
    path = _resolve_theme_file(self._app(), theme, "theme")
    with _com.translate_com_errors():
        self._doc.com.ApplyDocumentTheme(path)
    return os.path.splitext(os.path.basename(path))[0]

list_available

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

The built-in themes, colour schemes, and font schemes Office ships.

Returns {"themes": [...], "color_schemes": [...], "font_schemes": [...]} (names without extension) β€” the values apply, set_colors, and set_fonts accept. Empty lists if the library directory is absent.

Source code in src/wordlive/_themes.py
def list_available(self) -> dict[str, list[str]]:
    """The built-in themes, colour schemes, and font schemes Office ships.

    Returns ``{"themes": [...], "color_schemes": [...], "font_schemes":
    [...]}`` (names without extension) β€” the values `apply`, `set_colors`,
    and `set_fonts` accept. Empty lists if the library directory is absent.
    """
    with _com.translate_com_errors():
        return _list_builtin(self._app())

to_dict

to_dict() -> dict[str, Any]

The current theme as {colors, major_font, minor_font}.

Source code in src/wordlive/_themes.py
def to_dict(self) -> dict[str, Any]:
    """The current theme as ``{colors, major_font, minor_font}``."""
    return {
        "colors": self.colors,
        "major_font": self.major_font,
        "minor_font": self.minor_font,
    }

Anchoring & linking

Create a named anchor, then point at it. doc.bookmarks.add(name, anchor) creates a bookmark over an anchor's range (the name is validated against Word's rules first) β€” the prerequisite for internal navigation. anchor.link_to( address=…) makes the anchor an external hyperlink (URL / mailto: / file path); anchor.link_to(bookmark=…) makes it an internal jump to a bookmark. With text=None the anchor's existing range becomes the link; text=… instead inserts new linked text at the end of the range (so a heading keeps its content). anchor.insert_cross_reference( target, kind=…) inserts a reference to another anchor β€” target is a bookmark:NAME, heading:N, footnote:N, or endnote:N id, and kind is "text" / "page" / "number" / "above_below". anchor.insert_caption( label="Figure", text=…, position=None) adds an auto-numbered caption in its own Caption-styled paragraph (never fused into the target); position is "above"/"below", defaulting to above for a Table and below otherwise, and on a table cell the caption attaches to the whole table. Pair it with a cross-reference for "see Figure 2". Cross-references and TOC/page-number fields go stale when the document shifts β€” refresh them with Document.update_fields().

Content controls are the structured-document fill-in fields (the read/write side is doc.content_controls["NAME"]). anchor.insert_content_control( kind="rich_text", title=…, tag=…, items=…, where="wrap", lock_contents=False, lock_control=False) creates one and returns the ContentControl: where="wrap" (default) surrounds the anchor's existing range β€” e.g. a range:START-END from find β€” and "before" / "after" insert a fresh empty control. kind is rich_text (default) / text / picture / combo_box / dropdown / date / checkbox / building_block / group / repeating_section; items (combo_box/dropdown only) is a list of strings or {"text": …, "value": …} dicts; lock_contents stops edits to the value and lock_control stops deletion. A title (or, failing that, a tag) names the control so it's addressable later as cc:TITLE; the returned wrapper works even unnamed. doc.content_controls.add(anchor, kind=…, **kwargs) takes an Anchor or an anchor-id string.

A control's metadata is editable in place β€” no delete + reinsert. cc.set_properties(title=…, tag=…, lock_contents=…, lock_control=…) re-sets the labels and locks (tri-state: omit to leave, None/"" to clear title/tag; a rename changes the cc:NAME anchor id), and cc.set_items([...]) replaces a combo_box/dropdown's choice list. Both are chainable and raise OpError on a wrong-kind control or bad input.

wordlive.BookmarkCollection

BookmarkCollection(doc: Document)

Indexable view over a document's bookmarks.

list() and iteration return only user-visible bookmarks. Word's hidden bookmarks (_Toc..., _Ref..., etc.) are filtered out by default; address them by their exact name through bookmarks[name] if you need them.

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

add

add(name: str, anchor: Anchor | str) -> Bookmark

Create a bookmark named name over anchor's range and return it.

anchor is an Anchor or an anchor id string (resolved via doc.anchor_by_id). name is validated against Word's rules β€” it must start with a letter and contain only letters, digits, and underscores (no spaces), max 40 characters β€” and an invalid name raises OpError before anything is created. Adding a bookmark with an existing name moves it to the new range (Word's own behaviour). This is the prerequisite for internal hyperlinks (Anchor.link_to) and cross-references (Anchor.insert_cross_reference). Wrap in doc.edit(...) for atomic undo.

Source code in src/wordlive/_anchors/_bookmarks.py
def add(self, name: str, anchor: Anchor | str) -> Bookmark:
    """Create a bookmark named `name` over `anchor`'s range and return it.

    `anchor` is an [`Anchor`][wordlive.Anchor] or an anchor id string
    (resolved via `doc.anchor_by_id`). `name` is validated against Word's
    rules β€” it must start with a letter and contain only letters, digits, and
    underscores (no spaces), max 40 characters β€” and an invalid name raises
    `OpError` *before* anything is created. Adding a bookmark with an existing
    name moves it to the new range (Word's own behaviour). This is the
    prerequisite for internal hyperlinks
    ([`Anchor.link_to`][wordlive.Anchor.link_to]) and cross-references
    ([`Anchor.insert_cross_reference`][wordlive.Anchor.insert_cross_reference]).
    Wrap in `doc.edit(...)` for atomic undo.
    """
    _validate_bookmark_name(name)
    resolved = self._doc.anchor_by_id(anchor) if isinstance(anchor, str) else anchor
    with _com.translate_com_errors():
        rng = resolved.com
        self._doc.com.Bookmarks.Add(Name=name, Range=rng)
    return Bookmark(self._doc, name)

list

list(*, include_hidden: bool = False) -> list[str]

Names of every user-visible bookmark in document order.

Set include_hidden=True to also return Word's internal bookmarks (TOC entries, cross-references, etc.) whose names start with _.

Source code in src/wordlive/_anchors/_bookmarks.py
def list(self, *, include_hidden: bool = False) -> list[str]:
    """Names of every user-visible bookmark in document order.

    Set `include_hidden=True` to also return Word's internal bookmarks
    (TOC entries, cross-references, etc.) whose names start with `_`.
    """
    with _com.translate_com_errors():
        if include_hidden:
            return [str(bm.Name) for bm in _bookmarks_including_hidden(self._doc.com)]
        names = [str(bm.Name) for bm in self._doc.com.Bookmarks]
    return [n for n in names if _is_user_bookmark(n)]

Durable handles (pins)

Positional para:N / heading:N ids renumber when a structural edit shifts the document. When a positional anchor misses, AnchorNotFoundError.hint says why (out-of-range vs body-text-not-a-heading, the paragraph count, the nearest heading) and recommends pinning. doc.pin(anchor, name=None) (alias doc.stamp) plants a hidden bookmark over an anchor's range and returns a pin:<code> id β€” random, or a readable slug via name="budget-intro" β€” that Word keeps attached to the same content across inserts / deletes / edits; resolve it with doc.anchor_by_id("pin:…") like any anchor (a deleted target's pin vanishes). doc.pin_outline(levels=…) pins every heading in one call and returns the {anchor_id: pin} map (idempotent β€” reuses a heading's existing handle), and doc.outline(pin=True) adds a pin to each outline row. Wrap pin calls in doc.edit(...) for atomic undo. In an exec batch, bind: "name" on an insert op mints a pin on the new content, and $ops[N].field references an earlier op's output (see CLI / MCP). The methods are Document.pin / stamp, pin_outline, and outline(pin=…).

Document.hyperlinks and Document.fields are discovery collections β€” the read mirrors of Anchor.link_to / Anchor.insert_field. doc.hyperlinks.list() reports each link's visible text, external address or internal sub_address bookmark, screen tip, and a range:START-END / para:N; doc.fields.list() reports each field's kind (the code's leading keyword β€” PAGE / REF / TOC / …), raw code, rendered result, locked, and a range:START-END / para:N. Index either (doc.hyperlinks[2], doc.fields[2]) for the single-item wrapper.

Hyperlinks are also editable in place β€” no delete + reinsert. On the indexed Hyperlink, h.update(address=…, sub_address=…, text=…, screen_tip=…) (or the individual set_address / set_sub_address / set_text / set_screen_tip) retargets or relabels the link; omitted fields are left untouched, the setters are chainable, and address / sub_address stay orthogonal. They retarget, they don't unlink: sub_address / screen_tip clear with "", but Word keeps every link pointing somewhere with visible text, so address / text can't be emptied (raises OpError). Fields remain read-only.

wordlive.HyperlinkCollection

HyperlinkCollection(doc: Document)

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

Listing is read-only; index a Hyperlink to edit it in place (doc.hyperlinks[2].update(...)).

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

list

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

Every hyperlink as {index, text, address, sub_address, screen_tip, anchor_id, para}.

Source code in src/wordlive/_hyperlinks.py
def list(self) -> list[dict[str, Any]]:
    """Every hyperlink as `{index, text, address, sub_address, screen_tip, anchor_id, para}`."""
    return [h.to_dict() for h in self]
Hyperlink(doc: Document, com: Any, index: int)

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

Source code in src/wordlive/_hyperlinks.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 Hyperlink object β€” escape hatch (Follow, Delete, sub-ranges, …).

text property

text: str

The link's visible (clickable) text.

address property

address: str

The external destination (URL / mailto / file path), or "" for an internal link.

sub_address property

sub_address: str

The in-document target β€” a bookmark name for an internal jump, else "".

to_dict

to_dict() -> dict[str, Any]

{index, text, address, sub_address, screen_tip, anchor_id, para} β€” the list() shape.

anchor_id is a range:START-END over the link's range; para is the para:N the link sits in (or None). For an internal link address is empty and sub_address holds the bookmark name it points at.

Source code in src/wordlive/_hyperlinks.py
def to_dict(self) -> dict[str, Any]:
    """`{index, text, address, sub_address, screen_tip, anchor_id, para}` β€” the `list()` shape.

    `anchor_id` is a `range:START-END` over the link's range; `para` is the
    `para:N` the link sits in (or ``None``). For an internal link `address`
    is empty and `sub_address` holds the bookmark name it points at.
    """
    with _com.translate_com_errors():
        rng = self._com.Range
        try:
            start, end = int(rng.Start), int(rng.End)
        except Exception:
            start, end = None, None
    para_id: str | None = None
    if start is not None:
        para = self._doc.paragraphs.at(start)
        para_id = para.anchor_id if para is not None else None
    return {
        "index": self._index,
        "text": self.text,
        "address": self.address,
        "sub_address": self.sub_address,
        "screen_tip": _safe_str(self._com, "ScreenTip"),
        "anchor_id": f"range:{start}-{end}" if start is not None else None,
        "para": para_id,
    }

update

update(*, address: str | None = None, sub_address: str | None = None, text: str | None = None, screen_tip: str | None = None) -> Hyperlink

Retarget / relabel this link in place β€” no delete + reinsert.

Pass a string to set a field; omit it (or pass None) to leave it. address is the external destination (URL / mailto / file path); sub_address is the in-document target (a bookmark name); text is the visible clickable text; screen_tip is the hover tooltip. address and sub_address stay orthogonal β€” setting one does not clear the other.

These setters retarget, they don't unlink. sub_address and screen_tip can be emptied with "", but Word keeps every link pointing somewhere with visible text, so address and text cannot be cleared (passing "" raises OpError β€” delete the link via .com to remove it). Returns self (chainable); wrap in doc.edit(...) for atomic undo. Bad input raises OpError.

Source code in src/wordlive/_hyperlinks.py
def update(
    self,
    *,
    address: str | None = None,
    sub_address: str | None = None,
    text: str | None = None,
    screen_tip: str | None = None,
) -> Hyperlink:
    """Retarget / relabel this link in place β€” no delete + reinsert.

    Pass a string to set a field; omit it (or pass ``None``) to leave it.
    `address` is the external destination (URL / mailto / file path);
    `sub_address` is the in-document target (a bookmark name); `text` is the
    visible clickable text; `screen_tip` is the hover tooltip. `address` and
    `sub_address` stay orthogonal β€” setting one does not clear the other.

    These setters *retarget*, they don't unlink. `sub_address` and
    `screen_tip` can be emptied with ``""``, but Word keeps every link
    pointing somewhere with visible text, so `address` and `text` **cannot**
    be cleared (passing ``""`` raises `OpError` β€” delete the link via `.com`
    to remove it). Returns `self` (chainable); wrap in `doc.edit(...)` for
    atomic undo. Bad input raises `OpError`.
    """
    try:
        with _com.translate_com_errors():
            if address is not None:
                if address == "":
                    raise ValueError(
                        "a hyperlink's address cannot be cleared; delete the link to remove it"
                    )
                self._com.Address = str(address)
            if sub_address is not None:
                self._com.SubAddress = str(sub_address)
            if text is not None:
                if text == "":
                    raise ValueError(
                        "a hyperlink's visible text cannot be cleared; "
                        "delete the link to remove it"
                    )
                self._com.TextToDisplay = str(text)
            if screen_tip is not None:
                self._com.ScreenTip = str(screen_tip)
    except (ValueError, TypeError) as e:
        raise OpError(str(e)) from e
    return self

set_address

set_address(address: str) -> Hyperlink

Set the external destination (URL / mailto / file path).

Source code in src/wordlive/_hyperlinks.py
def set_address(self, address: str) -> Hyperlink:
    """Set the external destination (URL / mailto / file path)."""
    return self.update(address=address)

set_sub_address

set_sub_address(sub_address: str) -> Hyperlink

Set the in-document target (a bookmark name); "" clears it.

Source code in src/wordlive/_hyperlinks.py
def set_sub_address(self, sub_address: str) -> Hyperlink:
    """Set the in-document target (a bookmark name); ``""`` clears it."""
    return self.update(sub_address=sub_address)

set_text

set_text(text: str) -> Hyperlink

Set the visible (clickable) text.

Source code in src/wordlive/_hyperlinks.py
def set_text(self, text: str) -> Hyperlink:
    """Set the visible (clickable) text."""
    return self.update(text=text)

set_screen_tip

set_screen_tip(screen_tip: str) -> Hyperlink

Set the hover tooltip; "" clears it.

Source code in src/wordlive/_hyperlinks.py
def set_screen_tip(self, screen_tip: str) -> Hyperlink:
    """Set the hover tooltip; ``""`` clears it."""
    return self.update(screen_tip=screen_tip)

wordlive.FieldCollection

FieldCollection(doc: Document)

Indexable, iterable, read-only view over a document's fields (doc.fields).

Scope is the main text story (doc.Fields); fields that live only in headers/footers are reached through the section's header/footer range on the .com escape hatch for now.

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

list

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

Every field as {index, kind, type, code, result, locked, anchor_id, para}.

Source code in src/wordlive/_fields.py
def list(self) -> list[dict[str, Any]]:
    """Every field as `{index, kind, type, code, result, locked, anchor_id, para}`."""
    return [f.to_dict() for f in self]

wordlive.Field

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

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

Source code in src/wordlive/_fields.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 Field object β€” escape hatch (Update, Unlink, ShowCodes, …).

code property

code: str

The raw field code (e.g. "PAGE", "REF bookmark \h").

result property

result: str

The field's last-rendered value (run update_fields to refresh it).

type property

type: int

Word's numeric Field.Type (the WdFieldType value).

kind property

kind: str

The leading keyword of the field code β€” "PAGE", "REF", "TOC", ….

to_dict

to_dict() -> dict[str, Any]

{index, kind, type, code, result, locked, anchor_id, para} β€” the list() shape.

kind is the leading code keyword; type is Word's numeric field type; anchor_id is a range:START-END over the field; para is the para:N it sits in (or None).

Source code in src/wordlive/_fields.py
def to_dict(self) -> dict[str, Any]:
    """`{index, kind, type, code, result, locked, anchor_id, para}` β€” the `list()` shape.

    `kind` is the leading code keyword; `type` is Word's numeric field type;
    `anchor_id` is a `range:START-END` over the field; `para` is the `para:N`
    it sits in (or ``None``).
    """
    with _com.translate_com_errors():
        code = _clean(self._com.Code.Text).strip()
        type_int = int(self._com.Type)
        result = _clean(self._com.Result.Text)
        try:
            locked = bool(self._com.Locked)
        except Exception:
            locked = False
        rng = self._com.Code
        try:
            start, end = int(rng.Start), int(rng.End)
        except Exception:
            start, end = None, None
    para_id: str | None = None
    if start is not None:
        para = self._doc.paragraphs.at(start)
        para_id = para.anchor_id if para is not None else None
    return {
        "index": self._index,
        "kind": field_kind(code, type_int),
        "type": type_int,
        "code": code,
        "result": result,
        "locked": locked,
        "anchor_id": f"range:{start}-{end}" if start is not None else None,
        "para": para_id,
    }

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, plus insert_page_number() sugar for a { PAGE } field. Section.set_page_setup(...) is the write mirror of page_setup() β€” margins, orientation, paper size, gutter, and multi-column layout (columns=N), per section.

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)),
        }

set_page_setup

set_page_setup(*, margins: Any = None, top_margin: Any = None, bottom_margin: Any = None, left_margin: Any = None, right_margin: Any = None, gutter: Any = None, orientation: Any = None, paper_size: Any = None, columns: int | None = None, column_spacing: Any = None) -> None

Set this section's page geometry β€” the write mirror of page_setup().

All kwargs are optional and tri-state; only the ones passed are written. margins sets all four margins at once; the per-side *_margin kwargs override it. Lengths (margins, *_margin, gutter, column_spacing) are in points or a unit string ("1in", "2.5cm"). orientation is "portrait" / "landscape"; paper_size is "letter" / "legal" / "tabloid" / "a3" / "a4" / "a5" (setting it resizes the page). columns (an int β‰₯ 1) lays the section out in that many equal, newspaper-style columns β€” the section counterpart to insert_break("column") β€” with column_spacing as the gap between them.

Per-section: doc.sections[N].set_page_setup(...); for a single-section document doc.sections[1] is the whole document. Bad input raises OpError. Wrap in doc.edit(...) for atomic undo.

Deferred: unequal column widths, line numbering, vertical alignment, and different-first-page toggles.

Source code in src/wordlive/_sections.py
def set_page_setup(
    self,
    *,
    margins: Any = None,
    top_margin: Any = None,
    bottom_margin: Any = None,
    left_margin: Any = None,
    right_margin: Any = None,
    gutter: Any = None,
    orientation: Any = None,
    paper_size: Any = None,
    columns: int | None = None,
    column_spacing: Any = None,
) -> None:
    """Set this section's page geometry β€” the write mirror of `page_setup()`.

    All kwargs are optional and tri-state; only the ones passed are written.
    `margins` sets all four margins at once; the per-side `*_margin` kwargs
    override it. Lengths (`margins`, `*_margin`, `gutter`, `column_spacing`)
    are in points or a unit string (`"1in"`, `"2.5cm"`). `orientation` is
    `"portrait"` / `"landscape"`; `paper_size` is `"letter"` / `"legal"` /
    `"tabloid"` / `"a3"` / `"a4"` / `"a5"` (setting it resizes the page).
    `columns` (an int β‰₯ 1) lays the section out in that many equal,
    newspaper-style columns β€” the section counterpart to
    [`insert_break("column")`][wordlive.Anchor.insert_break] β€” with
    `column_spacing` as the gap between them.

    Per-section: `doc.sections[N].set_page_setup(...)`; for a single-section
    document `doc.sections[1]` is the whole document. Bad input raises
    `OpError`. Wrap in `doc.edit(...)` for atomic undo.

    Deferred: unequal column widths, line numbering, vertical alignment,
    and different-first-page toggles.
    """
    try:
        if columns is not None and (
            isinstance(columns, bool) or not isinstance(columns, int) or columns < 1
        ):
            raise ValueError(f"columns must be a positive integer; got {columns!r}")
        # Resolve everything that can raise before touching COM, so a bad
        # value leaves the page setup untouched rather than half-applied.
        agg = to_points(margins) if margins is not None else None
        res_top = to_points(top_margin) if top_margin is not None else agg
        res_bottom = to_points(bottom_margin) if bottom_margin is not None else agg
        res_left = to_points(left_margin) if left_margin is not None else agg
        res_right = to_points(right_margin) if right_margin is not None else agg
        res_gutter = to_points(gutter) if gutter is not None else None
        res_spacing = to_points(column_spacing) if column_spacing is not None else None
        wd_orientation = (
            _coerce_named(orientation, _ORIENTATIONS, "orientation")
            if orientation is not None
            else None
        )
        wd_paper = (
            _coerce_named(paper_size, _PAPER_SIZES, "paper size")
            if paper_size is not None
            else None
        )
        with _com.translate_com_errors():
            ps = self._com.PageSetup
            # Orientation / paper size first: each resizes the page, and we
            # want any explicit margins below to win over the resize.
            if wd_orientation is not None:
                ps.Orientation = wd_orientation
            if wd_paper is not None:
                ps.PaperSize = wd_paper
            if res_top is not None:
                ps.TopMargin = res_top
            if res_bottom is not None:
                ps.BottomMargin = res_bottom
            if res_left is not None:
                ps.LeftMargin = res_left
            if res_right is not None:
                ps.RightMargin = res_right
            if res_gutter is not None:
                ps.Gutter = res_gutter
            if columns is not None:
                ps.TextColumns.SetCount(int(columns))
            if res_spacing is not None:
                ps.TextColumns.Spacing = res_spacing
    except (ValueError, TypeError) as e:
        raise OpError(str(e)) from e

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.

insert_page_number

insert_page_number(*, where: str = 'after') -> None

Insert a { PAGE } page-number field into this header/footer.

Sugar for insert_field("page") β€” the canonical place for a page number. Combine with a literal "Page " / " of " and an insert_field("numpages") for a "Page X of Y" footer. Wrap in doc.edit(...) for atomic undo.

Source code in src/wordlive/_sections.py
def insert_page_number(self, *, where: str = "after") -> None:
    """Insert a `{ PAGE }` page-number field into this header/footer.

    Sugar for [`insert_field("page")`][wordlive.Anchor.insert_field] β€” the
    canonical place for a page number. Combine with a literal "Page " /
    " of " and an `insert_field("numpages")` for a "Page X of Y" footer.
    Wrap in `doc.edit(...)` for atomic undo.
    """
    self.insert_field("page", where=where)

Review & track changes

Comments and tracked-change recording, reading, and resolution.

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.

Document.revisions is a RevisionCollection that reads those tracked changes back as structured data β€” the way to see what a tracked batch recorded. revisions.list() reports each change as {index, type, author, text, anchor_id, start, end, date}, where type is "insert" / "delete" / "format" / … . The visual counterpart is snapshot(markup="all") (see Snapshots).

Resolve them, too: revisions[N].accept() / .reject() make a single change permanent / undo it (and renumber the rest), while revisions.accept_all(within=anchor) / reject_all(within=anchor) do the whole document β€” or just one anchor's range when within is given β€” and return the count resolved.

For a read that separates a tracked edit's two sides, the Anchor helpers text_final (as if accepted), text_original (as if rejected), and revision_segments() (the ordered {text, change} breakdown) reconstruct both: Word's plain text read is the final view (inserted runs present, deleted runs gone), so the original wording lives only on the delete revisions.

wordlive.RevisionCollection

RevisionCollection(doc: Document)

Indexable, iterable, read-only view over a document's tracked changes (doc.revisions).

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

accept_all

accept_all(*, within: Anchor | None = None) -> int

Accept every tracked change at once and report how many were resolved.

With no within, accepts the whole document; pass any anchor (heading, section range, cell, range:START-END, …) as within to accept only the tracked changes inside that range β€” "accept all my edits in this section". Returns the count accepted (read before the operation, since accepting empties the collection). Wrap in doc.edit(...) for atomic undo.

Source code in src/wordlive/_revisions.py
def accept_all(self, *, within: Anchor | None = None) -> int:
    """Accept every tracked change at once and report how many were resolved.

    With no `within`, accepts the whole document; pass any anchor (heading,
    section range, cell, `range:START-END`, …) as `within` to accept only the
    tracked changes inside that range β€” "accept all my edits in this section".
    Returns the count accepted (read before the operation, since accepting
    empties the collection). Wrap in `doc.edit(...)` for atomic undo.
    """
    with _com.translate_com_errors():
        revisions = self._revisions_com(within)
        count = int(revisions.Count)
        if count:
            revisions.AcceptAll()
    return count

reject_all

reject_all(*, within: Anchor | None = None) -> int

Reject every tracked change at once and report how many were resolved.

The mirror of accept_all: whole-document by default, or scoped to within's range. Returns the count rejected. Wrap in doc.edit(...) for atomic undo.

Source code in src/wordlive/_revisions.py
def reject_all(self, *, within: Anchor | None = None) -> int:
    """Reject every tracked change at once and report how many were resolved.

    The mirror of [`accept_all`][wordlive.RevisionCollection.accept_all]:
    whole-document by default, or scoped to `within`'s range. Returns the
    count rejected. Wrap in `doc.edit(...)` for atomic undo.
    """
    with _com.translate_com_errors():
        revisions = self._revisions_com(within)
        count = int(revisions.Count)
        if count:
            revisions.RejectAll()
    return count

list

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

All tracked changes as {index, type, author, text, anchor_id, start, end, date} dicts.

Source code in src/wordlive/_revisions.py
def list(self) -> list[dict[str, Any]]:
    """All tracked changes as `{index, type, author, text, anchor_id, start, end, date}` dicts."""
    return [r.to_dict() for r in self]

wordlive.Revision

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

A single tracked change, located by its 1-based document index.

Source code in src/wordlive/_revisions.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 Revision object β€” escape hatch (Accept/Reject, sub-ranges, …).

type property

type: str

The revision kind: "insert", "delete", "format", … ("other" if unknown).

text property

text: str

The inserted or deleted text (the run the revision covers).

date property

date: str | None

When the revision was made, ISO-8601 β€” None if Word doesn't report it.

accept

accept() -> None

Accept this tracked change β€” make it permanent.

For an insertion the inserted text stays and loses its revision mark; for a deletion the struck-through text is removed. Accepting renumbers the remaining revisions (this one is consumed), so cached doc.revisions[N] indices past it shift down by one β€” re-list between resolves, or use the bulk accept_all. Wrap in doc.edit(...) for atomic undo.

Source code in src/wordlive/_revisions.py
def accept(self) -> None:
    """Accept this tracked change β€” make it permanent.

    For an insertion the inserted text stays and loses its revision mark; for
    a deletion the struck-through text is removed. Accepting **renumbers** the
    remaining revisions (this one is consumed), so cached `doc.revisions[N]`
    indices past it shift down by one β€” re-list between resolves, or use the
    bulk [`accept_all`][wordlive.RevisionCollection.accept_all]. Wrap in
    `doc.edit(...)` for atomic undo.
    """
    with _com.translate_com_errors():
        self._com.Accept()

reject

reject() -> None

Reject this tracked change β€” undo it.

For an insertion the inserted text is removed; for a deletion the struck-through text is restored. Like accept this consumes the revision and renumbers the rest. Wrap in doc.edit(...) for atomic undo.

Source code in src/wordlive/_revisions.py
def reject(self) -> None:
    """Reject this tracked change β€” undo it.

    For an insertion the inserted text is removed; for a deletion the
    struck-through text is restored. Like [`accept`][wordlive.Revision.accept]
    this consumes the revision and renumbers the rest. Wrap in `doc.edit(...)`
    for atomic undo.
    """
    with _com.translate_com_errors():
        self._com.Reject()

to_dict

to_dict() -> dict[str, Any]

{index, type, author, text, anchor_id, start, end, date} β€” the list() shape.

anchor_id is a range:START-END over the revision's run (so a hit can be fed back into read/comments.add); text is the inserted or deleted text.

Source code in src/wordlive/_revisions.py
def to_dict(self) -> dict[str, Any]:
    """`{index, type, author, text, anchor_id, start, end, date}` β€” the `list()` shape.

    `anchor_id` is a `range:START-END` over the revision's run (so a hit can
    be fed back into `read`/`comments.add`); `text` is the inserted or
    deleted text.
    """
    with _com.translate_com_errors():
        rng = self._com.Range
        start, end = int(rng.Start), int(rng.End)
        return {
            "index": self._index,
            "type": revision_type_name(self._com.Type),
            "author": str(self._com.Author or ""),
            "text": _clean(rng.Text),
            "anchor_id": f"range:{start}-{end}",
            "start": start,
            "end": end,
            "date": self.date,
        }

Inspecting, exporting & verifying

Metadata and proofing, linting, Markdown / HTML export, checkpoints, and snapshots.

Document metadata, variables & proofing

Document.properties is a read/write PropertyCollection over the document's metadata: read() returns {builtin, custom} (the Title / Author / Subject / Keywords / … bag plus any custom name/value pairs), set(name, value) writes a built-in property, set(name, value, custom=True) a custom one, and delete(name) removes a custom one. Document.variables is a VariableCollection over the invisible named string storage that backs { DOCVARIABLE } fields β€” list() returns {name: value}; set / get / delete manage them. Wrap writes in doc.edit(...) for atomic undo.

Document.proofing() runs Word's proofing tools and returns {spelling, grammar, readability}: spelling/grammar each give a count plus a (capped) list of {text, anchor_id, para} for the flagged runs, and readability gives the Flesch Reading Ease, Flesch-Kincaid Grade Level, passive-sentence %, and averages. It's a pure read but heavier than stats() β€” it asks Word to (re)check the document. Documented on Document.

wordlive.PropertyCollection

PropertyCollection(doc: Document)

Read/write view over a document's built-in and custom properties.

doc.properties.read() returns {"builtin": {…}, "custom": {…}}. Write a built-in property with set("Title", "…") and a custom one with set("Project", "Apollo", custom=True) (created if it doesn't exist). The built-in stat properties (word count, creation date, …) are read-only; Word raises if you try to set one, surfaced as an OpError.

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

builtin

builtin() -> dict[str, Any]

The built-in properties that carry a value, as {name: value}.

Source code in src/wordlive/_properties.py
def builtin(self) -> dict[str, Any]:
    """The built-in properties that carry a value, as `{name: value}`."""
    with _com.translate_com_errors():
        return _read_bag(self._doc.com.BuiltInDocumentProperties)

custom

custom() -> dict[str, Any]

The custom (user-defined) properties, as {name: value}.

Source code in src/wordlive/_properties.py
def custom(self) -> dict[str, Any]:
    """The custom (user-defined) properties, as `{name: value}`."""
    with _com.translate_com_errors():
        return _read_bag(self._doc.com.CustomDocumentProperties)

read

read() -> dict[str, Any]

{"builtin": {…}, "custom": {…}} β€” every property with a value.

Source code in src/wordlive/_properties.py
def read(self) -> dict[str, Any]:
    """`{"builtin": {…}, "custom": {…}}` β€” every property with a value."""
    return {"builtin": self.builtin(), "custom": self.custom()}

get

get(name: str) -> Any

Look up one property's value by name (built-in first, then custom).

Raises AnchorNotFoundError (kind "property") if no built-in or custom property of that name carries a value.

Source code in src/wordlive/_properties.py
def get(self, name: str) -> Any:
    """Look up one property's value by name (built-in first, then custom).

    Raises `AnchorNotFoundError` (kind `"property"`) if no built-in or custom
    property of that name carries a value.
    """
    builtin = self.builtin()
    if name in builtin:
        return builtin[name]
    custom = self.custom()
    if name in custom:
        return custom[name]
    raise AnchorNotFoundError("property", name)

set

set(name: str, value: Any, *, custom: bool = False) -> None

Set property name to value (a built-in by default, or a custom one).

With custom=False (default) this writes a built-in property β€” the writable ones are Title, Subject, Author, Keywords, Comments, Category, Manager, Company, Content status, and Hyperlink base; the stat/date properties are read-only and raise OpError. With custom=True it sets the custom property, creating it if absent (the type is inferred from value: bool/int/float/str). Wrap in doc.edit(...) for atomic undo.

Source code in src/wordlive/_properties.py
def set(self, name: str, value: Any, *, custom: bool = False) -> None:
    """Set property `name` to `value` (a built-in by default, or a custom one).

    With `custom=False` (default) this writes a **built-in** property β€” the
    writable ones are Title, Subject, Author, Keywords, Comments, Category,
    Manager, Company, Content status, and Hyperlink base; the stat/date
    properties are read-only and raise `OpError`. With `custom=True` it sets
    the custom property, creating it if absent (the type is inferred from
    `value`: bool/int/float/str). Wrap in `doc.edit(...)` for atomic undo.
    """
    if custom:
        self._set_custom(name, value)
    else:
        self._set_builtin(name, value)

delete

delete(name: str) -> None

Delete a custom property by name.

Only custom properties can be removed β€” built-in ones are part of the format. Raises AnchorNotFoundError (kind "property") if no custom property of that name exists. Wrap in doc.edit(...) for atomic undo.

Source code in src/wordlive/_properties.py
def delete(self, name: str) -> None:
    """Delete a custom property by name.

    Only custom properties can be removed β€” built-in ones are part of the
    format. Raises `AnchorNotFoundError` (kind `"property"`) if no custom
    property of that name exists. Wrap in `doc.edit(...)` for atomic undo.
    """
    with _com.translate_com_errors():
        bag = self._doc.com.CustomDocumentProperties
        existing = {str(p.Name) for p in bag}
        if name not in existing:
            raise AnchorNotFoundError("property", name)
        bag(name).Delete()

wordlive.VariableCollection

VariableCollection(doc: Document)

Read/write view over a document's variables (doc.variables).

doc.variables.list() returns {name: value}. set(name, value) creates or updates a variable; get(name) reads one; delete(name) removes it. Values are stored as strings. Wrap writes in doc.edit(...) for atomic undo.

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

list

list() -> dict[str, str]

Every variable as a {name: value} dict (Word's Variables order).

Source code in src/wordlive/_variables.py
def list(self) -> dict[str, str]:
    """Every variable as a `{name: value}` dict (Word's `Variables` order)."""
    out: dict[str, str] = {}
    with _com.translate_com_errors():
        for var in self._doc.com.Variables:
            out[str(var.Name)] = str(var.Value)
    return out

get

get(name: str) -> str

Read one variable's value by name.

Raises AnchorNotFoundError (kind "variable") if no variable of that name exists.

Source code in src/wordlive/_variables.py
def get(self, name: str) -> str:
    """Read one variable's value by name.

    Raises `AnchorNotFoundError` (kind `"variable"`) if no variable of that
    name exists.
    """
    with _com.translate_com_errors():
        for var in self._doc.com.Variables:
            if str(var.Name) == name:
                return str(var.Value)
    raise AnchorNotFoundError("variable", name)

set

set(name: str, value: Any) -> None

Create or update variable name with value (stored as a string).

Word's Variables.Add errors on a name that already exists, so an existing variable is updated in place and a new one is added. Wrap in doc.edit(...) for atomic undo.

Source code in src/wordlive/_variables.py
def set(self, name: str, value: Any) -> None:
    """Create or update variable `name` with `value` (stored as a string).

    Word's `Variables.Add` errors on a name that already exists, so an
    existing variable is updated in place and a new one is added. Wrap in
    `doc.edit(...)` for atomic undo.
    """
    text = str(value)
    with _com.translate_com_errors():
        variables = self._doc.com.Variables
        existing = {str(v.Name) for v in variables}
        if name in existing:
            variables(name).Value = text
        else:
            # Positional args (Name, Value) β€” keyword args are unreliable
            # under pywin32 late binding.
            variables.Add(name, text)

delete

delete(name: str) -> None

Delete variable name.

Raises AnchorNotFoundError (kind "variable") if it doesn't exist. Wrap in doc.edit(...) for atomic undo.

Source code in src/wordlive/_variables.py
def delete(self, name: str) -> None:
    """Delete variable `name`.

    Raises `AnchorNotFoundError` (kind `"variable"`) if it doesn't exist.
    Wrap in `doc.edit(...)` for atomic undo.
    """
    with _com.translate_com_errors():
        variables = self._doc.com.Variables
        existing = {str(v.Name) for v in variables}
        if name not in existing:
            raise AnchorNotFoundError("variable", name)
        variables(name).Delete()

Linting & regularizing

New to the linter? The Linting & regularizing guide has the mental model, a guided walkthrough, and the full rule catalog; this section is the API-level reference.

Document.lint(rules=None, within=None) audits the document for formatting inconsistency, structural slips, and policy breaches β€” the "what's off before I hand this over" read. It returns a severity-ranked list of findings, each a dict {rule, kind, severity, anchor_id, message, fixable, fix, observed, expected} where kind is "consistency" / "structural" / "policy", severity is "error" / "warning" / "info", and fix (present iff fixable) is an op-shaped dict β€” literally the exec op regularize would run. rules=None runs the default set (every on-by-default consistency + structural rule; policy and opinionated rules are off); pass a list of rule ids/tags to include just those, or {"exclude": [ids/tags]} to drop some. A rule that's off by default still runs when named or via its tag β€” rules=["typography"] lights up the whole typography cluster including its off-by-default members. within scopes the audit to one anchor (heading:N / range:S-E / table:N:R:C, or an Anchor). It's a pure read: layout rules repaginate content-neutrally, leaving selection, scroll, and Saved untouched.

Foundation rules β€” structural: heading-keep-with-next, table-repeat-header, list-numbering-continuity; consistency: heading-font-consistent, heading-spacing-consistent, body-font-consistent (name/size/bold, body prose only β€” table cells belong to table-style-consistent), and mixed-run-format (report-only). Typography rules (tag typography) β€” on by default: trailing-whitespace, leading-whitespace, space-before-punctuation, double-space, manual-heading-formatting (report-only; skips table cells), table-style-consistent; off by default: hyphen-as-range, em-dash-usage, tabs-for-layout, manual-line-break. The fixable typography rules write via find_replace's regex mode scoped to the offending paragraph, so they stay idempotent.

Finalization rules (tag finalization, all off by default β€” an opt-in "is-this-ready-to-send?" check): comments-present, unaccepted-revisions, track-changes-on, hidden-text-present, and stale-fields (updatable TOC/SEQ/REF/PAGE fields present β€” a refresh nudge) are report-only; leftover-highlight is the one fixable rule (clears the highlight, idempotent). Enable the cluster with rules=["finalization"].

Field-code rules (the P1 cross-reference/caption backbone) β€” on by default: broken-cross-reference (a REF/PAGEREF field rendering Word's "reference source not found" error) and caption-manual-numbering (a Caption paragraph whose figure/table number is literal text, not a SEQ field); off by default (tag layout): page-numbers-present (no PAGE field in any header/footer); off by default (tag crossref / academia): xref-as-literal-text (a body paragraph mentioning a figure/table by literal number with no REF field β€” heuristic, so opt-in). All are report-only. The cross-reference/caption rules carry the academia tag, so rules=["academia"] selects the cluster.

Hyperlink rules (a walk over doc.hyperlinks) β€” on by default: hyperlink-broken-internal (an internal HYPERLINK \l jump whose target bookmark no longer exists β€” a dead link); off by default (tags hyperlinks / print): hyperlink-bare-for-print (an external link whose visible text doesn't contain its URL, so the destination is invisible on paper) and hyperlink-display-is-raw-url (a link whose whole label is a bare URL). All report-only. rules=["hyperlinks"] selects the cluster; rules=["print"] selects just the two print/sharing rules.

Heading & document-structure rules (Β§B β€” a walk over doc.outline()) β€” on by default: heading-level-skip (the outline jumps a level β€” an H1 followed by an H3 with no H2) and empty-heading (a heading paragraph with no text); off by default (tags headings / structure): adjacent-headings (two headings in a row with no body between), heading-numbering-manual (a heading numbered by hand, 3.1 Methods, not by automatic numbering), heading-trailing-period (a heading ending in a period β€” the one fixable rule, strips it in place), and toc-present-and-current (top-level headings but no table-of-contents field β€” presence-only, since Word exposes no field-staleness flag). rules=["structure"] (or rules=["headings"]) selects the cluster.

Layout / document-level rules (Β§H β€” a walk over doc.sections / doc.properties plus the doc.watermark() read), all off by default and report-only: header-footer-consistent (the primary header/footer text disagrees across the document's own sections) and draft-watermark-present (a leftover DRAFT / CONFIDENTIAL watermark, also tagged finalization); and three policy rules β€” document-properties-filled (a required built-in property left empty; required defaults to ["Title", "Author"]), confidentiality-notice / copyright-notice (a profile-supplied notice string β€” copyright-notice defaults to "Β©" β€” missing from every header/footer and the body). rules=["layout"] selects the whole cluster; rules=["notices"] selects just the two notice rules.

Policy rules (off unless a profile enables them β€” spec-linter.md Β§6): body-justified (body paragraphs not justified β€” fix justifies them), body-line-spacing (line spacing β‰  the profile's target, e.g. "1.5" β€” fix sets it), table-numeric-right-align (a table column that's mostly numbers, above threshold, but not right-aligned β€” fix right-aligns those cells). All three fix idempotently through format_paragraph. A profile is a path to a wordlive.lint.json file or an inline dict; it opts policy rules in, supplies their targets, and can override a rule's severity or disable a default rule:

profile = {
    "rules": {
        "body-justified":            {"enabled": True, "severity": "warning"},
        "body-line-spacing":         {"enabled": True, "target": "1.5"},
        "table-numeric-right-align": {"enabled": True, "threshold": 0.8},
        "double-space":              {"enabled": False},   # disable a default rule
    }
}
doc.lint(profile=profile)            # or profile="wordlive.lint.json"
doc.regularize(profile=profile)      # applies the policy fixes too

Document.regularize(rules=None, within=None, profile=None, dry_run=False, allow_content=False) is the write side: it applies the fixable findings in one doc.edit("Regularize formatting") (one Ctrl-Z reverts them all; selection and scroll preserved) and returns {applied, skipped, deferred, findings} (plus ops_run, and dry_run when set). The default fixes are targeted and idempotent β€” each writes the style's own value back as a direct property, so a second regularize applies nothing (a tested invariant). dry_run=True plans without writing; rules / within / profile select the same way as lint. Fixes that change content rather than formatting (deleting a stray paragraph, inserting a caption, stripping a watermark) are flagged adds_content on the finding and withheld into deferred unless you pass allow_content=True β€” so a default pass never silently rewrites what the document says. It's Track-Changes-aware (the edits are tracked when Track Changes is on).

findings = doc.lint(within="heading:3")          # audit one section
for f in findings:
    print(f["rule"], f["severity"], f["fixable"])
doc.regularize(rules=["heading-keep-with-next"], dry_run=True)  # preview
doc.regularize(within="heading:3")               # apply, one atomic undo

A finding is also available as the exported Finding dataclass (from wordlive import Finding) β€” a frozen dataclass carrying the fields above, with a .to_dict(). lint / regularize are documented on Document.

wordlive.Finding dataclass

Finding(rule: str, kind: str, severity: str, anchor_id: str, message: str, fixable: bool = False, fix: FixOps | None = None, adds_content: bool = False, observed: str | None = None, expected: str | None = None)

One linter result. fix is present iff fixable β€” an op-shaped dict (or list of them) regularize runs verbatim through the batch op loop.

Markdown & HTML export

Document.to_markdown(within=None) and Document.to_html(within=None) are the read mirror of insert_markdown β€” they serialise the whole document (or one anchor's range) to clean Markdown or an HTML fragment. Both render from one document walk, so they agree on structure: headings, bullet / numbered lists (nested), **bold** / *italic* / `code` (a monospace run; HTML keeps underline too), GFM pipe tables, inline images as ![alt](image:N), and hyperlinks as [text](url). Round-tripping is a fixed point: to_markdown escapes exactly what insert_markdown unescapes, so read-modify-write neither drops nor accretes backslashes. Export is lossy by design, like the constrained-subset import: it round-trips the dialect import speaks and reads the rest richer (deeper headings, tables), but colours, merged table cells, and (in Markdown) underline don't survive.

within scopes to an anchor's literal range β€” pass a range:START-END (e.g. from find), an anchor id, or an Anchor. A heading:N covers only the heading line, not its section body β€” use between or a range: for "the section under X". within=None (the default) serialises the whole document. Both are pure reads.

md = doc.to_markdown()                      # the whole document as Markdown
section = doc.to_markdown(within="heading:3")   # one heading's range
html = doc.to_html(within="range:120-540")  # a found span as HTML

Document.read(budget=6000, depth=None) is the token-budgeted read of the whole document β€” load an 80-page doc into context cheaply while every anchor stays addressable. Headings are verbatim (each tagged <!-- heading:N -->), tables become one-line shape stubs, and body text is sampled to fit budget (~4 chars/token), weighted so shallower sections keep more than deep ones; overflow elides to markers that name the para: range, so an agent can drill in with to_markdown(within=…). depth caps how deep a section keeps body.

overview = doc.read(budget=4000)            # the whole doc, budgeted + addressable
shallow = doc.read(budget=4000, depth=1)    # outline + only top-level bodies

Documented on Document.

Checkpoint & diff

Document.checkpoint(include="text+style", within=None) fingerprints the document's structure right now and returns an opaque, serialisable Checkpoint (from wordlive import Checkpoint). Store the token, let edits happen (agent or user), then ask what changed β€” the only reliable way to do so, since Word emits no content-change event, and how an agent verifies its own edits landed without re-reading the whole document. include sets the fingerprint depth: "text" (cheapest β€” a restyle is invisible), "text+style" (default β€” folds the applied paragraph style in, so a restyle surfaces), or "text+format" (also hashes each paragraph's format_info, so a pure direct-formatting edit surfaces as a reformat). within=anchor fingerprints one section/range. A pure read β€” selection, scroll, and Saved are untouched.

Document.changes_since(cp) diffs a stored checkpoint against the document now; Document.diff(cp_a, cp_b) diffs two stored checkpoints. Both accept a Checkpoint, its to_json() string, or the parsed dict (so a token round-tripped through a file works directly), and return a structured change list. Each change is one of replace (text edit), insert, delete, restyle (same text, style changed), or reformat (same text+style, direct formatting changed β€” only with include="text+format"), carrying {op, anchor_id, index_before, index_after, text_before, text_after, style_before, style_after} as applicable. Inserts / replaces / restyles carry the current para:N (anchor_id) so the caller can act on the change immediately; a delete references only the old index/text (its anchor is gone). Table edits are reported coarsely (per-cell diffing is deferred) as table_change / table_insert / table_delete, each carrying the table:N anchor_id and the before/after shape ([rows, cols]). Alignment is by paragraph content (difflib.SequenceMatcher), not index β€” para:N renumbers under inserts/deletes β€” and an unchanged document returns [] via a whole-document doc_hash fast-path. Because alignment is content-only, paragraphs with identical text (blank lines, repeated boilerplate) can mis-pair amid an edit (usually spurious blank-line churn, not a misclassified real change). A within=range:START-END scope cannot be re-derived by changes_since (offsets shift under edits β€” it raises a clear error); use a stable anchor (heading:N / bookmark: / cc:) or diff() two stored checkpoints.

cp = doc.checkpoint()                       # fingerprint now
# … agent or user edits …
changes = doc.changes_since(cp)             # structured change list
touched = {c["anchor_id"] for c in changes if "anchor_id" in c}
assert touched == {"para:4", "para:7"}      # verify my edits landed where I meant

token = cp.to_json()                        # persist the token (e.g. to a file)
later = Checkpoint.from_json(token)

Pure reads β€” not exec ops (the token round-trips through the caller, not Word). Deferred: pin-backed exact identity (track=True), move detection (moves=True), per-cell table diffing, and an in-document checkpoint store.

wordlive.Checkpoint dataclass

Checkpoint(version: int, include: str, scope: str | None, paragraphs: list[dict[str, Any]], tables: list[dict[str, Any]] = list(), doc_hash: str = '')

An opaque, serialisable structural fingerprint of the document at one moment. Build with Document.checkpoint; the caller holds the token and feeds it back to changes_since / diff.

paragraphs is one dict per paragraph in document order ({i, text, style, level, list, fmt, key, hash}): key is the alignment identity (normalised text only β€” deliberately not style/level, so a restyled paragraph still aligns as equal and surfaces as a restyle rather than a delete+insert), and hash is the change key (normalised text plus, per include, style and the format fingerprint). tables fingerprints each table coarsely ({index, shape, cells_hash} β€” detects a cell changed); doc_hash is the whole-fingerprint fast-path (equal β‡’ no changes).

Because key is text-only, paragraphs with identical normalised text (blank lines, repeated boilerplate) share a key and are interchangeable to the aligner, so an edit amid many identical lines can mis-pair them (usually a spurious blank-line insert/delete, not a misclassified real change). Exact per-paragraph identity is the deferred track=True (pin-backed) mode.

to_json

to_json() -> str

Serialise to a JSON string β€” the token the caller stores.

Source code in src/wordlive/_checkpoint.py
def to_json(self) -> str:
    """Serialise to a JSON string β€” the token the caller stores."""
    return json.dumps(asdict(self), ensure_ascii=False)

from_json classmethod

from_json(data: str | dict[str, Any]) -> Checkpoint

Rebuild a Checkpoint from to_json() output (a JSON string or the already-parsed dict).

Source code in src/wordlive/_checkpoint.py
@classmethod
def from_json(cls, data: str | dict[str, Any]) -> Checkpoint:
    """Rebuild a `Checkpoint` from `to_json()` output (a JSON string or the
    already-parsed dict)."""
    obj = json.loads(data) if isinstance(data, str) else dict(data)
    return cls(
        version=int(obj.get("version", VERSION)),
        include=str(obj.get("include", "text+style")),
        scope=obj.get("scope"),
        paragraphs=list(obj.get("paragraphs", [])),
        tables=list(obj.get("tables", [])),
        doc_hash=str(obj.get("doc_hash", "")),
    )

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. Pass markup="all" to render tracked changes and comments as visible revision marks and balloons instead of the final document (the structured counterpart is Document.revisions). dpi (default 150) sets resolution; max_dim caps each page's long edge in pixels (only ever lowering it) β€” the lever for a cheap whole-document layout check, since a vision model's token cost scales with pixel area, so a long-edge cap is a predictable per-page budget regardless of paper size (~1000 stays legible). 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
    doc.snapshot("review.png", markup="all")              # show tracked changes
    shots = doc.snapshot(max_dim=1000)                    # whole doc, cheap layout check

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.

Reference

Typed constants and the exception taxonomy.

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.

wordlive verifies each find/replace target against the located match before writing, and raises this instead of overwriting the wrong span. It means the document shifted out from under the match between locating and writing β€” typically because an earlier edit in the same batch moved later text, or Track Changes left both the inserted and deleted runs in place (so offsets no longer line up). Re-read the text (find / paragraphs) and retry, or target the span directly by anchor id. 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}, not the expected "
        f"{expected!r}; refusing to overwrite a span that shifted under the match "
        f"(re-read the text and retry, or target it by anchor id)"
    )
    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.ExcelNotAvailableError

ExcelNotAvailableError(message: str = 'Microsoft Excel is not available; charts require Excel installed')

Bases: WordliveError

insert_chart needs Microsoft Excel, but it isn't available over COM.

Charts are Excel-backed: Range.InlineShapes.AddChart2 embeds a chart whose data lives in a hidden Excel workbook, so a working Excel.Application COM server is required. Raised (after a non-mutating probe, before any chart is inserted β€” the document is left untouched) when Excel isn't installed or can't be launched. Unlike WordBusyError this is a one-time environment problem, not a transient busy state β€” install Excel, or render the chart elsewhere and embed it with insert_image. It has its own CLI exit code (6), parallel to WordNotRunningError's exit 4, so a missing-Excel failure is distinguishable from generic bad input. Not retryable.

Source code in src/wordlive/exceptions.py
def __init__(
    self, message: str = "Microsoft Excel is not available; charts require Excel installed"
) -> None:
    super().__init__(message)

wordlive.OpError

OpError(message: str)

Bases: WordliveError

A batch/exec op (or a single dispatched write) was malformed.

Raised for an unknown op kind, a missing required field, or a mutually exclusive pair given together (e.g. both path and base64 for an image). It's a bad-input error β€” fix the request β€” so it maps to the generic exit code (1), the same place click.ClickException used to land. Not retryable.

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

wordlive.EquationError

EquationError(message: str)

Bases: WordliveError

An equation given to insert_equation couldn't be built into Word math.

Raised for malformed input (no input dialect given, or more than one of unicodemath/latex/mathml), unparseable MathML, a LaTeX source the optional converter rejects, or a missing LaTeX→MathML backend (pip install "wordlive[latex]"). It's a bad-input / dependency problem — not a "named thing is missing" — so it maps to the generic exit code (1), like its sibling ImageSourceError. Not retryable: fix the input (or install the extra).

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

wordlive.PathNotAllowedError

PathNotAllowedError(message: str)

Bases: WordliveError

A filesystem path was refused by the gated CLI / MCP surface's policy.

wordlive's Python API is trusted and ungated, but the CLI and MCP surfaces β€” whose inputs can be prompt-injected β€” run every filesystem path through a default-deny policy. This is raised when a save / save-as / export-pdf target falls outside the configured save-directory whitelist (or no whitelist is configured, so saving is off), or when an image-source path is a non-local form (UNC \…, file://, or a URL) or sits outside the optional image-directory allowlist. It's a policy denial / bad-input error β€” not a "named thing is missing" β€” so it maps to the generic exit code (1), keeping the six-code contract untouched. Not retryable: configure a whitelist (--save-dir / WORDLIVE_SAVE_DIRS, --image-dir / WORDLIVE_IMAGE_DIRS) or pass a local path inside it.

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