texdiff 0.2.5__tar.gz → 0.2.6__tar.gz
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.
- {texdiff-0.2.5/src/texdiff.egg-info → texdiff-0.2.6}/PKG-INFO +1 -1
- {texdiff-0.2.5 → texdiff-0.2.6}/pyproject.toml +1 -1
- {texdiff-0.2.5 → texdiff-0.2.6}/src/texdiff/__init__.py +1 -1
- {texdiff-0.2.5 → texdiff-0.2.6}/src/texdiff/align.py +12 -0
- {texdiff-0.2.5 → texdiff-0.2.6}/src/texdiff/api.py +46 -11
- {texdiff-0.2.5 → texdiff-0.2.6}/src/texdiff/emit.py +270 -1
- {texdiff-0.2.5 → texdiff-0.2.6}/src/texdiff/textdiff.py +69 -0
- {texdiff-0.2.5 → texdiff-0.2.6/src/texdiff.egg-info}/PKG-INFO +1 -1
- {texdiff-0.2.5 → texdiff-0.2.6}/tests/test_align.py +13 -0
- {texdiff-0.2.5 → texdiff-0.2.6}/tests/test_api.py +61 -0
- texdiff-0.2.6/tests/test_textdiff.py +123 -0
- texdiff-0.2.5/tests/test_textdiff.py +0 -61
- {texdiff-0.2.5 → texdiff-0.2.6}/LICENSE +0 -0
- {texdiff-0.2.5 → texdiff-0.2.6}/README.md +0 -0
- {texdiff-0.2.5 → texdiff-0.2.6}/setup.cfg +0 -0
- {texdiff-0.2.5 → texdiff-0.2.6}/src/texdiff/check.py +0 -0
- {texdiff-0.2.5 → texdiff-0.2.6}/src/texdiff/cli.py +0 -0
- {texdiff-0.2.5 → texdiff-0.2.6}/src/texdiff/flatten.py +0 -0
- {texdiff-0.2.5 → texdiff-0.2.6}/src/texdiff/newlines.py +0 -0
- {texdiff-0.2.5 → texdiff-0.2.6}/src/texdiff/nodes.py +0 -0
- {texdiff-0.2.5 → texdiff-0.2.6}/src/texdiff/oldlines.py +0 -0
- {texdiff-0.2.5 → texdiff-0.2.6}/src/texdiff/parse.py +0 -0
- {texdiff-0.2.5 → texdiff-0.2.6}/src/texdiff/preamble.py +0 -0
- {texdiff-0.2.5 → texdiff-0.2.6}/src/texdiff/tables.py +0 -0
- {texdiff-0.2.5 → texdiff-0.2.6}/src/texdiff.egg-info/SOURCES.txt +0 -0
- {texdiff-0.2.5 → texdiff-0.2.6}/src/texdiff.egg-info/dependency_links.txt +0 -0
- {texdiff-0.2.5 → texdiff-0.2.6}/src/texdiff.egg-info/entry_points.txt +0 -0
- {texdiff-0.2.5 → texdiff-0.2.6}/src/texdiff.egg-info/requires.txt +0 -0
- {texdiff-0.2.5 → texdiff-0.2.6}/src/texdiff.egg-info/top_level.txt +0 -0
- {texdiff-0.2.5 → texdiff-0.2.6}/tests/test_alignment.py +0 -0
- {texdiff-0.2.5 → texdiff-0.2.6}/tests/test_check.py +0 -0
- {texdiff-0.2.5 → texdiff-0.2.6}/tests/test_cli.py +0 -0
- {texdiff-0.2.5 → texdiff-0.2.6}/tests/test_corpus.py +0 -0
- {texdiff-0.2.5 → texdiff-0.2.6}/tests/test_emit.py +0 -0
- {texdiff-0.2.5 → texdiff-0.2.6}/tests/test_flatten.py +0 -0
- {texdiff-0.2.5 → texdiff-0.2.6}/tests/test_parse.py +0 -0
- {texdiff-0.2.5 → texdiff-0.2.6}/tests/test_preamble.py +0 -0
- {texdiff-0.2.5 → texdiff-0.2.6}/tests/test_table_head_order.py +0 -0
- {texdiff-0.2.5 → texdiff-0.2.6}/tests/test_tables.py +0 -0
- {texdiff-0.2.5 → texdiff-0.2.6}/tests/test_tables_render.py +0 -0
- {texdiff-0.2.5 → texdiff-0.2.6}/tests/test_verbatim.py +0 -0
|
@@ -107,6 +107,18 @@ def _align_plain(old: list[Node], new: list[Node]) -> list[Edit]:
|
|
|
107
107
|
def _pair(o: Node, n: Node) -> Edit:
|
|
108
108
|
if o.text == n.text:
|
|
109
109
|
return Match(node=o)
|
|
110
|
+
if not (o.text or "").strip() and not (n.text or "").strip():
|
|
111
|
+
# whitespace-only pair: the two revisions differ only in
|
|
112
|
+
# how many blank lines separate two paragraphs. A Modify
|
|
113
|
+
# here renders as a del-block + add-block pair, and the
|
|
114
|
+
# marker glue pass then collapses the blank line BETWEEN
|
|
115
|
+
# those markers - the paragraph break both revisions had
|
|
116
|
+
# disappears, and the next paragraph's \sphinxAtStartPar
|
|
117
|
+
# (a \leavevmode, NOT a \par) leaves its first line glued
|
|
118
|
+
# to the previous one. Keep the NEW bytes verbatim: the
|
|
119
|
+
# break survives and no change is marked for invisible
|
|
120
|
+
# whitespace.
|
|
121
|
+
return Match(node=n)
|
|
110
122
|
if o.kind == "row" and n.kind == "row" and o.name == n.name:
|
|
111
123
|
# equal row signature (content key) with differing text
|
|
112
124
|
# means the difference is purely structural: \hline on
|
|
@@ -21,6 +21,11 @@ from .align import Delete, Edit, Insert, Match, Modify, align
|
|
|
21
21
|
# re-added wholesale instead of word-marked: two sentences sharing
|
|
22
22
|
# less than half their words are a rewrite, not an edit
|
|
23
23
|
_REPLACE_WORD_SIMILARITY = 0.5
|
|
24
|
+
|
|
25
|
+
# minimum common word run at a paragraph edge that keeps the pair on
|
|
26
|
+
# the word-level path even below the ratio floor: a whole clause
|
|
27
|
+
# (4+ words) is clearly the same sentence edited, not a rewrite
|
|
28
|
+
_EDGE_WORD_MIN = 4
|
|
24
29
|
from .emit import LatexdiffMarkup, render
|
|
25
30
|
from .flatten import Flattener, flatten_file, flatten_source
|
|
26
31
|
from .nodes import Node, text_node
|
|
@@ -257,24 +262,54 @@ def _run_similarity(a: str, b: str) -> float:
|
|
|
257
262
|
|
|
258
263
|
|
|
259
264
|
def _extends_by_words(short: str, long: str) -> bool:
|
|
260
|
-
"""Whether one run
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
265
|
+
"""Whether one run shares a long word edge with the other.
|
|
266
|
+
|
|
267
|
+
Two cases keep a paragraph word-diffed even when the overlap
|
|
268
|
+
ratio falls below the replace threshold:
|
|
269
|
+
|
|
270
|
+
* **appended clause** - ``... compression.`` growing into
|
|
271
|
+
``... compression: the file structure ...``: the shared
|
|
272
|
+
prefix is drowned by the additions, yet the change IS an
|
|
273
|
+
edit of the same sentence (struck final period, inserted
|
|
274
|
+
tail);
|
|
275
|
+
* **shared edge** - a paragraph split at a macro boundary (the
|
|
276
|
+
parser cuts runs at ``\\sphinxhyphen`` etc.) can leave a tail
|
|
277
|
+
whose common prefix with the other side is a whole clause
|
|
278
|
+
(``array support and HDF5 diagnostic suppression)``) while
|
|
279
|
+
the rest is reworded. Full containment misses it; a long
|
|
280
|
+
common edge catches it.
|
|
281
|
+
|
|
282
|
+
The word differ renders both correctly, so a shared edge of at
|
|
283
|
+
least a few words overrides the ratio before the paragraph is
|
|
269
284
|
judged a wholesale rewrite.
|
|
270
285
|
"""
|
|
271
286
|
wa = [w for w in (t.strip(".,;:!?()\"'`") for t in short.split()) if w]
|
|
272
287
|
wb = [w for w in (t.strip(".,;:!?()\"'`") for t in long.split()) if w]
|
|
273
288
|
if len(wa) >= len(wb):
|
|
274
289
|
wa, wb = wb, wa
|
|
275
|
-
if not wa or
|
|
290
|
+
if not wa or not wb:
|
|
276
291
|
return False
|
|
277
|
-
|
|
292
|
+
# containment (append/remove): needs a meaningful addition
|
|
293
|
+
if len(wb) - len(wa) >= 3 and (
|
|
294
|
+
wb[: len(wa)] == wa or wb[len(wb) - len(wa):] == wa
|
|
295
|
+
):
|
|
296
|
+
return True
|
|
297
|
+
# shared edge: longest common word run at the start or the end
|
|
298
|
+
# of both, counted case-insensitively (``Writing`` vs
|
|
299
|
+
# ``writing`` is the same word) and requiring a meaningful
|
|
300
|
+
# stretch - a whole clause, not a stop word
|
|
301
|
+
def edge(a: list[str], b: list[str]) -> int:
|
|
302
|
+
n = 0
|
|
303
|
+
for x, y in zip(a, b):
|
|
304
|
+
if x.lower() != y.lower():
|
|
305
|
+
break
|
|
306
|
+
n += 1
|
|
307
|
+
return n
|
|
308
|
+
|
|
309
|
+
return max(
|
|
310
|
+
edge(wa, wb),
|
|
311
|
+
edge(wa[::-1], wb[::-1]),
|
|
312
|
+
) >= _EDGE_WORD_MIN
|
|
278
313
|
|
|
279
314
|
|
|
280
315
|
def _paragraph_edits(old: str, new: str) -> list[Edit] | None:
|
|
@@ -87,6 +87,7 @@ def render(edits: list[Edit], markup: LatexdiffMarkup = LatexdiffMarkup()) -> st
|
|
|
87
87
|
# between two Inserts and blocks the fusion
|
|
88
88
|
edits = _pull_punctuation_deletes(edits)
|
|
89
89
|
edits = _fuse_split_add_regions(edits)
|
|
90
|
+
edits = _hoist_repeated_phrases(edits)
|
|
90
91
|
for edit in edits:
|
|
91
92
|
if isinstance(edit, Match):
|
|
92
93
|
out.append(edit.node.text)
|
|
@@ -685,6 +686,268 @@ def _fuse_split_add_regions(edits: list[Edit]) -> list[Edit]:
|
|
|
685
686
|
return out
|
|
686
687
|
|
|
687
688
|
|
|
689
|
+
# relocated-phrase detection: a word run shared between a deleted
|
|
690
|
+
# region and the following added region must span at least this many
|
|
691
|
+
# words ...
|
|
692
|
+
_SHARED_RUN_MIN_WORDS = 4
|
|
693
|
+
# ... AND at least this fraction of the shorter region's words, so
|
|
694
|
+
# the bar rises with region size: a 30-word region needs a 10-word
|
|
695
|
+
# shared run, a 6-word region only the floor of 4
|
|
696
|
+
_SHARED_RUN_MIN_FRACTION = 3
|
|
697
|
+
|
|
698
|
+
# edge punctuation: word identity ignores it ("filtering)" pairs
|
|
699
|
+
# with "filtering"), but the characters themselves belong to the
|
|
700
|
+
# side that carries them and keep that side's markup
|
|
701
|
+
_SHARED_RUN_PUNCT = ".,;:!?()\"'`"
|
|
702
|
+
|
|
703
|
+
|
|
704
|
+
def _shared_run_min(na: int, nb: int) -> int:
|
|
705
|
+
"""Minimum word count for a run to count as relocated text.
|
|
706
|
+
|
|
707
|
+
Scales with region size instead of a flat constant: the longer
|
|
708
|
+
the deleted and added regions, the longer a common stretch must
|
|
709
|
+
be before it is treated as deliberate unchanged content that
|
|
710
|
+
moved, rather than words two different sentences happen to
|
|
711
|
+
share.
|
|
712
|
+
"""
|
|
713
|
+
return max(
|
|
714
|
+
_SHARED_RUN_MIN_WORDS, min(na, nb) // _SHARED_RUN_MIN_FRACTION
|
|
715
|
+
)
|
|
716
|
+
|
|
717
|
+
|
|
718
|
+
def _word_spans(text: str) -> list[tuple[int, int, str]]:
|
|
719
|
+
"""(start, end, word) for every whitespace-separated word."""
|
|
720
|
+
return [(m.start(), m.end(), m.group(0)) for m in re.finditer(r"\S+", text)]
|
|
721
|
+
|
|
722
|
+
|
|
723
|
+
def _relocated_span(del_text: str, ins_text: str) -> tuple[int, int] | None:
|
|
724
|
+
"""Character span in ``del_text`` of a phrase that also exists
|
|
725
|
+
verbatim in ``ins_text`` (the following added region).
|
|
726
|
+
|
|
727
|
+
Words are compared with edge punctuation stripped - a deleted
|
|
728
|
+
word ``filtering)`` pairs with an added ``filtering`` - but the
|
|
729
|
+
returned span stops at word-character boundaries, so stray
|
|
730
|
+
punctuation (the ``(`` of ``(with``, the ``).`` after
|
|
731
|
+
``filtering``) stays struck on the old side. The shared span
|
|
732
|
+
must appear in the added text character-for-character; interior
|
|
733
|
+
punctuation differences are not relocation.
|
|
734
|
+
"""
|
|
735
|
+
dw = _word_spans(del_text)
|
|
736
|
+
iw = _word_spans(ins_text)
|
|
737
|
+
if not dw or not iw:
|
|
738
|
+
return None
|
|
739
|
+
|
|
740
|
+
def keys(spans: list[tuple[int, int, str]]) -> list[str]:
|
|
741
|
+
# empty keys (pure-punctuation words) must not match each
|
|
742
|
+
# other; sentinel keeps 1:1 index alignment with spans
|
|
743
|
+
return [
|
|
744
|
+
w[2].strip(_SHARED_RUN_PUNCT) or f"\x00{w[0]}"
|
|
745
|
+
for w in spans
|
|
746
|
+
]
|
|
747
|
+
|
|
748
|
+
dk, ik = keys(dw), keys(iw)
|
|
749
|
+
sm = SequenceMatcher(a=dk, b=ik, autojunk=False)
|
|
750
|
+
best: tuple[int, int] | None = None
|
|
751
|
+
for blk in sm.get_matching_blocks():
|
|
752
|
+
if blk.size < _shared_run_min(len(dk), len(ik)):
|
|
753
|
+
continue
|
|
754
|
+
first, last = dw[blk.a], dw[blk.a + blk.size - 1]
|
|
755
|
+
s = first[0] + (len(first[2]) - len(first[2].lstrip(_SHARED_RUN_PUNCT)))
|
|
756
|
+
e = last[1] - (len(last[2]) - len(last[2].rstrip(_SHARED_RUN_PUNCT)))
|
|
757
|
+
if e <= s:
|
|
758
|
+
continue
|
|
759
|
+
if ins_text.find(del_text[s:e]) < 0:
|
|
760
|
+
continue
|
|
761
|
+
if best is None or (e - s) > (best[1] - best[0]):
|
|
762
|
+
best = (s, e)
|
|
763
|
+
return best
|
|
764
|
+
|
|
765
|
+
|
|
766
|
+
def _hoist_repeated_phrases(edits: list[Edit]) -> list[Edit]:
|
|
767
|
+
"""Render a phrase that moved across a node boundary once, unmarked.
|
|
768
|
+
|
|
769
|
+
The parser splits text at macro boundaries (``\\sphinxhyphen{}``,
|
|
770
|
+
``{\\hyperref{...}}``), so a sentence fragment deleted on the old
|
|
771
|
+
side and its identical twin inside the following inserted text
|
|
772
|
+
can never meet inside one word diff - the aligner saw two
|
|
773
|
+
different fragments. The phrase would render twice: once struck
|
|
774
|
+
red in place, once again coloured as new ("with configurable
|
|
775
|
+
zlib compression and shuffle filtering" retired AND re-added).
|
|
776
|
+
|
|
777
|
+
A delete whose text contains a word run long enough to be
|
|
778
|
+
deliberate unchanged content and shared - word-for-word, ignoring
|
|
779
|
+
edge punctuation - with the following added region (inserts and
|
|
780
|
+
macro glue up to the next real delete or paragraph break) is a
|
|
781
|
+
relocation, not a deletion. Both regions give the shared run up:
|
|
782
|
+
the delete keeps only what truly went away (the stray bracket
|
|
783
|
+
characters around the run) and, when the run sits inside ONE
|
|
784
|
+
insert, that insert splits into add-run / plain shared text /
|
|
785
|
+
add-run - the phrase typesets once, unmarked, exactly as
|
|
786
|
+
latexdiff renders text present in both revisions.
|
|
787
|
+
"""
|
|
788
|
+
|
|
789
|
+
def text_node(t: str, like: Node) -> Node:
|
|
790
|
+
return Node(kind=like.kind, text=t, atom=True, pos=like.pos)
|
|
791
|
+
|
|
792
|
+
for _ in range(10): # converges; guard against pathological loops
|
|
793
|
+
replaced = False
|
|
794
|
+
for i, edit in enumerate(edits):
|
|
795
|
+
if not (isinstance(edit, Delete) and edit.old.kind == "text"):
|
|
796
|
+
continue
|
|
797
|
+
del_text = edit.old.text
|
|
798
|
+
if len(_word_spans(del_text)) < _SHARED_RUN_MIN_WORDS:
|
|
799
|
+
continue
|
|
800
|
+
region: list[str] = []
|
|
801
|
+
# eligible inserts in the region, as (index, accumulated
|
|
802
|
+
# offset) so a char span in the joined text maps back
|
|
803
|
+
ins_at: list[tuple[int, int]] = []
|
|
804
|
+
off = 0
|
|
805
|
+
for j, nxt in enumerate(edits[i + 1 :], start=i + 1):
|
|
806
|
+
if isinstance(nxt, Delete):
|
|
807
|
+
if nxt.old.text.strip():
|
|
808
|
+
break # real competing old content ends it
|
|
809
|
+
continue # old paragraph separator: text goes on
|
|
810
|
+
if isinstance(nxt, Match):
|
|
811
|
+
if "\n\n" in nxt.node.text:
|
|
812
|
+
break # paragraph boundary ends the region
|
|
813
|
+
region.append(nxt.node.text)
|
|
814
|
+
off += len(nxt.node.text)
|
|
815
|
+
elif isinstance(nxt, Insert) and _inline_kind(nxt.new):
|
|
816
|
+
region.append(nxt.new.text)
|
|
817
|
+
ins_at.append((j, off))
|
|
818
|
+
off += len(nxt.new.text)
|
|
819
|
+
elif (
|
|
820
|
+
isinstance(nxt, Insert)
|
|
821
|
+
and nxt.new.kind == "group"
|
|
822
|
+
and not _needs_block_env(nxt.new)
|
|
823
|
+
):
|
|
824
|
+
# inline group (e.g. {\hyperref{...}}): part of the
|
|
825
|
+
# surrounding sentence, its text belongs to the
|
|
826
|
+
# added region
|
|
827
|
+
region.append(nxt.new.text)
|
|
828
|
+
ins_at.append((j, off))
|
|
829
|
+
off += len(nxt.new.text)
|
|
830
|
+
else:
|
|
831
|
+
break
|
|
832
|
+
span = _relocated_span(del_text, "".join(region))
|
|
833
|
+
if span is None:
|
|
834
|
+
continue
|
|
835
|
+
lo, hi = span
|
|
836
|
+
shared = del_text[lo:hi]
|
|
837
|
+
# position of the shared run inside the JOINED region -
|
|
838
|
+
# del-text offsets and region offsets are different
|
|
839
|
+
# coordinate systems
|
|
840
|
+
rlo = "".join(region).find(shared)
|
|
841
|
+
if rlo < 0:
|
|
842
|
+
continue
|
|
843
|
+
# locate the single insert carrying the shared run; when
|
|
844
|
+
# it straddles insert/match boundaries the plain-text
|
|
845
|
+
# emission is not possible - the insert stays whole and
|
|
846
|
+
# the phrase renders as (unstruck) added text
|
|
847
|
+
carrier: tuple[int, int] | None = None
|
|
848
|
+
for j, base in ins_at:
|
|
849
|
+
txt = edits[j].new.text
|
|
850
|
+
if base <= rlo and base + len(txt) >= rlo + len(shared):
|
|
851
|
+
found = txt.find(shared)
|
|
852
|
+
if found < 0:
|
|
853
|
+
carrier = None
|
|
854
|
+
break
|
|
855
|
+
carrier = (j, found)
|
|
856
|
+
break
|
|
857
|
+
if carrier is None:
|
|
858
|
+
continue
|
|
859
|
+
j, c_off = carrier
|
|
860
|
+
ins = edits[j]
|
|
861
|
+
before, after = (
|
|
862
|
+
ins.new.text[:c_off],
|
|
863
|
+
ins.new.text[c_off + len(shared) :],
|
|
864
|
+
)
|
|
865
|
+
# the delete keeps only its genuinely removed edges. The
|
|
866
|
+
# PREFIX edge stays at the delete's position (the "(" of
|
|
867
|
+
# a retired parenthetical reads correctly right before
|
|
868
|
+
# the replacement text); the SUFFIX edge belongs AFTER
|
|
869
|
+
# the relocated phrase - the old ")" closed the
|
|
870
|
+
# parenthetical, which now ends at the phrase's new
|
|
871
|
+
# position, so striking it before the phrase puts the
|
|
872
|
+
# closing bracket in the middle of the sentence.
|
|
873
|
+
head = [t for t in (del_text[:lo],) if t.strip()]
|
|
874
|
+
tail = [t for t in (del_text[hi:],) if t.strip()]
|
|
875
|
+
# the insert: shared run pulled out as plain text. Edge
|
|
876
|
+
# fragments keep their WHITESPACE - a dropped fragment's
|
|
877
|
+
# leading/trailing newlines carry the new revision's
|
|
878
|
+
# paragraphing, and losing them glues the next paragraph
|
|
879
|
+
# onto the relocated phrase ('...filtering).' + 'SAFE
|
|
880
|
+
# handling:' on one line).
|
|
881
|
+
def ws_only(t: str) -> str:
|
|
882
|
+
return "".join(c for c in t if c.isspace())
|
|
883
|
+
|
|
884
|
+
pieces: list[Edit] = [Match(node=text_node(shared, ins.new))]
|
|
885
|
+
if before.strip():
|
|
886
|
+
pieces.insert(
|
|
887
|
+
0, Insert(new=text_node(before, ins.new))
|
|
888
|
+
)
|
|
889
|
+
elif before:
|
|
890
|
+
pieces.insert(0, Match(node=text_node(before, ins.new)))
|
|
891
|
+
# an add-side tail that is pure punctuation the delete's
|
|
892
|
+
# suffix edge already carries (the final "." of both
|
|
893
|
+
# revisions) must not render twice - the struck suffix
|
|
894
|
+
# covers it, but any whitespace in the tail is the new
|
|
895
|
+
# side's paragraphing and survives as a plain match
|
|
896
|
+
dropped_tail = False
|
|
897
|
+
if after.strip() and not (
|
|
898
|
+
tail
|
|
899
|
+
and after.strip() in "".join(tail).strip()
|
|
900
|
+
and not re.search(r"\w", after)
|
|
901
|
+
):
|
|
902
|
+
pieces.append(Insert(new=text_node(after, ins.new)))
|
|
903
|
+
else:
|
|
904
|
+
dropped_tail = True
|
|
905
|
+
if tail:
|
|
906
|
+
# the old suffix may end with punctuation the new
|
|
907
|
+
# side repeats after the phrase (both revisions end
|
|
908
|
+
# the sentence with "."): strike only the part that
|
|
909
|
+
# truly went away (the ")") and let the repeated
|
|
910
|
+
# punctuation render once, plain
|
|
911
|
+
t = "".join(tail)
|
|
912
|
+
a = after.strip()
|
|
913
|
+
common = 0
|
|
914
|
+
while (
|
|
915
|
+
common < len(t)
|
|
916
|
+
and common < len(a)
|
|
917
|
+
and t[-1 - common] == a[-1 - common]
|
|
918
|
+
and t[-1 - common] in _SHARED_RUN_PUNCT
|
|
919
|
+
):
|
|
920
|
+
common += 1
|
|
921
|
+
pure = t[: len(t) - common]
|
|
922
|
+
carry = t[len(t) - common :]
|
|
923
|
+
# suffix edge + shared punctuation, then the new
|
|
924
|
+
# side's paragraph break - the order matters: the
|
|
925
|
+
# break glued after a dropped tail sticks the next
|
|
926
|
+
# paragraph onto the struck bracket
|
|
927
|
+
if pure.strip():
|
|
928
|
+
pieces.append(Delete(old=text_node(pure, edit.old)))
|
|
929
|
+
if carry:
|
|
930
|
+
pieces.append(
|
|
931
|
+
Match(node=text_node(carry, edit.old))
|
|
932
|
+
)
|
|
933
|
+
if dropped_tail:
|
|
934
|
+
ws = ws_only(after)
|
|
935
|
+
if ws:
|
|
936
|
+
pieces.append(Match(node=text_node(ws, ins.new)))
|
|
937
|
+
edits = (
|
|
938
|
+
edits[:i]
|
|
939
|
+
+ [Delete(old=text_node(t, edit.old)) for t in head]
|
|
940
|
+
+ edits[i + 1 : j]
|
|
941
|
+
+ pieces
|
|
942
|
+
+ edits[j + 1 :]
|
|
943
|
+
)
|
|
944
|
+
replaced = True
|
|
945
|
+
break
|
|
946
|
+
if not replaced:
|
|
947
|
+
break
|
|
948
|
+
return edits
|
|
949
|
+
|
|
950
|
+
|
|
688
951
|
# sentence punctuation a stranded Delete of which reads as noise
|
|
689
952
|
_LONE_PUNCTUATION = {".", ",", ";", ":", "!", "?", ")", "(", "]."}
|
|
690
953
|
|
|
@@ -1616,7 +1879,13 @@ def _render_recursed(edit: Modify, markup: LatexdiffMarkup) -> str:
|
|
|
1616
1879
|
edit.new.text, markup.add_open, markup.add_close
|
|
1617
1880
|
)
|
|
1618
1881
|
prefix, suffix = wrapped
|
|
1619
|
-
|
|
1882
|
+
# the inner script is a flat list of atomic edits just like the
|
|
1883
|
+
# top-level one: relocation hoisting must run here too (a moved
|
|
1884
|
+
# phrase and its twin can straddle a macro inside the recursed
|
|
1885
|
+
# body, e.g. ...schema} + \sphinxhyphen{} + driven writer...)
|
|
1886
|
+
rendered = _render_row_region(
|
|
1887
|
+
_hoist_repeated_phrases(edit.inner or []), markup
|
|
1888
|
+
)
|
|
1620
1889
|
# the parse absorbs a longtable tail (``\endfoot\endlastfoot``)
|
|
1621
1890
|
# into the first data row node's text; when the surrounding
|
|
1622
1891
|
# prefix already carries the tail, drop the duplicate from the
|
|
@@ -86,6 +86,7 @@ def word_diff(old: str, new: str) -> list[Chunk]:
|
|
|
86
86
|
chunks = _merge_adjacent(chunks)
|
|
87
87
|
chunks = _whitespace_to_new(chunks)
|
|
88
88
|
chunks = _merge_replacements(chunks)
|
|
89
|
+
chunks = _coalesce_interleaved(chunks)
|
|
89
90
|
return chunks
|
|
90
91
|
|
|
91
92
|
|
|
@@ -155,3 +156,71 @@ def _merge_replacements(chunks: list[Chunk]) -> list[Chunk]:
|
|
|
155
156
|
changed = True
|
|
156
157
|
break
|
|
157
158
|
return _merge_adjacent(out)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
# a fragmented region must have at least this many change groups
|
|
162
|
+
# (delete/insert runs) before it is coalesced into one replacement;
|
|
163
|
+
# fewer means a plain word-level edit, not a reordering
|
|
164
|
+
_COALESCE_MIN_GROUPS = 3
|
|
165
|
+
|
|
166
|
+
# an equal chunk of at least this many words counts as a stable
|
|
167
|
+
# "bridge": edits on either side of it are SEPARATE edits, each
|
|
168
|
+
# keeping its own word-level markup. Shorter equals are filler the
|
|
169
|
+
# sequence matcher paired across the real edit and get absorbed
|
|
170
|
+
_BRIDGE_WORDS = 2
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _word_count(text: str) -> int:
|
|
174
|
+
return len([w for w in text.split() if w])
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _coalesce_interleaved(chunks: list[Chunk]) -> list[Chunk]:
|
|
178
|
+
"""Collapse fragmented regions between stable bridges.
|
|
179
|
+
|
|
180
|
+
A reworded sentence often shares filler words with its new
|
|
181
|
+
version (``with``, ``configurable``, ``zlib``, ...), and the
|
|
182
|
+
sequence matcher happily pairs them while crossing the real
|
|
183
|
+
edit - the result is a del/ins mosaic where reviewer-relevant
|
|
184
|
+
clauses are shredded token by token.
|
|
185
|
+
|
|
186
|
+
Partition the chunk list at stable bridges (equal chunks of a
|
|
187
|
+
few words or more - the shared prefix, the shared tail, or
|
|
188
|
+
genuinely unchanged stretches inside the sentence). A region
|
|
189
|
+
BETWEEN bridges that consists of three or more change groups
|
|
190
|
+
is a crossed reordering, not separate small edits: render it
|
|
191
|
+
as ONE struck old region followed by ONE added new region -
|
|
192
|
+
the track-changes look of a whole-clause replacement.
|
|
193
|
+
"""
|
|
194
|
+
if not chunks or len(chunks) < 5:
|
|
195
|
+
return chunks
|
|
196
|
+
|
|
197
|
+
# segment boundaries: indices after which a stable bridge ends
|
|
198
|
+
out: list[Chunk] = []
|
|
199
|
+
seg: list[Chunk] = [] # current region between bridges
|
|
200
|
+
|
|
201
|
+
def flush(seg: list[Chunk]) -> None:
|
|
202
|
+
if not seg:
|
|
203
|
+
return
|
|
204
|
+
groups = 0
|
|
205
|
+
prev_op = EQUAL
|
|
206
|
+
for c in seg:
|
|
207
|
+
if c.op != EQUAL and prev_op == EQUAL:
|
|
208
|
+
groups += 1
|
|
209
|
+
prev_op = c.op
|
|
210
|
+
if groups >= _COALESCE_MIN_GROUPS:
|
|
211
|
+
old_mid = "".join(c.text for c in seg if c.op in (EQUAL, DELETE))
|
|
212
|
+
new_mid = "".join(c.text for c in seg if c.op in (EQUAL, INSERT))
|
|
213
|
+
out.append(Chunk(DELETE, old_mid))
|
|
214
|
+
out.append(Chunk(INSERT, new_mid))
|
|
215
|
+
else:
|
|
216
|
+
out.extend(seg)
|
|
217
|
+
|
|
218
|
+
for c in chunks:
|
|
219
|
+
if c.op == EQUAL and _word_count(c.text) >= _BRIDGE_WORDS:
|
|
220
|
+
flush(seg)
|
|
221
|
+
seg = []
|
|
222
|
+
out.append(c)
|
|
223
|
+
else:
|
|
224
|
+
seg.append(c)
|
|
225
|
+
flush(seg)
|
|
226
|
+
return out
|
|
@@ -90,6 +90,19 @@ class TestSameSignatureDifferentText:
|
|
|
90
90
|
edits = align(old, new)
|
|
91
91
|
assert [type(e).__name__ for e in edits] == ["Insert", "Match", "Match"]
|
|
92
92
|
|
|
93
|
+
def test_whitespace_only_pair_matches_new_bytes(self):
|
|
94
|
+
# two revisions differing only in blank-line count between
|
|
95
|
+
# paragraphs: a Modify here renders as del-block + add-block
|
|
96
|
+
# markers, and the marker glue pass then eats the blank line
|
|
97
|
+
# between them - the paragraph break vanishes and the next
|
|
98
|
+
# paragraph's first line glues to the previous one. The pair
|
|
99
|
+
# must Match the NEW bytes instead.
|
|
100
|
+
old = [text_node("para one."), text_node("\n\n\n"), text_node("para two.")]
|
|
101
|
+
new = [text_node("para one."), text_node("\n\n"), text_node("para two.")]
|
|
102
|
+
edits = align(old, new)
|
|
103
|
+
assert all(isinstance(e, Match) for e in edits)
|
|
104
|
+
assert edits[1].node.text == "\n\n" # new bytes kept
|
|
105
|
+
|
|
93
106
|
|
|
94
107
|
def test_sibling_swap_rescue_pairs_verbatim_equal_swap_as_match():
|
|
95
108
|
"""A swap that lands on a verbatim-equal sibling must Match.
|
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
import re
|
|
6
|
+
|
|
5
7
|
import pytest
|
|
6
8
|
|
|
7
9
|
from texdiff import DiffResult, diff_documents, diff_files
|
|
@@ -245,3 +247,62 @@ class TestAppendedClause:
|
|
|
245
247
|
# paragraph inline form, never word fragments)
|
|
246
248
|
assert "\\DIFdel{Write output to NetCDF4" in out
|
|
247
249
|
assert "\\DIFadd{Completely different" in out
|
|
250
|
+
|
|
251
|
+
def test_shared_edge_paragraph_stays_word_diffed(self):
|
|
252
|
+
# a paragraph split at a macro boundary leaves a tail whose
|
|
253
|
+
# common prefix with the other side is a whole clause while
|
|
254
|
+
# the rest is reworded: the ratio dips below the floor, but
|
|
255
|
+
# the shared edge proves it is an edit, not a rewrite
|
|
256
|
+
old = (
|
|
257
|
+
"\\documentclass{article}\n\\begin{document}\n"
|
|
258
|
+
"Common methods for reading HDF5 datasets (with masked-array "
|
|
259
|
+
"support and HDF5 diagnostic suppression) and writing NetCDF4 "
|
|
260
|
+
"outputs (with configurable zlib compression and shuffle "
|
|
261
|
+
"filtering).\n\\end{document}\n"
|
|
262
|
+
)
|
|
263
|
+
new = old.replace(
|
|
264
|
+
"suppression) and writing NetCDF4 outputs (with configurable "
|
|
265
|
+
"zlib compression and shuffle filtering).",
|
|
266
|
+
"suppression). Writing of NetCDF4 outputs is performed through "
|
|
267
|
+
"the generic schema-driven writer (see Overview), with "
|
|
268
|
+
"configurable zlib compression and shuffle filtering.",
|
|
269
|
+
)
|
|
270
|
+
out = diff_documents(old, new, inject_preamble=False).marked_up
|
|
271
|
+
# the shared prefix is untouched - no wholesale retire
|
|
272
|
+
assert "\\DIFdel{Common methods for reading" not in out
|
|
273
|
+
# and the change is marked inline at word level
|
|
274
|
+
assert "\\DIFdel{" in out and "\\DIFadd{" in out
|
|
275
|
+
|
|
276
|
+
def test_relocated_phrase_not_struck_twice(self):
|
|
277
|
+
# the parser splits the new sentence at \sphinxhyphen{}, so
|
|
278
|
+
# the deleted parenthetical and its identical twin inside
|
|
279
|
+
# the following inserted text never meet inside ONE word
|
|
280
|
+
# diff. Without relocation hoisting the phrase renders
|
|
281
|
+
# twice: struck red in place AND again in the added region.
|
|
282
|
+
# A long shared word run is a relocation, not a deletion:
|
|
283
|
+
# only the stray parens stay struck, the phrase carries
|
|
284
|
+
# once (in the added region).
|
|
285
|
+
old = (
|
|
286
|
+
"\\begin{document}\n"
|
|
287
|
+
"The reader retires one option (with configurable zlib "
|
|
288
|
+
"compression and shuffle filtering).\n"
|
|
289
|
+
"\\end{document}\n"
|
|
290
|
+
)
|
|
291
|
+
new = (
|
|
292
|
+
"\\begin{document}\n"
|
|
293
|
+
"The reader retires one option is performed through the "
|
|
294
|
+
"generic schema\\sphinxhyphen{}driven writer (see Overview), "
|
|
295
|
+
"with configurable zlib compression and shuffle filtering.\n"
|
|
296
|
+
"\\end{document}\n"
|
|
297
|
+
)
|
|
298
|
+
out = diff_documents(old, new, inject_preamble=False).marked_up
|
|
299
|
+
# the relocated phrase is never struck...
|
|
300
|
+
assert not re.search(r"\\DIFdel\{[^}]*configurable", out)
|
|
301
|
+
# ...but is present once, in the added region
|
|
302
|
+
assert out.count("with configurable zlib compression") == 1
|
|
303
|
+
# the truly deleted opening paren keeps its strike; the
|
|
304
|
+
# closing paren is struck after the relocated phrase while
|
|
305
|
+
# the sentence-final period (present in BOTH revisions)
|
|
306
|
+
# stays plain, and the paragraph break survives
|
|
307
|
+
assert "\\DIFdel{(" in out
|
|
308
|
+
assert re.search(r"filtering\\DIFdel\{\)\}\.", out)
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""Contract tests for texdiff.textdiff: word-level diffs of text runs."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from texdiff.textdiff import Chunk, word_diff
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def apply(chunks: list[Chunk], old: bool) -> str:
|
|
9
|
+
return "".join(
|
|
10
|
+
c.text for c in chunks if c.op == "equal" or (c.op == ("delete" if old else "insert"))
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class TestBasics:
|
|
15
|
+
def test_identical_text_single_equal(self):
|
|
16
|
+
chunks = word_diff("same text", "same text")
|
|
17
|
+
assert chunks == [Chunk("equal", "same text")]
|
|
18
|
+
|
|
19
|
+
def test_reconstruction(self):
|
|
20
|
+
old, new = "the quick brown fox", "the quick red fox"
|
|
21
|
+
chunks = word_diff(old, new)
|
|
22
|
+
assert apply(chunks, True) == old
|
|
23
|
+
assert apply(chunks, False) == new
|
|
24
|
+
|
|
25
|
+
def test_changed_word_split_out(self):
|
|
26
|
+
chunks = word_diff("the quick brown fox", "the quick red fox")
|
|
27
|
+
ops = [c.op for c in chunks]
|
|
28
|
+
assert ops.count("delete") == 1
|
|
29
|
+
assert ops.count("insert") == 1
|
|
30
|
+
deleted = [c for c in chunks if c.op == "delete"]
|
|
31
|
+
inserted = [c for c in chunks if c.op == "insert"]
|
|
32
|
+
assert deleted[0].text == "brown"
|
|
33
|
+
assert inserted[0].text == "red"
|
|
34
|
+
|
|
35
|
+
def test_insertion_in_middle(self):
|
|
36
|
+
chunks = word_diff("a c", "a b c")
|
|
37
|
+
ops = [c.op for c in chunks]
|
|
38
|
+
assert ops == ["equal", "insert", "equal"]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class TestLaTeXSafety:
|
|
42
|
+
def test_command_name_not_split_from_braces(self):
|
|
43
|
+
chunks = word_diff("\\textbf{x}", "\\emph{x}")
|
|
44
|
+
# no intermediate chunk may end between the backslash command
|
|
45
|
+
# name and its argument braces
|
|
46
|
+
for c in chunks:
|
|
47
|
+
assert not (c.text.endswith("\\textb") or c.text.endswith("\\emp"))
|
|
48
|
+
|
|
49
|
+
def test_whitespace_only_change_keeps_new(self):
|
|
50
|
+
chunks = word_diff("a b", "a b")
|
|
51
|
+
assert apply(chunks, False) == "a b"
|
|
52
|
+
assert apply(chunks, True) in ("a b", "a b")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class TestSemanticCleanup:
|
|
56
|
+
def test_adjacent_edits_merge_into_replacement(self):
|
|
57
|
+
# a scattered single-char edit should become ONE delete+insert
|
|
58
|
+
# region, not many alternating islands
|
|
59
|
+
chunks = word_diff("xxaxx", "xxbxx")
|
|
60
|
+
ops = [c.op for c in chunks if c.op != "equal"]
|
|
61
|
+
assert ops == ["delete", "insert"]
|
|
62
|
+
|
|
63
|
+
def test_reworded_sentence_not_shredded_word_by_word(self):
|
|
64
|
+
# a reworded sentence shares filler words with its new version
|
|
65
|
+
# ("with", "configurable", "zlib", ...); the sequence matcher
|
|
66
|
+
# pairs them across the real edit, producing a del/ins mosaic.
|
|
67
|
+
# The region between stable bridges must collapse into whole
|
|
68
|
+
# delete + insert clauses - the track-changes look reviewers
|
|
69
|
+
# expect - while the shared prefix and suffix stay equal.
|
|
70
|
+
old = (
|
|
71
|
+
"Common methods for reading HDF5 datasets (with masked-array "
|
|
72
|
+
"support and HDF5 diagnostic suppression) and writing NetCDF4 "
|
|
73
|
+
"outputs (with configurable zlib compression and shuffle "
|
|
74
|
+
"filtering)."
|
|
75
|
+
)
|
|
76
|
+
new = (
|
|
77
|
+
"Common methods for reading HDF5 datasets (with masked-array "
|
|
78
|
+
"support and HDF5 diagnostic suppression). Writing of NetCDF4 "
|
|
79
|
+
"outputs is performed through the generic schema-driven writer "
|
|
80
|
+
"(see Overview), with configurable zlib compression and shuffle "
|
|
81
|
+
"filtering."
|
|
82
|
+
)
|
|
83
|
+
chunks = word_diff(old, new)
|
|
84
|
+
# contract: chunks still reconstruct both sides exactly
|
|
85
|
+
assert apply(chunks, True) == old
|
|
86
|
+
assert apply(chunks, False) == new
|
|
87
|
+
# the shared prefix survives as an equal region; the long shared
|
|
88
|
+
# tail ("configurable ... shuffle") too - it may be followed by
|
|
89
|
+
# one tiny token pair ("filtering)." vs "filtering.")
|
|
90
|
+
assert chunks[0].op == "equal" and "HDF5 diagnostic" in chunks[0].text
|
|
91
|
+
equal_tails = [c for c in chunks if c.op == "equal" and "shuffle" in c.text]
|
|
92
|
+
assert equal_tails
|
|
93
|
+
# the reworded middle is coalesced: few change regions, each a
|
|
94
|
+
# whole clause - not dozens of single-token del/ins islands
|
|
95
|
+
change_groups = 0
|
|
96
|
+
prev = "equal"
|
|
97
|
+
for c in chunks:
|
|
98
|
+
if c.op in ("delete", "insert") and prev == "equal":
|
|
99
|
+
change_groups += 1
|
|
100
|
+
prev = c.op
|
|
101
|
+
assert change_groups <= 4
|
|
102
|
+
# and the rewording appears as whole clauses: the crossed
|
|
103
|
+
# "suppression) and writing" pair is a multi-word delete
|
|
104
|
+
clausal = [t for t in (c.text for c in chunks if c.op == "delete")]
|
|
105
|
+
assert any("suppression" in t and "writing" in t for t in clausal), clausal
|
|
106
|
+
|
|
107
|
+
def test_separate_small_edits_keep_word_level(self):
|
|
108
|
+
# two genuinely distinct single-word edits separated by a long
|
|
109
|
+
# stable stretch: word-level markup, NOT coalesced into one
|
|
110
|
+
# big replacement
|
|
111
|
+
chunks = word_diff(
|
|
112
|
+
"alpha beta gamma delta epsilon zeta eta theta",
|
|
113
|
+
" ALPHA beta gamma delta epsilon ZETA eta theta",
|
|
114
|
+
)
|
|
115
|
+
# each case-only word change is its own small region; the long
|
|
116
|
+
# bridge between them forbids coalescing
|
|
117
|
+
groups = 0
|
|
118
|
+
prev = "equal"
|
|
119
|
+
for c in chunks:
|
|
120
|
+
if c.op in ("delete", "insert") and prev == "equal":
|
|
121
|
+
groups += 1
|
|
122
|
+
prev = c.op
|
|
123
|
+
assert groups == 2
|
|
@@ -1,61 +0,0 @@
|
|
|
1
|
-
"""Contract tests for texdiff.textdiff: word-level diffs of text runs."""
|
|
2
|
-
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
|
-
from texdiff.textdiff import Chunk, word_diff
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
def apply(chunks: list[Chunk], old: bool) -> str:
|
|
9
|
-
return "".join(
|
|
10
|
-
c.text for c in chunks if c.op == "equal" or (c.op == ("delete" if old else "insert"))
|
|
11
|
-
)
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
class TestBasics:
|
|
15
|
-
def test_identical_text_single_equal(self):
|
|
16
|
-
chunks = word_diff("same text", "same text")
|
|
17
|
-
assert chunks == [Chunk("equal", "same text")]
|
|
18
|
-
|
|
19
|
-
def test_reconstruction(self):
|
|
20
|
-
old, new = "the quick brown fox", "the quick red fox"
|
|
21
|
-
chunks = word_diff(old, new)
|
|
22
|
-
assert apply(chunks, True) == old
|
|
23
|
-
assert apply(chunks, False) == new
|
|
24
|
-
|
|
25
|
-
def test_changed_word_split_out(self):
|
|
26
|
-
chunks = word_diff("the quick brown fox", "the quick red fox")
|
|
27
|
-
ops = [c.op for c in chunks]
|
|
28
|
-
assert ops.count("delete") == 1
|
|
29
|
-
assert ops.count("insert") == 1
|
|
30
|
-
deleted = [c for c in chunks if c.op == "delete"]
|
|
31
|
-
inserted = [c for c in chunks if c.op == "insert"]
|
|
32
|
-
assert deleted[0].text == "brown"
|
|
33
|
-
assert inserted[0].text == "red"
|
|
34
|
-
|
|
35
|
-
def test_insertion_in_middle(self):
|
|
36
|
-
chunks = word_diff("a c", "a b c")
|
|
37
|
-
ops = [c.op for c in chunks]
|
|
38
|
-
assert ops == ["equal", "insert", "equal"]
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
class TestLaTeXSafety:
|
|
42
|
-
def test_command_name_not_split_from_braces(self):
|
|
43
|
-
chunks = word_diff("\\textbf{x}", "\\emph{x}")
|
|
44
|
-
# no intermediate chunk may end between the backslash command
|
|
45
|
-
# name and its argument braces
|
|
46
|
-
for c in chunks:
|
|
47
|
-
assert not (c.text.endswith("\\textb") or c.text.endswith("\\emp"))
|
|
48
|
-
|
|
49
|
-
def test_whitespace_only_change_keeps_new(self):
|
|
50
|
-
chunks = word_diff("a b", "a b")
|
|
51
|
-
assert apply(chunks, False) == "a b"
|
|
52
|
-
assert apply(chunks, True) in ("a b", "a b")
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
class TestSemanticCleanup:
|
|
56
|
-
def test_adjacent_edits_merge_into_replacement(self):
|
|
57
|
-
# a scattered single-char edit should become ONE delete+insert
|
|
58
|
-
# region, not many alternating islands
|
|
59
|
-
chunks = word_diff("xxaxx", "xxbxx")
|
|
60
|
-
ops = [c.op for c in chunks if c.op != "equal"]
|
|
61
|
-
assert ops == ["delete", "insert"]
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|