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.
- checksums.yaml +4 -4
- data/config/themes/119.yml +3 -2
- data/lib/ea/fonts/cambria_7_styled_widths.json +293 -0
- data/lib/ea/fonts/metrics.rb +50 -7
- data/lib/ea/model/diagram_element.rb +4 -0
- data/lib/ea/sources/qea/diagram_builder/auto_routing.rb +124 -11
- data/lib/ea/sources/qea/diagram_builder/font_settings.rb +50 -0
- data/lib/ea/sources/qea/diagram_builder/routing.rb +82 -82
- data/lib/ea/sources/qea/diagram_builder.rb +9 -30
- data/lib/ea/sources/qea/instance_builder.rb +1 -0
- data/lib/ea/sources/qea/property_builder.rb +11 -1
- data/lib/ea/svg/ea_emitter/color_resolver.rb +1 -2
- data/lib/ea/svg/ea_emitter/compartment/attributes.rb +2 -1
- data/lib/ea/svg/ea_emitter/compartment/constraints.rb +5 -14
- data/lib/ea/svg/ea_emitter/compartment/enum_literals.rb +11 -5
- data/lib/ea/svg/ea_emitter/compartment/header.rb +2 -1
- data/lib/ea/svg/ea_emitter/compartment/note_body.rb +6 -4
- data/lib/ea/svg/ea_emitter/compartment/note_html.rb +66 -0
- data/lib/ea/svg/ea_emitter/compartment/operations.rb +2 -1
- data/lib/ea/svg/ea_emitter/compartment.rb +1 -0
- data/lib/ea/svg/ea_emitter/connector_redock.rb +30 -14
- data/lib/ea/svg/ea_emitter/element/attribute_line_builder.rb +11 -9
- data/lib/ea/svg/ea_emitter/element/attribute_renderer.rb +42 -17
- data/lib/ea/svg/ea_emitter/element/compartment_geometry.rb +46 -15
- data/lib/ea/svg/ea_emitter/element/constraint_renderer.rb +34 -2
- data/lib/ea/svg/ea_emitter/element/enumeration_literal_renderer.rb +8 -5
- data/lib/ea/svg/ea_emitter/element/header_line_pipeline.rb +3 -2
- data/lib/ea/svg/ea_emitter/element/header_line_provider/instance_spec.rb +10 -5
- data/lib/ea/svg/ea_emitter/element/header_line_provider/name.rb +2 -1
- data/lib/ea/svg/ea_emitter/element/header_renderer.rb +14 -12
- data/lib/ea/svg/ea_emitter/element/operation_renderer.rb +10 -7
- data/lib/ea/svg/ea_emitter/elements.rb +41 -33
- data/lib/ea/svg/ea_emitter/font_resolver.rb +14 -4
- data/lib/ea/svg/ea_emitter/ghost_labels.rb +3 -0
- data/lib/ea/svg/ea_emitter/label/end_label.rb +37 -4
- data/lib/ea/svg/ea_emitter/labels.rb +12 -4
- data/lib/ea/svg/ea_emitter/marker/diamond.rb +17 -23
- data/lib/ea/svg/ea_emitter/render_context.rb +1 -1
- data/lib/ea/svg/ea_emitter/text_renderer.rb +6 -5
- data/lib/ea/theme/definition.rb +5 -2
- data/lib/ea/theme/loader.rb +3 -2
- data/lib/ea/version.rb +1 -1
- data/memory/deviations.md +3 -0
- metadata +5 -1
|
@@ -115,9 +115,11 @@ module Ea
|
|
|
115
115
|
else
|
|
116
116
|
stored_ray_gap?(source, target, model.style[:mode], geom[:edge])
|
|
117
117
|
end
|
|
118
|
-
return model unless allowed
|
|
118
|
+
return model unless allowed && stored_association_path?(model, link_row, source, target, geom)
|
|
119
119
|
|
|
120
120
|
set_stored_ray_origins(model, source, target, geom)
|
|
121
|
+
model.style[:association_ray_outline] = true if model.connector_type == "Association"
|
|
122
|
+
model
|
|
121
123
|
end
|
|
122
124
|
|
|
123
125
|
# An empty-Path Mode 3 Association follows the line between its
|
|
@@ -125,6 +127,7 @@ module Ea
|
|
|
125
127
|
# The target origin must lie inside its own stroked outline.
|
|
126
128
|
def association_stored_ray(model, link_row, diagram_row, geom, context)
|
|
127
129
|
return model if context
|
|
130
|
+
return stored_diagonal_corner(model, link_row, diagram_row, geom) unless link_row.path.to_s.empty?
|
|
128
131
|
return model unless association_ray_candidate?(model, link_row)
|
|
129
132
|
|
|
130
133
|
source, target = stored_ray_boxes(link_row, diagram_row)
|
|
@@ -141,6 +144,47 @@ module Ea
|
|
|
141
144
|
model
|
|
142
145
|
end
|
|
143
146
|
|
|
147
|
+
# A single authored bend joins the stored mathematical origins.
|
|
148
|
+
# Clip each leg independently; a facing-edge shortcut loses the bend.
|
|
149
|
+
def stored_diagonal_corner(model, link_row, diagram_row, geom)
|
|
150
|
+
return model unless model.connector_type == "Association" && association_ray_style?(model.style.to_h) &&
|
|
151
|
+
model.style[:lwidth] == "2" && geom.values_at(:sx, :sy, :ex, :ey).all?
|
|
152
|
+
|
|
153
|
+
corners = intermediate_waypoints(link_row)
|
|
154
|
+
return model unless corners.size == 1
|
|
155
|
+
|
|
156
|
+
boxes = stored_ray_boxes(link_row, diagram_row)
|
|
157
|
+
origins = [stored_ray_origin(boxes[0], geom[:sx], geom[:sy]),
|
|
158
|
+
stored_ray_origin(boxes[1], geom[:ex], geom[:ey])]
|
|
159
|
+
bend = Ea::Model::Point.new(x: corners[0][0], y: corners[0][1])
|
|
160
|
+
left, right = boxes.sort_by(&:x)
|
|
161
|
+
return model unless bend.x.between?(left.x + left.width + 1, right.x - 1) &&
|
|
162
|
+
boxes.all? { |box| bend.y.between?(box.y, box.y + box.height) }
|
|
163
|
+
|
|
164
|
+
outlines = boxes.map { |box| grown_bounds(box, 1) }
|
|
165
|
+
return model unless origins.zip(outlines).all? { |origin, box| inside_box?(origin, box) }
|
|
166
|
+
return model if outlines.any? { |box| inside_box?(bend, box) }
|
|
167
|
+
return model unless origins.all? { |origin| origin.x != bend.x && origin.y != bend.y }
|
|
168
|
+
|
|
169
|
+
ends = origins.zip(outlines).map { |origin, box| diagonal_corner_exit(origin, bend, box) }
|
|
170
|
+
return model unless ends.all?
|
|
171
|
+
|
|
172
|
+
model.waypoints = [ends[0], bend, ends[1]].map { |point| Ea::Model::Waypoint.new(position: point) }
|
|
173
|
+
model.source_anchor, model.target_anchor = origins
|
|
174
|
+
model
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def diagonal_corner_exit(origin, bend, box)
|
|
178
|
+
dx = bend.x - origin.x
|
|
179
|
+
dy = bend.y - origin.y
|
|
180
|
+
times = [(dx.positive? ? box.x + box.width - origin.x : box.x - origin.x).to_f / dx,
|
|
181
|
+
(dy.positive? ? box.y + box.height - origin.y : box.y - origin.y).to_f / dy]
|
|
182
|
+
time = times.select { |value| value.between?(0, 1) }.min
|
|
183
|
+
return unless time
|
|
184
|
+
|
|
185
|
+
Ea::Model::Point.new(x: (origin.x + time * dx).floor, y: (origin.y + time * dy).floor)
|
|
186
|
+
end
|
|
187
|
+
|
|
144
188
|
def port_waypoints(model, origins)
|
|
145
189
|
[model.source_port || origins.first, model.target_port || origins.last].map do |point|
|
|
146
190
|
Ea::Model::Waypoint.new(position: point)
|
|
@@ -198,7 +242,15 @@ module Ea
|
|
|
198
242
|
def association_ray_candidate?(model, link_row)
|
|
199
243
|
model.connector_type == "Association" && link_row.path.to_s.empty? &&
|
|
200
244
|
model.source_anchor && model.target_anchor &&
|
|
201
|
-
(model.source_port || model.target_port
|
|
245
|
+
(model.source_port || model.target_port || aligned_default_origins?(model)) &&
|
|
246
|
+
association_ray_style?(model.style.to_h)
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
# Zero offsets omit ports, but an explicitly stored axis-aligned
|
|
250
|
+
# width-2 ray still uses the logical centers and stroked outlines.
|
|
251
|
+
def aligned_default_origins?(model)
|
|
252
|
+
model.has_geometry_offsets && model.style[:lwidth] == "2" &&
|
|
253
|
+
(model.source_anchor.x == model.target_anchor.x || model.source_anchor.y == model.target_anchor.y)
|
|
202
254
|
end
|
|
203
255
|
|
|
204
256
|
# A thin connector still meets the outer stroke of explicitly
|
|
@@ -238,73 +290,6 @@ module Ea
|
|
|
238
290
|
point.y.between?(bounds.y, bounds.y + bounds.height)
|
|
239
291
|
end
|
|
240
292
|
|
|
241
|
-
# A Mode 2 Association with equal SX/EX (vertical EDGE 1/3) or
|
|
242
|
-
# equal SY/EY (horizontal EDGE 4) and no custom Path docks as a
|
|
243
|
-
# straight line through the middle of the boxes' overlap, moved
|
|
244
|
-
# by the shared stored offset. Stored SY counts upward.
|
|
245
|
-
def overlap_ray_origins(model, link_row, diagram_row, geom, context)
|
|
246
|
-
return model if context
|
|
247
|
-
return model unless overlap_ray_candidate?(model, link_row, geom)
|
|
248
|
-
|
|
249
|
-
source, target = stored_ray_boxes(link_row, diagram_row)
|
|
250
|
-
origins = overlap_origins(source, target, geom)
|
|
251
|
-
return model unless origins
|
|
252
|
-
|
|
253
|
-
model.source_anchor, model.target_anchor = origins.map do |ox, oy|
|
|
254
|
-
Ea::Model::Point.new(x: ox, y: oy)
|
|
255
|
-
end
|
|
256
|
-
model.style[:overlap_ray] = true
|
|
257
|
-
model
|
|
258
|
-
end
|
|
259
|
-
|
|
260
|
-
# Both ray origins share the overlap coordinate on the axis the
|
|
261
|
-
# offsets move and sit at each box's centre on the other axis.
|
|
262
|
-
def overlap_origins(source, target, geom)
|
|
263
|
-
along, across, offset = overlap_axes(geom)
|
|
264
|
-
coordinate = overlap_ray_coordinate(*box_span(source, along), *box_span(target, along), offset)
|
|
265
|
-
return nil unless coordinate && disjoint_span?(*box_span(source, across), *box_span(target, across))
|
|
266
|
-
|
|
267
|
-
[source, target].map do |box|
|
|
268
|
-
start, size = box_span(box, across)
|
|
269
|
-
centre = start + size / 2.0
|
|
270
|
-
geom[:edge] == 4 ? [centre, coordinate] : [coordinate, centre]
|
|
271
|
-
end
|
|
272
|
-
end
|
|
273
|
-
|
|
274
|
-
# [axis the offsets move, axis the boxes face, stored offset]
|
|
275
|
-
def overlap_axes(geom)
|
|
276
|
-
return [%i[y height], %i[x width], -geom[:sy]] if geom[:edge] == 4
|
|
277
|
-
|
|
278
|
-
[%i[x width], %i[y height], geom[:sx]]
|
|
279
|
-
end
|
|
280
|
-
|
|
281
|
-
def box_span(box, keys)
|
|
282
|
-
keys.map { |key| box.public_send(key) }
|
|
283
|
-
end
|
|
284
|
-
|
|
285
|
-
def overlap_ray_candidate?(model, link_row, geom)
|
|
286
|
-
model.connector_type == "Association" && model.style[:mode] == "2" &&
|
|
287
|
-
model.waypoints.size == 2 && link_row.path.to_s.empty? &&
|
|
288
|
-
model.has_geometry_offsets && uniform_overlap_offsets?(geom)
|
|
289
|
-
end
|
|
290
|
-
|
|
291
|
-
def uniform_overlap_offsets?(geom)
|
|
292
|
-
return false unless geom.values_at(:sx, :sy, :ex, :ey).all?
|
|
293
|
-
|
|
294
|
-
geom[:edge] == 4 ? geom[:sy] == geom[:ey] : [1, 3].include?(geom[:edge]) && geom[:sx] == geom[:ex]
|
|
295
|
-
end
|
|
296
|
-
|
|
297
|
-
# Floor of the overlap midpoint plus the shared offset; nil
|
|
298
|
-
# when the boxes do not overlap on this axis.
|
|
299
|
-
def overlap_ray_coordinate(start_a, size_a, start_b, size_b, offset)
|
|
300
|
-
low = [start_a, start_b].max
|
|
301
|
-
high = [start_a + size_a, start_b + size_b].min
|
|
302
|
-
return nil if low > high
|
|
303
|
-
|
|
304
|
-
coordinate = ((low + high) / 2.0 + offset).floor
|
|
305
|
-
coordinate if coordinate.between?(low, high)
|
|
306
|
-
end
|
|
307
|
-
|
|
308
293
|
def disjoint_span?(start_a, size_a, start_b, size_b)
|
|
309
294
|
start_a + size_a < start_b || start_b + size_b < start_a
|
|
310
295
|
end
|
|
@@ -322,11 +307,17 @@ module Ea
|
|
|
322
307
|
end
|
|
323
308
|
|
|
324
309
|
def stored_ray_candidate?(model, link_row, geom)
|
|
325
|
-
|
|
310
|
+
stored_ray_type?(model) &&
|
|
326
311
|
model.waypoints.size == 2 && stored_ray_style?(model.style, link_row) &&
|
|
327
312
|
stored_ray_geometry?(model, geom)
|
|
328
313
|
end
|
|
329
314
|
|
|
315
|
+
def stored_ray_type?(model)
|
|
316
|
+
%w[Generalization Aggregation].include?(model.connector_type) ||
|
|
317
|
+
(model.connector_type == "Association" && model.style[:mode] == "1" &&
|
|
318
|
+
model.style[:lwidth] == "2")
|
|
319
|
+
end
|
|
320
|
+
|
|
330
321
|
# Boundary-port rounding can move a computed jog off a level saved
|
|
331
322
|
# ray. Equal stored origin rows specify the line before clipping.
|
|
332
323
|
def normalized_level_ray?(model, source, target, geom)
|
|
@@ -408,6 +399,7 @@ module Ea
|
|
|
408
399
|
when "Association"
|
|
409
400
|
return unless style[:lwidth] == "0"
|
|
410
401
|
|
|
402
|
+
redock_child_below(points, bounds_from_rect(source_placement), bounds_from_rect(target_placement))
|
|
411
403
|
redock_parent_above(points, link_row, source_placement, target_placement, strict: true)
|
|
412
404
|
end
|
|
413
405
|
end
|
|
@@ -517,15 +509,18 @@ module Ea
|
|
|
517
509
|
[bounds.y, bounds.y + bounds.height].include?(point[1])
|
|
518
510
|
end
|
|
519
511
|
|
|
520
|
-
# A width-2 Mode 3
|
|
521
|
-
#
|
|
512
|
+
# A width-2 Mode 3 route without a tree style keeps authored orthogonal
|
|
513
|
+
# bends: two corners for Associations, three or more for Aggregations.
|
|
514
|
+
# Each end docks on
|
|
522
515
|
# the 1px outline its adjacent leg points at, from the corner's own
|
|
523
516
|
# coordinate; any end that cannot do so leaves the route unchanged.
|
|
524
517
|
def stored_stair_route(link_row, connector, source, target)
|
|
525
518
|
return nil unless stair_style?(link_row, connector)
|
|
526
519
|
|
|
527
520
|
corners = stored_square_corners(link_row)
|
|
528
|
-
|
|
521
|
+
unless corners && stair_corner_count?(corners, connector.connector_type) && alternating_legs?(corners)
|
|
522
|
+
return nil
|
|
523
|
+
end
|
|
529
524
|
|
|
530
525
|
first = outline_dock(corners[0], corners[1], source)
|
|
531
526
|
last = outline_dock(corners[-1], corners[-2], target)
|
|
@@ -534,10 +529,14 @@ module Ea
|
|
|
534
529
|
|
|
535
530
|
def stair_style?(link_row, connector)
|
|
536
531
|
style = DiagramStyleParser.parse(link_row.style)
|
|
537
|
-
connector.connector_type
|
|
532
|
+
%w[Aggregation Association].include?(connector.connector_type) && style[:mode] == "3" &&
|
|
538
533
|
style[:lwidth] == "2" && !style.key?(:tree)
|
|
539
534
|
end
|
|
540
535
|
|
|
536
|
+
def stair_corner_count?(corners, connector_type)
|
|
537
|
+
connector_type == "Association" ? corners.size == 2 : corners.size >= 3
|
|
538
|
+
end
|
|
539
|
+
|
|
541
540
|
# Every leg is non-zero and runs along the opposite axis of the last.
|
|
542
541
|
def alternating_legs?(corners)
|
|
543
542
|
axes = corners.each_cons(2).map { |from, to| leg_axis(from, to) }
|
|
@@ -595,6 +594,8 @@ module Ea
|
|
|
595
594
|
# flush on its box edge.
|
|
596
595
|
def derived_lateral_route(link_row, connector, source, target)
|
|
597
596
|
style = DiagramStyleParser.parse(link_row.style)
|
|
597
|
+
return derived_vertical_lateral_route(style, link_row, connector, source, target) if style[:tree] == "LV"
|
|
598
|
+
|
|
598
599
|
return nil unless style[:mode] == "3" && style[:tree] == "LH" &&
|
|
599
600
|
derived_lateral_path?(connector.connector_type, link_row.path.to_s)
|
|
600
601
|
|
|
@@ -726,7 +727,7 @@ module Ea
|
|
|
726
727
|
# Keep a vertical tree trunk whose bends still fit both boxes
|
|
727
728
|
# and the open gap; the closest facing edges need not share its x.
|
|
728
729
|
def preserve_stored_tree?(points, link_row, connector, src, tgt)
|
|
729
|
-
return false unless points.size == 4 && stored_tree_type?(points, link_row, connector)
|
|
730
|
+
return false unless points.size == 4 && stored_tree_type?(points, link_row, connector, src, tgt)
|
|
730
731
|
|
|
731
732
|
style = DiagramStyleParser.parse(link_row.style)
|
|
732
733
|
return false unless style[:lwidth] == "2" || flush_width_zero_tree?(link_row)
|
|
@@ -897,14 +898,13 @@ module Ea
|
|
|
897
898
|
style[:tree] == "V" || (style[:mode] == "3" && style[:tree].nil?)
|
|
898
899
|
end
|
|
899
900
|
|
|
900
|
-
def stored_tree_type?(points, link_row, connector)
|
|
901
|
-
connector.connector_type == "Aggregation"
|
|
902
|
-
|
|
901
|
+
def stored_tree_type?(points, link_row, connector, source, target)
|
|
902
|
+
return true if connector.connector_type == "Aggregation"
|
|
903
|
+
return false unless connector.connector_type == "Association" && flush_width_zero_tree?(link_row)
|
|
903
904
|
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
flush_width_zero_tree?(link_row)
|
|
905
|
+
# Distinct thin bends are supported when the source sits above
|
|
906
|
+
# the target; the established reverse case keeps straight trunks.
|
|
907
|
+
points[1] == points[-2] || source.y + source.height < target.y
|
|
908
908
|
end
|
|
909
909
|
|
|
910
910
|
# A flush trunk may also hang below its parent; a bend repeated at
|
|
@@ -13,11 +13,13 @@ module Ea
|
|
|
13
13
|
autoload :Routing, "ea/sources/qea/diagram_builder/routing"
|
|
14
14
|
autoload :AutoRouting, "ea/sources/qea/diagram_builder/auto_routing"
|
|
15
15
|
autoload :StoredSquarePorts, "ea/sources/qea/diagram_builder/stored_square_ports"
|
|
16
|
+
autoload :FontSettings, "ea/sources/qea/diagram_builder/font_settings"
|
|
16
17
|
include Routing
|
|
17
18
|
include AutoRouting
|
|
18
19
|
include StoredSquarePorts
|
|
20
|
+
include FontSettings
|
|
19
21
|
private_constant :Routing
|
|
20
|
-
private_constant :AutoRouting, :StoredSquarePorts
|
|
22
|
+
private_constant :AutoRouting, :StoredSquarePorts, :FontSettings
|
|
21
23
|
|
|
22
24
|
attr_reader :database
|
|
23
25
|
|
|
@@ -123,6 +125,8 @@ module Ea
|
|
|
123
125
|
def build_element(obj_row, diagram_row)
|
|
124
126
|
@current_obj_row = obj_row
|
|
125
127
|
style_hash = DiagramStyleParser.parse(obj_row.objectstyle)
|
|
128
|
+
style_hash[:stored_attribute_order] = true
|
|
129
|
+
font_style = effective_font_style(obj_row, style_hash)
|
|
126
130
|
Ea::Model::DiagramElement.new(
|
|
127
131
|
id: IdNormalizer.synthetic("de", diagram_row.diagram_id,
|
|
128
132
|
obj_row.instance_id),
|
|
@@ -130,8 +134,10 @@ module Ea
|
|
|
130
134
|
model_element_ref: ref_for_object(obj_row),
|
|
131
135
|
bounds: bounds_from_rect(obj_row),
|
|
132
136
|
style: style_hash,
|
|
133
|
-
font_family: presence(
|
|
134
|
-
font_size: font_size_from(
|
|
137
|
+
font_family: presence(font_style[:font]),
|
|
138
|
+
font_size: font_size_from(font_style),
|
|
139
|
+
font_size_unit: "pt",
|
|
140
|
+
font_logical_height: font_logical_height_from(font_style),
|
|
135
141
|
font_bold: truthy?(style_hash[:bold]),
|
|
136
142
|
font_italic: truthy?(style_hash[:italic]),
|
|
137
143
|
font_underline: truthy?(style_hash[:ul]),
|
|
@@ -144,33 +150,6 @@ module Ea
|
|
|
144
150
|
raw == "1" || raw == "-1"
|
|
145
151
|
end
|
|
146
152
|
|
|
147
|
-
def presence(value)
|
|
148
|
-
value.nil? || value.to_s.empty? ? nil : value
|
|
149
|
-
end
|
|
150
|
-
|
|
151
|
-
# EA stores the element font size in tenths of a point and
|
|
152
|
-
# renders it through GDI's double rounding: pixel height =
|
|
153
|
-
# floor(tenths * 96 / 720), then point size =
|
|
154
|
-
# floor(pixels * 72 / 96). 70 -> 9px -> 6pt (F3660305 Text),
|
|
155
|
-
# 80 -> 10px -> 7pt, 90 -> 12px -> 9pt (4EBDE645's PI_*
|
|
156
|
-
# classes), 100 -> 13px -> 9pt (0D31AED1's all-100 elements
|
|
157
|
-
# publish 321 texts at 9pt), 120 -> 16px -> 12pt
|
|
158
|
-
# (412BC89E's Boundary title), 140 -> 18px -> 13pt.
|
|
159
|
-
# fontsz=0 ("use default", written back by the font dialog)
|
|
160
|
-
# renders at the 7pt theme size.
|
|
161
|
-
USE_DEFAULT_FONTSZ = [0].freeze
|
|
162
|
-
GDI_DPI = 96
|
|
163
|
-
|
|
164
|
-
def font_size_from(style_hash)
|
|
165
|
-
raw = style_hash[:fontsz]
|
|
166
|
-
return nil if raw.nil? || raw.to_s.empty?
|
|
167
|
-
|
|
168
|
-
return nil if USE_DEFAULT_FONTSZ.include?(raw.to_i)
|
|
169
|
-
|
|
170
|
-
pixels = (raw.to_f * GDI_DPI / 720).floor
|
|
171
|
-
(pixels * 72.0 / GDI_DPI).floor
|
|
172
|
-
end
|
|
173
|
-
|
|
174
153
|
def build_connectors(diagram_row)
|
|
175
154
|
links = database.diagram_links_for(diagram_row.diagram_id) || []
|
|
176
155
|
context = ContextDiagramSupport.context?(diagram_row)
|
|
@@ -34,6 +34,7 @@ module Ea
|
|
|
34
34
|
Ea::Model::InstanceSpecification.new(
|
|
35
35
|
id: IdNormalizer.from_guid(row.ea_guid),
|
|
36
36
|
name: row.name.to_s,
|
|
37
|
+
role_name: row.alias,
|
|
37
38
|
classifier_id: classifier_id_for(row),
|
|
38
39
|
classifier_name: classifier_name_for(row),
|
|
39
40
|
stereotype: row.stereotype.to_s,
|
|
@@ -16,7 +16,8 @@ module Ea
|
|
|
16
16
|
|
|
17
17
|
def build_all_for(owner_object)
|
|
18
18
|
attrs = database.attributes_for_object(owner_object.ea_object_id) || []
|
|
19
|
-
attrs.
|
|
19
|
+
attrs.each_with_index.sort_by { |row, index| attribute_sort_key(row, index) }
|
|
20
|
+
.map { |row, _index| build_one(row, owner_object) }
|
|
20
21
|
end
|
|
21
22
|
|
|
22
23
|
def build_one(row, owner_object)
|
|
@@ -48,6 +49,15 @@ module Ea
|
|
|
48
49
|
|
|
49
50
|
private
|
|
50
51
|
|
|
52
|
+
# Equal explicit Pos values use name order in EA's published diagrams.
|
|
53
|
+
# Missing positions retain source order before the explicit zero cohort.
|
|
54
|
+
def attribute_sort_key(row, index)
|
|
55
|
+
return [row.sort_position, 0, "", "", index] if row.pos.nil?
|
|
56
|
+
|
|
57
|
+
name = row.name.to_s
|
|
58
|
+
[row.sort_position, 1, name.downcase, name, index]
|
|
59
|
+
end
|
|
60
|
+
|
|
51
61
|
# EA stores some bounds as literal text ("n"); those render
|
|
52
62
|
# verbatim in attribute lines while computed-unbounded bounds
|
|
53
63
|
# render as "*" (verified against EA-published SVGs).
|
|
@@ -7,11 +7,10 @@ module Ea
|
|
|
7
7
|
#
|
|
8
8
|
# Theme :119 applies:
|
|
9
9
|
# - Border color (#9A8484) for element strokes
|
|
10
|
-
# - Attribute color (#66413F) for attribute text
|
|
11
10
|
# - Method color for operation text
|
|
12
11
|
# Theme :119 does NOT apply:
|
|
13
12
|
# - Per-type fill colors (elements use default #FFFFFF)
|
|
14
|
-
# -
|
|
13
|
+
# - Attribute text color (inherits the black text color)
|
|
15
14
|
#
|
|
16
15
|
# Precedence chain for fill:
|
|
17
16
|
# 1. Element's BCol int (when present and not sentinel)
|
|
@@ -16,7 +16,8 @@ module Ea
|
|
|
16
16
|
context.attr_lines,
|
|
17
17
|
bounds: context.bounds,
|
|
18
18
|
first_y: context.geometry.attr_first_y,
|
|
19
|
-
family: context.family, size: context.size,
|
|
19
|
+
family: context.family, size: context.size,
|
|
20
|
+
size_unit: context.size_unit, logical_height: context.logical_height,
|
|
20
21
|
fill: context.theme.attribute_text_color,
|
|
21
22
|
visibility_x_offset: context.theme.attribute_spec.visibility_x_offset,
|
|
22
23
|
content_x_offset: context.theme.attribute_spec.content_x_offset
|
|
@@ -11,26 +11,17 @@ module Ea
|
|
|
11
11
|
# content and suppresses them when they would overflow the
|
|
12
12
|
# stored box height (never truncates rows).
|
|
13
13
|
module Constraints
|
|
14
|
-
DEFAULT_TEXT_COLOR = "#000000"
|
|
15
|
-
|
|
16
14
|
module_function
|
|
17
15
|
|
|
18
16
|
def render(context)
|
|
19
17
|
return nil unless context.constraints&.any?
|
|
20
|
-
return nil unless context.geometry.
|
|
21
|
-
|
|
22
|
-
first_y = context.geometry.tagged_value_first_y
|
|
23
|
-
line_h = context.size + 6
|
|
24
|
-
last_y = first_y + (context.constraints.size - 1) * line_h
|
|
25
|
-
return nil if last_y + 4 > context.bounds.y + context.bounds.height
|
|
18
|
+
return nil unless context.geometry.constraints_first_y
|
|
26
19
|
|
|
27
|
-
Element::
|
|
28
|
-
context.
|
|
29
|
-
|
|
30
|
-
first_y: first_y,
|
|
31
|
-
family: context.family, size: context.size,
|
|
32
|
-
fill: DEFAULT_TEXT_COLOR
|
|
20
|
+
divider = Element::DividerRenderer.render(
|
|
21
|
+
context.bounds, y: context.geometry.constraints_divider_y,
|
|
22
|
+
stroke: context.stroke, stroke_width: context.stroke_width
|
|
33
23
|
)
|
|
24
|
+
"#{divider}\n#{Element::ConstraintRenderer.render_compartment(context)}"
|
|
34
25
|
end
|
|
35
26
|
end
|
|
36
27
|
end
|
|
@@ -23,7 +23,10 @@ module Ea
|
|
|
23
23
|
end
|
|
24
24
|
|
|
25
25
|
def render_literals_block(context)
|
|
26
|
-
line_h =
|
|
26
|
+
line_h = Ea::Fonts::Metrics.compartment_line_height(
|
|
27
|
+
context.size, family: context.family, unit: context.size_unit, logical_height: context.logical_height,
|
|
28
|
+
offset: LITERAL_LINE_OFFSET
|
|
29
|
+
)
|
|
27
30
|
text_blocks = []
|
|
28
31
|
row_y = context.geometry.enum_literal_first_y
|
|
29
32
|
# EA renders a centered italic "literals" caption above
|
|
@@ -69,12 +72,13 @@ module Ea
|
|
|
69
72
|
def caption_text(context, y)
|
|
70
73
|
content = "literals"
|
|
71
74
|
len = TextRenderer.estimate_width(content, context.size, nil,
|
|
72
|
-
family: context.family).round
|
|
75
|
+
family: context.family, logical_height: context.logical_height).round
|
|
73
76
|
x = (context.bounds.x + (context.bounds.width - len) / 2.0).floor
|
|
74
77
|
TextRenderer.new(
|
|
75
78
|
content: content,
|
|
76
79
|
x: x, y: y,
|
|
77
|
-
family: context.family, size: context.size,
|
|
80
|
+
family: context.family, size: context.size,
|
|
81
|
+
size_unit: context.size_unit, logical_height: context.logical_height,
|
|
78
82
|
fill: context.theme.text_color,
|
|
79
83
|
stroke_in_text: context.theme.stroke_in_text_color,
|
|
80
84
|
width_factor: context.theme.text_width_factor,
|
|
@@ -87,7 +91,8 @@ module Ea
|
|
|
87
91
|
content: " ",
|
|
88
92
|
x: context.bounds.x + (context.theme.attribute_spec.visibility_x_offset || 5),
|
|
89
93
|
y: y,
|
|
90
|
-
family: context.family, size: context.size,
|
|
94
|
+
family: context.family, size: context.size,
|
|
95
|
+
size_unit: context.size_unit, logical_height: context.logical_height,
|
|
91
96
|
fill: context.theme.text_color,
|
|
92
97
|
stroke_in_text: context.theme.stroke_in_text_color,
|
|
93
98
|
width_factor: context.theme.text_width_factor
|
|
@@ -100,7 +105,8 @@ module Ea
|
|
|
100
105
|
content: content,
|
|
101
106
|
x: context.bounds.x + (context.theme.attribute_spec.content_x_offset || 26),
|
|
102
107
|
y: y,
|
|
103
|
-
family: context.family, size: context.size,
|
|
108
|
+
family: context.family, size: context.size,
|
|
109
|
+
size_unit: context.size_unit, logical_height: context.logical_height,
|
|
104
110
|
fill: context.theme.text_color,
|
|
105
111
|
stroke_in_text: context.theme.stroke_in_text_color,
|
|
106
112
|
width_factor: context.theme.text_width_factor
|
|
@@ -17,7 +17,8 @@ module Ea
|
|
|
17
17
|
context.header_lines,
|
|
18
18
|
bounds: context.bounds,
|
|
19
19
|
first_y: context.geometry.header_first_y,
|
|
20
|
-
family: context.family, size: context.size,
|
|
20
|
+
family: context.family, size: context.size,
|
|
21
|
+
size_unit: context.size_unit, logical_height: context.logical_height,
|
|
21
22
|
fill: context.theme.text_color,
|
|
22
23
|
weight_normal: context.theme.text_weight_normal,
|
|
23
24
|
weight_bold: context.theme.text_weight_bold,
|
|
@@ -39,10 +39,10 @@ module Ea
|
|
|
39
39
|
pitch = size + 7
|
|
40
40
|
first_offset = size + 9
|
|
41
41
|
y_index = 0
|
|
42
|
-
paragraphs =
|
|
42
|
+
paragraphs = NoteHtml.paragraphs(body)
|
|
43
43
|
paragraphs.flat_map do |para|
|
|
44
|
-
lines = wrap_paragraph(para, usable, size, context.family)
|
|
45
|
-
lines.each_with_index.map do |line, i|
|
|
44
|
+
lines = wrap_paragraph(para.text, usable, size, context.family)
|
|
45
|
+
paragraph_svg = lines.each_with_index.map do |line, i|
|
|
46
46
|
y = context.bounds.y + first_offset + ((y_index + i) * pitch)
|
|
47
47
|
TextRenderer.new(
|
|
48
48
|
content: line,
|
|
@@ -54,7 +54,9 @@ module Ea
|
|
|
54
54
|
stroke_in_text: context.theme.stroke_in_text_color,
|
|
55
55
|
width_factor: context.theme.text_width_factor
|
|
56
56
|
).to_svg
|
|
57
|
-
end
|
|
57
|
+
end
|
|
58
|
+
y_index += lines.size + (para.tight ? 0 : 1)
|
|
59
|
+
paragraph_svg
|
|
58
60
|
end
|
|
59
61
|
end
|
|
60
62
|
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ea
|
|
4
|
+
module Svg
|
|
5
|
+
module EaEmitter
|
|
6
|
+
module Compartment
|
|
7
|
+
# Splits a note body into paragraphs. A body with HTML markup is
|
|
8
|
+
# shown by EA as plain text: tags are dropped, entities decoded,
|
|
9
|
+
# `<ul>`/`</ul>` lines take no row, and each `<li>` becomes a
|
|
10
|
+
# "- " line stacked directly under the previous text or item
|
|
11
|
+
# (`tight`) instead of leaving the blank row between paragraphs.
|
|
12
|
+
module NoteHtml
|
|
13
|
+
Paragraph = Struct.new(:text, :tight)
|
|
14
|
+
|
|
15
|
+
TAG = %r{</?(?:ul|ol|li|b|i|u|p|br|strong|em)\b[^>]*>}i
|
|
16
|
+
LINE_BREAK = /<br\b[^>]*>/i
|
|
17
|
+
LIST_WRAPPER = %r{\A</?(?:ul|ol)>\z}i
|
|
18
|
+
LIST_ITEM = %r{\A<li>(.*)</li>\z}i
|
|
19
|
+
ENTITIES = { "<" => "<", ">" => ">", "&" => "&", """ => '"' }.freeze
|
|
20
|
+
|
|
21
|
+
module_function
|
|
22
|
+
|
|
23
|
+
def paragraphs(body)
|
|
24
|
+
text = body.to_s
|
|
25
|
+
return plain(text) unless text.match?(TAG)
|
|
26
|
+
|
|
27
|
+
entries = html_lines(text)
|
|
28
|
+
entries.each_with_index.map do |(line, _item, broken), index|
|
|
29
|
+
Paragraph.new(line, broken || entries[index + 1]&.at(1) || false)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def plain(text)
|
|
34
|
+
text.split(/\n/).reject { |line| line.strip.empty? }.map { |line| Paragraph.new(line, false) }
|
|
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
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Entries are [text, starts_item, broken]. `<br>` ends a line
|
|
48
|
+
# without the blank row a new paragraph gets, so every segment
|
|
49
|
+
# but the last is `broken` (tight). Only the first segment
|
|
50
|
+
# carries the list bullet.
|
|
51
|
+
def broken_lines(line, bullet)
|
|
52
|
+
segments = line.split(LINE_BREAK).map { |segment| clean(segment) }.reject(&:empty?)
|
|
53
|
+
segments.each_with_index.map do |segment, index|
|
|
54
|
+
first = index.zero?
|
|
55
|
+
[first ? "#{bullet}#{segment}" : segment, first && !bullet.empty?, index < segments.size - 1]
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def clean(line)
|
|
60
|
+
line.gsub(TAG, "").gsub(/&(?:lt|gt|amp|quot);/, ENTITIES).squeeze(" ").strip
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
@@ -27,7 +27,8 @@ module Ea
|
|
|
27
27
|
context.classifier.operations,
|
|
28
28
|
bounds: context.bounds,
|
|
29
29
|
first_y: context.geometry.op_first_y,
|
|
30
|
-
family: context.family, size: context.size
|
|
30
|
+
family: context.family, size: context.size,
|
|
31
|
+
size_unit: context.size_unit, logical_height: context.logical_height
|
|
31
32
|
)
|
|
32
33
|
parts.join("\n")
|
|
33
34
|
end
|
|
@@ -12,6 +12,7 @@ module Ea
|
|
|
12
12
|
module Compartment
|
|
13
13
|
autoload :Shape, "ea/svg/ea_emitter/compartment/shape"
|
|
14
14
|
autoload :NoteBody, "ea/svg/ea_emitter/compartment/note_body"
|
|
15
|
+
autoload :NoteHtml, "ea/svg/ea_emitter/compartment/note_html"
|
|
15
16
|
autoload :Header, "ea/svg/ea_emitter/compartment/header"
|
|
16
17
|
autoload :HeaderDivider, "ea/svg/ea_emitter/compartment/header_divider"
|
|
17
18
|
autoload :StereotypeIcon, "ea/svg/ea_emitter/compartment/stereotype_icon"
|