doc-control 0.0.1.dev0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. doc_control/__init__.py +4 -0
  2. doc_control/__main__.py +3 -0
  3. doc_control/cli.py +80 -0
  4. doc_control/envelope.py +40 -0
  5. doc_control/errors.py +72 -0
  6. doc_control/model/__init__.py +1 -0
  7. doc_control/model/address.py +113 -0
  8. doc_control/model/align.py +529 -0
  9. doc_control/model/anchor.py +609 -0
  10. doc_control/model/annotations.py +280 -0
  11. doc_control/model/document.py +109 -0
  12. doc_control/model/format.py +109 -0
  13. doc_control/model/index.py +353 -0
  14. doc_control/model/layout.py +143 -0
  15. doc_control/model/normalized.py +160 -0
  16. doc_control/model/pages.py +403 -0
  17. doc_control/model/parts.py +50 -0
  18. doc_control/model/search.py +403 -0
  19. doc_control/model/segment.py +445 -0
  20. doc_control/model/structure.py +418 -0
  21. doc_control/model/styles.py +134 -0
  22. doc_control/ooxml/__init__.py +1 -0
  23. doc_control/ooxml/commentparts.py +679 -0
  24. doc_control/ooxml/formatting.py +181 -0
  25. doc_control/ooxml/resolve.py +269 -0
  26. doc_control/ooxml/revisions.py +1024 -0
  27. doc_control/ooxml/splice.py +957 -0
  28. doc_control/ooxml/styledef.py +504 -0
  29. doc_control/ooxml/zipio.py +454 -0
  30. doc_control/ops/__init__.py +1 -0
  31. doc_control/ops/anchor.py +135 -0
  32. doc_control/ops/batch.py +177 -0
  33. doc_control/ops/comments.py +433 -0
  34. doc_control/ops/compare.py +620 -0
  35. doc_control/ops/diff.py +177 -0
  36. doc_control/ops/dotx.py +206 -0
  37. doc_control/ops/find.py +190 -0
  38. doc_control/ops/history.py +479 -0
  39. doc_control/ops/layout.py +40 -0
  40. doc_control/ops/locate.py +364 -0
  41. doc_control/ops/meta.py +49 -0
  42. doc_control/ops/page.py +104 -0
  43. doc_control/ops/paging.py +124 -0
  44. doc_control/ops/read.py +517 -0
  45. doc_control/ops/recipe.py +108 -0
  46. doc_control/ops/rename.py +370 -0
  47. doc_control/ops/revision.py +616 -0
  48. doc_control/ops/segment.py +289 -0
  49. doc_control/ops/styleset.py +262 -0
  50. doc_control/ops/stylewrite.py +573 -0
  51. doc_control/ops/text.py +207 -0
  52. doc_control/ops/tracked.py +1441 -0
  53. doc_control/py.typed +0 -0
  54. doc_control/recipes/engine.py +209 -0
  55. doc_control/recipes/schema.py +140 -0
  56. doc_control/recipes/store.py +96 -0
  57. doc_control/registry.py +76 -0
  58. doc_control/safety/__init__.py +38 -0
  59. doc_control/safety/history.py +508 -0
  60. doc_control/safety/lock.py +69 -0
  61. doc_control/safety/store.py +415 -0
  62. doc_control/safety/verify.py +357 -0
  63. doc_control/schema.py +143 -0
  64. doc_control-0.0.1.dev0.dist-info/METADATA +62 -0
  65. doc_control-0.0.1.dev0.dist-info/RECORD +68 -0
  66. doc_control-0.0.1.dev0.dist-info/WHEEL +4 -0
  67. doc_control-0.0.1.dev0.dist-info/entry_points.txt +2 -0
  68. doc_control-0.0.1.dev0.dist-info/licenses/LICENSE +202 -0
@@ -0,0 +1,364 @@
1
+ """`locate`: where a paragraph is (chapter, scene, estimated page) and how to find it again.
2
+
3
+ For each paragraph the result carries
4
+
5
+ * ``chapter`` (id, title, number, kind) and ``scene`` (id, ordinal within its chapter), from
6
+ :mod:`doc_control.model.structure` (same ``overrides`` and ``profile`` as `segment`, so the
7
+ ids agree); ``null`` before the first chapter or scene;
8
+ * ``page`` and ``page_label`` from :mod:`doc_control.model.pages`: always an estimate;
9
+ * a *locator phrase*: the shortest ``head … tail`` made of the paragraph's first N and last M
10
+ words that no other paragraph of the document also has.
11
+
12
+ Locator phrase
13
+ Words are the paragraph text split on whitespace (punctuation stays on the word). Two
14
+ words are the same when they are equal ignoring case (accents count, as in Word). The
15
+ phrase keeps the document's own characters (no-break spaces stay). It is a citation for a
16
+ report, not a search string: each half usually occurs elsewhere (use ``find_phrase``).
17
+ A paragraph *matches* ``(head, tail)`` when it has at least N + M words,
18
+ starts with the head words and ends with the tail words. The search tries N + M = 2, 3, ...
19
+ (N and M at least 1, at most :data:`MAX_SIDE` each, larger N first among equals) and takes
20
+ the first pair only this paragraph matches. When N + M reaches the paragraph's word count the
21
+ phrase is the whole text (``complete: true``) and matches by equality. The phrase is
22
+ ``null`` with a ``reason``: ``empty`` (no words), ``duplicate`` (another paragraph has the
23
+ same words, ignoring case) or ``not_unique_within_limit`` (long, and every head/tail of up to
24
+ :data:`MAX_SIDE` words is shared).
25
+
26
+ Find phrase
27
+ ``find_phrase`` is what to type in Word's Find: the shortest run of consecutive words (at
28
+ most :data:`MAX_FIND_WORDS`; a run starting at the paragraph's first word wins over one
29
+ inside it) whose exact text occurs once in the whole document under Word's Find rules:
30
+ case-insensitive, accent-sensitive, the document's own characters (no-break spaces stay,
31
+ whitespace is not collapsed), occurrences counted in the joined text (paragraph marks
32
+ included, overlaps counted). A line break inside a paragraph ends a phrase. Text outside
33
+ the main story (footnotes, headers) is not searched. When none is unique
34
+ ``find_phrase_unique`` is false with ``find_phrase_reason`` (``empty``, ``duplicate`` or
35
+ ``none_within_25_words``), and a non-empty paragraph gets ``context``: the find phrase of
36
+ the nearest earlier paragraph that has one, the paragraph it is in, and how many
37
+ paragraphs after it this one comes.
38
+
39
+ Cost: the indexes (prefix and suffix word tuples, up to ``MAX_SIDE`` long), the structure and
40
+ the page estimates are built once per call, then each paragraph is a handful of set
41
+ lookups. Whole-document mode is paged and each response builds only its own rows.
42
+ """
43
+
44
+ from __future__ import annotations
45
+
46
+ import re
47
+ from collections import Counter
48
+ from collections.abc import Callable, Sequence
49
+ from dataclasses import dataclass
50
+ from typing import Any, overload
51
+
52
+ from doc_control.envelope import Reply
53
+ from doc_control.model.document import Document
54
+ from doc_control.model.pages import estimate_pages
55
+ from doc_control.model.structure import detect, paragraphs_of
56
+ from doc_control.ops.paging import PAGING_PROPERTIES, paged
57
+ from doc_control.ops.segment import (
58
+ OVERRIDES_SCHEMA,
59
+ PROFILE_SCHEMA,
60
+ overrides_of,
61
+ profile_of,
62
+ )
63
+ from doc_control.registry import operation
64
+
65
+ MAX_SIDE = 12
66
+ SEPARATOR = " … "
67
+ _TITLE = 160
68
+ MAX_FIND_WORDS = 25
69
+ _WORD = re.compile(r"\S+")
70
+ _BREAK = re.compile(r"[\n\r\x0b\x0c]")
71
+
72
+
73
+ @dataclass(frozen=True)
74
+ class Locator:
75
+ phrase: str | None
76
+ head: int = 0
77
+ tail: int = 0
78
+ complete: bool = False
79
+ reason: str | None = None
80
+
81
+
82
+ def word_spans(text: str) -> list[tuple[int, int]]:
83
+ """``(start, end)`` of each whitespace-separated word of ``text``, characters untouched."""
84
+ return [(m.start(), m.end()) for m in _WORD.finditer(text)]
85
+
86
+
87
+ class LocatorIndex:
88
+ """Word prefixes and suffixes of every paragraph, to answer 'who else starts/ends so?'."""
89
+
90
+ def __init__(self, texts: list[str], spans: list[list[tuple[int, int]]] | None = None) -> None:
91
+ self.texts = texts
92
+ self.spans = spans if spans is not None else [word_spans(t) for t in texts]
93
+ self.keys = [
94
+ tuple(t[s:e].casefold() for s, e in sp) for t, sp in zip(texts, self.spans, strict=True)
95
+ ]
96
+ self.prefixes: dict[tuple[str, ...], set[int]] = {}
97
+ self.suffixes: dict[tuple[str, ...], set[int]] = {}
98
+ self.whole: dict[tuple[str, ...], int] = {}
99
+ for i, key in enumerate(self.keys):
100
+ if not key:
101
+ continue
102
+ self.whole[key] = self.whole.get(key, 0) + 1
103
+ for n in range(1, min(len(key), MAX_SIDE) + 1):
104
+ self.prefixes.setdefault(key[:n], set()).add(i)
105
+ self.suffixes.setdefault(key[-n:], set()).add(i)
106
+
107
+ def matches(self, i: int, head: int, tail: int) -> int:
108
+ """Paragraphs with at least ``head + tail`` words that start and end like paragraph i."""
109
+ key = self.keys[i]
110
+ starts = self.prefixes[key[:head]]
111
+ ends = self.suffixes[key[-tail:]]
112
+ small, big = (starts, ends) if len(starts) <= len(ends) else (ends, starts)
113
+ need = head + tail
114
+ return sum(1 for j in small if j in big and len(self.keys[j]) >= need)
115
+
116
+ def locator(self, i: int) -> Locator:
117
+ key = self.keys[i]
118
+ count = len(key)
119
+ if count == 0:
120
+ return Locator(None, reason="empty")
121
+ if self.whole[key] > 1:
122
+ return Locator(None, reason="duplicate")
123
+ text, spans = self.texts[i], self.spans[i]
124
+ for total in range(2, min(count, 2 * MAX_SIDE + 1)):
125
+ for head in range(min(total - 1, MAX_SIDE), 0, -1):
126
+ tail = total - head
127
+ if tail > MAX_SIDE:
128
+ break
129
+ if self.matches(i, head, tail) == 1:
130
+ first = text[spans[0][0] : spans[head - 1][1]]
131
+ last = text[spans[-tail][0] : spans[-1][1]]
132
+ return Locator(first + SEPARATOR + last, head, tail)
133
+ if count <= 2 * MAX_SIDE:
134
+ return Locator(text[spans[0][0] : spans[-1][1]], count, 0, complete=True)
135
+ return Locator(None, reason="not_unique_within_limit")
136
+
137
+
138
+ @dataclass(frozen=True)
139
+ class FindPhrase:
140
+ """A run of words that occurs once in the document (``text`` None when none does)."""
141
+
142
+ text: str | None
143
+ words: int = 0
144
+ start: int = 0 # character offset in the paragraph
145
+ reason: str | None = None
146
+
147
+
148
+ class FindIndex:
149
+ """Shortest words that Word's Find would meet exactly once in the whole document.
150
+
151
+ Find semantics: case-insensitive (``casefold``), accent-sensitive, on the document's own
152
+ characters (a no-break space stays one; whitespace is not collapsed); paragraph marks are
153
+ ``\\r`` in the searched text, and a line break inside a paragraph (or any ``\\n``, ``\\r``,
154
+ vertical tab, form feed) ends a phrase. Occurrences may overlap.
155
+
156
+ A word-aligned window that occurs twice cannot be unique, so counting windows of each
157
+ length (one pass per length over integer tokens, dropped afterwards) rules candidates
158
+ out cheaply; the survivors are confirmed with a substring search in the joined text.
159
+ """
160
+
161
+ def __init__(self, index: LocatorIndex) -> None:
162
+ self.texts = index.texts
163
+ self.spans = index.spans
164
+ self.haystack = "\r".join(t.casefold() for t in self.texts)
165
+ self._tokens = self._tokenise()
166
+ self._runs = [self._run_ends(t, sp) for t, sp in zip(self.texts, self.spans, strict=True)]
167
+ self._windows = sum(len(r) for r in index.spans)
168
+ self._copies = Counter(t.casefold() for t in self.texts)
169
+ self._keys = index.keys
170
+ self.found: list[FindPhrase] = []
171
+ self._solve()
172
+
173
+ def _tokenise(self) -> list[list[int]]:
174
+ """Per paragraph ``word, gap, word, ...`` as ints (words folded, gaps as written)."""
175
+ ids: dict[str, int] = {}
176
+ out: list[list[int]] = []
177
+ for text, spans in zip(self.texts, self.spans, strict=True):
178
+ toks: list[int] = []
179
+ for k, (begin, end) in enumerate(spans):
180
+ if k:
181
+ toks.append(ids.setdefault(text[spans[k - 1][1] : begin], len(ids)))
182
+ toks.append(ids.setdefault(text[begin:end].casefold(), len(ids)))
183
+ out.append(toks)
184
+ return out
185
+
186
+ @staticmethod
187
+ def _run_ends(text: str, spans: list[tuple[int, int]]) -> list[int]:
188
+ """Per word, the last word reachable from it without crossing a line or paragraph break."""
189
+ ends = [0] * len(spans)
190
+ for k in range(len(spans) - 1, -1, -1):
191
+ joined = k + 1 < len(spans) and not _BREAK.search(text, spans[k][1], spans[k + 1][0])
192
+ ends[k] = ends[k + 1] if joined else k
193
+ return ends
194
+
195
+ def _solve(self) -> None:
196
+ count = len(self.texts)
197
+ self.found = [FindPhrase(None, reason=f"none_within_{MAX_FIND_WORDS}_words")] * count
198
+ open_: list[int] = []
199
+ for i, key in enumerate(self._keys):
200
+ if not key:
201
+ self.found[i] = FindPhrase(None, reason="empty")
202
+ elif self._copies[self.texts[i].casefold()] > 1:
203
+ # Every window of a repeated paragraph occurs in its twin as well.
204
+ self.found[i] = FindPhrase(None, reason="duplicate")
205
+ else:
206
+ open_.append(i)
207
+ for length in range(1, MAX_FIND_WORDS + 1):
208
+ if not open_:
209
+ break
210
+ seen: Counter[tuple[int, ...]] | None = None
211
+ # A substring search costs about 300 window counts: with few paragraphs left,
212
+ # skip the counting pass and confirm their windows directly.
213
+ if sum(max(len(self._runs[i]) - length + 1, 0) for i in open_) * 300 > self._windows:
214
+ seen = Counter()
215
+ for toks, runs in zip(self._tokens, self._runs, strict=True):
216
+ for s in range(len(runs) - length + 1):
217
+ if runs[s] >= s + length - 1:
218
+ seen[tuple(toks[2 * s : 2 * (s + length) - 1])] += 1
219
+ still: list[int] = []
220
+ for i in open_:
221
+ hit = self._pick(i, length, seen)
222
+ if hit is None:
223
+ still.append(i)
224
+ else:
225
+ self.found[i] = hit
226
+ open_ = still
227
+
228
+ def _pick(
229
+ self, i: int, length: int, seen: Counter[tuple[int, ...]] | None
230
+ ) -> FindPhrase | None:
231
+ text, spans, runs = self.texts[i], self.spans[i], self._runs[i]
232
+ starts = range(len(spans) - length + 1) # the paragraph's start comes first
233
+ for s in starts:
234
+ if runs[s] < s + length - 1:
235
+ continue
236
+ begin, end = spans[s][0], spans[s + length - 1][1]
237
+ if seen is not None and seen[tuple(self._tokens[i][2 * s : 2 * (s + length) - 1])] != 1:
238
+ continue
239
+ key = text[begin:end].casefold()
240
+ first = self.haystack.find(key)
241
+ if first >= 0 and self.haystack.find(key, first + 1) < 0:
242
+ return FindPhrase(text[begin:end], length, begin)
243
+ return None
244
+
245
+
246
+ class _Rows(Sequence[dict[str, Any]]):
247
+ """Rows built only for the slice a page asks for; the indexes behind them are shared."""
248
+
249
+ def __init__(self, numbers: list[int], build: Callable[[int], dict[str, Any]]) -> None:
250
+ self._numbers = numbers
251
+ self._build = build
252
+
253
+ def __len__(self) -> int:
254
+ return len(self._numbers)
255
+
256
+ @overload
257
+ def __getitem__(self, item: int) -> dict[str, Any]: ...
258
+ @overload
259
+ def __getitem__(self, item: slice) -> list[dict[str, Any]]: ...
260
+ def __getitem__(self, item: int | slice) -> dict[str, Any] | list[dict[str, Any]]:
261
+ if isinstance(item, slice):
262
+ return [self._build(n) for n in self._numbers[item]]
263
+ return self._build(self._numbers[item])
264
+
265
+
266
+ @operation(
267
+ "locate",
268
+ "read",
269
+ "Chapter, scene, estimated page and the shortest unique first-and-last-words phrase of "
270
+ "each paragraph, plus a phrase Word's Find meets once (one, a range, or the whole "
271
+ "document, paged).",
272
+ {
273
+ "type": "object",
274
+ "properties": {
275
+ "doc": {"type": "string", "minLength": 1, "description": "Path to the .docx."},
276
+ "at": {
277
+ "type": "string",
278
+ "minLength": 1,
279
+ "description": "Paragraph address or range (offsets ignored), e.g. p142#a7b2a7b2; "
280
+ "default the whole document (empty paragraphs left out).",
281
+ },
282
+ "overrides": OVERRIDES_SCHEMA,
283
+ "profile": PROFILE_SCHEMA,
284
+ **PAGING_PROPERTIES,
285
+ },
286
+ "required": ["doc"],
287
+ "additionalProperties": False,
288
+ },
289
+ )
290
+ def locate(request: dict[str, Any]) -> Reply:
291
+ doc = Document(request["doc"])
292
+ index = doc.index
293
+ count = len(index)
294
+ texts = [index.paragraph(n).text for n in range(1, count + 1)]
295
+ found = detect(paragraphs_of(doc), profile_of(request), overrides_of(request))
296
+ pages = estimate_pages(doc)
297
+ locators = LocatorIndex(texts)
298
+ finds = FindIndex(locators).found
299
+
300
+ chapter_at = [0] * (count + 2) # 1-based paragraph -> 1-based chapter, 0 for none
301
+ for k, chapter in enumerate(found.chapters, 1):
302
+ for n in range(chapter.first, chapter.last + 1):
303
+ chapter_at[n] = k
304
+ scene_at = [0] * (count + 2)
305
+ ordinal: dict[str | None, int] = {}
306
+ ordinals = [0]
307
+ for k, scene in enumerate(found.scenes, 1):
308
+ ordinal[scene.chapter] = ordinal.get(scene.chapter, 0) + 1
309
+ ordinals.append(ordinal[scene.chapter])
310
+ for n in range(scene.first, scene.last + 1):
311
+ scene_at[n] = k
312
+
313
+ if "at" in request:
314
+ resolved = index.resolve_range(request["at"])
315
+ numbers = range(resolved.first.number, resolved.last.number + 1)
316
+ else:
317
+ numbers = range(1, count + 1)
318
+ wanted = [n for n in numbers if "at" in request or texts[n - 1].strip()]
319
+
320
+ def row(n: int) -> dict[str, Any]:
321
+ page = pages.rows[n - 1]
322
+ chapter = found.chapters[chapter_at[n] - 1] if chapter_at[n] else None
323
+ scene = found.scenes[scene_at[n] - 1] if scene_at[n] else None
324
+ loc = locators.locator(n - 1)
325
+ out: dict[str, Any] = {
326
+ "at": str(index.paragraph(n).address),
327
+ "chapter": None
328
+ if chapter is None
329
+ else {
330
+ "id": chapter.id,
331
+ "title": " ".join(index.paragraph(chapter.first).text.split())[:_TITLE],
332
+ "number": chapter.number,
333
+ "kind": chapter.kind,
334
+ },
335
+ "scene": None if scene is None else {"id": scene.id, "ordinal": ordinals[scene_at[n]]},
336
+ "page": page.page,
337
+ "page_label": page.label,
338
+ "phrase": loc.phrase,
339
+ }
340
+ find = finds[n - 1]
341
+ out["find_phrase"] = find.text
342
+ out["find_phrase_unique"] = find.text is not None
343
+ if find.text is not None:
344
+ out["find_phrase_words"] = find.words
345
+ else:
346
+ out["find_phrase_reason"] = find.reason
347
+ earlier = next((j for j in range(n - 2, -1, -1) if finds[j].text is not None), None)
348
+ if earlier is not None and find.reason != "empty":
349
+ out["context"] = {
350
+ "after": finds[earlier].text,
351
+ "at": str(index.paragraph(earlier + 1).address),
352
+ "paragraphs_after": n - 1 - earlier,
353
+ }
354
+ if loc.phrase is None:
355
+ out["reason"] = loc.reason
356
+ else:
357
+ out["head_words"] = loc.head
358
+ out["tail_words"] = loc.tail
359
+ if loc.complete:
360
+ out["complete"] = True
361
+ return out
362
+
363
+ head: dict[str, Any] = {"estimated": True, "page_count": pages.pages, "basis": pages.basis}
364
+ return paged("locate", doc, request, "paragraphs", _Rows(wanted, row), head)
@@ -0,0 +1,49 @@
1
+ """Self-description: capabilities and help."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from doc_control import API_VERSION, __version__
8
+ from doc_control.errors import HINTS, DocControlError, ErrorCode
9
+ from doc_control.registry import REGISTRY, operation
10
+
11
+ _EMPTY: dict[str, Any] = {"type": "object", "properties": {}, "additionalProperties": False}
12
+
13
+
14
+ @operation("capabilities", "read", "API version, engines and the list of operations.", _EMPTY)
15
+ def capabilities(_: dict[str, Any]) -> dict[str, Any]:
16
+ return {
17
+ "api": API_VERSION,
18
+ "version": __version__,
19
+ "engines": ["file"],
20
+ "operations": [
21
+ {"name": op.name, "kind": op.kind, "summary": op.summary}
22
+ for op in sorted(REGISTRY.values(), key=lambda o: o.name)
23
+ ],
24
+ "errors": sorted(code.value for code in HINTS),
25
+ }
26
+
27
+
28
+ @operation(
29
+ "help",
30
+ "read",
31
+ "JSON Schema of one operation, or of all operations.",
32
+ {
33
+ "type": "object",
34
+ "properties": {"op": {"type": "string", "description": "Operation name."}},
35
+ "additionalProperties": False,
36
+ },
37
+ )
38
+ def help_(request: dict[str, Any]) -> dict[str, Any]:
39
+ name = request.get("op")
40
+ if name is None:
41
+ return {"operations": {n: _describe(n) for n in sorted(REGISTRY)}}
42
+ if name not in REGISTRY:
43
+ raise DocControlError(ErrorCode.SCHEMA_INVALID, f"unknown operation: {name}", op=name)
44
+ return {"operations": {name: _describe(name)}}
45
+
46
+
47
+ def _describe(name: str) -> dict[str, Any]:
48
+ op = REGISTRY[name]
49
+ return {"kind": op.kind, "summary": op.summary, "schema": op.schema}
@@ -0,0 +1,104 @@
1
+ """`page.estimate`: the estimated page of every paragraph, always marked as an estimate.
2
+
3
+ The model and its constants are documented in :mod:`doc_control.model.pages`. The list is
4
+ capped and continues with `next` like the other read operations.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import Any
10
+
11
+ from doc_control.envelope import Reply
12
+ from doc_control.model.document import Document
13
+ from doc_control.model.pages import CONSTANTS, NOTES, PageEstimate, estimate_pages
14
+ from doc_control.ops.paging import PAGING_PROPERTIES, paged
15
+ from doc_control.registry import operation
16
+
17
+
18
+ def _page_rows(est: PageEstimate) -> list[dict[str, Any]]:
19
+ """One row per page that has a paragraph starting on it."""
20
+ rows: list[dict[str, Any]] = []
21
+ for row in est.rows:
22
+ last = rows[-1] if rows else None
23
+ if last is not None and last["page"] == row.page:
24
+ last["to"] = row.number
25
+ last["paragraphs"] += 1
26
+ else:
27
+ rows.append(
28
+ {
29
+ "page": row.page,
30
+ "page_label": row.label,
31
+ "from": row.number,
32
+ "to": row.number,
33
+ "paragraphs": 1,
34
+ }
35
+ )
36
+ return rows
37
+
38
+
39
+ @operation(
40
+ "page.estimate",
41
+ "read",
42
+ "Estimated page of each paragraph (or of one): rendered page marks and explicit "
43
+ "breaks first, a word-count model otherwise. Always `estimated: true`.",
44
+ {
45
+ "type": "object",
46
+ "properties": {
47
+ "doc": {"type": "string", "minLength": 1, "description": "Path to the .docx."},
48
+ "at": {
49
+ "type": "string",
50
+ "minLength": 1,
51
+ "description": "Only this paragraph, by address, e.g. p142#a7b2a7b2.",
52
+ },
53
+ "by": {
54
+ "type": "string",
55
+ "enum": ["paragraph", "page"],
56
+ "description": "List one row per paragraph (default) or per page.",
57
+ },
58
+ **PAGING_PROPERTIES,
59
+ },
60
+ "required": ["doc"],
61
+ "additionalProperties": False,
62
+ },
63
+ )
64
+ def page_estimate(request: dict[str, Any]) -> Reply:
65
+ doc = Document(request["doc"])
66
+ est = estimate_pages(doc)
67
+ head: dict[str, Any] = {
68
+ "estimated": True,
69
+ "page_count": est.pages,
70
+ "basis": est.basis,
71
+ "words_per_page": est.words_per_page,
72
+ "rendered_marks": est.marks,
73
+ "model": CONSTANTS,
74
+ "notes": list(NOTES),
75
+ }
76
+ if "at" in request:
77
+ para = doc.index.resolve_address(request["at"])
78
+ row = est.rows[para.number - 1]
79
+ result = {
80
+ **head,
81
+ "at": str(para.address),
82
+ "page": row.page,
83
+ "page_label": row.label,
84
+ "source": row.source,
85
+ }
86
+ if row.end_page != row.page:
87
+ result["end_page"] = row.end_page
88
+ result["end_page_label"] = row.end_label
89
+ return Reply(result, doc=doc.envelope_doc())
90
+ if request.get("by") == "page":
91
+ return paged("page.estimate", doc, request, "pages", _page_rows(est), head)
92
+ items: list[dict[str, Any]] = []
93
+ for row in est.rows:
94
+ item: dict[str, Any] = {
95
+ "at": str(doc.index.paragraph(row.number).address),
96
+ "page": row.page,
97
+ "page_label": row.label,
98
+ "source": row.source,
99
+ }
100
+ if row.end_page != row.page:
101
+ item["end_page"] = row.end_page
102
+ item["end_page_label"] = row.end_label
103
+ items.append(item)
104
+ return paged("page.estimate", doc, request, "paragraphs", items, head)
@@ -0,0 +1,124 @@
1
+ """Response size cap and cursors for read operations.
2
+
3
+ A default response never exceeds :data:`RESPONSE_BUDGET` bytes (about 2,000
4
+ tokens at 4 bytes per token). Lists stop at the budget, or at the request's
5
+ ``limit``, and say where to continue in ``next``. A cursor is
6
+ ``c:<op>:<sha8>:<offset>``: it is tied to the document's hash, so reading on
7
+ after the file changed is ``DOC_CHANGED_ON_DISK`` rather than a silent skip.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import hashlib
13
+ import json
14
+ import re
15
+ from collections.abc import Sequence
16
+ from typing import Any
17
+
18
+ from doc_control.envelope import Reply, success
19
+ from doc_control.errors import DocControlError, ErrorCode
20
+ from doc_control.model.document import Document
21
+
22
+ RESPONSE_BUDGET = 8000
23
+ MAX_LIMIT = 1000
24
+ _CURSOR = re.compile(
25
+ r"c:(?P<op>[a-z.]+):(?P<sha>[0-9a-f]{8}):(?P<scope>[0-9a-f]{6}):(?P<offset>0|[1-9][0-9]{0,11})"
26
+ )
27
+ _NOT_SCOPE = frozenset({"doc", "limit", "cursor"})
28
+
29
+ #: Properties every list request accepts, to merge into an operation's schema.
30
+ PAGING_PROPERTIES: dict[str, Any] = {
31
+ "limit": {
32
+ "type": "integer",
33
+ "minimum": 1,
34
+ "maximum": MAX_LIMIT,
35
+ "description": "Most items to return; a response is also capped at about 8,000 bytes.",
36
+ },
37
+ "cursor": {
38
+ "type": "string",
39
+ "minLength": 1,
40
+ "description": "The `next` value of the previous response, to continue the list.",
41
+ },
42
+ }
43
+
44
+
45
+ def _scope(request: dict[str, Any]) -> str:
46
+ """Short hash of what selects the items (filters, target address), not of paging."""
47
+ chosen = {k: v for k, v in sorted(request.items()) if k not in _NOT_SCOPE}
48
+ return hashlib.sha256(json.dumps(chosen, sort_keys=True).encode()).hexdigest()[:6]
49
+
50
+
51
+ def make_cursor(op: str, doc: Document, request: dict[str, Any], offset: int) -> str:
52
+ return f"c:{op}:{doc.sha256[:8]}:{_scope(request)}:{offset}"
53
+
54
+
55
+ def read_cursor(op: str, doc: Document, request: dict[str, Any]) -> int:
56
+ """The offset the request's cursor continues from (0 without one).
57
+
58
+ A cursor only continues the request that issued it: the same operation, the same
59
+ filters or address, and the same file.
60
+ """
61
+ cursor = request.get("cursor")
62
+ if cursor is None:
63
+ return 0
64
+ m = _CURSOR.fullmatch(cursor)
65
+ if m is None or m["op"] != op:
66
+ raise DocControlError(
67
+ ErrorCode.SCHEMA_INVALID, f"cursor is not a {op} cursor: {cursor!r}", path="$.cursor"
68
+ )
69
+ if m["scope"] != _scope(request):
70
+ raise DocControlError(
71
+ ErrorCode.SCHEMA_INVALID,
72
+ "cursor was issued for a request with different filters or address",
73
+ path="$.cursor",
74
+ )
75
+ if m["sha"] != doc.sha256[:8]:
76
+ raise DocControlError(
77
+ ErrorCode.DOC_CHANGED_ON_DISK,
78
+ f"{doc.path} changed since the cursor was issued",
79
+ path=doc.path,
80
+ actual_sha256=doc.sha256,
81
+ )
82
+ return int(m["offset"])
83
+
84
+
85
+ def size(envelope: dict[str, Any]) -> int:
86
+ """Bytes of the envelope as the CLI prints it."""
87
+ return len(json.dumps(envelope, ensure_ascii=False).encode("utf-8"))
88
+
89
+
90
+ def paged(
91
+ op: str,
92
+ doc: Document,
93
+ request: dict[str, Any],
94
+ key: str,
95
+ items: Sequence[Any],
96
+ base: dict[str, Any] | None = None,
97
+ ) -> Reply:
98
+ """A list response: ``base`` fields plus ``key`` holding the page, ``total`` and ``next``.
99
+
100
+ Takes items from the cursor's offset while the whole envelope stays within the
101
+ budget and the limit; always at least one, so a list always advances.
102
+ """
103
+ start = read_cursor(op, doc, request)
104
+ limit = request.get("limit", MAX_LIMIT)
105
+ head: dict[str, Any] = {**(base or {}), "total": len(items), key: []}
106
+ placeholder = make_cursor(op, doc, request, 10**12)
107
+ room = RESPONSE_BUDGET - size(
108
+ success(op, head, doc=doc.envelope_doc(), next_cursor=placeholder)
109
+ )
110
+ taken: list[Any] = []
111
+ used = 0
112
+ for item in items[start : start + limit]:
113
+ cost = len(json.dumps(item, ensure_ascii=False).encode("utf-8")) + 2 # ", " between items
114
+ if taken and used + cost > room:
115
+ break
116
+ taken.append(item)
117
+ used += cost
118
+ end = start + len(taken)
119
+ head[key] = taken
120
+ return Reply(
121
+ head,
122
+ doc=doc.envelope_doc(),
123
+ next_cursor=make_cursor(op, doc, request, end) if end < len(items) else None,
124
+ )