ea 0.3.1 → 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 (35) 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/xmi/annotation_builder.rb +1 -1
  19. data/lib/ea/sources/xmi/diagram_builder.rb +164 -44
  20. data/lib/ea/sources/xmi/extension_elements.rb +74 -0
  21. data/lib/ea/sources/xmi/extension_geometry_parser.rb +107 -0
  22. data/lib/ea/sources/xmi/extension_style_parser.rb +78 -0
  23. data/lib/ea/sources/xmi.rb +3 -0
  24. data/lib/ea/svg/ea_emitter/background.rb +16 -0
  25. data/lib/ea/svg/ea_emitter/canvas.rb +65 -0
  26. data/lib/ea/svg/ea_emitter/connectors.rb +50 -0
  27. data/lib/ea/svg/ea_emitter/document.rb +44 -0
  28. data/lib/ea/svg/ea_emitter/elements.rb +169 -0
  29. data/lib/ea/svg/ea_emitter/labels.rb +64 -0
  30. data/lib/ea/svg/ea_emitter/markers.rb +117 -0
  31. data/lib/ea/svg/ea_emitter.rb +29 -0
  32. data/lib/ea/svg/stereotype_color_resolver.rb +50 -0
  33. data/lib/ea/svg.rb +2 -0
  34. data/lib/ea/version.rb +1 -1
  35. metadata +26 -2
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ea
4
+ module Svg
5
+ module EaEmitter
6
+ # Computes the canvas dimensions for the root <svg> element.
7
+ # Union of all element image_bounds (the padded bounds EA
8
+ # uses for the visual box), with a 10 px outer margin.
9
+ # Emits cm dimensions (px / 28.346 at 72 DPI).
10
+ Canvas = Struct.new(:min_x, :min_y, :width, :height, keyword_init: true) do
11
+ PX_PER_CM = 28.3464567
12
+
13
+ def self.from(diagram)
14
+ points = []
15
+ (diagram.elements || []).each do |e|
16
+ b = e.image_bounds || e.bounds
17
+ next unless b
18
+
19
+ points << [b.x, b.y]
20
+ points << [b.x + b.width, b.y + b.height]
21
+ end
22
+ (diagram.connectors || []).each do |c|
23
+ (c.waypoints || []).each do |wp|
24
+ next unless wp.position
25
+
26
+ points << [wp.position.x, wp.position.y]
27
+ end
28
+ end
29
+ return new(min_x: 0, min_y: 0, width: 1, height: 1) if points.empty?
30
+
31
+ xs = points.map(&:first)
32
+ ys = points.map(&:last)
33
+ margin = 10
34
+ new(
35
+ min_x: (xs.min || 0) - margin,
36
+ min_y: (ys.min || 0) - margin,
37
+ width: (xs.max - xs.min) + (2 * margin),
38
+ height: (ys.max - ys.min) + (2 * margin)
39
+ )
40
+ end
41
+
42
+ def view_box
43
+ "#{min_x} #{min_y} #{width} #{height}"
44
+ end
45
+
46
+ def width_cm
47
+ format_cm(width)
48
+ end
49
+
50
+ def height_cm
51
+ format_cm(height)
52
+ end
53
+
54
+ private
55
+
56
+ def format_cm(px)
57
+ return "0cm" if px.nil? || px.zero?
58
+
59
+ cm = (px / PX_PER_CM.to_f)
60
+ format("%.2fcm", cm)
61
+ end
62
+ end
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ea
4
+ module Svg
5
+ module EaEmitter
6
+ # Emits the connectors layer: for each DiagramConnector, emits
7
+ # one <g> containing the <path d="M x y L x2 y2..."/> through
8
+ # its waypoints. Markers (arrowheads, diamonds) are emitted
9
+ # separately by the Markers emitter.
10
+ class Connectors
11
+ attr_reader :diagram
12
+
13
+ def initialize(diagram)
14
+ @diagram = diagram
15
+ end
16
+
17
+ def render
18
+ paths = visible_connectors.filter_map { |c| path_for(c) }
19
+ return "" if paths.empty?
20
+
21
+ %(<g style="stroke-width:2;stroke-linecap:round;stroke-linejoin:bevel; fill:#000000;fill-opacity:0.00; stroke:#000000; stroke-opacity:1.00">\n#{paths.join("\n")}\n</g>)
22
+ end
23
+
24
+ private
25
+
26
+ def visible_connectors
27
+ (diagram.connectors || []).reject(&:hidden)
28
+ end
29
+
30
+ def path_for(connector)
31
+ pts = waypoints_for(connector)
32
+ return nil if pts.size < 2
33
+
34
+ d = pts.each_with_index.map do |p, idx|
35
+ "#{idx.zero? ? 'M' : 'L'} #{p[0]} #{p[1]}"
36
+ end.join(" ")
37
+ %( <path d="#{d}" shape-rendering="auto"/>)
38
+ end
39
+
40
+ def waypoints_for(connector)
41
+ (connector.waypoints || []).filter_map do |wp|
42
+ next unless wp.position
43
+
44
+ [wp.position.x, wp.position.y]
45
+ end
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ea
4
+ module Svg
5
+ module EaEmitter
6
+ # Top-level orchestrator: emits a complete standalone SVG
7
+ # document matching EA's structure. Calls the layer emitters
8
+ # in EA's canonical order.
9
+ class Document
10
+ BUILD_ID = "1628"
11
+
12
+ attr_reader :diagram, :model_index, :options
13
+
14
+ def initialize(diagram, model_index:, **_options)
15
+ @diagram = diagram
16
+ @model_index = model_index
17
+ end
18
+
19
+ def render
20
+ canvas = Canvas.from(diagram)
21
+ layers = [
22
+ Background.render(canvas),
23
+ Elements.new(diagram, model_index: model_index).render,
24
+ Connectors.new(diagram).render,
25
+ Markers.new(diagram, model_index: model_index).render,
26
+ Labels.new(diagram).render
27
+ ].reject { |s| s.nil? || s.empty? }
28
+
29
+ <<~SVG
30
+ <?xml version="1.0" encoding="UTF-8" standalone="no"?>
31
+ <!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.0//EN" "http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
32
+
33
+ <svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="#{canvas.width_cm}" height="#{canvas.height_cm}" viewBox="#{canvas.view_box}">
34
+ <title></title>
35
+ <desc>Created with Enterprise Architect (Build: #{BUILD_ID}) 2</desc>
36
+
37
+ #{layers.join("\n\n")}
38
+ </svg>
39
+ SVG
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,169 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ea
4
+ module Svg
5
+ module EaEmitter
6
+ # Emits the elements layer: for each DiagramElement on the
7
+ # diagram, emits the EA-shape group (filled rect) plus the
8
+ # EA-text group (stereotype + name + attributes + operations).
9
+ #
10
+ # Each element is rendered in z_order (ascending). Output
11
+ # follows EA's pattern of layered groups per element:
12
+ #
13
+ # <g style="fill:COLOR;..."><rect x y w h/></g>
14
+ # <g style="fill:#000000;..."><text>...</text></g>
15
+ # <g style="..."><path d="M x y L x2 y2"/></g> (divider)
16
+ # <g style="..."><text>attr1</text>...</g>
17
+ class Elements
18
+ DEFAULT_FILL = "#FFFFFF"
19
+ DEFAULT_STROKE = "#000000"
20
+ DEFAULT_FONT_FAMILY = "Calibri"
21
+ DEFAULT_FONT_SIZE = 13
22
+
23
+ attr_reader :diagram, :model_index
24
+
25
+ def initialize(diagram, model_index:)
26
+ @diagram = diagram
27
+ @model_index = model_index
28
+ end
29
+
30
+ def render
31
+ ordered_elements.map { |e| render_one(e) }.join("\n")
32
+ end
33
+
34
+ private
35
+
36
+ def ordered_elements
37
+ (diagram.elements || []).sort_by { |e| e.z_order || 0 }
38
+ end
39
+
40
+ def render_one(element)
41
+ bounds = element.image_bounds || element.bounds
42
+ return "" unless bounds
43
+
44
+ classifier = model_element_for(element)
45
+ fill = color_from_ea(element.background_color) || fill_for_classifier(classifier)
46
+ stroke = color_from_ea(element.line_color) || DEFAULT_STROKE
47
+ stroke_width = element.line_width || 2
48
+
49
+ parts = []
50
+ parts << render_shape_group(bounds, fill, stroke, stroke_width)
51
+ parts << render_header_text(element, bounds, classifier)
52
+ parts << render_divider(bounds, stroke, stroke_width) if classifier
53
+ parts << render_attribute_text(element, bounds, classifier) if classifier
54
+ parts.compact.join("\n")
55
+ end
56
+
57
+ def render_shape_group(bounds, fill, stroke, stroke_width)
58
+ %(<g style="stroke-width:#{stroke_width};stroke-linecap:round;stroke-linejoin:bevel; fill:#{fill};fill-opacity:1.00; stroke:#{stroke}; stroke-opacity:1.00">\n <rect x="#{bounds.x}" y="#{bounds.y}" width="#{bounds.width}" height="#{bounds.height}" rx="0.00" shape-rendering="auto" />\n</g>)
59
+ end
60
+
61
+ def render_header_text(element, bounds, classifier)
62
+ return "" unless classifier
63
+
64
+ lines = header_lines(classifier)
65
+ return "" if lines.empty?
66
+
67
+ family = element.font_family || DEFAULT_FONT_FAMILY
68
+ size = element.font_size || DEFAULT_FONT_SIZE
69
+ text_blocks = lines.each_with_index.map do |(text, style), idx|
70
+ weight = style == :bold ? 700 : 400
71
+ font_style = style == :italic ? "italic" : "normal"
72
+ y = bounds.y + 17 + (idx * 17)
73
+ %( <text x="#{bounds.x + (bounds.width / 2.0)}" y="#{y}" textLength="#{text.length * 7}" style="font-family:#{family}; font-weight:#{weight}; font-style:#{font_style}; font-size:#{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>)
74
+ end
75
+ %(<g style="stroke-width:1;stroke-linecap:round;stroke-linejoin:bevel; fill:#000000;fill-opacity:1.00; stroke:#000000; stroke-opacity:0.00">\n#{text_blocks.join("\n")}\n</g>)
76
+ end
77
+
78
+ def render_divider(bounds, stroke, stroke_width)
79
+ y = bounds.y + 50
80
+ %(<g style="stroke-width:#{stroke_width};stroke-linecap:round;stroke-linejoin:bevel; fill:#000000;fill-opacity:0.00; stroke:#{stroke}; stroke-opacity:1.00">\n <path d="M #{bounds.x} #{y} L #{bounds.x + bounds.width} #{y}" shape-rendering="auto"/>\n</g>)
81
+ end
82
+
83
+ def render_attribute_text(element, bounds, classifier)
84
+ return "" unless classifier.properties || classifier.operations
85
+
86
+ attr_lines = (classifier.properties || []).map do |p|
87
+ "+ #{p.name}: #{p.type_name || ''} #{mult_text(p)}"
88
+ end
89
+ return "" if attr_lines.empty?
90
+
91
+ family = element.font_family || DEFAULT_FONT_FAMILY
92
+ size = element.font_size || DEFAULT_FONT_SIZE
93
+ text_blocks = attr_lines.each_with_index.map do |line, idx|
94
+ y = bounds.y + 68 + (idx * 17)
95
+ %( <text x="#{bounds.x + 5}" y="#{y}" textLength="#{line.length * 7}" style="font-family:#{family}; font-weight:400; font-style:normal; font-size:#{size}px; fill:#000000;fill-opacity:1.00; stroke:#000000; stroke-opacity:0.00 stroke-width:0; white-space: pre;" xml:space="preserve">#{escape(line.strip)}</text>)
96
+ end
97
+ %(<g style="stroke-width:1;stroke-linecap:round;stroke-linejoin:bevel; fill:#000000;fill-opacity:1.00; stroke:#000000; stroke-opacity:0.00">\n#{text_blocks.join("\n")}\n</g>)
98
+ end
99
+
100
+ def header_lines(classifier)
101
+ lines = []
102
+ if classifier.is_a?(Ea::Model::Klass) && classifier.is_abstract
103
+ lines << ["_#{classifier.name}", :italic]
104
+ elsif classifier.stereotype_refs&.any?
105
+ lines << ["«#{classifier.stereotype_refs.first}»", :normal]
106
+ lines << [(classifier.qualified_name || classifier.name).to_s, :bold]
107
+ else
108
+ lines << [(classifier.qualified_name || classifier.name).to_s, :bold]
109
+ end
110
+ lines
111
+ end
112
+
113
+ def mult_text(p)
114
+ lower = p.multiplicity_lower
115
+ upper = p.multiplicity_upper
116
+ return "" if lower.nil? && upper.nil?
117
+ return "" if lower == 1 && upper == 1
118
+
119
+ "[#{lower || 0}..#{upper == -1 ? "*" : upper}]"
120
+ end
121
+
122
+ def fill_for_classifier(classifier)
123
+ return DEFAULT_FILL unless classifier
124
+
125
+ stereotype = primary_stereotype(classifier)
126
+ return DEFAULT_FILL unless stereotype
127
+
128
+ stereotype_color_resolver.fill_for(stereotype)
129
+ end
130
+
131
+ def stereotype_color_resolver
132
+ @stereotype_color_resolver ||= Ea::Svg::StereotypeColorResolver.new
133
+ end
134
+
135
+ def primary_stereotype(classifier)
136
+ refs = classifier.stereotype_refs
137
+ return nil if refs.nil? || refs.empty?
138
+
139
+ refs.first.to_s.split("::").last
140
+ end
141
+
142
+ def model_element_for(element)
143
+ ref = element.model_element_ref
144
+ return nil unless ref
145
+
146
+ candidate = model_index[ref]
147
+ return nil unless candidate.is_a?(Ea::Model::Classifier)
148
+
149
+ candidate
150
+ end
151
+
152
+ def color_from_ea(bgr_int)
153
+ return nil if bgr_int.nil?
154
+
155
+ r = bgr_int & 0xff
156
+ g = (bgr_int >> 8) & 0xff
157
+ b = (bgr_int >> 16) & 0xff
158
+ format("#%02X%02X%02X", r, g, b)
159
+ end
160
+
161
+ def escape(text)
162
+ return "" if text.nil?
163
+
164
+ text.to_s.gsub("&", "&amp;").gsub("<", "&lt;").gsub(">", "&gt;").gsub("\"", "&quot;")
165
+ end
166
+ end
167
+ end
168
+ end
169
+ end
@@ -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
@@ -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.1"
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.1
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-20 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