ea 0.6.107 → 0.6.108

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 (40) hide show
  1. checksums.yaml +4 -4
  2. data/config/themes/119.yml +3 -2
  3. data/lib/ea/fonts/cambria_7_styled_widths.json +293 -0
  4. data/lib/ea/fonts/metrics.rb +50 -7
  5. data/lib/ea/model/diagram_element.rb +4 -0
  6. data/lib/ea/sources/qea/diagram_builder/auto_routing.rb +124 -11
  7. data/lib/ea/sources/qea/diagram_builder/font_settings.rb +50 -0
  8. data/lib/ea/sources/qea/diagram_builder/routing.rb +82 -82
  9. data/lib/ea/sources/qea/diagram_builder.rb +8 -30
  10. data/lib/ea/sources/qea/instance_builder.rb +1 -0
  11. data/lib/ea/svg/ea_emitter/color_resolver.rb +1 -2
  12. data/lib/ea/svg/ea_emitter/compartment/attributes.rb +2 -1
  13. data/lib/ea/svg/ea_emitter/compartment/enum_literals.rb +11 -5
  14. data/lib/ea/svg/ea_emitter/compartment/header.rb +2 -1
  15. data/lib/ea/svg/ea_emitter/compartment/note_body.rb +6 -4
  16. data/lib/ea/svg/ea_emitter/compartment/note_html.rb +66 -0
  17. data/lib/ea/svg/ea_emitter/compartment/operations.rb +2 -1
  18. data/lib/ea/svg/ea_emitter/compartment.rb +1 -0
  19. data/lib/ea/svg/ea_emitter/connector_redock.rb +30 -14
  20. data/lib/ea/svg/ea_emitter/element/attribute_line_builder.rb +11 -9
  21. data/lib/ea/svg/ea_emitter/element/attribute_renderer.rb +24 -8
  22. data/lib/ea/svg/ea_emitter/element/compartment_geometry.rb +18 -7
  23. data/lib/ea/svg/ea_emitter/element/constraint_renderer.rb +9 -5
  24. data/lib/ea/svg/ea_emitter/element/enumeration_literal_renderer.rb +8 -5
  25. data/lib/ea/svg/ea_emitter/element/header_line_pipeline.rb +3 -2
  26. data/lib/ea/svg/ea_emitter/element/header_line_provider/instance_spec.rb +10 -5
  27. data/lib/ea/svg/ea_emitter/element/header_line_provider/name.rb +2 -1
  28. data/lib/ea/svg/ea_emitter/element/header_renderer.rb +14 -12
  29. data/lib/ea/svg/ea_emitter/element/operation_renderer.rb +10 -7
  30. data/lib/ea/svg/ea_emitter/elements.rb +39 -32
  31. data/lib/ea/svg/ea_emitter/font_resolver.rb +14 -4
  32. data/lib/ea/svg/ea_emitter/ghost_labels.rb +3 -0
  33. data/lib/ea/svg/ea_emitter/label/end_label.rb +37 -4
  34. data/lib/ea/svg/ea_emitter/labels.rb +12 -4
  35. data/lib/ea/svg/ea_emitter/render_context.rb +1 -1
  36. data/lib/ea/svg/ea_emitter/text_renderer.rb +6 -5
  37. data/lib/ea/theme/definition.rb +5 -2
  38. data/lib/ea/theme/loader.rb +3 -2
  39. data/lib/ea/version.rb +1 -1
  40. metadata +4 -1
@@ -115,9 +115,11 @@ module Ea
115
115
  else
116
116
  stored_ray_gap?(source, target, model.style[:mode], geom[:edge])
117
117
  end
118
- return model unless allowed
118
+ return model unless allowed && stored_association_path?(model, link_row, source, target, geom)
119
119
 
120
120
  set_stored_ray_origins(model, source, target, geom)
121
+ model.style[:association_ray_outline] = true if model.connector_type == "Association"
122
+ model
121
123
  end
122
124
 
123
125
  # An empty-Path Mode 3 Association follows the line between its
@@ -125,6 +127,7 @@ module Ea
125
127
  # The target origin must lie inside its own stroked outline.
126
128
  def association_stored_ray(model, link_row, diagram_row, geom, context)
127
129
  return model if context
130
+ return stored_diagonal_corner(model, link_row, diagram_row, geom) unless link_row.path.to_s.empty?
128
131
  return model unless association_ray_candidate?(model, link_row)
129
132
 
130
133
  source, target = stored_ray_boxes(link_row, diagram_row)
@@ -141,6 +144,47 @@ module Ea
141
144
  model
142
145
  end
143
146
 
147
+ # A single authored bend joins the stored mathematical origins.
148
+ # Clip each leg independently; a facing-edge shortcut loses the bend.
149
+ def stored_diagonal_corner(model, link_row, diagram_row, geom)
150
+ return model unless model.connector_type == "Association" && association_ray_style?(model.style.to_h) &&
151
+ model.style[:lwidth] == "2" && geom.values_at(:sx, :sy, :ex, :ey).all?
152
+
153
+ corners = intermediate_waypoints(link_row)
154
+ return model unless corners.size == 1
155
+
156
+ boxes = stored_ray_boxes(link_row, diagram_row)
157
+ origins = [stored_ray_origin(boxes[0], geom[:sx], geom[:sy]),
158
+ stored_ray_origin(boxes[1], geom[:ex], geom[:ey])]
159
+ bend = Ea::Model::Point.new(x: corners[0][0], y: corners[0][1])
160
+ left, right = boxes.sort_by(&:x)
161
+ return model unless bend.x.between?(left.x + left.width + 1, right.x - 1) &&
162
+ boxes.all? { |box| bend.y.between?(box.y, box.y + box.height) }
163
+
164
+ outlines = boxes.map { |box| grown_bounds(box, 1) }
165
+ return model unless origins.zip(outlines).all? { |origin, box| inside_box?(origin, box) }
166
+ return model if outlines.any? { |box| inside_box?(bend, box) }
167
+ return model unless origins.all? { |origin| origin.x != bend.x && origin.y != bend.y }
168
+
169
+ ends = origins.zip(outlines).map { |origin, box| diagonal_corner_exit(origin, bend, box) }
170
+ return model unless ends.all?
171
+
172
+ model.waypoints = [ends[0], bend, ends[1]].map { |point| Ea::Model::Waypoint.new(position: point) }
173
+ model.source_anchor, model.target_anchor = origins
174
+ model
175
+ end
176
+
177
+ def diagonal_corner_exit(origin, bend, box)
178
+ dx = bend.x - origin.x
179
+ dy = bend.y - origin.y
180
+ times = [(dx.positive? ? box.x + box.width - origin.x : box.x - origin.x).to_f / dx,
181
+ (dy.positive? ? box.y + box.height - origin.y : box.y - origin.y).to_f / dy]
182
+ time = times.select { |value| value.between?(0, 1) }.min
183
+ return unless time
184
+
185
+ Ea::Model::Point.new(x: (origin.x + time * dx).floor, y: (origin.y + time * dy).floor)
186
+ end
187
+
144
188
  def port_waypoints(model, origins)
145
189
  [model.source_port || origins.first, model.target_port || origins.last].map do |point|
146
190
  Ea::Model::Waypoint.new(position: point)
@@ -198,7 +242,15 @@ module Ea
198
242
  def association_ray_candidate?(model, link_row)
199
243
  model.connector_type == "Association" && link_row.path.to_s.empty? &&
200
244
  model.source_anchor && model.target_anchor &&
201
- (model.source_port || model.target_port) && association_ray_style?(model.style.to_h)
245
+ (model.source_port || model.target_port || aligned_default_origins?(model)) &&
246
+ association_ray_style?(model.style.to_h)
247
+ end
248
+
249
+ # Zero offsets omit ports, but an explicitly stored axis-aligned
250
+ # width-2 ray still uses the logical centers and stroked outlines.
251
+ def aligned_default_origins?(model)
252
+ model.has_geometry_offsets && model.style[:lwidth] == "2" &&
253
+ (model.source_anchor.x == model.target_anchor.x || model.source_anchor.y == model.target_anchor.y)
202
254
  end
203
255
 
204
256
  # A thin connector still meets the outer stroke of explicitly
@@ -238,73 +290,6 @@ module Ea
238
290
  point.y.between?(bounds.y, bounds.y + bounds.height)
239
291
  end
240
292
 
241
- # A Mode 2 Association with equal SX/EX (vertical EDGE 1/3) or
242
- # equal SY/EY (horizontal EDGE 4) and no custom Path docks as a
243
- # straight line through the middle of the boxes' overlap, moved
244
- # by the shared stored offset. Stored SY counts upward.
245
- def overlap_ray_origins(model, link_row, diagram_row, geom, context)
246
- return model if context
247
- return model unless overlap_ray_candidate?(model, link_row, geom)
248
-
249
- source, target = stored_ray_boxes(link_row, diagram_row)
250
- origins = overlap_origins(source, target, geom)
251
- return model unless origins
252
-
253
- model.source_anchor, model.target_anchor = origins.map do |ox, oy|
254
- Ea::Model::Point.new(x: ox, y: oy)
255
- end
256
- model.style[:overlap_ray] = true
257
- model
258
- end
259
-
260
- # Both ray origins share the overlap coordinate on the axis the
261
- # offsets move and sit at each box's centre on the other axis.
262
- def overlap_origins(source, target, geom)
263
- along, across, offset = overlap_axes(geom)
264
- coordinate = overlap_ray_coordinate(*box_span(source, along), *box_span(target, along), offset)
265
- return nil unless coordinate && disjoint_span?(*box_span(source, across), *box_span(target, across))
266
-
267
- [source, target].map do |box|
268
- start, size = box_span(box, across)
269
- centre = start + size / 2.0
270
- geom[:edge] == 4 ? [centre, coordinate] : [coordinate, centre]
271
- end
272
- end
273
-
274
- # [axis the offsets move, axis the boxes face, stored offset]
275
- def overlap_axes(geom)
276
- return [%i[y height], %i[x width], -geom[:sy]] if geom[:edge] == 4
277
-
278
- [%i[x width], %i[y height], geom[:sx]]
279
- end
280
-
281
- def box_span(box, keys)
282
- keys.map { |key| box.public_send(key) }
283
- end
284
-
285
- def overlap_ray_candidate?(model, link_row, geom)
286
- model.connector_type == "Association" && model.style[:mode] == "2" &&
287
- model.waypoints.size == 2 && link_row.path.to_s.empty? &&
288
- model.has_geometry_offsets && uniform_overlap_offsets?(geom)
289
- end
290
-
291
- def uniform_overlap_offsets?(geom)
292
- return false unless geom.values_at(:sx, :sy, :ex, :ey).all?
293
-
294
- geom[:edge] == 4 ? geom[:sy] == geom[:ey] : [1, 3].include?(geom[:edge]) && geom[:sx] == geom[:ex]
295
- end
296
-
297
- # Floor of the overlap midpoint plus the shared offset; nil
298
- # when the boxes do not overlap on this axis.
299
- def overlap_ray_coordinate(start_a, size_a, start_b, size_b, offset)
300
- low = [start_a, start_b].max
301
- high = [start_a + size_a, start_b + size_b].min
302
- return nil if low > high
303
-
304
- coordinate = ((low + high) / 2.0 + offset).floor
305
- coordinate if coordinate.between?(low, high)
306
- end
307
-
308
293
  def disjoint_span?(start_a, size_a, start_b, size_b)
309
294
  start_a + size_a < start_b || start_b + size_b < start_a
310
295
  end
@@ -322,11 +307,17 @@ module Ea
322
307
  end
323
308
 
324
309
  def stored_ray_candidate?(model, link_row, geom)
325
- %w[Generalization Aggregation].include?(model.connector_type) &&
310
+ stored_ray_type?(model) &&
326
311
  model.waypoints.size == 2 && stored_ray_style?(model.style, link_row) &&
327
312
  stored_ray_geometry?(model, geom)
328
313
  end
329
314
 
315
+ def stored_ray_type?(model)
316
+ %w[Generalization Aggregation].include?(model.connector_type) ||
317
+ (model.connector_type == "Association" && model.style[:mode] == "1" &&
318
+ model.style[:lwidth] == "2")
319
+ end
320
+
330
321
  # Boundary-port rounding can move a computed jog off a level saved
331
322
  # ray. Equal stored origin rows specify the line before clipping.
332
323
  def normalized_level_ray?(model, source, target, geom)
@@ -408,6 +399,7 @@ module Ea
408
399
  when "Association"
409
400
  return unless style[:lwidth] == "0"
410
401
 
402
+ redock_child_below(points, bounds_from_rect(source_placement), bounds_from_rect(target_placement))
411
403
  redock_parent_above(points, link_row, source_placement, target_placement, strict: true)
412
404
  end
413
405
  end
@@ -517,15 +509,18 @@ module Ea
517
509
  [bounds.y, bounds.y + bounds.height].include?(point[1])
518
510
  end
519
511
 
520
- # A width-2 Mode 3 Aggregation without a tree style keeps an authored
521
- # orthogonal stair-step of three or more corners. Each end docks on
512
+ # A width-2 Mode 3 route without a tree style keeps authored orthogonal
513
+ # bends: two corners for Associations, three or more for Aggregations.
514
+ # Each end docks on
522
515
  # the 1px outline its adjacent leg points at, from the corner's own
523
516
  # coordinate; any end that cannot do so leaves the route unchanged.
524
517
  def stored_stair_route(link_row, connector, source, target)
525
518
  return nil unless stair_style?(link_row, connector)
526
519
 
527
520
  corners = stored_square_corners(link_row)
528
- return nil unless corners && corners.size >= 3 && alternating_legs?(corners)
521
+ unless corners && stair_corner_count?(corners, connector.connector_type) && alternating_legs?(corners)
522
+ return nil
523
+ end
529
524
 
530
525
  first = outline_dock(corners[0], corners[1], source)
531
526
  last = outline_dock(corners[-1], corners[-2], target)
@@ -534,10 +529,14 @@ module Ea
534
529
 
535
530
  def stair_style?(link_row, connector)
536
531
  style = DiagramStyleParser.parse(link_row.style)
537
- connector.connector_type == "Aggregation" && style[:mode] == "3" &&
532
+ %w[Aggregation Association].include?(connector.connector_type) && style[:mode] == "3" &&
538
533
  style[:lwidth] == "2" && !style.key?(:tree)
539
534
  end
540
535
 
536
+ def stair_corner_count?(corners, connector_type)
537
+ connector_type == "Association" ? corners.size == 2 : corners.size >= 3
538
+ end
539
+
541
540
  # Every leg is non-zero and runs along the opposite axis of the last.
542
541
  def alternating_legs?(corners)
543
542
  axes = corners.each_cons(2).map { |from, to| leg_axis(from, to) }
@@ -595,6 +594,8 @@ module Ea
595
594
  # flush on its box edge.
596
595
  def derived_lateral_route(link_row, connector, source, target)
597
596
  style = DiagramStyleParser.parse(link_row.style)
597
+ return derived_vertical_lateral_route(style, link_row, connector, source, target) if style[:tree] == "LV"
598
+
598
599
  return nil unless style[:mode] == "3" && style[:tree] == "LH" &&
599
600
  derived_lateral_path?(connector.connector_type, link_row.path.to_s)
600
601
 
@@ -726,7 +727,7 @@ module Ea
726
727
  # Keep a vertical tree trunk whose bends still fit both boxes
727
728
  # and the open gap; the closest facing edges need not share its x.
728
729
  def preserve_stored_tree?(points, link_row, connector, src, tgt)
729
- return false unless points.size == 4 && stored_tree_type?(points, link_row, connector)
730
+ return false unless points.size == 4 && stored_tree_type?(points, link_row, connector, src, tgt)
730
731
 
731
732
  style = DiagramStyleParser.parse(link_row.style)
732
733
  return false unless style[:lwidth] == "2" || flush_width_zero_tree?(link_row)
@@ -897,14 +898,13 @@ module Ea
897
898
  style[:tree] == "V" || (style[:mode] == "3" && style[:tree].nil?)
898
899
  end
899
900
 
900
- def stored_tree_type?(points, link_row, connector)
901
- connector.connector_type == "Aggregation" || flush_straight_association?(points, link_row, connector)
902
- end
901
+ def stored_tree_type?(points, link_row, connector, source, target)
902
+ return true if connector.connector_type == "Aggregation"
903
+ return false unless connector.connector_type == "Association" && flush_width_zero_tree?(link_row)
903
904
 
904
- # A flush Association keeps only a straight trunk (one bend stored twice).
905
- def flush_straight_association?(points, link_row, connector)
906
- connector.connector_type == "Association" && points[1] == points[-2] &&
907
- flush_width_zero_tree?(link_row)
905
+ # Distinct thin bends are supported when the source sits above
906
+ # the target; the established reverse case keeps straight trunks.
907
+ points[1] == points[-2] || source.y + source.height < target.y
908
908
  end
909
909
 
910
910
  # A flush trunk may also hang below its parent; a bend repeated at
@@ -13,11 +13,13 @@ module Ea
13
13
  autoload :Routing, "ea/sources/qea/diagram_builder/routing"
14
14
  autoload :AutoRouting, "ea/sources/qea/diagram_builder/auto_routing"
15
15
  autoload :StoredSquarePorts, "ea/sources/qea/diagram_builder/stored_square_ports"
16
+ autoload :FontSettings, "ea/sources/qea/diagram_builder/font_settings"
16
17
  include Routing
17
18
  include AutoRouting
18
19
  include StoredSquarePorts
20
+ include FontSettings
19
21
  private_constant :Routing
20
- private_constant :AutoRouting, :StoredSquarePorts
22
+ private_constant :AutoRouting, :StoredSquarePorts, :FontSettings
21
23
 
22
24
  attr_reader :database
23
25
 
@@ -124,6 +126,7 @@ module Ea
124
126
  @current_obj_row = obj_row
125
127
  style_hash = DiagramStyleParser.parse(obj_row.objectstyle)
126
128
  style_hash[:stored_attribute_order] = true
129
+ font_style = effective_font_style(obj_row, style_hash)
127
130
  Ea::Model::DiagramElement.new(
128
131
  id: IdNormalizer.synthetic("de", diagram_row.diagram_id,
129
132
  obj_row.instance_id),
@@ -131,8 +134,10 @@ module Ea
131
134
  model_element_ref: ref_for_object(obj_row),
132
135
  bounds: bounds_from_rect(obj_row),
133
136
  style: style_hash,
134
- font_family: presence(style_hash[:font]),
135
- font_size: font_size_from(style_hash),
137
+ font_family: presence(font_style[:font]),
138
+ font_size: font_size_from(font_style),
139
+ font_size_unit: "pt",
140
+ font_logical_height: font_logical_height_from(font_style),
136
141
  font_bold: truthy?(style_hash[:bold]),
137
142
  font_italic: truthy?(style_hash[:italic]),
138
143
  font_underline: truthy?(style_hash[:ul]),
@@ -145,33 +150,6 @@ module Ea
145
150
  raw == "1" || raw == "-1"
146
151
  end
147
152
 
148
- def presence(value)
149
- value.nil? || value.to_s.empty? ? nil : value
150
- end
151
-
152
- # EA stores the element font size in tenths of a point and
153
- # renders it through GDI's double rounding: pixel height =
154
- # floor(tenths * 96 / 720), then point size =
155
- # floor(pixels * 72 / 96). 70 -> 9px -> 6pt (F3660305 Text),
156
- # 80 -> 10px -> 7pt, 90 -> 12px -> 9pt (4EBDE645's PI_*
157
- # classes), 100 -> 13px -> 9pt (0D31AED1's all-100 elements
158
- # publish 321 texts at 9pt), 120 -> 16px -> 12pt
159
- # (412BC89E's Boundary title), 140 -> 18px -> 13pt.
160
- # fontsz=0 ("use default", written back by the font dialog)
161
- # renders at the 7pt theme size.
162
- USE_DEFAULT_FONTSZ = [0].freeze
163
- GDI_DPI = 96
164
-
165
- def font_size_from(style_hash)
166
- raw = style_hash[:fontsz]
167
- return nil if raw.nil? || raw.to_s.empty?
168
-
169
- return nil if USE_DEFAULT_FONTSZ.include?(raw.to_i)
170
-
171
- pixels = (raw.to_f * GDI_DPI / 720).floor
172
- (pixels * 72.0 / GDI_DPI).floor
173
- end
174
-
175
153
  def build_connectors(diagram_row)
176
154
  links = database.diagram_links_for(diagram_row.diagram_id) || []
177
155
  context = ContextDiagramSupport.context?(diagram_row)
@@ -34,6 +34,7 @@ module Ea
34
34
  Ea::Model::InstanceSpecification.new(
35
35
  id: IdNormalizer.from_guid(row.ea_guid),
36
36
  name: row.name.to_s,
37
+ role_name: row.alias,
37
38
  classifier_id: classifier_id_for(row),
38
39
  classifier_name: classifier_name_for(row),
39
40
  stereotype: row.stereotype.to_s,
@@ -7,11 +7,10 @@ module Ea
7
7
  #
8
8
  # Theme :119 applies:
9
9
  # - Border color (#9A8484) for element strokes
10
- # - Attribute color (#66413F) for attribute text
11
10
  # - Method color for operation text
12
11
  # Theme :119 does NOT apply:
13
12
  # - Per-type fill colors (elements use default #FFFFFF)
14
- # - Text color (#000000 black for non-attribute text)
13
+ # - Attribute text color (inherits the black text color)
15
14
  #
16
15
  # Precedence chain for fill:
17
16
  # 1. Element's BCol int (when present and not sentinel)
@@ -16,7 +16,8 @@ module Ea
16
16
  context.attr_lines,
17
17
  bounds: context.bounds,
18
18
  first_y: context.geometry.attr_first_y,
19
- family: context.family, size: context.size, size_unit: context.size_unit,
19
+ family: context.family, size: context.size,
20
+ size_unit: context.size_unit, logical_height: context.logical_height,
20
21
  fill: context.theme.attribute_text_color,
21
22
  visibility_x_offset: context.theme.attribute_spec.visibility_x_offset,
22
23
  content_x_offset: context.theme.attribute_spec.content_x_offset
@@ -23,7 +23,10 @@ module Ea
23
23
  end
24
24
 
25
25
  def render_literals_block(context)
26
- line_h = context.size + LITERAL_LINE_OFFSET
26
+ line_h = Ea::Fonts::Metrics.compartment_line_height(
27
+ context.size, family: context.family, unit: context.size_unit, logical_height: context.logical_height,
28
+ offset: LITERAL_LINE_OFFSET
29
+ )
27
30
  text_blocks = []
28
31
  row_y = context.geometry.enum_literal_first_y
29
32
  # EA renders a centered italic "literals" caption above
@@ -69,12 +72,13 @@ module Ea
69
72
  def caption_text(context, y)
70
73
  content = "literals"
71
74
  len = TextRenderer.estimate_width(content, context.size, nil,
72
- family: context.family).round
75
+ family: context.family, logical_height: context.logical_height).round
73
76
  x = (context.bounds.x + (context.bounds.width - len) / 2.0).floor
74
77
  TextRenderer.new(
75
78
  content: content,
76
79
  x: x, y: y,
77
- family: context.family, size: context.size, size_unit: context.size_unit,
80
+ family: context.family, size: context.size,
81
+ size_unit: context.size_unit, logical_height: context.logical_height,
78
82
  fill: context.theme.text_color,
79
83
  stroke_in_text: context.theme.stroke_in_text_color,
80
84
  width_factor: context.theme.text_width_factor,
@@ -87,7 +91,8 @@ module Ea
87
91
  content: " ",
88
92
  x: context.bounds.x + (context.theme.attribute_spec.visibility_x_offset || 5),
89
93
  y: y,
90
- family: context.family, size: context.size, size_unit: context.size_unit,
94
+ family: context.family, size: context.size,
95
+ size_unit: context.size_unit, logical_height: context.logical_height,
91
96
  fill: context.theme.text_color,
92
97
  stroke_in_text: context.theme.stroke_in_text_color,
93
98
  width_factor: context.theme.text_width_factor
@@ -100,7 +105,8 @@ module Ea
100
105
  content: content,
101
106
  x: context.bounds.x + (context.theme.attribute_spec.content_x_offset || 26),
102
107
  y: y,
103
- family: context.family, size: context.size, size_unit: context.size_unit,
108
+ family: context.family, size: context.size,
109
+ size_unit: context.size_unit, logical_height: context.logical_height,
104
110
  fill: context.theme.text_color,
105
111
  stroke_in_text: context.theme.stroke_in_text_color,
106
112
  width_factor: context.theme.text_width_factor
@@ -17,7 +17,8 @@ module Ea
17
17
  context.header_lines,
18
18
  bounds: context.bounds,
19
19
  first_y: context.geometry.header_first_y,
20
- family: context.family, size: context.size, size_unit: context.size_unit,
20
+ family: context.family, size: context.size,
21
+ size_unit: context.size_unit, logical_height: context.logical_height,
21
22
  fill: context.theme.text_color,
22
23
  weight_normal: context.theme.text_weight_normal,
23
24
  weight_bold: context.theme.text_weight_bold,
@@ -39,10 +39,10 @@ module Ea
39
39
  pitch = size + 7
40
40
  first_offset = size + 9
41
41
  y_index = 0
42
- paragraphs = body.to_s.split(/\n/).reject { |p| p.strip.empty? }
42
+ paragraphs = NoteHtml.paragraphs(body)
43
43
  paragraphs.flat_map do |para|
44
- lines = wrap_paragraph(para, usable, size, context.family)
45
- lines.each_with_index.map do |line, i|
44
+ lines = wrap_paragraph(para.text, usable, size, context.family)
45
+ paragraph_svg = lines.each_with_index.map do |line, i|
46
46
  y = context.bounds.y + first_offset + ((y_index + i) * pitch)
47
47
  TextRenderer.new(
48
48
  content: line,
@@ -54,7 +54,9 @@ module Ea
54
54
  stroke_in_text: context.theme.stroke_in_text_color,
55
55
  width_factor: context.theme.text_width_factor
56
56
  ).to_svg
57
- end.tap { y_index += lines.size + 1 }
57
+ end
58
+ y_index += lines.size + (para.tight ? 0 : 1)
59
+ paragraph_svg
58
60
  end
59
61
  end
60
62
 
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ea
4
+ module Svg
5
+ module EaEmitter
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.
12
+ module NoteHtml
13
+ Paragraph = Struct.new(:text, :tight)
14
+
15
+ TAG = %r{</?(?:ul|ol|li|b|i|u|p|br|strong|em)\b[^>]*>}i
16
+ LINE_BREAK = /<br\b[^>]*>/i
17
+ LIST_WRAPPER = %r{\A</?(?:ul|ol)>\z}i
18
+ LIST_ITEM = %r{\A<li>(.*)</li>\z}i
19
+ ENTITIES = { "&lt;" => "<", "&gt;" => ">", "&amp;" => "&", "&quot;" => '"' }.freeze
20
+
21
+ module_function
22
+
23
+ def paragraphs(body)
24
+ 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)
30
+ end
31
+ end
32
+
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
45
+ end
46
+
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?)
53
+ segments.each_with_index.map do |segment, index|
54
+ first = index.zero?
55
+ [first ? "#{bullet}#{segment}" : segment, first && !bullet.empty?, index < segments.size - 1]
56
+ end
57
+ end
58
+
59
+ def clean(line)
60
+ line.gsub(TAG, "").gsub(/&(?:lt|gt|amp|quot);/, ENTITIES).squeeze(" ").strip
61
+ end
62
+ end
63
+ end
64
+ end
65
+ end
66
+ end
@@ -27,7 +27,8 @@ module Ea
27
27
  context.classifier.operations,
28
28
  bounds: context.bounds,
29
29
  first_y: context.geometry.op_first_y,
30
- family: context.family, size: context.size
30
+ family: context.family, size: context.size,
31
+ size_unit: context.size_unit, logical_height: context.logical_height
31
32
  )
32
33
  parts.join("\n")
33
34
  end
@@ -12,6 +12,7 @@ module Ea
12
12
  module Compartment
13
13
  autoload :Shape, "ea/svg/ea_emitter/compartment/shape"
14
14
  autoload :NoteBody, "ea/svg/ea_emitter/compartment/note_body"
15
+ autoload :NoteHtml, "ea/svg/ea_emitter/compartment/note_html"
15
16
  autoload :Header, "ea/svg/ea_emitter/compartment/header"
16
17
  autoload :HeaderDivider, "ea/svg/ea_emitter/compartment/header_divider"
17
18
  autoload :StereotypeIcon, "ea/svg/ea_emitter/compartment/stereotype_icon"
@@ -53,10 +53,10 @@ module Ea
53
53
  return stored_top_pairs(connector, source_bounds, pairs)
54
54
  end
55
55
 
56
- # EA docks these markers on the outer edge of the 2px box stroke.
57
- outline = bounds.map do |box|
58
- Ea::Model::Bounds.new(x: box.x - 1, y: box.y - 1,
59
- width: box.width + 2, height: box.height + 2)
56
+ refs = [source_ref, target_ref]
57
+ duids = connector.style.to_h.values_at(:soid, :eoid)
58
+ outline = bounds.each_with_index.map do |box, index|
59
+ outline_bounds(box, diagram, refs[index], duids[index])
60
60
  end
61
61
  candidate = clipped_pairs(outline, anchors)
62
62
  candidate[0] = external_source_point(connector, outline.first, anchors, candidate.first)
@@ -231,19 +231,34 @@ module Ea
231
231
  return false unless bounds.all? { |box| box.width.positive? && box.height.positive? }
232
232
 
233
233
  ends = [[refs.first, style[:soid]], [refs.last, style[:eoid]]]
234
- ends.all? { |ref, duid| border_two?(diagram, ref, duid) }
234
+ ends.all? { |ref, duid| border_width?(diagram, ref, duid, 2) }
235
235
  end
236
236
  private_class_method :bordered_outline?
237
237
 
238
238
  # Every placement of the element (the DUID-matched one when the
239
- # connector names it) must declare a border width of 2.
240
- def border_two?(diagram, ref, duid)
239
+ # connector names it) must declare the same border width.
240
+ def border_width?(diagram, ref, duid, width)
241
+ placed = end_placements(diagram, ref, duid)
242
+ placed.any? && placed.all? { |el| el.line_width == width || el.style.to_h[:lwth] == width.to_s }
243
+ end
244
+ private_class_method :border_width?
245
+
246
+ # Explicit zero-width borders keep that end on the geometric edge;
247
+ # absent or inherited widths retain the default outer-stroke docking.
248
+ def outline_bounds(box, diagram, ref, duid)
249
+ return box if border_width?(diagram, ref, duid, 0)
250
+
251
+ Ea::Model::Bounds.new(x: box.x - 1, y: box.y - 1,
252
+ width: box.width + 2, height: box.height + 2)
253
+ end
254
+ private_class_method :outline_bounds
255
+
256
+ def end_placements(diagram, ref, duid)
241
257
  placed = Array(diagram&.elements).select { |el| el.model_element_ref == ref }
242
258
  named = placed.select { |el| duid && el.duid.to_s.casecmp?(duid.to_s) }
243
- placed = named unless named.empty?
244
- placed.any? && placed.all? { |el| el.line_width == 2 || el.style.to_h[:lwth] == "2" }
259
+ named.empty? ? placed : named
245
260
  end
246
- private_class_method :border_two?
261
+ private_class_method :end_placements
247
262
 
248
263
  def outline_connector?(connector, bounds)
249
264
  return true if default_model_connector?(connector)
@@ -397,12 +412,13 @@ module Ea
397
412
  end
398
413
  private_class_method :explicit_horizontal_source_dock?
399
414
 
400
- # A stored Association ray whose source port certifies the shared
401
- # anchor row is one explicit horizontal line: EA seats neither end
402
- # in a compartment row, unlike Generalization/Aggregation targets.
415
+ # The QEA producer certifies stored Association rays from their
416
+ # endpoint origins. Keep the clipped ray even when a diagonal end
417
+ # coincides with a header divider. Other callers need a certified
418
+ # horizontal port before bypassing compartment row seating.
403
419
  def explicit_association_ray?(pairs, connector)
404
420
  produced = connector.style.to_h[:stored_association_ray] == true
405
- return true if produced && pairs.first[1] == pairs.last[1]
421
+ return true if produced
406
422
 
407
423
  certified_association_ray?(pairs, connector)
408
424
  end
@@ -48,6 +48,7 @@ module Ea
48
48
  parts << ": #{type_str}" unless type_str.empty?
49
49
  mult = multiplicity
50
50
  parts << " #{mult}" unless mult.empty?
51
+ parts << " {ordered}" if property.is_ordered
51
52
  parts << " = #{property.default_value}" if property.default_value && !property.default_value.to_s.empty?
52
53
  parts.join.rstrip
53
54
  end
@@ -100,20 +101,21 @@ module Ea
100
101
  def multiplicity
101
102
  lower = property.multiplicity_lower
102
103
  upper = property.multiplicity_upper
103
- if lower == 1 && upper == 1
104
- raw = property.multiplicity_lower_raw
105
- # Literal text bounds EA displays verbatim ('elements:
106
- # Character [size]' - stored bounds 'size'/'size' parse
107
- # to the 1..1 default).
108
- return "[#{raw}]" if raw && raw == property.multiplicity_upper_raw
109
-
110
- return ""
111
- end
104
+ return default_multiplicity if lower == 1 && upper == 1
112
105
  return "" if lower.nil? && upper.nil?
106
+ return "[#{lower}]" if lower == upper && upper != -1
113
107
 
114
108
  "[#{lower || 0}..#{bound_text(upper)}]"
115
109
  end
116
110
 
111
+ # Literal text bounds EA displays verbatim ('elements:
112
+ # Character [size]' - stored bounds 'size'/'size' parse
113
+ # to the 1..1 default).
114
+ def default_multiplicity
115
+ raw = property.multiplicity_lower_raw
116
+ raw && raw == property.multiplicity_upper_raw ? "[#{raw}]" : ""
117
+ end
118
+
117
119
  def bound_text(value)
118
120
  # EA renders a stored literal bound ("n") verbatim, and a
119
121
  # computed-unbounded upper as "*" (verified against