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,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())
|