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
@@ -5,15 +5,25 @@ module Ea
5
5
  # A connector drawn on a diagram between two DiagramElements.
6
6
  # References the underlying Relationship (by id) and the source
7
7
  # and target DiagramElements (by id). Carries ordered waypoints
8
- # for routing.
8
+ # for routing plus the EA EDGE codes (1=top, 2=right, 3=bottom,
9
+ # 4=left, 5..8=diagonal variants) which tell the renderer where
10
+ # to anchor the line on each end.
9
11
  class DiagramConnector < Base
10
12
  attribute :diagram_id, :string
11
13
  attribute :relationship_ref, :string
12
14
  attribute :source_element_ref, :string
13
15
  attribute :target_element_ref, :string
16
+ attribute :source_duid, :string
17
+ attribute :target_duid, :string
18
+ attribute :source_edge, :integer
19
+ attribute :target_edge, :integer
14
20
  attribute :waypoints, Waypoint, collection: true, initialize_empty: true
15
21
  attribute :label, :string
16
22
  attribute :style, :hash, default: -> { {} }
23
+ attribute :line_color, :integer
24
+ attribute :line_width, :integer
25
+ attribute :hidden, :boolean
26
+ attribute :label_boxes, :hash, default: -> { {} }
17
27
 
18
28
  json do
19
29
  map "id", to: :id
@@ -21,9 +31,17 @@ module Ea
21
31
  map "relationshipRef", to: :relationship_ref
22
32
  map "sourceElementRef", to: :source_element_ref
23
33
  map "targetElementRef", to: :target_element_ref
34
+ map "sourceDuid", to: :source_duid
35
+ map "targetDuid", to: :target_duid
36
+ map "sourceEdge", to: :source_edge
37
+ map "targetEdge", to: :target_edge
24
38
  map "waypoints", to: :waypoints, render_empty: true
25
39
  map "label", to: :label
26
40
  map "style", to: :style
41
+ map "lineColor", to: :line_color
42
+ map "lineWidth", to: :line_width
43
+ map "hidden", to: :hidden, render_default: true
44
+ map "labelBoxes", to: :label_boxes
27
45
  end
28
46
  end
29
47
  end
@@ -5,18 +5,46 @@ module Ea
5
5
  # A single placed element on a diagram. References the model
6
6
  # element it visualizes (by id), carries pixel bounds and a
7
7
  # parsed style map (fill color, line color, font, etc.).
8
+ #
9
+ # Carries two distinct bounds:
10
+ # - `bounds` — logical rect (EA's Left/Top/Right/Bottom)
11
+ # - `image_bounds` — visual rect including padding (EA's
12
+ # imgL/imgT/imgR/imgB); used by EA's SVG renderer for the
13
+ # actual `<rect>` width/height
8
14
  class DiagramElement < Base
9
15
  attribute :diagram_id, :string
10
16
  attribute :model_element_ref, :string # id of Classifier/Package/etc.
11
17
  attribute :bounds, Bounds
18
+ attribute :image_bounds, Bounds
12
19
  attribute :style, :hash, default: -> { {} } # parsed style fields
20
+ attribute :background_color, :integer # EA BGR integer
21
+ attribute :line_color, :integer
22
+ attribute :line_width, :integer
23
+ attribute :font_family, :string
24
+ attribute :font_size, :integer
25
+ attribute :font_bold, :boolean
26
+ attribute :font_italic, :boolean
27
+ attribute :font_underline, :boolean
28
+ attribute :z_order, :integer # seqno
29
+ attribute :duid, :string # EA's per-placement DUID for connector resolution
13
30
 
14
31
  json do
15
32
  map "id", to: :id
16
33
  map "diagramId", to: :diagram_id
17
34
  map "modelElementRef", to: :model_element_ref
18
35
  map "bounds", to: :bounds
36
+ map "imageBounds", to: :image_bounds
19
37
  map "style", to: :style
38
+ map "backgroundColor", to: :background_color
39
+ map "lineColor", to: :line_color
40
+ map "lineWidth", to: :line_width
41
+ map "fontFamily", to: :font_family
42
+ map "fontSize", to: :font_size
43
+ map "fontBold", to: :font_bold, render_default: true
44
+ map "fontItalic", to: :font_italic, render_default: true
45
+ map "fontUnderline", to: :font_underline, render_default: true
46
+ map "zOrder", to: :z_order
47
+ map "duid", to: :duid
20
48
  end
21
49
  end
22
50
  end
@@ -63,11 +63,113 @@ module Ea
63
63
  link_row.instance_id),
64
64
  diagram_id: IdNormalizer.from_guid(diagram_row.ea_guid),
65
65
  relationship_ref: ref_for_connector(link_row),
66
- waypoints: parse_waypoints(link_row.geometry),
66
+ waypoints: waypoints_for_link(link_row, diagram_row),
67
67
  style: DiagramStyleParser.parse(link_row.style)
68
68
  )
69
69
  end
70
70
 
71
+ # EA's t_diagramlinks.Geometry field stores connector routing
72
+ # in a packed format. The leading "X1,Y1,X2,Y2" pair (when
73
+ # present) is in a coordinate frame that does NOT match the
74
+ # element rect frame (elements use RectTop < RectBottom with
75
+ # negative y; geometry uses positive y). Rather than trust
76
+ # the stored absolute coords, we compute the polyline from
77
+ # the actual placed-element bounds + the SX/SY/EX/EY delta
78
+ # fields, which describe the bend routing relative to the
79
+ # element edges.
80
+ #
81
+ # If we can't resolve the source or target element placement
82
+ # for this diagram, we emit no waypoints (the connector will
83
+ # be invisible) — better than drawing garbage lines.
84
+ def waypoints_for_link(link_row, diagram_row)
85
+ connector = database.find_connector(link_row.connectorid)
86
+ return [] unless connector
87
+
88
+ source_placement = diagram_object_placement(diagram_row.diagram_id,
89
+ connector.start_object_id)
90
+ target_placement = diagram_object_placement(diagram_row.diagram_id,
91
+ connector.end_object_id)
92
+ return [] unless source_placement && target_placement
93
+
94
+ geom = parse_geometry_fields(link_row.geometry)
95
+ edge_out = geom[:edge] || 0
96
+
97
+ source_point = element_edge_point(source_placement, :source, edge_out)
98
+ target_point = element_edge_point(target_placement, :target, edge_out)
99
+ points = [source_point]
100
+ # SX/SY: delta from source point to first bend.
101
+ if geom[:sx] && geom[:sy] && (geom[:sx].nonzero? || geom[:sy].nonzero?)
102
+ points << [source_point[0] + geom[:sx], source_point[1] + geom[:sy]]
103
+ end
104
+ # EX/EY: delta from second bend to target point. Reverse
105
+ # to get from target back to the bend.
106
+ if geom[:ex] && geom[:ey] && (geom[:ex].nonzero? || geom[:ey].nonzero?)
107
+ points << [target_point[0] - geom[:ex], target_point[1] - geom[:ey]]
108
+ end
109
+ points << target_point
110
+
111
+ points.map do |x, y|
112
+ Ea::Model::Waypoint.new(position: Ea::Model::Point.new(x: x, y: y))
113
+ end
114
+ end
115
+
116
+ # Parse the SX, SY, EX, EY, EDGE key=value pairs out of the
117
+ # geometry string. Each is `KEY=<int>;`. We deliberately do
118
+ # NOT extract the leading X1,Y1,X2,Y2 numbers because those
119
+ # are in a different coordinate frame (see note above).
120
+ def parse_geometry_fields(geometry)
121
+ return {} if geometry.nil? || geometry.empty?
122
+
123
+ s = geometry.to_s
124
+ {
125
+ sx: pick_int(s, /SX=(-?\d+)/),
126
+ sy: pick_int(s, /SY=(-?\d+)/),
127
+ ex: pick_int(s, /EX=(-?\d+)/),
128
+ ey: pick_int(s, /EY=(-?\d+)/),
129
+ edge: pick_int(s, /EDGE=(-?\d+)/)
130
+ }
131
+ end
132
+
133
+ def pick_int(str, pattern)
134
+ match = str.match(pattern)
135
+ return nil unless match
136
+
137
+ Integer(match[1])
138
+ rescue ArgumentError
139
+ nil
140
+ end
141
+
142
+ # Find where a given EA object is placed on this diagram.
143
+ def diagram_object_placement(diagram_id, ea_object_id)
144
+ objects = database.diagram_objects_for(diagram_id) || []
145
+ objects.find { |o| o.ea_object_id == ea_object_id }
146
+ end
147
+
148
+ # Compute the connection point on an element's edge for the
149
+ # given side. EA's `EDGE` field on t_diagramlinks tells us
150
+ # which edge the connector attaches to:
151
+ # 1 = top (source), 2 = right, 3 = bottom, 4 = left,
152
+ # plus 5/6/7/8 for diagonals (treated as the cardinal here).
153
+ # We use the center of the chosen edge as the connection point.
154
+ def element_edge_point(placement, end_kind, edge_code)
155
+ b = bounds_from_rect(placement)
156
+ case effective_edge(edge_code, end_kind)
157
+ when :top then [b.x + b.width / 2, b.y]
158
+ when :right then [b.x + b.width, b.y + b.height / 2]
159
+ when :bottom then [b.x + b.width / 2, b.y + b.height]
160
+ when :left then [b.x, b.y + b.height / 2]
161
+ else [b.x + b.width / 2, b.y + b.height / 2]
162
+ end
163
+ end
164
+
165
+ def effective_edge(edge_code, end_kind)
166
+ mapping = {
167
+ 1 => :top, 2 => :right, 3 => :bottom, 4 => :left,
168
+ 5 => :top, 6 => :right, 7 => :bottom, 8 => :left
169
+ }
170
+ mapping[edge_code.to_i] || :center
171
+ end
172
+
71
173
  def package_id_for(diagram_row)
72
174
  pkg = database.find_package(diagram_row.package_id)
73
175
  return nil unless pkg
@@ -106,24 +208,6 @@ module Ea
106
208
 
107
209
  IdNormalizer.from_guid(conn.ea_guid)
108
210
  end
109
-
110
- # EA stores connector geometry as a packed string of x,y
111
- # pairs separated by semicolons and pipes. Conservative
112
- # parse: extract digit pairs, take first/last as endpoints.
113
- def parse_waypoints(geometry)
114
- return [] if geometry.nil? || geometry.empty?
115
-
116
- numbers = geometry.to_s.scan(/-?\d+/).map(&:to_i)
117
- return [] if numbers.size < 4
118
-
119
- numbers.each_slice(2).filter_map do |xy|
120
- next nil if xy.size < 2
121
-
122
- Ea::Model::Waypoint.new(
123
- position: Ea::Model::Point.new(x: xy[0], y: xy[1])
124
- )
125
- end
126
- end
127
211
  end
128
212
  end
129
213
  end
@@ -23,7 +23,7 @@ module Ea
23
23
  end
24
24
 
25
25
  def self.from_comments(comments, owner_id, kind: "documentation")
26
- Array(comments).map { |c| new(owner_id).build(kind, c.body) }
26
+ Array(comments).map { |c| new(owner_id).build(kind, c.body_element || c.body_attribute) }
27
27
  end
28
28
 
29
29
  def self.comments_on(element)
@@ -3,10 +3,11 @@
3
3
  module Ea
4
4
  module Sources
5
5
  module Xmi
6
- # Translates XMI uml:Diagram elements into Ea::Model::Diagram
7
- # instances. Coordinates come from the UML-DI owned_element
8
- # bounds; element references come from the model_element
9
- # attribute.
6
+ # Translates EA diagrams from the XMI source into Ea::Model
7
+ # instances. Uses the rich placement data inside the
8
+ # <xmi:Extension> block (Left/Top/Right/Bottom, BCol, font,
9
+ # SOID/EOID, EDGE, label boxes) when present. Falls back to
10
+ # the UML-DI owned_element bounds otherwise.
10
11
  class DiagramBuilder
11
12
  attr_reader :root
12
13
 
@@ -15,67 +16,186 @@ module Ea
15
16
  end
16
17
 
17
18
  def build_all
18
- diagrams = Array(root.model.diagram)
19
- diagrams.map { |d| build_one(d) }
19
+ ext_diagrams = extension_diagrams
20
+ return [] if ext_diagrams.empty?
21
+
22
+ ext_diagrams.map { |d| build_one(d) }
23
+ end
24
+
25
+ # The XMI stores diagrams in two places:
26
+ # - root.model.diagram — UML-DI shape (often just one or none)
27
+ # - root.extension.diagrams.diagram — the rich EA extension
28
+ # block with placement/style/connector data (the source of
29
+ # truth for rendering).
30
+ # We use the extension block when present; fall back to UML-DI
31
+ # otherwise.
32
+ def extension_diagrams
33
+ ext = root.extension
34
+ return [] unless ext&.diagrams
35
+
36
+ ext.diagrams.diagram.to_a
20
37
  end
21
38
 
22
- def build_one(diagram)
23
- id = IdNormalizer.from_xmi_id(diagram.id)
24
- ext_diagram = extension_diagram_for(id)
39
+ def build_one(ext_diagram)
40
+ id = IdNormalizer.from_xmi_id(ext_diagram.id)
41
+ ext_elements = ExtensionElements.new(ext_diagram)
42
+ placed_elements = ext_elements.placed_elements
43
+ placed_connectors = ext_elements.placed_connectors
44
+ props = ext_diagram.properties
45
+
25
46
  Ea::Model::Diagram.new(
26
47
  id: id,
27
- name: ext_diagram&.properties&.name,
28
- package_id: ext_diagram&.model&.package,
29
- diagram_type: ext_diagram&.properties&.type&.split(":")&.last&.downcase,
30
- bounds: bounds_for(diagram),
31
- elements: build_elements(diagram, id),
32
- connectors: [],
33
- annotations: AnnotationBuilder.from_element(diagram, id)
48
+ name: props&.name || ext_diagram.id,
49
+ package_id: ext_diagram.model&.package,
50
+ diagram_type: props&.type&.split(":")&.last&.downcase,
51
+ bounds: canvas_bounds(placed_elements),
52
+ elements: build_elements(placed_elements, id),
53
+ connectors: build_connectors(placed_connectors,
54
+ placed_elements, id),
55
+ annotations: []
34
56
  )
35
57
  end
36
58
 
37
59
  private
38
60
 
39
- def extension_diagram_for(diagram_id)
40
- return nil unless root.extension&.diagrams
61
+ def canvas_bounds(placed_elements)
62
+ return nil if placed_elements.empty?
63
+
64
+ left = placed_elements.map { |e| e.geometry.img_left || e.geometry.left }.compact.min
65
+ top = placed_elements.map { |e| e.geometry.img_top || e.geometry.top }.compact.min
66
+ right = placed_elements.map { |e| e.geometry.img_right || e.geometry.right }.compact.max
67
+ bottom = placed_elements.map { |e| e.geometry.img_bottom || e.geometry.bottom }.compact.max
68
+ return nil unless left && top && right && bottom
41
69
 
42
- root.extension.diagrams.diagram.find { |d| d.id == diagram_id }
70
+ Ea::Model::Bounds.new(x: 0, y: 0,
71
+ width: right - left,
72
+ height: bottom - top)
43
73
  end
44
74
 
45
- def bounds_for(diagram)
46
- first_bounds = diagram.owned_element.flat_map(&:bounds).first
47
- return nil unless first_bounds
75
+ def build_elements(placed, owner_id)
76
+ placed.map { |p| build_element(p, owner_id) }
77
+ end
48
78
 
49
- Ea::Model::Bounds.new(
50
- x: first_bounds.x || 0,
51
- y: first_bounds.y || 0,
52
- width: first_bounds.width || 0,
53
- height: first_bounds.height || 0
79
+ def build_element(placed, owner_id)
80
+ geom = placed.geometry
81
+ style = placed.style
82
+ Ea::Model::DiagramElement.new(
83
+ id: IdNormalizer.synthetic_id(owner_id, "elem", placed.subject),
84
+ diagram_id: owner_id,
85
+ model_element_ref: placed.subject,
86
+ bounds: build_bounds(geom.left, geom.top, geom.right, geom.bottom),
87
+ image_bounds: build_bounds(geom.img_left, geom.img_top, geom.img_right, geom.img_bottom),
88
+ background_color: style.background_color,
89
+ line_color: style.line_color,
90
+ line_width: style.line_width,
91
+ font_family: style.font_family,
92
+ font_size: style.font_size,
93
+ font_bold: style.bold,
94
+ font_italic: style.italic,
95
+ font_underline: style.underline,
96
+ z_order: placed.seqno,
97
+ duid: style.duid,
98
+ style: {}
54
99
  )
55
100
  end
56
101
 
57
- def build_elements(diagram, owner_diagram_id)
58
- diagram.owned_element.flat_map do |oe|
59
- oe.bounds.each_with_index.map do |bnd, idx|
60
- build_element(oe, bnd, owner_diagram_id, idx)
61
- end
102
+ def build_bounds(left, top, right, bottom)
103
+ return nil unless left && top && right && bottom
104
+
105
+ Ea::Model::Bounds.new(x: left, y: top,
106
+ width: right - left,
107
+ height: bottom - top)
108
+ end
109
+
110
+ def build_connectors(placed_connectors, placed_elements, owner_id)
111
+ # Index elements by DUID so we can resolve SOID/EOID.
112
+ by_duid = placed_elements.each_with_object({}) do |pe, acc|
113
+ acc[pe.style.duid] = pe if pe.style.duid
62
114
  end
115
+
116
+ placed_connectors.map { |p| build_connector(p, by_duid, owner_id) }
63
117
  end
64
118
 
65
- def build_element(owned_element, bounds, diagram_id, idx)
66
- Ea::Model::DiagramElement.new(
67
- id: IdNormalizer.synthetic_id(diagram_id, "elem", idx.to_s),
68
- diagram_id: diagram_id,
69
- model_element_ref: IdNormalizer.from_xmi_id(owned_element.model_element),
70
- bounds: Ea::Model::Bounds.new(
71
- x: bounds.x || 0,
72
- y: bounds.y || 0,
73
- width: bounds.width || 0,
74
- height: bounds.height || 0
75
- ),
76
- style: {}
119
+ def build_connector(placed, by_duid, owner_id)
120
+ geom = placed.geometry
121
+ style = placed.style
122
+ source = by_duid[style.soid]
123
+ target = by_duid[style.eoid]
124
+ waypoints = compute_waypoints(geom, source, target)
125
+ Ea::Model::DiagramConnector.new(
126
+ id: IdNormalizer.synthetic_id(owner_id, "conn", placed.subject),
127
+ diagram_id: owner_id,
128
+ relationship_ref: placed.subject,
129
+ source_duid: style.soid,
130
+ target_duid: style.eoid,
131
+ source_element_ref: source && synthetic_element_id(owner_id, source),
132
+ target_element_ref: target && synthetic_element_id(owner_id, target),
133
+ source_edge: geom.edge,
134
+ target_edge: geom.edge,
135
+ waypoints: waypoints,
136
+ style: {},
137
+ line_color: style.color,
138
+ line_width: style.line_width,
139
+ hidden: style.hidden,
140
+ label_boxes: serialize_label_boxes(geom.label_boxes)
77
141
  )
78
142
  end
143
+
144
+ def synthetic_element_id(owner_id, placed)
145
+ IdNormalizer.synthetic_id(owner_id, "elem", placed.subject)
146
+ end
147
+
148
+ def compute_waypoints(geom, source, target)
149
+ src_pt = edge_point(source, geom.edge, :source)
150
+ tgt_pt = edge_point(target, geom.edge, :target)
151
+ return [] unless src_pt && tgt_pt
152
+
153
+ pts = [src_pt]
154
+ if geom.sx && geom.sy && (geom.sx.nonzero? || geom.sy.nonzero?)
155
+ pts << [src_pt[0] + geom.sx, src_pt[1] + geom.sy]
156
+ end
157
+ if geom.ex && geom.ey && (geom.ex.nonzero? || geom.ey.nonzero?)
158
+ pts << [tgt_pt[0] - geom.ex, tgt_pt[1] - geom.ey]
159
+ end
160
+ pts << tgt_pt
161
+ pts.map do |x, y|
162
+ Ea::Model::Waypoint.new(position: Ea::Model::Point.new(x: x, y: y))
163
+ end
164
+ end
165
+
166
+ def edge_point(placed, edge_code, _end_kind)
167
+ return nil unless placed
168
+
169
+ geom = placed.geometry
170
+ left = geom.left
171
+ top = geom.top
172
+ right = geom.right
173
+ bottom = geom.bottom
174
+ return nil unless left && top && right && bottom
175
+
176
+ case edge_code
177
+ when 1 then [(left + right) / 2, top] # top
178
+ when 2 then [right, (top + bottom) / 2] # right
179
+ when 3 then [(left + right) / 2, bottom] # bottom
180
+ when 4 then [left, (top + bottom) / 2] # left
181
+ else [(left + right) / 2, (top + bottom) / 2]
182
+ end
183
+ end
184
+
185
+ def serialize_label_boxes(label_boxes)
186
+ return {} unless label_boxes
187
+
188
+ label_boxes.transform_values do |box|
189
+ {
190
+ "cx" => box.cx, "cy" => box.cy,
191
+ "ox" => box.ox, "oy" => box.oy,
192
+ "bold" => box.bold,
193
+ "italic" => box.italic,
194
+ "underline" => box.underline,
195
+ "align" => box.align
196
+ }
197
+ end
198
+ end
79
199
  end
80
200
  end
81
201
  end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ea
4
+ module Sources
5
+ module Xmi
6
+ # Walks the <xmi:Extension>/<elements> block for a single
7
+ # EA diagram. Returns two arrays: placed elements and placed
8
+ # connectors, each carrying the parsed geometry + style data.
9
+ #
10
+ # The xmi gem exposes the raw rows via Sparx::Diagram::Elements.
11
+ # This class adds EA-aware parsing on top.
12
+ class ExtensionElements
13
+ attr_reader :diagram
14
+
15
+ def initialize(diagram)
16
+ @diagram = diagram
17
+ end
18
+
19
+ def placed_elements
20
+ rows.filter_map { |row| build_placed_element(row) }
21
+ end
22
+
23
+ def placed_connectors
24
+ rows.filter_map { |row| build_placed_connector(row) }
25
+ end
26
+
27
+ private
28
+
29
+ def rows
30
+ elements_collection = diagram&.elements
31
+ return [] unless elements_collection
32
+
33
+ elements_collection.element.to_a
34
+ end
35
+
36
+ def build_placed_element(row)
37
+ return nil unless row.subject
38
+
39
+ placement = ExtensionGeometryParser.parse(row.geometry)
40
+ return nil unless placement.left || placement.img_left
41
+
42
+ style = ExtensionStyleParser.parse(row.style)
43
+ PlacedElement.new(
44
+ subject: row.subject,
45
+ seqno: row.seqno,
46
+ geometry: placement,
47
+ style: style
48
+ )
49
+ end
50
+
51
+ def build_placed_connector(row)
52
+ return nil unless row.subject
53
+
54
+ placement = ExtensionGeometryParser.parse(row.geometry)
55
+ style = ExtensionStyleParser.parse(row.style)
56
+ # Connectors carry SOID/EOID in their style; geometry has
57
+ # only bend routing.
58
+ return nil unless style.soid && style.eoid
59
+
60
+ PlacedConnector.new(
61
+ subject: row.subject,
62
+ geometry: placement,
63
+ style: style
64
+ )
65
+ end
66
+
67
+ # Value types — the diagram builder translates these into
68
+ # Ea::Model::DiagramElement / DiagramConnector.
69
+ PlacedElement = Struct.new(:subject, :seqno, :geometry, :style, keyword_init: true)
70
+ PlacedConnector = Struct.new(:subject, :geometry, :style, keyword_init: true)
71
+ end
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,107 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ea
4
+ module Sources
5
+ module Xmi
6
+ # Parses EA's packed geometry string from
7
+ # <xmi:Extension>/<elements>/<element>/@geometry. Returns a
8
+ # typed Placement struct with separate fields for placement
9
+ # and bend routing. Label boxes decoded into a Hash keyed by
10
+ # EA's role names (:llb, :llt, :lrt, :lrb).
11
+ module ExtensionGeometryParser
12
+ LABEL_BOX_KEYS = %i[llb llt lrt lrb lmt lmb irhs ilhs].freeze
13
+
14
+ module_function
15
+
16
+ def parse(geometry_string)
17
+ return Placement.new if geometry_string.nil? || geometry_string.empty?
18
+
19
+ kv = split_pairs(geometry_string)
20
+ Placement.new(
21
+ left: int(kv["Left"]),
22
+ top: int(kv["Top"]),
23
+ right: int(kv["Right"]),
24
+ bottom: int(kv["Bottom"]),
25
+ img_left: int(kv["imgL"]),
26
+ img_top: int(kv["imgT"]),
27
+ img_right: int(kv["imgR"]),
28
+ img_bottom: int(kv["imgB"]),
29
+ sx: int(kv["SX"]),
30
+ sy: int(kv["SY"]),
31
+ ex: int(kv["EX"]),
32
+ ey: int(kv["EY"]),
33
+ edge: int(kv["EDGE"]),
34
+ label_boxes: extract_label_boxes(geometry_string),
35
+ path: kv["Path"]
36
+ )
37
+ end
38
+
39
+ def split_pairs(str)
40
+ str.split(";").filter_map do |pair|
41
+ next nil unless pair.include?("=")
42
+ next nil if pair.start_with?("$") # $LLB= etc handled separately
43
+
44
+ key, value = pair.split("=", 2)
45
+ [key, value] if key && value
46
+ end.to_h
47
+ end
48
+
49
+ def int(value)
50
+ return nil if value.nil? || value.empty?
51
+
52
+ Integer(value)
53
+ rescue ArgumentError
54
+ nil
55
+ end
56
+
57
+ def extract_label_boxes(geometry_string)
58
+ geometry_string.to_s.scan(/\$?([A-Z]{3})=((?:[A-Za-z]{1,4}=-?\d+:)+)/).each_with_object({}) do |(key, body), acc|
59
+ sym = key.downcase.to_sym
60
+ next unless LABEL_BOX_KEYS.include?(sym)
61
+ next if body.nil? || body.empty?
62
+
63
+ acc[sym] = parse_label_body(body)
64
+ end
65
+ end
66
+
67
+ def parse_label_body(body)
68
+ pairs = body.to_s.split(":").filter_map do |pair|
69
+ next nil unless pair.include?("=")
70
+
71
+ k, v = pair.split("=", 2)
72
+ [k, int(v)] if k && v
73
+ end
74
+ hash = pairs.to_h
75
+ LabelBox.new(
76
+ cx: hash["CX"],
77
+ cy: hash["CY"],
78
+ ox: hash["OX"],
79
+ oy: hash["OY"],
80
+ bold: hash["BLD"] == 1,
81
+ italic: hash["ITA"] == 1,
82
+ underline: hash["UND"] == 1,
83
+ hidden: hash["HDN"] == 1,
84
+ align: hash["ALN"],
85
+ direction: hash["DIR"],
86
+ rotation: hash["ROT"]
87
+ )
88
+ end
89
+
90
+ Placement = Struct.new(
91
+ :left, :top, :right, :bottom,
92
+ :img_left, :img_top, :img_right, :img_bottom,
93
+ :sx, :sy, :ex, :ey, :edge,
94
+ :label_boxes, :path,
95
+ keyword_init: true
96
+ )
97
+
98
+ LabelBox = Struct.new(
99
+ :cx, :cy, :ox, :oy,
100
+ :bold, :italic, :underline, :hidden,
101
+ :align, :direction, :rotation,
102
+ keyword_init: true
103
+ )
104
+ end
105
+ end
106
+ end
107
+ end