Quick Start¶
This guide covers the essential usage patterns for docx-editor.
Opening Documents¶
from docx_editor import Document
doc = Document.open("contract.docx", author="Legal Team")
# ... make changes ...
doc.save()
doc.close()
The recommended approach is to use a context manager so the temporary workspace is cleaned up automatically:
from docx_editor import Document
with Document.open("contract.docx", author="Legal Team") as doc:
# ... make changes ...
doc.save()
If the source .docx was modified outside this library since the workspace was created, Document.open() raises WorkspaceSyncError instead of silently discarding the workspace. The same error is raised when a leftover workspace holds unsaved changes from a previous session — for example, one that saved to a different path (or whose save failed) and never called close() — since adopting it would carry those edits into the new session. The error message includes the workspace path. Pass force_recreate=True to acknowledge the divergence and re-unpack from the current source (after a crashed script, one Document.discard_workspace("contract.docx") beats carrying the flag on every open — it also clears a lock the dead process left behind):
doc = Document.open("contract.docx", force_recreate=True)
Workspace location¶
Opening a document unpacks it into a workspace directory. By default this lives under the platform user cache — ~/.cache/docx-editor/ on Linux (or $XDG_CACHE_HOME), ~/Library/Caches/docx-editor/ on macOS, %LOCALAPPDATA%\docx-editor\ on Windows — in a subfolder named after a hash of the document's absolute path.
Override the base directory with the DOCX_EDITOR_WORKSPACE_DIR environment variable, or per-call with workspace_dir=:
# Keep the workspace next to the document (relative path → resolved against
# the document's directory); useful for inspecting the unpacked XML.
doc = Document.open("contract.docx", workspace_dir=".docx")
The default location moved from the old .docx/<stem>/ folder next to the document. Leftover .docx/ folders from older versions are no longer used and can be deleted.
Paragraph References¶
Tracked edit methods are scoped to a hash-anchored paragraph reference. Start by listing paragraphs:
with Document.open("contract.docx") as doc:
for paragraph in doc.list_paragraphs():
print(paragraph)
Output looks like:
P1#a7b2| Introduction to the contract...
P2#f3c1| Payment is due within 30 days...
P3#b2c4| Section 5 describes review obligations...
A bare list_paragraphs() call returns at most 200 paragraphs. When more remain, the last entry is a truncation notice (e.g. "... 50 more paragraphs; use start=201 or limit=None") telling you the next start; notice lines always begin with ... and never look like refs. Refs keep their global 1-based index across pages (page 2 starts at P201, not P1):
with Document.open("contract.docx") as doc:
page1 = doc.list_paragraphs() # up to P200, then "... N more" notice
page2 = doc.list_paragraphs(start=201) # next page, per the notice
everything = doc.list_paragraphs(limit=None) # uncapped, never a notice
Use the P{index}#{hash} part as the paragraph= argument. Edit methods return a new paragraph ref after the hash changes, so keep the returned value when chaining edits in the same paragraph.
When the search text appears more than once in the target paragraph, pass occurrence= (0-based, so occurrence=1 is the second match) to replace(), delete(), insert_after(), insert_before(), and add_comment(). Omitting it requires a unique match — otherwise the call raises AmbiguousTextError.
Better still, let a search do that bookkeeping: those five methods (and the EditOperation constructors) accept a SearchResult in place of the target text, taking the paragraph and occurrence from the match.
with Document.open("contract.docx") as doc:
if match := doc.find_text("30 days"): # or find_all(...)[1] for the 2nd
doc.replace(match, "60 days") # no paragraph=, no occurrence=
Check the result first: find_text() returns None when there is no match, and
passing that None as a target raises ValueError (CommentError from
add_comment) rather than quietly doing nothing.
Track Changes¶
Replace Text¶
with Document.open("contract.docx") as doc:
ref = "P2#f3c1"
ref = doc.replace("30 days", "60 days", paragraph=ref)
doc.save()
This creates a tracked deletion of 30 and a tracked insertion of 60. replace() trims the common prefix and suffix and tracks only the differing words, so the unchanged surrounding word (days) is left intact rather than deleted and re-inserted.
Delete Text¶
with Document.open("contract.docx") as doc:
ref = "P5#d4e5"
doc.delete("obsolete clause", paragraph=ref)
doc.save()
Insert Text¶
with Document.open("contract.docx") as doc:
ref = "P3#b2c4"
ref = doc.insert_after("Section 5", " (as amended)", paragraph=ref)
doc.insert_before("review", "legal ", paragraph=ref)
doc.save()
Rewrite a Paragraph¶
with Document.open("contract.docx") as doc:
ref = "P2#f3c1"
doc.rewrite_paragraph(ref, "Payment is due within 60 days after invoice receipt.")
doc.save()
rewrite_paragraph() compares the current paragraph text to the desired text and creates tracked changes from the diff.
Batch Edits¶
from docx_editor import Document, EditOperation
with Document.open("contract.docx") as doc:
new_refs = doc.batch_edit([
EditOperation.replace("30 days", "60 days", paragraph="P2#f3c1"),
EditOperation.delete("obsolete clause", paragraph="P5#d4e5"),
EditOperation.insert_after("Section 5", " (as amended)", paragraph="P3#b2c4"),
])
print(new_refs)
doc.save()
To validate a batch before applying it, pass dry_run=True. This checks every operation up front and returns a list of EditValidationResult objects (each with valid and error) without modifying the document:
with Document.open("contract.docx") as doc:
results = doc.batch_edit([
EditOperation.replace("30 days", "60 days", paragraph="P2#f3c1"),
EditOperation.delete("obsolete clause", paragraph="P5#d4e5"),
], dry_run=True)
for r in results:
print(r.index, r.valid, r.error)
Comments¶
Add a Comment¶
comment_id = doc.add_comment("Section 5", "Please review this section")
print(f"Created comment with ID: {comment_id}")
Reply to a Comment¶
reply_id = doc.reply_to_comment(comment_id=0, reply="I agree with this change")
List Comments¶
comments = doc.list_comments()
for comment in comments:
print(f"Comment {comment.id}: {comment.text}")
print(f" Author: {comment.author}")
print(f" Resolved: {comment.resolved}")
for reply in comment.replies:
print(f" Reply: {reply.text}")
Filter by author:
my_comments = doc.list_comments(author="Legal Team")
Resolve or Delete a Comment¶
doc.resolve_comment(comment_id=0)
doc.delete_comment(comment_id=0)
Revision Management¶
List Revisions¶
revisions = doc.list_revisions()
for rev in revisions:
print(f"{rev.type}: '{rev.text}' by {rev.author}")
Filter by author:
my_revisions = doc.list_revisions(author="Legal Team")
Accept or Reject a Revision¶
# Accept keeps an insertion's content, permanently removes a deletion's,
# keeps a move_to's text at its destination, removes a move_from's text
# from its source, and drops a property_change's record of the old
# properties.
doc.accept_revision(revision_id=1)
# Reject removes an insertion's content, restores a deletion's, removes a
# move_to's text from its destination (including any edit of your own made
# inside it — edit after resolving the move), restores a move_from's text at
# its source, and puts a property_change's recorded properties back.
doc.reject_revision(revision_id=1)
The two halves of a move share an inferred changeset; resolve them together
(accept_all(), or accept_changeset() on that changeset) so the text ends up
in exactly one place.
Accept or Reject a Group or Changeset¶
Revisions form three nested tiers: an individual revision, the group of revisions from one logical edit (e.g. a single replace() or rewrite_paragraph()), and the changeset of all groups from one whole call (batch_edit() / batch_rewrite()). Accept or reject a whole tier in one call:
from docx_editor import EditOperation
# Every edit method returns an EditResult carrying group_id and changeset_id.
result = doc.rewrite_paragraph("P3#b2c4", "Section 5 has been revised in full.")
doc.accept_group(result.group_id) # resolve one logical edit as a unit
# One batch_edit is one changeset that may span several groups (edit paragraphs
# the rewrite above didn't touch, so their refs are still fresh):
new_refs = doc.batch_edit([
EditOperation.replace("30 days", "60 days", paragraph="P2#f3c1"),
EditOperation.delete("obsolete clause", paragraph="P5#d4e5"),
])
doc.reject_changeset(new_refs[0].changeset_id) # undo the whole batch in one call
Every Revision also carries group_id/group_source and changeset_id/changeset_source — the source is "recorded" for edits made in this session and "inferred" for revisions reconstructed when an existing document is reopened. Group and changeset ids are renumbered each time the document is opened, so always resolve them with an id from the current session's EditResult or list_revisions().
Explain a Redline¶
Pass note= to replace, delete, insert_after, insert_before,
rewrite_paragraph, or any EditOperation of a batch_edit, and the rationale
is anchored as a comment on exactly the revisions that edit created — the reviewer sees the change and the
reason for it together:
result = doc.replace(
"30 days", "60 days",
paragraph="P2#f3c1",
note="Aligns payment terms with the master agreement (§4.2).",
)
print(result.comment_id) # 0 — an ordinary comment id you can reply to
doc.reject_group(result.group_id) # the redline and its rationale go together
The note comment is revision-scoped: it is deleted as soon as the revisions
it explains are resolved — on accept_all() just as on a reject, so a pipeline
that accepts everything ships a clean document rather than one full of agent
rationale. Operations of one batch_edit sharing the same note text share one
comment, whose range follows a redline that is still pending when only part of
it is resolved. For rationale that must outlive the revision, use
add_comment() instead. See Rationale notes.
Accept or Reject All¶
count = doc.accept_all()
print(f"Accepted {count} revisions")
count = doc.reject_all(author="OtherUser")
print(f"Rejected {count} revisions")
These resolve insertions, deletions, content moves (both halves together) and
paragraph-property changes. If the document also holds other revision types —
run-format changes, table revisions — the returned count carries them as
.unhandled; check it before reporting a document as fully adjudicated:
result = doc.accept_all()
if result.unhandled:
print(f"Still pending: {result.unhandled_types}")
for row in doc.list_unhandled_revisions():
print(f" {row.tag} by {row.author}")
Saving and Closing¶
doc.save() # Save to original path
doc.save("contract_v2.docx") # Save to a new path
doc.close() # Delete workspace
doc.close(cleanup=False) # Keep workspace (in the cache dir) for inspection
Complete Example¶
from docx_editor import Document
with Document.open("contract.docx", author="Legal Review") as doc:
for paragraph in doc.list_paragraphs():
print(paragraph)
payment_ref = doc.replace("30 days", "60 days", paragraph="P2#f3c1")
doc.insert_after("payment terms", " (net 60)", paragraph=payment_ref)
doc.delete("penalty clause", paragraph="P7#a10b")
doc.add_comment("Section 5", "Needs legal review")
for rev in doc.list_revisions():
print(f"{rev.type}: {rev.text}")
doc.accept_all(author="Senior Counsel")
doc.save()