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,529 @@
1
+ """Paragraph alignment between two versions of a document (the engine of ``diff``).
2
+
3
+ :func:`align` takes the paragraphs of an old and a new version (anything with ``text``
4
+ and ``fp``, e.g. :class:`doc_control.model.index.Paragraph`) and returns an
5
+ :class:`Alignment`: a list of :class:`Change` records, each saying what became of one
6
+ or several paragraphs. Only visible text is compared; formatting-only edits are not seen.
7
+
8
+ Kinds
9
+ ``unchanged`` same text (same fingerprint), possibly at another number
10
+ ``modified`` same paragraph with edited text (``similarity`` >= MODIFIED_MIN)
11
+ ``moved`` same text, out of order with respect to its neighbours
12
+ ``split`` one old paragraph became several adjacent new ones
13
+ ``merged`` several adjacent old paragraphs became one new one
14
+ ``deleted`` only in the old version
15
+ ``inserted`` only in the new version
16
+ A ``modified`` paragraph that also changed place carries ``moved=True``.
17
+
18
+ Algorithm (near-linear on real documents; every quadratic step is capped, see the
19
+ constants: difflib only on gaps up to DIFFLIB_CELLS, split/merge runs up to
20
+ MAX_GROUP_RUN, candidate pairs per paragraph up to TOP_CANDIDATES)
21
+ 1. Anchors: paragraphs whose fingerprint is unique on both sides are paired; the
22
+ longest increasing chain of them is "in order", the rest are moves.
23
+ 2. Repeated and wordless paragraphs (titles, separators, short replies) are paired
24
+ by context: next to a paired neighbour, with the partner's neighbour when the
25
+ text is equal, nearest context first, so a moved block carries its repeated
26
+ lines. What is left is aligned in order, gap by gap, then by identical text.
27
+ 3. Split and merge: a leftover paragraph whose words reappear (>= GROUP_CONTAINMENT
28
+ of each part's words) in a run of two or more adjacent leftover paragraphs of
29
+ the other side, the run's words matching it at >= GROUP_MIN_SIMILARITY.
30
+ 4. Modified: candidate pairs from the same gap between anchors and from a rare-word
31
+ index, scored by word-level difflib ratio (character-level under SHORT_WORDS
32
+ words), assigned greedily best first at >= MODIFIED_MIN.
33
+ Blank paragraphs (no word characters) are matched only in step 2.
34
+ """
35
+
36
+ from __future__ import annotations
37
+
38
+ import bisect
39
+ import heapq
40
+ import re
41
+ import unicodedata
42
+ from collections import Counter, defaultdict
43
+ from collections.abc import Callable, Sequence
44
+ from dataclasses import dataclass
45
+ from difflib import SequenceMatcher
46
+ from typing import Protocol
47
+
48
+ MODIFIED_MIN = 0.6 # word-level similarity from which two paragraphs are "the same, edited"
49
+ GROUP_MIN_SIMILARITY = 0.8 # similarity of one paragraph with the concatenation of its parts
50
+ GROUP_CONTAINMENT = 0.8 # share of a part's words found in the paragraph it came from
51
+ MAX_GROUP_RUN = 8 # a split/merge has at most this many parts (longer runs are not tried)
52
+ GROUP_MIN_WORDS = 6 # smaller paragraphs are never split/merge candidates
53
+ SHORT_WORDS = 6 # under this many words (either side) similarity is character-level
54
+ RARE_POSTINGS = 20 # a word in more leftover paragraphs than this does not nominate candidates
55
+ GAP_PAIRS = 400 # all pairs inside a gap are scored when the gap is at most this big
56
+ TOP_CANDIDATES = 4 # index nominations kept per paragraph
57
+ DIFFLIB_CELLS = 1_000_000 # a gap without anchors is aligned by difflib up to this many cells
58
+
59
+ UNCHANGED = "unchanged"
60
+ MODIFIED = "modified"
61
+ MOVED = "moved"
62
+ SPLIT = "split"
63
+ MERGED = "merged"
64
+ DELETED = "deleted"
65
+ INSERTED = "inserted"
66
+ KINDS = (UNCHANGED, MODIFIED, MOVED, SPLIT, MERGED, DELETED, INSERTED)
67
+
68
+ _WORD = re.compile(r"\w+")
69
+
70
+
71
+ class _Para(Protocol):
72
+ @property
73
+ def text(self) -> str: ...
74
+ @property
75
+ def fp(self) -> str: ...
76
+
77
+
78
+ @dataclass(frozen=True)
79
+ class Change:
80
+ """What became of ``old`` paragraphs (0-based indexes) in the new version (``new``)."""
81
+
82
+ kind: str
83
+ old: tuple[int, ...]
84
+ new: tuple[int, ...]
85
+ similarity: float | None = None # modified, split, merged
86
+ moved: bool = False # modified paragraphs that also changed place
87
+
88
+
89
+ @dataclass(frozen=True)
90
+ class Alignment:
91
+ """Every paragraph of both versions appears in exactly one change, in old order."""
92
+
93
+ changes: tuple[Change, ...]
94
+ old_count: int
95
+ new_count: int
96
+
97
+ def counts(self) -> dict[str, int]:
98
+ counter = Counter(c.kind for c in self.changes)
99
+ return {k: counter.get(k, 0) for k in KINDS}
100
+
101
+ def new_for_old(self) -> dict[int, tuple[int, ...]]:
102
+ """Old index -> new indexes (empty tuple for a deleted paragraph)."""
103
+ return {o: c.new for c in self.changes for o in c.old}
104
+
105
+
106
+ def _words(text: str) -> list[str]:
107
+ return _WORD.findall(unicodedata.normalize("NFC", text).casefold())
108
+
109
+
110
+ def _lis(pairs: list[tuple[int, int]]) -> list[tuple[int, int]]:
111
+ """Longest chain increasing in the second element, for pairs sorted by the first."""
112
+ tails: list[int] = [] # smallest tail value of a chain of each length
113
+ tail_at: list[int] = [] # index into pairs of that tail
114
+ prev = [-1] * len(pairs)
115
+ for i, (_, n) in enumerate(pairs):
116
+ k = bisect.bisect_left(tails, n)
117
+ if k == len(tails):
118
+ tails.append(n)
119
+ tail_at.append(i)
120
+ else:
121
+ tails[k] = n
122
+ tail_at[k] = i
123
+ prev[i] = tail_at[k - 1] if k else -1
124
+ out: list[tuple[int, int]] = []
125
+ i = tail_at[-1] if tail_at else -1
126
+ while i >= 0:
127
+ out.append(pairs[i])
128
+ i = prev[i]
129
+ out.reverse()
130
+ return out
131
+
132
+
133
+ def _unique_pairs(a: Sequence[str], b: Sequence[str]) -> list[tuple[int, int]]:
134
+ """Pairs of paragraphs whose fingerprint occurs exactly once on each side."""
135
+ count_a, count_b = Counter(a), Counter(b)
136
+ pos_b = {fp: j for j, fp in enumerate(b) if count_b[fp] == 1}
137
+ return [(i, pos_b[fp]) for i, fp in enumerate(a) if count_a[fp] == 1 and fp in pos_b]
138
+
139
+
140
+ def _ratio(a: Sequence[str], b: Sequence[str], floor: float) -> float:
141
+ """difflib ratio of two sequences, 0.0 when it cannot reach ``floor``."""
142
+ matcher = SequenceMatcher(None, a, b, autojunk=False)
143
+ if matcher.real_quick_ratio() < floor or matcher.quick_ratio() < floor:
144
+ return 0.0
145
+ return matcher.ratio()
146
+
147
+
148
+ def _runs(sorted_indexes: list[int]) -> list[list[int]]:
149
+ """Split a sorted list into maximal runs of consecutive integers."""
150
+ runs: list[list[int]] = []
151
+ for m in sorted_indexes:
152
+ if runs and runs[-1][-1] == m - 1:
153
+ runs[-1].append(m)
154
+ else:
155
+ runs.append([m])
156
+ return runs
157
+
158
+
159
+ def _trim_run(run: list[int], target: list[str], many: list[list[str]]) -> list[int]:
160
+ """Drop end paragraphs of a run (at most MAX_GROUP_RUN long) while that improves the match."""
161
+ while len(run) > 2:
162
+ full = _ratio(target, [w for m in run for w in many[m]], 0.0)
163
+ head = _ratio(target, [w for m in run[1:] for w in many[m]], 0.0)
164
+ tail = _ratio(target, [w for m in run[:-1] for w in many[m]], 0.0)
165
+ if max(head, tail) <= full:
166
+ break
167
+ run = run[1:] if head >= tail else run[:-1]
168
+ return run
169
+
170
+
171
+ class _Aligner:
172
+ def __init__(self, old: Sequence[_Para], new: Sequence[_Para]) -> None:
173
+ self.old = old
174
+ self.new = new
175
+ self.words_old = [_words(p.text) for p in old]
176
+ self.words_new = [_words(p.text) for p in new]
177
+ self.match_old: list[int] = [-1] * len(old) # 1:1 partner, -1 when none
178
+ self.match_new: list[int] = [-1] * len(new)
179
+ self.group_old: set[int] = set() # claimed by a split/merge
180
+ self.group_new: set[int] = set()
181
+ self.changes: list[Change] = []
182
+ self.scores: dict[tuple[int, int], float] = {}
183
+ self.base_old: list[int] = [] # anchor chain (step 1), increasing in both
184
+ self.base_new: list[int] = []
185
+
186
+ def free_old(self, i: int) -> bool:
187
+ return self.match_old[i] < 0 and i not in self.group_old and bool(self.words_old[i])
188
+
189
+ def free_new(self, j: int) -> bool:
190
+ return self.match_new[j] < 0 and j not in self.group_new and bool(self.words_new[j])
191
+
192
+ def pair(self, i: int, j: int) -> None:
193
+ self.match_old[i] = j
194
+ self.match_new[j] = i
195
+
196
+ def in_order(self, i: int, j: int) -> bool:
197
+ """Is (i, j) consistent with the anchor chain, i.e. not a move?"""
198
+ k = bisect.bisect_left(self.base_old, i)
199
+ before = self.base_new[k - 1] if k else -1
200
+ after = self.base_new[k] if k < len(self.base_new) else len(self.new)
201
+ return before < j < after
202
+
203
+ def run(self) -> Alignment:
204
+ unique = _unique_pairs([p.fp for p in self.old], [p.fp for p in self.new])
205
+ chain = _lis(unique)
206
+ in_chain = set(chain)
207
+ self.base_old = [i for i, _ in chain]
208
+ self.base_new = [j for _, j in chain]
209
+ extras = [(i, j, False) for i, j in unique if (i, j) not in in_chain]
210
+ for i, j in unique:
211
+ self.pair(i, j)
212
+ extras += self.extend()
213
+ extras += self.fill_gaps()
214
+ extras += self.exact_leftovers()
215
+ self.groups(split=True)
216
+ self.groups(split=False)
217
+ extras += self.fuzzy()
218
+ return self.assemble(extras)
219
+
220
+ def extend(self) -> list[tuple[int, int, bool]]:
221
+ """Pair repeated and wordless paragraphs by context.
222
+
223
+ A paragraph next to an already paired one is paired with the partner's
224
+ neighbour on the same side when the two have the same text, so a moved block
225
+ carries its repeated lines (titles, separators, short replies) with it. Blocks
226
+ claim neighbours: those next to an anchor first, then next to what was paired from
227
+ an anchor, and so on (nearest context wins), the largest block first within a level.
228
+ """
229
+ run_len = [0] * len(self.old) # length of the run of consecutive pairs each is in
230
+ i = 0
231
+ while i < len(self.old):
232
+ if self.match_old[i] < 0:
233
+ i += 1
234
+ continue
235
+ k = i
236
+ while k + 1 < len(self.old) and self.match_old[k + 1] == self.match_old[k] + 1:
237
+ k += 1
238
+ for t in range(i, k + 1):
239
+ run_len[t] = k - i + 1
240
+ i = k + 1
241
+ heap: list[tuple[int, int, int, int]] = [] # (depth, -weight, old, new)
242
+
243
+ def push(o: int, n: int, weight: int, depth: int) -> None:
244
+ for step in (-1, 1):
245
+ io, jn = o + step, n + step
246
+ if (
247
+ 0 <= io < len(self.old)
248
+ and 0 <= jn < len(self.new)
249
+ and self.match_old[io] < 0
250
+ and self.match_new[jn] < 0
251
+ and self.old[io].fp == self.new[jn].fp
252
+ ):
253
+ heapq.heappush(heap, (depth, -weight, io, jn))
254
+
255
+ for o, n in enumerate(self.match_old):
256
+ if n >= 0:
257
+ push(o, n, run_len[o], 1)
258
+ out: list[tuple[int, int, bool]] = []
259
+ votes: dict[str, list[int]] = defaultdict(lambda: [0, 0]) # fp -> [with next, with prev]
260
+ deferred: list[int] = []
261
+
262
+ def candidates(io: int) -> list[tuple[int, int]]:
263
+ """(new index, side) for line ``io``; side 0 = with its next paragraph, 1 = prev."""
264
+ found: list[tuple[int, int]] = []
265
+ for side, step in ((0, 1), (1, -1)):
266
+ nb = io + step
267
+ if 0 <= nb < len(self.old) and self.match_old[nb] >= 0:
268
+ jn = self.match_old[nb] - step
269
+ if (
270
+ 0 <= jn < len(self.new)
271
+ and self.match_new[jn] < 0
272
+ and self.new[jn].fp == self.old[io].fp
273
+ ):
274
+ found.append((jn, side))
275
+ return found
276
+
277
+ def accept(io: int, jn: int, weight: int, depth: int) -> None:
278
+ self.pair(io, jn)
279
+ out.append((io, jn, False))
280
+ push(io, jn, weight, depth + 1)
281
+
282
+ while True:
283
+ while heap:
284
+ depth, neg, io, jn = heapq.heappop(heap)
285
+ if self.match_old[io] >= 0 or self.match_new[jn] >= 0:
286
+ continue
287
+ found = candidates(io)
288
+ if len({j for j, _ in found}) > 1: # the neighbours disagree: decide later
289
+ deferred.append(io)
290
+ continue
291
+ if len(found) == 1 and self.decisive(io, 1 - found[0][1]):
292
+ votes[self.old[io].fp][found[0][1]] += 1
293
+ accept(io, jn, -neg, depth)
294
+ # Ambiguous lines (a heading between the end of one block and the start of the
295
+ # next): a line goes with the side its text usually goes with, headings with
296
+ # the paragraph after them and closing lines with the one before; undecided
297
+ # ones go with the next paragraph.
298
+ progressed = False
299
+ for io in deferred:
300
+ if self.match_old[io] >= 0:
301
+ continue
302
+ found = candidates(io)
303
+ if len({j for j, _ in found}) < 2:
304
+ if found:
305
+ accept(io, found[0][0], 1, 1)
306
+ progressed = True
307
+ continue
308
+ with_next, with_prev = votes[self.old[io].fp]
309
+ want = 1 if with_prev > with_next else 0
310
+ jn = next(j for j, side in found if side == want)
311
+ accept(io, jn, 1, 1)
312
+ progressed = True
313
+ break # its neighbours may now settle other lines: drain the heap first
314
+ if not progressed:
315
+ return out
316
+ deferred = [i for i in deferred if self.match_old[i] < 0]
317
+
318
+ def decisive(self, io: int, side: int) -> bool:
319
+ """Does the neighbour on ``side`` (0 next, 1 prev) of old line ``io`` tell something?
320
+
321
+ It does when it is paired (so its refusal to host the line counts) or when the
322
+ document ends there; an unpaired neighbour (edited, split) says nothing.
323
+ """
324
+ nb = io + (1 if side == 0 else -1)
325
+ return not 0 <= nb < len(self.old) or self.match_old[nb] >= 0
326
+
327
+ def fill_gaps(self) -> list[tuple[int, int, bool]]:
328
+ """Pair what is left of identical text in order, gap by gap between anchors."""
329
+ slots_old: dict[int, list[int]] = defaultdict(list)
330
+ slots_new: dict[int, list[int]] = defaultdict(list)
331
+ for i in range(len(self.old)):
332
+ if self.match_old[i] < 0:
333
+ slots_old[bisect.bisect_left(self.base_old, i)].append(i)
334
+ for j in range(len(self.new)):
335
+ if self.match_new[j] < 0:
336
+ slots_new[bisect.bisect_left(self.base_new, j)].append(j)
337
+ out: list[tuple[int, int, bool]] = []
338
+ for slot, olds in slots_old.items():
339
+ news = slots_new.get(slot)
340
+ if not news or len(olds) * len(news) > DIFFLIB_CELLS:
341
+ continue
342
+ matcher = SequenceMatcher(
343
+ None,
344
+ [self.old[i].fp for i in olds],
345
+ [self.new[j].fp for j in news],
346
+ autojunk=False,
347
+ )
348
+ for block in matcher.get_matching_blocks():
349
+ for k in range(block.size):
350
+ i, j = olds[block.a + k], news[block.b + k]
351
+ self.pair(i, j)
352
+ out.append((i, j, False))
353
+ return out
354
+
355
+ def exact_leftovers(self) -> list[tuple[int, int, bool]]:
356
+ """Identical text left over after step 1, as (old, new, is_modified=False)."""
357
+ by_fp: dict[str, list[int]] = defaultdict(list)
358
+ for j in range(len(self.new)):
359
+ if self.free_new(j):
360
+ by_fp[self.new[j].fp].append(j)
361
+ taken: Counter[str] = Counter()
362
+ out: list[tuple[int, int, bool]] = []
363
+ for i in range(len(self.old)):
364
+ if not self.free_old(i):
365
+ continue
366
+ fp = self.old[i].fp
367
+ js = by_fp.get(fp, [])
368
+ if taken[fp] < len(js):
369
+ j = js[taken[fp]]
370
+ taken[fp] += 1
371
+ self.pair(i, j)
372
+ out.append((i, j, False))
373
+ return out
374
+
375
+ def groups(self, *, split: bool) -> None:
376
+ """Split (one old -> several new) or merge (several old -> one new) detection."""
377
+ one_free: Callable[[int], bool]
378
+ many_free: Callable[[int], bool]
379
+ if split:
380
+ one_words, many_words = self.words_old, self.words_new
381
+ one_free, many_free = self.free_old, self.free_new
382
+ else:
383
+ one_words, many_words = self.words_new, self.words_old
384
+ one_free, many_free = self.free_new, self.free_old
385
+ index: dict[str, list[int]] = defaultdict(list)
386
+ for m in range(len(many_words)):
387
+ if many_free(m):
388
+ for w in set(many_words[m]):
389
+ index[w].append(m)
390
+ order = sorted(
391
+ (
392
+ o
393
+ for o in range(len(one_words))
394
+ if one_free(o) and len(one_words[o]) >= GROUP_MIN_WORDS
395
+ ),
396
+ key=lambda o: -len(one_words[o]),
397
+ )
398
+ for o in order:
399
+ if not one_free(o):
400
+ continue
401
+ vocab = set(one_words[o])
402
+ votes: Counter[int] = Counter()
403
+ for w in vocab:
404
+ posting = index.get(w, ())
405
+ if len(posting) <= RARE_POSTINGS * 5:
406
+ votes.update(posting)
407
+ parts = sorted(
408
+ m
409
+ for m in votes
410
+ if many_free(m)
411
+ and sum(w in vocab for w in many_words[m]) >= GROUP_CONTAINMENT * len(many_words[m])
412
+ )
413
+ for run in _runs(parts):
414
+ if len(run) < 2 or len(run) > MAX_GROUP_RUN:
415
+ continue
416
+ # ratio >= GROUP_MIN_SIMILARITY needs the parts' words within [2/3, 3/2] of
417
+ # the paragraph's; a run with fewer words in all cannot reach it by trimming.
418
+ if sum(len(many_words[m]) for m in run) * 3 < 2 * len(one_words[o]):
419
+ continue
420
+ run = _trim_run(run, one_words[o], many_words)
421
+ if len(run) < 2:
422
+ continue
423
+ joined = [w for m in run for w in many_words[m]]
424
+ score = round(_ratio(one_words[o], joined, GROUP_MIN_SIMILARITY), 4)
425
+ if score < GROUP_MIN_SIMILARITY:
426
+ continue
427
+ key = tuple(run)
428
+ if split:
429
+ self.changes.append(Change(SPLIT, (o,), key, score))
430
+ self.group_old.add(o)
431
+ self.group_new.update(key)
432
+ else:
433
+ self.changes.append(Change(MERGED, key, (o,), score))
434
+ self.group_new.add(o)
435
+ self.group_old.update(key)
436
+ break
437
+
438
+ def fuzzy(self) -> list[tuple[int, int, bool]]:
439
+ """Modified paragraphs: 1:1 pairs of leftovers above MODIFIED_MIN, best first."""
440
+ olds = [i for i in range(len(self.old)) if self.free_old(i)]
441
+ news = [j for j in range(len(self.new)) if self.free_new(j)]
442
+ if not olds or not news:
443
+ return []
444
+ candidates: set[tuple[int, int]] = set()
445
+ slot_old: dict[int, list[int]] = defaultdict(list)
446
+ slot_new: dict[int, list[int]] = defaultdict(list)
447
+ for i in olds:
448
+ slot_old[bisect.bisect_left(self.base_old, i)].append(i)
449
+ for j in news:
450
+ slot_new[bisect.bisect_left(self.base_new, j)].append(j)
451
+ for slot, gap_old in slot_old.items():
452
+ gap_new = slot_new.get(slot, [])
453
+ if len(gap_old) * len(gap_new) <= GAP_PAIRS:
454
+ candidates.update((i, j) for i in gap_old for j in gap_new)
455
+ index: dict[str, list[int]] = defaultdict(list)
456
+ for i in olds:
457
+ for w in set(self.words_old[i]):
458
+ index[w].append(i)
459
+ for j in news:
460
+ votes: Counter[int] = Counter()
461
+ for w in set(self.words_new[j]):
462
+ posting = index.get(w, ())
463
+ if len(posting) <= RARE_POSTINGS:
464
+ votes.update(posting)
465
+ candidates.update((i, j) for i, _ in votes.most_common(TOP_CANDIDATES))
466
+ scored = sorted(
467
+ ((self.similarity(i, j), i, j) for i, j in candidates),
468
+ key=lambda t: (-t[0], t[1], t[2]),
469
+ )
470
+ out: list[tuple[int, int, bool]] = []
471
+ for score, i, j in scored:
472
+ if score < MODIFIED_MIN:
473
+ break
474
+ if self.match_old[i] < 0 and self.match_new[j] < 0:
475
+ self.pair(i, j)
476
+ out.append((i, j, True))
477
+ self.scores[(i, j)] = score
478
+ return out
479
+
480
+ def similarity(self, i: int, j: int) -> float:
481
+ wa, wb = self.words_old[i], self.words_new[j]
482
+ if min(len(wa), len(wb)) < SHORT_WORDS and max(len(wa), len(wb)) < 40:
483
+ return _ratio(" ".join(wa), " ".join(wb), MODIFIED_MIN)
484
+ return _ratio(wa, wb, MODIFIED_MIN)
485
+
486
+ def assemble(self, extras: list[tuple[int, int, bool]]) -> Alignment:
487
+ # Pairs the anchor chain did not hold are moves, unless they sit between their
488
+ # anchors and do not cross one another.
489
+ kept = set(_lis(sorted((i, j) for i, j, _ in extras if self.in_order(i, j))))
490
+ modified = {(i, j) for i, j, mod in extras if mod}
491
+ base = set(zip(self.base_old, self.base_new, strict=True))
492
+ for i, j in sorted(base | {(i, j) for i, j, _ in extras}):
493
+ moved = (i, j) not in base and (i, j) not in kept
494
+ if (i, j) in modified:
495
+ score = round(self.scores[(i, j)], 4)
496
+ self.changes.append(Change(MODIFIED, (i,), (j,), score, moved))
497
+ else:
498
+ self.changes.append(Change(MOVED if moved else UNCHANGED, (i,), (j,)))
499
+ claimed_old = {i for c in self.changes for i in c.old}
500
+ claimed_new = {j for c in self.changes for j in c.new}
501
+ self.changes.extend(
502
+ Change(DELETED, (i,), ()) for i in range(len(self.old)) if i not in claimed_old
503
+ )
504
+ self.changes.extend(
505
+ Change(INSERTED, (), (j,)) for j in range(len(self.new)) if j not in claimed_new
506
+ )
507
+ # An insertion sorts right after the old position of the new paragraph before it.
508
+ owner_of_new: dict[int, int] = {}
509
+ for c in self.changes:
510
+ for j in c.new:
511
+ if c.old:
512
+ owner_of_new[j] = c.old[-1]
513
+ pred: list[int] = []
514
+ last = -1
515
+ for j in range(len(self.new)):
516
+ last = owner_of_new.get(j, last)
517
+ pred.append(last)
518
+
519
+ def key(change: Change) -> tuple[float, int]:
520
+ if change.old:
521
+ return (float(change.old[0]), change.new[0] if change.new else -1)
522
+ return (pred[change.new[0]] + 0.5, change.new[0])
523
+
524
+ return Alignment(tuple(sorted(self.changes, key=key)), len(self.old), len(self.new))
525
+
526
+
527
+ def align(old: Sequence[_Para], new: Sequence[_Para]) -> Alignment:
528
+ """Align the paragraphs of two versions; see the module docstring."""
529
+ return _Aligner(old, new).run()