ea 0.6.4 → 0.6.6
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/CHANGELOG.md +39 -0
- data/config/themes/119.yml +4 -4
- data/lib/ea/model/diagram_connector.rb +9 -0
- data/lib/ea/model/property.rb +6 -1
- data/lib/ea/sources/qea/classifier_builder.rb +3 -1
- data/lib/ea/sources/qea/diagram_builder.rb +91 -9
- data/lib/ea/sources/qea/property_builder.rb +12 -2
- data/lib/ea/svg/ea_emitter/compartment/attributes.rb +1 -0
- data/lib/ea/svg/ea_emitter/compartment/constraints.rb +1 -0
- data/lib/ea/svg/ea_emitter/compartment/enum_literals.rb +9 -25
- data/lib/ea/svg/ea_emitter/compartment/header.rb +1 -0
- data/lib/ea/svg/ea_emitter/element/attribute_line_builder.rb +5 -0
- data/lib/ea/svg/ea_emitter/element/attribute_renderer.rb +51 -11
- data/lib/ea/svg/ea_emitter/element/compartment_geometry.rb +12 -10
- data/lib/ea/svg/ea_emitter/element/header_line_provider/name.rb +6 -15
- data/lib/ea/svg/ea_emitter/element/header_line_provider/stereotype_label.rb +25 -8
- data/lib/ea/svg/ea_emitter/element/header_renderer.rb +16 -4
- data/lib/ea/svg/ea_emitter/element/legend_renderer.rb +29 -4
- data/lib/ea/svg/ea_emitter/elements.rb +70 -20
- data/lib/ea/svg/ea_emitter/font_resolver.rb +5 -6
- data/lib/ea/svg/ea_emitter/label/end_label.rb +48 -4
- data/lib/ea/svg/ea_emitter/labels.rb +38 -2
- data/lib/ea/svg/ea_emitter/text_renderer.rb +97 -8
- data/lib/ea/svg/parity/checker.rb +19 -9
- data/lib/ea/svg/parity/corpus_report.rb +83 -0
- data/lib/ea/svg/parity/differ.rb +231 -0
- data/lib/ea/svg/parity.rb +2 -0
- data/lib/ea/theme/definition.rb +3 -3
- data/lib/ea/theme/loader.rb +2 -2
- data/lib/ea/version.rb +1 -1
- metadata +4 -2
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Ea
|
|
6
|
+
module Svg
|
|
7
|
+
module Parity
|
|
8
|
+
# Renders every diagram of a Document and diffs it against the
|
|
9
|
+
# EA-authored reference export for that diagram (EAID_-named
|
|
10
|
+
# files), aggregating itemized deltas into a corpus report.
|
|
11
|
+
#
|
|
12
|
+
# References stay wherever EA published them (e.g. an EA "Save
|
|
13
|
+
# as image" directory); nothing is copied into the gem.
|
|
14
|
+
class CorpusReport
|
|
15
|
+
DELTA_KEYS = %i[extra_text missing_text moved_text wrong_font
|
|
16
|
+
wrong_size wrong_weight].freeze
|
|
17
|
+
|
|
18
|
+
attr_reader :document, :ref_dir
|
|
19
|
+
|
|
20
|
+
def initialize(document:, ref_dir:)
|
|
21
|
+
@document = document
|
|
22
|
+
@ref_dir = ref_dir
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Returns { diagrams: [...], totals: {...} } and writes JSON
|
|
26
|
+
# when output_path is given.
|
|
27
|
+
def run(output_path: nil)
|
|
28
|
+
index = document.index_by_id
|
|
29
|
+
rows = diagrams_with_references.map do |diagram|
|
|
30
|
+
measure_one(diagram, index)
|
|
31
|
+
end.compact
|
|
32
|
+
|
|
33
|
+
result = { totals: totals(rows), diagrams: rows }
|
|
34
|
+
File.write(output_path, JSON.pretty_generate(result)) if output_path
|
|
35
|
+
result
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def diagrams_with_references
|
|
41
|
+
document.diagrams.select { |d| reference_path_for(d) }
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# EA publishes images as EAID_<guid-with-underscores>.svg;
|
|
45
|
+
# fall back to the raw diagram id for non-QEA sources.
|
|
46
|
+
def reference_path_for(diagram)
|
|
47
|
+
[
|
|
48
|
+
File.join(ref_dir, "EAID_#{diagram.id.tr('-', '_')}.svg"),
|
|
49
|
+
File.join(ref_dir, "#{Ea::Sources::Qea::IdNormalizer.to_eaid(diagram.id)}.svg"),
|
|
50
|
+
File.join(ref_dir, "#{diagram.id}.svg")
|
|
51
|
+
].find { |p| File.exist?(p) }
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def measure_one(diagram, index)
|
|
55
|
+
ref_path = reference_path_for(diagram)
|
|
56
|
+
return nil unless ref_path
|
|
57
|
+
|
|
58
|
+
ours = Ea::Svg::EaEmitter::Document.new(
|
|
59
|
+
diagram, model_index: index, document: document
|
|
60
|
+
).render
|
|
61
|
+
differ = Differ.new(ours: ours, reference: File.read(ref_path))
|
|
62
|
+
deltas = differ.text_deltas
|
|
63
|
+
|
|
64
|
+
{
|
|
65
|
+
id: diagram.id, name: diagram.name,
|
|
66
|
+
deltas: DELTA_KEYS.to_h { |k| [k, deltas[k].size] },
|
|
67
|
+
shapes: differ.shape_summary.slice(:matched, :missing, :extra)
|
|
68
|
+
}
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def totals(rows)
|
|
72
|
+
sum = DELTA_KEYS.to_h { |k| [k, 0] }
|
|
73
|
+
shapes = { matched: 0, missing: 0, extra: 0 }
|
|
74
|
+
rows.each do |row|
|
|
75
|
+
DELTA_KEYS.each { |k| sum[k] += row[:deltas][k] }
|
|
76
|
+
shapes.each_key { |k| shapes[k] += row[:shapes][k] }
|
|
77
|
+
end
|
|
78
|
+
sum.merge(shapes: shapes, diagrams: rows.size)
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "nokogiri"
|
|
4
|
+
|
|
5
|
+
module Ea
|
|
6
|
+
module Svg
|
|
7
|
+
module Parity
|
|
8
|
+
# Normalizes an EA-style SVG (ours or EA's own export — both use
|
|
9
|
+
# the same writer conventions) into a flat list of primitives,
|
|
10
|
+
# then reports itemized differences: missing/extra/moved texts,
|
|
11
|
+
# wrong fonts, shape geometry deltas.
|
|
12
|
+
#
|
|
13
|
+
# Primitives inherit fill/stroke from their wrapping <g style>
|
|
14
|
+
# since both writers emit one primitive per group.
|
|
15
|
+
class Differ
|
|
16
|
+
EPSILON = 0.51 # sub-pixel jitter is noise; EA writes 2 decimals
|
|
17
|
+
MATCH_DISTANCE = 6.0 # max centroid distance for shape pairing
|
|
18
|
+
|
|
19
|
+
attr_reader :ours, :reference
|
|
20
|
+
|
|
21
|
+
def initialize(ours:, reference:)
|
|
22
|
+
@ours = parse(ours)
|
|
23
|
+
@reference = parse(reference)
|
|
24
|
+
@shift = dominant_shift
|
|
25
|
+
end
|
|
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 -----------------------------------
|
|
48
|
+
|
|
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
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
primitives
|
|
68
|
+
end
|
|
69
|
+
private :parse
|
|
70
|
+
|
|
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
|
|
85
|
+
|
|
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
|
|
97
|
+
|
|
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?
|
|
101
|
+
|
|
102
|
+
[x + float(node["width"]).to_f / 2, y + float(node["height"]).to_f / 2]
|
|
103
|
+
end
|
|
104
|
+
private :rect_centroid
|
|
105
|
+
|
|
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
|
|
116
|
+
|
|
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
|
|
126
|
+
|
|
127
|
+
def centroid(points)
|
|
128
|
+
return [0.0, 0.0] if points.nil? || points.empty?
|
|
129
|
+
|
|
130
|
+
xs = points.map(&:first); ys = points.map(&:last)
|
|
131
|
+
[xs.sum / points.size, ys.sum / points.size]
|
|
132
|
+
end
|
|
133
|
+
private :centroid
|
|
134
|
+
|
|
135
|
+
def float(value)
|
|
136
|
+
value&.to_f
|
|
137
|
+
end
|
|
138
|
+
private :float
|
|
139
|
+
|
|
140
|
+
def normalize_weight(raw)
|
|
141
|
+
return "400" if raw.nil? || raw == "0"
|
|
142
|
+
|
|
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
|
|
162
|
+
end
|
|
163
|
+
candidates.delete(ref)
|
|
164
|
+
|
|
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])
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
unmatched = ref_by_content.values.flatten
|
|
172
|
+
deltas[:missing_text].concat(unmatched)
|
|
173
|
+
deltas
|
|
174
|
+
end
|
|
175
|
+
|
|
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).
|
|
180
|
+
def shape_summary
|
|
181
|
+
our_shapes = @ours.reject { |p| p[:kind] == :text }
|
|
182
|
+
.map { |p| shift_primitive(p) }
|
|
183
|
+
ref_shapes = @reference.reject { |p| p[:kind] == :text }
|
|
184
|
+
|
|
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
|
|
189
|
+
|
|
190
|
+
ref_shapes.delete(ref)
|
|
191
|
+
matched += 1
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
{
|
|
195
|
+
our_shapes: our_shapes.size, ref_shapes: ref_shapes.size + matched,
|
|
196
|
+
matched: matched, missing: ref_shapes.size, extra: our_shapes.size - matched
|
|
197
|
+
}
|
|
198
|
+
end
|
|
199
|
+
|
|
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
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
private :shift_primitive
|
|
208
|
+
|
|
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?
|
|
212
|
+
|
|
213
|
+
ax, ay = a[:centroid]; bx, by = b[:centroid]
|
|
214
|
+
Math.sqrt(((ax - bx).abs**2) + ((ay - by).abs**2))
|
|
215
|
+
end
|
|
216
|
+
private :shape_distance
|
|
217
|
+
|
|
218
|
+
def moved?(ours, ref)
|
|
219
|
+
((ours[:x] + @shift[0]) - ref[:x]).abs > EPSILON ||
|
|
220
|
+
((ours[:y] + @shift[1]) - ref[:y]).abs > EPSILON
|
|
221
|
+
end
|
|
222
|
+
private :moved?
|
|
223
|
+
|
|
224
|
+
def distance(a, b)
|
|
225
|
+
Math.sqrt(((a[:x] - b[:x]).abs**2) + ((a[:y] - b[:y]).abs**2))
|
|
226
|
+
end
|
|
227
|
+
private :distance
|
|
228
|
+
end
|
|
229
|
+
end
|
|
230
|
+
end
|
|
231
|
+
end
|
data/lib/ea/svg/parity.rb
CHANGED
|
@@ -7,6 +7,8 @@ module Ea
|
|
|
7
7
|
# source format (.qea or .xmi) from specs or a rake task.
|
|
8
8
|
module Parity
|
|
9
9
|
autoload :Checker, "ea/svg/parity/checker"
|
|
10
|
+
autoload :CorpusReport, "ea/svg/parity/corpus_report"
|
|
11
|
+
autoload :Differ, "ea/svg/parity/differ"
|
|
10
12
|
autoload :Suite, "ea/svg/parity/suite"
|
|
11
13
|
autoload :Source, "ea/svg/parity/source"
|
|
12
14
|
end
|
data/lib/ea/theme/definition.rb
CHANGED
|
@@ -44,10 +44,10 @@ module Ea
|
|
|
44
44
|
DEFAULT_FRAME = FrameSpec.new(inset: 6, tab_height: 20, tab_slant: 13,
|
|
45
45
|
tab_label_x: 11, tab_label_y: 19,
|
|
46
46
|
tab_padding: 7).freeze
|
|
47
|
-
DEFAULT_COMPARTMENTS = CompartmentSpec.new(header_top_padding:
|
|
47
|
+
DEFAULT_COMPARTMENTS = CompartmentSpec.new(header_top_padding: 12,
|
|
48
48
|
header_line_offset: 6,
|
|
49
49
|
divider_offset: 8,
|
|
50
|
-
attr_line_offset:
|
|
50
|
+
attr_line_offset: 6,
|
|
51
51
|
attr_first_offset: 7,
|
|
52
52
|
op_line_offset: 4).freeze
|
|
53
53
|
DEFAULT_ATTR = AttrSpec.new(visibility_x_offset: 5,
|
|
@@ -74,7 +74,7 @@ module Ea
|
|
|
74
74
|
stroke_in_text_color: "#000000",
|
|
75
75
|
element_border_width: 2, connector_line_width: 2,
|
|
76
76
|
divider_stroke_width: 2, marker_stroke_width: 2,
|
|
77
|
-
text_width_factor: 0.
|
|
77
|
+
text_width_factor: 0.612,
|
|
78
78
|
geometry: nil, frame: nil, compartments: nil,
|
|
79
79
|
attribute_spec: nil, markers: nil, package: nil,
|
|
80
80
|
note: nil, fills: {})
|
data/lib/ea/theme/loader.rb
CHANGED
|
@@ -86,10 +86,10 @@ module Ea
|
|
|
86
86
|
tab_padding: frame["tab_padding"] || 7
|
|
87
87
|
),
|
|
88
88
|
compartments: comps.empty? ? nil : Definition::CompartmentSpec.new(
|
|
89
|
-
header_top_padding: comps["header_top_padding"] ||
|
|
89
|
+
header_top_padding: comps["header_top_padding"] || 12,
|
|
90
90
|
header_line_offset: comps["header_line_offset"] || 6,
|
|
91
91
|
divider_offset: comps["divider_offset"] || 8,
|
|
92
|
-
attr_line_offset: comps["attr_line_offset"] ||
|
|
92
|
+
attr_line_offset: comps["attr_line_offset"] || 7,
|
|
93
93
|
attr_first_offset: comps["attr_first_offset"] || 7,
|
|
94
94
|
op_line_offset: comps["op_line_offset"] || 4
|
|
95
95
|
),
|
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.6
|
|
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-09-
|
|
11
|
+
date: 2026-09-28 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: lutaml-model
|
|
@@ -945,6 +945,8 @@ files:
|
|
|
945
945
|
- lib/ea/svg/ea_emitter/visibility_symbol.rb
|
|
946
946
|
- lib/ea/svg/parity.rb
|
|
947
947
|
- lib/ea/svg/parity/checker.rb
|
|
948
|
+
- lib/ea/svg/parity/corpus_report.rb
|
|
949
|
+
- lib/ea/svg/parity/differ.rb
|
|
948
950
|
- lib/ea/svg/parity/source.rb
|
|
949
951
|
- lib/ea/svg/parity/suite.rb
|
|
950
952
|
- lib/ea/svg/stereotype_color_resolver.rb
|