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.
Files changed (61) hide show
  1. checksums.yaml +4 -4
  2. data/lib/ea/cli/app.rb +10 -0
  3. data/lib/ea/cli/command/diagrams.rb +4 -1
  4. data/lib/ea/cli/command/render.rb +8 -1
  5. data/lib/ea/cli/command/spa.rb +2 -1
  6. data/lib/ea/cli/command/svg.rb +9 -6
  7. data/lib/ea/fonts/metrics.rb +54 -6
  8. data/lib/ea/model/note.rb +2 -0
  9. data/lib/ea/sources/qea/diagram_builder/routing.rb +188 -8
  10. data/lib/ea/sources/qea/diagram_builder.rb +14 -1
  11. data/lib/ea/sources/qea/note_builder.rb +7 -1
  12. data/lib/ea/sources/xmi/diagram_builder/element_borders.rb +27 -0
  13. data/lib/ea/sources/xmi/diagram_builder.rb +58 -15
  14. data/lib/ea/sources/xmi/extension_style_parser.rb +1 -1
  15. data/lib/ea/sources/xmi/note_builder.rb +2 -1
  16. data/lib/ea/sources/xmi/stored_route/reversed_routes.rb +78 -0
  17. data/lib/ea/sources/xmi/stored_route.rb +83 -6
  18. data/lib/ea/spa/generator.rb +4 -2
  19. data/lib/ea/spa/projector.rb +4 -2
  20. data/lib/ea/svg/ea_emitter/bottom_margin.rb +104 -0
  21. data/lib/ea/svg/ea_emitter/bounds_calculator.rb +25 -15
  22. data/lib/ea/svg/ea_emitter/canvas.rb +80 -14
  23. data/lib/ea/svg/ea_emitter/canvas_anchor.rb +132 -0
  24. data/lib/ea/svg/ea_emitter/compartment/attributes.rb +5 -0
  25. data/lib/ea/svg/ea_emitter/compartment/constraints.rb +5 -0
  26. data/lib/ea/svg/ea_emitter/compartment/enum_literals.rb +5 -0
  27. data/lib/ea/svg/ea_emitter/compartment/header.rb +5 -0
  28. data/lib/ea/svg/ea_emitter/compartment/header_divider.rb +5 -0
  29. data/lib/ea/svg/ea_emitter/compartment/instance_slots.rb +5 -0
  30. data/lib/ea/svg/ea_emitter/compartment/instance_underline.rb +5 -0
  31. data/lib/ea/svg/ea_emitter/compartment/marker.rb +5 -0
  32. data/lib/ea/svg/ea_emitter/compartment/note_body.rb +11 -2
  33. data/lib/ea/svg/ea_emitter/compartment/note_text.rb +5 -0
  34. data/lib/ea/svg/ea_emitter/compartment/operations.rb +5 -0
  35. data/lib/ea/svg/ea_emitter/compartment/package_contents.rb +5 -0
  36. data/lib/ea/svg/ea_emitter/compartment/package_from_parent.rb +34 -4
  37. data/lib/ea/svg/ea_emitter/compartment/shape.rb +30 -5
  38. data/lib/ea/svg/ea_emitter/compartment/stereotype_icon.rb +6 -0
  39. data/lib/ea/svg/ea_emitter/compartment/tagged_values.rb +5 -0
  40. data/lib/ea/svg/ea_emitter/compartment.rb +13 -0
  41. data/lib/ea/svg/ea_emitter/connector_bridges.rb +117 -29
  42. data/lib/ea/svg/ea_emitter/connectors.rb +67 -17
  43. data/lib/ea/svg/ea_emitter/content_extent.rb +104 -0
  44. data/lib/ea/svg/ea_emitter/diagram_frame.rb +7 -0
  45. data/lib/ea/svg/ea_emitter/document.rb +76 -31
  46. data/lib/ea/svg/ea_emitter/element/legend_renderer.rb +22 -10
  47. data/lib/ea/svg/ea_emitter/element/note_shape_renderer.rb +57 -32
  48. data/lib/ea/svg/ea_emitter/elements.rb +24 -5
  49. data/lib/ea/svg/ea_emitter/font_resolver.rb +3 -1
  50. data/lib/ea/svg/ea_emitter/label/end_label.rb +134 -14
  51. data/lib/ea/svg/ea_emitter/labels.rb +16 -3
  52. data/lib/ea/svg/ea_emitter/layer_sequencer.rb +54 -33
  53. data/lib/ea/svg/ea_emitter/markers.rb +22 -10
  54. data/lib/ea/svg/ea_emitter/paint_order.rb +30 -0
  55. data/lib/ea/svg/ea_emitter/pen.rb +19 -0
  56. data/lib/ea/svg/ea_emitter/render_context.rb +14 -3
  57. data/lib/ea/svg/ea_emitter/text_renderer.rb +24 -8
  58. data/lib/ea/svg/ea_emitter.rb +5 -0
  59. data/lib/ea/svg/parity/source.rb +1 -1
  60. data/lib/ea/version.rb +1 -1
  61. 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 = points.each_cons(2).map do |first, last|
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
- separate_crossings(crossings, first, last).sort_by { |point| distance(point, first) }
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).negative?
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, crossing)
128
- start, finish = SegmentGeometry.chord(first, last, crossing, RADIUS)
129
- ["L #{coordinates(start)}", "A #{RADIUS} #{RADIUS} 180 0 0 #{coordinates(finish)}"]
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 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,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
- 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,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 stored-rect
73
- # canvas content area.
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.min_x + canvas.width ||
78
- b.y + b.height > canvas.min_y + canvas.height
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
- "stroke-width:1;stroke-linecap:round;stroke-linejoin:bevel; " \
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
- "stroke-width:1;stroke-linecap:round;stroke-linejoin:bevel; " \
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
- "stroke-width:1;stroke-linecap:round;stroke-linejoin:bevel; " \
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