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