ea 0.6.130 → 0.6.132

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.
@@ -9,6 +9,8 @@ module Ea
9
9
  # SOID/EOID, EDGE, label boxes) when present. Falls back to
10
10
  # the UML-DI owned_element bounds otherwise.
11
11
  class DiagramBuilder
12
+ autoload :UmldiEdges, "ea/sources/xmi/diagram_builder/umldi_edges"
13
+
12
14
  attr_reader :root, :xmi_path
13
15
 
14
16
  def initialize(root, xmi_path = nil)
@@ -53,6 +55,7 @@ module Ea
53
55
  bounds: canvas_bounds(placed_elements),
54
56
  style: style_for(ext_diagram),
55
57
  style_ex: style_ex_for(ext_diagram),
58
+ show_notes: style_ex_for(ext_diagram).to_s.include?("ShowNotes=1"),
56
59
  elements: build_elements(placed_elements, id, umldi_keywords),
57
60
  connectors: build_connectors(placed_connectors,
58
61
  placed_elements, id),
@@ -144,20 +147,23 @@ module Ea
144
147
  acc[pe.style.duid] = pe if pe.style.duid
145
148
  end
146
149
 
147
- placed_connectors.map { |p| build_connector(p, by_duid, owner_id) }
150
+ placed_connectors.each_with_index.map do |placed, index|
151
+ build_connector(placed, by_duid, owner_id, index)
152
+ end
148
153
  end
149
154
 
150
- def build_connector(placed, by_duid, owner_id)
155
+ def build_connector(placed, by_duid, owner_id, rank)
151
156
  geom = placed.geometry
152
157
  style = placed.style
153
158
  source = by_duid[style.soid]
154
159
  target = by_duid[style.eoid]
155
- waypoints = compute_waypoints(geom, source, target)
156
160
  ext_meta = connector_meta(placed.subject)
161
+ route = connector_route(placed, source, target, owner_id, ext_meta[:ea_type])
157
162
  Ea::Model::DiagramConnector.new(
158
163
  id: IdNormalizer.synthetic_id(owner_id, "conn", placed.subject),
159
164
  diagram_id: owner_id,
160
165
  relationship_ref: placed.subject,
166
+ paint_order: [rank],
161
167
  source_duid: style.soid,
162
168
  target_duid: style.eoid,
163
169
  source_element_ref: source && synthetic_element_id(owner_id, source),
@@ -166,8 +172,8 @@ module Ea
166
172
  target_edge: geom.edge,
167
173
  connector_type: ext_meta[:ea_type],
168
174
  direction: ext_meta[:direction],
169
- waypoints: waypoints,
170
- style: {},
175
+ waypoints: route[:waypoints],
176
+ style: route[:style],
171
177
  line_color: style.color,
172
178
  line_width: style.line_width,
173
179
  hidden: style.hidden,
@@ -211,11 +217,35 @@ module Ea
211
217
  IdNormalizer.synthetic_id(owner_id, "elem", placed.subject)
212
218
  end
213
219
 
214
- def compute_waypoints(geom, source, target)
220
+ def connector_route(placed, source, target, owner_id, type)
221
+ native = umldi_waypoints(owner_id, placed) if placed.geometry.bend_points.to_a.empty?
222
+ points = native || compute_waypoints(placed.geometry, source, target,
223
+ style: placed.style, connector_type: type)
224
+ native ||= umldi_waypoints(owner_id, placed) if points.empty?
225
+ { waypoints: native || points, style: native ? { umldi_route: true } : {} }
226
+ end
227
+
228
+ # EA's unique exported edge supplies bendless routes and recovers
229
+ # routes whose placement endpoints cannot be resolved.
230
+ def umldi_waypoints(diagram_id, placed)
231
+ return unless xmi_path
232
+
233
+ route = umldi_edges.route_for(diagram_id, placed.subject)
234
+ route&.map { |x, y| Ea::Model::Waypoint.new(position: Ea::Model::Point.new(x: x, y: y)) }
235
+ end
236
+
237
+ def umldi_edges
238
+ @umldi_edges ||= UmldiEdges.new(xmi_path)
239
+ end
240
+
241
+ def compute_waypoints(geom, source, target, style:, connector_type:)
215
242
  src_bounds = bounds_from_placed(source)
216
243
  tgt_bounds = bounds_from_placed(target)
217
244
  return [] unless src_bounds && tgt_bounds
218
245
 
246
+ stored = stored_route(geom, style, connector_type, src_bounds, tgt_bounds)
247
+ return stored if stored
248
+
219
249
  router = Ea::Svg::ConnectorRouter.new(
220
250
  source_bounds: src_bounds,
221
251
  target_bounds: tgt_bounds,
@@ -229,6 +259,27 @@ module Ea
229
259
  end
230
260
  end
231
261
 
262
+ # Stored bends are routed by StoredRoute where a QEA rule claims
263
+ # them; anything else keeps the generic router.
264
+ def stored_route(geom, style, connector_type, src_bounds, tgt_bounds)
265
+ offsets = { source: [geom.sx, geom.sy], target: [geom.ex, geom.ey] }
266
+ points = StoredRoute.new.waypoints(
267
+ link: StoredRoute::Link.new(style.raw, geom.path.to_s.tr("$", ";"), offset_geometry(geom)),
268
+ connector_type: connector_type, source: src_bounds, target: tgt_bounds,
269
+ bends: geom.bend_points.to_a, offsets: offsets, edge: geom.edge
270
+ )
271
+ return unless points
272
+
273
+ points.map do |x, y|
274
+ Ea::Model::Waypoint.new(position: Ea::Model::Point.new(x: x, y: y))
275
+ end
276
+ end
277
+
278
+ def offset_geometry(geom)
279
+ { "SX" => geom.sx, "SY" => geom.sy, "EX" => geom.ex, "EY" => geom.ey }
280
+ .filter_map { |key, value| "#{key}=#{value};" if value }.join
281
+ end
282
+
232
283
  def bounds_from_placed(placed)
233
284
  return nil unless placed
234
285
 
@@ -264,12 +315,16 @@ module Ea
264
315
 
265
316
  label_boxes.transform_values do |box|
266
317
  {
267
- "cx" => box.cx, "cy" => box.cy,
268
318
  "ox" => box.ox, "oy" => box.oy,
319
+ "cx" => box.cx, "cy" => box.cy,
320
+ "hidden" => box.hidden,
269
321
  "bold" => box.bold,
270
322
  "italic" => box.italic,
271
323
  "underline" => box.underline,
272
- "align" => box.align
324
+ "color" => box.color,
325
+ "alignment" => box.align,
326
+ "direction" => box.direction,
327
+ "rotation" => box.rotation
273
328
  }
274
329
  end
275
330
  end
@@ -38,8 +38,9 @@ module Ea
38
38
  end
39
39
 
40
40
  # EA's Path= field uses the format `x1:y1$x2:y2$...` where
41
- # each pair is a bend point. Coordinates are absolute pixel
42
- # positions on the diagram canvas.
41
+ # each pair is a bend point. EA stores Y negated (as in QEA
42
+ # t_diagramlinks.Path); the result uses screen-positive Y,
43
+ # like element bounds.
43
44
  def parse_bend_path(raw)
44
45
  return [] if raw.nil? || raw.empty?
45
46
 
@@ -49,7 +50,7 @@ module Ea
49
50
  x_str, y_str = pair.split(":", 2)
50
51
  x = int(x_str)
51
52
  y = int(y_str)
52
- [x, y] if x && y
53
+ [x, -y] if x && y
53
54
  end
54
55
  end
55
56
 
@@ -72,7 +73,7 @@ module Ea
72
73
  end
73
74
 
74
75
  def extract_label_boxes(geometry_string)
75
- geometry_string.to_s.scan(/\$?([A-Z]{3})=((?:[A-Za-z]{1,4}=-?\d+:)+)/).each_with_object({}) do |(key, body), acc|
76
+ geometry_string.to_s.scan(/\$?([A-Z]{3})=((?:[A-Za-z]{1,4}=-?\d+:?)+)/).each_with_object({}) do |(key, body), acc|
76
77
  sym = key.downcase.to_sym
77
78
  next unless LABEL_BOX_KEYS.include?(sym)
78
79
  next if body.nil? || body.empty?
@@ -98,6 +99,7 @@ module Ea
98
99
  italic: hash["ITA"] == 1,
99
100
  underline: hash["UND"] == 1,
100
101
  hidden: hash["HDN"] == 1,
102
+ color: hash["CLR"],
101
103
  align: hash["ALN"],
102
104
  direction: hash["DIR"],
103
105
  rotation: hash["ROT"]
@@ -114,7 +116,7 @@ module Ea
114
116
 
115
117
  LabelBox = Struct.new(
116
118
  :cx, :cy, :ox, :oy,
117
- :bold, :italic, :underline, :hidden,
119
+ :bold, :italic, :underline, :hidden, :color,
118
120
  :align, :direction, :rotation,
119
121
  keyword_init: true
120
122
  )
@@ -67,13 +67,14 @@ module Ea
67
67
  value == "1" || value == "true" || value == "-1"
68
68
  end
69
69
 
70
+ # fontsz is EA's tenths of a point, rendered through GDI's double
71
+ # rounding (pixels = floor(tenths * 96 / 720), points =
72
+ # floor(pixels * 72 / 96)). Absent or zero means inherit, as nil.
70
73
  def scale_font_size(value)
71
- return 13 if value.nil? || value.empty?
74
+ tenths = int(value)
75
+ return nil if tenths.nil? || tenths.zero?
72
76
 
73
- pct = int(value)
74
- return 13 if pct.nil? || pct.zero?
75
-
76
- (pct * 13 / 100).round
77
+ (tenths * 96 / 720 * 72 / 96.0).floor
77
78
  end
78
79
 
79
80
  Style = Struct.new(
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "nokogiri"
4
+
5
+ module Ea
6
+ module Sources
7
+ module Xmi
8
+ # Type names EA stores in the <xmi:Extension><elements> block,
9
+ # where the UML side only carries a primitive-type href or a
10
+ # placeholder idref. The xmi gem does not expose operations or
11
+ # parameters of the extension, so the raw XML is read once.
12
+ #
13
+ # <attribute xmi:idref="ID"><properties type="int"/>
14
+ # <operation xmi:idref="ID"><type type="void"/>
15
+ # <parameter xmi:idref="ID"><properties type="int"/>
16
+ class ExtensionTypes
17
+ def self.from_path(xmi_path)
18
+ return empty if xmi_path.nil?
19
+
20
+ new(Nokogiri::XML(File.read(xmi_path)))
21
+ end
22
+
23
+ def self.empty
24
+ new(nil)
25
+ end
26
+
27
+ def initialize(document)
28
+ @attributes = index(document, "attribute", "properties")
29
+ @returns = index(document, "operation", "type")
30
+ @parameters = index(document, "parameter", "properties")
31
+ end
32
+
33
+ def attribute_type(id)
34
+ @attributes[id]
35
+ end
36
+
37
+ def return_type(operation_id)
38
+ @returns[operation_id]
39
+ end
40
+
41
+ def parameter_type(parameter_id)
42
+ @parameters[parameter_id]
43
+ end
44
+
45
+ private
46
+
47
+ def index(document, owner, child)
48
+ return {} unless document
49
+
50
+ document.xpath(%(//*[local-name()="#{owner}"][@*[local-name()="idref"]])).each_with_object({}) do |node, acc|
51
+ type = node.at_xpath(%(*[local-name()="#{child}"]/@type))&.value
52
+ idref = node.at_xpath("@*[local-name()='idref']").value
53
+ acc[idref] = type unless type.nil? || type.empty?
54
+ end
55
+ end
56
+ end
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "nokogiri"
4
+
5
+ module Ea
6
+ module Sources
7
+ module Xmi
8
+ # Builds Ea::Model::InstanceSpecification objects from
9
+ # uml:InstanceSpecification packaged elements. Slots come from
10
+ # the `runstate` attribute of the element's Extension entry,
11
+ # which carries slot names and operators that the typed
12
+ # `<slot>` children omit.
13
+ class InstanceBuilder
14
+ XMI_TYPE = "uml:InstanceSpecification"
15
+ RUNSTATE_XPATH =
16
+ %(//*[local-name()="element"]/*[local-name()="extendedProperties"][@runstate])
17
+
18
+ attr_reader :root, :xmi_path, :classifiers
19
+
20
+ def initialize(root, xmi_path, classifiers)
21
+ @root = root
22
+ @xmi_path = xmi_path
23
+ @classifiers = classifiers
24
+ end
25
+
26
+ def build_all
27
+ instances = []
28
+ walk(root.model) do |element, package|
29
+ instances << build_one(element, package) if element.type == XMI_TYPE
30
+ end
31
+ instances
32
+ end
33
+
34
+ private
35
+
36
+ def walk(element, package = nil, &block)
37
+ yield element, package
38
+ element.packaged_element.each do |inner|
39
+ walk(inner, inner.type == "uml:Package" ? inner : package, &block)
40
+ end
41
+ end
42
+
43
+ def build_one(element, package)
44
+ id = IdNormalizer.from_xmi_id(element.id)
45
+ properties = extension_properties[id]
46
+ Ea::Model::InstanceSpecification.new(
47
+ id: id,
48
+ name: element.name.to_s,
49
+ role_name: properties&.alias,
50
+ classifier_id: IdNormalizer.from_xmi_id(element.classifier),
51
+ classifier_name: classifier_names[element.classifier],
52
+ stereotype: properties&.stereotype.to_s,
53
+ package_id: package && IdNormalizer.from_xmi_id(package.id),
54
+ package_name: package&.name,
55
+ qualified_name: qualified_name(element, package),
56
+ slots: slots_for(id),
57
+ annotations: AnnotationBuilder.from_element(element, id) +
58
+ AnnotationBuilder.from_documentation(properties&.documentation, id)
59
+ )
60
+ end
61
+
62
+ def qualified_name(element, package)
63
+ name = element.name.to_s
64
+ return name if name.empty? || package.nil?
65
+
66
+ "#{package.name}::#{name}"
67
+ end
68
+
69
+ def classifier_names
70
+ @classifier_names ||= classifiers.to_h { |c| [c.id, c.name.to_s] }
71
+ end
72
+
73
+ def extension_properties
74
+ @extension_properties ||=
75
+ (root.extension&.elements&.element || []).to_h { |e| [e.idref, e.properties] }
76
+ end
77
+
78
+ def slots_for(id)
79
+ runstates[id].to_s.scan(Ea::Sources::Qea::InstanceBuilder::RUN_STATE_PATTERN).map do |(name, value, op)|
80
+ Ea::Model::Slot.new(name: name.to_s, value: value.to_s, op: op.to_s.empty? ? "=" : op)
81
+ end
82
+ end
83
+
84
+ def runstates
85
+ @runstates ||= extension_runstates
86
+ end
87
+
88
+ def extension_runstates
89
+ return {} if xmi_path.nil?
90
+
91
+ Nokogiri::XML(File.read(xmi_path))
92
+ .xpath(RUNSTATE_XPATH)
93
+ .to_h { |node| [node.parent["xmi:idref"], node["runstate"]] }
94
+ end
95
+ end
96
+ end
97
+ end
98
+ end
@@ -19,7 +19,7 @@ module Ea
19
19
  owner_id: owner.id,
20
20
  qualified_name: "#{owner.name}::#{op.name}",
21
21
  return_type_name: return_type_name_for(op),
22
- visibility: op.visibility,
22
+ visibility: op.visibility || "public",
23
23
  is_static: boolean(op.is_static),
24
24
  is_abstract: boolean(op.is_abstract),
25
25
  parameters: build_parameters(op, id),
@@ -30,21 +30,21 @@ module Ea
30
30
  private
31
31
 
32
32
  def build_parameters(op, owner_op_id)
33
- op.owned_parameter.map.with_index do |param, idx|
34
- direction = param.direction || (idx.zero? ? "return" : "in")
33
+ op.owned_parameter.reject { |p| p.direction == "return" }.map.with_index do |param, idx|
34
+ direction = param.direction || "in"
35
35
  Ea::Model::Parameter.new(
36
36
  id: IdNormalizer.from_xmi_id(param.id) ||
37
37
  IdNormalizer.synthetic_id(owner_op_id, "param", idx.to_s),
38
38
  name: param.name || direction,
39
39
  ordinal: idx,
40
40
  direction: direction,
41
- type_name: param.type
41
+ type_name: param.classifier_type
42
42
  )
43
43
  end
44
44
  end
45
45
 
46
46
  def return_type_name_for(op)
47
- op.owned_parameter.find { |p| p.direction == "return" }&.type
47
+ op.owned_parameter.find { |p| p.direction == "return" }&.classifier_type
48
48
  end
49
49
 
50
50
  def boolean(value)
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "nokogiri"
4
+
5
+ module Ea
6
+ module Sources
7
+ module Xmi
8
+ # The xmi gem omits ownedReception, so read those source members
9
+ # directly rather than inferring operations from extension rows.
10
+ class ReceptionBuilder
11
+ def initialize(xmi_path)
12
+ @xmi_path = xmi_path
13
+ end
14
+
15
+ def grouped_by_owner
16
+ return {} unless @xmi_path
17
+
18
+ @grouped_by_owner ||= read_receptions(Nokogiri::XML(File.read(@xmi_path)))
19
+ end
20
+
21
+ private
22
+
23
+ def read_receptions(document)
24
+ scopes = extension_scopes(document)
25
+ xpath = '//*[local-name()="Model"]//*[local-name()="packagedElement"]/*[local-name()="ownedReception"]'
26
+ document.xpath(xpath).each_with_object({}) do |node, grouped|
27
+ owner_id = xmi_attribute(node.parent, "id")
28
+ next unless owner_id
29
+
30
+ (grouped[owner_id] ||= []) << build_one(node, owner_id, scopes)
31
+ end
32
+ end
33
+
34
+ def build_one(node, owner_id, scopes)
35
+ id = xmi_attribute(node, "id") ||
36
+ IdNormalizer.synthetic_id(owner_id, "reception", node["name"])
37
+ Ea::Model::Operation.new(
38
+ id: id, name: node["name"], owner_id: owner_id,
39
+ qualified_name: "#{node.parent["name"]}::#{node["name"]}",
40
+ is_reception: true,
41
+ visibility: node["visibility"] || scopes[id] || "public"
42
+ )
43
+ end
44
+
45
+ def extension_scopes(document)
46
+ xpath = '//*[local-name()="Extension"]//*[local-name()="receptions"]/*[local-name()="reception"]'
47
+ document.xpath(xpath).each_with_object({}) do |node, scopes|
48
+ id = xmi_attribute(node, "idref")
49
+ scopes[id] = node["scope"].downcase if id && node["scope"]
50
+ end
51
+ end
52
+
53
+ def xmi_attribute(node, name)
54
+ node.at_xpath("@*[local-name()='#{name}']")&.value
55
+ end
56
+ end
57
+ end
58
+ end
59
+ end
@@ -7,6 +7,13 @@ module Ea
7
7
  # uml:Generalization, uml:Realization, uml:Dependency) into
8
8
  # the appropriate Ea::Model::Relationship subclass.
9
9
  class RelationshipBuilder
10
+ # Connector types that imply an aggregation on the source end
11
+ # when the end itself records none.
12
+ AGGREGATION_FOR_CONNECTOR_TYPE = {
13
+ "Aggregation" => "shared",
14
+ "Composition" => "composite"
15
+ }.freeze
16
+
10
17
  attr_reader :root
11
18
 
12
19
  def initialize(root)
@@ -42,7 +49,7 @@ module Ea
42
49
  id = IdNormalizer.from_xmi_id(element.id)
43
50
  {
44
51
  id: id,
45
- name: element.name || element.type&.split(":")&.last&.downcase,
52
+ name: element.name,
46
53
  qualified_name: element.name,
47
54
  annotations: AnnotationBuilder.from_element(element, id)
48
55
  }
@@ -59,6 +66,41 @@ module Ea
59
66
  end
60
67
 
61
68
  def association_args(element)
69
+ connector = connector_index[element.id]
70
+ return owned_end_args(element) unless connector&.source && connector.target
71
+
72
+ connector_end_args(element, connector)
73
+ end
74
+
75
+ def connector_end_args(element, connector)
76
+ source = connector.source
77
+ target = connector.target
78
+ {
79
+ source_id: source.idref,
80
+ target_id: target.idref,
81
+ source_role_name: source.role&.name,
82
+ target_role_name: target.role&.name,
83
+ source_multiplicity_lower: card_lower(source.type&.multiplicity),
84
+ source_multiplicity_upper: card_upper(source.type&.multiplicity),
85
+ target_multiplicity_lower: card_lower(target.type&.multiplicity),
86
+ target_multiplicity_upper: card_upper(target.type&.multiplicity),
87
+ source_multiplicity_raw: raw_card(source.type&.multiplicity),
88
+ target_multiplicity_raw: raw_card(target.type&.multiplicity),
89
+ source_aggregation: source_aggregation(connector),
90
+ target_aggregation: target.type&.aggregation || "none",
91
+ source_navigable: navigable?(element, :source),
92
+ target_navigable: navigable?(element, :target)
93
+ }
94
+ end
95
+
96
+ def source_aggregation(connector)
97
+ flagged = connector.source.type&.aggregation || "none"
98
+ return flagged unless flagged == "none"
99
+
100
+ AGGREGATION_FOR_CONNECTOR_TYPE.fetch(connector.properties&.ea_type, "none")
101
+ end
102
+
103
+ def owned_end_args(element)
62
104
  ends = element.member_ends.to_a
63
105
  source_id = ends.first&.idref
64
106
  target_id = ends.last&.idref
@@ -81,6 +123,24 @@ module Ea
81
123
  }
82
124
  end
83
125
 
126
+ def raw_card(cardinality)
127
+ text = cardinality.to_s.strip
128
+ text.empty? ? nil : text
129
+ end
130
+
131
+ def card_lower(cardinality)
132
+ text = raw_card(cardinality)
133
+ Integer(text.split("..").first, exception: false) if text
134
+ end
135
+
136
+ def card_upper(cardinality)
137
+ text = raw_card(cardinality)
138
+ return nil unless text
139
+
140
+ upper = text.split("..").last
141
+ upper == "*" ? -1 : Integer(upper, exception: false)
142
+ end
143
+
84
144
  def generalization_args(element)
85
145
  # PackagedElement#generalization is a collection of
86
146
  # AssociationGeneralization rows. The "specific" (child)