ea 0.6.139 → 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 (34) 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 +37 -10
  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 +83 -6
  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 +99 -25
  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.rb +4 -0
  33. data/lib/ea/version.rb +1 -1
  34. metadata +7 -1
@@ -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)] }
@@ -39,9 +42,16 @@ module Ea
39
42
  end
40
43
  end
41
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
+
42
51
  def direct?(connector)
43
52
  style = connector.style || {}
44
- style.key?(:direct) || style.key?("direct")
53
+ style.key?(:direct) || style.key?("direct") ||
54
+ TARGET_FIRST_TYPES.include?(connector.connector_type)
45
55
  end
46
56
 
47
57
  def serialize(points, segments)
@@ -74,7 +84,7 @@ module Ea
74
84
  crossing(first, last, start, finish)
75
85
  end
76
86
  end
77
- separate_crossings(crossings, first, last).sort_by { |point| distance(point, first) }
87
+ BridgeSpans.new(crossings, first, last).to_a.sort_by { |bridge| distance(bridge.start, first) }
78
88
  end
79
89
 
80
90
  # The order key is only proven across different start placements, so
@@ -96,26 +106,6 @@ module Ea
96
106
  connector.source_element_ref && connector.source_element_ref == partner.source_element_ref
97
107
  end
98
108
 
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
109
  def crossing(first, last, start, finish)
120
110
  SegmentGeometry.crossing(first, last, start, finish, RADIUS)
121
111
  end
@@ -124,15 +114,99 @@ module Ea
124
114
  SegmentGeometry.distance(point, other)
125
115
  end
126
116
 
127
- def arc_commands(first, last, crossing)
128
- start, finish = SegmentGeometry.chord(first, last, crossing, RADIUS)
129
- ["L #{coordinates(start)}", "A #{RADIUS} #{RADIUS} 180 0 0 #{coordinates(finish)}"]
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)}"]
123
+ end
124
+
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
130
+
131
+ half = Canvas.coord(SegmentGeometry.distance(bridge.start, bridge.finish) / 2.0)
132
+ axis.zero? ? "#{half} #{RADIUS}" : "#{RADIUS} #{half}"
130
133
  end
131
134
 
132
135
  def coordinates(point)
133
136
  point.map { |value| Canvas.coord(value) }.join(" ")
134
137
  end
135
138
  end
139
+
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) }
166
+ end
167
+
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)
174
+ end
175
+
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) }
180
+ end
181
+
182
+ def merge(component)
183
+ return if spread(component) > ConnectorBridges::NEAR_COINCIDENT
184
+
185
+ finish = component.map(&:finish).max_by { |point| distance(point) }
186
+ ConnectorBridges::Bridge.new(component.first.start, finish, false)
187
+ end
188
+
189
+ def spread(component)
190
+ centres = component.map { |bridge| bridge.start[@axis] + bridge.finish[@axis] }
191
+ (centres.max - centres.min) / 2.0
192
+ end
193
+
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)
208
+ end
209
+ end
136
210
  end
137
211
  end
138
212
  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