ea 0.6.139 → 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 (61) 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 +54 -6
  8. data/lib/ea/model/note.rb +2 -0
  9. data/lib/ea/sources/qea/diagram_builder/routing.rb +188 -8
  10. data/lib/ea/sources/qea/diagram_builder.rb +14 -1
  11. data/lib/ea/sources/qea/note_builder.rb +7 -1
  12. data/lib/ea/sources/xmi/diagram_builder/element_borders.rb +27 -0
  13. data/lib/ea/sources/xmi/diagram_builder.rb +58 -15
  14. data/lib/ea/sources/xmi/extension_style_parser.rb +1 -1
  15. data/lib/ea/sources/xmi/note_builder.rb +2 -1
  16. data/lib/ea/sources/xmi/stored_route/reversed_routes.rb +78 -0
  17. data/lib/ea/sources/xmi/stored_route.rb +83 -6
  18. data/lib/ea/spa/generator.rb +4 -2
  19. data/lib/ea/spa/projector.rb +4 -2
  20. data/lib/ea/svg/ea_emitter/bottom_margin.rb +104 -0
  21. data/lib/ea/svg/ea_emitter/bounds_calculator.rb +25 -15
  22. data/lib/ea/svg/ea_emitter/canvas.rb +80 -14
  23. data/lib/ea/svg/ea_emitter/canvas_anchor.rb +132 -0
  24. data/lib/ea/svg/ea_emitter/compartment/attributes.rb +5 -0
  25. data/lib/ea/svg/ea_emitter/compartment/constraints.rb +5 -0
  26. data/lib/ea/svg/ea_emitter/compartment/enum_literals.rb +5 -0
  27. data/lib/ea/svg/ea_emitter/compartment/header.rb +5 -0
  28. data/lib/ea/svg/ea_emitter/compartment/header_divider.rb +5 -0
  29. data/lib/ea/svg/ea_emitter/compartment/instance_slots.rb +5 -0
  30. data/lib/ea/svg/ea_emitter/compartment/instance_underline.rb +5 -0
  31. data/lib/ea/svg/ea_emitter/compartment/marker.rb +5 -0
  32. data/lib/ea/svg/ea_emitter/compartment/note_body.rb +11 -2
  33. data/lib/ea/svg/ea_emitter/compartment/note_text.rb +5 -0
  34. data/lib/ea/svg/ea_emitter/compartment/operations.rb +5 -0
  35. data/lib/ea/svg/ea_emitter/compartment/package_contents.rb +5 -0
  36. data/lib/ea/svg/ea_emitter/compartment/package_from_parent.rb +34 -4
  37. data/lib/ea/svg/ea_emitter/compartment/shape.rb +30 -5
  38. data/lib/ea/svg/ea_emitter/compartment/stereotype_icon.rb +6 -0
  39. data/lib/ea/svg/ea_emitter/compartment/tagged_values.rb +5 -0
  40. data/lib/ea/svg/ea_emitter/compartment.rb +13 -0
  41. data/lib/ea/svg/ea_emitter/connector_bridges.rb +117 -29
  42. data/lib/ea/svg/ea_emitter/connectors.rb +67 -17
  43. data/lib/ea/svg/ea_emitter/content_extent.rb +104 -0
  44. data/lib/ea/svg/ea_emitter/diagram_frame.rb +7 -0
  45. data/lib/ea/svg/ea_emitter/document.rb +76 -31
  46. data/lib/ea/svg/ea_emitter/element/legend_renderer.rb +22 -10
  47. data/lib/ea/svg/ea_emitter/element/note_shape_renderer.rb +57 -32
  48. data/lib/ea/svg/ea_emitter/elements.rb +24 -5
  49. data/lib/ea/svg/ea_emitter/font_resolver.rb +3 -1
  50. data/lib/ea/svg/ea_emitter/label/end_label.rb +134 -14
  51. data/lib/ea/svg/ea_emitter/labels.rb +16 -3
  52. data/lib/ea/svg/ea_emitter/layer_sequencer.rb +54 -33
  53. data/lib/ea/svg/ea_emitter/markers.rb +22 -10
  54. data/lib/ea/svg/ea_emitter/paint_order.rb +30 -0
  55. data/lib/ea/svg/ea_emitter/pen.rb +19 -0
  56. data/lib/ea/svg/ea_emitter/render_context.rb +14 -3
  57. data/lib/ea/svg/ea_emitter/text_renderer.rb +24 -8
  58. data/lib/ea/svg/ea_emitter.rb +5 -0
  59. data/lib/ea/svg/parity/source.rb +1 -1
  60. data/lib/ea/version.rb +1 -1
  61. metadata +9 -2
@@ -9,6 +9,8 @@ module Ea
9
9
  # dog-eared rectangle (top-right corner folded in) with the
10
10
  # note text wrapped inside.
11
11
  #
12
+ # Native EA emits the body and the folded corner as two polygons.
13
+ #
12
14
  # Layout:
13
15
  #
14
16
  # ┌──────────────────┐
@@ -19,15 +21,25 @@ module Ea
19
21
  #
20
22
  class NoteShapeRenderer
21
23
  FOLD_SIZE = 12
24
+ FOLD_FILL = "#DCDCDC"
22
25
  TEXT_X_OFFSET = 5
23
26
  TEXT_Y_OFFSET = 12
24
27
  LINE_HEIGHT = 12
25
28
 
26
- def self.render(bounds, body:, fill:, stroke:, stroke_width:, family:, size:, text_fill:)
27
- new(bounds, body, fill, stroke, stroke_width, family, size, text_fill).to_svg
29
+ # EA never sets the note pen: the outline takes the cap and join
30
+ # left by the previous drawing, or the initial pen (square/miter)
31
+ # when the note is the first thing painted. Checked on all six
32
+ # native notes: 7C44AC09 first (square/miter), 7D960BD8 after
33
+ # square/bevel icons (square/bevel), 676B9C9A, 926AFE38, 9DA6C553
34
+ # and C028B286 after round/bevel shapes (round/bevel).
35
+ INITIAL_PEN = Pen::INITIAL
36
+
37
+ def self.render(bounds, body:, fill:, stroke:, stroke_width:, family:, size:, text_fill:, pen: nil)
38
+ new(bounds, body, fill, stroke, stroke_width, family, size, text_fill, pen || INITIAL_PEN).to_svg
28
39
  end
29
40
 
30
- def initialize(bounds, body, fill, stroke, stroke_width, family, size, text_fill)
41
+ def initialize(bounds, body, fill, stroke, stroke_width, family, size, text_fill, pen)
42
+ @pen = pen
31
43
  @bounds = bounds
32
44
  @body = body.to_s
33
45
  @fill = fill
@@ -39,7 +51,7 @@ module Ea
39
51
  end
40
52
 
41
53
  def to_svg
42
- %(<g style="#{group_style}">\n #{path_body}\n #{fold_line}\n #{text_block}\n</g>)
54
+ [body_layer, fold_layer, text_layer].reject(&:empty?).join("\n")
43
55
  end
44
56
 
45
57
  private
@@ -47,39 +59,52 @@ module Ea
47
59
  attr_reader :bounds, :body, :fill, :stroke, :stroke_width,
48
60
  :family, :size, :text_fill
49
61
 
50
- def group_style
51
- "stroke-width:#{stroke_width};stroke-linecap:square;stroke-linejoin:bevel; " \
52
- "fill:#{fill};fill-opacity:1.00; stroke:#{stroke}; stroke-opacity:1.00"
62
+ def group_style(layer_fill)
63
+ "stroke-width:#{stroke_width};stroke-linecap:#{@pen[0]};stroke-linejoin:#{@pen[1]}; " \
64
+ "fill:#{layer_fill};fill-opacity:1.00; stroke:#{stroke}; stroke-opacity:1.00"
53
65
  end
54
66
 
55
- # Folded-corner body emitted as a closed <path> matching
56
- # EA's encoding (not <polygon>). EA uses M..L..L..Z with
57
- # stroke-linejoin=bevel for the dog-eared silhouette.
58
- def path_body
59
- x = bounds.x
60
- y = bounds.y
61
- w = bounds.width
62
- h = bounds.height
63
- fold = [FOLD_SIZE, w / 3, h / 3].min
64
- d = "M #{Canvas.coord(x)} #{Canvas.coord(y)} " \
65
- "L #{Canvas.coord(x + w - fold)} #{Canvas.coord(y)} " \
66
- "L #{Canvas.coord(x + w)} #{Canvas.coord(y + fold)} " \
67
- "L #{Canvas.coord(x + w)} #{Canvas.coord(y + h)} " \
68
- "L #{Canvas.coord(x)} #{Canvas.coord(y + h)} " \
69
- "L #{Canvas.coord(x)} #{Canvas.coord(y)} Z"
70
- %(<path d="#{d}" shape-rendering="auto"/>)
67
+ def body_layer
68
+ %(<g style="#{group_style(fill)}">\n #{polygon(body_points)}\n</g>)
69
+ end
70
+
71
+ def fold_layer
72
+ %(<g style="#{group_style(FOLD_FILL)}">\n #{polygon(fold_points)}\n</g>)
73
+ end
74
+
75
+ def text_layer
76
+ text = text_block
77
+ text.empty? ? "" : %(<g style="#{group_style(fill)}">\n #{text}\n</g>)
71
78
  end
72
79
 
73
- # Inner edges of the folded corner; the outer path supplies the diagonal.
74
- def fold_line
75
- x = bounds.x
80
+ # EA encodes the note silhouette as a <polygon> (all six
81
+ # native Note placements in the PLATEAU and ArcGIS corpora),
82
+ # starting at the top-left corner and running down first.
83
+ def body_points
84
+ left, top, right, bottom = edges
85
+ [[left, top], [left, bottom], [right, bottom],
86
+ [right, top + fold_size], [right - fold_size, top]]
87
+ end
88
+
89
+ def edges
90
+ [bounds.x, bounds.y, bounds.x + bounds.width, bounds.y + bounds.height]
91
+ end
92
+
93
+ # The folded corner is a separate triangle, filled light
94
+ # grey regardless of the note's own fill.
95
+ def fold_points
96
+ r = bounds.x + bounds.width
76
97
  y = bounds.y
77
- w = bounds.width
78
- fold = [FOLD_SIZE, w / 3, bounds.height / 3].min
79
- d = "M #{Canvas.coord(x + w - fold)} #{Canvas.coord(y)} " \
80
- "L #{Canvas.coord(x + w - fold)} #{Canvas.coord(y + fold)} " \
81
- "L #{Canvas.coord(x + w)} #{Canvas.coord(y + fold)}"
82
- %(<path d="#{d}" shape-rendering="auto"/>)
98
+ [[r - fold_size, y], [r - fold_size, y + fold_size], [r, y + fold_size]]
99
+ end
100
+
101
+ def fold_size
102
+ [FOLD_SIZE, bounds.width / 3, bounds.height / 3].min
103
+ end
104
+
105
+ def polygon(points)
106
+ list = points.map { |px, py| "#{Canvas.coord(px)} #{Canvas.coord(py)}" }.join(" ")
107
+ %(<polygon points="#{list}" shape-rendering="auto" style="fill-rule:evenodd;"/>)
83
108
  end
84
109
 
85
110
  def text_block
@@ -33,7 +33,12 @@ module Ea
33
33
  def groups
34
34
  @drawn_bounds = {}
35
35
  @divider_y = {}
36
- ordered_elements.flat_map { |e| groups_for(e) }
36
+ @previous_pen = nil
37
+ ordered_elements.flat_map do |e|
38
+ painted = groups_for(e)
39
+ @previous_pen = painted.last&.pen || @previous_pen
40
+ painted.map(&:svg)
41
+ end
37
42
  end
38
43
 
39
44
  # Final drawn Bounds per model element reference, captured
@@ -50,8 +55,15 @@ module Ea
50
55
 
51
56
  private
52
57
 
58
+ # EA paints placements by descending Sequence (the lowest
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.
53
63
  def ordered_elements
54
- (diagram.elements || []).sort_by { |e| e.z_order || 0 }
64
+ (diagram.elements || []).sort_by.with_index do |e, i|
65
+ e.z_order ? [1, -e.z_order, i] : [0, 0, i]
66
+ end
55
67
  end
56
68
 
57
69
  def groups_for(element)
@@ -63,7 +75,7 @@ module Ea
63
75
  record_drawn_bounds(element, context)
64
76
  return [] if element.style&.fetch("connector_proxy", false)
65
77
 
66
- Compartment.render_all(context).compact
78
+ Compartment.paint_all(context)
67
79
  end
68
80
 
69
81
  # Logical-space drawn bounds: stored x/y with the RENDERED
@@ -300,7 +312,8 @@ module Ea
300
312
  canvas: canvas,
301
313
  diagram: diagram,
302
314
  model_index: model_index,
303
- off_canvas_parent_name: parent_name
315
+ off_canvas_parent_name: parent_name,
316
+ previous_pen: @previous_pen
304
317
  )
305
318
  end
306
319
 
@@ -380,10 +393,16 @@ module Ea
380
393
  attr_group_lead: attr_group_lead)
381
394
  end
382
395
 
396
+ # A ghost-led header (italic parent label above the stereotype /
397
+ # name) takes the same compact offsets: EA seats its divider at
398
+ # the last header baseline + 7 (empirical fit, 21 QEA dividers
399
+ # that were +1: 0114FD24 uro::AbstractConstruction etc.; 20 of
400
+ # 21 now exact, no other compared item moved). The tight ghost
401
+ # variant has no corpus case and keeps the default offsets.
383
402
  def compact_header_offsets?(classifier, header_lines, size, **options)
384
403
  classifier.is_a?(Ea::Model::Klass) &&
385
404
  Ea::Fonts::Metrics.compact_compartment_font?(size, family: options[:family], unit: options[:size_unit]) &&
386
- !%i[italic italic_tight].include?(header_lines.dig(0, 1)) &&
405
+ header_lines.dig(0, 1) != :italic_tight &&
387
406
  options.values_at(:logical_height, :marker_count, :enum_literals_count,
388
407
  :divider_offset, :attr_first_offset) == [nil, 0, 0, 8, 7] &&
389
408
  theme.compartments.header_line_offset == 6 && theme.compartments.attr_line_offset == 6
@@ -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
@@ -45,6 +48,15 @@ module Ea
45
48
  @font_unit = font_unit
46
49
  @font_weight = font_weight
47
50
  @font_style = font_style
51
+ @reach = []
52
+ end
53
+
54
+ # [right, bottom] edge, in model coordinates, of the label boxes
55
+ # (position + stored CX/CY) emitted so far; nil before any.
56
+ def reach
57
+ return nil if @reach.empty?
58
+
59
+ [@reach.map(&:first).max, @reach.map(&:last).max]
48
60
  end
49
61
 
50
62
  # Returns an Array of `<text>` SVG strings for one endpoint.
@@ -102,6 +114,10 @@ module Ea
102
114
  stereo = connector_source_dest_stereotype(connector, end_kind)
103
115
  stereo = stereo.nil? || stereo.empty? ? nil : "«#{stereo}»"
104
116
  direction = leg_direction(end_kind, heading)
117
+ if left_seated_source?(connector, end_kind)
118
+ text_pos, mult_pos = left_seated_positions(anchor, role, mult)
119
+ direction = nil
120
+ end
105
121
  if direction && !(tree && zero_offset?(text_box, mult_box))
106
122
  text_pos = plain_leftward_source_role(connector, direction, anchor, text_box, role) ||
107
123
  plain_vertical_role(connector, direction, anchor, text_box, role) ||
@@ -127,7 +143,9 @@ module Ea
127
143
  directional_multiplicity(:left, anchor, mult_box, mult)
128
144
  end
129
145
  mult_pos = target_multiplicity_seat(connector, heading, anchor, mult_box) ||
146
+ target_diagonal_property_multiplicity(connector, heading, anchor, text_box, mult_box) ||
130
147
  face_multiplicity || target_horizontal_multiplicity(heading, anchor, mult_box, mult) ||
148
+ target_sloped_rightward_multiplicity(connector, heading, anchor, mult_box, role, mult) ||
131
149
  mult_pos
132
150
  end
133
151
 
@@ -138,10 +156,14 @@ module Ea
138
156
  [text_box, mult_box], mult) || mult_pos
139
157
  if external_upward_property_source?(connector, heading, anchor, text_box, source_bounds)
140
158
  text_pos = external_upward_property_role(anchor, role) || text_pos
159
+ mult_pos = external_upward_property_multiplicity(anchor, mult) || mult_pos
141
160
  end
142
161
  end
143
162
 
144
- 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
145
167
 
146
168
  if end_kind == :target && !from_property
147
169
  bend_positions = target_bend_positions(connector, bend, text_box, mult_box, role)
@@ -168,7 +190,8 @@ module Ea
168
190
  end
169
191
 
170
192
  build_texts(role:, mult:, standalone:,
171
- text_pos:, mult_pos:, stereo:, text_box:, stereo_pos:)
193
+ text_pos:, mult_pos:, stereo:, text_box:, stereo_pos:,
194
+ mult_box:)
172
195
  end
173
196
 
174
197
  private
@@ -310,12 +333,30 @@ module Ea
310
333
  # fallback.
311
334
  def own_stereotype_only(text_box, anchor, connector, end_kind)
312
335
  stereo = connector_source_dest_stereotype(connector, end_kind)
313
- pos = position_at(text_box, anchor)
336
+ pos = stereotype_only_seat(text_box, anchor, connector, end_kind, stereo) ||
337
+ position_at(text_box, anchor)
314
338
  return [] if stereo.nil? || stereo.empty? || pos.nil?
315
339
 
316
340
  [text_at(pos[0], pos[1], "«#{stereo}»")]
317
341
  end
318
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
+
319
360
  # EA's tree-route label clusters (hand-verified against the
320
361
  # EA-published reference, SY 8542 with canvas=(0,0)):
321
362
  # source-end pairs stack at the exit point (role +3/-23,
@@ -345,6 +386,24 @@ module Ea
345
386
  end
346
387
  end
347
388
 
389
+ def left_seated_source?(connector, end_kind)
390
+ end_kind == :source && connector.style.to_h[:left_seated_source] == true
391
+ end
392
+
393
+ # EMPIRICAL FIT (5038EEE3 connectors 207 and 212, the only reversed
394
+ # links the builder flags): the source cluster sits left of the
395
+ # anchor like a source leaving left, whichever way the route leaves:
396
+ # role right edge 60px out, 7px above; multiplicity ends 3px short
397
+ # of the anchor, 15px below.
398
+ def left_seated_positions(anchor, role, mult)
399
+ role_width = Ea::Fonts::Metrics.text_length(role.to_s, font_size, family: font_family) ||
400
+ role.to_s.length * 6
401
+ mult_width = Ea::Fonts::Metrics.text_length(mult.to_s, font_size, family: font_family) ||
402
+ mult.to_s.length * 6
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]]
405
+ end
406
+
348
407
  # Stored source-end boxes on a 7pt label whose first leg
349
408
  # leaves straight up or straight right are seated at the box
350
409
  # corner, anchor + (OX, OY) with no sign flip, 3px to its
@@ -365,14 +424,15 @@ module Ea
365
424
  nil
366
425
  end
367
426
 
368
- # Saved bounds seat a source Property role at the stereotype's left
369
- # edge. A centred stored box (ALN=1), or a phantom's synthesized box
427
+ # Saved bounds seat a source or target Property role at the stereotype's
428
+ # left edge (target: +endPoint on 159E9DA0 and 90BCA409, the only
429
+ # corpus targets narrower than the stereotype). A centred stored box (ALN=1), or a phantom's synthesized box
370
430
  # without alignment, centres the shorter role over it; a
371
431
  # left-aligned box keeps both lines on that edge.
372
432
  def bounds_role_centering(connector, end_kind, role, stereo, text_box)
373
- return 0 unless end_kind == :source && stereo && seat_font?
433
+ return 0 unless stereo && seat_font?
374
434
  return 0 unless centred_bounds_box?(connector, text_box)
375
- return 0 unless property_label?(connector, :source)
435
+ return 0 unless property_label?(connector, end_kind)
376
436
 
377
437
  -[stereotype_offset(role, stereo, { "alignment" => 1 }), 0].min
378
438
  end
@@ -427,10 +487,10 @@ module Ea
427
487
  end
428
488
 
429
489
  # Measured box heights: left/down Property clusters store CY 35,
430
- # right-seat clusters CY 31. Phantom outlines leaving down carry a
431
- # 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.
432
492
  def centered_property_box?(connector, direction, box)
433
- cy = { left: 35, down: 35, right: 31 }[direction]
493
+ cy = { left: 35, down: 35, right: 31, up: 31 }[direction]
434
494
  (cy && box.values_at("cy", "alignment") == [cy, 1]) ||
435
495
  (direction == :down && phantom_property_box?(connector, direction, box))
436
496
  end
@@ -452,6 +512,16 @@ module Ea
452
512
  property_label?(connector, :source)
453
513
  end
454
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
+
455
525
  def property_role_x(direction, origin, offset)
456
526
  direction == :right ? origin + offset : origin - offset
457
527
  end
@@ -492,6 +562,17 @@ module Ea
492
562
  [[anchor[0] - inset - width, MIN_ROLE_X].max, anchor[1] + baseline]
493
563
  end
494
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
+
495
576
  def external_upward_property_source?(connector, heading, anchor, box, bounds)
496
577
  upward_source_heading?(heading) &&
497
578
  zero_source_property_box?(box) && cambria_box?(box) && source_property_cambria?(connector) &&
@@ -634,6 +715,16 @@ module Ea
634
715
  property_role_offsets(:down).first if dy.negative? && dy.abs > dx.abs
635
716
  end
636
717
 
718
+ # With no stored multiplicity box, a Property target on a steep
719
+ # upward diagonal seats it beside the anchor, above the role baseline.
720
+ def target_diagonal_property_multiplicity(connector, heading, anchor, text_box, mult_box)
721
+ return nil if mult_box
722
+ return nil unless diagonal_property_box?(connector, text_box)
723
+ return nil unless heading && heading[1].negative? && heading[1].abs > heading[0].abs
724
+
725
+ [anchor[0] + 8, anchor[1] - 7]
726
+ end
727
+
637
728
  def target_vertical_role(connector, heading, anchor, box, role)
638
729
  return nil unless heading && heading[0].zero? && !heading[1].zero?
639
730
 
@@ -842,6 +933,18 @@ module Ea
842
933
  mult_seat(:right, anchor, box)
843
934
  end
844
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
+
845
948
  def target_left_face_multiplicity?(connector, heading, box, mult, target_face)
846
949
  seat_font? && !mult_empty?(mult) && property_label?(connector, :target) && cambria_box?(box) &&
847
950
  stored_offsets_zero?(box) && target_left_face_heading?(heading, target_face)
@@ -861,9 +964,12 @@ module Ea
861
964
  end
862
965
 
863
966
  def target_multiplicity_box?(connector, box)
864
- # Property targets seat at the stored corner; plain moved labels
865
- # retain their explicit offsets.
866
- 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")
867
973
  end
868
974
 
869
975
  def cambria_box?(box)
@@ -905,9 +1011,11 @@ module Ea
905
1011
  end
906
1012
 
907
1013
  def build_texts(role:, mult:, standalone:, text_pos:, mult_pos:,
908
- stereo:, text_box:, stereo_pos: nil)
1014
+ stereo:, text_box:, stereo_pos: nil, mult_box: nil)
909
1015
  if role && !role.empty? && text_pos
910
1016
  x, y = text_pos
1017
+ note_reach(text_pos, text_box)
1018
+ note_reach(mult_pos, mult_box) if mult && !mult.empty? && mult_pos
911
1019
  texts = [text_at(x, y, role)]
912
1020
  if stereo
913
1021
  stereo_x, stereo_y = stereo_pos || [x + stereotype_offset(role, stereo, text_box),
@@ -919,12 +1027,24 @@ module Ea
919
1027
  end
920
1028
  texts
921
1029
  elsif standalone && !standalone.empty? && mult_pos
1030
+ note_reach(mult_pos, mult_box)
922
1031
  [text_at(*mult_pos, standalone)]
923
1032
  else
924
1033
  []
925
1034
  end
926
1035
  end
927
1036
 
1037
+ # A label box starts at the text's x and at its first baseline
1038
+ # less the rounded font height (EA's text top).
1039
+ def note_reach(position, box)
1040
+ width = box && box["cx"]
1041
+ height = box && box["cy"]
1042
+ return unless width && height
1043
+
1044
+ @reach << [position[0] + width,
1045
+ position[1] - font_pixel_size.ceil + height]
1046
+ end
1047
+
928
1048
  def umldi_label_position(bounds)
929
1049
  [bounds[:x], bounds[:y] + font_pixel_size.ceil]
930
1050
  end
@@ -43,6 +43,12 @@ module Ea
43
43
  %(<g style="stroke-width:1;stroke-linecap:round;stroke-linejoin:bevel; fill:#000000;fill-opacity:1.00; stroke:#000000; stroke-opacity:0.00">\n#{texts.join("\n")}\n</g>)
44
44
  end
45
45
 
46
+ # Right edge of the stored end-label boxes (model coordinates)
47
+ # of the last render, or nil when none carry a width.
48
+ def reach
49
+ end_renderer.reach
50
+ end
51
+
46
52
  private
47
53
 
48
54
  def visible_connectors
@@ -85,8 +91,8 @@ module Ea
85
91
  stored_tgt = end_boxes(boxes, :lrt, :lrb)
86
92
  umldi_bounds = connector.umldi_label_bounds || {}
87
93
  stored_labels = !(stored_src.nil? && stored_tgt.nil?) || !umldi_bounds.empty?
88
- src_tb, src_mb = stored_src || unstored_label_boxes(points, :source, stored_labels)
89
- 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)
90
96
 
91
97
  texts = []
92
98
  end_renderer.texts(text_box: src_tb, mult_box: src_mb,
@@ -120,8 +126,15 @@ module Ea
120
126
 
121
127
  # A connector that stores label boxes for only one end seats the
122
128
  # other end's labels at its anchor, like a stored zero offset.
123
- 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)
124
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
125
138
 
126
139
  [{ "ox" => 0, "oy" => 0, "alignment" => 0 }, { "ox" => 0, "oy" => 0, "alignment" => 0 }]
127
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,
@@ -49,12 +46,34 @@ module Ea
49
46
  @elements&.drawn_bounds
50
47
  end
51
48
 
49
+ # Row-slot seating stays off. Elements records each divider in canvas
50
+ # space, so it only matched logical connector ends when the canvas
51
+ # origin was 0 (the content-anchored default) and then moved one end
52
+ # 13 px in all four corpus cases it matched (5D6DB5A0 Aggregation and
53
+ # Generalization, ADC8773C, F46E8F19), none of which EA seats on a
54
+ # row; it never matched an XMI route. Under the framed canvas it was
55
+ # inert, which is the behaviour native parity was measured against.
52
56
  def divider_y_by_ref
53
- @elements&.divider_y
57
+ nil
58
+ end
59
+
60
+ # Right edge of the connector end-label boxes, model coordinates.
61
+ def label_reach
62
+ @labels&.reach
54
63
  end
55
64
 
56
65
  private
57
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
+
58
77
  def frame_layers
59
78
  DiagramFrame.new(canvas: canvas, theme: theme).layers(diagram).map(&:to_svg)
60
79
  end
@@ -66,46 +85,48 @@ module Ea
66
85
  @elements.groups
67
86
  end
68
87
 
88
+ # EA paints connector by connector: each line is followed by
89
+ # its own heads, and only adjacent same-style groups merge.
69
90
  def connector_layers
70
- connector_layers_raw = Connectors.new(diagram, canvas: canvas,
71
- grouped: true,
72
- stroke_width: DEFAULT_STROKE_WIDTH,
73
- drawn_bounds: drawn_bounds,
74
- divider_y_by_ref: divider_y_by_ref,
75
- model_index: model_index).layers
76
- marker_layers_raw = Markers.new(diagram, model_index: model_index,
77
- canvas: canvas, grouped: true,
78
- stroke_width: DEFAULT_STROKE_WIDTH,
79
- drawn_bounds: drawn_bounds,
80
- divider_y_by_ref: divider_y_by_ref).layers
81
- merge_layers_by_style(connector_layers_raw + marker_layers_raw).map(&:to_svg)
91
+ lines = Connectors.new(diagram, canvas: canvas,
92
+ grouped: true,
93
+ stroke_width: DEFAULT_STROKE_WIDTH,
94
+ drawn_bounds: drawn_bounds,
95
+ divider_y_by_ref: divider_y_by_ref,
96
+ model_index: model_index).connector_layers
97
+ markers = Markers.new(diagram, model_index: model_index,
98
+ canvas: canvas, grouped: true,
99
+ stroke_width: DEFAULT_STROKE_WIDTH,
100
+ drawn_bounds: drawn_bounds,
101
+ divider_y_by_ref: divider_y_by_ref).connector_layers
102
+ by_owner = {}.compare_by_identity
103
+ (lines + markers).each { |owner, layer| (by_owner[owner] ||= []) << layer }
104
+ ordered = PaintOrder.connectors(diagram).flat_map { |connector| by_owner.fetch(connector, []) }
105
+ merge_adjacent_by_style(ordered).map(&:to_svg)
82
106
  end
83
107
 
84
108
  def labels_layer
85
- Labels.new(diagram, canvas: canvas, model_index: model_index,
109
+ @labels = Labels.new(diagram, canvas: canvas, model_index: model_index,
86
110
  theme: theme, document: document,
87
111
  drawn_bounds: drawn_bounds,
88
- divider_y_by_ref: divider_y_by_ref).render
112
+ divider_y_by_ref: divider_y_by_ref)
113
+ @labels.render
89
114
  end
90
115
 
91
116
  def ghost_labels_layer
92
117
  GhostLabels.new(diagram, canvas: canvas).render
93
118
  end
94
119
 
95
- def merge_layers_by_style(layers)
96
- merged = {}
97
- layers.each do |layer|
98
- if merged[layer.style_key]
99
- merged[layer.style_key] = Layer.new(
100
- style_key: layer.style_key,
101
- style: layer.style,
102
- body: "#{merged[layer.style_key].body}\n #{layer.body}"
103
- )
120
+ def merge_adjacent_by_style(layers)
121
+ layers.each_with_object([]) do |layer, merged|
122
+ last = merged.last
123
+ if last && last.style_key == layer.style_key
124
+ merged[-1] = Layer.new(style_key: last.style_key, style: last.style,
125
+ body: "#{last.body}\n #{layer.body}")
104
126
  else
105
- merged[layer.style_key] = layer
127
+ merged << layer
106
128
  end
107
129
  end
108
- merged.values
109
130
  end
110
131
  end
111
132
  end