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.
- checksums.yaml +4 -4
- data/TODO.diagrams/01-xmi-extension-elements-parsing.md +67 -0
- data/TODO.diagrams/02-diagram-element-rich-model.md +61 -0
- data/TODO.diagrams/03-ea-style-svg-emitter.md +70 -0
- data/TODO.diagrams/04-connector-routing-from-soid-eoid.md +55 -0
- data/TODO.diagrams/05-marker-rendering-per-relationship-type.md +49 -0
- data/TODO.diagrams/06-connector-labels.md +49 -0
- data/TODO.diagrams/07-canvas-size-and-viewbox.md +39 -0
- data/TODO.diagrams/08-visual-regression-harness.md +34 -0
- data/TODO.diagrams/09-cli-render-all-diagrams.md +25 -0
- data/TODO.diagrams/10-stereotype-color-config.md +29 -0
- data/TODO.diagrams/11-elkrb-auto-layout-integration.md +53 -0
- data/config/stereotype_colors.yml +15 -0
- data/lib/ea/cli/app.rb +7 -1
- data/lib/ea/cli/command/svg.rb +50 -6
- data/lib/ea/model/diagram_connector.rb +19 -1
- data/lib/ea/model/diagram_element.rb +28 -0
- data/lib/ea/sources/xmi/annotation_builder.rb +1 -1
- data/lib/ea/sources/xmi/diagram_builder.rb +164 -44
- data/lib/ea/sources/xmi/extension_elements.rb +74 -0
- data/lib/ea/sources/xmi/extension_geometry_parser.rb +107 -0
- data/lib/ea/sources/xmi/extension_style_parser.rb +78 -0
- data/lib/ea/sources/xmi.rb +3 -0
- data/lib/ea/svg/ea_emitter/background.rb +16 -0
- data/lib/ea/svg/ea_emitter/canvas.rb +65 -0
- data/lib/ea/svg/ea_emitter/connectors.rb +50 -0
- data/lib/ea/svg/ea_emitter/document.rb +44 -0
- data/lib/ea/svg/ea_emitter/elements.rb +169 -0
- data/lib/ea/svg/ea_emitter/labels.rb +64 -0
- data/lib/ea/svg/ea_emitter/markers.rb +117 -0
- data/lib/ea/svg/ea_emitter.rb +29 -0
- data/lib/ea/svg/stereotype_color_resolver.rb +50 -0
- data/lib/ea/svg.rb +2 -0
- data/lib/ea/version.rb +1 -1
- 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("&", "&").gsub("<", "<").gsub(">", ">").gsub("\"", """)
|
|
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("&", "&").gsub("<", "<").gsub(">", ">").gsub("\"", """)
|
|
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
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.
|
|
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-
|
|
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
|