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 +4 -4
- data/config/themes/default.yml +7 -0
- data/lib/ea/model/diagram.rb +6 -0
- data/lib/ea/qea/services/database_loader.rb +4 -1
- data/lib/ea/sources/xmi/adapter.rb +20 -0
- data/lib/ea/sources/xmi/connector_index.rb +30 -0
- data/lib/ea/sources/xmi/constraint_builder.rb +81 -0
- data/lib/ea/sources/xmi/diagram_builder.rb +1 -6
- data/lib/ea/sources/xmi/extension_types.rb +9 -2
- data/lib/ea/sources/xmi/relationship_builder.rb +17 -11
- data/lib/ea/sources/xmi.rb +2 -0
- data/lib/ea/svg/ea_emitter/compartment/header.rb +3 -1
- data/lib/ea/svg/ea_emitter/compartment/header_divider.rb +1 -1
- data/lib/ea/svg/ea_emitter/compartment/instance_underline.rb +1 -1
- data/lib/ea/svg/ea_emitter/connector_redock.rb +13 -6
- data/lib/ea/svg/ea_emitter/document.rb +1 -1
- data/lib/ea/svg/ea_emitter/element/attribute_renderer.rb +6 -1
- data/lib/ea/svg/ea_emitter/element/compartment_geometry.rb +9 -0
- data/lib/ea/svg/ea_emitter/element/header_renderer.rb +7 -0
- data/lib/ea/svg/ea_emitter/elements.rb +25 -3
- data/lib/ea/svg/ea_emitter/label/end_label.rb +41 -9
- data/lib/ea/svg/ea_emitter/label/midpoint_label.rb +66 -0
- data/lib/ea/svg/ea_emitter/label/plain_source_seat.rb +25 -2
- data/lib/ea/svg/ea_emitter/label/plain_target_seat.rb +64 -1
- data/lib/ea/svg/ea_emitter/labels.rb +53 -31
- data/lib/ea/svg/ea_emitter/markers.rb +1 -1
- data/lib/ea/version.rb +1 -1
- metadata +4 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b51e9399fd02dbf1d891731d7a6cdcf754b1778efdbd3e3946479a02aa0761a2
|
|
4
|
+
data.tar.gz: 96c4a49164b75a78ac1a165bdd24f4a1a13e9e8199f84e1577fd111b7498b332
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 19e20b995ba1bcad98fb01f30b8cad1eec339994c57782f543e9b0014fe08f8bf4e2176ce4f5bea790e07c08bbe26f5745ee5609ec36ac1f1669e02e40f27973
|
|
7
|
+
data.tar.gz: ee051963f75748a2b32fb6021078146c76687d539e3f76cb52fd76281ef7abf6adeb355bfe48d528aa39244c400f5ea2a80064519f166c19098c3b5ecfef5dac
|
data/config/themes/default.yml
CHANGED
|
@@ -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
|
data/lib/ea/model/diagram.rb
CHANGED
|
@@ -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 ||=
|
|
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}"]
|
|
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
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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 ||=
|
|
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)
|
data/lib/ea/sources/xmi.rb
CHANGED
|
@@ -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.
|
|
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
|
|
@@ -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
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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, :
|
|
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) ||
|
|
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? &&
|
|
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:
|
|
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
|
-
|
|
1049
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
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
|
|
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
|
-
[
|
|
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
|
|
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
|
-
|
|
41
|
-
return "" if
|
|
50
|
+
items = visible_connectors.flat_map { |c| texts_for(c) }
|
|
51
|
+
return "" if items.empty?
|
|
42
52
|
|
|
43
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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,
|
data/lib/ea/version.rb
CHANGED
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.
|
|
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-
|
|
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
|