ea 0.6.95 → 0.6.97

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 87aea8f508b2ef7e8f98038519122caff0f87c967f3e59132838885124d0db84
4
- data.tar.gz: 381aa0a30e5c9c3c60f0885f4fd2ca737d909f8fc620e36725e787db909898ab
3
+ metadata.gz: c2ca3f0fd4381aec39af89849d601c85defa496b697f1fe625558c8a210aef64
4
+ data.tar.gz: adbc3773a202f06ad7327ace0fbea64daacea45a4719afb28959332673dafdcd
5
5
  SHA512:
6
- metadata.gz: adba2730ddbc449a79ce0063273cb6f75c4fc88d219899e12e13bc4226d2d88d5ef438c062384d908296e62938d1b937c56b926043e89dc55b4d43a1d53c63c8
7
- data.tar.gz: b677683c53a71f27f68c2c80f7b150517288751bdbf586d78ad0fbe986b014931b57659235587e4ba61b8753656de72f6949363291fd6f1ab2d6e1a0a70aa5d1
6
+ metadata.gz: 99b878a17d58444e251964011cf2af59d46459223e657b8ffbf1a4602dc572af56e594bedd05c321cec0502d91e654cdd013d5b408277027d1c4f41c65885d3f
7
+ data.tar.gz: 73ef9107ec26ea7b60f7c9e5d00b7fa09b4d08b10842345c589a75dfd13a62b553287b95b0ad1b71e0c45fc032ab30ff1299fecb03e2a3abac69027a69180e97
@@ -10,6 +10,9 @@ font:
10
10
  weight_normal: 400
11
11
  weight_bold: 700
12
12
 
13
+ label_font:
14
+ family: Cambria
15
+
13
16
  # Element-level font (distinct from diagram-level frame font).
14
17
  # EA renders classifier compartments at this size regardless of
15
18
  # the diagram-level font.size.
@@ -0,0 +1,8 @@
1
+ id: "39"
2
+ name: EA theme 39 connector labels
3
+ font:
4
+ unit: px
5
+ label_font:
6
+ family: Cambria
7
+ size: 7
8
+ unit: pt
@@ -0,0 +1,77 @@
1
+ {
2
+ " ": 2,
3
+ "(": 4,
4
+ ")": 4,
5
+ "*": 4,
6
+ "+": 6,
7
+ "-": 3,
8
+ ".": 2,
9
+ "0": 6,
10
+ "1": 6,
11
+ "2": 6,
12
+ "3": 6,
13
+ "4": 6,
14
+ "5": 6,
15
+ "6": 6,
16
+ "8": 6,
17
+ "9": 6,
18
+ ":": 3,
19
+ "<": 6,
20
+ "=": 6,
21
+ ">": 6,
22
+ "A": 6,
23
+ "B": 6,
24
+ "C": 6,
25
+ "D": 7,
26
+ "E": 6,
27
+ "F": 5,
28
+ "G": 6,
29
+ "H": 7,
30
+ "I": 3,
31
+ "K": 6,
32
+ "L": 5,
33
+ "M": 8,
34
+ "N": 7,
35
+ "O": 7,
36
+ "P": 6,
37
+ "Q": 7,
38
+ "R": 6,
39
+ "S": 5,
40
+ "T": 6,
41
+ "U": 6,
42
+ "V": 6,
43
+ "W": 9,
44
+ "X": 6,
45
+ "Y": 6,
46
+ "Z": 5,
47
+ "[": 4,
48
+ "]": 4,
49
+ "a": 5,
50
+ "b": 5,
51
+ "c": 4,
52
+ "d": 6,
53
+ "e": 5,
54
+ "f": 3,
55
+ "g": 5,
56
+ "h": 6,
57
+ "i": 3,
58
+ "j": 3,
59
+ "k": 5,
60
+ "l": 3,
61
+ "m": 8,
62
+ "n": 6,
63
+ "o": 5,
64
+ "p": 6,
65
+ "q": 5,
66
+ "r": 4,
67
+ "s": 4,
68
+ "t": 3,
69
+ "u": 6,
70
+ "v": 5,
71
+ "w": 8,
72
+ "x": 5,
73
+ "y": 5,
74
+ "z": 5,
75
+ "{": 4,
76
+ "}": 4
77
+ }
@@ -29,9 +29,18 @@ module Ea
29
29
  "calibri" => "Carlito",
30
30
  "carlito" => "Carlito",
31
31
  "arial" => "Arial",
32
- "arial narrow" => "Arial Narrow"
32
+ "arial narrow" => "Arial Narrow",
33
+ "cambria" => "Cambria"
33
34
  }.freeze
34
35
 
36
+ # Cambria measurements are in SVG units at 7pt, not em advances.
37
+ # Unmeasured characters and other sizes/styles must use the fallback.
38
+ CAMBRIA_7_REGULAR_WIDTHS = JSON.parse(File.read(
39
+ File.expand_path("cambria_7_regular_widths.json", __dir__)
40
+ )).freeze
41
+ CAMBRIA_7_GUILLEMET_PAIR_WIDTH = 10
42
+ private_constant :CAMBRIA_7_REGULAR_WIDTHS, :CAMBRIA_7_GUILLEMET_PAIR_WIDTH
43
+
35
44
  # Advance used for codepoints missing from the tables.
36
45
  DEFAULT_ADVANCE = 0.25
37
46
 
@@ -64,6 +73,12 @@ module Ea
64
73
  return nil unless supported_family?(family)
65
74
  return nil if text.nil? || text.empty?
66
75
 
76
+ return cambria_text_length(text, size_pt, weight, style) if resolve_family(family) == "Cambria"
77
+
78
+ advance_text_length(text, size_pt, family, weight, style)
79
+ end
80
+
81
+ def advance_text_length(text, size_pt, family, weight, style)
67
82
  resolved = resolve_family(family)
68
83
  if GDI_HEIGHT_FAMILIES.include?(resolved)
69
84
  height = (size_pt.to_f * 10 / 7).round
@@ -86,6 +101,26 @@ module Ea
86
101
  cjk_count * CJK_ADVANCE * size_pt.to_f).round(3)
87
102
  end
88
103
 
104
+ private_class_method :advance_text_length
105
+
106
+ def cambria_text_length(text, size_pt, weight, style)
107
+ return nil unless size_pt.to_f.to_r == 7
108
+ return nil unless ["", "0", "400", "normal"].include?(weight.to_s)
109
+ return nil unless ["", "normal"].include?(style.to_s)
110
+
111
+ return cambria_ascii_length(text) unless text.start_with?("«") && text.end_with?("»")
112
+
113
+ inner_width = cambria_ascii_length(text[1...-1])
114
+ inner_width + CAMBRIA_7_GUILLEMET_PAIR_WIDTH if inner_width
115
+ end
116
+ private_class_method :cambria_text_length
117
+
118
+ def cambria_ascii_length(text)
119
+ widths = text.each_char.map { |char| CAMBRIA_7_REGULAR_WIDTHS[char] }
120
+ widths.sum unless widths.empty? || widths.include?(nil)
121
+ end
122
+ private_class_method :cambria_ascii_length
123
+
89
124
  # Families whose EA driver quantizes per-glyph advances
90
125
  # (fitted from published textLengths).
91
126
  GDI_HEIGHT_FAMILIES = %w[Carlito].freeze
@@ -8,6 +8,187 @@ module Ea
8
8
  module Routing
9
9
  private
10
10
 
11
+ # Stored SY/EY use mathematical Y; ray origins use screen Y.
12
+ # Computed-jog origins are already normalized to their derived ports.
13
+ def correct_stored_ray_origins(model, link_row, diagram_row, geom, context)
14
+ return model if context
15
+ return model unless stored_ray_candidate?(model, link_row, geom)
16
+
17
+ source, target = stored_ray_boxes(link_row, diagram_row)
18
+ return model unless stored_ray_gap?(source, target, model.style[:mode], geom[:edge])
19
+
20
+ set_stored_ray_origins(model, source, target, geom)
21
+ end
22
+
23
+ def stored_ray_boxes(link_row, diagram_row)
24
+ connector = database.find_connector(link_row.connectorid)
25
+ [source_placement_for(connector, diagram_row),
26
+ target_placement_for(connector, diagram_row)].map { |placement| bounds_from_rect(placement) }
27
+ end
28
+
29
+ def set_stored_ray_origins(model, source, target, geom)
30
+ model.source_anchor = stored_ray_origin(source, geom[:sx], geom[:sy])
31
+ model.target_anchor = stored_ray_origin(target, geom[:ex], geom[:ey])
32
+ model
33
+ end
34
+
35
+ def stored_ray_candidate?(model, link_row, geom)
36
+ %w[Generalization Aggregation].include?(model.connector_type) &&
37
+ model.waypoints.size == 2 && stored_ray_style?(model.style, link_row) &&
38
+ stored_ray_geometry?(model, geom) && !normalized_port_anchors?(model)
39
+ end
40
+
41
+ def stored_ray_style?(style, link_row)
42
+ %w[1 3].include?(style[:mode]) && !style[:tree] &&
43
+ (style[:mode] == "1" || link_row.path.to_s.empty?)
44
+ end
45
+
46
+ def stored_ray_geometry?(model, geom)
47
+ model.has_geometry_offsets && model.source_port && model.target_port &&
48
+ (!geom[:sy].to_i.zero? || !geom[:ey].to_i.zero?)
49
+ end
50
+
51
+ def normalized_port_anchors?(model)
52
+ same_port_anchor?(model.source_anchor, model.source_port) &&
53
+ same_port_anchor?(model.target_anchor, model.target_port)
54
+ end
55
+
56
+ def same_port_anchor?(anchor, port)
57
+ anchor.x == port.x && anchor.y == port.y
58
+ end
59
+
60
+ def stored_ray_gap?(source, target, mode, edge)
61
+ return horizontal_ray_gap?(source, target) || vertical_ray_gap?(source, target) if mode == "1"
62
+
63
+ if edge == 1
64
+ target.y + target.height < source.y
65
+ elsif edge == 4
66
+ target.x + target.width < source.x
67
+ end
68
+ end
69
+
70
+ def horizontal_ray_gap?(source, target)
71
+ source.x + source.width < target.x || target.x + target.width < source.x
72
+ end
73
+
74
+ def vertical_ray_gap?(source, target)
75
+ source.y + source.height < target.y || target.y + target.height < source.y
76
+ end
77
+
78
+ def stored_ray_origin(bounds, x_offset, y_offset)
79
+ Ea::Model::Point.new(x: bounds.x + bounds.width / 2 + x_offset.to_i,
80
+ y: bounds.y + bounds.height / 2 - y_offset.to_i)
81
+ end
82
+
83
+ # Square routing keeps the stored orthogonal trunk, even when a
84
+ # shorter facing-edge route exists between the same boxes.
85
+ def stored_square_route(link_row, connector, source, target)
86
+ return nil unless stored_square_style?(link_row, connector)
87
+
88
+ corners = stored_square_corners(link_row)
89
+ square_corner_route(corners, source, target) if corners
90
+ end
91
+
92
+ def stored_square_style?(link_row, connector)
93
+ style = DiagramStyleParser.parse(link_row.style)
94
+ %w[Association Aggregation Generalization].include?(connector.connector_type) &&
95
+ style[:mode] == "3" && style[:tree] == "OS" && style[:lwidth] == "2"
96
+ end
97
+
98
+ def stored_square_corners(link_row)
99
+ return nil unless link_row.path.to_s.match?(/\A(?:-?\d+:-?\d+;)*-?\d+:-?\d+;?\z/)
100
+
101
+ corners = intermediate_waypoints(link_row)
102
+ corners if corners.each_cons(2).all? { |a, b| a[0] == b[0] || a[1] == b[1] }
103
+ end
104
+
105
+ def square_corner_route(corners, source, target)
106
+ first = square_corner_dock(corners.first, source)
107
+ last = square_corner_dock(corners.last, target)
108
+ return nil unless first && last
109
+
110
+ points = corners.dup
111
+ points.unshift(first) unless points.first == first
112
+ points.push(last) unless points.last == last
113
+ points if points.size >= 3
114
+ end
115
+
116
+ def square_corner_dock(corner, bounds)
117
+ x, y = corner
118
+ projected = [x.clamp(bounds.x, bounds.x + bounds.width),
119
+ y.clamp(bounds.y, bounds.y + bounds.height)]
120
+ return nil if projected[0] != x && projected[1] != y
121
+
122
+ projected if rectangle_edge?(projected, bounds)
123
+ end
124
+
125
+ def rectangle_edge?(point, bounds)
126
+ [bounds.x, bounds.x + bounds.width].include?(point[0]) ||
127
+ [bounds.y, bounds.y + bounds.height].include?(point[1])
128
+ end
129
+
130
+ # A valid lateral corner joins one vertical leg and one horizontal
131
+ # leg. Keep it even when the boxes also admit a shorter direct route.
132
+ def stored_lateral_route(link_row, connector, source, target)
133
+ return nil unless %w[Aggregation Association].include?(connector.connector_type)
134
+
135
+ style = DiagramStyleParser.parse(link_row.style)
136
+ return nil unless stored_lateral_style?(style)
137
+
138
+ corner = stored_lateral_corner(link_row)
139
+ return nil unless corner
140
+
141
+ if style[:tree] == "LH"
142
+ horizontal_corner_route(corner, source, target)
143
+ else
144
+ vertical_corner_route(corner, source, target)
145
+ end
146
+ end
147
+
148
+ def stored_lateral_corner(link_row)
149
+ return nil unless link_row.path.to_s.match?(/\A-?\d+:-?\d+;?\z/)
150
+
151
+ intermediate_waypoints(link_row).first
152
+ end
153
+
154
+ def stored_lateral_style?(style)
155
+ style[:mode] == "3" && %w[0 2].include?(style[:lwidth]) && %w[LH LV].include?(style[:tree])
156
+ end
157
+
158
+ def horizontal_corner?(corner, source, target)
159
+ within_horizontal_span?(corner, source) && within_height?(corner[1], target) &&
160
+ !within_height?(corner[1], source) && !within_horizontal_span?(corner, target)
161
+ end
162
+
163
+ def horizontal_corner_route(corner, source, target)
164
+ return nil unless horizontal_corner?(corner, source, target)
165
+
166
+ x, y = corner
167
+
168
+ source_y = y < source.y ? source.y : source.y + source.height
169
+ target_x = x < target.x ? target.x : target.x + target.width
170
+ [[x, source_y], corner, [target_x, y]]
171
+ end
172
+
173
+ def vertical_corner?(corner, source, target)
174
+ within_height?(corner[1], source) && within_horizontal_span?(corner, target) &&
175
+ !within_horizontal_span?(corner, source) && !within_height?(corner[1], target)
176
+ end
177
+
178
+ def vertical_corner_route(corner, source, target)
179
+ return nil unless vertical_corner?(corner, source, target)
180
+
181
+ x, y = corner
182
+
183
+ source_x = x < source.x ? source.x : source.x + source.width
184
+ target_y = y < target.y ? target.y : target.y + target.height
185
+ [[source_x, y], corner, [x, target_y]]
186
+ end
187
+
188
+ def within_height?(height, bounds)
189
+ height.between?(bounds.y, bounds.y + bounds.height)
190
+ end
191
+
11
192
  # A lateral tree leaves the side edge before turning upward.
12
193
  # Keep that dock only when its first leg actually points outward.
13
194
  def lateral_source_exit?(points, link_row, bounds)
@@ -385,7 +385,7 @@ module Ea
385
385
  geom = parse_geometry_fields(link_row.geometry)
386
386
  context = ContextDiagramSupport.package_context(diagram_row) ||
387
387
  ContextDiagramSupport.element_regenerate?(diagram_row)
388
- collapse_computed_jog(Ea::Model::DiagramConnector.new(
388
+ model = collapse_computed_jog(Ea::Model::DiagramConnector.new(
389
389
  id: IdNormalizer.synthetic("dc", diagram_row.diagram_id,
390
390
  link_row.instance_id),
391
391
  diagram_id: IdNormalizer.from_guid(diagram_row.ea_guid),
@@ -410,6 +410,7 @@ module Ea
410
410
  has_geometry_offsets: geometry_has_offsets?(link_row.geometry),
411
411
  is_substitutable: substitutable_connector?(connector)
412
412
  ), link_row, diagram_row, geom)
413
+ correct_stored_ray_origins(model, link_row, diagram_row, geom, context)
413
414
  end
414
415
 
415
416
  # Ray origin for one connector end: the element's center
@@ -648,7 +649,9 @@ module Ea
648
649
  # EDGE=2 routes are excluded; they are already exact.
649
650
  src = bounds_from_rect(source_placement)
650
651
  tgt = bounds_from_rect(target_placement)
651
- tree_route = horizontal_tree_route(points, link_row, connector, src, tgt)
652
+ tree_route = stored_square_route(link_row, connector, src, tgt) ||
653
+ stored_lateral_route(link_row, connector, src, tgt) ||
654
+ horizontal_tree_route(points, link_row, connector, src, tgt)
652
655
  if tree_route
653
656
  points = tree_route
654
657
  elsif %w[Association Aggregation].include?(connector.connector_type) &&
@@ -106,7 +106,7 @@ module Ea
106
106
  def outline_connector?(connector, bounds)
107
107
  return true if %w[Generalization Aggregation].include?(connector.connector_type)
108
108
 
109
- return false unless stored_association?(connector)
109
+ return false unless stored_association?(connector) || offset_free_association?(connector)
110
110
 
111
111
  # Diagonal Association rounding and tree docks keep their existing rules.
112
112
  pairs = clipped_pairs(bounds, [connector.source_anchor, connector.target_anchor])
@@ -122,6 +122,20 @@ module Ea
122
122
  end
123
123
  private_class_method :stored_association?
124
124
 
125
+ def offset_free_association?(connector)
126
+ style = connector.style.to_h
127
+ connector.connector_type == "Association" && style[:mode] == "3" && !style[:tree] &&
128
+ %i[direct regenerated].none? { |key| style.key?(key) } && offset_free_anchors?(connector)
129
+ end
130
+ private_class_method :offset_free_association?
131
+
132
+ def offset_free_anchors?(connector)
133
+ !connector.has_geometry_offsets &&
134
+ [connector.source_port, connector.target_port].all?(&:nil?) &&
135
+ [connector.source_anchor, connector.target_anchor].all?
136
+ end
137
+ private_class_method :offset_free_anchors?
138
+
125
139
  def clipped_pairs(bounds, anchors)
126
140
  source, target = bounds
127
141
  source_anchor, target_anchor = anchors
@@ -75,11 +75,8 @@ module Ea
75
75
  text_pos, mult_pos = tree_positions(end_kind, anchor, role,
76
76
  text_pos, mult_pos)
77
77
  elsif mult_box && stored_offsets_zero?(mult_box)
78
- # A stored mult box with OX=0:OY=0 means "EA default":
79
- # the multiplicity sits 12px right of and 15px below the
80
- # endpoint anchor, regardless of line direction
81
- # (87-sample corpus fit: dy = +15 constant, dx = +12
82
- # dominant with a +3 minority).
78
+ # Start at the legacy default; measured directional seats
79
+ # below replace it for default Cambria labels.
83
80
  mult_pos = [anchor[0] + 12, anchor[1] + 15]
84
81
  end
85
82
  # The end's stored stereotype renders verbatim («Type» on
@@ -89,8 +86,13 @@ module Ea
89
86
  stereo = stereo.nil? || stereo.empty? ? nil : "«#{stereo}»"
90
87
  direction = leg_direction(end_kind, heading)
91
88
  if direction && !(tree && zero_offset?(text_box, mult_box))
92
- text_pos = role_seat(direction, anchor, text_box, role) || text_pos
93
- mult_pos = mult_seat(direction, anchor, mult_box) || mult_pos
89
+ text_pos = source_property_seat(connector, direction, anchor, text_box, role) ||
90
+ plain_upward_seat(connector, direction, anchor, text_box, role) ||
91
+ role_seat(direction, anchor, text_box, role) || text_pos
92
+ mult_pos = directional_multiplicity(connector, direction, anchor, mult_box, mult) ||
93
+ mult_seat(direction, anchor, mult_box) || mult_pos
94
+ elsif end_kind == :target && !(tree && zero_offset?(text_box, mult_box))
95
+ mult_pos = target_multiplicity_seat(heading, anchor, mult_box) || mult_pos
94
96
  end
95
97
 
96
98
  build_texts(role:, mult:, standalone:,
@@ -147,19 +149,22 @@ module Ea
147
149
  # right: the multiplicity 6px above it on an upward leg and
148
150
  # 15px below it on a rightward one, the role 7px above it on
149
151
  # a rightward one. Other sizes, headings, the target end,
150
- # custom presentation keep their offsets; zero multiplicity boxes keep their default seat.
152
+ # custom presentation keep their offsets. Zero multiplicity boxes
153
+ # follow the direction for Cambria and keep the default otherwise.
151
154
  def leg_direction(end_kind, heading)
152
155
  return nil unless source_seat?(end_kind, heading)
153
156
 
154
157
  dx, dy = heading
155
158
  return :up if dx.zero? && dy.negative?
156
159
  return :right if dy.zero? && dx.positive?
160
+ return :left if dy.zero? && dx.negative?
161
+ return :down if dx.zero? && dy.positive?
157
162
 
158
163
  nil
159
164
  end
160
165
 
161
166
  # Role text on a rightward leg is left-aligned at the corner,
162
- # 7px above it. Upward legs keep the plain role offset.
167
+ # 7px above it.
163
168
  def role_seat(direction, anchor, box, role)
164
169
  return nil unless direction == :right
165
170
  return nil if box.nil? || role_empty?(role)
@@ -169,8 +174,101 @@ module Ea
169
174
  [x + SEAT_X_INSET, y - 7]
170
175
  end
171
176
 
177
+ # Property-role clusters use a direction-specific right edge;
178
+ # alignment controls the stereotype line within that cluster.
179
+ def source_property_seat(connector, direction, anchor, box, role)
180
+ return nil unless %i[up left down].include?(direction) && property_role_box?(box, direction)
181
+ return nil unless source_property_font?(connector, direction)
182
+
183
+ width = Ea::Fonts::Metrics.text_length(role, font_size, family: font_family)
184
+ return nil unless width
185
+
186
+ x, y = seat_corner(anchor, box)
187
+ inset, baseline = property_role_offsets(direction)
188
+ [x - inset - width, y + baseline]
189
+ end
190
+
191
+ def plain_upward_seat(connector, direction, anchor, box, role)
192
+ return nil unless direction == :up && cambria_box?(box)
193
+ return nil unless [0, 1].include?(box["alignment"])
194
+ return nil unless connector_source_dest_stereotype(connector, :source).empty?
195
+ return nil unless plain_role_route?(connector.style.to_h)
196
+
197
+ width = Ea::Fonts::Metrics.text_length(role, font_size, family: font_family)
198
+ return nil unless width
199
+
200
+ x, y = seat_corner(anchor, box)
201
+ [x - SEAT_X_INSET - width, y - 6]
202
+ end
203
+
204
+ def plain_role_route?(style)
205
+ %w[1 3].include?(style[:mode]) && %w[0 2].include?(style[:lwidth]) &&
206
+ [nil, "V"].include?(style[:tree])
207
+ end
208
+
209
+ def property_role_box?(box, direction)
210
+ alignments = direction == :down ? [1] : [0, 1]
211
+ box && alignments.include?(box["alignment"]) && default_presentation?(box)
212
+ end
213
+
214
+ def property_role_offsets(direction)
215
+ case direction
216
+ when :up then [60, -6]
217
+ when :left then [60, -7]
218
+ else [65, 15]
219
+ end
220
+ end
221
+
222
+ def source_property_font?(connector, direction)
223
+ font_family.to_s.casecmp("Cambria").zero? &&
224
+ property_label?(connector, :source) &&
225
+ source_property_route?(connector.style.to_h, direction)
226
+ end
227
+
228
+ def source_property_route?(style, direction)
229
+ case direction
230
+ when :left then left_property_route?(style)
231
+ when :down then downward_property_route?(style)
232
+ else style[:lwidth] == "2" && style[:tree] != "OS"
233
+ end
234
+ end
235
+
236
+ def left_property_route?(style)
237
+ style[:mode] == "3" && %w[0 2].include?(style[:lwidth]) &&
238
+ [nil, "H", "LV"].include?(style[:tree])
239
+ end
240
+
241
+ def downward_property_route?(style)
242
+ %w[1 3].include?(style[:mode]) && style[:lwidth] == "2" && !style[:tree]
243
+ end
244
+
245
+ def directional_multiplicity(connector, direction, anchor, box, mult)
246
+ return nil unless cambria_box?(box)
247
+
248
+ x, y = seat_corner(anchor, box)
249
+ return [x + 8, y + 15] if direction == :down
250
+ return nil unless direction == :left && source_property_font?(connector, direction)
251
+
252
+ width = Ea::Fonts::Metrics.text_length(mult, font_size, family: font_family)
253
+ [x - width - 3, y + 15] if width
254
+ end
255
+
256
+ def target_multiplicity_seat(heading, anchor, box)
257
+ return nil unless seat_font?
258
+ return nil unless heading && heading[0].zero? && heading[1].negative?
259
+ return nil unless cambria_box?(box) && stored_offsets_zero?(box)
260
+
261
+ [anchor[0] + 8, anchor[1] - 7]
262
+ end
263
+
264
+ def cambria_box?(box)
265
+ box && default_presentation?(box) && font_family.to_s.casecmp("Cambria").zero?
266
+ end
267
+
172
268
  def mult_seat(direction, anchor, box)
173
- return nil if box.nil? || stored_offsets_zero?(box)
269
+ return nil unless %i[up right].include?(direction)
270
+ return nil if box.nil?
271
+ return nil if stored_offsets_zero?(box) && !cambria_box?(box)
174
272
  return nil unless default_presentation?(box)
175
273
 
176
274
  x, y = seat_corner(anchor, box)
@@ -91,12 +91,13 @@ module Ea
91
91
  end_renderer.texts(text_box: src_tb, mult_box: src_mb,
92
92
  anchor: source_pt, connector: connector,
93
93
  end_kind: :source, tree: tree && tree[:source],
94
- heading: stored_src && leg_heading(points),
94
+ heading: stored_src && source_heading(connector, points),
95
95
  stored_labels: stored_labels).each { |t| texts << t }
96
96
  end_renderer.texts(text_box: tgt_tb,
97
97
  mult_box: tgt_mb,
98
98
  anchor: target_pt, connector: connector,
99
99
  end_kind: :target, tree: tree && tree[:target],
100
+ heading: stored_tgt && leg_heading(points.reverse),
100
101
  stored_labels: stored_labels).each { |t| texts << t }
101
102
  texts
102
103
  end
@@ -112,6 +113,72 @@ module Ea
112
113
  { source: true, target: true }
113
114
  end
114
115
 
116
+ # A diagonal clipped leg keeps the seat of its unambiguous exit face.
117
+ # Unknown faces retain the stored-upward fallback.
118
+ def source_heading(connector, points)
119
+ heading = leg_heading(points)
120
+ return heading unless points.size == 2 && heading.none?(&:zero?)
121
+
122
+ box = source_dock_bounds(connector)
123
+ face = box && dock_face_heading(points.first, box)
124
+ return face if face && face.zip(heading).any? { |axis, delta| (axis * delta).positive? }
125
+
126
+ stored_upward_heading(connector, heading)
127
+ end
128
+
129
+ def stored_upward_heading(connector, heading)
130
+ return heading unless heading[1].negative?
131
+
132
+ stored = waypoint_pairs(connector)
133
+ return heading unless stored.size == 2
134
+
135
+ original = leg_heading(stored)
136
+ original[0].zero? && original[1].negative? ? original : heading
137
+ end
138
+
139
+ def source_dock_bounds(connector)
140
+ return nil unless model_index && drawn_bounds
141
+
142
+ relationship = model_index[connector.relationship_ref]
143
+ return nil unless relationship
144
+
145
+ source, target = ConnectorRedock.end_refs(relationship)
146
+ return nil if source == target
147
+ return nil unless drawn_bounds[target]
148
+
149
+ drawn_bounds[source]
150
+ end
151
+
152
+ def dock_face_heading(point, box)
153
+ return nil unless box.width.positive? && box.height.positive?
154
+
155
+ vertical_dock_heading(point, box) || horizontal_dock_heading(point, box)
156
+ end
157
+
158
+ def vertical_dock_heading(point, box)
159
+ x, y = point
160
+ left = box.x
161
+ return nil unless x > left && x < left + box.width
162
+
163
+ top = box.y
164
+ return [0, -1] if [top, top - 1].include?(y)
165
+
166
+ bottom = top + box.height
167
+ [0, 1] if [bottom, bottom + 1].include?(y)
168
+ end
169
+
170
+ def horizontal_dock_heading(point, box)
171
+ x, y = point
172
+ top = box.y
173
+ return nil unless y > top && y < top + box.height
174
+
175
+ left = box.x
176
+ return [-1, 0] if [left, left - 1].include?(x)
177
+
178
+ right = left + box.width
179
+ [1, 0] if [right, right + 1].include?(x)
180
+ end
181
+
115
182
  def leg_heading(points)
116
183
  [points[1][0] - points[0][0], points[1][1] - points[0][1]]
117
184
  end
@@ -159,7 +226,7 @@ module Ea
159
226
  theme: theme,
160
227
  font_family: label_font_family,
161
228
  font_size: label_font_size,
162
- font_unit: theme.font_size_unit
229
+ font_unit: theme.label_font_size_unit
163
230
  )
164
231
  end
165
232
 
@@ -169,7 +236,7 @@ module Ea
169
236
  model_index: model_index,
170
237
  font_family: label_font_family,
171
238
  font_size: label_font_size,
172
- font_unit: theme.font_size_unit
239
+ font_unit: theme.label_font_size_unit
173
240
  )
174
241
  end
175
242
 
@@ -195,11 +262,11 @@ module Ea
195
262
  end
196
263
 
197
264
  def label_font_family
198
- theme.font_family || DEFAULT_FAMILY
265
+ theme.label_font_family || DEFAULT_FAMILY
199
266
  end
200
267
 
201
268
  def label_font_size
202
- theme.font_size || DEFAULT_SIZE
269
+ theme.label_font_size || DEFAULT_SIZE
203
270
  end
204
271
  end
205
272
  end
@@ -65,6 +65,8 @@ module Ea
65
65
  text_y_offset: 12, line_height: 13).freeze
66
66
 
67
67
  def initialize(id:, name: nil, font_family: nil, font_size: nil,
68
+ label_font_family: nil, label_font_size: nil,
69
+ label_font_size_unit: nil,
68
70
  font_size_unit: "pt", text_weight_normal: 400,
69
71
  text_weight_bold: 700,
70
72
  element_font_size: 9, element_font_unit: "pt",
@@ -81,6 +83,9 @@ module Ea
81
83
  @id = id.to_s
82
84
  @name = name
83
85
  @font_family = font_family
86
+ @label_font_family = label_font_family
87
+ @label_font_size = label_font_size
88
+ @label_font_size_unit = label_font_size_unit
84
89
  @font_size = font_size
85
90
  @font_size_unit = font_size_unit
86
91
  @text_weight_normal = text_weight_normal
@@ -119,7 +124,22 @@ module Ea
119
124
  fills[classifier.class.name]
120
125
  end
121
126
 
127
+ def label_font_family
128
+ @label_font_family || font_family
129
+ end
130
+
131
+ def label_font_size
132
+ @label_font_size || font_size
133
+ end
134
+
135
+ def label_font_size_unit
136
+ @label_font_size_unit || font_size_unit
137
+ end
138
+
122
139
  def with(**overrides)
140
+ clear_label_override(overrides, :font_family, :label_font_family)
141
+ clear_label_override(overrides, :font_size, :label_font_size)
142
+ clear_label_override(overrides, :font_size_unit, :label_font_size_unit)
123
143
  Definition.new(**to_h.merge(overrides))
124
144
  end
125
145
 
@@ -151,6 +171,9 @@ module Ea
151
171
  id: id, name: name,
152
172
  font_family: font_family, font_size: font_size,
153
173
  font_size_unit: font_size_unit,
174
+ label_font_family: @label_font_family,
175
+ label_font_size: @label_font_size,
176
+ label_font_size_unit: @label_font_size_unit,
154
177
  text_weight_normal: text_weight_normal,
155
178
  text_weight_bold: text_weight_bold,
156
179
  element_font_size: element_font_size,
@@ -171,6 +194,12 @@ module Ea
171
194
  package: package, note: note, fills: fills
172
195
  }
173
196
  end
197
+
198
+ private
199
+
200
+ def clear_label_override(overrides, global_key, label_key)
201
+ overrides[label_key] = nil if overrides.key?(global_key) && !overrides.key?(label_key)
202
+ end
174
203
  end
175
204
  end
176
205
  end
@@ -10,6 +10,7 @@ module Ea
10
10
  # name: EA White Theme
11
11
  # font: { family: Carlito, size: 7, unit: pt, weight_normal: 400, ... }
12
12
  # element_font: { size: 9, unit: pt }
13
+ # label_font: { family: Cambria, size: 7, unit: pt }
13
14
  # colors: { text: "#000000", attribute_text: "#66413F", ... }
14
15
  # strokes: { element_border: 2, connector_line: 2, ... }
15
16
  # text_metrics: { width_factor: 0.65 }
@@ -38,6 +39,7 @@ module Ea
38
39
  def from_hash(data)
39
40
  font = data["font"] || {}
40
41
  elem_font = data["element_font"] || {}
42
+ label_font = data["label_font"] || {}
41
43
  colors = data["colors"] || {}
42
44
  strokes = data["strokes"] || {}
43
45
  text_metrics = data["text_metrics"] || {}
@@ -53,6 +55,9 @@ module Ea
53
55
  id: data["id"],
54
56
  name: data["name"],
55
57
  font_family: font["family"],
58
+ label_font_family: label_font["family"],
59
+ label_font_size: label_font["size"],
60
+ label_font_size_unit: label_font["unit"],
56
61
  font_size: font["size"],
57
62
  font_size_unit: font["unit"] || "pt",
58
63
  text_weight_normal: font["weight_normal"] || 400,
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.95"
7
+ VERSION = "0.6.97"
8
8
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ea
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.6.95
4
+ version: 0.6.97
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ribose Inc.
@@ -501,6 +501,7 @@ files:
501
501
  - config/qea_schema.yml
502
502
  - config/stereotype_colors.yml
503
503
  - config/themes/119.yml
504
+ - config/themes/39.yml
504
505
  - config/themes/default.yml
505
506
  - docs/ea_to_uml_type_mapping.md
506
507
  - docs/xmi_qea_conversion_capabilities.md
@@ -615,6 +616,7 @@ files:
615
616
  - lib/ea/export/xsd/generator.rb
616
617
  - lib/ea/export/xsd/namespace_registry.rb
617
618
  - lib/ea/fonts.rb
619
+ - lib/ea/fonts/cambria_7_regular_widths.json
618
620
  - lib/ea/fonts/carlito_metrics.json
619
621
  - lib/ea/fonts/metrics.rb
620
622
  - lib/ea/guid_format.rb