ea 0.6.140 → 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 (54) 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 +44 -16
  8. data/lib/ea/sources/qea/diagram_builder/routing.rb +64 -22
  9. data/lib/ea/sources/qea/diagram_builder.rb +4 -1
  10. data/lib/ea/sources/xmi/diagram_builder.rb +21 -5
  11. data/lib/ea/sources/xmi/extension_style_parser.rb +1 -1
  12. data/lib/ea/sources/xmi/stored_route/reversed_routes.rb +10 -26
  13. data/lib/ea/spa/generator.rb +4 -2
  14. data/lib/ea/spa/projector.rb +4 -2
  15. data/lib/ea/svg/ea_emitter/bottom_margin.rb +16 -12
  16. data/lib/ea/svg/ea_emitter/bounds_calculator.rb +22 -14
  17. data/lib/ea/svg/ea_emitter/canvas.rb +43 -5
  18. data/lib/ea/svg/ea_emitter/canvas_anchor.rb +12 -4
  19. data/lib/ea/svg/ea_emitter/compartment/attributes.rb +5 -0
  20. data/lib/ea/svg/ea_emitter/compartment/constraints.rb +5 -0
  21. data/lib/ea/svg/ea_emitter/compartment/enum_literals.rb +5 -0
  22. data/lib/ea/svg/ea_emitter/compartment/header.rb +5 -0
  23. data/lib/ea/svg/ea_emitter/compartment/header_divider.rb +5 -0
  24. data/lib/ea/svg/ea_emitter/compartment/instance_slots.rb +5 -0
  25. data/lib/ea/svg/ea_emitter/compartment/instance_underline.rb +5 -0
  26. data/lib/ea/svg/ea_emitter/compartment/marker.rb +5 -0
  27. data/lib/ea/svg/ea_emitter/compartment/note_body.rb +7 -2
  28. data/lib/ea/svg/ea_emitter/compartment/note_text.rb +5 -0
  29. data/lib/ea/svg/ea_emitter/compartment/operations.rb +5 -0
  30. data/lib/ea/svg/ea_emitter/compartment/package_contents.rb +5 -0
  31. data/lib/ea/svg/ea_emitter/compartment/package_from_parent.rb +12 -1
  32. data/lib/ea/svg/ea_emitter/compartment/shape.rb +14 -1
  33. data/lib/ea/svg/ea_emitter/compartment/stereotype_icon.rb +6 -0
  34. data/lib/ea/svg/ea_emitter/compartment/tagged_values.rb +5 -0
  35. data/lib/ea/svg/ea_emitter/compartment.rb +13 -0
  36. data/lib/ea/svg/ea_emitter/connector_bridges.rb +18 -4
  37. data/lib/ea/svg/ea_emitter/content_extent.rb +11 -2
  38. data/lib/ea/svg/ea_emitter/diagram_frame.rb +7 -0
  39. data/lib/ea/svg/ea_emitter/document.rb +21 -5
  40. data/lib/ea/svg/ea_emitter/element/legend_renderer.rb +18 -14
  41. data/lib/ea/svg/ea_emitter/element/note_shape_renderer.rb +1 -8
  42. data/lib/ea/svg/ea_emitter/elements.rb +11 -9
  43. data/lib/ea/svg/ea_emitter/font_resolver.rb +3 -1
  44. data/lib/ea/svg/ea_emitter/label/end_label.rb +72 -10
  45. data/lib/ea/svg/ea_emitter/labels.rb +10 -3
  46. data/lib/ea/svg/ea_emitter/layer_sequencer.rb +14 -7
  47. data/lib/ea/svg/ea_emitter/paint_order.rb +5 -4
  48. data/lib/ea/svg/ea_emitter/pen.rb +19 -0
  49. data/lib/ea/svg/ea_emitter/render_context.rb +1 -1
  50. data/lib/ea/svg/ea_emitter/text_renderer.rb +24 -8
  51. data/lib/ea/svg/ea_emitter.rb +1 -0
  52. data/lib/ea/svg/parity/source.rb +1 -1
  53. data/lib/ea/version.rb +1 -1
  54. metadata +3 -2
@@ -33,6 +33,13 @@ module Ea
33
33
 
34
34
  attr_reader :diagram, :model_index
35
35
 
36
+ # Where the leftmost model x lands: 30 + min_x capped at 35. A
37
+ # negative min_x keeps the 30 of min_x 0 (the corpus has none),
38
+ # so content left of the origin is not drawn off the canvas.
39
+ def self.inset_left_for(min_x)
40
+ [[INSET_LEFT - 5 + min_x, INSET_LEFT - 5].max, INSET_LEFT].min
41
+ end
42
+
36
43
  def initialize(diagram, model_index: nil)
37
44
  @diagram = diagram
38
45
  @model_index = model_index
@@ -53,7 +60,7 @@ module Ea
53
60
  # Left inset scales from 30 up to the 35 cap with the
54
61
  # stored leftmost x: leftmost drawn x = 30 + min_x capped
55
62
  # at 35 (corpus: min_x 0 -> 30, 2 -> 32, 4 -> 34, >=5 -> 35).
56
- @effective_inset_left = [INSET_LEFT - 5 + min_x, INSET_LEFT].min
63
+ @effective_inset_left = self.class.inset_left_for(min_x)
57
64
  [
58
65
  min_x,
59
66
  min_y,
@@ -62,19 +69,7 @@ module Ea
62
69
  ]
63
70
  end
64
71
 
65
- private
66
-
67
- # Element x-extent: logical bounds only (matches EA's canvas
68
- # left/right exactly).
69
- # Element y-extent: union of logical + image_bounds (image
70
- # extends below bounds for shadow / image padding), plus the
71
- # "(from Parent)" subtitle extent: EA draws the subtitle 15px
72
- # below the body and its text descent adds ~4 more, reserving
73
- # 19px of canvas below subtitle-bearing packages (the +19
74
- # height family: 05DF5000 Main, Metadata All, package
75
- # Clients/Suppliers contexts).
76
- SUBTITLE_CANVAS_EXTRA = 19
77
-
72
+ # Canvas points reserved below "(from Parent)" subtitles.
78
73
  def subtitle_points
79
74
  pts = []
80
75
  (diagram.elements || []).each do |e|
@@ -88,6 +83,19 @@ module Ea
88
83
  pts
89
84
  end
90
85
 
86
+ private
87
+
88
+ # Element x-extent: logical bounds only (matches EA's canvas
89
+ # left/right exactly).
90
+ # Element y-extent: union of logical + image_bounds (image
91
+ # extends below bounds for shadow / image padding), plus the
92
+ # "(from Parent)" subtitle extent: EA draws the subtitle 15px
93
+ # below the body and its text descent adds ~4 more, reserving
94
+ # 19px of canvas below subtitle-bearing packages (the +19
95
+ # height family: 05DF5000 Main, Metadata All, package
96
+ # Clients/Suppliers contexts).
97
+ SUBTITLE_CANVAS_EXTRA = 19
98
+
91
99
  def element_points
92
100
  pts = []
93
101
  (diagram.elements || []).each do |e|
@@ -23,6 +23,8 @@ module Ea
23
23
  FRAME_INSET_LEFT = 35
24
24
  INSET_TOUCHING_ORIGIN = 30
25
25
  FRAME_INSET_TOP = 40
26
+ # Where the diagram frame's border line sits from the canvas edge.
27
+ FRAME_BORDER_INSET = 6
26
28
 
27
29
  attr_reader :min_x, :min_y, :width, :height, :inset_left, :inset_top
28
30
 
@@ -32,8 +34,8 @@ module Ea
32
34
  insets: [FRAME_INSET_LEFT, FRAME_INSET_TOP])
33
35
  @min_x = min_x
34
36
  @min_y = min_y
35
- @width = width
36
- @height = height
37
+ @width = whole(width)
38
+ @height = whole(height)
37
39
  @inset_left, @inset_top = insets
38
40
  end
39
41
 
@@ -60,11 +62,23 @@ module Ea
60
62
  bottom: BottomMargin.for(diagram, model_index))
61
63
  end
62
64
 
65
+ return empty_framed(diagram, min_x, min_y) unless bc.max_x
66
+
63
67
  new(min_x: min_x, min_y: min_y, width: width, height: height,
64
68
  insets: [bc.effective_inset_left || FRAME_INSET_LEFT,
65
69
  FRAME_INSET_TOP])
66
70
  end
67
71
 
72
+ # No element to size on: the frame alone, wide enough for its
73
+ # name tab.
74
+ def self.empty_framed(diagram, min_x, min_y)
75
+ width = [INSET_TOUCHING_ORIGIN + BoundsCalculator::INSET_RIGHT,
76
+ DiagramFrame.new(canvas: nil).min_width(diagram)].max
77
+ new(min_x: min_x, min_y: min_y, width: width,
78
+ height: BoundsCalculator::INSET_TOP + BoundsCalculator::INSET_BOTTOM,
79
+ insets: [BoundsCalculator.inset_left_for(min_x), FRAME_INSET_TOP])
80
+ end
81
+
68
82
  # Second-pass canvas over the DRAWN extents captured by the
69
83
  # element pass (grown classifiers, autosized packages, plus
70
84
  # BoundsCalculator's subtitle reservation). EA sizes the
@@ -79,7 +93,7 @@ module Ea
79
93
  pts << [b.x, b.y]
80
94
  pts << [b.x + b.width, b.y + b.height]
81
95
  end
82
- extra = base.send(:subtitle_points)
96
+ extra = base.subtitle_points
83
97
  min_x = (pts + extra).map(&:first).min || 0
84
98
  min_y = (pts + extra).map(&:last).min || 0
85
99
  max_x = (pts + extra).map(&:first).max || 0
@@ -95,8 +109,7 @@ module Ea
95
109
 
96
110
  # Same scaled rule as BoundsCalculator: drawn leftmost =
97
111
  # min(35, 30 + min_x).
98
- inset_left = [BoundsCalculator::INSET_LEFT - 5 + min_x,
99
- BoundsCalculator::INSET_LEFT].min
112
+ inset_left = BoundsCalculator.inset_left_for(min_x)
100
113
  new(min_x: min_x, min_y: min_y,
101
114
  width: (max_x - min_x) + inset_left +
102
115
  BoundsCalculator::INSET_RIGHT,
@@ -105,6 +118,25 @@ module Ea
105
118
  insets: [inset_left, FRAME_INSET_TOP])
106
119
  end
107
120
 
121
+ # This canvas when every point of `shapes` ([min_x, min_y, max_x,
122
+ # max_y] in canvas coordinates) lies inside its frame border (the
123
+ # FRAME_BORDER_INSET line), otherwise a canvas grown so the shapes
124
+ # sit CONTENT_MARGIN inside the border on the overflowing sides.
125
+ def holding(shapes)
126
+ left, top, right, bottom = shapes
127
+ reach = CanvasAnchor::CONTENT_MARGIN
128
+ grow_left = left < FRAME_BORDER_INSET ? reach - left : 0
129
+ grow_top = top < FRAME_BORDER_INSET ? reach - top : 0
130
+ new_width = right > width - FRAME_BORDER_INSET ? right + reach : width
131
+ new_height = bottom > height - FRAME_BORDER_INSET ? bottom + reach : height
132
+ return self if [grow_left, grow_top].all?(&:zero?) &&
133
+ new_width == width && new_height == height
134
+
135
+ Canvas.new(min_x: min_x - grow_left, min_y: min_y - grow_top,
136
+ width: new_width + grow_left, height: new_height + grow_top,
137
+ insets: [inset_left, inset_top])
138
+ end
139
+
108
140
  def view_box
109
141
  "0 0 #{width} #{height}"
110
142
  end
@@ -139,6 +171,12 @@ module Ea
139
171
 
140
172
  private
141
173
 
174
+ # Measured extents arrive as Floats ("508.0"); EA writes whole
175
+ # pixels into the viewBox and background rect.
176
+ def whole(pixels)
177
+ pixels.is_a?(Float) && pixels == pixels.to_i ? pixels.to_i : pixels
178
+ end
179
+
142
180
  def format_cm(px)
143
181
  return "0cm" if px.nil? || px.zero?
144
182
 
@@ -25,6 +25,12 @@ module Ea
25
25
  # 4881CEEE, 70C77F92, 846DD800, 88AF6BD7, AC2191E3). Below, the
26
26
  # same box (its top is the first baseline less the font height,
27
27
  # its height the stored CY) + 5 holds the canvas open on 95246DCF.
28
+ #
29
+ # Drawn text also holds the right and bottom edges open (text
30
+ # right/bottom + TEXT_MARGIN, symmetric with the origin rule), so
31
+ # a name wider than its box is not clipped. Checked on the 188
32
+ # PLATEAU natives: widths/heights unchanged (185/188, 188/188);
33
+ # 8DAB169F moves 13 closer; no native contradicts it.
28
34
  module CanvasAnchor
29
35
  CONTENT_MARGIN = 10
30
36
  TEXT_MARGIN = 5
@@ -66,18 +72,20 @@ module Ea
66
72
  origin_x = origin(shapes && shapes[0], texts && texts[0])
67
73
  origin_y = origin(shapes && shapes[1], texts && texts[1])
68
74
  Canvas.new(min_x: origin_x, min_y: origin_y,
69
- width: right_edge(shapes, label_reach) - origin_x,
70
- height: bottom_edge(shapes, label_reach, bottom) - origin_y,
75
+ width: right_edge(shapes, texts, label_reach) - origin_x,
76
+ height: bottom_edge(shapes, texts, label_reach, bottom) - origin_y,
71
77
  insets: [0, 0])
72
78
  end
73
79
 
74
- def right_edge(shapes, label_reach)
80
+ def right_edge(shapes, texts, label_reach)
75
81
  [shapes && shapes[2] + CONTENT_MARGIN,
82
+ texts && (texts[2] + TEXT_MARGIN).ceil,
76
83
  label_reach && label_reach[0] + LABEL_BOX_MARGIN].compact.max
77
84
  end
78
85
 
79
- def bottom_edge(shapes, label_reach, bottom)
86
+ def bottom_edge(shapes, texts, label_reach, bottom)
80
87
  [shapes[3] + bottom,
88
+ texts && (texts[3] + TEXT_MARGIN).ceil,
81
89
  label_reach && label_reach[1] + LABEL_BOX_MARGIN].compact.max
82
90
  end
83
91
 
@@ -6,6 +6,11 @@ module Ea
6
6
  module Compartment
7
7
  # Attribute compartment. Skipped when no attribute lines.
8
8
  module Attributes
9
+ # The pen the first group of this compartment leaves behind.
10
+ def self.pen(_context)
11
+ Pen::ROUND_BEVEL
12
+ end
13
+
9
14
  module_function
10
15
 
11
16
  def render(context)
@@ -11,6 +11,11 @@ module Ea
11
11
  # content and suppresses them when they would overflow the
12
12
  # stored box height (never truncates rows).
13
13
  module Constraints
14
+ # The pen the first group of this compartment leaves behind.
15
+ def self.pen(_context)
16
+ Pen::ROUND_BEVEL
17
+ end
18
+
14
19
  module_function
15
20
 
16
21
  def render(context)
@@ -8,6 +8,11 @@ module Ea
8
8
  # italic header + each literal name. Skipped when the
9
9
  # classifier has no literals or no content geometry.
10
10
  module EnumLiterals
11
+ # The pen the first group of this compartment leaves behind.
12
+ def self.pen(_context)
13
+ Pen::ROUND_BEVEL
14
+ end
15
+
11
16
  module_function
12
17
 
13
18
  # Caption and literal rows share the same baseline spacing.
@@ -7,6 +7,11 @@ module Ea
7
7
  # Header compartment: stereotype label + class name. Skips
8
8
  # rendering when the classifier produces no header lines.
9
9
  module Header
10
+ # The pen the first group of this compartment leaves behind.
11
+ def self.pen(_context)
12
+ Pen::ROUND_BEVEL
13
+ end
14
+
10
15
  module_function
11
16
 
12
17
  def render(context)
@@ -8,6 +8,11 @@ module Ea
8
8
  # compartment (attributes / operations / literals / tagged
9
9
  # values). Skipped when there is no content below.
10
10
  module HeaderDivider
11
+ # The pen the first group of this compartment leaves behind.
12
+ def self.pen(_context)
13
+ Pen::ROUND_BEVEL
14
+ end
15
+
11
16
  module_function
12
17
 
13
18
  def render(context)
@@ -12,6 +12,11 @@ module Ea
12
12
  # InstanceSpecification or has neither slots nor a
13
13
  # classifier.
14
14
  module InstanceSlots
15
+ # The pen the first group of this compartment leaves behind.
16
+ def self.pen(_context)
17
+ Pen::ROUND_BEVEL
18
+ end
19
+
15
20
  ROW_OFFSET = 13
16
21
  SUBTITLE_OFFSET_FROM_BOTTOM = 15
17
22
 
@@ -8,6 +8,11 @@ module Ea
8
8
  # short horizontal line below each instance name to denote
9
9
  # that it is an instance (UML convention).
10
10
  module InstanceUnderline
11
+ # The pen the first group of this compartment leaves behind.
12
+ def self.pen(_context)
13
+ Pen::SQUARE_MITER
14
+ end
15
+
11
16
  Y_OFFSET = 1
12
17
 
13
18
  module_function
@@ -10,6 +10,11 @@ module Ea
10
10
  # Real OCL constraints render trailing, after the content —
11
11
  # the two are distinct compartments.
12
12
  module Marker
13
+ # The pen the first group of this compartment leaves behind.
14
+ def self.pen(_context)
15
+ Pen::ROUND_BEVEL
16
+ end
17
+
13
18
  module_function
14
19
 
15
20
  def render(context)
@@ -15,6 +15,11 @@ module Ea
15
15
 
16
16
  module_function
17
17
 
18
+ # The text group keeps the pen the note outline was drawn with.
19
+ def pen(context)
20
+ context.previous_pen || Pen::INITIAL
21
+ end
22
+
18
23
  def render(context)
19
24
  return nil if context.model_element.is_a?(Ea::Model::Note) &&
20
25
  context.model_element.legend
@@ -63,7 +68,7 @@ module Ea
63
68
  # [pitch, first-baseline offset] for the note font.
64
69
  def vertical_layout(context)
65
70
  size = context.size
66
- height = context.logical_height
71
+ height = Ea::Fonts::Metrics.source_logical_height(context.logical_height)
67
72
  height ||= Ea::Fonts::Metrics.gdi_logical_height(size) if context.size_unit == "pt"
68
73
  ascent, descent = Ea::Fonts::Metrics.gdi_cell_metrics(context.family, height)
69
74
  return [size + 7, size + 9] unless ascent
@@ -121,7 +126,7 @@ module Ea
121
126
  # (EA never sets a note pen; 7C44AC09 square/miter, 7D960BD8
122
127
  # square/bevel), so the next element inherits the right pen.
123
128
  def group_style(context)
124
- cap, join = context.previous_pen || Element::NoteShapeRenderer::INITIAL_PEN
129
+ cap, join = pen(context)
125
130
  "stroke-width:1;stroke-linecap:#{cap};stroke-linejoin:#{join}; " \
126
131
  "fill:#{context.theme.text_color};fill-opacity:1.00; " \
127
132
  "stroke:#000000; stroke-opacity:0.00"
@@ -10,6 +10,11 @@ module Ea
10
10
  # Lines are estimated by character width and split at word
11
11
  # boundaries to fit the element bounds.
12
12
  module NoteText
13
+ # The pen the first group of this compartment leaves behind.
14
+ def self.pen(_context)
15
+ Pen::ROUND_BEVEL
16
+ end
17
+
13
18
  X_PADDING = 5
14
19
  HAND_DRAW_X_PADDING = 10
15
20
  Y_FIRST_OFFSET = 15
@@ -7,6 +7,11 @@ module Ea
7
7
  # Operation compartment: divider line + operation signatures.
8
8
  # Skipped when no operations or when geometry cannot fit them.
9
9
  module Operations
10
+ # The pen the first group of this compartment leaves behind.
11
+ def self.pen(_context)
12
+ Pen::ROUND_BEVEL
13
+ end
14
+
10
15
  module_function
11
16
 
12
17
  def render(context)
@@ -11,6 +11,11 @@ module Ea
11
11
  # text (Enumeration gets a list-style icon; other classifiers
12
12
  # get the "folded paper" silhouette).
13
13
  module PackageContents
14
+ # The pen the first group of this compartment leaves behind.
15
+ def self.pen(_context)
16
+ Pen::SQUARE_BEVEL
17
+ end
18
+
14
19
  ROW_HEIGHT = 16
15
20
  ICON_X_OFFSET = 5
16
21
  TEXT_X_OFFSET = 23
@@ -13,6 +13,11 @@ module Ea
13
13
  # (379 diagrams, 378 exact): classifiers never render the
14
14
  # subtitle, and ShowForeign=0 diagrams render none.
15
15
  module PackageFromParent
16
+ # The pen the first group of this compartment leaves behind.
17
+ def self.pen(_context)
18
+ Pen::ROUND_BEVEL
19
+ end
20
+
16
21
  module_function
17
22
 
18
23
  def render(context)
@@ -49,6 +54,10 @@ module Ea
49
54
  ASCENT_FAMILIES = %w[cambria carlito].freeze
50
55
  GAP_BELOW_BODY = 5
51
56
  LEGACY_Y_OFFSET_FROM_BOTTOM = 15
57
+ # Heights above this (a 100pt caption is H143) are corrupt
58
+ # input; they keep the legacy offset instead of pushing the
59
+ # caption far off the element.
60
+ MAX_CAPTION_HEIGHT = 60
52
61
 
53
62
  LINE_PITCH = 13
54
63
  WRAP_PADDING = 15
@@ -82,10 +91,12 @@ module Ea
82
91
  module_function :subtitle
83
92
 
84
93
  def offset_from_bottom(context)
85
- height = context.logical_height
94
+ height = Ea::Fonts::Metrics.source_logical_height(context.logical_height)
86
95
  height ||= Ea::Fonts::Metrics.gdi_logical_height(context.size) if context.size_unit == "pt"
87
96
  return LEGACY_Y_OFFSET_FROM_BOTTOM unless ASCENT_FAMILIES.include?(context.family.to_s.downcase)
88
97
 
98
+ return LEGACY_Y_OFFSET_FROM_BOTTOM unless height.nil? || height <= MAX_CAPTION_HEIGHT
99
+
89
100
  ascent, = Ea::Fonts::Metrics.gdi_cell_metrics(context.family, height)
90
101
  ascent ? GAP_BELOW_BODY + ascent : LEGACY_Y_OFFSET_FROM_BOTTOM
91
102
  end
@@ -9,6 +9,19 @@ module Ea
9
9
  module Shape
10
10
  module_function
11
11
 
12
+ # Notes and legends take the pen the previous drawing left; every
13
+ # other shape draws its own round/bevel pen.
14
+ def pen(context)
15
+ return Pen::ROUND_BEVEL unless pen_inheriting?(context)
16
+
17
+ context.previous_pen || Pen::INITIAL
18
+ end
19
+
20
+ def pen_inheriting?(context)
21
+ legend?(context) || context.model_element.is_a?(Ea::Model::Note)
22
+ end
23
+ private_class_method :pen_inheriting?
24
+
12
25
  def render(context)
13
26
  if legend?(context)
14
27
  Element::LegendRenderer.render(
@@ -17,7 +30,7 @@ module Ea
17
30
  family: context.family,
18
31
  item_font: { size: legend_item_size(context), unit: context.size_unit,
19
32
  logical_height: context.logical_height },
20
- first_painted: context.first_painted
33
+ pen: context.previous_pen
21
34
  )
22
35
  elsif context.model_element.is_a?(Ea::Model::Note)
23
36
  Element::NoteShapeRenderer.render(
@@ -13,6 +13,12 @@ module Ea
13
13
  # Rendered after the header so the icon sits inside the
14
14
  # element body below the stereotype label.
15
15
  module StereotypeIcon
16
+ # ShapeScript output sets no stroke pen, so the icon leaves the
17
+ # previous pen in force.
18
+ def self.pen(_context)
19
+ nil
20
+ end
21
+
16
22
  module_function
17
23
 
18
24
  def render(context)
@@ -7,6 +7,11 @@ module Ea
7
7
  # Tagged value compartment. Skipped when no tagged values
8
8
  # or when geometry cannot fit them.
9
9
  module TaggedValues
10
+ # The pen the first group of this compartment leaves behind.
11
+ def self.pen(_context)
12
+ Pen::ROUND_BEVEL
13
+ end
14
+
10
15
  DEFAULT_TEXT_COLOR = "#000000"
11
16
 
12
17
  module_function
@@ -49,6 +49,10 @@ module Ea
49
49
  InstanceUnderline
50
50
  ].freeze
51
51
 
52
+ # One rendered compartment and the pen its first group leaves behind
53
+ # (nil when it sets none, leaving the previous pen in force).
54
+ Painted = Struct.new(:svg, :pen)
55
+
52
56
  module_function
53
57
 
54
58
  # Renders every applicable compartment for the context.
@@ -57,6 +61,15 @@ module Ea
57
61
  def render_all(context)
58
62
  ALL.map { |compartment| compartment.render(context) }
59
63
  end
64
+
65
+ # Like render_all, but each applicable compartment comes with the
66
+ # pen it leaves behind; compartments that do not apply are dropped.
67
+ def paint_all(context)
68
+ ALL.filter_map do |compartment|
69
+ svg = compartment.render(context)
70
+ Painted.new(svg, compartment.pen(context)) if svg
71
+ end
72
+ end
60
73
  end
61
74
  end
62
75
  end
@@ -21,10 +21,9 @@ module Ea
21
21
  def path_for(connector, points)
22
22
  priority = priority_for(connector)
23
23
  return centerline(connector, points) unless priority
24
+ return direct_path(connector, priority, points) if points.size == 2 && direct?(connector)
24
25
 
25
- segments = points.each_cons(2).map do |first, last|
26
- crossings_for(connector, priority, first, last)
27
- end
26
+ segments = bridge_segments(connector, priority, points)
28
27
  return centerline(connector, points) if segments.all?(&:empty?)
29
28
 
30
29
  serialize(points, segments)
@@ -32,6 +31,21 @@ module Ea
32
31
 
33
32
  private
34
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
+
35
49
  def centerline(connector, points)
36
50
  seats = points.map { |point| coordinates(point) }
37
51
  # Direct lines retain EA's target-first repeat and source-first model.
@@ -94,7 +108,7 @@ module Ea
94
108
 
95
109
  @routes.select do |partner, _points, order|
96
110
  !partner.equal?(connector) && order.is_a?(Array) && order.size == priority.size &&
97
- !same_source?(connector, partner) && (order <=> priority).negative?
111
+ !same_source?(connector, partner) && (order <=> priority)&.negative?
98
112
  end
99
113
  end
100
114
 
@@ -60,13 +60,22 @@ module Ea
60
60
  path_data.scan(PATH_COMMAND).flat_map do |command, body|
61
61
  numbers = body.scan(NUMBER).map(&:to_f)
62
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) }
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)
65
66
  else []
66
67
  end
67
68
  end
68
69
  end
69
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
+
70
79
  def self.text_box(attrs)
71
80
  style = attrs["style"].to_s
72
81
  size = style[/font-size:([\d.]+)/, 1].to_f
@@ -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)
@@ -44,10 +44,25 @@ module Ea
44
44
  content_anchor: content_anchor)
45
45
  seq = sequence(canvas)
46
46
  canvas, seq = redo_on_drawn(canvas, seq)
47
- content_anchor ? anchor_on_content(canvas, seq) : [canvas, seq]
47
+ return anchor_on_content(canvas, seq) if content_anchor
48
+
49
+ frame ? grow_to_drawn_shapes(canvas, seq) : [canvas, seq]
48
50
  end
49
51
  private :lay_out
50
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
+
51
66
  def redo_on_drawn(canvas, seq)
52
67
  grown = seq.drawn_bounds
53
68
  return [canvas, seq] unless grown&.values&.any? &&
@@ -103,13 +118,14 @@ module Ea
103
118
  %(<g id="images">\n#{fragments.join("\n")}\n</g>)
104
119
  end
105
120
 
106
- # True when any drawn bound falls outside the stored-rect
107
- # 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).
108
124
  def exceeds?(canvas, bounds)
109
125
  bounds.any? do |b|
110
126
  b.x < canvas.min_x || b.y < canvas.min_y ||
111
- b.x + b.width > canvas.min_x + canvas.width ||
112
- 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
113
129
  end
114
130
  end
115
131