Skip to content

Python Track Changes Library for Word Documents

Read and write track changes in Word documents (.docx) with Python. docx-revisions is a Python library that extends python-docx to support Microsoft Word track changes and document revisions — OOXML revision markup (insertions and deletions) — so you can programmatically accept text, list revisions with author and date, and apply insertions, deletions, or replacements with full revision metadata.

Installation

pip install docx-revisions
or
uv add docx-revisions

Quick Start

Read tracked changes

from docx_revisions import RevisionDocument

rdoc = RevisionDocument("tracked_changes.docx")
for para in rdoc.paragraphs:
    if para.has_track_changes:
        for change in para.track_changes:
            print(f"{type(change).__name__}: '{change.text}' by {change.author}")
        print(f"Accepted: {para.accepted_text}")
        print(f"Original: {para.original_text}")

Accept or reject all changes

from docx_revisions import RevisionDocument

rdoc = RevisionDocument("tracked_changes.docx")
rdoc.accept_all()   # or rdoc.reject_all()
rdoc.save("clean.docx")

Find and replace with tracking

from docx_revisions import RevisionDocument

rdoc = RevisionDocument("contract.docx")
count = rdoc.find_and_replace_tracked("Acme Corp", "NewCo Inc", author="Legal")
print(f"Replaced {count} occurrences")
rdoc.save("contract_revised.docx")

Add tracked insertions and deletions

from docx_revisions import RevisionParagraph

rp = RevisionParagraph.from_paragraph(paragraph)
rp.add_tracked_insertion("new text", author="Editor")
rp.add_tracked_deletion(start=5, end=10, author="Editor")

API reference

docx_revisions

docx-revisions: Track changes support for python-docx.

RevisionDocument

Entry point for reading and writing tracked changes in a docx file.

Example
from docx_revisions import RevisionDocument

rdoc = RevisionDocument("contract.docx")
for para in rdoc.paragraphs:
    if para.has_track_changes:
        print(para.accepted_text)

rdoc.accept_all()
rdoc.save("contract_clean.docx")
Source code in docx_revisions/document.py
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
class RevisionDocument:
    """Entry point for reading and writing tracked changes in a docx file.

    Example:
        ```python
        from docx_revisions import RevisionDocument

        rdoc = RevisionDocument("contract.docx")
        for para in rdoc.paragraphs:
            if para.has_track_changes:
                print(para.accepted_text)

        rdoc.accept_all()
        rdoc.save("contract_clean.docx")
        ```
    """

    def __init__(self, path_or_doc: str | Path | IO[bytes] | _DocumentClass | None = None):
        """Open a docx file or wrap an existing ``Document``.

        Args:
            path_or_doc: A file path, file-like object, or an existing
                ``Document`` instance.  Pass ``None`` to create a new
                blank document.
        """
        if isinstance(path_or_doc, _DocumentClass):
            self._document = path_or_doc
        else:
            self._document = _new_document(path_or_doc)

    @property
    def document(self) -> _DocumentClass:
        """The underlying python-docx ``Document`` object."""
        return self._document

    @property
    def paragraphs(self) -> List[RevisionParagraph]:
        """All body paragraphs as ``RevisionParagraph`` objects."""
        return [RevisionParagraph.from_paragraph(p) for p in self._document.paragraphs]

    @property
    def track_changes(self) -> List[TrackedChange]:
        """All tracked changes across the entire document body."""
        changes: List[TrackedChange] = []
        for para in self.paragraphs:
            changes.extend(para.track_changes)
        return changes

    def accept_all(self) -> None:
        """Accept every tracked change in the document.

        Insertions are kept (wrapper removed), deletions are removed entirely.
        """
        for para in self.paragraphs:
            for change in list(para.track_changes):
                change.accept()

    def reject_all(self) -> None:
        """Reject every tracked change in the document.

        Insertions are removed entirely, deletions are kept (wrapper removed,
        ``w:delText`` converted back to ``w:t``).
        """
        for para in self.paragraphs:
            for change in list(para.track_changes):
                change.reject()

    def find_and_replace_tracked(
        self, search_text: str, replace_text: str, author: str = "", comment: str | None = None
    ) -> int:
        """Find and replace across the whole document with track changes.

        Searches all paragraphs in the document body and tables.

        Args:
            search_text: Text to find.
            replace_text: Replacement text.
            author: Author name for the revisions.
            comment: Optional comment text (requires python-docx comment
                support).

        Returns:
            Total number of replacements made.

        Example:
            ```python
            rdoc = RevisionDocument("doc.docx")
            count = rdoc.find_and_replace_tracked(
                "Acme Corp", "NewCo Inc", author="Legal"
            )
            print(f"Replaced {count} occurrences")
            rdoc.save("doc_revised.docx")
            ```
        """
        total_count = 0

        for para in self.paragraphs:
            total_count += para.replace_tracked(search_text, replace_text, author=author, comment=comment)

        for table in self._document.tables:
            for row in table.rows:
                for cell in row.cells:
                    for p in cell.paragraphs:
                        rp = RevisionParagraph.from_paragraph(p)
                        total_count += rp.replace_tracked(search_text, replace_text, author=author, comment=comment)

        return total_count

    def save(self, path: str | Path) -> None:
        """Save the document to *path*.

        Args:
            path: Destination file path.
        """
        self._document.save(str(path))

document property

The underlying python-docx Document object.

paragraphs property

All body paragraphs as RevisionParagraph objects.

track_changes property

All tracked changes across the entire document body.

__init__(path_or_doc=None)

Open a docx file or wrap an existing Document.

Parameters:

Name Type Description Default
path_or_doc str | Path | IO[bytes] | Document | None

A file path, file-like object, or an existing Document instance. Pass None to create a new blank document.

None
Source code in docx_revisions/document.py
37
38
39
40
41
42
43
44
45
46
47
48
def __init__(self, path_or_doc: str | Path | IO[bytes] | _DocumentClass | None = None):
    """Open a docx file or wrap an existing ``Document``.

    Args:
        path_or_doc: A file path, file-like object, or an existing
            ``Document`` instance.  Pass ``None`` to create a new
            blank document.
    """
    if isinstance(path_or_doc, _DocumentClass):
        self._document = path_or_doc
    else:
        self._document = _new_document(path_or_doc)

accept_all()

Accept every tracked change in the document.

Insertions are kept (wrapper removed), deletions are removed entirely.

Source code in docx_revisions/document.py
68
69
70
71
72
73
74
75
def accept_all(self) -> None:
    """Accept every tracked change in the document.

    Insertions are kept (wrapper removed), deletions are removed entirely.
    """
    for para in self.paragraphs:
        for change in list(para.track_changes):
            change.accept()

reject_all()

Reject every tracked change in the document.

Insertions are removed entirely, deletions are kept (wrapper removed, w:delText converted back to w:t).

Source code in docx_revisions/document.py
77
78
79
80
81
82
83
84
85
def reject_all(self) -> None:
    """Reject every tracked change in the document.

    Insertions are removed entirely, deletions are kept (wrapper removed,
    ``w:delText`` converted back to ``w:t``).
    """
    for para in self.paragraphs:
        for change in list(para.track_changes):
            change.reject()

find_and_replace_tracked(search_text, replace_text, author='', comment=None)

Find and replace across the whole document with track changes.

Searches all paragraphs in the document body and tables.

Parameters:

Name Type Description Default
search_text str

Text to find.

required
replace_text str

Replacement text.

required
author str

Author name for the revisions.

''
comment str | None

Optional comment text (requires python-docx comment support).

None

Returns:

Type Description
int

Total number of replacements made.

Example
rdoc = RevisionDocument("doc.docx")
count = rdoc.find_and_replace_tracked(
    "Acme Corp", "NewCo Inc", author="Legal"
)
print(f"Replaced {count} occurrences")
rdoc.save("doc_revised.docx")
Source code in docx_revisions/document.py
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
def find_and_replace_tracked(
    self, search_text: str, replace_text: str, author: str = "", comment: str | None = None
) -> int:
    """Find and replace across the whole document with track changes.

    Searches all paragraphs in the document body and tables.

    Args:
        search_text: Text to find.
        replace_text: Replacement text.
        author: Author name for the revisions.
        comment: Optional comment text (requires python-docx comment
            support).

    Returns:
        Total number of replacements made.

    Example:
        ```python
        rdoc = RevisionDocument("doc.docx")
        count = rdoc.find_and_replace_tracked(
            "Acme Corp", "NewCo Inc", author="Legal"
        )
        print(f"Replaced {count} occurrences")
        rdoc.save("doc_revised.docx")
        ```
    """
    total_count = 0

    for para in self.paragraphs:
        total_count += para.replace_tracked(search_text, replace_text, author=author, comment=comment)

    for table in self._document.tables:
        for row in table.rows:
            for cell in row.cells:
                for p in cell.paragraphs:
                    rp = RevisionParagraph.from_paragraph(p)
                    total_count += rp.replace_tracked(search_text, replace_text, author=author, comment=comment)

    return total_count

save(path)

Save the document to path.

Parameters:

Name Type Description Default
path str | Path

Destination file path.

required
Source code in docx_revisions/document.py
128
129
130
131
132
133
134
def save(self, path: str | Path) -> None:
    """Save the document to *path*.

    Args:
        path: Destination file path.
    """
    self._document.save(str(path))

RevisionParagraph

Bases: Paragraph

A Paragraph subclass that adds track-change support.

Create from an existing Paragraph with from_paragraph() — the two objects share the same underlying XML element so mutations via either reference are visible to both.

Example
from docx import Document
from docx_revisions import RevisionParagraph

doc = Document("example.docx")
for para in doc.paragraphs:
    rp = RevisionParagraph.from_paragraph(para)
    if rp.has_track_changes:
        print(f"Insertions: {len(rp.insertions)}")
        print(f"Deletions:  {len(rp.deletions)}")
Source code in docx_revisions/paragraph.py
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
class RevisionParagraph(Paragraph):
    """A ``Paragraph`` subclass that adds track-change support.

    Create from an existing ``Paragraph`` with ``from_paragraph()`` — the
    two objects share the same underlying XML element so mutations via either
    reference are visible to both.

    Example:
        ```python
        from docx import Document
        from docx_revisions import RevisionParagraph

        doc = Document("example.docx")
        for para in doc.paragraphs:
            rp = RevisionParagraph.from_paragraph(para)
            if rp.has_track_changes:
                print(f"Insertions: {len(rp.insertions)}")
                print(f"Deletions:  {len(rp.deletions)}")
        ```
    """

    @classmethod
    def from_paragraph(cls, para: Paragraph) -> RevisionParagraph:
        """Create a ``RevisionParagraph`` that shares *para*'s XML element.

        Args:
            para: An existing ``Paragraph`` object.

        Returns:
            A ``RevisionParagraph`` wrapping the same ``<w:p>`` element.
        """
        return cls(para._p, para._parent)

    # ------------------------------------------------------------------
    # Read-only properties
    # ------------------------------------------------------------------

    @property
    def has_track_changes(self) -> bool:
        """True if this paragraph contains any ``w:ins`` or ``w:del`` children."""
        return bool(self._p.xpath("./w:ins | ./w:del"))

    @property
    def insertions(self) -> List[TrackedInsertion]:
        """All tracked insertions in this paragraph, in document order."""
        return [
            TrackedInsertion(e, self)  # pyright: ignore[reportArgumentType]
            for e in self._p.xpath("./w:ins")
        ]

    @property
    def deletions(self) -> List[TrackedDeletion]:
        """All tracked deletions in this paragraph, in document order."""
        return [
            TrackedDeletion(e, self)  # pyright: ignore[reportArgumentType]
            for e in self._p.xpath("./w:del")
        ]

    @property
    def track_changes(self) -> List[TrackedChange]:
        """All tracked changes (insertions and deletions) in document order."""
        changes: List[TrackedChange] = []
        for e in self._p.xpath("./w:ins | ./w:del"):
            tag = e.tag  # pyright: ignore[reportUnknownMemberType]
            if tag == qn("w:ins"):
                changes.append(TrackedInsertion(e, self))  # pyright: ignore[reportArgumentType]
            elif tag == qn("w:del"):
                changes.append(TrackedDeletion(e, self))  # pyright: ignore[reportArgumentType]
        return changes

    def _text_view(self, *, accept_changes: bool) -> str:
        """Return paragraph text with changes either accepted or rejected.

        Args:
            accept_changes: If True, include insertions and skip deletions
                (accepted view).  If False, include deletions and skip
                insertions (original/rejected view).
        """
        include_tag = qn("w:ins") if accept_changes else qn("w:del")
        parts: List[str] = []
        for element in self._p.xpath("./w:r | ./w:ins | ./w:del"):
            tag = element.tag  # pyright: ignore[reportUnknownMemberType]
            if tag == qn("w:r"):
                run = Run(element, self)
                parts.append(run.text)
            elif tag == include_tag:
                tracked = TrackedInsertion(element, self) if accept_changes else TrackedDeletion(element, self)  # pyright: ignore[reportArgumentType]
                parts.append(tracked.text)
        return "".join(parts)

    @property
    def accepted_text(self) -> str:
        """Text of this paragraph with all changes accepted.

        Insertions are kept, deletions are removed.
        """
        return self._text_view(accept_changes=True)

    @property
    def original_text(self) -> str:
        """Text of this paragraph with all changes rejected.

        Deletions are kept, insertions are removed.
        """
        return self._text_view(accept_changes=False)

    # ------------------------------------------------------------------
    # Iteration
    # ------------------------------------------------------------------

    def iter_inner_content(  # type: ignore[override]
        self, include_revisions: bool = False
    ) -> Iterator[Run | Hyperlink | TrackedInsertion | TrackedDeletion]:
        """Generate runs, hyperlinks, and optionally revisions in document order.

        Args:
            include_revisions: If True, also yields ``TrackedInsertion`` and
                ``TrackedDeletion`` objects for run-level tracked changes.
                Defaults to False for backward compatibility.

        Yields:
            ``Run``, ``Hyperlink``, ``TrackedInsertion``, or ``TrackedDeletion``
            objects in document order.
        """
        if include_revisions:
            elements = self._p.xpath("./w:r | ./w:hyperlink | ./w:ins | ./w:del")
        else:
            elements = self._p.xpath("./w:r | ./w:hyperlink")

        for element in elements:
            tag = element.tag  # pyright: ignore[reportUnknownMemberType]
            if tag == qn("w:r"):
                yield Run(element, self)
            elif tag == qn("w:hyperlink"):
                yield Hyperlink(element, self)  # pyright: ignore[reportArgumentType]
            elif tag == qn("w:ins"):
                yield TrackedInsertion(element, self)  # pyright: ignore[reportArgumentType]
            elif tag == qn("w:del"):
                yield TrackedDeletion(element, self)  # pyright: ignore[reportArgumentType]

    # ------------------------------------------------------------------
    # Write operations
    # ------------------------------------------------------------------

    def add_tracked_insertion(
        self,
        text: str | None = None,
        style: str | CharacterStyle | None = None,
        author: str = "",
        revision_id: int | None = None,
    ) -> TrackedInsertion:
        """Append a tracked insertion containing a run with the specified text.

        The run is wrapped in a ``w:ins`` element, marking it as inserted
        content when track changes is enabled.

        Args:
            text: Text to add to the run.
            style: Character style to apply to the run.
            author: Author name for the revision.  Defaults to empty string.
            revision_id: Unique ID for this revision.  Auto-generated if not
                provided.

        Returns:
            A ``TrackedInsertion`` wrapping the new ``w:ins`` element.

        Example:
            ```python
            rp = RevisionParagraph.from_paragraph(paragraph)
            tracked = rp.add_tracked_insertion("new text", author="Editor")
            print(tracked.text)
            ```
        """
        if revision_id is None:
            revision_id = self._next_revision_id()

        ins = OxmlElement(
            "w:ins",
            attrs=revision_attrs(revision_id, author, dt.datetime.now(dt.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")),
        )

        r = OxmlElement("w:r")
        ins.append(r)
        self._p.append(ins)  # pyright: ignore[reportUnknownMemberType]

        tracked_insertion = TrackedInsertion(ins, self)  # pyright: ignore[reportArgumentType]
        if text:
            for run in tracked_insertion.runs:
                run.text = text
        if style:
            for run in tracked_insertion.runs:
                run.style = style

        return tracked_insertion

    def add_tracked_deletion(
        self, start: int, end: int, author: str = "", revision_id: int | None = None
    ) -> TrackedDeletion:
        """Wrap existing text at *[start, end)* in a ``w:del`` element.

        The text remains in the document but is marked as deleted.  The
        corresponding ``w:t`` elements are converted to ``w:delText``.

        Args:
            start: Starting character offset (0-based, inclusive).
            end: Ending character offset (0-based, exclusive).
            author: Author name for the revision.
            revision_id: Unique ID for this revision.  Auto-generated if not
                provided.

        Returns:
            A ``TrackedDeletion`` wrapping the new ``w:del`` element.

        Raises:
            ValueError: If offsets are invalid.
        """
        para_text = self.text
        if start < 0 or end > len(para_text) or start >= end:
            raise ValueError(f"Invalid offsets: start={start}, end={end} for text of length {len(para_text)}")

        if revision_id is None:
            revision_id = self._next_revision_id()

        run_boundaries = self._get_run_boundaries()
        if not run_boundaries:
            raise ValueError("Paragraph has no runs")

        start_run_idx, start_offset = self._find_run_at_offset(run_boundaries, start)
        end_run_idx, end_offset = self._find_run_at_offset(run_boundaries, end)

        now = dt.datetime.now(dt.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
        runs = list(self.runs)

        # Collect the deleted text
        deleted_text_parts: List[str] = []
        if start_run_idx == end_run_idx:
            deleted_text_parts.append(runs[start_run_idx].text[start_offset:end_offset])
        else:
            deleted_text_parts.append(runs[start_run_idx].text[start_offset:])
            for i in range(start_run_idx + 1, end_run_idx):
                deleted_text_parts.append(runs[i].text)
            deleted_text_parts.append(runs[end_run_idx].text[:end_offset])
        deleted_text = "".join(deleted_text_parts)

        # Build the w:del element
        start_r = runs[start_run_idx]._r
        parent = start_r.getparent()
        if parent is None:
            raise ValueError("Run has no parent element")

        before_text = runs[start_run_idx].text[:start_offset]
        after_text = runs[end_run_idx].text[end_offset:]

        index = list(parent).index(start_r)
        for i in range(start_run_idx, end_run_idx + 1):
            run_elem = runs[i]._r
            if run_elem.getparent() is parent:
                parent.remove(run_elem)

        insert_idx = index

        if before_text:
            parent.insert(insert_idx, make_text_run(before_text))
            insert_idx += 1

        del_elem = make_del_element(deleted_text, author, revision_id, now)
        parent.insert(insert_idx, del_elem)
        insert_idx += 1

        if after_text:
            parent.insert(insert_idx, make_text_run(after_text))

        return TrackedDeletion(del_elem, self)  # pyright: ignore[reportArgumentType]

    def replace_tracked(self, search_text: str, replace_text: str, author: str = "", comment: str | None = None) -> int:
        """Replace all occurrences of *search_text* with *replace_text* using track changes.

        Each replacement creates a tracked deletion of *search_text* and a
        tracked insertion of *replace_text*.  Matches text across run
        boundaries (handles OOXML run splitting).

        Args:
            search_text: Text to find and replace.
            replace_text: Text to insert in place of *search_text*.
            author: Author name for the revision.
            comment: Optional comment text (requires python-docx comment
                support).

        Returns:
            The number of replacements made.

        Example:
            ```python
            rp = RevisionParagraph.from_paragraph(paragraph)
            count = rp.replace_tracked("old", "new", author="Editor")
            ```
        """
        count = 0
        # Concatenate all run text and search across run boundaries.
        full_text = self.text
        search_len = len(search_text)

        # Find all match positions in the concatenated text.
        # Process right-to-left so earlier offsets stay valid after each splice.
        positions: list[int] = []
        start = 0
        while True:
            idx = full_text.find(search_text, start)
            if idx == -1:
                break
            positions.append(idx)
            start = idx + search_len

        # Apply replacements right-to-left to preserve offsets.
        for pos in reversed(positions):
            self.replace_tracked_at(pos, pos + search_len, replace_text, author=author, comment=comment)
            count += 1

        return count

    def replace_tracked_at(
        self, start: int, end: int, replace_text: str, author: str = "", comment: str | None = None
    ) -> None:
        """Replace text at character offsets *[start, end)* using track changes.

        Creates a tracked deletion of the text at positions ``[start, end)``
        and a tracked insertion of *replace_text* at that position.  The
        offsets are relative to ``paragraph.text``.

        Args:
            start: Starting character offset (0-based, inclusive).
            end: Ending character offset (0-based, exclusive).
            replace_text: Text to insert in place of the deleted text.
            author: Author name for the revision.
            comment: Optional comment text (requires python-docx comment
                support).

        Raises:
            ValueError: If *start* or *end* are out of bounds or *start* >= *end*.
        """
        para_text = self.text
        if start < 0 or end > len(para_text) or start >= end:
            raise ValueError(f"Invalid offsets: start={start}, end={end} for text of length {len(para_text)}")

        run_boundaries = self._get_run_boundaries()
        if not run_boundaries:
            raise ValueError("Paragraph has no runs")

        start_run_idx, start_offset_in_run = self._find_run_at_offset(run_boundaries, start)
        end_run_idx, end_offset_in_run = self._find_run_at_offset(run_boundaries, end)

        now = dt.datetime.now(dt.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
        runs = list(self.runs)

        # Compute the text splits
        if start_run_idx == end_run_idx:
            run = runs[start_run_idx]
            text = run.text
            before_text = text[:start_offset_in_run] or None
            deleted_text = text[start_offset_in_run:end_offset_in_run]
            after_text = text[end_offset_in_run:] or None
            first_r = run._r
        else:
            start_run = runs[start_run_idx]
            start_text = start_run.text
            before_text = start_text[:start_offset_in_run] or None
            deleted_from_start = start_text[start_offset_in_run:]

            end_run = runs[end_run_idx]
            end_text = end_run.text
            deleted_from_end = end_text[:end_offset_in_run]
            after_text = end_text[end_offset_in_run:] or None

            middle_deleted = "".join(runs[i].text for i in range(start_run_idx + 1, end_run_idx))
            deleted_text = deleted_from_start + middle_deleted + deleted_from_end
            first_r = start_run._r

        parent = first_r.getparent()
        if parent is None:
            return

        index = list(parent).index(first_r)

        # Remove spanned runs
        for i in range(start_run_idx, end_run_idx + 1):
            run_elem = runs[i]._r
            if run_elem.getparent() is parent:
                parent.remove(run_elem)

        splice_tracked_replace(
            parent, index, before_text, deleted_text, replace_text, after_text, author, self._next_revision_id, now
        )

    # ------------------------------------------------------------------
    # Private helpers
    # ------------------------------------------------------------------

    def _next_revision_id(self) -> int:
        """Generate the next unique revision ID for this document."""
        return next_revision_id(self._p)

    def _get_run_boundaries(self) -> List[tuple[int, int, int]]:
        """Return list of ``(run_index, start_offset, end_offset)`` for each run."""
        boundaries = []
        offset = 0
        for i, run in enumerate(self.runs):
            run_len = len(run.text)
            boundaries.append((i, offset, offset + run_len))
            offset += run_len
        return boundaries

    def _find_run_at_offset(self, boundaries: List[tuple[int, int, int]], offset: int) -> tuple[int, int]:
        """Find which run contains *offset* and the offset within that run."""
        for run_idx, run_start, run_end in boundaries:
            if run_start <= offset < run_end or (offset == run_end and run_idx == len(boundaries) - 1):
                return run_idx, offset - run_start
        last_idx, last_start, _ = boundaries[-1]
        return last_idx, offset - last_start

has_track_changes property

True if this paragraph contains any w:ins or w:del children.

insertions property

All tracked insertions in this paragraph, in document order.

deletions property

All tracked deletions in this paragraph, in document order.

track_changes property

All tracked changes (insertions and deletions) in document order.

accepted_text property

Text of this paragraph with all changes accepted.

Insertions are kept, deletions are removed.

original_text property

Text of this paragraph with all changes rejected.

Deletions are kept, insertions are removed.

from_paragraph(para) classmethod

Create a RevisionParagraph that shares para's XML element.

Parameters:

Name Type Description Default
para Paragraph

An existing Paragraph object.

required

Returns:

Type Description
RevisionParagraph

A RevisionParagraph wrapping the same <w:p> element.

Source code in docx_revisions/paragraph.py
53
54
55
56
57
58
59
60
61
62
63
@classmethod
def from_paragraph(cls, para: Paragraph) -> RevisionParagraph:
    """Create a ``RevisionParagraph`` that shares *para*'s XML element.

    Args:
        para: An existing ``Paragraph`` object.

    Returns:
        A ``RevisionParagraph`` wrapping the same ``<w:p>`` element.
    """
    return cls(para._p, para._parent)

iter_inner_content(include_revisions=False)

Generate runs, hyperlinks, and optionally revisions in document order.

Parameters:

Name Type Description Default
include_revisions bool

If True, also yields TrackedInsertion and TrackedDeletion objects for run-level tracked changes. Defaults to False for backward compatibility.

False

Yields:

Type Description
Run | Hyperlink | TrackedInsertion | TrackedDeletion

Run, Hyperlink, TrackedInsertion, or TrackedDeletion

Run | Hyperlink | TrackedInsertion | TrackedDeletion

objects in document order.

Source code in docx_revisions/paragraph.py
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
def iter_inner_content(  # type: ignore[override]
    self, include_revisions: bool = False
) -> Iterator[Run | Hyperlink | TrackedInsertion | TrackedDeletion]:
    """Generate runs, hyperlinks, and optionally revisions in document order.

    Args:
        include_revisions: If True, also yields ``TrackedInsertion`` and
            ``TrackedDeletion`` objects for run-level tracked changes.
            Defaults to False for backward compatibility.

    Yields:
        ``Run``, ``Hyperlink``, ``TrackedInsertion``, or ``TrackedDeletion``
        objects in document order.
    """
    if include_revisions:
        elements = self._p.xpath("./w:r | ./w:hyperlink | ./w:ins | ./w:del")
    else:
        elements = self._p.xpath("./w:r | ./w:hyperlink")

    for element in elements:
        tag = element.tag  # pyright: ignore[reportUnknownMemberType]
        if tag == qn("w:r"):
            yield Run(element, self)
        elif tag == qn("w:hyperlink"):
            yield Hyperlink(element, self)  # pyright: ignore[reportArgumentType]
        elif tag == qn("w:ins"):
            yield TrackedInsertion(element, self)  # pyright: ignore[reportArgumentType]
        elif tag == qn("w:del"):
            yield TrackedDeletion(element, self)  # pyright: ignore[reportArgumentType]

add_tracked_insertion(text=None, style=None, author='', revision_id=None)

Append a tracked insertion containing a run with the specified text.

The run is wrapped in a w:ins element, marking it as inserted content when track changes is enabled.

Parameters:

Name Type Description Default
text str | None

Text to add to the run.

None
style str | CharacterStyle | None

Character style to apply to the run.

None
author str

Author name for the revision. Defaults to empty string.

''
revision_id int | None

Unique ID for this revision. Auto-generated if not provided.

None

Returns:

Type Description
TrackedInsertion

A TrackedInsertion wrapping the new w:ins element.

Example
rp = RevisionParagraph.from_paragraph(paragraph)
tracked = rp.add_tracked_insertion("new text", author="Editor")
print(tracked.text)
Source code in docx_revisions/paragraph.py
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
def add_tracked_insertion(
    self,
    text: str | None = None,
    style: str | CharacterStyle | None = None,
    author: str = "",
    revision_id: int | None = None,
) -> TrackedInsertion:
    """Append a tracked insertion containing a run with the specified text.

    The run is wrapped in a ``w:ins`` element, marking it as inserted
    content when track changes is enabled.

    Args:
        text: Text to add to the run.
        style: Character style to apply to the run.
        author: Author name for the revision.  Defaults to empty string.
        revision_id: Unique ID for this revision.  Auto-generated if not
            provided.

    Returns:
        A ``TrackedInsertion`` wrapping the new ``w:ins`` element.

    Example:
        ```python
        rp = RevisionParagraph.from_paragraph(paragraph)
        tracked = rp.add_tracked_insertion("new text", author="Editor")
        print(tracked.text)
        ```
    """
    if revision_id is None:
        revision_id = self._next_revision_id()

    ins = OxmlElement(
        "w:ins",
        attrs=revision_attrs(revision_id, author, dt.datetime.now(dt.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")),
    )

    r = OxmlElement("w:r")
    ins.append(r)
    self._p.append(ins)  # pyright: ignore[reportUnknownMemberType]

    tracked_insertion = TrackedInsertion(ins, self)  # pyright: ignore[reportArgumentType]
    if text:
        for run in tracked_insertion.runs:
            run.text = text
    if style:
        for run in tracked_insertion.runs:
            run.style = style

    return tracked_insertion

add_tracked_deletion(start, end, author='', revision_id=None)

Wrap existing text at [start, end) in a w:del element.

The text remains in the document but is marked as deleted. The corresponding w:t elements are converted to w:delText.

Parameters:

Name Type Description Default
start int

Starting character offset (0-based, inclusive).

required
end int

Ending character offset (0-based, exclusive).

required
author str

Author name for the revision.

''
revision_id int | None

Unique ID for this revision. Auto-generated if not provided.

None

Returns:

Type Description
TrackedDeletion

A TrackedDeletion wrapping the new w:del element.

Raises:

Type Description
ValueError

If offsets are invalid.

Source code in docx_revisions/paragraph.py
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
def add_tracked_deletion(
    self, start: int, end: int, author: str = "", revision_id: int | None = None
) -> TrackedDeletion:
    """Wrap existing text at *[start, end)* in a ``w:del`` element.

    The text remains in the document but is marked as deleted.  The
    corresponding ``w:t`` elements are converted to ``w:delText``.

    Args:
        start: Starting character offset (0-based, inclusive).
        end: Ending character offset (0-based, exclusive).
        author: Author name for the revision.
        revision_id: Unique ID for this revision.  Auto-generated if not
            provided.

    Returns:
        A ``TrackedDeletion`` wrapping the new ``w:del`` element.

    Raises:
        ValueError: If offsets are invalid.
    """
    para_text = self.text
    if start < 0 or end > len(para_text) or start >= end:
        raise ValueError(f"Invalid offsets: start={start}, end={end} for text of length {len(para_text)}")

    if revision_id is None:
        revision_id = self._next_revision_id()

    run_boundaries = self._get_run_boundaries()
    if not run_boundaries:
        raise ValueError("Paragraph has no runs")

    start_run_idx, start_offset = self._find_run_at_offset(run_boundaries, start)
    end_run_idx, end_offset = self._find_run_at_offset(run_boundaries, end)

    now = dt.datetime.now(dt.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
    runs = list(self.runs)

    # Collect the deleted text
    deleted_text_parts: List[str] = []
    if start_run_idx == end_run_idx:
        deleted_text_parts.append(runs[start_run_idx].text[start_offset:end_offset])
    else:
        deleted_text_parts.append(runs[start_run_idx].text[start_offset:])
        for i in range(start_run_idx + 1, end_run_idx):
            deleted_text_parts.append(runs[i].text)
        deleted_text_parts.append(runs[end_run_idx].text[:end_offset])
    deleted_text = "".join(deleted_text_parts)

    # Build the w:del element
    start_r = runs[start_run_idx]._r
    parent = start_r.getparent()
    if parent is None:
        raise ValueError("Run has no parent element")

    before_text = runs[start_run_idx].text[:start_offset]
    after_text = runs[end_run_idx].text[end_offset:]

    index = list(parent).index(start_r)
    for i in range(start_run_idx, end_run_idx + 1):
        run_elem = runs[i]._r
        if run_elem.getparent() is parent:
            parent.remove(run_elem)

    insert_idx = index

    if before_text:
        parent.insert(insert_idx, make_text_run(before_text))
        insert_idx += 1

    del_elem = make_del_element(deleted_text, author, revision_id, now)
    parent.insert(insert_idx, del_elem)
    insert_idx += 1

    if after_text:
        parent.insert(insert_idx, make_text_run(after_text))

    return TrackedDeletion(del_elem, self)  # pyright: ignore[reportArgumentType]

replace_tracked(search_text, replace_text, author='', comment=None)

Replace all occurrences of search_text with replace_text using track changes.

Each replacement creates a tracked deletion of search_text and a tracked insertion of replace_text. Matches text across run boundaries (handles OOXML run splitting).

Parameters:

Name Type Description Default
search_text str

Text to find and replace.

required
replace_text str

Text to insert in place of search_text.

required
author str

Author name for the revision.

''
comment str | None

Optional comment text (requires python-docx comment support).

None

Returns:

Type Description
int

The number of replacements made.

Example
rp = RevisionParagraph.from_paragraph(paragraph)
count = rp.replace_tracked("old", "new", author="Editor")
Source code in docx_revisions/paragraph.py
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
def replace_tracked(self, search_text: str, replace_text: str, author: str = "", comment: str | None = None) -> int:
    """Replace all occurrences of *search_text* with *replace_text* using track changes.

    Each replacement creates a tracked deletion of *search_text* and a
    tracked insertion of *replace_text*.  Matches text across run
    boundaries (handles OOXML run splitting).

    Args:
        search_text: Text to find and replace.
        replace_text: Text to insert in place of *search_text*.
        author: Author name for the revision.
        comment: Optional comment text (requires python-docx comment
            support).

    Returns:
        The number of replacements made.

    Example:
        ```python
        rp = RevisionParagraph.from_paragraph(paragraph)
        count = rp.replace_tracked("old", "new", author="Editor")
        ```
    """
    count = 0
    # Concatenate all run text and search across run boundaries.
    full_text = self.text
    search_len = len(search_text)

    # Find all match positions in the concatenated text.
    # Process right-to-left so earlier offsets stay valid after each splice.
    positions: list[int] = []
    start = 0
    while True:
        idx = full_text.find(search_text, start)
        if idx == -1:
            break
        positions.append(idx)
        start = idx + search_len

    # Apply replacements right-to-left to preserve offsets.
    for pos in reversed(positions):
        self.replace_tracked_at(pos, pos + search_len, replace_text, author=author, comment=comment)
        count += 1

    return count

replace_tracked_at(start, end, replace_text, author='', comment=None)

Replace text at character offsets [start, end) using track changes.

Creates a tracked deletion of the text at positions [start, end) and a tracked insertion of replace_text at that position. The offsets are relative to paragraph.text.

Parameters:

Name Type Description Default
start int

Starting character offset (0-based, inclusive).

required
end int

Ending character offset (0-based, exclusive).

required
replace_text str

Text to insert in place of the deleted text.

required
author str

Author name for the revision.

''
comment str | None

Optional comment text (requires python-docx comment support).

None

Raises:

Type Description
ValueError

If start or end are out of bounds or start >= end.

Source code in docx_revisions/paragraph.py
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
def replace_tracked_at(
    self, start: int, end: int, replace_text: str, author: str = "", comment: str | None = None
) -> None:
    """Replace text at character offsets *[start, end)* using track changes.

    Creates a tracked deletion of the text at positions ``[start, end)``
    and a tracked insertion of *replace_text* at that position.  The
    offsets are relative to ``paragraph.text``.

    Args:
        start: Starting character offset (0-based, inclusive).
        end: Ending character offset (0-based, exclusive).
        replace_text: Text to insert in place of the deleted text.
        author: Author name for the revision.
        comment: Optional comment text (requires python-docx comment
            support).

    Raises:
        ValueError: If *start* or *end* are out of bounds or *start* >= *end*.
    """
    para_text = self.text
    if start < 0 or end > len(para_text) or start >= end:
        raise ValueError(f"Invalid offsets: start={start}, end={end} for text of length {len(para_text)}")

    run_boundaries = self._get_run_boundaries()
    if not run_boundaries:
        raise ValueError("Paragraph has no runs")

    start_run_idx, start_offset_in_run = self._find_run_at_offset(run_boundaries, start)
    end_run_idx, end_offset_in_run = self._find_run_at_offset(run_boundaries, end)

    now = dt.datetime.now(dt.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
    runs = list(self.runs)

    # Compute the text splits
    if start_run_idx == end_run_idx:
        run = runs[start_run_idx]
        text = run.text
        before_text = text[:start_offset_in_run] or None
        deleted_text = text[start_offset_in_run:end_offset_in_run]
        after_text = text[end_offset_in_run:] or None
        first_r = run._r
    else:
        start_run = runs[start_run_idx]
        start_text = start_run.text
        before_text = start_text[:start_offset_in_run] or None
        deleted_from_start = start_text[start_offset_in_run:]

        end_run = runs[end_run_idx]
        end_text = end_run.text
        deleted_from_end = end_text[:end_offset_in_run]
        after_text = end_text[end_offset_in_run:] or None

        middle_deleted = "".join(runs[i].text for i in range(start_run_idx + 1, end_run_idx))
        deleted_text = deleted_from_start + middle_deleted + deleted_from_end
        first_r = start_run._r

    parent = first_r.getparent()
    if parent is None:
        return

    index = list(parent).index(first_r)

    # Remove spanned runs
    for i in range(start_run_idx, end_run_idx + 1):
        run_elem = runs[i]._r
        if run_elem.getparent() is parent:
            parent.remove(run_elem)

    splice_tracked_replace(
        parent, index, before_text, deleted_text, replace_text, after_text, author, self._next_revision_id, now
    )

TrackedChange

Bases: Parented

Base class for tracked change proxy objects.

Provides common functionality for both insertions and deletions.

Source code in docx_revisions/revision.py
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
class TrackedChange(Parented):
    """Base class for tracked change proxy objects.

    Provides common functionality for both insertions and deletions.
    """

    def __init__(self, element: CT_RunTrackChange, parent: t.ProvidesStoryPart):
        super().__init__(parent)
        self._element = element

    @property
    def author(self) -> str:
        """The author who made this change."""
        return self._element.author

    @author.setter
    def author(self, value: str):
        self._element.author = value

    @property
    def date(self) -> dt.datetime | None:
        """The date/time when this change was made, or None if not recorded."""
        return self._element.date_value

    @date.setter
    def date(self, value: dt.datetime | None):
        self._element.date_value = value

    @property
    def revision_id(self) -> int:
        """The unique identifier for this revision."""
        return self._element.id

    @revision_id.setter
    def revision_id(self, value: int):
        self._element.id = value

    # ------------------------------------------------------------------
    # Shared content-access properties
    # ------------------------------------------------------------------

    @property
    def is_block_level(self) -> bool:
        """True if this change contains block-level content (paragraphs/tables)."""
        return bool(self._element.inner_content_elements)

    @property
    def is_run_level(self) -> bool:
        """True if this change contains run-level content."""
        return bool(self._element.run_content_elements)

    def iter_inner_content(self) -> Iterator[Paragraph | Table]:
        """Generate Paragraph or Table objects for block-level content."""
        from docx.table import Table
        from docx.text.paragraph import Paragraph

        for element in self._element.inner_content_elements:
            tag = element.tag  # pyright: ignore[reportUnknownMemberType]
            if tag == qn("w:p"):
                yield Paragraph(element, self._parent)  # pyright: ignore[reportArgumentType]
            elif tag == qn("w:tbl"):
                yield Table(element, self._parent)  # pyright: ignore[reportArgumentType]

    def iter_runs(self) -> Iterator[Run]:
        """Generate Run objects for run-level content."""
        from docx.text.run import Run

        for r in self._element.run_content_elements:
            yield Run(r, self._parent)  # pyright: ignore[reportArgumentType]

    @property
    def paragraphs(self) -> List[Paragraph]:
        """List of paragraphs in this change (for block-level changes)."""
        from docx.text.paragraph import Paragraph

        return [
            Paragraph(p, self._parent)  # pyright: ignore[reportArgumentType]
            for p in self._element.p_lst
        ]

    @property
    def runs(self) -> List[Run]:
        """List of runs in this change (for run-level changes)."""
        from docx.text.run import Run

        return [
            Run(r, self._parent)  # pyright: ignore[reportArgumentType]
            for r in self._element.r_lst
        ]

    def accept(self) -> None:
        """Accept this tracked change.

        For an insertion, this removes the revision wrapper, keeping the content.
        For a deletion, this removes both the wrapper and the content.
        """
        raise NotImplementedError("Subclasses must implement accept()")

    def reject(self) -> None:
        """Reject this tracked change.

        For an insertion, this removes both the wrapper and the content.
        For a deletion, this removes the revision wrapper, keeping the content.
        """
        raise NotImplementedError("Subclasses must implement reject()")

author property writable

The author who made this change.

date property writable

The date/time when this change was made, or None if not recorded.

revision_id property writable

The unique identifier for this revision.

is_block_level property

True if this change contains block-level content (paragraphs/tables).

is_run_level property

True if this change contains run-level content.

paragraphs property

List of paragraphs in this change (for block-level changes).

runs property

List of runs in this change (for run-level changes).

iter_inner_content()

Generate Paragraph or Table objects for block-level content.

Source code in docx_revisions/revision.py
77
78
79
80
81
82
83
84
85
86
87
def iter_inner_content(self) -> Iterator[Paragraph | Table]:
    """Generate Paragraph or Table objects for block-level content."""
    from docx.table import Table
    from docx.text.paragraph import Paragraph

    for element in self._element.inner_content_elements:
        tag = element.tag  # pyright: ignore[reportUnknownMemberType]
        if tag == qn("w:p"):
            yield Paragraph(element, self._parent)  # pyright: ignore[reportArgumentType]
        elif tag == qn("w:tbl"):
            yield Table(element, self._parent)  # pyright: ignore[reportArgumentType]

iter_runs()

Generate Run objects for run-level content.

Source code in docx_revisions/revision.py
89
90
91
92
93
94
def iter_runs(self) -> Iterator[Run]:
    """Generate Run objects for run-level content."""
    from docx.text.run import Run

    for r in self._element.run_content_elements:
        yield Run(r, self._parent)  # pyright: ignore[reportArgumentType]

accept()

Accept this tracked change.

For an insertion, this removes the revision wrapper, keeping the content. For a deletion, this removes both the wrapper and the content.

Source code in docx_revisions/revision.py
116
117
118
119
120
121
122
def accept(self) -> None:
    """Accept this tracked change.

    For an insertion, this removes the revision wrapper, keeping the content.
    For a deletion, this removes both the wrapper and the content.
    """
    raise NotImplementedError("Subclasses must implement accept()")

reject()

Reject this tracked change.

For an insertion, this removes both the wrapper and the content. For a deletion, this removes the revision wrapper, keeping the content.

Source code in docx_revisions/revision.py
124
125
126
127
128
129
130
def reject(self) -> None:
    """Reject this tracked change.

    For an insertion, this removes both the wrapper and the content.
    For a deletion, this removes the revision wrapper, keeping the content.
    """
    raise NotImplementedError("Subclasses must implement reject()")

TrackedDeletion

Bases: TrackedChange

Proxy object wrapping a w:del element.

Represents content that was deleted while track changes was enabled. The deleted content is still present in the document but marked as deleted.

Source code in docx_revisions/revision.py
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
class TrackedDeletion(TrackedChange):
    """Proxy object wrapping a ``w:del`` element.

    Represents content that was deleted while track changes was enabled.
    The deleted content is still present in the document but marked as
    deleted.
    """

    @property
    def text(self) -> str:
        """The text content of this deletion.

        For block-level deletions, returns concatenated text of all paragraphs.
        For run-level deletions, concatenates text from ``w:delText`` elements
        found via xpath, since ``Run.text`` only reads ``w:t``.
        """
        if self.is_block_level:
            return "\n".join(p.text for p in self.paragraphs)
        # w:del runs use w:delText instead of w:t, so we need xpath
        del_texts = self._element.xpath(".//w:delText")
        if del_texts:
            return "".join(t.text or "" for t in del_texts)
        # Fallback: try normal run text (for cases where w:t is still used)
        return "".join(r.text for r in self.runs)

    def accept(self) -> None:
        """Accept this deletion, removing both the content and the revision wrapper."""
        parent = self._element.getparent()
        if parent is not None:
            parent.remove(self._element)

    def reject(self) -> None:
        """Reject this deletion, keeping the content but removing the revision wrapper.

        Also converts ``w:delText`` elements back to ``w:t`` so the text
        becomes visible again.
        """
        parent = self._element.getparent()
        if parent is None:
            return

        # Convert w:delText back to w:t before unwrapping
        for del_text in self._element.xpath(".//w:delText"):
            from docx.oxml.parser import OxmlElement

            t_elem = OxmlElement("w:t")
            t_elem.text = del_text.text
            space_val = del_text.get(qn("xml:space"))
            if space_val:
                t_elem.set(qn("xml:space"), space_val)
            del_text_parent = del_text.getparent()
            if del_text_parent is not None:
                del_text_parent.replace(del_text, t_elem)

        index = list(parent).index(self._element)
        for child in reversed(list(self._element)):
            parent.insert(index, child)

        parent.remove(self._element)

text property

The text content of this deletion.

For block-level deletions, returns concatenated text of all paragraphs. For run-level deletions, concatenates text from w:delText elements found via xpath, since Run.text only reads w:t.

accept()

Accept this deletion, removing both the content and the revision wrapper.

Source code in docx_revisions/revision.py
196
197
198
199
200
def accept(self) -> None:
    """Accept this deletion, removing both the content and the revision wrapper."""
    parent = self._element.getparent()
    if parent is not None:
        parent.remove(self._element)

reject()

Reject this deletion, keeping the content but removing the revision wrapper.

Also converts w:delText elements back to w:t so the text becomes visible again.

Source code in docx_revisions/revision.py
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
def reject(self) -> None:
    """Reject this deletion, keeping the content but removing the revision wrapper.

    Also converts ``w:delText`` elements back to ``w:t`` so the text
    becomes visible again.
    """
    parent = self._element.getparent()
    if parent is None:
        return

    # Convert w:delText back to w:t before unwrapping
    for del_text in self._element.xpath(".//w:delText"):
        from docx.oxml.parser import OxmlElement

        t_elem = OxmlElement("w:t")
        t_elem.text = del_text.text
        space_val = del_text.get(qn("xml:space"))
        if space_val:
            t_elem.set(qn("xml:space"), space_val)
        del_text_parent = del_text.getparent()
        if del_text_parent is not None:
            del_text_parent.replace(del_text, t_elem)

    index = list(parent).index(self._element)
    for child in reversed(list(self._element)):
        parent.insert(index, child)

    parent.remove(self._element)

TrackedInsertion

Bases: TrackedChange

Proxy object wrapping a w:ins element.

Represents content that was inserted while track changes was enabled. The inserted content can be paragraphs, tables, or runs depending on context.

Source code in docx_revisions/revision.py
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
class TrackedInsertion(TrackedChange):
    """Proxy object wrapping a ``w:ins`` element.

    Represents content that was inserted while track changes was enabled.
    The inserted content can be paragraphs, tables, or runs depending on
    context.
    """

    @property
    def text(self) -> str:
        """The text content of this insertion.

        For block-level insertions, returns concatenated text of all paragraphs.
        For run-level insertions, returns concatenated text of all runs.
        """
        if self.is_block_level:
            return "\n".join(p.text for p in self.paragraphs)
        return "".join(r.text for r in self.runs)

    def accept(self) -> None:
        """Accept this insertion, keeping the content but removing the revision wrapper."""
        parent = self._element.getparent()
        if parent is None:
            return

        index = list(parent).index(self._element)
        for child in reversed(list(self._element)):
            parent.insert(index, child)

        parent.remove(self._element)

    def reject(self) -> None:
        """Reject this insertion, removing both the content and the revision wrapper."""
        parent = self._element.getparent()
        if parent is not None:
            parent.remove(self._element)

text property

The text content of this insertion.

For block-level insertions, returns concatenated text of all paragraphs. For run-level insertions, returns concatenated text of all runs.

accept()

Accept this insertion, keeping the content but removing the revision wrapper.

Source code in docx_revisions/revision.py
152
153
154
155
156
157
158
159
160
161
162
def accept(self) -> None:
    """Accept this insertion, keeping the content but removing the revision wrapper."""
    parent = self._element.getparent()
    if parent is None:
        return

    index = list(parent).index(self._element)
    for child in reversed(list(self._element)):
        parent.insert(index, child)

    parent.remove(self._element)

reject()

Reject this insertion, removing both the content and the revision wrapper.

Source code in docx_revisions/revision.py
164
165
166
167
168
def reject(self) -> None:
    """Reject this insertion, removing both the content and the revision wrapper."""
    parent = self._element.getparent()
    if parent is not None:
        parent.remove(self._element)

RevisionRun

Bases: Run

A Run subclass that adds track-change support.

Create from an existing Run with from_run() — the two objects share the same underlying XML element.

Example
from docx_revisions import RevisionRun

run = paragraph.runs[0]
rr = RevisionRun.from_run(run)
tracked = rr.delete_tracked(author="Editor")
Source code in docx_revisions/run.py
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
class RevisionRun(Run):
    """A ``Run`` subclass that adds track-change support.

    Create from an existing ``Run`` with ``from_run()`` — the two objects
    share the same underlying XML element.

    Example:
        ```python
        from docx_revisions import RevisionRun

        run = paragraph.runs[0]
        rr = RevisionRun.from_run(run)
        tracked = rr.delete_tracked(author="Editor")
        ```
    """

    @classmethod
    def from_run(cls, run: Run) -> RevisionRun:
        """Create a ``RevisionRun`` that shares *run*'s XML element.

        Args:
            run: An existing ``Run`` object.

        Returns:
            A ``RevisionRun`` wrapping the same ``<w:r>`` element.
        """
        return cls(run._r, run._parent)

    def delete_tracked(self, author: str = "", revision_id: int | None = None) -> TrackedDeletion:
        """Mark this run as deleted with track changes.

        Instead of removing the run, it is wrapped in a ``w:del`` element to
        mark it as deleted content.  The run remains in the document but is
        displayed as deleted text (e.g., with strikethrough).  The ``w:t``
        elements are converted to ``w:delText``.

        Args:
            author: Author name for the revision.
            revision_id: Unique ID for this revision.  Auto-generated if not
                provided.

        Returns:
            A ``TrackedDeletion`` wrapping the ``w:del`` element.

        Raises:
            ValueError: If the run has no parent element.
        """
        if revision_id is None:
            revision_id = self._next_revision_id()

        parent = self._r.getparent()
        if parent is None:
            raise ValueError("Run has no parent element")

        del_elem = OxmlElement(
            "w:del",
            attrs=revision_attrs(revision_id, author, dt.datetime.now(dt.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")),
        )

        for t_elem in self._r.findall(qn("w:t")):
            delText = OxmlElement("w:delText")
            delText.text = t_elem.text
            if t_elem.get(qn("xml:space")) == "preserve":
                delText.set(qn("xml:space"), "preserve")
            t_elem.getparent().replace(t_elem, delText)  # pyright: ignore[reportOptionalMemberAccess]

        index = list(parent).index(self._r)
        parent.insert(index, del_elem)
        del_elem.append(self._r)

        return TrackedDeletion(del_elem, self._parent)  # pyright: ignore[reportArgumentType]

    def replace_tracked_at(self, start: int, end: int, replace_text: str, author: str = "") -> None:
        """Replace text at character offsets *[start, end)* using track changes.

        Creates a tracked deletion of the text at positions ``[start, end)``
        and a tracked insertion of *replace_text* at that position.

        Args:
            start: Starting character offset (0-based, inclusive).
            end: Ending character offset (0-based, exclusive).
            replace_text: Text to insert in place of the deleted text.
            author: Author name for the revision.

        Raises:
            ValueError: If *start* or *end* are out of bounds or *start* >= *end*.
        """
        text = self.text
        if start < 0 or end > len(text) or start >= end:
            raise ValueError(f"Invalid offsets: start={start}, end={end} for text of length {len(text)}")

        now = dt.datetime.now(dt.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
        before_text = text[:start] or None
        deleted_text = text[start:end]
        after_text = text[end:] or None

        r_elem = self._r
        parent = r_elem.getparent()
        if parent is None:
            raise ValueError("Run has no parent element")

        index = list(parent).index(r_elem)
        parent.remove(r_elem)

        splice_tracked_replace(
            parent, index, before_text, deleted_text, replace_text, after_text, author, self._next_revision_id, now
        )

    def _next_revision_id(self) -> int:
        """Generate the next unique revision ID for this document."""
        return next_revision_id(self._r)

from_run(run) classmethod

Create a RevisionRun that shares run's XML element.

Parameters:

Name Type Description Default
run Run

An existing Run object.

required

Returns:

Type Description
RevisionRun

A RevisionRun wrapping the same <w:r> element.

Source code in docx_revisions/run.py
35
36
37
38
39
40
41
42
43
44
45
@classmethod
def from_run(cls, run: Run) -> RevisionRun:
    """Create a ``RevisionRun`` that shares *run*'s XML element.

    Args:
        run: An existing ``Run`` object.

    Returns:
        A ``RevisionRun`` wrapping the same ``<w:r>`` element.
    """
    return cls(run._r, run._parent)

delete_tracked(author='', revision_id=None)

Mark this run as deleted with track changes.

Instead of removing the run, it is wrapped in a w:del element to mark it as deleted content. The run remains in the document but is displayed as deleted text (e.g., with strikethrough). The w:t elements are converted to w:delText.

Parameters:

Name Type Description Default
author str

Author name for the revision.

''
revision_id int | None

Unique ID for this revision. Auto-generated if not provided.

None

Returns:

Type Description
TrackedDeletion

A TrackedDeletion wrapping the w:del element.

Raises:

Type Description
ValueError

If the run has no parent element.

Source code in docx_revisions/run.py
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
def delete_tracked(self, author: str = "", revision_id: int | None = None) -> TrackedDeletion:
    """Mark this run as deleted with track changes.

    Instead of removing the run, it is wrapped in a ``w:del`` element to
    mark it as deleted content.  The run remains in the document but is
    displayed as deleted text (e.g., with strikethrough).  The ``w:t``
    elements are converted to ``w:delText``.

    Args:
        author: Author name for the revision.
        revision_id: Unique ID for this revision.  Auto-generated if not
            provided.

    Returns:
        A ``TrackedDeletion`` wrapping the ``w:del`` element.

    Raises:
        ValueError: If the run has no parent element.
    """
    if revision_id is None:
        revision_id = self._next_revision_id()

    parent = self._r.getparent()
    if parent is None:
        raise ValueError("Run has no parent element")

    del_elem = OxmlElement(
        "w:del",
        attrs=revision_attrs(revision_id, author, dt.datetime.now(dt.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")),
    )

    for t_elem in self._r.findall(qn("w:t")):
        delText = OxmlElement("w:delText")
        delText.text = t_elem.text
        if t_elem.get(qn("xml:space")) == "preserve":
            delText.set(qn("xml:space"), "preserve")
        t_elem.getparent().replace(t_elem, delText)  # pyright: ignore[reportOptionalMemberAccess]

    index = list(parent).index(self._r)
    parent.insert(index, del_elem)
    del_elem.append(self._r)

    return TrackedDeletion(del_elem, self._parent)  # pyright: ignore[reportArgumentType]

replace_tracked_at(start, end, replace_text, author='')

Replace text at character offsets [start, end) using track changes.

Creates a tracked deletion of the text at positions [start, end) and a tracked insertion of replace_text at that position.

Parameters:

Name Type Description Default
start int

Starting character offset (0-based, inclusive).

required
end int

Ending character offset (0-based, exclusive).

required
replace_text str

Text to insert in place of the deleted text.

required
author str

Author name for the revision.

''

Raises:

Type Description
ValueError

If start or end are out of bounds or start >= end.

Source code in docx_revisions/run.py
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
def replace_tracked_at(self, start: int, end: int, replace_text: str, author: str = "") -> None:
    """Replace text at character offsets *[start, end)* using track changes.

    Creates a tracked deletion of the text at positions ``[start, end)``
    and a tracked insertion of *replace_text* at that position.

    Args:
        start: Starting character offset (0-based, inclusive).
        end: Ending character offset (0-based, exclusive).
        replace_text: Text to insert in place of the deleted text.
        author: Author name for the revision.

    Raises:
        ValueError: If *start* or *end* are out of bounds or *start* >= *end*.
    """
    text = self.text
    if start < 0 or end > len(text) or start >= end:
        raise ValueError(f"Invalid offsets: start={start}, end={end} for text of length {len(text)}")

    now = dt.datetime.now(dt.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
    before_text = text[:start] or None
    deleted_text = text[start:end]
    after_text = text[end:] or None

    r_elem = self._r
    parent = r_elem.getparent()
    if parent is None:
        raise ValueError("Run has no parent element")

    index = list(parent).index(r_elem)
    parent.remove(r_elem)

    splice_tracked_replace(
        parent, index, before_text, deleted_text, replace_text, after_text, author, self._next_revision_id, now
    )