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
@@ -9,6 +9,9 @@ module Ea
9
9
  # boxes). Returns nil where QEA would fall back to bare SX/SY deltas,
10
10
  # which the generic router keeps handling.
11
11
  class StoredRoute < Qea::DiagramBuilder
12
+ autoload :ReversedRoutes, "ea/sources/xmi/stored_route/reversed_routes"
13
+ include ReversedRoutes
14
+
12
15
  Link = Struct.new(:style, :path, :geometry)
13
16
  Connector = Struct.new(:connector_type)
14
17
 
@@ -29,6 +32,9 @@ module Ea
29
32
  # Saved rays retain origins and computed boundary ports, matching QEA.
30
33
  def ray_metadata(model, link:, bounds:, offsets:, edge:)
31
34
  if model.connector_type == "Generalization"
35
+ overlap = mode2_overlap_metadata(model, link, bounds, offsets, edge)
36
+ return overlap unless overlap.empty?
37
+
32
38
  return generalization_ray_metadata(model, link, bounds, offsets, edge)
33
39
  end
34
40
 
@@ -49,11 +55,10 @@ module Ea
49
55
  # UMLDI fallback. A single stored corner and an empty-path stored ray
50
56
  # carry mathematical origins that the generic facing-edge route loses.
51
57
  def association_route(model, link:, bounds:, offsets:, edge:)
52
- overlap = umldi_association_metadata(model, link, bounds, offsets, edge)
53
- return overlap unless overlap.empty?
54
-
55
- partial = partial_port_metadata(model, link, bounds, offsets, edge)
56
- return partial unless partial.empty?
58
+ ROUTE_STAGES.each do |stage|
59
+ found = method(stage).call(model, link, bounds, offsets, edge)
60
+ return found unless found.empty?
61
+ end
57
62
 
58
63
  return {} unless stored_association_candidate?(model, bounds)
59
64
 
@@ -67,6 +72,69 @@ module Ea
67
72
 
68
73
  private
69
74
 
75
+ ROUTE_STAGES = %i[umldi_association_metadata shared_overlap_metadata
76
+ source_row_metadata auto_port_elbow_metadata reversed_tree_metadata
77
+ reversed_corner_metadata reversed_lateral_tree_metadata partial_port_metadata].freeze
78
+
79
+ # Overlap rules need both end boxes and every end offset.
80
+ def ray_inputs?(bounds, offsets)
81
+ bounds.values.all? && offsets.values.flatten.none?(&:nil?)
82
+ end
83
+
84
+ # A Mode 2 Generalization whose stored Path is a horizontal jog is EA's
85
+ # auto-route: QEA replaces the stored bends with one vertical ray, and
86
+ # the same QEA rule decides it here.
87
+ def mode2_overlap_metadata(model, link, bounds, offsets, edge)
88
+ return {} if link.path.to_s.empty? || !ray_inputs?(bounds, offsets)
89
+
90
+ candidate = association_model(model, bounds, offsets)
91
+ initial = candidate.waypoints
92
+ mode2_overlap_ray(candidate, link, bounds, ray_geometry(offsets, edge), false)
93
+ return {} if candidate.waypoints.equal?(initial)
94
+
95
+ { waypoints: candidate.waypoints, source_anchor: candidate.source_anchor,
96
+ target_anchor: candidate.target_anchor, has_geometry_offsets: true }
97
+ end
98
+
99
+ # A stored (non-UMLDI) Association through the shared overlap row takes
100
+ # QEA's overlap origins and flush ports, decided by the same QEA rule.
101
+ def shared_overlap_metadata(model, link, bounds, offsets, edge)
102
+ return {} if model.style[:umldi_route] || !ray_inputs?(bounds, offsets)
103
+
104
+ candidate = association_model(model, bounds, offsets)
105
+ geom = ray_geometry(offsets, edge)
106
+ association_stored_ray(candidate, link, bounds, geom, false) if association_ray_style?(model.style.to_h)
107
+ overlap_ray_origins(candidate, link, bounds, geom, false)
108
+ return {} unless candidate.style[:overlap_ray]
109
+
110
+ association_metadata(candidate)
111
+ end
112
+
113
+ # A Mode 2 Association with unequal end offsets and no (or a stale) Path
114
+ # rides the source row, as QEA's source_row_association_ray decides.
115
+ def source_row_metadata(model, link, bounds, offsets, edge)
116
+ return {} if model.style[:umldi_route] || !ray_inputs?(bounds, offsets)
117
+
118
+ candidate = association_model(model, bounds, offsets)
119
+ source_row_association_ray(candidate, link, bounds, ray_geometry(offsets, edge), false)
120
+ return {} unless candidate.style[:overlap_ray]
121
+
122
+ association_metadata(candidate)
123
+ end
124
+
125
+ # A Mode 2 Association with uniform offsets, EDGE 4 and no Path keeps its
126
+ # physical ports and takes QEA's elbow, even when UMLDI saved a straight
127
+ # route (30D62231, 934F4502).
128
+ def auto_port_elbow_metadata(model, link, bounds, offsets, edge)
129
+ return {} unless ray_inputs?(bounds, offsets)
130
+
131
+ candidate = association_model(model, bounds, offsets)
132
+ auto_port_elbow(candidate, link, bounds, ray_geometry(offsets, edge), false)
133
+ return {} unless candidate.style[:auto_port_elbow]
134
+
135
+ association_metadata(candidate)
136
+ end
137
+
70
138
  def umldi_association_metadata(model, link, bounds, offsets, edge)
71
139
  overlap = umldi_overlap_metadata(model, link, bounds, offsets, edge)
72
140
  return overlap unless overlap.empty?
@@ -99,7 +167,9 @@ module Ea
99
167
  return {} unless umldi_overlap_candidate?(model, bounds, offsets)
100
168
 
101
169
  candidate = association_model(model, bounds, offsets)
102
- overlap_ray_origins(candidate, link, bounds, ray_geometry(offsets, edge), false)
170
+ geom = ray_geometry(offsets, edge)
171
+ association_stored_ray(candidate, link, bounds, geom, false) if association_ray_style?(model.style.to_h)
172
+ overlap_ray_origins(candidate, link, bounds, geom, false)
103
173
  return {} unless candidate.style[:overlap_ray]
104
174
 
105
175
  { source_anchor: candidate.source_anchor, target_anchor: candidate.target_anchor,
@@ -162,6 +232,13 @@ module Ea
162
232
  y: box.y + box.height / 2 + offset[1].to_i)
163
233
  end
164
234
 
235
+ # XMI has no QEA database from which to infer a wider element outline,
236
+ # so the saved connector width stands. A border-aware variant changed no
237
+ # connector in the PLATEAU census.
238
+ def association_outline_width(model, _link, _bounds)
239
+ model.style[:lwidth]
240
+ end
241
+
165
242
  def stored_ray_boxes(_link, bounds)
166
243
  bounds.values_at(:source, :target)
167
244
  end
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ea
4
+ module Svg
5
+ module EaEmitter
6
+ # Space EA leaves below the lowest element of an unframed export.
7
+ #
8
+ # EMPIRICAL FIT (no stored selector was found; measured on the 187
9
+ # PLATEAU QEA diagrams whose native SVG is available, 172 match):
10
+ # below the lowest element the canvas keeps 21 + its font size in
11
+ # points (the objectstyle fontsz is in tenths of a point, 0 meaning
12
+ # the 7pt default): 31 for fontsz 100, 30 for 90, 28 for the
13
+ # default. A plain Note, and a Package that draws no "(from
14
+ # Parent)" subtitle, keep the bare 10 margin, as does a classifier
15
+ # of the diagram's own package. Misses: 2 outliers.
16
+ class BottomMargin
17
+ BARE = 10
18
+ LINE_ALLOWANCE = 21
19
+ HOME_TEXT_ALLOWANCE = 13
20
+ DEFAULT_POINTS = 7
21
+
22
+ def self.for(diagram, model_index)
23
+ lowest = lowest_elements(diagram)
24
+ return CanvasAnchor::BOTTOM_MARGIN if lowest.empty?
25
+
26
+ lowest.map { |element| of(element, diagram, model_index) }.max
27
+ end
28
+
29
+ def self.lowest_elements(diagram)
30
+ placed = (diagram.elements || []).select { |e| e.bounds || e.image_bounds }
31
+ bottom = placed.map { |e| edge(e) }.max
32
+ placed.select { |e| edge(e) == bottom }
33
+ end
34
+
35
+ def self.edge(element)
36
+ bounds = element.bounds || element.image_bounds
37
+ bounds.y + bounds.height
38
+ end
39
+
40
+ def self.of(element, diagram, model_index)
41
+ model = model_index && model_index[element.model_element_ref]
42
+ return home_text_margin(element) if home_text?(model, diagram)
43
+ return BARE if plain_note?(model) || bare_package?(model, diagram) ||
44
+ home_classifier?(model, diagram)
45
+
46
+ LINE_ALLOWANCE + points(element)
47
+ end
48
+
49
+ # QEA keeps the raw objectstyle fontsz; XMI keeps only the GDI
50
+ # pixel height it was rendered at (tenths * 96 / 720, floored).
51
+ def self.points(element)
52
+ tenths = element.style && element.style[:fontsz].to_i
53
+ return tenths / 10 if tenths.to_i.positive?
54
+
55
+ pixels = element.font_logical_height.to_i.abs
56
+ pixels.positive? ? (pixels * 0.75).round : DEFAULT_POINTS
57
+ end
58
+
59
+ def self.plain_note?(model)
60
+ model.is_a?(Ea::Model::Note) && model.note_type != "Text"
61
+ end
62
+
63
+ # A classifier that lives in the diagram's own package draws no
64
+ # "(from Package)" row under its name (nor an owner-class
65
+ # qualifier), so EA keeps only the bare margin below it. Checked
66
+ # on the 7 PLATEAU diagrams whose lowest class is in the
67
+ # diagram's package (all natively 10) against the 84 that are
68
+ # foreign (all 28..31).
69
+ def self.home_classifier?(model, diagram)
70
+ model.is_a?(Ea::Model::Classifier) &&
71
+ !diagram.package_id.to_s.empty? &&
72
+ model.package_id == diagram.package_id &&
73
+ model.nested_parent_name.to_s.empty?
74
+ end
75
+
76
+ # A Text element of the diagram's own package keeps 13 + its
77
+ # font size in points (22 at 9pt) instead of the foreign 21 + size.
78
+ # EMPIRICAL FIT on a single sample (PLATEAU 2E98EA19, fontsz 90,
79
+ # natively 22); the 8 foreign Text cases (28..30) are unaffected.
80
+ def self.home_text?(model, diagram)
81
+ model.is_a?(Ea::Model::Note) && model.note_type == "Text" &&
82
+ !diagram.package_id.to_s.empty? &&
83
+ model.package_id == diagram.package_id
84
+ end
85
+
86
+ def self.home_text_margin(element)
87
+ HOME_TEXT_ALLOWANCE + points(element)
88
+ end
89
+
90
+ def self.bare_package?(model, diagram)
91
+ return false unless model.is_a?(Ea::Model::Package)
92
+
93
+ parent = model.parent_id
94
+ !(diagram.show_foreign && !parent.to_s.empty? &&
95
+ parent != diagram.package_id)
96
+ end
97
+ end
98
+ end
99
+ end
100
+ end
@@ -38,7 +38,7 @@ module Ea
38
38
  @model_index = model_index
39
39
  end
40
40
 
41
- attr_reader :effective_inset_left
41
+ attr_reader :effective_inset_left, :max_x, :max_y
42
42
 
43
43
  def compute
44
44
  points = element_points
@@ -48,6 +48,8 @@ module Ea
48
48
  ys = points.map(&:last)
49
49
  min_x = xs.min || 0
50
50
  min_y = ys.min || 0
51
+ @max_x = xs.max
52
+ @max_y = ys.max
51
53
  # Left inset scales from 30 up to the 35 cap with the
52
54
  # stored leftmost x: leftmost drawn x = 30 + min_x capped
53
55
  # at 35 (corpus: min_x 0 -> 30, 2 -> 32, 4 -> 34, >=5 -> 35).
@@ -24,25 +24,45 @@ module Ea
24
24
  INSET_TOUCHING_ORIGIN = 30
25
25
  FRAME_INSET_TOP = 40
26
26
 
27
- attr_reader :min_x, :min_y, :width, :height, :inset_left
27
+ attr_reader :min_x, :min_y, :width, :height, :inset_left, :inset_top
28
28
 
29
- def initialize(min_x:, min_y:, width:, height:, inset_left: FRAME_INSET_LEFT)
29
+ # `insets` is [left, top]: where model (min_x, min_y) lands on
30
+ # the canvas.
31
+ def initialize(min_x:, min_y:, width:, height:,
32
+ insets: [FRAME_INSET_LEFT, FRAME_INSET_TOP])
30
33
  @min_x = min_x
31
34
  @min_y = min_y
32
35
  @width = width
33
36
  @height = height
34
- @inset_left = inset_left
37
+ @inset_left, @inset_top = insets
38
+ end
39
+
40
+ # Model-space origin of this canvas.
41
+ def origin_x
42
+ min_x - inset_left
43
+ end
44
+
45
+ def origin_y
46
+ min_y - inset_top
35
47
  end
36
48
 
37
49
  # Build a Canvas for a diagram by running the bound
38
50
  # calculator. Defaults model_index to nil (no canvas-aware
39
51
  # element bounds needed) for callers that only need a
40
52
  # frame-size estimate.
41
- def self.from(diagram, model_index: nil)
53
+ def self.from(diagram, model_index: nil, content_anchor: false)
42
54
  bc = BoundsCalculator.new(diagram, model_index: model_index)
43
55
  min_x, min_y, width, height = bc.compute
56
+ if content_anchor
57
+ return CanvasAnchor.blank unless bc.max_x
58
+
59
+ return CanvasAnchor.stored(min_x, min_y, bc.max_x, bc.max_y,
60
+ bottom: BottomMargin.for(diagram, model_index))
61
+ end
62
+
44
63
  new(min_x: min_x, min_y: min_y, width: width, height: height,
45
- inset_left: bc.effective_inset_left || FRAME_INSET_LEFT)
64
+ insets: [bc.effective_inset_left || FRAME_INSET_LEFT,
65
+ FRAME_INSET_TOP])
46
66
  end
47
67
 
48
68
  # Second-pass canvas over the DRAWN extents captured by the
@@ -51,7 +71,8 @@ module Ea
51
71
  # canvas from drawn extents, so diagrams whose lowest/rightmost
52
72
  # element grew get a taller/wider canvas than the stored-rect
53
73
  # first pass provides.
54
- def self.from_drawn(diagram, drawn_bounds, model_index: nil)
74
+ def self.from_drawn(diagram, drawn_bounds, model_index: nil,
75
+ content_anchor: false)
55
76
  base = BoundsCalculator.new(diagram, model_index: model_index)
56
77
  pts = []
57
78
  drawn_bounds.each_value do |b|
@@ -63,7 +84,14 @@ module Ea
63
84
  min_y = (pts + extra).map(&:last).min || 0
64
85
  max_x = (pts + extra).map(&:first).max || 0
65
86
  max_y = (pts + extra).map(&:last).max || 0
66
- return from(diagram, model_index: model_index) if pts.empty?
87
+ if pts.empty?
88
+ return from(diagram, model_index: model_index,
89
+ content_anchor: content_anchor)
90
+ end
91
+ if content_anchor
92
+ return CanvasAnchor.stored(min_x, min_y, max_x, max_y,
93
+ bottom: BottomMargin.for(diagram, model_index))
94
+ end
67
95
 
68
96
  # Same scaled rule as BoundsCalculator: drawn leftmost =
69
97
  # min(35, 30 + min_x).
@@ -74,7 +102,7 @@ module Ea
74
102
  BoundsCalculator::INSET_RIGHT,
75
103
  height: (max_y - min_y) + BoundsCalculator::INSET_TOP +
76
104
  BoundsCalculator::INSET_BOTTOM,
77
- inset_left: inset_left)
105
+ insets: [inset_left, FRAME_INSET_TOP])
78
106
  end
79
107
 
80
108
  def view_box
@@ -97,7 +125,7 @@ module Ea
97
125
  end
98
126
 
99
127
  def translate_y(y)
100
- y - min_y + FRAME_INSET_TOP
128
+ y - min_y + inset_top
101
129
  end
102
130
 
103
131
  # Compact coordinate formatter. Integers lose the decimal
@@ -0,0 +1,124 @@
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
+ module CanvasAnchor
29
+ CONTENT_MARGIN = 10
30
+ TEXT_MARGIN = 5
31
+ ORIGIN_FLOOR = -5
32
+ BOTTOM_MARGIN = 31
33
+ LABEL_BOX_MARGIN = 5
34
+ # EMPIRICAL FIT: the one PLATEAU diagram with nothing on it
35
+ # (29DD0967) exports a 210 x 110 white canvas.
36
+ BLANK_SIZE = [210, 110].freeze
37
+
38
+ module_function
39
+
40
+ # Where content starting at `content_min` lands on the canvas.
41
+ def inset(content_min)
42
+ content_min - floored(content_min - CONTENT_MARGIN)
43
+ end
44
+
45
+ # Canvas of a diagram that places no element.
46
+ def blank
47
+ Canvas.new(min_x: 0, min_y: 0, width: BLANK_SIZE[0],
48
+ height: BLANK_SIZE[1], insets: [0, 0])
49
+ end
50
+
51
+ # Canvas for shapes spanning the given model-space extent (the
52
+ # stored element rects, before any drawn content is measured).
53
+ def stored(min_x, min_y, max_x, max_y, bottom: BOTTOM_MARGIN)
54
+ left = inset(min_x)
55
+ top = inset(min_y)
56
+ Canvas.new(min_x: min_x, min_y: min_y,
57
+ width: (max_x - min_x) + left + CONTENT_MARGIN,
58
+ height: (max_y - min_y) + top + bottom,
59
+ insets: [left, top])
60
+ end
61
+
62
+ # Canvas for measured content, each box [min_x, min_y, max_x,
63
+ # max_y] in model coordinates (nil when absent). `label_reach` is
64
+ # the [right, bottom] edge of the connector end-label boxes.
65
+ def measured(shapes, texts, label_reach, bottom: BOTTOM_MARGIN)
66
+ origin_x = origin(shapes && shapes[0], texts && texts[0])
67
+ origin_y = origin(shapes && shapes[1], texts && texts[1])
68
+ 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,
71
+ insets: [0, 0])
72
+ end
73
+
74
+ def right_edge(shapes, label_reach)
75
+ [shapes && shapes[2] + CONTENT_MARGIN,
76
+ label_reach && label_reach[0] + LABEL_BOX_MARGIN].compact.max
77
+ end
78
+
79
+ def bottom_edge(shapes, label_reach, bottom)
80
+ [shapes[3] + bottom,
81
+ label_reach && label_reach[1] + LABEL_BOX_MARGIN].compact.max
82
+ end
83
+
84
+ def origin(shape_min, text_min)
85
+ lead = [shape_min && shape_min - CONTENT_MARGIN,
86
+ text_min && text_min - TEXT_MARGIN].compact.min
87
+ floored(lead)
88
+ end
89
+
90
+ # The floor encodes only "a shape at 0 draws at 5" (the corpus
91
+ # has no shape left of 0). Content starting further out keeps
92
+ # its full margin so nothing is drawn off the canvas.
93
+ def floored(lead)
94
+ lead >= ORIGIN_FLOOR - CONTENT_MARGIN ? [lead, ORIGIN_FLOOR].max : lead
95
+ end
96
+
97
+ # The canvas anchored on the content drawn into `layers` (the
98
+ # background excluded) and `label_reach`, or `canvas` itself
99
+ # when there is nothing to measure.
100
+ def reanchor(canvas, layers, label_reach, bottom: BOTTOM_MARGIN)
101
+ extent = ContentExtent.measure(layers)
102
+ return canvas unless extent.shapes
103
+
104
+ shift = [canvas.origin_x, canvas.origin_y]
105
+ measured(*[extent.shapes, extent.texts]
106
+ .map { |box| model_box(box, shift) }, label_reach,
107
+ bottom: bottom)
108
+ end
109
+
110
+ def model_box(box, shift)
111
+ return nil unless box
112
+
113
+ [box[0] + shift[0], box[1] + shift[1],
114
+ box[2] + shift[0], box[3] + shift[1]]
115
+ end
116
+
117
+ def same_canvas?(one, other)
118
+ [one.origin_x, one.origin_y, one.width, one.height] ==
119
+ [other.origin_x, other.origin_y, other.width, other.height]
120
+ end
121
+ end
122
+ end
123
+ end
124
+ end
@@ -117,8 +117,12 @@ module Ea
117
117
  end
118
118
  module_function :cjk_char?
119
119
 
120
+ # The text group keeps the pen the note outline was drawn with
121
+ # (EA never sets a note pen; 7C44AC09 square/miter, 7D960BD8
122
+ # square/bevel), so the next element inherits the right pen.
120
123
  def group_style(context)
121
- "stroke-width:1;stroke-linecap:round;stroke-linejoin:bevel; " \
124
+ cap, join = context.previous_pen || Element::NoteShapeRenderer::INITIAL_PEN
125
+ "stroke-width:1;stroke-linecap:#{cap};stroke-linejoin:#{join}; " \
122
126
  "fill:#{context.theme.text_color};fill-opacity:1.00; " \
123
127
  "stroke:#000000; stroke-opacity:0.00"
124
128
  end
@@ -37,9 +37,18 @@ module Ea
37
37
 
38
38
  module_function :render
39
39
 
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
40
+ # EA draws the subtitle BELOW the body bottom (outside the
41
+ # box): GAP_BELOW_BODY plus the GDI text ascent of the caption
42
+ # font. Cambria 7pt (H10, ascent 10) gives the 15px seen in
43
+ # every Cambria native package SVG (14E9F5FD, 157826AA,
44
+ # 2A5E0832, 6481E2CC); Carlito 9pt (H13, ascent 12) gives the
45
+ # 17px of 37AC19B9. The 5px gap is fitted on the Cambria cases,
46
+ # the Carlito case is the held-out check. Fonts or units with
47
+ # no GDI cell data keep the legacy 15px.
48
+ # Only families with native evidence use the ascent offset.
49
+ ASCENT_FAMILIES = %w[cambria carlito].freeze
50
+ GAP_BELOW_BODY = 5
51
+ LEGACY_Y_OFFSET_FROM_BOTTOM = 15
43
52
 
44
53
  LINE_PITCH = 13
45
54
  WRAP_PADDING = 15
@@ -47,7 +56,7 @@ module Ea
47
56
  def subtitle(text, context)
48
57
  bounds = context.bounds
49
58
  fill = context.theme.attribute_text_color
50
- y = bounds.y + bounds.height + Y_OFFSET_FROM_BOTTOM
59
+ y = bounds.y + bounds.height + offset_from_bottom(context)
51
60
  # EA word-wraps the subtitle to the package BODY width
52
61
  # (minus padding), centers each line by its integer
53
62
  # textLength inside the body, and floors x; successive
@@ -72,6 +81,16 @@ module Ea
72
81
  end
73
82
  module_function :subtitle
74
83
 
84
+ def offset_from_bottom(context)
85
+ height = context.logical_height
86
+ height ||= Ea::Fonts::Metrics.gdi_logical_height(context.size) if context.size_unit == "pt"
87
+ return LEGACY_Y_OFFSET_FROM_BOTTOM unless ASCENT_FAMILIES.include?(context.family.to_s.downcase)
88
+
89
+ ascent, = Ea::Fonts::Metrics.gdi_cell_metrics(context.family, height)
90
+ ascent ? GAP_BELOW_BODY + ascent : LEGACY_Y_OFFSET_FROM_BOTTOM
91
+ end
92
+ module_function :offset_from_bottom
93
+
75
94
  # EA carries the break space onto the broken line: line 1
76
95
  # is "(from ... Metadata - " with the trailing space, and
77
96
  # its textLength includes it (05DF5000: tl 146).
@@ -16,7 +16,8 @@ module Ea
16
16
  legend: context.model_element.legend,
17
17
  family: context.family,
18
18
  item_font: { size: legend_item_size(context), unit: context.size_unit,
19
- logical_height: context.logical_height }
19
+ logical_height: context.logical_height },
20
+ first_painted: context.first_painted
20
21
  )
21
22
  elsif context.model_element.is_a?(Ea::Model::Note)
22
23
  Element::NoteShapeRenderer.render(
@@ -24,7 +25,8 @@ module Ea
24
25
  body: "", fill: context.fill, stroke: context.stroke,
25
26
  stroke_width: context.stroke_width,
26
27
  family: context.family, size: context.size,
27
- text_fill: context.text_fill
28
+ text_fill: context.text_fill,
29
+ pen: context.previous_pen
28
30
  )
29
31
  elsif context.model_element.is_a?(Ea::Model::Package)
30
32
  label = package_label(context.model_element)
@@ -59,12 +61,22 @@ module Ea
59
61
  return unless element
60
62
  return element.font_size if element.font_size&.nonzero?
61
63
 
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
64
+ context.size if inherits_context_size?(element)
65
65
  end
66
66
  private_class_method :legend_item_size
67
67
 
68
+ # Parsed source defaults inherit; unspecified callers keep 9pt.
69
+ # A parsed element (provenance recorded) that resolved no size
70
+ # also inherits: XMI legends carry no fontsz at all, and native
71
+ # EA draws their items at the context size (Cambria 7pt).
72
+ def inherits_context_size?(element)
73
+ return true if element.font_size_inherited
74
+ return true if element.style&.fetch(:fontsz, nil).to_s == "0"
75
+
76
+ !element.font_size_inherited.nil? && element.font_size.nil?
77
+ end
78
+ private_class_method :inherits_context_size?
79
+
68
80
  # A Text element with LegendOpts= renders as the legend
69
81
  # block rather than a folder silhouette. Dispatch on the
70
82
  # presence of the attached Legend payload.