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 +4 -4
- data/lib/ea/svg/parity/differ.rb +246 -161
- data/lib/ea/version.rb +1 -1
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 1f9087e59e10aff45bf81103a88593079d215fc97aa689f41cc8c4ea9a41d37f
|
|
4
|
+
data.tar.gz: 0652d5a945a6ff2ccd3c45cbf1302be809f6846eaad1832eced4c617bfd20059
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 460b7225fbd3fd826628f1e9a1c2400a6452579aa3000f33186fd9ed0907ea41582573c6c41e2aa4961993062725e3aa9fe4533096dad4c19b696c2afe8f6cfc
|
|
7
|
+
data.tar.gz: b2bef638ce7b06732af0318e7ba159511cd8684b45c990bb2900ee0b1b825bb485d92adc84175c597b53853278ec8285072202e4bd3a7760ed4df83d2a6e5ef7
|
data/lib/ea/svg/parity/differ.rb
CHANGED
|
@@ -24,207 +24,292 @@ module Ea
|
|
|
24
24
|
@shift = dominant_shift
|
|
25
25
|
end
|
|
26
26
|
|
|
27
|
-
#
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
-
|
|
68
|
-
end
|
|
69
|
-
private :parse
|
|
44
|
+
private :primitive
|
|
70
45
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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
|
-
|
|
103
|
-
|
|
104
|
-
private :rect_centroid
|
|
115
|
+
def centroid(points)
|
|
116
|
+
return [0.0, 0.0] if points.nil? || points.empty?
|
|
105
117
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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
|
-
|
|
128
|
-
|
|
129
|
+
def normalize_weight(raw)
|
|
130
|
+
return "400" if raw.nil? || raw == "0"
|
|
129
131
|
|
|
130
|
-
|
|
131
|
-
|
|
132
|
+
raw
|
|
133
|
+
end
|
|
134
|
+
private :normalize_weight
|
|
132
135
|
end
|
|
133
|
-
|
|
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
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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
|
-
|
|
141
|
-
|
|
173
|
+
def text_shift(ours, candidates)
|
|
174
|
+
reference = candidates&.min_by { |candidate| distance(ours, candidate) }
|
|
175
|
+
return unless reference
|
|
142
176
|
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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
|
-
|
|
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
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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
|
-
|
|
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
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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
|
-
|
|
191
|
-
|
|
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
|
-
|
|
196
|
-
|
|
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
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
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 :
|
|
284
|
+
private :shape_pairs
|
|
208
285
|
|
|
209
|
-
def
|
|
210
|
-
return Float::INFINITY unless
|
|
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
|
-
|
|
214
|
-
|
|
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 :
|
|
293
|
+
private :max_point_delta
|
|
217
294
|
|
|
218
|
-
def
|
|
219
|
-
|
|
220
|
-
(
|
|
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 :
|
|
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
|
-
|
|
225
|
-
|
|
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 :
|
|
312
|
+
private :shape_distance
|
|
228
313
|
end
|
|
229
314
|
end
|
|
230
315
|
end
|
data/lib/ea/version.rb
CHANGED
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.
|
|
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-
|
|
11
|
+
date: 2026-11-13 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: lutaml-model
|