Module: Uniword::Diff::Semantic::ParagraphComparator
- Defined in:
- lib/uniword/diff/semantic/paragraph_comparator.rb
Overview
LCS-based paragraph comparator. Aligns old and new paragraph lists with a proper dynamic-programming LCS, classifies each aligned pair, and emits changes for any pair that isn't :unchanged.
Reuses the paragraph text via Paragraph#text.
Class Method Summary collapse
-
.align(old_keys, new_keys) ⇒ Array<Array(Integer, Integer)>
DP LCS alignment of two arrays of fingerprints.
-
.backtrack(dp, a, b, i, j) ⇒ Object
Backtrack through the DP table to recover matching pairs.
-
.classify_modified(old_para, new_para, old_idx, new_idx) ⇒ Object
Classify a modified pair by what changed.
-
.collapse_remove_add_pairs(alignment) ⇒ Array<Array(Integer, Integer)>
Collapse adjacent
[i, nil], [nil, j]pairs into[i, j]modified pairs. - .each_change(old_paras, new_paras) {|change| ... } ⇒ void
- .emit_changes(alignment, old_paras, new_paras) ⇒ Object
-
.fingerprint(paragraph) ⇒ String
Stable text fingerprint for one paragraph.
-
.format_only_change?(old_xml, new_xml) ⇒ Boolean
Heuristic: a format-only change alters rPr but not run structure.
-
.identical?(old_para, new_para) ⇒ Boolean
Two paragraphs are identical when their full XML is byte- equal — same text AND same formatting AND same structure.
-
.lcs_match_pairs(a, b) ⇒ Array<Array(Integer, Integer)>
DP LCS: returns the list of matching (i, j) pairs in order.
-
.modifier_for(old_para, new_para) ⇒ Object
Decide what changed between two paragraphs with the same text but different XML.
Class Method Details
.align(old_keys, new_keys) ⇒ Array<Array(Integer, Integer)>
DP LCS alignment of two arrays of fingerprints. Returns a
list of [old_idx, new_idx] pairs:
- matching pair `[i, j]` when keys are equal
- `[i, nil]` when old[i] is removed
- `[nil, j]` when new[j] is added
47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 |
# File 'lib/uniword/diff/semantic/paragraph_comparator.rb', line 47 def align(old_keys, new_keys) lcs_pairs = lcs_match_pairs(old_keys, new_keys) pairs = [] i = 0 j = 0 lcs_pairs.each do |mi, mj| while i < mi pairs << [i, nil] i += 1 end while j < mj pairs << [nil, j] j += 1 end pairs << [mi, mj] i = mi + 1 j = mj + 1 end while i < old_keys.length pairs << [i, nil] i += 1 end while j < new_keys.length pairs << [nil, j] j += 1 end pairs end |
.backtrack(dp, a, b, i, j) ⇒ Object
Backtrack through the DP table to recover matching pairs.
98 99 100 101 102 103 104 105 106 107 108 |
# File 'lib/uniword/diff/semantic/paragraph_comparator.rb', line 98 def backtrack(dp, a, b, i, j) return [] if i.zero? || j.zero? if a[i - 1] == b[j - 1] backtrack(dp, a, b, i - 1, j - 1) << [i - 1, j - 1] elsif dp[i - 1][j] >= dp[i][j - 1] backtrack(dp, a, b, i - 1, j) else backtrack(dp, a, b, i, j - 1) end end |
.classify_modified(old_para, new_para, old_idx, new_idx) ⇒ Object
Classify a modified pair by what changed.
160 161 162 163 164 165 166 167 168 169 170 |
# File 'lib/uniword/diff/semantic/paragraph_comparator.rb', line 160 def classify_modified(old_para, new_para, old_idx, new_idx) modifier = modifier_for(old_para, new_para) Change.new( kind: :modified, modifier: modifier, old_index: old_idx, new_index: new_idx, description: "Paragraph #{old_idx + 1} -> #{new_idx + 1} " \ "(#{modifier})", ) end |
.collapse_remove_add_pairs(alignment) ⇒ Array<Array(Integer, Integer)>
Collapse adjacent [i, nil], [nil, j] pairs into [i, j]
modified pairs. Without this, text edits show as one remove
- one add instead of one modification.
136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 |
# File 'lib/uniword/diff/semantic/paragraph_comparator.rb', line 136 def collapse_remove_add_pairs(alignment) result = [] i = 0 while i < alignment.length curr = alignment[i] nxt = alignment[i + 1] if curr[0] && curr[1].nil? && nxt && nxt[0].nil? && nxt[1] result << [curr[0], nxt[1]] i += 2 else result << curr i += 1 end end result end |
.each_change(old_paras, new_paras) {|change| ... } ⇒ void
This method returns an undefined value.
19 20 21 22 23 24 25 26 27 |
# File 'lib/uniword/diff/semantic/paragraph_comparator.rb', line 19 def each_change(old_paras, new_paras, &block) old_paras ||= [] new_paras ||= [] old_keys = old_paras.map { |p| fingerprint(p) } new_keys = new_paras.map { |p| fingerprint(p) } alignment = align(old_keys, new_keys) emit_changes(alignment, old_paras, new_paras, &block) end |
.emit_changes(alignment, old_paras, new_paras) ⇒ Object
110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 |
# File 'lib/uniword/diff/semantic/paragraph_comparator.rb', line 110 def emit_changes(alignment, old_paras, new_paras) collapsed = collapse_remove_add_pairs(alignment) collapsed.each do |old_idx, new_idx| case [old_idx.nil?, new_idx.nil?] when [true, false] yield Change.new(kind: :added, new_index: new_idx, description: "Paragraph #{new_idx + 1} added") when [false, true] yield Change.new(kind: :removed, old_index: old_idx, description: "Paragraph #{old_idx + 1} removed") when [false, false] next if identical?(old_paras[old_idx], new_paras[new_idx]) yield classify_modified(old_paras[old_idx], new_paras[new_idx], old_idx, new_idx) end end end |
.fingerprint(paragraph) ⇒ String
Stable text fingerprint for one paragraph. Identical text produces identical fingerprints.
34 35 36 |
# File 'lib/uniword/diff/semantic/paragraph_comparator.rb', line 34 def fingerprint(paragraph) (paragraph&.text || "").to_s end |
.format_only_change?(old_xml, new_xml) ⇒ Boolean
Heuristic: a format-only change alters rPr but not run
structure. True when removing all <w:rPr>...</w:rPr>
blocks equalizes the XML.
187 188 189 190 191 |
# File 'lib/uniword/diff/semantic/paragraph_comparator.rb', line 187 def format_only_change?(old_xml, new_xml) stripped_old = old_xml.gsub(%r{<w:rPr>.*?</w:rPr>}m, "") stripped_new = new_xml.gsub(%r{<w:rPr>.*?</w:rPr>}m, "") stripped_old == stripped_new end |
.identical?(old_para, new_para) ⇒ Boolean
Two paragraphs are identical when their full XML is byte- equal — same text AND same formatting AND same structure.
155 156 157 |
# File 'lib/uniword/diff/semantic/paragraph_comparator.rb', line 155 def identical?(old_para, new_para) old_para.to_xml == new_para.to_xml end |
.lcs_match_pairs(a, b) ⇒ Array<Array(Integer, Integer)>
DP LCS: returns the list of matching (i, j) pairs in order.
81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 |
# File 'lib/uniword/diff/semantic/paragraph_comparator.rb', line 81 def lcs_match_pairs(a, b) n = a.length m = b.length dp = Array.new(n + 1) { Array.new(m + 1, 0) } (1..n).each do |i| (1..m).each do |j| dp[i][j] = if a[i - 1] == b[j - 1] dp[i - 1][j - 1] + 1 else [dp[i - 1][j], dp[i][j - 1]].max end end end backtrack(dp, a, b, n, m) end |
.modifier_for(old_para, new_para) ⇒ Object
Decide what changed between two paragraphs with the same text but different XML.
174 175 176 177 178 179 180 181 182 |
# File 'lib/uniword/diff/semantic/paragraph_comparator.rb', line 174 def modifier_for(old_para, new_para) return :text if old_para.text != new_para.text old_xml = old_para.to_xml new_xml = new_para.to_xml return :format if format_only_change?(old_xml, new_xml) :structure end |