ea 0.6.134 → 0.6.136

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: 3320f99ef26e1639eaa88eba212fe6620cc7c25b5337681431bf1ba991c384c1
4
- data.tar.gz: 3a72b42b4c3dd40b4254ec74684b3e74a25e6f35f1efc85ec8e7bb1fc59f113b
3
+ metadata.gz: 121e254bdf4c232789483be3fc0de9f65e0b70a89c34009db6421d9dcae1337d
4
+ data.tar.gz: 1f11075a36fe5f7208b264f4f10e409230afe184101157ba41a3bf05fd57efb6
5
5
  SHA512:
6
- metadata.gz: 8ce24e25f39d48215c9dcbe03b8c86a932ffd310d64f228e6e5603448814faf84fc023f1a07dae736f4dabb926970b86d1bd478e51d640366462c1765a643796
7
- data.tar.gz: 19a7f0f2e20a3985c32de44288755f7daa7f5f35e41908c6491a9ada31562059c20826e75d7482ad494b8e9c2e15ab32f5635f1801607c359f7fcdc6a455ea50
6
+ metadata.gz: f924fea92342d3525a5fb5ea6b1b8c0167afc5f709d1c373a52b178a263efa29c9adc8bf690c4fba1073053a2f75c5888abbd509dcd187c39a57a0ab4c089286
7
+ data.tar.gz: 5c558dcefad07838fd403fa8eae5a9e7bf00e802348de364d28adb692e02bb76a6de2909139570431bb96a364955c276a5a356b05ba5107ce6906f0aa03315a5
@@ -35,6 +35,7 @@ module Ea
35
35
  instance_specifications: instance_specifications,
36
36
  diagrams: diagrams
37
37
  )
38
+ mark_connector_proxies(doc)
38
39
  apply_tagged_values(doc)
39
40
  apply_receptions(doc)
40
41
  apply_stereotypes(doc)
@@ -44,6 +45,23 @@ module Ea
44
45
 
45
46
  private
46
47
 
48
+ # EA serializes connector anchors as UML class stubs. Keep their
49
+ # identity and geometry while distinguishing their graphical role.
50
+ def mark_connector_proxies(doc)
51
+ proxy_ids = connector_proxy_ids
52
+ return if proxy_ids.empty?
53
+
54
+ doc.diagrams.flat_map(&:elements).each do |element|
55
+ element.style["connector_proxy"] = true if proxy_ids.include?(element.model_element_ref)
56
+ end
57
+ end
58
+
59
+ def connector_proxy_ids
60
+ (root.extension&.elements&.element || []).filter_map do |element|
61
+ IdNormalizer.from_xmi_id(element.idref) if element.type == "uml:ProxyConnector"
62
+ end
63
+ end
64
+
47
65
  def apply_receptions(doc)
48
66
  grouped = ReceptionBuilder.new(xmi_path).grouped_by_owner
49
67
  doc.classifiers.each do |classifier|
@@ -99,10 +117,9 @@ module Ea
99
117
  end
100
118
  end
101
119
 
102
- # Replaces each type reference with its in-model qualified
103
- # name; references that are not classifiers (primitive hrefs,
104
- # EA placeholder idrefs) take the type name EA stores in the
105
- # extension block.
120
+ # Replaces each type reference with the type name EA stores in
121
+ # the extension block; references the extension does not name
122
+ # fall back to the classifier's in-model qualified name.
106
123
  def resolve_type_names(doc)
107
124
  qualified_by_id = doc.classifiers.each_with_object({}) do |c, acc|
108
125
  acc[c.id] = c.qualified_name if c.id && c.qualified_name
@@ -111,8 +128,8 @@ module Ea
111
128
 
112
129
  doc.classifiers.each do |classifier|
113
130
  (classifier.properties || []).each do |prop|
114
- prop.type_name = qualified_by_id[prop.type_ref] ||
115
- types.attribute_type(prop.id) || prop.type_name
131
+ prop.type_name = types.attribute_type(prop.id) ||
132
+ qualified_by_id[prop.type_ref] || prop.type_name
116
133
  end
117
134
  (classifier.operations || []).each do |op|
118
135
  resolve_operation_types(op, qualified_by_id, types)
@@ -121,12 +138,12 @@ module Ea
121
138
  end
122
139
 
123
140
  def resolve_operation_types(operation, qualified_by_id, types)
124
- operation.return_type_name = qualified_by_id[operation.return_type_name] ||
125
- types.return_type(operation.id) ||
141
+ operation.return_type_name = types.return_type(operation.id) ||
142
+ qualified_by_id[operation.return_type_name] ||
126
143
  operation.return_type_name
127
144
  operation.parameters.each do |param|
128
- param.type_name = qualified_by_id[param.type_name] ||
129
- types.parameter_type(param.id) || param.type_name
145
+ param.type_name = types.parameter_type(param.id) ||
146
+ qualified_by_id[param.type_name] || param.type_name
130
147
  end
131
148
  end
132
149
 
@@ -16,41 +16,49 @@ module Ea
16
16
 
17
17
  def build_all
18
18
  classifiers = []
19
- walk(root.model) do |element, parent_pkg|
20
- next unless TypeClassifierMap.classifier_type?(element.type)
19
+ root.model.packaged_element.each do |top|
20
+ walk(top, top.type == "uml:Package" ? top : nil) do |element, parent_pkg, owner_class|
21
+ next unless TypeClassifierMap.classifier_type?(element.type)
21
22
 
22
- classifiers << build_one(element, parent_pkg)
23
+ classifiers << build_one(element, parent_pkg, owner_class)
24
+ end
23
25
  end
24
26
  classifiers
25
27
  end
26
28
 
27
- def build_one(element, containing_package)
29
+ def build_one(element, containing_package, owner_class = nil)
28
30
  klass = TypeClassifierMap.class_for(element.type)
29
- args = common_args(element, containing_package)
31
+ args = common_args(element, containing_package, owner_class)
30
32
  args.merge!(enumeration_args(element)) if klass == Ea::Model::Enumeration
31
33
  klass.new(**args)
32
34
  end
33
35
 
34
36
  private
35
37
 
36
- # Depth-first walk of the packaged_element tree. Yields each
37
- # element with its containing uml:Package (or nil at the
38
- # model root).
39
- def walk(element, parent_pkg = nil, &block)
40
- yield element, parent_pkg if block
38
+ # Depth-first walk of the packaged_element and nestedClassifier
39
+ # trees. Yields each element with its containing uml:Package
40
+ # (nil at the model root) and the uml:Class that nests it
41
+ # (nil unless it is a nestedClassifier of a class).
42
+ def walk(element, parent_pkg = nil, owner_class = nil, &block)
43
+ yield element, parent_pkg, owner_class if block
41
44
  element.packaged_element.each do |inner|
42
45
  new_parent = inner.type == "uml:Package" ? inner : parent_pkg
43
- walk(inner, new_parent, &block)
46
+ walk(inner, new_parent, nil, &block)
47
+ end
48
+ element.nested_classifier.each do |inner|
49
+ walk(inner, parent_pkg, element, &block)
44
50
  end
45
51
  end
46
52
 
47
- def common_args(element, containing_package)
53
+ def common_args(element, containing_package, owner_class)
48
54
  id = IdNormalizer.from_xmi_id(element.id)
55
+ nested_parent = owner_class&.name
49
56
  {
50
57
  id: id,
51
58
  name: element.name,
52
59
  package_id: containing_package && IdNormalizer.from_xmi_id(containing_package.id),
53
- qualified_name: qualified_name(element, containing_package),
60
+ nested_parent_name: nested_parent,
61
+ qualified_name: qualified_name(element, containing_package, nested_parent),
54
62
  is_abstract: boolean(element.is_abstract),
55
63
  visibility: element.visibility,
56
64
  properties: PropertyBuilder.new.build_all_for(element),
@@ -83,10 +91,12 @@ module Ea
83
91
  end
84
92
 
85
93
  # A name that already carries a namespace prefix
86
- # (e.g. "gml:CodeType") is the qualified name as saved.
87
- def qualified_name(element, containing_package)
94
+ # (e.g. "gml:CodeType") is the qualified name as saved; a
95
+ # nested classifier is prefixed with its owner class.
96
+ def qualified_name(element, containing_package, nested_parent)
88
97
  name = element.name.to_s
89
98
  return name if name.include?(":")
99
+ return "#{nested_parent}::#{name}" if nested_parent
90
100
 
91
101
  pkg_name = containing_package&.name
92
102
  pkg_name ? "#{pkg_name}::#{name}" : name
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "nokogiri"
4
+
5
+ module Ea
6
+ module Sources
7
+ module Xmi
8
+ class DiagramBuilder
9
+ # The XMI gem omits role stereotypes. Stream the saved connector
10
+ # records once, without retaining another full XML document.
11
+ class ConnectorMetadata
12
+ def initialize(xmi_path)
13
+ @xmi_path = xmi_path
14
+ end
15
+
16
+ def for(connector)
17
+ {
18
+ source_stereotype: role_stereotypes.dig(connector.idref, :source),
19
+ target_stereotype: role_stereotypes.dig(connector.idref, :target),
20
+ is_substitutable: false_substitutability?(connector.xrefs&.value.to_s)
21
+ }
22
+ end
23
+
24
+ private
25
+
26
+ def false_substitutability?(xrefs)
27
+ xrefs.scan(/\$XREFPROP=(.*?)\$ENDXREF;/m).any? do |match|
28
+ record = match.first
29
+ next false unless record.include?("$NAM=CustomProperties$NAM;") &&
30
+ record.include?("$TYP=connector property$TYP;")
31
+
32
+ values = record.scan(/@PROP=(.*?)@ENDPROP;/m).filter_map do |property|
33
+ substitutability_value(property.first)
34
+ end
35
+ !values.empty? && !values.include?("-1")
36
+ end
37
+ end
38
+
39
+ def substitutability_value(property)
40
+ return unless property.include?("@NAME=isSubstitutable@ENDNAME;") &&
41
+ property.include?("@TYPE=Boolean@ENDTYPE;")
42
+
43
+ property[/@VALU=(.*?)@ENDVALU;/m, 1]
44
+ end
45
+
46
+ def role_stereotypes
47
+ @role_stereotypes ||= read_role_stereotypes
48
+ end
49
+
50
+ def read_role_stereotypes
51
+ return {} unless @xmi_path
52
+
53
+ File.open(@xmi_path) do |file|
54
+ Nokogiri::XML::Reader(file).each_with_object({}) do |node, roles|
55
+ next unless node.node_type == Nokogiri::XML::Reader::TYPE_ELEMENT && node.local_name == "connector"
56
+
57
+ read_connector_roles(node, roles)
58
+ end
59
+ end
60
+ end
61
+
62
+ def read_connector_roles(node, roles)
63
+ connector = Nokogiri::XML(node.outer_xml).root
64
+ id = connector.at_xpath("@*[local-name()='idref']")&.value
65
+ return unless id
66
+
67
+ roles[id] = %i[source target].to_h do |side|
68
+ value = connector.at_xpath("./*[local-name()='#{side}']/*[local-name()='role']/@stereotype")&.value
69
+ [side, value]
70
+ end
71
+ end
72
+ end
73
+ end
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "nokogiri"
4
+
5
+ module Ea
6
+ module Sources
7
+ module Xmi
8
+ class DiagramBuilder
9
+ # The XMI gem exposes placement fonts but drops object styleex.
10
+ # Read saved defaults once, then let each placement override them.
11
+ class FontSettings
12
+ def initialize(xmi_path)
13
+ @xmi_path = xmi_path
14
+ end
15
+
16
+ def for(placed)
17
+ style = placed.style
18
+ defaults = object_styles.fetch(placed.subject, {})
19
+ placement = ExtensionStyleParser.split_pairs(style.raw.to_s)
20
+ raw_size = saved_size(placement) || saved_size(defaults)
21
+ {
22
+ family: presence(style.font_family) || defaults["font"],
23
+ size: style.font_size || ExtensionStyleParser.scale_font_size(raw_size),
24
+ logical_height: logical_height(raw_size)
25
+ }
26
+ end
27
+
28
+ private
29
+
30
+ def presence(value)
31
+ value unless value.nil? || value.empty?
32
+ end
33
+
34
+ def saved_size(style)
35
+ value = style["fontsz"]
36
+ value if ExtensionStyleParser.int(value)&.nonzero?
37
+ end
38
+
39
+ def logical_height(value)
40
+ tenths = ExtensionStyleParser.int(value)
41
+ tenths * 96 / 720 if tenths
42
+ end
43
+
44
+ def object_styles
45
+ @object_styles ||= saved_objects.each_with_object({}) do |element, styles|
46
+ id = element.at_xpath("@*[local-name()='idref']")&.value
47
+ style = element.at_xpath("./*[local-name()='style']/@styleex")&.value
48
+ styles[id] = ExtensionStyleParser.split_pairs(style) if id && style
49
+ end
50
+ end
51
+
52
+ def saved_objects
53
+ return [] unless @xmi_path
54
+
55
+ doc = Nokogiri::XML(File.read(@xmi_path))
56
+ doc.xpath("//*[local-name()='Extension']/*[local-name()='elements']/*[local-name()='element']")
57
+ end
58
+ end
59
+ end
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ea
4
+ module Sources
5
+ module Xmi
6
+ class DiagramBuilder
7
+ # Keep graphical spelling provenance separate from semantic stereotype names.
8
+ class StereotypeProfiles
9
+ def initialize(root)
10
+ @elements = root.extension&.elements&.element.to_a.to_h { |element| [element.idref, element] }
11
+ end
12
+
13
+ def for(subject)
14
+ element = @elements[subject]
15
+ name = element&.properties&.stereotype
16
+ return {} if name.to_s.empty?
17
+
18
+ fqname = matching_profile(element.xrefs&.value.to_s, name)
19
+ fqname ? { stereotype_fqname: fqname } : {}
20
+ end
21
+
22
+ private
23
+
24
+ def matching_profile(value, name)
25
+ value.split("@STEREO;").drop(1).each do |block|
26
+ description = "@STEREO;#{block.partition("@ENDSTEREO;").first}@ENDSTEREO;"
27
+ application = Qea::Xref::Parser.parse(description).stereotype
28
+ return application.fqname if application.name == name
29
+ end
30
+ nil
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
@@ -9,6 +9,9 @@ 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 :ConnectorMetadata, "ea/sources/xmi/diagram_builder/connector_metadata"
13
+ autoload :FontSettings, "ea/sources/xmi/diagram_builder/font_settings"
14
+ autoload :StereotypeProfiles, "ea/sources/xmi/diagram_builder/stereotype_profiles"
12
15
  autoload :UmldiEdges, "ea/sources/xmi/diagram_builder/umldi_edges"
13
16
 
14
17
  attr_reader :root, :xmi_path
@@ -58,6 +61,8 @@ module Ea
58
61
  show_notes: style_ex_for(ext_diagram).to_s.include?("ShowNotes=1"),
59
62
  show_package_contents: style_flag?(ext_diagram, "PackageContents"),
60
63
  show_foreign: style_flag?(ext_diagram, "HighlightForeign"),
64
+ show_parents: !style_flag?(ext_diagram, "HideParents"),
65
+ hand_draw: style_flag?(ext_diagram, "HandDraw"),
61
66
  elements: build_elements(placed_elements, id, umldi_keywords),
62
67
  connectors: build_connectors(placed_connectors,
63
68
  placed_elements, id),
@@ -113,6 +118,7 @@ module Ea
113
118
  geom = placed.geometry
114
119
  style = placed.style
115
120
  bounds = build_bounds(geom.left, geom.top, geom.right, geom.bottom)
121
+ font = (@font_settings ||= FontSettings.new(xmi_path)).for(placed)
116
122
  Ea::Model::DiagramElement.new(
117
123
  id: IdNormalizer.synthetic_id(owner_id, "elem", placed.subject),
118
124
  diagram_id: owner_id,
@@ -122,8 +128,10 @@ module Ea
122
128
  background_color: style.background_color,
123
129
  line_color: style.line_color,
124
130
  line_width: style.line_width,
125
- font_family: style.font_family,
126
- font_size: style.font_size,
131
+ font_family: font[:family],
132
+ font_size: font[:size],
133
+ font_logical_height: font[:logical_height],
134
+ font_size_inherited: ExtensionStyleParser.split_pairs(style.raw.to_s)["fontsz"] == "0",
127
135
  font_bold: style.bold,
128
136
  font_italic: style.italic,
129
137
  font_underline: style.underline,
@@ -132,7 +140,7 @@ module Ea
132
140
  z_order: placed.seqno,
133
141
  duid: style.duid,
134
142
  umldi_keyword: umldi_keywords[placed.subject],
135
- style: {}
143
+ style: (@stereotype_profiles ||= StereotypeProfiles.new(root)).for(placed.subject)
136
144
  )
137
145
  end
138
146
 
@@ -177,6 +185,9 @@ module Ea
177
185
  target_edge: geom.edge,
178
186
  connector_type: ext_meta[:ea_type],
179
187
  direction: ext_meta[:direction],
188
+ source_stereotype: ext_meta[:source_stereotype],
189
+ target_stereotype: ext_meta[:target_stereotype],
190
+ is_substitutable: ext_meta.fetch(:is_substitutable, false),
180
191
  waypoints: route[:waypoints],
181
192
  style: route[:style],
182
193
  line_color: style.color,
@@ -194,7 +205,11 @@ module Ea
194
205
  {
195
206
  ea_type: props&.ea_type,
196
207
  direction: props&.direction
197
- }
208
+ }.merge(connector_metadata.for(connector))
209
+ end
210
+
211
+ def connector_metadata
212
+ @connector_metadata ||= ConnectorMetadata.new(xmi_path)
198
213
  end
199
214
 
200
215
  def connector_index
@@ -60,12 +60,16 @@ module Ea
60
60
  end
61
61
 
62
62
  def child_classifier_ids(packaged_element)
63
- packaged_element.packaged_element
64
- .filter_map do |pe|
65
- next unless TypeClassifierMap.classifier_type?(pe.type)
63
+ packaged_element.packaged_element.flat_map do |element|
64
+ classifier_ids(element)
65
+ end
66
+ end
67
+
68
+ def classifier_ids(element)
69
+ return [] unless TypeClassifierMap.classifier_type?(element.type)
66
70
 
67
- IdNormalizer.from_xmi_id(pe.id)
68
- end
71
+ [IdNormalizer.from_xmi_id(element.id),
72
+ *element.nested_classifier.flat_map { |inner| classifier_ids(inner) }].compact
69
73
  end
70
74
  end
71
75
  end
@@ -24,11 +24,11 @@ module Ea
24
24
 
25
25
  def build_all
26
26
  relationships = []
27
- walk(root.model) do |element|
28
- next unless RelationshipTypeMap.relationship_type?(element.type)
29
-
30
- rel = build_one(element)
31
- relationships << rel if rel
27
+ root.model.packaged_element.each do |top|
28
+ walk(top) do |element|
29
+ relationships << build_one(element) if RelationshipTypeMap.relationship_type?(element.type)
30
+ relationships.concat(owned_generalizations(element))
31
+ end
32
32
  end
33
33
  relationships + package_import_relationships(relationships)
34
34
  end
@@ -69,6 +69,17 @@ module Ea
69
69
  def walk(element, &block)
70
70
  yield element if block
71
71
  element.packaged_element.each { |inner| walk(inner, &block) }
72
+ element.nested_classifier.each { |inner| walk(inner, &block) }
73
+ end
74
+
75
+ def owned_generalizations(element)
76
+ element.generalization.map do |generalization|
77
+ Ea::Model::Generalization.new(
78
+ id: IdNormalizer.from_xmi_id(generalization.id),
79
+ specific_id: IdNormalizer.from_xmi_id(element.id),
80
+ general_id: IdNormalizer.from_xmi_id(generalization.general)
81
+ )
82
+ end
72
83
  end
73
84
 
74
85
  def common_args(element)
@@ -40,14 +40,24 @@ module Ea
40
40
 
41
41
  private
42
42
 
43
- # Text of the shape's UMLKeywordLabel child; name labels and
44
- # other labelled children never contribute a keyword.
43
+ # EA can save both a built-in keyword and a custom stereotype label.
44
+ # Prefer the label matching the element's saved stereotype, if present.
45
45
  def keyword_text(shape)
46
- label = shape.xpath("./ownedElement[@*[local-name()=\"type\"]=\"umldi:UMLKeywordLabel\"]").first
46
+ labels = shape.xpath("./ownedElement[@*[local-name()=\"type\"]=\"umldi:UMLKeywordLabel\"]")
47
+ stereotype = stereotypes_by_element[shape["modelElement"]]
48
+ label = labels.find { |candidate| stereotype && candidate["text"] == stereotype } || labels.first
47
49
  text = label && label["text"]
48
50
  text unless text.nil? || text.empty?
49
51
  end
50
52
 
53
+ def stereotypes_by_element
54
+ @stereotypes_by_element ||= doc.xpath("//*[local-name()=\"Extension\"]/elements/element")
55
+ .each_with_object({}) do |element, result|
56
+ stereotype = element.at_xpath("./properties")&.[]("stereotype")
57
+ result[element["idref"] || element["xmi:idref"]] = stereotype if stereotype
58
+ end
59
+ end
60
+
51
61
  def doc
52
62
  @doc ||= Nokogiri::XML(File.read(xmi_path))
53
63
  end
@@ -11,7 +11,9 @@ module Ea
11
11
  # boundaries to fit the element bounds.
12
12
  module NoteText
13
13
  X_PADDING = 5
14
+ HAND_DRAW_X_PADDING = 10
14
15
  Y_FIRST_OFFSET = 15
16
+ HAND_DRAW_Y_FIRST_OFFSET = 11
15
17
  LINE_OFFSET = 13
16
18
  CHAR_WIDTH_FACTOR = 0.55
17
19
 
@@ -24,23 +26,30 @@ module Ea
24
26
  notes = note_bodies(context.classifier)
25
27
  return nil if notes.empty?
26
28
 
27
- first_y = context.geometry.attr_first_y + Y_FIRST_OFFSET
29
+ x_padding, y_offset = note_offsets(context)
30
+ first_y = context.geometry.attr_first_y + y_offset
28
31
  bounds = context.bounds
29
32
  fill = context.theme.attribute_text_color
30
- usable_width = bounds.width - (X_PADDING * 2)
33
+ usable_width = bounds.width - (x_padding * 2)
31
34
  max_chars = (usable_width / (context.size * CHAR_WIDTH_FACTOR)).floor
32
35
 
33
36
  texts = []
34
37
  y = first_y
35
38
  notes.each do |body|
36
39
  wrapped_lines(body, max_chars).each do |line|
37
- texts << build_text(line, bounds.x + X_PADDING, y, context, fill)
40
+ texts << build_text(line, bounds.x + x_padding, y, context, fill)
38
41
  y += LINE_OFFSET
39
42
  end
40
43
  end
41
44
  wrap_group(texts.join("\n"), fill)
42
45
  end
43
46
 
47
+ # Native hand-drawn classifiers use a wider documentation inset.
48
+ def note_offsets(context)
49
+ context.diagram.hand_draw ? [HAND_DRAW_X_PADDING, HAND_DRAW_Y_FIRST_OFFSET] : [X_PADDING, Y_FIRST_OFFSET]
50
+ end
51
+ module_function :note_offsets
52
+
44
53
  def show_notes?(context)
45
54
  context.diagram && context.diagram.show_notes
46
55
  end
@@ -67,7 +76,9 @@ module Ea
67
76
  elsif (current + " " + word).length <= max_chars
68
77
  current = "#{current} #{word}"
69
78
  else
70
- lines << current
79
+ # EA retains the consumed word separator on a wrapped
80
+ # documentation row, including it in SVG textLength.
81
+ lines << "#{current} "
71
82
  current = word
72
83
  end
73
84
  end
@@ -90,8 +90,8 @@ module Ea
90
90
  end
91
91
 
92
92
  # Builds attribute display lines from classifier properties,
93
- # hiding properties that are navigable association ends
94
- # (those are rendered as connector lines).
93
+ # hiding association-end properties even when their connectors
94
+ # are not drawn on the diagram.
95
95
  #
96
96
  # When a property carries a stereotype (e.g. «voidable»),
97
97
  # EA renders the stereotype label as a separate line above
@@ -171,19 +171,11 @@ module Ea
171
171
  end
172
172
  private_class_method :build_text
173
173
 
174
- # Association-end properties render in the box only when
175
- # their association is NOT drawn on the diagram (drawn ones
176
- # render as connector role labels). exclude_association_ids
177
- # carries the diagram's drawn association ids; nil preserves
178
- # the legacy hide-all behavior.
179
- def self.displayable_properties(classifier, exclude_association_ids)
180
- return nil unless classifier.properties
181
-
182
- classifier.properties.reject do |prop|
183
- prop.association_id &&
184
- (exclude_association_ids.nil? ||
185
- exclude_association_ids.include?(prop.association_id))
186
- end
174
+ # Association ends remain in the model for connector role labels,
175
+ # but never become class-compartment attribute rows. The public
176
+ # exclude_association_ids keyword remains accepted for callers.
177
+ def self.displayable_properties(classifier, _exclude_association_ids)
178
+ classifier.properties&.reject(&:association_id)
187
179
  end
188
180
  private_class_method :displayable_properties
189
181
 
@@ -31,7 +31,7 @@ module Ea
31
31
  # @return [Array<String>] «name» labels (EA renders one
32
32
  # line per applied stereotype)
33
33
  def self.label_for(classifier, umldi_keyword, stereotype_fqname = nil)
34
- return ["«#{umldi_keyword}»"] if umldi_keyword && !umldi_keyword.empty?
34
+ return ["«#{canonical(umldi_keyword, stereotype_fqname)}»"] if umldi_keyword && !umldi_keyword.empty?
35
35
 
36
36
  explicit = explicit_stereotype(classifier, stereotype_fqname)
37
37
  return explicit if explicit
@@ -61,6 +61,8 @@ module Ea
61
61
  return [] unless context
62
62
 
63
63
  record_drawn_bounds(element, context)
64
+ return [] if element.style&.fetch("connector_proxy", false)
65
+
64
66
  Compartment.render_all(context).compact
65
67
  end
66
68
 
@@ -341,9 +341,21 @@ module Ea
341
341
  inset, baseline = property_role_offsets(direction)
342
342
  left = property_role_x(direction, x, inset + width)
343
343
  left = [left, MIN_ROLE_X].max if direction == :up
344
+ left += source_property_centering(connector, direction, box, role)
344
345
  [left, y + baseline]
345
346
  end
346
347
 
348
+ # Center a short role above the wider stereotype without moving
349
+ # the directional cluster's left edge. Other boxes keep their seat.
350
+ def source_property_centering(connector, direction, box, role)
351
+ return 0 unless %i[left down].include?(direction)
352
+ return 0 unless seat_font? && font_weight == 400 && font_style == "normal"
353
+ return 0 unless box.values_at("cy", "alignment") == [35, 1]
354
+
355
+ stereo = "«#{connector_source_dest_stereotype(connector, :source)}»"
356
+ -[stereotype_offset(role, stereo, box), 0].min
357
+ end
358
+
347
359
  def source_property_seat?(connector, direction, box)
348
360
  phantom_property_box?(connector, direction, box) ||
349
361
  (%i[up left right down].include?(direction) &&
@@ -885,8 +897,7 @@ module Ea
885
897
  # relationship guid), falling back to the property index
886
898
  # lookup by association source/target id.
887
899
  if connector&.relationship_ref
888
- placed = placed_classifier_id(association, end_kind)
889
- prop = assoc_end_property(placed, connector.relationship_ref)
900
+ prop = placed_end_property(association, end_kind, connector.relationship_ref)
890
901
  return prop if prop
891
902
  end
892
903
 
@@ -898,13 +909,27 @@ module Ea
898
909
  fallback_property_lookup(id)
899
910
  end
900
911
 
912
+ def placed_end_property(association, end_kind, relationship_ref)
913
+ placed = placed_classifier_id(association, end_kind)
914
+ property = assoc_end_property(placed, relationship_ref)
915
+ property unless property && opposite_role_property?(association, end_kind, property)
916
+ end
917
+
918
+ # A UML attribute belongs to the classifier opposite its role.
919
+ # Do not draw that same role again at its anonymous owner end.
920
+ def opposite_role_property?(association, end_kind, property)
921
+ own = end_kind == :source ? association.source_role_name : association.target_role_name
922
+ other = end_kind == :source ? association.target_role_name : association.source_role_name
923
+ role_empty?(own) && other == property.name
924
+ end
925
+
901
926
  def placed_classifier_id(association, end_kind)
902
927
  end_kind == :source ? association.source_id : association.target_id
903
928
  end
904
929
 
905
- # Find the synthesized association-end property on the
906
- # placed classifier whose association_id matches the
907
- # connector's relationship reference.
930
+ # Named association-end properties carry typed labels. Unnamed
931
+ # XMI member-end references defer to the saved association role
932
+ # and cardinality; direct property-id endpoints are resolved above.
908
933
  def assoc_end_property(classifier_id, relationship_ref)
909
934
  return nil unless classifier_id
910
935
 
@@ -912,7 +937,7 @@ module Ea
912
937
  return nil unless classifier.is_a?(Ea::Model::Classifier)
913
938
 
914
939
  (classifier.properties || []).find do |p|
915
- p.association_id == relationship_ref
940
+ p.association_id == relationship_ref && !p.name.to_s.empty?
916
941
  end
917
942
  end
918
943
 
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.134"
7
+ VERSION = "0.6.136"
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.134
4
+ version: 0.6.136
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ribose Inc.
@@ -852,6 +852,9 @@ 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/connector_metadata.rb
856
+ - lib/ea/sources/xmi/diagram_builder/font_settings.rb
857
+ - lib/ea/sources/xmi/diagram_builder/stereotype_profiles.rb
855
858
  - lib/ea/sources/xmi/diagram_builder/umldi_edges.rb
856
859
  - lib/ea/sources/xmi/extension_elements.rb
857
860
  - lib/ea/sources/xmi/extension_geometry_parser.rb