Skip to content

revisions

revisions

The tracked-changes API — reading, accepting and rejecting revisions.

Any document that has been through review carries revision markup, and before this existed the library's handling of it was silently wrong rather than loudly broken: inserted text was dropped, deleted text was dropped, and Paragraph.text returned something that matched neither the original nor the final version of the document.

The text model is now defined:

  • Paragraph.text is the document as it now reads — every revision accepted. Insertions are included; deletions are not. This is what almost every caller wants and what makes .text consistent with what a reader sees with markup hidden.
  • Paragraph.original_text is the document as it read before the revisions. Deletions are included; insertions are not.

Accepting a revision makes the first reading permanent; rejecting it makes the second.

In scope: w:ins, w:del, w:moveFrom and w:moveTo, whether they wrap content, mark a paragraph mark as inserted or deleted (which is how a paragraph split or merge is tracked), or mark a table row; and the *Change elements recording a formatting change.

Not in scope: w:numberingChange, and the cell-level merge revisions (w:cellMerge). Both are rare and neither has a well-defined accept that this library could perform without guessing.

Revision

Revision(
    element: CT_TrackChange, parent: ProvidesStoryPart
)

Bases: StoryChild

One tracked change — an insertion, a deletion, a move or a formatting change.

Not constructed directly; reached through Document.revisions or Paragraph.revisions.

Source code in src/docx/revisions.py
def __init__(self, element: CT_TrackChange, parent: t.ProvidesStoryPart):
    super().__init__(parent)
    self._element = element

author property

author: str

The name of whoever made this change.

The empty string when the document does not say, which is the case for a w:tblGridChange and for a document stripped of personal information.

date property

date: datetime | None

When the change was made, None when the document does not say.

None too for an unparseable timestamp, which an anonymised document has.

id property

id: int | None

The w:id of this revision, None when it carries none.

Unique among the revisions of a document when present.

is_paragraph_mark property

is_paragraph_mark: bool

True when this revision is of a paragraph mark rather than of content.

An inserted paragraph mark is a paragraph split; a deleted one is a merge with the paragraph that follows. Accepting or rejecting one therefore joins or splits paragraphs rather than adding or removing text.

is_row property

is_row: bool

True when this revision marks a whole table row as inserted or deleted.

text property

text: str

The text this revision covers, the empty string when it covers none.

Empty for a paragraph-mark revision, a row revision and a formatting change, none of which cover text of their own.

type property

Member of WdRevisionType saying what kind of change this is.

accept

accept() -> None

Keep this change, and remove the record of it.

An insertion's content stays and stops being marked as new; a deletion's content goes; a formatting change's record goes, leaving the current formatting in place. Accepting a deleted paragraph mark merges the paragraph with the one after it, which is what the deletion recorded.

Source code in src/docx/revisions.py
def accept(self) -> None:
    """Keep this change, and remove the record of it.

    An insertion's content stays and stops being marked as new; a deletion's content
    goes; a formatting change's record goes, leaving the current formatting in
    place. Accepting a deleted paragraph mark merges the paragraph with the one
    after it, which is what the deletion recorded.
    """
    self._apply(accept=True)

reject

reject() -> None

Undo this change, and remove the record of it.

An insertion's content goes; a deletion's content comes back, its w:delText turned back into w:t; a formatting change puts the recorded previous properties back. Rejecting an inserted paragraph mark merges the paragraph with the one after it, undoing the split.

Source code in src/docx/revisions.py
def reject(self) -> None:
    """Undo this change, and remove the record of it.

    An insertion's content goes; a deletion's content comes back, its `w:delText`
    turned back into `w:t`; a formatting change puts the recorded previous
    properties back. Rejecting an inserted paragraph mark merges the paragraph with
    the one after it, undoing the split.
    """
    self._apply(accept=False)

iter_revisions

iter_revisions(
    element: BaseOxmlElement, parent: ProvidesStoryPart
) -> Iterator[Revision]

Generate a Revision for each tracked change in the subtree of element.

Source code in src/docx/revisions.py
def iter_revisions(element: BaseOxmlElement, parent: t.ProvidesStoryPart) -> Iterator[Revision]:
    """Generate a |Revision| for each tracked change in the subtree of `element`."""
    for revision_elm in iter_revision_elements(element):
        yield Revision(revision_elm, parent)

apply_all

apply_all(
    element: BaseOxmlElement,
    parent: ProvidesStoryPart,
    accept: bool,
) -> int

Accept or reject every revision in element, returning how many were applied.

Applied innermost-last and in reverse document order, so that unwrapping or removing one revision cannot invalidate another that has not been reached yet — a nested revision is dealt with before the one containing it.

Source code in src/docx/revisions.py
def apply_all(element: BaseOxmlElement, parent: t.ProvidesStoryPart, accept: bool) -> int:
    """Accept or reject every revision in `element`, returning how many were applied.

    Applied innermost-last and in reverse document order, so that unwrapping or removing
    one revision cannot invalidate another that has not been reached yet — a nested
    revision is dealt with before the one containing it.
    """
    revisions = list(iter_revisions(element, parent))
    for revision in reversed(revisions):
        # -- a revision whose container was already removed is no longer in the tree --
        if revision._element.getparent() is None:  # pyright: ignore[reportPrivateUsage]
            continue
        if accept:
            revision.accept()
        else:
            revision.reject()
    return len(revisions)

collect_authors

collect_authors(revisions: List[Revision]) -> List[str]

The distinct authors of revisions, in the order they first appear.

Source code in src/docx/revisions.py
def collect_authors(revisions: List[Revision]) -> List[str]:
    """The distinct authors of `revisions`, in the order they first appear."""
    authors: List[str] = []
    for revision in revisions:
        if revision.author not in authors:
            authors.append(revision.author)
    return authors