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
@@ -23,26 +23,60 @@ 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
- attr_reader :min_x, :min_y, :width, :height, :inset_left
29
+ attr_reader :min_x, :min_y, :width, :height, :inset_left, :inset_top
28
30
 
29
- def initialize(min_x:, min_y:, width:, height:, inset_left: FRAME_INSET_LEFT)
31
+ # `insets` is [left, top]: where model (min_x, min_y) lands on
32
+ # the canvas.
33
+ def initialize(min_x:, min_y:, width:, height:,
34
+ insets: [FRAME_INSET_LEFT, FRAME_INSET_TOP])
30
35
  @min_x = min_x
31
36
  @min_y = min_y
32
- @width = width
33
- @height = height
34
- @inset_left = inset_left
37
+ @width = whole(width)
38
+ @height = whole(height)
39
+ @inset_left, @inset_top = insets
40
+ end
41
+
42
+ # Model-space origin of this canvas.
43
+ def origin_x
44
+ min_x - inset_left
45
+ end
46
+
47
+ def origin_y
48
+ min_y - inset_top
35
49
  end
36
50
 
37
51
  # Build a Canvas for a diagram by running the bound
38
52
  # calculator. Defaults model_index to nil (no canvas-aware
39
53
  # element bounds needed) for callers that only need a
40
54
  # frame-size estimate.
41
- def self.from(diagram, model_index: nil)
55
+ def self.from(diagram, model_index: nil, content_anchor: false)
42
56
  bc = BoundsCalculator.new(diagram, model_index: model_index)
43
57
  min_x, min_y, width, height = bc.compute
58
+ if content_anchor
59
+ return CanvasAnchor.blank unless bc.max_x
60
+
61
+ return CanvasAnchor.stored(min_x, min_y, bc.max_x, bc.max_y,
62
+ bottom: BottomMargin.for(diagram, model_index))
63
+ end
64
+
65
+ return empty_framed(diagram, min_x, min_y) unless bc.max_x
66
+
44
67
  new(min_x: min_x, min_y: min_y, width: width, height: height,
45
- inset_left: bc.effective_inset_left || FRAME_INSET_LEFT)
68
+ insets: [bc.effective_inset_left || FRAME_INSET_LEFT,
69
+ FRAME_INSET_TOP])
70
+ end
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])
46
80
  end
47
81
 
48
82
  # Second-pass canvas over the DRAWN extents captured by the
@@ -51,30 +85,56 @@ module Ea
51
85
  # canvas from drawn extents, so diagrams whose lowest/rightmost
52
86
  # element grew get a taller/wider canvas than the stored-rect
53
87
  # first pass provides.
54
- def self.from_drawn(diagram, drawn_bounds, model_index: nil)
88
+ def self.from_drawn(diagram, drawn_bounds, model_index: nil,
89
+ content_anchor: false)
55
90
  base = BoundsCalculator.new(diagram, model_index: model_index)
56
91
  pts = []
57
92
  drawn_bounds.each_value do |b|
58
93
  pts << [b.x, b.y]
59
94
  pts << [b.x + b.width, b.y + b.height]
60
95
  end
61
- extra = base.send(:subtitle_points)
96
+ extra = base.subtitle_points
62
97
  min_x = (pts + extra).map(&:first).min || 0
63
98
  min_y = (pts + extra).map(&:last).min || 0
64
99
  max_x = (pts + extra).map(&:first).max || 0
65
100
  max_y = (pts + extra).map(&:last).max || 0
66
- return from(diagram, model_index: model_index) if pts.empty?
101
+ if pts.empty?
102
+ return from(diagram, model_index: model_index,
103
+ content_anchor: content_anchor)
104
+ end
105
+ if content_anchor
106
+ return CanvasAnchor.stored(min_x, min_y, max_x, max_y,
107
+ bottom: BottomMargin.for(diagram, model_index))
108
+ end
67
109
 
68
110
  # Same scaled rule as BoundsCalculator: drawn leftmost =
69
111
  # min(35, 30 + min_x).
70
- inset_left = [BoundsCalculator::INSET_LEFT - 5 + min_x,
71
- BoundsCalculator::INSET_LEFT].min
112
+ inset_left = BoundsCalculator.inset_left_for(min_x)
72
113
  new(min_x: min_x, min_y: min_y,
73
114
  width: (max_x - min_x) + inset_left +
74
115
  BoundsCalculator::INSET_RIGHT,
75
116
  height: (max_y - min_y) + BoundsCalculator::INSET_TOP +
76
117
  BoundsCalculator::INSET_BOTTOM,
77
- inset_left: inset_left)
118
+ insets: [inset_left, FRAME_INSET_TOP])
119
+ end
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])
78
138
  end
79
139
 
80
140
  def view_box
@@ -97,7 +157,7 @@ module Ea
97
157
  end
98
158
 
99
159
  def translate_y(y)
100
- y - min_y + FRAME_INSET_TOP
160
+ y - min_y + inset_top
101
161
  end
102
162
 
103
163
  # Compact coordinate formatter. Integers lose the decimal
@@ -111,6 +171,12 @@ module Ea
111
171
 
112
172
  private
113
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
+
114
180
  def format_cm(px)
115
181
  return "0cm" if px.nil? || px.zero?
116
182
 
@@ -0,0 +1,132 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ea
4
+ module Svg
5
+ module EaEmitter
6
+ # Canvas policy of an UNFRAMED export (EA's "Save Diagram Image"
7
+ # without the automatic frame: the PLATEAU native SVGs). Measured
8
+ # on the 220 native SVGs, where it reproduces the origin of 205 of
9
+ # 215 diagrams from the native content alone (the other 10 sit at
10
+ # the floor below):
11
+ #
12
+ # origin = max(min(shape_min - 10, text_left_or_top - 5), -5)
13
+ #
14
+ # in model coordinates, per axis. Shapes are rect/path/polygon/
15
+ # ellipse points; a text box runs from its left edge and from
16
+ # baseline minus the rounded font height. The right margin is 10
17
+ # from the shapes.
18
+ #
19
+ # EMPIRICAL FITS (no saved selector was found in any QEA table):
20
+ # BOTTOM_MARGIN 31 is only the fallback; BottomMargin picks the
21
+ # margin from the lowest element. The right edge also reaches
22
+ # every connector end-label box (its position plus the stored
23
+ # CX width) + LABEL_BOX_MARGIN 5, exact on the 7 PLATEAU diagrams
24
+ # whose labels overhang the shapes (30D62231, 47E4600F,
25
+ # 4881CEEE, 70C77F92, 846DD800, 88AF6BD7, AC2191E3). Below, the
26
+ # same box (its top is the first baseline less the font height,
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.
34
+ module CanvasAnchor
35
+ CONTENT_MARGIN = 10
36
+ TEXT_MARGIN = 5
37
+ ORIGIN_FLOOR = -5
38
+ BOTTOM_MARGIN = 31
39
+ LABEL_BOX_MARGIN = 5
40
+ # EMPIRICAL FIT: the one PLATEAU diagram with nothing on it
41
+ # (29DD0967) exports a 210 x 110 white canvas.
42
+ BLANK_SIZE = [210, 110].freeze
43
+
44
+ module_function
45
+
46
+ # Where content starting at `content_min` lands on the canvas.
47
+ def inset(content_min)
48
+ content_min - floored(content_min - CONTENT_MARGIN)
49
+ end
50
+
51
+ # Canvas of a diagram that places no element.
52
+ def blank
53
+ Canvas.new(min_x: 0, min_y: 0, width: BLANK_SIZE[0],
54
+ height: BLANK_SIZE[1], insets: [0, 0])
55
+ end
56
+
57
+ # Canvas for shapes spanning the given model-space extent (the
58
+ # stored element rects, before any drawn content is measured).
59
+ def stored(min_x, min_y, max_x, max_y, bottom: BOTTOM_MARGIN)
60
+ left = inset(min_x)
61
+ top = inset(min_y)
62
+ Canvas.new(min_x: min_x, min_y: min_y,
63
+ width: (max_x - min_x) + left + CONTENT_MARGIN,
64
+ height: (max_y - min_y) + top + bottom,
65
+ insets: [left, top])
66
+ end
67
+
68
+ # Canvas for measured content, each box [min_x, min_y, max_x,
69
+ # max_y] in model coordinates (nil when absent). `label_reach` is
70
+ # the [right, bottom] edge of the connector end-label boxes.
71
+ def measured(shapes, texts, label_reach, bottom: BOTTOM_MARGIN)
72
+ origin_x = origin(shapes && shapes[0], texts && texts[0])
73
+ origin_y = origin(shapes && shapes[1], texts && texts[1])
74
+ Canvas.new(min_x: origin_x, min_y: origin_y,
75
+ width: right_edge(shapes, texts, label_reach) - origin_x,
76
+ height: bottom_edge(shapes, texts, label_reach, bottom) - origin_y,
77
+ insets: [0, 0])
78
+ end
79
+
80
+ def right_edge(shapes, texts, label_reach)
81
+ [shapes && shapes[2] + CONTENT_MARGIN,
82
+ texts && (texts[2] + TEXT_MARGIN).ceil,
83
+ label_reach && label_reach[0] + LABEL_BOX_MARGIN].compact.max
84
+ end
85
+
86
+ def bottom_edge(shapes, texts, label_reach, bottom)
87
+ [shapes[3] + bottom,
88
+ texts && (texts[3] + TEXT_MARGIN).ceil,
89
+ label_reach && label_reach[1] + LABEL_BOX_MARGIN].compact.max
90
+ end
91
+
92
+ def origin(shape_min, text_min)
93
+ lead = [shape_min && shape_min - CONTENT_MARGIN,
94
+ text_min && text_min - TEXT_MARGIN].compact.min
95
+ floored(lead)
96
+ end
97
+
98
+ # The floor encodes only "a shape at 0 draws at 5" (the corpus
99
+ # has no shape left of 0). Content starting further out keeps
100
+ # its full margin so nothing is drawn off the canvas.
101
+ def floored(lead)
102
+ lead >= ORIGIN_FLOOR - CONTENT_MARGIN ? [lead, ORIGIN_FLOOR].max : lead
103
+ end
104
+
105
+ # The canvas anchored on the content drawn into `layers` (the
106
+ # background excluded) and `label_reach`, or `canvas` itself
107
+ # when there is nothing to measure.
108
+ def reanchor(canvas, layers, label_reach, bottom: BOTTOM_MARGIN)
109
+ extent = ContentExtent.measure(layers)
110
+ return canvas unless extent.shapes
111
+
112
+ shift = [canvas.origin_x, canvas.origin_y]
113
+ measured(*[extent.shapes, extent.texts]
114
+ .map { |box| model_box(box, shift) }, label_reach,
115
+ bottom: bottom)
116
+ end
117
+
118
+ def model_box(box, shift)
119
+ return nil unless box
120
+
121
+ [box[0] + shift[0], box[1] + shift[1],
122
+ box[2] + shift[0], box[3] + shift[1]]
123
+ end
124
+
125
+ def same_canvas?(one, other)
126
+ [one.origin_x, one.origin_y, one.width, one.height] ==
127
+ [other.origin_x, other.origin_y, other.width, other.height]
128
+ end
129
+ end
130
+ end
131
+ end
132
+ end
@@ -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
@@ -117,8 +122,12 @@ module Ea
117
122
  end
118
123
  module_function :cjk_char?
119
124
 
125
+ # The text group keeps the pen the note outline was drawn with
126
+ # (EA never sets a note pen; 7C44AC09 square/miter, 7D960BD8
127
+ # square/bevel), so the next element inherits the right pen.
120
128
  def group_style(context)
121
- "stroke-width:1;stroke-linecap:round;stroke-linejoin:bevel; " \
129
+ cap, join = pen(context)
130
+ "stroke-width:1;stroke-linecap:#{cap};stroke-linejoin:#{join}; " \
122
131
  "fill:#{context.theme.text_color};fill-opacity:1.00; " \
123
132
  "stroke:#000000; stroke-opacity:0.00"
124
133
  end
@@ -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)
@@ -37,9 +42,22 @@ module Ea
37
42
 
38
43
  module_function :render
39
44
 
40
- # EA draws the subtitle 15px BELOW the body bottom (outside
41
- # the box) — verified against EA-published package SVGs.
42
- Y_OFFSET_FROM_BOTTOM = 15
45
+ # EA draws the subtitle BELOW the body bottom (outside the
46
+ # box): GAP_BELOW_BODY plus the GDI text ascent of the caption
47
+ # font. Cambria 7pt (H10, ascent 10) gives the 15px seen in
48
+ # every Cambria native package SVG (14E9F5FD, 157826AA,
49
+ # 2A5E0832, 6481E2CC); Carlito 9pt (H13, ascent 12) gives the
50
+ # 17px of 37AC19B9. The 5px gap is fitted on the Cambria cases,
51
+ # the Carlito case is the held-out check. Fonts or units with
52
+ # no GDI cell data keep the legacy 15px.
53
+ # Only families with native evidence use the ascent offset.
54
+ ASCENT_FAMILIES = %w[cambria carlito].freeze
55
+ GAP_BELOW_BODY = 5
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
43
61
 
44
62
  LINE_PITCH = 13
45
63
  WRAP_PADDING = 15
@@ -47,7 +65,7 @@ module Ea
47
65
  def subtitle(text, context)
48
66
  bounds = context.bounds
49
67
  fill = context.theme.attribute_text_color
50
- y = bounds.y + bounds.height + Y_OFFSET_FROM_BOTTOM
68
+ y = bounds.y + bounds.height + offset_from_bottom(context)
51
69
  # EA word-wraps the subtitle to the package BODY width
52
70
  # (minus padding), centers each line by its integer
53
71
  # textLength inside the body, and floors x; successive
@@ -72,6 +90,18 @@ module Ea
72
90
  end
73
91
  module_function :subtitle
74
92
 
93
+ def offset_from_bottom(context)
94
+ height = Ea::Fonts::Metrics.source_logical_height(context.logical_height)
95
+ height ||= Ea::Fonts::Metrics.gdi_logical_height(context.size) if context.size_unit == "pt"
96
+ return LEGACY_Y_OFFSET_FROM_BOTTOM unless ASCENT_FAMILIES.include?(context.family.to_s.downcase)
97
+
98
+ return LEGACY_Y_OFFSET_FROM_BOTTOM unless height.nil? || height <= MAX_CAPTION_HEIGHT
99
+
100
+ ascent, = Ea::Fonts::Metrics.gdi_cell_metrics(context.family, height)
101
+ ascent ? GAP_BELOW_BODY + ascent : LEGACY_Y_OFFSET_FROM_BOTTOM
102
+ end
103
+ module_function :offset_from_bottom
104
+
75
105
  # EA carries the break space onto the broken line: line 1
76
106
  # is "(from ... Metadata - " with the trailing space, and
77
107
  # its textLength includes it (05DF5000: tl 146).
@@ -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(
@@ -16,7 +29,8 @@ module Ea
16
29
  legend: context.model_element.legend,
17
30
  family: context.family,
18
31
  item_font: { size: legend_item_size(context), unit: context.size_unit,
19
- logical_height: context.logical_height }
32
+ logical_height: context.logical_height },
33
+ pen: context.previous_pen
20
34
  )
21
35
  elsif context.model_element.is_a?(Ea::Model::Note)
22
36
  Element::NoteShapeRenderer.render(
@@ -24,7 +38,8 @@ module Ea
24
38
  body: "", fill: context.fill, stroke: context.stroke,
25
39
  stroke_width: context.stroke_width,
26
40
  family: context.family, size: context.size,
27
- text_fill: context.text_fill
41
+ text_fill: context.text_fill,
42
+ pen: context.previous_pen
28
43
  )
29
44
  elsif context.model_element.is_a?(Ea::Model::Package)
30
45
  label = package_label(context.model_element)
@@ -59,12 +74,22 @@ module Ea
59
74
  return unless element
60
75
  return element.font_size if element.font_size&.nonzero?
61
76
 
62
- # Parsed source defaults inherit; unspecified callers keep 9pt.
63
- inherited = element.font_size_inherited || element.style&.fetch(:fontsz, nil).to_s == "0"
64
- context.size if inherited
77
+ context.size if inherits_context_size?(element)
65
78
  end
66
79
  private_class_method :legend_item_size
67
80
 
81
+ # Parsed source defaults inherit; unspecified callers keep 9pt.
82
+ # A parsed element (provenance recorded) that resolved no size
83
+ # also inherits: XMI legends carry no fontsz at all, and native
84
+ # EA draws their items at the context size (Cambria 7pt).
85
+ def inherits_context_size?(element)
86
+ return true if element.font_size_inherited
87
+ return true if element.style&.fetch(:fontsz, nil).to_s == "0"
88
+
89
+ !element.font_size_inherited.nil? && element.font_size.nil?
90
+ end
91
+ private_class_method :inherits_context_size?
92
+
68
93
  # A Text element with LegendOpts= renders as the legend
69
94
  # block rather than a folder silhouette. Dispatch on the
70
95
  # presence of the attached Legend payload.
@@ -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