ea 0.6.3 → 0.6.5

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 (47) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +72 -0
  3. data/config/themes/119.yml +4 -4
  4. data/frontend/dist/app.iife.js +8 -8
  5. data/frontend/src/App.vue +6 -2
  6. data/frontend/src/components/AppHeader.vue +35 -8
  7. data/frontend/src/components/AppSidebar.vue +2 -2
  8. data/frontend/src/components/ClassDetails.vue +103 -120
  9. data/frontend/src/components/DiagramView.vue +19 -6
  10. data/frontend/src/components/PackageDetails.vue +67 -26
  11. data/frontend/src/components/PackageTreeNode.vue +25 -20
  12. data/frontend/src/components/SearchResults.vue +21 -8
  13. data/frontend/src/components/WelcomeView.vue +2 -2
  14. data/frontend/src/stores/dataStore.ts +142 -48
  15. data/frontend/src/stores/uiStore.ts +3 -4
  16. data/frontend/src/types.ts +160 -163
  17. data/lib/ea/model/diagram_connector.rb +9 -0
  18. data/lib/ea/model/property.rb +6 -1
  19. data/lib/ea/sources/qea/classifier_builder.rb +3 -1
  20. data/lib/ea/sources/qea/diagram_builder.rb +91 -9
  21. data/lib/ea/sources/qea/property_builder.rb +12 -2
  22. data/lib/ea/spa/output/single_file_strategy.rb +1 -1
  23. data/lib/ea/spa/projector.rb +108 -6
  24. data/lib/ea/svg/ea_emitter/compartment/attributes.rb +1 -0
  25. data/lib/ea/svg/ea_emitter/compartment/constraints.rb +1 -0
  26. data/lib/ea/svg/ea_emitter/compartment/enum_literals.rb +9 -25
  27. data/lib/ea/svg/ea_emitter/compartment/header.rb +1 -0
  28. data/lib/ea/svg/ea_emitter/element/attribute_line_builder.rb +5 -0
  29. data/lib/ea/svg/ea_emitter/element/attribute_renderer.rb +51 -11
  30. data/lib/ea/svg/ea_emitter/element/compartment_geometry.rb +12 -10
  31. data/lib/ea/svg/ea_emitter/element/header_line_provider/name.rb +6 -15
  32. data/lib/ea/svg/ea_emitter/element/header_line_provider/stereotype_label.rb +25 -8
  33. data/lib/ea/svg/ea_emitter/element/header_renderer.rb +16 -4
  34. data/lib/ea/svg/ea_emitter/element/legend_renderer.rb +29 -4
  35. data/lib/ea/svg/ea_emitter/elements.rb +70 -20
  36. data/lib/ea/svg/ea_emitter/font_resolver.rb +5 -6
  37. data/lib/ea/svg/ea_emitter/label/end_label.rb +3 -1
  38. data/lib/ea/svg/ea_emitter/labels.rb +38 -2
  39. data/lib/ea/svg/ea_emitter/text_renderer.rb +97 -8
  40. data/lib/ea/svg/parity/checker.rb +19 -9
  41. data/lib/ea/svg/parity/corpus_report.rb +83 -0
  42. data/lib/ea/svg/parity/differ.rb +231 -0
  43. data/lib/ea/svg/parity.rb +2 -0
  44. data/lib/ea/theme/definition.rb +3 -3
  45. data/lib/ea/theme/loader.rb +2 -2
  46. data/lib/ea/version.rb +1 -1
  47. metadata +4 -2
@@ -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
@@ -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: 9,
47
+ DEFAULT_COMPARTMENTS = CompartmentSpec.new(header_top_padding: 12,
48
48
  header_line_offset: 6,
49
49
  divider_offset: 8,
50
- attr_line_offset: 4,
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.65,
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: {})
@@ -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"] || 9,
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"] || 4,
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
@@ -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.3"
7
+ VERSION = "0.6.5"
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.3
4
+ version: 0.6.5
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-24 00:00:00.000000000 Z
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