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