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.
- doc_control/__init__.py +4 -0
- doc_control/__main__.py +3 -0
- doc_control/cli.py +80 -0
- doc_control/envelope.py +40 -0
- doc_control/errors.py +72 -0
- doc_control/model/__init__.py +1 -0
- doc_control/model/address.py +113 -0
- doc_control/model/align.py +529 -0
- doc_control/model/anchor.py +609 -0
- doc_control/model/annotations.py +280 -0
- doc_control/model/document.py +109 -0
- doc_control/model/format.py +109 -0
- doc_control/model/index.py +353 -0
- doc_control/model/layout.py +143 -0
- doc_control/model/normalized.py +160 -0
- doc_control/model/pages.py +403 -0
- doc_control/model/parts.py +50 -0
- doc_control/model/search.py +403 -0
- doc_control/model/segment.py +445 -0
- doc_control/model/structure.py +418 -0
- doc_control/model/styles.py +134 -0
- doc_control/ooxml/__init__.py +1 -0
- doc_control/ooxml/commentparts.py +679 -0
- doc_control/ooxml/formatting.py +181 -0
- doc_control/ooxml/resolve.py +269 -0
- doc_control/ooxml/revisions.py +1024 -0
- doc_control/ooxml/splice.py +957 -0
- doc_control/ooxml/styledef.py +504 -0
- doc_control/ooxml/zipio.py +454 -0
- doc_control/ops/__init__.py +1 -0
- doc_control/ops/anchor.py +135 -0
- doc_control/ops/batch.py +177 -0
- doc_control/ops/comments.py +433 -0
- doc_control/ops/compare.py +620 -0
- doc_control/ops/diff.py +177 -0
- doc_control/ops/dotx.py +206 -0
- doc_control/ops/find.py +190 -0
- doc_control/ops/history.py +479 -0
- doc_control/ops/layout.py +40 -0
- doc_control/ops/locate.py +364 -0
- doc_control/ops/meta.py +49 -0
- doc_control/ops/page.py +104 -0
- doc_control/ops/paging.py +124 -0
- doc_control/ops/read.py +517 -0
- doc_control/ops/recipe.py +108 -0
- doc_control/ops/rename.py +370 -0
- doc_control/ops/revision.py +616 -0
- doc_control/ops/segment.py +289 -0
- doc_control/ops/styleset.py +262 -0
- doc_control/ops/stylewrite.py +573 -0
- doc_control/ops/text.py +207 -0
- doc_control/ops/tracked.py +1441 -0
- doc_control/py.typed +0 -0
- doc_control/recipes/engine.py +209 -0
- doc_control/recipes/schema.py +140 -0
- doc_control/recipes/store.py +96 -0
- doc_control/registry.py +76 -0
- doc_control/safety/__init__.py +38 -0
- doc_control/safety/history.py +508 -0
- doc_control/safety/lock.py +69 -0
- doc_control/safety/store.py +415 -0
- doc_control/safety/verify.py +357 -0
- doc_control/schema.py +143 -0
- doc_control-0.0.1.dev0.dist-info/METADATA +62 -0
- doc_control-0.0.1.dev0.dist-info/RECORD +68 -0
- doc_control-0.0.1.dev0.dist-info/WHEEL +4 -0
- doc_control-0.0.1.dev0.dist-info/entry_points.txt +2 -0
- 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)
|
doc_control/ops/meta.py
ADDED
|
@@ -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}
|
doc_control/ops/page.py
ADDED
|
@@ -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
|
+
)
|