texdiff 0.2.4__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.
Files changed (41) hide show
  1. {texdiff-0.2.4/src/texdiff.egg-info → texdiff-0.2.6}/PKG-INFO +1 -1
  2. {texdiff-0.2.4 → texdiff-0.2.6}/pyproject.toml +1 -1
  3. {texdiff-0.2.4 → texdiff-0.2.6}/src/texdiff/__init__.py +1 -1
  4. {texdiff-0.2.4 → texdiff-0.2.6}/src/texdiff/align.py +17 -1
  5. {texdiff-0.2.4 → texdiff-0.2.6}/src/texdiff/api.py +46 -11
  6. {texdiff-0.2.4 → texdiff-0.2.6}/src/texdiff/emit.py +270 -1
  7. {texdiff-0.2.4 → texdiff-0.2.6}/src/texdiff/textdiff.py +69 -0
  8. {texdiff-0.2.4 → texdiff-0.2.6/src/texdiff.egg-info}/PKG-INFO +1 -1
  9. {texdiff-0.2.4 → texdiff-0.2.6}/tests/test_align.py +51 -0
  10. {texdiff-0.2.4 → texdiff-0.2.6}/tests/test_api.py +61 -0
  11. texdiff-0.2.6/tests/test_textdiff.py +123 -0
  12. texdiff-0.2.4/tests/test_textdiff.py +0 -61
  13. {texdiff-0.2.4 → texdiff-0.2.6}/LICENSE +0 -0
  14. {texdiff-0.2.4 → texdiff-0.2.6}/README.md +0 -0
  15. {texdiff-0.2.4 → texdiff-0.2.6}/setup.cfg +0 -0
  16. {texdiff-0.2.4 → texdiff-0.2.6}/src/texdiff/check.py +0 -0
  17. {texdiff-0.2.4 → texdiff-0.2.6}/src/texdiff/cli.py +0 -0
  18. {texdiff-0.2.4 → texdiff-0.2.6}/src/texdiff/flatten.py +0 -0
  19. {texdiff-0.2.4 → texdiff-0.2.6}/src/texdiff/newlines.py +0 -0
  20. {texdiff-0.2.4 → texdiff-0.2.6}/src/texdiff/nodes.py +0 -0
  21. {texdiff-0.2.4 → texdiff-0.2.6}/src/texdiff/oldlines.py +0 -0
  22. {texdiff-0.2.4 → texdiff-0.2.6}/src/texdiff/parse.py +0 -0
  23. {texdiff-0.2.4 → texdiff-0.2.6}/src/texdiff/preamble.py +0 -0
  24. {texdiff-0.2.4 → texdiff-0.2.6}/src/texdiff/tables.py +0 -0
  25. {texdiff-0.2.4 → texdiff-0.2.6}/src/texdiff.egg-info/SOURCES.txt +0 -0
  26. {texdiff-0.2.4 → texdiff-0.2.6}/src/texdiff.egg-info/dependency_links.txt +0 -0
  27. {texdiff-0.2.4 → texdiff-0.2.6}/src/texdiff.egg-info/entry_points.txt +0 -0
  28. {texdiff-0.2.4 → texdiff-0.2.6}/src/texdiff.egg-info/requires.txt +0 -0
  29. {texdiff-0.2.4 → texdiff-0.2.6}/src/texdiff.egg-info/top_level.txt +0 -0
  30. {texdiff-0.2.4 → texdiff-0.2.6}/tests/test_alignment.py +0 -0
  31. {texdiff-0.2.4 → texdiff-0.2.6}/tests/test_check.py +0 -0
  32. {texdiff-0.2.4 → texdiff-0.2.6}/tests/test_cli.py +0 -0
  33. {texdiff-0.2.4 → texdiff-0.2.6}/tests/test_corpus.py +0 -0
  34. {texdiff-0.2.4 → texdiff-0.2.6}/tests/test_emit.py +0 -0
  35. {texdiff-0.2.4 → texdiff-0.2.6}/tests/test_flatten.py +0 -0
  36. {texdiff-0.2.4 → texdiff-0.2.6}/tests/test_parse.py +0 -0
  37. {texdiff-0.2.4 → texdiff-0.2.6}/tests/test_preamble.py +0 -0
  38. {texdiff-0.2.4 → texdiff-0.2.6}/tests/test_table_head_order.py +0 -0
  39. {texdiff-0.2.4 → texdiff-0.2.6}/tests/test_tables.py +0 -0
  40. {texdiff-0.2.4 → texdiff-0.2.6}/tests/test_tables_render.py +0 -0
  41. {texdiff-0.2.4 → texdiff-0.2.6}/tests/test_verbatim.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: texdiff
3
- Version: 0.2.4
3
+ Version: 0.2.6
4
4
  Summary: AST-driven semantic diff for LaTeX documents
5
5
  Author: GoBobr
6
6
  License: MIT License
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "texdiff"
7
- version = "0.2.4"
7
+ version = "0.2.6"
8
8
  description = "AST-driven semantic diff for LaTeX documents"
9
9
  readme = "README.md"
10
10
  license = { file = "LICENSE" }
@@ -42,4 +42,4 @@ __all__ = [
42
42
  "word_diff",
43
43
  ]
44
44
 
45
- __version__ = "0.2.4"
45
+ __version__ = "0.2.6"
@@ -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
@@ -690,8 +702,12 @@ def _sibling_swap_rescue(edits: list[Edit], _pair) -> list[Edit]:
690
702
  for i, j in swaps.items():
691
703
  swap_new[i] = edits[j].new
692
704
  edits[j] = Insert(new=modifies[i].new)
705
+ # pair through _pair, NOT raw Modify: the swapped-in sibling can
706
+ # be verbatim-equal to the old node (the rescue often trades a
707
+ # mis-pairing for the node's TRUE counterpart). A raw Modify then
708
+ # renders an unchanged table as wholesale retired + reinserted.
693
709
  return [
694
- Modify(old=modifies[idx].old, new=swap_new[idx])
710
+ _pair(modifies[idx].old, swap_new[idx])
695
711
  if idx in swaps
696
712
  else e
697
713
  for idx, e in enumerate(edits)
@@ -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 is the other with words appended (or removed).
261
-
262
- Text appended to a sentence (``... compression.`` growing into
263
- ``... compression: the file structure ...``) drives the word
264
- overlap ratio far below the replace threshold - the shared
265
- prefix is drowned by the additions - yet the change IS an edit
266
- of the same sentence: the word differ renders exactly the right
267
- thing (struck final period, inserted tail). Prefix/suffix word
268
- containment therefore overrides the ratio before a paragraph is
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 len(wb) - len(wa) < 3:
290
+ if not wa or not wb:
276
291
  return False
277
- return wb[: len(wa)] == wa or wb[len(wb) - len(wa):] == wa
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
- rendered = _render_row_region(edit.inner or [], markup)
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
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: texdiff
3
- Version: 0.2.4
3
+ Version: 0.2.6
4
4
  Summary: AST-driven semantic diff for LaTeX documents
5
5
  Author: GoBobr
6
6
  License: MIT License
@@ -89,3 +89,54 @@ class TestSameSignatureDifferentText:
89
89
  new = [group(text_node("schemas")), group(text_node("handlers")), text_node("tail\n\n")]
90
90
  edits = align(old, new)
91
91
  assert [type(e).__name__ for e in edits] == ["Insert", "Match", "Match"]
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
+
106
+
107
+ def test_sibling_swap_rescue_pairs_verbatim_equal_swap_as_match():
108
+ """A swap that lands on a verbatim-equal sibling must Match.
109
+
110
+ The sibling-swap rescue re-pairs a Modify with the same-signature
111
+ Insert the old side actually resembles. When the swapped-in
112
+ sibling is byte-identical to the old node - an unchanged table
113
+ re-inserted next to a new sibling table (PGS "consumed
114
+ variables" band table) - the rescue used to construct a raw
115
+ Modify. Emit then rendered the IDENTICAL table as a wholesale
116
+ retired (red, unstruck) copy followed by a blue re-inserted
117
+ copy. The swap must go through _pair so equal text degrades to
118
+ a plain Match.
119
+ """
120
+ from texdiff.align import _sibling_swap_rescue
121
+ from texdiff.parse import parse
122
+
123
+ def _env(body: str):
124
+ return parse(f"\\begin{{savenotes}}\n{body}\n\\end{{savenotes}}\n")[0]
125
+
126
+ def _pair(o, n):
127
+ if o.text == n.text:
128
+ return Match(node=o)
129
+ return Modify(old=o, new=n)
130
+
131
+ old_node = _env("alpha beta gamma delta epsilon zeta")
132
+ wrong_new = _env("an unrelated climatology tail with other words")
133
+ right_new = _env("alpha beta gamma delta epsilon zeta")
134
+
135
+ edits = [Modify(old=old_node, new=wrong_new), Insert(new=right_new)]
136
+ out = _sibling_swap_rescue(edits, _pair)
137
+ # the identical counterpart matches: renders black, not retired
138
+ assert type(out[0]).__name__ == "Match"
139
+ assert out[0].node is old_node
140
+ # the previously (wrongly) chosen sibling stays visible as insert
141
+ assert type(out[1]).__name__ == "Insert"
142
+ assert out[1].new is wrong_new
@@ -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