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.
Files changed (32) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +39 -0
  3. data/config/themes/119.yml +4 -4
  4. data/lib/ea/model/diagram_connector.rb +9 -0
  5. data/lib/ea/model/property.rb +6 -1
  6. data/lib/ea/sources/qea/classifier_builder.rb +3 -1
  7. data/lib/ea/sources/qea/diagram_builder.rb +91 -9
  8. data/lib/ea/sources/qea/property_builder.rb +12 -2
  9. data/lib/ea/svg/ea_emitter/compartment/attributes.rb +1 -0
  10. data/lib/ea/svg/ea_emitter/compartment/constraints.rb +1 -0
  11. data/lib/ea/svg/ea_emitter/compartment/enum_literals.rb +9 -25
  12. data/lib/ea/svg/ea_emitter/compartment/header.rb +1 -0
  13. data/lib/ea/svg/ea_emitter/element/attribute_line_builder.rb +5 -0
  14. data/lib/ea/svg/ea_emitter/element/attribute_renderer.rb +51 -11
  15. data/lib/ea/svg/ea_emitter/element/compartment_geometry.rb +12 -10
  16. data/lib/ea/svg/ea_emitter/element/header_line_provider/name.rb +6 -15
  17. data/lib/ea/svg/ea_emitter/element/header_line_provider/stereotype_label.rb +25 -8
  18. data/lib/ea/svg/ea_emitter/element/header_renderer.rb +16 -4
  19. data/lib/ea/svg/ea_emitter/element/legend_renderer.rb +29 -4
  20. data/lib/ea/svg/ea_emitter/elements.rb +70 -20
  21. data/lib/ea/svg/ea_emitter/font_resolver.rb +5 -6
  22. data/lib/ea/svg/ea_emitter/label/end_label.rb +48 -4
  23. data/lib/ea/svg/ea_emitter/labels.rb +38 -2
  24. data/lib/ea/svg/ea_emitter/text_renderer.rb +97 -8
  25. data/lib/ea/svg/parity/checker.rb +19 -9
  26. data/lib/ea/svg/parity/corpus_report.rb +83 -0
  27. data/lib/ea/svg/parity/differ.rb +231 -0
  28. data/lib/ea/svg/parity.rb +2 -0
  29. data/lib/ea/theme/definition.rb +3 -3
  30. data/lib/ea/theme/loader.rb +2 -2
  31. data/lib/ea/version.rb +1 -1
  32. 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
- label = label_for(classifier, context.umldi_keyword)
22
- return [] unless label
21
+ labels = label_for(classifier, context.umldi_keyword)
22
+ return [] if labels.empty?
23
23
 
24
- [[label, :normal]]
24
+ labels.map { |l| [l, :normal] }
25
25
  end
26
26
 
27
- # @return [String, nil] «name» label or nil
27
+ # @return [Array<String>] «name» labels (EA renders one
28
+ # line per applied stereotype)
28
29
  def self.label_for(classifier, umldi_keyword)
29
- return "«#{umldi_keyword}»" if umldi_keyword && !umldi_keyword.empty?
30
+ return ["«#{umldi_keyword}»"] if umldi_keyword && !umldi_keyword.empty?
30
31
 
31
32
  explicit = explicit_stereotype(classifier)
32
33
  return explicit if explicit
33
34
 
34
35
  case classifier
35
36
  when Ea::Model::Klass, Ea::Model::Package, Ea::Model::Note
36
- nil
37
+ []
37
38
  else
38
39
  fallback = fallback_name(classifier)
39
- fallback ? "«#{fallback}»" : nil
40
+ fallback ? ["«#{fallback}»"] : []
40
41
  end
41
42
  end
42
43
 
@@ -47,7 +48,23 @@ module Ea
47
48
  refs = classifier.stereotype_refs
48
49
  return nil unless refs&.any?
49
50
 
50
- "«#{refs.first}»"
51
+ refs.map { |ref| "«#{canonical(ref)}»" }
52
+ end
53
+
54
+ # EA displays canonical stereotype spellings regardless of
55
+ # how the QEA stored them (property → Property). Prefer the
56
+ # MDG alias registry when its definition is available; the
57
+ # overrides map covers the spellings verified directly
58
+ # against EA-published reference SVGs.
59
+ DISPLAY_OVERRIDES = {
60
+ "property" => "Property",
61
+ "featuretype" => "FeatureType",
62
+ "objecttype" => "ObjectType",
63
+ "codelist" => "CodeList"
64
+ }.freeze
65
+
66
+ def self.canonical(ref)
67
+ DISPLAY_OVERRIDES.fetch(ref.downcase, ref)
51
68
  end
52
69
 
53
70
  def self.fallback_name(classifier)
@@ -14,7 +14,7 @@ module Ea
14
14
  def self.render(lines, bounds:, first_y:, family:,
15
15
  size:, size_unit: "pt",
16
16
  fill: "#000000", weight_normal: 400, weight_bold: 700,
17
- stroke_in_text: "#000000", width_factor: 0.65,
17
+ stroke_in_text: "#000000", width_factor: 0.612,
18
18
  line_offset: 6)
19
19
  line_h = size + line_offset
20
20
  text_blocks = lines.each_with_index.map do |(text, style), idx|
@@ -23,11 +23,23 @@ module Ea
23
23
  else weight_normal
24
24
  end
25
25
  font_style = (style == :italic || style == :bold_italic) ? "italic" : "normal"
26
- y = first_y + (idx * line_h)
27
- centered_x = center_x_for(text, bounds, size, width_factor)
26
+ # EA spaces the line AFTER a right-aligned ghost 6px
27
+ # wider (ghost +13, next +32 at 7pt) - verified against
28
+ # EA-published reference SVGs.
29
+ y = first_y + (idx * line_h) +
30
+ (idx >= 1 && lines.first.last == :italic ? 6 : 0)
31
+ x = if style == :italic
32
+ # EA right-aligns the off-canvas parent ghost at
33
+ # the box's right edge (verified across EA-published
34
+ # reference SVGs).
35
+ bounds.x + bounds.width -
36
+ Ea::Svg::EaEmitter::TextRenderer.estimate_width(text, size, width_factor)
37
+ else
38
+ center_x_for(text, bounds, size, width_factor)
39
+ end
28
40
  TextRenderer.new(
29
41
  content: text,
30
- x: centered_x, y: y,
42
+ x: x, y: y,
31
43
  family: family, size: size, size_unit: size_unit,
32
44
  weight: weight, style: font_style, fill: fill,
33
45
  stroke_in_text: stroke_in_text, width_factor: width_factor
@@ -24,8 +24,8 @@ module Ea
24
24
  class LegendRenderer
25
25
  CONTAINER_RX = 3
26
26
  ICON_SIZE = 17
27
- TITLE_Y_OFFSET = 19
28
- FIRST_ITEM_Y_OFFSET = 30
27
+ TITLE_Y_OFFSET = 22
28
+ FIRST_ITEM_Y_OFFSET = 33
29
29
  ITEM_SPACING = 19
30
30
  ICON_X_OFFSET = 10
31
31
  ICON_LABEL_GAP = 22
@@ -130,11 +130,36 @@ module Ea
130
130
  end
131
131
 
132
132
  def title_text
133
- x = bounds.x + (bounds.width / 2) - (title_text_width / 2)
133
+ x = title_x
134
134
  y = bounds.y + TITLE_Y_OFFSET
135
135
  build_text(legend.title, x: x, y: y, size: title_font_size, weight: 700)
136
136
  end
137
137
 
138
+ # EA centers the title over the ITEM BLOCK (icon left edge
139
+ # to the right end of the widest label), not the container
140
+ # box - verified against EA-published reference SVGs.
141
+ def title_x
142
+ icon_left = bounds.x + ICON_X_OFFSET
143
+ block_right = icon_left + ICON_LABEL_GAP +
144
+ widest_item_label_width
145
+ block_left = icon_left
146
+ block_left + (block_right - block_left - title_text_width) / 2.0
147
+ end
148
+
149
+ CJK_RANGE = /[\u3000-\u9fff\uff00-\uffef]/
150
+
151
+ # CJK glyphs run ~0.82em, others ~0.612em (regressed from
152
+ # EA's own textLength values on mixed-script labels).
153
+ def estimated_width(text, size)
154
+ text.to_s.each_char.sum do |ch|
155
+ ch.match?(CJK_RANGE) ? size * 0.70 : size * 0.612
156
+ end
157
+ end
158
+
159
+ def widest_item_label_width
160
+ legend.items.map { |i| estimated_width(i.name, item_font_size) }.max || 0.0
161
+ end
162
+
138
163
  def item_top_y(index)
139
164
  bounds.y + FIRST_ITEM_Y_OFFSET + (index * ITEM_SPACING)
140
165
  end
@@ -148,7 +173,7 @@ module Ea
148
173
  end
149
174
 
150
175
  def title_text_width
151
- legend.title.to_s.length * title_font_size * 0.6
176
+ estimated_width(legend.title, title_font_size)
152
177
  end
153
178
 
154
179
  def build_text(content, x:, y:, size:, weight:)
@@ -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 = (is_classifier && show_attributes?) ? Element::AttributeRenderer.lines_for(classifier, lookup: attribute_lookup) : []
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 = CompartmentGeometry.new(bounds: bounds, size: size,
77
- header_lines_count: header_lines.size,
78
- attr_lines_count: attr_lines.size,
79
- op_lines_count: op_lines.size,
80
- tagged_values_count: tagged_values_for(classifier).size,
81
- header_top_padding: theme.compartments.header_top_padding,
82
- header_line_offset: theme.compartments.header_line_offset,
83
- divider_offset: theme.compartments.divider_offset,
84
- attr_line_offset: theme.compartments.attr_line_offset,
85
- attr_first_offset: theme.compartments.attr_first_offset)
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
- # Returns true when any element placed on the current diagram
170
- # lives in the same package as `parent`. EA suppresses the
171
- # parent-class ghost line in that case — the parent's package
172
- # is already "represented" on the diagram, so the off-canvas
173
- # parent is implicitly visible to the reader.
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? do |e|
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 (matches
21
- # reference SVGs which use 9pt for classifier header,
22
- # attributes, operations, etc. — distinct from theme's
23
- # diagram-level font size which applies only to frame
24
- # label and swimlanes).
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
- role = name.empty? ? nil : "#{visibility}#{name}"
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
- [anchor[0] + offset_x.to_i, anchor[1] + offset_y.to_i]
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: boxes[:llt], mult_box: boxes[:llb],
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: boxes[:lrt], mult_box: boxes[:lrb],
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.65
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
- def self.estimate_width(text, size, width_factor = 0.65)
67
- content = text.to_s
68
- space_count = content.count(" ")
69
- letter_count = content.length - space_count
70
- letter_count * size * width_factor + space_count * size * 0.3
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
- our_family = first_text_style(our_doc)[/font-family:([^;]+)/, 1]
55
- ref_family = first_text_style(ref_doc)[/font-family:([^;]+)/, 1]
56
- our_family == ref_family
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