ea 0.6.128 → 0.6.130

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.
@@ -80,9 +80,9 @@ module Ea
80
80
  target_multiplicity_raw: raw_card(row.destcard),
81
81
  source_aggregation: source_aggregation_for(row),
82
82
  target_aggregation: aggregation_from(row.destisaggregate),
83
- source_navigable: navigable?(row.sourceaccess, row.direction,
83
+ source_navigable: navigable?(row.sourceisnavigable, row.direction,
84
84
  :source),
85
- target_navigable: navigable?(row.destaccess, row.direction, :target)
85
+ target_navigable: navigable?(row.destisnavigable, row.direction, :target)
86
86
  }
87
87
  end
88
88
 
@@ -148,10 +148,10 @@ module Ea
148
148
  ConnectorRelationshipMap.source_aggregation_for(row.connector_type)
149
149
  end
150
150
 
151
- def navigable?(_access, direction, end_label)
152
- # EA's navigability is messy. Treat unspecified direction
153
- # as bidirectional, "Source -> Destination" as target-only
154
- # navigable, etc. Conservative default: both ends navigable.
151
+ def navigable?(flag, direction, end_label)
152
+ return flag == 1 if [0, 1].include?(flag)
153
+
154
+ # Missing or unspecified flags retain the direction fallback.
155
155
  case direction.to_s
156
156
  when "<->", "" then true
157
157
  when "->" then end_label == :target
@@ -76,8 +76,8 @@ module Ea
76
76
  target_multiplicity_upper: parse_upper(target_end),
77
77
  source_aggregation: source_end&.aggregation || "none",
78
78
  target_aggregation: target_end&.aggregation || "none",
79
- source_navigable: navigable?(source_end),
80
- target_navigable: navigable?(target_end)
79
+ source_navigable: navigable?(element, :source),
80
+ target_navigable: navigable?(element, :target)
81
81
  }
82
82
  end
83
83
 
@@ -128,13 +128,20 @@ module Ea
128
128
  Integer(raw) rescue 1
129
129
  end
130
130
 
131
- def navigable?(end_element)
132
- return true unless end_element
131
+ def connector_index
132
+ @connector_index ||= begin
133
+ connectors = root.extension&.connectors&.connector || []
134
+ connectors.each_with_object({}) do |connector, index|
135
+ index[connector.idref] = connector if connector.idref
136
+ end
137
+ end
138
+ end
133
139
 
134
- # XMI represents non-navigable ends with aggregation=none
135
- # and no role name; treat presence of role name or any
136
- # aggregation as navigable. Default: navigable.
137
- true
140
+ def navigable?(element, endpoint)
141
+ connector = connector_index[element.id]
142
+ end_element = connector&.public_send(endpoint)
143
+ flag = end_element&.modifiers&.is_navigable
144
+ flag.nil? || flag
138
145
  end
139
146
  end
140
147
  end
@@ -8,6 +8,11 @@ module Ea
8
8
  # Renders only when the host element is a Note with a
9
9
  # non-empty body.
10
10
  module NoteBody
11
+ # Native notes put the first baseline this far below the note
12
+ # top plus the font's GDI cell ascent (Arial 9pt/10pt +27,
13
+ # Cambria 7pt +24, Carlito 9pt +26).
14
+ TEXT_TOP = 14
15
+
11
16
  module_function
12
17
 
13
18
  def render(context)
@@ -22,17 +27,15 @@ module Ea
22
27
  end
23
28
 
24
29
  def wrapped_lines(context, body)
25
- # Lines fit bounds.width - 4. Pitch and
26
- # first-line offset scale with the note font (9pt: pitch
27
- # 16 = size+7, first line at bounds.y + 18 = size+9), and
28
- # each paragraph break adds one blank pitch row
29
- # (paragraph 2 starts 32 after the last line of
30
- # paragraph 1).
30
+ # Lines fit bounds.width - 4. Pitch is the GDI cell height
31
+ # of the note font and the first baseline sits TEXT_TOP
32
+ # plus the cell ascent below the top; fonts without
33
+ # vertical data keep pitch size+7 and offset size+9. Each
34
+ # empty source line adds one blank pitch row.
31
35
  usable = context.bounds.width - 4
32
36
  size = context.size
33
37
  family = context.family
34
- pitch = size + 7
35
- first_offset = size + 9
38
+ pitch, first_offset = vertical_layout(context)
36
39
  y_index = 0
37
40
  paragraphs = NoteHtml.paragraphs(body)
38
41
  paragraphs.flat_map do |para|
@@ -52,28 +55,38 @@ module Ea
52
55
  width_factor: context.theme.text_width_factor
53
56
  ).to_svg
54
57
  end
55
- y_index += lines.size + (para.tight ? 0 : 1)
58
+ y_index += lines.size + para.gap
56
59
  paragraph_svg
57
60
  end
58
61
  end
59
62
 
60
- # Wrap at whitespace until native script-specific break metrics are
61
- # established. Every word but the paragraph's last carries its
62
- # trailing space into the fit test.
63
+ # [pitch, first-baseline offset] for the note font.
64
+ def vertical_layout(context)
65
+ size = context.size
66
+ height = context.logical_height
67
+ height ||= Ea::Fonts::Metrics.gdi_logical_height(size) if context.size_unit == "pt"
68
+ ascent, descent = Ea::Fonts::Metrics.gdi_cell_metrics(context.family, height)
69
+ return [size + 7, size + 9] unless ascent
70
+
71
+ [ascent + descent, TEXT_TOP + ascent]
72
+ end
73
+ module_function :vertical_layout
74
+
75
+ # Break at whitespace and between a CJK character and its
76
+ # neighbours. Every atom but the paragraph's last carries a
77
+ # following space, when the source has one, into the fit test.
63
78
  def wrap_paragraph(para, usable, size, family, logical_height: nil)
64
79
  lines = []
65
80
  current = ""
66
- words = para.strip.split(/\s+/)
67
- words.each_with_index do |word, index|
68
- candidate = current.empty? ? word : "#{current} #{word}"
69
- trailing = index < words.size - 1 ? " " : ""
70
- width = TextRenderer.estimate_width("#{candidate}#{trailing}", size, nil,
71
- family: family, logical_height: logical_height).round
72
- if current.empty? || width <= usable
81
+ atoms = break_atoms(para)
82
+ atoms.each_with_index do |atom, index|
83
+ candidate = current.empty? ? atom[:text] : "#{current}#{atom[:space]}#{atom[:text]}"
84
+ measured = "#{candidate}#{atoms.dig(index + 1, :space)}"
85
+ if current.empty? || fit_width(measured, size, family, logical_height) <= usable
73
86
  current = candidate
74
87
  else
75
88
  lines << current
76
- current = word
89
+ current = atom[:text]
77
90
  end
78
91
  end
79
92
  lines << current unless current.empty?
@@ -81,6 +94,29 @@ module Ea
81
94
  end
82
95
  module_function :wrap_paragraph
83
96
 
97
+ def fit_width(text, size, family, logical_height)
98
+ TextRenderer.estimate_width(text, size, nil, family: family, logical_height: logical_height).round
99
+ end
100
+ module_function :fit_width
101
+
102
+ # Unbreakable runs of text with the space that precedes each one.
103
+ def break_atoms(text)
104
+ words = text.split(/\s+/).reject(&:empty?)
105
+ words.each_with_index.flat_map { |word, index| word_atoms(word, index.positive? ? " " : "") }
106
+ end
107
+ module_function :break_atoms
108
+
109
+ def word_atoms(word, space)
110
+ runs = word.chars.slice_when { |a, b| cjk_char?(a) || cjk_char?(b) }.map(&:join)
111
+ runs.each_with_index.map { |run, i| { text: run, space: i.zero? ? space : "" } }
112
+ end
113
+ module_function :word_atoms
114
+
115
+ def cjk_char?(char)
116
+ Ea::Fonts::Metrics.cjk?(char.ord)
117
+ end
118
+ module_function :cjk_char?
119
+
84
120
  def group_style(context)
85
121
  "stroke-width:1;stroke-linecap:round;stroke-linejoin:bevel; " \
86
122
  "fill:#{context.theme.text_color};fill-opacity:1.00; " \
@@ -4,13 +4,14 @@ module Ea
4
4
  module Svg
5
5
  module EaEmitter
6
6
  module Compartment
7
- # Splits a note body into paragraphs. A body with HTML markup is
8
- # shown by EA as plain text: tags are dropped, entities decoded,
9
- # `<ul>`/`</ul>` lines take no row, and each `<li>` becomes a
10
- # "- " line stacked directly under the previous text or item
11
- # (`tight`) instead of leaving the blank row between paragraphs.
7
+ # Splits a note body into paragraphs, one per non-empty source
8
+ # line. Each paragraph stacks directly under the previous one
9
+ # (`gap` 0); every empty source line after it adds one blank row.
10
+ # A body with HTML markup is shown by EA as plain text: tags are
11
+ # dropped, entities decoded, `<ul>`/`</ul>` lines take no row,
12
+ # each `<li>` becomes a "- " line and `<br>` ends a line.
12
13
  module NoteHtml
13
- Paragraph = Struct.new(:text, :tight)
14
+ Paragraph = Struct.new(:text, :gap)
14
15
 
15
16
  TAG = %r{</?(?:ul|ol|li|b|i|u|p|br|strong|em)\b[^>]*>}i
16
17
  LINE_BREAK = /<br\b[^>]*>/i
@@ -22,37 +23,23 @@ module Ea
22
23
 
23
24
  def paragraphs(body)
24
25
  text = body.to_s
25
- return plain(text) unless text.match?(TAG)
26
-
27
- entries = html_lines(text)
28
- entries.each_with_index.map do |(line, _item, broken), index|
29
- Paragraph.new(line, broken || entries[index + 1]&.at(1) || false)
26
+ markup = text.match?(TAG)
27
+ lines = text.split(/\r?\n/)
28
+ lines.reject! { |raw| raw.strip.match?(LIST_WRAPPER) } if markup
29
+ lines.each_with_object([]) do |raw, paragraphs|
30
+ raw.strip.empty? ? paragraphs.last&.gap += 1 : paragraphs.concat(line_paragraphs(raw, markup))
30
31
  end
31
32
  end
32
33
 
33
- def plain(text)
34
- text.split(/\n/).reject { |line| line.strip.empty? }.map { |line| Paragraph.new(line, false) }
35
- end
36
-
37
- def html_lines(text)
38
- text.split(/\r?\n/).flat_map do |raw|
39
- line = raw.strip
40
- next [] if line.empty? || line.match?(LIST_WRAPPER)
41
-
42
- item = line.match(LIST_ITEM)
43
- item ? broken_lines(item[1], "- ") : broken_lines(line, "")
44
- end
34
+ def line_paragraphs(raw, markup)
35
+ markup ? html_lines(raw.strip) : [Paragraph.new(raw, 0)]
45
36
  end
46
37
 
47
- # Entries are [text, starts_item, broken]. `<br>` ends a line
48
- # without the blank row a new paragraph gets, so every segment
49
- # but the last is `broken` (tight). Only the first segment
50
- # carries the list bullet.
51
- def broken_lines(line, bullet)
52
- segments = line.split(LINE_BREAK).map { |segment| clean(segment) }.reject(&:empty?)
38
+ def html_lines(line)
39
+ item = line.match(LIST_ITEM)
40
+ segments = (item ? item[1] : line).split(LINE_BREAK).map { |segment| clean(segment) }.reject(&:empty?)
53
41
  segments.each_with_index.map do |segment, index|
54
- first = index.zero?
55
- [first ? "#{bullet}#{segment}" : segment, first && !bullet.empty?, index < segments.size - 1]
42
+ Paragraph.new(item && index.zero? ? "- #{segment}" : segment, 0)
56
43
  end
57
44
  end
58
45
 
@@ -19,6 +19,10 @@ module Ea
19
19
  # self-loop, 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
+
23
+ tether = association_class_pairs(connector, bounds_map)
24
+ return tether if tether
25
+
22
26
  points = waypoint_pairs(connector)
23
27
  lateral = lateral_tree?(connector, points)
24
28
  trunk = trunk_tree?(connector, points)
@@ -74,6 +78,42 @@ module Ea
74
78
  auto_port_elbow_pairs(connector, source, target, pairs)
75
79
  end
76
80
 
81
+ # A tether joins its saved association anchor to the facing
82
+ # side-center of the attached class, rather than to the other proxy.
83
+ def association_class_pairs(connector, bounds_map)
84
+ bounds = bounds_map[connector.style.to_h[:association_class_ref]]
85
+ anchor = connector.source_anchor
86
+ return nil unless bounds && anchor
87
+
88
+ [[anchor.x, anchor.y], association_class_dock(bounds, anchor)]
89
+ end
90
+ private_class_method :association_class_pairs
91
+
92
+ def association_class_dock(bounds, anchor)
93
+ center = association_class_center(bounds)
94
+ if horizontal_association_class?(bounds, anchor, center)
95
+ [association_class_edge_coordinate(bounds.x, bounds.width, anchor.x), center[1]]
96
+ else
97
+ [center[0], association_class_edge_coordinate(bounds.y, bounds.height, anchor.y)]
98
+ end
99
+ end
100
+ private_class_method :association_class_dock
101
+
102
+ def association_class_center(bounds)
103
+ [bounds.x + bounds.width / 2, bounds.y + bounds.height / 2]
104
+ end
105
+ private_class_method :association_class_center
106
+
107
+ def horizontal_association_class?(bounds, anchor, center)
108
+ (anchor.x - center[0]).abs * bounds.height > (anchor.y - center[1]).abs * bounds.width
109
+ end
110
+ private_class_method :horizontal_association_class?
111
+
112
+ def association_class_edge_coordinate(origin, size, anchor)
113
+ anchor > origin + size / 2 ? origin + size : origin
114
+ end
115
+ private_class_method :association_class_edge_coordinate
116
+
77
117
  # Auto routing turns at the clipped target column when the source
78
118
  # leaves a side edge and the target enters through its top or bottom.
79
119
  def auto_port_elbow_pairs(connector, source, target, pairs)
@@ -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
 
@@ -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,12 +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
52
57
  # bend - first bend of a saved descending vertical stair
53
58
  # stored_labels - the connector has stored label boxes at
54
59
  # either end; false keeps the opposite-end
55
60
  # fallback
56
61
  def texts(text_box:, mult_box:, anchor:, connector:, end_kind:,
57
- tree: false, heading: nil, stored_labels: false, bend: nil)
62
+ tree: false, heading: nil, stored_labels: false, bend: nil, source_bounds: nil)
58
63
  from_property, role, mult = role_and_multiplicity(connector, end_kind)
59
64
  if role_empty?(role) && mult_empty?(mult)
60
65
  other_from_prop, other_role, other_mult = role_and_multiplicity(connector,
@@ -105,6 +110,7 @@ module Ea
105
110
  text_pos = plain_leftward_target_role(connector, heading, anchor, text_box, role) ||
106
111
  target_leftward_property_role(connector, heading, anchor, text_box, role) ||
107
112
  target_property_role(connector, heading, anchor, text_box, role) ||
113
+ saved_ray_target_property_role(connector, heading, anchor, text_box, role) ||
108
114
  target_diagonal_property_role(connector, heading, anchor, text_box, role) ||
109
115
  target_vertical_role(connector, heading, anchor, text_box, role) ||
110
116
  target_rightward_role(heading, anchor, text_box, role) || text_pos
@@ -115,6 +121,9 @@ module Ea
115
121
  if end_kind == :source
116
122
  text_pos = source_top_property_role(connector, heading, anchor, text_box, role) ||
117
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
118
127
  end
119
128
 
120
129
  text_box = text_box.merge("alignment" => 1) if phantom_property_box?(connector, direction, text_box)
@@ -138,12 +147,48 @@ module Ea
138
147
  return nil unless heading && heading[0].negative? && heading[1].zero?
139
148
  return nil unless plain_target_role?(connector, box, role)
140
149
 
150
+ return saved_ray_plain_target_position(anchor, box, role) if moved_plain_target_ray?(connector, box)
151
+
141
152
  PlainTargetSeat.position(
142
153
  connector: connector, anchor: anchor, box: box,
143
154
  width: Ea::Fonts::Metrics.text_length(role, font_size, family: font_family)
144
155
  )
145
156
  end
146
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
+
147
192
  def plain_target_role?(connector, box, role)
148
193
  seat_font? && cambria_box?(box) && !role_empty?(role) &&
149
194
  connector_source_dest_stereotype(connector, :target).to_s.empty?
@@ -302,6 +347,43 @@ module Ea
302
347
  source_property_seat(connector, :up, anchor, box, role)
303
348
  end
304
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
+
305
387
  def zero_source_property_box?(box)
306
388
  box && box["alignment"]&.zero? && stored_offsets_zero?(box)
307
389
  end
@@ -371,7 +453,8 @@ module Ea
371
453
  end
372
454
 
373
455
  def horizontal_target_property_route?(style)
374
- style.values_at(:mode, :lwidth) == %w[3 2] && [nil, "LH"].include?(style[:tree])
456
+ style.values_at(:mode, :lwidth, :tree) == ["3", "0", nil] ||
457
+ (style.values_at(:mode, :lwidth) == %w[3 2] && [nil, "LH"].include?(style[:tree]))
375
458
  end
376
459
 
377
460
  # An upward target uses the downward property's right edge and
@@ -453,9 +536,8 @@ module Ea
453
536
  seat_font? && cambria_box?(box) && [0, 1].include?(box["alignment"])
454
537
  end
455
538
 
456
- # A target leg that runs down seats only on the "LV" tree route
457
- # with line width 2; every other vertical seat needs the plain
458
- # 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.
459
541
  def plain_vertical_connector?(connector, end_kind, direction)
460
542
  return false unless connector.connector_type == "Association" &&
461
543
  connector_source_dest_stereotype(connector, end_kind).empty?
@@ -468,6 +550,8 @@ module Ea
468
550
 
469
551
  if direction == :target_down
470
552
  style[:lwidth] == "2" && style[:tree] == "LV"
553
+ elsif direction == :target_up
554
+ style[:lwidth] == "0" && [nil, "LV"].include?(style[:tree])
471
555
  else
472
556
  style[:lwidth] == "0" && !style[:tree]
473
557
  end
@@ -915,6 +999,7 @@ module Ea
915
999
  y_t = canvas ? canvas.translate_y(y) : y
916
1000
  TextRenderer.new(content:, x: x_t, y: y_t,
917
1001
  family: font_family, size: font_size,
1002
+ weight: font_weight, style: font_style,
918
1003
  size_unit: font_unit,
919
1004
  fill: fill_color).to_svg
920
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