ea 0.6.127 → 0.6.129

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. checksums.yaml +4 -4
  2. data/lib/ea/fonts/carlito_gdi_widths.json +1 -0
  3. data/lib/ea/fonts/cjk_fallback_widths.json +1 -0
  4. data/lib/ea/fonts/metrics.rb +73 -15
  5. data/lib/ea/fonts/vertical_metrics.json +154 -0
  6. data/lib/ea/sources/qea/diagram_builder/association_class.rb +52 -0
  7. data/lib/ea/sources/qea/diagram_builder/auto_routing.rb +26 -4
  8. data/lib/ea/sources/qea/diagram_builder/routing.rb +90 -4
  9. data/lib/ea/sources/qea/diagram_builder.rb +29 -5
  10. data/lib/ea/sources/qea/relationship_builder.rb +6 -6
  11. data/lib/ea/sources/xmi/relationship_builder.rb +15 -8
  12. data/lib/ea/svg/ea_emitter/compartment/attributes.rb +1 -1
  13. data/lib/ea/svg/ea_emitter/compartment/header.rb +10 -1
  14. data/lib/ea/svg/ea_emitter/compartment/instance_slots.rb +24 -5
  15. data/lib/ea/svg/ea_emitter/compartment/note_body.rb +56 -20
  16. data/lib/ea/svg/ea_emitter/compartment/note_html.rb +18 -31
  17. data/lib/ea/svg/ea_emitter/connector_redock.rb +52 -3
  18. data/lib/ea/svg/ea_emitter/connectors.rb +1 -0
  19. data/lib/ea/svg/ea_emitter/element/header_renderer.rb +13 -3
  20. data/lib/ea/svg/ea_emitter/element/tagged_value_renderer.rb +14 -5
  21. data/lib/ea/svg/ea_emitter/elements.rb +13 -1
  22. data/lib/ea/svg/ea_emitter/font_resolver.rb +6 -1
  23. data/lib/ea/svg/ea_emitter/label/end_label.rb +182 -10
  24. data/lib/ea/svg/ea_emitter/label/midpoint_label.rb +28 -8
  25. data/lib/ea/svg/ea_emitter/label/plain_source_seat.rb +29 -0
  26. data/lib/ea/svg/ea_emitter/label/plain_target_seat.rb +28 -0
  27. data/lib/ea/svg/ea_emitter/label/source_stair_seat.rb +60 -0
  28. data/lib/ea/svg/ea_emitter/label/target_bend_seat.rb +45 -0
  29. data/lib/ea/svg/ea_emitter/label.rb +4 -0
  30. data/lib/ea/svg/ea_emitter/labels.rb +19 -3
  31. data/lib/ea/svg/ea_emitter/marker/diamond.rb +29 -5
  32. data/lib/ea/theme/definition.rb +9 -1
  33. data/lib/ea/version.rb +1 -1
  34. metadata +9 -1
@@ -91,6 +91,7 @@ module Ea
91
91
  end
92
92
 
93
93
  def dashed?(connector)
94
+ return true if connector.style.to_h[:association_class_ref]
94
95
  return true if connector.connector_type == "Dependency"
95
96
  return false unless connector.connector_type == "Package"
96
97
 
@@ -18,13 +18,14 @@ module Ea
18
18
  size:, size_unit: "pt", logical_height: nil,
19
19
  fill: "#000000", weight_normal: 400, weight_bold: 700,
20
20
  stroke_in_text: "#000000", width_factor: 0.612,
21
- line_offset: 6)
21
+ line_offset: 6, default_signal_header: false)
22
22
  logical_height = nil unless size_unit == "pt"
23
23
  layout = line_layout(
24
24
  lines, bounds: bounds, first_y: first_y, family: family,
25
25
  size: size, size_unit: size_unit, logical_height: logical_height,
26
26
  weight_normal: weight_normal, weight_bold: weight_bold,
27
- width_factor: width_factor, line_offset: line_offset
27
+ width_factor: width_factor, line_offset: line_offset,
28
+ default_signal_header: default_signal_header
28
29
  )
29
30
  text_blocks = layout.map do |line|
30
31
  TextRenderer.new(
@@ -44,12 +45,21 @@ module Ea
44
45
  def self.line_layout(lines, bounds:, first_y:, family:, size:,
45
46
  size_unit: "pt", logical_height: nil,
46
47
  weight_normal: 400, weight_bold: 700,
47
- width_factor: 0.612, line_offset: 6)
48
+ width_factor: 0.612, line_offset: 6, default_signal_header: false)
48
49
  logical_height = nil unless size_unit == "pt"
49
50
  line_h = Ea::Fonts::Metrics.compartment_line_height(
50
51
  size, family: family, unit: size_unit, offset: line_offset,
51
52
  logical_height: logical_height
52
53
  )
54
+ # Native default Cambria 7pt Signals use a 15px header pitch.
55
+ # Keep explicit fonts, logical heights and layout overrides intact.
56
+ if default_signal_header && family == "Cambria" && size == 7 &&
57
+ size_unit == "pt" && logical_height.nil? && line_offset == 6 &&
58
+ first_y == bounds.y + 13 && lines.size == 2 &&
59
+ lines.first == ["«signal»", :normal] && lines.last.last == :bold
60
+ first_y -= 1
61
+ line_h = 15
62
+ end
53
63
  ghost_shift = HeaderLines.parent_ghost_gap(lines)
54
64
  lines.each_with_index.map do |(text, style), idx|
55
65
  weight = %i[bold bold_italic].include?(style) ? weight_bold : weight_normal
@@ -19,8 +19,8 @@ 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, size), first_y,
23
- HEADER_TEXT, family, size, fill,
22
+ text_blocks = [build_text(bounds.x + centered_x_offset(bounds, family, size, size_unit, logical_height),
23
+ first_y, HEADER_TEXT, family, size, fill,
24
24
  style: "italic", size_unit: size_unit, logical_height: logical_height)]
25
25
  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),
@@ -48,9 +48,18 @@ module Ea
48
48
  private_class_method :line_height
49
49
 
50
50
  # Center the "tags" header text within the bounds width.
51
- def self.centered_x_offset(bounds, size)
52
- text_width = HEADER_TEXT.length * (size / 2)
53
- centered = (bounds.width / 2 - text_width / 2).floor
51
+ def self.centered_x_offset(bounds, family, size, size_unit, logical_height)
52
+ # QEA point captions center on the same integer width emitted in SVG.
53
+ # Retain the legacy pixel API's placement.
54
+ text_width = if size_unit == "pt"
55
+ TextRenderer.estimate_width(
56
+ HEADER_TEXT, size, family: family, weight: 400,
57
+ style: "italic", logical_height: logical_height
58
+ ).round
59
+ else
60
+ HEADER_TEXT.length * (size / 2)
61
+ end
62
+ centered = ((bounds.width - text_width) / 2.0).floor
54
63
  [HEADER_X_OFFSET, centered].max
55
64
  end
56
65
  private_class_method :centered_x_offset
@@ -156,6 +156,7 @@ module Ea
156
156
  logical_height: logical_height,
157
157
  content_x_offset: attribute_content_x_offset(size, family, size_unit)),
158
158
  row_content_width(op_lines, 3, size, family, logical_height: logical_height),
159
+ tag_content_width(element, classifier, size, family, logical_height: logical_height),
159
160
  Compartment::EnumLiterals.content_width(
160
161
  enum_literals_for(classifier),
161
162
  theme: theme, size: size, family: family,
@@ -302,7 +303,7 @@ module Ea
302
303
  end
303
304
 
304
305
  def attribute_content_x_offset(size, family, size_unit)
305
- offset = (theme.attribute_spec.content_x_offset || Element::AttributeRenderer::DEFAULT_CONTENT_X_OFFSET) +
306
+ offset = (theme.attribute_content_x_offset || Element::AttributeRenderer::DEFAULT_CONTENT_X_OFFSET) +
306
307
  2 * (size - 7)
307
308
  offset -= 1 if Ea::Fonts::Metrics.compact_compartment_font?(size, family: family, unit: size_unit)
308
309
  offset
@@ -312,6 +313,17 @@ module Ea
312
313
  Ea::Fonts::Metrics.compact_compartment_font?(size, family: family, unit: size_unit) ? 7 : 8
313
314
  end
314
315
 
316
+ def tag_content_width(element, classifier, size, family, logical_height: nil)
317
+ return nil unless element.show_tagged_values
318
+
319
+ tagged_values_for(classifier).map do |tag|
320
+ TextRenderer.estimate_width("#{tag.key} = #{tag.value}", size,
321
+ family: family, weight: 400, style: "normal",
322
+ logical_height: logical_height).round +
323
+ 2 * Element::TaggedValueRenderer::LINE_X_OFFSET
324
+ end.max
325
+ end
326
+
315
327
  def row_content_width(lines, right_pad, size, family, **font_options)
316
328
  return nil if lines.to_a.empty?
317
329
 
@@ -17,6 +17,11 @@ module Ea
17
17
  # text-emitting layer.
18
18
  class FontResolver
19
19
  DEFAULT_FONT_FAMILY = "Yu Gothic UI"
20
+ # EA draws every text kind of a hand-drawn diagram (names, notes,
21
+ # connector labels) in this family.
22
+ HAND_DRAW_FAMILY = "Source Sans Pro"
23
+ # Native hand-drawn connector end labels carry font-weight:0.
24
+ HAND_DRAW_LABEL_WEIGHT = 0
20
25
  # EA's default element compartment font size. EA-published
21
26
  # reference SVGs render body text at 7pt (the current EA
22
27
  # theme size) for every element whose objectstyle fontsz is
@@ -87,7 +92,7 @@ module Ea
87
92
  private
88
93
 
89
94
  def default_family_for(explicit)
90
- return "Source Sans Pro" if diagram.hand_draw
95
+ return HAND_DRAW_FAMILY if diagram.hand_draw
91
96
 
92
97
  theme_family_for(explicit) || diagram_default_family || DEFAULT_FONT_FAMILY
93
98
  end
@@ -28,10 +28,12 @@ module Ea
28
28
  MIN_ROLE_X = 3
29
29
 
30
30
  attr_reader :canvas, :model_index, :document, :theme,
31
- :font_family, :font_size, :font_unit
31
+ :font_family, :font_size, :font_unit,
32
+ :font_weight, :font_style
32
33
 
33
34
  def initialize(canvas:, model_index:, document:, theme:,
34
- font_family:, font_size:, font_unit:)
35
+ font_family:, font_size:, font_unit:,
36
+ font_weight: 400, font_style: "normal")
35
37
  @canvas = canvas
36
38
  @model_index = model_index
37
39
  @document = document
@@ -39,6 +41,8 @@ module Ea
39
41
  @font_family = font_family
40
42
  @font_size = font_size
41
43
  @font_unit = font_unit
44
+ @font_weight = font_weight
45
+ @font_style = font_style
42
46
  end
43
47
 
44
48
  # Returns an Array of `<text>` SVG strings for one endpoint.
@@ -49,11 +53,13 @@ module Ea
49
53
  # connector - the DiagramConnector (for relationship lookup)
50
54
  # end_kind - :source or :target
51
55
  # heading - [dx, dy] of the first leg leaving the anchor
56
+ # source_bounds - effective drawn source outline, in anchor coordinates
57
+ # bend - first bend of a saved descending vertical stair
52
58
  # stored_labels - the connector has stored label boxes at
53
59
  # either end; false keeps the opposite-end
54
60
  # fallback
55
61
  def texts(text_box:, mult_box:, anchor:, connector:, end_kind:,
56
- tree: false, heading: nil, stored_labels: false)
62
+ tree: false, heading: nil, stored_labels: false, bend: nil, source_bounds: nil)
57
63
  from_property, role, mult = role_and_multiplicity(connector, end_kind)
58
64
  if role_empty?(role) && mult_empty?(mult)
59
65
  other_from_prop, other_role, other_mult = role_and_multiplicity(connector,
@@ -92,7 +98,8 @@ module Ea
92
98
  stereo = stereo.nil? || stereo.empty? ? nil : "«#{stereo}»"
93
99
  direction = leg_direction(end_kind, heading)
94
100
  if direction && !(tree && zero_offset?(text_box, mult_box))
95
- text_pos = plain_vertical_role(connector, direction, anchor, text_box, role) ||
101
+ text_pos = plain_leftward_source_role(connector, direction, anchor, text_box, role) ||
102
+ plain_vertical_role(connector, direction, anchor, text_box, role) ||
96
103
  source_property_seat(connector, direction, anchor, text_box, role) ||
97
104
  plain_upward_seat(connector, direction, anchor, text_box, role) ||
98
105
  role_seat(direction, anchor, text_box, role) || text_pos
@@ -100,7 +107,10 @@ module Ea
100
107
  horizontal_tree_multiplicity(connector, direction, anchor, mult_box, mult) ||
101
108
  mult_seat(direction, anchor, mult_box) || mult_pos
102
109
  elsif end_kind == :target && !(tree && zero_offset?(text_box, mult_box))
103
- text_pos = target_property_role(connector, heading, anchor, text_box, role) ||
110
+ text_pos = plain_leftward_target_role(connector, heading, anchor, text_box, role) ||
111
+ target_leftward_property_role(connector, heading, anchor, text_box, role) ||
112
+ target_property_role(connector, heading, anchor, text_box, role) ||
113
+ saved_ray_target_property_role(connector, heading, anchor, text_box, role) ||
104
114
  target_diagonal_property_role(connector, heading, anchor, text_box, role) ||
105
115
  target_vertical_role(connector, heading, anchor, text_box, role) ||
106
116
  target_rightward_role(heading, anchor, text_box, role) || text_pos
@@ -111,16 +121,112 @@ module Ea
111
121
  if end_kind == :source
112
122
  text_pos = source_top_property_role(connector, heading, anchor, text_box, role) ||
113
123
  source_bottom_property_role(connector, heading, anchor, text_box, role) || text_pos
124
+ if external_upward_property_source?(connector, heading, anchor, text_box, source_bounds)
125
+ text_pos = external_upward_property_role(anchor, role) || text_pos
126
+ end
114
127
  end
115
128
 
116
129
  text_box = text_box.merge("alignment" => 1) if phantom_property_box?(connector, direction, text_box)
117
130
 
131
+ if end_kind == :target && !from_property
132
+ bend_positions = target_bend_positions(connector, bend, text_box, mult_box, role)
133
+ text_pos, mult_pos = bend_positions if bend_positions
134
+ end
135
+ if end_kind == :source && !from_property
136
+ stair_positions = source_stair_positions(connector, anchor, text_box, mult_box, role)
137
+ text_pos, mult_pos = stair_positions if stair_positions
138
+ end
139
+
118
140
  build_texts(role:, mult:, standalone:,
119
141
  text_pos:, mult_pos:, stereo:, text_box:)
120
142
  end
121
143
 
122
144
  private
123
145
 
146
+ def plain_leftward_target_role(connector, heading, anchor, box, role)
147
+ return nil unless heading && heading[0].negative? && heading[1].zero?
148
+ return nil unless plain_target_role?(connector, box, role)
149
+
150
+ return saved_ray_plain_target_position(anchor, box, role) if moved_plain_target_ray?(connector, box)
151
+
152
+ PlainTargetSeat.position(
153
+ connector: connector, anchor: anchor, box: box,
154
+ width: Ea::Fonts::Metrics.text_length(role, font_size, family: font_family)
155
+ )
156
+ end
157
+
158
+ def saved_ray_plain_target_position(anchor, box, role)
159
+ width = Ea::Fonts::Metrics.text_length(role, font_size, family: font_family)
160
+ x, y = seat_corner(anchor, box)
161
+ [x - width - SEAT_X_INSET, y - 7] if width
162
+ end
163
+
164
+ def moved_plain_target_ray?(connector, box)
165
+ saved_target_ray?(connector) && box.values_at("alignment", "cy") == [1, 16] &&
166
+ !stored_offsets_zero?(box)
167
+ end
168
+
169
+ def saved_target_ray?(connector)
170
+ connector.connector_type == "Association" &&
171
+ connector.style.to_h.values_at(:mode, :lwidth, :tree, :stored_association_ray) == ["3", "0", nil, true]
172
+ end
173
+
174
+ # Moved Property boxes on saved rays arriving from below use the
175
+ # downward seat, relative to the persisted label corner.
176
+ def saved_ray_target_property_role(connector, heading, anchor, box, role)
177
+ return nil unless downward_saved_property?(connector, heading, box)
178
+
179
+ width = Ea::Fonts::Metrics.text_length(role, font_size, family: font_family)
180
+ return nil unless width
181
+
182
+ x, y = seat_corner(anchor, box)
183
+ inset, baseline = property_role_offsets(:down)
184
+ [x - inset - width, y + baseline]
185
+ end
186
+
187
+ def downward_saved_property?(connector, heading, box)
188
+ heading && heading[1].positive? && heading[1] > heading[0].abs &&
189
+ saved_target_ray?(connector) && moved_target_property_box?(connector, box)
190
+ end
191
+
192
+ def plain_target_role?(connector, box, role)
193
+ seat_font? && cambria_box?(box) && !role_empty?(role) &&
194
+ connector_source_dest_stereotype(connector, :target).to_s.empty?
195
+ end
196
+
197
+ def plain_leftward_source_role(connector, direction, anchor, box, role)
198
+ return nil unless direction == :left && seat_font? && cambria_box?(box) && !role_empty?(role)
199
+ return nil unless connector_source_dest_stereotype(connector, :source).to_s.empty?
200
+
201
+ PlainSourceSeat.position(
202
+ connector: connector, anchor: anchor, box: box,
203
+ width: Ea::Fonts::Metrics.text_length(role, font_size, family: font_family)
204
+ )
205
+ end
206
+
207
+ def source_stair_positions(connector, anchor, text_box, mult_box, role)
208
+ return nil unless seat_font? && cambria_box?(text_box) && cambria_box?(mult_box)
209
+ return nil if role_empty?(role)
210
+
211
+ property = property_label?(connector, :source)
212
+ return nil unless property || connector_source_dest_stereotype(connector, :source).to_s.empty?
213
+
214
+ SourceStairSeat.positions(
215
+ connector: connector, anchor: anchor, text_box: text_box, mult_box: mult_box,
216
+ width: Ea::Fonts::Metrics.text_length(role, font_size, family: font_family)
217
+ )
218
+ end
219
+
220
+ def target_bend_positions(connector, bend, text_box, mult_box, role)
221
+ return nil unless bend && seat_font? && cambria_box?(text_box) && cambria_box?(mult_box)
222
+ return nil unless connector_source_dest_stereotype(connector, :target).to_s.empty? && !role_empty?(role)
223
+
224
+ TargetBendSeat.positions(
225
+ connector: connector, bend: bend, text_box: text_box, mult_box: mult_box,
226
+ width: Ea::Fonts::Metrics.text_length(role, font_size, family: font_family)
227
+ )
228
+ end
229
+
124
230
  # With stored boxes on the connector, an end that carries no
125
231
  # data of its own draws only its own stereotype; the data end
126
232
  # draws the role and multiplicity; with no data anywhere nothing
@@ -241,6 +347,43 @@ module Ea
241
347
  source_property_seat(connector, :up, anchor, box, role)
242
348
  end
243
349
 
350
+ # A persisted origin above the drawn source outline keeps the
351
+ # upward seat's inset and the shared upper baseline, including
352
+ # steep diagonals. Origins clipped onto the face retain -6.
353
+ def external_upward_property_role(anchor, role)
354
+ width = Ea::Fonts::Metrics.text_length(role, font_size, family: font_family)
355
+ return nil unless width
356
+
357
+ inset, = property_role_offsets(:up)
358
+ _, baseline = property_role_offsets(:left)
359
+ [[anchor[0] - inset - width, MIN_ROLE_X].max, anchor[1] + baseline]
360
+ end
361
+
362
+ def external_upward_property_source?(connector, heading, anchor, box, bounds)
363
+ upward_source_heading?(heading) &&
364
+ zero_source_property_box?(box) && cambria_box?(box) && source_property_cambria?(connector) &&
365
+ external_source_route?(connector, anchor) && above_source_outline?(anchor, bounds)
366
+ end
367
+
368
+ def upward_source_heading?(heading)
369
+ source_seat?(:source, heading) && heading[1].negative? && heading[1].abs > heading[0].abs
370
+ end
371
+
372
+ def external_source_route?(connector, anchor)
373
+ origin = connector.source_anchor
374
+ connector.connector_type == "Association" && connector.waypoints.size == 3 &&
375
+ connector.style.to_h.values_at(:mode, :lwidth, :tree) == ["3", "0", nil] &&
376
+ connector.label_boxes.to_h.values_at(:llt, :llb).all?(&:nil?) &&
377
+ origin && [origin.x, origin.y] == anchor
378
+ end
379
+
380
+ def above_source_outline?(anchor, bounds)
381
+ return false unless bounds&.width&.positive? && bounds.height.positive?
382
+
383
+ anchor[1] < bounds.y &&
384
+ anchor[0].between?(bounds.x, bounds.x + bounds.width)
385
+ end
386
+
244
387
  def zero_source_property_box?(box)
245
388
  box && box["alignment"]&.zero? && stored_offsets_zero?(box)
246
389
  end
@@ -281,7 +424,7 @@ module Ea
281
424
  return nil unless direction == :up && cambria_box?(box)
282
425
  return nil unless [0, 1].include?(box["alignment"])
283
426
  return nil unless connector_source_dest_stereotype(connector, :source).empty?
284
- return nil unless plain_role_route?(connector.style.to_h)
427
+ return nil unless plain_role_route?(connector.style.to_h, box)
285
428
 
286
429
  width = Ea::Fonts::Metrics.text_length(role, font_size, family: font_family)
287
430
  return nil unless width
@@ -290,6 +433,30 @@ module Ea
290
433
  [x - SEAT_X_INSET - width, y - 6]
291
434
  end
292
435
 
436
+ # Moved target Property clusters on horizontal arrivals share
437
+ # the leftward source seat relative to their stored corner.
438
+ def target_leftward_property_role(connector, heading, anchor, box, role)
439
+ return nil unless heading && heading[0].negative? && heading[1].zero?
440
+ return nil unless moved_target_property_box?(connector, box)
441
+ return nil unless horizontal_target_property_route?(connector.style.to_h)
442
+
443
+ width = Ea::Fonts::Metrics.text_length(role, font_size, family: font_family)
444
+ return nil unless width
445
+
446
+ x, y = seat_corner(anchor, box)
447
+ [x - property_role_offsets(:left).first - width, y - 7]
448
+ end
449
+
450
+ def moved_target_property_box?(connector, box)
451
+ plain_vertical_box?(box) && box["alignment"] == 1 && [31, 35].include?(box["cy"]) &&
452
+ !stored_offsets_zero?(box) && property_label?(connector, :target)
453
+ end
454
+
455
+ def horizontal_target_property_route?(style)
456
+ style.values_at(:mode, :lwidth, :tree) == ["3", "0", nil] ||
457
+ (style.values_at(:mode, :lwidth) == %w[3 2] && [nil, "LH"].include?(style[:tree]))
458
+ end
459
+
293
460
  # An upward target uses the downward property's right edge and
294
461
  # the upward target baseline, both relative to its stored corner.
295
462
  def target_property_role(connector, heading, anchor, box, role)
@@ -369,9 +536,8 @@ module Ea
369
536
  seat_font? && cambria_box?(box) && [0, 1].include?(box["alignment"])
370
537
  end
371
538
 
372
- # A target leg that runs down seats only on the "LV" tree route
373
- # with line width 2; every other vertical seat needs the plain
374
- # width-0 route.
539
+ # Downward target seats require LV width 2; upward target seats
540
+ # also accept LV width 0. Source seats require a plain width-0 route.
375
541
  def plain_vertical_connector?(connector, end_kind, direction)
376
542
  return false unless connector.connector_type == "Association" &&
377
543
  connector_source_dest_stereotype(connector, end_kind).empty?
@@ -384,12 +550,16 @@ module Ea
384
550
 
385
551
  if direction == :target_down
386
552
  style[:lwidth] == "2" && style[:tree] == "LV"
553
+ elsif direction == :target_up
554
+ style[:lwidth] == "0" && [nil, "LV"].include?(style[:tree])
387
555
  else
388
556
  style[:lwidth] == "0" && !style[:tree]
389
557
  end
390
558
  end
391
559
 
392
- def plain_role_route?(style)
560
+ def plain_role_route?(style, box)
561
+ return box["cy"] == 16 if orthogonal_stub_route?(style)
562
+
393
563
  %w[1 3].include?(style[:mode]) && %w[0 2].include?(style[:lwidth]) &&
394
564
  [nil, "V"].include?(style[:tree])
395
565
  end
@@ -473,6 +643,7 @@ module Ea
473
643
 
474
644
  def left_property_route?(style)
475
645
  downward_property_route?(style) || mode_two_route?(style) ||
646
+ (style[:overlap_ray] == true && style.values_at(:mode, :lwidth, :tree) == ["2", "0", nil]) ||
476
647
  (style[:mode] == "3" && %w[0 2].include?(style[:lwidth]) &&
477
648
  [nil, "H", "LV"].include?(style[:tree]))
478
649
  end
@@ -828,6 +999,7 @@ module Ea
828
999
  y_t = canvas ? canvas.translate_y(y) : y
829
1000
  TextRenderer.new(content:, x: x_t, y: y_t,
830
1001
  family: font_family, size: font_size,
1002
+ weight: font_weight, style: font_style,
831
1003
  size_unit: font_unit,
832
1004
  fill: fill_color).to_svg
833
1005
  end
@@ -15,15 +15,17 @@ module Ea
15
15
  # Stereotype wins when both are present.
16
16
  class MidpointLabel
17
17
  attr_reader :canvas, :model_index, :font_family, :font_size,
18
- :font_unit
18
+ :font_unit, :font_weight, :font_style
19
19
 
20
20
  def initialize(canvas:, model_index:, font_family:, font_size:,
21
- font_unit:)
21
+ font_unit:, font_weight: 400, font_style: "normal")
22
22
  @canvas = canvas
23
23
  @model_index = model_index
24
24
  @font_family = font_family
25
25
  @font_size = font_size
26
26
  @font_unit = font_unit
27
+ @font_weight = font_weight
28
+ @font_style = font_style
27
29
  end
28
30
 
29
31
  # Returns the SVG `<text>` for the connector's midpoint
@@ -112,14 +114,15 @@ module Ea
112
114
  return nil unless stereotype_label(connector)
113
115
 
114
116
  box = connector.label_boxes.to_h[:lmb]
115
- return nil unless default_name_box?(box)
117
+ return nil unless default_name_box?(box, alignments: [0, 1])
116
118
 
117
119
  stored_position(connector, points, box, LINE_PITCH)
118
120
  end
119
121
 
120
122
  # At 7pt, a centered name's stored corner is 3px below its baseline.
121
123
  def stored_name_position(connector, points)
122
- stored_position(connector, points, stored_name_box(connector), -3)
124
+ seat_points = horizontal_bend_seat(points) || points
125
+ stored_position(connector, seat_points, stored_name_box(connector, points), -3)
123
126
  end
124
127
 
125
128
  def stored_position(connector, points, box, baseline)
@@ -133,21 +136,37 @@ module Ea
133
136
  [x + offset_x - (width / 2.0).floor, y + offset_y + baseline]
134
137
  end
135
138
 
136
- def stored_name_box(connector)
139
+ def stored_name_box(connector, points)
137
140
  return nil unless connector.is_a?(Ea::Model::DiagramConnector) && measured_name_font?
138
141
  return nil if connector.is_substitutable || stereotype_label(connector)
139
142
  return nil if midpoint_content(connector).include?(": ")
140
143
 
141
144
  box = connector.label_boxes.to_h[:lmt]
142
- box if default_name_box?(box)
145
+ alignments = name_box_alignments(points)
146
+ box if default_name_box?(box, alignments: alignments)
147
+ end
148
+
149
+ def name_box_alignments(points)
150
+ horizontal_bend_seat(points) ? [0, 1] : [1]
151
+ end
152
+
153
+ # A vertical-to-horizontal bend seats its name on the horizontal leg.
154
+ def horizontal_bend_seat(points)
155
+ return nil unless points.size == 3
156
+
157
+ source, bend, target = points
158
+ return nil unless source[0] == bend[0] && source[1] != bend[1] &&
159
+ bend[1] == target[1] && bend[0] != target[0]
160
+
161
+ [bend, target]
143
162
  end
144
163
 
145
164
  def measured_name_font?
146
165
  font_family.to_s.casecmp("Cambria").zero? && font_size == 7 && font_unit == "pt"
147
166
  end
148
167
 
149
- def default_name_box?(box)
150
- return false unless box && box["alignment"] == 1
168
+ def default_name_box?(box, alignments: [1])
169
+ return false unless box && alignments.include?(box["alignment"])
151
170
  return false unless box.values_at("ox", "oy").all?
152
171
  return false unless [nil, 0, 1].include?(box["direction"]) && [nil, -1].include?(box["color"])
153
172
 
@@ -191,6 +210,7 @@ module Ea
191
210
  y_t = canvas ? canvas.translate_y(y) : y
192
211
  TextRenderer.new(content:, x: x_t, y: y_t,
193
212
  family: font_family, size: font_size,
213
+ weight: font_weight, style: font_style,
194
214
  size_unit: font_unit,
195
215
  fill: "#000000").to_svg
196
216
  end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ea
4
+ module Svg
5
+ module EaEmitter
6
+ module Label
7
+ # Plain leftward roles end before the stored corner, mirroring
8
+ # the 3px gutter and baseline of the rightward role seat.
9
+ module PlainSourceSeat
10
+ def self.position(connector:, anchor:, box:, width:)
11
+ return nil unless width && eligible_connector?(connector) && moved_role_box?(box)
12
+
13
+ [anchor[0] + box["ox"].to_i - width - 3, anchor[1] + box["oy"].to_i - 7]
14
+ end
15
+
16
+ def self.eligible_connector?(connector)
17
+ connector.connector_type == "Aggregation" &&
18
+ connector.style.to_h.values_at(:mode, :lwidth, :tree, :plain_source_seat) == ["3", "2", nil, true]
19
+ end
20
+
21
+ def self.moved_role_box?(box)
22
+ box.values_at("alignment", "cy") == [1, 18] &&
23
+ box.values_at("ox", "oy").map(&:to_i) != [0, 0]
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ea
4
+ module Svg
5
+ module EaEmitter
6
+ module Label
7
+ # Plain leftward target roles end three pixels before the anchor.
8
+ module PlainTargetSeat
9
+ def self.position(connector:, anchor:, box:, width:)
10
+ return nil unless width && eligible_connector?(connector) && zero_role_box?(box)
11
+
12
+ [anchor[0] - width - 3, anchor[1] - 7]
13
+ end
14
+
15
+ def self.eligible_connector?(connector)
16
+ connector.connector_type == "Association" &&
17
+ connector.style.to_h.values_at(:mode, :lwidth, :tree, :plain_target_seat) == ["3", "2", nil, true]
18
+ end
19
+
20
+ def self.zero_role_box?(box)
21
+ box.values_at("alignment", "cy") == [1, 18] &&
22
+ box.values_at("ox", "oy").map(&:to_i) == [0, 0]
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ea
4
+ module Svg
5
+ module EaEmitter
6
+ module Label
7
+ # Moved source pairs on descending stairs share a baseline 18px
8
+ # below the stored source dock. Property clusters reserve the
9
+ # wider role gutter; multiplicities keep the plain 3px gutter.
10
+ module SourceStairSeat
11
+ def self.positions(connector:, anchor:, text_box:, mult_box:, width:)
12
+ return nil unless width && connector.connector_type == "Aggregation"
13
+ return nil unless connector.style.to_h.values_at(:mode, :lwidth, :tree) == %w[3 0 V]
14
+ return nil unless descending_stair?(connector.waypoints)
15
+
16
+ height, inset = role_geometry(connector)
17
+ return nil unless moved_box?(text_box, height) && moved_box?(mult_box, 18)
18
+
19
+ [position(anchor, text_box, -width - inset), position(anchor, mult_box, 3)]
20
+ end
21
+
22
+ def self.role_geometry(connector)
23
+ connector.source_stereotype.to_s.strip.casecmp("property").zero? ? [35, 60] : [18, 3]
24
+ end
25
+ private_class_method :role_geometry
26
+
27
+ def self.descending_stair?(waypoints)
28
+ return false unless waypoints.size == 4 && waypoints.all?(&:position)
29
+
30
+ first, bend, corner, last = waypoints.map(&:position)
31
+ descending_leg?(first, bend) && descending_leg?(corner, last) &&
32
+ horizontal_leg?(bend, corner)
33
+ end
34
+ private_class_method :descending_stair?
35
+
36
+ def self.descending_leg?(first, last)
37
+ first.x == last.x && first.y < last.y
38
+ end
39
+ private_class_method :descending_leg?
40
+
41
+ def self.horizontal_leg?(first, last)
42
+ first.y == last.y && first.x != last.x
43
+ end
44
+ private_class_method :horizontal_leg?
45
+
46
+ def self.position(anchor, box, inset)
47
+ [anchor[0] + box["ox"].to_i + inset, anchor[1] + box["oy"].to_i + 18]
48
+ end
49
+ private_class_method :position
50
+
51
+ def self.moved_box?(box, height)
52
+ box["alignment"] == 1 && box["cy"] == height &&
53
+ [box["ox"].to_i, box["oy"].to_i] != [0, 0]
54
+ end
55
+ private_class_method :moved_box?
56
+ end
57
+ end
58
+ end
59
+ end
60
+ end