ea 0.6.107 → 0.6.109

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 (41) hide show
  1. checksums.yaml +4 -4
  2. data/config/themes/119.yml +3 -2
  3. data/lib/ea/diagram/display_config.rb +5 -0
  4. data/lib/ea/fonts/cambria_7_styled_widths.json +293 -0
  5. data/lib/ea/fonts/metrics.rb +50 -7
  6. data/lib/ea/model/diagram_element.rb +4 -0
  7. data/lib/ea/sources/qea/diagram_builder/auto_routing.rb +124 -11
  8. data/lib/ea/sources/qea/diagram_builder/font_settings.rb +50 -0
  9. data/lib/ea/sources/qea/diagram_builder/routing.rb +82 -82
  10. data/lib/ea/sources/qea/diagram_builder.rb +8 -30
  11. data/lib/ea/sources/qea/instance_builder.rb +1 -0
  12. data/lib/ea/svg/ea_emitter/color_resolver.rb +1 -2
  13. data/lib/ea/svg/ea_emitter/compartment/attributes.rb +2 -1
  14. data/lib/ea/svg/ea_emitter/compartment/enum_literals.rb +19 -42
  15. data/lib/ea/svg/ea_emitter/compartment/header.rb +2 -1
  16. data/lib/ea/svg/ea_emitter/compartment/note_body.rb +6 -4
  17. data/lib/ea/svg/ea_emitter/compartment/note_html.rb +66 -0
  18. data/lib/ea/svg/ea_emitter/compartment/operations.rb +3 -1
  19. data/lib/ea/svg/ea_emitter/compartment.rb +1 -0
  20. data/lib/ea/svg/ea_emitter/connector_redock.rb +30 -14
  21. data/lib/ea/svg/ea_emitter/element/attribute_line_builder.rb +11 -9
  22. data/lib/ea/svg/ea_emitter/element/attribute_renderer.rb +24 -8
  23. data/lib/ea/svg/ea_emitter/element/compartment_geometry.rb +20 -10
  24. data/lib/ea/svg/ea_emitter/element/constraint_renderer.rb +9 -5
  25. data/lib/ea/svg/ea_emitter/element/enumeration_literal_renderer.rb +8 -5
  26. data/lib/ea/svg/ea_emitter/element/header_line_pipeline.rb +3 -2
  27. data/lib/ea/svg/ea_emitter/element/header_line_provider/instance_spec.rb +10 -5
  28. data/lib/ea/svg/ea_emitter/element/header_line_provider/name.rb +11 -15
  29. data/lib/ea/svg/ea_emitter/element/header_renderer.rb +14 -12
  30. data/lib/ea/svg/ea_emitter/element/operation_renderer.rb +26 -13
  31. data/lib/ea/svg/ea_emitter/elements.rb +45 -37
  32. data/lib/ea/svg/ea_emitter/font_resolver.rb +14 -4
  33. data/lib/ea/svg/ea_emitter/ghost_labels.rb +3 -0
  34. data/lib/ea/svg/ea_emitter/label/end_label.rb +37 -4
  35. data/lib/ea/svg/ea_emitter/labels.rb +12 -4
  36. data/lib/ea/svg/ea_emitter/render_context.rb +1 -1
  37. data/lib/ea/svg/ea_emitter/text_renderer.rb +6 -5
  38. data/lib/ea/theme/definition.rb +5 -2
  39. data/lib/ea/theme/loader.rb +3 -2
  40. data/lib/ea/version.rb +1 -1
  41. 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
@@ -6,13 +6,11 @@ module Ea
6
6
  module Compartment
7
7
  # Enumeration literal compartment: divider line + "literals"
8
8
  # italic header + each literal name. Skipped when the
9
- # classifier has no literals or the geometry cannot fit them.
9
+ # classifier has no literals or no content geometry.
10
10
  module EnumLiterals
11
11
  module_function
12
12
 
13
- # EA: literal rows sit directly under the header divider,
14
- # 13px apart at 7pt (attr rows use 14), first row at
15
- # divider + 14. No compartment divider, no header label.
13
+ # Caption and literal rows share the same baseline spacing.
16
14
  LITERAL_LINE_OFFSET = 6
17
15
 
18
16
  def render(context)
@@ -23,19 +21,14 @@ module Ea
23
21
  end
24
22
 
25
23
  def render_literals_block(context)
26
- line_h = context.size + LITERAL_LINE_OFFSET
24
+ line_h = Ea::Fonts::Metrics.compartment_line_height(
25
+ context.size, family: context.family, unit: context.size_unit, logical_height: context.logical_height,
26
+ offset: LITERAL_LINE_OFFSET
27
+ )
27
28
  text_blocks = []
28
29
  row_y = context.geometry.enum_literal_first_y
29
- # EA renders a centered italic "literals" caption above
30
- # the rows when the literals carry stored code values
31
- # (t_attribute.Default); bare literal lists render the
32
- # rows directly. Corpus-verified (PT_TypeOfSA:
33
- # "literals" caption + "ServingParcel = SP" rows;
34
- # CV_SequenceType: bare rows, no caption).
35
- if caption?(context)
36
- text_blocks << caption_text(context, row_y)
37
- row_y += line_h
38
- end
30
+ text_blocks << caption_text(context, row_y)
31
+ row_y += line_h
39
32
  context.enum_literals.each_with_index do |literal, idx|
40
33
  y = row_y + (idx * line_h)
41
34
  text_blocks << visibility_placeholder(context, y)
@@ -44,37 +37,16 @@ module Ea
44
37
  wrap_group(text_blocks, context.theme.text_color)
45
38
  end
46
39
 
47
- def caption?(context)
48
- values_caption?(context) || grown?(context)
49
- end
50
-
51
- # EA draws the 'literals' caption on every enum box it
52
- # re-lays (grown height) - corpus 8/8 grown=caption. Boxes
53
- # keeping stored size keep their stored caption state,
54
- # captured by the stored literal values.
55
- def grown?(context)
56
- stored = context.element&.bounds
57
- return false unless stored
58
-
59
- context.bounds.height > stored.height + 4
60
- end
61
-
62
- def values_caption?(context)
63
- context.enum_literals.any? do |l|
64
- value = l.value.to_s
65
- !value.empty? && value != l.name.to_s
66
- end
67
- end
68
-
69
40
  def caption_text(context, y)
70
41
  content = "literals"
71
42
  len = TextRenderer.estimate_width(content, context.size, nil,
72
- family: context.family).round
43
+ family: context.family, logical_height: context.logical_height).round
73
44
  x = (context.bounds.x + (context.bounds.width - len) / 2.0).floor
74
45
  TextRenderer.new(
75
46
  content: content,
76
47
  x: x, y: y,
77
- family: context.family, size: context.size, size_unit: context.size_unit,
48
+ family: context.family, size: context.size,
49
+ size_unit: context.size_unit, logical_height: context.logical_height,
78
50
  fill: context.theme.text_color,
79
51
  stroke_in_text: context.theme.stroke_in_text_color,
80
52
  width_factor: context.theme.text_width_factor,
@@ -87,7 +59,8 @@ module Ea
87
59
  content: " ",
88
60
  x: context.bounds.x + (context.theme.attribute_spec.visibility_x_offset || 5),
89
61
  y: y,
90
- family: context.family, size: context.size, size_unit: context.size_unit,
62
+ family: context.family, size: context.size,
63
+ size_unit: context.size_unit, logical_height: context.logical_height,
91
64
  fill: context.theme.text_color,
92
65
  stroke_in_text: context.theme.stroke_in_text_color,
93
66
  width_factor: context.theme.text_width_factor
@@ -96,11 +69,15 @@ module Ea
96
69
 
97
70
  def literal_name_text(context, literal, y)
98
71
  content = literal_display(literal)
72
+ # EA uses the attribute row point-size increment for literal names.
73
+ offset = context.theme.attribute_spec.content_x_offset || 26
74
+ offset += 2 * (context.size - 7) if context.size_unit == "pt"
99
75
  TextRenderer.new(
100
76
  content: content,
101
- x: context.bounds.x + (context.theme.attribute_spec.content_x_offset || 26),
77
+ x: context.bounds.x + offset,
102
78
  y: y,
103
- family: context.family, size: context.size, size_unit: context.size_unit,
79
+ family: context.family, size: context.size,
80
+ size_unit: context.size_unit, logical_height: context.logical_height,
104
81
  fill: context.theme.text_color,
105
82
  stroke_in_text: context.theme.stroke_in_text_color,
106
83
  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,9 @@ 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,
32
+ parameter_detail: context.diagram&.display_config&.operation_parameter_detail
31
33
  )
32
34
  parts.join("\n")
33
35
  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"