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.
- 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/qea/diagram_builder.rb +103 -19
- 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/spa/configuration.rb +17 -3
- data/lib/ea/spa/projector.rb +6 -1
- data/lib/ea/svg/connector_path.rb +109 -17
- 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/element_box.rb +202 -17
- data/lib/ea/svg/renderer.rb +8 -1
- 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,78 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ea
|
|
4
|
+
module Sources
|
|
5
|
+
module Xmi
|
|
6
|
+
# Parses EA's packed style string from
|
|
7
|
+
# <xmi:Extension>/<elements>/<element>/@style. Returns a typed
|
|
8
|
+
# Style struct so renderers consume typed fields.
|
|
9
|
+
module ExtensionStyleParser
|
|
10
|
+
module_function
|
|
11
|
+
|
|
12
|
+
def parse(style_string)
|
|
13
|
+
return Style.new if style_string.nil? || style_string.empty?
|
|
14
|
+
|
|
15
|
+
kv = split_pairs(style_string)
|
|
16
|
+
Style.new(
|
|
17
|
+
background_color: int(kv["BCol"]),
|
|
18
|
+
line_color: int(kv["LCol"]),
|
|
19
|
+
line_width: int(kv["LWth"] || kv["LWidth"]),
|
|
20
|
+
font_family: kv["font"],
|
|
21
|
+
font_size: scale_font_size(kv["fontsz"]),
|
|
22
|
+
bold: truthy?(kv["bold"]),
|
|
23
|
+
italic: truthy?(kv["italic"]),
|
|
24
|
+
underline: truthy?(kv["ul"]),
|
|
25
|
+
black: truthy?(kv["black"]),
|
|
26
|
+
hide_icon: truthy?(kv["HideIcon"]),
|
|
27
|
+
duid: kv["DUID"],
|
|
28
|
+
soid: kv["SOID"],
|
|
29
|
+
eoid: kv["EOID"],
|
|
30
|
+
mode: int(kv["Mode"]),
|
|
31
|
+
color: int(kv["Color"]),
|
|
32
|
+
hidden: truthy?(kv["Hidden"]),
|
|
33
|
+
raw: style_string
|
|
34
|
+
)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def split_pairs(str)
|
|
38
|
+
str.split(";").filter_map do |pair|
|
|
39
|
+
next nil unless pair.include?("=")
|
|
40
|
+
|
|
41
|
+
key, value = pair.split("=", 2)
|
|
42
|
+
[key, value] if key && value && !value.empty?
|
|
43
|
+
end.to_h
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def int(value)
|
|
47
|
+
return nil if value.nil? || value.empty?
|
|
48
|
+
|
|
49
|
+
Integer(value)
|
|
50
|
+
rescue ArgumentError
|
|
51
|
+
nil
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def truthy?(value)
|
|
55
|
+
value == "1" || value == "true" || value == "-1"
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def scale_font_size(value)
|
|
59
|
+
return 13 if value.nil? || value.empty?
|
|
60
|
+
|
|
61
|
+
pct = int(value)
|
|
62
|
+
return 13 if pct.nil?
|
|
63
|
+
|
|
64
|
+
(pct * 13 / 100).round
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
Style = Struct.new(
|
|
68
|
+
:background_color, :line_color, :line_width,
|
|
69
|
+
:font_family, :font_size,
|
|
70
|
+
:bold, :italic, :underline, :black, :hide_icon,
|
|
71
|
+
:duid, :soid, :eoid, :mode, :color, :hidden,
|
|
72
|
+
:raw,
|
|
73
|
+
keyword_init: true
|
|
74
|
+
)
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
data/lib/ea/sources/xmi.rb
CHANGED
|
@@ -23,6 +23,9 @@ module Ea
|
|
|
23
23
|
autoload :RelationshipBuilder, "ea/sources/xmi/relationship_builder"
|
|
24
24
|
autoload :AnnotationBuilder, "ea/sources/xmi/annotation_builder"
|
|
25
25
|
autoload :DiagramBuilder, "ea/sources/xmi/diagram_builder"
|
|
26
|
+
autoload :ExtensionElements, "ea/sources/xmi/extension_elements"
|
|
27
|
+
autoload :ExtensionGeometryParser, "ea/sources/xmi/extension_geometry_parser"
|
|
28
|
+
autoload :ExtensionStyleParser, "ea/sources/xmi/extension_style_parser"
|
|
26
29
|
autoload :Adapter, "ea/sources/xmi/adapter"
|
|
27
30
|
end
|
|
28
31
|
end
|
data/lib/ea/spa/configuration.rb
CHANGED
|
@@ -51,6 +51,17 @@ module Ea
|
|
|
51
51
|
@data["appearance"] || {}
|
|
52
52
|
end
|
|
53
53
|
|
|
54
|
+
# Diagram rendering control. Default: true (preserve historical
|
|
55
|
+
# behavior). Set `diagrams.enabled: false` in the YAML to skip
|
|
56
|
+
# diagram shards entirely — useful when the renderer output is
|
|
57
|
+
# not yet production-quality and you'd rather hide diagrams
|
|
58
|
+
# than ship broken visuals.
|
|
59
|
+
def render_diagrams?
|
|
60
|
+
diagrams_cfg = @data["diagrams"].is_a?(Hash) ? @data["diagrams"] : {}
|
|
61
|
+
enabled = diagrams_cfg.key?("enabled") ? diagrams_cfg["enabled"] : true
|
|
62
|
+
enabled != false
|
|
63
|
+
end
|
|
64
|
+
|
|
54
65
|
# Apply overrides to a model Metadata instance, returning a new
|
|
55
66
|
# one. The original is left untouched.
|
|
56
67
|
def apply_to_metadata(model_metadata)
|
|
@@ -61,10 +72,13 @@ module Ea
|
|
|
61
72
|
hash_to_metadata(merged)
|
|
62
73
|
end
|
|
63
74
|
|
|
64
|
-
# Surface the relevant config (ui, appearance) into
|
|
65
|
-
# SPA skeleton embeds in its metadata payload
|
|
75
|
+
# Surface the relevant config (ui, appearance, diagrams) into
|
|
76
|
+
# a hash the SPA skeleton embeds in its metadata payload so
|
|
77
|
+
# the frontend can hide its diagram UI when disabled.
|
|
66
78
|
def view_extras
|
|
67
|
-
{ "ui" => ui, "appearance" => appearance }
|
|
79
|
+
extras = { "ui" => ui, "appearance" => appearance }
|
|
80
|
+
extras["diagrams"] = { "enabled" => render_diagrams? }
|
|
81
|
+
extras.reject { |_, v| v.nil? || v.empty? }
|
|
68
82
|
end
|
|
69
83
|
|
|
70
84
|
private
|
data/lib/ea/spa/projector.rb
CHANGED
|
@@ -43,12 +43,17 @@ module Ea
|
|
|
43
43
|
end
|
|
44
44
|
|
|
45
45
|
# Enumerate every (id, kind, shard) triple the SPA can address.
|
|
46
|
+
# Diagrams are skipped when the configuration disables them.
|
|
46
47
|
def each_shard
|
|
47
48
|
return enum_for(:each_shard) unless block_given?
|
|
48
49
|
|
|
49
50
|
document.classifiers.each { |c| yield shard_for(c) }
|
|
50
51
|
document.packages.each { |p| yield shard_for(p) }
|
|
51
|
-
document.diagrams.each { |d| yield shard_for(d) }
|
|
52
|
+
document.diagrams.each { |d| yield shard_for(d) } if render_diagrams?
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def render_diagrams?
|
|
56
|
+
configuration ? configuration.render_diagrams? : true
|
|
52
57
|
end
|
|
53
58
|
|
|
54
59
|
private
|
|
@@ -2,26 +2,34 @@
|
|
|
2
2
|
|
|
3
3
|
module Ea
|
|
4
4
|
module Svg
|
|
5
|
-
# Renders one DiagramConnector as an SVG <
|
|
6
|
-
#
|
|
7
|
-
#
|
|
8
|
-
#
|
|
5
|
+
# Renders one DiagramConnector as an SVG <path> through its
|
|
6
|
+
# waypoints, with an arrowhead at the target end. Matches EA's
|
|
7
|
+
# convention: filled triangle for navigable associations, filled
|
|
8
|
+
# diamond for aggregations, open diamond for shared aggregations.
|
|
9
9
|
class ConnectorPath
|
|
10
|
-
|
|
10
|
+
ARROW_SIZE = 8
|
|
11
11
|
|
|
12
|
-
|
|
12
|
+
attr_reader :connector, :relationship
|
|
13
|
+
|
|
14
|
+
def initialize(connector, relationship: nil)
|
|
13
15
|
@connector = connector
|
|
16
|
+
@relationship = relationship
|
|
14
17
|
end
|
|
15
18
|
|
|
16
19
|
def render
|
|
17
20
|
points = waypoints
|
|
18
|
-
return "" if points.
|
|
21
|
+
return "" if points.size < 2
|
|
22
|
+
|
|
23
|
+
path_d = build_path_d(points)
|
|
24
|
+
style = StyleResolver.new(connector.style)
|
|
19
25
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
26
|
+
parts = []
|
|
27
|
+
parts << %(<g class="connector" data-connector-id="#{escape(connector.id)}">)
|
|
28
|
+
parts << %( <path d="#{path_d}" stroke="#{style.stroke_color}" stroke-width="#{style.stroke_width}" fill="none"/>)
|
|
29
|
+
parts << render_source_marker(points) if source_marker?
|
|
30
|
+
parts << render_target_marker(points)
|
|
31
|
+
parts << %(</g>)
|
|
32
|
+
parts.join("\n ")
|
|
25
33
|
end
|
|
26
34
|
|
|
27
35
|
private
|
|
@@ -30,16 +38,100 @@ module Ea
|
|
|
30
38
|
connector.waypoints.filter_map do |wp|
|
|
31
39
|
next unless wp.position
|
|
32
40
|
|
|
33
|
-
|
|
41
|
+
[wp.position.x, wp.position.y]
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def build_path_d(points)
|
|
46
|
+
moves = points.each_with_index.map do |p, idx|
|
|
47
|
+
prefix = idx.zero? ? "M" : "L"
|
|
48
|
+
"#{prefix} #{p[0]} #{p[1]}"
|
|
49
|
+
end
|
|
50
|
+
moves.join(" ")
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def source_marker?
|
|
54
|
+
return false unless relationship
|
|
55
|
+
|
|
56
|
+
relationship.is_a?(Ea::Model::Association) &&
|
|
57
|
+
relationship.source_aggregation != "none"
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def render_source_marker(points)
|
|
61
|
+
case relationship.source_aggregation
|
|
62
|
+
when "composite" then filled_diamond(points.first, points[1])
|
|
63
|
+
when "shared" then open_diamond(points.first, points[1])
|
|
64
|
+
else ""
|
|
34
65
|
end
|
|
35
66
|
end
|
|
36
67
|
|
|
37
|
-
def
|
|
38
|
-
|
|
68
|
+
def render_target_marker(points)
|
|
69
|
+
filled_arrow(points.last, points[-2])
|
|
39
70
|
end
|
|
40
71
|
|
|
41
|
-
def
|
|
42
|
-
|
|
72
|
+
def filled_arrow(tip, base)
|
|
73
|
+
return "" unless tip && base
|
|
74
|
+
|
|
75
|
+
# Build a triangle pointing from base toward tip
|
|
76
|
+
bx, by = base
|
|
77
|
+
tx, ty = tip
|
|
78
|
+
# Perpendicular vector for the wings
|
|
79
|
+
dx = tx - bx
|
|
80
|
+
dy = ty - by
|
|
81
|
+
len = Math.sqrt(dx * dx + dy * dy)
|
|
82
|
+
return "" if len.zero?
|
|
83
|
+
|
|
84
|
+
ux = dx / len
|
|
85
|
+
uy = dy / len
|
|
86
|
+
# Wing points are ARROW_SIZE back from tip, perpendicular
|
|
87
|
+
back_x = tx - ux * ARROW_SIZE
|
|
88
|
+
back_y = ty - uy * ARROW_SIZE
|
|
89
|
+
perp_x = -uy * (ARROW_SIZE / 2.0)
|
|
90
|
+
perp_y = ux * (ARROW_SIZE / 2.0)
|
|
91
|
+
w1_x = back_x + perp_x
|
|
92
|
+
w1_y = back_y + perp_y
|
|
93
|
+
w2_x = back_x - perp_x
|
|
94
|
+
w2_y = back_y - perp_y
|
|
95
|
+
points_str = "#{tx} #{ty} #{w1_x.round(1)} #{w1_y.round(1)} #{w2_x.round(1)} #{w2_y.round(1)}"
|
|
96
|
+
%( <polygon points="#{points_str}" fill="black" stroke="black" stroke-width="1"/>)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def filled_diamond(tip, base)
|
|
100
|
+
diamond_polygon(tip, base, fill: "black")
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def open_diamond(tip, base)
|
|
104
|
+
diamond_polygon(tip, base, fill: "white")
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def diamond_polygon(tip, base, fill:)
|
|
108
|
+
bx, by = base
|
|
109
|
+
tx, ty = tip
|
|
110
|
+
dx = tx - bx
|
|
111
|
+
dy = ty - by
|
|
112
|
+
len = Math.sqrt(dx * dx + dy * dy)
|
|
113
|
+
return "" if len.zero?
|
|
114
|
+
|
|
115
|
+
ux = dx / len
|
|
116
|
+
uy = dy / len
|
|
117
|
+
back_x = tx - ux * ARROW_SIZE
|
|
118
|
+
back_y = ty - uy * ARROW_SIZE
|
|
119
|
+
perp_x = -uy * (ARROW_SIZE / 2.0)
|
|
120
|
+
perp_y = ux * (ARROW_SIZE / 2.0)
|
|
121
|
+
far_x = tx - ux * (2 * ARROW_SIZE)
|
|
122
|
+
far_y = ty - uy * (2 * ARROW_SIZE)
|
|
123
|
+
w1_x = back_x + perp_x
|
|
124
|
+
w1_y = back_y + perp_y
|
|
125
|
+
w2_x = back_x - perp_x
|
|
126
|
+
w2_y = back_y - perp_y
|
|
127
|
+
points_str = "#{far_x.round(1)} #{far_y.round(1)} #{w1_x.round(1)} #{w1_y.round(1)} #{tx} #{ty} #{w2_x.round(1)} #{w2_y.round(1)}"
|
|
128
|
+
%( <polygon points="#{points_str}" fill="#{fill}" stroke="black" stroke-width="1"/>)
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def escape(text)
|
|
132
|
+
return "" if text.nil?
|
|
133
|
+
|
|
134
|
+
text.to_s.gsub("\"", """)
|
|
43
135
|
end
|
|
44
136
|
end
|
|
45
137
|
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ea
|
|
4
|
+
module Svg
|
|
5
|
+
module EaEmitter
|
|
6
|
+
# Emits the background layer: white rect over the full canvas.
|
|
7
|
+
module Background
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
def render(canvas)
|
|
11
|
+
%(<g style="fill:#FFFFFF;fill-opacity:1.00;">\n <rect x="#{canvas.min_x}" y="#{canvas.min_y}" width="#{canvas.width}" height="#{canvas.height}" shape-rendering="auto"/>\n</g>)
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -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
|