ea 0.6.138 → 0.6.140
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/metrics.rb +22 -2
- data/lib/ea/model/note.rb +2 -0
- data/lib/ea/sources/qea/diagram_builder/routing.rb +140 -2
- data/lib/ea/sources/qea/diagram_builder.rb +10 -0
- data/lib/ea/sources/qea/note_builder.rb +7 -1
- data/lib/ea/sources/xmi/diagram_builder/element_borders.rb +27 -0
- data/lib/ea/sources/xmi/diagram_builder.rb +40 -11
- data/lib/ea/sources/xmi/note_builder.rb +2 -1
- data/lib/ea/sources/xmi/stored_route/reversed_routes.rb +94 -0
- data/lib/ea/sources/xmi/stored_route.rb +121 -8
- data/lib/ea/svg/ea_emitter/bottom_margin.rb +100 -0
- data/lib/ea/svg/ea_emitter/bounds_calculator.rb +3 -1
- data/lib/ea/svg/ea_emitter/canvas.rb +37 -9
- data/lib/ea/svg/ea_emitter/canvas_anchor.rb +124 -0
- data/lib/ea/svg/ea_emitter/compartment/note_body.rb +5 -1
- data/lib/ea/svg/ea_emitter/compartment/package_from_parent.rb +23 -4
- data/lib/ea/svg/ea_emitter/compartment/shape.rb +17 -5
- data/lib/ea/svg/ea_emitter/connector_bridges.rb +113 -41
- data/lib/ea/svg/ea_emitter/connectors.rb +67 -17
- data/lib/ea/svg/ea_emitter/content_extent.rb +95 -0
- data/lib/ea/svg/ea_emitter/document.rb +56 -27
- data/lib/ea/svg/ea_emitter/element/legend_renderer.rb +14 -6
- data/lib/ea/svg/ea_emitter/element/note_shape_renderer.rb +64 -32
- data/lib/ea/svg/ea_emitter/elements.rb +21 -4
- data/lib/ea/svg/ea_emitter/label/end_label.rb +64 -6
- data/lib/ea/svg/ea_emitter/labels.rb +6 -0
- data/lib/ea/svg/ea_emitter/layer_sequencer.rb +40 -26
- data/lib/ea/svg/ea_emitter/markers.rb +22 -10
- data/lib/ea/svg/ea_emitter/paint_order.rb +29 -0
- data/lib/ea/svg/ea_emitter/render_context.rb +14 -3
- data/lib/ea/svg/ea_emitter/segment_geometry.rb +123 -0
- data/lib/ea/svg/ea_emitter.rb +6 -0
- data/lib/ea/version.rb +1 -1
- metadata +8 -1
|
@@ -9,6 +9,9 @@ module Ea
|
|
|
9
9
|
# boxes). Returns nil where QEA would fall back to bare SX/SY deltas,
|
|
10
10
|
# which the generic router keeps handling.
|
|
11
11
|
class StoredRoute < Qea::DiagramBuilder
|
|
12
|
+
autoload :ReversedRoutes, "ea/sources/xmi/stored_route/reversed_routes"
|
|
13
|
+
include ReversedRoutes
|
|
14
|
+
|
|
12
15
|
Link = Struct.new(:style, :path, :geometry)
|
|
13
16
|
Connector = Struct.new(:connector_type)
|
|
14
17
|
|
|
@@ -26,9 +29,15 @@ module Ea
|
|
|
26
29
|
finish(points, link, connector, source, target, bends, ports, offsets, edge)
|
|
27
30
|
end
|
|
28
31
|
|
|
29
|
-
#
|
|
30
|
-
# well as their computed boundary ports, just as the QEA builder does.
|
|
32
|
+
# Saved rays retain origins and computed boundary ports, matching QEA.
|
|
31
33
|
def ray_metadata(model, link:, bounds:, offsets:, edge:)
|
|
34
|
+
if model.connector_type == "Generalization"
|
|
35
|
+
overlap = mode2_overlap_metadata(model, link, bounds, offsets, edge)
|
|
36
|
+
return overlap unless overlap.empty?
|
|
37
|
+
|
|
38
|
+
return generalization_ray_metadata(model, link, bounds, offsets, edge)
|
|
39
|
+
end
|
|
40
|
+
|
|
32
41
|
return {} unless direct_association_ray?(model, bounds, offsets)
|
|
33
42
|
|
|
34
43
|
source, target = bounds.values_at(:source, :target)
|
|
@@ -46,11 +55,10 @@ module Ea
|
|
|
46
55
|
# UMLDI fallback. A single stored corner and an empty-path stored ray
|
|
47
56
|
# carry mathematical origins that the generic facing-edge route loses.
|
|
48
57
|
def association_route(model, link:, bounds:, offsets:, edge:)
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
return partial unless partial.empty?
|
|
58
|
+
ROUTE_STAGES.each do |stage|
|
|
59
|
+
found = method(stage).call(model, link, bounds, offsets, edge)
|
|
60
|
+
return found unless found.empty?
|
|
61
|
+
end
|
|
54
62
|
|
|
55
63
|
return {} unless stored_association_candidate?(model, bounds)
|
|
56
64
|
|
|
@@ -64,6 +72,69 @@ module Ea
|
|
|
64
72
|
|
|
65
73
|
private
|
|
66
74
|
|
|
75
|
+
ROUTE_STAGES = %i[umldi_association_metadata shared_overlap_metadata
|
|
76
|
+
source_row_metadata auto_port_elbow_metadata reversed_tree_metadata
|
|
77
|
+
reversed_corner_metadata reversed_lateral_tree_metadata partial_port_metadata].freeze
|
|
78
|
+
|
|
79
|
+
# Overlap rules need both end boxes and every end offset.
|
|
80
|
+
def ray_inputs?(bounds, offsets)
|
|
81
|
+
bounds.values.all? && offsets.values.flatten.none?(&:nil?)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# A Mode 2 Generalization whose stored Path is a horizontal jog is EA's
|
|
85
|
+
# auto-route: QEA replaces the stored bends with one vertical ray, and
|
|
86
|
+
# the same QEA rule decides it here.
|
|
87
|
+
def mode2_overlap_metadata(model, link, bounds, offsets, edge)
|
|
88
|
+
return {} if link.path.to_s.empty? || !ray_inputs?(bounds, offsets)
|
|
89
|
+
|
|
90
|
+
candidate = association_model(model, bounds, offsets)
|
|
91
|
+
initial = candidate.waypoints
|
|
92
|
+
mode2_overlap_ray(candidate, link, bounds, ray_geometry(offsets, edge), false)
|
|
93
|
+
return {} if candidate.waypoints.equal?(initial)
|
|
94
|
+
|
|
95
|
+
{ waypoints: candidate.waypoints, source_anchor: candidate.source_anchor,
|
|
96
|
+
target_anchor: candidate.target_anchor, has_geometry_offsets: true }
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# A stored (non-UMLDI) Association through the shared overlap row takes
|
|
100
|
+
# QEA's overlap origins and flush ports, decided by the same QEA rule.
|
|
101
|
+
def shared_overlap_metadata(model, link, bounds, offsets, edge)
|
|
102
|
+
return {} if model.style[:umldi_route] || !ray_inputs?(bounds, offsets)
|
|
103
|
+
|
|
104
|
+
candidate = association_model(model, bounds, offsets)
|
|
105
|
+
geom = ray_geometry(offsets, edge)
|
|
106
|
+
association_stored_ray(candidate, link, bounds, geom, false) if association_ray_style?(model.style.to_h)
|
|
107
|
+
overlap_ray_origins(candidate, link, bounds, geom, false)
|
|
108
|
+
return {} unless candidate.style[:overlap_ray]
|
|
109
|
+
|
|
110
|
+
association_metadata(candidate)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# A Mode 2 Association with unequal end offsets and no (or a stale) Path
|
|
114
|
+
# rides the source row, as QEA's source_row_association_ray decides.
|
|
115
|
+
def source_row_metadata(model, link, bounds, offsets, edge)
|
|
116
|
+
return {} if model.style[:umldi_route] || !ray_inputs?(bounds, offsets)
|
|
117
|
+
|
|
118
|
+
candidate = association_model(model, bounds, offsets)
|
|
119
|
+
source_row_association_ray(candidate, link, bounds, ray_geometry(offsets, edge), false)
|
|
120
|
+
return {} unless candidate.style[:overlap_ray]
|
|
121
|
+
|
|
122
|
+
association_metadata(candidate)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# A Mode 2 Association with uniform offsets, EDGE 4 and no Path keeps its
|
|
126
|
+
# physical ports and takes QEA's elbow, even when UMLDI saved a straight
|
|
127
|
+
# route (30D62231, 934F4502).
|
|
128
|
+
def auto_port_elbow_metadata(model, link, bounds, offsets, edge)
|
|
129
|
+
return {} unless ray_inputs?(bounds, offsets)
|
|
130
|
+
|
|
131
|
+
candidate = association_model(model, bounds, offsets)
|
|
132
|
+
auto_port_elbow(candidate, link, bounds, ray_geometry(offsets, edge), false)
|
|
133
|
+
return {} unless candidate.style[:auto_port_elbow]
|
|
134
|
+
|
|
135
|
+
association_metadata(candidate)
|
|
136
|
+
end
|
|
137
|
+
|
|
67
138
|
def umldi_association_metadata(model, link, bounds, offsets, edge)
|
|
68
139
|
overlap = umldi_overlap_metadata(model, link, bounds, offsets, edge)
|
|
69
140
|
return overlap unless overlap.empty?
|
|
@@ -96,7 +167,9 @@ module Ea
|
|
|
96
167
|
return {} unless umldi_overlap_candidate?(model, bounds, offsets)
|
|
97
168
|
|
|
98
169
|
candidate = association_model(model, bounds, offsets)
|
|
99
|
-
|
|
170
|
+
geom = ray_geometry(offsets, edge)
|
|
171
|
+
association_stored_ray(candidate, link, bounds, geom, false) if association_ray_style?(model.style.to_h)
|
|
172
|
+
overlap_ray_origins(candidate, link, bounds, geom, false)
|
|
100
173
|
return {} unless candidate.style[:overlap_ray]
|
|
101
174
|
|
|
102
175
|
{ source_anchor: candidate.source_anchor, target_anchor: candidate.target_anchor,
|
|
@@ -159,6 +232,13 @@ module Ea
|
|
|
159
232
|
y: box.y + box.height / 2 + offset[1].to_i)
|
|
160
233
|
end
|
|
161
234
|
|
|
235
|
+
# XMI has no QEA database from which to infer a wider element outline,
|
|
236
|
+
# so the saved connector width stands. A border-aware variant changed no
|
|
237
|
+
# connector in the PLATEAU census.
|
|
238
|
+
def association_outline_width(model, _link, _bounds)
|
|
239
|
+
model.style[:lwidth]
|
|
240
|
+
end
|
|
241
|
+
|
|
162
242
|
def stored_ray_boxes(_link, bounds)
|
|
163
243
|
bounds.values_at(:source, :target)
|
|
164
244
|
end
|
|
@@ -169,6 +249,39 @@ module Ea
|
|
|
169
249
|
bounds.values.all? && offsets.values.flatten.none?(&:nil?)
|
|
170
250
|
end
|
|
171
251
|
|
|
252
|
+
# A direct Generalization clips its saved mathematical origins rather
|
|
253
|
+
# than treating boundary ports as the origins of the final drawn ray.
|
|
254
|
+
def generalization_ray_metadata(model, link, bounds, offsets, edge)
|
|
255
|
+
geom = ray_geometry(offsets, edge)
|
|
256
|
+
return {} unless generalization_ray_candidate?(model, link, bounds, offsets, geom)
|
|
257
|
+
|
|
258
|
+
source, target = bounds.values_at(:source, :target)
|
|
259
|
+
return {} unless stored_ray_gap?(source, target, model.style[:mode], edge)
|
|
260
|
+
|
|
261
|
+
points = ray_points(source, target, offsets, geom)
|
|
262
|
+
return {} if points.empty?
|
|
263
|
+
|
|
264
|
+
points.merge(style: generalization_ray_style(model, points, source, target))
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
def generalization_ray_candidate?(model, link, bounds, offsets, geom)
|
|
268
|
+
model.waypoints.size == 2 && stored_ray_style?(model.style, link) &&
|
|
269
|
+
bounds.values.all? && offsets.values.flatten.none?(&:nil?) &&
|
|
270
|
+
(!geom[:sy].zero? || !geom[:ey].zero?)
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
def generalization_ray_style(model, points, source, target)
|
|
274
|
+
candidate = Ea::Model::DiagramConnector.new(
|
|
275
|
+
connector_type: model.connector_type, style: model.style.to_h.dup,
|
|
276
|
+
source_anchor: points[:source_anchor], target_anchor: points[:target_anchor]
|
|
277
|
+
)
|
|
278
|
+
return model.style.to_h unless centered_origin_ray?(candidate, source, target)
|
|
279
|
+
|
|
280
|
+
flags = { centered_origin_ray: true }
|
|
281
|
+
flags[:stored_origin_ray] = true if stored_origin_ray?(candidate, source, target)
|
|
282
|
+
model.style.to_h.merge(flags)
|
|
283
|
+
end
|
|
284
|
+
|
|
172
285
|
def ray_geometry(offsets, edge)
|
|
173
286
|
{ sx: offsets[:source][0], sy: offsets[:source][1],
|
|
174
287
|
ex: offsets[:target][0], ey: offsets[:target][1], edge: edge }
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ea
|
|
4
|
+
module Svg
|
|
5
|
+
module EaEmitter
|
|
6
|
+
# Space EA leaves below the lowest element of an unframed export.
|
|
7
|
+
#
|
|
8
|
+
# EMPIRICAL FIT (no stored selector was found; measured on the 187
|
|
9
|
+
# PLATEAU QEA diagrams whose native SVG is available, 172 match):
|
|
10
|
+
# below the lowest element the canvas keeps 21 + its font size in
|
|
11
|
+
# points (the objectstyle fontsz is in tenths of a point, 0 meaning
|
|
12
|
+
# the 7pt default): 31 for fontsz 100, 30 for 90, 28 for the
|
|
13
|
+
# default. A plain Note, and a Package that draws no "(from
|
|
14
|
+
# Parent)" subtitle, keep the bare 10 margin, as does a classifier
|
|
15
|
+
# of the diagram's own package. Misses: 2 outliers.
|
|
16
|
+
class BottomMargin
|
|
17
|
+
BARE = 10
|
|
18
|
+
LINE_ALLOWANCE = 21
|
|
19
|
+
HOME_TEXT_ALLOWANCE = 13
|
|
20
|
+
DEFAULT_POINTS = 7
|
|
21
|
+
|
|
22
|
+
def self.for(diagram, model_index)
|
|
23
|
+
lowest = lowest_elements(diagram)
|
|
24
|
+
return CanvasAnchor::BOTTOM_MARGIN if lowest.empty?
|
|
25
|
+
|
|
26
|
+
lowest.map { |element| of(element, diagram, model_index) }.max
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def self.lowest_elements(diagram)
|
|
30
|
+
placed = (diagram.elements || []).select { |e| e.bounds || e.image_bounds }
|
|
31
|
+
bottom = placed.map { |e| edge(e) }.max
|
|
32
|
+
placed.select { |e| edge(e) == bottom }
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def self.edge(element)
|
|
36
|
+
bounds = element.bounds || element.image_bounds
|
|
37
|
+
bounds.y + bounds.height
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def self.of(element, diagram, model_index)
|
|
41
|
+
model = model_index && model_index[element.model_element_ref]
|
|
42
|
+
return home_text_margin(element) if home_text?(model, diagram)
|
|
43
|
+
return BARE if plain_note?(model) || bare_package?(model, diagram) ||
|
|
44
|
+
home_classifier?(model, diagram)
|
|
45
|
+
|
|
46
|
+
LINE_ALLOWANCE + points(element)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# QEA keeps the raw objectstyle fontsz; XMI keeps only the GDI
|
|
50
|
+
# pixel height it was rendered at (tenths * 96 / 720, floored).
|
|
51
|
+
def self.points(element)
|
|
52
|
+
tenths = element.style && element.style[:fontsz].to_i
|
|
53
|
+
return tenths / 10 if tenths.to_i.positive?
|
|
54
|
+
|
|
55
|
+
pixels = element.font_logical_height.to_i.abs
|
|
56
|
+
pixels.positive? ? (pixels * 0.75).round : DEFAULT_POINTS
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def self.plain_note?(model)
|
|
60
|
+
model.is_a?(Ea::Model::Note) && model.note_type != "Text"
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# A classifier that lives in the diagram's own package draws no
|
|
64
|
+
# "(from Package)" row under its name (nor an owner-class
|
|
65
|
+
# qualifier), so EA keeps only the bare margin below it. Checked
|
|
66
|
+
# on the 7 PLATEAU diagrams whose lowest class is in the
|
|
67
|
+
# diagram's package (all natively 10) against the 84 that are
|
|
68
|
+
# foreign (all 28..31).
|
|
69
|
+
def self.home_classifier?(model, diagram)
|
|
70
|
+
model.is_a?(Ea::Model::Classifier) &&
|
|
71
|
+
!diagram.package_id.to_s.empty? &&
|
|
72
|
+
model.package_id == diagram.package_id &&
|
|
73
|
+
model.nested_parent_name.to_s.empty?
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# A Text element of the diagram's own package keeps 13 + its
|
|
77
|
+
# font size in points (22 at 9pt) instead of the foreign 21 + size.
|
|
78
|
+
# EMPIRICAL FIT on a single sample (PLATEAU 2E98EA19, fontsz 90,
|
|
79
|
+
# natively 22); the 8 foreign Text cases (28..30) are unaffected.
|
|
80
|
+
def self.home_text?(model, diagram)
|
|
81
|
+
model.is_a?(Ea::Model::Note) && model.note_type == "Text" &&
|
|
82
|
+
!diagram.package_id.to_s.empty? &&
|
|
83
|
+
model.package_id == diagram.package_id
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def self.home_text_margin(element)
|
|
87
|
+
HOME_TEXT_ALLOWANCE + points(element)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def self.bare_package?(model, diagram)
|
|
91
|
+
return false unless model.is_a?(Ea::Model::Package)
|
|
92
|
+
|
|
93
|
+
parent = model.parent_id
|
|
94
|
+
!(diagram.show_foreign && !parent.to_s.empty? &&
|
|
95
|
+
parent != diagram.package_id)
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
@@ -38,7 +38,7 @@ module Ea
|
|
|
38
38
|
@model_index = model_index
|
|
39
39
|
end
|
|
40
40
|
|
|
41
|
-
attr_reader :effective_inset_left
|
|
41
|
+
attr_reader :effective_inset_left, :max_x, :max_y
|
|
42
42
|
|
|
43
43
|
def compute
|
|
44
44
|
points = element_points
|
|
@@ -48,6 +48,8 @@ module Ea
|
|
|
48
48
|
ys = points.map(&:last)
|
|
49
49
|
min_x = xs.min || 0
|
|
50
50
|
min_y = ys.min || 0
|
|
51
|
+
@max_x = xs.max
|
|
52
|
+
@max_y = ys.max
|
|
51
53
|
# Left inset scales from 30 up to the 35 cap with the
|
|
52
54
|
# stored leftmost x: leftmost drawn x = 30 + min_x capped
|
|
53
55
|
# at 35 (corpus: min_x 0 -> 30, 2 -> 32, 4 -> 34, >=5 -> 35).
|
|
@@ -24,25 +24,45 @@ module Ea
|
|
|
24
24
|
INSET_TOUCHING_ORIGIN = 30
|
|
25
25
|
FRAME_INSET_TOP = 40
|
|
26
26
|
|
|
27
|
-
attr_reader :min_x, :min_y, :width, :height, :inset_left
|
|
27
|
+
attr_reader :min_x, :min_y, :width, :height, :inset_left, :inset_top
|
|
28
28
|
|
|
29
|
-
|
|
29
|
+
# `insets` is [left, top]: where model (min_x, min_y) lands on
|
|
30
|
+
# the canvas.
|
|
31
|
+
def initialize(min_x:, min_y:, width:, height:,
|
|
32
|
+
insets: [FRAME_INSET_LEFT, FRAME_INSET_TOP])
|
|
30
33
|
@min_x = min_x
|
|
31
34
|
@min_y = min_y
|
|
32
35
|
@width = width
|
|
33
36
|
@height = height
|
|
34
|
-
@inset_left =
|
|
37
|
+
@inset_left, @inset_top = insets
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Model-space origin of this canvas.
|
|
41
|
+
def origin_x
|
|
42
|
+
min_x - inset_left
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def origin_y
|
|
46
|
+
min_y - inset_top
|
|
35
47
|
end
|
|
36
48
|
|
|
37
49
|
# Build a Canvas for a diagram by running the bound
|
|
38
50
|
# calculator. Defaults model_index to nil (no canvas-aware
|
|
39
51
|
# element bounds needed) for callers that only need a
|
|
40
52
|
# frame-size estimate.
|
|
41
|
-
def self.from(diagram, model_index: nil)
|
|
53
|
+
def self.from(diagram, model_index: nil, content_anchor: false)
|
|
42
54
|
bc = BoundsCalculator.new(diagram, model_index: model_index)
|
|
43
55
|
min_x, min_y, width, height = bc.compute
|
|
56
|
+
if content_anchor
|
|
57
|
+
return CanvasAnchor.blank unless bc.max_x
|
|
58
|
+
|
|
59
|
+
return CanvasAnchor.stored(min_x, min_y, bc.max_x, bc.max_y,
|
|
60
|
+
bottom: BottomMargin.for(diagram, model_index))
|
|
61
|
+
end
|
|
62
|
+
|
|
44
63
|
new(min_x: min_x, min_y: min_y, width: width, height: height,
|
|
45
|
-
|
|
64
|
+
insets: [bc.effective_inset_left || FRAME_INSET_LEFT,
|
|
65
|
+
FRAME_INSET_TOP])
|
|
46
66
|
end
|
|
47
67
|
|
|
48
68
|
# Second-pass canvas over the DRAWN extents captured by the
|
|
@@ -51,7 +71,8 @@ module Ea
|
|
|
51
71
|
# canvas from drawn extents, so diagrams whose lowest/rightmost
|
|
52
72
|
# element grew get a taller/wider canvas than the stored-rect
|
|
53
73
|
# first pass provides.
|
|
54
|
-
def self.from_drawn(diagram, drawn_bounds, model_index: nil
|
|
74
|
+
def self.from_drawn(diagram, drawn_bounds, model_index: nil,
|
|
75
|
+
content_anchor: false)
|
|
55
76
|
base = BoundsCalculator.new(diagram, model_index: model_index)
|
|
56
77
|
pts = []
|
|
57
78
|
drawn_bounds.each_value do |b|
|
|
@@ -63,7 +84,14 @@ module Ea
|
|
|
63
84
|
min_y = (pts + extra).map(&:last).min || 0
|
|
64
85
|
max_x = (pts + extra).map(&:first).max || 0
|
|
65
86
|
max_y = (pts + extra).map(&:last).max || 0
|
|
66
|
-
|
|
87
|
+
if pts.empty?
|
|
88
|
+
return from(diagram, model_index: model_index,
|
|
89
|
+
content_anchor: content_anchor)
|
|
90
|
+
end
|
|
91
|
+
if content_anchor
|
|
92
|
+
return CanvasAnchor.stored(min_x, min_y, max_x, max_y,
|
|
93
|
+
bottom: BottomMargin.for(diagram, model_index))
|
|
94
|
+
end
|
|
67
95
|
|
|
68
96
|
# Same scaled rule as BoundsCalculator: drawn leftmost =
|
|
69
97
|
# min(35, 30 + min_x).
|
|
@@ -74,7 +102,7 @@ module Ea
|
|
|
74
102
|
BoundsCalculator::INSET_RIGHT,
|
|
75
103
|
height: (max_y - min_y) + BoundsCalculator::INSET_TOP +
|
|
76
104
|
BoundsCalculator::INSET_BOTTOM,
|
|
77
|
-
|
|
105
|
+
insets: [inset_left, FRAME_INSET_TOP])
|
|
78
106
|
end
|
|
79
107
|
|
|
80
108
|
def view_box
|
|
@@ -97,7 +125,7 @@ module Ea
|
|
|
97
125
|
end
|
|
98
126
|
|
|
99
127
|
def translate_y(y)
|
|
100
|
-
y - min_y +
|
|
128
|
+
y - min_y + inset_top
|
|
101
129
|
end
|
|
102
130
|
|
|
103
131
|
# Compact coordinate formatter. Integers lose the decimal
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ea
|
|
4
|
+
module Svg
|
|
5
|
+
module EaEmitter
|
|
6
|
+
# Canvas policy of an UNFRAMED export (EA's "Save Diagram Image"
|
|
7
|
+
# without the automatic frame: the PLATEAU native SVGs). Measured
|
|
8
|
+
# on the 220 native SVGs, where it reproduces the origin of 205 of
|
|
9
|
+
# 215 diagrams from the native content alone (the other 10 sit at
|
|
10
|
+
# the floor below):
|
|
11
|
+
#
|
|
12
|
+
# origin = max(min(shape_min - 10, text_left_or_top - 5), -5)
|
|
13
|
+
#
|
|
14
|
+
# in model coordinates, per axis. Shapes are rect/path/polygon/
|
|
15
|
+
# ellipse points; a text box runs from its left edge and from
|
|
16
|
+
# baseline minus the rounded font height. The right margin is 10
|
|
17
|
+
# from the shapes.
|
|
18
|
+
#
|
|
19
|
+
# EMPIRICAL FITS (no saved selector was found in any QEA table):
|
|
20
|
+
# BOTTOM_MARGIN 31 is only the fallback; BottomMargin picks the
|
|
21
|
+
# margin from the lowest element. The right edge also reaches
|
|
22
|
+
# every connector end-label box (its position plus the stored
|
|
23
|
+
# CX width) + LABEL_BOX_MARGIN 5, exact on the 7 PLATEAU diagrams
|
|
24
|
+
# whose labels overhang the shapes (30D62231, 47E4600F,
|
|
25
|
+
# 4881CEEE, 70C77F92, 846DD800, 88AF6BD7, AC2191E3). Below, the
|
|
26
|
+
# same box (its top is the first baseline less the font height,
|
|
27
|
+
# its height the stored CY) + 5 holds the canvas open on 95246DCF.
|
|
28
|
+
module CanvasAnchor
|
|
29
|
+
CONTENT_MARGIN = 10
|
|
30
|
+
TEXT_MARGIN = 5
|
|
31
|
+
ORIGIN_FLOOR = -5
|
|
32
|
+
BOTTOM_MARGIN = 31
|
|
33
|
+
LABEL_BOX_MARGIN = 5
|
|
34
|
+
# EMPIRICAL FIT: the one PLATEAU diagram with nothing on it
|
|
35
|
+
# (29DD0967) exports a 210 x 110 white canvas.
|
|
36
|
+
BLANK_SIZE = [210, 110].freeze
|
|
37
|
+
|
|
38
|
+
module_function
|
|
39
|
+
|
|
40
|
+
# Where content starting at `content_min` lands on the canvas.
|
|
41
|
+
def inset(content_min)
|
|
42
|
+
content_min - floored(content_min - CONTENT_MARGIN)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Canvas of a diagram that places no element.
|
|
46
|
+
def blank
|
|
47
|
+
Canvas.new(min_x: 0, min_y: 0, width: BLANK_SIZE[0],
|
|
48
|
+
height: BLANK_SIZE[1], insets: [0, 0])
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Canvas for shapes spanning the given model-space extent (the
|
|
52
|
+
# stored element rects, before any drawn content is measured).
|
|
53
|
+
def stored(min_x, min_y, max_x, max_y, bottom: BOTTOM_MARGIN)
|
|
54
|
+
left = inset(min_x)
|
|
55
|
+
top = inset(min_y)
|
|
56
|
+
Canvas.new(min_x: min_x, min_y: min_y,
|
|
57
|
+
width: (max_x - min_x) + left + CONTENT_MARGIN,
|
|
58
|
+
height: (max_y - min_y) + top + bottom,
|
|
59
|
+
insets: [left, top])
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Canvas for measured content, each box [min_x, min_y, max_x,
|
|
63
|
+
# max_y] in model coordinates (nil when absent). `label_reach` is
|
|
64
|
+
# the [right, bottom] edge of the connector end-label boxes.
|
|
65
|
+
def measured(shapes, texts, label_reach, bottom: BOTTOM_MARGIN)
|
|
66
|
+
origin_x = origin(shapes && shapes[0], texts && texts[0])
|
|
67
|
+
origin_y = origin(shapes && shapes[1], texts && texts[1])
|
|
68
|
+
Canvas.new(min_x: origin_x, min_y: origin_y,
|
|
69
|
+
width: right_edge(shapes, label_reach) - origin_x,
|
|
70
|
+
height: bottom_edge(shapes, label_reach, bottom) - origin_y,
|
|
71
|
+
insets: [0, 0])
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def right_edge(shapes, label_reach)
|
|
75
|
+
[shapes && shapes[2] + CONTENT_MARGIN,
|
|
76
|
+
label_reach && label_reach[0] + LABEL_BOX_MARGIN].compact.max
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def bottom_edge(shapes, label_reach, bottom)
|
|
80
|
+
[shapes[3] + bottom,
|
|
81
|
+
label_reach && label_reach[1] + LABEL_BOX_MARGIN].compact.max
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def origin(shape_min, text_min)
|
|
85
|
+
lead = [shape_min && shape_min - CONTENT_MARGIN,
|
|
86
|
+
text_min && text_min - TEXT_MARGIN].compact.min
|
|
87
|
+
floored(lead)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# The floor encodes only "a shape at 0 draws at 5" (the corpus
|
|
91
|
+
# has no shape left of 0). Content starting further out keeps
|
|
92
|
+
# its full margin so nothing is drawn off the canvas.
|
|
93
|
+
def floored(lead)
|
|
94
|
+
lead >= ORIGIN_FLOOR - CONTENT_MARGIN ? [lead, ORIGIN_FLOOR].max : lead
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# The canvas anchored on the content drawn into `layers` (the
|
|
98
|
+
# background excluded) and `label_reach`, or `canvas` itself
|
|
99
|
+
# when there is nothing to measure.
|
|
100
|
+
def reanchor(canvas, layers, label_reach, bottom: BOTTOM_MARGIN)
|
|
101
|
+
extent = ContentExtent.measure(layers)
|
|
102
|
+
return canvas unless extent.shapes
|
|
103
|
+
|
|
104
|
+
shift = [canvas.origin_x, canvas.origin_y]
|
|
105
|
+
measured(*[extent.shapes, extent.texts]
|
|
106
|
+
.map { |box| model_box(box, shift) }, label_reach,
|
|
107
|
+
bottom: bottom)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def model_box(box, shift)
|
|
111
|
+
return nil unless box
|
|
112
|
+
|
|
113
|
+
[box[0] + shift[0], box[1] + shift[1],
|
|
114
|
+
box[2] + shift[0], box[3] + shift[1]]
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def same_canvas?(one, other)
|
|
118
|
+
[one.origin_x, one.origin_y, one.width, one.height] ==
|
|
119
|
+
[other.origin_x, other.origin_y, other.width, other.height]
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
@@ -117,8 +117,12 @@ module Ea
|
|
|
117
117
|
end
|
|
118
118
|
module_function :cjk_char?
|
|
119
119
|
|
|
120
|
+
# The text group keeps the pen the note outline was drawn with
|
|
121
|
+
# (EA never sets a note pen; 7C44AC09 square/miter, 7D960BD8
|
|
122
|
+
# square/bevel), so the next element inherits the right pen.
|
|
120
123
|
def group_style(context)
|
|
121
|
-
|
|
124
|
+
cap, join = context.previous_pen || Element::NoteShapeRenderer::INITIAL_PEN
|
|
125
|
+
"stroke-width:1;stroke-linecap:#{cap};stroke-linejoin:#{join}; " \
|
|
122
126
|
"fill:#{context.theme.text_color};fill-opacity:1.00; " \
|
|
123
127
|
"stroke:#000000; stroke-opacity:0.00"
|
|
124
128
|
end
|
|
@@ -37,9 +37,18 @@ module Ea
|
|
|
37
37
|
|
|
38
38
|
module_function :render
|
|
39
39
|
|
|
40
|
-
# EA draws the subtitle
|
|
41
|
-
#
|
|
42
|
-
|
|
40
|
+
# EA draws the subtitle BELOW the body bottom (outside the
|
|
41
|
+
# box): GAP_BELOW_BODY plus the GDI text ascent of the caption
|
|
42
|
+
# font. Cambria 7pt (H10, ascent 10) gives the 15px seen in
|
|
43
|
+
# every Cambria native package SVG (14E9F5FD, 157826AA,
|
|
44
|
+
# 2A5E0832, 6481E2CC); Carlito 9pt (H13, ascent 12) gives the
|
|
45
|
+
# 17px of 37AC19B9. The 5px gap is fitted on the Cambria cases,
|
|
46
|
+
# the Carlito case is the held-out check. Fonts or units with
|
|
47
|
+
# no GDI cell data keep the legacy 15px.
|
|
48
|
+
# Only families with native evidence use the ascent offset.
|
|
49
|
+
ASCENT_FAMILIES = %w[cambria carlito].freeze
|
|
50
|
+
GAP_BELOW_BODY = 5
|
|
51
|
+
LEGACY_Y_OFFSET_FROM_BOTTOM = 15
|
|
43
52
|
|
|
44
53
|
LINE_PITCH = 13
|
|
45
54
|
WRAP_PADDING = 15
|
|
@@ -47,7 +56,7 @@ module Ea
|
|
|
47
56
|
def subtitle(text, context)
|
|
48
57
|
bounds = context.bounds
|
|
49
58
|
fill = context.theme.attribute_text_color
|
|
50
|
-
y = bounds.y + bounds.height +
|
|
59
|
+
y = bounds.y + bounds.height + offset_from_bottom(context)
|
|
51
60
|
# EA word-wraps the subtitle to the package BODY width
|
|
52
61
|
# (minus padding), centers each line by its integer
|
|
53
62
|
# textLength inside the body, and floors x; successive
|
|
@@ -72,6 +81,16 @@ module Ea
|
|
|
72
81
|
end
|
|
73
82
|
module_function :subtitle
|
|
74
83
|
|
|
84
|
+
def offset_from_bottom(context)
|
|
85
|
+
height = context.logical_height
|
|
86
|
+
height ||= Ea::Fonts::Metrics.gdi_logical_height(context.size) if context.size_unit == "pt"
|
|
87
|
+
return LEGACY_Y_OFFSET_FROM_BOTTOM unless ASCENT_FAMILIES.include?(context.family.to_s.downcase)
|
|
88
|
+
|
|
89
|
+
ascent, = Ea::Fonts::Metrics.gdi_cell_metrics(context.family, height)
|
|
90
|
+
ascent ? GAP_BELOW_BODY + ascent : LEGACY_Y_OFFSET_FROM_BOTTOM
|
|
91
|
+
end
|
|
92
|
+
module_function :offset_from_bottom
|
|
93
|
+
|
|
75
94
|
# EA carries the break space onto the broken line: line 1
|
|
76
95
|
# is "(from ... Metadata - " with the trailing space, and
|
|
77
96
|
# its textLength includes it (05DF5000: tl 146).
|
|
@@ -16,7 +16,8 @@ module Ea
|
|
|
16
16
|
legend: context.model_element.legend,
|
|
17
17
|
family: context.family,
|
|
18
18
|
item_font: { size: legend_item_size(context), unit: context.size_unit,
|
|
19
|
-
logical_height: context.logical_height }
|
|
19
|
+
logical_height: context.logical_height },
|
|
20
|
+
first_painted: context.first_painted
|
|
20
21
|
)
|
|
21
22
|
elsif context.model_element.is_a?(Ea::Model::Note)
|
|
22
23
|
Element::NoteShapeRenderer.render(
|
|
@@ -24,7 +25,8 @@ module Ea
|
|
|
24
25
|
body: "", fill: context.fill, stroke: context.stroke,
|
|
25
26
|
stroke_width: context.stroke_width,
|
|
26
27
|
family: context.family, size: context.size,
|
|
27
|
-
text_fill: context.text_fill
|
|
28
|
+
text_fill: context.text_fill,
|
|
29
|
+
pen: context.previous_pen
|
|
28
30
|
)
|
|
29
31
|
elsif context.model_element.is_a?(Ea::Model::Package)
|
|
30
32
|
label = package_label(context.model_element)
|
|
@@ -59,12 +61,22 @@ module Ea
|
|
|
59
61
|
return unless element
|
|
60
62
|
return element.font_size if element.font_size&.nonzero?
|
|
61
63
|
|
|
62
|
-
|
|
63
|
-
inherited = element.font_size_inherited || element.style&.fetch(:fontsz, nil).to_s == "0"
|
|
64
|
-
context.size if inherited
|
|
64
|
+
context.size if inherits_context_size?(element)
|
|
65
65
|
end
|
|
66
66
|
private_class_method :legend_item_size
|
|
67
67
|
|
|
68
|
+
# Parsed source defaults inherit; unspecified callers keep 9pt.
|
|
69
|
+
# A parsed element (provenance recorded) that resolved no size
|
|
70
|
+
# also inherits: XMI legends carry no fontsz at all, and native
|
|
71
|
+
# EA draws their items at the context size (Cambria 7pt).
|
|
72
|
+
def inherits_context_size?(element)
|
|
73
|
+
return true if element.font_size_inherited
|
|
74
|
+
return true if element.style&.fetch(:fontsz, nil).to_s == "0"
|
|
75
|
+
|
|
76
|
+
!element.font_size_inherited.nil? && element.font_size.nil?
|
|
77
|
+
end
|
|
78
|
+
private_class_method :inherits_context_size?
|
|
79
|
+
|
|
68
80
|
# A Text element with LegendOpts= renders as the legend
|
|
69
81
|
# block rather than a folder silhouette. Dispatch on the
|
|
70
82
|
# presence of the attached Legend payload.
|