Skip to content

isolate

isolate

Run-splitting primitives and the cross-run text replacement built on them.

Word splits a paragraph into runs for reasons that have nothing to do with formatting — spell-check state, language tagging, revision marks, the rsid bookkeeping it uses to track editing sessions. A string a reader sees as one word is routinely three runs, so anything that searches or edits paragraph text one run at a time misses most matches.

The primitive here is isolate_range: given a character range measured against CT_P.text, split the runs covering it so that the range is covered by whole runs and nothing else, with each original run's w:rPr carried onto the pieces it was divided into. Everything else — replacement, and reformatting a range — builds on that.

Offsets are measured in the same character space as CT_P.text, so w:tab counts as one character and a text-wrapping w:br as one newline. A w:instrText contributes nothing: it holds a field instruction rather than document text, and splitting one corrupts the field.

_Atom

Bases: NamedTuple

One text-bearing run child, located in the paragraph's character space.

is_divisible property

is_divisible: bool

True when this atom's text can be cut at an interior offset.

Only w:t holds a string of arbitrary length. Every other text-bearing child maps to a fixed one-character string — a tab is one "\t" — so a boundary can fall on either side of it but never inside it.

iter_runs

iter_runs(element: BaseOxmlElement) -> Iterator[CT_R]

Generate each w:r contributing text to element, in document order.

A w:hyperlink is descended into, since its runs are part of the paragraph's text, and a w:sdt is looked through the same way iter_run_content does.

Source code in src/docx/oxml/text/isolate.py
def iter_runs(element: BaseOxmlElement) -> Iterator[CT_R]:
    """Generate each `w:r` contributing text to `element`, in document order.

    A `w:hyperlink` is descended into, since its runs are part of the paragraph's text,
    and a `w:sdt` is looked through the same way :func:`iter_run_content` does.
    """
    for item in iter_run_content(element):
        if item.tag == qn("w:r"):
            yield cast("CT_R", item)
        else:  # -- a `w:hyperlink`, which holds runs of its own --
            yield from iter_runs(item)

isolate_range

isolate_range(p: CT_P, start: int, end: int) -> List[CT_R]

Split the runs of p so [start, end) is covered by whole runs, and return them.

Each returned run lies entirely within the range, and together they cover it. The formatting of every original run is preserved on each piece it was divided into.

An empty list is returned for an empty range, and for a range beyond the end of the paragraph's text. Raises ValueError for a reversed or negative range.

Source code in src/docx/oxml/text/isolate.py
def isolate_range(p: CT_P, start: int, end: int) -> List[CT_R]:
    """Split the runs of `p` so `[start, end)` is covered by whole runs, and return them.

    Each returned run lies entirely within the range, and together they cover it. The
    formatting of every original run is preserved on each piece it was divided into.

    An empty list is returned for an empty range, and for a range beyond the end of the
    paragraph's text. Raises |ValueError| for a reversed or negative range.
    """
    if start < 0 or end < start:
        raise ValueError(f"invalid character range ({start}, {end})")
    if start == end:
        return []

    _split_at(p, start)
    _split_at(p, end)

    # -- a run is in range when its atoms are; a run holding no text at all (an image,
    # -- a field character) is not part of the matched text and is left alone --
    runs: List[CT_R] = []
    for atom in _iter_atoms(p):
        if start <= atom.start and atom.end <= end and atom.r not in runs:
            runs.append(atom.r)
    return runs

replace_range

replace_range(
    p: CT_P, start: int, end: int, text: str
) -> None

Replace the characters of p in [start, end) with text.

The replacement takes the formatting of the run holding the first replaced character, which is what Word's own Find and Replace does and what callers expect. When the range spans several runs the remaining matched text is removed from each of them and their formatting goes with it; the runs themselves are left in place, so a hyperlink, bookmark or field partly covered by the range keeps its structure.

Source code in src/docx/oxml/text/isolate.py
def replace_range(p: CT_P, start: int, end: int, text: str) -> None:
    """Replace the characters of `p` in `[start, end)` with `text`.

    The replacement takes the formatting of the run holding the first replaced
    character, which is what Word's own Find and Replace does and what callers expect.
    When the range spans several runs the remaining matched text is removed from each of
    them and their formatting goes with it; the runs themselves are left in place, so a
    hyperlink, bookmark or field partly covered by the range keeps its structure.
    """
    if start < 0 or end < start:
        raise ValueError(f"invalid character range ({start}, {end})")

    _divide_at(p, start)
    _divide_at(p, end)

    atoms = [a for a in _iter_atoms(p) if start <= a.start and a.end <= end and a.text]
    new_elements = _content_elements_for(text)

    if atoms:
        anchor = atoms[0].element
        for element in new_elements:
            anchor.addprevious(element)
        for atom in atoms:
            parent = atom.element.getparent()
            if parent is not None:
                parent.remove(atom.element)
        _clear_placeholder(atoms[0].r)
        return

    # -- an empty range: there is nothing to remove, only a position to insert at --
    if not new_elements:
        return
    _insert_at(p, start, new_elements)