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

        Only paragraphs in the document body are returned. Paragraphs inside
        tables are excluded. Use :attr:`all_paragraphs` to iterate over every
        paragraph including those nested in tables.
        """
        return [RevisionParagraph.from_paragraph(p) for p in self._document.paragraphs]

    @property
    def all_paragraphs(self) -> List[RevisionParagraph]:
        """Every paragraph in the document, including those inside tables.

        Walks the document body and recurses into all tables (including
        nested tables within cells).

        Example:
            ```python
            rdoc = RevisionDocument("contract.docx")
            for para in rdoc.all_paragraphs:
                if para.has_track_changes:
                    print(para.accepted_text)
            ```
        """
        return list(self._iter_all_paragraphs())

    def _iter_all_paragraphs(self) -> Iterator[RevisionParagraph]:
        """Yield every ``RevisionParagraph`` in the body and all tables.

        Recurses into nested tables via ``cell.tables``.
        """
        for p in self._document.paragraphs:
            yield RevisionParagraph.from_paragraph(p)
        for table in self._document.tables:
            yield from self._iter_table_paragraphs(table)

    def _iter_table_paragraphs(self, table: _Table) -> Iterator[RevisionParagraph]:
        """Yield every ``RevisionParagraph`` inside *table* recursively."""
        for row in table.rows:
            for cell in row.cells:
                for p in cell.paragraphs:
                    yield RevisionParagraph.from_paragraph(p)
                for nested in cell.tables:
                    yield from self._iter_table_paragraphs(nested)

    @property
    def track_changes(self) -> List[TrackedChange]:
        """All tracked changes across the document body and tables."""
        changes: List[TrackedChange] = []
        for para in self._iter_all_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.
        Tracked changes inside tables (including nested tables) are processed.
        Loops until no tracked changes remain so that nested revisions (which
        can arise from ``replace_tracked(index_mode="accepted")``) are fully
        resolved.
        """
        for para in self._iter_all_paragraphs():
            while para.track_changes:
                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``). Tracked changes inside
        tables (including nested tables) are processed. Loops until no tracked
        changes remain.
        """
        for para in self._iter_all_paragraphs():
            while para.track_changes:
                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,
        index_mode: IndexMode = "text",
    ) -> int:
        """Find and replace across the whole document with track changes.

        Searches all paragraphs in the document body and tables (including
        nested 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).
            index_mode: Which text view to search against per paragraph.  See
                :meth:`RevisionParagraph.replace_tracked` — ``"text"`` (default),
                ``"accepted"``, or ``"original"``.

        Returns:
            Total number of replacements made.

        Example:
            ```python
            rdoc = RevisionDocument("doc.docx")
            # Replace against the accepted view so matches inside prior
            # tracked insertions are also found.
            count = rdoc.find_and_replace_tracked(
                "Acme Corp", "NewCo Inc", author="Legal", index_mode="accepted"
            )
            rdoc.save("doc_revised.docx")
            ```
        """
        total_count = 0
        for para in self._iter_all_paragraphs():
            total_count += para.replace_tracked(
                search_text, replace_text, author=author, comment=comment, index_mode=index_mode
            )
        return total_count

    def save(self, path_or_stream: str | Path | IO[bytes]) -> None:
        """Save the document to a path or file-like object.

        Args:
            path_or_stream: Destination file path (``str`` or ``Path``) or a
                writable binary file-like object (anything with a ``write``
                method, such as ``io.BytesIO``).

        Raises:
            TypeError: If *path_or_stream* is neither a path nor a writable
                binary stream.
            ValueError: If *path_or_stream* is an empty string, or is a text-
                mode file object.

        Example:
            ```python
            import io
            from docx_revisions import RevisionDocument

            rdoc = RevisionDocument("contract.docx")
            rdoc.accept_all()

            buffer = io.BytesIO()
            rdoc.save(buffer)
            buffer.seek(0)
            data = buffer.read()
            ```
        """
        if isinstance(path_or_stream, str | Path):
            path_str = str(path_or_stream)
            if not path_str:
                raise ValueError("save() path must not be empty")
            self._document.save(path_str)
            return

        write = getattr(path_or_stream, "write", None)
        if not callable(write):
            raise TypeError(
                f"save() expects a str, Path, or writable binary file-like object; got {type(path_or_stream).__name__}"
            )

        mode = getattr(path_or_stream, "mode", None)
        if isinstance(mode, str) and "b" not in mode:
            raise ValueError(
                f"save() requires a binary-mode stream; got mode={mode!r}. "
                "Open the file with mode='wb' or use io.BytesIO()."
            )

        self._document.save(path_or_stream)

document property

The underlying python-docx Document object.

paragraphs property

All body paragraphs as RevisionParagraph objects.

Only paragraphs in the document body are returned. Paragraphs inside tables are excluded. Use :attr:all_paragraphs to iterate over every paragraph including those nested in tables.

all_paragraphs property

Every paragraph in the document, including those inside tables.

Walks the document body and recurses into all tables (including nested tables within cells).

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

track_changes property

All tracked changes across the document body and tables.

__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
38
39
40
41
42
43
44
45
46
47
48
49
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. Tracked changes inside tables (including nested tables) are processed. Loops until no tracked changes remain so that nested revisions (which can arise from replace_tracked(index_mode="accepted")) are fully resolved.

Source code in docx_revisions/document.py
110
111
112
113
114
115
116
117
118
119
120
121
122
def accept_all(self) -> None:
    """Accept every tracked change in the document.

    Insertions are kept (wrapper removed), deletions are removed entirely.
    Tracked changes inside tables (including nested tables) are processed.
    Loops until no tracked changes remain so that nested revisions (which
    can arise from ``replace_tracked(index_mode="accepted")``) are fully
    resolved.
    """
    for para in self._iter_all_paragraphs():
        while para.track_changes:
            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). Tracked changes inside tables (including nested tables) are processed. Loops until no tracked changes remain.

Source code in docx_revisions/document.py
124
125
126
127
128
129
130
131
132
133
134
135
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``). Tracked changes inside
    tables (including nested tables) are processed. Loops until no tracked
    changes remain.
    """
    for para in self._iter_all_paragraphs():
        while para.track_changes:
            for change in list(para.track_changes):
                change.reject()

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

Find and replace across the whole document with track changes.

Searches all paragraphs in the document body and tables (including nested 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
index_mode IndexMode

Which text view to search against per paragraph. See :meth:RevisionParagraph.replace_tracked"text" (default), "accepted", or "original".

'text'

Returns:

Type Description
int

Total number of replacements made.

Example
rdoc = RevisionDocument("doc.docx")
# Replace against the accepted view so matches inside prior
# tracked insertions are also found.
count = rdoc.find_and_replace_tracked(
    "Acme Corp", "NewCo Inc", author="Legal", index_mode="accepted"
)
rdoc.save("doc_revised.docx")
Source code in docx_revisions/document.py
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
def find_and_replace_tracked(
    self,
    search_text: str,
    replace_text: str,
    author: str = "",
    comment: str | None = None,
    index_mode: IndexMode = "text",
) -> int:
    """Find and replace across the whole document with track changes.

    Searches all paragraphs in the document body and tables (including
    nested 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).
        index_mode: Which text view to search against per paragraph.  See
            :meth:`RevisionParagraph.replace_tracked` — ``"text"`` (default),
            ``"accepted"``, or ``"original"``.

    Returns:
        Total number of replacements made.

    Example:
        ```python
        rdoc = RevisionDocument("doc.docx")
        # Replace against the accepted view so matches inside prior
        # tracked insertions are also found.
        count = rdoc.find_and_replace_tracked(
            "Acme Corp", "NewCo Inc", author="Legal", index_mode="accepted"
        )
        rdoc.save("doc_revised.docx")
        ```
    """
    total_count = 0
    for para in self._iter_all_paragraphs():
        total_count += para.replace_tracked(
            search_text, replace_text, author=author, comment=comment, index_mode=index_mode
        )
    return total_count

save(path_or_stream)

Save the document to a path or file-like object.

Parameters:

Name Type Description Default
path_or_stream str | Path | IO[bytes]

Destination file path (str or Path) or a writable binary file-like object (anything with a write method, such as io.BytesIO).

required

Raises:

Type Description
TypeError

If path_or_stream is neither a path nor a writable binary stream.

ValueError

If path_or_stream is an empty string, or is a text- mode file object.

Example
import io
from docx_revisions import RevisionDocument

rdoc = RevisionDocument("contract.docx")
rdoc.accept_all()

buffer = io.BytesIO()
rdoc.save(buffer)
buffer.seek(0)
data = buffer.read()
Source code in docx_revisions/document.py
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
def save(self, path_or_stream: str | Path | IO[bytes]) -> None:
    """Save the document to a path or file-like object.

    Args:
        path_or_stream: Destination file path (``str`` or ``Path``) or a
            writable binary file-like object (anything with a ``write``
            method, such as ``io.BytesIO``).

    Raises:
        TypeError: If *path_or_stream* is neither a path nor a writable
            binary stream.
        ValueError: If *path_or_stream* is an empty string, or is a text-
            mode file object.

    Example:
        ```python
        import io
        from docx_revisions import RevisionDocument

        rdoc = RevisionDocument("contract.docx")
        rdoc.accept_all()

        buffer = io.BytesIO()
        rdoc.save(buffer)
        buffer.seek(0)
        data = buffer.read()
        ```
    """
    if isinstance(path_or_stream, str | Path):
        path_str = str(path_or_stream)
        if not path_str:
            raise ValueError("save() path must not be empty")
        self._document.save(path_str)
        return

    write = getattr(path_or_stream, "write", None)
    if not callable(write):
        raise TypeError(
            f"save() expects a str, Path, or writable binary file-like object; got {type(path_or_stream).__name__}"
        )

    mode = getattr(path_or_stream, "mode", None)
    if isinstance(mode, str) and "b" not in mode:
        raise ValueError(
            f"save() requires a binary-mode stream; got mode={mode!r}. "
            "Open the file with mode='wb' or use io.BytesIO()."
        )

    self._document.save(path_or_stream)

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
 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
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
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")
        skip_tag = qn("w:del") if accept_changes else qn("w:ins")

        def walk(element: etree._Element) -> str:
            parts: List[str] = []
            for child in element.xpath("./w:r | ./w:ins | ./w:del"):
                tag = child.tag
                if tag == qn("w:r"):
                    for t in child.xpath("./w:t | ./w:delText"):
                        parts.append(t.text or "")
                elif tag == include_tag:
                    parts.append(walk(child))
                elif tag == skip_tag:
                    continue
            return "".join(parts)

        return walk(self._p)

    @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, index_mode: IndexMode = "text"
    ) -> 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.
            index_mode: Which text view the offsets index into:
                ``"text"`` (default, raw ``paragraph.text`` ignoring prior
                revisions), ``"accepted"`` (``paragraph.accepted_text``, with
                prior insertions kept and deletions skipped), or
                ``"original"`` (``paragraph.original_text``, with prior
                deletions kept and insertions skipped).

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

        Raises:
            ValueError: If offsets are invalid.

        Example:
            ```python
            # Delete characters from the accepted (post-revision) view
            rp.add_tracked_deletion(0, 5, author="Editor", index_mode="accepted")
            ```
        """
        view_text = self._view_text(index_mode)
        if start < 0 or end > len(view_text) or start >= end:
            raise ValueError(f"Invalid offsets: start={start}, end={end} for text of length {len(view_text)}")

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

        units = self._get_editable_units(index_mode)
        if not units:
            raise ValueError("Paragraph has no runs")
        boundaries = self._unit_boundaries(units)

        start_unit_idx, start_offset = self._find_unit_at_offset(boundaries, start)
        end_unit_idx, end_offset = self._find_unit_at_offset(boundaries, end)

        # All units in the [start, end) span must share the same parent for a
        # clean single-parent splice.  This holds when the span is entirely in
        # top-level w:r runs, or entirely inside one w:ins / w:del wrapper.
        start_parent = units[start_unit_idx].getparent()
        end_parent = units[end_unit_idx].getparent()
        if start_parent is None or start_parent is not end_parent:
            raise ValueError(
                "Cannot apply tracked deletion across a revision boundary; "
                "operate on a narrower span entirely inside or outside a prior revision."
            )
        parent = start_parent

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

        def _r_text(r: etree._Element) -> str:
            parts = []
            for child in r.xpath("./w:t | ./w:delText"):
                parts.append(child.text or "")
            return "".join(parts)

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

        start_r = units[start_unit_idx]
        before_text = _r_text(start_r)[:start_offset]
        after_text = _r_text(units[end_unit_idx])[end_offset:]

        index = list(parent).index(start_r)
        for i in range(start_unit_idx, end_unit_idx + 1):
            run_elem = units[i]
            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,
        index_mode: IndexMode = "text",
    ) -> 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).
            index_mode: Which text view to search against:
                ``"text"`` (default, raw ``paragraph.text``),
                ``"accepted"`` (``paragraph.accepted_text``, includes prior
                insertions, skips prior deletions), or ``"original"``
                (``paragraph.original_text``, includes prior deletions, skips
                prior insertions).

        Returns:
            The number of replacements made.

        Example:
            ```python
            # Default: search raw run text
            rp.replace_tracked("old", "new", author="Editor")

            # Search the accepted view — matches land inside prior w:ins blocks
            rp.replace_tracked(
                "old", "new", author="Editor", index_mode="accepted"
            )
            ```
        """
        count = 0
        full_text = self._view_text(index_mode)
        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, index_mode=index_mode
            )
            count += 1

        return count

    def replace_tracked_at(
        self,
        start: int,
        end: int,
        replace_text: str,
        author: str = "",
        comment: str | None = None,
        index_mode: IndexMode = "text",
    ) -> 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.
            comment: Optional comment text (requires python-docx comment
                support).
            index_mode: Which text view the offsets index into.  See
                :meth:`replace_tracked`.

        Raises:
            ValueError: If *start* or *end* are out of bounds or *start* >= *end*.

        Example:
            ```python
            # Offsets are interpreted against accepted_text
            rp.replace_tracked_at(
                0, 5, "Hi", author="Editor", index_mode="accepted"
            )
            ```
        """
        view_text = self._view_text(index_mode)
        if start < 0 or end > len(view_text) or start >= end:
            raise ValueError(f"Invalid offsets: start={start}, end={end} for text of length {len(view_text)}")

        units = self._get_editable_units(index_mode)
        if not units:
            raise ValueError("Paragraph has no runs")
        boundaries = self._unit_boundaries(units)

        start_unit_idx, start_offset_in_unit = self._find_unit_at_offset(boundaries, start)
        end_unit_idx, end_offset_in_unit = self._find_unit_at_offset(boundaries, end)

        start_parent = units[start_unit_idx].getparent()
        end_parent = units[end_unit_idx].getparent()
        if start_parent is None or start_parent is not end_parent:
            raise ValueError(
                "Cannot apply tracked replacement across a revision boundary; "
                "operate on a narrower span entirely inside or outside a prior revision."
            )
        parent = start_parent

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

        def _r_text(r: etree._Element) -> str:
            parts = []
            for child in r.xpath("./w:t | ./w:delText"):
                parts.append(child.text or "")
            return "".join(parts)

        if start_unit_idx == end_unit_idx:
            r = units[start_unit_idx]
            text = _r_text(r)
            before_text = text[:start_offset_in_unit] or None
            deleted_text = text[start_offset_in_unit:end_offset_in_unit]
            after_text = text[end_offset_in_unit:] or None
            first_r = r
        else:
            first_r = units[start_unit_idx]
            start_text = _r_text(first_r)
            before_text = start_text[:start_offset_in_unit] or None
            deleted_from_start = start_text[start_offset_in_unit:]

            end_r = units[end_unit_idx]
            end_text = _r_text(end_r)
            deleted_from_end = end_text[:end_offset_in_unit]
            after_text = end_text[end_offset_in_unit:] or None

            middle_deleted = "".join(_r_text(units[i]) for i in range(start_unit_idx + 1, end_unit_idx))
            deleted_text = deleted_from_start + middle_deleted + deleted_from_end

        index = list(parent).index(first_r)

        # Remove spanned runs (only if they share the parent, which the check above guarantees)
        for i in range(start_unit_idx, end_unit_idx + 1):
            run_elem = units[i]
            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 _view_text(self, index_mode: IndexMode) -> str:
        """Return the paragraph text for the chosen index mode."""
        if index_mode == "text":
            return self.text
        if index_mode == "accepted":
            return self.accepted_text
        if index_mode == "original":
            return self.original_text
        raise ValueError(f"Unknown index_mode: {index_mode!r}")

    def _get_editable_units(self, index_mode: IndexMode) -> List[etree._Element]:
        """Return the ordered list of ``w:r`` elements that make up *index_mode*'s view.

        - ``"text"``: only top-level ``w:r`` children.
        - ``"accepted"``: walk ``w:r`` children, recurse into ``w:ins``
          (prior insertions visible), skip ``w:del``.
        - ``"original"``: walk ``w:r`` children, recurse into ``w:del``
          (prior deletions visible), skip ``w:ins``.
        """
        if index_mode == "text":
            return list(self._p.xpath("./w:r"))

        if index_mode == "accepted":
            recurse_tag = qn("w:ins")
            skip_tag = qn("w:del")
        elif index_mode == "original":
            recurse_tag = qn("w:del")
            skip_tag = qn("w:ins")
        else:
            raise ValueError(f"Unknown index_mode: {index_mode!r}")

        units: List[etree._Element] = []

        def walk(element: etree._Element) -> None:
            for child in element.xpath("./w:r | ./w:ins | ./w:del"):
                tag = child.tag
                if tag == qn("w:r"):
                    units.append(child)
                elif tag == recurse_tag:
                    walk(child)
                elif tag == skip_tag:
                    continue

        walk(self._p)
        return units

    @staticmethod
    def _unit_boundaries(units: List[etree._Element]) -> List[tuple[int, int, int]]:
        """Return ``(unit_index, start_offset, end_offset)`` for each unit."""
        boundaries: List[tuple[int, int, int]] = []
        offset = 0
        for i, r in enumerate(units):
            # Sum text from both w:t and w:delText direct children
            run_len = 0
            for child in r.xpath("./w:t | ./w:delText"):
                run_len += len(child.text or "")
            boundaries.append((i, offset, offset + run_len))
            offset += run_len
        return boundaries

    @staticmethod
    def _find_unit_at_offset(boundaries: List[tuple[int, int, int]], offset: int) -> tuple[int, int]:
        """Find which unit contains *offset* and the offset within that unit."""
        for unit_idx, unit_start, unit_end in boundaries:
            if unit_start <= offset < unit_end or (offset == unit_end and unit_idx == len(boundaries) - 1):
                return unit_idx, offset - unit_start
        last_idx, last_start, _ = boundaries[-1]
        return last_idx, offset - last_start

    # Back-compat aliases (used by older external code or tests that may import them)
    def _get_run_boundaries(self) -> List[tuple[int, int, int]]:
        """Deprecated: use :meth:`_get_editable_units` + :meth:`_unit_boundaries`."""
        return self._unit_boundaries(self._get_editable_units("text"))

    def _find_run_at_offset(self, boundaries: List[tuple[int, int, int]], offset: int) -> tuple[int, int]:
        """Deprecated: use :meth:`_find_unit_at_offset`."""
        return self._find_unit_at_offset(boundaries, offset)

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
56
57
58
59
60
61
62
63
64
65
66
@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
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
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
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
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, index_mode='text')

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
index_mode IndexMode

Which text view the offsets index into: "text" (default, raw paragraph.text ignoring prior revisions), "accepted" (paragraph.accepted_text, with prior insertions kept and deletions skipped), or "original" (paragraph.original_text, with prior deletions kept and insertions skipped).

'text'

Returns:

Type Description
TrackedDeletion

A TrackedDeletion wrapping the new w:del element.

Raises:

Type Description
ValueError

If offsets are invalid.

Example
# Delete characters from the accepted (post-revision) view
rp.add_tracked_deletion(0, 5, author="Editor", index_mode="accepted")
Source code in docx_revisions/paragraph.py
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
def add_tracked_deletion(
    self, start: int, end: int, author: str = "", revision_id: int | None = None, index_mode: IndexMode = "text"
) -> 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.
        index_mode: Which text view the offsets index into:
            ``"text"`` (default, raw ``paragraph.text`` ignoring prior
            revisions), ``"accepted"`` (``paragraph.accepted_text``, with
            prior insertions kept and deletions skipped), or
            ``"original"`` (``paragraph.original_text``, with prior
            deletions kept and insertions skipped).

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

    Raises:
        ValueError: If offsets are invalid.

    Example:
        ```python
        # Delete characters from the accepted (post-revision) view
        rp.add_tracked_deletion(0, 5, author="Editor", index_mode="accepted")
        ```
    """
    view_text = self._view_text(index_mode)
    if start < 0 or end > len(view_text) or start >= end:
        raise ValueError(f"Invalid offsets: start={start}, end={end} for text of length {len(view_text)}")

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

    units = self._get_editable_units(index_mode)
    if not units:
        raise ValueError("Paragraph has no runs")
    boundaries = self._unit_boundaries(units)

    start_unit_idx, start_offset = self._find_unit_at_offset(boundaries, start)
    end_unit_idx, end_offset = self._find_unit_at_offset(boundaries, end)

    # All units in the [start, end) span must share the same parent for a
    # clean single-parent splice.  This holds when the span is entirely in
    # top-level w:r runs, or entirely inside one w:ins / w:del wrapper.
    start_parent = units[start_unit_idx].getparent()
    end_parent = units[end_unit_idx].getparent()
    if start_parent is None or start_parent is not end_parent:
        raise ValueError(
            "Cannot apply tracked deletion across a revision boundary; "
            "operate on a narrower span entirely inside or outside a prior revision."
        )
    parent = start_parent

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

    def _r_text(r: etree._Element) -> str:
        parts = []
        for child in r.xpath("./w:t | ./w:delText"):
            parts.append(child.text or "")
        return "".join(parts)

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

    start_r = units[start_unit_idx]
    before_text = _r_text(start_r)[:start_offset]
    after_text = _r_text(units[end_unit_idx])[end_offset:]

    index = list(parent).index(start_r)
    for i in range(start_unit_idx, end_unit_idx + 1):
        run_elem = units[i]
        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, index_mode='text')

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
index_mode IndexMode

Which text view to search against: "text" (default, raw paragraph.text), "accepted" (paragraph.accepted_text, includes prior insertions, skips prior deletions), or "original" (paragraph.original_text, includes prior deletions, skips prior insertions).

'text'

Returns:

Type Description
int

The number of replacements made.

Example
# Default: search raw run text
rp.replace_tracked("old", "new", author="Editor")

# Search the accepted view — matches land inside prior w:ins blocks
rp.replace_tracked(
    "old", "new", author="Editor", index_mode="accepted"
)
Source code in docx_revisions/paragraph.py
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
def replace_tracked(
    self,
    search_text: str,
    replace_text: str,
    author: str = "",
    comment: str | None = None,
    index_mode: IndexMode = "text",
) -> 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).
        index_mode: Which text view to search against:
            ``"text"`` (default, raw ``paragraph.text``),
            ``"accepted"`` (``paragraph.accepted_text``, includes prior
            insertions, skips prior deletions), or ``"original"``
            (``paragraph.original_text``, includes prior deletions, skips
            prior insertions).

    Returns:
        The number of replacements made.

    Example:
        ```python
        # Default: search raw run text
        rp.replace_tracked("old", "new", author="Editor")

        # Search the accepted view — matches land inside prior w:ins blocks
        rp.replace_tracked(
            "old", "new", author="Editor", index_mode="accepted"
        )
        ```
    """
    count = 0
    full_text = self._view_text(index_mode)
    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, index_mode=index_mode
        )
        count += 1

    return count

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

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.

''
comment str | None

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

None
index_mode IndexMode

Which text view the offsets index into. See :meth:replace_tracked.

'text'

Raises:

Type Description
ValueError

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

Example
# Offsets are interpreted against accepted_text
rp.replace_tracked_at(
    0, 5, "Hi", author="Editor", index_mode="accepted"
)
Source code in docx_revisions/paragraph.py
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
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
def replace_tracked_at(
    self,
    start: int,
    end: int,
    replace_text: str,
    author: str = "",
    comment: str | None = None,
    index_mode: IndexMode = "text",
) -> 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.
        comment: Optional comment text (requires python-docx comment
            support).
        index_mode: Which text view the offsets index into.  See
            :meth:`replace_tracked`.

    Raises:
        ValueError: If *start* or *end* are out of bounds or *start* >= *end*.

    Example:
        ```python
        # Offsets are interpreted against accepted_text
        rp.replace_tracked_at(
            0, 5, "Hi", author="Editor", index_mode="accepted"
        )
        ```
    """
    view_text = self._view_text(index_mode)
    if start < 0 or end > len(view_text) or start >= end:
        raise ValueError(f"Invalid offsets: start={start}, end={end} for text of length {len(view_text)}")

    units = self._get_editable_units(index_mode)
    if not units:
        raise ValueError("Paragraph has no runs")
    boundaries = self._unit_boundaries(units)

    start_unit_idx, start_offset_in_unit = self._find_unit_at_offset(boundaries, start)
    end_unit_idx, end_offset_in_unit = self._find_unit_at_offset(boundaries, end)

    start_parent = units[start_unit_idx].getparent()
    end_parent = units[end_unit_idx].getparent()
    if start_parent is None or start_parent is not end_parent:
        raise ValueError(
            "Cannot apply tracked replacement across a revision boundary; "
            "operate on a narrower span entirely inside or outside a prior revision."
        )
    parent = start_parent

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

    def _r_text(r: etree._Element) -> str:
        parts = []
        for child in r.xpath("./w:t | ./w:delText"):
            parts.append(child.text or "")
        return "".join(parts)

    if start_unit_idx == end_unit_idx:
        r = units[start_unit_idx]
        text = _r_text(r)
        before_text = text[:start_offset_in_unit] or None
        deleted_text = text[start_offset_in_unit:end_offset_in_unit]
        after_text = text[end_offset_in_unit:] or None
        first_r = r
    else:
        first_r = units[start_unit_idx]
        start_text = _r_text(first_r)
        before_text = start_text[:start_offset_in_unit] or None
        deleted_from_start = start_text[start_offset_in_unit:]

        end_r = units[end_unit_idx]
        end_text = _r_text(end_r)
        deleted_from_end = end_text[:end_offset_in_unit]
        after_text = end_text[end_offset_in_unit:] or None

        middle_deleted = "".join(_r_text(units[i]) for i in range(start_unit_idx + 1, end_unit_idx))
        deleted_text = deleted_from_start + middle_deleted + deleted_from_end

    index = list(parent).index(first_r)

    # Remove spanned runs (only if they share the parent, which the check above guarantees)
    for i in range(start_unit_idx, end_unit_idx + 1):
        run_elem = units[i]
        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
    )