ea 0.6.4 → 0.6.6
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/CHANGELOG.md +39 -0
- data/config/themes/119.yml +4 -4
- data/lib/ea/model/diagram_connector.rb +9 -0
- data/lib/ea/model/property.rb +6 -1
- data/lib/ea/sources/qea/classifier_builder.rb +3 -1
- data/lib/ea/sources/qea/diagram_builder.rb +91 -9
- data/lib/ea/sources/qea/property_builder.rb +12 -2
- data/lib/ea/svg/ea_emitter/compartment/attributes.rb +1 -0
- data/lib/ea/svg/ea_emitter/compartment/constraints.rb +1 -0
- data/lib/ea/svg/ea_emitter/compartment/enum_literals.rb +9 -25
- data/lib/ea/svg/ea_emitter/compartment/header.rb +1 -0
- data/lib/ea/svg/ea_emitter/element/attribute_line_builder.rb +5 -0
- data/lib/ea/svg/ea_emitter/element/attribute_renderer.rb +51 -11
- data/lib/ea/svg/ea_emitter/element/compartment_geometry.rb +12 -10
- data/lib/ea/svg/ea_emitter/element/header_line_provider/name.rb +6 -15
- data/lib/ea/svg/ea_emitter/element/header_line_provider/stereotype_label.rb +25 -8
- data/lib/ea/svg/ea_emitter/element/header_renderer.rb +16 -4
- data/lib/ea/svg/ea_emitter/element/legend_renderer.rb +29 -4
- data/lib/ea/svg/ea_emitter/elements.rb +70 -20
- data/lib/ea/svg/ea_emitter/font_resolver.rb +5 -6
- data/lib/ea/svg/ea_emitter/label/end_label.rb +48 -4
- data/lib/ea/svg/ea_emitter/labels.rb +38 -2
- data/lib/ea/svg/ea_emitter/text_renderer.rb +97 -8
- data/lib/ea/svg/parity/checker.rb +19 -9
- data/lib/ea/svg/parity/corpus_report.rb +83 -0
- data/lib/ea/svg/parity/differ.rb +231 -0
- data/lib/ea/svg/parity.rb +2 -0
- data/lib/ea/theme/definition.rb +3 -3
- data/lib/ea/theme/loader.rb +2 -2
- data/lib/ea/version.rb +1 -1
- metadata +4 -2
|
@@ -18,25 +18,26 @@ module Ea
|
|
|
18
18
|
classifier = context.classifier
|
|
19
19
|
return [] if classifier.is_a?(Ea::Model::InstanceSpecification)
|
|
20
20
|
|
|
21
|
-
|
|
22
|
-
return []
|
|
21
|
+
labels = label_for(classifier, context.umldi_keyword)
|
|
22
|
+
return [] if labels.empty?
|
|
23
23
|
|
|
24
|
-
[
|
|
24
|
+
labels.map { |l| [l, :normal] }
|
|
25
25
|
end
|
|
26
26
|
|
|
27
|
-
# @return [String
|
|
27
|
+
# @return [Array<String>] «name» labels (EA renders one
|
|
28
|
+
# line per applied stereotype)
|
|
28
29
|
def self.label_for(classifier, umldi_keyword)
|
|
29
|
-
return "«#{umldi_keyword}»" if umldi_keyword && !umldi_keyword.empty?
|
|
30
|
+
return ["«#{umldi_keyword}»"] if umldi_keyword && !umldi_keyword.empty?
|
|
30
31
|
|
|
31
32
|
explicit = explicit_stereotype(classifier)
|
|
32
33
|
return explicit if explicit
|
|
33
34
|
|
|
34
35
|
case classifier
|
|
35
36
|
when Ea::Model::Klass, Ea::Model::Package, Ea::Model::Note
|
|
36
|
-
|
|
37
|
+
[]
|
|
37
38
|
else
|
|
38
39
|
fallback = fallback_name(classifier)
|
|
39
|
-
fallback ? "«#{fallback}»" :
|
|
40
|
+
fallback ? ["«#{fallback}»"] : []
|
|
40
41
|
end
|
|
41
42
|
end
|
|
42
43
|
|
|
@@ -47,7 +48,23 @@ module Ea
|
|
|
47
48
|
refs = classifier.stereotype_refs
|
|
48
49
|
return nil unless refs&.any?
|
|
49
50
|
|
|
50
|
-
"«#{
|
|
51
|
+
refs.map { |ref| "«#{canonical(ref)}»" }
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# EA displays canonical stereotype spellings regardless of
|
|
55
|
+
# how the QEA stored them (property → Property). Prefer the
|
|
56
|
+
# MDG alias registry when its definition is available; the
|
|
57
|
+
# overrides map covers the spellings verified directly
|
|
58
|
+
# against EA-published reference SVGs.
|
|
59
|
+
DISPLAY_OVERRIDES = {
|
|
60
|
+
"property" => "Property",
|
|
61
|
+
"featuretype" => "FeatureType",
|
|
62
|
+
"objecttype" => "ObjectType",
|
|
63
|
+
"codelist" => "CodeList"
|
|
64
|
+
}.freeze
|
|
65
|
+
|
|
66
|
+
def self.canonical(ref)
|
|
67
|
+
DISPLAY_OVERRIDES.fetch(ref.downcase, ref)
|
|
51
68
|
end
|
|
52
69
|
|
|
53
70
|
def self.fallback_name(classifier)
|
|
@@ -14,7 +14,7 @@ module Ea
|
|
|
14
14
|
def self.render(lines, bounds:, first_y:, family:,
|
|
15
15
|
size:, size_unit: "pt",
|
|
16
16
|
fill: "#000000", weight_normal: 400, weight_bold: 700,
|
|
17
|
-
stroke_in_text: "#000000", width_factor: 0.
|
|
17
|
+
stroke_in_text: "#000000", width_factor: 0.612,
|
|
18
18
|
line_offset: 6)
|
|
19
19
|
line_h = size + line_offset
|
|
20
20
|
text_blocks = lines.each_with_index.map do |(text, style), idx|
|
|
@@ -23,11 +23,23 @@ module Ea
|
|
|
23
23
|
else weight_normal
|
|
24
24
|
end
|
|
25
25
|
font_style = (style == :italic || style == :bold_italic) ? "italic" : "normal"
|
|
26
|
-
|
|
27
|
-
|
|
26
|
+
# EA spaces the line AFTER a right-aligned ghost 6px
|
|
27
|
+
# wider (ghost +13, next +32 at 7pt) - verified against
|
|
28
|
+
# EA-published reference SVGs.
|
|
29
|
+
y = first_y + (idx * line_h) +
|
|
30
|
+
(idx >= 1 && lines.first.last == :italic ? 6 : 0)
|
|
31
|
+
x = if style == :italic
|
|
32
|
+
# EA right-aligns the off-canvas parent ghost at
|
|
33
|
+
# the box's right edge (verified across EA-published
|
|
34
|
+
# reference SVGs).
|
|
35
|
+
bounds.x + bounds.width -
|
|
36
|
+
Ea::Svg::EaEmitter::TextRenderer.estimate_width(text, size, width_factor)
|
|
37
|
+
else
|
|
38
|
+
center_x_for(text, bounds, size, width_factor)
|
|
39
|
+
end
|
|
28
40
|
TextRenderer.new(
|
|
29
41
|
content: text,
|
|
30
|
-
x:
|
|
42
|
+
x: x, y: y,
|
|
31
43
|
family: family, size: size, size_unit: size_unit,
|
|
32
44
|
weight: weight, style: font_style, fill: fill,
|
|
33
45
|
stroke_in_text: stroke_in_text, width_factor: width_factor
|
|
@@ -24,8 +24,8 @@ module Ea
|
|
|
24
24
|
class LegendRenderer
|
|
25
25
|
CONTAINER_RX = 3
|
|
26
26
|
ICON_SIZE = 17
|
|
27
|
-
TITLE_Y_OFFSET =
|
|
28
|
-
FIRST_ITEM_Y_OFFSET =
|
|
27
|
+
TITLE_Y_OFFSET = 22
|
|
28
|
+
FIRST_ITEM_Y_OFFSET = 33
|
|
29
29
|
ITEM_SPACING = 19
|
|
30
30
|
ICON_X_OFFSET = 10
|
|
31
31
|
ICON_LABEL_GAP = 22
|
|
@@ -130,11 +130,36 @@ module Ea
|
|
|
130
130
|
end
|
|
131
131
|
|
|
132
132
|
def title_text
|
|
133
|
-
x =
|
|
133
|
+
x = title_x
|
|
134
134
|
y = bounds.y + TITLE_Y_OFFSET
|
|
135
135
|
build_text(legend.title, x: x, y: y, size: title_font_size, weight: 700)
|
|
136
136
|
end
|
|
137
137
|
|
|
138
|
+
# EA centers the title over the ITEM BLOCK (icon left edge
|
|
139
|
+
# to the right end of the widest label), not the container
|
|
140
|
+
# box - verified against EA-published reference SVGs.
|
|
141
|
+
def title_x
|
|
142
|
+
icon_left = bounds.x + ICON_X_OFFSET
|
|
143
|
+
block_right = icon_left + ICON_LABEL_GAP +
|
|
144
|
+
widest_item_label_width
|
|
145
|
+
block_left = icon_left
|
|
146
|
+
block_left + (block_right - block_left - title_text_width) / 2.0
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
CJK_RANGE = /[\u3000-\u9fff\uff00-\uffef]/
|
|
150
|
+
|
|
151
|
+
# CJK glyphs run ~0.82em, others ~0.612em (regressed from
|
|
152
|
+
# EA's own textLength values on mixed-script labels).
|
|
153
|
+
def estimated_width(text, size)
|
|
154
|
+
text.to_s.each_char.sum do |ch|
|
|
155
|
+
ch.match?(CJK_RANGE) ? size * 0.70 : size * 0.612
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def widest_item_label_width
|
|
160
|
+
legend.items.map { |i| estimated_width(i.name, item_font_size) }.max || 0.0
|
|
161
|
+
end
|
|
162
|
+
|
|
138
163
|
def item_top_y(index)
|
|
139
164
|
bounds.y + FIRST_ITEM_Y_OFFSET + (index * ITEM_SPACING)
|
|
140
165
|
end
|
|
@@ -148,7 +173,7 @@ module Ea
|
|
|
148
173
|
end
|
|
149
174
|
|
|
150
175
|
def title_text_width
|
|
151
|
-
legend.title
|
|
176
|
+
estimated_width(legend.title, title_font_size)
|
|
152
177
|
end
|
|
153
178
|
|
|
154
179
|
def build_text(content, x:, y:, size:, weight:)
|
|
@@ -71,18 +71,29 @@ module Ea
|
|
|
71
71
|
font_size: size,
|
|
72
72
|
off_canvas_parent_name: parent_name) : []
|
|
73
73
|
is_classifier = classifier.is_a?(Ea::Model::Classifier)
|
|
74
|
-
attr_lines =
|
|
74
|
+
attr_lines = if is_classifier && show_attributes?
|
|
75
|
+
Element::AttributeRenderer.lines_for(
|
|
76
|
+
classifier, lookup: attribute_lookup,
|
|
77
|
+
exclude_association_ids: drawn_association_ids
|
|
78
|
+
)
|
|
79
|
+
else
|
|
80
|
+
[]
|
|
81
|
+
end
|
|
75
82
|
op_lines = (is_classifier && show_operations?) ? Element::OperationRenderer.lines_for(classifier) : []
|
|
76
|
-
geometry =
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
83
|
+
geometry = compartment_geometry(bounds, size, attr_lines, op_lines,
|
|
84
|
+
tagged_values_for(classifier).size,
|
|
85
|
+
header_lines)
|
|
86
|
+
# EA suppresses the whole attribute compartment when it does
|
|
87
|
+
# not fit the stored box height (never truncates rows) - the
|
|
88
|
+
# AOS box on CityFurniture hides its association property
|
|
89
|
+
# this way. Verified against EA-published reference SVGs.
|
|
90
|
+
if attr_lines.any? &&
|
|
91
|
+
geometry.attr_bottom_y.to_i + size > bounds.y + bounds.height
|
|
92
|
+
attr_lines = []
|
|
93
|
+
geometry = compartment_geometry(bounds, size, attr_lines, op_lines,
|
|
94
|
+
tagged_values_for(classifier).size,
|
|
95
|
+
header_lines)
|
|
96
|
+
end
|
|
86
97
|
RenderContext.new(
|
|
87
98
|
element: element,
|
|
88
99
|
bounds: bounds,
|
|
@@ -109,6 +120,47 @@ module Ea
|
|
|
109
120
|
)
|
|
110
121
|
end
|
|
111
122
|
|
|
123
|
+
def compartment_geometry(bounds, size, attr_lines, op_lines,
|
|
124
|
+
tagged_count, header_lines)
|
|
125
|
+
CompartmentGeometry.new(bounds: bounds, size: size,
|
|
126
|
+
header_lines_count: header_lines.size,
|
|
127
|
+
attr_lines_count: attr_lines.size,
|
|
128
|
+
op_lines_count: op_lines.size,
|
|
129
|
+
tagged_values_count: tagged_count,
|
|
130
|
+
header_top_padding: header_padding_for(header_lines),
|
|
131
|
+
header_line_offset: theme.compartments.header_line_offset,
|
|
132
|
+
divider_offset: theme.compartments.divider_offset,
|
|
133
|
+
attr_line_offset: theme.compartments.attr_line_offset,
|
|
134
|
+
attr_first_offset: attr_first_offset_for(attr_lines))
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# EA seats a stereotype line 3px higher than a bare class name
|
|
138
|
+
# (first baseline +16 vs +19 at 7pt) - verified against the
|
|
139
|
+
# EA-published reference SVGs.
|
|
140
|
+
def header_padding_for(header_lines)
|
|
141
|
+
first = header_lines.first
|
|
142
|
+
return theme.compartments.header_top_padding unless first
|
|
143
|
+
|
|
144
|
+
if first.last == :italic
|
|
145
|
+
6 # ghost-led: EA seats the right-aligned ghost at +13
|
|
146
|
+
elsif first.first.to_s.start_with?("«")
|
|
147
|
+
9
|
|
148
|
+
else
|
|
149
|
+
theme.compartments.header_top_padding
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# A stereotype group header («Property») sits 4px below where
|
|
154
|
+
# plain rows start (divider+18 vs divider+14 at 7pt).
|
|
155
|
+
def attr_first_offset_for(attr_lines)
|
|
156
|
+
attr_lines.first.to_s.start_with?("«") ? 11 : 7
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def drawn_association_ids
|
|
160
|
+
(diagram.connectors || []).select(&:renderable?)
|
|
161
|
+
.map(&:relationship_ref).compact.to_set
|
|
162
|
+
end
|
|
163
|
+
|
|
112
164
|
def enum_literals_for(classifier)
|
|
113
165
|
return [] unless classifier.is_a?(Ea::Model::Enumeration)
|
|
114
166
|
|
|
@@ -166,19 +218,17 @@ module Ea
|
|
|
166
218
|
(diagram.elements || []).any? { |e| e.model_element_ref == model_element_ref }
|
|
167
219
|
end
|
|
168
220
|
|
|
169
|
-
#
|
|
170
|
-
#
|
|
171
|
-
#
|
|
172
|
-
#
|
|
173
|
-
#
|
|
221
|
+
# EA suppresses the parent-class ghost line only when the
|
|
222
|
+
# parent's package is itself placed on the diagram as a
|
|
223
|
+
# package element. A mere same-package classifier does NOT
|
|
224
|
+
# suppress it — verified against EA-published reference SVGs
|
|
225
|
+
# (CityFurniture renders the ghost for AbstractOccupiedSpace
|
|
226
|
+
# although the class shares its parent's package).
|
|
174
227
|
def parent_package_represented_on_diagram?(parent)
|
|
175
228
|
parent_pkg = parent.is_a?(Ea::Model::Classifier) ? parent.package_id : nil
|
|
176
229
|
return false unless parent_pkg
|
|
177
230
|
|
|
178
|
-
(diagram.elements || []).any?
|
|
179
|
-
sibling = model_index[e.model_element_ref]
|
|
180
|
-
sibling.is_a?(Ea::Model::Classifier) && sibling.package_id == parent_pkg
|
|
181
|
-
end
|
|
231
|
+
(diagram.elements || []).any? { |e| e.model_element_ref == parent_pkg }
|
|
182
232
|
end
|
|
183
233
|
|
|
184
234
|
# EA renders a package's child classifiers and sub-packages as
|
|
@@ -17,12 +17,11 @@ module Ea
|
|
|
17
17
|
# text-emitting layer.
|
|
18
18
|
class FontResolver
|
|
19
19
|
DEFAULT_FONT_FAMILY = "Yu Gothic UI"
|
|
20
|
-
# EA's default element compartment font size
|
|
21
|
-
# reference SVGs
|
|
22
|
-
#
|
|
23
|
-
#
|
|
24
|
-
|
|
25
|
-
DEFAULT_ELEMENT_FONT_SIZE = 9
|
|
20
|
+
# EA's default element compartment font size. EA-published
|
|
21
|
+
# reference SVGs render body text at 7pt (the current EA
|
|
22
|
+
# theme size) for every element whose objectstyle fontsz is
|
|
23
|
+
# a use-default marker (see QEA DiagramBuilder.font_size_from).
|
|
24
|
+
DEFAULT_ELEMENT_FONT_SIZE = 7
|
|
26
25
|
|
|
27
26
|
attr_reader :diagram, :theme
|
|
28
27
|
|
|
@@ -122,13 +122,23 @@ module Ea
|
|
|
122
122
|
assoc = association_for(connector)
|
|
123
123
|
return [nil, nil, nil] unless assoc
|
|
124
124
|
|
|
125
|
-
prop = property_at_end(assoc, end_kind)
|
|
125
|
+
prop = property_at_end(assoc, end_kind, connector)
|
|
126
126
|
return [true, *property_role(prop)] if prop
|
|
127
127
|
|
|
128
128
|
[false, *association_role(assoc, end_kind)]
|
|
129
129
|
end
|
|
130
130
|
|
|
131
|
-
def property_at_end(association, end_kind)
|
|
131
|
+
def property_at_end(association, end_kind, connector = nil)
|
|
132
|
+
# EA renders the source classifier's synthesized
|
|
133
|
+
# association-end property (matched by the connector's
|
|
134
|
+
# relationship guid), falling back to the property index
|
|
135
|
+
# lookup by association source/target id.
|
|
136
|
+
if connector&.relationship_ref
|
|
137
|
+
placed = placed_classifier_id(association, end_kind)
|
|
138
|
+
prop = assoc_end_property(placed, connector.relationship_ref)
|
|
139
|
+
return prop if prop
|
|
140
|
+
end
|
|
141
|
+
|
|
132
142
|
id = end_kind == :source ? association.source_id : association.target_id
|
|
133
143
|
return nil unless id
|
|
134
144
|
|
|
@@ -137,9 +147,29 @@ module Ea
|
|
|
137
147
|
fallback_property_lookup(id)
|
|
138
148
|
end
|
|
139
149
|
|
|
150
|
+
def placed_classifier_id(association, end_kind)
|
|
151
|
+
end_kind == :source ? association.source_id : association.target_id
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# Find the synthesized association-end property on the
|
|
155
|
+
# placed classifier whose association_id matches the
|
|
156
|
+
# connector's relationship reference.
|
|
157
|
+
def assoc_end_property(classifier_id, relationship_ref)
|
|
158
|
+
return nil unless classifier_id
|
|
159
|
+
|
|
160
|
+
classifier = model_index[classifier_id]
|
|
161
|
+
return nil unless classifier.is_a?(Ea::Model::Classifier)
|
|
162
|
+
|
|
163
|
+
(classifier.properties || []).find do |p|
|
|
164
|
+
p.association_id == relationship_ref
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
|
|
140
168
|
# Linear-scan fallback for callers that did not wire in
|
|
141
169
|
# the Document. Production paths go through Document's
|
|
142
170
|
# property_index for O(1) lookup.
|
|
171
|
+
|
|
172
|
+
|
|
143
173
|
def fallback_property_lookup(id)
|
|
144
174
|
return nil unless model_index
|
|
145
175
|
|
|
@@ -155,11 +185,23 @@ module Ea
|
|
|
155
185
|
def property_role(property)
|
|
156
186
|
visibility = visibility_prefix(property)
|
|
157
187
|
name = property.name.to_s
|
|
158
|
-
|
|
188
|
+
type_name = property.type_name.to_s
|
|
189
|
+
line = if type_name.empty? || type_name == " "
|
|
190
|
+
"#{visibility}#{name}"
|
|
191
|
+
else
|
|
192
|
+
"#{visibility}#{name}: #{namespace_double_colon(type_name)}"
|
|
193
|
+
end
|
|
194
|
+
role = line.empty? ? nil : line
|
|
159
195
|
mult = multiplicity_text(property)
|
|
160
196
|
[role, mult]
|
|
161
197
|
end
|
|
162
198
|
|
|
199
|
+
def namespace_double_colon(type_name)
|
|
200
|
+
return type_name if type_name.nil? || type_name.empty?
|
|
201
|
+
|
|
202
|
+
type_name.to_s.gsub(/([A-Za-z0-9_]):([A-Za-z])/, '\1::\2')
|
|
203
|
+
end
|
|
204
|
+
|
|
163
205
|
def association_role(assoc, end_kind)
|
|
164
206
|
name = end_kind == :source ? assoc.source_role_name : assoc.target_role_name
|
|
165
207
|
lower = end_kind == :source ? assoc.source_multiplicity_lower : assoc.target_multiplicity_lower
|
|
@@ -202,7 +244,9 @@ module Ea
|
|
|
202
244
|
offset_y = box["oy"] || box[:oy]
|
|
203
245
|
return nil if offset_x.nil? && offset_y.nil?
|
|
204
246
|
|
|
205
|
-
|
|
247
|
+
# Waypoint anchors are mirrored to screen-down space; the
|
|
248
|
+
# stored OY offset counts the other way, so negate it.
|
|
249
|
+
[anchor[0] + offset_x.to_i, anchor[1] - offset_y.to_i]
|
|
206
250
|
end
|
|
207
251
|
|
|
208
252
|
def property_y_offset
|
|
@@ -73,16 +73,52 @@ module Ea
|
|
|
73
73
|
target_pt = points.last
|
|
74
74
|
boxes = connector.label_boxes || {}
|
|
75
75
|
|
|
76
|
+
src_tb, src_mb = end_boxes(boxes, :llt, :llb) ||
|
|
77
|
+
default_label_boxes(points, :source)
|
|
78
|
+
tgt_tb, tgt_mb = end_boxes(boxes, :lrt, :lrb) ||
|
|
79
|
+
default_label_boxes(points, :target)
|
|
80
|
+
|
|
76
81
|
texts = []
|
|
77
|
-
end_renderer.texts(text_box:
|
|
82
|
+
end_renderer.texts(text_box: src_tb, mult_box: src_mb,
|
|
78
83
|
anchor: source_pt, connector: connector,
|
|
79
84
|
end_kind: :source).each { |t| texts << t }
|
|
80
|
-
end_renderer.texts(text_box:
|
|
85
|
+
end_renderer.texts(text_box: tgt_tb, mult_box: tgt_mb,
|
|
81
86
|
anchor: target_pt, connector: connector,
|
|
82
87
|
end_kind: :target).each { |t| texts << t }
|
|
83
88
|
texts
|
|
84
89
|
end
|
|
85
90
|
|
|
91
|
+
def end_boxes(boxes, text_key, mult_key)
|
|
92
|
+
return nil if boxes[text_key].nil? && boxes[mult_key].nil?
|
|
93
|
+
|
|
94
|
+
[boxes[text_key], boxes[mult_key]]
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# EA's default label placement for connectors without stored
|
|
98
|
+
# label boxes: role name 5px along the line and 31px below it,
|
|
99
|
+
# multiplicity 18px along and 21px above (screen-down coords).
|
|
100
|
+
# Derived from 219 labels across EA-published reference SVGs.
|
|
101
|
+
def default_label_boxes(points, end_kind)
|
|
102
|
+
e = end_kind == :source ? points.first : points.last
|
|
103
|
+
other = end_kind == :source ? points[1] : points[-2]
|
|
104
|
+
return [nil, nil] unless e && other
|
|
105
|
+
|
|
106
|
+
ux = other[0] - e[0]
|
|
107
|
+
uy = other[1] - e[1]
|
|
108
|
+
len = Math.sqrt(ux**2 + uy**2)
|
|
109
|
+
return [nil, nil] if len < 0.5
|
|
110
|
+
|
|
111
|
+
ux /= len
|
|
112
|
+
uy /= len
|
|
113
|
+
vx = uy
|
|
114
|
+
vy = -ux
|
|
115
|
+
role_box = { "ox" => (5 * ux - 31 * vx).round,
|
|
116
|
+
"oy" => (5 * uy - 31 * vy).round }
|
|
117
|
+
mult_box = { "ox" => (18 * ux + 21 * vx).round,
|
|
118
|
+
"oy" => (18 * uy + 21 * vy).round }
|
|
119
|
+
[role_box, mult_box]
|
|
120
|
+
end
|
|
121
|
+
|
|
86
122
|
def registry
|
|
87
123
|
@registry ||= Label::Registry.new(model_index: model_index)
|
|
88
124
|
end
|
|
@@ -20,7 +20,7 @@ module Ea
|
|
|
20
20
|
DEFAULT_FILL = "#000000"
|
|
21
21
|
DEFAULT_ROTATION = -0.00
|
|
22
22
|
DEFAULT_STROKE_IN_TEXT = "#000000"
|
|
23
|
-
DEFAULT_WIDTH_FACTOR = 0.
|
|
23
|
+
DEFAULT_WIDTH_FACTOR = 0.612
|
|
24
24
|
|
|
25
25
|
attr_reader :content, :x, :y, :family, :size, :weight, :style,
|
|
26
26
|
:fill, :text_length, :rotation, :size_unit,
|
|
@@ -55,19 +55,108 @@ module Ea
|
|
|
55
55
|
|
|
56
56
|
private
|
|
57
57
|
|
|
58
|
+
# nil coordinates occur on degenerate placements (e.g. label
|
|
59
|
+
# boxes without offsets); EA always has a position, so render
|
|
60
|
+
# at the origin rather than crashing the whole diagram.
|
|
58
61
|
def format_attrs
|
|
59
|
-
%(x="#{format('%.2f', x)}" y="#{format('%.2f', y)}")
|
|
62
|
+
%(x="#{format('%.2f', x.to_f)}" y="#{format('%.2f', y.to_f)}")
|
|
60
63
|
end
|
|
61
64
|
|
|
62
65
|
def format_style
|
|
63
66
|
"font-family:#{family}; font-weight:#{weight}; font-style:#{style}; font-size:#{size}#{size_unit}; fill:#{fill};fill-opacity:1.00; stroke:#{stroke_in_text}; stroke-opacity:0.00 stroke-width:0; white-space: pre;"
|
|
64
67
|
end
|
|
65
68
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
69
|
+
# Per-codepoint advance widths (em), least-squares fitted from
|
|
70
|
+
# EA's own textLength attributes across ~8k published reference
|
|
71
|
+
# texts (mean residual 0.097em - real-metric extraction).
|
|
72
|
+
GLYPH_WIDTHS = {
|
|
73
|
+
"e" => 0.731,
|
|
74
|
+
"t" => 0.438,
|
|
75
|
+
"a" => 0.72,
|
|
76
|
+
"r" => 0.446,
|
|
77
|
+
"i" => 0.302,
|
|
78
|
+
"o" => 0.717,
|
|
79
|
+
"n" => 0.693,
|
|
80
|
+
" " => 0.311,
|
|
81
|
+
"s" => 0.568,
|
|
82
|
+
"c" => 0.567,
|
|
83
|
+
"l" => 0.287,
|
|
84
|
+
"u" => 0.732,
|
|
85
|
+
":" => 0.433,
|
|
86
|
+
"m" => 1.125,
|
|
87
|
+
"+" => 0.951,
|
|
88
|
+
"d" => 0.707,
|
|
89
|
+
"p" => 0.703,
|
|
90
|
+
"C" => 0.725,
|
|
91
|
+
"g" => 0.726,
|
|
92
|
+
"y" => 0.733,
|
|
93
|
+
"." => 0.458,
|
|
94
|
+
"S" => 0.706,
|
|
95
|
+
"f" => 0.426,
|
|
96
|
+
"_" => 0.755,
|
|
97
|
+
"T" => 0.702,
|
|
98
|
+
"L" => 0.604,
|
|
99
|
+
"D" => 0.871,
|
|
100
|
+
"1" => 0.708,
|
|
101
|
+
"0" => 0.703,
|
|
102
|
+
"P" => 0.698,
|
|
103
|
+
"h" => 0.699,
|
|
104
|
+
"\u00bb" => 0.802,
|
|
105
|
+
"\u00ab" => 0.566,
|
|
106
|
+
"b" => 0.72,
|
|
107
|
+
"I" => 0.422,
|
|
108
|
+
"A" => 0.907,
|
|
109
|
+
"v" => 0.685,
|
|
110
|
+
"M" => 1.294,
|
|
111
|
+
"R" => 0.764,
|
|
112
|
+
")" => 0.405,
|
|
113
|
+
"(" => 0.289,
|
|
114
|
+
"*" => 0.646,
|
|
115
|
+
"[" => -0.029,
|
|
116
|
+
"]" => 0.669,
|
|
117
|
+
"O" => 0.968,
|
|
118
|
+
"E" => 0.683,
|
|
119
|
+
"B" => 0.801,
|
|
120
|
+
"V" => 0.898,
|
|
121
|
+
"x" => 0.628,
|
|
122
|
+
"G" => 0.875,
|
|
123
|
+
"=" => 0.629,
|
|
124
|
+
"F" => 0.74,
|
|
125
|
+
"N" => 0.893,
|
|
126
|
+
"U" => 0.907,
|
|
127
|
+
"2" => 0.743,
|
|
128
|
+
"w" => 0.983,
|
|
129
|
+
"}" => 0.345,
|
|
130
|
+
"{" => 0.461,
|
|
131
|
+
"k" => 0.792,
|
|
132
|
+
"9" => 0.839,
|
|
133
|
+
"j" => 0.366,
|
|
134
|
+
"q" => 0.657,
|
|
135
|
+
"3" => 0.694,
|
|
136
|
+
"5" => 0.72,
|
|
137
|
+
">" => 0.682,
|
|
138
|
+
"," => 0.549,
|
|
139
|
+
"-" => 0.485,
|
|
140
|
+
"<" => 0.696,
|
|
141
|
+
"Q" => 0.967,
|
|
142
|
+
"6" => 0.776,
|
|
143
|
+
"4" => 0.658,
|
|
144
|
+
"/" => 0.496,
|
|
145
|
+
"z" => 0.59,
|
|
146
|
+
"H" => 0.854,
|
|
147
|
+
"W" => 1.292,
|
|
148
|
+
"\"" => 0.508,
|
|
149
|
+
"J" => 0.555,
|
|
150
|
+
"X" => 0.711,
|
|
151
|
+
"7" => 0.671,
|
|
152
|
+
"8" => 0.74
|
|
153
|
+
}.freeze
|
|
154
|
+
GLYPH_FALLBACK = 0.61
|
|
155
|
+
|
|
156
|
+
def self.estimate_width(text, size, _width_factor = nil)
|
|
157
|
+
text.to_s.each_char.sum do |ch|
|
|
158
|
+
(GLYPH_WIDTHS[ch] || GLYPH_FALLBACK) * size.to_f
|
|
159
|
+
end
|
|
71
160
|
end
|
|
72
161
|
|
|
73
162
|
def formatted_text_length
|
|
@@ -77,7 +166,7 @@ module Ea
|
|
|
77
166
|
end
|
|
78
167
|
|
|
79
168
|
def formatted_transform
|
|
80
|
-
"rotate(#{format('%<r>.2f', r: rotation)} #{format('%.2f', x)} #{format('%.2f', y)})"
|
|
169
|
+
"rotate(#{format('%<r>.2f', r: rotation)} #{format('%.2f', x.to_f)} #{format('%.2f', y.to_f)})"
|
|
81
170
|
end
|
|
82
171
|
|
|
83
172
|
def escaped_content
|
|
@@ -25,6 +25,7 @@ module Ea
|
|
|
25
25
|
text: count_diff("text"),
|
|
26
26
|
group: top_level_group_diff,
|
|
27
27
|
font_family: font_family_match,
|
|
28
|
+
font_size: font_size_match,
|
|
28
29
|
view_box: view_box_match,
|
|
29
30
|
text_overlap: text_overlap_ratio
|
|
30
31
|
)
|
|
@@ -51,9 +52,23 @@ module Ea
|
|
|
51
52
|
end
|
|
52
53
|
|
|
53
54
|
def font_family_match
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
55
|
+
font_style_histogram(our_doc, "font-family") ==
|
|
56
|
+
font_style_histogram(ref_doc, "font-family")
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Exact histogram of every emitted font-size (e.g.
|
|
60
|
+
# {"9.00"=>42, "13.00"=>21}) — a first-text comparison would
|
|
61
|
+
# pass diagrams whose body text renders at the wrong size.
|
|
62
|
+
def font_size_match
|
|
63
|
+
font_style_histogram(our_doc, "font-size") ==
|
|
64
|
+
font_style_histogram(ref_doc, "font-size")
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def font_style_histogram(doc, property)
|
|
68
|
+
doc.css("text").each_with_object(Hash.new(0)) do |node, acc|
|
|
69
|
+
value = (node["style"] || "")[/#{Regexp.quote(property)}:([^;]+)/, 1]
|
|
70
|
+
acc[value] += 1 if value
|
|
71
|
+
end
|
|
57
72
|
end
|
|
58
73
|
|
|
59
74
|
def view_box_match
|
|
@@ -69,11 +84,6 @@ module Ea
|
|
|
69
84
|
(our_set & ref_set).size.to_f / (our_set | ref_set).size.to_f
|
|
70
85
|
end
|
|
71
86
|
|
|
72
|
-
def first_text_style(doc)
|
|
73
|
-
first = doc.css("text").first
|
|
74
|
-
first ? (first["style"] || "") : ""
|
|
75
|
-
end
|
|
76
|
-
|
|
77
87
|
Diff = Struct.new(:ours, :reference, keyword_init: true) do
|
|
78
88
|
def delta
|
|
79
89
|
ours - reference
|
|
@@ -85,7 +95,7 @@ module Ea
|
|
|
85
95
|
end
|
|
86
96
|
|
|
87
97
|
Report = Struct.new(:rect, :path, :polygon, :text, :group,
|
|
88
|
-
:font_family, :view_box, :text_overlap,
|
|
98
|
+
:font_family, :font_size, :view_box, :text_overlap,
|
|
89
99
|
keyword_init: true) do
|
|
90
100
|
def shape_delta_total
|
|
91
101
|
[rect, path, polygon].sum(&:delta).abs
|