ea 0.6.106 → 0.6.108

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. checksums.yaml +4 -4
  2. data/config/themes/119.yml +3 -2
  3. data/lib/ea/fonts/cambria_7_styled_widths.json +293 -0
  4. data/lib/ea/fonts/metrics.rb +50 -7
  5. data/lib/ea/model/diagram_element.rb +4 -0
  6. data/lib/ea/sources/qea/diagram_builder/auto_routing.rb +124 -11
  7. data/lib/ea/sources/qea/diagram_builder/font_settings.rb +50 -0
  8. data/lib/ea/sources/qea/diagram_builder/routing.rb +82 -82
  9. data/lib/ea/sources/qea/diagram_builder.rb +9 -30
  10. data/lib/ea/sources/qea/instance_builder.rb +1 -0
  11. data/lib/ea/sources/qea/property_builder.rb +11 -1
  12. data/lib/ea/svg/ea_emitter/color_resolver.rb +1 -2
  13. data/lib/ea/svg/ea_emitter/compartment/attributes.rb +2 -1
  14. data/lib/ea/svg/ea_emitter/compartment/constraints.rb +5 -14
  15. data/lib/ea/svg/ea_emitter/compartment/enum_literals.rb +11 -5
  16. data/lib/ea/svg/ea_emitter/compartment/header.rb +2 -1
  17. data/lib/ea/svg/ea_emitter/compartment/note_body.rb +6 -4
  18. data/lib/ea/svg/ea_emitter/compartment/note_html.rb +66 -0
  19. data/lib/ea/svg/ea_emitter/compartment/operations.rb +2 -1
  20. data/lib/ea/svg/ea_emitter/compartment.rb +1 -0
  21. data/lib/ea/svg/ea_emitter/connector_redock.rb +30 -14
  22. data/lib/ea/svg/ea_emitter/element/attribute_line_builder.rb +11 -9
  23. data/lib/ea/svg/ea_emitter/element/attribute_renderer.rb +42 -17
  24. data/lib/ea/svg/ea_emitter/element/compartment_geometry.rb +46 -15
  25. data/lib/ea/svg/ea_emitter/element/constraint_renderer.rb +34 -2
  26. data/lib/ea/svg/ea_emitter/element/enumeration_literal_renderer.rb +8 -5
  27. data/lib/ea/svg/ea_emitter/element/header_line_pipeline.rb +3 -2
  28. data/lib/ea/svg/ea_emitter/element/header_line_provider/instance_spec.rb +10 -5
  29. data/lib/ea/svg/ea_emitter/element/header_line_provider/name.rb +2 -1
  30. data/lib/ea/svg/ea_emitter/element/header_renderer.rb +14 -12
  31. data/lib/ea/svg/ea_emitter/element/operation_renderer.rb +10 -7
  32. data/lib/ea/svg/ea_emitter/elements.rb +41 -33
  33. data/lib/ea/svg/ea_emitter/font_resolver.rb +14 -4
  34. data/lib/ea/svg/ea_emitter/ghost_labels.rb +3 -0
  35. data/lib/ea/svg/ea_emitter/label/end_label.rb +37 -4
  36. data/lib/ea/svg/ea_emitter/labels.rb +12 -4
  37. data/lib/ea/svg/ea_emitter/marker/diamond.rb +17 -23
  38. data/lib/ea/svg/ea_emitter/render_context.rb +1 -1
  39. data/lib/ea/svg/ea_emitter/text_renderer.rb +6 -5
  40. data/lib/ea/theme/definition.rb +5 -2
  41. data/lib/ea/theme/loader.rb +3 -2
  42. data/lib/ea/version.rb +1 -1
  43. data/memory/deviations.md +3 -0
  44. metadata +5 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 944b29164fec6d17c5e89224547ae993a7ef4dfbee74ac1c15208eba6f88f0d5
4
- data.tar.gz: e0d40f4203d9ef1bf8a1d6f0cf72349b7e3e052ed2c3c920f96f1ca655ff2741
3
+ metadata.gz: e5457390e5e190fab6345e9bb50a0bb773b1223e7e0617de9b8121efb6eb0cf7
4
+ data.tar.gz: b56bf3ce3bb1b5b8f1339f509b1ba99080f72eb480c0d85db5826c7251f91caa
5
5
  SHA512:
6
- metadata.gz: 4087c56867c690c67f9c99011a3e8bd4c043fdcb4bb25880a6d7020d7bab5c6e9bed6adc5b25475e37d9dfe64034d6ce7a52fea44d163fb7a41df37998ce1186
7
- data.tar.gz: 4ae9f0ff3fc328d2f4ceeec2a9e8b0acb51fc72326c9806b00471449dfc656d3b60029f002b4bd5e59269c32807144ed1a660dafb29bc9b4916731e5ba57e761
6
+ metadata.gz: 769abf22c7038cab232e7ca4965dd44d6e11c1940d1de7cbc9f5821478109d5113d430805af6278caa34fa827167f92834400631992305f402ea38483d2e98d9
7
+ data.tar.gz: 19a7d924227c5783ac2e348ab6b682943746112bc3809fd32d2c1edef3c92fabdf2e74ac1de1f910c996b355825ab083cb25d20d7910ac68851801d4d5c5b401
@@ -17,14 +17,15 @@ label_font:
17
17
  # EA renders classifier compartments at this size regardless of
18
18
  # the diagram-level font.size.
19
19
  element_font:
20
+ family: Cambria
20
21
  size: 9
21
22
  unit: pt
22
23
 
23
24
  # Colors (decoded from EA's BGR integer encoding).
24
- # AttrColor=4145510→#66413F, ElemLineColor=8684698→#9A8484.
25
+ # ElemLineColor=8684698→#9A8484. Attribute rows inherit `text`: EA's
26
+ # exported SVGs draw them black, and QEA stores no attribute color.
25
27
  colors:
26
28
  text: "#000000"
27
- attribute_text: "#66413F"
28
29
  method_text: "#335C5D"
29
30
  border: "#9A8484"
30
31
  fill: "#FFFFFF"
@@ -0,0 +1,293 @@
1
+ {
2
+ "bold": {
3
+ " ": 2,
4
+ "!": 3,
5
+ "\"": 4,
6
+ "#": 6,
7
+ "$": 5,
8
+ "%": 10,
9
+ "&": 7,
10
+ "'": 3,
11
+ "(": 4,
12
+ ")": 4,
13
+ "*": 5,
14
+ "+": 6,
15
+ ",": 2,
16
+ "-": 3,
17
+ ".": 2,
18
+ "/": 5,
19
+ "0": 6,
20
+ "1": 6,
21
+ "2": 6,
22
+ "3": 6,
23
+ "4": 6,
24
+ "5": 6,
25
+ "6": 6,
26
+ "7": 6,
27
+ "8": 6,
28
+ "9": 6,
29
+ ":": 3,
30
+ ";": 3,
31
+ "<": 6,
32
+ "=": 6,
33
+ ">": 6,
34
+ "?": 5,
35
+ "@": 9,
36
+ "A": 7,
37
+ "B": 7,
38
+ "C": 6,
39
+ "D": 7,
40
+ "E": 6,
41
+ "F": 6,
42
+ "G": 6,
43
+ "H": 7,
44
+ "I": 4,
45
+ "J": 3,
46
+ "K": 7,
47
+ "L": 6,
48
+ "M": 8,
49
+ "N": 7,
50
+ "O": 7,
51
+ "P": 6,
52
+ "Q": 7,
53
+ "R": 7,
54
+ "S": 5,
55
+ "T": 6,
56
+ "U": 7,
57
+ "V": 6,
58
+ "W": 10,
59
+ "X": 6,
60
+ "Y": 6,
61
+ "Z": 6,
62
+ "[": 4,
63
+ "\\": 5,
64
+ "]": 4,
65
+ "^": 6,
66
+ "_": 4,
67
+ "`": 3,
68
+ "a": 5,
69
+ "b": 6,
70
+ "c": 5,
71
+ "d": 6,
72
+ "e": 5,
73
+ "f": 3,
74
+ "g": 5,
75
+ "h": 6,
76
+ "i": 3,
77
+ "j": 3,
78
+ "k": 6,
79
+ "l": 3,
80
+ "m": 9,
81
+ "n": 6,
82
+ "o": 6,
83
+ "p": 6,
84
+ "q": 6,
85
+ "r": 5,
86
+ "s": 5,
87
+ "t": 4,
88
+ "u": 6,
89
+ "v": 5,
90
+ "w": 8,
91
+ "x": 5,
92
+ "y": 5,
93
+ "z": 5,
94
+ "{": 4,
95
+ "|": 3,
96
+ "}": 4,
97
+ "~": 6
98
+ },
99
+ "italic": {
100
+ " ": 2,
101
+ "!": 3,
102
+ "\"": 4,
103
+ "#": 6,
104
+ "$": 5,
105
+ "%": 9,
106
+ "&": 7,
107
+ "'": 2,
108
+ "(": 4,
109
+ ")": 4,
110
+ "*": 4,
111
+ "+": 5,
112
+ ",": 2,
113
+ "-": 3,
114
+ ".": 2,
115
+ "/": 5,
116
+ "0": 5,
117
+ "1": 5,
118
+ "2": 5,
119
+ "3": 5,
120
+ "4": 5,
121
+ "5": 5,
122
+ "6": 5,
123
+ "7": 5,
124
+ "8": 5,
125
+ "9": 5,
126
+ ":": 3,
127
+ ";": 3,
128
+ "<": 5,
129
+ "=": 5,
130
+ ">": 5,
131
+ "?": 4,
132
+ "@": 9,
133
+ "A": 6,
134
+ "B": 6,
135
+ "C": 5,
136
+ "D": 6,
137
+ "E": 6,
138
+ "F": 5,
139
+ "G": 6,
140
+ "H": 7,
141
+ "I": 3,
142
+ "J": 3,
143
+ "K": 6,
144
+ "L": 5,
145
+ "M": 8,
146
+ "N": 7,
147
+ "O": 6,
148
+ "P": 6,
149
+ "Q": 6,
150
+ "R": 6,
151
+ "S": 5,
152
+ "T": 6,
153
+ "U": 6,
154
+ "V": 6,
155
+ "W": 9,
156
+ "X": 5,
157
+ "Y": 5,
158
+ "Z": 5,
159
+ "[": 3,
160
+ "\\": 5,
161
+ "]": 3,
162
+ "^": 5,
163
+ "_": 4,
164
+ "`": 3,
165
+ "a": 5,
166
+ "b": 5,
167
+ "c": 4,
168
+ "d": 5,
169
+ "e": 5,
170
+ "f": 3,
171
+ "g": 5,
172
+ "h": 5,
173
+ "i": 3,
174
+ "j": 3,
175
+ "k": 5,
176
+ "l": 3,
177
+ "m": 8,
178
+ "n": 5,
179
+ "o": 5,
180
+ "p": 5,
181
+ "q": 5,
182
+ "r": 4,
183
+ "s": 4,
184
+ "t": 3,
185
+ "u": 5,
186
+ "v": 5,
187
+ "w": 7,
188
+ "x": 4,
189
+ "y": 5,
190
+ "z": 4,
191
+ "{": 4,
192
+ "|": 3,
193
+ "}": 4,
194
+ "~": 5
195
+ },
196
+ "bold_italic": {
197
+ " ": 2,
198
+ "!": 3,
199
+ "\"": 4,
200
+ "#": 6,
201
+ "$": 5,
202
+ "%": 10,
203
+ "&": 7,
204
+ "'": 2,
205
+ "(": 4,
206
+ ")": 4,
207
+ "*": 4,
208
+ "+": 6,
209
+ ",": 2,
210
+ "-": 3,
211
+ ".": 2,
212
+ "/": 5,
213
+ "0": 6,
214
+ "1": 6,
215
+ "2": 6,
216
+ "3": 6,
217
+ "4": 6,
218
+ "5": 6,
219
+ "6": 6,
220
+ "7": 6,
221
+ "8": 6,
222
+ "9": 6,
223
+ ":": 3,
224
+ ";": 3,
225
+ "<": 6,
226
+ "=": 6,
227
+ ">": 6,
228
+ "?": 4,
229
+ "@": 9,
230
+ "A": 6,
231
+ "B": 6,
232
+ "C": 6,
233
+ "D": 7,
234
+ "E": 6,
235
+ "F": 5,
236
+ "G": 6,
237
+ "H": 7,
238
+ "I": 3,
239
+ "J": 3,
240
+ "K": 7,
241
+ "L": 5,
242
+ "M": 8,
243
+ "N": 7,
244
+ "O": 7,
245
+ "P": 6,
246
+ "Q": 7,
247
+ "R": 6,
248
+ "S": 5,
249
+ "T": 6,
250
+ "U": 7,
251
+ "V": 6,
252
+ "W": 9,
253
+ "X": 6,
254
+ "Y": 6,
255
+ "Z": 5,
256
+ "[": 4,
257
+ "\\": 5,
258
+ "]": 4,
259
+ "^": 6,
260
+ "_": 4,
261
+ "`": 3,
262
+ "a": 6,
263
+ "b": 6,
264
+ "c": 5,
265
+ "d": 6,
266
+ "e": 5,
267
+ "f": 3,
268
+ "g": 6,
269
+ "h": 6,
270
+ "i": 3,
271
+ "j": 3,
272
+ "k": 5,
273
+ "l": 3,
274
+ "m": 9,
275
+ "n": 6,
276
+ "o": 5,
277
+ "p": 6,
278
+ "q": 6,
279
+ "r": 5,
280
+ "s": 4,
281
+ "t": 4,
282
+ "u": 6,
283
+ "v": 5,
284
+ "w": 8,
285
+ "x": 5,
286
+ "y": 5,
287
+ "z": 5,
288
+ "{": 4,
289
+ "|": 3,
290
+ "}": 4,
291
+ "~": 6
292
+ }
293
+ }
@@ -38,8 +38,15 @@ module Ea
38
38
  CAMBRIA_7_REGULAR_WIDTHS = JSON.parse(File.read(
39
39
  File.expand_path("cambria_7_regular_widths.json", __dir__)
40
40
  )).freeze
41
+ # Styled ASCII advances come from Cambria's native TTF metrics,
42
+ # quantized at the same 10px logical height as EA's 7pt font.
43
+ # Bold uses monochrome hinted advances from the native TTF.
44
+ CAMBRIA_7_STYLED_WIDTHS = JSON.parse(File.read(
45
+ File.expand_path("cambria_7_styled_widths.json", __dir__)
46
+ )).freeze
41
47
  CAMBRIA_7_GUILLEMET_PAIR_WIDTH = 10
42
- private_constant :CAMBRIA_7_REGULAR_WIDTHS, :CAMBRIA_7_GUILLEMET_PAIR_WIDTH
48
+ private_constant :CAMBRIA_7_REGULAR_WIDTHS, :CAMBRIA_7_STYLED_WIDTHS,
49
+ :CAMBRIA_7_GUILLEMET_PAIR_WIDTH
43
50
 
44
51
  # Advance used for codepoints missing from the tables.
45
52
  DEFAULT_ADVANCE = 0.25
@@ -60,6 +67,32 @@ module Ea
60
67
 
61
68
  module_function
62
69
 
70
+ # EA's Cambria 7pt compartment rows use a 12px cell; Carlito
71
+ # uses 13px. Keep unmeasured sizes and pixel fonts on their
72
+ # configured spacing.
73
+ def compartment_line_height(size, family:, unit: "pt", offset: 6, logical_height: nil)
74
+ return size + offset - 1 if compact_compartment_font?(size, family: family, unit: unit)
75
+
76
+ # Source sizes 90 and 100 both export as 9pt but retain
77
+ # distinct 12px and 13px logical heights. Apply that same
78
+ # source-height delta to the existing compartment spacing.
79
+ adjustment = if logical_height && unit == "pt" &&
80
+ GDI_HEIGHT_FAMILIES.include?(resolve_family(family))
81
+ logical_height - gdi_logical_height(size)
82
+ else
83
+ 0
84
+ end
85
+ size + offset + adjustment
86
+ end
87
+
88
+ def gdi_logical_height(size)
89
+ (size.to_f * 10 / 7).round
90
+ end
91
+
92
+ def compact_compartment_font?(size, family:, unit: "pt")
93
+ family.to_s.casecmp("Cambria").zero? && size == 7 && unit == "pt"
94
+ end
95
+
63
96
  # Width of `text` in EA's exported SVG units for the given pt
64
97
  # size, or nil when the family is not metric-compatible.
65
98
  #
@@ -69,19 +102,19 @@ module Ea
69
102
  # textLength is the SUM of those per-glyph integers - corpus
70
103
  # fitted to 97-100% exact on 10k Carlito texts (bold 7pt
71
104
  # 1666/1671). The legacy sum-then-round model matched only 27%.
72
- def text_length(text, size_pt, family: nil, weight: nil, style: nil)
105
+ def text_length(text, size_pt, family: nil, weight: nil, style: nil, logical_height: nil)
73
106
  return nil unless supported_family?(family)
74
107
  return nil if text.nil? || text.empty?
75
108
 
76
109
  return cambria_text_length(text, size_pt, weight, style) if resolve_family(family) == "Cambria"
77
110
 
78
- advance_text_length(text, size_pt, family, weight, style)
111
+ advance_text_length(text, size_pt, family, weight, style, logical_height)
79
112
  end
80
113
 
81
- def advance_text_length(text, size_pt, family, weight, style)
114
+ def advance_text_length(text, size_pt, family, weight, style, logical_height)
82
115
  resolved = resolve_family(family)
83
116
  if GDI_HEIGHT_FAMILIES.include?(resolved)
84
- height = (size_pt.to_f * 10 / 7).round
117
+ height = logical_height || gdi_logical_height(size_pt)
85
118
  table = table_for(family, weight, style)
86
119
  latin = 0
87
120
  cjk_count = 0
@@ -105,8 +138,11 @@ module Ea
105
138
 
106
139
  def cambria_text_length(text, size_pt, weight, style)
107
140
  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)
141
+ return nil unless ["", "0", "400", "normal", "700", "bold"].include?(weight.to_s)
142
+ return nil unless ["", "normal", "italic"].include?(style.to_s)
143
+
144
+ key = STYLE_KEYS.fetch([bold?(weight), italic?(style)])
145
+ return cambria_styled_length(text, key) unless key == :regular
110
146
 
111
147
  return cambria_ascii_length(text) unless text.start_with?("«") && text.end_with?("»")
112
148
 
@@ -115,6 +151,13 @@ module Ea
115
151
  end
116
152
  private_class_method :cambria_text_length
117
153
 
154
+ def cambria_styled_length(text, key)
155
+ table = CAMBRIA_7_STYLED_WIDTHS.fetch(key.to_s)
156
+ widths = text.each_char.map { |char| table[char] }
157
+ widths.sum unless widths.empty? || widths.include?(nil)
158
+ end
159
+ private_class_method :cambria_styled_length
160
+
118
161
  def cambria_ascii_length(text)
119
162
  widths = text.each_char.map { |char| CAMBRIA_7_REGULAR_WIDTHS[char] }
120
163
  widths.sum unless widths.empty? || widths.include?(nil)
@@ -22,6 +22,8 @@ module Ea
22
22
  attribute :line_width, :integer
23
23
  attribute :font_family, :string
24
24
  attribute :font_size, :integer
25
+ attribute :font_logical_height, :integer # original GDI pixel height before point normalization
26
+ attribute :font_size_unit, :string # source font size unit, before theme fallback
25
27
  attribute :font_bold, :boolean
26
28
  attribute :font_italic, :boolean
27
29
  attribute :font_underline, :boolean
@@ -53,6 +55,8 @@ module Ea
53
55
  map "lineWidth", to: :line_width
54
56
  map "fontFamily", to: :font_family
55
57
  map "fontSize", to: :font_size
58
+ map "fontSizeUnit", to: :font_size_unit
59
+ map "fontLogicalHeight", to: :font_logical_height
56
60
  map "fontBold", to: :font_bold, render_default: true
57
61
  map "fontItalic", to: :font_italic, render_default: true
58
62
  map "fontUnderline", to: :font_underline, render_default: true
@@ -8,12 +8,118 @@ module Ea
8
8
  module AutoRouting
9
9
  private
10
10
 
11
- # Nonuniform horizontal offsets seat the route at the source row.
11
+ # An empty flush LV tree uses the target's center x and source's
12
+ # center y for its corner, the transposed LH construction. Keep
13
+ # authored corners and other connector styles on their existing path.
14
+ def derived_vertical_lateral_route(style, link_row, connector, source, target)
15
+ return nil unless style[:mode] == "3" && style[:lwidth] == "0" &&
16
+ connector.connector_type == "Association" && link_row.path.to_s.empty?
17
+
18
+ corner = [target.x + target.width / 2, source.y + source.height / 2]
19
+ vertical_corner_route(corner, source, target)
20
+ end
21
+
22
+ # Without an authored Path, a direct Association can still store
23
+ # aligned mathematical origins rather than screen-space ports.
24
+ def stored_association_path?(model, link_row, source, target, geom)
25
+ return true unless model.connector_type == "Association" && link_row.path.to_s.empty?
26
+ return false unless geom.values_at(:sx, :sy, :ex, :ey).all?
27
+
28
+ origins = [stored_ray_origin(source, geom[:sx], geom[:sy]),
29
+ stored_ray_origin(target, geom[:ex], geom[:ey])]
30
+ origins.first.y == origins.last.y && horizontal_ray_gap?(source, target) &&
31
+ origins.zip([source, target]).all? { |origin, box| inside_box?(origin, grown_bounds(box, 1)) }
32
+ end
33
+
34
+ # A Mode 2 Association with equal SX/EX (vertical EDGE 1/3) or
35
+ # equal SY/EY (horizontal EDGE 2/4) and no custom Path docks as a
36
+ # straight line through the middle of the boxes' overlap, moved
37
+ # by the shared stored offset. Stored SY counts upward.
38
+ def overlap_ray_origins(model, link_row, diagram_row, geom, context)
39
+ return model if context
40
+ return model unless overlap_ray_candidate?(model, link_row, geom)
41
+
42
+ source, target = stored_ray_boxes(link_row, diagram_row)
43
+ origins = overlap_origins(source, target, geom)
44
+ return model unless origins && overlap_correction?(source, target, geom)
45
+
46
+ model.source_anchor, model.target_anchor = origins.map do |ox, oy|
47
+ Ea::Model::Point.new(x: ox, y: oy)
48
+ end
49
+ if model.waypoints.size == 3
50
+ model.waypoints = port_waypoints(model, [model.source_anchor, model.target_anchor])
51
+ end
52
+ model.style[:overlap_ray] = true
53
+ model
54
+ end
55
+
56
+ # Aligned, zero-offset EDGE 2 routes already have their legacy docks.
57
+ def overlap_correction?(source, target, geom)
58
+ geom[:edge] != 2 || !geom[:sy].zero? ||
59
+ source.y * 2 + source.height != target.y * 2 + target.height
60
+ end
61
+
62
+ # Both ray origins share the overlap coordinate on the axis the
63
+ # offsets move and sit at each box's centre on the other axis.
64
+ def overlap_origins(source, target, geom)
65
+ along, across, offset = overlap_axes(geom)
66
+ coordinate = overlap_ray_coordinate(*box_span(source, along), *box_span(target, along), offset)
67
+ return nil unless coordinate && disjoint_span?(*box_span(source, across), *box_span(target, across))
68
+
69
+ [source, target].map do |box|
70
+ start, size = box_span(box, across)
71
+ centre = start + size / 2.0
72
+ [2, 4].include?(geom[:edge]) ? [centre, coordinate] : [coordinate, centre]
73
+ end
74
+ end
75
+
76
+ # [axis the offsets move, axis the boxes face, stored offset]
77
+ def overlap_axes(geom)
78
+ return [%i[y height], %i[x width], -geom[:sy]] if [2, 4].include?(geom[:edge])
79
+
80
+ [%i[x width], %i[y height], geom[:sx]]
81
+ end
82
+
83
+ def box_span(box, keys)
84
+ keys.map { |key| box.public_send(key) }
85
+ end
86
+
87
+ def overlap_ray_candidate?(model, link_row, geom)
88
+ model.connector_type == "Association" && model.style[:mode] == "2" &&
89
+ [2, 3].include?(model.waypoints.size) && link_row.path.to_s.empty? && !model.style[:tree] &&
90
+ model.has_geometry_offsets && uniform_overlap_offsets?(geom)
91
+ end
92
+
93
+ def uniform_overlap_offsets?(geom)
94
+ return false unless geom.values_at(:sx, :sy, :ex, :ey).all?
95
+
96
+ if [2, 4].include?(geom[:edge])
97
+ geom[:sy] == geom[:ey]
98
+ else
99
+ [1, 3].include?(geom[:edge]) && geom[:sx] == geom[:ex]
100
+ end
101
+ end
102
+
103
+ # Floor of the overlap midpoint plus the shared offset; nil
104
+ # when the boxes do not overlap on this axis.
105
+ def overlap_ray_coordinate(start_a, size_a, start_b, size_b, offset)
106
+ low = [start_a, start_b].max
107
+ high = [start_a + size_a, start_b + size_b].min
108
+ return nil if low > high
109
+
110
+ coordinate = ((low + high) / 2.0 + offset).floor
111
+ coordinate if coordinate.between?(low, high)
112
+ end
113
+
114
+ # Nonuniform horizontal offsets move the shared overlap row by SY.
12
115
  # Uniform offsets retain the shared-overlap routing contract.
13
116
  def source_row_association_ray(model, link_row, diagram_row, geom, context)
14
- return model if context || !source_row_association?(model, link_row, geom)
117
+ return model if context || !source_row_association?(model, geom)
15
118
 
16
- origins = source_row_origins(*stored_ray_boxes(link_row, diagram_row), geom)
119
+ source, target = stored_ray_boxes(link_row, diagram_row)
120
+ return model unless source_row_path?(link_row, source, target)
121
+
122
+ origins = source_row_origins(source, target, geom)
17
123
  return model unless origins
18
124
 
19
125
  model.source_anchor, model.target_anchor = origins
@@ -72,16 +178,12 @@ module Ea
72
178
  def source_row_origins(source, target, geom)
73
179
  return nil unless source_row_faces_target?(source, target, geom[:edge])
74
180
 
75
- row = source_row_y(source, geom)
76
- return nil unless [source, target].all? { |box| within_height?(row, box) }
181
+ row = overlap_ray_coordinate(source.y, source.height, target.y, target.height, -geom[:sy])
182
+ return nil unless row && [source, target].all? { |box| within_height?(row, box) }
77
183
 
78
184
  [source, target].map { |box| Ea::Model::Point.new(x: box.x + box.width / 2.0, y: row) }
79
185
  end
80
186
 
81
- def source_row_y(source, geom)
82
- (source.y + source.height / 2.0 - geom[:sy]).floor
83
- end
84
-
85
187
  def source_row_faces_target?(source, target, edge)
86
188
  if edge == 2
87
189
  source.x + source.width < target.x
@@ -90,13 +192,24 @@ module Ea
90
192
  end
91
193
  end
92
194
 
93
- def source_row_association?(model, link_row, geom)
195
+ def source_row_association?(model, geom)
94
196
  model.connector_type == "Association" && model.has_geometry_offsets &&
95
197
  model.source_anchor && model.target_anchor &&
96
- link_row.path.to_s.empty? && source_row_style?(model.style.to_h) &&
198
+ source_row_style?(model.style.to_h) &&
97
199
  source_row_offsets?(geom)
98
200
  end
99
201
 
202
+ def source_row_path?(link_row, source, target)
203
+ link_row.path.to_s.empty? || stale_horizontal_jog?(link_row, source, target)
204
+ end
205
+
206
+ # A horizontal saved jog outside both boxes is stale auto-routing.
207
+ # A corner that still lies beside either box remains authored.
208
+ def stale_horizontal_jog?(link_row, source, target)
209
+ corners = intermediate_waypoints(link_row)
210
+ horizontal_jog?(corners) && [source, target].none? { |box| within_height?(corners.first[1], box) }
211
+ end
212
+
100
213
  def source_row_style?(style)
101
214
  style[:mode] == "2" && style[:lwidth] == "2" && !style[:tree]
102
215
  end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ea
4
+ module Sources
5
+ module Qea
6
+ class DiagramBuilder
7
+ # Resolves saved classifier and placement fonts and EA's point sizes.
8
+ module FontSettings
9
+ private
10
+
11
+ # Placement defaults inherit the classifier's saved font before the
12
+ # diagram theme. A zero fontsz is EA's use-default marker.
13
+ def effective_font_style(obj_row, style)
14
+ object = database.find_object(obj_row.ea_object_id)
15
+ defaults = DiagramStyleParser.parse(object&.styleex)
16
+ {
17
+ font: presence(style[:font]) || defaults[:font],
18
+ fontsz: font_size_from(style) ? style[:fontsz] : defaults[:fontsz]
19
+ }
20
+ end
21
+
22
+ def presence(value)
23
+ value.nil? || value.to_s.empty? ? nil : value
24
+ end
25
+
26
+ # EA stores the element font size in tenths of a point and
27
+ # renders it through GDI's double rounding: pixel height =
28
+ # floor(tenths * 96 / 720), then point size =
29
+ # floor(pixels * 72 / 96). A zero fontsz inherits the default.
30
+ USE_DEFAULT_FONTSZ = [0].freeze
31
+ GDI_DPI = 96
32
+
33
+ def font_logical_height_from(style_hash)
34
+ raw = style_hash[:fontsz]
35
+ return nil if raw.nil? || raw.to_s.empty?
36
+
37
+ return nil if USE_DEFAULT_FONTSZ.include?(raw.to_i)
38
+
39
+ (raw.to_f * GDI_DPI / 720).floor
40
+ end
41
+
42
+ def font_size_from(style_hash)
43
+ pixels = font_logical_height_from(style_hash)
44
+ (pixels * 72.0 / GDI_DPI).floor if pixels
45
+ end
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end