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,454 @@
1
+ """Read a .docx package and write a new one that changes only the given parts.
2
+
3
+ Untouched members are copied raw: their compressed bytes, method, flags,
4
+ timestamps, names, extra fields, attributes and comments go into the new file
5
+ verbatim, and so does the order. Replaced members keep their header fields and
6
+ are recompressed with their original method; new members are appended,
7
+ deflated. The new file is written next to the target with the target's
8
+ permission bits and moved into place with an atomic replace, so a failure never
9
+ leaves a half-written document.
10
+
11
+ The writer is our own (stdlib ``zipfile`` only parses and validates), because
12
+ ``zipfile`` can only write a member by recompressing it. ZIP64 archives and
13
+ encrypted members are refused: Word never writes them for normal documents.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import hashlib
19
+ import io
20
+ import os
21
+ import stat
22
+ import struct
23
+ import tempfile
24
+ import time
25
+ import zipfile
26
+ import zlib
27
+ from collections import Counter
28
+ from collections.abc import Callable, Mapping
29
+ from pathlib import Path
30
+
31
+ from doc_control.errors import DocControlError, ErrorCode
32
+
33
+ REQUIRED_PARTS = ("[Content_Types].xml", "word/document.xml")
34
+
35
+ _LOCAL = struct.Struct("<4s5H3L2H")
36
+ _CENTRAL = struct.Struct("<4s4B4HL2L5H2L")
37
+ _END = struct.Struct("<4s4H2LH")
38
+ _LOCAL_SIG = b"PK\x03\x04"
39
+ _CENTRAL_SIG = b"PK\x01\x02"
40
+ _END_SIG = b"PK\x05\x06"
41
+ _ZIP64_LOCATOR_SIG = b"PK\x06\x07"
42
+ _ZIP64_EXTRA_ID = 0x0001
43
+ _U16 = 0xFFFF
44
+ _U32 = 0xFFFFFFFF
45
+ _FLAG_ENCRYPTED = 0x0001
46
+ _FLAG_DATA_DESCRIPTOR = 0x0008
47
+ _FLAG_UTF8 = 0x0800
48
+ # Deflate option bits 1-2 of the general purpose flags -> zlib level.
49
+ _DEFLATE_LEVELS = {0: 6, 1: 9, 2: 1, 3: 1}
50
+ _SUPPORTED_METHODS = (zipfile.ZIP_STORED, zipfile.ZIP_DEFLATED)
51
+ _READ_ERRORS = (
52
+ zipfile.BadZipFile,
53
+ zlib.error,
54
+ NotImplementedError,
55
+ RuntimeError,
56
+ EOFError,
57
+ OSError,
58
+ ValueError,
59
+ struct.error,
60
+ )
61
+
62
+
63
+ class _Member:
64
+ """A member's central-directory record plus where its raw data lives."""
65
+
66
+ def __init__(self, info: zipfile.ZipInfo, raw: bytes) -> None:
67
+ self.info = info
68
+ off = info.header_offset
69
+ if raw[off : off + 4] != _LOCAL_SIG:
70
+ raise zipfile.BadZipFile(f"bad local header for {info.filename}")
71
+ fields = _LOCAL.unpack_from(raw, off)
72
+ nlen, xlen = fields[9], fields[10]
73
+ start = off + _LOCAL.size
74
+ self.local_extra = raw[start + nlen : start + nlen + xlen]
75
+ data_at = start + nlen + xlen
76
+ if data_at + info.compress_size > len(raw):
77
+ raise zipfile.BadZipFile(f"truncated data for {info.filename}")
78
+ self.data = raw[data_at : data_at + info.compress_size]
79
+
80
+ @property
81
+ def name_bytes(self) -> bytes:
82
+ return _encode_name(self.info.orig_filename, self.info.flag_bits)
83
+
84
+
85
+ class Package:
86
+ """A read-only view of a .docx zip, with members in their original order."""
87
+
88
+ def __init__(self, path: Path) -> None:
89
+ self.path = path
90
+ if not path.is_file():
91
+ raise DocControlError(ErrorCode.DOC_NOT_FOUND, f"no such file: {path}", path=str(path))
92
+ raw = path.read_bytes()
93
+ self.sha256 = hashlib.sha256(raw).hexdigest()
94
+ try:
95
+ self._load(raw)
96
+ except DocControlError:
97
+ raise
98
+ except _READ_ERRORS as exc:
99
+ raise DocControlError(
100
+ ErrorCode.NOT_A_DOCX, f"corrupt zip package {path}: {exc}", path=str(path)
101
+ ) from exc
102
+ missing = [name for name in REQUIRED_PARTS if name not in self._data]
103
+ if missing:
104
+ raise DocControlError(
105
+ ErrorCode.NOT_A_DOCX,
106
+ f"missing parts: {', '.join(missing)}",
107
+ missing=missing,
108
+ path=str(path),
109
+ )
110
+
111
+ def _load(self, raw: bytes) -> None:
112
+ path = str(self.path)
113
+ end = _find_end(raw)
114
+ if end < 0:
115
+ raise DocControlError(ErrorCode.NOT_A_DOCX, f"not a zip package: {path}", path=path)
116
+ if _has_zip64_end(raw, end):
117
+ raise _unsupported(path, "ZIP64 archives are not supported")
118
+ # zipfile finds the end record with rfind, so an archive comment holding an end
119
+ # signature misleads it. Hand it the archive without the comment (member offsets
120
+ # do not move) and keep the comment ourselves.
121
+ self.comment = raw[end + _END.size :]
122
+ view = raw[:end] + _END.pack(*_END.unpack_from(raw, end)[:7], 0)
123
+ with zipfile.ZipFile(io.BytesIO(view)) as zf:
124
+ self.infos: list[zipfile.ZipInfo] = zf.infolist()
125
+ dupes = sorted(n for n, c in Counter(i.filename for i in self.infos).items() if c > 1)
126
+ if dupes:
127
+ raise DocControlError(
128
+ ErrorCode.NOT_A_DOCX,
129
+ f"duplicate member names: {', '.join(dupes)}",
130
+ duplicates=dupes,
131
+ path=path,
132
+ )
133
+ self._members: dict[str, _Member] = {}
134
+ self._data: dict[str, bytes] = {}
135
+ for info in self.infos:
136
+ name = info.filename
137
+ if info.flag_bits & _FLAG_ENCRYPTED:
138
+ raise _unsupported(path, f"encrypted member: {name}")
139
+ member = _Member(info, raw)
140
+ if _is_zip64(info, member.local_extra):
141
+ raise _unsupported(path, f"ZIP64 member: {name}")
142
+ if info.compress_type not in _SUPPORTED_METHODS:
143
+ raise zipfile.BadZipFile(
144
+ f"unsupported compression method {info.compress_type} in {name}"
145
+ )
146
+ try:
147
+ self._data[name] = zf.read(info)
148
+ except _READ_ERRORS as exc:
149
+ raise zipfile.BadZipFile(f"{name}: {exc}") from exc
150
+ self._members[name] = member
151
+
152
+ @property
153
+ def names(self) -> list[str]:
154
+ return [info.filename for info in self.infos]
155
+
156
+ def read(self, name: str) -> bytes:
157
+ return self._data[name]
158
+
159
+ def __contains__(self, name: object) -> bool:
160
+ return name in self._data
161
+
162
+ def fingerprint(self, name: str) -> tuple[object, ...]:
163
+ """Everything a verbatim copy of member ``name`` keeps: raw bytes and header fields."""
164
+ info = self._members[name].info
165
+ return (
166
+ self._members[name].data,
167
+ info.compress_type,
168
+ info.flag_bits,
169
+ info.CRC,
170
+ info.date_time,
171
+ info.extra,
172
+ self._members[name].local_extra,
173
+ info.comment,
174
+ info.external_attr,
175
+ )
176
+
177
+
178
+ def sha256_file(path: Path) -> str:
179
+ digest = hashlib.sha256()
180
+ with path.open("rb") as fh:
181
+ for chunk in iter(lambda: fh.read(1 << 20), b""):
182
+ digest.update(chunk)
183
+ return digest.hexdigest()
184
+
185
+
186
+ def write_package(
187
+ pkg: Package,
188
+ replacements: Mapping[str, bytes],
189
+ dest: Path,
190
+ stage: Callable[[str], None] | None = None,
191
+ ) -> str:
192
+ """Write ``pkg`` with ``replacements`` applied to ``dest``; return its sha256.
193
+
194
+ Names not in the package are appended as new deflated members. ``stage``, if
195
+ given, is called with ``temp_write``, ``fsync`` and ``replace`` just before
196
+ each step (the seam fault injection uses); whatever it raises propagates
197
+ after the temp file is removed, with ``dest`` untouched.
198
+ """
199
+ for name, data in replacements.items():
200
+ if not isinstance(data, bytes):
201
+ raise TypeError(f"replacement for {name} must be bytes")
202
+ payload = build_package(pkg, replacements)
203
+ commit_bytes(payload, dest, stage)
204
+ return hashlib.sha256(payload).hexdigest()
205
+
206
+
207
+ def build_package(pkg: Package, replacements: Mapping[str, bytes]) -> bytes:
208
+ """The bytes ``write_package`` would write, without touching any file."""
209
+ return _build(pkg, replacements)
210
+
211
+
212
+ def restore_file(src: Path, dest: Path) -> None:
213
+ """Put the bytes of ``src`` at ``dest`` with the same atomic replace a write uses."""
214
+ commit_bytes(src.read_bytes(), dest, None)
215
+
216
+
217
+ def commit_bytes(
218
+ payload: bytes,
219
+ dest: Path,
220
+ stage: Callable[[str], None] | None,
221
+ *,
222
+ exclusive: bool = False,
223
+ ) -> None:
224
+ """Write ``payload`` to ``dest`` via a same-directory temp file and an atomic replace.
225
+
226
+ With ``exclusive`` the file is created only if ``dest`` does not exist, also if it
227
+ appears meanwhile (``FileExistsError``): a hard link, or an exclusive copy where
228
+ links are unsupported, never a replace.
229
+ """
230
+
231
+ def at(name: str) -> None:
232
+ if stage is not None:
233
+ stage(name)
234
+
235
+ mode = stat.S_IMODE(dest.stat().st_mode) if dest.exists() else 0o666 & ~_umask()
236
+ fd, tmp_name = tempfile.mkstemp(dir=dest.parent, prefix=f".{dest.name}.", suffix=".tmp")
237
+ tmp = Path(tmp_name)
238
+ try:
239
+ with os.fdopen(fd, "wb") as fh:
240
+ at("temp_write")
241
+ fh.write(payload)
242
+ fh.flush()
243
+ at("fsync")
244
+ os.fsync(fh.fileno())
245
+ tmp.chmod(mode)
246
+ at("replace")
247
+ if exclusive:
248
+ _claim(tmp, dest)
249
+ else:
250
+ _replace(tmp, dest)
251
+ except BaseException:
252
+ tmp.unlink(missing_ok=True)
253
+ raise
254
+
255
+
256
+ def _claim(tmp: Path, dest: Path) -> None:
257
+ """Give ``tmp`` the name ``dest`` without replacing an existing file."""
258
+ try:
259
+ os.link(tmp, dest)
260
+ except FileExistsError:
261
+ raise
262
+ except (OSError, NotImplementedError):
263
+ if os.name == "nt":
264
+ tmp.rename(dest) # Windows rename never clobbers
265
+ return
266
+ fd = os.open(dest, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o666)
267
+ try:
268
+ with os.fdopen(fd, "wb") as out:
269
+ out.write(tmp.read_bytes())
270
+ out.flush()
271
+ os.fsync(out.fileno())
272
+ except BaseException:
273
+ dest.unlink(missing_ok=True)
274
+ raise
275
+ tmp.unlink()
276
+
277
+
278
+ def _build(pkg: Package, replacements: Mapping[str, bytes]) -> bytes:
279
+ out = bytearray()
280
+ central = bytearray()
281
+ count = 0
282
+
283
+ def emit(
284
+ info: zipfile.ZipInfo,
285
+ name: bytes,
286
+ flags: int,
287
+ method: int,
288
+ crc: int,
289
+ size: int,
290
+ data: bytes,
291
+ local_extra: bytes,
292
+ ) -> None:
293
+ nonlocal count
294
+ offset = len(out)
295
+ if offset > _U32 or len(data) > _U32 or size > _U32:
296
+ raise _unsupported(str(pkg.path), "result would need ZIP64")
297
+ dos_time, dos_date = _dos_datetime(info.date_time)
298
+ out.extend(
299
+ _LOCAL.pack(
300
+ _LOCAL_SIG,
301
+ info.extract_version,
302
+ flags,
303
+ method,
304
+ dos_time,
305
+ dos_date,
306
+ crc,
307
+ len(data),
308
+ size,
309
+ len(name),
310
+ len(local_extra),
311
+ )
312
+ )
313
+ out.extend(name + local_extra + data)
314
+ central.extend(
315
+ _CENTRAL.pack(
316
+ _CENTRAL_SIG,
317
+ info.create_version,
318
+ info.create_system,
319
+ info.extract_version,
320
+ info.reserved,
321
+ flags,
322
+ method,
323
+ dos_time,
324
+ dos_date,
325
+ crc,
326
+ len(data),
327
+ size,
328
+ len(name),
329
+ len(info.extra),
330
+ len(info.comment),
331
+ 0,
332
+ info.internal_attr,
333
+ info.external_attr,
334
+ offset,
335
+ )
336
+ )
337
+ central.extend(name + info.extra + info.comment)
338
+ count += 1
339
+
340
+ for info in pkg.infos:
341
+ member = pkg._members[info.filename]
342
+ flags = info.flag_bits & ~_FLAG_DATA_DESCRIPTOR
343
+ if info.filename in replacements:
344
+ content = replacements[info.filename]
345
+ data = _compress(content, info.compress_type, flags)
346
+ crc, size = zlib.crc32(content), len(content)
347
+ else:
348
+ data, crc, size = member.data, info.CRC, info.file_size
349
+ emit(
350
+ info, member.name_bytes, flags, info.compress_type, crc, size, data, member.local_extra
351
+ )
352
+
353
+ for name, content in replacements.items():
354
+ if name in pkg:
355
+ continue
356
+ info = zipfile.ZipInfo(name, time.localtime()[:6])
357
+ info.create_system = 0
358
+ info.create_version = info.extract_version = 20
359
+ info.external_attr = 0
360
+ flags = 0 if name.isascii() else _FLAG_UTF8
361
+ data = _compress(content, zipfile.ZIP_DEFLATED, flags)
362
+ emit(
363
+ info,
364
+ _encode_name(name, flags),
365
+ flags,
366
+ zipfile.ZIP_DEFLATED,
367
+ zlib.crc32(content),
368
+ len(content),
369
+ data,
370
+ b"",
371
+ )
372
+
373
+ if count > _U16 or len(out) > _U32 or len(central) > _U32:
374
+ raise _unsupported(str(pkg.path), "result would need ZIP64")
375
+ cd_offset = len(out)
376
+ out.extend(central)
377
+ out.extend(_END.pack(_END_SIG, 0, 0, count, count, len(central), cd_offset, len(pkg.comment)))
378
+ out.extend(pkg.comment)
379
+ return bytes(out)
380
+
381
+
382
+ def _compress(content: bytes, method: int, flags: int) -> bytes:
383
+ if method == zipfile.ZIP_STORED:
384
+ return content
385
+ level = _DEFLATE_LEVELS[(flags >> 1) & 0x3]
386
+ comp = zlib.compressobj(level, zlib.DEFLATED, -15)
387
+ return comp.compress(content) + comp.flush()
388
+
389
+
390
+ def _dos_datetime(dt: tuple[int, int, int, int, int, int]) -> tuple[int, int]:
391
+ year, month, day, hour, minute, second = dt
392
+ return (hour << 11) | (minute << 5) | (second // 2), ((year - 1980) << 9) | (month << 5) | day
393
+
394
+
395
+ def _encode_name(name: str, flags: int) -> bytes:
396
+ return name.encode("utf-8" if flags & _FLAG_UTF8 else "cp437")
397
+
398
+
399
+ def _extra_ids(extra: bytes) -> set[int]:
400
+ ids: set[int] = set()
401
+ pos = 0
402
+ while pos + 4 <= len(extra):
403
+ tag, length = struct.unpack_from("<2H", extra, pos)
404
+ ids.add(tag)
405
+ pos += 4 + length
406
+ return ids
407
+
408
+
409
+ def _is_zip64(info: zipfile.ZipInfo, local_extra: bytes) -> bool:
410
+ if max(info.file_size, info.compress_size, info.header_offset) >= _U32:
411
+ return True
412
+ return _ZIP64_EXTRA_ID in _extra_ids(info.extra) | _extra_ids(local_extra)
413
+
414
+
415
+ def _umask() -> int:
416
+ """The process umask (read by setting and restoring it; there is no getter)."""
417
+ mask = os.umask(0o022)
418
+ os.umask(mask)
419
+ return mask
420
+
421
+
422
+ def _find_end(raw: bytes) -> int:
423
+ """Offset of the real end record: the last one whose comment runs exactly to EOF."""
424
+ pos = len(raw) - _END.size
425
+ floor = max(0, pos - _U16)
426
+ while pos >= floor:
427
+ pos = raw.rfind(_END_SIG, floor, pos + 4)
428
+ if pos < 0:
429
+ break
430
+ if _END.unpack_from(raw, pos)[7] == len(raw) - pos - _END.size:
431
+ return pos
432
+ pos -= 1
433
+ return -1
434
+
435
+
436
+ def _has_zip64_end(raw: bytes, end: int) -> bool:
437
+ """Whether the end record at ``end`` is a ZIP64 one (locator just before it, or sentinels)."""
438
+ if end >= 20 and raw[end - 20 : end - 16] == _ZIP64_LOCATOR_SIG:
439
+ return True
440
+ fields = _END.unpack_from(raw, end)
441
+ return _U16 in fields[1:5] or _U32 in fields[5:7]
442
+
443
+
444
+ def _unsupported(path: str, message: str) -> DocControlError:
445
+ return DocControlError(ErrorCode.UNSUPPORTED_CONTENT, message, path=path)
446
+
447
+
448
+ def _replace(src: Path, dest: Path) -> None:
449
+ """``Path.replace`` with one retry, for Windows targets briefly held open."""
450
+ try:
451
+ src.replace(dest)
452
+ except PermissionError:
453
+ time.sleep(0.5)
454
+ src.replace(dest)
@@ -0,0 +1 @@
1
+ """Operation modules; each registers its operations on import."""
@@ -0,0 +1,135 @@
1
+ """`anchor.create` and `anchor.resolve`: quote anchors that survive edits.
2
+
3
+ The matching rules and the score are in :mod:`doc_control.model.anchor`. Both operations
4
+ only read the document.
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.errors import DocControlError, ErrorCode
13
+ from doc_control.model.anchor import (
14
+ CONTEXT_CHARS,
15
+ MAX_CONTEXT_CHARS,
16
+ MAX_QUOTE_CHARS,
17
+ MIN_SCORE,
18
+ Anchor,
19
+ create,
20
+ resolve,
21
+ sentence_of,
22
+ )
23
+ from doc_control.model.document import Document
24
+ from doc_control.registry import operation
25
+
26
+ ANCHOR_SCHEMA: dict[str, Any] = {
27
+ "type": "object",
28
+ "properties": {
29
+ "exact": {
30
+ "type": "string",
31
+ "minLength": 1,
32
+ "description": f"The quoted text (at most {MAX_QUOTE_CHARS} characters).",
33
+ },
34
+ "prefix": {"type": "string", "description": "Text just before the quote."},
35
+ "suffix": {"type": "string", "description": "Text just after the quote."},
36
+ "hint": {
37
+ "type": "string",
38
+ "description": "Paragraph the quote was in, like `p142`; the nearer, the better.",
39
+ },
40
+ "fingerprint": {"type": "string", "description": "That paragraph's fingerprint then."},
41
+ "at": {"type": "string", "description": "The range at creation (informative)."},
42
+ "sentence": {"type": "string", "description": "The sentence address at creation."},
43
+ },
44
+ "required": ["exact"],
45
+ "additionalProperties": False,
46
+ }
47
+
48
+
49
+ @operation(
50
+ "anchor.create",
51
+ "read",
52
+ "Make an anchor (quote with context) for a range, to find the passage again after edits.",
53
+ {
54
+ "type": "object",
55
+ "properties": {
56
+ "doc": {"type": "string", "minLength": 1, "description": "Path to the .docx file."},
57
+ "at": {
58
+ "type": "string",
59
+ "minLength": 1,
60
+ "description": "Range inside one paragraph, e.g. p142#a7b2a7b2:10-15,"
61
+ " or a whole paragraph or sentence (p142#a7b2a7b2.s3).",
62
+ },
63
+ "context": {
64
+ "type": "integer",
65
+ "minimum": 0,
66
+ "maximum": MAX_CONTEXT_CHARS,
67
+ "default": CONTEXT_CHARS,
68
+ "description": "Characters of prefix and of suffix to keep.",
69
+ },
70
+ },
71
+ "required": ["doc", "at"],
72
+ "additionalProperties": False,
73
+ },
74
+ )
75
+ def anchor_create(request: dict[str, Any]) -> Reply:
76
+ doc = Document(request["doc"])
77
+ anchor = create(doc.index, request["at"], request.get("context", CONTEXT_CHARS))
78
+ return Reply({"anchor": anchor.to_dict()}, doc=doc.envelope_doc())
79
+
80
+
81
+ @operation(
82
+ "anchor.resolve",
83
+ "read",
84
+ "Find an anchor's passage now: its range, score and match kind (exact or fuzzy), or the"
85
+ " ambiguous candidates. mode exact never returns a fuzzy match.",
86
+ {
87
+ "type": "object",
88
+ "properties": {
89
+ "doc": {"type": "string", "minLength": 1, "description": "Path to the .docx file."},
90
+ "anchor": {
91
+ **ANCHOR_SCHEMA,
92
+ "description": "An anchor from anchor.create, or just {exact, prefix, suffix}.",
93
+ },
94
+ "mode": {
95
+ "enum": ["best", "exact"],
96
+ "default": "best",
97
+ "description": "best: verbatim matches first, then fuzzy ones (match says which)."
98
+ " exact: only places holding the quote verbatim; anything else is"
99
+ " ANCHOR_NOT_FOUND.",
100
+ },
101
+ },
102
+ "required": ["doc", "anchor"],
103
+ "additionalProperties": False,
104
+ },
105
+ )
106
+ def anchor_resolve(request: dict[str, Any]) -> Reply:
107
+ doc = Document(request["doc"])
108
+ anchor = Anchor.from_dict(request["anchor"])
109
+ exact_only = request.get("mode", "best") == "exact"
110
+ outcome = resolve(doc.index, anchor, exact_only=exact_only)
111
+ best = outcome.best
112
+ if outcome.status == "ambiguous":
113
+ raise DocControlError(
114
+ ErrorCode.ANCHOR_AMBIGUOUS,
115
+ f"{len(outcome.candidates)} places match the anchor about equally well",
116
+ candidates=[{**c.to_dict(), "match": c.kind} for c in outcome.candidates],
117
+ mode="exact" if exact_only else "best",
118
+ )
119
+ if best is None or outcome.status == "not_found":
120
+ raise DocControlError(
121
+ ErrorCode.ANCHOR_NOT_FOUND,
122
+ (
123
+ "no verbatim occurrence of the quote"
124
+ if exact_only
125
+ else f"no place scores {MIN_SCORE:.2f} or more against the anchor"
126
+ ),
127
+ min_score=MIN_SCORE,
128
+ mode="exact" if exact_only else "best",
129
+ best_score=round(best.score, 4) if best is not None else 0.0,
130
+ **({"best_at": best.at} if best is not None else {}),
131
+ )
132
+ result: dict[str, Any] = {"at": best.at, "score": round(best.score, 4), "match": best.kind}
133
+ if anchor.sentence is not None:
134
+ result["sentence"] = sentence_of(doc.index, best)
135
+ return Reply(result, doc=doc.envelope_doc())