ea 0.6.127 → 0.6.129
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 +4 -4
- data/lib/ea/fonts/carlito_gdi_widths.json +1 -0
- data/lib/ea/fonts/cjk_fallback_widths.json +1 -0
- data/lib/ea/fonts/metrics.rb +73 -15
- data/lib/ea/fonts/vertical_metrics.json +154 -0
- data/lib/ea/sources/qea/diagram_builder/association_class.rb +52 -0
- data/lib/ea/sources/qea/diagram_builder/auto_routing.rb +26 -4
- data/lib/ea/sources/qea/diagram_builder/routing.rb +90 -4
- data/lib/ea/sources/qea/diagram_builder.rb +29 -5
- data/lib/ea/sources/qea/relationship_builder.rb +6 -6
- data/lib/ea/sources/xmi/relationship_builder.rb +15 -8
- data/lib/ea/svg/ea_emitter/compartment/attributes.rb +1 -1
- data/lib/ea/svg/ea_emitter/compartment/header.rb +10 -1
- data/lib/ea/svg/ea_emitter/compartment/instance_slots.rb +24 -5
- data/lib/ea/svg/ea_emitter/compartment/note_body.rb +56 -20
- data/lib/ea/svg/ea_emitter/compartment/note_html.rb +18 -31
- data/lib/ea/svg/ea_emitter/connector_redock.rb +52 -3
- data/lib/ea/svg/ea_emitter/connectors.rb +1 -0
- data/lib/ea/svg/ea_emitter/element/header_renderer.rb +13 -3
- data/lib/ea/svg/ea_emitter/element/tagged_value_renderer.rb +14 -5
- data/lib/ea/svg/ea_emitter/elements.rb +13 -1
- data/lib/ea/svg/ea_emitter/font_resolver.rb +6 -1
- data/lib/ea/svg/ea_emitter/label/end_label.rb +182 -10
- data/lib/ea/svg/ea_emitter/label/midpoint_label.rb +28 -8
- data/lib/ea/svg/ea_emitter/label/plain_source_seat.rb +29 -0
- data/lib/ea/svg/ea_emitter/label/plain_target_seat.rb +28 -0
- data/lib/ea/svg/ea_emitter/label/source_stair_seat.rb +60 -0
- data/lib/ea/svg/ea_emitter/label/target_bend_seat.rb +45 -0
- data/lib/ea/svg/ea_emitter/label.rb +4 -0
- data/lib/ea/svg/ea_emitter/labels.rb +19 -3
- data/lib/ea/svg/ea_emitter/marker/diamond.rb +29 -5
- data/lib/ea/theme/definition.rb +9 -1
- data/lib/ea/version.rb +1 -1
- metadata +9 -1
|
@@ -81,13 +81,15 @@ module Ea
|
|
|
81
81
|
# A Mode 2 Generalization whose stored Path is a horizontal jog
|
|
82
82
|
# between vertically separated boxes that overlap in x is EA's
|
|
83
83
|
# auto-route: one vertical ray at the overlap midpoint plus SX.
|
|
84
|
-
#
|
|
84
|
+
# Empty Paths qualify only when both saved origins face the gap.
|
|
85
|
+
# The ray docks on both 1px outlines.
|
|
85
86
|
def mode2_overlap_ray(model, link_row, diagram_row, geom, context)
|
|
86
87
|
return model if context || !mode2_overlap_candidate?(model, link_row, geom)
|
|
87
88
|
|
|
88
89
|
source, target = stored_ray_boxes(link_row, diagram_row)
|
|
89
90
|
column = overlap_ray_coordinate(source.x, source.width, target.x, target.width, geom[:sx])
|
|
90
91
|
return model unless column && vertical_separation?(source, target)
|
|
92
|
+
return model unless mode2_overlap_path?(link_row, source, target, geom, column)
|
|
91
93
|
|
|
92
94
|
set_overlap_ray(model, column, source, target)
|
|
93
95
|
end
|
|
@@ -104,8 +106,35 @@ module Ea
|
|
|
104
106
|
|
|
105
107
|
def mode2_overlap_candidate?(model, link_row, geom)
|
|
106
108
|
model.connector_type == "Generalization" && model.has_geometry_offsets &&
|
|
107
|
-
mode2_overlap_style?(model.style.to_h, geom) &&
|
|
108
|
-
|
|
109
|
+
mode2_overlap_style?(model.style.to_h, geom) &&
|
|
110
|
+
((model.waypoints.size == 2 && link_row.path.to_s.empty?) ||
|
|
111
|
+
(model.waypoints.size == 4 && horizontal_jog?(intermediate_waypoints(link_row))))
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def mode2_overlap_path?(link_row, source, target, geom, column)
|
|
115
|
+
!link_row.path.to_s.empty? || mode2_facing_origins?(source, target, geom, column)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Saved origins on the facing border or one pixel inside it identify
|
|
119
|
+
# the empty auto-route. Interior and external origins retain their ray.
|
|
120
|
+
def mode2_facing_origins?(source, target, geom, column)
|
|
121
|
+
return false unless geom.values_at(:sx, :sy, :ex, :ey).all?
|
|
122
|
+
|
|
123
|
+
origins = [stored_ray_origin(source, geom[:sx], geom[:sy]),
|
|
124
|
+
stored_ray_origin(target, geom[:ex], geom[:ey])]
|
|
125
|
+
return false unless mode2_origins_share_column?(origins, source, column)
|
|
126
|
+
|
|
127
|
+
origins.zip([source, target], overlap_ray_ends(column, source, target)).all? do |origin, box, dock|
|
|
128
|
+
mode2_origin_on_face?(origin, box, dock[1])
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def mode2_origin_on_face?(origin, box, outline_y)
|
|
133
|
+
inside_box?(origin, box) && (origin.y - outline_y).abs.between?(1, 2)
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def mode2_origins_share_column?(origins, source, column)
|
|
137
|
+
origins.first.x == column && origins.last.x.between?(source.x, source.x + source.width)
|
|
109
138
|
end
|
|
110
139
|
|
|
111
140
|
def mode2_overlap_style?(style, geom)
|
|
@@ -152,6 +181,7 @@ module Ea
|
|
|
152
181
|
# Computed-jog origins are already normalized to their derived ports.
|
|
153
182
|
def correct_stored_ray_origins(model, link_row, diagram_row, geom, context)
|
|
154
183
|
return model if context
|
|
184
|
+
return notelink_stored_ray(model, link_row, diagram_row, geom) if model.connector_type == "NoteLink"
|
|
155
185
|
return model unless stored_ray_candidate?(model, link_row, geom)
|
|
156
186
|
|
|
157
187
|
source, target = stored_ray_boxes(link_row, diagram_row)
|
|
@@ -164,9 +194,47 @@ module Ea
|
|
|
164
194
|
|
|
165
195
|
set_stored_ray_origins(model, source, target, geom)
|
|
166
196
|
model.style[:association_ray_outline] = true if model.connector_type == "Association"
|
|
197
|
+
model.style[:stored_origin_ray] = true if stored_origin_ray?(model, source, target)
|
|
167
198
|
model
|
|
168
199
|
end
|
|
169
200
|
|
|
201
|
+
# A straight top-docked NoteLink uses mathematical saved Y offsets,
|
|
202
|
+
# like the other custom rays, while authored bends keep their ports.
|
|
203
|
+
def notelink_stored_ray(model, link_row, diagram_row, geom)
|
|
204
|
+
return model unless notelink_ray_candidate?(model, link_row, geom)
|
|
205
|
+
|
|
206
|
+
source, target = stored_ray_boxes(link_row, diagram_row)
|
|
207
|
+
return model unless target.y + target.height < source.y
|
|
208
|
+
return model unless notelink_origins_inside?(source, target, geom)
|
|
209
|
+
|
|
210
|
+
set_stored_ray_origins(model, source, target, geom)
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def notelink_ray_candidate?(model, link_row, geom)
|
|
214
|
+
model.waypoints.size == 2 && link_row.path.to_s.empty? && model.style[:lwidth] == "2" &&
|
|
215
|
+
association_ray_style?(model.style.to_h) &&
|
|
216
|
+
geom[:edge] == 1 && geom.values_at(:sx, :sy, :ex, :ey).all?
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
def notelink_origins_inside?(source, target, geom)
|
|
220
|
+
origins = [stored_ray_origin(source, geom[:sx], geom[:sy]),
|
|
221
|
+
stored_ray_origin(target, geom[:ex], geom[:ey])]
|
|
222
|
+
origins.zip([source, target]).all? { |origin, box| inside_box?(origin, box) }
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
# Certify only a straight, width-2 custom-line ray whose corrected
|
|
226
|
+
# stored origins lie inside their own outlines. Its clipped row is
|
|
227
|
+
# authored geometry, even when it coincides with a header divider.
|
|
228
|
+
def stored_origin_ray?(model, source, target)
|
|
229
|
+
style = model.style.to_h
|
|
230
|
+
return false unless model.connector_type == "Generalization" &&
|
|
231
|
+
style[:lwidth] == "2" && association_ray_style?(style)
|
|
232
|
+
|
|
233
|
+
[model.source_anchor, model.target_anchor].zip([source, target]).all? do |origin, box|
|
|
234
|
+
inside_box?(origin, grown_bounds(box, 1))
|
|
235
|
+
end
|
|
236
|
+
end
|
|
237
|
+
|
|
170
238
|
# An empty-Path Mode 3 Association follows the line between its
|
|
171
239
|
# stored endpoint origins; an omitted offset defaults to the center.
|
|
172
240
|
# The target origin must lie inside its own stroked outline.
|
|
@@ -176,6 +244,8 @@ module Ea
|
|
|
176
244
|
return model unless association_ray_candidate?(model, link_row)
|
|
177
245
|
|
|
178
246
|
source, target = stored_ray_boxes(link_row, diagram_row)
|
|
247
|
+
return model if default_center_ray?(model) && !horizontal_ray_gap?(source, target)
|
|
248
|
+
|
|
179
249
|
width = association_outline_width(model, link_row, diagram_row)
|
|
180
250
|
origins = association_ray_origins(source, target, geom, width)
|
|
181
251
|
return model unless origins
|
|
@@ -287,10 +357,26 @@ module Ea
|
|
|
287
357
|
def association_ray_candidate?(model, link_row)
|
|
288
358
|
model.connector_type == "Association" && link_row.path.to_s.empty? &&
|
|
289
359
|
model.source_anchor && model.target_anchor &&
|
|
290
|
-
|
|
360
|
+
association_ray_ports?(model) &&
|
|
291
361
|
association_ray_style?(model.style.to_h)
|
|
292
362
|
end
|
|
293
363
|
|
|
364
|
+
def association_ray_ports?(model)
|
|
365
|
+
model.source_port || model.target_port || aligned_default_origins?(model) || default_center_ray?(model)
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
# Without stored offsets or ports, a width-2 custom line between
|
|
369
|
+
# horizontally separated boxes follows their centers to the stroke.
|
|
370
|
+
def default_center_ray?(model)
|
|
371
|
+
!model.has_geometry_offsets && model.style[:lwidth] == "2" && model.waypoints.size == 2 &&
|
|
372
|
+
[model.source_port, model.target_port].all?(&:nil?) &&
|
|
373
|
+
diagonal_origins?(model.source_anchor, model.target_anchor)
|
|
374
|
+
end
|
|
375
|
+
|
|
376
|
+
def diagonal_origins?(first, last)
|
|
377
|
+
first.x != last.x && first.y != last.y
|
|
378
|
+
end
|
|
379
|
+
|
|
294
380
|
# Zero offsets omit ports, but an explicitly stored axis-aligned
|
|
295
381
|
# width-2 ray still uses the logical centers and stroked outlines.
|
|
296
382
|
def aligned_default_origins?(model)
|
|
@@ -14,12 +14,14 @@ module Ea
|
|
|
14
14
|
autoload :AutoRouting, "ea/sources/qea/diagram_builder/auto_routing"
|
|
15
15
|
autoload :StoredSquarePorts, "ea/sources/qea/diagram_builder/stored_square_ports"
|
|
16
16
|
autoload :FontSettings, "ea/sources/qea/diagram_builder/font_settings"
|
|
17
|
+
autoload :AssociationClass, "ea/sources/qea/diagram_builder/association_class"
|
|
17
18
|
include Routing
|
|
18
19
|
include AutoRouting
|
|
19
20
|
include StoredSquarePorts
|
|
20
21
|
include FontSettings
|
|
22
|
+
include AssociationClass
|
|
21
23
|
private_constant :Routing
|
|
22
|
-
private_constant :AutoRouting, :StoredSquarePorts, :FontSettings
|
|
24
|
+
private_constant :AutoRouting, :StoredSquarePorts, :FontSettings, :AssociationClass
|
|
23
25
|
|
|
24
26
|
attr_reader :database
|
|
25
27
|
|
|
@@ -396,9 +398,10 @@ module Ea
|
|
|
396
398
|
# Internal producer flag, like :direct. A model relationship with no
|
|
397
399
|
# link row is stroked at the 2px box outline, so its ends dock there.
|
|
398
400
|
def phantom_style(connector_type)
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
%w[Aggregation Association].include?(connector_type)
|
|
401
|
+
style = { lwidth: "1" }
|
|
402
|
+
style[:direct] = true if PHANTOM_DOUBLE_BACK_TYPES.include?(connector_type)
|
|
403
|
+
style[:phantom_outline] = true if %w[Aggregation Association].include?(connector_type)
|
|
404
|
+
style
|
|
402
405
|
end
|
|
403
406
|
|
|
404
407
|
# Computes the edge attachment point on source bounds that
|
|
@@ -424,6 +427,24 @@ module Ea
|
|
|
424
427
|
end
|
|
425
428
|
end
|
|
426
429
|
|
|
430
|
+
# Preserve original Path absence: authored two-point paths can look
|
|
431
|
+
# identical after routing but retain their stored label offsets.
|
|
432
|
+
def mark_plain_source_seat(model, link_row, context)
|
|
433
|
+
return if context || !link_row.path.to_s.empty? || model.connector_type != "Aggregation"
|
|
434
|
+
return unless model.style.values_at(:mode, :lwidth, :tree) == ["3", "2", nil]
|
|
435
|
+
return unless model.source_stereotype.to_s.empty?
|
|
436
|
+
|
|
437
|
+
model.style[:plain_source_seat] = true
|
|
438
|
+
end
|
|
439
|
+
|
|
440
|
+
def mark_plain_target_seat(model, link_row, context)
|
|
441
|
+
return if context || !link_row.path.to_s.empty? || model.connector_type != "Association"
|
|
442
|
+
return unless model.style.values_at(:mode, :lwidth, :tree) == ["3", "2", nil]
|
|
443
|
+
return unless model.target_stereotype.to_s.empty?
|
|
444
|
+
|
|
445
|
+
model.style[:plain_target_seat] = true
|
|
446
|
+
end
|
|
447
|
+
|
|
427
448
|
def build_connector(link_row, diagram_row)
|
|
428
449
|
connector = database.find_connector(link_row.connectorid)
|
|
429
450
|
geom = parse_geometry_fields(link_row.geometry)
|
|
@@ -454,6 +475,8 @@ module Ea
|
|
|
454
475
|
has_geometry_offsets: geometry_has_offsets?(link_row.geometry),
|
|
455
476
|
is_substitutable: substitutable_connector?(connector)
|
|
456
477
|
), link_row, diagram_row, geom)
|
|
478
|
+
mark_plain_source_seat(model, link_row, context)
|
|
479
|
+
mark_plain_target_seat(model, link_row, context)
|
|
457
480
|
model = collapse_partial_port_jog(model, link_row, diagram_row)
|
|
458
481
|
model = correct_partial_port_origins(model, link_row, diagram_row, geom, context)
|
|
459
482
|
model = direct_empty_path_ray(model, link_row, diagram_row, geom, context)
|
|
@@ -466,7 +489,8 @@ module Ea
|
|
|
466
489
|
model = auto_port_elbow(model, link_row, diagram_row, geom, context)
|
|
467
490
|
model = mark_default_square_ray(model, link_row, geom, context)
|
|
468
491
|
model = restore_stored_square_ports(model, link_row, diagram_row, geom, context)
|
|
469
|
-
reversed_stored_corner(model, link_row, diagram_row, geom, context)
|
|
492
|
+
model = reversed_stored_corner(model, link_row, diagram_row, geom, context)
|
|
493
|
+
mark_association_class(model, connector, diagram_row)
|
|
470
494
|
end
|
|
471
495
|
|
|
472
496
|
# Ray origin for one connector end: the element's center
|
|
@@ -80,9 +80,9 @@ module Ea
|
|
|
80
80
|
target_multiplicity_raw: raw_card(row.destcard),
|
|
81
81
|
source_aggregation: source_aggregation_for(row),
|
|
82
82
|
target_aggregation: aggregation_from(row.destisaggregate),
|
|
83
|
-
source_navigable: navigable?(row.
|
|
83
|
+
source_navigable: navigable?(row.sourceisnavigable, row.direction,
|
|
84
84
|
:source),
|
|
85
|
-
target_navigable: navigable?(row.
|
|
85
|
+
target_navigable: navigable?(row.destisnavigable, row.direction, :target)
|
|
86
86
|
}
|
|
87
87
|
end
|
|
88
88
|
|
|
@@ -148,10 +148,10 @@ module Ea
|
|
|
148
148
|
ConnectorRelationshipMap.source_aggregation_for(row.connector_type)
|
|
149
149
|
end
|
|
150
150
|
|
|
151
|
-
def navigable?(
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
#
|
|
151
|
+
def navigable?(flag, direction, end_label)
|
|
152
|
+
return flag == 1 if [0, 1].include?(flag)
|
|
153
|
+
|
|
154
|
+
# Missing or unspecified flags retain the direction fallback.
|
|
155
155
|
case direction.to_s
|
|
156
156
|
when "<->", "" then true
|
|
157
157
|
when "->" then end_label == :target
|
|
@@ -76,8 +76,8 @@ module Ea
|
|
|
76
76
|
target_multiplicity_upper: parse_upper(target_end),
|
|
77
77
|
source_aggregation: source_end&.aggregation || "none",
|
|
78
78
|
target_aggregation: target_end&.aggregation || "none",
|
|
79
|
-
source_navigable: navigable?(
|
|
80
|
-
target_navigable: navigable?(
|
|
79
|
+
source_navigable: navigable?(element, :source),
|
|
80
|
+
target_navigable: navigable?(element, :target)
|
|
81
81
|
}
|
|
82
82
|
end
|
|
83
83
|
|
|
@@ -128,13 +128,20 @@ module Ea
|
|
|
128
128
|
Integer(raw) rescue 1
|
|
129
129
|
end
|
|
130
130
|
|
|
131
|
-
def
|
|
132
|
-
|
|
131
|
+
def connector_index
|
|
132
|
+
@connector_index ||= begin
|
|
133
|
+
connectors = root.extension&.connectors&.connector || []
|
|
134
|
+
connectors.each_with_object({}) do |connector, index|
|
|
135
|
+
index[connector.idref] = connector if connector.idref
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
133
139
|
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
140
|
+
def navigable?(element, endpoint)
|
|
141
|
+
connector = connector_index[element.id]
|
|
142
|
+
end_element = connector&.public_send(endpoint)
|
|
143
|
+
flag = end_element&.modifiers&.is_navigable
|
|
144
|
+
flag.nil? || flag
|
|
138
145
|
end
|
|
139
146
|
end
|
|
140
147
|
end
|
|
@@ -20,7 +20,7 @@ module Ea
|
|
|
20
20
|
size_unit: context.size_unit, logical_height: context.logical_height,
|
|
21
21
|
fill: context.theme.attribute_text_color,
|
|
22
22
|
visibility_x_offset: context.theme.attribute_spec.visibility_x_offset,
|
|
23
|
-
content_x_offset: context.theme.
|
|
23
|
+
content_x_offset: context.theme.attribute_content_x_offset
|
|
24
24
|
)
|
|
25
25
|
end
|
|
26
26
|
end
|
|
@@ -24,9 +24,18 @@ module Ea
|
|
|
24
24
|
weight_bold: context.theme.text_weight_bold,
|
|
25
25
|
stroke_in_text: context.theme.stroke_in_text_color,
|
|
26
26
|
width_factor: context.theme.text_width_factor,
|
|
27
|
-
line_offset: context.theme.compartments.header_line_offset
|
|
27
|
+
line_offset: context.theme.compartments.header_line_offset,
|
|
28
|
+
default_signal_header: default_signal_header?(context)
|
|
28
29
|
)
|
|
29
30
|
end
|
|
31
|
+
|
|
32
|
+
def default_signal_header?(context)
|
|
33
|
+
context.classifier.is_a?(Ea::Model::Signal) &&
|
|
34
|
+
context.classifier.stereotype_refs.empty? &&
|
|
35
|
+
context.element.font_size_inherited == true &&
|
|
36
|
+
context.element.font_family.nil? &&
|
|
37
|
+
!context.element.font_bold && !context.element.font_italic
|
|
38
|
+
end
|
|
30
39
|
end
|
|
31
40
|
end
|
|
32
41
|
end
|
|
@@ -13,7 +13,7 @@ module Ea
|
|
|
13
13
|
# classifier.
|
|
14
14
|
module InstanceSlots
|
|
15
15
|
ROW_OFFSET = 13
|
|
16
|
-
|
|
16
|
+
SUBTITLE_OFFSET_FROM_BOTTOM = 15
|
|
17
17
|
|
|
18
18
|
module_function
|
|
19
19
|
|
|
@@ -30,11 +30,11 @@ module Ea
|
|
|
30
30
|
fill = context.theme.attribute_text_color
|
|
31
31
|
blocks = []
|
|
32
32
|
slots.each_with_index do |slot, idx|
|
|
33
|
-
y = first_y + (idx *
|
|
33
|
+
y = first_y + (idx * row_offset(context))
|
|
34
34
|
blocks << line_text(slot, bounds, y, context, fill)
|
|
35
35
|
end
|
|
36
36
|
if subtitle
|
|
37
|
-
y =
|
|
37
|
+
y = bounds.y + bounds.height + SUBTITLE_OFFSET_FROM_BOTTOM
|
|
38
38
|
blocks << subtitle_text(subtitle, bounds, y, context, fill)
|
|
39
39
|
end
|
|
40
40
|
wrap(blocks.join("\n"), fill)
|
|
@@ -88,9 +88,19 @@ module Ea
|
|
|
88
88
|
end
|
|
89
89
|
module_function :content_x_offset
|
|
90
90
|
|
|
91
|
+
def row_offset(context)
|
|
92
|
+
return ROW_OFFSET unless Ea::Fonts::Metrics.compact_compartment_font?(
|
|
93
|
+
context.size, family: context.family, unit: context.size_unit
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
Ea::Fonts::Metrics.compartment_line_height(
|
|
97
|
+
context.size, family: context.family, unit: context.size_unit
|
|
98
|
+
)
|
|
99
|
+
end
|
|
100
|
+
module_function :row_offset
|
|
101
|
+
|
|
91
102
|
def subtitle_text(text, bounds, y_pos, context, fill)
|
|
92
|
-
|
|
93
|
-
x_pos = bounds.x + (bounds.width - estimated_width) / 2.0
|
|
103
|
+
x_pos = (bounds.x + (bounds.width - subtitle_width(text, context)) / 2.0).floor
|
|
94
104
|
TextRenderer.new(
|
|
95
105
|
content: text,
|
|
96
106
|
x: x_pos,
|
|
@@ -102,6 +112,15 @@ module Ea
|
|
|
102
112
|
end
|
|
103
113
|
module_function :subtitle_text
|
|
104
114
|
|
|
115
|
+
def subtitle_width(text, context)
|
|
116
|
+
TextRenderer.estimate_width(
|
|
117
|
+
text, context.size, nil,
|
|
118
|
+
family: context.family, style: "italic",
|
|
119
|
+
logical_height: context.size_unit == "pt" ? context.logical_height : nil
|
|
120
|
+
).round
|
|
121
|
+
end
|
|
122
|
+
module_function :subtitle_width
|
|
123
|
+
|
|
105
124
|
def wrap(body, fill)
|
|
106
125
|
group_style = "stroke-width:1;stroke-linecap:round;" \
|
|
107
126
|
"stroke-linejoin:bevel; fill:#{fill};" \
|
|
@@ -8,6 +8,11 @@ module Ea
|
|
|
8
8
|
# Renders only when the host element is a Note with a
|
|
9
9
|
# non-empty body.
|
|
10
10
|
module NoteBody
|
|
11
|
+
# Native notes put the first baseline this far below the note
|
|
12
|
+
# top plus the font's GDI cell ascent (Arial 9pt/10pt +27,
|
|
13
|
+
# Cambria 7pt +24, Carlito 9pt +26).
|
|
14
|
+
TEXT_TOP = 14
|
|
15
|
+
|
|
11
16
|
module_function
|
|
12
17
|
|
|
13
18
|
def render(context)
|
|
@@ -22,17 +27,15 @@ module Ea
|
|
|
22
27
|
end
|
|
23
28
|
|
|
24
29
|
def wrapped_lines(context, body)
|
|
25
|
-
# Lines fit bounds.width - 4. Pitch
|
|
26
|
-
#
|
|
27
|
-
#
|
|
28
|
-
#
|
|
29
|
-
#
|
|
30
|
-
# paragraph 1).
|
|
30
|
+
# Lines fit bounds.width - 4. Pitch is the GDI cell height
|
|
31
|
+
# of the note font and the first baseline sits TEXT_TOP
|
|
32
|
+
# plus the cell ascent below the top; fonts without
|
|
33
|
+
# vertical data keep pitch size+7 and offset size+9. Each
|
|
34
|
+
# empty source line adds one blank pitch row.
|
|
31
35
|
usable = context.bounds.width - 4
|
|
32
36
|
size = context.size
|
|
33
37
|
family = context.family
|
|
34
|
-
pitch =
|
|
35
|
-
first_offset = size + 9
|
|
38
|
+
pitch, first_offset = vertical_layout(context)
|
|
36
39
|
y_index = 0
|
|
37
40
|
paragraphs = NoteHtml.paragraphs(body)
|
|
38
41
|
paragraphs.flat_map do |para|
|
|
@@ -52,28 +55,38 @@ module Ea
|
|
|
52
55
|
width_factor: context.theme.text_width_factor
|
|
53
56
|
).to_svg
|
|
54
57
|
end
|
|
55
|
-
y_index += lines.size +
|
|
58
|
+
y_index += lines.size + para.gap
|
|
56
59
|
paragraph_svg
|
|
57
60
|
end
|
|
58
61
|
end
|
|
59
62
|
|
|
60
|
-
#
|
|
61
|
-
|
|
62
|
-
|
|
63
|
+
# [pitch, first-baseline offset] for the note font.
|
|
64
|
+
def vertical_layout(context)
|
|
65
|
+
size = context.size
|
|
66
|
+
height = context.logical_height
|
|
67
|
+
height ||= Ea::Fonts::Metrics.gdi_logical_height(size) if context.size_unit == "pt"
|
|
68
|
+
ascent, descent = Ea::Fonts::Metrics.gdi_cell_metrics(context.family, height)
|
|
69
|
+
return [size + 7, size + 9] unless ascent
|
|
70
|
+
|
|
71
|
+
[ascent + descent, TEXT_TOP + ascent]
|
|
72
|
+
end
|
|
73
|
+
module_function :vertical_layout
|
|
74
|
+
|
|
75
|
+
# Break at whitespace and between a CJK character and its
|
|
76
|
+
# neighbours. Every atom but the paragraph's last carries a
|
|
77
|
+
# following space, when the source has one, into the fit test.
|
|
63
78
|
def wrap_paragraph(para, usable, size, family, logical_height: nil)
|
|
64
79
|
lines = []
|
|
65
80
|
current = ""
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
candidate = current.empty? ?
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
family: family, logical_height: logical_height).round
|
|
72
|
-
if current.empty? || width <= usable
|
|
81
|
+
atoms = break_atoms(para)
|
|
82
|
+
atoms.each_with_index do |atom, index|
|
|
83
|
+
candidate = current.empty? ? atom[:text] : "#{current}#{atom[:space]}#{atom[:text]}"
|
|
84
|
+
measured = "#{candidate}#{atoms.dig(index + 1, :space)}"
|
|
85
|
+
if current.empty? || fit_width(measured, size, family, logical_height) <= usable
|
|
73
86
|
current = candidate
|
|
74
87
|
else
|
|
75
88
|
lines << current
|
|
76
|
-
current =
|
|
89
|
+
current = atom[:text]
|
|
77
90
|
end
|
|
78
91
|
end
|
|
79
92
|
lines << current unless current.empty?
|
|
@@ -81,6 +94,29 @@ module Ea
|
|
|
81
94
|
end
|
|
82
95
|
module_function :wrap_paragraph
|
|
83
96
|
|
|
97
|
+
def fit_width(text, size, family, logical_height)
|
|
98
|
+
TextRenderer.estimate_width(text, size, nil, family: family, logical_height: logical_height).round
|
|
99
|
+
end
|
|
100
|
+
module_function :fit_width
|
|
101
|
+
|
|
102
|
+
# Unbreakable runs of text with the space that precedes each one.
|
|
103
|
+
def break_atoms(text)
|
|
104
|
+
words = text.split(/\s+/).reject(&:empty?)
|
|
105
|
+
words.each_with_index.flat_map { |word, index| word_atoms(word, index.positive? ? " " : "") }
|
|
106
|
+
end
|
|
107
|
+
module_function :break_atoms
|
|
108
|
+
|
|
109
|
+
def word_atoms(word, space)
|
|
110
|
+
runs = word.chars.slice_when { |a, b| cjk_char?(a) || cjk_char?(b) }.map(&:join)
|
|
111
|
+
runs.each_with_index.map { |run, i| { text: run, space: i.zero? ? space : "" } }
|
|
112
|
+
end
|
|
113
|
+
module_function :word_atoms
|
|
114
|
+
|
|
115
|
+
def cjk_char?(char)
|
|
116
|
+
Ea::Fonts::Metrics.cjk?(char.ord)
|
|
117
|
+
end
|
|
118
|
+
module_function :cjk_char?
|
|
119
|
+
|
|
84
120
|
def group_style(context)
|
|
85
121
|
"stroke-width:1;stroke-linecap:round;stroke-linejoin:bevel; " \
|
|
86
122
|
"fill:#{context.theme.text_color};fill-opacity:1.00; " \
|
|
@@ -4,13 +4,14 @@ module Ea
|
|
|
4
4
|
module Svg
|
|
5
5
|
module EaEmitter
|
|
6
6
|
module Compartment
|
|
7
|
-
# Splits a note body into paragraphs
|
|
8
|
-
#
|
|
9
|
-
#
|
|
10
|
-
#
|
|
11
|
-
#
|
|
7
|
+
# Splits a note body into paragraphs, one per non-empty source
|
|
8
|
+
# line. Each paragraph stacks directly under the previous one
|
|
9
|
+
# (`gap` 0); every empty source line after it adds one blank row.
|
|
10
|
+
# A body with HTML markup is shown by EA as plain text: tags are
|
|
11
|
+
# dropped, entities decoded, `<ul>`/`</ul>` lines take no row,
|
|
12
|
+
# each `<li>` becomes a "- " line and `<br>` ends a line.
|
|
12
13
|
module NoteHtml
|
|
13
|
-
Paragraph = Struct.new(:text, :
|
|
14
|
+
Paragraph = Struct.new(:text, :gap)
|
|
14
15
|
|
|
15
16
|
TAG = %r{</?(?:ul|ol|li|b|i|u|p|br|strong|em)\b[^>]*>}i
|
|
16
17
|
LINE_BREAK = /<br\b[^>]*>/i
|
|
@@ -22,37 +23,23 @@ module Ea
|
|
|
22
23
|
|
|
23
24
|
def paragraphs(body)
|
|
24
25
|
text = body.to_s
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
26
|
+
markup = text.match?(TAG)
|
|
27
|
+
lines = text.split(/\r?\n/)
|
|
28
|
+
lines.reject! { |raw| raw.strip.match?(LIST_WRAPPER) } if markup
|
|
29
|
+
lines.each_with_object([]) do |raw, paragraphs|
|
|
30
|
+
raw.strip.empty? ? paragraphs.last&.gap += 1 : paragraphs.concat(line_paragraphs(raw, markup))
|
|
30
31
|
end
|
|
31
32
|
end
|
|
32
33
|
|
|
33
|
-
def
|
|
34
|
-
|
|
35
|
-
end
|
|
36
|
-
|
|
37
|
-
def html_lines(text)
|
|
38
|
-
text.split(/\r?\n/).flat_map do |raw|
|
|
39
|
-
line = raw.strip
|
|
40
|
-
next [] if line.empty? || line.match?(LIST_WRAPPER)
|
|
41
|
-
|
|
42
|
-
item = line.match(LIST_ITEM)
|
|
43
|
-
item ? broken_lines(item[1], "- ") : broken_lines(line, "")
|
|
44
|
-
end
|
|
34
|
+
def line_paragraphs(raw, markup)
|
|
35
|
+
markup ? html_lines(raw.strip) : [Paragraph.new(raw, 0)]
|
|
45
36
|
end
|
|
46
37
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
# carries the list bullet.
|
|
51
|
-
def broken_lines(line, bullet)
|
|
52
|
-
segments = line.split(LINE_BREAK).map { |segment| clean(segment) }.reject(&:empty?)
|
|
38
|
+
def html_lines(line)
|
|
39
|
+
item = line.match(LIST_ITEM)
|
|
40
|
+
segments = (item ? item[1] : line).split(LINE_BREAK).map { |segment| clean(segment) }.reject(&:empty?)
|
|
53
41
|
segments.each_with_index.map do |segment, index|
|
|
54
|
-
|
|
55
|
-
[first ? "#{bullet}#{segment}" : segment, first && !bullet.empty?, index < segments.size - 1]
|
|
42
|
+
Paragraph.new(item && index.zero? ? "- #{segment}" : segment, 0)
|
|
56
43
|
end
|
|
57
44
|
end
|
|
58
45
|
|
|
@@ -19,6 +19,10 @@ module Ea
|
|
|
19
19
|
# self-loop, 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
|
+
|
|
23
|
+
tether = association_class_pairs(connector, bounds_map)
|
|
24
|
+
return tether if tether
|
|
25
|
+
|
|
22
26
|
points = waypoint_pairs(connector)
|
|
23
27
|
lateral = lateral_tree?(connector, points)
|
|
24
28
|
trunk = trunk_tree?(connector, points)
|
|
@@ -34,6 +38,9 @@ module Ea
|
|
|
34
38
|
target_bounds = bounds_map[target_ref]
|
|
35
39
|
return nil unless source_bounds && target_bounds
|
|
36
40
|
return nil if source_ref == target_ref
|
|
41
|
+
if flush_overlap_ray?(connector)
|
|
42
|
+
return clipped_pairs([source_bounds, target_bounds], [connector.source_anchor, connector.target_anchor])
|
|
43
|
+
end
|
|
37
44
|
return trunk_pairs(points, source_bounds, target_bounds, connector.style[:tree]) if trunk
|
|
38
45
|
|
|
39
46
|
square = OrthogonalRay.pairs_for(connector, source_bounds, target_bounds)
|
|
@@ -71,6 +78,42 @@ module Ea
|
|
|
71
78
|
auto_port_elbow_pairs(connector, source, target, pairs)
|
|
72
79
|
end
|
|
73
80
|
|
|
81
|
+
# A tether joins its saved association anchor to the facing
|
|
82
|
+
# side-center of the attached class, rather than to the other proxy.
|
|
83
|
+
def association_class_pairs(connector, bounds_map)
|
|
84
|
+
bounds = bounds_map[connector.style.to_h[:association_class_ref]]
|
|
85
|
+
anchor = connector.source_anchor
|
|
86
|
+
return nil unless bounds && anchor
|
|
87
|
+
|
|
88
|
+
[[anchor.x, anchor.y], association_class_dock(bounds, anchor)]
|
|
89
|
+
end
|
|
90
|
+
private_class_method :association_class_pairs
|
|
91
|
+
|
|
92
|
+
def association_class_dock(bounds, anchor)
|
|
93
|
+
center = association_class_center(bounds)
|
|
94
|
+
if horizontal_association_class?(bounds, anchor, center)
|
|
95
|
+
[association_class_edge_coordinate(bounds.x, bounds.width, anchor.x), center[1]]
|
|
96
|
+
else
|
|
97
|
+
[center[0], association_class_edge_coordinate(bounds.y, bounds.height, anchor.y)]
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
private_class_method :association_class_dock
|
|
101
|
+
|
|
102
|
+
def association_class_center(bounds)
|
|
103
|
+
[bounds.x + bounds.width / 2, bounds.y + bounds.height / 2]
|
|
104
|
+
end
|
|
105
|
+
private_class_method :association_class_center
|
|
106
|
+
|
|
107
|
+
def horizontal_association_class?(bounds, anchor, center)
|
|
108
|
+
(anchor.x - center[0]).abs * bounds.height > (anchor.y - center[1]).abs * bounds.width
|
|
109
|
+
end
|
|
110
|
+
private_class_method :horizontal_association_class?
|
|
111
|
+
|
|
112
|
+
def association_class_edge_coordinate(origin, size, anchor)
|
|
113
|
+
anchor > origin + size / 2 ? origin + size : origin
|
|
114
|
+
end
|
|
115
|
+
private_class_method :association_class_edge_coordinate
|
|
116
|
+
|
|
74
117
|
# Auto routing turns at the clipped target column when the source
|
|
75
118
|
# leaves a side edge and the target enters through its top or bottom.
|
|
76
119
|
def auto_port_elbow_pairs(connector, source, target, pairs)
|
|
@@ -240,9 +283,13 @@ module Ea
|
|
|
240
283
|
end
|
|
241
284
|
private_class_method :stroke_outline?
|
|
242
285
|
|
|
243
|
-
# The
|
|
244
|
-
|
|
245
|
-
|
|
286
|
+
# The builder certifies the empty flush route's shared row and nominal docks.
|
|
287
|
+
def flush_overlap_ray?(connector)
|
|
288
|
+
connector.connector_type == "Association" && connector.style.to_h[:flush_overlap_ray] == true
|
|
289
|
+
end
|
|
290
|
+
private_class_method :flush_overlap_ray?
|
|
291
|
+
|
|
292
|
+
# The QEA builder marks Mode 2 overlap rays for outer-stroke docking.
|
|
246
293
|
def overlap_ray?(connector)
|
|
247
294
|
connector.connector_type == "Association" && connector.style.to_h[:overlap_ray] == true
|
|
248
295
|
end
|
|
@@ -429,7 +476,9 @@ module Ea
|
|
|
429
476
|
bounds_map, divider_y_by_ref)
|
|
430
477
|
return nil unless bounds_map && divider_y_by_ref
|
|
431
478
|
return nil unless pairs.size == 2
|
|
479
|
+
return nil if flush_overlap_ray?(connector)
|
|
432
480
|
return nil if explicit_association_ray?(pairs, connector)
|
|
481
|
+
return nil if connector.style.to_h[:stored_origin_ray] == true
|
|
433
482
|
|
|
434
483
|
rel = relationship_for(connector, model_index)
|
|
435
484
|
return nil unless rel
|