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,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
@@ -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
@@ -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 a hash the
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 }.reject { |_, v| v.nil? || v.empty? }
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
@@ -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 <polyline> through
6
- # its waypoints. Arrowheads and per-direction routing are
7
- # future work; today we emit a straight-segment polyline
8
- # matching the source layout.
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
- attr_reader :connector
10
+ ARROW_SIZE = 8
11
11
 
12
- def initialize(connector)
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.empty?
21
+ return "" if points.size < 2
22
+
23
+ path_d = build_path_d(points)
24
+ style = StyleResolver.new(connector.style)
19
25
 
20
- <<~SVG.chomp
21
- <polyline points="#{points.join(' ')}"
22
- fill="none" stroke="#{stroke_color}"
23
- stroke-width="#{stroke_width}"/>
24
- SVG
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
- "#{wp.position.x},#{wp.position.y}"
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 stroke_color
38
- StyleResolver.new(connector.style).stroke_color
68
+ def render_target_marker(points)
69
+ filled_arrow(points.last, points[-2])
39
70
  end
40
71
 
41
- def stroke_width
42
- StyleResolver.new(connector.style).stroke_width
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("\"", "&quot;")
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("&", "&amp;").gsub("<", "&lt;").gsub(">", "&gt;").gsub("\"", "&quot;")
165
+ end
166
+ end
167
+ end
168
+ end
169
+ end