Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,7 @@ with Document.open("reviewed.docx", author="Editor") as doc:

# Find text across element boundaries
match = doc.find_text("Aim: To")
if match and match.spans_boundary:
if match and match.spans_revision:
print("Text spans a revision boundary")

# Replace works even across revision boundaries
Expand All @@ -147,8 +147,8 @@ from docx_editor import Document, EditOperation
with Document.open("contract.docx", author="Editor") as doc:
refs = doc.list_paragraphs()
doc.batch_edit([
EditOperation(action="replace", find="old", replace_with="new", paragraph="P2#f3c1"),
EditOperation(action="delete", text="remove this", paragraph="P5#d4e5"),
EditOperation.replace("old", "new", paragraph="P2#f3c1"),
EditOperation.delete("remove this", paragraph="P5#d4e5"),
])
doc.save()
```
Expand Down
129 changes: 123 additions & 6 deletions docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -191,16 +191,22 @@ Find text in the document, including text spanning XML element boundaries.
**Parameters:**

- `text` (str): Text to search for
- `occurrence` (int): Which occurrence to return. Defaults to 0.
- `occurrence` (int): Which occurrence to return, counted document-wide. Defaults to 0.

**Returns:** Match information, or None if not found
**Returns:** [`SearchResult`](#searchresult), or None if not found

**Example:**

```python
match = doc.find_text("Aim: To")
if match and match.spans_boundary:
print("Text spans multiple XML contexts")
if match:
if match.spans_revision:
print("Text spans a tracked-revision boundary")
# The ref + in-paragraph occurrence pin the exact match for a follow-up edit
doc.replace(
"Aim: To", "Goal: To",
paragraph=match.paragraph_ref, occurrence=match.paragraph_occurrence,
)
```

#### `count_matches(text)`
Expand Down Expand Up @@ -329,11 +335,14 @@ Apply multiple edits after validating paragraph hashes up front.
from docx_editor import EditOperation

new_refs = doc.batch_edit([
EditOperation(action="replace", find="old", replace_with="new", paragraph="P2#f3c1"),
EditOperation(action="delete", text="remove this", paragraph="P5#d4e5"),
EditOperation.replace("old", "new", paragraph="P2#f3c1"),
EditOperation.delete("remove this", paragraph="P5#d4e5"),
])
```

Prefer the typed constructors ([`EditOperation`](#editoperation)) — they validate
arguments when the operation is built, so mistakes fail fast instead of at apply time.

#### `batch_rewrite(rewrites)`

Rewrite multiple paragraphs after validating paragraph hashes up front.
Expand Down Expand Up @@ -632,6 +641,114 @@ for rev in revisions:

---

## EditOperation

A single edit operation for `batch_edit()`. Build operations with the typed
constructors below — they validate arguments at construction time with the same
rules `batch_edit()` applies, so mistakes surface immediately. The raw
`EditOperation(action=..., ...)` form remains supported.

```python
from docx_editor import EditOperation
```

### Constructors

#### `EditOperation.replace(find, replace_with, *, paragraph, occurrence=0)`

- `find` (str): Text to find and replace. Must be non-empty.
- `replace_with` (str): Replacement text. Empty string is allowed (replacing
with nothing is a valid tracked deletion).

#### `EditOperation.delete(text, *, paragraph, occurrence=0)`

- `text` (str): Text to mark as deleted. Must be non-empty.

#### `EditOperation.insert_after(anchor, text, *, paragraph, occurrence=0)`

#### `EditOperation.insert_before(anchor, text, *, paragraph, occurrence=0)`

- `anchor` (str): Text to find as insertion point. Must be non-empty.
- `text` (str): Text to insert.

All constructors also take:

- `paragraph` (str): Paragraph reference from `list_paragraphs()`, such as `P2#f3c1`
- `occurrence` (int): Which occurrence within the paragraph. Defaults to 0. Must be >= 0.

**Raises:** `ValueError` at construction time if the paragraph ref is malformed,
`occurrence` is negative, a search-target argument (`find`, delete `text`,
`anchor`) is empty, or a payload argument (`replace_with`, insert `text`) is
`None` — payloads may be empty strings, search targets may not. Each
signature mirrors the corresponding `Document` method 1:1, so
`doc.replace(...)` translates mechanically to `EditOperation.replace(...)`.

### Example

```python
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="P7#b1c2"),
])
```

---

## SearchResult

The result of `Document.find_text()`. Carries no XML/DOM internals.

```python
from docx_editor import SearchResult
```

### Attributes

| Attribute | Type | Description |
|-----------|------|-------------|
| `start` | int | Start offset of the match in the containing paragraph's visible text |
| `end` | int | Exclusive end offset, same coordinate space |
| `text` | str | The matched text |
| `paragraph_ref` | str | Hash-anchored ref like `P3#a7b2`, usable as the `paragraph=` argument of follow-up edits |
| `paragraph_occurrence` | int | Occurrence index of this match within its paragraph, usable as the `occurrence=` argument of follow-up edits |
| `spans_revision` | bool | True if the match crosses a tracked-revision boundary |

`start`/`end` are offsets within the matched paragraph's visible text, **not**
document-wide offsets. Coordinate systems differ between search and edit:
`find_text`'s `occurrence` counts matches document-wide, while edit methods
count within one paragraph — `paragraph_occurrence` bridges the two, so always
pass it alongside `paragraph_ref` when chaining into an edit. `paragraph_ref`
is computed at search time and — like refs from `list_paragraphs()` — goes
stale once that paragraph is edited.

### Example

```python
match = doc.find_text("30 days")
if match:
doc.replace(
"30 days", "60 days",
paragraph=match.paragraph_ref, occurrence=match.paragraph_occurrence,
)
```

---

## Deprecated internals

The text-map machinery (`TextMap`, `TextMapMatch`, `TextPosition`,
`build_text_map`, `find_in_text_map`) is no longer part of the public API:
these names have been removed from `docx_editor.__all__`, and accessing them
via the top-level package emits a `DeprecationWarning`. They will be removed
from the package namespace in the next release.

Use `Document.find_text()` / [`SearchResult`](#searchresult) instead. If you
genuinely need the internals (raw DOM positions), import them from
`docx_editor.xml_editor`.

---

## Exceptions

### `TextNotFoundError`
Expand Down
6 changes: 3 additions & 3 deletions docs/quickstart.md
Original file line number Diff line number Diff line change
Expand Up @@ -124,9 +124,9 @@ from docx_editor import Document, EditOperation

with Document.open("contract.docx") as doc:
new_refs = doc.batch_edit([
EditOperation(action="replace", find="30 days", replace_with="60 days", paragraph="P2#f3c1"),
EditOperation(action="delete", text="obsolete clause", paragraph="P5#d4e5"),
EditOperation(action="insert_after", anchor="Section 5", text=" (as amended)", paragraph="P3#b2c4"),
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()
Expand Down
38 changes: 26 additions & 12 deletions docx_editor/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,18 +54,13 @@
WorkspaceSyncError,
XMLError,
)
from .track_changes import EditOperation, EditValidationResult, Revision
from .track_changes import EditOperation, EditValidationResult, Revision, SearchResult
from .xml_editor import (
ParagraphInfo,
ParagraphLocation,
ParagraphRef,
TableCell,
TextMap,
TextMapMatch,
TextPosition,
build_text_map,
compute_paragraph_hash,
find_in_text_map,
)

__all__ = [
Expand All @@ -74,6 +69,7 @@
"EditOperation",
"EditValidationResult",
"Revision",
"SearchResult",
"Comment",
# Exceptions
"DocxEditError",
Expand All @@ -92,16 +88,34 @@
"HashMismatchError",
"ParagraphIndexError",
"BatchOperationError",
# Text map & paragraph refs
"TextPosition",
"TextMap",
"TextMapMatch",
# Paragraph refs
"ParagraphInfo",
"ParagraphRef",
"build_text_map",
"compute_paragraph_hash",
"find_in_text_map",
# Paragraph location
"ParagraphLocation",
"TableCell",
]

# Internal text-map machinery removed from the public API (see SearchResult /
# Document.find_text instead). Accessing these via the top-level package warns
# for one release before removal; the real objects remain importable from
# docx_editor.xml_editor.
_DEPRECATED_INTERNALS = frozenset({"TextMap", "TextMapMatch", "TextPosition", "build_text_map", "find_in_text_map"})


def __getattr__(name: str):
if name in _DEPRECATED_INTERNALS:
import warnings

from . import xml_editor

warnings.warn(
f"docx_editor.{name} is internal and will be removed from the public API "
f"in the next release; use Document.find_text()/SearchResult instead, or "
f"import from docx_editor.xml_editor if you need the internals.",
DeprecationWarning,
stacklevel=2,
)
return getattr(xml_editor, name)
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
41 changes: 35 additions & 6 deletions docx_editor/document.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@

from .comments import Comment, CommentManager
from .exceptions import HashMismatchError, ParagraphIndexError
from .track_changes import EditOperation, EditValidationResult, Revision, RevisionManager
from .track_changes import EditOperation, EditValidationResult, Revision, RevisionManager, SearchResult
from .workspace import Workspace
from .xml_editor import (
DocxXMLEditor,
Expand Down Expand Up @@ -162,13 +162,42 @@ def workspace_path(self) -> Path:

# ==================== Track Changes API ====================

def find_text(self, text: str, occurrence: int = 0):
def find_text(self, text: str, occurrence: int = 0) -> SearchResult | None:
"""Find text in the document, including across element boundaries.

Returns match info or None if not found.
Args:
text: Text to search for
occurrence: Which occurrence document-wide (0 = first, 1 = second, etc.)

Returns:
A SearchResult, or None if the text (or that occurrence) is not
found. Fields:

- ``start`` / ``end``: character offsets of the match in the
*containing paragraph's* visible text (not document-wide).
- ``text``: the matched text.
- ``paragraph_ref``: hash-anchored ref like "P3#a7b2", directly
usable as the ``paragraph=`` argument of follow-up edits. Valid
until that paragraph is edited.
- ``paragraph_occurrence``: occurrence index of this match within
its paragraph — pass as the ``occurrence=`` of a follow-up edit
(edit methods count occurrences within the paragraph, not
document-wide).
- ``spans_revision``: True if the match crosses a tracked-revision
boundary (e.g. part of it is inside a tracked insertion).

Example:
match = doc.find_text("30 days")
if match:
doc.replace(
"30 days",
"60 days",
paragraph=match.paragraph_ref,
occurrence=match.paragraph_occurrence,
)
"""
self._ensure_open()
return self._revision_manager._find_across_boundaries(text, occurrence)
return self._revision_manager.find_text(text, occurrence)

def count_matches(self, text: str) -> int:
"""Count how many times a text string appears in the document.
Expand Down Expand Up @@ -587,8 +616,8 @@ def batch_edit(

Example:
new_refs = doc.batch_edit([
EditOperation(action="replace", find="old", replace_with="new", paragraph="P20#a7b2"),
EditOperation(action="delete", text="remove", paragraph="P15#f3c1"),
EditOperation.replace("old", "new", paragraph="P20#a7b2"),
EditOperation.delete("remove", paragraph="P15#f3c1"),
])

# Pre-flight the batch (note: same-paragraph sequential effects are
Expand Down
Loading
Loading