You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Commit 55e9cfa
Browse filesBrowse the repository at this point in the historyBrowse files
Docs-only sync of README, docs/{api,index,quickstart}, and skills/docx/SKILL to the current API surface (changeset tier, dry_run, occurrence, count_matches, session status, new exception sections). Every claim verified against source; multi-reviewer + red-team pass fixed a stale-ref changeset example, an OOXML→XML accuracy nit, and batch_edit result-naming consistency.
Copy file name to clipboardExpand all lines: README.md
+9-2Lines changed: 9 additions & 2 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -23,8 +23,8 @@ Pure Python library for Word document track changes and comments, without requir
23
23
-**Cross-Boundary Editing**: Find and replace text spanning multiple XML elements and revision boundaries
24
24
-**Mixed-State Editing**: Atomic decomposition for text spanning `<w:ins>`/`<w:del>` boundaries
25
25
-**Comments**: Add, reply, resolve, and delete comments
26
-
-**Revision Management**: List, accept, and reject tracked changes
27
-
-**Session Mode**: Optional persistent kernel (`docx-session start/exec/eval/stop`) keeps documents open across many small commands — ideal for AI agents (`pip install "docx-editor[session]"`)
26
+
-**Revision Management**: List, accept, and reject tracked changes at three granularities — individual revisions, groups (one logical edit), and changesets (one whole `batch_edit`/`batch_rewrite` call); `EditResult` and `Revision` objects carry `group_id` and `changeset_id`
27
+
-**Session Mode**: Optional persistent kernel (`docx-session start/exec/eval/status/stop`) keeps documents open across many small commands — ideal for AI agents (`pip install "docx-editor[session]"`)
28
28
-**Cross-Platform**: Works on Linux, macOS, and Windows
29
29
-**No Dependencies**: Only requires `defusedxml` for secure XML parsing
30
30
@@ -89,6 +89,9 @@ with Document.open("contract.docx", author=author) as doc:
89
89
doc.replace("net", "gross", paragraph=r) # chain without list_paragraphs()
Copy file name to clipboardExpand all lines: docs/api.md
+56-5Lines changed: 56 additions & 5 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -134,7 +134,7 @@ Return one paragraph as a structured `ParagraphInfo` record — the single-item
134
134
135
135
**Returns:**`ParagraphInfo` (index, hash-anchored ref, full untruncated text) for the paragraph at `index`.
136
136
137
-
**Raises:**[`ParagraphIndexError`](#exceptions) if `index` is out of range (`< 1` or greater than `paragraph_count()`).
137
+
**Raises:**[`ParagraphIndexError`](#paragraphindexerror) if `index` is out of range (`< 1` or greater than `paragraph_count()`).
138
138
139
139
**Example:**
140
140
@@ -154,7 +154,7 @@ Return the paragraphs surrounding `ref`, in document order — the "show me the
154
154
155
155
**Returns:** List of `ParagraphInfo` records (index, ref, full text) — identical to what `list_paragraphs_structured()` would emit for the same span.
156
156
157
-
**Raises:**`ValueError` if `ref` is malformed or `window < 0`; [`ParagraphIndexError`](#exceptions) / [`HashMismatchError`](#exceptions) for an out-of-range or stale `ref`.
157
+
**Raises:**`ValueError` if `ref` is malformed or `window < 0`; [`ParagraphIndexError`](#paragraphindexerror) / [`HashMismatchError`](#hashmismatcherror) for an out-of-range or stale `ref`.
158
158
159
159
**Example:**
160
160
@@ -246,7 +246,7 @@ Find text in the document, including text spanning XML element boundaries.
246
246
247
247
**Returns:**[`SearchResult`](#searchresult), or None if not found
248
248
249
-
**Raises:**`ValueError` if `text` is empty or `paragraph` is malformed; [`ParagraphIndexError`](#exceptions) / [`HashMismatchError`](#exceptions) for an out-of-range or stale `paragraph`.
249
+
**Raises:**`ValueError` if `text` is empty or `paragraph` is malformed; [`ParagraphIndexError`](#paragraphindexerror) / [`HashMismatchError`](#hashmismatcherror) for an out-of-range or stale `paragraph`.
250
250
251
251
**Example:**
252
252
@@ -275,7 +275,7 @@ what a follow-up edit needs.
275
275
276
276
**Returns:** list of [`SearchResult`](#searchresult), empty when nothing matches (no-match is not an error for an enumeration API)
277
277
278
-
**Raises:**`ValueError` if `text` is empty or `paragraph` is malformed; [`ParagraphIndexError`](#exceptions) / [`HashMismatchError`](#exceptions) for an out-of-range or stale `paragraph`.
278
+
**Raises:**`ValueError` if `text` is empty or `paragraph` is malformed; [`ParagraphIndexError`](#paragraphindexerror) / [`HashMismatchError`](#hashmismatcherror) for an out-of-range or stale `paragraph`.
279
279
280
280
**Example:**
281
281
@@ -585,7 +585,7 @@ List all tracked changes in the document.
585
585
586
586
**Returns:** List of Revision objects
587
587
588
-
**Raises:** The `paragraph` ref is validated exactly like in the edit methods — `ValueError` (malformed ref), [`ParagraphIndexError`](#exceptions) (index out of range), [`HashMismatchError`](#exceptions) (stale hash).
588
+
**Raises:** The `paragraph` ref is validated exactly like in the edit methods — `ValueError` (malformed ref), [`ParagraphIndexError`](#paragraphindexerror) (index out of range), [`HashMismatchError`](#hashmismatcherror) (stale hash).
589
589
590
590
**Example:**
591
591
@@ -916,6 +916,10 @@ from docx_editor import Revision
916
916
|`author`| str | The revision author |
917
917
|`date`| datetime or None | When the revision was made |
918
918
|`text`| str | The inserted or deleted text |
919
+
|`paragraph_ref`| str or None | Hash-anchored reference (`"P{i}#{hash}"`) of the containing paragraph; None when the revision sits outside any `<w:p>` (e.g. a `<w:trPr>` row marker) |
920
+
|`occurrence`| int or None | 0-based occurrence index of `text` within the containing paragraph, counted in the view where the revision's text lives (the visible view for insertions, the original pre-revision view for deletions). For insertions it plugs directly into the `occurrence=` parameter of the anchor APIs; None whenever targeting-by-text does not apply (empty text, a host insertion partly consumed by a nested deletion, or a nested deletion) |
921
+
|`nested_under`| int or None | id of the nearest enclosing revision (e.g. a foreign deletion inside another author's pending insertion), else None |
922
+
|`contains_ids`| tuple[int, ...]| ids of the revisions nested inside this one, in document order (empty tuple when none) |
919
923
|`group_id`| int or None | Revision group this revision belongs to (see [`accept_group()`](#accept_groupgroup_id)): recorded for this session's edits, inferred by reconstruction for revisions already in the file; None only for ungroupable revisions (missing author/date, outside any paragraph, duplicated id, or a mid-session split half of a foreign insertion) |
920
924
|`group_source`| str or None | Provenance of `group_id`: `"recorded"` (created through this open Document) or `"inferred"` (reconstructed at parse time from same-paragraph contiguity + identical author and date); None iff `group_id` is None |
921
925
|`changeset_id`| int or None | Changeset (one whole call) this revision's group belongs to (see [`accept_changeset()`](#accept_changesetchangeset_id) / [`reject_changeset()`](#reject_changesetchangeset_id)) — the `(author, date)` class over groups; None iff `group_id` is None |
@@ -1161,6 +1165,41 @@ except AmbiguousTextError as e:
1161
1165
occurrence=r.paragraph_occurrence)
1162
1166
```
1163
1167
1168
+
### `HashMismatchError`
1169
+
1170
+
Raised when a `paragraph` ref's hash no longer matches the paragraph's current
1171
+
content — the paragraph changed (usually by an earlier edit) since the ref was
1172
+
listed, so the ref is stale. Structured fields: `paragraph_index` (1-based),
1173
+
`expected_hash` (the hash in the stale ref), `actual_hash` (the paragraph's
1174
+
current hash), and `paragraph_preview` (the current text). Recover by retrying
1175
+
with the fresh ref `P{paragraph_index}#{actual_hash}`, or re-list paragraphs.
1176
+
1177
+
```python
1178
+
from docx_editor.exceptions import HashMismatchError
Raised when a paragraph index is out of range — `< 1` or greater than
1189
+
`paragraph_count()` (for example `get_paragraph(0)`). Structured fields:
1190
+
`index` (the offending index) and `total_paragraphs` (how many the document
1191
+
has). Clamp to `1..total_paragraphs` and retry, or call `list_paragraphs()`
1192
+
to pick a valid ref.
1193
+
1194
+
```python
1195
+
from docx_editor.exceptions import ParagraphIndexError
1196
+
1197
+
try:
1198
+
para = doc.get_paragraph(index)
1199
+
except ParagraphIndexError as e:
1200
+
para = doc.get_paragraph(max(1, min(e.index, e.total_paragraphs)))
1201
+
```
1202
+
1164
1203
### `BatchOperationError`
1165
1204
1166
1205
The only exception `batch_edit()` / `batch_rewrite()` raise for a failing
@@ -1214,6 +1253,18 @@ Raised by `Document.open()` when the source file does not exist. Structured fiel
1214
1253
from docx_editor.exceptions import DocumentNotFoundError
1215
1254
```
1216
1255
1256
+
### `InvalidDocumentError`
1257
+
1258
+
Raised by `Document.open()` when the source path is not a valid `.docx` — wrong
1259
+
suffix, a directory, an empty/truncated file, not a ZIP, missing
1260
+
`word/document.xml`, or malformed XML. Structured field: `path` (the source
1261
+
path). Not an in-loop retry: the message names which check failed; fix or
1262
+
re-export the input file.
1263
+
1264
+
```python
1265
+
from docx_editor.exceptions import InvalidDocumentError
1266
+
```
1267
+
1217
1268
### `DocumentClosedError`
1218
1269
1219
1270
Raised when any operation is attempted on a `Document` after `close()`. Closing discards the workspace (unless `cleanup=False`), so the object cannot keep serving reads or edits — reopen the source to continue. Structured field: `path` (the source path of the closed document).
Copy file name to clipboardExpand all lines: docs/quickstart.md
+37-1Lines changed: 37 additions & 1 deletion
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -72,6 +72,8 @@ with Document.open("contract.docx") as doc:
72
72
73
73
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.
74
74
75
+
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`.
76
+
75
77
## Track Changes
76
78
77
79
### Replace Text
@@ -83,7 +85,7 @@ with Document.open("contract.docx") as doc:
83
85
doc.save()
84
86
```
85
87
86
-
This creates a tracked deletion of `30 days` and a tracked insertion of `60days`.
88
+
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.
87
89
88
90
### Delete Text
89
91
@@ -130,6 +132,18 @@ with Document.open("contract.docx") as doc:
130
132
doc.save()
131
133
```
132
134
135
+
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:
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:
219
+
220
+
```python
221
+
from docx_editor import EditOperation
222
+
223
+
# Every edit method returns an EditResult carrying group_id and changeset_id.
224
+
result = doc.rewrite_paragraph("P3#b2c4", "Section 5 has been revised in full.")
225
+
doc.accept_group(result.group_id) # resolve one logical edit as a unit
226
+
227
+
# One batch_edit is one changeset that may span several groups (edit paragraphs
228
+
# the rewrite above didn't touch, so their refs are still fresh):
doc.reject_changeset(new_refs[0].changeset_id) # undo the whole batch in one call
234
+
```
235
+
236
+
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()`.
0 commit comments