ea 0.6.136 → 0.6.138

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.
@@ -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
@@ -26,8 +26,174 @@ module Ea
26
26
  finish(points, link, connector, source, target, bends, ports, offsets, edge)
27
27
  end
28
28
 
29
+ # Direct custom-line associations and aggregations retain ray origins as
30
+ # well as their computed boundary ports, just as the QEA builder does.
31
+ def ray_metadata(model, link:, bounds:, offsets:, edge:)
32
+ return {} unless direct_association_ray?(model, bounds, offsets)
33
+
34
+ source, target = bounds.values_at(:source, :target)
35
+ geom = ray_geometry(offsets, edge)
36
+ return {} unless saved_association_ray?(model, link, source, target, geom)
37
+
38
+ ray_points(source, target, offsets, geom)
39
+ end
40
+
41
+ def phantom_waypoints(source, target)
42
+ [bounds_edge_point(source, target), bounds_edge_point(target, source)]
43
+ end
44
+
45
+ # Apply QEA's saved Association stages after the initial route, including
46
+ # UMLDI fallback. A single stored corner and an empty-path stored ray
47
+ # carry mathematical origins that the generic facing-edge route loses.
48
+ def association_route(model, link:, bounds:, offsets:, edge:)
49
+ overlap = umldi_association_metadata(model, link, bounds, offsets, edge)
50
+ return overlap unless overlap.empty?
51
+
52
+ partial = partial_port_metadata(model, link, bounds, offsets, edge)
53
+ return partial unless partial.empty?
54
+
55
+ return {} unless stored_association_candidate?(model, bounds)
56
+
57
+ candidate = association_model(model, bounds, offsets)
58
+ initial = candidate.waypoints
59
+ association_stored_ray(candidate, link, bounds, ray_geometry(offsets, edge), false)
60
+ return {} if candidate.waypoints.equal?(initial)
61
+
62
+ association_metadata(candidate)
63
+ end
64
+
29
65
  private
30
66
 
67
+ def umldi_association_metadata(model, link, bounds, offsets, edge)
68
+ overlap = umldi_overlap_metadata(model, link, bounds, offsets, edge)
69
+ return overlap unless overlap.empty?
70
+
71
+ umldi_stored_ray_metadata(model, link, bounds, offsets, edge)
72
+ end
73
+
74
+ # UMLDI owns the route and ports. Classify a thin saved ray only when
75
+ # its origins pass the shared unstroked-box checks; XMI has no QEA
76
+ # database from which to infer a wider element outline.
77
+ def umldi_stored_ray_metadata(model, link, bounds, offsets, edge)
78
+ return {} unless umldi_thin_ray_candidate?(model, bounds, offsets)
79
+
80
+ candidate = association_model(model, bounds, offsets)
81
+ return {} unless association_ray_candidate?(candidate, link)
82
+ return {} unless association_ray_origins(*bounds.values_at(:source, :target),
83
+ ray_geometry(offsets, edge), "0")
84
+
85
+ { style: model.style.to_h.merge(stored_association_ray: true, association_ray_outline: true) }
86
+ end
87
+
88
+ def umldi_thin_ray_candidate?(model, bounds, offsets)
89
+ model.style[:umldi_route] && model.style[:lwidth] == "0" &&
90
+ bounds.values.all? && offsets.values.flatten.none?(&:nil?)
91
+ end
92
+
93
+ # UMLDI owns the drawn route; retain QEA's overlap origins only for
94
+ # interpreting the stored relative end labels against that route.
95
+ def umldi_overlap_metadata(model, link, bounds, offsets, edge)
96
+ return {} unless umldi_overlap_candidate?(model, bounds, offsets)
97
+
98
+ candidate = association_model(model, bounds, offsets)
99
+ overlap_ray_origins(candidate, link, bounds, ray_geometry(offsets, edge), false)
100
+ return {} unless candidate.style[:overlap_ray]
101
+
102
+ { source_anchor: candidate.source_anchor, target_anchor: candidate.target_anchor,
103
+ has_geometry_offsets: true, style: candidate.style.to_h }
104
+ end
105
+
106
+ def umldi_overlap_candidate?(model, bounds, offsets)
107
+ model.style.to_h.values_at(:umldi_route, :mode, :lwidth) == [true, "2", "0"] &&
108
+ model.waypoints.size == 2 && bounds.values.all? && offsets.values.flatten.none?(&:nil?)
109
+ end
110
+
111
+ # A zero-offset end has no explicit port, but QEA still carries both
112
+ # mathematical origins for the emitter's final boundary redocking.
113
+ def partial_port_metadata(model, link, bounds, offsets, edge)
114
+ return {} unless partial_port_candidate?(model, link, bounds, offsets)
115
+
116
+ source, target = bounds.values_at(:source, :target)
117
+ return {} unless saved_association_ray?(model, link, source, target, ray_geometry(offsets, edge))
118
+
119
+ association_ends(bounds, offsets).merge(has_geometry_offsets: true)
120
+ end
121
+
122
+ def partial_port_candidate?(model, link, bounds, offsets)
123
+ model.connector_type == "Aggregation" && !model.style[:umldi_route] && !link.path.to_s.empty? &&
124
+ direct_association_ray?(model, bounds, offsets) &&
125
+ offsets.values.count { |offset| offset.all?(&:zero?) } == 1
126
+ end
127
+
128
+ def stored_association_candidate?(model, bounds)
129
+ model.connector_type == "Association" && bounds.values.all? &&
130
+ association_ray_style?(model.style.to_h) && model.style[:lwidth] == "2"
131
+ end
132
+
133
+ def association_metadata(model)
134
+ fields = %i[waypoints source_anchor target_anchor source_port target_port has_geometry_offsets]
135
+ fields.to_h { |name| [name, model.public_send(name)] }.merge(
136
+ style: model.style.to_h.reject { |key, _| key == :umldi_route }
137
+ )
138
+ end
139
+
140
+ def association_model(model, bounds, offsets)
141
+ Ea::Model::DiagramConnector.new(
142
+ { connector_type: model.connector_type, style: model.style.to_h.dup, waypoints: model.waypoints,
143
+ has_geometry_offsets: !offsets[:source][0].nil? }.merge(association_ends(bounds, offsets))
144
+ )
145
+ end
146
+
147
+ def association_ends(bounds, offsets)
148
+ %i[source target].each_with_object({}) do |kind, result|
149
+ box = bounds.fetch(kind)
150
+ offset = offsets.fetch(kind)
151
+ port = port_for(box, offset)
152
+ result["#{kind}_port".to_sym] = point_for(port) if port
153
+ result["#{kind}_anchor".to_sym] = association_anchor(box, offset)
154
+ end
155
+ end
156
+
157
+ def association_anchor(box, offset)
158
+ Ea::Model::Point.new(x: box.x + box.width / 2 + offset[0].to_i,
159
+ y: box.y + box.height / 2 + offset[1].to_i)
160
+ end
161
+
162
+ def stored_ray_boxes(_link, bounds)
163
+ bounds.values_at(:source, :target)
164
+ end
165
+
166
+ def direct_association_ray?(model, bounds, offsets)
167
+ %w[Association Aggregation].include?(model.connector_type) && model.waypoints.size == 2 &&
168
+ model.style.to_h.values_at(:mode, :lwidth, :tree) == ["1", "2", nil] &&
169
+ bounds.values.all? && offsets.values.flatten.none?(&:nil?)
170
+ end
171
+
172
+ def ray_geometry(offsets, edge)
173
+ { sx: offsets[:source][0], sy: offsets[:source][1],
174
+ ex: offsets[:target][0], ey: offsets[:target][1], edge: edge }
175
+ end
176
+
177
+ def saved_association_ray?(model, link, source, target, geom)
178
+ (!geom[:sy].zero? || !geom[:ey].zero?) &&
179
+ stored_ray_gap?(source, target, "1", geom[:edge]) &&
180
+ stored_association_path?(model, link, source, target, geom)
181
+ end
182
+
183
+ def ray_points(source, target, offsets, geom)
184
+ ports = [port_for(source, offsets[:source]), port_for(target, offsets[:target])]
185
+ return {} unless ports.all?
186
+
187
+ { source_port: point_for(ports.first), target_port: point_for(ports.last),
188
+ source_anchor: stored_ray_origin(source, geom[:sx], geom[:sy]),
189
+ target_anchor: stored_ray_origin(target, geom[:ex], geom[:ey]),
190
+ has_geometry_offsets: true }
191
+ end
192
+
193
+ def point_for(pair)
194
+ Ea::Model::Point.new(x: pair[0], y: pair[1])
195
+ end
196
+
31
197
  # Placements here are already Ea::Model::Bounds.
32
198
  def bounds_from_rect(bounds)
33
199
  bounds
@@ -64,10 +230,37 @@ module Ea
64
230
 
65
231
  def bent_points(src, bends, tgt, link, connector, source, target)
66
232
  points = [src, *bends, tgt]
67
- redock_parent_above(points, link, source, target) if connector.connector_type == "Generalization"
233
+ if connector.connector_type == "Generalization"
234
+ preserve_side_entry = authored_lateral_side_entry?(points, bends, link, source, target)
235
+ redock_parent_above(points, link, source, target)
236
+ points[-1] = [points[-1][0], points[1][1]] if preserve_side_entry
237
+ end
68
238
  points
69
239
  end
70
240
 
241
+ # A single LH tree bend can be the authored side-entry level itself.
242
+ # Parent-above redocking normally moves a Generalization to the target
243
+ # bottom, which destroys that horizontal leg and disagrees with QEA.
244
+ def authored_lateral_side_entry?(points, bends, link, source, target)
245
+ return false unless bends.one? && points.size == 3 && lateral_side_entry_style?(link) &&
246
+ target.y + target.height <= source.y
247
+
248
+ level_on_target_side?(points[1], target)
249
+ end
250
+
251
+ def lateral_side_entry_style?(link)
252
+ style = Qea::DiagramStyleParser.parse(link.style)
253
+ style[:mode] == "3" && style[:tree] == "LH" && style[:lwidth] == "2"
254
+ end
255
+
256
+ def within_width?(x, bounds)
257
+ x.between?(bounds.x, bounds.x + bounds.width)
258
+ end
259
+
260
+ def level_on_target_side?(bend, target)
261
+ bend[1].between?(target.y, target.y + target.height) && !within_width?(bend[0], target)
262
+ end
263
+
71
264
  def finish(points, link, connector, source, target, bends, ports, offsets, edge)
72
265
  tree_route = stored_tree_route(points, link, connector, source, target, ports)
73
266
  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
@@ -22,9 +22,11 @@ module Ea
22
22
  SEAT_FONT_SIZE = 7
23
23
  SEAT_X_INSET = 3
24
24
  SEAT_STEREOTYPE_GAP = 12
25
- # EA keeps an upward property seat's left edge at diagram x >= 3;
26
- # the stereotype line moves with the role. Measured only for the
27
- # default Cambria 7pt boxed cluster.
25
+ # EA keeps an end-label role's left edge at diagram x >= 3; the
26
+ # stereotype line moves with the role. Measured on the Plateau
27
+ # v5.1 export: of 776 saved end labels, the only three whose
28
+ # UMLDI bounds start left of 3 (47E4600F, CB34B578, 159E9DA0) are
29
+ # drawn at x = 3, and no label is drawn left of it.
28
30
  MIN_ROLE_X = 3
29
31
 
30
32
  attr_reader :canvas, :model_index, :document, :theme,
@@ -61,12 +63,13 @@ module Ea
61
63
  # fallback
62
64
  def texts(text_box:, mult_box:, anchor:, connector:, end_kind:,
63
65
  tree: false, heading: nil, stored_labels: false, bend: nil, source_bounds: nil,
64
- target_face: nil)
66
+ target_face: nil, umldi_bounds: {})
65
67
  from_property, role, mult = role_and_multiplicity(connector, end_kind)
66
68
  if role_empty?(role) && mult_empty?(mult)
67
69
  other_from_prop, other_role, other_mult = role_and_multiplicity(connector,
68
70
  opposite(end_kind))
69
- if stored_labels
71
+ if stored_labels || connector.style.to_h[:explicit_end_roles] ||
72
+ connector.style.to_h[:phantom_outline] == true
70
73
  return [] if role_empty?(other_role) && mult_empty?(other_mult)
71
74
 
72
75
  return own_stereotype_only(text_box, anchor, connector,
@@ -104,7 +107,7 @@ module Ea
104
107
  plain_vertical_role(connector, direction, anchor, text_box, role) ||
105
108
  source_property_seat(connector, direction, anchor, text_box, role) ||
106
109
  plain_upward_seat(connector, direction, anchor, text_box, role) ||
107
- role_seat(direction, anchor, text_box, role) || text_pos
110
+ role_seat(connector, direction, anchor, text_box, role) || text_pos
108
111
  mult_pos = directional_multiplicity(direction, anchor, mult_box, mult) ||
109
112
  horizontal_tree_multiplicity(connector, direction, anchor, mult_box, mult) ||
110
113
  mult_seat(direction, anchor, mult_box) || mult_pos
@@ -149,12 +152,53 @@ module Ea
149
152
  text_pos, mult_pos = stair_positions if stair_positions
150
153
  end
151
154
 
155
+ role_bounds = umldi_bounds[end_kind == :source ? :source_role : :target_role]
156
+ mult_bounds = umldi_bounds[end_kind == :source ? :source_multiplicity : :target_multiplicity]
157
+ text_pos = umldi_label_position(role_bounds) if role_bounds
158
+ centering = role_bounds ? bounds_role_centering(connector, end_kind, role, stereo, text_box) : 0
159
+ text_pos = [text_pos[0] + centering, text_pos[1]] if centering.positive?
160
+ mult_pos = umldi_label_position(mult_bounds) if mult_bounds
161
+ if stereo && role_bounds
162
+ stereo_pos = umldi_stereotype_position(role_bounds, role, stereo, text_box)
163
+ stereo_pos = [role_bounds[:x], stereo_pos[1]] if centering.positive?
164
+ end
165
+
166
+ if text_pos && !role_empty?(role) && min_role_x_cluster?(connector, end_kind, heading, text_box)
167
+ text_pos, stereo_pos = clamp_role_x(text_pos, stereo_pos)
168
+ end
169
+
152
170
  build_texts(role:, mult:, standalone:,
153
- text_pos:, mult_pos:, stereo:, text_box:)
171
+ text_pos:, mult_pos:, stereo:, text_box:, stereo_pos:)
154
172
  end
155
173
 
156
174
  private
157
175
 
176
+ # Measured only for default Cambria 7pt Property roles on a vertical
177
+ # leg rising from the end; other fonts, boxes and legs keep their
178
+ # unclamped seats.
179
+ def min_role_x_cluster?(connector, end_kind, heading, text_box)
180
+ rising_vertical?(heading) && plain_seat_font? &&
181
+ cambria_box?(text_box) && property_label?(connector, end_kind)
182
+ end
183
+
184
+ def rising_vertical?(heading)
185
+ heading && heading[0].zero? && heading[1].negative?
186
+ end
187
+
188
+ def plain_seat_font?
189
+ seat_font? && font_weight == 400 && font_style == "normal"
190
+ end
191
+
192
+ # Shift the role and its stereotype line together when the role
193
+ # would start left of EA's minimum label x.
194
+ def clamp_role_x(text_pos, stereo_pos)
195
+ shift = MIN_ROLE_X - text_pos[0]
196
+ return [text_pos, stereo_pos] unless shift.positive?
197
+
198
+ [[text_pos[0] + shift, text_pos[1]],
199
+ stereo_pos && [stereo_pos[0] + shift, stereo_pos[1]]]
200
+ end
201
+
158
202
  # ALN2 single-line target roles retain their horizontal placement
159
203
  # and use the saved corner's baseline on a straight stored ray.
160
204
  def horizontal_aligned_target_baseline?(connector, heading, box, role)
@@ -190,8 +234,11 @@ module Ea
190
234
  [x - width - SEAT_X_INSET, y - 7] if width
191
235
  end
192
236
 
237
+ # UMLDI routes carry the inferred saved-ray flag; their stored LRT
238
+ # box keeps the plain target seat.
193
239
  def moved_plain_target_ray?(connector, box)
194
- saved_target_ray?(connector) && box.values_at("alignment", "cy") == [1, 16] &&
240
+ saved_target_ray?(connector) && connector.style.to_h[:umldi_route] != true &&
241
+ box.values_at("alignment", "cy") == [1, 16] &&
195
242
  !stored_offsets_zero?(box)
196
243
  end
197
244
 
@@ -318,15 +365,33 @@ module Ea
318
365
  nil
319
366
  end
320
367
 
368
+ # Saved bounds seat a source Property role at the stereotype's left
369
+ # edge. A centred stored box (ALN=1), or a phantom's synthesized box
370
+ # without alignment, centres the shorter role over it; a
371
+ # left-aligned box keeps both lines on that edge.
372
+ def bounds_role_centering(connector, end_kind, role, stereo, text_box)
373
+ return 0 unless end_kind == :source && stereo && seat_font?
374
+ return 0 unless centred_bounds_box?(connector, text_box)
375
+ return 0 unless property_label?(connector, :source)
376
+
377
+ -[stereotype_offset(role, stereo, { "alignment" => 1 }), 0].min
378
+ end
379
+
380
+ # Phantom outlines have no saved diagram link, so their boxes are
381
+ # synthesized and EA draws its centred default label.
382
+ def centred_bounds_box?(connector, text_box)
383
+ connector.style.to_h[:phantom_outline] == true || text_box.to_h["alignment"] == 1
384
+ end
385
+
321
386
  # Role text on a rightward leg is left-aligned at the corner,
322
387
  # 7px above it.
323
- def role_seat(direction, anchor, box, role)
388
+ def role_seat(connector, direction, anchor, box, role)
324
389
  return nil unless direction == :right
325
390
  return nil if box.nil? || role_empty?(role)
326
391
  return nil unless default_presentation?(box)
327
392
 
328
393
  x, y = seat_corner(anchor, box)
329
- [x + SEAT_X_INSET, y - 7]
394
+ [x + SEAT_X_INSET + source_property_centering(connector, direction, box, role), y - 7]
330
395
  end
331
396
 
332
397
  # Property-role clusters use a direction-specific horizontal seat;
@@ -348,12 +413,26 @@ module Ea
348
413
  # Center a short role above the wider stereotype without moving
349
414
  # the directional cluster's left edge. Other boxes keep their seat.
350
415
  def source_property_centering(connector, direction, box, role)
351
- return 0 unless %i[left down].include?(direction)
416
+ return 0 unless centered_property_box?(connector, direction, box)
417
+
418
+ property_role_centering(connector, :source, box, role)
419
+ end
420
+
421
+ def property_role_centering(connector, end_kind, box, role)
352
422
  return 0 unless seat_font? && font_weight == 400 && font_style == "normal"
353
- return 0 unless box.values_at("cy", "alignment") == [35, 1]
423
+ return 0 unless property_label?(connector, end_kind)
424
+
425
+ stereo = "«#{connector_source_dest_stereotype(connector, end_kind)}»"
426
+ -[stereotype_offset(role, stereo, box.merge("alignment" => 1)), 0].min
427
+ end
354
428
 
355
- stereo = "«#{connector_source_dest_stereotype(connector, :source)}»"
356
- -[stereotype_offset(role, stereo, box), 0].min
429
+ # Measured box heights: left/down Property clusters store CY 35,
430
+ # right-seat clusters CY 31. Phantom outlines leaving down carry a
431
+ # synthesized zero box that centers the same way.
432
+ def centered_property_box?(connector, direction, box)
433
+ cy = { left: 35, down: 35, right: 31 }[direction]
434
+ (cy && box.values_at("cy", "alignment") == [cy, 1]) ||
435
+ (direction == :down && phantom_property_box?(connector, direction, box))
357
436
  end
358
437
 
359
438
  def source_property_seat?(connector, direction, box)
@@ -367,7 +446,7 @@ module Ea
367
446
  # Their default Property cluster uses the directional seat with a
368
447
  # centered stereotype; persisted or moved boxes keep their own seat.
369
448
  def phantom_property_box?(connector, direction, box)
370
- %i[up left].include?(direction) && seat_font? && cambria_box?(box) &&
449
+ %i[up left down].include?(direction) && seat_font? && cambria_box?(box) &&
371
450
  box["alignment"].nil? && stored_offsets_zero?(box) &&
372
451
  connector.style.to_h[:phantom_outline] == true &&
373
452
  property_label?(connector, :source)
@@ -522,7 +601,7 @@ module Ea
522
601
 
523
602
  x, y = seat_corner(anchor, box)
524
603
  inset, = property_role_offsets(:down)
525
- [x - inset - width, y - 7]
604
+ [x - inset - width + property_role_centering(connector, :target, box, role), y - 7]
526
605
  end
527
606
 
528
607
  def upward_target_property?(connector, heading)
@@ -665,7 +744,8 @@ module Ea
665
744
 
666
745
  def downward_source_route?(style, box)
667
746
  [orthogonal_stub_route?(style), lateral_horizontal_route?(style), mode_two_route?(style),
668
- downward_property_route?(style), persisted_width_zero_down_box?(box, style)].any?
747
+ downward_property_route?(style), persisted_width_zero_down_box?(box, style),
748
+ centered_width_zero_down_box?(box, style)].any?
669
749
  end
670
750
 
671
751
  def persisted_width_zero_down_box?(box, style)
@@ -673,6 +753,11 @@ module Ea
673
753
  style.values_at(:mode, :lwidth, :tree) == ["3", "0", nil]
674
754
  end
675
755
 
756
+ def centered_width_zero_down_box?(box, style)
757
+ box["alignment"] == 1 && stored_offsets_zero?(box) &&
758
+ style.values_at(:mode, :lwidth, :tree) == ["3", "0", nil]
759
+ end
760
+
676
761
  # Mode 3, width 2 on the horizontal lateral tree.
677
762
  def lateral_horizontal_route?(style)
678
763
  style.values_at(:mode, :lwidth, :tree) == %w[3 2 LH]
@@ -820,13 +905,14 @@ module Ea
820
905
  end
821
906
 
822
907
  def build_texts(role:, mult:, standalone:, text_pos:, mult_pos:,
823
- stereo:, text_box:)
908
+ stereo:, text_box:, stereo_pos: nil)
824
909
  if role && !role.empty? && text_pos
825
910
  x, y = text_pos
826
911
  texts = [text_at(x, y, role)]
827
912
  if stereo
828
- stereo_x = x + stereotype_offset(role, stereo, text_box)
829
- texts << text_at(stereo_x, y + property_y_offset(text_box), stereo)
913
+ stereo_x, stereo_y = stereo_pos || [x + stereotype_offset(role, stereo, text_box),
914
+ y + property_y_offset(text_box)]
915
+ texts << text_at(stereo_x, stereo_y, stereo)
830
916
  end
831
917
  if mult && !mult.empty? && mult_pos
832
918
  texts << text_at(*mult_pos, mult)
@@ -839,6 +925,19 @@ module Ea
839
925
  end
840
926
  end
841
927
 
928
+ def umldi_label_position(bounds)
929
+ [bounds[:x], bounds[:y] + font_pixel_size.ceil]
930
+ end
931
+
932
+ def umldi_stereotype_position(bounds, role, stereo, text_box)
933
+ [bounds[:x] + stereotype_offset(role, stereo, text_box),
934
+ bounds[:y] + font_pixel_size.ceil + (font_pixel_size.ceil + 2)]
935
+ end
936
+
937
+ def font_pixel_size
938
+ font_unit == "pt" ? font_size * 4.0 / 3 : font_size
939
+ end
940
+
842
941
  def stereotype_offset(role, stereo, text_box)
843
942
  return 0 unless text_box && text_box["alignment"] == 1
844
943
 
@@ -83,7 +83,8 @@ module Ea
83
83
 
84
84
  stored_src = end_boxes(boxes, :llt, :llb)
85
85
  stored_tgt = end_boxes(boxes, :lrt, :lrb)
86
- stored_labels = !(stored_src.nil? && stored_tgt.nil?)
86
+ umldi_bounds = connector.umldi_label_bounds || {}
87
+ stored_labels = !(stored_src.nil? && stored_tgt.nil?) || !umldi_bounds.empty?
87
88
  src_tb, src_mb = stored_src || unstored_label_boxes(points, :source, stored_labels)
88
89
  tgt_tb, tgt_mb = stored_tgt || unstored_label_boxes(points, :target, stored_labels)
89
90
 
@@ -93,6 +94,7 @@ module Ea
93
94
  end_kind: :source, tree: tree && tree[:source],
94
95
  heading: (stored_src || stored_labels) && source_heading(connector, points),
95
96
  stored_labels: stored_labels,
97
+ umldi_bounds: available_umldi_bounds(umldi_bounds, boxes, :source),
96
98
  source_bounds: source_dock_bounds(connector)).each { |t| texts << t }
97
99
  end_renderer.texts(text_box: tgt_tb,
98
100
  mult_box: tgt_mb,
@@ -101,10 +103,21 @@ module Ea
101
103
  bend: Label::TargetBendSeat.reference(points),
102
104
  heading: (stored_tgt || stored_labels) && target_heading(connector, points),
103
105
  target_face: target_dock_heading(connector, target_pt, target_heading(connector, points)),
104
- stored_labels: stored_labels).each { |t| texts << t }
106
+ stored_labels: stored_labels,
107
+ umldi_bounds: available_umldi_bounds(umldi_bounds, boxes, :target)).each { |t| texts << t }
105
108
  texts
106
109
  end
107
110
 
111
+ # Role bounds the XMI builder keeps belong to this connector's own
112
+ # memberEnd and win over a stored role box; a stored multiplicity box
113
+ # wins over multiplicity bounds.
114
+ def available_umldi_bounds(bounds, label_boxes, end_kind)
115
+ mult_key = end_kind == :source ? :llb : :lrb
116
+ result = bounds.dup
117
+ result.delete(end_kind == :source ? :source_multiplicity : :target_multiplicity) if label_boxes.key?(mult_key)
118
+ result
119
+ end
120
+
108
121
  # A connector that stores label boxes for only one end seats the
109
122
  # other end's labels at its anchor, like a stored zero offset.
110
123
  def unstored_label_boxes(points, end_kind, stored_labels)
@@ -52,11 +52,16 @@ module Ea
52
52
  # feature compartment — even one whose rows are all hidden
53
53
  # by visibility flags (10E3E08A: CodeList with 3 attributes
54
54
  # toggled off still renders the divider over an empty body;
55
- # only feature-less elements omit it).
55
+ # only feature-less elements omit it). Association-end
56
+ # properties are not EA attributes (QEA keeps them as connector
57
+ # roles; the XMI reads them as ownedAttribute), so a class whose
58
+ # only properties are association ends has no feature compartment
59
+ # and no divider (A59065AF urf::Administration).
56
60
  def hidden_features_present?
57
61
  return false unless classifier.respond_to?(:properties)
58
62
 
59
- classifier.properties.any? || classifier.operations.any?
63
+ classifier.properties.any? { |property| !property.association_id } ||
64
+ classifier.operations.any?
60
65
  end
61
66
  end
62
67
  end
@@ -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.136"
7
+ VERSION = "0.6.138"
8
8
  end