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
@@ -81,13 +81,15 @@ module Ea
81
81
  # A Mode 2 Generalization whose stored Path is a horizontal jog
82
82
  # between vertically separated boxes that overlap in x is EA's
83
83
  # auto-route: one vertical ray at the overlap midpoint plus SX.
84
- # The stored jog is stale; the ray docks on both 1px outlines.
84
+ # Empty Paths qualify only when both saved origins face the gap.
85
+ # The ray docks on both 1px outlines.
85
86
  def mode2_overlap_ray(model, link_row, diagram_row, geom, context)
86
87
  return model if context || !mode2_overlap_candidate?(model, link_row, geom)
87
88
 
88
89
  source, target = stored_ray_boxes(link_row, diagram_row)
89
90
  column = overlap_ray_coordinate(source.x, source.width, target.x, target.width, geom[:sx])
90
91
  return model unless column && vertical_separation?(source, target)
92
+ return model unless mode2_overlap_path?(link_row, source, target, geom, column)
91
93
 
92
94
  set_overlap_ray(model, column, source, target)
93
95
  end
@@ -104,8 +106,35 @@ module Ea
104
106
 
105
107
  def mode2_overlap_candidate?(model, link_row, geom)
106
108
  model.connector_type == "Generalization" && model.has_geometry_offsets &&
107
- mode2_overlap_style?(model.style.to_h, geom) && model.waypoints.size == 4 &&
108
- horizontal_jog?(intermediate_waypoints(link_row))
109
+ mode2_overlap_style?(model.style.to_h, geom) &&
110
+ ((model.waypoints.size == 2 && link_row.path.to_s.empty?) ||
111
+ (model.waypoints.size == 4 && horizontal_jog?(intermediate_waypoints(link_row))))
112
+ end
113
+
114
+ def mode2_overlap_path?(link_row, source, target, geom, column)
115
+ !link_row.path.to_s.empty? || mode2_facing_origins?(source, target, geom, column)
116
+ end
117
+
118
+ # Saved origins on the facing border or one pixel inside it identify
119
+ # the empty auto-route. Interior and external origins retain their ray.
120
+ def mode2_facing_origins?(source, target, geom, column)
121
+ return false unless geom.values_at(:sx, :sy, :ex, :ey).all?
122
+
123
+ origins = [stored_ray_origin(source, geom[:sx], geom[:sy]),
124
+ stored_ray_origin(target, geom[:ex], geom[:ey])]
125
+ return false unless mode2_origins_share_column?(origins, source, column)
126
+
127
+ origins.zip([source, target], overlap_ray_ends(column, source, target)).all? do |origin, box, dock|
128
+ mode2_origin_on_face?(origin, box, dock[1])
129
+ end
130
+ end
131
+
132
+ def mode2_origin_on_face?(origin, box, outline_y)
133
+ inside_box?(origin, box) && (origin.y - outline_y).abs.between?(1, 2)
134
+ end
135
+
136
+ def mode2_origins_share_column?(origins, source, column)
137
+ origins.first.x == column && origins.last.x.between?(source.x, source.x + source.width)
109
138
  end
110
139
 
111
140
  def mode2_overlap_style?(style, geom)
@@ -152,6 +181,7 @@ module Ea
152
181
  # Computed-jog origins are already normalized to their derived ports.
153
182
  def correct_stored_ray_origins(model, link_row, diagram_row, geom, context)
154
183
  return model if context
184
+ return notelink_stored_ray(model, link_row, diagram_row, geom) if model.connector_type == "NoteLink"
155
185
  return model unless stored_ray_candidate?(model, link_row, geom)
156
186
 
157
187
  source, target = stored_ray_boxes(link_row, diagram_row)
@@ -164,9 +194,47 @@ module Ea
164
194
 
165
195
  set_stored_ray_origins(model, source, target, geom)
166
196
  model.style[:association_ray_outline] = true if model.connector_type == "Association"
197
+ model.style[:stored_origin_ray] = true if stored_origin_ray?(model, source, target)
167
198
  model
168
199
  end
169
200
 
201
+ # A straight top-docked NoteLink uses mathematical saved Y offsets,
202
+ # like the other custom rays, while authored bends keep their ports.
203
+ def notelink_stored_ray(model, link_row, diagram_row, geom)
204
+ return model unless notelink_ray_candidate?(model, link_row, geom)
205
+
206
+ source, target = stored_ray_boxes(link_row, diagram_row)
207
+ return model unless target.y + target.height < source.y
208
+ return model unless notelink_origins_inside?(source, target, geom)
209
+
210
+ set_stored_ray_origins(model, source, target, geom)
211
+ end
212
+
213
+ def notelink_ray_candidate?(model, link_row, geom)
214
+ model.waypoints.size == 2 && link_row.path.to_s.empty? && model.style[:lwidth] == "2" &&
215
+ association_ray_style?(model.style.to_h) &&
216
+ geom[:edge] == 1 && geom.values_at(:sx, :sy, :ex, :ey).all?
217
+ end
218
+
219
+ def notelink_origins_inside?(source, target, geom)
220
+ origins = [stored_ray_origin(source, geom[:sx], geom[:sy]),
221
+ stored_ray_origin(target, geom[:ex], geom[:ey])]
222
+ origins.zip([source, target]).all? { |origin, box| inside_box?(origin, box) }
223
+ end
224
+
225
+ # Certify only a straight, width-2 custom-line ray whose corrected
226
+ # stored origins lie inside their own outlines. Its clipped row is
227
+ # authored geometry, even when it coincides with a header divider.
228
+ def stored_origin_ray?(model, source, target)
229
+ style = model.style.to_h
230
+ return false unless model.connector_type == "Generalization" &&
231
+ style[:lwidth] == "2" && association_ray_style?(style)
232
+
233
+ [model.source_anchor, model.target_anchor].zip([source, target]).all? do |origin, box|
234
+ inside_box?(origin, grown_bounds(box, 1))
235
+ end
236
+ end
237
+
170
238
  # An empty-Path Mode 3 Association follows the line between its
171
239
  # stored endpoint origins; an omitted offset defaults to the center.
172
240
  # The target origin must lie inside its own stroked outline.
@@ -176,6 +244,8 @@ module Ea
176
244
  return model unless association_ray_candidate?(model, link_row)
177
245
 
178
246
  source, target = stored_ray_boxes(link_row, diagram_row)
247
+ return model if default_center_ray?(model) && !horizontal_ray_gap?(source, target)
248
+
179
249
  width = association_outline_width(model, link_row, diagram_row)
180
250
  origins = association_ray_origins(source, target, geom, width)
181
251
  return model unless origins
@@ -287,10 +357,26 @@ module Ea
287
357
  def association_ray_candidate?(model, link_row)
288
358
  model.connector_type == "Association" && link_row.path.to_s.empty? &&
289
359
  model.source_anchor && model.target_anchor &&
290
- (model.source_port || model.target_port || aligned_default_origins?(model)) &&
360
+ association_ray_ports?(model) &&
291
361
  association_ray_style?(model.style.to_h)
292
362
  end
293
363
 
364
+ def association_ray_ports?(model)
365
+ model.source_port || model.target_port || aligned_default_origins?(model) || default_center_ray?(model)
366
+ end
367
+
368
+ # Without stored offsets or ports, a width-2 custom line between
369
+ # horizontally separated boxes follows their centers to the stroke.
370
+ def default_center_ray?(model)
371
+ !model.has_geometry_offsets && model.style[:lwidth] == "2" && model.waypoints.size == 2 &&
372
+ [model.source_port, model.target_port].all?(&:nil?) &&
373
+ diagonal_origins?(model.source_anchor, model.target_anchor)
374
+ end
375
+
376
+ def diagonal_origins?(first, last)
377
+ first.x != last.x && first.y != last.y
378
+ end
379
+
294
380
  # Zero offsets omit ports, but an explicitly stored axis-aligned
295
381
  # width-2 ray still uses the logical centers and stroked outlines.
296
382
  def aligned_default_origins?(model)
@@ -14,12 +14,14 @@ module Ea
14
14
  autoload :AutoRouting, "ea/sources/qea/diagram_builder/auto_routing"
15
15
  autoload :StoredSquarePorts, "ea/sources/qea/diagram_builder/stored_square_ports"
16
16
  autoload :FontSettings, "ea/sources/qea/diagram_builder/font_settings"
17
+ autoload :AssociationClass, "ea/sources/qea/diagram_builder/association_class"
17
18
  include Routing
18
19
  include AutoRouting
19
20
  include StoredSquarePorts
20
21
  include FontSettings
22
+ include AssociationClass
21
23
  private_constant :Routing
22
- private_constant :AutoRouting, :StoredSquarePorts, :FontSettings
24
+ private_constant :AutoRouting, :StoredSquarePorts, :FontSettings, :AssociationClass
23
25
 
24
26
  attr_reader :database
25
27
 
@@ -396,9 +398,10 @@ module Ea
396
398
  # Internal producer flag, like :direct. A model relationship with no
397
399
  # link row is stroked at the 2px box outline, so its ends dock there.
398
400
  def phantom_style(connector_type)
399
- return { direct: true } if PHANTOM_DOUBLE_BACK_TYPES.include?(connector_type)
400
-
401
- %w[Aggregation Association].include?(connector_type) ? { phantom_outline: true } : {}
401
+ style = { lwidth: "1" }
402
+ style[:direct] = true if PHANTOM_DOUBLE_BACK_TYPES.include?(connector_type)
403
+ style[:phantom_outline] = true if %w[Aggregation Association].include?(connector_type)
404
+ style
402
405
  end
403
406
 
404
407
  # Computes the edge attachment point on source bounds that
@@ -424,6 +427,24 @@ module Ea
424
427
  end
425
428
  end
426
429
 
430
+ # Preserve original Path absence: authored two-point paths can look
431
+ # identical after routing but retain their stored label offsets.
432
+ def mark_plain_source_seat(model, link_row, context)
433
+ return if context || !link_row.path.to_s.empty? || model.connector_type != "Aggregation"
434
+ return unless model.style.values_at(:mode, :lwidth, :tree) == ["3", "2", nil]
435
+ return unless model.source_stereotype.to_s.empty?
436
+
437
+ model.style[:plain_source_seat] = true
438
+ end
439
+
440
+ def mark_plain_target_seat(model, link_row, context)
441
+ return if context || !link_row.path.to_s.empty? || model.connector_type != "Association"
442
+ return unless model.style.values_at(:mode, :lwidth, :tree) == ["3", "2", nil]
443
+ return unless model.target_stereotype.to_s.empty?
444
+
445
+ model.style[:plain_target_seat] = true
446
+ end
447
+
427
448
  def build_connector(link_row, diagram_row)
428
449
  connector = database.find_connector(link_row.connectorid)
429
450
  geom = parse_geometry_fields(link_row.geometry)
@@ -454,6 +475,8 @@ module Ea
454
475
  has_geometry_offsets: geometry_has_offsets?(link_row.geometry),
455
476
  is_substitutable: substitutable_connector?(connector)
456
477
  ), link_row, diagram_row, geom)
478
+ mark_plain_source_seat(model, link_row, context)
479
+ mark_plain_target_seat(model, link_row, context)
457
480
  model = collapse_partial_port_jog(model, link_row, diagram_row)
458
481
  model = correct_partial_port_origins(model, link_row, diagram_row, geom, context)
459
482
  model = direct_empty_path_ray(model, link_row, diagram_row, geom, context)
@@ -466,7 +489,8 @@ module Ea
466
489
  model = auto_port_elbow(model, link_row, diagram_row, geom, context)
467
490
  model = mark_default_square_ray(model, link_row, geom, context)
468
491
  model = restore_stored_square_ports(model, link_row, diagram_row, geom, context)
469
- reversed_stored_corner(model, link_row, diagram_row, geom, context)
492
+ model = reversed_stored_corner(model, link_row, diagram_row, geom, context)
493
+ mark_association_class(model, connector, diagram_row)
470
494
  end
471
495
 
472
496
  # Ray origin for one connector end: the element's center
@@ -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
@@ -20,7 +20,7 @@ module Ea
20
20
  size_unit: context.size_unit, logical_height: context.logical_height,
21
21
  fill: context.theme.attribute_text_color,
22
22
  visibility_x_offset: context.theme.attribute_spec.visibility_x_offset,
23
- content_x_offset: context.theme.attribute_spec.content_x_offset
23
+ content_x_offset: context.theme.attribute_content_x_offset
24
24
  )
25
25
  end
26
26
  end
@@ -24,9 +24,18 @@ module Ea
24
24
  weight_bold: context.theme.text_weight_bold,
25
25
  stroke_in_text: context.theme.stroke_in_text_color,
26
26
  width_factor: context.theme.text_width_factor,
27
- line_offset: context.theme.compartments.header_line_offset
27
+ line_offset: context.theme.compartments.header_line_offset,
28
+ default_signal_header: default_signal_header?(context)
28
29
  )
29
30
  end
31
+
32
+ def default_signal_header?(context)
33
+ context.classifier.is_a?(Ea::Model::Signal) &&
34
+ context.classifier.stereotype_refs.empty? &&
35
+ context.element.font_size_inherited == true &&
36
+ context.element.font_family.nil? &&
37
+ !context.element.font_bold && !context.element.font_italic
38
+ end
30
39
  end
31
40
  end
32
41
  end
@@ -13,7 +13,7 @@ module Ea
13
13
  # classifier.
14
14
  module InstanceSlots
15
15
  ROW_OFFSET = 13
16
- SUBTITLE_EXTRA_OFFSET = 10
16
+ SUBTITLE_OFFSET_FROM_BOTTOM = 15
17
17
 
18
18
  module_function
19
19
 
@@ -30,11 +30,11 @@ module Ea
30
30
  fill = context.theme.attribute_text_color
31
31
  blocks = []
32
32
  slots.each_with_index do |slot, idx|
33
- y = first_y + (idx * ROW_OFFSET)
33
+ y = first_y + (idx * row_offset(context))
34
34
  blocks << line_text(slot, bounds, y, context, fill)
35
35
  end
36
36
  if subtitle
37
- y = first_y + (slots.size * ROW_OFFSET) + SUBTITLE_EXTRA_OFFSET
37
+ y = bounds.y + bounds.height + SUBTITLE_OFFSET_FROM_BOTTOM
38
38
  blocks << subtitle_text(subtitle, bounds, y, context, fill)
39
39
  end
40
40
  wrap(blocks.join("\n"), fill)
@@ -88,9 +88,19 @@ module Ea
88
88
  end
89
89
  module_function :content_x_offset
90
90
 
91
+ def row_offset(context)
92
+ return ROW_OFFSET unless Ea::Fonts::Metrics.compact_compartment_font?(
93
+ context.size, family: context.family, unit: context.size_unit
94
+ )
95
+
96
+ Ea::Fonts::Metrics.compartment_line_height(
97
+ context.size, family: context.family, unit: context.size_unit
98
+ )
99
+ end
100
+ module_function :row_offset
101
+
91
102
  def subtitle_text(text, bounds, y_pos, context, fill)
92
- estimated_width = text.length * context.size * 0.5
93
- x_pos = bounds.x + (bounds.width - estimated_width) / 2.0
103
+ x_pos = (bounds.x + (bounds.width - subtitle_width(text, context)) / 2.0).floor
94
104
  TextRenderer.new(
95
105
  content: text,
96
106
  x: x_pos,
@@ -102,6 +112,15 @@ module Ea
102
112
  end
103
113
  module_function :subtitle_text
104
114
 
115
+ def subtitle_width(text, context)
116
+ TextRenderer.estimate_width(
117
+ text, context.size, nil,
118
+ family: context.family, style: "italic",
119
+ logical_height: context.size_unit == "pt" ? context.logical_height : nil
120
+ ).round
121
+ end
122
+ module_function :subtitle_width
123
+
105
124
  def wrap(body, fill)
106
125
  group_style = "stroke-width:1;stroke-linecap:round;" \
107
126
  "stroke-linejoin:bevel; fill:#{fill};" \
@@ -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)
@@ -34,6 +38,9 @@ module Ea
34
38
  target_bounds = bounds_map[target_ref]
35
39
  return nil unless source_bounds && target_bounds
36
40
  return nil if source_ref == target_ref
41
+ if flush_overlap_ray?(connector)
42
+ return clipped_pairs([source_bounds, target_bounds], [connector.source_anchor, connector.target_anchor])
43
+ end
37
44
  return trunk_pairs(points, source_bounds, target_bounds, connector.style[:tree]) if trunk
38
45
 
39
46
  square = OrthogonalRay.pairs_for(connector, source_bounds, target_bounds)
@@ -71,6 +78,42 @@ module Ea
71
78
  auto_port_elbow_pairs(connector, source, target, pairs)
72
79
  end
73
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
+
74
117
  # Auto routing turns at the clipped target column when the source
75
118
  # leaves a side edge and the target enters through its top or bottom.
76
119
  def auto_port_elbow_pairs(connector, source, target, pairs)
@@ -240,9 +283,13 @@ module Ea
240
283
  end
241
284
  private_class_method :stroke_outline?
242
285
 
243
- # The QEA builder marks the Mode 2 Association overlap rays it
244
- # produced; EA docks them on the outer stroke edge whatever the
245
- # line width. Naturally aligned centres keep the legacy docking.
286
+ # The builder certifies the empty flush route's shared row and nominal docks.
287
+ def flush_overlap_ray?(connector)
288
+ connector.connector_type == "Association" && connector.style.to_h[:flush_overlap_ray] == true
289
+ end
290
+ private_class_method :flush_overlap_ray?
291
+
292
+ # The QEA builder marks Mode 2 overlap rays for outer-stroke docking.
246
293
  def overlap_ray?(connector)
247
294
  connector.connector_type == "Association" && connector.style.to_h[:overlap_ray] == true
248
295
  end
@@ -429,7 +476,9 @@ module Ea
429
476
  bounds_map, divider_y_by_ref)
430
477
  return nil unless bounds_map && divider_y_by_ref
431
478
  return nil unless pairs.size == 2
479
+ return nil if flush_overlap_ray?(connector)
432
480
  return nil if explicit_association_ray?(pairs, connector)
481
+ return nil if connector.style.to_h[:stored_origin_ray] == true
433
482
 
434
483
  rel = relationship_for(connector, model_index)
435
484
  return nil unless rel