ea 0.6.140 → 0.6.141

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 (54) hide show
  1. checksums.yaml +4 -4
  2. data/lib/ea/cli/app.rb +10 -0
  3. data/lib/ea/cli/command/diagrams.rb +4 -1
  4. data/lib/ea/cli/command/render.rb +8 -1
  5. data/lib/ea/cli/command/spa.rb +2 -1
  6. data/lib/ea/cli/command/svg.rb +9 -6
  7. data/lib/ea/fonts/metrics.rb +44 -16
  8. data/lib/ea/sources/qea/diagram_builder/routing.rb +64 -22
  9. data/lib/ea/sources/qea/diagram_builder.rb +4 -1
  10. data/lib/ea/sources/xmi/diagram_builder.rb +21 -5
  11. data/lib/ea/sources/xmi/extension_style_parser.rb +1 -1
  12. data/lib/ea/sources/xmi/stored_route/reversed_routes.rb +10 -26
  13. data/lib/ea/spa/generator.rb +4 -2
  14. data/lib/ea/spa/projector.rb +4 -2
  15. data/lib/ea/svg/ea_emitter/bottom_margin.rb +16 -12
  16. data/lib/ea/svg/ea_emitter/bounds_calculator.rb +22 -14
  17. data/lib/ea/svg/ea_emitter/canvas.rb +43 -5
  18. data/lib/ea/svg/ea_emitter/canvas_anchor.rb +12 -4
  19. data/lib/ea/svg/ea_emitter/compartment/attributes.rb +5 -0
  20. data/lib/ea/svg/ea_emitter/compartment/constraints.rb +5 -0
  21. data/lib/ea/svg/ea_emitter/compartment/enum_literals.rb +5 -0
  22. data/lib/ea/svg/ea_emitter/compartment/header.rb +5 -0
  23. data/lib/ea/svg/ea_emitter/compartment/header_divider.rb +5 -0
  24. data/lib/ea/svg/ea_emitter/compartment/instance_slots.rb +5 -0
  25. data/lib/ea/svg/ea_emitter/compartment/instance_underline.rb +5 -0
  26. data/lib/ea/svg/ea_emitter/compartment/marker.rb +5 -0
  27. data/lib/ea/svg/ea_emitter/compartment/note_body.rb +7 -2
  28. data/lib/ea/svg/ea_emitter/compartment/note_text.rb +5 -0
  29. data/lib/ea/svg/ea_emitter/compartment/operations.rb +5 -0
  30. data/lib/ea/svg/ea_emitter/compartment/package_contents.rb +5 -0
  31. data/lib/ea/svg/ea_emitter/compartment/package_from_parent.rb +12 -1
  32. data/lib/ea/svg/ea_emitter/compartment/shape.rb +14 -1
  33. data/lib/ea/svg/ea_emitter/compartment/stereotype_icon.rb +6 -0
  34. data/lib/ea/svg/ea_emitter/compartment/tagged_values.rb +5 -0
  35. data/lib/ea/svg/ea_emitter/compartment.rb +13 -0
  36. data/lib/ea/svg/ea_emitter/connector_bridges.rb +18 -4
  37. data/lib/ea/svg/ea_emitter/content_extent.rb +11 -2
  38. data/lib/ea/svg/ea_emitter/diagram_frame.rb +7 -0
  39. data/lib/ea/svg/ea_emitter/document.rb +21 -5
  40. data/lib/ea/svg/ea_emitter/element/legend_renderer.rb +18 -14
  41. data/lib/ea/svg/ea_emitter/element/note_shape_renderer.rb +1 -8
  42. data/lib/ea/svg/ea_emitter/elements.rb +11 -9
  43. data/lib/ea/svg/ea_emitter/font_resolver.rb +3 -1
  44. data/lib/ea/svg/ea_emitter/label/end_label.rb +72 -10
  45. data/lib/ea/svg/ea_emitter/labels.rb +10 -3
  46. data/lib/ea/svg/ea_emitter/layer_sequencer.rb +14 -7
  47. data/lib/ea/svg/ea_emitter/paint_order.rb +5 -4
  48. data/lib/ea/svg/ea_emitter/pen.rb +19 -0
  49. data/lib/ea/svg/ea_emitter/render_context.rb +1 -1
  50. data/lib/ea/svg/ea_emitter/text_renderer.rb +24 -8
  51. data/lib/ea/svg/ea_emitter.rb +1 -0
  52. data/lib/ea/svg/parity/source.rb +1 -1
  53. data/lib/ea/version.rb +1 -1
  54. metadata +3 -2
@@ -32,23 +32,24 @@ module Ea
32
32
  LABEL_BASELINE_FROM_ICON_TOP = 12
33
33
  DEFAULT_ITEM_FONT_SIZE = 9
34
34
  # The legend pen is not set explicitly: the panel inherits the pen
35
- # left by the previous drawing (round/bevel), or EA's initial pen
36
- # (square/miter) when the legend is the first thing painted. Held
37
- # across the corpus: 11 of 11 legends painted first are square, 105
38
- # of 105 painted later are round.
39
- INHERITED_STROKE = "stroke-width:1;stroke-linecap:round;stroke-linejoin:bevel;"
40
- INITIAL_STROKE = "stroke-width:1;stroke-linecap:square;stroke-linejoin:miter;"
35
+ # left by the previous drawing, or EA's initial pen (square/miter)
36
+ # when nothing was drawn before it - the same rule notes follow.
37
+ # Held across the corpus: 11 of 11 legends painted first are
38
+ # square/miter, 105 of 105 painted later follow a class-like
39
+ # drawing that leaves round/bevel.
40
+ INITIAL_PEN = Element::NoteShapeRenderer::INITIAL_PEN
41
41
 
42
- def self.render(bounds, legend:, family:, canvas: nil, item_font: {}, first_painted: false)
43
- new(bounds, legend, family, canvas, item_font, first_painted: first_painted).to_svg
42
+ def self.render(bounds, legend:, family:, canvas: nil, item_font: {}, pen: nil)
43
+ new(bounds, legend, family, canvas, item_font, pen: pen).to_svg
44
44
  end
45
45
 
46
- def initialize(bounds, legend, family, canvas, item_font = {}, first_painted: false)
46
+ def initialize(bounds, legend, family, canvas, item_font = {}, pen: nil)
47
47
  @bounds = bounds
48
48
  @legend = legend
49
49
  @family = family
50
50
  @canvas = canvas
51
- @pen = first_painted ? INITIAL_STROKE : INHERITED_STROKE
51
+ cap, join = pen || INITIAL_PEN
52
+ @pen = "stroke-width:1;stroke-linecap:#{cap};stroke-linejoin:#{join};"
52
53
  @item_font = item_font[:size]&.nonzero? ? item_font : {}
53
54
  end
54
55
 
@@ -162,10 +163,11 @@ module Ea
162
163
  (block_left + (block_right - block_left - title_text_width.round) / 2.0).floor
163
164
  end
164
165
 
165
- def estimated_width(text, size, weight: nil, logical_height: nil, text_family: family)
166
+ def estimated_width(text, size, weight: nil, logical_height: nil, text_family: family,
167
+ legend_title: false)
166
168
  TextRenderer.estimate_width(
167
169
  text, size, family: text_family, weight: weight,
168
- logical_height: logical_height
170
+ logical_height: logical_height, legend_title: legend_title
169
171
  ).round
170
172
  end
171
173
 
@@ -193,7 +195,8 @@ module Ea
193
195
  end
194
196
 
195
197
  def title_text_width
196
- estimated_width(legend.title, title_font_size, weight: 700, text_family: heading_family)
198
+ estimated_width(legend.title, title_font_size, weight: 700, text_family: heading_family,
199
+ legend_title: true)
197
200
  end
198
201
 
199
202
  def heading_family
@@ -206,7 +209,8 @@ module Ea
206
209
  TextRenderer.new(
207
210
  content: content.to_s, x: tx, y: ty,
208
211
  family: weight.zero? ? family : heading_family, size: size, size_unit: size_unit,
209
- weight: weight, fill: label_fill, logical_height: weight.zero? ? item_font[:logical_height] : nil
212
+ weight: weight, fill: label_fill, logical_height: weight.zero? ? item_font[:logical_height] : nil,
213
+ legend_title: !weight.zero?
210
214
  ).to_svg
211
215
  end
212
216
 
@@ -32,14 +32,7 @@ module Ea
32
32
  # native notes: 7C44AC09 first (square/miter), 7D960BD8 after
33
33
  # square/bevel icons (square/bevel), 676B9C9A, 926AFE38, 9DA6C553
34
34
  # and C028B286 after round/bevel shapes (round/bevel).
35
- INITIAL_PEN = %w[square miter].freeze
36
- PEN_PATTERN = /stroke-linecap:(\w+);stroke-linejoin:(\w+)/
37
-
38
- # The [cap, join] a rendered group leaves behind, or nil.
39
- def self.pen_of(group)
40
- match = PEN_PATTERN.match(group.to_s)
41
- match&.captures
42
- end
35
+ INITIAL_PEN = Pen::INITIAL
43
36
 
44
37
  def self.render(bounds, body:, fill:, stroke:, stroke_width:, family:, size:, text_fill:, pen: nil)
45
38
  new(bounds, body, fill, stroke, stroke_width, family, size, text_fill, pen || INITIAL_PEN).to_svg
@@ -33,13 +33,11 @@ module Ea
33
33
  def groups
34
34
  @drawn_bounds = {}
35
35
  @divider_y = {}
36
- @painted_any = false
37
36
  @previous_pen = nil
38
37
  ordered_elements.flat_map do |e|
39
- groups = groups_for(e)
40
- @painted_any ||= groups.any?
41
- @previous_pen = Element::NoteShapeRenderer.pen_of(groups.last) || @previous_pen
42
- groups
38
+ painted = groups_for(e)
39
+ @previous_pen = painted.last&.pen || @previous_pen
40
+ painted.map(&:svg)
43
41
  end
44
42
  end
45
43
 
@@ -58,9 +56,14 @@ module Ea
58
56
  private
59
57
 
60
58
  # EA paints placements by descending Sequence (the lowest
61
- # Sequence is on top); equal keys keep their stored order.
59
+ # Sequence is on top); equal keys keep their stored order. A
60
+ # placement with no Sequence (no such row in the corpus, so EA's
61
+ # treatment is unmeasured) is painted first, as before the
62
+ # descending order, rather than ahead of the numbered ones.
62
63
  def ordered_elements
63
- (diagram.elements || []).sort_by.with_index { |e, i| [-(e.z_order || 0), i] }
64
+ (diagram.elements || []).sort_by.with_index do |e, i|
65
+ e.z_order ? [1, -e.z_order, i] : [0, 0, i]
66
+ end
64
67
  end
65
68
 
66
69
  def groups_for(element)
@@ -72,7 +75,7 @@ module Ea
72
75
  record_drawn_bounds(element, context)
73
76
  return [] if element.style&.fetch("connector_proxy", false)
74
77
 
75
- Compartment.render_all(context).compact
78
+ Compartment.paint_all(context)
76
79
  end
77
80
 
78
81
  # Logical-space drawn bounds: stored x/y with the RENDERED
@@ -310,7 +313,6 @@ module Ea
310
313
  diagram: diagram,
311
314
  model_index: model_index,
312
315
  off_canvas_parent_name: parent_name,
313
- first_painted: !@painted_any,
314
316
  previous_pen: @previous_pen
315
317
  )
316
318
  end
@@ -68,7 +68,9 @@ module Ea
68
68
  end
69
69
 
70
70
  def logical_height_for(element)
71
- element&.font_logical_height if size_unit_for(element) == "pt"
71
+ return nil unless size_unit_for(element) == "pt"
72
+
73
+ Ea::Fonts::Metrics.source_logical_height(element&.font_logical_height)
72
74
  end
73
75
 
74
76
  def weight_for(element)
@@ -22,6 +22,9 @@ module Ea
22
22
  SEAT_FONT_SIZE = 7
23
23
  SEAT_X_INSET = 3
24
24
  SEAT_STEREOTYPE_GAP = 12
25
+ SEAT_STEREOTYPE_ONLY_INSET = 8
26
+ SEAT_STEREOTYPE_ONLY_DROP = 15
27
+ SEAT_MULT_DROP = 15
25
28
  # EA keeps an end-label role's left edge at diagram x >= 3; the
26
29
  # stereotype line moves with the role. Measured on the Plateau
27
30
  # v5.1 export: of 776 saved end labels, the only three whose
@@ -142,6 +145,7 @@ module Ea
142
145
  mult_pos = target_multiplicity_seat(connector, heading, anchor, mult_box) ||
143
146
  target_diagonal_property_multiplicity(connector, heading, anchor, text_box, mult_box) ||
144
147
  face_multiplicity || target_horizontal_multiplicity(heading, anchor, mult_box, mult) ||
148
+ target_sloped_rightward_multiplicity(connector, heading, anchor, mult_box, role, mult) ||
145
149
  mult_pos
146
150
  end
147
151
 
@@ -152,10 +156,14 @@ module Ea
152
156
  [text_box, mult_box], mult) || mult_pos
153
157
  if external_upward_property_source?(connector, heading, anchor, text_box, source_bounds)
154
158
  text_pos = external_upward_property_role(anchor, role) || text_pos
159
+ mult_pos = external_upward_property_multiplicity(anchor, mult) || mult_pos
155
160
  end
156
161
  end
157
162
 
158
- text_box = text_box.merge("alignment" => 1) if phantom_property_box?(connector, direction, text_box)
163
+ if phantom_property_box?(connector, direction, text_box) ||
164
+ rightward_phantom_property_box?(connector, direction, text_box)
165
+ text_box = text_box.merge("alignment" => 1)
166
+ end
159
167
 
160
168
  if end_kind == :target && !from_property
161
169
  bend_positions = target_bend_positions(connector, bend, text_box, mult_box, role)
@@ -325,12 +333,30 @@ module Ea
325
333
  # fallback.
326
334
  def own_stereotype_only(text_box, anchor, connector, end_kind)
327
335
  stereo = connector_source_dest_stereotype(connector, end_kind)
328
- pos = position_at(text_box, anchor)
336
+ pos = stereotype_only_seat(text_box, anchor, connector, end_kind, stereo) ||
337
+ position_at(text_box, anchor)
329
338
  return [] if stereo.nil? || stereo.empty? || pos.nil?
330
339
 
331
340
  [text_at(pos[0], pos[1], "«#{stereo}»")]
332
341
  end
333
342
 
343
+ # EMPIRICAL FIT (34FF671B and 5038EEE3, the only two ends in the
344
+ # Plateau v5.1 corpus that draw a Property stereotype with no role
345
+ # or multiplicity): a one-line centred target box is seated at its
346
+ # corner, anchor + (OX, OY) with no sign flip, like the source-end
347
+ # corner seat; the stereotype ends 8px left of the corner and its
348
+ # baseline sits 15px below it. Both reproduce the native SVG exactly.
349
+ def stereotype_only_seat(text_box, anchor, connector, end_kind, stereo)
350
+ return nil unless end_kind == :target && property_label?(connector, end_kind)
351
+ return nil unless seat_font? && cambria_box?(text_box) && text_box.values_at("alignment", "cy") == [1, 16]
352
+
353
+ width = Ea::Fonts::Metrics.text_length("«#{stereo}»", font_size, family: font_family)
354
+ return nil unless width
355
+
356
+ x, y = seat_corner(anchor, text_box)
357
+ [x - width - SEAT_STEREOTYPE_ONLY_INSET, y + SEAT_STEREOTYPE_ONLY_DROP]
358
+ end
359
+
334
360
  # EA's tree-route label clusters (hand-verified against the
335
361
  # EA-published reference, SY 8542 with canvas=(0,0)):
336
362
  # source-end pairs stack at the exit point (role +3/-23,
@@ -374,8 +400,8 @@ module Ea
374
400
  role.to_s.length * 6
375
401
  mult_width = Ea::Fonts::Metrics.text_length(mult.to_s, font_size, family: font_family) ||
376
402
  mult.to_s.length * 6
377
- [[anchor[0] - 60 - role_width, anchor[1] - 7],
378
- [anchor[0] - mult_width - 3, anchor[1] + 15]]
403
+ [[[anchor[0] - 60 - role_width, MIN_ROLE_X].max, anchor[1] - 7],
404
+ [[anchor[0] - mult_width - 3, MIN_ROLE_X].max, anchor[1] + 15]]
379
405
  end
380
406
 
381
407
  # Stored source-end boxes on a 7pt label whose first leg
@@ -461,10 +487,10 @@ module Ea
461
487
  end
462
488
 
463
489
  # Measured box heights: left/down Property clusters store CY 35,
464
- # right-seat clusters CY 31. Phantom outlines leaving down carry a
465
- # synthesized zero box that centers the same way.
490
+ # right-seat and up clusters CY 31. Phantom outlines leaving down
491
+ # carry a synthesized zero box that centers the same way.
466
492
  def centered_property_box?(connector, direction, box)
467
- cy = { left: 35, down: 35, right: 31 }[direction]
493
+ cy = { left: 35, down: 35, right: 31, up: 31 }[direction]
468
494
  (cy && box.values_at("cy", "alignment") == [cy, 1]) ||
469
495
  (direction == :down && phantom_property_box?(connector, direction, box))
470
496
  end
@@ -486,6 +512,16 @@ module Ea
486
512
  property_label?(connector, :source)
487
513
  end
488
514
 
515
+ # A rightward phantom keeps the plain role seat; only its stereotype
516
+ # is centred under the role (0BE81AC5: native stereotype 4px right of
517
+ # the role for a 52px role over a 44px stereotype).
518
+ def rightward_phantom_property_box?(connector, direction, box)
519
+ direction == :right && seat_font? && cambria_box?(box) &&
520
+ box["alignment"].nil? && stored_offsets_zero?(box) &&
521
+ connector.style.to_h[:phantom_outline] == true &&
522
+ property_label?(connector, :source)
523
+ end
524
+
489
525
  def property_role_x(direction, origin, offset)
490
526
  direction == :right ? origin + offset : origin - offset
491
527
  end
@@ -526,6 +562,17 @@ module Ea
526
562
  [[anchor[0] - inset - width, MIN_ROLE_X].max, anchor[1] + baseline]
527
563
  end
528
564
 
565
+ # Multiplicity of a floating upward source (5038EEE3: both such
566
+ # ends, QEA; native "0..1" at anchor.x - width - 3, anchor.y + 15).
567
+ def external_upward_property_multiplicity(anchor, mult)
568
+ return nil if mult_empty?(mult)
569
+
570
+ width = Ea::Fonts::Metrics.text_length(mult, font_size, family: font_family)
571
+ return nil unless width
572
+
573
+ [anchor[0] - width - SEAT_X_INSET, anchor[1] + SEAT_MULT_DROP]
574
+ end
575
+
529
576
  def external_upward_property_source?(connector, heading, anchor, box, bounds)
530
577
  upward_source_heading?(heading) &&
531
578
  zero_source_property_box?(box) && cambria_box?(box) && source_property_cambria?(connector) &&
@@ -886,6 +933,18 @@ module Ea
886
933
  mult_seat(:right, anchor, box)
887
934
  end
888
935
 
936
+ # EMPIRICAL FIT (5038EEE3, the only connector in the Plateau v5.1
937
+ # corpus with a Property source and a role-less target whose leg
938
+ # arrives sloped from the right):
939
+ # the multiplicity takes the rightward corner seat, as on a
940
+ # horizontal rightward leg. Reproduces the native SVG exactly.
941
+ def target_sloped_rightward_multiplicity(connector, heading, anchor, box, role, mult)
942
+ return nil unless seat_font? && role_empty?(role) && !mult_empty?(mult)
943
+ return nil unless heading && heading[0] > heading[1].abs && property_label?(connector, :source)
944
+
945
+ mult_seat(:right, anchor, box)
946
+ end
947
+
889
948
  def target_left_face_multiplicity?(connector, heading, box, mult, target_face)
890
949
  seat_font? && !mult_empty?(mult) && property_label?(connector, :target) && cambria_box?(box) &&
891
950
  stored_offsets_zero?(box) && target_left_face_heading?(heading, target_face)
@@ -905,9 +964,12 @@ module Ea
905
964
  end
906
965
 
907
966
  def target_multiplicity_box?(connector, box)
908
- # Property targets seat at the stored corner; plain moved labels
909
- # retain their explicit offsets.
910
- cambria_box?(box) && (stored_offsets_zero?(box) || property_label?(connector, :target))
967
+ # Property targets and vertical tree targets (5038EEE3) seat at
968
+ # the stored corner; plain moved labels retain their explicit
969
+ # offsets.
970
+ cambria_box?(box) &&
971
+ (stored_offsets_zero?(box) || property_label?(connector, :target) ||
972
+ connector.style.to_h[:tree] == "LV")
911
973
  end
912
974
 
913
975
  def cambria_box?(box)
@@ -91,8 +91,8 @@ module Ea
91
91
  stored_tgt = end_boxes(boxes, :lrt, :lrb)
92
92
  umldi_bounds = connector.umldi_label_bounds || {}
93
93
  stored_labels = !(stored_src.nil? && stored_tgt.nil?) || !umldi_bounds.empty?
94
- src_tb, src_mb = stored_src || unstored_label_boxes(points, :source, stored_labels)
95
- tgt_tb, tgt_mb = stored_tgt || unstored_label_boxes(points, :target, stored_labels)
94
+ src_tb, src_mb = stored_src || unstored_label_boxes(points, :source, stored_labels, connector)
95
+ tgt_tb, tgt_mb = stored_tgt || unstored_label_boxes(points, :target, stored_labels, connector)
96
96
 
97
97
  texts = []
98
98
  end_renderer.texts(text_box: src_tb, mult_box: src_mb,
@@ -126,8 +126,15 @@ module Ea
126
126
 
127
127
  # A connector that stores label boxes for only one end seats the
128
128
  # other end's labels at its anchor, like a stored zero offset.
129
- def unstored_label_boxes(points, end_kind, stored_labels)
129
+ #
130
+ # A phantom outline has no saved link row, so EA draws its default
131
+ # (centred) label: the synthesized zero box carries no alignment,
132
+ # exactly like the QEA phantom's DEFAULT_LABEL_BOXES. Measured on
133
+ # the Plateau v5.1 export (A081C63E: 4 «property» lines, centred
134
+ # under the role in the native SVG, left-aligned from XMI).
135
+ def unstored_label_boxes(points, end_kind, stored_labels, connector)
130
136
  return default_label_boxes(points, end_kind) unless stored_labels
137
+ return [{ "ox" => 0, "oy" => 0 }, { "ox" => 0, "oy" => 0 }] if connector.style.to_h[:phantom_outline] == true
131
138
 
132
139
  [{ "ox" => 0, "oy" => 0, "alignment" => 0 }, { "ox" => 0, "oy" => 0, "alignment" => 0 }]
133
140
  end
@@ -32,14 +32,11 @@ module Ea
32
32
  @document = document
33
33
  end
34
34
 
35
+ # Emitted once per sequencer: the canvas, diagram and options
36
+ # are fixed at construction, so a re-layout builds a new
37
+ # sequencer rather than re-emitting this one.
35
38
  def layers
36
- layers = [Background.render(canvas)]
37
- layers += frame_layers if frame_enabled
38
- layers += element_layers
39
- layers += connector_layers
40
- layers << labels_layer
41
- layers << ghost_labels_layer
42
- layers
39
+ @layers ||= build_layers.freeze
43
40
  end
44
41
 
45
42
  # Final drawn Bounds per model element ref (grown heights,
@@ -67,6 +64,16 @@ module Ea
67
64
 
68
65
  private
69
66
 
67
+ def build_layers
68
+ layers = [Background.render(canvas)]
69
+ layers += frame_layers if frame_enabled
70
+ layers += element_layers
71
+ layers += connector_layers
72
+ layers << labels_layer
73
+ layers << ghost_labels_layer
74
+ layers
75
+ end
76
+
70
77
  def frame_layers
71
78
  DiagramFrame.new(canvas: canvas, theme: theme).layers(diagram).map(&:to_svg)
72
79
  end
@@ -7,8 +7,9 @@ module Ea
7
7
  # start element's placement row, then the connector id: the
8
8
  # `paint_order` key the diagram builders store. Verified without a
9
9
  # single inversion over 12785 connector pairs of the QEA corpus.
10
- # Connectors with an unresolvable key follow the keyed ones, and
11
- # phantoms (no stored key) come last, each in link order.
10
+ # Connectors with an unresolvable key (empty, or with a nil part)
11
+ # follow the keyed ones, and phantoms (no stored key) come last, each
12
+ # in link order.
12
13
  module PaintOrder
13
14
  module_function
14
15
 
@@ -18,9 +19,9 @@ module Ea
18
19
  rank = if order.nil?
19
20
  2
20
21
  else
21
- order.empty? ? 1 : 0
22
+ order.empty? || order.any?(&:nil?) ? 1 : 0
22
23
  end
23
- [rank, order || [], index]
24
+ [rank, rank.zero? ? order : [], index]
24
25
  end.map(&:first)
25
26
  end
26
27
  end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ea
4
+ module Svg
5
+ module EaEmitter
6
+ # The [cap, join] stroke pen a drawing leaves behind. EA never sets a
7
+ # pen on notes and legends: they take whatever the previous drawing
8
+ # left, so the emitter threads the pen from one element to the next.
9
+ module Pen
10
+ ROUND_BEVEL = %w[round bevel].freeze
11
+ SQUARE_BEVEL = %w[square bevel].freeze
12
+ SQUARE_MITER = %w[square miter].freeze
13
+
14
+ # The pen in force when nothing has been drawn yet.
15
+ INITIAL = SQUARE_MITER
16
+ end
17
+ end
18
+ end
19
+ end
@@ -19,7 +19,7 @@ module Ea
19
19
  :enum_literals, :tagged_values, :constraints, :marker_lines,
20
20
  :package_content_lines,
21
21
  :geometry, :theme, :canvas, :diagram, :model_index,
22
- :off_canvas_parent_name, :first_painted, :previous_pen,
22
+ :off_canvas_parent_name, :previous_pen,
23
23
  keyword_init: true
24
24
  )
25
25
  def note_body
@@ -24,14 +24,15 @@ module Ea
24
24
 
25
25
  attr_reader :content, :x, :y, :family, :size, :weight, :style,
26
26
  :fill, :text_length, :rotation, :size_unit,
27
- :stroke_in_text, :width_factor, :logical_height
27
+ :stroke_in_text, :width_factor, :logical_height, :legend_title
28
28
 
29
29
  def initialize(content:, x:, y:, family:, size:, weight: 400,
30
30
  style: "normal", fill: DEFAULT_FILL,
31
31
  text_length: nil, rotation: DEFAULT_ROTATION,
32
32
  size_unit: "px", logical_height: nil,
33
33
  stroke_in_text: DEFAULT_STROKE_IN_TEXT,
34
- width_factor: DEFAULT_WIDTH_FACTOR)
34
+ width_factor: DEFAULT_WIDTH_FACTOR, legend_title: false)
35
+ @legend_title = legend_title
35
36
  @content = content.to_s
36
37
  @x = x
37
38
  @y = y
@@ -155,23 +156,38 @@ module Ea
155
156
  GLYPH_FALLBACK = 0.61
156
157
 
157
158
  def self.estimate_width(text, size, _width_factor = nil, family: nil,
158
- weight: nil, style: nil, logical_height: nil)
159
- real = Ea::Fonts::Metrics.text_length(text, size, family: family,
160
- weight: weight,
161
- style: style, logical_height: logical_height)
159
+ weight: nil, style: nil, logical_height: nil,
160
+ legend_title: false)
161
+ opts = { family: family, weight: weight, style: style,
162
+ logical_height: logical_height, legend_title: legend_title }
163
+ real = Ea::Fonts::Metrics.text_length(text, size, **opts)
162
164
  return real if real
165
+ return piecewise_width(text, size, opts) if Ea::Fonts::Metrics.piecewise_family?(family, size)
163
166
 
167
+ text.to_s.each_char.sum { |ch| glyph_estimate(ch, size) }
168
+ end
169
+
170
+ # A Cambria run outside 7pt that is not wholly in the measured
171
+ # table: table characters are exact, the rest are estimated one
172
+ # at a time with the glyph fallback, so the
173
+ # width never shrinks when a character is appended.
174
+ def self.piecewise_width(text, size, opts)
164
175
  text.to_s.each_char.sum do |ch|
165
- (GLYPH_WIDTHS[ch] || GLYPH_FALLBACK) * size.to_f
176
+ Ea::Fonts::Metrics.text_length(ch, size, **opts) || glyph_estimate(ch, size)
166
177
  end
167
178
  end
168
179
 
180
+ def self.glyph_estimate(char, size)
181
+ (GLYPH_WIDTHS[char] || GLYPH_FALLBACK) * size.to_f
182
+ end
183
+
169
184
  def formatted_text_length
170
185
  return text_length.to_i.to_s if text_length
171
186
 
172
187
  width = self.class.estimate_width(content, size, width_factor,
173
188
  family: family, weight: weight,
174
- style: style, logical_height: logical_height)
189
+ style: style, logical_height: logical_height,
190
+ legend_title: legend_title)
175
191
  width.round.to_s
176
192
  end
177
193
 
@@ -32,6 +32,7 @@ module Ea
32
32
  autoload :Background, "ea/svg/ea_emitter/background"
33
33
  autoload :BoundsCalculator, "ea/svg/ea_emitter/bounds_calculator"
34
34
  autoload :Style, "ea/svg/ea_emitter/style"
35
+ autoload :Pen, "ea/svg/ea_emitter/pen"
35
36
  autoload :Elements, "ea/svg/ea_emitter/elements"
36
37
  autoload :PaintOrder, "ea/svg/ea_emitter/paint_order"
37
38
  autoload :Connectors, "ea/svg/ea_emitter/connectors"
@@ -44,7 +44,7 @@ module Ea
44
44
  private
45
45
 
46
46
  def load_qea
47
- raise NotImplementedError, "QEA Document loading not yet wired into bench"
47
+ Ea::Sources::Qea::Adapter.from_path(path)
48
48
  end
49
49
 
50
50
  def load_xmi
data/lib/ea/version.rb CHANGED
@@ -4,5 +4,5 @@ module Ea
4
4
  # Gem release version. Extracted to its own file so the
5
5
  # `gem-release` tool's `gem bump` can find and update it via the
6
6
  # standard `lib/<gem>/version.rb` convention.
7
- VERSION = "0.6.140"
7
+ VERSION = "0.6.141"
8
8
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ea
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.6.140
4
+ version: 0.6.141
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ribose Inc.
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-10-21 00:00:00.000000000 Z
11
+ date: 2026-10-22 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: lutaml-model
@@ -982,6 +982,7 @@ files:
982
982
  - lib/ea/svg/ea_emitter/markers.rb
983
983
  - lib/ea/svg/ea_emitter/orthogonal_ray.rb
984
984
  - lib/ea/svg/ea_emitter/paint_order.rb
985
+ - lib/ea/svg/ea_emitter/pen.rb
985
986
  - lib/ea/svg/ea_emitter/render_context.rb
986
987
  - lib/ea/svg/ea_emitter/segment_geometry.rb
987
988
  - lib/ea/svg/ea_emitter/style.rb