ea 0.6.139 → 0.6.141
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/cli/app.rb +10 -0
- data/lib/ea/cli/command/diagrams.rb +4 -1
- data/lib/ea/cli/command/render.rb +8 -1
- data/lib/ea/cli/command/spa.rb +2 -1
- data/lib/ea/cli/command/svg.rb +9 -6
- data/lib/ea/fonts/metrics.rb +54 -6
- data/lib/ea/model/note.rb +2 -0
- data/lib/ea/sources/qea/diagram_builder/routing.rb +188 -8
- data/lib/ea/sources/qea/diagram_builder.rb +14 -1
- 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 +58 -15
- data/lib/ea/sources/xmi/extension_style_parser.rb +1 -1
- data/lib/ea/sources/xmi/note_builder.rb +2 -1
- data/lib/ea/sources/xmi/stored_route/reversed_routes.rb +78 -0
- data/lib/ea/sources/xmi/stored_route.rb +83 -6
- data/lib/ea/spa/generator.rb +4 -2
- data/lib/ea/spa/projector.rb +4 -2
- data/lib/ea/svg/ea_emitter/bottom_margin.rb +104 -0
- data/lib/ea/svg/ea_emitter/bounds_calculator.rb +25 -15
- data/lib/ea/svg/ea_emitter/canvas.rb +80 -14
- data/lib/ea/svg/ea_emitter/canvas_anchor.rb +132 -0
- data/lib/ea/svg/ea_emitter/compartment/attributes.rb +5 -0
- data/lib/ea/svg/ea_emitter/compartment/constraints.rb +5 -0
- data/lib/ea/svg/ea_emitter/compartment/enum_literals.rb +5 -0
- data/lib/ea/svg/ea_emitter/compartment/header.rb +5 -0
- data/lib/ea/svg/ea_emitter/compartment/header_divider.rb +5 -0
- data/lib/ea/svg/ea_emitter/compartment/instance_slots.rb +5 -0
- data/lib/ea/svg/ea_emitter/compartment/instance_underline.rb +5 -0
- data/lib/ea/svg/ea_emitter/compartment/marker.rb +5 -0
- data/lib/ea/svg/ea_emitter/compartment/note_body.rb +11 -2
- data/lib/ea/svg/ea_emitter/compartment/note_text.rb +5 -0
- data/lib/ea/svg/ea_emitter/compartment/operations.rb +5 -0
- data/lib/ea/svg/ea_emitter/compartment/package_contents.rb +5 -0
- data/lib/ea/svg/ea_emitter/compartment/package_from_parent.rb +34 -4
- data/lib/ea/svg/ea_emitter/compartment/shape.rb +30 -5
- data/lib/ea/svg/ea_emitter/compartment/stereotype_icon.rb +6 -0
- data/lib/ea/svg/ea_emitter/compartment/tagged_values.rb +5 -0
- data/lib/ea/svg/ea_emitter/compartment.rb +13 -0
- data/lib/ea/svg/ea_emitter/connector_bridges.rb +117 -29
- data/lib/ea/svg/ea_emitter/connectors.rb +67 -17
- data/lib/ea/svg/ea_emitter/content_extent.rb +104 -0
- data/lib/ea/svg/ea_emitter/diagram_frame.rb +7 -0
- data/lib/ea/svg/ea_emitter/document.rb +76 -31
- data/lib/ea/svg/ea_emitter/element/legend_renderer.rb +22 -10
- data/lib/ea/svg/ea_emitter/element/note_shape_renderer.rb +57 -32
- data/lib/ea/svg/ea_emitter/elements.rb +24 -5
- data/lib/ea/svg/ea_emitter/font_resolver.rb +3 -1
- data/lib/ea/svg/ea_emitter/label/end_label.rb +134 -14
- data/lib/ea/svg/ea_emitter/labels.rb +16 -3
- data/lib/ea/svg/ea_emitter/layer_sequencer.rb +54 -33
- data/lib/ea/svg/ea_emitter/markers.rb +22 -10
- data/lib/ea/svg/ea_emitter/paint_order.rb +30 -0
- data/lib/ea/svg/ea_emitter/pen.rb +19 -0
- data/lib/ea/svg/ea_emitter/render_context.rb +14 -3
- data/lib/ea/svg/ea_emitter/text_renderer.rb +24 -8
- data/lib/ea/svg/ea_emitter.rb +5 -0
- data/lib/ea/svg/parity/source.rb +1 -1
- data/lib/ea/version.rb +1 -1
- metadata +9 -2
|
@@ -10,6 +10,9 @@ module Ea
|
|
|
10
10
|
class ConnectorBridges
|
|
11
11
|
RADIUS = 6
|
|
12
12
|
PHANTOM = :phantom
|
|
13
|
+
COINCIDENT_LEFTOVER = 3
|
|
14
|
+
NEAR_COINCIDENT = 2
|
|
15
|
+
Bridge = Struct.new(:start, :finish, :leftover)
|
|
13
16
|
|
|
14
17
|
def initialize(_diagram, routes)
|
|
15
18
|
@routes = routes.map { |connector, points| [connector, points, priority_for(connector)] }
|
|
@@ -18,10 +21,9 @@ module Ea
|
|
|
18
21
|
def path_for(connector, points)
|
|
19
22
|
priority = priority_for(connector)
|
|
20
23
|
return centerline(connector, points) unless priority
|
|
24
|
+
return direct_path(connector, priority, points) if points.size == 2 && direct?(connector)
|
|
21
25
|
|
|
22
|
-
segments =
|
|
23
|
-
crossings_for(connector, priority, first, last)
|
|
24
|
-
end
|
|
26
|
+
segments = bridge_segments(connector, priority, points)
|
|
25
27
|
return centerline(connector, points) if segments.all?(&:empty?)
|
|
26
28
|
|
|
27
29
|
serialize(points, segments)
|
|
@@ -29,6 +31,21 @@ module Ea
|
|
|
29
31
|
|
|
30
32
|
private
|
|
31
33
|
|
|
34
|
+
# Direct lines are written target-first like their centerline, so the
|
|
35
|
+
# bridges are walked in that travel direction (arc sweeps are relative
|
|
36
|
+
# to it) and the target repeat closes the path.
|
|
37
|
+
def direct_path(connector, priority, points)
|
|
38
|
+
travel = points.reverse
|
|
39
|
+
segments = bridge_segments(connector, priority, travel)
|
|
40
|
+
return centerline(connector, points) if segments.all?(&:empty?)
|
|
41
|
+
|
|
42
|
+
"#{serialize(travel, segments)} M #{coordinates(travel.first)}"
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def bridge_segments(connector, priority, points)
|
|
46
|
+
points.each_cons(2).map { |first, last| crossings_for(connector, priority, first, last) }
|
|
47
|
+
end
|
|
48
|
+
|
|
32
49
|
def centerline(connector, points)
|
|
33
50
|
seats = points.map { |point| coordinates(point) }
|
|
34
51
|
# Direct lines retain EA's target-first repeat and source-first model.
|
|
@@ -39,9 +56,16 @@ module Ea
|
|
|
39
56
|
end
|
|
40
57
|
end
|
|
41
58
|
|
|
59
|
+
# EA also draws NoteLink, Package and two-point Dependency lines
|
|
60
|
+
# target-first with the repeat (9DA6C553 NoteLink; 2A5E0832 and
|
|
61
|
+
# DFBB1072 Package; 6481E2CC and DFBB1072 Dependency), the opposite
|
|
62
|
+
# of the stored source-to-target waypoint order.
|
|
63
|
+
TARGET_FIRST_TYPES = %w[NoteLink Package Dependency].freeze
|
|
64
|
+
|
|
42
65
|
def direct?(connector)
|
|
43
66
|
style = connector.style || {}
|
|
44
|
-
style.key?(:direct) || style.key?("direct")
|
|
67
|
+
style.key?(:direct) || style.key?("direct") ||
|
|
68
|
+
TARGET_FIRST_TYPES.include?(connector.connector_type)
|
|
45
69
|
end
|
|
46
70
|
|
|
47
71
|
def serialize(points, segments)
|
|
@@ -74,7 +98,7 @@ module Ea
|
|
|
74
98
|
crossing(first, last, start, finish)
|
|
75
99
|
end
|
|
76
100
|
end
|
|
77
|
-
|
|
101
|
+
BridgeSpans.new(crossings, first, last).to_a.sort_by { |bridge| distance(bridge.start, first) }
|
|
78
102
|
end
|
|
79
103
|
|
|
80
104
|
# The order key is only proven across different start placements, so
|
|
@@ -84,7 +108,7 @@ module Ea
|
|
|
84
108
|
|
|
85
109
|
@routes.select do |partner, _points, order|
|
|
86
110
|
!partner.equal?(connector) && order.is_a?(Array) && order.size == priority.size &&
|
|
87
|
-
!same_source?(connector, partner) && (order <=> priority)
|
|
111
|
+
!same_source?(connector, partner) && (order <=> priority)&.negative?
|
|
88
112
|
end
|
|
89
113
|
end
|
|
90
114
|
|
|
@@ -96,26 +120,6 @@ module Ea
|
|
|
96
120
|
connector.source_element_ref && connector.source_element_ref == partner.source_element_ref
|
|
97
121
|
end
|
|
98
122
|
|
|
99
|
-
# Partners crossing at one point share a single arc (0BE81AC5 draws one
|
|
100
|
-
# arc for three coincident verticals). Distinct crossings whose spans
|
|
101
|
-
# overlap are all suppressed so route order cannot select one of
|
|
102
|
-
# several ambiguous arcs.
|
|
103
|
-
def separate_crossings(crossings, first, last)
|
|
104
|
-
crossings = crossings.uniq { |point| point.map { |value| value.round(6) } }
|
|
105
|
-
axis = SegmentGeometry.dominant_axis(first, last)
|
|
106
|
-
spans = crossings.map do |point|
|
|
107
|
-
SegmentGeometry.chord(first, last, point, RADIUS).map { |seat| seat[axis] }.minmax
|
|
108
|
-
end
|
|
109
|
-
crossings.reject.with_index { |_point, index| overlapping_span?(spans, index) }
|
|
110
|
-
end
|
|
111
|
-
|
|
112
|
-
def overlapping_span?(spans, index)
|
|
113
|
-
minimum, maximum = spans[index]
|
|
114
|
-
spans.each_with_index.any? do |(other_minimum, other_maximum), other_index|
|
|
115
|
-
index != other_index && minimum < other_maximum && other_minimum < maximum
|
|
116
|
-
end
|
|
117
|
-
end
|
|
118
|
-
|
|
119
123
|
def crossing(first, last, start, finish)
|
|
120
124
|
SegmentGeometry.crossing(first, last, start, finish, RADIUS)
|
|
121
125
|
end
|
|
@@ -124,15 +128,99 @@ module Ea
|
|
|
124
128
|
SegmentGeometry.distance(point, other)
|
|
125
129
|
end
|
|
126
130
|
|
|
127
|
-
def arc_commands(first, last,
|
|
128
|
-
|
|
129
|
-
["L #{coordinates(start)}",
|
|
131
|
+
def arc_commands(first, last, bridge)
|
|
132
|
+
arc = "A #{radii(first, last, bridge)} 180 0 0 #{coordinates(bridge.finish)}"
|
|
133
|
+
commands = ["L #{coordinates(bridge.start)}", arc]
|
|
134
|
+
return commands unless bridge.leftover
|
|
135
|
+
|
|
136
|
+
commands + ["L #{coordinates(bridge.finish)}", "A #{RADIUS} #{RADIUS} 0 1 0 #{coordinates(bridge.finish)}"]
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# An axis-aligned arc has half its chord length as the radius along
|
|
140
|
+
# the carrier and RADIUS across it; slanted chords keep the circle.
|
|
141
|
+
def radii(first, last, bridge)
|
|
142
|
+
axis = SegmentGeometry.axis_for(first, last)
|
|
143
|
+
return "#{RADIUS} #{RADIUS}" unless axis
|
|
144
|
+
|
|
145
|
+
half = Canvas.coord(SegmentGeometry.distance(bridge.start, bridge.finish) / 2.0)
|
|
146
|
+
axis.zero? ? "#{half} #{RADIUS}" : "#{RADIUS} #{half}"
|
|
130
147
|
end
|
|
131
148
|
|
|
132
149
|
def coordinates(point)
|
|
133
150
|
point.map { |value| Canvas.coord(value) }.join(" ")
|
|
134
151
|
end
|
|
135
152
|
end
|
|
153
|
+
|
|
154
|
+
# The arc chords decorating one carrier segment, from its crossings.
|
|
155
|
+
#
|
|
156
|
+
# Partners crossing at one point share a single arc (0BE81AC5 draws one
|
|
157
|
+
# arc for three coincident verticals).
|
|
158
|
+
#
|
|
159
|
+
# Axis-aligned carrier: crossings within NEAR_COINCIDENT pixels merge
|
|
160
|
+
# into one arc spanning the outer chord ends (30D62231 draws one
|
|
161
|
+
# 14-long arc over three horizontals one pixel apart; wider overlaps
|
|
162
|
+
# have no corpus case and stay suppressed).
|
|
163
|
+
#
|
|
164
|
+
# Slanted carrier: distinct crossings whose spans overlap are all
|
|
165
|
+
# suppressed so route order cannot select one of several ambiguous
|
|
166
|
+
# arcs. Empirical fit, 0BE81AC5 (the only slanted carrier with three
|
|
167
|
+
# coincident partners in the corpus): EA also writes one zero-length
|
|
168
|
+
# arc after the chord. Fewer coincident partners are not evidenced.
|
|
169
|
+
class BridgeSpans
|
|
170
|
+
def initialize(crossings, first, last)
|
|
171
|
+
@first = first
|
|
172
|
+
@axis = SegmentGeometry.dominant_axis(first, last)
|
|
173
|
+
@aligned = !SegmentGeometry.axis_for(first, last).nil?
|
|
174
|
+
groups = crossings.group_by { |point| point.map { |value| value.round(6) } }.values
|
|
175
|
+
@bridges = groups.map { |group| bridge_at(group, last) }
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def to_a
|
|
179
|
+
@aligned ? merged : @bridges.reject.with_index { |_bridge, index| overlapping?(index) }
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
private
|
|
183
|
+
|
|
184
|
+
def bridge_at(group, last)
|
|
185
|
+
start, finish = SegmentGeometry.chord(@first, last, group.first, ConnectorBridges::RADIUS)
|
|
186
|
+
leftover = !@aligned && group.size >= ConnectorBridges::COINCIDENT_LEFTOVER
|
|
187
|
+
ConnectorBridges::Bridge.new(start, finish, leftover)
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def merged
|
|
191
|
+
ordered = @bridges.sort_by { |bridge| distance(bridge.start) }
|
|
192
|
+
components = ordered.slice_when { |one, other| !overlap?(one, other) }
|
|
193
|
+
components.filter_map { |component| component.one? ? component.first : merge(component) }
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def merge(component)
|
|
197
|
+
return if spread(component) > ConnectorBridges::NEAR_COINCIDENT
|
|
198
|
+
|
|
199
|
+
finish = component.map(&:finish).max_by { |point| distance(point) }
|
|
200
|
+
ConnectorBridges::Bridge.new(component.first.start, finish, false)
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def spread(component)
|
|
204
|
+
centres = component.map { |bridge| bridge.start[@axis] + bridge.finish[@axis] }
|
|
205
|
+
(centres.max - centres.min) / 2.0
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def overlapping?(index)
|
|
209
|
+
@bridges.each_with_index.any? do |other, other_index|
|
|
210
|
+
index != other_index && overlap?(@bridges[index], other)
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def overlap?(one, other)
|
|
215
|
+
minimum, maximum = [one.start[@axis], one.finish[@axis]].minmax
|
|
216
|
+
other_minimum, other_maximum = [other.start[@axis], other.finish[@axis]].minmax
|
|
217
|
+
minimum < other_maximum && other_minimum < maximum
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def distance(point)
|
|
221
|
+
SegmentGeometry.distance(point, @first)
|
|
222
|
+
end
|
|
223
|
+
end
|
|
136
224
|
end
|
|
137
225
|
end
|
|
138
226
|
end
|
|
@@ -34,36 +34,70 @@ module Ea
|
|
|
34
34
|
# EA draws each connector (and its markers) with the pen width
|
|
35
35
|
# stored in the diagram link style (`LWidth`); links without
|
|
36
36
|
# the field keep the diagram default.
|
|
37
|
+
#
|
|
38
|
+
# A link saved with `LWidth=0` defers to the connector's own saved
|
|
39
|
+
# pen (`t_connector.IsBold`, XMI `<appearance linewidth>`), which
|
|
40
|
+
# DiagramConnector#line_width carries. Checked on connector 438 in
|
|
41
|
+
# 9400D9BC and FEBE5726 (IsBold=2, native stroke-width 2); the 314
|
|
42
|
+
# LWidth=0 links with IsBold=0 stay one pixel.
|
|
37
43
|
def self.stroke_width_for(connector, default)
|
|
38
44
|
style = connector.style || {}
|
|
39
45
|
raw = style[:lwidth] || style["lwidth"]
|
|
40
46
|
return default if raw.to_s.strip.empty?
|
|
47
|
+
return connector.line_width if raw.to_i.zero? && connector.line_width.to_i.positive?
|
|
41
48
|
|
|
42
49
|
[raw.to_i, 1].max
|
|
43
50
|
end
|
|
44
51
|
|
|
52
|
+
DEFAULT_STROKE = "#000000"
|
|
53
|
+
|
|
54
|
+
# EA strokes a connector and its markers in the colour stored in
|
|
55
|
+
# the diagram link style (`Color`, a BGR integer; -1 = default).
|
|
56
|
+
# Checked on the #69738C links of 5DA1E7A7, 73025C5A, C31C1314
|
|
57
|
+
# and E7AABA98.
|
|
58
|
+
def self.stroke_color_for(connector)
|
|
59
|
+
raw = connector.line_color
|
|
60
|
+
raw = (connector.style || {})[:color] if raw.nil?
|
|
61
|
+
return DEFAULT_STROKE if raw.nil? || raw.to_s.strip.empty?
|
|
62
|
+
|
|
63
|
+
Element::BColDecoder.to_hex(raw.to_i) || DEFAULT_STROKE
|
|
64
|
+
end
|
|
65
|
+
|
|
45
66
|
# Layer key for a style group: the plain key at the default
|
|
46
|
-
# width, suffixed
|
|
47
|
-
#
|
|
48
|
-
def self.layer_key(key, width, default)
|
|
49
|
-
|
|
67
|
+
# width and colour, suffixed otherwise so layers of different
|
|
68
|
+
# widths or colours never merge.
|
|
69
|
+
def self.layer_key(key, width, default, color = DEFAULT_STROKE)
|
|
70
|
+
key = :"#{key}_w#{width}" unless width == default
|
|
71
|
+
color == DEFAULT_STROKE ? key : :"#{key}_c#{color.delete("#")}"
|
|
50
72
|
end
|
|
51
73
|
|
|
52
74
|
def layers
|
|
53
|
-
styles = paths_with_styles
|
|
75
|
+
styles = paths_with_styles.map { |_connector, style, path| [style, path] }
|
|
54
76
|
|
|
55
77
|
if grouped
|
|
56
|
-
styles.group_by(&:first).map do |(width,
|
|
57
|
-
line_layer(width,
|
|
78
|
+
styles.group_by(&:first).map do |(width, dash, color), entries|
|
|
79
|
+
line_layer(width, dash, color, entries.map(&:last).join("\n "))
|
|
58
80
|
end
|
|
59
81
|
else
|
|
60
|
-
styles.map { |(width,
|
|
82
|
+
styles.map { |(width, dash, color), path| line_layer(width, dash, color, path) }
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# One `[connector, Layer]` pair per drawn connector, in
|
|
87
|
+
# diagram link order: EA paints each connector (line, then its
|
|
88
|
+
# heads) in turn, so the sequencer interleaves these with the
|
|
89
|
+
# marker layers instead of bucketing every line first.
|
|
90
|
+
def connector_layers
|
|
91
|
+
paths_with_styles.map do |connector, (width, dash, color), path|
|
|
92
|
+
[connector, line_layer(width, dash, color, path)]
|
|
61
93
|
end
|
|
62
94
|
end
|
|
63
95
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
96
|
+
# EA draws dashed lines with square caps and round joins.
|
|
97
|
+
def line_style(width = stroke_width, color = DEFAULT_STROKE, dashed: false)
|
|
98
|
+
cap_join = dashed ? "square;stroke-linejoin:round" : "round;stroke-linejoin:bevel"
|
|
99
|
+
"stroke-width:#{width};stroke-linecap:#{cap_join}; " \
|
|
100
|
+
"fill:#000000;fill-opacity:0.00; stroke:#{color}; stroke-opacity:1.00"
|
|
67
101
|
end
|
|
68
102
|
|
|
69
103
|
# Backwards-compatible render — joins all layers' `<g>`.
|
|
@@ -80,19 +114,35 @@ module Ea
|
|
|
80
114
|
end
|
|
81
115
|
bridges = ConnectorBridges.new(diagram, routes)
|
|
82
116
|
routes.map do |connector, points|
|
|
83
|
-
[
|
|
117
|
+
[connector,
|
|
118
|
+
[self.class.stroke_width_for(connector, stroke_width), dash_for(connector),
|
|
119
|
+
self.class.stroke_color_for(connector)],
|
|
84
120
|
path_for(connector, points, bridges)]
|
|
85
121
|
end
|
|
86
122
|
end
|
|
87
123
|
|
|
88
|
-
def line_layer(width,
|
|
89
|
-
key =
|
|
90
|
-
style = line_style(width)
|
|
91
|
-
style += ";stroke-dasharray
|
|
92
|
-
Layer.new(style_key: self.class.layer_key(key, width, stroke_width),
|
|
124
|
+
def line_layer(width, dash, color, body)
|
|
125
|
+
key = dash ? :"connector_line_dash_#{dash.tr(",", "_")}" : LINE_STYLE_KEY
|
|
126
|
+
style = line_style(width, color, dashed: !dash.nil?)
|
|
127
|
+
style += ";stroke-dasharray:#{dash}" if dash
|
|
128
|
+
Layer.new(style_key: self.class.layer_key(key, width, stroke_width, color),
|
|
93
129
|
style: style, body: body)
|
|
94
130
|
end
|
|
95
131
|
|
|
132
|
+
# Dash pattern as "dash,gap", nil when solid. A NoteLink is
|
|
133
|
+
# dashed at width + 2 (empirical fit, EA native: 4,4 at width 2 in
|
|
134
|
+
# 676B9C9A and 926AFE38, 3,3 at width 1 in 9DA6C553; no source
|
|
135
|
+
# field found); every other dashed connector uses 6,3 (23 native
|
|
136
|
+
# cases at width 1).
|
|
137
|
+
def dash_for(connector)
|
|
138
|
+
if connector.connector_type == "NoteLink"
|
|
139
|
+
n = self.class.stroke_width_for(connector, stroke_width) + 2
|
|
140
|
+
"#{n},#{n}"
|
|
141
|
+
elsif dashed?(connector)
|
|
142
|
+
"6,3"
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
|
|
96
146
|
def dashed?(connector)
|
|
97
147
|
return true if connector.style.to_h[:association_class_ref]
|
|
98
148
|
return true if connector.connector_type == "Dependency"
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ea
|
|
4
|
+
module Svg
|
|
5
|
+
module EaEmitter
|
|
6
|
+
# Measures the extent of emitted SVG content: the points EA draws
|
|
7
|
+
# shapes through (rect, path M/L/C/A end points, polygon, ellipse)
|
|
8
|
+
# and the box of every text run (left edge, top = baseline - font
|
|
9
|
+
# height, right = left + textLength). EA anchors an unframed
|
|
10
|
+
# export's canvas on this extent.
|
|
11
|
+
class ContentExtent
|
|
12
|
+
NUMBER = /-?\d*\.?\d+(?:[eE][-+]?\d+)?/
|
|
13
|
+
ELEMENT = /<(rect|path|polygon|polyline|ellipse|text)\b([^>]*)>/
|
|
14
|
+
ATTRIBUTE = /\b([\w:-]+)="([^"]*)"/
|
|
15
|
+
PATH_COMMAND = /([MLACHVZ])([^MLACHVZ]*)/
|
|
16
|
+
PX_PER_PT = 96 / 72.0
|
|
17
|
+
|
|
18
|
+
# Each box is [min_x, min_y, max_x, max_y] or nil when the
|
|
19
|
+
# content has no shape (or no text).
|
|
20
|
+
attr_reader :shapes, :texts
|
|
21
|
+
|
|
22
|
+
def self.measure(svg_fragments)
|
|
23
|
+
shape_points = []
|
|
24
|
+
text_boxes = []
|
|
25
|
+
svg_fragments.each do |svg|
|
|
26
|
+
svg.to_s.scan(ELEMENT) do |kind, attrs|
|
|
27
|
+
attributes = attrs.scan(ATTRIBUTE).to_h
|
|
28
|
+
next text_boxes << text_box(attributes) if kind == "text"
|
|
29
|
+
|
|
30
|
+
shape_points.concat(points(kind, attributes))
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
new(shape_points, text_boxes)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def self.points(kind, attrs)
|
|
37
|
+
case kind
|
|
38
|
+
when "rect" then rect_points(attrs)
|
|
39
|
+
when "path" then path_points(attrs["d"].to_s)
|
|
40
|
+
when "polygon", "polyline" then pairs(attrs["points"].to_s)
|
|
41
|
+
else ellipse_points(attrs)
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def self.rect_points(attrs)
|
|
46
|
+
left, top, wide, high = %w[x y width height].map { |k| attrs[k].to_f }
|
|
47
|
+
[[left, top], [left + wide, top + high]]
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def self.ellipse_points(attrs)
|
|
51
|
+
cx, cy, rx, ry = %w[cx cy rx ry].map { |k| attrs[k].to_f }
|
|
52
|
+
[[cx - rx, cy - ry], [cx + rx, cy + ry]]
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def self.pairs(body)
|
|
56
|
+
body.scan(NUMBER).map(&:to_f).each_slice(2).select { |pair| pair.size == 2 }
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def self.path_points(path_data)
|
|
60
|
+
path_data.scan(PATH_COMMAND).flat_map do |command, body|
|
|
61
|
+
numbers = body.scan(NUMBER).map(&:to_f)
|
|
62
|
+
case command
|
|
63
|
+
when "M", "L" then numbers.each_slice(2).select { |pair| pair.size == 2 }
|
|
64
|
+
when "C" then end_points(numbers, 6)
|
|
65
|
+
when "A" then end_points(numbers, 7)
|
|
66
|
+
else []
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# The segment end point only: a Bezier's control points lie
|
|
72
|
+
# outside the drawn curve and EA does not count them (basic
|
|
73
|
+
# E325B9D8, hand-drawn style: 504 x 274 natively, 508 x 278
|
|
74
|
+
# with the control points).
|
|
75
|
+
def self.end_points(numbers, stride)
|
|
76
|
+
numbers.each_slice(stride).select { |a| a.size == stride }.map { |a| a.last(2) }
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def self.text_box(attrs)
|
|
80
|
+
style = attrs["style"].to_s
|
|
81
|
+
size = style[/font-size:([\d.]+)/, 1].to_f
|
|
82
|
+
size *= PX_PER_PT if style[/font-size:[\d.]+(pt|px)/, 1] == "pt"
|
|
83
|
+
left = attrs["x"].to_f
|
|
84
|
+
base = attrs["y"].to_f
|
|
85
|
+
[left, base - size.ceil, left + attrs["textLength"].to_f, base]
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def initialize(shape_points, text_boxes)
|
|
89
|
+
@shapes = box(shape_points.map(&:first), shape_points.map(&:last))
|
|
90
|
+
@texts = box(text_boxes.map(&:first) + text_boxes.map { |b| b[2] },
|
|
91
|
+
text_boxes.map { |b| b[1] } + text_boxes.map { |b| b[3] })
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
private
|
|
95
|
+
|
|
96
|
+
def box(columns, rows)
|
|
97
|
+
return nil if columns.empty?
|
|
98
|
+
|
|
99
|
+
[columns.min, rows.min, columns.max, rows.max]
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
@@ -48,6 +48,13 @@ module Ea
|
|
|
48
48
|
].compact
|
|
49
49
|
end
|
|
50
50
|
|
|
51
|
+
# Narrowest canvas that holds the name tab inside the border
|
|
52
|
+
# (the border inset on both sides of the tab).
|
|
53
|
+
def min_width(diagram)
|
|
54
|
+
2 * theme.frame.inset + tab_label_width(diagram) +
|
|
55
|
+
theme.frame.tab_slant
|
|
56
|
+
end
|
|
57
|
+
|
|
51
58
|
private
|
|
52
59
|
|
|
53
60
|
def border_layer(canvas)
|
|
@@ -10,35 +10,23 @@ module Ea
|
|
|
10
10
|
class Document
|
|
11
11
|
BUILD_ID = "1624"
|
|
12
12
|
|
|
13
|
-
attr_reader :diagram, :model_index, :frame, :document
|
|
13
|
+
attr_reader :diagram, :model_index, :frame, :document, :content_anchor
|
|
14
14
|
|
|
15
|
-
|
|
15
|
+
# No frame by default, like EA's own export; `frame: true` opts
|
|
16
|
+
# in. `content_anchor` (unframed only, on by default) sizes and
|
|
17
|
+
# origins the canvas on the drawn content like EA's native
|
|
18
|
+
# unframed export; off, it keeps the framed 35/40 content origin.
|
|
19
|
+
def initialize(diagram, model_index:, frame: false, document: nil,
|
|
20
|
+
content_anchor: true, **_options)
|
|
16
21
|
@diagram = diagram
|
|
17
22
|
@model_index = model_index
|
|
18
|
-
@frame = frame
|
|
23
|
+
@frame = frame
|
|
19
24
|
@document = document
|
|
25
|
+
@content_anchor = content_anchor && !@frame
|
|
20
26
|
end
|
|
21
27
|
|
|
22
28
|
def render
|
|
23
|
-
canvas =
|
|
24
|
-
seq = LayerSequencer.new(diagram, model_index: model_index,
|
|
25
|
-
canvas: canvas, frame: frame,
|
|
26
|
-
document: document)
|
|
27
|
-
seq.layers
|
|
28
|
-
# Second pass over the DRAWN extents: grown classifiers and
|
|
29
|
-
# autosized packages can exceed the stored-rect canvas EA
|
|
30
|
-
# sizes from drawn extents. Skip when the first pass already
|
|
31
|
-
# covers the drawn extents (the common case).
|
|
32
|
-
grown = seq.drawn_bounds
|
|
33
|
-
if grown && grown.values.any? &&
|
|
34
|
-
exceeds?(canvas, grown.values)
|
|
35
|
-
canvas = Canvas.from_drawn(diagram, grown,
|
|
36
|
-
model_index: model_index)
|
|
37
|
-
seq = LayerSequencer.new(diagram, model_index: model_index,
|
|
38
|
-
canvas: canvas, frame: frame,
|
|
39
|
-
document: document)
|
|
40
|
-
seq.layers
|
|
41
|
-
end
|
|
29
|
+
canvas, seq = lay_out
|
|
42
30
|
layers = seq.layers.reject { |s| s.nil? || s.empty? }
|
|
43
31
|
image_layer = emit_images
|
|
44
32
|
layers << image_layer if image_layer
|
|
@@ -46,6 +34,67 @@ module Ea
|
|
|
46
34
|
%(<?xml version="1.0" encoding="UTF-8" standalone="no"?>\n<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.0//EN" "http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">\n\n<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="#{canvas.width_cm}" height="#{canvas.height_cm}" viewBox="#{canvas.view_box}">\n<title></title>\n<desc>Created with Enterprise Architect (Build: #{BUILD_ID}) 2</desc>\n#{layers.join("\n")}\n</svg>)
|
|
47
35
|
end
|
|
48
36
|
|
|
37
|
+
# First pass over the stored rects, then a second pass over the
|
|
38
|
+
# DRAWN extents: grown classifiers and autosized packages can
|
|
39
|
+
# exceed the stored-rect canvas EA sizes from drawn extents.
|
|
40
|
+
# Skipped when the first pass already covers them (the common
|
|
41
|
+
# case). An unframed export is finally anchored on the content.
|
|
42
|
+
def lay_out
|
|
43
|
+
canvas = Canvas.from(diagram, model_index: model_index,
|
|
44
|
+
content_anchor: content_anchor)
|
|
45
|
+
seq = sequence(canvas)
|
|
46
|
+
canvas, seq = redo_on_drawn(canvas, seq)
|
|
47
|
+
return anchor_on_content(canvas, seq) if content_anchor
|
|
48
|
+
|
|
49
|
+
frame ? grow_to_drawn_shapes(canvas, seq) : [canvas, seq]
|
|
50
|
+
end
|
|
51
|
+
private :lay_out
|
|
52
|
+
|
|
53
|
+
# A framed canvas is sized from element rects, so a stored
|
|
54
|
+
# connector bend beyond them is drawn outside the viewBox. Grows
|
|
55
|
+
# the canvas to hold every drawn shape; a diagram whose shapes
|
|
56
|
+
# already fit keeps its canvas untouched.
|
|
57
|
+
def grow_to_drawn_shapes(canvas, seq)
|
|
58
|
+
shapes = ContentExtent.measure(seq.layers.drop(1)).shapes
|
|
59
|
+
return [canvas, seq] unless shapes
|
|
60
|
+
|
|
61
|
+
grown = canvas.holding(shapes)
|
|
62
|
+
grown.equal?(canvas) ? [canvas, seq] : [grown, sequence(grown)]
|
|
63
|
+
end
|
|
64
|
+
private :grow_to_drawn_shapes
|
|
65
|
+
|
|
66
|
+
def redo_on_drawn(canvas, seq)
|
|
67
|
+
grown = seq.drawn_bounds
|
|
68
|
+
return [canvas, seq] unless grown&.values&.any? &&
|
|
69
|
+
exceeds?(canvas, grown.values)
|
|
70
|
+
|
|
71
|
+
canvas = Canvas.from_drawn(diagram, grown, model_index: model_index,
|
|
72
|
+
content_anchor: content_anchor)
|
|
73
|
+
[canvas, sequence(canvas)]
|
|
74
|
+
end
|
|
75
|
+
private :redo_on_drawn
|
|
76
|
+
|
|
77
|
+
def sequence(canvas)
|
|
78
|
+
seq = LayerSequencer.new(diagram, model_index: model_index,
|
|
79
|
+
canvas: canvas, frame: frame,
|
|
80
|
+
document: document)
|
|
81
|
+
seq.layers
|
|
82
|
+
seq
|
|
83
|
+
end
|
|
84
|
+
private :sequence
|
|
85
|
+
|
|
86
|
+
# Re-renders at the canvas anchored on the measured drawn
|
|
87
|
+
# content when that differs from the stored-rect canvas.
|
|
88
|
+
def anchor_on_content(canvas, seq)
|
|
89
|
+
anchored = CanvasAnchor.reanchor(canvas, seq.layers.drop(1),
|
|
90
|
+
seq.label_reach,
|
|
91
|
+
bottom: BottomMargin.for(diagram, model_index))
|
|
92
|
+
return [canvas, seq] if CanvasAnchor.same_canvas?(canvas, anchored)
|
|
93
|
+
|
|
94
|
+
[anchored, sequence(anchored)]
|
|
95
|
+
end
|
|
96
|
+
private :anchor_on_content
|
|
97
|
+
|
|
49
98
|
# Walks t_image rows (when the document carries a Database
|
|
50
99
|
# with an :images collection) and invokes EmfRenderer on
|
|
51
100
|
# each. Emits an `<image>` element per successfully converted
|
|
@@ -69,13 +118,14 @@ module Ea
|
|
|
69
118
|
%(<g id="images">\n#{fragments.join("\n")}\n</g>)
|
|
70
119
|
end
|
|
71
120
|
|
|
72
|
-
# True when any drawn bound falls outside the
|
|
73
|
-
# canvas
|
|
121
|
+
# True when any drawn bound falls outside the canvas content
|
|
122
|
+
# area; the far edges are measured from the canvas' model-space
|
|
123
|
+
# origin (its insets included).
|
|
74
124
|
def exceeds?(canvas, bounds)
|
|
75
125
|
bounds.any? do |b|
|
|
76
126
|
b.x < canvas.min_x || b.y < canvas.min_y ||
|
|
77
|
-
b.x + b.width > canvas.
|
|
78
|
-
b.y + b.height > canvas.
|
|
127
|
+
b.x + b.width > canvas.origin_x + canvas.width ||
|
|
128
|
+
b.y + b.height > canvas.origin_y + canvas.height
|
|
79
129
|
end
|
|
80
130
|
end
|
|
81
131
|
|
|
@@ -86,11 +136,6 @@ module Ea
|
|
|
86
136
|
%(<g>#{svg}</g>)
|
|
87
137
|
end
|
|
88
138
|
private :render_image
|
|
89
|
-
|
|
90
|
-
def empty_diagram?(source_diagram)
|
|
91
|
-
source_diagram.elements.empty? && source_diagram.connectors.empty?
|
|
92
|
-
end
|
|
93
|
-
private :empty_diagram?
|
|
94
139
|
end
|
|
95
140
|
end
|
|
96
141
|
end
|
|
@@ -31,16 +31,25 @@ module Ea
|
|
|
31
31
|
ICON_LABEL_GAP = 22
|
|
32
32
|
LABEL_BASELINE_FROM_ICON_TOP = 12
|
|
33
33
|
DEFAULT_ITEM_FONT_SIZE = 9
|
|
34
|
+
# The legend pen is not set explicitly: the panel inherits the pen
|
|
35
|
+
# left by the previous drawing, or EA's initial pen (square/miter)
|
|
36
|
+
# when nothing was drawn before it - the same rule notes follow.
|
|
37
|
+
# Held across the corpus: 11 of 11 legends painted first are
|
|
38
|
+
# square/miter, 105 of 105 painted later follow a class-like
|
|
39
|
+
# drawing that leaves round/bevel.
|
|
40
|
+
INITIAL_PEN = Element::NoteShapeRenderer::INITIAL_PEN
|
|
34
41
|
|
|
35
|
-
def self.render(bounds, legend:, family:, canvas: nil, item_font: {})
|
|
36
|
-
new(bounds, legend, family, canvas, item_font).to_svg
|
|
42
|
+
def self.render(bounds, legend:, family:, canvas: nil, item_font: {}, pen: nil)
|
|
43
|
+
new(bounds, legend, family, canvas, item_font, pen: pen).to_svg
|
|
37
44
|
end
|
|
38
45
|
|
|
39
|
-
def initialize(bounds, legend, family, canvas, item_font = {})
|
|
46
|
+
def initialize(bounds, legend, family, canvas, item_font = {}, pen: nil)
|
|
40
47
|
@bounds = bounds
|
|
41
48
|
@legend = legend
|
|
42
49
|
@family = family
|
|
43
50
|
@canvas = canvas
|
|
51
|
+
cap, join = pen || INITIAL_PEN
|
|
52
|
+
@pen = "stroke-width:1;stroke-linecap:#{cap};stroke-linejoin:#{join};"
|
|
44
53
|
@item_font = item_font[:size]&.nonzero? ? item_font : {}
|
|
45
54
|
end
|
|
46
55
|
|
|
@@ -82,19 +91,19 @@ module Ea
|
|
|
82
91
|
end
|
|
83
92
|
|
|
84
93
|
def container_style
|
|
85
|
-
"
|
|
94
|
+
"#{@pen} " \
|
|
86
95
|
"fill:#{container_fill};fill-opacity:1.00; " \
|
|
87
96
|
"stroke:#{container_stroke}; stroke-opacity:1.00"
|
|
88
97
|
end
|
|
89
98
|
|
|
90
99
|
def icon_style(item)
|
|
91
|
-
"
|
|
100
|
+
"#{@pen} " \
|
|
92
101
|
"fill:#{icon_fill(item)};fill-opacity:1.00; " \
|
|
93
102
|
"stroke:#000000; stroke-opacity:1.00"
|
|
94
103
|
end
|
|
95
104
|
|
|
96
105
|
def label_style
|
|
97
|
-
"
|
|
106
|
+
"#{@pen} " \
|
|
98
107
|
"fill:#{label_fill};fill-opacity:1.00; " \
|
|
99
108
|
"stroke:#000000; stroke-opacity:0.00"
|
|
100
109
|
end
|
|
@@ -154,10 +163,11 @@ module Ea
|
|
|
154
163
|
(block_left + (block_right - block_left - title_text_width.round) / 2.0).floor
|
|
155
164
|
end
|
|
156
165
|
|
|
157
|
-
def estimated_width(text, size, weight: nil, logical_height: nil, text_family: family
|
|
166
|
+
def estimated_width(text, size, weight: nil, logical_height: nil, text_family: family,
|
|
167
|
+
legend_title: false)
|
|
158
168
|
TextRenderer.estimate_width(
|
|
159
169
|
text, size, family: text_family, weight: weight,
|
|
160
|
-
logical_height: logical_height
|
|
170
|
+
logical_height: logical_height, legend_title: legend_title
|
|
161
171
|
).round
|
|
162
172
|
end
|
|
163
173
|
|
|
@@ -185,7 +195,8 @@ module Ea
|
|
|
185
195
|
end
|
|
186
196
|
|
|
187
197
|
def title_text_width
|
|
188
|
-
estimated_width(legend.title, title_font_size, weight: 700, text_family: heading_family
|
|
198
|
+
estimated_width(legend.title, title_font_size, weight: 700, text_family: heading_family,
|
|
199
|
+
legend_title: true)
|
|
189
200
|
end
|
|
190
201
|
|
|
191
202
|
def heading_family
|
|
@@ -198,7 +209,8 @@ module Ea
|
|
|
198
209
|
TextRenderer.new(
|
|
199
210
|
content: content.to_s, x: tx, y: ty,
|
|
200
211
|
family: weight.zero? ? family : heading_family, size: size, size_unit: size_unit,
|
|
201
|
-
weight: weight, fill: label_fill, logical_height: weight.zero? ? item_font[:logical_height] : nil
|
|
212
|
+
weight: weight, fill: label_fill, logical_height: weight.zero? ? item_font[:logical_height] : nil,
|
|
213
|
+
legend_title: !weight.zero?
|
|
202
214
|
).to_svg
|
|
203
215
|
end
|
|
204
216
|
|