ea 0.6.130 → 0.6.132

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.
@@ -0,0 +1,146 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ea
4
+ module Sources
5
+ module Xmi
6
+ # Routes an XMI connector the way the QEA source does: the same
7
+ # center-ray ports and the same QEA route helpers, over the same
8
+ # inputs (link style, bends in `x:y;` form, connector type, endpoint
9
+ # boxes). Returns nil where QEA would fall back to bare SX/SY deltas,
10
+ # which the generic router keeps handling.
11
+ class StoredRoute < Qea::DiagramBuilder
12
+ Link = Struct.new(:style, :path, :geometry)
13
+ Connector = Struct.new(:connector_type)
14
+
15
+ def initialize
16
+ super(nil)
17
+ end
18
+
19
+ def waypoints(link:, connector_type:, source:, target:, bends:, offsets:, edge: nil)
20
+ connector = Connector.new(connector_type)
21
+ ports = { source: port_for(source, offsets[:source]), target: port_for(target, offsets[:target]) }
22
+ edge ||= 0
23
+ points = initial_points(link, connector, source, target, bends, ports, edge)
24
+ return unless points
25
+
26
+ finish(points, link, connector, source, target, bends, ports, offsets, edge)
27
+ end
28
+
29
+ private
30
+
31
+ # Placements here are already Ea::Model::Bounds.
32
+ def bounds_from_rect(bounds)
33
+ bounds
34
+ end
35
+
36
+ def ported?(offset)
37
+ offset.none?(&:nil?) && offset.any?(&:nonzero?)
38
+ end
39
+
40
+ def port_for(box, offset)
41
+ ray_port(box, offset) if ported?(offset)
42
+ end
43
+
44
+ def initial_points(link, connector, source, target, bends, ports, edge)
45
+ src = ports[:source] || element_edge_point(source, :source, edge)
46
+ tgt = ports[:target] || element_edge_point(target, :target, edge)
47
+ if Qea::DiagramStyleParser.parse(link.style)[:mode] == "1"
48
+ [src, tgt]
49
+ elsif edge == 2 && ports.values.all? && !valid_tree_path?(bends, source)
50
+ [src, [tgt[0], src[1]], tgt]
51
+ elsif edge == 2 && bends.size == 1
52
+ tree_points(source, target, bends.first)
53
+ elsif bends.any?
54
+ bent_points(src, bends, tgt, link, connector, source, target)
55
+ elsif ports.values.any?
56
+ [src, tgt]
57
+ end
58
+ end
59
+
60
+ def tree_points(src, tgt, bend)
61
+ target_y = tgt.y + tgt.height <= src.y ? tgt.y + tgt.height : tgt.y
62
+ [[src.x + src.width, bend[1]], [bend[0], bend[1]], [bend[0], target_y]]
63
+ end
64
+
65
+ def bent_points(src, bends, tgt, link, connector, source, target)
66
+ points = [src, *bends, tgt]
67
+ redock_parent_above(points, link, source, target) if connector.connector_type == "Generalization"
68
+ points
69
+ end
70
+
71
+ def finish(points, link, connector, source, target, bends, ports, offsets, edge)
72
+ tree_route = stored_tree_route(points, link, connector, source, target, ports)
73
+ if tree_route
74
+ points = tree_route
75
+ elsif %w[Association Aggregation].include?(connector.connector_type) && edge != 2
76
+ points = facing(points, link, connector, source, target, bends)
77
+ end
78
+ points = level_entry_route(points, link, connector, source, target) if bends.any? && !tree_route
79
+ points = drop_zero_length_tree_segments(points, link)
80
+ empty_path_ray(points, link, connector, source, target, bends, ports, offsets, edge)
81
+ end
82
+
83
+ # An empty Path has no authored corner to keep: a computed EDGE2 jog
84
+ # between ports that face across a gap becomes the straight port line.
85
+ def empty_path_ray(points, link, connector, source, target, bends, ports, offsets, edge)
86
+ style = Qea::DiagramStyleParser.parse(link.style)
87
+ return points unless %w[Generalization Aggregation].include?(connector.connector_type) &&
88
+ points.size == 3 && bends.empty? && edge == 2 && ports.values.all? &&
89
+ style[:mode] == "3" && style[:lwidth] == "2" && !style[:tree]
90
+ return points unless ray_gap?(source, target, offsets)
91
+
92
+ ports.values_at(:source, :target)
93
+ end
94
+
95
+ def ray_gap?(source, target, offsets)
96
+ return false unless source.x + source.width < target.x
97
+
98
+ [[source, offsets[:source]], [target, offsets[:target]]].all? do |box, (x, y)|
99
+ within_height?(stored_ray_origin(box, x, y).y, source) &&
100
+ within_height?(stored_ray_origin(box, x, y).y, target)
101
+ end
102
+ end
103
+
104
+ def stored_tree_route(points, link, connector, source, target, ports)
105
+ stored_square_route(link, connector, source, target) ||
106
+ stored_stair_route(link, connector, source, target) ||
107
+ stored_lateral_route(link, connector, source, target, ported: ports.values.all?) ||
108
+ derived_lateral_route(link, connector, source, target) ||
109
+ horizontal_tree_route(points, link, connector, source, target)
110
+ end
111
+
112
+ def facing(points, link, connector, source, target, bends)
113
+ stored_tree = preserve_stored_tree?(points, link, connector, source, target)
114
+ unless stored_tree || level_corner_route?(points, link, connector, source, target)
115
+ points = facing_edge_route(points, source, target)
116
+ end
117
+ if connector.connector_type == "Aggregation" && bends.any? && points.size >= 4 &&
118
+ (generalization_outline_offset(link, points.size) == 1 ||
119
+ (stored_tree && flush_width_zero_tree?(link)))
120
+ redock_parent_above(points, link, source, target, strict: true)
121
+ end
122
+ redock_vertical_tree_trunk(points, link, connector, source, target) if bends.any?
123
+ points
124
+ end
125
+
126
+ # EA's SX/SY (EX/EY) point from the box center toward the port,
127
+ # y up; the port is where that ray leaves the box.
128
+ def ray_port(bounds, offset)
129
+ dx = offset[0].to_f
130
+ dy = -offset[1].to_f
131
+ center_x = bounds.x + bounds.width / 2.0
132
+ center_y = bounds.y + bounds.height / 2.0
133
+ t = [ray_scale(dx, bounds.x, bounds.width, center_x),
134
+ ray_scale(dy, bounds.y, bounds.height, center_y)].min
135
+ [(center_x + t * dx).round, (center_y + t * dy).round]
136
+ end
137
+
138
+ def ray_scale(delta, origin, length, center)
139
+ return Float::INFINITY if delta.zero?
140
+
141
+ ((delta.positive? ? origin + length : origin) - center) / delta
142
+ end
143
+ end
144
+ end
145
+ end
146
+ end
@@ -15,6 +15,7 @@ module Ea
15
15
  "uml:PrimitiveType" => "Ea::Model::PrimitiveType",
16
16
  "uml:Enumeration" => "Ea::Model::Enumeration",
17
17
  "uml:Interface" => "Ea::Model::Interface",
18
+ "uml:Signal" => "Ea::Model::Signal",
18
19
  }.freeze
19
20
 
20
21
  def class_for(xmi_type)
@@ -40,6 +40,14 @@ module Ea
40
40
 
41
41
  private
42
42
 
43
+ # Text of the shape's UMLKeywordLabel child; name labels and
44
+ # other labelled children never contribute a keyword.
45
+ def keyword_text(shape)
46
+ label = shape.xpath("./ownedElement[@*[local-name()=\"type\"]=\"umldi:UMLKeywordLabel\"]").first
47
+ text = label && label["text"]
48
+ text unless text.nil? || text.empty?
49
+ end
50
+
43
51
  def doc
44
52
  @doc ||= Nokogiri::XML(File.read(xmi_path))
45
53
  end
@@ -47,34 +55,14 @@ module Ea
47
55
  def keywords_by_diagram
48
56
  @keywords_by_diagram ||= begin
49
57
  result = {}
50
- # The Sparx XMI export declares the umldi: namespace
51
- # but the elements inside Diagram have lost their
52
- # prefix binding once parsed by Nokogiri — the
53
- # element name resolves to local-name "ownedElement"
54
- # and the umldi:UMLClassifierShape type information
55
- # lives in the literal `type` attribute. We therefore
56
- # match shape children by the presence of `modelElement`
57
- # and the keyword label by its `text` attribute.
58
58
  doc.xpath("//*[local-name()=\"Diagram\"]").each do |diagram|
59
59
  diagram_id = diagram["id"] || diagram["xmi:id"]
60
60
  next unless diagram_id
61
61
 
62
62
  keywords = {}
63
63
  diagram.xpath("./ownedElement[@modelElement]").each do |shape|
64
- model_id = shape["modelElement"]
65
- next unless model_id
66
-
67
- # Keyword label is the first ownedElement child
68
- # with a non-empty `text` attribute. The name
69
- # label follows it.
70
- shape.xpath("./ownedElement[@text]").each do |child|
71
- text = child["text"]
72
- next if text.nil? || text.empty?
73
- next if text.start_with?("«") # safety
74
-
75
- keywords[model_id] = text
76
- break
77
- end
64
+ text = keyword_text(shape)
65
+ keywords[shape["modelElement"]] = text if text
78
66
  end
79
67
  result[diagram_id] = keywords unless keywords.empty?
80
68
  end
@@ -18,14 +18,18 @@ module Ea
18
18
  autoload :MetadataBuilder, "ea/sources/xmi/metadata_builder"
19
19
  autoload :PackageBuilder, "ea/sources/xmi/package_builder"
20
20
  autoload :ClassifierBuilder, "ea/sources/xmi/classifier_builder"
21
+ autoload :InstanceBuilder, "ea/sources/xmi/instance_builder"
21
22
  autoload :PropertyBuilder, "ea/sources/xmi/property_builder"
22
23
  autoload :OperationBuilder, "ea/sources/xmi/operation_builder"
24
+ autoload :ReceptionBuilder, "ea/sources/xmi/reception_builder"
23
25
  autoload :RelationshipBuilder, "ea/sources/xmi/relationship_builder"
24
26
  autoload :AnnotationBuilder, "ea/sources/xmi/annotation_builder"
25
27
  autoload :TagBuilder, "ea/sources/xmi/tag_builder"
26
28
  autoload :NoteBuilder, "ea/sources/xmi/note_builder"
27
29
  autoload :StereotypeBuilder, "ea/sources/xmi/stereotype_builder"
28
30
  autoload :DiagramBuilder, "ea/sources/xmi/diagram_builder"
31
+ autoload :StoredRoute, "ea/sources/xmi/stored_route"
32
+ autoload :ExtensionTypes, "ea/sources/xmi/extension_types"
29
33
  autoload :ExtensionElements, "ea/sources/xmi/extension_elements"
30
34
  autoload :ExtensionGeometryParser, "ea/sources/xmi/extension_geometry_parser"
31
35
  autoload :ExtensionStyleParser, "ea/sources/xmi/extension_style_parser"
@@ -0,0 +1,140 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ea
4
+ module Svg
5
+ module EaEmitter
6
+ # Bridges decorate resolved render paths; stored routes and marker seats
7
+ # remain centerlines. The connector's stored order key (reproducing EA's
8
+ # exported link order), rather than style group order, determines which
9
+ # connector passes over a crossing.
10
+ class ConnectorBridges
11
+ RADIUS = 6
12
+
13
+ def initialize(_diagram, routes)
14
+ @routes = routes.map { |connector, points| [connector, points, priority_for(connector)] }
15
+ end
16
+
17
+ def path_for(connector, points)
18
+ priority = priority_for(connector)
19
+ return centerline(connector, points) unless priority
20
+
21
+ segments = points.each_cons(2).map do |first, last|
22
+ crossings_for(connector, priority, first, last)
23
+ end
24
+ return centerline(connector, points) if segments.all?(&:empty?)
25
+
26
+ serialize(points, segments)
27
+ end
28
+
29
+ private
30
+
31
+ def centerline(connector, points)
32
+ seats = points.map { |point| coordinates(point) }
33
+ # Direct lines retain EA's target-first repeat and source-first model.
34
+ if points.size == 2 && direct?(connector)
35
+ "M #{seats.last} L #{seats.first} M #{seats.last}"
36
+ else
37
+ "M #{seats.join(" L ")}"
38
+ end
39
+ end
40
+
41
+ def direct?(connector)
42
+ style = connector.style || {}
43
+ style.key?(:direct) || style.key?("direct")
44
+ end
45
+
46
+ def serialize(points, segments)
47
+ commands = ["M #{coordinates(points.first)}"]
48
+ points.each_cons(2).with_index do |(first, last), index|
49
+ segments[index].each do |crossing|
50
+ commands.concat(arc_commands(first, last, crossing))
51
+ end
52
+ commands << "L #{coordinates(last)}"
53
+ end
54
+ commands.join(" ")
55
+ end
56
+
57
+ # nil or empty: no bridge decision for this connector.
58
+ def priority_for(connector)
59
+ connector.paint_order if connector.paint_order&.any?
60
+ end
61
+
62
+ def crossings_for(connector, priority, first, last)
63
+ axis = axis_for(first, last)
64
+ return [] unless axis
65
+
66
+ crossings = lower_routes(connector, priority).flat_map do |_partner, points, _order|
67
+ points.each_cons(2).filter_map do |start, finish|
68
+ crossing(first, last, start, finish, axis)
69
+ end
70
+ end
71
+ separate_crossings(crossings, axis).sort_by { |point| (point[axis] - first[axis]).abs }
72
+ end
73
+
74
+ # The order key is only proven across different start placements, so
75
+ # connectors leaving the same placement make no carrier decision.
76
+ def lower_routes(connector, priority)
77
+ @routes.select do |partner, _points, order|
78
+ !partner.equal?(connector) && order && order.size == priority.size &&
79
+ !same_source?(connector, partner) && (order <=> priority).negative?
80
+ end
81
+ end
82
+
83
+ def same_source?(connector, partner)
84
+ connector.source_element_ref && connector.source_element_ref == partner.source_element_ref
85
+ end
86
+
87
+ # Suppress both overlapping spans, including coincident crossings,
88
+ # so route order cannot select one of several ambiguous arcs.
89
+ def separate_crossings(crossings, axis)
90
+ crossings.reject.with_index do |point, index|
91
+ crossings.each_with_index.any? do |other, other_index|
92
+ index != other_index && (point[axis] - other[axis]).abs < 2 * RADIUS
93
+ end
94
+ end
95
+ end
96
+
97
+ def crossing(first, last, start, finish, axis)
98
+ return unless axis_for(start, finish) == 1 - axis
99
+
100
+ point = axis.zero? ? [start[0], first[1]] : [first[0], start[1]]
101
+ return unless segment_interior?(point, first, last, axis) &&
102
+ segment_interior?(point, start, finish, 1 - axis)
103
+
104
+ point
105
+ end
106
+
107
+ def segment_interior?(point, first, last, axis)
108
+ interior?(point[axis], first[axis], last[axis])
109
+ end
110
+
111
+ def interior?(value, first, last)
112
+ minimum, maximum = [first, last].minmax
113
+ value > minimum && value < maximum &&
114
+ value - minimum >= RADIUS && maximum - value >= RADIUS
115
+ end
116
+
117
+ def axis_for(first, last)
118
+ return 0 if first[1] == last[1] && first[0] != last[0]
119
+ return 1 if first[0] == last[0] && first[1] != last[1]
120
+
121
+ nil
122
+ end
123
+
124
+ def arc_commands(first, last, crossing)
125
+ axis = axis_for(first, last)
126
+ direction = last[axis] > first[axis] ? 1 : -1
127
+ start = crossing.dup
128
+ finish = crossing.dup
129
+ start[axis] -= direction * RADIUS
130
+ finish[axis] += direction * RADIUS
131
+ ["L #{coordinates(start)}", "A #{RADIUS} #{RADIUS} 180 0 0 #{coordinates(finish)}"]
132
+ end
133
+
134
+ def coordinates(point)
135
+ point.map { |value| Canvas.coord(value) }.join(" ")
136
+ end
137
+ end
138
+ end
139
+ end
140
+ end
@@ -16,9 +16,10 @@ module Ea
16
16
  # Returns replacement waypoint pairs for direct or lateral tree routes,
17
17
  # for the connector, or nil when the connector must keep its
18
18
  # parsed waypoints (not direct, not 2-point, unresolved ends,
19
- # self-loop, or no drawn bounds for either end).
19
+ # self-loop, an authoritative UMLDI route, or no drawn bounds for either end).
20
20
  def pairs_for(connector, diagram, model_index, bounds_map)
21
21
  return nil unless bounds_map
22
+ return nil if connector.style.to_h[:umldi_route] == true
22
23
 
23
24
  tether = association_class_pairs(connector, bounds_map)
24
25
  return tether if tether
@@ -53,8 +54,8 @@ module Ea
53
54
 
54
55
  return nil if flush_lateral_line?(connector, points, source_bounds, target_bounds)
55
56
 
56
- anchors = [connector.source_anchor, connector.target_anchor]
57
57
  bounds = [source_bounds, target_bounds]
58
+ anchors = drawn_center_anchors(connector, diagram, [source_ref, target_ref], bounds)
58
59
  original = clipped_pairs(bounds, anchors)
59
60
  unless stroke_outline?(connector, bounds) || overlap_ray?(connector) ||
60
61
  bordered_outline?(connector, diagram, [source_ref, target_ref], bounds)
@@ -78,6 +79,38 @@ module Ea
78
79
  auto_port_elbow_pairs(connector, source, target, pairs)
79
80
  end
80
81
 
82
+ def drawn_center_anchors(connector, diagram, refs, bounds)
83
+ anchors = [connector.source_anchor, connector.target_anchor]
84
+ return anchors unless connector.style.to_h[:centered_origin_ray] == true
85
+
86
+ duids = connector.style.to_h.values_at(:soid, :eoid)
87
+ anchors.each_with_index.map do |anchor, index|
88
+ saved = unique_logical_bounds(diagram, refs[index], duids[index])
89
+ centered_anchor(anchor, saved, bounds[index])
90
+ end
91
+ end
92
+ private_class_method :drawn_center_anchors
93
+
94
+ def unique_logical_bounds(diagram, ref, duid)
95
+ placements = end_placements(diagram, ref, duid)
96
+ placements.first.bounds if placements.size == 1
97
+ end
98
+ private_class_method :unique_logical_bounds
99
+
100
+ def centered_anchor(anchor, saved, drawn)
101
+ return anchor unless anchor && saved
102
+
103
+ Ea::Model::Point.new(x: anchor.x + center_shift(saved, drawn, :x, :width),
104
+ y: anchor.y + center_shift(saved, drawn, :y, :height))
105
+ end
106
+ private_class_method :centered_anchor
107
+
108
+ def center_shift(saved, drawn, coordinate, size)
109
+ drawn.public_send(coordinate) + drawn.public_send(size) / 2 -
110
+ saved.public_send(coordinate) - saved.public_send(size) / 2
111
+ end
112
+ private_class_method :center_shift
113
+
81
114
  # A tether joins its saved association anchor to the facing
82
115
  # side-center of the attached class, rather than to the other proxy.
83
116
  def association_class_pairs(connector, bounds_map)
@@ -475,6 +508,7 @@ module Ea
475
508
  def row_slot_adjust(pairs, connector, diagram, model_index,
476
509
  bounds_map, divider_y_by_ref)
477
510
  return nil unless bounds_map && divider_y_by_ref
511
+ return nil if connector.style.to_h[:umldi_route] == true
478
512
  return nil unless pairs.size == 2
479
513
  return nil if flush_overlap_ray?(connector)
480
514
  return nil if explicit_association_ray?(pairs, connector)
@@ -74,11 +74,14 @@ module Ea
74
74
  private
75
75
 
76
76
  def paths_with_styles
77
- visible_connectors.filter_map do |connector|
78
- path = path_for(connector)
79
- next unless path
80
-
81
- [[self.class.stroke_width_for(connector, stroke_width), dashed?(connector)], path]
77
+ routes = visible_connectors.filter_map do |connector|
78
+ points = resolved_points(connector)
79
+ [connector, points.map { |point| translate_point(point) }] if points.size >= 2
80
+ end
81
+ bridges = ConnectorBridges.new(diagram, routes)
82
+ routes.map do |connector, points|
83
+ [[self.class.stroke_width_for(connector, stroke_width), dashed?(connector)],
84
+ path_for(connector, points, bridges)]
82
85
  end
83
86
  end
84
87
 
@@ -103,39 +106,17 @@ module Ea
103
106
  (diagram.connectors || []).select(&:renderable?)
104
107
  end
105
108
 
106
- def path_for(connector)
109
+ def resolved_points(connector)
107
110
  pts = ConnectorRedock.pairs_for(connector, diagram, model_index,
108
111
  drawn_bounds) ||
109
112
  waypoints_for(connector)
110
- pts = ConnectorRedock.row_slot_adjust(pts, connector, diagram,
111
- model_index, drawn_bounds,
112
- divider_y_by_ref) || pts
113
- return nil if pts.size < 2
114
-
115
- # DIRECT (auto-routed straight) connectors: EA writes the
116
- # segment TARGET-FIRST with a trailing zero-length repeat —
117
- # "M target L source M target" (B6847577, E58034A3,
118
- # 8CF27BE8). Same pixels as source-first; only the byte
119
- # order differs, so reversal lives here at the emitter and
120
- # the marker anchoring (source = first waypoint) is
121
- # untouched.
122
- if pts.size == 2 && direct?(connector)
123
- x1, y1 = translate_point(pts.first)
124
- x2, y2 = translate_point(pts.last)
125
- d = "M #{Canvas.coord(x2)} #{Canvas.coord(y2)} L #{Canvas.coord(x1)} #{Canvas.coord(y1)} " \
126
- "M #{Canvas.coord(x2)} #{Canvas.coord(y2)}"
127
- else
128
- d = pts.each_with_index.map do |p, idx|
129
- x, y = translate_point(p)
130
- "#{idx.zero? ? 'M' : 'L'} #{Canvas.coord(x)} #{Canvas.coord(y)}"
131
- end.join(" ")
132
- end
133
- %(<path d="#{d}" shape-rendering="auto"/>)
113
+ ConnectorRedock.row_slot_adjust(pts, connector, diagram,
114
+ model_index, drawn_bounds,
115
+ divider_y_by_ref) || pts
134
116
  end
135
117
 
136
- def direct?(connector)
137
- style = connector.style || {}
138
- style.key?(:direct) || style.key?("direct")
118
+ def path_for(connector, pts, bridges)
119
+ %(<path d="#{bridges.path_for(connector, pts)}" shape-rendering="auto"/>)
139
120
  end
140
121
 
141
122
  def waypoints_for(connector)
@@ -146,10 +127,10 @@ module Ea
146
127
  end
147
128
  end
148
129
 
149
- def translate_point(p)
150
- return p unless @canvas
130
+ def translate_point(point)
131
+ return point unless @canvas
151
132
 
152
- [@canvas.translate_x(p[0]), @canvas.translate_y(p[1])]
133
+ [@canvas.translate_x(point[0]), @canvas.translate_y(point[1])]
153
134
  end
154
135
  end
155
136
  end
@@ -54,12 +54,14 @@ module Ea
54
54
  # end_kind - :source or :target
55
55
  # heading - [dx, dy] of the first leg leaving the anchor
56
56
  # source_bounds - effective drawn source outline, in anchor coordinates
57
+ # target_face - exact drawn target dock face when it leads the emitted leg
57
58
  # bend - first bend of a saved descending vertical stair
58
59
  # stored_labels - the connector has stored label boxes at
59
60
  # either end; false keeps the opposite-end
60
61
  # fallback
61
62
  def texts(text_box:, mult_box:, anchor:, connector:, end_kind:,
62
- tree: false, heading: nil, stored_labels: false, bend: nil, source_bounds: nil)
63
+ tree: false, heading: nil, stored_labels: false, bend: nil, source_bounds: nil,
64
+ target_face: nil)
63
65
  from_property, role, mult = role_and_multiplicity(connector, end_kind)
64
66
  if role_empty?(role) && mult_empty?(mult)
65
67
  other_from_prop, other_role, other_mult = role_and_multiplicity(connector,
@@ -114,13 +116,23 @@ module Ea
114
116
  target_diagonal_property_role(connector, heading, anchor, text_box, role) ||
115
117
  target_vertical_role(connector, heading, anchor, text_box, role) ||
116
118
  target_rightward_role(heading, anchor, text_box, role) || text_pos
119
+ if horizontal_aligned_target_baseline?(connector, heading, text_box, role)
120
+ text_pos = [text_pos[0], seat_corner(anchor, text_box)[1] - 7]
121
+ end
122
+ face_multiplicity = if target_left_face_multiplicity?(connector, heading, mult_box, mult,
123
+ target_face)
124
+ directional_multiplicity(:left, anchor, mult_box, mult)
125
+ end
117
126
  mult_pos = target_multiplicity_seat(connector, heading, anchor, mult_box) ||
118
- target_horizontal_multiplicity(heading, anchor, mult_box, mult) || mult_pos
127
+ face_multiplicity || target_horizontal_multiplicity(heading, anchor, mult_box, mult) ||
128
+ mult_pos
119
129
  end
120
130
 
121
131
  if end_kind == :source
122
132
  text_pos = source_top_property_role(connector, heading, anchor, text_box, role) ||
123
133
  source_bottom_property_role(connector, heading, anchor, text_box, role) || text_pos
134
+ mult_pos = source_top_property_multiplicity(connector, heading, anchor,
135
+ [text_box, mult_box], mult) || mult_pos
124
136
  if external_upward_property_source?(connector, heading, anchor, text_box, source_bounds)
125
137
  text_pos = external_upward_property_role(anchor, role) || text_pos
126
138
  end
@@ -143,6 +155,23 @@ module Ea
143
155
 
144
156
  private
145
157
 
158
+ # ALN2 single-line target roles retain their horizontal placement
159
+ # and use the saved corner's baseline on a straight stored ray.
160
+ def horizontal_aligned_target_baseline?(connector, heading, box, role)
161
+ return false unless heading && heading[1].zero? && !heading[0].zero?
162
+ return false unless saved_target_ray?(connector) && !role_empty?(role)
163
+ return false unless connector_source_dest_stereotype(connector, :target).to_s.empty?
164
+
165
+ aligned_target_baseline_box?(box)
166
+ end
167
+
168
+ def aligned_target_baseline_box?(box)
169
+ return false unless seat_font? && font_weight == 400 && font_style == "normal"
170
+ return false unless box && box["alignment"] == 2 && [13, 14].include?(box["cy"])
171
+
172
+ cambria_box?(box.merge("alignment" => 0))
173
+ end
174
+
146
175
  def plain_leftward_target_role(connector, heading, anchor, box, role)
147
176
  return nil unless heading && heading[0].negative? && heading[1].zero?
148
177
  return nil unless plain_target_role?(connector, box, role)
@@ -339,14 +368,27 @@ module Ea
339
368
  # EDGE=1 certifies the top dock independently of the clipped ray's
340
369
  # heading. An absent source box uses the upward Property seat.
341
370
  def source_top_property_role(connector, heading, anchor, box, role)
342
- return nil unless source_seat?(:source, heading) && heading[0].negative? && heading[1].positive?
343
- return nil unless zero_source_property_box?(box)
344
-
345
- return nil unless source_top_property_route?(connector.style.to_h)
371
+ return nil unless source_top_property_box?(connector, heading, box)
346
372
 
347
373
  source_property_seat(connector, :up, anchor, box, role)
348
374
  end
349
375
 
376
+ def source_top_property_multiplicity(connector, heading, anchor, boxes, mult)
377
+ return nil if mult_empty?(mult)
378
+
379
+ role_box, mult_box = boxes
380
+ return nil unless source_top_property_box?(connector, heading, role_box)
381
+ return nil unless source_top_property_box?(connector, heading, mult_box)
382
+
383
+ mult_seat(:up, anchor, mult_box)
384
+ end
385
+
386
+ def source_top_property_box?(connector, heading, box)
387
+ source_seat?(:source, heading) && heading[0].negative? && heading[1].positive? &&
388
+ zero_source_property_box?(box) && source_top_property_route?(connector.style.to_h) &&
389
+ source_property_seat?(connector, :up, box)
390
+ end
391
+
350
392
  # A persisted origin above the drawn source outline keeps the
351
393
  # upward seat's inset and the shared upper baseline, including
352
394
  # steep diagonals. Origins clipped onto the face retain -6.
@@ -697,11 +739,21 @@ module Ea
697
739
  def target_horizontal_multiplicity(heading, anchor, box, mult)
698
740
  return nil unless seat_font? && heading && heading[1].zero? && !heading[0].zero?
699
741
  return nil if mult_empty?(mult)
742
+
700
743
  return directional_multiplicity(:left, anchor, box, mult) if heading[0].negative?
701
744
 
702
745
  mult_seat(:right, anchor, box)
703
746
  end
704
747
 
748
+ def target_left_face_multiplicity?(connector, heading, box, mult, target_face)
749
+ seat_font? && !mult_empty?(mult) && property_label?(connector, :target) && cambria_box?(box) &&
750
+ stored_offsets_zero?(box) && target_left_face_heading?(heading, target_face)
751
+ end
752
+
753
+ def target_left_face_heading?(heading, target_face)
754
+ heading && !heading[1].zero? && heading[0].negative? && target_face == [-1, 0]
755
+ end
756
+
705
757
  def target_multiplicity_seat(connector, heading, anchor, box)
706
758
  return nil unless seat_font?
707
759
  return nil unless heading && heading[0].zero? && heading[1].negative?
@@ -100,6 +100,7 @@ module Ea
100
100
  end_kind: :target, tree: tree && tree[:target],
101
101
  bend: Label::TargetBendSeat.reference(points),
102
102
  heading: (stored_tgt || stored_labels) && target_heading(connector, points),
103
+ target_face: target_dock_heading(connector, target_pt, target_heading(connector, points)),
103
104
  stored_labels: stored_labels).each { |t| texts << t }
104
105
  texts
105
106
  end
@@ -202,6 +203,29 @@ module Ea
202
203
  drawn_bounds[source]
203
204
  end
204
205
 
206
+ def target_dock_bounds(connector)
207
+ return nil unless model_index && drawn_bounds
208
+
209
+ relationship = model_index[connector.relationship_ref]
210
+ return nil unless relationship
211
+
212
+ source, target = ConnectorRedock.end_refs(relationship)
213
+ return nil if source == target
214
+ return nil unless drawn_bounds[source]
215
+
216
+ drawn_bounds[target]
217
+ end
218
+
219
+ def target_dock_heading(connector, point, heading)
220
+ return nil unless heading
221
+
222
+ box = target_dock_bounds(connector)
223
+ face = box && dock_face_heading(point, box)
224
+ return nil unless face&.zip(heading)&.any? { |axis, delta| (axis * delta).positive? }
225
+
226
+ face
227
+ end
228
+
205
229
  # Saved rays can start outside the box. Project onto its boundary;
206
230
  # corners stay ambiguous and the face must still lead the emitted leg.
207
231
  def source_dock_heading(connector, point, box)