ea 0.6.3 → 0.6.5

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 (47) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +72 -0
  3. data/config/themes/119.yml +4 -4
  4. data/frontend/dist/app.iife.js +8 -8
  5. data/frontend/src/App.vue +6 -2
  6. data/frontend/src/components/AppHeader.vue +35 -8
  7. data/frontend/src/components/AppSidebar.vue +2 -2
  8. data/frontend/src/components/ClassDetails.vue +103 -120
  9. data/frontend/src/components/DiagramView.vue +19 -6
  10. data/frontend/src/components/PackageDetails.vue +67 -26
  11. data/frontend/src/components/PackageTreeNode.vue +25 -20
  12. data/frontend/src/components/SearchResults.vue +21 -8
  13. data/frontend/src/components/WelcomeView.vue +2 -2
  14. data/frontend/src/stores/dataStore.ts +142 -48
  15. data/frontend/src/stores/uiStore.ts +3 -4
  16. data/frontend/src/types.ts +160 -163
  17. data/lib/ea/model/diagram_connector.rb +9 -0
  18. data/lib/ea/model/property.rb +6 -1
  19. data/lib/ea/sources/qea/classifier_builder.rb +3 -1
  20. data/lib/ea/sources/qea/diagram_builder.rb +91 -9
  21. data/lib/ea/sources/qea/property_builder.rb +12 -2
  22. data/lib/ea/spa/output/single_file_strategy.rb +1 -1
  23. data/lib/ea/spa/projector.rb +108 -6
  24. data/lib/ea/svg/ea_emitter/compartment/attributes.rb +1 -0
  25. data/lib/ea/svg/ea_emitter/compartment/constraints.rb +1 -0
  26. data/lib/ea/svg/ea_emitter/compartment/enum_literals.rb +9 -25
  27. data/lib/ea/svg/ea_emitter/compartment/header.rb +1 -0
  28. data/lib/ea/svg/ea_emitter/element/attribute_line_builder.rb +5 -0
  29. data/lib/ea/svg/ea_emitter/element/attribute_renderer.rb +51 -11
  30. data/lib/ea/svg/ea_emitter/element/compartment_geometry.rb +12 -10
  31. data/lib/ea/svg/ea_emitter/element/header_line_provider/name.rb +6 -15
  32. data/lib/ea/svg/ea_emitter/element/header_line_provider/stereotype_label.rb +25 -8
  33. data/lib/ea/svg/ea_emitter/element/header_renderer.rb +16 -4
  34. data/lib/ea/svg/ea_emitter/element/legend_renderer.rb +29 -4
  35. data/lib/ea/svg/ea_emitter/elements.rb +70 -20
  36. data/lib/ea/svg/ea_emitter/font_resolver.rb +5 -6
  37. data/lib/ea/svg/ea_emitter/label/end_label.rb +3 -1
  38. data/lib/ea/svg/ea_emitter/labels.rb +38 -2
  39. data/lib/ea/svg/ea_emitter/text_renderer.rb +97 -8
  40. data/lib/ea/svg/parity/checker.rb +19 -9
  41. data/lib/ea/svg/parity/corpus_report.rb +83 -0
  42. data/lib/ea/svg/parity/differ.rb +231 -0
  43. data/lib/ea/svg/parity.rb +2 -0
  44. data/lib/ea/theme/definition.rb +3 -3
  45. data/lib/ea/theme/loader.rb +2 -2
  46. data/lib/ea/version.rb +1 -1
  47. metadata +4 -2
@@ -76,7 +76,22 @@ module Ea
76
76
  def metadata_hash
77
77
  base = document.metadata
78
78
  merged = configuration ? configuration.apply_to_metadata(base) : base
79
- JSON.parse(merged.to_json)
79
+ hash = JSON.parse(merged.to_json)
80
+ hash["statistics"] = statistics_hash
81
+ hash
82
+ end
83
+
84
+ def statistics_hash
85
+ {
86
+ "packages" => document.packages.size,
87
+ "classes" => document.classifiers.size,
88
+ "attributes" => document.classifiers.sum { |c| c.properties.size },
89
+ "operations" => document.classifiers.sum { |c| c.operations.size },
90
+ "associations" => document.relationships.count do |r|
91
+ r.is_a?(Ea::Model::Association)
92
+ end,
93
+ "diagrams" => render_diagrams? ? document.diagrams.size : 0
94
+ }
80
95
  end
81
96
 
82
97
  def view_extras
@@ -84,14 +99,16 @@ module Ea
84
99
  end
85
100
 
86
101
  def build_package_tree
102
+ sub_ids_by_parent = document.packages.group_by(&:parent_id)
87
103
  nodes = document.packages.map do |pkg|
88
104
  PackageTreeNode.new(
89
105
  id: pkg.id,
90
106
  name: pkg.name,
91
107
  parent_id: pkg.parent_id,
92
- child_ids: pkg.sub_package_ids,
108
+ child_ids: (pkg.sub_package_ids +
109
+ (sub_ids_by_parent[pkg.id] || []).map(&:id)).uniq,
93
110
  classifier_ids: classifiers_in_package_ids(pkg.id),
94
- diagram_ids: pkg.diagram_ids
111
+ diagram_ids: diagram_ids_for_package(pkg.id)
95
112
  )
96
113
  end
97
114
  PackageTree.new(
@@ -105,7 +122,7 @@ module Ea
105
122
  end
106
123
 
107
124
  def build_entries
108
- document.classifiers.map do |c|
125
+ classifier_entries = document.classifiers.map do |c|
109
126
  SkeletonEntry.new(
110
127
  id: c.id,
111
128
  name: c.name,
@@ -115,6 +132,27 @@ module Ea
115
132
  shard_url: shard_url_for.call(c)
116
133
  )
117
134
  end
135
+ package_entries = document.packages.map do |p|
136
+ SkeletonEntry.new(
137
+ id: p.id,
138
+ name: p.name,
139
+ kind: "package",
140
+ package_id: p.parent_id,
141
+ qualified_name: p.qualified_name,
142
+ shard_url: shard_url_for.call(p)
143
+ )
144
+ end
145
+ diagram_entries = render_diagrams? ? document.diagrams.map do |d|
146
+ SkeletonEntry.new(
147
+ id: d.id,
148
+ name: d.name,
149
+ kind: "diagram",
150
+ package_id: d.package_id,
151
+ qualified_name: d.name,
152
+ shard_url: shard_url_for.call(d)
153
+ )
154
+ end : []
155
+ classifier_entries + package_entries + diagram_entries
118
156
  end
119
157
 
120
158
  def build_search_entries
@@ -191,8 +229,72 @@ module Ea
191
229
  end
192
230
  end
193
231
 
194
- def payload_for(element)
195
- JSON.parse(element.to_json)
232
+ def payload_for(model_element)
233
+ payload = JSON.parse(model_element.to_json)
234
+ case model_element
235
+ when Ea::Model::Classifier
236
+ augment_classifier_payload!(payload, model_element)
237
+ when Ea::Model::Package
238
+ payload["classifierIds"] = classifiers_in_package_ids(model_element.id)
239
+ payload["diagramIds"] = diagram_ids_for_package(model_element.id)
240
+ when Ea::Model::Diagram
241
+ augment_diagram_payload!(payload, model_element)
242
+ end
243
+ payload
244
+ end
245
+
246
+ def diagram_ids_for_package(package_id)
247
+ (document.packages.find { |p| p.id == package_id }&.diagram_ids ||
248
+ []) + document.diagrams.select { |d| d.package_id == package_id }.map(&:id)
249
+ end
250
+
251
+ def augment_classifier_payload!(payload, classifier)
252
+ rels = document.relationships_for(classifier.id)
253
+ payload["generalizations"] = rels
254
+ .select { |r| r.is_a?(Ea::Model::Generalization) && r.specific_id == classifier.id }
255
+ .map { |r| { "id" => r.id, "targetId" => r.general_id } }
256
+ payload["specializations"] = rels
257
+ .select { |r| r.is_a?(Ea::Model::Generalization) && r.general_id == classifier.id }
258
+ .map { |r| { "id" => r.id, "targetId" => r.specific_id } }
259
+ payload["associations"] = rels
260
+ .select { |r| r.is_a?(Ea::Model::Association) }
261
+ .map { |r| association_stub(r, classifier.id) }
262
+ payload
263
+ end
264
+
265
+ def association_stub(assoc, classifier_id)
266
+ {
267
+ "id" => assoc.id,
268
+ "name" => assoc.name,
269
+ "sourceId" => assoc.source_id,
270
+ "targetId" => assoc.target_id,
271
+ "thisEndRoleName" => assoc.source_id == classifier_id ? assoc.source_role_name : assoc.target_role_name,
272
+ "otherEndRoleName" => assoc.source_id == classifier_id ? assoc.target_role_name : assoc.source_role_name,
273
+ "otherEndId" => assoc.source_id == classifier_id ? assoc.target_id : assoc.source_id,
274
+ "otherEndMultiplicity" => cardinality(assoc, classifier_id),
275
+ "otherEndAggregation" => assoc.source_id == classifier_id ? assoc.target_aggregation : assoc.source_aggregation
276
+ }
277
+ end
278
+
279
+ def cardinality(assoc, classifier_id)
280
+ if assoc.source_id == classifier_id
281
+ [assoc.target_multiplicity_lower, assoc.target_multiplicity_upper]
282
+ else
283
+ [assoc.source_multiplicity_lower, assoc.source_multiplicity_upper]
284
+ end
285
+ end
286
+
287
+ def augment_diagram_payload!(payload, diagram)
288
+ payload["svg"] = Ea::Svg::EaEmitter::Document.new(
289
+ diagram,
290
+ model_index: document.index_by_id,
291
+ document: document
292
+ ).render
293
+ payload
294
+ rescue StandardError => e
295
+ warn "ea: skipping SVG render for diagram #{diagram.id} " \
296
+ "(#{diagram.name.inspect}): #{e.class}: #{e.message}"
297
+ payload
196
298
  end
197
299
  end
198
300
  end
@@ -10,6 +10,7 @@ module Ea
10
10
 
11
11
  def render(context)
12
12
  return nil if context.attr_lines.empty?
13
+ return nil unless context.geometry.attr_first_y
13
14
 
14
15
  Element::AttributeRenderer.render(
15
16
  context.attr_lines,
@@ -14,6 +14,7 @@ module Ea
14
14
 
15
15
  def render(context)
16
16
  return nil unless context.constraints&.any?
17
+ return nil unless context.geometry.tagged_value_first_y
17
18
 
18
19
  Element::ConstraintRenderer.render(
19
20
  context.constraints,
@@ -10,45 +10,29 @@ module Ea
10
10
  module EnumLiterals
11
11
  module_function
12
12
 
13
+ # EA: literal rows sit directly under the header divider,
14
+ # 13px apart at 7pt (attr rows use 14), first row at
15
+ # divider + 14. No compartment divider, no header label.
16
+ LITERAL_LINE_OFFSET = 6
17
+
13
18
  def render(context)
14
19
  return nil unless context.enum_literals.any?
15
20
  return nil unless context.geometry.enum_literal_first_y
16
21
 
17
- [
18
- Element::DividerRenderer.render(
19
- context.bounds,
20
- y: context.geometry.enum_divider_y,
21
- stroke: context.stroke,
22
- stroke_width: context.stroke_width
23
- ),
24
- render_literals_block(context)
25
- ].join("\n")
22
+ render_literals_block(context)
26
23
  end
27
24
 
28
25
  def render_literals_block(context)
29
- line_h = context.size + (context.theme.compartments.attr_line_offset || 4)
30
- text_blocks = [literal_header_text(context)]
26
+ line_h = context.size + LITERAL_LINE_OFFSET
27
+ text_blocks = []
31
28
  context.enum_literals.each_with_index do |literal, idx|
32
- y = context.geometry.enum_literal_first_y + ((idx + 1) * line_h)
29
+ y = context.geometry.enum_literal_first_y + (idx * line_h)
33
30
  text_blocks << visibility_placeholder(context, y)
34
31
  text_blocks << literal_name_text(context, literal, y)
35
32
  end
36
33
  wrap_group(text_blocks, context.theme.text_color)
37
34
  end
38
35
 
39
- def literal_header_text(context)
40
- TextRenderer.new(
41
- content: "literals",
42
- x: context.bounds.x + (context.bounds.width / 2.0) -
43
- (8 * context.size * context.theme.text_width_factor) / 2,
44
- y: context.geometry.enum_literal_first_y,
45
- family: context.family, size: context.size, size_unit: context.size_unit,
46
- style: "italic", fill: context.theme.text_color,
47
- stroke_in_text: context.theme.stroke_in_text_color,
48
- width_factor: context.theme.text_width_factor
49
- ).to_svg
50
- end
51
-
52
36
  def visibility_placeholder(context, y)
53
37
  TextRenderer.new(
54
38
  content: " ",
@@ -11,6 +11,7 @@ module Ea
11
11
 
12
12
  def render(context)
13
13
  return nil if context.header_lines.empty?
14
+ return nil unless context.geometry.header_first_y
14
15
 
15
16
  Element::HeaderRenderer.render(
16
17
  context.header_lines,
@@ -103,6 +103,11 @@ module Ea
103
103
  end
104
104
 
105
105
  def bound_text(value)
106
+ # EA renders a stored literal bound ("n") verbatim, and a
107
+ # computed-unbounded upper as "*" (verified against
108
+ # EA-published reference SVGs).
109
+ return property.multiplicity_upper_raw if value == -1 && property.multiplicity_upper_raw
110
+
106
111
  value == -1 ? "*" : value.to_s
107
112
  end
108
113
  end
@@ -9,15 +9,19 @@ module Ea
9
9
  # content) matching EA's encoding.
10
10
  class AttributeRenderer
11
11
  DEFAULT_VISIBILITY_X_OFFSET = 5
12
- DEFAULT_CONTENT_X_OFFSET = 26
12
+ DEFAULT_CONTENT_X_OFFSET = 22
13
13
  DEFAULT_FONT_UNIT = "pt"
14
+ # EA seats compartment stereotype headers («Property») 12px
15
+ # from the box edge and spaces rows 13px at 7pt.
16
+ STEREOTYPE_HEADER_X_OFFSET = 12
17
+ ROW_LINE_OFFSET = 6
14
18
 
15
19
  def self.render(lines, bounds:, first_y:, family:,
16
20
  size:, size_unit: DEFAULT_FONT_UNIT,
17
21
  fill: "#000000",
18
22
  visibility_x_offset: DEFAULT_VISIBILITY_X_OFFSET,
19
23
  content_x_offset: DEFAULT_CONTENT_X_OFFSET)
20
- line_h = size + 4
24
+ line_h = size + ROW_LINE_OFFSET
21
25
  text_blocks = []
22
26
  lines.each_with_index do |line, idx|
23
27
  y = first_y + (idx * line_h)
@@ -25,6 +29,8 @@ module Ea
25
29
  if visibility
26
30
  text_blocks << build_text(bounds.x + visibility_x_offset, y, visibility, family, size, size_unit, fill)
27
31
  text_blocks << build_text(bounds.x + content_x_offset, y, rest, family, size, size_unit, fill)
32
+ elsif line.strip.start_with?("«")
33
+ text_blocks << build_text(bounds.x + STEREOTYPE_HEADER_X_OFFSET, y, line.strip, family, size, size_unit, fill)
28
34
  else
29
35
  text_blocks << build_text(bounds.x + visibility_x_offset, y, line.strip, family, size, size_unit, fill)
30
36
  end
@@ -44,19 +50,44 @@ module Ea
44
50
  #
45
51
  # Pass a `lookup` (any callable returning a Classifier for
46
52
  # an id) to enable inherited-property namespace prefixing.
47
- def self.lines_for(classifier, lookup: nil)
48
- props = displayable_properties(classifier)
53
+ def self.lines_for(classifier, lookup: nil,
54
+ exclude_association_ids: nil)
55
+ props = displayable_properties(classifier, exclude_association_ids)
49
56
  return [] unless props
50
57
 
51
- props.flat_map do |prop|
52
- lines = []
53
- stereotype = property_stereotype(prop)
54
- lines << "«#{stereotype}»" if stereotype
55
- lines << AttributeLineBuilder.new(prop, host: classifier,
58
+ stereotypes = props.map { |p| property_stereotype(p) }.compact.uniq
59
+ if stereotypes.size == 1 && props.all? { |p| property_stereotype(p) }
60
+ # EA renders ONE compartment stereotype header for a
61
+ # uniformly-stereotyped attribute set (e.g. «Property»
62
+ # above GML property rows) and lists rows
63
+ # alphabetically - verified against EA-published SVGs.
64
+ header = "«#{canonical_stereotype(stereotypes.first)}»"
65
+ [header] + props.sort_by { |p| p.name.to_s }.map do |prop|
66
+ AttributeLineBuilder.new(prop, host: classifier,
67
+ lookup: lookup).to_s
68
+ end
69
+ else
70
+ props.flat_map do |prop|
71
+ lines = []
72
+ stereotype = property_stereotype(prop)
73
+ lines << "«#{stereotype}»" if stereotype
74
+ lines << AttributeLineBuilder.new(prop, host: classifier,
56
75
  lookup: lookup).to_s
76
+ end
57
77
  end
58
78
  end
59
79
 
80
+ STEREOTYPE_DISPLAY = {
81
+ "property" => "Property",
82
+ "featuretype" => "FeatureType",
83
+ "objecttype" => "ObjectType",
84
+ "codelist" => "CodeList"
85
+ }.freeze
86
+
87
+ def self.canonical_stereotype(ref)
88
+ STEREOTYPE_DISPLAY.fetch(ref.to_s.downcase, ref.to_s)
89
+ end
90
+
60
91
  # Internal helpers
61
92
 
62
93
  def self.split_visibility(line)
@@ -75,10 +106,19 @@ module Ea
75
106
  end
76
107
  private_class_method :build_text
77
108
 
78
- def self.displayable_properties(classifier)
109
+ # Association-end properties render in the box only when
110
+ # their association is NOT drawn on the diagram (drawn ones
111
+ # render as connector role labels). exclude_association_ids
112
+ # carries the diagram's drawn association ids; nil preserves
113
+ # the legacy hide-all behavior.
114
+ def self.displayable_properties(classifier, exclude_association_ids)
79
115
  return nil unless classifier.properties
80
116
 
81
- classifier.properties.reject(&:association_id)
117
+ classifier.properties.reject do |prop|
118
+ prop.association_id &&
119
+ (exclude_association_ids.nil? ||
120
+ exclude_association_ids.include?(prop.association_id))
121
+ end
82
122
  end
83
123
  private_class_method :displayable_properties
84
124
 
@@ -27,10 +27,10 @@ module Ea
27
27
  def initialize(bounds:, size:, header_lines_count:,
28
28
  attr_lines_count:, op_lines_count:,
29
29
  tagged_values_count:,
30
- header_top_padding: 9,
30
+ header_top_padding: 12,
31
31
  header_line_offset: 6,
32
32
  divider_offset: 8,
33
- attr_line_offset: 4,
33
+ attr_line_offset: 6,
34
34
  attr_first_offset: 7)
35
35
  @bounds = bounds
36
36
  @size = size
@@ -46,7 +46,7 @@ module Ea
46
46
  end
47
47
 
48
48
  def header_first_y
49
- bounds.y + size + (header_top_padding || 9)
49
+ bounds.y + size + (header_top_padding || 12)
50
50
  end
51
51
 
52
52
  # Y of the divider line between header and the rest.
@@ -61,7 +61,7 @@ module Ea
61
61
  end
62
62
 
63
63
  def attr_first_y
64
- return bounds.y + size + (header_top_padding || 9) + 12 unless divider_y
64
+ return bounds.y + size + (header_top_padding || 12) + 12 unless divider_y
65
65
 
66
66
  divider_y + size + (attr_first_offset || 7)
67
67
  end
@@ -69,7 +69,7 @@ module Ea
69
69
  def attr_bottom_y
70
70
  return attr_first_y unless attr_lines_count&.positive?
71
71
 
72
- attr_first_y + (attr_lines_count - 1) * (size + (attr_line_offset || 4))
72
+ attr_first_y + (attr_lines_count - 1) * (size + (attr_line_offset || 6))
73
73
  end
74
74
 
75
75
  def op_divider_y
@@ -88,12 +88,14 @@ module Ea
88
88
  op_first_y + (op_lines_count - 1) * (size + 4)
89
89
  end
90
90
 
91
- def enum_divider_y
92
- op_bottom_y + size + 5
93
- end
94
-
91
+ # EA draws a single divider under the header and seats
92
+ # enumeration literals directly after it (+14 at 7pt),
93
+ # exactly like attribute rows - no second divider and no
94
+ # "literals" header. Verified against EA-published SVGs.
95
95
  def enum_literal_first_y
96
- enum_divider_y + size + 5
96
+ return nil unless divider_y
97
+
98
+ divider_y + size + (attr_first_offset || 7)
97
99
  end
98
100
 
99
101
  # Tagged values appear after attributes (or after ops if
@@ -51,22 +51,13 @@ module Ea
51
51
  (bounds_width - QUALIFIED_WRAP_PADDING)
52
52
  end
53
53
 
54
- def self.display_name(classifier, diagram_package_id = nil,
54
+ # EA renders the plain classifier name in element headers
55
+ # (element ownership qualification is a display option the
56
+ # corpus diagrams leave off - verified against EA-published
57
+ # reference SVGs).
58
+ def self.display_name(classifier, _diagram_package_id = nil,
55
59
  _visually_nested = false)
56
- name = if classifier.is_a?(Ea::Model::Classifier)
57
- (classifier.qualified_name || classifier.name).to_s
58
- else
59
- classifier.name.to_s
60
- end
61
- return name if diagram_package_id.nil?
62
- return name unless name.include?("::")
63
-
64
- if classifier.is_a?(Ea::Model::Classifier) &&
65
- classifier.package_id == diagram_package_id
66
- name.split("::").last
67
- else
68
- name
69
- end
60
+ classifier.name.to_s
70
61
  end
71
62
  end
72
63
  end
@@ -18,25 +18,26 @@ module Ea
18
18
  classifier = context.classifier
19
19
  return [] if classifier.is_a?(Ea::Model::InstanceSpecification)
20
20
 
21
- label = label_for(classifier, context.umldi_keyword)
22
- return [] unless label
21
+ labels = label_for(classifier, context.umldi_keyword)
22
+ return [] if labels.empty?
23
23
 
24
- [[label, :normal]]
24
+ labels.map { |l| [l, :normal] }
25
25
  end
26
26
 
27
- # @return [String, nil] «name» label or nil
27
+ # @return [Array<String>] «name» labels (EA renders one
28
+ # line per applied stereotype)
28
29
  def self.label_for(classifier, umldi_keyword)
29
- return "«#{umldi_keyword}»" if umldi_keyword && !umldi_keyword.empty?
30
+ return ["«#{umldi_keyword}»"] if umldi_keyword && !umldi_keyword.empty?
30
31
 
31
32
  explicit = explicit_stereotype(classifier)
32
33
  return explicit if explicit
33
34
 
34
35
  case classifier
35
36
  when Ea::Model::Klass, Ea::Model::Package, Ea::Model::Note
36
- nil
37
+ []
37
38
  else
38
39
  fallback = fallback_name(classifier)
39
- fallback ? "«#{fallback}»" : nil
40
+ fallback ? ["«#{fallback}»"] : []
40
41
  end
41
42
  end
42
43
 
@@ -47,7 +48,23 @@ module Ea
47
48
  refs = classifier.stereotype_refs
48
49
  return nil unless refs&.any?
49
50
 
50
- "«#{refs.first}»"
51
+ refs.map { |ref| "«#{canonical(ref)}»" }
52
+ end
53
+
54
+ # EA displays canonical stereotype spellings regardless of
55
+ # how the QEA stored them (property → Property). Prefer the
56
+ # MDG alias registry when its definition is available; the
57
+ # overrides map covers the spellings verified directly
58
+ # against EA-published reference SVGs.
59
+ DISPLAY_OVERRIDES = {
60
+ "property" => "Property",
61
+ "featuretype" => "FeatureType",
62
+ "objecttype" => "ObjectType",
63
+ "codelist" => "CodeList"
64
+ }.freeze
65
+
66
+ def self.canonical(ref)
67
+ DISPLAY_OVERRIDES.fetch(ref.downcase, ref)
51
68
  end
52
69
 
53
70
  def self.fallback_name(classifier)
@@ -14,7 +14,7 @@ module Ea
14
14
  def self.render(lines, bounds:, first_y:, family:,
15
15
  size:, size_unit: "pt",
16
16
  fill: "#000000", weight_normal: 400, weight_bold: 700,
17
- stroke_in_text: "#000000", width_factor: 0.65,
17
+ stroke_in_text: "#000000", width_factor: 0.612,
18
18
  line_offset: 6)
19
19
  line_h = size + line_offset
20
20
  text_blocks = lines.each_with_index.map do |(text, style), idx|
@@ -23,11 +23,23 @@ module Ea
23
23
  else weight_normal
24
24
  end
25
25
  font_style = (style == :italic || style == :bold_italic) ? "italic" : "normal"
26
- y = first_y + (idx * line_h)
27
- centered_x = center_x_for(text, bounds, size, width_factor)
26
+ # EA spaces the line AFTER a right-aligned ghost 6px
27
+ # wider (ghost +13, next +32 at 7pt) - verified against
28
+ # EA-published reference SVGs.
29
+ y = first_y + (idx * line_h) +
30
+ (idx >= 1 && lines.first.last == :italic ? 6 : 0)
31
+ x = if style == :italic
32
+ # EA right-aligns the off-canvas parent ghost at
33
+ # the box's right edge (verified across EA-published
34
+ # reference SVGs).
35
+ bounds.x + bounds.width -
36
+ Ea::Svg::EaEmitter::TextRenderer.estimate_width(text, size, width_factor)
37
+ else
38
+ center_x_for(text, bounds, size, width_factor)
39
+ end
28
40
  TextRenderer.new(
29
41
  content: text,
30
- x: centered_x, y: y,
42
+ x: x, y: y,
31
43
  family: family, size: size, size_unit: size_unit,
32
44
  weight: weight, style: font_style, fill: fill,
33
45
  stroke_in_text: stroke_in_text, width_factor: width_factor
@@ -24,8 +24,8 @@ module Ea
24
24
  class LegendRenderer
25
25
  CONTAINER_RX = 3
26
26
  ICON_SIZE = 17
27
- TITLE_Y_OFFSET = 19
28
- FIRST_ITEM_Y_OFFSET = 30
27
+ TITLE_Y_OFFSET = 22
28
+ FIRST_ITEM_Y_OFFSET = 33
29
29
  ITEM_SPACING = 19
30
30
  ICON_X_OFFSET = 10
31
31
  ICON_LABEL_GAP = 22
@@ -130,11 +130,36 @@ module Ea
130
130
  end
131
131
 
132
132
  def title_text
133
- x = bounds.x + (bounds.width / 2) - (title_text_width / 2)
133
+ x = title_x
134
134
  y = bounds.y + TITLE_Y_OFFSET
135
135
  build_text(legend.title, x: x, y: y, size: title_font_size, weight: 700)
136
136
  end
137
137
 
138
+ # EA centers the title over the ITEM BLOCK (icon left edge
139
+ # to the right end of the widest label), not the container
140
+ # box - verified against EA-published reference SVGs.
141
+ def title_x
142
+ icon_left = bounds.x + ICON_X_OFFSET
143
+ block_right = icon_left + ICON_LABEL_GAP +
144
+ widest_item_label_width
145
+ block_left = icon_left
146
+ block_left + (block_right - block_left - title_text_width) / 2.0
147
+ end
148
+
149
+ CJK_RANGE = /[\u3000-\u9fff\uff00-\uffef]/
150
+
151
+ # CJK glyphs run ~0.82em, others ~0.612em (regressed from
152
+ # EA's own textLength values on mixed-script labels).
153
+ def estimated_width(text, size)
154
+ text.to_s.each_char.sum do |ch|
155
+ ch.match?(CJK_RANGE) ? size * 0.70 : size * 0.612
156
+ end
157
+ end
158
+
159
+ def widest_item_label_width
160
+ legend.items.map { |i| estimated_width(i.name, item_font_size) }.max || 0.0
161
+ end
162
+
138
163
  def item_top_y(index)
139
164
  bounds.y + FIRST_ITEM_Y_OFFSET + (index * ITEM_SPACING)
140
165
  end
@@ -148,7 +173,7 @@ module Ea
148
173
  end
149
174
 
150
175
  def title_text_width
151
- legend.title.to_s.length * title_font_size * 0.6
176
+ estimated_width(legend.title, title_font_size)
152
177
  end
153
178
 
154
179
  def build_text(content, x:, y:, size:, weight:)