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.
Files changed (32) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +39 -0
  3. data/config/themes/119.yml +4 -4
  4. data/lib/ea/model/diagram_connector.rb +9 -0
  5. data/lib/ea/model/property.rb +6 -1
  6. data/lib/ea/sources/qea/classifier_builder.rb +3 -1
  7. data/lib/ea/sources/qea/diagram_builder.rb +91 -9
  8. data/lib/ea/sources/qea/property_builder.rb +12 -2
  9. data/lib/ea/svg/ea_emitter/compartment/attributes.rb +1 -0
  10. data/lib/ea/svg/ea_emitter/compartment/constraints.rb +1 -0
  11. data/lib/ea/svg/ea_emitter/compartment/enum_literals.rb +9 -25
  12. data/lib/ea/svg/ea_emitter/compartment/header.rb +1 -0
  13. data/lib/ea/svg/ea_emitter/element/attribute_line_builder.rb +5 -0
  14. data/lib/ea/svg/ea_emitter/element/attribute_renderer.rb +51 -11
  15. data/lib/ea/svg/ea_emitter/element/compartment_geometry.rb +12 -10
  16. data/lib/ea/svg/ea_emitter/element/header_line_provider/name.rb +6 -15
  17. data/lib/ea/svg/ea_emitter/element/header_line_provider/stereotype_label.rb +25 -8
  18. data/lib/ea/svg/ea_emitter/element/header_renderer.rb +16 -4
  19. data/lib/ea/svg/ea_emitter/element/legend_renderer.rb +29 -4
  20. data/lib/ea/svg/ea_emitter/elements.rb +70 -20
  21. data/lib/ea/svg/ea_emitter/font_resolver.rb +5 -6
  22. data/lib/ea/svg/ea_emitter/label/end_label.rb +48 -4
  23. data/lib/ea/svg/ea_emitter/labels.rb +38 -2
  24. data/lib/ea/svg/ea_emitter/text_renderer.rb +97 -8
  25. data/lib/ea/svg/parity/checker.rb +19 -9
  26. data/lib/ea/svg/parity/corpus_report.rb +83 -0
  27. data/lib/ea/svg/parity/differ.rb +231 -0
  28. data/lib/ea/svg/parity.rb +2 -0
  29. data/lib/ea/theme/definition.rb +3 -3
  30. data/lib/ea/theme/loader.rb +2 -2
  31. data/lib/ea/version.rb +1 -1
  32. 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
@@ -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.4"
7
+ VERSION = "0.6.6"
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.4
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-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