ea 0.6.135 → 0.6.137

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c90b3802a319e6dec5d22f9d196bfc328c942c679925634774dc55780874deef
4
- data.tar.gz: 737a502a84e3a08c01070024b0793204ae4c460c928fbd531297fbd3d02fdf82
3
+ metadata.gz: 86d6c89e9ac852772ebf6ee8eca6f4a08550af81c7f48908655c94b4bc8ab18c
4
+ data.tar.gz: 6ebe1a0e0647909564a7a403730e4041a8eb639ded4479deebbc19db6f3e8e5a
5
5
  SHA512:
6
- metadata.gz: a3df522a4725e068ec20bd515db2b35c058f270819cf5f503c187c59dc0d1db7b4b21e3e9d886db33c5960f2510bbe55b47ddddb0731f8c22a55b97e8d25b789
7
- data.tar.gz: a2319133a4bcc7e0473ec82128b8b23205134cf5e09804f60b15c9e0a9a76ebd3795d6dea3566a4827fcc52b91c65dc14c521d93fbf5133ea63aa189bf229098
6
+ metadata.gz: 91e45ba11088734c4ad3998c124945a4a80fc1001e7f3384fd69c02087e7578fcb2d2d1b8c3a6e1b183e26b03c7debd00fc1bdebe216337cb55855573f85f157
7
+ data.tar.gz: 6edd840e00dbf74517daad8bd65e2c28d0cec57ad1c02a15016417327d7c3e7dbd1bacfa22c25f6d62d60af632b4d6812110d31523c642a670b386bb86f1a448
@@ -0,0 +1,127 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "nokogiri"
4
+
5
+ module Ea
6
+ module Sources
7
+ module Xmi
8
+ class DiagramBuilder
9
+ # EA stores the association-class reference, connector subtype and proxy
10
+ # classifiers in extension attributes omitted by the XMI model gem.
11
+ class AssociationClassMetadata
12
+ def initialize(xmi_path)
13
+ @xmi_path = xmi_path
14
+ end
15
+
16
+ def for(connector, placed_subjects:, waypoints:)
17
+ return unless valid_tether?(connector, waypoints)
18
+
19
+ attached_id = connector_data.dig(connector.idref, :association_class)
20
+ return unless attached_id && class_ids.include?(attached_id)
21
+ return unless placed_subjects.any? { |subject| guid_key(subject) == guid_key(attached_id) }
22
+
23
+ attached_id
24
+ end
25
+
26
+ private
27
+
28
+ def valid_tether?(connector, waypoints)
29
+ @xmi_path && connector && association_class?(connector) &&
30
+ proxy_pair?(connector) && coincident_route?(waypoints)
31
+ end
32
+
33
+ def association_class?(connector)
34
+ connector.properties&.ea_type == "Association" &&
35
+ connector_data.dig(connector.idref, :subtype) == "Class" &&
36
+ connector_data.dig(connector.idref, :private_data).to_s.match?(/\A[1-9]\d*\z/)
37
+ end
38
+
39
+ def proxy_pair?(connector)
40
+ endpoints = [connector.source, connector.target]
41
+ return false unless proxy_endpoints?(endpoints)
42
+
43
+ classifiers = endpoints.map { |endpoint| proxy_classifiers[endpoint.idref] }
44
+ matching_classifiers?(classifiers)
45
+ end
46
+
47
+ def proxy_endpoints?(endpoints)
48
+ endpoints.all? { |endpoint| endpoint&.model&.type == "ProxyConnector" }
49
+ end
50
+
51
+ def matching_classifiers?(classifiers)
52
+ classifiers.none? { |classifier| classifier.to_s.empty? } && classifiers.uniq.one?
53
+ end
54
+
55
+ def coincident_route?(waypoints)
56
+ first = waypoints&.first&.position
57
+ last = waypoints&.last&.position
58
+ first && last && first.x == last.x && first.y == last.y
59
+ end
60
+
61
+ def connector_data
62
+ @connector_data ||= read_metadata.fetch(:connectors)
63
+ end
64
+
65
+ def proxy_classifiers
66
+ @proxy_classifiers ||= read_metadata.fetch(:proxies)
67
+ end
68
+
69
+ def class_ids
70
+ @class_ids ||= read_metadata.fetch(:classes)
71
+ end
72
+
73
+ def read_metadata
74
+ @read_metadata ||= scan_metadata
75
+ end
76
+
77
+ def scan_metadata
78
+ data = { connectors: {}, proxies: {}, classes: [] }
79
+ File.open(@xmi_path) do |file|
80
+ Nokogiri::XML::Reader(file).each { |node| read_node(node, data) }
81
+ end
82
+ data
83
+ end
84
+
85
+ def read_node(node, data)
86
+ return unless node.node_type == Nokogiri::XML::Reader::TYPE_ELEMENT
87
+
88
+ read_element(node, data) if node.local_name == "element"
89
+ read_connector(node, data) if node.local_name == "connector"
90
+ end
91
+
92
+ def read_element(node, data)
93
+ element = Nokogiri::XML(node.outer_xml).root
94
+ id = attr(element, "idref")
95
+ return unless id
96
+
97
+ type = attr(element, "type")
98
+ data[:classes] << id if type == "uml:Class"
99
+ data[:proxies][id] = attr(element, "classifier") if type == "uml:ProxyConnector"
100
+ end
101
+
102
+ def read_connector(node, data)
103
+ connector = Nokogiri::XML(node.outer_xml).root
104
+ id = attr(connector, "idref")
105
+ return unless id
106
+
107
+ extended = connector.at_xpath("./*[local-name()='extendedProperties']")
108
+ properties = connector.at_xpath("./*[local-name()='properties']")
109
+ data[:connectors][id] = {
110
+ subtype: attr(properties, "subtype"),
111
+ association_class: attr(extended, "associationclass"),
112
+ private_data: attr(extended, "privatedata1")
113
+ }
114
+ end
115
+
116
+ def attr(node, name)
117
+ node&.attribute_nodes&.find { |attribute| attribute.name == name }&.value
118
+ end
119
+
120
+ def guid_key(subject)
121
+ subject&.sub(/\AEA(?:ID|PK)_/, "")&.upcase
122
+ end
123
+ end
124
+ end
125
+ end
126
+ end
127
+ end
@@ -10,6 +10,7 @@ module Ea
10
10
  # the UML-DI owned_element bounds otherwise.
11
11
  class DiagramBuilder
12
12
  autoload :ConnectorMetadata, "ea/sources/xmi/diagram_builder/connector_metadata"
13
+ autoload :AssociationClassMetadata, "ea/sources/xmi/diagram_builder/association_class_metadata"
13
14
  autoload :FontSettings, "ea/sources/xmi/diagram_builder/font_settings"
14
15
  autoload :StereotypeProfiles, "ea/sources/xmi/diagram_builder/stereotype_profiles"
15
16
  autoload :UmldiEdges, "ea/sources/xmi/diagram_builder/umldi_edges"
@@ -44,7 +45,7 @@ module Ea
44
45
 
45
46
  def build_one(ext_diagram)
46
47
  id = IdNormalizer.from_xmi_id(ext_diagram.id)
47
- ext_elements = ExtensionElements.new(ext_diagram)
48
+ ext_elements = ExtensionElements.new(ext_diagram, connector_subjects: connector_index)
48
49
  placed_elements = ext_elements.placed_elements
49
50
  placed_connectors = ext_elements.placed_connectors
50
51
  props = ext_diagram.properties
@@ -130,6 +131,7 @@ module Ea
130
131
  line_width: style.line_width,
131
132
  font_family: font[:family],
132
133
  font_size: font[:size],
134
+ font_size_unit: "pt",
133
135
  font_logical_height: font[:logical_height],
134
136
  font_size_inherited: ExtensionStyleParser.split_pairs(style.raw.to_s)["fontsz"] == "0",
135
137
  font_bold: style.bold,
@@ -140,10 +142,32 @@ module Ea
140
142
  z_order: placed.seqno,
141
143
  duid: style.duid,
142
144
  umldi_keyword: umldi_keywords[placed.subject],
143
- style: (@stereotype_profiles ||= StereotypeProfiles.new(root)).for(placed.subject)
145
+ style: element_style(placed.subject)
144
146
  )
145
147
  end
146
148
 
149
+ # XMI preserves UML member order in ownedAttribute. Carry that
150
+ # provenance to the renderer so it does not replace it with a name sort.
151
+ def element_style(subject)
152
+ style = (@stereotype_profiles ||= StereotypeProfiles.new(root)).for(subject)
153
+ return style unless source_ordered_classifiers.include?(subject)
154
+
155
+ style.merge(stored_attribute_order: true)
156
+ end
157
+
158
+ def source_ordered_classifiers
159
+ @source_ordered_classifiers ||= begin
160
+ ids = []
161
+ visit = lambda do |element|
162
+ ids << element.id if TypeClassifierMap.classifier_type?(element.type)
163
+ element.packaged_element.to_a.each { |child| visit.call(child) }
164
+ element.nested_classifier.to_a.each { |child| visit.call(child) }
165
+ end
166
+ root.model&.packaged_element.to_a.each { |element| visit.call(element) }
167
+ ids
168
+ end
169
+ end
170
+
147
171
  def build_bounds(left, top, right, bottom)
148
172
  return nil unless left && top && right && bottom
149
173
 
@@ -160,16 +184,33 @@ module Ea
160
184
  by_subject = placed_elements.group_by { |pe| guid_key(pe.subject) }
161
185
 
162
186
  placed_connectors.each_with_index.map do |placed, index|
163
- build_connector(placed, by_duid, by_subject, owner_id, index)
187
+ connector = build_connector(placed, by_duid, by_subject, owner_id, index)
188
+ mark_association_class(connector, placed, placed_elements)
164
189
  end
165
190
  end
166
191
 
192
+ def mark_association_class(connector, placed, placed_elements)
193
+ reference = association_class_metadata.for(
194
+ connector_index[placed.subject],
195
+ placed_subjects: placed_elements.map(&:subject),
196
+ waypoints: connector.waypoints
197
+ )
198
+ if reference
199
+ connector.style[:association_class_ref] = reference
200
+ connector.source_anchor = connector.waypoints.first.position
201
+ end
202
+ connector
203
+ end
204
+
205
+ def association_class_metadata
206
+ @association_class_metadata ||= AssociationClassMetadata.new(xmi_path)
207
+ end
208
+
167
209
  def build_connector(placed, by_duid, by_subject, owner_id, rank)
168
210
  geom = placed.geometry
169
211
  style = placed.style
170
- edge_source, edge_target = umldi_ends(owner_id, placed)
171
- source = by_duid[style.soid] || sole_placement(by_subject, edge_source)
172
- target = by_duid[style.eoid] || sole_placement(by_subject, edge_target)
212
+ edge_source, edge_target = connector_ends(owner_id, placed)
213
+ source, target = placed_ends(style, by_duid, by_subject, edge_source, edge_target)
173
214
  ext_meta = connector_meta(placed.subject)
174
215
  route = connector_route(placed, source, target, owner_id, ext_meta[:ea_type])
175
216
  Ea::Model::DiagramConnector.new(
@@ -197,6 +238,23 @@ module Ea
197
238
  )
198
239
  end
199
240
 
241
+ # The saved SOID/EOID can name the model connector's ends in reverse
242
+ # order; the model connector decides which placement is the source.
243
+ def placed_ends(style, by_duid, by_subject, edge_source, edge_target)
244
+ source = by_duid[style.soid] || sole_placement(by_subject, edge_source)
245
+ target = by_duid[style.eoid] || sole_placement(by_subject, edge_target)
246
+ return [source, target] unless source && target &&
247
+ reversed_ends?(source, target, edge_source, edge_target)
248
+
249
+ [target, source]
250
+ end
251
+
252
+ def reversed_ends?(source, target, edge_source, edge_target)
253
+ guid_key(edge_source) != guid_key(edge_target) &&
254
+ guid_key(source.subject) == guid_key(edge_target) &&
255
+ guid_key(target.subject) == guid_key(edge_source)
256
+ end
257
+
200
258
  def connector_meta(subject_id)
201
259
  connector = connector_index[subject_id]
202
260
  return { ea_type: nil, direction: nil } unless connector
@@ -242,7 +300,18 @@ module Ea
242
300
  points = native || compute_waypoints(placed.geometry, source, target,
243
301
  style: placed.style, connector_type: type)
244
302
  native ||= umldi_waypoints(owner_id, placed) if points.empty?
245
- { waypoints: native || points, style: native ? { umldi_route: true } : {} }
303
+ { waypoints: native || points, style: connector_route_style(placed, native) }
304
+ end
305
+
306
+ def connector_route_style(placed, native)
307
+ route_style = native ? { umldi_route: true } : {}
308
+ # Label seats use EA's saved routing mode on every route source;
309
+ # umldi_route still marks UMLDI waypoints as authoritative.
310
+ route_style.merge!(Qea::DiagramStyleParser.parse(placed.style.raw).slice(:mode, :lwidth, :tree))
311
+ # Source-known links without DUIDs use classifier endpoint metadata,
312
+ # not a saved label box that might refer to the opposite end.
313
+ route_style[:explicit_end_roles] = true unless placed.style.soid && placed.style.eoid
314
+ route_style
246
315
  end
247
316
 
248
317
  # EA's unique exported edge supplies bendless routes and recovers
@@ -254,6 +323,15 @@ module Ea
254
323
  route&.map { |x, y| Ea::Model::Waypoint.new(position: Ea::Model::Point.new(x: x, y: y)) }
255
324
  end
256
325
 
326
+ def connector_ends(diagram_id, placed)
327
+ umldi_ends(diagram_id, placed) || extension_ends(placed.subject)
328
+ end
329
+
330
+ def extension_ends(subject)
331
+ connector = connector_index[subject]
332
+ [connector&.source&.idref, connector&.target&.idref]
333
+ end
334
+
257
335
  def umldi_ends(diagram_id, placed)
258
336
  return unless xmi_path
259
337
 
@@ -12,8 +12,9 @@ module Ea
12
12
  class ExtensionElements
13
13
  attr_reader :diagram
14
14
 
15
- def initialize(diagram)
15
+ def initialize(diagram, connector_subjects: {})
16
16
  @diagram = diagram
17
+ @connector_subjects = connector_subjects
17
18
  end
18
19
 
19
20
  def placed_elements
@@ -53,9 +54,9 @@ module Ea
53
54
 
54
55
  placement = ExtensionGeometryParser.parse(row.geometry)
55
56
  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
57
+ # Some EA placements omit both DUIDs. The extension connector
58
+ # index still identifies these rows without treating shapes as links.
59
+ return nil unless (style.soid && style.eoid) || @connector_subjects.key?(row.subject)
59
60
 
60
61
  PlacedConnector.new(
61
62
  subject: row.subject,
@@ -45,12 +45,30 @@ module Ea
45
45
  def package_import_relationships(built)
46
46
  built_ids = built.to_set(&:id)
47
47
  connector_index.filter_map do |id, connector|
48
- next if built_ids.include?(id) || connector.properties&.ea_type != "Package"
48
+ next if built_ids.include?(id)
49
49
 
50
- package_import_relationship(id, connector)
50
+ extension_import_relationship(id, connector)
51
51
  end
52
52
  end
53
53
 
54
+ def extension_import_relationship(id, connector)
55
+ case connector.properties&.ea_type
56
+ when "Package" then package_import_relationship(id, connector)
57
+ when "Dependency"
58
+ dependency_import_relationship(id, connector) if connector_stereotype(connector) == "import"
59
+ end
60
+ end
61
+
62
+ # EA exports elementImport outside packagedElement, so the model walk
63
+ # misses it; the extension retains the importing/imported endpoints.
64
+ def dependency_import_relationship(id, connector)
65
+ Ea::Model::Dependency.new(
66
+ id: id, stereotype: "import",
67
+ client_id: connector.source&.idref,
68
+ supplier_id: connector.target&.idref
69
+ )
70
+ end
71
+
54
72
  def connector_stereotype(connector)
55
73
  connector&.xrefs&.value.to_s[CONNECTOR_STEREOTYPE]
56
74
  end
@@ -64,10 +64,37 @@ module Ea
64
64
 
65
65
  def bent_points(src, bends, tgt, link, connector, source, target)
66
66
  points = [src, *bends, tgt]
67
- redock_parent_above(points, link, source, target) if connector.connector_type == "Generalization"
67
+ if connector.connector_type == "Generalization"
68
+ preserve_side_entry = authored_lateral_side_entry?(points, bends, link, source, target)
69
+ redock_parent_above(points, link, source, target)
70
+ points[-1] = [points[-1][0], points[1][1]] if preserve_side_entry
71
+ end
68
72
  points
69
73
  end
70
74
 
75
+ # A single LH tree bend can be the authored side-entry level itself.
76
+ # Parent-above redocking normally moves a Generalization to the target
77
+ # bottom, which destroys that horizontal leg and disagrees with QEA.
78
+ def authored_lateral_side_entry?(points, bends, link, source, target)
79
+ return false unless bends.one? && points.size == 3 && lateral_side_entry_style?(link) &&
80
+ target.y + target.height <= source.y
81
+
82
+ level_on_target_side?(points[1], target)
83
+ end
84
+
85
+ def lateral_side_entry_style?(link)
86
+ style = Qea::DiagramStyleParser.parse(link.style)
87
+ style[:mode] == "3" && style[:tree] == "LH" && style[:lwidth] == "2"
88
+ end
89
+
90
+ def within_width?(x, bounds)
91
+ x.between?(bounds.x, bounds.x + bounds.width)
92
+ end
93
+
94
+ def level_on_target_side?(bend, target)
95
+ bend[1].between?(target.y, target.y + target.height) && !within_width?(bend[0], target)
96
+ end
97
+
71
98
  def finish(points, link, connector, source, target, bends, ports, offsets, edge)
72
99
  tree_route = stored_tree_route(points, link, connector, source, target, ports)
73
100
  if tree_route
@@ -94,8 +94,9 @@ module Ea
94
94
  end
95
95
 
96
96
  def content_x_offset(theme, size, size_unit, family)
97
- # EA uses the attribute row point-size increment for literal names.
98
- offset = theme.attribute_spec.content_x_offset || 26
97
+ # EA seats literal names at the attribute-name content x, with the
98
+ # attribute row point-size increment.
99
+ offset = theme.attribute_content_x_offset || 26
99
100
  offset += 2 * (size - 7) if size_unit == "pt"
100
101
  offset -= 1 if Ea::Fonts::Metrics.compact_compartment_font?(size, family: family, unit: size_unit)
101
102
  offset
@@ -19,11 +19,12 @@ module Ea
19
19
  # self-loop, an authoritative UMLDI route, or no drawn bounds for either end).
20
20
  def pairs_for(connector, diagram, model_index, bounds_map)
21
21
  return nil unless bounds_map
22
- return nil if connector.style.to_h[:umldi_route] == true
23
22
 
24
23
  tether = association_class_pairs(connector, bounds_map)
25
24
  return tether if tether
26
25
 
26
+ return nil if connector.style.to_h[:umldi_route] == true
27
+
27
28
  points = waypoint_pairs(connector)
28
29
  lateral = lateral_tree?(connector, points)
29
30
  trunk = trunk_tree?(connector, points)
@@ -19,10 +19,10 @@ module Ea
19
19
  def self.render(tagged_values, bounds:, first_y:, family:, size:, fill: "#000000",
20
20
  size_unit: "px", logical_height: nil)
21
21
  line_h = line_height(size, family, size_unit, logical_height)
22
- text_blocks = [build_text(bounds.x + centered_x_offset(bounds, family, size, size_unit, logical_height),
22
+ text_blocks = [build_text(header_x(bounds, family, size, size_unit, logical_height),
23
23
  first_y, HEADER_TEXT, family, size, fill,
24
24
  style: "italic", size_unit: size_unit, logical_height: logical_height)]
25
- tagged_values.each_with_index do |tv, idx|
25
+ ordered_values(tagged_values).each_with_index do |tv, idx|
26
26
  text_blocks << build_text(bounds.x + LINE_X_OFFSET, first_y + ((idx + 1) * line_h),
27
27
  "#{tv.key} = #{tv.value}", family, size, fill,
28
28
  size_unit: size_unit, logical_height: logical_height)
@@ -30,6 +30,11 @@ module Ea
30
30
  %(<g style="#{Style::TEXT_GROUP}">\n#{text_blocks.join("\n")}\n</g>)
31
31
  end
32
32
 
33
+ def self.ordered_values(tagged_values)
34
+ tagged_values.sort_by { |tv| tv.key.to_s.downcase }
35
+ end
36
+ private_class_method :ordered_values
37
+
33
38
  def self.build_text(x, y, content, family, size, fill, style: "normal",
34
39
  size_unit: "px", logical_height: nil)
35
40
  TextRenderer.new(content: content, x: x, y: y,
@@ -48,7 +53,7 @@ module Ea
48
53
  private_class_method :line_height
49
54
 
50
55
  # Center the "tags" header text within the bounds width.
51
- def self.centered_x_offset(bounds, family, size, size_unit, logical_height)
56
+ def self.header_x(bounds, family, size, size_unit, logical_height)
52
57
  # QEA point captions center on the same integer width emitted in SVG.
53
58
  # Retain the legacy pixel API's placement.
54
59
  text_width = if size_unit == "pt"
@@ -60,9 +65,9 @@ module Ea
60
65
  HEADER_TEXT.length * (size / 2)
61
66
  end
62
67
  centered = ((bounds.width - text_width) / 2.0).floor
63
- [HEADER_X_OFFSET, centered].max
68
+ bounds.x + [HEADER_X_OFFSET, centered].max
64
69
  end
65
- private_class_method :centered_x_offset
70
+ private_class_method :header_x
66
71
  end
67
72
  end
68
73
  end
@@ -66,7 +66,7 @@ module Ea
66
66
  if role_empty?(role) && mult_empty?(mult)
67
67
  other_from_prop, other_role, other_mult = role_and_multiplicity(connector,
68
68
  opposite(end_kind))
69
- if stored_labels
69
+ if stored_labels || connector.style.to_h[:explicit_end_roles]
70
70
  return [] if role_empty?(other_role) && mult_empty?(other_mult)
71
71
 
72
72
  return own_stereotype_only(text_box, anchor, connector,
@@ -138,8 +138,8 @@ module Ea
138
138
  @label_font_size_unit || font_size_unit
139
139
  end
140
140
 
141
- # Attribute cells have a different inherited seat from enum literals,
142
- # which continue to consume the legacy attribute_spec default.
141
+ # Content x of attribute and enum-literal names; a theme's own
142
+ # attribute_spec overrides the inherited seat.
143
143
  def attribute_content_x_offset
144
144
  @attribute_spec_override ? attribute_spec.content_x_offset : 22
145
145
  end
data/lib/ea/version.rb CHANGED
@@ -4,5 +4,5 @@ module Ea
4
4
  # Gem release version. Extracted to its own file so the
5
5
  # `gem-release` tool's `gem bump` can find and update it via the
6
6
  # standard `lib/<gem>/version.rb` convention.
7
- VERSION = "0.6.135"
7
+ VERSION = "0.6.137"
8
8
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ea
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.6.135
4
+ version: 0.6.137
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ribose Inc.
@@ -852,6 +852,7 @@ files:
852
852
  - lib/ea/sources/xmi/annotation_builder.rb
853
853
  - lib/ea/sources/xmi/classifier_builder.rb
854
854
  - lib/ea/sources/xmi/diagram_builder.rb
855
+ - lib/ea/sources/xmi/diagram_builder/association_class_metadata.rb
855
856
  - lib/ea/sources/xmi/diagram_builder/connector_metadata.rb
856
857
  - lib/ea/sources/xmi/diagram_builder/font_settings.rb
857
858
  - lib/ea/sources/xmi/diagram_builder/stereotype_profiles.rb