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.
Files changed (35) hide show
  1. checksums.yaml +4 -4
  2. data/lib/ea/fonts/metrics.rb +22 -2
  3. data/lib/ea/model/note.rb +2 -0
  4. data/lib/ea/sources/qea/diagram_builder/routing.rb +140 -2
  5. data/lib/ea/sources/qea/diagram_builder.rb +10 -0
  6. data/lib/ea/sources/qea/note_builder.rb +7 -1
  7. data/lib/ea/sources/xmi/diagram_builder/element_borders.rb +27 -0
  8. data/lib/ea/sources/xmi/diagram_builder.rb +40 -11
  9. data/lib/ea/sources/xmi/note_builder.rb +2 -1
  10. data/lib/ea/sources/xmi/stored_route/reversed_routes.rb +94 -0
  11. data/lib/ea/sources/xmi/stored_route.rb +121 -8
  12. data/lib/ea/svg/ea_emitter/bottom_margin.rb +100 -0
  13. data/lib/ea/svg/ea_emitter/bounds_calculator.rb +3 -1
  14. data/lib/ea/svg/ea_emitter/canvas.rb +37 -9
  15. data/lib/ea/svg/ea_emitter/canvas_anchor.rb +124 -0
  16. data/lib/ea/svg/ea_emitter/compartment/note_body.rb +5 -1
  17. data/lib/ea/svg/ea_emitter/compartment/package_from_parent.rb +23 -4
  18. data/lib/ea/svg/ea_emitter/compartment/shape.rb +17 -5
  19. data/lib/ea/svg/ea_emitter/connector_bridges.rb +113 -41
  20. data/lib/ea/svg/ea_emitter/connectors.rb +67 -17
  21. data/lib/ea/svg/ea_emitter/content_extent.rb +95 -0
  22. data/lib/ea/svg/ea_emitter/document.rb +56 -27
  23. data/lib/ea/svg/ea_emitter/element/legend_renderer.rb +14 -6
  24. data/lib/ea/svg/ea_emitter/element/note_shape_renderer.rb +64 -32
  25. data/lib/ea/svg/ea_emitter/elements.rb +21 -4
  26. data/lib/ea/svg/ea_emitter/label/end_label.rb +64 -6
  27. data/lib/ea/svg/ea_emitter/labels.rb +6 -0
  28. data/lib/ea/svg/ea_emitter/layer_sequencer.rb +40 -26
  29. data/lib/ea/svg/ea_emitter/markers.rb +22 -10
  30. data/lib/ea/svg/ea_emitter/paint_order.rb +29 -0
  31. data/lib/ea/svg/ea_emitter/render_context.rb +14 -3
  32. data/lib/ea/svg/ea_emitter/segment_geometry.rb +123 -0
  33. data/lib/ea/svg/ea_emitter.rb +6 -0
  34. data/lib/ea/version.rb +1 -1
  35. metadata +8 -1
@@ -9,6 +9,10 @@ module Ea
9
9
  # connector passes over a crossing.
10
10
  class ConnectorBridges
11
11
  RADIUS = 6
12
+ PHANTOM = :phantom
13
+ COINCIDENT_LEFTOVER = 3
14
+ NEAR_COINCIDENT = 2
15
+ Bridge = Struct.new(:start, :finish, :leftover)
12
16
 
13
17
  def initialize(_diagram, routes)
14
18
  @routes = routes.map { |connector, points| [connector, points, priority_for(connector)] }
@@ -38,9 +42,16 @@ module Ea
38
42
  end
39
43
  end
40
44
 
45
+ # EA also draws NoteLink, Package and two-point Dependency lines
46
+ # target-first with the repeat (9DA6C553 NoteLink; 2A5E0832 and
47
+ # DFBB1072 Package; 6481E2CC and DFBB1072 Dependency), the opposite
48
+ # of the stored source-to-target waypoint order.
49
+ TARGET_FIRST_TYPES = %w[NoteLink Package Dependency].freeze
50
+
41
51
  def direct?(connector)
42
52
  style = connector.style || {}
43
- style.key?(:direct) || style.key?("direct")
53
+ style.key?(:direct) || style.key?("direct") ||
54
+ TARGET_FIRST_TYPES.include?(connector.connector_type)
44
55
  end
45
56
 
46
57
  def serialize(points, segments)
@@ -56,83 +67,144 @@ module Ea
56
67
 
57
68
  # nil or empty: no bridge decision for this connector.
58
69
  def priority_for(connector)
70
+ return PHANTOM if phantom?(connector)
71
+
59
72
  connector.paint_order if connector.paint_order&.any?
60
73
  end
61
74
 
62
- def crossings_for(connector, priority, first, last)
63
- axis = axis_for(first, last)
64
- return [] unless axis
75
+ # A phantom (relationship without a link row) has no stored order; it
76
+ # is appended after every link in the export, so it passes over them.
77
+ def phantom?(connector)
78
+ connector.paint_order.nil?
79
+ end
65
80
 
81
+ def crossings_for(connector, priority, first, last)
66
82
  crossings = lower_routes(connector, priority).flat_map do |_partner, points, _order|
67
83
  points.each_cons(2).filter_map do |start, finish|
68
- crossing(first, last, start, finish, axis)
84
+ crossing(first, last, start, finish)
69
85
  end
70
86
  end
71
- separate_crossings(crossings, axis).sort_by { |point| (point[axis] - first[axis]).abs }
87
+ BridgeSpans.new(crossings, first, last).to_a.sort_by { |bridge| distance(bridge.start, first) }
72
88
  end
73
89
 
74
90
  # The order key is only proven across different start placements, so
75
91
  # connectors leaving the same placement make no carrier decision.
76
92
  def lower_routes(connector, priority)
93
+ return keyed_routes(connector) if priority == PHANTOM
94
+
77
95
  @routes.select do |partner, _points, order|
78
- !partner.equal?(connector) && order && order.size == priority.size &&
96
+ !partner.equal?(connector) && order.is_a?(Array) && order.size == priority.size &&
79
97
  !same_source?(connector, partner) && (order <=> priority).negative?
80
98
  end
81
99
  end
82
100
 
101
+ def keyed_routes(connector)
102
+ @routes.select { |partner, _points, order| !partner.equal?(connector) && order.is_a?(Array) }
103
+ end
104
+
83
105
  def same_source?(connector, partner)
84
106
  connector.source_element_ref && connector.source_element_ref == partner.source_element_ref
85
107
  end
86
108
 
87
- # Suppress both overlapping spans, including coincident crossings,
88
- # so route order cannot select one of several ambiguous arcs.
89
- def separate_crossings(crossings, axis)
90
- crossings.reject.with_index do |point, index|
91
- crossings.each_with_index.any? do |other, other_index|
92
- index != other_index && (point[axis] - other[axis]).abs < 2 * RADIUS
93
- end
94
- end
109
+ def crossing(first, last, start, finish)
110
+ SegmentGeometry.crossing(first, last, start, finish, RADIUS)
111
+ end
112
+
113
+ def distance(point, other)
114
+ SegmentGeometry.distance(point, other)
115
+ end
116
+
117
+ def arc_commands(first, last, bridge)
118
+ arc = "A #{radii(first, last, bridge)} 180 0 0 #{coordinates(bridge.finish)}"
119
+ commands = ["L #{coordinates(bridge.start)}", arc]
120
+ return commands unless bridge.leftover
121
+
122
+ commands + ["L #{coordinates(bridge.finish)}", "A #{RADIUS} #{RADIUS} 0 1 0 #{coordinates(bridge.finish)}"]
95
123
  end
96
124
 
97
- def crossing(first, last, start, finish, axis)
98
- return unless axis_for(start, finish) == 1 - axis
125
+ # An axis-aligned arc has half its chord length as the radius along
126
+ # the carrier and RADIUS across it; slanted chords keep the circle.
127
+ def radii(first, last, bridge)
128
+ axis = SegmentGeometry.axis_for(first, last)
129
+ return "#{RADIUS} #{RADIUS}" unless axis
99
130
 
100
- point = axis.zero? ? [start[0], first[1]] : [first[0], start[1]]
101
- return unless segment_interior?(point, first, last, axis) &&
102
- segment_interior?(point, start, finish, 1 - axis)
131
+ half = Canvas.coord(SegmentGeometry.distance(bridge.start, bridge.finish) / 2.0)
132
+ axis.zero? ? "#{half} #{RADIUS}" : "#{RADIUS} #{half}"
133
+ end
134
+
135
+ def coordinates(point)
136
+ point.map { |value| Canvas.coord(value) }.join(" ")
137
+ end
138
+ end
103
139
 
104
- point
140
+ # The arc chords decorating one carrier segment, from its crossings.
141
+ #
142
+ # Partners crossing at one point share a single arc (0BE81AC5 draws one
143
+ # arc for three coincident verticals).
144
+ #
145
+ # Axis-aligned carrier: crossings within NEAR_COINCIDENT pixels merge
146
+ # into one arc spanning the outer chord ends (30D62231 draws one
147
+ # 14-long arc over three horizontals one pixel apart; wider overlaps
148
+ # have no corpus case and stay suppressed).
149
+ #
150
+ # Slanted carrier: distinct crossings whose spans overlap are all
151
+ # suppressed so route order cannot select one of several ambiguous
152
+ # arcs. Empirical fit, 0BE81AC5 (the only slanted carrier with three
153
+ # coincident partners in the corpus): EA also writes one zero-length
154
+ # arc after the chord. Fewer coincident partners are not evidenced.
155
+ class BridgeSpans
156
+ def initialize(crossings, first, last)
157
+ @first = first
158
+ @axis = SegmentGeometry.dominant_axis(first, last)
159
+ @aligned = !SegmentGeometry.axis_for(first, last).nil?
160
+ groups = crossings.group_by { |point| point.map { |value| value.round(6) } }.values
161
+ @bridges = groups.map { |group| bridge_at(group, last) }
162
+ end
163
+
164
+ def to_a
165
+ @aligned ? merged : @bridges.reject.with_index { |_bridge, index| overlapping?(index) }
105
166
  end
106
167
 
107
- def segment_interior?(point, first, last, axis)
108
- interior?(point[axis], first[axis], last[axis])
168
+ private
169
+
170
+ def bridge_at(group, last)
171
+ start, finish = SegmentGeometry.chord(@first, last, group.first, ConnectorBridges::RADIUS)
172
+ leftover = !@aligned && group.size >= ConnectorBridges::COINCIDENT_LEFTOVER
173
+ ConnectorBridges::Bridge.new(start, finish, leftover)
109
174
  end
110
175
 
111
- def interior?(value, first, last)
112
- minimum, maximum = [first, last].minmax
113
- value > minimum && value < maximum &&
114
- value - minimum >= RADIUS && maximum - value >= RADIUS
176
+ def merged
177
+ ordered = @bridges.sort_by { |bridge| distance(bridge.start) }
178
+ components = ordered.slice_when { |one, other| !overlap?(one, other) }
179
+ components.filter_map { |component| component.one? ? component.first : merge(component) }
115
180
  end
116
181
 
117
- def axis_for(first, last)
118
- return 0 if first[1] == last[1] && first[0] != last[0]
119
- return 1 if first[0] == last[0] && first[1] != last[1]
182
+ def merge(component)
183
+ return if spread(component) > ConnectorBridges::NEAR_COINCIDENT
120
184
 
121
- nil
185
+ finish = component.map(&:finish).max_by { |point| distance(point) }
186
+ ConnectorBridges::Bridge.new(component.first.start, finish, false)
122
187
  end
123
188
 
124
- def arc_commands(first, last, crossing)
125
- axis = axis_for(first, last)
126
- direction = last[axis] > first[axis] ? 1 : -1
127
- start = crossing.dup
128
- finish = crossing.dup
129
- start[axis] -= direction * RADIUS
130
- finish[axis] += direction * RADIUS
131
- ["L #{coordinates(start)}", "A #{RADIUS} #{RADIUS} 180 0 0 #{coordinates(finish)}"]
189
+ def spread(component)
190
+ centres = component.map { |bridge| bridge.start[@axis] + bridge.finish[@axis] }
191
+ (centres.max - centres.min) / 2.0
132
192
  end
133
193
 
134
- def coordinates(point)
135
- point.map { |value| Canvas.coord(value) }.join(" ")
194
+ def overlapping?(index)
195
+ @bridges.each_with_index.any? do |other, other_index|
196
+ index != other_index && overlap?(@bridges[index], other)
197
+ end
198
+ end
199
+
200
+ def overlap?(one, other)
201
+ minimum, maximum = [one.start[@axis], one.finish[@axis]].minmax
202
+ other_minimum, other_maximum = [other.start[@axis], other.finish[@axis]].minmax
203
+ minimum < other_maximum && other_minimum < maximum
204
+ end
205
+
206
+ def distance(point)
207
+ SegmentGeometry.distance(point, @first)
136
208
  end
137
209
  end
138
210
  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 with the width otherwise so layers of
47
- # different widths never merge.
48
- def self.layer_key(key, width, default)
49
- width == default ? key : :"#{key}_w#{width}"
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, dashed), entries|
57
- line_layer(width, dashed, entries.map(&:last).join("\n "))
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, dashed), path| line_layer(width, dashed, path) }
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
- def line_style(width = stroke_width)
65
- "stroke-width:#{width};stroke-linecap:round;stroke-linejoin:bevel; " \
66
- "fill:#000000;fill-opacity:0.00; stroke:#000000; stroke-opacity:1.00"
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
- [[self.class.stroke_width_for(connector, stroke_width), dashed?(connector)],
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, dashed, body)
89
- key = dashed ? :connector_line_dashed : LINE_STYLE_KEY
90
- style = line_style(width)
91
- style += ";stroke-dasharray:6,3" if dashed
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,95 @@
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", "C" then numbers.each_slice(2).select { |pair| pair.size == 2 }
64
+ when "A" then numbers.each_slice(7).map { |a| a.last(2) }
65
+ else []
66
+ end
67
+ end
68
+ end
69
+
70
+ def self.text_box(attrs)
71
+ style = attrs["style"].to_s
72
+ size = style[/font-size:([\d.]+)/, 1].to_f
73
+ size *= PX_PER_PT if style[/font-size:[\d.]+(pt|px)/, 1] == "pt"
74
+ left = attrs["x"].to_f
75
+ base = attrs["y"].to_f
76
+ [left, base - size.ceil, left + attrs["textLength"].to_f, base]
77
+ end
78
+
79
+ def initialize(shape_points, text_boxes)
80
+ @shapes = box(shape_points.map(&:first), shape_points.map(&:last))
81
+ @texts = box(text_boxes.map(&:first) + text_boxes.map { |b| b[2] },
82
+ text_boxes.map { |b| b[1] } + text_boxes.map { |b| b[3] })
83
+ end
84
+
85
+ private
86
+
87
+ def box(columns, rows)
88
+ return nil if columns.empty?
89
+
90
+ [columns.min, rows.min, columns.max, rows.max]
91
+ end
92
+ end
93
+ end
94
+ end
95
+ end
@@ -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
- def initialize(diagram, model_index:, frame: nil, document: nil, **_options)
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.nil? ? !empty_diagram?(diagram) : 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 = Canvas.from(diagram, model_index: model_index)
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,52 @@ 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
+ content_anchor ? anchor_on_content(canvas, seq) : [canvas, seq]
48
+ end
49
+ private :lay_out
50
+
51
+ def redo_on_drawn(canvas, seq)
52
+ grown = seq.drawn_bounds
53
+ return [canvas, seq] unless grown&.values&.any? &&
54
+ exceeds?(canvas, grown.values)
55
+
56
+ canvas = Canvas.from_drawn(diagram, grown, model_index: model_index,
57
+ content_anchor: content_anchor)
58
+ [canvas, sequence(canvas)]
59
+ end
60
+ private :redo_on_drawn
61
+
62
+ def sequence(canvas)
63
+ seq = LayerSequencer.new(diagram, model_index: model_index,
64
+ canvas: canvas, frame: frame,
65
+ document: document)
66
+ seq.layers
67
+ seq
68
+ end
69
+ private :sequence
70
+
71
+ # Re-renders at the canvas anchored on the measured drawn
72
+ # content when that differs from the stored-rect canvas.
73
+ def anchor_on_content(canvas, seq)
74
+ anchored = CanvasAnchor.reanchor(canvas, seq.layers.drop(1),
75
+ seq.label_reach,
76
+ bottom: BottomMargin.for(diagram, model_index))
77
+ return [canvas, seq] if CanvasAnchor.same_canvas?(canvas, anchored)
78
+
79
+ [anchored, sequence(anchored)]
80
+ end
81
+ private :anchor_on_content
82
+
49
83
  # Walks t_image rows (when the document carries a Database
50
84
  # with an :images collection) and invokes EmfRenderer on
51
85
  # each. Emits an `<image>` element per successfully converted
@@ -86,11 +120,6 @@ module Ea
86
120
  %(<g>#{svg}</g>)
87
121
  end
88
122
  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
123
  end
95
124
  end
96
125
  end
@@ -31,16 +31,24 @@ 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 (round/bevel), or EA's initial pen
36
+ # (square/miter) when the legend is the first thing painted. Held
37
+ # across the corpus: 11 of 11 legends painted first are square, 105
38
+ # of 105 painted later are round.
39
+ INHERITED_STROKE = "stroke-width:1;stroke-linecap:round;stroke-linejoin:bevel;"
40
+ INITIAL_STROKE = "stroke-width:1;stroke-linecap:square;stroke-linejoin:miter;"
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: {}, first_painted: false)
43
+ new(bounds, legend, family, canvas, item_font, first_painted: first_painted).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 = {}, first_painted: false)
40
47
  @bounds = bounds
41
48
  @legend = legend
42
49
  @family = family
43
50
  @canvas = canvas
51
+ @pen = first_painted ? INITIAL_STROKE : INHERITED_STROKE
44
52
  @item_font = item_font[:size]&.nonzero? ? item_font : {}
45
53
  end
46
54
 
@@ -82,19 +90,19 @@ module Ea
82
90
  end
83
91
 
84
92
  def container_style
85
- "stroke-width:1;stroke-linecap:round;stroke-linejoin:bevel; " \
93
+ "#{@pen} " \
86
94
  "fill:#{container_fill};fill-opacity:1.00; " \
87
95
  "stroke:#{container_stroke}; stroke-opacity:1.00"
88
96
  end
89
97
 
90
98
  def icon_style(item)
91
- "stroke-width:1;stroke-linecap:round;stroke-linejoin:bevel; " \
99
+ "#{@pen} " \
92
100
  "fill:#{icon_fill(item)};fill-opacity:1.00; " \
93
101
  "stroke:#000000; stroke-opacity:1.00"
94
102
  end
95
103
 
96
104
  def label_style
97
- "stroke-width:1;stroke-linecap:round;stroke-linejoin:bevel; " \
105
+ "#{@pen} " \
98
106
  "fill:#{label_fill};fill-opacity:1.00; " \
99
107
  "stroke:#000000; stroke-opacity:0.00"
100
108
  end