#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# Timestamp: 2026-06-02 00:00:00
# File: src/scitex_msword/track_changes.py
#
# Part of scitex-msword (AGPL-3.0-only). See LICENSE at the repo root.
"""
Track-Changes (revision) utilities for python-docx Documents.
This module surfaces the OOXML revision primitives so agents can:
1. Toggle Word's "Track Changes" switch (``<w:trackChanges/>`` in
``word/settings.xml``) via :func:`enable_track_changes`.
2. Wrap agent edits as ``<w:ins>`` / ``<w:del>`` revisions
(:func:`wrap_as_tracked_insertion`, :func:`wrap_as_tracked_deletion`).
3. Extract all tracked changes (:func:`extract_tracked_changes`).
4. Accept / reject all changes in bulk
(:func:`accept_all_tracked_changes`, :func:`reject_all_tracked_changes`).
OOXML refs: ``w:trackChanges`` (ECMA-376 §17.15.1.86), ``w:ins``
(§17.13.5.18), ``w:del`` (§17.13.5.14), ``w:delText`` (§17.13.5.15).
"""
from __future__ import annotations
from datetime import datetime, timezone
from typing import Any, List, Optional, Sequence
try:
from docx.document import Document as DocxDocument # type: ignore[import-untyped]
from docx.oxml.ns import qn # type: ignore[import-untyped]
from docx.text.paragraph import Paragraph # type: ignore[import-untyped]
from docx.text.run import Run # type: ignore[import-untyped]
from lxml import etree
DOCX_AVAILABLE = True
_DOCX_IMPORT_ERROR: Optional[Exception] = None
except ImportError as exc: # pragma: no cover
DOCX_AVAILABLE = False
_DOCX_IMPORT_ERROR = exc
DocxDocument = None # type: ignore[assignment,misc]
Paragraph = None # type: ignore[assignment,misc]
Run = None # type: ignore[assignment,misc]
qn = None # type: ignore[assignment]
etree = None # type: ignore[assignment]
_W_NS = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"
def _ensure_docx_available() -> None:
if not DOCX_AVAILABLE:
raise ImportError(
"python-docx (and lxml) are required for scitex_msword.track_changes. "
"Install via `pip install python-docx`."
) from _DOCX_IMPORT_ERROR
# Internal OOXML helpers live in ``_track_changes_helpers`` so this
# module stays focused on the public API surface. The names are
# re-imported verbatim so existing call-sites need no changes.
from ._track_changes_helpers import (
_make_w_element,
_next_revision_id,
_now_iso,
_resolve_runs,
_scan_max_revision_id,
_settings_element,
_wrap_runs_in_element,
)
# ---------------------------------------------------------------------------
# API 1: enable_track_changes / is_track_changes_enabled
# ---------------------------------------------------------------------------
[docs]
def enable_track_changes(
document: "DocxDocument",
enabled: bool = True,
*,
emit_doc_protection_echo: bool = True,
) -> "DocxDocument":
"""Toggle Word's Track Changes switch on the document.
``enabled=True`` writes ``<w:trackRevisions/>`` (ECMA-376
§17.15.1.92 — the actual toggle in ``CT_Settings``). When
``emit_doc_protection_echo=True`` (default) also writes the
matching ``<w:documentProtection w:edit="trackedChanges"
w:enforcement="0"/>`` to mirror desktop Word's emit so the
Restrict-Editing pane reads correctly and so some Word
2016/2019 enterprise builds preserve TC across reopen.
``enabled=False`` removes both elements.
v0.3.1 fix: ≤v0.3.0 emitted ``<w:trackChanges/>`` (``CT_HdrFtr``
§17.10.1.84) which desktop Word silently ignores in
``CT_Settings`` — TC was never actually toggled. Re-save older
sxm-produced files with this version.
"""
_ensure_docx_available()
from ._settings_order import (
ensure_document_protection_for_tracked_changes,
insert_in_settings_order,
)
settings_el = _settings_element(document)
# Strip any stale ``<w:trackChanges/>`` left behind by ≤v0.3.0
# (which emitted the wrong element name — see the docstring). Both
# the enabled-True and enabled-False branches drop it: the writer
# is now idempotent, and a real-world input that ever went through
# an older sxm version comes out byte-clean (proj-grant v40→v42
# dogfood).
for stale in settings_el.findall(qn("w:trackChanges")):
settings_el.remove(stale)
existing = settings_el.findall(qn("w:trackRevisions"))
if enabled:
if not existing:
insert_in_settings_order(
settings_el,
_make_w_element("trackRevisions"),
"trackRevisions",
)
else:
for dup in existing[1:]:
settings_el.remove(dup)
if emit_doc_protection_echo:
ensure_document_protection_for_tracked_changes(
settings_el, _make_w_element
)
else:
for el in existing:
settings_el.remove(el)
for el in settings_el.findall(qn("w:documentProtection")):
if el.get(qn("w:edit")) == "trackedChanges":
settings_el.remove(el)
return document
[docs]
def save_with_track_changes_on(
document: "DocxDocument",
path,
*,
track_revisions: bool = True,
emit_doc_protection_echo: bool = True,
) -> "DocxDocument":
"""Save a Document, default-correct for Word Track Changes.
Default kwargs write the Word-matching recipe (``trackRevisions``
+ ``documentProtection`` ``enforcement=0``) — desktop Word
honours this natively. Pass ``track_revisions=False`` for the
clean-export step that strips TC; pair with
``emit_doc_protection_echo=False`` if you want ``trackRevisions``
without the protection-pane echo. See :func:`enable_track_changes`.
"""
_ensure_docx_available()
enable_track_changes(
document,
enabled=track_revisions,
emit_doc_protection_echo=emit_doc_protection_echo,
)
document.save(str(path))
return document
[docs]
def is_track_changes_enabled(document: "DocxDocument") -> bool:
"""Return True iff ``<w:trackRevisions/>`` is present in settings.xml.
v0.3.1 fix: previous releases pattern-matched against
``<w:trackChanges/>`` (a different element entirely — see
:func:`enable_track_changes`) and so returned False for documents
created by Word itself or by sxm v0.3.1+. Now reads the correct
ECMA-376 §17.15.1.92 ``<w:trackRevisions/>`` toggle.
"""
_ensure_docx_available()
return (
_settings_element(document).find(qn("w:trackRevisions"))
is not None
)
# ---------------------------------------------------------------------------
# API 2: wrap_as_tracked_insertion
# ---------------------------------------------------------------------------
[docs]
def wrap_as_tracked_insertion(
paragraph: "Paragraph",
runs: Sequence[Any],
author: str = "agent",
date: Optional[str] = None,
w_id: Optional[int] = None,
) -> List[Any]:
"""
Wrap the given runs of ``paragraph`` in ``<w:ins>`` revision blocks.
Word renders the wrapped content as "inserted by <author>" and
surfaces it as an accept/reject-able revision.
Parameters
----------
paragraph : docx.text.paragraph.Paragraph
Paragraph that owns the runs to wrap.
runs : sequence of Run or int
Runs to wrap, by Run object or by 0-based index.
author : str, default "agent"
Recorded in ``w:author``.
date : str, optional
ISO-8601 string for ``w:date``; defaults to ``now(UTC)``.
w_id : int, optional
Explicit revision id; auto-assigned (max+1) when ``None``.
Returns
-------
list
Newly created ``<w:ins>`` lxml elements.
"""
_ensure_docx_available()
target_runs = _resolve_runs(paragraph, runs)
if not target_runs:
return []
attrs = {
"id": _next_revision_id(paragraph, w_id),
"author": author,
"date": date or _now_iso(),
}
return _wrap_runs_in_element(paragraph, target_runs, "ins", attrs)
# ---------------------------------------------------------------------------
# API 3: wrap_as_tracked_deletion
# ---------------------------------------------------------------------------
[docs]
def wrap_as_tracked_deletion(
paragraph: "Paragraph",
runs: Sequence[Any],
author: str = "agent",
date: Optional[str] = None,
w_id: Optional[int] = None,
) -> List[Any]:
"""
Wrap the given runs of ``paragraph`` in ``<w:del>`` revision blocks.
Each wrapped run's ``<w:t>`` children are also retagged as
``<w:delText>`` so Word renders the deletion with strike-through.
Parameters
----------
paragraph : docx.text.paragraph.Paragraph
Paragraph that owns the runs to wrap.
runs : sequence of Run or int
Runs to wrap, by Run object or by 0-based index.
author : str, default "agent"
Recorded in ``w:author``.
date : str, optional
ISO-8601 string for ``w:date``; defaults to ``now(UTC)``.
w_id : int, optional
Explicit revision id; auto-assigned (max+1) when ``None``.
Returns
-------
list
Newly created ``<w:del>`` lxml elements.
"""
_ensure_docx_available()
target_runs = _resolve_runs(paragraph, runs)
if not target_runs:
return []
attrs = {
"id": _next_revision_id(paragraph, w_id),
"author": author,
"date": date or _now_iso(),
}
wrappers = _wrap_runs_in_element(paragraph, target_runs, "del", attrs)
t_qn = qn("w:t")
delText_qn = qn("w:delText")
for wrapper in wrappers:
for t in list(wrapper.iter(t_qn)):
t.tag = delText_qn
return wrappers
# ---------------------------------------------------------------------------
# API 4: extract_tracked_changes
# ---------------------------------------------------------------------------
# ---------------------------------------------------------------------------
# API 5: accept_all / reject_all
# ---------------------------------------------------------------------------
def _unwrap_element(elem) -> None:
"""Replace ``elem`` in its parent with its own children, in order."""
parent = elem.getparent()
if parent is None:
return
idx = parent.index(elem)
for offset, child in enumerate(list(elem)):
elem.remove(child)
parent.insert(idx + offset, child)
parent.remove(elem)
[docs]
def accept_all_tracked_changes(document: "DocxDocument") -> "DocxDocument":
"""
Accept all tracked changes — equivalent to Word's "Accept All".
``<w:ins>`` wrappers are unwrapped (content remains); ``<w:del>``
wrappers and their contents are removed.
Parameters
----------
document : docx.Document
The Document to mutate in place.
Returns
-------
docx.Document
The same Document, mutated.
"""
_ensure_docx_available()
body = document.element.body
ins_tag = f"{{{_W_NS}}}ins"
del_tag = f"{{{_W_NS}}}del"
for el in [e for e in body.iter() if e.tag in (ins_tag, del_tag)]:
parent = el.getparent()
if parent is None:
continue
if el.tag == ins_tag:
_unwrap_element(el)
else:
parent.remove(el)
return document
[docs]
def reject_all_tracked_changes(document: "DocxDocument") -> "DocxDocument":
"""
Reject all tracked changes — equivalent to Word's "Reject All".
``<w:ins>`` wrappers and contents are removed; ``<w:del>`` wrappers
are unwrapped and their ``<w:delText>`` children retagged back to
``<w:t>`` so the original text is restored.
Parameters
----------
document : docx.Document
The Document to mutate in place.
Returns
-------
docx.Document
The same Document, mutated.
"""
_ensure_docx_available()
body = document.element.body
ins_tag = f"{{{_W_NS}}}ins"
del_tag = f"{{{_W_NS}}}del"
t_tag = qn("w:t")
delText_tag = f"{{{_W_NS}}}delText"
for el in [e for e in body.iter() if e.tag in (ins_tag, del_tag)]:
parent = el.getparent()
if parent is None:
continue
if el.tag == ins_tag:
parent.remove(el)
else:
for dt in list(el.iter(delText_tag)):
dt.tag = t_tag
_unwrap_element(el)
return document
__all__ = [
"enable_track_changes",
"is_track_changes_enabled",
"wrap_as_tracked_insertion",
"wrap_as_tracked_deletion",
"extract_tracked_changes",
"accept_all_tracked_changes",
"reject_all_tracked_changes",
]