ea 0.6.17 → 0.6.18

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 23adcefba5c783bfccc47f9cbb8d8cdf327431629857a4a7b46b67bbef529b21
4
- data.tar.gz: ad21177acd96ceaa4e4c0e30cf47073e6ae333639a8673ebc9771ee6ddd8d2d3
3
+ metadata.gz: 951615c9e10080cf517cdae2a6052582c8a61dfa83f5607306471073665742ee
4
+ data.tar.gz: c496317d449df3482decf289edcd71c625cff4fcae28ccb7c5bf7bb81cfdb91d
5
5
  SHA512:
6
- metadata.gz: 6a8b2a05a0ab5dd288494d62b24f7911a40e823db514a20a6ed6ee9897c2290b997b3b85d5580d6ba4bad516ed8da96f4b44f4eb69a69b064b38c50e8193faea
7
- data.tar.gz: f66f47990a91b56ee9d25c4e714355f4bbe603fd6f77418e8bfb022b7b71e0dff4d1ebb712a1404493fbb6a1d2a21f452f789999b38bdb098081e0216fbcf6ac
6
+ metadata.gz: accef1410192459bf992364629e34c414d96f4938e78b3776232b36287f88e1b12e029e42bc1c29ca9ca6496f62e88118f399416eda8db872e32e67d953c523f
7
+ data.tar.gz: b21a6f9a663ebbd335f6b3bf44841b5679c3c2b80b79f35e959e52bfc06fe75988cb350f5281a42046a1de5b6ceb8739dbea9df0e6596d41fc7b092fa0092d0b
@@ -19,6 +19,11 @@ module Ea
19
19
  attribute :target_edge, :integer
20
20
  attribute :connector_type, :string
21
21
  attribute :direction, :string
22
+ # t_connector.SourceStereotype / DestStereotype — EA renders
23
+ # the «Property» end-label exactly on the stereotyped end
24
+ # (corpus-verified; direction alone does not decide).
25
+ attribute :source_stereotype, :string
26
+ attribute :target_stereotype, :string
22
27
  attribute :waypoints, Waypoint, collection: true, initialize_empty: true
23
28
  attribute :label, :string
24
29
  attribute :style, :hash, default: -> { {} }
@@ -54,6 +59,8 @@ module Ea
54
59
  map "targetPort", to: :target_port
55
60
  map "connectorType", to: :connector_type
56
61
  map "direction", to: :direction
62
+ map "sourceStereotype", to: :source_stereotype
63
+ map "targetStereotype", to: :target_stereotype
57
64
  map "waypoints", to: :waypoints, render_empty: true
58
65
  map "label", to: :label
59
66
  map "style", to: :style
@@ -199,6 +199,8 @@ module Ea
199
199
  relationship_ref: ref_for_raw_connector(conn),
200
200
  connector_type: conn.connector_type,
201
201
  direction: conn.direction,
202
+ source_stereotype: conn.sourcestereotype,
203
+ target_stereotype: conn.deststereotype,
202
204
  waypoints: waypoints,
203
205
  label_boxes: DEFAULT_LABEL_BOXES,
204
206
  style: {},
@@ -240,6 +242,8 @@ module Ea
240
242
  relationship_ref: ref_for_connector(link_row),
241
243
  connector_type: connector&.connector_type,
242
244
  direction: connector&.direction,
245
+ source_stereotype: connector&.sourcestereotype,
246
+ target_stereotype: connector&.deststereotype,
243
247
  waypoints: waypoints_for_link(link_row, diagram_row),
244
248
  source_port: connection_port(diagram_row, connector, :source, geom),
245
249
  target_port: connection_port(diagram_row, connector, :target, geom),
@@ -8,20 +8,14 @@ module Ea
8
8
  # stays a small value-object with just coordinate translation
9
9
  # and formatting concerns.
10
10
  #
11
- # Sources contributing to the bounds:
12
- # - ElementBounds: logical x-extent, logical+image y-extent
13
- # - ConnectorBounds: waypoint positions
14
- # - MarkerExtent: MARKER_EXTENT padding around end points
15
- # - PackageTabExtent: extra space above Package elements for
16
- # the tab polygon (which sits above the
17
- # element's logical top).
18
- #
19
- # Each source is a method returning an Array<[x, y]> points.
20
- # Composing new sources = adding a method, no modification of
21
- # existing ones (OCP).
11
+ # Corpus-verified against 3,663 EA-published reference SVGs:
12
+ # EA's canvas covers the ELEMENT RECTS only — connector
13
+ # waypoints, arrow markers, and package-tab overhangs do NOT
14
+ # extend it. Width = element extent + 85 is exact on 72% of
15
+ # the corpus; height = element extent + 76 exact on 50% (the
16
+ # remainder carry invisible per-diagram reservations below
17
+ # the content).
22
18
  class BoundsCalculator
23
- MARKER_EXTENT = 15
24
- PACKAGE_TAB_HEIGHT = 20
25
19
  # EA's canvas includes non-uniform frame insets around the
26
20
  # element content. Reverse-engineered from reference SVG
27
21
  # byte-diff: maintenance diagram has element 169x80 at
@@ -30,11 +24,12 @@ module Ea
30
24
  # canvas_width = element_width + INSET_LEFT + INSET_RIGHT
31
25
  # canvas_height = element_height + INSET_TOP + INSET_BOTTOM
32
26
  # The top inset accommodates the frame tab + label space
33
- # above the element.
27
+ # above the element. The bottom inset 36 matches the
28
+ # dominant corpus mode (50% of 3,663 diagrams).
34
29
  INSET_LEFT = 35
35
30
  INSET_RIGHT = 50
36
31
  INSET_TOP = 40
37
- INSET_BOTTOM = 57
32
+ INSET_BOTTOM = 36
38
33
 
39
34
  attr_reader :diagram, :model_index
40
35
 
@@ -44,11 +39,7 @@ module Ea
44
39
  end
45
40
 
46
41
  def compute
47
- points = []
48
- points.concat(element_points)
49
- points.concat(connector_points)
50
- points.concat(marker_points)
51
- points.concat(package_tab_points)
42
+ points = element_points
52
43
  return [0, 0, 1, 1] if points.empty?
53
44
 
54
45
  xs = points.map(&:first)
@@ -85,57 +76,6 @@ module Ea
85
76
  end
86
77
  pts
87
78
  end
88
-
89
- # Package elements render with a tab polygon ABOVE the
90
- # element's logical top edge (height PACKAGE_TAB_HEIGHT).
91
- # Include those points so the canvas reserves space.
92
- def package_tab_points
93
- return [] if model_index.nil?
94
-
95
- pts = []
96
- (diagram.elements || []).each do |e|
97
- ref = e.model_element_ref
98
- next unless ref
99
-
100
- candidate = model_index[ref]
101
- next unless candidate.is_a?(Ea::Model::Package)
102
-
103
- primary = e.bounds || e.image_bounds
104
- next unless primary
105
-
106
- pts << [primary.x, primary.y - PACKAGE_TAB_HEIGHT]
107
- pts << [primary.x + primary.width, primary.y]
108
- end
109
- pts
110
- end
111
-
112
- def connector_points
113
- pts = []
114
- (diagram.connectors || []).each do |c|
115
- (c.waypoints || []).each do |wp|
116
- next unless wp.position
117
-
118
- pts << [wp.position.x, wp.position.y]
119
- end
120
- end
121
- pts
122
- end
123
-
124
- def marker_points
125
- pts = []
126
- (diagram.connectors || []).each do |c|
127
- waypoints = (c.waypoints || []).map(&:position).compact
128
- next unless waypoints.size >= 2
129
-
130
- source = waypoints.first
131
- target = waypoints.last
132
- pts << [source.x - MARKER_EXTENT, source.y - MARKER_EXTENT]
133
- pts << [source.x + MARKER_EXTENT, source.y + MARKER_EXTENT]
134
- pts << [target.x - MARKER_EXTENT, target.y - MARKER_EXTENT]
135
- pts << [target.x + MARKER_EXTENT, target.y + MARKER_EXTENT]
136
- end
137
- pts
138
- end
139
79
  end
140
80
  end
141
81
  end
@@ -21,10 +21,16 @@ module Ea
21
21
  %(<g style="#{group_style(context)}">\n#{text_blocks.join("\n")}\n</g>)
22
22
  end
23
23
 
24
+ # EA wraps note text by MEASURED width (Carlito advances),
25
+ # not character count: lines break at the last word whose
26
+ # cumulative integer textLength fits bounds.width - 15.
27
+ # Verified against EA-published reference SVGs.
24
28
  def wrapped_lines(context, body)
25
- max_chars = [(context.bounds.width / (context.size * 0.6)).floor, 10].max
29
+ usable = context.bounds.width - 15
30
+ size = context.size
31
+ family = context.family
26
32
  body.to_s.split(/\n/).flat_map do |para|
27
- para.gsub(/(.{1,#{max_chars}})(\s+|$)/, "\\1\n").strip.split(/\n/)
33
+ wrap_paragraph(para, usable, size, family)
28
34
  end.each_with_index.map do |line, idx|
29
35
  y = context.bounds.y + context.theme.note.text_y_offset +
30
36
  (idx * context.theme.note.line_height)
@@ -40,6 +46,26 @@ module Ea
40
46
  end
41
47
  end
42
48
 
49
+ def wrap_paragraph(para, usable, size, family)
50
+ words = para.strip.split(/\s+/)
51
+ lines = []
52
+ current = ""
53
+ words.each do |word|
54
+ candidate = current.empty? ? word : "#{current} #{word}"
55
+ width = TextRenderer.estimate_width(candidate, size, nil,
56
+ family: family).round
57
+ if current.empty? || width <= usable
58
+ current = candidate
59
+ else
60
+ lines << current
61
+ current = word
62
+ end
63
+ end
64
+ lines << current unless current.empty?
65
+ lines
66
+ end
67
+ module_function :wrap_paragraph
68
+
43
69
  def group_style(context)
44
70
  "stroke-width:1;stroke-linecap:round;stroke-linejoin:bevel; " \
45
71
  "fill:#{context.theme.text_color};fill-opacity:1.00; " \
@@ -67,7 +67,11 @@ module Ea
67
67
  lookup: lookup).to_s
68
68
  end
69
69
  else
70
- props.flat_map do |prop|
70
+ # EA lists attribute rows alphabetically (case-
71
+ # insensitive) — corpus-verified: 991 boxes strictly
72
+ # alphabetical vs 24 strictly stored-Pos order.
73
+ props.sort_by { |p| [p.name.to_s.downcase, p.name.to_s] }
74
+ .flat_map do |prop|
71
75
  lines = []
72
76
  stereotype = property_stereotype(prop)
73
77
  lines << "«#{stereotype}»" if stereotype
@@ -13,9 +13,13 @@ module Ea
13
13
  # Rendered as a third compartment below the attribute
14
14
  # compartment for Enumeration classifiers.
15
15
  class EnumerationLiteralRenderer
16
+ # EA lists literals alphabetically (case-insensitive) and
17
+ # spaces rows at the same 13px pitch as attributes at 7pt
18
+ # (size + 6) — corpus-verified against EA-published SVGs.
16
19
  def self.render(literals, bounds:, first_y:, family:, size:)
17
- line_h = size + 4
18
- text_blocks = literals.each_with_index.map do |literal, idx|
20
+ line_h = size + 6
21
+ sorted = literals.sort_by { |l| [l.name.to_s.downcase, l.name.to_s] }
22
+ text_blocks = sorted.each_with_index.map do |literal, idx|
19
23
  y = first_y + (idx * line_h)
20
24
  build_text(bounds.x + 5, y, literal.name.to_s, family, size)
21
25
  end
@@ -11,16 +11,17 @@ module Ea
11
11
  #
12
12
  # Context (Struct) carries everything a provider might need:
13
13
  # classifier, diagram_package_id, visually_nested,
14
- # umldi_keyword, bounds_width, font_size,
15
- # off_canvas_parent_name
14
+ # umldi_keyword, bounds_width, font_size, family,
15
+ # off_canvas_parent_name, foreign_package_name
16
16
  #
17
17
  # Each provider implements:
18
18
  # def self.call(context) → Array<[[text, style], ...]>
19
19
  module HeaderLinePipeline
20
20
  Context = Struct.new(:classifier, :diagram_package_id,
21
21
  :visually_nested, :umldi_keyword,
22
- :bounds_width, :font_size,
22
+ :bounds_width, :font_size, :family,
23
23
  :off_canvas_parent_name,
24
+ :foreign_package_name,
24
25
  keyword_init: true)
25
26
 
26
27
  PROVIDERS = [
@@ -8,16 +8,19 @@ module Ea
8
8
  # Emits the classifier's display name, with these behaviors:
9
9
  #
10
10
  # - Bold-italic for abstract Klass; bold otherwise.
11
- # - Qualified name wrap when the rendered width exceeds the
12
- # element bounds. EA splits "pkg::Class" across two lines
13
- # when the combined width is too wide.
11
+ # - Foreign elements (owning package ≠ the diagram's package,
12
+ # gated on t_diagram.ShowForeign) render the qualified
13
+ # name "OwningPackage::Name".
14
+ # - Qualified-name wrap: when the combined integer
15
+ # textLength exceeds the element bounds width, EA splits
16
+ # "pkg::Class" across two lines ("pkg::" then "Class"),
17
+ # each centered independently. Corpus-verified: every
18
+ # wrapped header overflows its box and every single-line
19
+ # header fits (tightest observed slack 6px).
14
20
  #
15
21
  # InstanceSpecification returns [] (InstanceSpec provider
16
22
  # already emitted the full label).
17
23
  class Name
18
- QUALIFIED_WRAP_WIDTH_FACTOR = 0.55
19
- QUALIFIED_WRAP_PADDING = 8
20
-
21
24
  def self.call(context)
22
25
  classifier = context.classifier
23
26
  return [] if classifier.is_a?(Ea::Model::InstanceSpecification)
@@ -34,9 +37,9 @@ module Ea
34
37
 
35
38
  def self.wrapped_name_lines(classifier, context, weight)
36
39
  name = display_name(classifier)
40
+ name = "#{context.foreign_package_name}::#{name}" if context.foreign_package_name
37
41
  unless context.bounds_width && name.include?("::") &&
38
- name_exceeds_bounds?(name, context.bounds_width,
39
- context.font_size || 9)
42
+ name_exceeds_bounds?(name, context, weight)
40
43
  return [[name, weight]]
41
44
  end
42
45
 
@@ -44,15 +47,18 @@ module Ea
44
47
  [["#{qualifier}::", weight], [base, weight]]
45
48
  end
46
49
 
47
- def self.name_exceeds_bounds?(name, bounds_width, font_size)
48
- TextRenderer.estimate_width(name, font_size,
49
- QUALIFIED_WRAP_WIDTH_FACTOR) >
50
- (bounds_width - QUALIFIED_WRAP_PADDING)
50
+ def self.name_exceeds_bounds?(name, context, weight)
51
+ font_style = weight == :bold_italic ? "italic" : "normal"
52
+ len = TextRenderer.estimate_width(
53
+ name, context.font_size || 9, nil,
54
+ family: context.family, weight: "700", style: font_style
55
+ ).round
56
+ len > context.bounds_width
51
57
  end
52
58
 
53
- # EA renders the plain classifier name in element headers.
54
- # (Some ISO-corpus diagrams show "Pkg::Name" headers — the
55
- # storage of that flag is not in the QEA; render plain.)
59
+ # EA renders the plain classifier name in element headers;
60
+ # qualification is layered on by the pipeline via
61
+ # context.foreign_package_name.
56
62
  def self.display_name(classifier, *_)
57
63
  classifier.name.to_s
58
64
  end
@@ -51,20 +51,12 @@ module Ea
51
51
  refs.map { |ref| "«#{canonical(ref)}»" }
52
52
  end
53
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
-
54
+ # EA renders the stereotype string exactly as stored in
55
+ # t_object.Stereotype — corpus-verified: 3,284 placements
56
+ # render verbatim ('CodeList', 'codeList', 'codelist',
57
+ # 'featureType' …), zero case-canonicalized.
66
58
  def self.canonical(ref)
67
- DISPLAY_OVERRIDES.fetch(ref.downcase, ref)
59
+ ref
68
60
  end
69
61
 
70
62
  def self.fallback_name(classifier)
@@ -69,7 +69,9 @@ module Ea
69
69
  umldi_keyword: element.umldi_keyword,
70
70
  bounds_width: raw_bounds&.width,
71
71
  font_size: size,
72
- off_canvas_parent_name: parent_name) : []
72
+ family: family,
73
+ off_canvas_parent_name: parent_name,
74
+ foreign_package_name: foreign_package_name_for(classifier)) : []
73
75
  is_classifier = classifier.is_a?(Ea::Model::Classifier)
74
76
  attr_lines = if is_classifier && show_attributes?
75
77
  Element::AttributeRenderer.lines_for(
@@ -214,6 +216,26 @@ module Ea
214
216
  Ea::Model::Constraint.new(name: name, kind: "Marker")
215
217
  end
216
218
 
219
+ # EA qualifies a placed classifier's header with its owning
220
+ # package name ("OwningPackage::Name") exactly when the
221
+ # diagram's t_diagram.ShowForeign flag is set AND the
222
+ # classifier lives in a different package than the diagram
223
+ # (descendant packages included). Corpus-verified (450
224
+ # diagrams): ShowForeign=0 renders plain everywhere.
225
+ def foreign_package_name_for(classifier)
226
+ return nil unless diagram.show_foreign
227
+ return nil unless classifier.is_a?(Ea::Model::Classifier)
228
+
229
+ pkg_id = classifier.package_id
230
+ return nil if pkg_id.nil? || pkg_id == diagram.package_id
231
+
232
+ pkg = model_index ? model_index[pkg_id] : nil
233
+ return nil unless pkg.is_a?(Ea::Model::Package)
234
+
235
+ name = pkg.name.to_s
236
+ name.empty? ? nil : name
237
+ end
238
+
217
239
  # EA renders the off-canvas parent classifier's name as an
218
240
  # italic line at the top of a placed element's header when
219
241
  # ALL of these hold:
@@ -61,7 +61,7 @@ module Ea
61
61
  text_pos, mult_pos = tree_positions(end_kind, anchor, role,
62
62
  text_pos, mult_pos)
63
63
  end
64
- show_property = property_label?(connector)
64
+ show_property = property_label?(connector, end_kind)
65
65
 
66
66
  build_texts(role:, mult:, standalone:,
67
67
  text_pos:, mult_pos:, show_property:)
@@ -113,27 +113,18 @@ module Ea
113
113
  end
114
114
  end
115
115
 
116
- # EA renders «property» between role name and multiplicity
117
- # ONLY for directed associations/aggregations — those with
118
- # t_connector.Direction set to "Source -> Destination" or
119
- # "Destination -> Source". "Unspecified" / "Bi-Directional"
120
- # connectors do NOT get the stereotype label.
121
- #
122
- # Verified against both QEAs (output by the same EA in the
123
- # same run):
124
- # - basic.qea: 4 Associations with Direction="Unspecified"
125
- # and role+mult → 0 «property» labels.
126
- # - plateau QEA: aggregations with Direction="Destination
127
- # -> Source" → «property» rendered (737 instances across
128
- # 137 diagrams).
129
- UNDIRECTED_DIRECTIONS = %w[Unspecified Bi-Directional].freeze
130
-
131
- def property_label?(connector)
132
- direction = connector.direction.to_s
133
- return false if direction.empty?
134
- return false if UNDIRECTED_DIRECTIONS.include?(direction)
135
-
136
- true
116
+ # EA renders «Property» between role name and multiplicity
117
+ # exactly on connector ends carrying
118
+ # t_connector.SourceStereotype / DestStereotype = "Property"
119
+ # (case-insensitive; stored variants include "property").
120
+ # Direction does not decide — Fig 11's directed aggregations
121
+ # carry no end stereotype and render none, while the CityGML
122
+ # profile diagrams mark ends explicitly.
123
+ PROPERTY_KEYWORD = "property"
124
+
125
+ def property_label?(connector, end_kind)
126
+ stereo = end_kind == :source ? connector.source_stereotype : connector.target_stereotype
127
+ stereo.to_s.downcase == PROPERTY_KEYWORD
137
128
  end
138
129
 
139
130
  def role_empty?(role)
@@ -47,6 +47,10 @@ module Ea
47
47
  end
48
48
 
49
49
  def texts_for(connector)
50
+ # EA never labels NoteLink connectors — the note text lives
51
+ # in t_connector.Name but renders only inside the Note box.
52
+ return [] if connector.connector_type.to_s.casecmp("NoteLink").zero?
53
+
50
54
  points = waypoint_pairs(connector)
51
55
  return [] if points.size < 2
52
56
 
@@ -62,7 +62,7 @@ module Ea
62
62
  tab_label_padding: 10,
63
63
  default_tab_width: 105).freeze
64
64
  DEFAULT_NOTE = NoteSpec.new(fold_size: 12, text_x_offset: 5,
65
- text_y_offset: 12, line_height: 12).freeze
65
+ text_y_offset: 12, line_height: 13).freeze
66
66
 
67
67
  def initialize(id:, name: nil, font_family: nil, font_size: nil,
68
68
  font_size_unit: "pt", text_weight_normal: 400,
data/lib/ea/version.rb CHANGED
@@ -4,5 +4,5 @@ module Ea
4
4
  # Gem release version. Extracted to its own file so the
5
5
  # `gem-release` tool's `gem bump` can find and update it via the
6
6
  # standard `lib/<gem>/version.rb` convention.
7
- VERSION = "0.6.17"
7
+ VERSION = "0.6.18"
8
8
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ea
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.6.17
4
+ version: 0.6.18
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ribose Inc.
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-10-01 00:00:00.000000000 Z
11
+ date: 2026-10-02 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: lutaml-model