ea 0.6.106 → 0.6.108

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 (44) hide show
  1. checksums.yaml +4 -4
  2. data/config/themes/119.yml +3 -2
  3. data/lib/ea/fonts/cambria_7_styled_widths.json +293 -0
  4. data/lib/ea/fonts/metrics.rb +50 -7
  5. data/lib/ea/model/diagram_element.rb +4 -0
  6. data/lib/ea/sources/qea/diagram_builder/auto_routing.rb +124 -11
  7. data/lib/ea/sources/qea/diagram_builder/font_settings.rb +50 -0
  8. data/lib/ea/sources/qea/diagram_builder/routing.rb +82 -82
  9. data/lib/ea/sources/qea/diagram_builder.rb +9 -30
  10. data/lib/ea/sources/qea/instance_builder.rb +1 -0
  11. data/lib/ea/sources/qea/property_builder.rb +11 -1
  12. data/lib/ea/svg/ea_emitter/color_resolver.rb +1 -2
  13. data/lib/ea/svg/ea_emitter/compartment/attributes.rb +2 -1
  14. data/lib/ea/svg/ea_emitter/compartment/constraints.rb +5 -14
  15. data/lib/ea/svg/ea_emitter/compartment/enum_literals.rb +11 -5
  16. data/lib/ea/svg/ea_emitter/compartment/header.rb +2 -1
  17. data/lib/ea/svg/ea_emitter/compartment/note_body.rb +6 -4
  18. data/lib/ea/svg/ea_emitter/compartment/note_html.rb +66 -0
  19. data/lib/ea/svg/ea_emitter/compartment/operations.rb +2 -1
  20. data/lib/ea/svg/ea_emitter/compartment.rb +1 -0
  21. data/lib/ea/svg/ea_emitter/connector_redock.rb +30 -14
  22. data/lib/ea/svg/ea_emitter/element/attribute_line_builder.rb +11 -9
  23. data/lib/ea/svg/ea_emitter/element/attribute_renderer.rb +42 -17
  24. data/lib/ea/svg/ea_emitter/element/compartment_geometry.rb +46 -15
  25. data/lib/ea/svg/ea_emitter/element/constraint_renderer.rb +34 -2
  26. data/lib/ea/svg/ea_emitter/element/enumeration_literal_renderer.rb +8 -5
  27. data/lib/ea/svg/ea_emitter/element/header_line_pipeline.rb +3 -2
  28. data/lib/ea/svg/ea_emitter/element/header_line_provider/instance_spec.rb +10 -5
  29. data/lib/ea/svg/ea_emitter/element/header_line_provider/name.rb +2 -1
  30. data/lib/ea/svg/ea_emitter/element/header_renderer.rb +14 -12
  31. data/lib/ea/svg/ea_emitter/element/operation_renderer.rb +10 -7
  32. data/lib/ea/svg/ea_emitter/elements.rb +41 -33
  33. data/lib/ea/svg/ea_emitter/font_resolver.rb +14 -4
  34. data/lib/ea/svg/ea_emitter/ghost_labels.rb +3 -0
  35. data/lib/ea/svg/ea_emitter/label/end_label.rb +37 -4
  36. data/lib/ea/svg/ea_emitter/labels.rb +12 -4
  37. data/lib/ea/svg/ea_emitter/marker/diamond.rb +17 -23
  38. data/lib/ea/svg/ea_emitter/render_context.rb +1 -1
  39. data/lib/ea/svg/ea_emitter/text_renderer.rb +6 -5
  40. data/lib/ea/theme/definition.rb +5 -2
  41. data/lib/ea/theme/loader.rb +3 -2
  42. data/lib/ea/version.rb +1 -1
  43. data/memory/deviations.md +3 -0
  44. metadata +5 -1
@@ -53,10 +53,10 @@ module Ea
53
53
  return stored_top_pairs(connector, source_bounds, pairs)
54
54
  end
55
55
 
56
- # EA docks these markers on the outer edge of the 2px box stroke.
57
- outline = bounds.map do |box|
58
- Ea::Model::Bounds.new(x: box.x - 1, y: box.y - 1,
59
- width: box.width + 2, height: box.height + 2)
56
+ refs = [source_ref, target_ref]
57
+ duids = connector.style.to_h.values_at(:soid, :eoid)
58
+ outline = bounds.each_with_index.map do |box, index|
59
+ outline_bounds(box, diagram, refs[index], duids[index])
60
60
  end
61
61
  candidate = clipped_pairs(outline, anchors)
62
62
  candidate[0] = external_source_point(connector, outline.first, anchors, candidate.first)
@@ -231,19 +231,34 @@ module Ea
231
231
  return false unless bounds.all? { |box| box.width.positive? && box.height.positive? }
232
232
 
233
233
  ends = [[refs.first, style[:soid]], [refs.last, style[:eoid]]]
234
- ends.all? { |ref, duid| border_two?(diagram, ref, duid) }
234
+ ends.all? { |ref, duid| border_width?(diagram, ref, duid, 2) }
235
235
  end
236
236
  private_class_method :bordered_outline?
237
237
 
238
238
  # Every placement of the element (the DUID-matched one when the
239
- # connector names it) must declare a border width of 2.
240
- def border_two?(diagram, ref, duid)
239
+ # connector names it) must declare the same border width.
240
+ def border_width?(diagram, ref, duid, width)
241
+ placed = end_placements(diagram, ref, duid)
242
+ placed.any? && placed.all? { |el| el.line_width == width || el.style.to_h[:lwth] == width.to_s }
243
+ end
244
+ private_class_method :border_width?
245
+
246
+ # Explicit zero-width borders keep that end on the geometric edge;
247
+ # absent or inherited widths retain the default outer-stroke docking.
248
+ def outline_bounds(box, diagram, ref, duid)
249
+ return box if border_width?(diagram, ref, duid, 0)
250
+
251
+ Ea::Model::Bounds.new(x: box.x - 1, y: box.y - 1,
252
+ width: box.width + 2, height: box.height + 2)
253
+ end
254
+ private_class_method :outline_bounds
255
+
256
+ def end_placements(diagram, ref, duid)
241
257
  placed = Array(diagram&.elements).select { |el| el.model_element_ref == ref }
242
258
  named = placed.select { |el| duid && el.duid.to_s.casecmp?(duid.to_s) }
243
- placed = named unless named.empty?
244
- placed.any? && placed.all? { |el| el.line_width == 2 || el.style.to_h[:lwth] == "2" }
259
+ named.empty? ? placed : named
245
260
  end
246
- private_class_method :border_two?
261
+ private_class_method :end_placements
247
262
 
248
263
  def outline_connector?(connector, bounds)
249
264
  return true if default_model_connector?(connector)
@@ -397,12 +412,13 @@ module Ea
397
412
  end
398
413
  private_class_method :explicit_horizontal_source_dock?
399
414
 
400
- # A stored Association ray whose source port certifies the shared
401
- # anchor row is one explicit horizontal line: EA seats neither end
402
- # in a compartment row, unlike Generalization/Aggregation targets.
415
+ # The QEA producer certifies stored Association rays from their
416
+ # endpoint origins. Keep the clipped ray even when a diagonal end
417
+ # coincides with a header divider. Other callers need a certified
418
+ # horizontal port before bypassing compartment row seating.
403
419
  def explicit_association_ray?(pairs, connector)
404
420
  produced = connector.style.to_h[:stored_association_ray] == true
405
- return true if produced && pairs.first[1] == pairs.last[1]
421
+ return true if produced
406
422
 
407
423
  certified_association_ray?(pairs, connector)
408
424
  end
@@ -48,6 +48,7 @@ module Ea
48
48
  parts << ": #{type_str}" unless type_str.empty?
49
49
  mult = multiplicity
50
50
  parts << " #{mult}" unless mult.empty?
51
+ parts << " {ordered}" if property.is_ordered
51
52
  parts << " = #{property.default_value}" if property.default_value && !property.default_value.to_s.empty?
52
53
  parts.join.rstrip
53
54
  end
@@ -100,20 +101,21 @@ module Ea
100
101
  def multiplicity
101
102
  lower = property.multiplicity_lower
102
103
  upper = property.multiplicity_upper
103
- if lower == 1 && upper == 1
104
- raw = property.multiplicity_lower_raw
105
- # Literal text bounds EA displays verbatim ('elements:
106
- # Character [size]' - stored bounds 'size'/'size' parse
107
- # to the 1..1 default).
108
- return "[#{raw}]" if raw && raw == property.multiplicity_upper_raw
109
-
110
- return ""
111
- end
104
+ return default_multiplicity if lower == 1 && upper == 1
112
105
  return "" if lower.nil? && upper.nil?
106
+ return "[#{lower}]" if lower == upper && upper != -1
113
107
 
114
108
  "[#{lower || 0}..#{bound_text(upper)}]"
115
109
  end
116
110
 
111
+ # Literal text bounds EA displays verbatim ('elements:
112
+ # Character [size]' - stored bounds 'size'/'size' parse
113
+ # to the 1..1 default).
114
+ def default_multiplicity
115
+ raw = property.multiplicity_lower_raw
116
+ raw && raw == property.multiplicity_upper_raw ? "[#{raw}]" : ""
117
+ end
118
+
117
119
  def bound_text(value)
118
120
  # EA renders a stored literal bound ("n") verbatim, and a
119
121
  # computed-unbounded upper as "*" (verified against
@@ -47,12 +47,14 @@ module Ea
47
47
  end
48
48
 
49
49
  def self.render(lines, bounds:, first_y:, family:,
50
- size:, size_unit: DEFAULT_FONT_UNIT,
50
+ size:, size_unit: DEFAULT_FONT_UNIT, logical_height: nil,
51
51
  fill: "#000000",
52
52
  visibility_x_offset: DEFAULT_VISIBILITY_X_OFFSET,
53
53
  content_x_offset: nil)
54
54
 
55
- line_h = size + ROW_LINE_OFFSET
55
+ line_h = Ea::Fonts::Metrics.compartment_line_height(
56
+ size, family: family, unit: size_unit, offset: ROW_LINE_OFFSET, logical_height: logical_height
57
+ )
56
58
  offsets = group_lead_offsets(lines)
57
59
  text_blocks = []
58
60
  lines.each_with_index do |line, idx|
@@ -60,15 +62,27 @@ module Ea
60
62
  visibility, rest = split_visibility(line)
61
63
  if visibility
62
64
  unless visibility.strip.empty?
63
- text_blocks << build_text(bounds.x + visibility_x_offset, y, visibility, family, size, size_unit, fill)
65
+ text_blocks << build_text(
66
+ bounds.x + visibility_x_offset, y, visibility, family, size, size_unit, fill,
67
+ logical_height: logical_height
68
+ )
64
69
  end
65
70
  offset = (content_x_offset || DEFAULT_CONTENT_X_OFFSET) +
66
71
  2 * (size - 7)
67
- text_blocks << build_text(bounds.x + offset, y, rest, family, size, size_unit, fill)
72
+ offset -= 1 if Ea::Fonts::Metrics.compact_compartment_font?(size, family: family, unit: size_unit)
73
+ text_blocks << build_text(
74
+ bounds.x + offset, y, rest, family, size, size_unit, fill, logical_height: logical_height
75
+ )
68
76
  elsif line.strip.start_with?("«")
69
- text_blocks << build_text(bounds.x + STEREOTYPE_HEADER_X_OFFSET, y, line.strip, family, size, size_unit, fill)
77
+ text_blocks << build_text(
78
+ bounds.x + STEREOTYPE_HEADER_X_OFFSET, y, line.strip, family, size, size_unit, fill,
79
+ logical_height: logical_height
80
+ )
70
81
  else
71
- text_blocks << build_text(bounds.x + visibility_x_offset, y, line.strip, family, size, size_unit, fill)
82
+ text_blocks << build_text(
83
+ bounds.x + visibility_x_offset, y, line.strip, family, size, size_unit, fill,
84
+ logical_height: logical_height
85
+ )
72
86
  end
73
87
  end
74
88
  group_style = "stroke-width:1;stroke-linecap:round;stroke-linejoin:bevel; fill:#{fill};fill-opacity:1.00; stroke:#000000; stroke-opacity:0.00"
@@ -87,7 +101,7 @@ module Ea
87
101
  # Pass a `lookup` (any callable returning a Classifier for
88
102
  # an id) to enable inherited-property namespace prefixing.
89
103
  def self.lines_for(classifier, lookup: nil,
90
- exclude_association_ids: nil)
104
+ exclude_association_ids: nil, stored_order: false)
91
105
  props = displayable_properties(classifier, exclude_association_ids)
92
106
  return [] unless props
93
107
 
@@ -95,19 +109,14 @@ module Ea
95
109
  if stereotypes.size == 1 && props.all? { |p| property_stereotype(p) }
96
110
  # EA renders ONE compartment stereotype header for a
97
111
  # uniformly-stereotyped attribute set (e.g. «Property»
98
- # above GML property rows) and lists rows
99
- # alphabetically - verified against EA-published SVGs.
112
+ # above GML property rows).
100
113
  header = "«#{canonical_stereotype(stereotypes.first)}»"
101
- [header] + props.sort_by { |p| p.name.to_s }.map do |prop|
114
+ [header] + ordered_properties(props, stored_order, case_sensitive: true).map do |prop|
102
115
  AttributeLineBuilder.new(prop, host: classifier,
103
116
  lookup: lookup).to_s
104
117
  end
105
118
  else
106
- # EA lists attribute rows alphabetically (case-
107
- # insensitive) — corpus-verified: 991 boxes strictly
108
- # alphabetical vs 24 strictly stored-Pos order.
109
- props.sort_by { |p| [p.name.to_s.downcase, p.name.to_s] }
110
- .flat_map do |prop|
119
+ ordered_properties(props, stored_order).flat_map do |prop|
111
120
  lines = []
112
121
  stereotype = property_stereotype(prop)
113
122
  lines << "«#{stereotype}»" if stereotype
@@ -117,6 +126,20 @@ module Ea
117
126
  end
118
127
  end
119
128
 
129
+ # QEA properties arrive in stored Pos order. Association ends retain
130
+ # their alphabetical fallback rather than using connector Pos values.
131
+ def self.ordered_properties(props, stored_order, case_sensitive: false)
132
+ alphabetical = lambda do |prop|
133
+ name = prop.name.to_s
134
+ case_sensitive ? [name] : [name.downcase, name]
135
+ end
136
+ return props.sort_by(&alphabetical) unless stored_order
137
+
138
+ own, ends = props.partition { |prop| !prop.association_id }
139
+ own + ends.sort_by(&alphabetical)
140
+ end
141
+ private_class_method :ordered_properties
142
+
120
143
  STEREOTYPE_DISPLAY = {
121
144
  "property" => "Property",
122
145
  "featuretype" => "FeatureType",
@@ -140,9 +163,11 @@ module Ea
140
163
  end
141
164
  private_class_method :split_visibility
142
165
 
143
- def self.build_text(x, y, content, family, size, size_unit = DEFAULT_FONT_UNIT, fill = "#000000")
166
+ def self.build_text(x, y, content, family, size, size_unit = DEFAULT_FONT_UNIT,
167
+ fill = "#000000", logical_height: nil)
144
168
  TextRenderer.new(content: content, x: x, y: y,
145
- family: family, size: size, size_unit: size_unit, fill: fill).to_svg
169
+ family: family, size: size, size_unit: size_unit,
170
+ logical_height: logical_height, fill: fill).to_svg
146
171
  end
147
172
  private_class_method :build_text
148
173
 
@@ -40,10 +40,14 @@ module Ea
40
40
  attr_line_offset: 6,
41
41
  attr_first_offset: 7,
42
42
  attr_group_lead: 0,
43
+ family: nil, size_unit: "pt", logical_height: nil,
43
44
  header_ghost: false,
44
45
  header_ghost_tight: false)
45
46
  @bounds = bounds
46
47
  @size = size
48
+ @family = family
49
+ @size_unit = size_unit
50
+ @logical_height = logical_height
47
51
  @header_lines_count = header_lines_count
48
52
  @attr_lines_count = attr_lines_count
49
53
  @op_lines_count = op_lines_count
@@ -61,6 +65,13 @@ module Ea
61
65
  @header_ghost_tight = header_ghost_tight
62
66
  end
63
67
 
68
+ def line_height(offset)
69
+ Ea::Fonts::Metrics.compartment_line_height(
70
+ size, family: @family, unit: @size_unit, offset: offset, logical_height: @logical_height
71
+ )
72
+ end
73
+ private :line_height
74
+
64
75
  def header_first_y
65
76
  bounds.y + size + (header_top_padding || 12)
66
77
  end
@@ -81,9 +92,9 @@ module Ea
81
92
  ghost_shift = header_ghost ? (header_ghost_tight ? 3 : 6) : 0
82
93
 
83
94
  header_first_y +
84
- ([header_lines_count, 1].max - 1) * (size + (header_line_offset || 6)) +
95
+ ([header_lines_count, 1].max - 1) * line_height(header_line_offset || 6) +
85
96
  ghost_shift +
86
- (marker_count.to_i * (size + 6)) +
97
+ (marker_count.to_i * line_height(6)) +
87
98
  (divider_offset || 8)
88
99
  end
89
100
 
@@ -104,13 +115,13 @@ module Ea
104
115
  def raw_attr_first_y
105
116
  return bounds.y + size + (header_top_padding || 12) + 12 unless divider_y
106
117
 
107
- divider_y + size + (attr_first_offset || 7)
118
+ divider_y + line_height((attr_first_offset || 7) - 1) + 1
108
119
  end
109
120
 
110
121
  def attr_bottom_y
111
122
  return attr_first_y unless attr_lines_count&.positive?
112
123
 
113
- attr_first_y + (attr_lines_count - 1) * (size + (attr_line_offset || 6)) +
124
+ attr_first_y + (attr_lines_count - 1) * line_height(attr_line_offset || 6) +
114
125
  @attr_group_lead.to_i
115
126
  end
116
127
 
@@ -137,7 +148,7 @@ module Ea
137
148
  def op_bottom_y
138
149
  return op_divider_y unless op_lines_count&.positive?
139
150
 
140
- op_first_y + (op_lines_count - 1) * (size + 6)
151
+ op_first_y + (op_lines_count - 1) * line_height(6)
141
152
  end
142
153
 
143
154
  # EA draws a single divider under the header and seats
@@ -147,17 +158,33 @@ module Ea
147
158
  def enum_literal_first_y
148
159
  return nil unless divider_y
149
160
 
150
- divider_y + size + (attr_first_offset || 7)
161
+ divider_y + line_height((attr_first_offset || 7) - 1) + 1
162
+ end
163
+
164
+ # OCL constraints follow the regular content with a divider and caption.
165
+ # Suppress the whole compartment when its last row cannot fit.
166
+ def constraints_first_y
167
+ return nil unless constraints_count.to_i.positive?
168
+
169
+ first_y = constraints_caption_y
170
+ first_y if first_y + constraints_count * (size + 5) + 4 <= bounds.y + bounds.height
171
+ end
172
+
173
+ def constraints_caption_y
174
+ (regular_content_bottom_y || raw_attr_first_y) + size + 13
151
175
  end
152
176
 
153
- # Tagged values appear after attributes (or after ops if
154
- # present). EA does not emit a separate divider — the
155
- # italic "tags" header marks the compartment. Constraints
156
- # render ABOVE the attributes (constraints_first_y), not
157
- # here.
177
+ def constraints_divider_y
178
+ constraints_first_y - size - 7 if constraints_first_y
179
+ end
180
+
181
+ def constraints_bottom_y
182
+ constraints_first_y + constraints_count * (size + 5) if constraints_first_y
183
+ end
184
+
185
+ # Tagged values follow the last visible content compartment.
158
186
  def tagged_value_first_y
159
- return nil unless tagged_values_count.to_i.positive? ||
160
- constraints_count.to_i.positive?
187
+ return nil unless tagged_values_count.to_i.positive?
161
188
 
162
189
  (content_bottom_y || raw_attr_first_y) + size + 5
163
190
  end
@@ -169,6 +196,10 @@ module Ea
169
196
  # the autosize gate must not treat the empty-compartment
170
197
  # anchor as content.
171
198
  def content_bottom_y
199
+ [regular_content_bottom_y, constraints_bottom_y].compact.max
200
+ end
201
+
202
+ def regular_content_bottom_y
172
203
  bottoms = []
173
204
  bottoms << attr_bottom_y if attr_lines_count.to_i.positive?
174
205
  bottoms << enum_literals_bottom_y if enum_literals_count.to_i.positive?
@@ -179,10 +210,10 @@ module Ea
179
210
  def enum_literals_bottom_y
180
211
  return nil unless enum_literal_first_y
181
212
 
182
- enum_literal_first_y + ([enum_literals_count, 1].max - 1) * (size + 6)
213
+ enum_literal_first_y + ([enum_literals_count, 1].max - 1) * line_height(6)
183
214
  end
184
215
  end
185
216
  end
186
217
  end
187
218
  end
188
- end
219
+ end
@@ -29,9 +29,41 @@ module Ea
29
29
  %(<g style="#{Style::TEXT_GROUP}">\n#{text_blocks.join("\n")}\n</g>)
30
30
  end
31
31
 
32
- def self.build_text(x, y, content, family, size, fill)
32
+ def self.render_compartment(context)
33
+ rows = context.constraints.each_with_index.map do |constraint, idx|
34
+ compartment_row(constraint, context, idx)
35
+ end
36
+ %(<g style="#{Style::TEXT_GROUP}">\n#{([compartment_caption(context)] + rows).join("\n")}\n</g>)
37
+ end
38
+
39
+ def self.compartment_row(constraint, context, index)
40
+ build_text(context.bounds.x + 5,
41
+ context.geometry.constraints_first_y + (index + 1) * (context.size + 5),
42
+ "{#{constraint.name}}", context.family, context.size, context.text_fill,
43
+ size_unit: context.size_unit, logical_height: context.logical_height)
44
+ end
45
+ private_class_method :compartment_row
46
+
47
+ def self.compartment_caption(context)
48
+ TextRenderer.new(content: "constraints", x: caption_x(context),
49
+ y: context.geometry.constraints_first_y, family: context.family,
50
+ size: context.size, size_unit: context.size_unit, logical_height: context.logical_height,
51
+ fill: context.text_fill, style: "italic").to_svg
52
+ end
53
+ private_class_method :compartment_caption
54
+
55
+ def self.caption_x(context)
56
+ width = TextRenderer.estimate_width("constraints", context.size, nil,
57
+ family: context.family, style: "italic",
58
+ logical_height: context.logical_height).round
59
+ context.bounds.x + ((context.bounds.width - width) / 2).floor
60
+ end
61
+ private_class_method :caption_x
62
+
63
+ def self.build_text(x, y, content, family, size, fill, size_unit: "px", logical_height: nil)
33
64
  TextRenderer.new(content: content, x: x, y: y,
34
- family: family, size: size, fill: fill).to_svg
65
+ family: family, size: size, size_unit: size_unit,
66
+ logical_height: logical_height, fill: fill).to_svg
35
67
  end
36
68
  private_class_method :build_text
37
69
  end
@@ -16,19 +16,22 @@ module Ea
16
16
  # EA lists literals alphabetically (case-insensitive) and
17
17
  # spaces rows at the same 13px pitch as attributes at 7pt
18
18
  # (size + 6) — corpus-verified against EA-published SVGs.
19
- def self.render(literals, bounds:, first_y:, family:, size:)
20
- line_h = size + 6
19
+ def self.render(literals, bounds:, first_y:, family:, size:, size_unit: "px", logical_height: nil)
20
+ line_h = Ea::Fonts::Metrics.compartment_line_height(
21
+ size, family: family, unit: size_unit, logical_height: logical_height
22
+ )
21
23
  sorted = literals.sort_by { |l| [l.name.to_s.downcase, l.name.to_s] }
22
24
  text_blocks = sorted.each_with_index.map do |literal, idx|
23
25
  y = first_y + (idx * line_h)
24
- build_text(bounds.x + 5, y, literal.name.to_s, family, size)
26
+ build_text(bounds.x + 5, y, literal.name.to_s, family, size, size_unit, logical_height: logical_height)
25
27
  end
26
28
  %(<g style="#{Style::TEXT_GROUP}">\n#{text_blocks.join("\n")}\n</g>)
27
29
  end
28
30
 
29
- def self.build_text(x, y, content, family, size)
31
+ def self.build_text(x, y, content, family, size, size_unit, logical_height: nil)
30
32
  TextRenderer.new(content: content, x: x, y: y,
31
- family: family, size: size).to_svg
33
+ family: family, size: size, size_unit: size_unit,
34
+ logical_height: logical_height).to_svg
32
35
  end
33
36
  private_class_method :build_text
34
37
  end
@@ -19,7 +19,7 @@ module Ea
19
19
  module HeaderLinePipeline
20
20
  Context = Struct.new(:classifier, :diagram_package_id,
21
21
  :visually_nested, :umldi_keyword,
22
- :bounds_width, :font_size, :family,
22
+ :bounds_width, :font_size, :family, :logical_height,
23
23
  :off_canvas_parent_name,
24
24
  :foreign_package_name,
25
25
  :suppress_stereotypes,
@@ -57,7 +57,8 @@ module Ea
57
57
  width = lambda do |s|
58
58
  TextRenderer.estimate_width(
59
59
  s, context.font_size || 9, nil,
60
- family: context.family, weight: "700", style: font_style
60
+ family: context.family, weight: "700", style: font_style,
61
+ logical_height: context.logical_height
61
62
  ).round
62
63
  end
63
64
  return [text] if context.bounds_width.nil?
@@ -20,15 +20,20 @@ module Ea
20
20
  return [] unless inst.is_a?(Ea::Model::InstanceSpecification)
21
21
 
22
22
  lines = stereotype_lines(inst, context)
23
- label = inst.name.to_s
24
- if inst.classifier_name && !inst.classifier_name.empty?
25
- label = "#{label}: #{inst.classifier_name}"
26
- end
27
- lines + HeaderLinePipeline.wrap_words(label, context, :bold).map do |line|
23
+ lines + HeaderLinePipeline.wrap_words(label_for(inst), context, :bold).map do |line|
28
24
  [line, :bold]
29
25
  end
30
26
  end
31
27
 
28
+ # "Name / alias: Classifier"; t_object.Alias is the instance role.
29
+ def self.label_for(inst)
30
+ label = inst.name.to_s
31
+ label = "#{label} / #{inst.role_name}" unless inst.role_name.to_s.empty?
32
+ return label if inst.classifier_name.to_s.empty?
33
+
34
+ "#{label}: #{inst.classifier_name}"
35
+ end
36
+
32
37
  def self.stereotype_lines(inst, context)
33
38
  return [] if context.suppress_stereotypes
34
39
 
@@ -56,7 +56,8 @@ module Ea
56
56
  font_style = weight == :bold_italic ? "italic" : "normal"
57
57
  len = TextRenderer.estimate_width(
58
58
  name, context.font_size || 9, nil,
59
- family: context.family, weight: "700", style: font_style
59
+ family: context.family, weight: "700", style: font_style,
60
+ logical_height: context.logical_height
60
61
  ).round
61
62
  # Same usable width as wrap_words: box width - 6.
62
63
  # EA wraps "Content information::MD_FeatureCatalogue-
@@ -12,17 +12,20 @@ module Ea
12
12
  # x= is the start position, not the center).
13
13
  class HeaderRenderer
14
14
  def self.render(lines, bounds:, first_y:, family:,
15
- size:, size_unit: "pt",
15
+ size:, size_unit: "pt", logical_height: nil,
16
16
  fill: "#000000", weight_normal: 400, weight_bold: 700,
17
17
  stroke_in_text: "#000000", width_factor: 0.612,
18
18
  line_offset: 6)
19
- line_h = size + line_offset
19
+ logical_height = nil unless size_unit == "pt"
20
+ line_h = Ea::Fonts::Metrics.compartment_line_height(
21
+ size, family: family, unit: size_unit, offset: line_offset, logical_height: logical_height
22
+ )
20
23
  text_blocks = lines.each_with_index.map do |(text, style), idx|
21
24
  weight = case style
22
25
  when :bold, :bold_italic then weight_bold
23
26
  else weight_normal
24
27
  end
25
- font_style = (style == :italic || style == :bold_italic) ? "italic" : "normal"
28
+ font_style = %i[italic italic_tight bold_italic].include?(style) ? "italic" : "normal"
26
29
  # EA spaces the line AFTER a right-aligned ghost 3px
27
30
  # wider than the pitch. Interfaces use a tighter +3
28
31
  # (936AA434/Surface: ghost 100, «interface» 116).
@@ -33,23 +36,22 @@ module Ea
33
36
  end
34
37
  y = first_y + (idx * line_h) +
35
38
  (idx >= 1 && ghost_shift.positive? ? ghost_shift : 0)
36
- x = if style == :italic
37
- # EA right-aligns the off-canvas parent ghost at
38
- # the box's right edge by its integer textLength.
39
- bounds.x + bounds.width -
39
+ x = if %i[italic italic_tight].include?(style)
40
+ # Parent ghosts end 3px inside the box's right edge.
41
+ bounds.x + bounds.width - 3 -
40
42
  Ea::Svg::EaEmitter::TextRenderer.estimate_width(
41
43
  text, size, width_factor,
42
- family: family, weight: weight, style: font_style
44
+ family: family, weight: weight, style: font_style, logical_height: logical_height
43
45
  ).round
44
46
  else
45
47
  center_x_for(text, bounds, size, width_factor,
46
48
  family: family, weight: weight,
47
- style: font_style)
49
+ style: font_style, logical_height: logical_height)
48
50
  end
49
51
  TextRenderer.new(
50
52
  content: text,
51
53
  x: x, y: y,
52
- family: family, size: size, size_unit: size_unit,
54
+ family: family, size: size, size_unit: size_unit, logical_height: logical_height,
53
55
  weight: weight, style: font_style, fill: fill,
54
56
  stroke_in_text: stroke_in_text, width_factor: width_factor
55
57
  ).to_svg
@@ -60,9 +62,9 @@ module Ea
60
62
  # EA centers text by its INTEGER textLength and floors the
61
63
  # resulting x (verified exact against EA-published SVGs).
62
64
  def self.center_x_for(text, bounds, size, width_factor,
63
- family: nil, weight: nil, style: nil)
65
+ family: nil, weight: nil, style: nil, logical_height: nil)
64
66
  len = Ea::Svg::EaEmitter::TextRenderer.estimate_width(
65
- text, size, width_factor, family: family, weight: weight, style: style
67
+ text, size, width_factor, family: family, weight: weight, style: style, logical_height: logical_height
66
68
  ).round
67
69
  (bounds.x + (bounds.width - len) / 2.0).floor
68
70
  end
@@ -16,10 +16,12 @@ module Ea
16
16
  VISIBILITY_X_OFFSET = 5
17
17
  CONTENT_X_OFFSET = 22
18
18
 
19
- def self.render(operations, bounds:, first_y:, family:, size:)
19
+ def self.render(operations, bounds:, first_y:, family:, size:, size_unit: "px", logical_height: nil)
20
20
  # EA's op rows pitch 13 (size + 6), same as attribute
21
21
  # rows (6D4C665D: ops 541..658 step 13).
22
- line_h = size + 6
22
+ line_h = Ea::Fonts::Metrics.compartment_line_height(
23
+ size, family: family, unit: size_unit, logical_height: logical_height
24
+ )
23
25
  text_blocks = []
24
26
  has_receptions = operations.any?(&:is_reception)
25
27
  if has_receptions
@@ -27,7 +29,7 @@ module Ea
27
29
  content: "receptions",
28
30
  x: bounds.x + (bounds.width / 2.0) - (8 * size * 0.65) / 2,
29
31
  y: first_y,
30
- family: family, size: size,
32
+ family: family, size: size, size_unit: size_unit, logical_height: logical_height,
31
33
  style: "italic", fill: "#000000"
32
34
  ).to_svg
33
35
  end
@@ -36,9 +38,9 @@ module Ea
36
38
  ops.each_with_index do |op, idx|
37
39
  y = first_y + ((idx + offset) * line_h)
38
40
  text_blocks << build_text(bounds.x + VISIBILITY_X_OFFSET, y,
39
- visibility_prefix(op), family, size)
41
+ visibility_prefix(op), family, size, size_unit, logical_height: logical_height)
40
42
  text_blocks << build_text(bounds.x + AttributeRenderer.content_x_offset(size), y,
41
- operation_text(op), family, size)
43
+ operation_text(op), family, size, size_unit, logical_height: logical_height)
42
44
  end
43
45
  %(<g style="#{Style::TEXT_GROUP}">\n#{text_blocks.join("\n")}\n</g>)
44
46
  end
@@ -90,9 +92,10 @@ module Ea
90
92
  end
91
93
  private_class_method :reception_params
92
94
 
93
- def self.build_text(x, y, content, family, size)
95
+ def self.build_text(x, y, content, family, size, size_unit, logical_height: nil)
94
96
  TextRenderer.new(content: content, x: x, y: y,
95
- family: family, size: size).to_svg
97
+ family: family, size: size, size_unit: size_unit,
98
+ logical_height: logical_height).to_svg
96
99
  end
97
100
  private_class_method :build_text
98
101
  end