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,620 @@
|
|
|
1
|
+
"""``compare``: write a NEW redline ``.docx`` from two versions of a document.
|
|
2
|
+
|
|
3
|
+
Request: ``old`` and ``new`` (paths to ``.docx`` files), ``output`` (path of the redline to
|
|
4
|
+
create; it must not exist and must not be one of the inputs), optional ``author`` (default
|
|
5
|
+
``Claude``), ``tag`` and ``date`` (ISO 8601 with a time zone; default: now).
|
|
6
|
+
|
|
7
|
+
The output is a copy of the OLD document's package in which every difference is a tracked
|
|
8
|
+
change by one author, so that accepting all gives the text of the NEW version and
|
|
9
|
+
rejecting all gives the text of the OLD one. Neither input is touched. Paragraphs are
|
|
10
|
+
aligned by :func:`doc_control.model.align.align` (the engine of ``diff``) and the changes
|
|
11
|
+
are written with the tracked-text primitives of ``para.*`` / ``text.replace``
|
|
12
|
+
(:func:`doc_control.ops.tracked.prepare_edits`):
|
|
13
|
+
|
|
14
|
+
* ``unchanged`` paragraphs stay as they are;
|
|
15
|
+
* ``modified`` paragraphs get word-level ``w:del`` / ``w:ins`` (``difflib`` on word tokens,
|
|
16
|
+
whitespace kept with the word before it), so only the words that changed are marked;
|
|
17
|
+
* ``deleted`` paragraphs are tracked deletions, paragraph mark included;
|
|
18
|
+
* ``inserted`` paragraphs are tracked insertions, with the properties of the paragraph
|
|
19
|
+
they follow in the old document (the old package has no style of the new one to copy);
|
|
20
|
+
* ``moved`` paragraphs are a tracked deletion at the old place plus a tracked copy at the
|
|
21
|
+
new one (v1 has no ``w:moveFrom`` / ``w:moveTo``); a moved paragraph that was also
|
|
22
|
+
edited is deleted and inserted with its new text;
|
|
23
|
+
* ``merged`` paragraphs: one replacement over the changed span that also deletes the
|
|
24
|
+
marks between the old paragraphs, so accepted they are one paragraph;
|
|
25
|
+
* ``split`` paragraphs: the old paragraph is edited into the first part and the other
|
|
26
|
+
parts are insertions.
|
|
27
|
+
|
|
28
|
+
Result: ``output`` (path, sha256), ``summary`` (paragraphs by kind, from ``diff``),
|
|
29
|
+
``revisions`` (count of tracked changes written), ``author``, ``date``. The envelope's
|
|
30
|
+
``doc`` is the old file. A warning is given when Word cannot represent a deletion (see
|
|
31
|
+
``_Plan.trailing``): the paragraph text is deleted and an empty paragraph remains.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
from __future__ import annotations
|
|
35
|
+
|
|
36
|
+
import itertools
|
|
37
|
+
import re
|
|
38
|
+
from bisect import bisect_left
|
|
39
|
+
from collections.abc import Callable
|
|
40
|
+
from dataclasses import dataclass, field
|
|
41
|
+
from datetime import datetime
|
|
42
|
+
from difflib import SequenceMatcher
|
|
43
|
+
from pathlib import Path
|
|
44
|
+
from typing import Any
|
|
45
|
+
|
|
46
|
+
from doc_control.envelope import Reply
|
|
47
|
+
from doc_control.errors import DocControlError, ErrorCode
|
|
48
|
+
from doc_control.model.align import (
|
|
49
|
+
MERGED,
|
|
50
|
+
MODIFIED,
|
|
51
|
+
MOVED,
|
|
52
|
+
SPLIT,
|
|
53
|
+
UNCHANGED,
|
|
54
|
+
Alignment,
|
|
55
|
+
align,
|
|
56
|
+
)
|
|
57
|
+
from doc_control.model.index import DocumentIndex, Paragraph, build_index
|
|
58
|
+
from doc_control.ooxml.revisions import (
|
|
59
|
+
DEFAULT_AUTHOR,
|
|
60
|
+
ParagraphEditor,
|
|
61
|
+
RevisionContext,
|
|
62
|
+
format_author,
|
|
63
|
+
format_date,
|
|
64
|
+
mark_state,
|
|
65
|
+
)
|
|
66
|
+
from doc_control.ooxml.zipio import Package, build_package, commit_bytes
|
|
67
|
+
from doc_control.ops.tracked import (
|
|
68
|
+
DeleteParagraphs,
|
|
69
|
+
Edit,
|
|
70
|
+
InsertParagraph,
|
|
71
|
+
MoveParagraphs,
|
|
72
|
+
ReplaceText,
|
|
73
|
+
boundary_ok,
|
|
74
|
+
next_in_container,
|
|
75
|
+
prepare_edits,
|
|
76
|
+
)
|
|
77
|
+
from doc_control.registry import operation
|
|
78
|
+
from doc_control.safety.verify import DOCUMENT_PART, verify_footprint
|
|
79
|
+
|
|
80
|
+
__all__ = ["CompareResult", "compare_documents", "word_edits"]
|
|
81
|
+
|
|
82
|
+
_TOKEN = re.compile(r"\w+\s*|[^\w\s]\s*|\s+")
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
# -- word-level differences -------------------------------------------------------------------
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _tokens(text: str) -> list[str]:
|
|
89
|
+
"""Words (with the whitespace after them) and punctuation marks, never splitting a
|
|
90
|
+
character from its combining marks."""
|
|
91
|
+
out: list[str] = []
|
|
92
|
+
for m in _TOKEN.finditer(text):
|
|
93
|
+
if out and not boundary_ok(text, m.start()):
|
|
94
|
+
out[-1] += m.group()
|
|
95
|
+
else:
|
|
96
|
+
out.append(m.group())
|
|
97
|
+
return out
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _offsets(tokens: list[str]) -> list[int]:
|
|
101
|
+
out = [0]
|
|
102
|
+
for tok in tokens:
|
|
103
|
+
out.append(out[-1] + len(tok))
|
|
104
|
+
return out
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def word_edits(old: str, new: str) -> list[tuple[int, int, str]]:
|
|
108
|
+
"""Replacements ``(start, end, text)`` over ``old`` (offsets, in order, never touching)
|
|
109
|
+
that turn it into ``new``, at word level."""
|
|
110
|
+
if old == new:
|
|
111
|
+
return []
|
|
112
|
+
a, b = _tokens(old), _tokens(new)
|
|
113
|
+
oa, ob = _offsets(a), _offsets(b)
|
|
114
|
+
out: list[tuple[int, int, str]] = []
|
|
115
|
+
for tag, i1, i2, j1, j2 in SequenceMatcher(None, a, b, autojunk=False).get_opcodes():
|
|
116
|
+
if tag != "equal":
|
|
117
|
+
out.append((oa[i1], oa[i2], new[ob[j1] : ob[j2]]))
|
|
118
|
+
return out
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _common_edges(old: str, new: str) -> tuple[int, int]:
|
|
122
|
+
"""Characters of ``old`` that open it and close it identically in ``new`` (whole tokens)."""
|
|
123
|
+
a, b = _tokens(old), _tokens(new)
|
|
124
|
+
head = 0
|
|
125
|
+
while head < min(len(a), len(b)) and a[head] == b[head]:
|
|
126
|
+
head += 1
|
|
127
|
+
tail = 0
|
|
128
|
+
while tail < min(len(a), len(b)) - head and a[-1 - tail] == b[-1 - tail]:
|
|
129
|
+
tail += 1
|
|
130
|
+
return sum(map(len, a[:head])), sum(map(len, a[len(a) - tail :]))
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
# -- the plan -----------------------------------------------------------------------------------
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _chain(pairs: list[tuple[int, int]]) -> list[int]:
|
|
137
|
+
"""Indexes (into ``pairs``, sorted by old) of a longest chain increasing in new."""
|
|
138
|
+
tails: list[int] = []
|
|
139
|
+
tail_at: list[int] = []
|
|
140
|
+
prev = [-1] * len(pairs)
|
|
141
|
+
for i, (_, n) in enumerate(pairs):
|
|
142
|
+
k = bisect_left(tails, n)
|
|
143
|
+
if k == len(tails):
|
|
144
|
+
tails.append(n)
|
|
145
|
+
tail_at.append(i)
|
|
146
|
+
else:
|
|
147
|
+
tails[k] = n
|
|
148
|
+
tail_at[k] = i
|
|
149
|
+
prev[i] = tail_at[k - 1] if k else -1
|
|
150
|
+
out: list[int] = []
|
|
151
|
+
i = tail_at[-1] if tail_at else -1
|
|
152
|
+
while i >= 0:
|
|
153
|
+
out.append(i)
|
|
154
|
+
i = prev[i]
|
|
155
|
+
return out[::-1]
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
@dataclass
|
|
159
|
+
class _Plan:
|
|
160
|
+
"""What happens to each paragraph; indexes are 0-based."""
|
|
161
|
+
|
|
162
|
+
old: DocumentIndex
|
|
163
|
+
new: DocumentIndex
|
|
164
|
+
kept: dict[int, int] = field(default_factory=dict) # old -> new, in place
|
|
165
|
+
tails: dict[int, list[int]] = field(default_factory=dict) # merge head -> swallowed olds
|
|
166
|
+
moves: dict[int, int] = field(default_factory=dict) # new -> old (copy of its text)
|
|
167
|
+
removed: set[int] = field(default_factory=set) # olds deleted outright
|
|
168
|
+
moved_out: set[int] = field(default_factory=set) # olds that leave by a tracked move
|
|
169
|
+
warnings: list[str] = field(default_factory=list)
|
|
170
|
+
trailing: bool = False # a deletion Word cannot represent was approximated
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _plan(old: DocumentIndex, new: DocumentIndex, alignment: Alignment) -> _Plan:
|
|
174
|
+
plan = _Plan(old, new)
|
|
175
|
+
pairs = [
|
|
176
|
+
(c.old[0], c.new[0], c)
|
|
177
|
+
for c in alignment.changes
|
|
178
|
+
if c.kind in (UNCHANGED, MODIFIED, MOVED, SPLIT, MERGED) and c.old and c.new
|
|
179
|
+
]
|
|
180
|
+
chain = {pairs[i][0] for i in _chain([(o, n) for o, n, _ in pairs])}
|
|
181
|
+
consumed: set[int] = set()
|
|
182
|
+
for o, n, change in pairs:
|
|
183
|
+
if o not in chain:
|
|
184
|
+
continue
|
|
185
|
+
plan.kept[o] = n
|
|
186
|
+
if change.kind == MERGED and len(change.old) > 1:
|
|
187
|
+
run = [old.paragraphs[i] for i in change.old]
|
|
188
|
+
if all(next_in_container(old, a) is b for a, b in itertools.pairwise(run)) and list(
|
|
189
|
+
change.old
|
|
190
|
+
) == list(range(change.old[0], change.old[0] + len(run))):
|
|
191
|
+
plan.tails[o] = list(change.old[1:])
|
|
192
|
+
consumed.update(change.old[1:])
|
|
193
|
+
# Move candidates: identical text, not in the chain, and the original can leave.
|
|
194
|
+
for o, n, change in pairs:
|
|
195
|
+
if (
|
|
196
|
+
o not in plan.kept
|
|
197
|
+
and change.kind in (UNCHANGED, MOVED)
|
|
198
|
+
and old.paragraphs[o].text == new.paragraphs[n].text
|
|
199
|
+
and next_in_container(old, old.paragraphs[o]) is not None
|
|
200
|
+
):
|
|
201
|
+
plan.moves[n] = o
|
|
202
|
+
plan.moved_out.add(o)
|
|
203
|
+
matched_new = set(plan.kept.values())
|
|
204
|
+
last_old, last_new = len(old) - 1, len(new) - 1
|
|
205
|
+
if (
|
|
206
|
+
last_old not in plan.kept
|
|
207
|
+
and last_old not in consumed
|
|
208
|
+
and last_new not in matched_new
|
|
209
|
+
and last_old >= 0
|
|
210
|
+
and last_new >= 0
|
|
211
|
+
):
|
|
212
|
+
# The last paragraphs end their documents: the old one cannot be deleted (Word cannot
|
|
213
|
+
# remove that mark), so it becomes the new one.
|
|
214
|
+
plan.kept[last_old] = last_new
|
|
215
|
+
source = plan.moves.pop(last_new, None)
|
|
216
|
+
if source is not None:
|
|
217
|
+
plan.moved_out.discard(source)
|
|
218
|
+
matched_new.add(last_new)
|
|
219
|
+
plan.removed = set(range(len(old))) - set(plan.kept) - consumed - plan.moved_out
|
|
220
|
+
return plan
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _anchor(plan: _Plan, kept_by_new: dict[int, int]) -> dict[int, int | None]:
|
|
224
|
+
"""For each new paragraph to insert: the old paragraph it follows (None: at the start)."""
|
|
225
|
+
out: dict[int, int | None] = {}
|
|
226
|
+
last: int | None = None
|
|
227
|
+
for j in range(len(plan.new)):
|
|
228
|
+
if j in kept_by_new:
|
|
229
|
+
o = kept_by_new[j]
|
|
230
|
+
last = plan.tails[o][-1] if o in plan.tails else o
|
|
231
|
+
else:
|
|
232
|
+
out[j] = last
|
|
233
|
+
return out
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
# -- the edits ----------------------------------------------------------------------------------
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def _at(para: Paragraph, start: int | None = None, end: int | None = None) -> str:
|
|
240
|
+
if start is None:
|
|
241
|
+
return str(para.address)
|
|
242
|
+
return f"{para.address}:{start}-{end}"
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _cross(first: Paragraph, start: int, last: Paragraph, end: int) -> str:
|
|
246
|
+
return f"{first.address}:{start}..{last.address}:{end}"
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _merge_edit(
|
|
250
|
+
old: DocumentIndex, head: int, tails: list[int], text: str
|
|
251
|
+
) -> tuple[Paragraph, int, Paragraph, int, str]:
|
|
252
|
+
"""One replacement from the old paragraphs ``head`` + ``tails`` (marks between them
|
|
253
|
+
deleted) to ``text``: ``(first, offset, last, offset, new text)``."""
|
|
254
|
+
paras = [old.paragraphs[i] for i in (head, *tails)]
|
|
255
|
+
joined = "".join(p.text for p in paras)
|
|
256
|
+
lead, trail = _common_edges(joined, text)
|
|
257
|
+
cuts = [len(p.text) for p in paras]
|
|
258
|
+
first_end = cuts[0]
|
|
259
|
+
last_start = len(joined) - cuts[-1]
|
|
260
|
+
start = min(lead, first_end)
|
|
261
|
+
end = max(len(joined) - trail, last_start)
|
|
262
|
+
replacement = text[start : len(text) - (len(joined) - end)]
|
|
263
|
+
return paras[0], start, paras[-1], end - last_start, replacement
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _unmove_trailing(plan: _Plan) -> None:
|
|
267
|
+
"""Moves whose original sits right before a removed last paragraph of a container become
|
|
268
|
+
a plain deletion plus an insertion: the whole run is then removed as one."""
|
|
269
|
+
old = plan.old
|
|
270
|
+
by_old = {o: n for n, o in plan.moves.items()}
|
|
271
|
+
for b in sorted(plan.removed):
|
|
272
|
+
if next_in_container(old, old.paragraphs[b]) is not None:
|
|
273
|
+
continue
|
|
274
|
+
j = b - 1
|
|
275
|
+
while (
|
|
276
|
+
j in plan.removed | plan.moved_out
|
|
277
|
+
and next_in_container(old, old.paragraphs[j]) is (old.paragraphs[j + 1])
|
|
278
|
+
):
|
|
279
|
+
if j in plan.moved_out:
|
|
280
|
+
plan.moved_out.discard(j)
|
|
281
|
+
plan.removed.add(j)
|
|
282
|
+
if j in by_old:
|
|
283
|
+
plan.moves.pop(by_old[j], None)
|
|
284
|
+
j -= 1
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def _edits(plan: _Plan) -> list[Edit]:
|
|
288
|
+
old, new = plan.old, plan.new
|
|
289
|
+
edits: list[Edit] = []
|
|
290
|
+
kept_by_new = {n: o for o, n in plan.kept.items()}
|
|
291
|
+
anchors = _anchor(plan, kept_by_new)
|
|
292
|
+
anchored = {a for a in anchors.values() if a is not None}
|
|
293
|
+
for j in [j for j, o in plan.moves.items() if not _can_move(plan, o, anchors.get(j))]:
|
|
294
|
+
gone = plan.moves.pop(j) # no tracked move possible: delete and insert
|
|
295
|
+
plan.moved_out.discard(gone)
|
|
296
|
+
plan.removed.add(gone)
|
|
297
|
+
|
|
298
|
+
_unmove_trailing(plan)
|
|
299
|
+
# Removal runs and the trailing special case: the last paragraph of a container.
|
|
300
|
+
swallow: dict[int, tuple[Paragraph, int]] = {} # kept old -> last paragraph it absorbs
|
|
301
|
+
runs: list[list[int]] = []
|
|
302
|
+
for o in sorted(plan.removed):
|
|
303
|
+
para = old.paragraphs[o]
|
|
304
|
+
if runs and runs[-1][-1] == o - 1 and next_in_container(old, old.paragraphs[o - 1]) is para:
|
|
305
|
+
runs[-1].append(o)
|
|
306
|
+
else:
|
|
307
|
+
runs.append([o])
|
|
308
|
+
delete_runs: list[list[int]] = []
|
|
309
|
+
for run in runs:
|
|
310
|
+
last = old.paragraphs[run[-1]]
|
|
311
|
+
if next_in_container(old, last) is not None:
|
|
312
|
+
delete_runs.append(run)
|
|
313
|
+
continue
|
|
314
|
+
prev = run[0] - 1
|
|
315
|
+
if (
|
|
316
|
+
prev >= 0
|
|
317
|
+
and prev not in anchored
|
|
318
|
+
and next_in_container(old, old.paragraphs[prev]) is old.paragraphs[run[0]]
|
|
319
|
+
):
|
|
320
|
+
head = next((h for h, t in plan.tails.items() if t[-1] == prev), None)
|
|
321
|
+
if head is not None: # the merge takes the run in as more of its tail
|
|
322
|
+
plan.tails[head].extend(run)
|
|
323
|
+
continue
|
|
324
|
+
if prev in plan.kept:
|
|
325
|
+
swallow[prev] = (last, len(last.text))
|
|
326
|
+
continue
|
|
327
|
+
plan.trailing = True
|
|
328
|
+
plan.warnings.append(
|
|
329
|
+
f"{last.address} is the last paragraph of its container and cannot be removed: "
|
|
330
|
+
"its text is deleted and an empty paragraph remains"
|
|
331
|
+
)
|
|
332
|
+
if len(run) > 1:
|
|
333
|
+
delete_runs.append(run[:-1])
|
|
334
|
+
if last.text:
|
|
335
|
+
edits.append(ReplaceText(_at(last, 0, len(last.text)), ""))
|
|
336
|
+
|
|
337
|
+
for run in delete_runs:
|
|
338
|
+
first, last = old.paragraphs[run[0]], old.paragraphs[run[-1]]
|
|
339
|
+
edits.append(
|
|
340
|
+
DeleteParagraphs(
|
|
341
|
+
str(first.address) + (f"..{last.address}" if first is not last else "")
|
|
342
|
+
)
|
|
343
|
+
)
|
|
344
|
+
|
|
345
|
+
for o, n in sorted(plan.kept.items()):
|
|
346
|
+
para, target = old.paragraphs[o], new.paragraphs[n].text
|
|
347
|
+
if o in plan.tails:
|
|
348
|
+
first, s, last, e, text = _merge_edit(old, o, plan.tails[o], target)
|
|
349
|
+
edits.append(ReplaceText(_cross(first, s, last, e), text))
|
|
350
|
+
continue
|
|
351
|
+
changes = word_edits(para.text, target)
|
|
352
|
+
if o in swallow:
|
|
353
|
+
end_para, end = swallow[o]
|
|
354
|
+
if changes and changes[-1][1] == len(para.text):
|
|
355
|
+
s, _, text = changes.pop()
|
|
356
|
+
else:
|
|
357
|
+
s, text = len(para.text), ""
|
|
358
|
+
edits.append(ReplaceText(_cross(para, s, end_para, end), text))
|
|
359
|
+
edits.extend(ReplaceText(_at(para, s, e), text) for s, e, text in changes)
|
|
360
|
+
|
|
361
|
+
for j, anchor in anchors.items():
|
|
362
|
+
where: dict[str, str]
|
|
363
|
+
if anchor is None:
|
|
364
|
+
where = {"before": str(old.paragraphs[0].address)}
|
|
365
|
+
else:
|
|
366
|
+
where = {"after": str(old.paragraphs[anchor].address)}
|
|
367
|
+
source = plan.moves.get(j)
|
|
368
|
+
if source is not None and _can_move(plan, source, anchor):
|
|
369
|
+
edits.append(MoveParagraphs(str(old.paragraphs[source].address), **where))
|
|
370
|
+
else:
|
|
371
|
+
edits.append(InsertParagraph(new.paragraphs[j].text, **where))
|
|
372
|
+
return edits
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def _carryable(old: DocumentIndex, para: Paragraph) -> bool:
|
|
376
|
+
"""False if the paragraph holds a bookmark, comment anchor or field a copy cannot carry."""
|
|
377
|
+
elem = old.spans.parse_paragraph(para.span_index)
|
|
378
|
+
try:
|
|
379
|
+
ParagraphEditor(elem, para, RevisionContext(set(), "x", "d")).copy_runs()
|
|
380
|
+
except DocControlError:
|
|
381
|
+
return False
|
|
382
|
+
return True
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def _can_move(plan: _Plan, source: int, anchor: int | None) -> bool:
|
|
386
|
+
"""A tracked move needs a target slot in front of a neighbour, away from the source."""
|
|
387
|
+
old = plan.old
|
|
388
|
+
if not _carryable(old, old.paragraphs[source]):
|
|
389
|
+
return False
|
|
390
|
+
if anchor is None:
|
|
391
|
+
return source != 0
|
|
392
|
+
last = plan.tails[anchor][-1] if anchor in plan.tails else anchor
|
|
393
|
+
return (
|
|
394
|
+
next_in_container(old, old.paragraphs[last]) is not None
|
|
395
|
+
and anchor not in (source, source - 1)
|
|
396
|
+
and last not in (source, source - 1)
|
|
397
|
+
)
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
# -- the result -----------------------------------------------------------------------------
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
@dataclass(frozen=True)
|
|
404
|
+
class CompareResult:
|
|
405
|
+
output: Path
|
|
406
|
+
sha256: str
|
|
407
|
+
summary: dict[str, int]
|
|
408
|
+
revisions: int
|
|
409
|
+
author: str
|
|
410
|
+
date: str
|
|
411
|
+
old: dict[str, Any]
|
|
412
|
+
new: dict[str, Any]
|
|
413
|
+
warnings: list[str]
|
|
414
|
+
|
|
415
|
+
def to_dict(self) -> dict[str, Any]:
|
|
416
|
+
return {
|
|
417
|
+
"old": self.old,
|
|
418
|
+
"new": self.new,
|
|
419
|
+
"output": {"path": str(self.output), "sha256": self.sha256},
|
|
420
|
+
"summary": self.summary,
|
|
421
|
+
"revisions": self.revisions,
|
|
422
|
+
"author": self.author,
|
|
423
|
+
"date": self.date,
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def accepts_to(got: list[str], want: list[str], *, unremovable: bool, foreign: bool) -> bool:
|
|
428
|
+
"""Is ``got`` (accept-all) the new text? Exact, except for the two known differences:
|
|
429
|
+
an unremovable last paragraph leaves one extra empty paragraph, and another author's
|
|
430
|
+
pending paragraph deletion joins paragraphs (the text must still agree)."""
|
|
431
|
+
if got == want:
|
|
432
|
+
return True
|
|
433
|
+
if foreign:
|
|
434
|
+
return "".join(got) == "".join(want)
|
|
435
|
+
if unremovable:
|
|
436
|
+
return [t for t in got if t] == [t for t in want if t] and len(got) >= len(want)
|
|
437
|
+
return False
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
def _accepted_texts(data: bytes, ids: set[str]) -> tuple[list[str], bool]:
|
|
441
|
+
"""Paragraph texts of the redline with this call's revisions accepted, and whether
|
|
442
|
+
another author's tracked paragraph deletion is pending (it joins paragraphs too)."""
|
|
443
|
+
index = build_index(data)
|
|
444
|
+
out: list[str] = []
|
|
445
|
+
carry = ""
|
|
446
|
+
foreign = False
|
|
447
|
+
for para in index.paragraphs:
|
|
448
|
+
elem = index.spans.parse_paragraph(para.span_index)
|
|
449
|
+
text = carry + para.text
|
|
450
|
+
marks = mark_state(elem)
|
|
451
|
+
foreign = foreign or any(kind == "del" and rid not in ids for kind, rid in marks)
|
|
452
|
+
if any(kind == "del" and rid in ids for kind, rid in marks):
|
|
453
|
+
carry = text
|
|
454
|
+
else:
|
|
455
|
+
out.append(text)
|
|
456
|
+
carry = ""
|
|
457
|
+
if carry:
|
|
458
|
+
out.append(carry)
|
|
459
|
+
return out, foreign
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
def _check_output(output: Path, inputs: list[Path]) -> None:
|
|
463
|
+
for source in inputs:
|
|
464
|
+
same = output.exists() and source.exists() and output.samefile(source)
|
|
465
|
+
if same or output.resolve() == source.resolve():
|
|
466
|
+
raise DocControlError(
|
|
467
|
+
ErrorCode.SCHEMA_INVALID,
|
|
468
|
+
f"output is one of the inputs: {output}",
|
|
469
|
+
path=str(output),
|
|
470
|
+
)
|
|
471
|
+
if output.exists():
|
|
472
|
+
raise DocControlError(
|
|
473
|
+
ErrorCode.SCHEMA_INVALID,
|
|
474
|
+
f"output already exists, nothing was written: {output}",
|
|
475
|
+
path=str(output),
|
|
476
|
+
)
|
|
477
|
+
if not output.parent.is_dir():
|
|
478
|
+
raise DocControlError(
|
|
479
|
+
ErrorCode.SCHEMA_INVALID,
|
|
480
|
+
f"the folder of the output does not exist: {output.parent}",
|
|
481
|
+
path=str(output),
|
|
482
|
+
)
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
def compare_documents(
|
|
486
|
+
old: Path,
|
|
487
|
+
new: Path,
|
|
488
|
+
output: Path,
|
|
489
|
+
*,
|
|
490
|
+
author: str = DEFAULT_AUTHOR,
|
|
491
|
+
tag: str | None = None,
|
|
492
|
+
now: datetime | None = None,
|
|
493
|
+
stage: Callable[[str], None] | None = None,
|
|
494
|
+
) -> CompareResult:
|
|
495
|
+
"""Write the redline of ``old`` -> ``new`` to ``output`` (see the module docstring).
|
|
496
|
+
|
|
497
|
+
Raises ``DOC_NOT_FOUND`` / ``NOT_A_DOCX`` for an input, ``SCHEMA_INVALID`` for an
|
|
498
|
+
unusable output path, ``UNSUPPORTED_CONTENT`` for content the tracked-text
|
|
499
|
+
primitives refuse, ``INTERNAL`` if the redline would not accept to the new text or
|
|
500
|
+
reject to the old one (nothing is written) and ``VERIFY_FAILED_RESTORED`` if the
|
|
501
|
+
written file fails verification (it is removed). Both inputs are only read.
|
|
502
|
+
"""
|
|
503
|
+
_check_output(output, [old, new])
|
|
504
|
+
pkg_old, pkg_new = Package(old), Package(new)
|
|
505
|
+
index_old = DocumentIndex.from_package(pkg_old)
|
|
506
|
+
index_new = DocumentIndex.from_package(pkg_new)
|
|
507
|
+
alignment = align(index_old.paragraphs, index_new.paragraphs)
|
|
508
|
+
info_old = {"path": str(old), "sha256": pkg_old.sha256, "paragraphs": len(index_old)}
|
|
509
|
+
info_new = {"path": str(new), "sha256": pkg_new.sha256, "paragraphs": len(index_new)}
|
|
510
|
+
plan = _plan(index_old, index_new, alignment)
|
|
511
|
+
edits = _edits(plan)
|
|
512
|
+
|
|
513
|
+
if not edits: # identical texts: the redline is a copy
|
|
514
|
+
payload = build_package(pkg_old, {})
|
|
515
|
+
revisions: list[int] = []
|
|
516
|
+
author_out, date_out = format_author(author, tag), format_date(now)
|
|
517
|
+
footprint = None
|
|
518
|
+
else:
|
|
519
|
+
prepared = prepare_edits(
|
|
520
|
+
pkg_old, edits, author=author, tag=tag, now=now, layered=True, index=index_old
|
|
521
|
+
)
|
|
522
|
+
result = prepared.result
|
|
523
|
+
revisions, author_out, date_out = result.revision_ids, result.author, result.date
|
|
524
|
+
footprint = prepared.footprint
|
|
525
|
+
got, foreign = _accepted_texts(prepared.new_data, {str(r) for r in revisions})
|
|
526
|
+
want = [p.text for p in index_new.paragraphs]
|
|
527
|
+
if not accepts_to(got, want, unremovable=plan.trailing, foreign=foreign):
|
|
528
|
+
bad = next((n for n, (a, b) in enumerate(zip(got, want, strict=False), 1) if a != b), 0)
|
|
529
|
+
raise DocControlError(
|
|
530
|
+
ErrorCode.INTERNAL,
|
|
531
|
+
f"self-check: accepting the redline does not give the new text (paragraph {bad})",
|
|
532
|
+
paragraph=bad,
|
|
533
|
+
)
|
|
534
|
+
if foreign:
|
|
535
|
+
plan.warnings.append(
|
|
536
|
+
"the old document has tracked paragraph deletions by other authors: "
|
|
537
|
+
"accepting everything also joins those paragraphs"
|
|
538
|
+
)
|
|
539
|
+
payload = build_package(pkg_old, {DOCUMENT_PART: prepared.new_data})
|
|
540
|
+
|
|
541
|
+
try:
|
|
542
|
+
commit_bytes(payload, output, stage, exclusive=True)
|
|
543
|
+
except FileExistsError as exc:
|
|
544
|
+
raise DocControlError(
|
|
545
|
+
ErrorCode.SCHEMA_INVALID,
|
|
546
|
+
f"output already exists, nothing was written: {output}",
|
|
547
|
+
path=str(output),
|
|
548
|
+
) from exc
|
|
549
|
+
written = Package(output)
|
|
550
|
+
if footprint is not None:
|
|
551
|
+
problems = verify_footprint(pkg_old, written, footprint)
|
|
552
|
+
if problems or written.read(DOCUMENT_PART) != prepared.new_data:
|
|
553
|
+
output.unlink(missing_ok=True)
|
|
554
|
+
raise DocControlError(
|
|
555
|
+
ErrorCode.VERIFY_FAILED_RESTORED,
|
|
556
|
+
"the redline failed verification and was removed: " + "; ".join(problems),
|
|
557
|
+
path=str(output),
|
|
558
|
+
problems=problems,
|
|
559
|
+
)
|
|
560
|
+
return CompareResult(
|
|
561
|
+
output=output,
|
|
562
|
+
sha256=written.sha256,
|
|
563
|
+
summary=alignment.counts(),
|
|
564
|
+
revisions=len(revisions),
|
|
565
|
+
author=author_out,
|
|
566
|
+
date=date_out,
|
|
567
|
+
old=info_old,
|
|
568
|
+
new=info_new,
|
|
569
|
+
warnings=plan.warnings,
|
|
570
|
+
)
|
|
571
|
+
|
|
572
|
+
|
|
573
|
+
def _parse_date(value: str) -> datetime:
|
|
574
|
+
try:
|
|
575
|
+
moment = datetime.fromisoformat(value)
|
|
576
|
+
except ValueError as exc:
|
|
577
|
+
raise DocControlError(
|
|
578
|
+
ErrorCode.SCHEMA_INVALID, f"date is not ISO 8601: {value!r}", date=value
|
|
579
|
+
) from exc
|
|
580
|
+
if moment.tzinfo is None:
|
|
581
|
+
raise DocControlError(
|
|
582
|
+
ErrorCode.SCHEMA_INVALID,
|
|
583
|
+
"date needs a time zone, e.g. 2026-10-12T09:00:00Z",
|
|
584
|
+
date=value,
|
|
585
|
+
)
|
|
586
|
+
return moment
|
|
587
|
+
|
|
588
|
+
|
|
589
|
+
@operation(
|
|
590
|
+
"compare",
|
|
591
|
+
"read",
|
|
592
|
+
"Write a new redline .docx from two versions: every difference a tracked change; "
|
|
593
|
+
"the inputs are never modified.",
|
|
594
|
+
{
|
|
595
|
+
"type": "object",
|
|
596
|
+
"properties": {
|
|
597
|
+
"old": {"type": "string", "description": "Path of the earlier version (.docx)."},
|
|
598
|
+
"new": {"type": "string", "description": "Path of the later version (.docx)."},
|
|
599
|
+
"output": {
|
|
600
|
+
"type": "string",
|
|
601
|
+
"description": "Path of the redline to create (must not exist).",
|
|
602
|
+
},
|
|
603
|
+
"author": {"type": "string", "description": "Revision author (default Claude)."},
|
|
604
|
+
"tag": {"type": "string", "description": "Appended to the author as `author (tag)`."},
|
|
605
|
+
"date": {"type": "string", "description": "Revision date, ISO 8601 with time zone."},
|
|
606
|
+
},
|
|
607
|
+
"required": ["old", "new", "output"],
|
|
608
|
+
"additionalProperties": False,
|
|
609
|
+
},
|
|
610
|
+
)
|
|
611
|
+
def compare(request: dict[str, Any]) -> Reply:
|
|
612
|
+
done = compare_documents(
|
|
613
|
+
Path(request["old"]),
|
|
614
|
+
Path(request["new"]),
|
|
615
|
+
Path(request["output"]),
|
|
616
|
+
author=request.get("author", DEFAULT_AUTHOR),
|
|
617
|
+
tag=request.get("tag"),
|
|
618
|
+
now=_parse_date(request["date"]) if "date" in request else None,
|
|
619
|
+
)
|
|
620
|
+
return Reply(done.to_dict(), doc=done.old, warnings=done.warnings)
|