ea 0.6.140 → 0.6.142

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 (71) hide show
  1. checksums.yaml +4 -4
  2. data/config/themes/default.yml +7 -0
  3. data/lib/ea/cli/app.rb +10 -0
  4. data/lib/ea/cli/command/diagrams.rb +4 -1
  5. data/lib/ea/cli/command/render.rb +8 -1
  6. data/lib/ea/cli/command/spa.rb +2 -1
  7. data/lib/ea/cli/command/svg.rb +9 -6
  8. data/lib/ea/fonts/metrics.rb +44 -16
  9. data/lib/ea/model/diagram.rb +6 -0
  10. data/lib/ea/qea/services/database_loader.rb +4 -1
  11. data/lib/ea/sources/qea/diagram_builder/routing.rb +64 -22
  12. data/lib/ea/sources/qea/diagram_builder.rb +4 -1
  13. data/lib/ea/sources/xmi/adapter.rb +20 -0
  14. data/lib/ea/sources/xmi/connector_index.rb +30 -0
  15. data/lib/ea/sources/xmi/constraint_builder.rb +81 -0
  16. data/lib/ea/sources/xmi/diagram_builder.rb +22 -11
  17. data/lib/ea/sources/xmi/extension_style_parser.rb +1 -1
  18. data/lib/ea/sources/xmi/extension_types.rb +9 -2
  19. data/lib/ea/sources/xmi/relationship_builder.rb +17 -11
  20. data/lib/ea/sources/xmi/stored_route/reversed_routes.rb +10 -26
  21. data/lib/ea/sources/xmi.rb +2 -0
  22. data/lib/ea/spa/generator.rb +4 -2
  23. data/lib/ea/spa/projector.rb +4 -2
  24. data/lib/ea/svg/ea_emitter/bottom_margin.rb +16 -12
  25. data/lib/ea/svg/ea_emitter/bounds_calculator.rb +22 -14
  26. data/lib/ea/svg/ea_emitter/canvas.rb +43 -5
  27. data/lib/ea/svg/ea_emitter/canvas_anchor.rb +12 -4
  28. data/lib/ea/svg/ea_emitter/compartment/attributes.rb +5 -0
  29. data/lib/ea/svg/ea_emitter/compartment/constraints.rb +5 -0
  30. data/lib/ea/svg/ea_emitter/compartment/enum_literals.rb +5 -0
  31. data/lib/ea/svg/ea_emitter/compartment/header.rb +8 -1
  32. data/lib/ea/svg/ea_emitter/compartment/header_divider.rb +6 -1
  33. data/lib/ea/svg/ea_emitter/compartment/instance_slots.rb +5 -0
  34. data/lib/ea/svg/ea_emitter/compartment/instance_underline.rb +6 -1
  35. data/lib/ea/svg/ea_emitter/compartment/marker.rb +5 -0
  36. data/lib/ea/svg/ea_emitter/compartment/note_body.rb +7 -2
  37. data/lib/ea/svg/ea_emitter/compartment/note_text.rb +5 -0
  38. data/lib/ea/svg/ea_emitter/compartment/operations.rb +5 -0
  39. data/lib/ea/svg/ea_emitter/compartment/package_contents.rb +5 -0
  40. data/lib/ea/svg/ea_emitter/compartment/package_from_parent.rb +12 -1
  41. data/lib/ea/svg/ea_emitter/compartment/shape.rb +14 -1
  42. data/lib/ea/svg/ea_emitter/compartment/stereotype_icon.rb +6 -0
  43. data/lib/ea/svg/ea_emitter/compartment/tagged_values.rb +5 -0
  44. data/lib/ea/svg/ea_emitter/compartment.rb +13 -0
  45. data/lib/ea/svg/ea_emitter/connector_bridges.rb +18 -4
  46. data/lib/ea/svg/ea_emitter/connector_redock.rb +13 -6
  47. data/lib/ea/svg/ea_emitter/content_extent.rb +11 -2
  48. data/lib/ea/svg/ea_emitter/diagram_frame.rb +7 -0
  49. data/lib/ea/svg/ea_emitter/document.rb +22 -6
  50. data/lib/ea/svg/ea_emitter/element/attribute_renderer.rb +6 -1
  51. data/lib/ea/svg/ea_emitter/element/compartment_geometry.rb +9 -0
  52. data/lib/ea/svg/ea_emitter/element/header_renderer.rb +7 -0
  53. data/lib/ea/svg/ea_emitter/element/legend_renderer.rb +18 -14
  54. data/lib/ea/svg/ea_emitter/element/note_shape_renderer.rb +1 -8
  55. data/lib/ea/svg/ea_emitter/elements.rb +36 -12
  56. data/lib/ea/svg/ea_emitter/font_resolver.rb +3 -1
  57. data/lib/ea/svg/ea_emitter/label/end_label.rb +113 -19
  58. data/lib/ea/svg/ea_emitter/label/midpoint_label.rb +66 -0
  59. data/lib/ea/svg/ea_emitter/label/plain_source_seat.rb +25 -2
  60. data/lib/ea/svg/ea_emitter/label/plain_target_seat.rb +64 -1
  61. data/lib/ea/svg/ea_emitter/labels.rb +64 -35
  62. data/lib/ea/svg/ea_emitter/layer_sequencer.rb +14 -7
  63. data/lib/ea/svg/ea_emitter/markers.rb +1 -1
  64. data/lib/ea/svg/ea_emitter/paint_order.rb +5 -4
  65. data/lib/ea/svg/ea_emitter/pen.rb +19 -0
  66. data/lib/ea/svg/ea_emitter/render_context.rb +1 -1
  67. data/lib/ea/svg/ea_emitter/text_renderer.rb +24 -8
  68. data/lib/ea/svg/ea_emitter.rb +1 -0
  69. data/lib/ea/svg/parity/source.rb +1 -1
  70. data/lib/ea/version.rb +1 -1
  71. metadata +5 -2
@@ -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
 
@@ -632,12 +632,19 @@ module Ea
632
632
  # Dependency client→supplier (client_id/supplier_id),
633
633
  # Association source_id/target_id.
634
634
  def end_refs(rel)
635
- case rel
636
- when Ea::Model::Generalization then [rel.specific_id, rel.general_id]
637
- when Ea::Model::Realization then [rel.realizing_id, rel.contract_id]
638
- when Ea::Model::Dependency then [rel.client_id, rel.supplier_id]
639
- else [rel.source_id, rel.target_id]
640
- end
635
+ refs = case rel
636
+ when Ea::Model::Generalization then [rel.specific_id, rel.general_id]
637
+ when Ea::Model::Realization then [rel.realizing_id, rel.contract_id]
638
+ when Ea::Model::Dependency then [rel.client_id, rel.supplier_id]
639
+ else [rel.source_id, rel.target_id]
640
+ end
641
+ refs.map { |ref| diagram_ref(ref) }
642
+ end
643
+
644
+ # A package is `EAPK_<guid>` in the XMI model but is placed on a
645
+ # diagram as `EAID_<guid>`; key its ends by the diagram spelling.
646
+ def diagram_ref(ref)
647
+ ref.is_a?(String) && ref.start_with?("EAPK_") ? "EAID_#{ref[5..]}" : ref
641
648
  end
642
649
 
643
650
  # Center-to-center ray clipped at both box outlines.
@@ -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)
@@ -17,7 +17,7 @@ module Ea
17
17
  # origins the canvas on the drawn content like EA's native
18
18
  # unframed export; off, it keeps the framed 35/40 content origin.
19
19
  def initialize(diagram, model_index:, frame: false, document: nil,
20
- content_anchor: true, **_options)
20
+ content_anchor: true)
21
21
  @diagram = diagram
22
22
  @model_index = model_index
23
23
  @frame = frame
@@ -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
 
@@ -116,7 +116,12 @@ module Ea
116
116
  lookup: lookup).to_s
117
117
  end
118
118
  else
119
- ordered_properties(props, stored_order).flat_map do |prop|
119
+ # EMPIRICAL FIT (test B58D1A53, the only corpus class mixing
120
+ # stereotyped and plain attributes): EA draws the plain rows
121
+ # first, then the stereotyped ones in stored order.
122
+ plain, stereotyped = ordered_properties(props, stored_order)
123
+ .partition { |prop| property_stereotype(prop).nil? }
124
+ (plain + stereotyped).flat_map do |prop|
120
125
  lines = []
121
126
  stereotype = property_stereotype(prop)
122
127
  lines << "«#{stereotype}»" if stereotype
@@ -206,6 +206,15 @@ module Ea
206
206
  content_bottom_y ? content_bottom_y + size + 5 : raw_attr_first_y
207
207
  end
208
208
 
209
+ # Bottom edge of the tags compartment: the last tag row plus one
210
+ # row of trailing space (204F4ACC: last baseline 83, EA bottom 95).
211
+ def tagged_values_bottom_y
212
+ return nil unless tagged_value_first_y
213
+
214
+ tagged_value_first_y +
215
+ (tagged_values_count.to_i + 1) * line_height(@size_unit == "pt" ? 6 : 4)
216
+ end
217
+
209
218
  # The lowest content edge among attributes, enum literals,
210
219
  # operations, and constraints — the anchor for trailing
211
220
  # compartments and the autosize height.
@@ -11,6 +11,7 @@ module Ea
11
11
  # the left edge from text-width approximation since SVG text
12
12
  # x= is the start position, not the center).
13
13
  class HeaderRenderer
14
+ OVERFLOW_INSET = 2
14
15
  HeaderLine = Struct.new(:text, :style, :x, :y, :text_length,
15
16
  :weight, :font_style, keyword_init: true)
16
17
 
@@ -83,7 +84,13 @@ module Ea
83
84
 
84
85
  # EA centers text by its INTEGER textLength and floors the
85
86
  # resulting x (verified exact against EA-published SVGs).
87
+ #
88
+ # EMPIRICAL FIT: a name wider than its box starts 2px inside the
89
+ # left edge instead of centring past it (simple 204F4ACC
90
+ # AcmeUmlEnumeration, 106 in a 90 box: x = box x + 2).
86
91
  def self.center_x_for(text_length, bounds)
92
+ return bounds.x + OVERFLOW_INSET if text_length > bounds.width
93
+
87
94
  (bounds.x + (bounds.width - text_length) / 2.0).floor
88
95
  end
89
96
  private_class_method :center_x_for
@@ -32,23 +32,24 @@ module Ea
32
32
  LABEL_BASELINE_FROM_ICON_TOP = 12
33
33
  DEFAULT_ITEM_FONT_SIZE = 9
34
34
  # The legend pen is not set explicitly: the panel inherits the pen
35
- # left by the previous drawing (round/bevel), or EA's initial pen
36
- # (square/miter) when the legend is the first thing painted. Held
37
- # across the corpus: 11 of 11 legends painted first are square, 105
38
- # of 105 painted later are round.
39
- INHERITED_STROKE = "stroke-width:1;stroke-linecap:round;stroke-linejoin:bevel;"
40
- INITIAL_STROKE = "stroke-width:1;stroke-linecap:square;stroke-linejoin:miter;"
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
41
41
 
42
- def self.render(bounds, legend:, family:, canvas: nil, item_font: {}, first_painted: false)
43
- new(bounds, legend, family, canvas, item_font, first_painted: first_painted).to_svg
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
44
44
  end
45
45
 
46
- def initialize(bounds, legend, family, canvas, item_font = {}, first_painted: false)
46
+ def initialize(bounds, legend, family, canvas, item_font = {}, pen: nil)
47
47
  @bounds = bounds
48
48
  @legend = legend
49
49
  @family = family
50
50
  @canvas = canvas
51
- @pen = first_painted ? INITIAL_STROKE : INHERITED_STROKE
51
+ cap, join = pen || INITIAL_PEN
52
+ @pen = "stroke-width:1;stroke-linecap:#{cap};stroke-linejoin:#{join};"
52
53
  @item_font = item_font[:size]&.nonzero? ? item_font : {}
53
54
  end
54
55
 
@@ -162,10 +163,11 @@ module Ea
162
163
  (block_left + (block_right - block_left - title_text_width.round) / 2.0).floor
163
164
  end
164
165
 
165
- 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)
166
168
  TextRenderer.estimate_width(
167
169
  text, size, family: text_family, weight: weight,
168
- logical_height: logical_height
170
+ logical_height: logical_height, legend_title: legend_title
169
171
  ).round
170
172
  end
171
173
 
@@ -193,7 +195,8 @@ module Ea
193
195
  end
194
196
 
195
197
  def title_text_width
196
- 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)
197
200
  end
198
201
 
199
202
  def heading_family
@@ -206,7 +209,8 @@ module Ea
206
209
  TextRenderer.new(
207
210
  content: content.to_s, x: tx, y: ty,
208
211
  family: weight.zero? ? family : heading_family, size: size, size_unit: size_unit,
209
- 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?
210
214
  ).to_svg
211
215
  end
212
216
 
@@ -32,14 +32,7 @@ module Ea
32
32
  # native notes: 7C44AC09 first (square/miter), 7D960BD8 after
33
33
  # square/bevel icons (square/bevel), 676B9C9A, 926AFE38, 9DA6C553
34
34
  # and C028B286 after round/bevel shapes (round/bevel).
35
- INITIAL_PEN = %w[square miter].freeze
36
- PEN_PATTERN = /stroke-linecap:(\w+);stroke-linejoin:(\w+)/
37
-
38
- # The [cap, join] a rendered group leaves behind, or nil.
39
- def self.pen_of(group)
40
- match = PEN_PATTERN.match(group.to_s)
41
- match&.captures
42
- end
35
+ INITIAL_PEN = Pen::INITIAL
43
36
 
44
37
  def self.render(bounds, body:, fill:, stroke:, stroke_width:, family:, size:, text_fill:, pen: nil)
45
38
  new(bounds, body, fill, stroke, stroke_width, family, size, text_fill, pen || INITIAL_PEN).to_svg
@@ -33,13 +33,11 @@ module Ea
33
33
  def groups
34
34
  @drawn_bounds = {}
35
35
  @divider_y = {}
36
- @painted_any = false
37
36
  @previous_pen = nil
38
37
  ordered_elements.flat_map do |e|
39
- groups = groups_for(e)
40
- @painted_any ||= groups.any?
41
- @previous_pen = Element::NoteShapeRenderer.pen_of(groups.last) || @previous_pen
42
- groups
38
+ painted = groups_for(e)
39
+ @previous_pen = painted.last&.pen || @previous_pen
40
+ painted.map(&:svg)
43
41
  end
44
42
  end
45
43
 
@@ -58,9 +56,14 @@ module Ea
58
56
  private
59
57
 
60
58
  # EA paints placements by descending Sequence (the lowest
61
- # Sequence is on top); equal keys keep their stored order.
59
+ # Sequence is on top); equal keys keep their stored order. A
60
+ # placement with no Sequence (no such row in the corpus, so EA's
61
+ # treatment is unmeasured) is painted first, as before the
62
+ # descending order, rather than ahead of the numbered ones.
62
63
  def ordered_elements
63
- (diagram.elements || []).sort_by.with_index { |e, i| [-(e.z_order || 0), i] }
64
+ (diagram.elements || []).sort_by.with_index do |e, i|
65
+ e.z_order ? [1, -e.z_order, i] : [0, 0, i]
66
+ end
64
67
  end
65
68
 
66
69
  def groups_for(element)
@@ -72,7 +75,7 @@ module Ea
72
75
  record_drawn_bounds(element, context)
73
76
  return [] if element.style&.fetch("connector_proxy", false)
74
77
 
75
- Compartment.render_all(context).compact
78
+ Compartment.paint_all(context)
76
79
  end
77
80
 
78
81
  # Logical-space drawn bounds: stored x/y with the RENDERED
@@ -286,6 +289,7 @@ module Ea
286
289
  height: grown_bottom + compartment_bottom_padding(size, family, size_unit) - bounds.y
287
290
  )
288
291
  end
292
+ bounds = grow_for_tagged_values(bounds, geometry, element, classifier)
289
293
  RenderContext.new(
290
294
  element: element,
291
295
  bounds: bounds,
@@ -310,7 +314,6 @@ module Ea
310
314
  diagram: diagram,
311
315
  model_index: model_index,
312
316
  off_canvas_parent_name: parent_name,
313
- first_painted: !@painted_any,
314
317
  previous_pen: @previous_pen
315
318
  )
316
319
  end
@@ -322,6 +325,21 @@ module Ea
322
325
  offset
323
326
  end
324
327
 
328
+ # A shown tags compartment that overflows the stored box grows
329
+ # it to the last tag row plus one row (204F4ACC AcmeUmlClass:
330
+ # QEA stores h70; EA draws h85, the XMI geometry already says 85).
331
+ # Fitted on that diagram only; boxes whose stored height already
332
+ # holds the tags are untouched.
333
+ def grow_for_tagged_values(bounds, geometry, element, classifier)
334
+ return bounds unless element.show_tagged_values && tagged_values_for(classifier).any?
335
+
336
+ tags_bottom = geometry.tagged_values_bottom_y
337
+ return bounds unless tags_bottom && tags_bottom > bounds.y + bounds.height
338
+
339
+ Ea::Model::Bounds.new(x: bounds.x, y: bounds.y, width: bounds.width,
340
+ height: tags_bottom - bounds.y)
341
+ end
342
+
325
343
  def compartment_bottom_padding(size, family, size_unit)
326
344
  Ea::Fonts::Metrics.compact_compartment_font?(size, family: family, unit: size_unit) ? 7 : 8
327
345
  end
@@ -397,12 +415,18 @@ module Ea
397
415
  # that were +1: 0114FD24 uro::AbstractConstruction etc.; 20 of
398
416
  # 21 now exact, no other compared item moved). The tight ghost
399
417
  # variant has no corpus case and keeps the default offsets.
418
+ #
419
+ # An Enumeration with literals takes it too (EMPIRICAL FIT, test
420
+ # B58D1A53 Enumeration: divider 346 = name 339 + 7; the only Cambria 7pt
421
+ # enumeration with literals in the small corpora).
400
422
  def compact_header_offsets?(classifier, header_lines, size, **options)
401
- classifier.is_a?(Ea::Model::Klass) &&
423
+ literals = options[:enum_literals_count]
424
+ kind_fits = (classifier.is_a?(Ea::Model::Klass) && literals.zero?) ||
425
+ (classifier.is_a?(Ea::Model::Enumeration) && literals.positive?)
426
+ kind_fits &&
402
427
  Ea::Fonts::Metrics.compact_compartment_font?(size, family: options[:family], unit: options[:size_unit]) &&
403
428
  header_lines.dig(0, 1) != :italic_tight &&
404
- options.values_at(:logical_height, :marker_count, :enum_literals_count,
405
- :divider_offset, :attr_first_offset) == [nil, 0, 0, 8, 7] &&
429
+ options.values_at(:logical_height, :marker_count, :divider_offset, :attr_first_offset) == [nil, 0, 8, 7] &&
406
430
  theme.compartments.header_line_offset == 6 && theme.compartments.attr_line_offset == 6
407
431
  end
408
432
 
@@ -68,7 +68,9 @@ module Ea
68
68
  end
69
69
 
70
70
  def logical_height_for(element)
71
- element&.font_logical_height if size_unit_for(element) == "pt"
71
+ return nil unless size_unit_for(element) == "pt"
72
+
73
+ Ea::Fonts::Metrics.source_logical_height(element&.font_logical_height)
72
74
  end
73
75
 
74
76
  def weight_for(element)