ea 0.3.0 → 0.4.0

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 (41) hide show
  1. checksums.yaml +4 -4
  2. data/TODO.diagrams/01-xmi-extension-elements-parsing.md +67 -0
  3. data/TODO.diagrams/02-diagram-element-rich-model.md +61 -0
  4. data/TODO.diagrams/03-ea-style-svg-emitter.md +70 -0
  5. data/TODO.diagrams/04-connector-routing-from-soid-eoid.md +55 -0
  6. data/TODO.diagrams/05-marker-rendering-per-relationship-type.md +49 -0
  7. data/TODO.diagrams/06-connector-labels.md +49 -0
  8. data/TODO.diagrams/07-canvas-size-and-viewbox.md +39 -0
  9. data/TODO.diagrams/08-visual-regression-harness.md +34 -0
  10. data/TODO.diagrams/09-cli-render-all-diagrams.md +25 -0
  11. data/TODO.diagrams/10-stereotype-color-config.md +29 -0
  12. data/TODO.diagrams/11-elkrb-auto-layout-integration.md +53 -0
  13. data/config/stereotype_colors.yml +15 -0
  14. data/lib/ea/cli/app.rb +7 -1
  15. data/lib/ea/cli/command/svg.rb +50 -6
  16. data/lib/ea/model/diagram_connector.rb +19 -1
  17. data/lib/ea/model/diagram_element.rb +28 -0
  18. data/lib/ea/sources/qea/diagram_builder.rb +103 -19
  19. data/lib/ea/sources/xmi/annotation_builder.rb +1 -1
  20. data/lib/ea/sources/xmi/diagram_builder.rb +164 -44
  21. data/lib/ea/sources/xmi/extension_elements.rb +74 -0
  22. data/lib/ea/sources/xmi/extension_geometry_parser.rb +107 -0
  23. data/lib/ea/sources/xmi/extension_style_parser.rb +78 -0
  24. data/lib/ea/sources/xmi.rb +3 -0
  25. data/lib/ea/spa/configuration.rb +17 -3
  26. data/lib/ea/spa/projector.rb +6 -1
  27. data/lib/ea/svg/connector_path.rb +109 -17
  28. data/lib/ea/svg/ea_emitter/background.rb +16 -0
  29. data/lib/ea/svg/ea_emitter/canvas.rb +65 -0
  30. data/lib/ea/svg/ea_emitter/connectors.rb +50 -0
  31. data/lib/ea/svg/ea_emitter/document.rb +44 -0
  32. data/lib/ea/svg/ea_emitter/elements.rb +169 -0
  33. data/lib/ea/svg/ea_emitter/labels.rb +64 -0
  34. data/lib/ea/svg/ea_emitter/markers.rb +117 -0
  35. data/lib/ea/svg/ea_emitter.rb +29 -0
  36. data/lib/ea/svg/element_box.rb +202 -17
  37. data/lib/ea/svg/renderer.rb +8 -1
  38. data/lib/ea/svg/stereotype_color_resolver.rb +50 -0
  39. data/lib/ea/svg.rb +2 -0
  40. data/lib/ea/version.rb +1 -1
  41. metadata +26 -2
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ea
4
+ module Svg
5
+ module EaEmitter
6
+ # Emits connector labels (role names, multiplicities) at the
7
+ # EA-recorded offset from the connector endpoints. Each label
8
+ # box has its own position decoded from the EA LLB/LLT/LRT/LRB
9
+ # geometry slots.
10
+ class Labels
11
+ DEFAULT_FAMILY = "Calibri"
12
+ DEFAULT_SIZE = 10
13
+
14
+ attr_reader :diagram
15
+
16
+ def initialize(diagram)
17
+ @diagram = diagram
18
+ end
19
+
20
+ def render
21
+ texts = visible_connectors.filter_map { |c| texts_for(c) }
22
+ return "" if texts.empty?
23
+
24
+ blocks = texts.flatten
25
+ %(<g style="stroke-width:1;stroke-linecap:round;stroke-linejoin:bevel; fill:#000000;fill-opacity:1.00; stroke:#000000; stroke-opacity:0.00">\n#{blocks.join("\n")}\n</g>)
26
+ end
27
+
28
+ private
29
+
30
+ def visible_connectors
31
+ (diagram.connectors || []).reject(&:hidden)
32
+ end
33
+
34
+ def texts_for(connector)
35
+ points = connector.waypoints.filter_map { |w| w.position && [w.position.x, w.position.y] }
36
+ return nil if points.size < 2
37
+
38
+ source_pt = points.first
39
+ target_pt = points.last
40
+
41
+ boxes = connector.label_boxes || {}
42
+ texts = []
43
+ if (box = boxes["llb"]) && box["ox"] && box["oy"]
44
+ texts << text_at(source_pt[0] + box["ox"], source_pt[1] + box["oy"], connector.label.to_s)
45
+ end
46
+ if (box = boxes["lrt"]) && box["ox"] && box["oy"]
47
+ texts << text_at(target_pt[0] + box["ox"], target_pt[1] + box["oy"], connector.label.to_s)
48
+ end
49
+ texts.empty? ? nil : texts
50
+ end
51
+
52
+ def text_at(x, y, text)
53
+ return nil if text.nil? || text.empty?
54
+
55
+ %( <text x="#{format('%.2f', x)}" y="#{format('%.2f', y)}" textLength="#{text.length * 6}" style="font-family:#{DEFAULT_FAMILY}; font-weight:0; font-style:normal; font-size:#{DEFAULT_SIZE}px; fill:#000000;fill-opacity:1.00; stroke:#000000; stroke-opacity:0.00 stroke-width:0; white-space: pre;" xml:space="preserve">#{escape(text)}</text>)
56
+ end
57
+
58
+ def escape(text)
59
+ text.to_s.gsub("&", "&amp;").gsub("<", "&lt;").gsub(">", "&gt;").gsub("\"", "&quot;")
60
+ end
61
+ end
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,117 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ea
4
+ module Svg
5
+ module EaEmitter
6
+ # Emits arrow / diamond markers per connector, matching EA's
7
+ # convention:
8
+ # - Association (navigable): filled triangle at target
9
+ # - Generalization: open triangle at target
10
+ # - Realization: open triangle at target (path dashed)
11
+ # - Dependency: open arrow at target (path dashed)
12
+ # - Aggregation (source): open diamond at source
13
+ # - Composition (source): filled diamond at source
14
+ class Markers
15
+ ARROW_SIZE = 10
16
+
17
+ attr_reader :diagram, :model_index
18
+
19
+ def initialize(diagram, model_index:)
20
+ @diagram = diagram
21
+ @model_index = model_index
22
+ end
23
+
24
+ def render
25
+ polys = visible_connectors.filter_map { |c| polygon_for(c) }
26
+ return "" if polys.empty?
27
+
28
+ polys.join("\n")
29
+ end
30
+
31
+ private
32
+
33
+ def visible_connectors
34
+ (diagram.connectors || []).reject(&:hidden)
35
+ end
36
+
37
+ def polygon_for(connector)
38
+ relationship = relationship_for(connector)
39
+ points = connector.waypoints.filter_map { |w| w.position && [w.position.x, w.position.y] }
40
+ return nil if points.size < 2
41
+
42
+ source = points.first
43
+ target = points.last
44
+ before_target = points[-2] || source
45
+
46
+ marker = marker_for(relationship)
47
+ return nil if marker == :none
48
+
49
+ polygon = case marker
50
+ when :filled_triangle then filled_triangle(target, before_target)
51
+ when :open_triangle then open_triangle(target, before_target)
52
+ end
53
+ return nil unless polygon
54
+
55
+ style = polygon_style(relationship)
56
+ %(<g style="#{style}">\n #{polygon}\n</g>)
57
+ end
58
+
59
+ def marker_for(relationship)
60
+ return :filled_triangle unless relationship
61
+
62
+ case relationship
63
+ when Ea::Model::Generalization, Ea::Model::Realization
64
+ :open_triangle
65
+ else
66
+ :filled_triangle
67
+ end
68
+ end
69
+
70
+ def polygon_style(relationship)
71
+ case marker_for(relationship)
72
+ when :open_triangle
73
+ "stroke-width:2;stroke-linecap:round;stroke-linejoin:bevel; fill:#FFFFFF;fill-opacity:1.00; stroke:#000000; stroke-opacity:1.00"
74
+ else
75
+ "stroke-width:2;stroke-linecap:round;stroke-linejoin:bevel; fill:#000000;fill-opacity:1.00; stroke:#000000; stroke-opacity:1.00"
76
+ end
77
+ end
78
+
79
+ def filled_triangle(tip, base)
80
+ polygon_points(tip, base, fill: true)
81
+ end
82
+
83
+ def open_triangle(tip, base)
84
+ polygon_points(tip, base, fill: false)
85
+ end
86
+
87
+ def polygon_points(tip, base, fill:)
88
+ bx, by = base
89
+ tx, ty = tip
90
+ dx = tx - bx
91
+ dy = ty - by
92
+ len = Math.sqrt(dx * dx + dy * dy)
93
+ return nil if len.zero?
94
+
95
+ ux = dx / len
96
+ uy = dy / len
97
+ back_x = tx - ux * ARROW_SIZE
98
+ back_y = ty - uy * ARROW_SIZE
99
+ perp_x = -uy * (ARROW_SIZE / 2.0)
100
+ perp_y = ux * (ARROW_SIZE / 2.0)
101
+ w1_x = back_x + perp_x
102
+ w1_y = back_y + perp_y
103
+ w2_x = back_x - perp_x
104
+ w2_y = back_y - perp_y
105
+ pts = "#{tx} #{ty} #{format('%.1f', w1_x)} #{format('%.1f', w1_y)} #{format('%.1f', w2_x)} #{format('%.1f', w2_y)}"
106
+ %(<polygon points="#{pts}" shape-rendering="auto" style="fill-rule:evenodd;"/>)
107
+ end
108
+
109
+ def relationship_for(connector)
110
+ return nil unless connector.relationship_ref
111
+
112
+ model_index[connector.relationship_ref]
113
+ end
114
+ end
115
+ end
116
+ end
117
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ea
4
+ module Svg
5
+ # EaEmitter orchestrates SVG output that mirrors EA's layering
6
+ # and naming conventions:
7
+ #
8
+ # 1. XML declaration + DOCTYPE (SVG 1.0)
9
+ # 2. Root <svg> with cm dimensions + viewBox
10
+ # 3. <title></title> + <desc>Created with Enterprise Architect..</desc>
11
+ # 4. Background <g> with full-canvas white rect
12
+ # 5. Per-element groups, layered: shape <g>, text <g>, divider
13
+ # <g>, attribute text <g>
14
+ # 6. Per-connector groups: path <g> and polygon <g>
15
+ # 7. Final labels <g> with all connector text
16
+ #
17
+ # Distinct from Ea::Svg::Renderer which produces a thinner
18
+ # output for diagrams that don't carry full EA style data.
19
+ module EaEmitter
20
+ autoload :Canvas, "ea/svg/ea_emitter/canvas"
21
+ autoload :Background, "ea/svg/ea_emitter/background"
22
+ autoload :Elements, "ea/svg/ea_emitter/elements"
23
+ autoload :Connectors, "ea/svg/ea_emitter/connectors"
24
+ autoload :Markers, "ea/svg/ea_emitter/markers"
25
+ autoload :Labels, "ea/svg/ea_emitter/labels"
26
+ autoload :Document, "ea/svg/ea_emitter/document"
27
+ end
28
+ end
29
+ end
@@ -4,11 +4,41 @@ require "ostruct"
4
4
 
5
5
  module Ea
6
6
  module Svg
7
- # Renders one DiagramElement as an SVG <g> containing the rect
8
- # and a name label. Class-shape rendering (with attribute /
9
- # operation compartments) is future work; for now we emit a
10
- # simple labeled box that mirrors EA's logical placement.
7
+ # Renders one DiagramElement as a 3-compartment UML class box,
8
+ # matching EA's drawing convention:
9
+ #
10
+ # ┌─────────────────────────┐
11
+ # │ «Stereotype» │ ← header (stereotype + name)
12
+ # │ qualified::Name │
13
+ # ├─────────────────────────┤
14
+ # │ + attr1: Type [0..1] │ ← attributes
15
+ # │ + attr2: Type [0..*] │
16
+ # ├─────────────────────────┤
17
+ # │ + op1(arg: T): T │ ← operations
18
+ # └─────────────────────────┘
19
+ #
20
+ # The stored element bounds are honored as the outer rectangle;
21
+ # internal layout (header / attrs / ops heights) is computed
22
+ # proportionally based on line count.
11
23
  class ElementBox
24
+ HEADER_LINE_HEIGHT = 17
25
+ ATTR_LINE_HEIGHT = 17
26
+ OP_LINE_HEIGHT = 17
27
+ TOP_PADDING = 17
28
+
29
+ STEREOTYPE_COLORS = {
30
+ "featuretype" => "#FFFFCC", # yellow
31
+ "feature" => "#FFFFCC",
32
+ "type" => "#CCFFCC", # green
33
+ "datatype" => "#FFCCFF", # pink
34
+ "basictype" => "#FFCCFF",
35
+ "codelist" => "#FFCCFF",
36
+ "enumeration" => "#FFCCFF",
37
+ "union" => "#F0E68C", # khaki
38
+ "adeelement" => "#F5F5DC" # beige
39
+ }.freeze
40
+ DEFAULT_FILL = "#FFFFFF"
41
+
12
42
  attr_reader :element, :model_index
13
43
 
14
44
  def initialize(element, model_index:)
@@ -20,31 +50,186 @@ module Ea
20
50
  return "" unless element.bounds
21
51
 
22
52
  b = normalize_bounds(element.bounds)
23
- style = StyleResolver.new(element.style)
24
- label_text = model_element_name
53
+ classifier = bound_classifier
54
+ return render_simple_box(b) unless classifier
55
+
56
+ render_class_box(b, classifier)
57
+ end
58
+
59
+ private
25
60
 
61
+ def bound_classifier
62
+ ref = element.model_element_ref
63
+ return nil unless ref
64
+
65
+ candidate = model_index[ref]
66
+ return nil unless candidate.is_a?(Ea::Model::Classifier)
67
+
68
+ candidate
69
+ end
70
+
71
+ def render_simple_box(bounds)
72
+ style = StyleResolver.new(element.style)
26
73
  <<~SVG.chomp
27
74
  <g class="element" data-element-id="#{escape(element.id)}">
28
- <rect x="#{b.x}" y="#{b.y}" width="#{b.width}" height="#{b.height}"
29
- fill="#{style.fill_color}" stroke="#{style.stroke_color}"
75
+ <rect x="#{bounds.x}" y="#{bounds.y}" width="#{bounds.width}" height="#{bounds.height}"
76
+ fill="#{fill_color(style)}" stroke="#{style.stroke_color}"
30
77
  stroke-width="#{style.stroke_width}"/>
31
- <text x="#{b.x + (b.width / 2.0)}"
32
- y="#{b.y + (b.height / 2.0)}"
78
+ <text x="#{bounds.x + (bounds.width / 2.0)}"
79
+ y="#{bounds.y + (bounds.height / 2.0)}"
33
80
  text-anchor="middle" dominant-baseline="middle"
34
81
  fill="#{style.font_color}"
35
- font-family="sans-serif" font-size="14">#{escape(label_text)}</text>
82
+ font-family="sans-serif" font-size="14">#{escape(element.id)}</text>
36
83
  </g>
37
84
  SVG
38
85
  end
39
86
 
40
- private
87
+ def render_class_box(bounds, classifier)
88
+ stereotype = primary_stereotype(classifier)
89
+ fill = STEREOTYPE_COLORS[stereotype&.downcase] || DEFAULT_FILL
90
+ style = StyleResolver.new(element.style)
91
+ stroke = style.stroke_color
92
+ stroke_width = style.stroke_width
93
+ font_color = style.font_color
41
94
 
42
- def model_element_name
43
- ref = element.model_element_ref
44
- return "(unbound)" unless ref
95
+ header_lines = build_header_lines(classifier, stereotype)
96
+ attr_lines = build_attribute_lines(classifier)
97
+ op_lines = build_operation_lines(classifier)
98
+
99
+ header_bottom = bounds.y + header_height(header_lines.size)
100
+ attrs_bottom = header_bottom + compartment_height(attr_lines.size)
101
+ ops_bottom = attrs_bottom + compartment_height(op_lines.size)
102
+
103
+ parts = []
104
+ parts << %(<g class="element" data-element-id="#{escape(element.id)}">)
105
+ parts << render_outer_rect(bounds, fill, stroke, stroke_width)
106
+ parts << render_header_divider(bounds, header_bottom, stroke, stroke_width)
107
+ parts << render_attrs_divider(bounds, attrs_bottom, stroke, stroke_width) if op_lines.any?
108
+ parts << render_header_text(bounds, header_lines, font_color)
109
+ parts << render_attribute_text(bounds, header_bottom, attr_lines, font_color)
110
+ parts << render_operation_text(bounds, attrs_bottom, op_lines, font_color)
111
+ parts << %(</g>)
112
+ parts.join("\n ")
113
+ end
114
+
115
+ def build_header_lines(classifier, stereotype)
116
+ lines = []
117
+ lines << "«#{stereotype}»" if stereotype && !stereotype.empty?
118
+ lines << (classifier.qualified_name&.split("::")&.last || classifier.name || "(unnamed)")
119
+ lines
120
+ end
121
+
122
+ def build_attribute_lines(classifier)
123
+ return [] unless classifier.properties
124
+
125
+ classifier.properties.map do |prop|
126
+ vis = visibility_marker(prop.visibility)
127
+ mult = multiplicity_text(prop)
128
+ type = prop.type_name ? ": #{prop.type_name}" : ""
129
+ "#{vis} #{prop.name}#{type}#{mult}"
130
+ end
131
+ end
132
+
133
+ def build_operation_lines(classifier)
134
+ return [] unless classifier.operations
135
+
136
+ classifier.operations.map do |op|
137
+ vis = visibility_marker(op.visibility)
138
+ ret = op.return_type_name ? ": #{op.return_type_name}" : ""
139
+ params = (op.parameters || []).map { |p| "#{p.name}: #{p.type_name || 'T'}" }.join(", ")
140
+ "#{vis} #{op.name}(#{params})#{ret}"
141
+ end
142
+ end
143
+
144
+ def visibility_marker(visibility)
145
+ case visibility&.downcase
146
+ when "public" then "+"
147
+ when "private" then "-"
148
+ when "protected" then "#"
149
+ when "package" then "~"
150
+ else "+"
151
+ end
152
+ end
153
+
154
+ def multiplicity_text(prop)
155
+ lower = prop.multiplicity_lower
156
+ upper = prop.multiplicity_upper
157
+ return "" if lower.nil? && upper.nil?
158
+ return "" if lower == 1 && upper == 1
159
+
160
+ upper_text = upper == -1 ? "*" : upper
161
+ lower_text = lower || 0
162
+ return "[0..*]" if lower_text == 0 && upper == -1
163
+ return "[#{lower_text}]" if lower_text == upper
164
+
165
+ "[#{lower_text}..#{upper_text}]"
166
+ end
167
+
168
+ def primary_stereotype(classifier)
169
+ refs = classifier.stereotype_refs
170
+ return nil if refs.nil? || refs.empty?
171
+
172
+ refs.first.to_s.split("::").last
173
+ end
174
+
175
+ def fill_color(style)
176
+ stereotype = primary_stereotype_from_style
177
+ STEREOTYPE_COLORS[stereotype&.downcase] || style.fill_color
178
+ end
179
+
180
+ def primary_stereotype_from_style
181
+ nil
182
+ end
183
+
184
+ def header_height(line_count)
185
+ TOP_PADDING + (line_count * HEADER_LINE_HEIGHT)
186
+ end
187
+
188
+ def compartment_height(line_count)
189
+ return 0 if line_count.zero?
190
+
191
+ 10 + (line_count * (line_count == 0 ? 0 : (line_count < 3 ? 17 : 17)))
192
+ end
193
+
194
+ def render_outer_rect(bounds, fill, stroke, stroke_width)
195
+ %( <rect x="#{bounds.x}" y="#{bounds.y}" width="#{bounds.width}" height="#{bounds.height}" fill="#{fill}" stroke="#{stroke}" stroke-width="#{stroke_width}"/>)
196
+ end
197
+
198
+ def render_header_divider(bounds, y, stroke, stroke_width)
199
+ %( <path d="M #{bounds.x} #{y} L #{bounds.x + bounds.width} #{y}" stroke="#{stroke}" stroke-width="#{stroke_width}" fill="none"/>)
200
+ end
201
+
202
+ def render_attrs_divider(bounds, y, stroke, stroke_width)
203
+ %( <path d="M #{bounds.x} #{y} L #{bounds.x + bounds.width} #{y}" stroke="#{stroke}" stroke-width="#{stroke_width}" fill="none"/>)
204
+ end
205
+
206
+ def render_header_text(bounds, lines, font_color)
207
+ center_x = bounds.x + (bounds.width / 2.0)
208
+ lines.each_with_index.map do |line, idx|
209
+ y = bounds.y + TOP_PADDING + (idx * HEADER_LINE_HEIGHT) - 4
210
+ weight = (idx == lines.size - 1) ? "bold" : "normal"
211
+ %( <text x="#{center_x}" y="#{y}" text-anchor="middle" fill="#{font_color}" font-family="sans-serif" font-size="13" font-weight="#{weight}">#{escape(line)}</text>)
212
+ end.join("\n ")
213
+ end
214
+
215
+ def render_attribute_text(bounds, header_bottom, lines, font_color)
216
+ return "" if lines.empty?
217
+
218
+ left = bounds.x + 5
219
+ lines.each_with_index.map do |line, idx|
220
+ y = header_bottom + 14 + (idx * ATTR_LINE_HEIGHT)
221
+ %( <text x="#{left}" y="#{y}" text-anchor="start" fill="#{font_color}" font-family="sans-serif" font-size="12">#{escape(line)}</text>)
222
+ end.join("\n ")
223
+ end
224
+
225
+ def render_operation_text(bounds, attrs_bottom, lines, font_color)
226
+ return "" if lines.empty?
45
227
 
46
- model = model_index[ref]
47
- model&.name || "(missing #{ref})"
228
+ left = bounds.x + 5
229
+ lines.each_with_index.map do |line, idx|
230
+ y = attrs_bottom + 14 + (idx * OP_LINE_HEIGHT)
231
+ %( <text x="#{left}" y="#{y}" text-anchor="start" fill="#{font_color}" font-family="sans-serif" font-size="12">#{escape(line)}</text>)
232
+ end.join("\n ")
48
233
  end
49
234
 
50
235
  def normalize_bounds(bounds)
@@ -52,10 +52,17 @@ module Ea
52
52
 
53
53
  def render_connectors
54
54
  diagram.connectors.map do |conn|
55
- ConnectorPath.new(conn).render
55
+ ConnectorPath.new(conn,
56
+ relationship: relationship_for(conn)).render
56
57
  end.join("\n ")
57
58
  end
58
59
 
60
+ def relationship_for(connector)
61
+ return nil unless connector.relationship_ref
62
+
63
+ model_index[connector.relationship_ref]
64
+ end
65
+
59
66
  def pad(bounds)
60
67
  p = options[:padding]
61
68
  OpenStruct.new(
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+
5
+ module Ea
6
+ module Svg
7
+ # Resolves a classifier's stereotype to a fill color. Looks up
8
+ # a YAML config (config/stereotype_colors.yml) by lowercase
9
+ # stereotype name. Falls back to a default white when no match.
10
+ #
11
+ # Loading the YAML is memoized at the class level via a
12
+ # class instance variable accessor method (no instance_variable_set).
13
+ class StereotypeColorResolver
14
+ DEFAULT_FILL = "#FFFFFF"
15
+ DEFAULT_CONFIG_PATH = File.expand_path("../../../config/stereotype_colors.yml", __dir__)
16
+
17
+ class << self
18
+ # Memoize the loaded default map. Re-reading from disk is
19
+ # expensive; load once per process.
20
+ def default_map
21
+ @default_map ||= load_yaml(DEFAULT_CONFIG_PATH)
22
+ end
23
+
24
+ # Test hook: clear the memo to force a reload.
25
+ def reset_default_map
26
+ @default_map = nil
27
+ end
28
+
29
+ def load_yaml(path)
30
+ return {} unless File.exist?(path)
31
+
32
+ YAML.safe_load(File.read(path)) || {}
33
+ end
34
+ end
35
+
36
+ attr_reader :color_map, :default
37
+
38
+ def initialize(color_map: self.class.default_map, default: DEFAULT_FILL)
39
+ @color_map = color_map
40
+ @default = default
41
+ end
42
+
43
+ def fill_for(stereotype_name)
44
+ return default if stereotype_name.nil? || stereotype_name.empty?
45
+
46
+ color_map[stereotype_name.downcase] || default
47
+ end
48
+ end
49
+ end
50
+ end
data/lib/ea/svg.rb CHANGED
@@ -14,8 +14,10 @@ module Ea
14
14
  module Svg
15
15
  autoload :BoundsCalculator, "ea/svg/bounds_calculator"
16
16
  autoload :StyleResolver, "ea/svg/style_resolver"
17
+ autoload :StereotypeColorResolver, "ea/svg/stereotype_color_resolver"
17
18
  autoload :ElementBox, "ea/svg/element_box"
18
19
  autoload :ConnectorPath, "ea/svg/connector_path"
19
20
  autoload :Renderer, "ea/svg/renderer"
21
+ autoload :EaEmitter, "ea/svg/ea_emitter"
20
22
  end
21
23
  end
data/lib/ea/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Ea
4
- VERSION = "0.3.0"
4
+ VERSION = "0.4.0"
5
5
  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.3.0
4
+ version: 0.4.0
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-07-19 00:00:00.000000000 Z
11
+ date: 2026-07-21 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: lutaml-model
@@ -193,6 +193,17 @@ files:
193
193
  - README.adoc
194
194
  - README.md
195
195
  - Rakefile
196
+ - TODO.diagrams/01-xmi-extension-elements-parsing.md
197
+ - TODO.diagrams/02-diagram-element-rich-model.md
198
+ - TODO.diagrams/03-ea-style-svg-emitter.md
199
+ - TODO.diagrams/04-connector-routing-from-soid-eoid.md
200
+ - TODO.diagrams/05-marker-rendering-per-relationship-type.md
201
+ - TODO.diagrams/06-connector-labels.md
202
+ - TODO.diagrams/07-canvas-size-and-viewbox.md
203
+ - TODO.diagrams/08-visual-regression-harness.md
204
+ - TODO.diagrams/09-cli-render-all-diagrams.md
205
+ - TODO.diagrams/10-stereotype-color-config.md
206
+ - TODO.diagrams/11-elkrb-auto-layout-integration.md
196
207
  - TODO.next/00-publish-blocking-bugs.md
197
208
  - TODO.next/01-standalone-ea-gem-identity.md
198
209
  - TODO.next/02-optional-lutaml-uml-dependency.md
@@ -257,6 +268,7 @@ files:
257
268
  - config/diagram_styles.yml
258
269
  - config/model_transformations.yml
259
270
  - config/qea_schema.yml
271
+ - config/stereotype_colors.yml
260
272
  - docs/ea_to_uml_type_mapping.md
261
273
  - docs/xmi_qea_conversion_capabilities.md
262
274
  - examples/lur/20251010_current_plateau_v5.1.lur
@@ -455,6 +467,9 @@ files:
455
467
  - lib/ea/sources/xmi/annotation_builder.rb
456
468
  - lib/ea/sources/xmi/classifier_builder.rb
457
469
  - lib/ea/sources/xmi/diagram_builder.rb
470
+ - lib/ea/sources/xmi/extension_elements.rb
471
+ - lib/ea/sources/xmi/extension_geometry_parser.rb
472
+ - lib/ea/sources/xmi/extension_style_parser.rb
458
473
  - lib/ea/sources/xmi/id_normalizer.rb
459
474
  - lib/ea/sources/xmi/metadata_builder.rb
460
475
  - lib/ea/sources/xmi/operation_builder.rb
@@ -482,8 +497,17 @@ files:
482
497
  - lib/ea/svg.rb
483
498
  - lib/ea/svg/bounds_calculator.rb
484
499
  - lib/ea/svg/connector_path.rb
500
+ - lib/ea/svg/ea_emitter.rb
501
+ - lib/ea/svg/ea_emitter/background.rb
502
+ - lib/ea/svg/ea_emitter/canvas.rb
503
+ - lib/ea/svg/ea_emitter/connectors.rb
504
+ - lib/ea/svg/ea_emitter/document.rb
505
+ - lib/ea/svg/ea_emitter/elements.rb
506
+ - lib/ea/svg/ea_emitter/labels.rb
507
+ - lib/ea/svg/ea_emitter/markers.rb
485
508
  - lib/ea/svg/element_box.rb
486
509
  - lib/ea/svg/renderer.rb
510
+ - lib/ea/svg/stereotype_color_resolver.rb
487
511
  - lib/ea/svg/style_resolver.rb
488
512
  - lib/ea/transformations.rb
489
513
  - lib/ea/transformations/configuration.rb