You need to sign in or sign up before continuing.

ea 0.6.146 → 0.6.147

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a92818758eafd68c6274586e0340fdcb4e18a8377a8a58823a70c43caecee061
4
- data.tar.gz: 83e1f3faa8ba1058878c09934373ccd26a9150fa5209d6e76def20a5f7ec1cdd
3
+ metadata.gz: 1f9087e59e10aff45bf81103a88593079d215fc97aa689f41cc8c4ea9a41d37f
4
+ data.tar.gz: 0652d5a945a6ff2ccd3c45cbf1302be809f6846eaad1832eced4c617bfd20059
5
5
  SHA512:
6
- metadata.gz: 68d6ff6682b9b666a3f48cb5e561a698014020979a69c1f26dbf88442d931d87440fd7dada02c8aadf77239baae639c820aac574598b204d4428cde19e4da982
7
- data.tar.gz: 56dbe257fb97239e26ae7476fd1ebb31dca3a6cd1d3bc0a5d565d0bc038c6536c5c7c5a9040504bc3dba86e798c715023bcf10e0f216e1d3f1732ec964ea9a0d
6
+ metadata.gz: 460b7225fbd3fd826628f1e9a1c2400a6452579aa3000f33186fd9ed0907ea41582573c6c41e2aa4961993062725e3aa9fe4533096dad4c19b696c2afe8f6cfc
7
+ data.tar.gz: b2bef638ce7b06732af0318e7ba159511cd8684b45c990bb2900ee0b1b825bb485d92adc84175c597b53853278ec8285072202e4bd3a7760ed4df83d2a6e5ef7
@@ -24,207 +24,292 @@ module Ea
24
24
  @shift = dominant_shift
25
25
  end
26
26
 
27
- # EA crops each published image to its own content bounds, so
28
- # the two canvases differ by a per-diagram translation. Estimate
29
- # it as the modal delta over content-matched texts and subtract
30
- # it before comparing positions.
31
- def dominant_shift
32
- ref_by_content = @reference.select { |p| p[:kind] == :text }
33
- .group_by { |p| p[:content] }
34
- deltas = []
35
- @ours.select { |p| p[:kind] == :text }.each do |ours|
36
- ref = ref_by_content[ours[:content]]&.min_by { |c| distance(ours, c) }
37
- next unless ref
38
-
39
- deltas << [(ref[:x] - ours[:x]).round, (ref[:y] - ours[:y]).round]
40
- end
41
- return [0.0, 0.0] if deltas.empty?
42
-
43
- deltas.tally.max_by { |_, count| count }.first.map(&:to_f)
44
- end
45
- private :dominant_shift
46
-
47
- # --- primitive extraction -----------------------------------
27
+ # Extracts the primitives consumed by both comparison reports.
28
+ class PrimitiveParser
29
+ def parse(svg)
30
+ Nokogiri::XML(svg).css("g").flat_map do |group|
31
+ group.element_children.filter_map { |node| primitive(node, group["style"] || "") }
32
+ end
33
+ end
48
34
 
49
- def parse(svg)
50
- doc = Nokogiri::XML(svg)
51
- primitives = []
52
- doc.css("g").each do |g|
53
- g_style = g["style"] || ""
54
- g.children.each do |node|
55
- next unless node.element?
56
-
57
- case node.name
58
- when "text" then primitives << text_primitive(node, g_style)
59
- when "rect" then primitives << rect_primitive(node, g_style)
60
- when "polygon" then primitives << points_primitive(:polygon, node["points"], g_style)
61
- when "line"
62
- primitives << points_primitive(:line, "#{node['x1']} #{node['y1']} #{node['x2']} #{node['y2']}", g_style)
63
- when "path" then primitives << path_primitive(node, g_style)
64
- end
35
+ def primitive(node, style)
36
+ case node.name
37
+ when "text" then text_primitive(node, style)
38
+ when "rect" then rect_primitive(node, style)
39
+ when "polygon" then points_primitive(:polygon, node["points"], style)
40
+ when "line" then points_primitive(:line, line_points(node), style)
41
+ when "path" then path_primitive(node, style)
65
42
  end
66
43
  end
67
- primitives
68
- end
69
- private :parse
44
+ private :primitive
70
45
 
71
- def text_primitive(node, g_style)
72
- style = node["style"] || ""
73
- {
74
- kind: :text,
75
- x: float(node["x"]), y: float(node["y"]),
76
- content: node.text.strip,
77
- family: style[/font-family:([^;]+)/, 1],
78
- size: style[/font-size:([^;]+)/, 1],
79
- weight: normalize_weight(style[/font-weight:([^;]+)/, 1]),
80
- style: style[/font-style:([^;]+)/, 1],
81
- fill: style[/[^-]fill:([^;]+)/, 1] || g_style[/fill:([^;]+)/, 1]
82
- }
83
- end
84
- private :text_primitive
46
+ def line_points(node)
47
+ %w[x1 y1 x2 y2].map { |key| node[key] }.join(" ")
48
+ end
49
+ private :line_points
50
+
51
+ def text_primitive(node, g_style)
52
+ style = node["style"] || ""
53
+ {
54
+ kind: :text,
55
+ x: float(node["x"]), y: float(node["y"]),
56
+ content: node.text.strip,
57
+ **text_style(style),
58
+ fill: style[/[^-]fill:([^;]+)/, 1] || g_style[/fill:([^;]+)/, 1]
59
+ }
60
+ end
61
+ private :text_primitive
62
+
63
+ def text_style(style)
64
+ {
65
+ family: style[/font-family:([^;]+)/, 1],
66
+ size: style[/font-size:([^;]+)/, 1],
67
+ weight: normalize_weight(style[/font-weight:([^;]+)/, 1]),
68
+ style: style[/font-style:([^;]+)/, 1]
69
+ }
70
+ end
71
+ private :text_style
72
+
73
+ def rect_primitive(node, g_style)
74
+ {
75
+ kind: :rect,
76
+ x: float(node["x"]), y: float(node["y"]),
77
+ width: float(node["width"]), height: float(node["height"]),
78
+ centroid: rect_centroid(node),
79
+ fill: g_style[/fill:([^;]+)/, 1],
80
+ stroke: g_style[/stroke:([^;]+)/, 1]
81
+ }
82
+ end
83
+ private :rect_primitive
85
84
 
86
- def rect_primitive(node, g_style)
87
- {
88
- kind: :rect,
89
- x: float(node["x"]), y: float(node["y"]),
90
- width: float(node["width"]), height: float(node["height"]),
91
- centroid: rect_centroid(node),
92
- fill: g_style[/fill:([^;]+)/, 1],
93
- stroke: g_style[/stroke:([^;]+)/, 1]
94
- }
95
- end
96
- private :rect_primitive
85
+ def rect_centroid(node)
86
+ x = float(node["x"])
87
+ y = float(node["y"])
88
+ return [0.0, 0.0] if x.nil? || y.nil?
97
89
 
98
- def rect_centroid(node)
99
- x = float(node["x"]); y = float(node["y"])
100
- return [0.0, 0.0] if x.nil? || y.nil?
90
+ [x + float(node["width"]).to_f / 2, y + float(node["height"]).to_f / 2]
91
+ end
92
+ private :rect_centroid
93
+
94
+ def path_primitive(node, g_style)
95
+ points = (node["d"] || "").scan(/-?\d+(?:\.\d+)?/).each_slice(2)
96
+ .map { |(x, y)| [x.to_f, y.to_f] }
97
+ {
98
+ kind: :path, points: points,
99
+ centroid: centroid(points), closed: node["d"].to_s.include?("Z"),
100
+ stroke: g_style[/stroke:([^;]+)/, 1]
101
+ }
102
+ end
103
+ private :path_primitive
104
+
105
+ def points_primitive(kind, points_attr, g_style)
106
+ points = (points_attr || "").scan(/-?\d+(?:\.\d+)?/).each_slice(2)
107
+ .map { |(x, y)| [x.to_f, y.to_f] }
108
+ {
109
+ kind: kind, points: points, centroid: centroid(points),
110
+ stroke: g_style[/stroke:([^;]+)/, 1]
111
+ }
112
+ end
113
+ private :points_primitive
101
114
 
102
- [x + float(node["width"]).to_f / 2, y + float(node["height"]).to_f / 2]
103
- end
104
- private :rect_centroid
115
+ def centroid(points)
116
+ return [0.0, 0.0] if points.nil? || points.empty?
105
117
 
106
- def path_primitive(node, g_style)
107
- points = (node["d"] || "").scan(/-?\d+(?:\.\d+)?/).each_slice(2)
108
- .map { |(x, y)| [x.to_f, y.to_f] }
109
- {
110
- kind: :path, points: points,
111
- centroid: centroid(points), closed: node["d"].to_s.include?("Z"),
112
- stroke: g_style[/stroke:([^;]+)/, 1]
113
- }
114
- end
115
- private :path_primitive
118
+ xs = points.map(&:first)
119
+ ys = points.map(&:last)
120
+ [xs.sum / points.size, ys.sum / points.size]
121
+ end
122
+ private :centroid
116
123
 
117
- def points_primitive(kind, points_attr, g_style)
118
- points = (points_attr || "").scan(/-?\d+(?:\.\d+)?/).each_slice(2)
119
- .map { |(x, y)| [x.to_f, y.to_f] }
120
- {
121
- kind: kind, points: points, centroid: centroid(points),
122
- stroke: g_style[/stroke:([^;]+)/, 1]
123
- }
124
- end
125
- private :points_primitive
124
+ def float(value)
125
+ value&.to_f
126
+ end
127
+ private :float
126
128
 
127
- def centroid(points)
128
- return [0.0, 0.0] if points.nil? || points.empty?
129
+ def normalize_weight(raw)
130
+ return "400" if raw.nil? || raw == "0"
129
131
 
130
- xs = points.map(&:first); ys = points.map(&:last)
131
- [xs.sum / points.size, ys.sum / points.size]
132
+ raw
133
+ end
134
+ private :normalize_weight
132
135
  end
133
- private :centroid
136
+ private_constant :PrimitiveParser
137
+
138
+ # Matches content and measures text/font drift after canvas alignment.
139
+ module TextComparison
140
+ # EA crops each published image to its own content bounds, so
141
+ # the two canvases differ by a per-diagram translation. Estimate
142
+ # it as the modal delta over content-matched texts and subtract
143
+ # it before comparing positions.
144
+ def dominant_shift
145
+ references = texts_by_content
146
+ deltas = our_texts.filter_map { |ours| text_shift(ours, references[ours[:content]]) }
147
+ return [0.0, 0.0] if deltas.empty?
148
+
149
+ deltas.tally.max_by { |_, count| count }.first.map(&:to_f)
150
+ end
151
+ private :dominant_shift
152
+
153
+ # Texts pair on identical content; positions, family, size and
154
+ # weight are then compared directly.
155
+ def text_deltas
156
+ references = texts_by_content
157
+ deltas = Hash.new { |hash, key| hash[key] = [] }
158
+ our_texts.each { |ours| compare_text(ours, references[ours[:content]], deltas) }
159
+ deltas[:missing_text].concat(references.values.flatten)
160
+ deltas
161
+ end
134
162
 
135
- def float(value)
136
- value&.to_f
137
- end
138
- private :float
163
+ def our_texts
164
+ @ours.select { |primitive| primitive[:kind] == :text }
165
+ end
166
+ private :our_texts
167
+
168
+ def texts_by_content
169
+ @reference.select { |primitive| primitive[:kind] == :text }.group_by { |primitive| primitive[:content] }
170
+ end
171
+ private :texts_by_content
139
172
 
140
- def normalize_weight(raw)
141
- return "400" if raw.nil? || raw == "0"
173
+ def text_shift(ours, candidates)
174
+ reference = candidates&.min_by { |candidate| distance(ours, candidate) }
175
+ return unless reference
142
176
 
143
- raw
144
- end
145
- private :normalize_weight
146
-
147
- # --- diffing -------------------------------------------------
148
-
149
- # Texts pair on identical content; positions, family, size and
150
- # weight are then compared directly.
151
- def text_deltas
152
- ref_by_content = @reference.select { |p| p[:kind] == :text }
153
- .group_by { |p| p[:content] }
154
- deltas = Hash.new { |h, k| h[k] = [] }
155
-
156
- @ours.select { |p| p[:kind] == :text }.each do |ours|
157
- candidates = ref_by_content[ours[:content]]
158
- ref = candidates&.min_by { |c| distance(ours, c) }
159
- if ref.nil?
160
- deltas[:extra_text] << ours
161
- next
177
+ [(reference[:x] - ours[:x]).round, (reference[:y] - ours[:y]).round]
178
+ end
179
+ private :text_shift
180
+
181
+ def compare_text(ours, candidates, deltas)
182
+ reference = candidates&.min_by { |candidate| distance(ours, candidate) }
183
+ return deltas[:extra_text] << ours unless reference
184
+
185
+ candidates.delete(reference)
186
+ deltas[:moved_text] << ours if moved?(ours, reference)
187
+ compare_font(ours, reference, deltas)
188
+ end
189
+ private :compare_text
190
+
191
+ def compare_font(ours, reference, deltas)
192
+ { family: :wrong_font, size: :wrong_size, weight: :wrong_weight }.each do |key, mismatch|
193
+ next if font_value(ours, key) == font_value(reference, key)
194
+
195
+ deltas[mismatch] << [ours, reference]
162
196
  end
163
- candidates.delete(ref)
197
+ end
198
+ private :compare_font
199
+
200
+ def font_value(primitive, key)
201
+ key == :weight ? normalize_weight(primitive[key]) : primitive[key]
202
+ end
203
+ private :font_value
164
204
 
165
- deltas[:moved_text] << ours if moved?(ours, ref)
166
- deltas[:wrong_font] << [ours, ref] if ours[:family] != ref[:family]
167
- deltas[:wrong_size] << [ours, ref] if ours[:size] != ref[:size]
168
- deltas[:wrong_weight] << [ours, ref] if normalize_weight(ours[:weight]) != normalize_weight(ref[:weight])
205
+ def moved?(ours, ref)
206
+ ((ours[:x] + @shift[0]) - ref[:x]).abs > EPSILON ||
207
+ ((ours[:y] + @shift[1]) - ref[:y]).abs > EPSILON
169
208
  end
209
+ private :moved?
170
210
 
171
- unmatched = ref_by_content.values.flatten
172
- deltas[:missing_text].concat(unmatched)
173
- deltas
211
+ def distance(ours, reference)
212
+ Math.hypot(ours[:x] - reference[:x], ours[:y] - reference[:y])
213
+ end
214
+ private :distance
215
+
216
+ def normalize_weight(raw)
217
+ return "400" if raw.nil? || raw == "0"
218
+
219
+ raw
220
+ end
221
+ private :normalize_weight
174
222
  end
223
+ private_constant :TextComparison
224
+ include TextComparison
175
225
 
176
- # Shapes pair greedily by centroid distance within the same
177
- # kind; paired shapes count as equal when their bounding boxes
178
- # agree within epsilon (routing bend-level diffs need the
179
- # point-level comparison and are reported as count mismatches).
226
+ def parse(svg)
227
+ PrimitiveParser.new.parse(svg)
228
+ end
229
+ private :parse
230
+
231
+ # Shapes pair greedily by shift-corrected centroid distance
232
+ # within the same kind. Geometry deltas do not affect these counts.
180
233
  def shape_summary
181
- our_shapes = @ours.reject { |p| p[:kind] == :text }
182
- .map { |p| shift_primitive(p) }
234
+ our_count = @ours.count { |p| p[:kind] != :text }
183
235
  ref_shapes = @reference.reject { |p| p[:kind] == :text }
236
+ matched = shape_pairs(ref_shapes).size
184
237
 
185
- matched = 0
186
- our_shapes.each do |ours|
187
- ref = ref_shapes.min_by { |c| shape_distance(ours, c) }
188
- next unless ref && shape_distance(ours, ref) <= MATCH_DISTANCE
238
+ {
239
+ our_shapes: our_count, ref_shapes: ref_shapes.size + matched,
240
+ matched: matched, missing: ref_shapes.size, extra: our_count - matched
241
+ }
242
+ end
189
243
 
190
- ref_shapes.delete(ref)
191
- matched += 1
244
+ # Signed rectangle deltas are shifted ours minus reference.
245
+ # Primitives retain their original coordinates; polygon vertices
246
+ # correspond by index, with unequal counts reported as infinity.
247
+ def shape_deltas
248
+ shape_pairs.filter_map do |ours, ref|
249
+ delta = case ours[:kind]
250
+ when :rect
251
+ rect_deltas(ours, ref)
252
+ when :polygon
253
+ { max_point_delta: max_point_delta(ours[:points], ref[:points]) }
254
+ end
255
+ { kind: ours[:kind], ours: ours, reference: ref }.merge(delta) if delta
192
256
  end
257
+ end
193
258
 
259
+ def rect_deltas(ours, ref)
194
260
  {
195
- our_shapes: our_shapes.size, ref_shapes: ref_shapes.size + matched,
196
- matched: matched, missing: ref_shapes.size, extra: our_shapes.size - matched
261
+ dx: dimension_delta(ours, ref, :x) + @shift[0],
262
+ dy: dimension_delta(ours, ref, :y) + @shift[1],
263
+ dwidth: dimension_delta(ours, ref, :width),
264
+ dheight: dimension_delta(ours, ref, :height)
197
265
  }
198
266
  end
267
+ private :rect_deltas
199
268
 
200
- def shift_primitive(p)
201
- if p[:centroid]
202
- p.merge(centroid: [p[:centroid][0] + @shift[0], p[:centroid][1] + @shift[1]])
203
- else
204
- p
269
+ def dimension_delta(ours, ref, key)
270
+ ours[key].to_f - ref[key].to_f
271
+ end
272
+ private :dimension_delta
273
+
274
+ def shape_pairs(ref_shapes = @reference.reject { |p| p[:kind] == :text })
275
+ @ours.reject { |p| p[:kind] == :text }.filter_map do |ours|
276
+ shifted = shift_primitive(ours)
277
+ ref = ref_shapes.min_by { |c| shape_distance(shifted, c) }
278
+ next unless ref && shape_distance(shifted, ref) <= MATCH_DISTANCE
279
+
280
+ ref_shapes.delete(ref)
281
+ [ours, ref]
205
282
  end
206
283
  end
207
- private :shift_primitive
284
+ private :shape_pairs
208
285
 
209
- def shape_distance(a, b)
210
- return Float::INFINITY unless a[:kind] == b[:kind]
211
- return Float::INFINITY if a[:centroid].nil? || b[:centroid].nil?
286
+ def max_point_delta(ours, ref)
287
+ return Float::INFINITY unless ours.size == ref.size
212
288
 
213
- ax, ay = a[:centroid]; bx, by = b[:centroid]
214
- Math.sqrt(((ax - bx).abs**2) + ((ay - by).abs**2))
289
+ ours.zip(ref).map do |(x, y), (ref_x, ref_y)|
290
+ Math.hypot(x + @shift[0] - ref_x, y + @shift[1] - ref_y)
291
+ end.max || 0.0
215
292
  end
216
- private :shape_distance
293
+ private :max_point_delta
217
294
 
218
- def moved?(ours, ref)
219
- ((ours[:x] + @shift[0]) - ref[:x]).abs > EPSILON ||
220
- ((ours[:y] + @shift[1]) - ref[:y]).abs > EPSILON
295
+ def shift_primitive(primitive)
296
+ if primitive[:centroid]
297
+ primitive.merge(centroid: [primitive[:centroid][0] + @shift[0], primitive[:centroid][1] + @shift[1]])
298
+ else
299
+ primitive
300
+ end
221
301
  end
222
- private :moved?
302
+ private :shift_primitive
303
+
304
+ def shape_distance(ours, reference)
305
+ return Float::INFINITY unless ours[:kind] == reference[:kind]
306
+ return Float::INFINITY if ours[:centroid].nil? || reference[:centroid].nil?
223
307
 
224
- def distance(a, b)
225
- Math.sqrt(((a[:x] - b[:x]).abs**2) + ((a[:y] - b[:y]).abs**2))
308
+ ax, ay = ours[:centroid]
309
+ bx, by = reference[:centroid]
310
+ Math.sqrt(((ax - bx).abs**2) + ((ay - by).abs**2))
226
311
  end
227
- private :distance
312
+ private :shape_distance
228
313
  end
229
314
  end
230
315
  end
data/lib/ea/version.rb CHANGED
@@ -4,5 +4,5 @@ module Ea
4
4
  # Gem release version. Extracted to its own file so the
5
5
  # `gem-release` tool's `gem bump` can find and update it via the
6
6
  # standard `lib/<gem>/version.rb` convention.
7
- VERSION = "0.6.146"
7
+ VERSION = "0.6.147"
8
8
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ea
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.6.146
4
+ version: 0.6.147
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ribose Inc.
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-11-03 00:00:00.000000000 Z
11
+ date: 2026-11-13 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: lutaml-model