ea 0.6.106 → 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 (44) 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 +9 -30
  10. data/lib/ea/sources/qea/instance_builder.rb +1 -0
  11. data/lib/ea/sources/qea/property_builder.rb +11 -1
  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/constraints.rb +5 -14
  15. data/lib/ea/svg/ea_emitter/compartment/enum_literals.rb +11 -5
  16. data/lib/ea/svg/ea_emitter/compartment/header.rb +2 -1
  17. data/lib/ea/svg/ea_emitter/compartment/note_body.rb +6 -4
  18. data/lib/ea/svg/ea_emitter/compartment/note_html.rb +66 -0
  19. data/lib/ea/svg/ea_emitter/compartment/operations.rb +2 -1
  20. data/lib/ea/svg/ea_emitter/compartment.rb +1 -0
  21. data/lib/ea/svg/ea_emitter/connector_redock.rb +30 -14
  22. data/lib/ea/svg/ea_emitter/element/attribute_line_builder.rb +11 -9
  23. data/lib/ea/svg/ea_emitter/element/attribute_renderer.rb +42 -17
  24. data/lib/ea/svg/ea_emitter/element/compartment_geometry.rb +46 -15
  25. data/lib/ea/svg/ea_emitter/element/constraint_renderer.rb +34 -2
  26. data/lib/ea/svg/ea_emitter/element/enumeration_literal_renderer.rb +8 -5
  27. data/lib/ea/svg/ea_emitter/element/header_line_pipeline.rb +3 -2
  28. data/lib/ea/svg/ea_emitter/element/header_line_provider/instance_spec.rb +10 -5
  29. data/lib/ea/svg/ea_emitter/element/header_line_provider/name.rb +2 -1
  30. data/lib/ea/svg/ea_emitter/element/header_renderer.rb +14 -12
  31. data/lib/ea/svg/ea_emitter/element/operation_renderer.rb +10 -7
  32. data/lib/ea/svg/ea_emitter/elements.rb +41 -33
  33. data/lib/ea/svg/ea_emitter/font_resolver.rb +14 -4
  34. data/lib/ea/svg/ea_emitter/ghost_labels.rb +3 -0
  35. data/lib/ea/svg/ea_emitter/label/end_label.rb +37 -4
  36. data/lib/ea/svg/ea_emitter/labels.rb +12 -4
  37. data/lib/ea/svg/ea_emitter/marker/diamond.rb +17 -23
  38. data/lib/ea/svg/ea_emitter/render_context.rb +1 -1
  39. data/lib/ea/svg/ea_emitter/text_renderer.rb +6 -5
  40. data/lib/ea/theme/definition.rb +5 -2
  41. data/lib/ea/theme/loader.rb +3 -2
  42. data/lib/ea/version.rb +1 -1
  43. data/memory/deviations.md +3 -0
  44. metadata +5 -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
 
@@ -123,6 +125,8 @@ module Ea
123
125
  def build_element(obj_row, diagram_row)
124
126
  @current_obj_row = obj_row
125
127
  style_hash = DiagramStyleParser.parse(obj_row.objectstyle)
128
+ style_hash[:stored_attribute_order] = true
129
+ font_style = effective_font_style(obj_row, style_hash)
126
130
  Ea::Model::DiagramElement.new(
127
131
  id: IdNormalizer.synthetic("de", diagram_row.diagram_id,
128
132
  obj_row.instance_id),
@@ -130,8 +134,10 @@ module Ea
130
134
  model_element_ref: ref_for_object(obj_row),
131
135
  bounds: bounds_from_rect(obj_row),
132
136
  style: style_hash,
133
- font_family: presence(style_hash[:font]),
134
- 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),
135
141
  font_bold: truthy?(style_hash[:bold]),
136
142
  font_italic: truthy?(style_hash[:italic]),
137
143
  font_underline: truthy?(style_hash[:ul]),
@@ -144,33 +150,6 @@ module Ea
144
150
  raw == "1" || raw == "-1"
145
151
  end
146
152
 
147
- def presence(value)
148
- value.nil? || value.to_s.empty? ? nil : value
149
- end
150
-
151
- # EA stores the element font size in tenths of a point and
152
- # renders it through GDI's double rounding: pixel height =
153
- # floor(tenths * 96 / 720), then point size =
154
- # floor(pixels * 72 / 96). 70 -> 9px -> 6pt (F3660305 Text),
155
- # 80 -> 10px -> 7pt, 90 -> 12px -> 9pt (4EBDE645's PI_*
156
- # classes), 100 -> 13px -> 9pt (0D31AED1's all-100 elements
157
- # publish 321 texts at 9pt), 120 -> 16px -> 12pt
158
- # (412BC89E's Boundary title), 140 -> 18px -> 13pt.
159
- # fontsz=0 ("use default", written back by the font dialog)
160
- # renders at the 7pt theme size.
161
- USE_DEFAULT_FONTSZ = [0].freeze
162
- GDI_DPI = 96
163
-
164
- def font_size_from(style_hash)
165
- raw = style_hash[:fontsz]
166
- return nil if raw.nil? || raw.to_s.empty?
167
-
168
- return nil if USE_DEFAULT_FONTSZ.include?(raw.to_i)
169
-
170
- pixels = (raw.to_f * GDI_DPI / 720).floor
171
- (pixels * 72.0 / GDI_DPI).floor
172
- end
173
-
174
153
  def build_connectors(diagram_row)
175
154
  links = database.diagram_links_for(diagram_row.diagram_id) || []
176
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,
@@ -16,7 +16,8 @@ module Ea
16
16
 
17
17
  def build_all_for(owner_object)
18
18
  attrs = database.attributes_for_object(owner_object.ea_object_id) || []
19
- attrs.map { |row| build_one(row, owner_object) }
19
+ attrs.each_with_index.sort_by { |row, index| attribute_sort_key(row, index) }
20
+ .map { |row, _index| build_one(row, owner_object) }
20
21
  end
21
22
 
22
23
  def build_one(row, owner_object)
@@ -48,6 +49,15 @@ module Ea
48
49
 
49
50
  private
50
51
 
52
+ # Equal explicit Pos values use name order in EA's published diagrams.
53
+ # Missing positions retain source order before the explicit zero cohort.
54
+ def attribute_sort_key(row, index)
55
+ return [row.sort_position, 0, "", "", index] if row.pos.nil?
56
+
57
+ name = row.name.to_s
58
+ [row.sort_position, 1, name.downcase, name, index]
59
+ end
60
+
51
61
  # EA stores some bounds as literal text ("n"); those render
52
62
  # verbatim in attribute lines while computed-unbounded bounds
53
63
  # render as "*" (verified against EA-published SVGs).
@@ -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
@@ -11,26 +11,17 @@ module Ea
11
11
  # content and suppresses them when they would overflow the
12
12
  # stored box height (never truncates rows).
13
13
  module Constraints
14
- DEFAULT_TEXT_COLOR = "#000000"
15
-
16
14
  module_function
17
15
 
18
16
  def render(context)
19
17
  return nil unless context.constraints&.any?
20
- return nil unless context.geometry.tagged_value_first_y
21
-
22
- first_y = context.geometry.tagged_value_first_y
23
- line_h = context.size + 6
24
- last_y = first_y + (context.constraints.size - 1) * line_h
25
- return nil if last_y + 4 > context.bounds.y + context.bounds.height
18
+ return nil unless context.geometry.constraints_first_y
26
19
 
27
- Element::ConstraintRenderer.render(
28
- context.constraints,
29
- bounds: context.bounds,
30
- first_y: first_y,
31
- family: context.family, size: context.size,
32
- fill: DEFAULT_TEXT_COLOR
20
+ divider = Element::DividerRenderer.render(
21
+ context.bounds, y: context.geometry.constraints_divider_y,
22
+ stroke: context.stroke, stroke_width: context.stroke_width
33
23
  )
24
+ "#{divider}\n#{Element::ConstraintRenderer.render_compartment(context)}"
34
25
  end
35
26
  end
36
27
  end
@@ -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"