ea 0.6.141 → 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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 4f4c5df621df87a4df4467c9cbe157f70ea49d0463a892ed7e57ada3550b1937
4
- data.tar.gz: 69fdddf140a6553c565287ce099bfa7f3d70d6c981502c559368c2e9860e70db
3
+ metadata.gz: b51e9399fd02dbf1d891731d7a6cdcf754b1778efdbd3e3946479a02aa0761a2
4
+ data.tar.gz: 96c4a49164b75a78ac1a165bdd24f4a1a13e9e8199f84e1577fd111b7498b332
5
5
  SHA512:
6
- metadata.gz: b5d5331f2954387a2ff2e9a93981964421958bac715ca436630b7aca0cefd00107973219d571a01e162692f6037cc695e52f94f04ab8808e899f1d08bd9fdba7
7
- data.tar.gz: 6c63841163568dc3b95d840ed14f4d18fd95e7bfc51bf111a8d40b32ae0aec920cf530a99012690f642c6ef4709e6df6adca3917e634f61a5088891fbf99d14c
6
+ metadata.gz: 19e20b995ba1bcad98fb01f30b8cad1eec339994c57782f543e9b0014fe08f8bf4e2176ce4f5bea790e07c08bbe26f5745ee5609ec36ac1f1669e02e40f27973
7
+ data.tar.gz: ee051963f75748a2b32fb6021078146c76687d539e3f76cb52fd76281ef7abf6adeb355bfe48d528aa39244c400f5ea2a80064519f166c19098c3b5ecfef5dac
@@ -4,6 +4,13 @@ font:
4
4
  family:
5
5
  size:
6
6
  unit: px
7
+ # Unthemed diagrams draw connector labels in Cambria 7pt, like the
8
+ # themed ones (test B58D1A53 native; 0 of 220 published SVGs carry the
9
+ # former Yu Gothic UI 13px fallback).
10
+ label_font:
11
+ family: Cambria
12
+ size: 7
13
+ unit: pt
7
14
  text:
8
15
  color: "#000000"
9
16
  weight_normal: 400
@@ -43,6 +43,12 @@ module Ea
43
43
  map "annotations", to: :annotations, render_empty: true
44
44
  end
45
45
 
46
+ # True for an object diagram. QEA stores "Object", the XMI adapter
47
+ # yields lowercase "object"; compare case-insensitively.
48
+ def object_diagram?
49
+ diagram_type.to_s.casecmp?("Object")
50
+ end
51
+
46
52
  # Parses style_ex into a Hash of flag => value. Returns empty
47
53
  # Hash when style_ex is nil/empty.
48
54
  #
@@ -102,7 +102,6 @@ module Ea
102
102
  # @raise [Errno::ENOENT] if QEA file not found
103
103
  # @raise [SQLite3::Exception] if database access fails
104
104
  def load
105
- # Connect but don't use with_connection - we need to keep it open
106
105
  @connection.connect unless @connection.connected?
107
106
  db = @connection.connection
108
107
 
@@ -117,6 +116,10 @@ module Ea
117
116
  end
118
117
 
119
118
  database.freeze
119
+ ensure
120
+ # All tables are in memory; nothing reads the connection after
121
+ # load, and an open handle locks the file on Windows.
122
+ @connection.close
120
123
  end
121
124
 
122
125
  # Load a single table
@@ -37,6 +37,7 @@ module Ea
37
37
  )
38
38
  mark_connector_proxies(doc)
39
39
  apply_tagged_values(doc)
40
+ apply_constraints(doc)
40
41
  apply_receptions(doc)
41
42
  apply_stereotypes(doc)
42
43
  resolve_type_names(doc)
@@ -93,6 +94,17 @@ module Ea
93
94
  end
94
95
  end
95
96
 
97
+ # Attach the extension's element constraints to their classifiers,
98
+ # as the QEA adapter does from t_objectconstraint.
99
+ def apply_constraints(doc)
100
+ grouped = ConstraintBuilder.new(xmi_path).grouped_by_element
101
+ return if grouped.empty?
102
+
103
+ doc.classifiers.each do |c|
104
+ c.constraints = grouped[c.id] if c.id && grouped[c.id]
105
+ end
106
+ end
107
+
96
108
  # Attach tagged values parsed from the <xmi:Extension><tags>
97
109
  # block to their owning classifiers and packages.
98
110
  def apply_tagged_values(doc)
@@ -130,6 +142,7 @@ module Ea
130
142
  (classifier.properties || []).each do |prop|
131
143
  prop.type_name = types.attribute_type(prop.id) ||
132
144
  qualified_by_id[prop.type_ref] || prop.type_name
145
+ apply_attribute_stereotype(prop, types)
133
146
  end
134
147
  (classifier.operations || []).each do |op|
135
148
  resolve_operation_types(op, qualified_by_id, types)
@@ -137,6 +150,13 @@ module Ea
137
150
  end
138
151
  end
139
152
 
153
+ def apply_attribute_stereotype(prop, types)
154
+ stereotype = types.attribute_stereotype(prop.id)
155
+ return if stereotype.nil? || prop.stereotype_refs.to_a.any?
156
+
157
+ prop.stereotype_refs = [stereotype]
158
+ end
159
+
140
160
  def resolve_operation_types(operation, qualified_by_id, types)
141
161
  operation.return_type_name = types.return_type(operation.id) ||
142
162
  qualified_by_id[operation.return_type_name] ||
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ea
4
+ module Sources
5
+ module Xmi
6
+ # Indexes the extension's `<connector>` nodes by `xmi:idref`. EA never
7
+ # repeats `<source>`, `<target>`, `<properties>` or `<xrefs>` inside one
8
+ # connector; if a hand-edited file does, the parse yields an Array where
9
+ # a single node is read, so the first occurrence is kept.
10
+ module ConnectorIndex
11
+ SINGLE_NODES = %i[source target properties xrefs].freeze
12
+
13
+ def self.build(root)
14
+ connectors = root.extension&.connectors&.connector || []
15
+ connectors.each_with_object({}) do |connector, index|
16
+ next unless connector.idref
17
+
18
+ SINGLE_NODES.each { |name| keep_first(connector, name) }
19
+ index[connector.idref] = connector
20
+ end
21
+ end
22
+
23
+ def self.keep_first(connector, name)
24
+ value = connector.public_send(name)
25
+ connector.public_send(:"#{name}=", value.first) if value.is_a?(Array)
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "nokogiri"
4
+
5
+ module Ea
6
+ module Sources
7
+ module Xmi
8
+ # Extracts element constraints from the `<xmi:Extension><elements>`
9
+ # block (`<element><constraints><constraint/></constraints>`), the
10
+ # XMI image of t_objectconstraint. The xmi gem does not expose them,
11
+ # so the file is streamed once and grouped by owning element id.
12
+ class ConstraintBuilder
13
+ attr_reader :xmi_path
14
+
15
+ def initialize(xmi_path)
16
+ @xmi_path = xmi_path
17
+ end
18
+
19
+ # Returns Hash{element_id => Array<Ea::Model::Constraint>}
20
+ def grouped_by_element
21
+ return {} if xmi_path.nil?
22
+
23
+ @grouped_by_element ||= scan
24
+ end
25
+
26
+ private
27
+
28
+ def scan
29
+ @path = []
30
+ @owner = nil
31
+ @grouped = {}
32
+ @seen_owners = {}
33
+ @blocks_seen = {}
34
+ @skip_block = false
35
+ File.open(xmi_path) { |io| Nokogiri::XML::Reader(io).each { |node| visit(node) } }
36
+ @grouped
37
+ end
38
+
39
+ def visit(node)
40
+ case node.node_type
41
+ when Nokogiri::XML::Reader::TYPE_END_ELEMENT
42
+ @owner = nil if @path.pop == "element"
43
+ when Nokogiri::XML::Reader::TYPE_ELEMENT
44
+ enter(node)
45
+ @path.push(node.local_name) unless node.self_closing?
46
+ end
47
+ end
48
+
49
+ def enter(node)
50
+ if node.local_name == "element" && @path.last == "elements" && !node.self_closing?
51
+ @owner = first_block_owner(node.attribute("xmi:idref"))
52
+ elsif node.local_name == "constraints" && @owner && @path.last == "element"
53
+ @skip_block = @blocks_seen.key?(@owner)
54
+ @blocks_seen[@owner] = true
55
+ elsif node.local_name == "constraint" && @owner && !@skip_block && @path.last(2) == %w[element constraints]
56
+ (@grouped[@owner] ||= []) << constraint(node)
57
+ end
58
+ end
59
+
60
+ # A repeated `<element xmi:idref="X">` or `<constraints>` block would
61
+ # append X's constraints again, so only the first of each is read.
62
+ def first_block_owner(idref)
63
+ return nil if @seen_owners.key?(idref)
64
+
65
+ @seen_owners[idref] = true
66
+ idref
67
+ end
68
+
69
+ def constraint(node)
70
+ Ea::Model::Constraint.new(
71
+ id: IdNormalizer.synthetic_id(@owner, "constraint", @grouped[@owner].to_a.size.to_s),
72
+ name: node.attribute("name").to_s,
73
+ kind: node.attribute("type").to_s,
74
+ body: node.attribute("description").to_s,
75
+ status: node.attribute("status").to_s
76
+ )
77
+ end
78
+ end
79
+ end
80
+ end
81
+ end
@@ -415,12 +415,7 @@ module Ea
415
415
  end
416
416
 
417
417
  def connector_index
418
- @connector_index ||= begin
419
- conns = root.extension&.connectors&.connector || []
420
- conns.each_with_object({}) do |c, acc|
421
- acc[c.idref] = c if c.idref
422
- end
423
- end
418
+ @connector_index ||= ConnectorIndex.build(root)
424
419
  end
425
420
 
426
421
  def umldi_extractor
@@ -26,6 +26,7 @@ module Ea
26
26
 
27
27
  def initialize(document)
28
28
  @attributes = index(document, "attribute", "properties")
29
+ @attribute_stereotypes = index(document, "attribute", "stereotype", "stereotype")
29
30
  @returns = index(document, "operation", "type")
30
31
  @parameters = index(document, "parameter", "properties")
31
32
  end
@@ -34,6 +35,12 @@ module Ea
34
35
  @attributes[id]
35
36
  end
36
37
 
38
+ # Stereotype EA stores on the attribute row
39
+ # (<attribute><stereotype stereotype="voidable"/>).
40
+ def attribute_stereotype(id)
41
+ @attribute_stereotypes[id]
42
+ end
43
+
37
44
  def return_type(operation_id)
38
45
  @returns[operation_id]
39
46
  end
@@ -44,11 +51,11 @@ module Ea
44
51
 
45
52
  private
46
53
 
47
- def index(document, owner, child)
54
+ def index(document, owner, child, attribute = "type")
48
55
  return {} unless document
49
56
 
50
57
  document.xpath(%(//*[local-name()="#{owner}"][@*[local-name()="idref"]])).each_with_object({}) do |node, acc|
51
- type = node.at_xpath(%(*[local-name()="#{child}"]/@type))&.value
58
+ type = node.at_xpath(%(*[local-name()="#{child}"]/@#{attribute}))&.value
52
59
  idref = node.at_xpath("@*[local-name()='idref']").value
53
60
  acc[idref] = type unless type.nil? || type.empty?
54
61
  end
@@ -51,12 +51,21 @@ module Ea
51
51
  end
52
52
  end
53
53
 
54
+ # Extension connector type => handler; a nil-returning handler skips
55
+ # the connector (Dependency counts only when stereotyped import).
56
+ EXTENSION_IMPORTS = {
57
+ "Package" => :package_import_relationship,
58
+ "Nesting" => :package_import_relationship,
59
+ "Dependency" => :stereotyped_dependency_import
60
+ }.freeze
61
+
54
62
  def extension_import_relationship(id, connector)
55
- case connector.properties&.ea_type
56
- when "Package" then package_import_relationship(id, connector)
57
- when "Dependency"
58
- dependency_import_relationship(id, connector) if connector_stereotype(connector) == "import"
59
- end
63
+ handler = EXTENSION_IMPORTS[connector.properties&.ea_type]
64
+ handler && method(handler).call(id, connector)
65
+ end
66
+
67
+ def stereotyped_dependency_import(id, connector)
68
+ dependency_import_relationship(id, connector) if connector_stereotype(connector) == "import"
60
69
  end
61
70
 
62
71
  # EA exports elementImport outside packagedElement, so the model walk
@@ -73,6 +82,8 @@ module Ea
73
82
  connector&.xrefs&.value.to_s[CONNECTOR_STEREOTYPE]
74
83
  end
75
84
 
85
+ # Nesting connectors, like package imports, exist only in the
86
+ # extension; QEA models both as an Association between the ends.
76
87
  def package_import_relationship(id, connector)
77
88
  Ea::Model::Association.new(
78
89
  id: id,
@@ -245,12 +256,7 @@ module Ea
245
256
  end
246
257
 
247
258
  def connector_index
248
- @connector_index ||= begin
249
- connectors = root.extension&.connectors&.connector || []
250
- connectors.each_with_object({}) do |connector, index|
251
- index[connector.idref] = connector if connector.idref
252
- end
253
- end
259
+ @connector_index ||= ConnectorIndex.build(root)
254
260
  end
255
261
 
256
262
  def navigable?(id, endpoint)
@@ -25,11 +25,13 @@ module Ea
25
25
  autoload :RelationshipBuilder, "ea/sources/xmi/relationship_builder"
26
26
  autoload :AnnotationBuilder, "ea/sources/xmi/annotation_builder"
27
27
  autoload :TagBuilder, "ea/sources/xmi/tag_builder"
28
+ autoload :ConstraintBuilder, "ea/sources/xmi/constraint_builder"
28
29
  autoload :LegendBuilder, "ea/sources/xmi/legend_builder"
29
30
  autoload :NoteBuilder, "ea/sources/xmi/note_builder"
30
31
  autoload :StereotypeBuilder, "ea/sources/xmi/stereotype_builder"
31
32
  autoload :DiagramBuilder, "ea/sources/xmi/diagram_builder"
32
33
  autoload :StoredRoute, "ea/sources/xmi/stored_route"
34
+ autoload :ConnectorIndex, "ea/sources/xmi/connector_index"
33
35
  autoload :ExtensionTypes, "ea/sources/xmi/extension_types"
34
36
  autoload :ExtensionElements, "ea/sources/xmi/extension_elements"
35
37
  autoload :ExtensionGeometryParser, "ea/sources/xmi/extension_geometry_parser"
@@ -34,10 +34,12 @@ module Ea
34
34
  )
35
35
  end
36
36
 
37
+ # No element font size of its own: QEA marks it inherited, XMI
38
+ # records the missing size without the flag.
37
39
  def default_signal_header?(context)
38
40
  context.classifier.is_a?(Ea::Model::Signal) &&
39
41
  context.classifier.stereotype_refs.empty? &&
40
- context.element.font_size_inherited == true &&
42
+ context.element.font_size.nil? &&
41
43
  context.element.font_family.nil? &&
42
44
  !context.element.font_bold && !context.element.font_italic
43
45
  end
@@ -45,7 +45,7 @@ module Ea
45
45
  end
46
46
 
47
47
  def object_diagram?(context)
48
- context.diagram && context.diagram.diagram_type == "Object"
48
+ context.diagram&.object_diagram?
49
49
  end
50
50
 
51
51
  def hand_draw?(context)
@@ -41,7 +41,7 @@ module Ea
41
41
  end
42
42
 
43
43
  def object_diagram?(context)
44
- context.diagram && context.diagram.diagram_type == "Object"
44
+ context.diagram&.object_diagram?
45
45
  end
46
46
  module_function :object_diagram?
47
47
 
@@ -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.
@@ -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
@@ -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
@@ -289,6 +289,7 @@ module Ea
289
289
  height: grown_bottom + compartment_bottom_padding(size, family, size_unit) - bounds.y
290
290
  )
291
291
  end
292
+ bounds = grow_for_tagged_values(bounds, geometry, element, classifier)
292
293
  RenderContext.new(
293
294
  element: element,
294
295
  bounds: bounds,
@@ -324,6 +325,21 @@ module Ea
324
325
  offset
325
326
  end
326
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
+
327
343
  def compartment_bottom_padding(size, family, size_unit)
328
344
  Ea::Fonts::Metrics.compact_compartment_font?(size, family: family, unit: size_unit) ? 7 : 8
329
345
  end
@@ -399,12 +415,18 @@ module Ea
399
415
  # that were +1: 0114FD24 uro::AbstractConstruction etc.; 20 of
400
416
  # 21 now exact, no other compared item moved). The tight ghost
401
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).
402
422
  def compact_header_offsets?(classifier, header_lines, size, **options)
403
- 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 &&
404
427
  Ea::Fonts::Metrics.compact_compartment_font?(size, family: options[:family], unit: options[:size_unit]) &&
405
428
  header_lines.dig(0, 1) != :italic_tight &&
406
- options.values_at(:logical_height, :marker_count, :enum_literals_count,
407
- :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] &&
408
430
  theme.compartments.header_line_offset == 6 && theme.compartments.attr_line_offset == 6
409
431
  end
410
432
 
@@ -134,7 +134,8 @@ module Ea
134
134
  saved_ray_target_property_role(connector, heading, anchor, text_box, role) ||
135
135
  target_diagonal_property_role(connector, heading, anchor, text_box, role) ||
136
136
  target_vertical_role(connector, heading, anchor, text_box, role) ||
137
- target_rightward_role(heading, anchor, text_box, role) || text_pos
137
+ target_rightward_role(heading, anchor, text_box, role) ||
138
+ aligned_target_role(connector, heading, text_pos, text_box, role) || text_pos
138
139
  if horizontal_aligned_target_baseline?(connector, heading, text_box, role)
139
140
  text_pos = [text_pos[0], seat_corner(anchor, text_box)[1] - 7]
140
141
  end
@@ -175,8 +176,11 @@ module Ea
175
176
  end
176
177
 
177
178
  role_bounds = umldi_bounds[end_kind == :source ? :source_role : :target_role]
179
+ # Unmoved lateral roles keep their measured seat: the saved UMLDI
180
+ # rectangle is stale for them (test B58D1A53 +role2 saved 24px low).
181
+ role_bounds = nil if PlainTargetSeat.unmoved_lateral_role?(connector, text_box)
178
182
  mult_bounds = umldi_bounds[end_kind == :source ? :source_multiplicity : :target_multiplicity]
179
- text_pos = umldi_label_position(role_bounds) if role_bounds
183
+ text_pos = umldi_label_position(role_bounds, text_box) if role_bounds
180
184
  centering = role_bounds ? bounds_role_centering(connector, end_kind, role, stereo, text_box) : 0
181
185
  text_pos = [text_pos[0] + centering, text_pos[1]] if centering.positive?
182
186
  mult_pos = umldi_label_position(mult_bounds) if mult_bounds
@@ -196,6 +200,12 @@ module Ea
196
200
 
197
201
  private
198
202
 
203
+ # The width the renderer prints (real metrics, else its glyph estimate),
204
+ # so seats do not vanish for glyphs the metrics table lacks.
205
+ def seat_width(text)
206
+ TextRenderer.estimate_width(text, font_size, family: font_family)
207
+ end
208
+
199
209
  # Measured only for default Cambria 7pt Property roles on a vertical
200
210
  # leg rising from the end; other fonts, boxes and legs keep their
201
211
  # unclamped seats.
@@ -294,12 +304,13 @@ module Ea
294
304
  end
295
305
 
296
306
  def plain_leftward_source_role(connector, direction, anchor, box, role)
297
- return nil unless direction == :left && seat_font? && cambria_box?(box) && !role_empty?(role)
307
+ return nil unless direction == :left && seat_font? && !role_empty?(role)
308
+ return nil unless box && cambria_box?(box.merge("alignment" => 1))
298
309
  return nil unless connector_source_dest_stereotype(connector, :source).to_s.empty?
299
310
 
300
311
  PlainSourceSeat.position(
301
312
  connector: connector, anchor: anchor, box: box,
302
- width: Ea::Fonts::Metrics.text_length(role, font_size, family: font_family)
313
+ width: seat_width(role)
303
314
  )
304
315
  end
305
316
 
@@ -725,6 +736,23 @@ module Ea
725
736
  [anchor[0] + 8, anchor[1] - 7]
726
737
  end
727
738
 
739
+ # Stored ALN=2 role boxes sit outside their corner; see
740
+ # PlainTargetSeat.aligned_position.
741
+ def aligned_target_role(connector, heading, text_pos, box, role)
742
+ PlainTargetSeat.aligned_role(
743
+ connector: connector, heading: heading, text_pos: text_pos,
744
+ box: box, role: role, font_eligible: aligned_seat_font?,
745
+ width: seat_width(role.to_s),
746
+ target_stereotype: connector_source_dest_stereotype(connector, :target)
747
+ )
748
+ end
749
+
750
+ # 7pt regular Cambria, the only face the ALN=2 samples were taken in.
751
+ def aligned_seat_font?
752
+ seat_font? && font_family.to_s.casecmp("Cambria").zero? &&
753
+ font_weight == 400 && font_style == "normal"
754
+ end
755
+
728
756
  def target_vertical_role(connector, heading, anchor, box, role)
729
757
  return nil unless heading && heading[0].zero? && !heading[1].zero?
730
758
 
@@ -1045,8 +1073,11 @@ module Ea
1045
1073
  position[1] - font_pixel_size.ceil + height]
1046
1074
  end
1047
1075
 
1048
- def umldi_label_position(bounds)
1049
- [bounds[:x], bounds[:y] + font_pixel_size.ceil]
1076
+ # EA draws an ALN=2 role 1px right of its saved UMLDI bounds
1077
+ # (basic: 9 roles in 8 diagrams, all exactly +1).
1078
+ def umldi_label_position(bounds, box = nil)
1079
+ inset = box && box["alignment"] == 2 ? 1 : 0
1080
+ [bounds[:x] + inset, bounds[:y] + font_pixel_size.ceil]
1050
1081
  end
1051
1082
 
1052
1083
  def umldi_stereotype_position(bounds, role, stereo, text_box)
@@ -1134,12 +1165,13 @@ module Ea
1134
1165
  property unless property && opposite_role_property?(association, end_kind, property)
1135
1166
  end
1136
1167
 
1137
- # A UML attribute belongs to the classifier opposite its role.
1138
- # Do not draw that same role again at its anonymous owner end.
1168
+ # A UML attribute belongs to the classifier opposite its role: a
1169
+ # property named after the opposite end's role, and not after this
1170
+ # end's own, is that end's label. Do not draw it again here.
1139
1171
  def opposite_role_property?(association, end_kind, property)
1140
1172
  own = end_kind == :source ? association.source_role_name : association.target_role_name
1141
1173
  other = end_kind == :source ? association.target_role_name : association.source_role_name
1142
- role_empty?(own) && other == property.name
1174
+ other == property.name && own != property.name
1143
1175
  end
1144
1176
 
1145
1177
  def placed_classifier_id(association, end_kind)
@@ -49,6 +49,35 @@ module Ea
49
49
  render_text(mx, my, content)
50
50
  end
51
51
 
52
+ # Returns the name-direction triangle `<polygon>` for a connector
53
+ # whose stored LMT box carries DIR=1, or nil. EA seats it beside
54
+ # the name, pointing along the source-to-target direction at the
55
+ # midpoint. Measured only on Direction="Unspecified" links (all 10
56
+ # basic.qea DIR=1 links; no DIR=1 link with another Direction exists
57
+ # in basic, test, simple, ArcGIS or PLATEAU), so other directions
58
+ # draw no triangle until a native sample shows their form. Right/down
59
+ # geometry is measured on basic.qea's native SVGs (2AAB709F,
60
+ # 4DBDAB9E, 4F421236, D2FA55D3 and 6E95B74E: tip 16px past the
61
+ # name, 10px long, 6px wide; below the name for a vertical leg).
62
+ # Left/up are the mirrored extrapolation: no corpus case.
63
+ def direction_marker(connector, points)
64
+ return nil unless midpoint_content(connector)
65
+
66
+ box = stored_name_box(connector, points)
67
+ return nil unless box && box["direction"] == 1
68
+ return nil unless connector.direction == "Unspecified"
69
+
70
+ # A name with an unmeasured glyph is drawn at the default seat, so
71
+ # the triangle follows where the name is drawn, by its printed width.
72
+ x, y = stored_name_position(connector, points) || label_position(connector, points)
73
+ width = TextRenderer.estimate_width(midpoint_content(connector), font_size, family: font_family)
74
+
75
+ tri = direction_triangle(x, y, width, travel(points))
76
+ coords = tri.flat_map { |px, py| [translate_x(px), translate_y(py)] }
77
+ %(<polygon points="#{coords.map { |v| Canvas.coord(v) }.join(" ")}" ) +
78
+ %(shape-rendering="auto" style="fill-rule:evenodd;"/>)
79
+ end
80
+
52
81
  # Returns the formatted «stereotype» string or nil. Used
53
82
  # by Label::Registry to dispatch end-label vs midpoint.
54
83
  def stereotype_label(connector)
@@ -194,6 +223,43 @@ module Ea
194
223
  target[1] < last_bend[1]
195
224
  end
196
225
 
226
+ # Unit travel direction [dx, dy] at the midpoint: dominant axis only.
227
+ def travel(points)
228
+ mid_idx = points.size / 2
229
+ from, to = if points.size.odd?
230
+ [points[mid_idx - 1], points[mid_idx + 1]]
231
+ else
232
+ [points[mid_idx - 1], points[mid_idx]]
233
+ end
234
+ dx = to[0] - from[0]
235
+ dy = to[1] - from[1]
236
+ return [dx.negative? ? -1 : 1, 0] if dx.abs >= dy.abs
237
+
238
+ [0, dy.negative? ? -1 : 1]
239
+ end
240
+
241
+ def direction_triangle(x, y, width, travel)
242
+ dx, dy = travel
243
+ if dy.zero?
244
+ tip_x = dx.positive? ? x + width + 16 : x - 16
245
+ base_x = tip_x - (dx * 10)
246
+ [[tip_x, y - 4], [base_x, y - 7], [base_x, y - 1]]
247
+ else
248
+ center_x = x + (width / 2.0).floor
249
+ tip_y = dy.positive? ? y + 13 : y - 21
250
+ base_y = tip_y - (dy * 10)
251
+ [[center_x, tip_y], [center_x - 3, base_y], [center_x + 3, base_y]]
252
+ end
253
+ end
254
+
255
+ def translate_x(value)
256
+ canvas ? canvas.translate_x(value) : value
257
+ end
258
+
259
+ def translate_y(value)
260
+ canvas ? canvas.translate_y(value) : value
261
+ end
262
+
197
263
  def midpoint(points)
198
264
  mid_idx = points.size / 2
199
265
  if points.size.odd?
@@ -7,10 +7,33 @@ module Ea
7
7
  # Plain leftward roles end before the stored corner, mirroring
8
8
  # the 3px gutter and baseline of the rightward role seat.
9
9
  module PlainSourceSeat
10
+ # EMPIRICAL FIT, stored 13px role boxes moved left of a leftward
11
+ # straight source on mode 3, width 0: ALN=0 ends 3px before the
12
+ # corner (basic D351FC34, F32E5AFE roleTwo, Association), ALN=2
13
+ # 2px before it (basic 6D0349D5 role b, Aggregation); both sit
14
+ # 7px above the corner like the Aggregation case below.
15
+ # Only cy=13 boxes are measured: every negative-OX LLT box with ALN 0 or
16
+ # 2 in basic, test, simple, ArcGIS and PLATEAU has cy=13 (cy=14 occurs
17
+ # only with ALN=1), so the ALN=2 target seat's 13-or-14 tolerance is
18
+ # not extended to the source.
19
+ STORED_INSETS = { 0 => 3, 2 => 2 }.freeze
20
+
10
21
  def self.position(connector:, anchor:, box:, width:)
11
- return nil unless width && eligible_connector?(connector) && moved_role_box?(box)
22
+ return nil unless width
23
+
24
+ inset = stored_inset(connector, box)
25
+ inset = 3 if inset.nil? && eligible_connector?(connector) && moved_role_box?(box)
26
+ return nil unless inset
27
+
28
+ [anchor[0] + box["ox"].to_i - width - inset, anchor[1] + box["oy"].to_i - 7]
29
+ end
30
+
31
+ def self.stored_inset(connector, box)
32
+ return nil unless %w[Association Aggregation].include?(connector.connector_type)
33
+ return nil unless connector.style.to_h.values_at(:mode, :lwidth, :tree) == ["3", "0", nil]
34
+ return nil unless box["cy"] == 13 && box["ox"].to_i.negative?
12
35
 
13
- [anchor[0] + box["ox"].to_i - width - 3, anchor[1] + box["oy"].to_i - 7]
36
+ STORED_INSETS[box["alignment"]]
14
37
  end
15
38
 
16
39
  def self.eligible_connector?(connector)
@@ -7,11 +7,74 @@ module Ea
7
7
  # Plain leftward target roles end three pixels before the anchor.
8
8
  module PlainTargetSeat
9
9
  def self.position(connector:, anchor:, box:, width:)
10
- return nil unless width && eligible_connector?(connector) && zero_role_box?(box)
10
+ return nil unless width
11
+ return nil unless (eligible_connector?(connector) && zero_role_box?(box)) ||
12
+ (lateral_connector?(connector) && lateral_zero_box?(box))
11
13
 
12
14
  [anchor[0] - width - 3, anchor[1] - 7]
13
15
  end
14
16
 
17
+ # EMPIRICAL FIT: an unmoved ALN=0 13px role on a width-0 derived
18
+ # lateral-horizontal association seats like the plain target above
19
+ # (test B58D1A53 +role2, the only such leftward target).
20
+ def self.unmoved_lateral_role?(connector, box)
21
+ !box.nil? && lateral_connector?(connector) && lateral_zero_box?(box)
22
+ end
23
+
24
+ def self.lateral_connector?(connector)
25
+ style = connector.style.to_h
26
+ connector.connector_type == "Association" &&
27
+ style.values_at(:mode, :lwidth, :tree) == %w[3 0 LH] &&
28
+ (style[:derived_lateral_line] || style[:umldi_route]) == true
29
+ end
30
+
31
+ def self.lateral_zero_box?(box)
32
+ box.values_at("alignment", "cy") == [0, 13] &&
33
+ box.values_at("ox", "oy").map(&:to_i) == [0, 0]
34
+ end
35
+
36
+ # EMPIRICAL FIT: a stored ALN=2 role box on a horizontal
37
+ # arrival keeps its mirrored corner, text_pos, and is drawn
38
+ # outside it: a leftward arrival (OX < 0) ends 2px short of the
39
+ # corner (7 samples in basic: 6E95B74E, 4F421236, 2AAB709F,
40
+ # 4DBDAB9E, D2FA55D3 x2, width 30); a rightward arrival (OX > 0)
41
+ # starts 4px past it (basic D351FC34, F32E5AFE roleOne, width 41).
42
+ # Eligibility and placement of a stored ALN=2 target role: a plain
43
+ # Association, mode 3 / width 0 stored ray, a single-line (CY 13/14)
44
+ # unstyled ALN=2 box, no target stereotype, in the eligible font.
45
+ def self.aligned_role(connector:, heading:, text_pos:, box:, role:, width:,
46
+ font_eligible:, target_stereotype:)
47
+ return nil unless font_eligible && !role.to_s.empty? && target_stereotype.to_s.empty?
48
+ return nil unless aligned_connector?(connector) && aligned_box?(box)
49
+
50
+ aligned_position(heading: heading, text_pos: text_pos, box: box, width: width)
51
+ end
52
+
53
+ def self.aligned_connector?(connector)
54
+ style = connector.style.to_h
55
+ connector.connector_type == "Association" &&
56
+ style.values_at(:mode, :lwidth, :tree) == ["3", "0", nil] && style[:stored_association_ray]
57
+ end
58
+
59
+ def self.aligned_box?(box)
60
+ return false unless box && box["alignment"] == 2 && [13, 14].include?(box["cy"])
61
+ return false unless box["ox"] && box["oy"] && [nil, -1].include?(box["color"])
62
+
63
+ box["rotation"].to_i.zero? && box["direction"].to_i.zero? &&
64
+ box.values_at("hidden", "bold", "italic", "underline").none?
65
+ end
66
+
67
+ def self.aligned_position(heading:, text_pos:, box:, width:)
68
+ return nil unless width && text_pos && heading && heading[1].zero?
69
+
70
+ ox = box["ox"].to_i
71
+ if heading[0].negative? && ox.negative?
72
+ [text_pos[0] - width - 2, text_pos[1]]
73
+ elsif heading[0].positive? && ox.positive?
74
+ [text_pos[0] + 4, text_pos[1]]
75
+ end
76
+ end
77
+
15
78
  def self.eligible_connector?(connector)
16
79
  connector.connector_type == "Association" &&
17
80
  connector.style.to_h.values_at(:mode, :lwidth, :tree, :plain_target_seat) == ["3", "2", nil, true]
@@ -36,11 +36,29 @@ module Ea
36
36
  @divider_y_by_ref = divider_y_by_ref
37
37
  end
38
38
 
39
+ TEXT_GROUP = "stroke-width:1;stroke-linecap:round;stroke-linejoin:%<join>s; " \
40
+ "fill:#000000;fill-opacity:1.00; stroke:#000000; stroke-opacity:0.00"
41
+ MARKER_GROUP = "stroke-width:1;stroke-linecap:round;stroke-linejoin:round; " \
42
+ "fill:#000000;fill-opacity:1.00; stroke:#000000; stroke-opacity:1.00"
43
+
44
+ # A name-direction triangle is a filled shape painted between the
45
+ # text runs; EA closes the text group around it and keeps the
46
+ # round-join pen for the text that follows.
47
+ DirectionMarker = Struct.new(:svg)
48
+
39
49
  def render
40
- texts = visible_connectors.flat_map { |c| texts_for(c) }
41
- return "" if texts.empty?
50
+ items = visible_connectors.flat_map { |c| texts_for(c) }
51
+ return "" if items.empty?
42
52
 
43
- %(<g style="stroke-width:1;stroke-linecap:round;stroke-linejoin:bevel; fill:#000000;fill-opacity:1.00; stroke:#000000; stroke-opacity:0.00">\n#{texts.join("\n")}\n</g>)
53
+ join = "bevel"
54
+ items.slice_when { |a, b| a.is_a?(DirectionMarker) || b.is_a?(DirectionMarker) }.map do |run|
55
+ if run.first.is_a?(DirectionMarker)
56
+ join = "round"
57
+ %(<g style="#{MARKER_GROUP}">\n #{run.first.svg}\n</g>)
58
+ else
59
+ %(<g style="#{format(TEXT_GROUP, join: join)}">\n#{run.join("\n")}\n</g>)
60
+ end
61
+ end.join("\n")
44
62
  end
45
63
 
46
64
  # Right edge of the stored end-label boxes (model coordinates)
@@ -51,8 +69,12 @@ module Ea
51
69
 
52
70
  private
53
71
 
72
+ # Labels follow the connector paint order (as the XMI diagram order
73
+ # already does), so QEA and XMI emit the same text sequence. Against
74
+ # native on PLATEAU this is a wash (79/188 exact sequences either way);
75
+ # it is verified on basic.qea's native SVGs.
54
76
  def visible_connectors
55
- (diagram.connectors || []).select(&:renderable?)
77
+ PaintOrder.connectors(diagram).select(&:renderable?)
56
78
  end
57
79
 
58
80
  def texts_for(connector)
@@ -63,22 +85,18 @@ module Ea
63
85
  points = label_points(connector)
64
86
  return [] if points.size < 2
65
87
 
66
- texts = []
67
-
68
88
  # Midpoint label: stereotype «import», or relationship
69
89
  # Name ("Association A"). Renders at the path midpoint.
70
90
  mid_text = midpoint_renderer.text_for(connector, points)
71
- texts << mid_text if mid_text
91
+ return [mid_text, direction_marker(connector, points)].compact unless registry.end_label?(connector)
72
92
 
73
93
  # End-labels: role name + «property» + multiplicity at
74
94
  # positioned LLT/LRT boxes. Only for associations without
75
95
  # a midpoint stereotype (stereotyped associations route
76
- # to midpoint only).
77
- if registry.end_label?(connector)
78
- end_label_texts(connector, points).each { |t| texts << t }
79
- end
80
-
81
- texts
96
+ # to midpoint only). EA paints the source end, then the
97
+ # midpoint label, then the target end (basic.xmi native SVGs).
98
+ source_texts, target_texts = end_label_texts(connector, points)
99
+ [*source_texts, mid_text, direction_marker(connector, points), *target_texts].compact
82
100
  end
83
101
 
84
102
  def end_label_texts(connector, points)
@@ -94,24 +112,23 @@ module Ea
94
112
  src_tb, src_mb = stored_src || unstored_label_boxes(points, :source, stored_labels, connector)
95
113
  tgt_tb, tgt_mb = stored_tgt || unstored_label_boxes(points, :target, stored_labels, connector)
96
114
 
97
- texts = []
98
- end_renderer.texts(text_box: src_tb, mult_box: src_mb,
99
- anchor: source_pt, connector: connector,
100
- end_kind: :source, tree: tree && tree[:source],
101
- heading: (stored_src || stored_labels) && source_heading(connector, points),
102
- stored_labels: stored_labels,
103
- umldi_bounds: available_umldi_bounds(umldi_bounds, boxes, :source),
104
- source_bounds: source_dock_bounds(connector)).each { |t| texts << t }
105
- end_renderer.texts(text_box: tgt_tb,
106
- mult_box: tgt_mb,
107
- anchor: target_pt, connector: connector,
108
- end_kind: :target, tree: tree && tree[:target],
109
- bend: Label::TargetBendSeat.reference(points),
110
- heading: (stored_tgt || stored_labels) && target_heading(connector, points),
111
- target_face: target_dock_heading(connector, target_pt, target_heading(connector, points)),
112
- stored_labels: stored_labels,
113
- umldi_bounds: available_umldi_bounds(umldi_bounds, boxes, :target)).each { |t| texts << t }
114
- texts
115
+ source_texts = end_renderer.texts(text_box: src_tb, mult_box: src_mb,
116
+ anchor: source_pt, connector: connector,
117
+ end_kind: :source, tree: tree && tree[:source],
118
+ heading: (stored_src || stored_labels) && source_heading(connector, points),
119
+ stored_labels: stored_labels,
120
+ umldi_bounds: available_umldi_bounds(umldi_bounds, boxes, :source),
121
+ source_bounds: source_dock_bounds(connector))
122
+ target_texts = end_renderer.texts(text_box: tgt_tb,
123
+ mult_box: tgt_mb,
124
+ anchor: target_pt, connector: connector,
125
+ end_kind: :target, tree: tree && tree[:target],
126
+ bend: Label::TargetBendSeat.reference(points),
127
+ heading: (stored_tgt || stored_labels) && target_heading(connector, points),
128
+ target_face: target_dock_heading(connector, target_pt, target_heading(connector, points)),
129
+ stored_labels: stored_labels,
130
+ umldi_bounds: available_umldi_bounds(umldi_bounds, boxes, :target))
131
+ [source_texts, target_texts]
115
132
  end
116
133
 
117
134
  # Role bounds the XMI builder keeps belong to this connector's own
@@ -348,6 +365,11 @@ module Ea
348
365
  )
349
366
  end
350
367
 
368
+ def direction_marker(connector, points)
369
+ svg = midpoint_renderer.direction_marker(connector, points)
370
+ DirectionMarker.new(svg) if svg
371
+ end
372
+
351
373
  def midpoint_renderer
352
374
  @midpoint_renderer ||= Label::MidpointLabel.new(
353
375
  canvas: canvas,
@@ -270,7 +270,7 @@ module Ea
270
270
  end
271
271
 
272
272
  def object_diagram?
273
- diagram.diagram_type == "Object"
273
+ diagram.object_diagram?
274
274
  end
275
275
 
276
276
  # Rounded [x, y] of the marker's tip (anchor) in canvas
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.141"
7
+ VERSION = "0.6.142"
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.141
4
+ version: 0.6.142
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-22 00:00:00.000000000 Z
11
+ date: 2026-10-23 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: lutaml-model
@@ -851,6 +851,8 @@ files:
851
851
  - lib/ea/sources/xmi/adapter.rb
852
852
  - lib/ea/sources/xmi/annotation_builder.rb
853
853
  - lib/ea/sources/xmi/classifier_builder.rb
854
+ - lib/ea/sources/xmi/connector_index.rb
855
+ - lib/ea/sources/xmi/constraint_builder.rb
854
856
  - lib/ea/sources/xmi/diagram_builder.rb
855
857
  - lib/ea/sources/xmi/diagram_builder/association_class_metadata.rb
856
858
  - lib/ea/sources/xmi/diagram_builder/connector_metadata.rb