diff --git a/README.md b/README.md
index 7c810a4..33fc6cb 100644
--- a/README.md
+++ b/README.md
@@ -117,16 +117,13 @@ with Document.open("reviewed.docx", author="Editor") as doc:
# Get visible text (inserted text included, deleted excluded)
text = doc.get_visible_text()
- # List paragraphs to find hash-anchored references
- refs = doc.list_paragraphs()
-
- # Page through large documents — you choose the page size; refs stay
- # globally indexed (page 2 with size 50 starts at P51, not P1)
- total = doc.paragraph_count()
- page_size = 50
- for start in range(1, total + 1, page_size):
- for ref in doc.list_paragraphs(start=start, limit=page_size):
- print(ref) # process this page of refs
+ # List paragraphs to find hash-anchored references. Large documents:
+ # a bare call returns at most 200 paragraphs, ending with a
+ # "... N more paragraphs; use start=201 or limit=None" notice.
+ # Refs stay globally indexed across pages (page 2 starts at P201, not P1).
+ page1 = doc.list_paragraphs() # up to P200, then the notice
+ page2 = doc.list_paragraphs(start=201) # next page, per the notice
+ everything = doc.list_paragraphs(limit=None) # uncapped, never a notice
# Find text across element boundaries
match = doc.find_text("Aim: To")
diff --git a/benchmarks/corpus/corpus_harness.py b/benchmarks/corpus/corpus_harness.py
index ada4e3f..0acba7e 100644
--- a/benchmarks/corpus/corpus_harness.py
+++ b/benchmarks/corpus/corpus_harness.py
@@ -152,7 +152,7 @@ def fail_rest(from_stage: str):
try:
# Stage 2: read
try:
- paras = doc.list_paragraphs_structured()
+ paras = doc.list_paragraphs_structured(limit=None)
visible = doc.get_visible_text()
revs = doc.list_revisions()
stages["read"] = {
diff --git a/benchmarks/hash_anchored_vs_plain.py b/benchmarks/hash_anchored_vs_plain.py
index 7e4c84d..1058b49 100644
--- a/benchmarks/hash_anchored_vs_plain.py
+++ b/benchmarks/hash_anchored_vs_plain.py
@@ -115,7 +115,7 @@ def open_saved():
hash_times = []
for _ in range(iterations):
d, t = open_saved()
- refs = d.list_paragraphs()
+ refs = d.list_paragraphs(limit=None)
# Find P15's ref
ref = None
for entry in refs:
@@ -167,7 +167,7 @@ def benchmark_accuracy():
print("=" * 60)
doc, tmp = build_multi_paragraph_doc(30)
- paragraphs = doc.list_paragraphs()
+ paragraphs = doc.list_paragraphs(limit=None)
n_paras = len(paragraphs)
print(f" Paragraphs: {n_paras}")
@@ -338,7 +338,7 @@ def benchmark_accuracy():
def _get_fresh_hash(doc, para_index):
"""Get the current hash for a paragraph by index (1-based)."""
- entries = doc.list_paragraphs()
+ entries = doc.list_paragraphs(limit=None)
entry = entries[para_index - 1]
ref = entry.split("|")[0]
return ref.split("#")[1]
@@ -376,7 +376,7 @@ def open_saved():
d, t = open_saved()
t0 = time.perf_counter()
for i in range(1, n_edits + 1):
- refs = d.list_paragraphs()
+ refs = d.list_paragraphs(limit=None)
ref = refs[i - 1].split("|")[0]
d.replace(f"item {i}", f"EDIT_{i}", paragraph=ref)
t1 = time.perf_counter()
@@ -388,7 +388,7 @@ def open_saved():
for _ in range(iterations):
d, t = open_saved()
t0 = time.perf_counter()
- refs = d.list_paragraphs()
+ refs = d.list_paragraphs(limit=None)
ops = [
EditOperation(
action="replace",
diff --git a/docs/api.md b/docs/api.md
index c959d07..21e8089 100644
--- a/docs/api.md
+++ b/docs/api.md
@@ -99,32 +99,50 @@ Return the total number of paragraphs in the document. A cheap bounds check for
count = doc.paragraph_count()
```
-#### `list_paragraphs(max_chars=80, *, start=1, limit=None)`
+#### `list_paragraphs(max_chars=80, *, start=1, limit=200)`
List paragraphs with hash-anchored references. Refs are **1-based global** indexes (`P1`, `P2`, …) and stay correct across pages — a slice starting at paragraph 51 emits `P51#…`, not `P1#…`.
+**Changed in 0.6.1:** a bare call now returns at most 200 paragraphs (previously all of them). Whenever paragraphs remain beyond the returned window — default or explicit `limit` — the last list entry is a **truncation notice** instead of a paragraph, e.g. `"... 50 more paragraphs; use start=201 or limit=None"`. Notice lines always start with `...` and never match the `P{index}#{hash}` ref shape; filter them with `entry.startswith("...")` when consuming entries as refs. Pass `limit=None` for the full, notice-free listing.
+
**Parameters:**
- `max_chars` (int): Maximum preview length (must be `>= 0`). Use `0` for refs only (e.g. `P1#a7b2`), with no preview or `| ` separator.
- `start` (int): 1-based index of the first paragraph to return (default 1). A `start` beyond the last paragraph yields an empty list.
-- `limit` (int | None): Maximum number of paragraphs to return, or `None` for all paragraphs from `start` onward (default `None`).
+- `limit` (int | None): Maximum number of paragraphs to return (default 200), or `None` for all paragraphs from `start` onward.
-**Returns:** List of strings in the form `P{index}#{hash}| preview text`, or bare `P{index}#{hash}` (no `| ` separator) when `max_chars=0`.
+**Returns:** List of strings in the form `P{index}#{hash}| preview text`, or bare `P{index}#{hash}` (no `| ` separator) when `max_chars=0` — plus one trailing `... N more paragraphs; use start=… or limit=None` notice when the window did not reach the end of the document.
**Example:**
```python
-refs = doc.list_paragraphs()
-for ref in refs:
- print(ref)
+page1 = doc.list_paragraphs() # up to P200, then "... N more" notice
+page2 = doc.list_paragraphs(start=201) # next page, per the notice
+refs = [e for e in page1 if not e.startswith("...")] # drop the notice line
+everything = doc.list_paragraphs(limit=None) # uncapped, never a notice
+```
-# Page through a large document — you pick the page size; refs stay
-# globally indexed (with size 50, page 2 starts at P51)
-count = doc.paragraph_count()
-page_size = 50
-for start in range(1, count + 1, page_size):
- for ref in doc.list_paragraphs(start=start, limit=page_size):
- print(ref) # process this page of refs
+`list_paragraphs_structured()` (same `start`/`limit` semantics, returns typed `ParagraphInfo` records with full untruncated text) shares the 200-record default cap but appends **no notice** — every entry stays a `ParagraphInfo`. Detect truncation by checking whether the last record's `index` is still below `paragraph_count()` (robust for any `start`), or pass `limit=None`.
+
+#### `context(ref, window=2)`
+
+Return the paragraphs surrounding `ref`, in document order — the "show me the section around this match" helper. Fetches the referenced paragraph plus up to `window` paragraphs on each side, clamped at the document edges (no padding, no wrap-around).
+
+**Parameters:**
+
+- `ref` (str): Paragraph reference (e.g. `P3#a7b2`) from `list_paragraphs()`, `find_text()`/`find_all()`, or an edit result.
+- `window` (int): Paragraphs to include on *each side* of the referenced one (default 2, so up to 5 records). Must be `>= 0`; `0` returns just the referenced paragraph.
+
+**Returns:** List of `ParagraphInfo` records (index, ref, full text) — identical to what `list_paragraphs_structured()` would emit for the same span.
+
+**Raises:** `ValueError` if `ref` is malformed or `window < 0`; [`ParagraphIndexError`](#exceptions) / [`HashMismatchError`](#exceptions) for an out-of-range or stale `ref`.
+
+**Example:**
+
+```python
+match = doc.find_text("Termination")
+for info in doc.context(match.paragraph_ref, window=2):
+ print(info) # "P{i}#{hash}| full paragraph text"
```
#### `get_paragraph_location(ref)`
@@ -945,6 +963,7 @@ from docx_editor import SearchResult
| `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 |
+| `paragraph_index` | int | 1-based index of the containing paragraph — the same integer embedded in `paragraph_ref`, so you never string-parse the ref |
`start`/`end` are offsets within the matched paragraph's visible text, **not**
document-wide offsets. Coordinate systems differ between search and edit:
@@ -955,6 +974,13 @@ document-wide offsets. Coordinate systems differ between search and edit:
is computed at search time and — like refs from `list_paragraphs()` — goes
stale once that paragraph is edited.
+`repr()`/`str()` are compact one-liners —
+`SearchResult(P3#a7b2 occ=0 '30 days')`, with a trailing `spans_rev` marker
+when `spans_revision` is true — so printing a whole `find_all()` list stays
+cheap. Matched text longer than 60 characters is elided with `...` in the
+display only; every field, including the full `text`, remains accessible as
+an attribute.
+
### Example
```python
diff --git a/docs/quickstart.md b/docs/quickstart.md
index 54d1e93..f6b3fe7 100644
--- a/docs/quickstart.md
+++ b/docs/quickstart.md
@@ -61,15 +61,13 @@ P2#f3c1| Payment is due within 30 days...
P3#b2c4| Section 5 describes review obligations...
```
-For large documents, page through paragraphs with `start`/`limit`. You choose the page size; refs keep their global 1-based index (with a page size of 50, page 2 starts at `P51`, not `P1`):
+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`):
```python
with Document.open("contract.docx") as doc:
- total = doc.paragraph_count()
- page_size = 50
- for start in range(1, total + 1, page_size):
- for paragraph in doc.list_paragraphs(start=start, limit=page_size):
- print(paragraph)
+ 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.
diff --git a/docx_editor/document.py b/docx_editor/document.py
index d9a7d80..0a34250 100644
--- a/docx_editor/document.py
+++ b/docx_editor/document.py
@@ -39,6 +39,10 @@
compute_paragraph_hash,
)
+# Default page size for list_paragraphs / list_paragraphs_structured. Bounds
+# the output of a bare call on large documents; pass limit=None for everything.
+_DEFAULT_LIST_LIMIT = 200
+
def _require_ref_string(paragraph: str) -> None:
"""Reject non-string paragraph refs before they can silently select the
@@ -231,6 +235,9 @@ def find_text(self, text: str, occurrence: int = 0, paragraph: str | None = None
document-wide).
- ``spans_revision``: True if the match crosses a tracked-revision
boundary (e.g. part of it is inside a tracked insertion).
+ - ``paragraph_index``: 1-based index of the containing paragraph —
+ the same integer embedded in ``paragraph_ref``, provided so you
+ never need to string-parse the ref.
Example:
match = doc.find_text("30 days")
@@ -345,7 +352,9 @@ def paragraph_count(self) -> int:
self._ensure_open()
return len(self._document_editor.dom.getElementsByTagName("w:p"))
- def list_paragraphs(self, max_chars: int = 80, *, start: int = 1, limit: int | None = None) -> list[str]:
+ def list_paragraphs(
+ self, max_chars: int = 80, *, start: int = 1, limit: int | None = _DEFAULT_LIST_LIMIT
+ ) -> list[str]:
"""List paragraphs with hash-anchored references.
Returns a list of strings like "P1#a7b2| Introduction to the..."
@@ -353,6 +362,16 @@ def list_paragraphs(self, max_chars: int = 80, *, start: int = 1, limit: int | N
are **1-based global** indexes (P1, P2, …) and stay correct across
pages — a slice starting at paragraph 51 emits "P51#…", not "P1#…".
+ Note:
+ Changed in 0.6.1: a bare call now returns at most 200 paragraphs
+ (it used to return all of them). Whenever paragraphs remain beyond
+ the returned window, the last list entry is a truncation notice
+ instead of a paragraph, e.g. ``"... 50 more paragraphs; use
+ start=201 or limit=None"``. Notice lines always start with
+ ``"..."`` and never match the ``P{i}#{hash}`` ref shape, so
+ ref-consuming code can filter them out. Pass ``limit=None`` to
+ restore the full listing.
+
Args:
max_chars: Maximum characters for the preview text (default 80).
Must be >= 0. Use 0 to get only the hash refs (e.g. "P1#a7b2"),
@@ -360,29 +379,32 @@ def list_paragraphs(self, max_chars: int = 80, *, start: int = 1, limit: int | N
start: 1-based index of the first paragraph to return (default 1).
Must be >= 1. A ``start`` beyond the last paragraph yields an
empty list.
- limit: Maximum number of paragraphs to return, or ``None`` for all
- paragraphs from ``start`` onward (default ``None``). Must be
- >= 0 when given; ``0`` yields an empty list.
+ limit: Maximum number of paragraphs to return (default 200), or
+ ``None`` for all paragraphs from ``start`` onward. Must be
+ >= 0 when given.
Returns:
- List of hash-tagged paragraph preview strings.
+ List of hash-tagged paragraph preview strings, plus one trailing
+ ``"... N more paragraphs; use start=… or limit=None"`` notice
+ when the window did not reach the end of the document.
Raises:
ValueError: If ``max_chars`` < 0, ``start`` < 1, or ``limit`` < 0.
Example:
- # Caller chooses the page size; ``start`` walks forward by it.
- count = doc.paragraph_count()
- page_size = 50
- for start in range(1, count + 1, page_size):
- for ref in doc.list_paragraphs(start=start, limit=page_size):
- print(ref)
+ # Walk a large document page by page; the trailing notice on each
+ # page tells you the next start.
+ page = doc.list_paragraphs() # P1..P200 + notice
+ page = doc.list_paragraphs(start=201) # P201.. and so on
+ everything = doc.list_paragraphs(limit=None) # no cap, no notice
"""
self._ensure_open()
if max_chars < 0:
raise ValueError(f"max_chars must be >= 0, got {max_chars}")
result = []
+ last_index = start - 1 # highest index emitted; start-1 when the slice is empty
for i, p in self._iter_paragraph_slice(start, limit):
+ last_index = i
h = compute_paragraph_hash(p)
if max_chars == 0:
result.append(f"P{i}#{h}")
@@ -392,6 +414,10 @@ def list_paragraphs(self, max_chars: int = 80, *, start: int = 1, limit: int | N
if len(tm.text) > max_chars:
preview += "..."
result.append(f"P{i}#{h}| {preview}")
+ remaining = self.paragraph_count() - last_index
+ if remaining > 0:
+ noun = "paragraph" if remaining == 1 else "paragraphs"
+ result.append(f"... {remaining} more {noun}; use start={last_index + 1} or limit=None")
return result
def _iter_paragraph_slice(self, start: int, limit: int | None) -> Iterator[tuple[int, Element]]:
@@ -415,7 +441,9 @@ def _iter_paragraph_slice(self, start: int, limit: int | None) -> Iterator[tuple
end = begin + limit if limit is not None else None
return enumerate(paragraphs[begin:end], start=begin + 1)
- def list_paragraphs_structured(self, *, start: int = 1, limit: int | None = None) -> list[ParagraphInfo]:
+ def list_paragraphs_structured(
+ self, *, start: int = 1, limit: int | None = _DEFAULT_LIST_LIMIT
+ ) -> list[ParagraphInfo]:
"""List paragraphs as structured :class:`ParagraphInfo` records.
Like :meth:`list_paragraphs`, but returns named records (index, ref,
@@ -431,26 +459,35 @@ def list_paragraphs_structured(self, *, start: int = 1, limit: int | None = None
across slices, with the same ``start``/``limit`` semantics as
:meth:`list_paragraphs`.
+ Note:
+ Changed in 0.6.1: a bare call now returns at most 200 records (it
+ used to return all of them). Unlike :meth:`list_paragraphs`, **no
+ truncation notice is appended** — every entry is a
+ :class:`ParagraphInfo`, never a string — so a capped result is
+ silent. To detect truncation, check whether the last record's
+ ``index`` is still below :meth:`paragraph_count` (robust for any
+ ``start``; with ``start=1``, comparing ``len(result)`` works too).
+ Pass ``limit=None`` for the full listing.
+
Args:
start: 1-based index of the first paragraph to return (default 1).
Must be >= 1. A ``start`` beyond the last paragraph yields an
empty list.
- limit: Maximum number of paragraphs to return, or ``None`` for all
- paragraphs from ``start`` onward (default ``None``). Must be
+ limit: Maximum number of paragraphs to return (default 200), or
+ ``None`` for all paragraphs from ``start`` onward. Must be
>= 0 when given; ``0`` yields an empty list.
Returns:
- List of :class:`ParagraphInfo` records.
+ List of :class:`ParagraphInfo` records (no notice entries).
Raises:
ValueError: If ``start`` < 1, or ``limit`` < 0.
Example:
- # Caller chooses the page size; ``start`` walks forward by it.
- page_size = 50
- for start in range(1, doc.paragraph_count() + 1, page_size):
- for info in doc.list_paragraphs_structured(start=start, limit=page_size):
- print(info.ref, info.text)
+ infos = doc.list_paragraphs_structured() # bounded: at most 200
+ if infos and infos[-1].index < doc.paragraph_count():
+ ... # truncated — continue from start=infos[-1].index + 1
+ everything = doc.list_paragraphs_structured(limit=None)
"""
self._ensure_open()
result = []
@@ -490,6 +527,81 @@ def get_paragraph(self, index: int) -> ParagraphInfo:
h = compute_paragraph_hash(p)
return ParagraphInfo(index=index, ref=f"P{index}#{h}", text=build_text_map(p).text)
+ def context(self, ref: str, window: int = 2) -> list[ParagraphInfo]:
+ """Return the paragraphs surrounding ``ref``, in document order.
+
+ Fetches the referenced paragraph plus up to ``window`` paragraphs on
+ each side (fewer at the document edges) — the "show me what's around
+ this match" helper for search results: pass a
+ :class:`~docx_editor.track_changes.SearchResult`'s ``paragraph_ref``
+ straight in. The records are identical to what
+ :meth:`list_paragraphs_structured` would emit for the same indexes.
+
+ Args:
+ ref: Paragraph reference (e.g., "P3#a7b2") from
+ :meth:`list_paragraphs`, :meth:`find_text`/:meth:`find_all`,
+ or an edit result.
+ window: Number of paragraphs to include on *each side* of the
+ referenced one (default 2, so up to 5 records). Must be >= 0;
+ ``0`` returns just the referenced paragraph. Clamped at the
+ document edges — no padding, no wrap-around.
+
+ Returns:
+ List of :class:`ParagraphInfo` records covering
+ ``max(1, i - window) .. min(paragraph_count(), i + window)``,
+ where ``i`` is the referenced paragraph's index.
+
+ Raises:
+ ValueError: If ``ref`` has an invalid format, or ``window`` < 0.
+ ParagraphIndexError: If the paragraph index is out of range.
+ HashMismatchError: If the hash no longer matches current
+ paragraph content (paragraph was modified after the ref
+ was captured).
+
+ Example:
+ match = doc.find_text("Termination")
+ for info in doc.context(match.paragraph_ref, window=2):
+ print(info)
+ """
+ self._ensure_open()
+ if window < 0:
+ raise ValueError(f"window must be >= 0, got {window}")
+ index, _ = self._resolve_validated_ref(ref)
+ first = max(1, index - window)
+ last = min(self.paragraph_count(), index + window)
+ return self.list_paragraphs_structured(start=first, limit=last - first + 1)
+
+ def _resolve_validated_ref(self, ref: str) -> tuple[int, list[Element]]:
+ """Parse ``ref``, bounds-check its index, and verify its hash.
+
+ Shared validation for the ref-taking read methods
+ (:meth:`get_paragraph_location`, :meth:`context`).
+
+ Returns:
+ ``(index, paragraphs)`` — the ref's 1-based index and the full
+ document paragraph list it was validated against, so callers that
+ need the elements don't re-query the DOM.
+
+ Raises:
+ ValueError: If ``ref`` has an invalid format.
+ ParagraphIndexError: If the paragraph index is out of range.
+ HashMismatchError: If the hash no longer matches current
+ paragraph content.
+ """
+ parsed = ParagraphRef.parse(ref)
+ paragraphs = self._document_editor.dom.getElementsByTagName("w:p")
+ if parsed.index < 1 or parsed.index > len(paragraphs):
+ raise ParagraphIndexError(parsed.index, len(paragraphs))
+ p = paragraphs[parsed.index - 1]
+ actual_hash = compute_paragraph_hash(p)
+ if actual_hash != parsed.hash:
+ tm = build_text_map(p)
+ preview = tm.text[:80]
+ if len(tm.text) > 80:
+ preview += "..."
+ raise HashMismatchError(parsed.index, parsed.hash, actual_hash, preview)
+ return parsed.index, paragraphs
+
def _style_maps(self) -> tuple[dict[str, int], dict[str, ListItem]]:
"""Outline-level and numbering maps defined by paragraph styles.
@@ -580,21 +692,11 @@ def get_paragraph_location(self, ref: str) -> ParagraphLocation:
print(f"section {loc.section}")
"""
self._ensure_open()
- parsed = ParagraphRef.parse(ref)
- paragraphs = self._document_editor.dom.getElementsByTagName("w:p")
- if parsed.index < 1 or parsed.index > len(paragraphs):
- raise ParagraphIndexError(parsed.index, len(paragraphs))
- p = paragraphs[parsed.index - 1]
- actual_hash = compute_paragraph_hash(p)
- if actual_hash != parsed.hash:
- tm = build_text_map(p)
- preview = tm.text[:80]
- if len(tm.text) > 80:
- preview += "..."
- raise HashMismatchError(parsed.index, parsed.hash, actual_hash, preview)
+ index, paragraphs = self._resolve_validated_ref(ref)
+ p = paragraphs[index - 1]
style_outlines, style_numbering = self._style_maps()
- heading_path = _compute_heading_paths(paragraphs[: parsed.index], style_outlines)[-1]
- section = _compute_section_indexes(paragraphs[: parsed.index])[-1]
+ heading_path = _compute_heading_paths(paragraphs[:index], style_outlines)[-1]
+ section = _compute_section_indexes(paragraphs[:index])[-1]
return _compute_paragraph_location(
p,
style_outlines=style_outlines,
@@ -1134,7 +1236,9 @@ def list_revisions(self, author: str | None = None, paragraph: str | None = None
Example:
# Reviewer workflow: inspect one paragraph's revisions, then act.
- for ref in doc.list_paragraphs(max_chars=0):
+ # limit=None: every entry must be a real ref, never a truncation
+ # notice, because each one is passed as paragraph= below.
+ for ref in doc.list_paragraphs(max_chars=0, limit=None):
for r in doc.list_revisions(paragraph=ref):
print(f"{r.id}: {r.type} '{r.text}' by {r.author}")
doc.accept_revision(3)
diff --git a/docx_editor/session.py b/docx_editor/session.py
index 1ba1589..1609aa5 100644
--- a/docx_editor/session.py
+++ b/docx_editor/session.py
@@ -523,7 +523,7 @@ def _read_code(arg: str) -> str:
route for code starting with '-' (argparse would read a bare "-x" as a flag):
docx-session exec - <<'PY'
-for p in doc.list_paragraphs():
+for p in doc.list_paragraphs(limit=None):
print(p)
PY"""
@@ -532,7 +532,7 @@ def _read_code(arg: str) -> str:
easy route for expressions starting with '-' (argparse would read a bare "-x" as a flag):
docx-session eval - <<'PY'
-[str(p) for p in doc.list_paragraphs() if 'deadline' in str(p)]
+[str(p) for p in doc.list_paragraphs(limit=None) if 'deadline' in str(p)]
PY"""
diff --git a/docx_editor/track_changes.py b/docx_editor/track_changes.py
index fe20e53..e59713b 100644
--- a/docx_editor/track_changes.py
+++ b/docx_editor/track_changes.py
@@ -257,6 +257,16 @@ class SearchResult:
methods count within one paragraph — ``paragraph_occurrence`` bridges the
two: pass it as the ``occurrence=`` of a follow-up edit to target exactly
the match ``find_text`` located.
+
+ ``paragraph_index`` is the 1-based document-order index of the containing
+ paragraph — the same integer embedded in ``paragraph_ref`` — so consumers
+ never need to string-parse the ref to compare or sort by position.
+
+ ``repr()``/``str()`` are compact one-liners
+ (``SearchResult(P3#a7b2 occ=0 '30 days')``) so printing a list of results
+ stays cheap; matched text longer than 60 characters is elided with
+ ``"..."`` in the display only. Every field — including the full ``text``
+ — remains accessible as an attribute.
"""
start: int # Start offset in the paragraph's visible text
@@ -265,6 +275,12 @@ class SearchResult:
paragraph_ref: str # Hash-anchored ref like "P3#a7b2"
paragraph_occurrence: int # Occurrence index of this match within its paragraph
spans_revision: bool # True if the match crosses a tracked-revision boundary
+ paragraph_index: int # 1-based index of the containing paragraph (same as in paragraph_ref)
+
+ def __repr__(self) -> str:
+ text = self.text if len(self.text) <= 60 else self.text[:57] + "..."
+ spans = " spans_rev" if self.spans_revision else ""
+ return f"SearchResult({self.paragraph_ref} occ={self.paragraph_occurrence} {text!r}{spans})"
@dataclass(frozen=True)
@@ -1252,6 +1268,7 @@ def find_text(self, text: str, occurrence: int = 0, paragraph: str | None = None
paragraph_ref=f"P{located.paragraph_index}#{compute_paragraph_hash(located.paragraph)}",
paragraph_occurrence=located.paragraph_occurrence,
spans_revision=located.match.spans_boundary,
+ paragraph_index=located.paragraph_index,
)
def find_all(self, text: str, paragraph: str | None = None) -> list[SearchResult]:
@@ -1300,6 +1317,7 @@ def find_all(self, text: str, paragraph: str | None = None) -> list[SearchResult
paragraph_ref=paragraph_ref,
paragraph_occurrence=local_occ,
spans_revision=match.spans_boundary,
+ paragraph_index=idx,
)
)
local_occ += 1
diff --git a/skills/docx/SKILL.md b/skills/docx/SKILL.md
index dfc4c75..cd09fb7 100644
--- a/skills/docx/SKILL.md
+++ b/skills/docx/SKILL.md
@@ -247,6 +247,15 @@ doc.batch_edit(ops)
# order as above (an edit never shifts the matches before it; ascending
# mis-targets, and descending is not valid for self-overlapping search strings
# like "aa" in "aaaa").
+# SearchResults print compactly — SearchResult(P3#a7b2 occ=0 '30 days') — so
+# printing a whole find_all() list is cheap. Each result also carries
+# paragraph_index (the int inside paragraph_ref); never string-parse refs.
+
+# Show the paragraphs AROUND a match ("what's the surrounding section?").
+# window=2 (default) returns up to 5 ParagraphInfo records, clamped at the
+# document edges; each prints as "P{i}#{hash}| full text":
+for info in doc.context(match.paragraph_ref, window=2):
+ print(info)
# Get all visible text (inserted text included, deleted text excluded)
visible = doc.get_visible_text()
@@ -624,15 +633,18 @@ with Document.open("file.docx", author=author) as doc:
doc.save()
```
-Refs are **1-based** global indexes (`P1`, not `P0`). For large documents, page through paragraphs to save tokens — `paragraph_count()` gives the total for bounds, and `start`/`limit` return a slice whose refs keep their global index. You pick the page size (there's no built-in limit); with `limit=N` the next page starts at `start + N`. Pass `max_chars=0` to get bare refs (`P1#a7b2`) with no preview text or `| ` separator:
+Refs are **1-based** global indexes (`P1`, not `P0`). A bare `list_paragraphs()` call returns at most **200 paragraphs** (the default `limit`). Whenever paragraphs remain beyond the returned window — default or explicit `limit` — the last list entry is a truncation notice instead of a paragraph, e.g. `"... 50 more paragraphs; use start=201 or limit=None"`, telling you the next `start`. Notice lines always begin with `...` and never match the `P{i}#{hash}` ref shape, so filter them with `entry.startswith("...")` when consuming entries as refs. `paragraph_count()` gives the total for bounds; `start`/`limit` return a slice whose refs keep their global index; `limit=None` removes the cap. Pass `max_chars=0` to get bare refs (`P1#a7b2`) with no preview text or `| ` separator:
```python
-total = doc.paragraph_count() # cheap bounds check
-page_size = 50 # caller's choice
-page2 = doc.list_paragraphs(start=1 + page_size, limit=page_size) # refs P51..P100
-refs_only = doc.list_paragraphs(max_chars=0) # "P1#a7b2", no preview
+page1 = doc.list_paragraphs() # up to P200, then "... N more" notice
+page2 = doc.list_paragraphs(start=201) # next page, per the notice
+refs = [e for e in page1 if not e.startswith("...")] # drop the notice line
+everything = doc.list_paragraphs(limit=None) # uncapped, never a notice
+refs_only = doc.list_paragraphs(max_chars=0, limit=None) # every "P1#a7b2", no preview, no notice
```
+(`list_paragraphs_structured()` has the same 200-record default cap but appends **no notice** — every entry stays a typed `ParagraphInfo`. Detect truncation by checking whether the last record's `index` is still below `paragraph_count()` (robust for any `start`), or pass `limit=None`.)
+
The `paragraph` argument is **required** for all edit methods. If the paragraph content has changed since you called `list_paragraphs()`, a `HashMismatchError` is raised — preventing edits to the wrong location.
**Every edit method returns an `EditResult` — a `str` subclass whose value is the new paragraph ref (plus `group_id`/`revision_ids`).** Chain edits without calling `list_paragraphs()` again:
diff --git a/tests/conftest.py b/tests/conftest.py
index a42a6f5..9afd19b 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -11,7 +11,7 @@
def find_ref(doc, text):
"""Find the paragraph ref containing the given text."""
- for entry in doc.list_paragraphs():
+ for entry in doc.list_paragraphs(limit=None):
if text in entry:
return entry.split("|")[0]
raise ValueError(f"Paragraph containing '{text}' not found")
diff --git a/tests/test_document.py b/tests/test_document.py
index be0fefb..7eea7a0 100644
--- a/tests/test_document.py
+++ b/tests/test_document.py
@@ -15,7 +15,9 @@
DocumentNotFoundError,
DocumentOpenError,
DocxEditError,
+ HashMismatchError,
InvalidDocumentError,
+ ParagraphIndexError,
WorkspaceSyncError,
)
from docx_editor.workspace import Workspace
@@ -843,6 +845,107 @@ def test_find_text_after_close_raises(self, clean_workspace):
doc.find_text("test")
+class TestDocumentContext:
+ """Document.context() — the paragraphs around a ref, clamped at the edges."""
+
+ @pytest.fixture
+ def ten_para_docx(self, simple_docx, temp_dir):
+ """simple.docx with its body swapped for 10 one-line paragraphs."""
+ body = "".join(f"Paragraph {i}" for i in range(1, 11))
+ dest = temp_dir / "ten.docx"
+ replace_document_xml(simple_docx, dest, f"{body}")
+ return dest
+
+ def test_middle_returns_full_window(self, ten_para_docx):
+ doc = Document.open(ten_para_docx)
+ try:
+ ref = doc.get_paragraph(5).ref
+ records = doc.context(ref) # default window=2 -> 5 records
+ assert [info.index for info in records] == [3, 4, 5, 6, 7]
+ # Records are exactly what the structured listing emits for the span.
+ assert records == doc.list_paragraphs_structured(start=3, limit=5)
+ finally:
+ doc.close()
+
+ def test_clamps_at_document_start(self, ten_para_docx):
+ doc = Document.open(ten_para_docx)
+ try:
+ ref = doc.get_paragraph(1).ref
+ records = doc.context(ref, window=3)
+ assert [info.index for info in records] == [1, 2, 3, 4]
+ finally:
+ doc.close()
+
+ def test_clamps_at_document_end(self, ten_para_docx):
+ doc = Document.open(ten_para_docx)
+ try:
+ ref = doc.get_paragraph(10).ref
+ records = doc.context(ref, window=3)
+ assert [info.index for info in records] == [7, 8, 9, 10]
+ finally:
+ doc.close()
+
+ def test_window_zero_returns_just_the_paragraph(self, ten_para_docx):
+ doc = Document.open(ten_para_docx)
+ try:
+ ref = doc.get_paragraph(4).ref
+ assert doc.context(ref, window=0) == [doc.get_paragraph(4)]
+ finally:
+ doc.close()
+
+ def test_search_result_ref_plugs_in(self, ten_para_docx):
+ doc = Document.open(ten_para_docx)
+ try:
+ match = doc.find_text("Paragraph 7")
+ assert match is not None
+ records = doc.context(match.paragraph_ref, window=1)
+ assert [info.index for info in records] == [6, 7, 8]
+ finally:
+ doc.close()
+
+ def test_negative_window_raises(self, ten_para_docx):
+ doc = Document.open(ten_para_docx)
+ try:
+ ref = doc.get_paragraph(1).ref
+ with pytest.raises(ValueError, match="window must be >= 0"):
+ doc.context(ref, window=-1)
+ finally:
+ doc.close()
+
+ def test_malformed_ref_raises_value_error(self, ten_para_docx):
+ doc = Document.open(ten_para_docx)
+ try:
+ with pytest.raises(ValueError, match="Invalid paragraph reference"):
+ doc.context("not-a-ref")
+ finally:
+ doc.close()
+
+ def test_stale_hash_raises(self, ten_para_docx):
+ doc = Document.open(ten_para_docx)
+ try:
+ ref = doc.get_paragraph(2).ref
+ doc.replace("Paragraph 2", "Mutated", paragraph=ref)
+ with pytest.raises(HashMismatchError):
+ doc.context(ref)
+ finally:
+ doc.close()
+
+ def test_out_of_range_raises(self, ten_para_docx):
+ doc = Document.open(ten_para_docx)
+ try:
+ with pytest.raises(ParagraphIndexError):
+ doc.context("P999#0000")
+ finally:
+ doc.close()
+
+ def test_after_close_raises(self, ten_para_docx):
+ doc = Document.open(ten_para_docx)
+ ref = doc.get_paragraph(1).ref
+ doc.close()
+ with pytest.raises(DocumentClosedError, match="closed"):
+ doc.context(ref)
+
+
class TestPublicApiSurface:
"""The text-map internals are deprecated at the top level; SearchResult is public."""
diff --git a/tests/test_find_all.py b/tests/test_find_all.py
index 918cab9..f101d66 100644
--- a/tests/test_find_all.py
+++ b/tests/test_find_all.py
@@ -140,6 +140,74 @@ def test_match_spanning_revision_sets_flag(self, multi_para_doc):
assert plain[0].spans_revision is False
+class TestSearchResultErgonomics:
+ """0.6.1 token-ergonomics contract: paragraph_index field + compact repr."""
+
+ def test_paragraph_index_matches_ref_document_wide(self, multi_para_doc):
+ doc, _ = multi_para_doc
+ results = doc.find_all("committee")
+ assert results, "fixture must produce matches"
+ for r in results:
+ assert r.paragraph_index == int(r.paragraph_ref.split("#")[0][1:])
+
+ def test_paragraph_index_matches_ref_scoped(self, multi_para_doc):
+ doc, _ = multi_para_doc
+ ref = doc.list_paragraphs()[2].split("|")[0]
+ for r in doc.find_all("committee", paragraph=ref):
+ assert r.paragraph_ref == ref
+ assert r.paragraph_index == 3
+
+ def test_find_text_carries_paragraph_index(self, multi_para_doc):
+ doc, _ = multi_para_doc
+ match = doc.find_text("[P05]")
+ assert match is not None
+ assert match.paragraph_index == 5
+ assert match.paragraph_ref.startswith("P5#")
+
+ def test_repr_is_compact_one_liner(self, multi_para_doc):
+ doc, _ = multi_para_doc
+ r = doc.find_all("item")[0]
+ text = repr(r)
+ assert "\n" not in text
+ assert r.paragraph_ref in text
+ assert f"occ={r.paragraph_occurrence}" in text
+ assert "'item'" in text
+ # No dataclass field-name boilerplate; short matches stay short.
+ assert "start=" not in text
+ assert "paragraph_ref=" not in text
+ assert len(text) < 60
+ # str() shares the compact form (dataclasses have no separate __str__).
+ assert str(r) == text
+
+ def test_repr_spans_rev_marker_only_when_spanning(self, multi_para_doc):
+ doc, _ = multi_para_doc
+ ref = doc.list_paragraphs()[0].split("|")[0]
+ doc.insert_after("review", "XX", paragraph=ref)
+
+ spanning = doc.find_all("reviewXX")[0]
+ assert spanning.spans_revision is True
+ assert repr(spanning).endswith(" spans_rev)")
+
+ plain = doc.find_all("[P01]")[0]
+ assert plain.spans_revision is False
+ assert "spans_rev" not in repr(plain)
+
+ def test_repr_elides_long_matched_text(self):
+ # Sentence-length search anchors are a documented pattern; the repr
+ # elides the display at 60 chars while the attribute keeps full text.
+ r = SearchResult(
+ start=0,
+ end=70,
+ text="x" * 70,
+ paragraph_ref="P1#abcd",
+ paragraph_occurrence=0,
+ spans_revision=False,
+ paragraph_index=1,
+ )
+ assert repr(r) == f"SearchResult(P1#abcd occ=0 '{'x' * 57}...')"
+ assert r.text == "x" * 70
+
+
class TestFindAllScoped:
def test_scoped_returns_only_that_paragraphs_hits(self, multi_para_doc):
doc, _ = multi_para_doc
diff --git a/tests/test_paragraph_hash.py b/tests/test_paragraph_hash.py
index d5a576b..4ea6dc3 100644
--- a/tests/test_paragraph_hash.py
+++ b/tests/test_paragraph_hash.py
@@ -7,6 +7,7 @@
import defusedxml.minidom
import pytest
+from conftest import replace_document_xml
from docx_editor import (
Document,
@@ -192,11 +193,15 @@ def test_pagination_slice(self, temp_docx):
full = doc.list_paragraphs()
assert len(full) >= 3, "fixture needs >=3 paragraphs for this test"
page = doc.list_paragraphs(start=2, limit=2)
- assert len(page) == 2
+ # Two refs plus a continuation notice (the 4-paragraph fixture
+ # has one paragraph left beyond the window).
+ assert len(page) == 3
# Global 1-based indexing preserved across pages
assert page[0].startswith("P2#")
assert page[1].startswith("P3#")
- assert page == full[1:3]
+ assert page[:2] == full[1:3]
+ # Singular case: the notice noun pluralizes with the count.
+ assert page[2] == "... 1 more paragraph; use start=4 or limit=None"
finally:
doc.close()
@@ -229,17 +234,23 @@ def test_max_chars_zero_refs_only(self, temp_docx):
finally:
doc.close()
- def test_default_behavior_unchanged(self, temp_docx):
+ def test_small_doc_default_equals_unbounded(self, temp_docx):
+ # Below the 200-paragraph default cap, a bare call is identical to
+ # limit=None: every paragraph, no truncation notice.
doc = Document.open(temp_docx)
try:
+ assert doc.paragraph_count() < 200
assert doc.list_paragraphs() == doc.list_paragraphs(start=1, limit=None)
finally:
doc.close()
- def test_pagination_limit_zero_returns_empty(self, temp_docx):
+ def test_pagination_limit_zero_returns_only_notice(self, temp_docx):
+ # limit=0 returns no refs, but the uniform notice rule still reports
+ # everything that remains.
doc = Document.open(temp_docx)
try:
- assert doc.list_paragraphs(limit=0) == []
+ count = doc.paragraph_count()
+ assert doc.list_paragraphs(limit=0) == [f"... {count} more paragraphs; use start=1 or limit=None"]
finally:
doc.close()
@@ -269,6 +280,109 @@ def test_negative_max_chars_raises(self, temp_docx):
doc.close()
+# ==================== Default-cap / truncation-notice Tests ====================
+
+
+class TestListParagraphsDefaultCap:
+ """The 0.6.1 token-ergonomics contract: bare calls cap at 200 paragraphs."""
+
+ TOTAL = 250
+
+ @pytest.fixture
+ def big_docx(self, simple_docx, temp_dir):
+ """simple.docx with its body swapped for 250 one-line paragraphs."""
+ body = "".join(f"Paragraph {i}" for i in range(1, self.TOTAL + 1))
+ dest = temp_dir / "big.docx"
+ replace_document_xml(simple_docx, dest, f"{body}")
+ return dest
+
+ def test_default_cap_returns_200_plus_notice(self, big_docx):
+ doc = Document.open(big_docx)
+ try:
+ result = doc.list_paragraphs()
+ assert len(result) == 201
+ assert result[0].startswith("P1#")
+ assert result[199].startswith("P200#")
+ assert result[200] == "... 50 more paragraphs; use start=201 or limit=None"
+ finally:
+ doc.close()
+
+ def test_limit_none_returns_everything_no_notice(self, big_docx):
+ doc = Document.open(big_docx)
+ try:
+ result = doc.list_paragraphs(limit=None)
+ assert len(result) == self.TOTAL
+ assert all(re.match(r"^P\d+#[0-9a-f]{4}\| ", entry) for entry in result)
+ finally:
+ doc.close()
+
+ def test_explicit_limit_notice_reports_next_start(self, big_docx):
+ # The notice is truncation-based, not default-based: explicit
+ # paginating callers get the continuation hint too.
+ doc = Document.open(big_docx)
+ try:
+ result = doc.list_paragraphs(start=11, limit=30)
+ assert len(result) == 31
+ assert result[0].startswith("P11#")
+ assert result[29].startswith("P40#")
+ assert result[30] == "... 210 more paragraphs; use start=41 or limit=None"
+ finally:
+ doc.close()
+
+ def test_window_reaching_end_has_no_notice(self, big_docx):
+ doc = Document.open(big_docx)
+ try:
+ last_page = doc.list_paragraphs(start=201, limit=50)
+ assert len(last_page) == 50
+ assert last_page[-1].startswith("P250#")
+ exact_fit = doc.list_paragraphs(start=1, limit=self.TOTAL)
+ assert len(exact_fit) == self.TOTAL
+ assert exact_fit[-1].startswith("P250#")
+ finally:
+ doc.close()
+
+ def test_start_beyond_total_stays_empty(self, big_docx):
+ doc = Document.open(big_docx)
+ try:
+ assert doc.list_paragraphs(start=self.TOTAL + 1) == []
+ finally:
+ doc.close()
+
+ def test_notice_never_matches_ref_pattern(self, big_docx):
+ # The documented filter rule: notice lines start with "..." and can
+ # never be mistaken for a P{i}#{hash} ref.
+ doc = Document.open(big_docx)
+ try:
+ result = doc.list_paragraphs()
+ assert result[-1].startswith("...")
+ assert not re.match(r"^P\d+#", result[-1])
+ refs = [entry for entry in result if not entry.startswith("...")]
+ assert len(refs) == 200
+ finally:
+ doc.close()
+
+ def test_structured_default_cap_no_notice(self, big_docx):
+ # The structured variant caps too, but stays homogeneously typed:
+ # no notice record, truncation detected via paragraph_count().
+ doc = Document.open(big_docx)
+ try:
+ result = doc.list_paragraphs_structured()
+ assert len(result) == 200
+ assert all(isinstance(info, ParagraphInfo) for info in result)
+ assert len(result) < doc.paragraph_count()
+ finally:
+ doc.close()
+
+ def test_structured_limit_none_returns_everything(self, big_docx):
+ doc = Document.open(big_docx)
+ try:
+ result = doc.list_paragraphs_structured(limit=None)
+ assert len(result) == self.TOTAL
+ assert result[-1].index == self.TOTAL
+ finally:
+ doc.close()
+
+
# ==================== list_paragraphs_structured Tests ====================