ea 0.6.4 → 0.6.5

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 +3 -1
  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
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e8a117bfcfdfe9fe4ace5104c3db01fc2ee338bc8a49654be3a5dd8794c9ed98
4
- data.tar.gz: 2cc7a8fe2d505872db1bd5ba02e2c951556838b08368ba697aafc19ae3d075d8
3
+ metadata.gz: 9d4eb047626f932a223d59f60d7a07ac839542e16f1f08cc1e27f99102592af7
4
+ data.tar.gz: d38407abf49dd80a886269968e944edfa691bc09ca31772556d8d20a89dac294
5
5
  SHA512:
6
- metadata.gz: 9b9d9a7fe445ba715bbf7fea49a8b20f5a29258bbed87808a4e62ebbce1a63186651d41f780e451042a7a80e4c2b41b390c070bb33f479be9a2f831348e9c748
7
- data.tar.gz: d31530d3396479880099baa74bc73858229d2c6fa69108b23ddffc0bb45f550737ada00924e6a1e22c594e532d3d531745c7cfb40af226c1266291fbe9250fef
6
+ metadata.gz: 50d22ea166bdb5ee80f60a16d2396dc12b90470d1c9f5048279ee9e9d4233b7778af481f04719618416f59d26ada1ddb750bbda03e8fd9f6c4abc1c279ecd9a1
7
+ data.tar.gz: 9ac1166cc762bce236ca889b8a28f26bb32f3688fa00ce925b53f91adf5999976057aefe91e4b684fdf55adbca352af6bd4f5901e9b5251ba0b29c21999becd5
data/CHANGELOG.md CHANGED
@@ -7,6 +7,45 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ### Added
11
+ - `Ea::Model::DiagramConnector` now carries `source_port` / `target_port`
12
+ — the absolute connection points EA docks each connector endpoint
13
+ at, derived from the `t_diagramlinks.Geometry` SX/SY and EX/EY
14
+ offsets by ray-casting from the element center through
15
+ center+offset to the box border (fixes #52; semantics verified
16
+ against EA-published reference SVGs). QEA-built connectors route
17
+ from these ports instead of edge-midpoint guesses, so arrows and
18
+ aggregation diamonds terminate on the element border exactly where
19
+ EA places them.
20
+ - QEA `diagram_builder` maps per-element `font_size` (EA's tenth-point
21
+ `fontsz`, custom values only) and `font_underline` from the
22
+ objectstyle string (fixes #53).
23
+ - `Ea::Svg::Parity::Checker` compares font-family and font-size
24
+ histograms across all emitted text (was: first-text only), so a
25
+ single wrong-size compartment fails the parity check (#54).
26
+
27
+ ### Fixed
28
+ - QEA diagram coordinates are now mirrored into EA's screen-down
29
+ space at build time (stored t_diagramobjects rects are bottom-up;
30
+ EA's own published SVGs draw the mirrored view). Connector ports,
31
+ Path waypoints, and bend fallbacks flip consistently, so SVG and
32
+ SPA output matches EA's orientation exactly.
33
+ - Element font sizes stored as EA's classic defaults (fontsz 80/90)
34
+ are treated as use-default markers: rendering falls back to the
35
+ 7pt theme size EA actually emits, and only genuinely custom sizes
36
+ override it.
37
+ - Attribute multiplicity bounds stored as EA's "n" no longer parse
38
+ to 1 (which silently dropped the `[1..n]` suffix); unbounded
39
+ uppers render as `[1..n]` like EA.
40
+ - `Ea::Svg::EaEmitter::TextRenderer` no longer raises
41
+ `TypeError: can't convert nil into Float` on degenerate placements
42
+ with missing coordinates or font size — it renders at the origin
43
+ with zero-width estimation instead (fixes #51; 297 of 3,702 PLATEAU
44
+ CG3 diagrams previously crashed `ea svg --all` and shipped without
45
+ SVG in SPA output).
46
+
47
+ ## [0.6.4]
48
+
10
49
  ### Added
11
50
  - `Ea::Spa::Projector` embeds model statistics (packages, classes,
12
51
  attributes, operations, associations, diagrams) in the skeleton
@@ -38,7 +38,7 @@ strokes:
38
38
  # Text geometry — EA uses GDI text metrics for exact widths. We
39
39
  # approximate with character_count * size * factor.
40
40
  text_metrics:
41
- width_factor: 0.65
41
+ width_factor: 0.612
42
42
 
43
43
  # Canvas frame insets (EA places element (0,0) at SVG (left, top)).
44
44
  canvas:
@@ -60,10 +60,10 @@ frame:
60
60
  # diagram byte-diff: element at y=40 size=9, stereotype y=58 →
61
61
  # offset = 18 = size * 2; name y=73 → line_h = size + 6).
62
62
  compartments:
63
- header_top_padding: 9
63
+ header_top_padding: 12
64
64
  header_line_offset: 6
65
65
  divider_offset: 8
66
- attr_line_offset: 4
66
+ attr_line_offset: 6
67
67
  attr_first_offset: 7
68
68
  op_line_offset: 4
69
69
  op_line_offset: 4
@@ -71,7 +71,7 @@ compartments:
71
71
  # Attribute text x-offsets within element bounds.
72
72
  attribute:
73
73
  visibility_x_offset: 5
74
- content_x_offset: 26
74
+ content_x_offset: 22
75
75
 
76
76
  # Marker geometry constants.
77
77
  markers:
@@ -33,6 +33,13 @@ module Ea
33
33
  # connectors (no SX/SY) get a plain solid line.
34
34
  attribute :has_geometry_offsets, :boolean, default: false
35
35
 
36
+ # Absolute connection points on the source/target element boxes,
37
+ # derived from the Geometry SX/SY and EX/EY edge offsets. EA
38
+ # docks the connector's visible endpoints at these ports; nil
39
+ # when the geometry string carries no offsets (auto-routed).
40
+ attribute :source_port, Ea::Model::Point
41
+ attribute :target_port, Ea::Model::Point
42
+
36
43
  json do
37
44
  map "id", to: :id
38
45
  map "diagramId", to: :diagram_id
@@ -43,6 +50,8 @@ module Ea
43
50
  map "targetDuid", to: :target_duid
44
51
  map "sourceEdge", to: :source_edge
45
52
  map "targetEdge", to: :target_edge
53
+ map "sourcePort", to: :source_port
54
+ map "targetPort", to: :target_port
46
55
  map "connectorType", to: :connector_type
47
56
  map "direction", to: :direction
48
57
  map "waypoints", to: :waypoints, render_empty: true
@@ -12,7 +12,11 @@ module Ea
12
12
  attribute :type_name, :string # name shown when type is external/primitive
13
13
  attribute :qualified_name, :string
14
14
  attribute :multiplicity_lower, :integer
15
- attribute :multiplicity_upper, :integer # -1 means "many"
15
+ attribute :multiplicity_upper, :integer
16
+ # Verbatim stored bound text (e.g. EA's literal "n") when the
17
+ # column value is non-numeric; rendered as-is by the SVG line
18
+ # builder while computed-unbounded uppers render as "*".
19
+ attribute :multiplicity_upper_raw, :string # -1 means "many"
16
20
  attribute :default_value, :string
17
21
  attribute :is_derived, :boolean, default: false
18
22
  attribute :is_readonly, :boolean, default: false
@@ -33,6 +37,7 @@ module Ea
33
37
  map "typeName", to: :type_name
34
38
  map "qualifiedName", to: :qualified_name
35
39
  map "multiplicityLower", to: :multiplicity_lower
40
+ map "multiplicityUpperRaw", to: :multiplicity_upper_raw
36
41
  map "multiplicityUpper", to: :multiplicity_upper
37
42
  map "defaultValue", to: :default_value
38
43
  map "isDerived", to: :is_derived, render_default: true
@@ -155,8 +155,10 @@ module Ea
155
155
 
156
156
  def build_assoc_property(conn, role, owner_object)
157
157
  target = database.find_object(conn.end_object_id) if conn.end_object_id&.to_i&.positive?
158
+ guid = IdNormalizer.from_guid(conn.ea_guid)
158
159
  Ea::Model::Property.new(
159
- id: IdNormalizer.from_guid(conn.ea_guid),
160
+ id: guid,
161
+ association_id: guid,
160
162
  name: role,
161
163
  owner_id: IdNormalizer.from_guid(owner_object.ea_guid),
162
164
  type_name: target&.name,
@@ -92,9 +92,11 @@ module Ea
92
92
  model_element_ref: ref_for_object(obj_row),
93
93
  bounds: bounds_from_rect(obj_row),
94
94
  style: style_hash,
95
- font_family: style_hash[:font],
95
+ font_family: presence(style_hash[:font]),
96
+ font_size: font_size_from(style_hash),
96
97
  font_bold: truthy?(style_hash[:bold]),
97
98
  font_italic: truthy?(style_hash[:italic]),
99
+ font_underline: truthy?(style_hash[:ul]),
98
100
  show_tagged_values: truthy?(style_hash[:tag])
99
101
  )
100
102
  end
@@ -103,6 +105,28 @@ module Ea
103
105
  raw == "1" || raw == "-1"
104
106
  end
105
107
 
108
+ def presence(value)
109
+ value.nil? || value.to_s.empty? ? nil : value
110
+ end
111
+
112
+ # EA stores the element font size in tenths of a point in the
113
+ # objectstyle string. 0 means "use default", and 80/90 (8pt /
114
+ # 9pt) are EA's classic defaults the dialogs write back when
115
+ # the user never customized — verified against EA-published
116
+ # reference images, where such elements render at the theme
117
+ # size. Only genuinely custom sizes are surfaced as explicit
118
+ # element fonts.
119
+ USE_DEFAULT_FONTSZ = [0, 80, 90].freeze
120
+
121
+ def font_size_from(style_hash)
122
+ raw = style_hash[:fontsz]
123
+ return nil if raw.nil? || raw.to_s.empty?
124
+
125
+ return nil if USE_DEFAULT_FONTSZ.include?(raw.to_i)
126
+
127
+ (raw.to_f / 10).round
128
+ end
129
+
106
130
  def build_connectors(diagram_row)
107
131
  links = database.diagram_links_for(diagram_row.diagram_id) || []
108
132
  explicit = links.map { |link_row| build_connector(link_row, diagram_row) }
@@ -216,6 +240,8 @@ module Ea
216
240
  connector_type: connector&.connector_type,
217
241
  direction: connector&.direction,
218
242
  waypoints: waypoints_for_link(link_row, diagram_row),
243
+ source_port: connection_port(diagram_row, connector, :source, geom),
244
+ target_port: connection_port(diagram_row, connector, :target, geom),
219
245
  label_boxes: geom[:label_boxes] || {},
220
246
  style: DiagramStyleParser.parse(link_row.style),
221
247
  hidden: hidden?(link_row),
@@ -340,12 +366,22 @@ module Ea
340
366
  geom = parse_geometry_fields(link_row.geometry)
341
367
  edge_out = geom[:edge] || 0
342
368
 
343
- source_point = element_edge_point(source_placement, :source, edge_out)
344
- target_point = element_edge_point(target_placement, :target, edge_out)
369
+ source_port = connection_port(diagram_row, connector, :source, geom)
370
+ target_port = connection_port(diagram_row, connector, :target, geom)
371
+ # Waypoint points flow through this method as [x, y] arrays;
372
+ # keep the ports in the same shape.
373
+ source_point = source_port ? [source_port.x, source_port.y] :
374
+ element_edge_point(source_placement, :source, edge_out)
375
+ target_point = target_port ? [target_port.x, target_port.y] :
376
+ element_edge_point(target_placement, :target, edge_out)
345
377
 
346
378
  intermediate = intermediate_waypoints(link_row)
347
379
  if intermediate.any?
348
380
  points = [source_point, *intermediate, target_point]
381
+ elsif source_port || target_port
382
+ # Exact ports are known — the SX/SY bend heuristics in
383
+ # sx_sy_ex_ey_waypoints would double-apply the offsets.
384
+ points = [source_point, target_point]
349
385
  else
350
386
  points = sx_sy_ex_ey_waypoints(source_point, target_point, geom)
351
387
  end
@@ -355,13 +391,53 @@ module Ea
355
391
  end
356
392
  end
357
393
 
394
+ # Absolute connection point EA docks the connector's visible
395
+ # endpoint at, derived from the Geometry SX/SY (source) and
396
+ # EX/EY (target) offsets. The offsets are measured inward from
397
+ # the placed object's box edges — the X offset counts from the
398
+ # edge FACING the other end (source: right edge for positive
399
+ # values, left edge for negative; target mirrored), while the
400
+ # Y offset counts from the top edge for positive values and
401
+ # from the bottom edge for negative. Derived and verified
402
+ # against EA's own rendering on the PLATEAU models.
403
+ def connection_port(diagram_row, connector, end_kind, geom)
404
+ return nil unless connector
405
+
406
+ ea_object_id = end_kind == :source ? connector.start_object_id : connector.end_object_id
407
+ placement = ea_object_id &&
408
+ diagram_object_placement(diagram_row.diagram_id, ea_object_id)
409
+ return nil unless placement
410
+
411
+ x_off = geom[end_kind == :source ? :sx : :ex]
412
+ y_off = geom[end_kind == :source ? :sy : :ey]
413
+ return nil if x_off.nil? || y_off.nil?
414
+
415
+ # EA's SX/SY and EX/EY are the offset of the docking line's
416
+ # direction from the element's CENTER (stored math coords,
417
+ # y up): the port is where the ray from the center through
418
+ # center+offset crosses the box border. Verified against
419
+ # EA-published reference SVGs. Bounds are already mirrored
420
+ # to screen-down space, so the offset's Y negates too.
421
+ b = bounds_from_rect(placement)
422
+ center_x = b.x + b.width / 2.0
423
+ center_y = b.y + b.height / 2.0
424
+ dx = x_off.to_f
425
+ dy = -y_off.to_f
426
+ return nil if dx.zero? && dy.zero?
427
+
428
+ tx = dx.zero? ? Float::INFINITY : (dx > 0 ? b.x + b.width - center_x : b.x - center_x) / dx
429
+ ty = dy.zero? ? Float::INFINITY : (dy > 0 ? b.y + b.height - center_y : b.y - center_y) / dy
430
+ t = [tx, ty].min
431
+ Ea::Model::Point.new(x: (center_x + t * dx).round, y: (center_y + t * dy).round)
432
+ end
433
+
358
434
  # Parse t_diagramlinks.Path into [[x, y], ...] intermediate
359
435
  # waypoints. Format: `x1:y1;x2:y2;...`. Empty string returns [].
360
436
  def intermediate_waypoints(link_row)
361
437
  raw = link_row.path.to_s
362
438
  return [] if raw.empty?
363
439
 
364
- raw.scan(/(-?\d+):(-?\d+)/).map { |x, y| [x.to_i, y.to_i] }
440
+ raw.scan(/(-?\d+):(-?\d+)/).map { |x, y| [x.to_i, -y.to_i] }
365
441
  end
366
442
 
367
443
  # Fallback when no explicit Path: compute source/target bend
@@ -369,10 +445,10 @@ module Ea
369
445
  def sx_sy_ex_ey_waypoints(source_point, target_point, geom)
370
446
  points = [source_point]
371
447
  if geom[:sx] && geom[:sy] && (geom[:sx].nonzero? || geom[:sy].nonzero?)
372
- points << [source_point[0] + geom[:sx], source_point[1] + geom[:sy]]
448
+ points << [source_point[0] + geom[:sx], source_point[1] - geom[:sy]]
373
449
  end
374
450
  if geom[:ex] && geom[:ey] && (geom[:ex].nonzero? || geom[:ey].nonzero?)
375
- points << [target_point[0] - geom[:ex], target_point[1] - geom[:ey]]
451
+ points << [target_point[0] - geom[:ex], target_point[1] + geom[:ey]]
376
452
  end
377
453
  points << target_point
378
454
  points
@@ -427,6 +503,10 @@ module Ea
427
503
  end
428
504
  public :parse_label_boxes
429
505
 
506
+ # Pure helpers exposed for specs and consumers — no database
507
+ # access, safe to call in isolation.
508
+ public :font_size_from, :connection_port
509
+
430
510
  # Parse one label box's colon-separated key=value body into
431
511
  # a Hash with string keys. Returns nil if OX/OY are absent.
432
512
  def parse_label_style(body)
@@ -509,8 +589,10 @@ module Ea
509
589
  # EA's t_diagramobjects stores rect with the origin at the
510
590
  # bottom-left (math convention) — recttop > rectbottom for
511
591
  # valid bounds, and y values are often negative. Convert to
512
- # screen-space (origin top-left) by taking min/max and using
513
- # absolute width/height.
592
+ # screen-space (origin top-left, y increasing downward) by
593
+ # mirroring: EA's own SVG export draws the model mirrored, so
594
+ # the flipped top edge is -(max(top, bottom)); verified
595
+ # against EA-published reference images (dx=0, dy=-mirror).
514
596
  def bounds_from_rect(obj_row)
515
597
  left = obj_row.rectleft || 0
516
598
  right = obj_row.rectright || 0
@@ -518,7 +600,7 @@ module Ea
518
600
  bottom = obj_row.rectbottom || 0
519
601
  Ea::Model::Bounds.new(
520
602
  x: [left, right].min,
521
- y: [top, bottom].min,
603
+ y: -[top, bottom].max,
522
604
  width: (right - left).abs,
523
605
  height: (bottom - top).abs
524
606
  )
@@ -27,6 +27,7 @@ module Ea
27
27
  type_name: row.type,
28
28
  qualified_name: "#{owner_object.name}::#{row.name}",
29
29
  multiplicity_lower: parse_lower(row),
30
+ multiplicity_upper_raw: raw_upper(row),
30
31
  multiplicity_upper: parse_upper(row),
31
32
  default_value: row.default,
32
33
  is_ordered: boolean(row.isordered),
@@ -42,6 +43,14 @@ module Ea
42
43
 
43
44
  private
44
45
 
46
+ # EA stores some bounds as literal text ("n"); those render
47
+ # verbatim in attribute lines while computed-unbounded bounds
48
+ # render as "*" (verified against EA-published SVGs).
49
+ def raw_upper(row)
50
+ raw = row.upperbound.to_s
51
+ raw.match?(/\A[nN]\z/) ? raw.downcase : nil
52
+ end
53
+
45
54
  def parse_lower(row)
46
55
  Integer(row.lowerbound || 1)
47
56
  rescue StandardError
@@ -49,10 +58,11 @@ module Ea
49
58
  end
50
59
 
51
60
  def parse_upper(row)
52
- return -1 if row.upperbound.to_s == "*"
61
+ raw = row.upperbound.to_s
62
+ return -1 if raw.match?(/\A[*nN]\z/)
53
63
 
54
64
  begin
55
- Integer(row.upperbound || 1)
65
+ Integer(raw.empty? ? 1 : raw)
56
66
  rescue StandardError
57
67
  1
58
68
  end
@@ -10,6 +10,7 @@ module Ea
10
10
 
11
11
  def render(context)
12
12
  return nil if context.attr_lines.empty?
13
+ return nil unless context.geometry.attr_first_y
13
14
 
14
15
  Element::AttributeRenderer.render(
15
16
  context.attr_lines,
@@ -14,6 +14,7 @@ module Ea
14
14
 
15
15
  def render(context)
16
16
  return nil unless context.constraints&.any?
17
+ return nil unless context.geometry.tagged_value_first_y
17
18
 
18
19
  Element::ConstraintRenderer.render(
19
20
  context.constraints,
@@ -10,45 +10,29 @@ module Ea
10
10
  module EnumLiterals
11
11
  module_function
12
12
 
13
+ # EA: literal rows sit directly under the header divider,
14
+ # 13px apart at 7pt (attr rows use 14), first row at
15
+ # divider + 14. No compartment divider, no header label.
16
+ LITERAL_LINE_OFFSET = 6
17
+
13
18
  def render(context)
14
19
  return nil unless context.enum_literals.any?
15
20
  return nil unless context.geometry.enum_literal_first_y
16
21
 
17
- [
18
- Element::DividerRenderer.render(
19
- context.bounds,
20
- y: context.geometry.enum_divider_y,
21
- stroke: context.stroke,
22
- stroke_width: context.stroke_width
23
- ),
24
- render_literals_block(context)
25
- ].join("\n")
22
+ render_literals_block(context)
26
23
  end
27
24
 
28
25
  def render_literals_block(context)
29
- line_h = context.size + (context.theme.compartments.attr_line_offset || 4)
30
- text_blocks = [literal_header_text(context)]
26
+ line_h = context.size + LITERAL_LINE_OFFSET
27
+ text_blocks = []
31
28
  context.enum_literals.each_with_index do |literal, idx|
32
- y = context.geometry.enum_literal_first_y + ((idx + 1) * line_h)
29
+ y = context.geometry.enum_literal_first_y + (idx * line_h)
33
30
  text_blocks << visibility_placeholder(context, y)
34
31
  text_blocks << literal_name_text(context, literal, y)
35
32
  end
36
33
  wrap_group(text_blocks, context.theme.text_color)
37
34
  end
38
35
 
39
- def literal_header_text(context)
40
- TextRenderer.new(
41
- content: "literals",
42
- x: context.bounds.x + (context.bounds.width / 2.0) -
43
- (8 * context.size * context.theme.text_width_factor) / 2,
44
- y: context.geometry.enum_literal_first_y,
45
- family: context.family, size: context.size, size_unit: context.size_unit,
46
- style: "italic", fill: context.theme.text_color,
47
- stroke_in_text: context.theme.stroke_in_text_color,
48
- width_factor: context.theme.text_width_factor
49
- ).to_svg
50
- end
51
-
52
36
  def visibility_placeholder(context, y)
53
37
  TextRenderer.new(
54
38
  content: " ",
@@ -11,6 +11,7 @@ module Ea
11
11
 
12
12
  def render(context)
13
13
  return nil if context.header_lines.empty?
14
+ return nil unless context.geometry.header_first_y
14
15
 
15
16
  Element::HeaderRenderer.render(
16
17
  context.header_lines,
@@ -103,6 +103,11 @@ module Ea
103
103
  end
104
104
 
105
105
  def bound_text(value)
106
+ # EA renders a stored literal bound ("n") verbatim, and a
107
+ # computed-unbounded upper as "*" (verified against
108
+ # EA-published reference SVGs).
109
+ return property.multiplicity_upper_raw if value == -1 && property.multiplicity_upper_raw
110
+
106
111
  value == -1 ? "*" : value.to_s
107
112
  end
108
113
  end
@@ -9,15 +9,19 @@ module Ea
9
9
  # content) matching EA's encoding.
10
10
  class AttributeRenderer
11
11
  DEFAULT_VISIBILITY_X_OFFSET = 5
12
- DEFAULT_CONTENT_X_OFFSET = 26
12
+ DEFAULT_CONTENT_X_OFFSET = 22
13
13
  DEFAULT_FONT_UNIT = "pt"
14
+ # EA seats compartment stereotype headers («Property») 12px
15
+ # from the box edge and spaces rows 13px at 7pt.
16
+ STEREOTYPE_HEADER_X_OFFSET = 12
17
+ ROW_LINE_OFFSET = 6
14
18
 
15
19
  def self.render(lines, bounds:, first_y:, family:,
16
20
  size:, size_unit: DEFAULT_FONT_UNIT,
17
21
  fill: "#000000",
18
22
  visibility_x_offset: DEFAULT_VISIBILITY_X_OFFSET,
19
23
  content_x_offset: DEFAULT_CONTENT_X_OFFSET)
20
- line_h = size + 4
24
+ line_h = size + ROW_LINE_OFFSET
21
25
  text_blocks = []
22
26
  lines.each_with_index do |line, idx|
23
27
  y = first_y + (idx * line_h)
@@ -25,6 +29,8 @@ module Ea
25
29
  if visibility
26
30
  text_blocks << build_text(bounds.x + visibility_x_offset, y, visibility, family, size, size_unit, fill)
27
31
  text_blocks << build_text(bounds.x + content_x_offset, y, rest, family, size, size_unit, fill)
32
+ elsif line.strip.start_with?("«")
33
+ text_blocks << build_text(bounds.x + STEREOTYPE_HEADER_X_OFFSET, y, line.strip, family, size, size_unit, fill)
28
34
  else
29
35
  text_blocks << build_text(bounds.x + visibility_x_offset, y, line.strip, family, size, size_unit, fill)
30
36
  end
@@ -44,19 +50,44 @@ module Ea
44
50
  #
45
51
  # Pass a `lookup` (any callable returning a Classifier for
46
52
  # an id) to enable inherited-property namespace prefixing.
47
- def self.lines_for(classifier, lookup: nil)
48
- props = displayable_properties(classifier)
53
+ def self.lines_for(classifier, lookup: nil,
54
+ exclude_association_ids: nil)
55
+ props = displayable_properties(classifier, exclude_association_ids)
49
56
  return [] unless props
50
57
 
51
- props.flat_map do |prop|
52
- lines = []
53
- stereotype = property_stereotype(prop)
54
- lines << "«#{stereotype}»" if stereotype
55
- lines << AttributeLineBuilder.new(prop, host: classifier,
58
+ stereotypes = props.map { |p| property_stereotype(p) }.compact.uniq
59
+ if stereotypes.size == 1 && props.all? { |p| property_stereotype(p) }
60
+ # EA renders ONE compartment stereotype header for a
61
+ # uniformly-stereotyped attribute set (e.g. «Property»
62
+ # above GML property rows) and lists rows
63
+ # alphabetically - verified against EA-published SVGs.
64
+ header = "«#{canonical_stereotype(stereotypes.first)}»"
65
+ [header] + props.sort_by { |p| p.name.to_s }.map do |prop|
66
+ AttributeLineBuilder.new(prop, host: classifier,
67
+ lookup: lookup).to_s
68
+ end
69
+ else
70
+ props.flat_map do |prop|
71
+ lines = []
72
+ stereotype = property_stereotype(prop)
73
+ lines << "«#{stereotype}»" if stereotype
74
+ lines << AttributeLineBuilder.new(prop, host: classifier,
56
75
  lookup: lookup).to_s
76
+ end
57
77
  end
58
78
  end
59
79
 
80
+ STEREOTYPE_DISPLAY = {
81
+ "property" => "Property",
82
+ "featuretype" => "FeatureType",
83
+ "objecttype" => "ObjectType",
84
+ "codelist" => "CodeList"
85
+ }.freeze
86
+
87
+ def self.canonical_stereotype(ref)
88
+ STEREOTYPE_DISPLAY.fetch(ref.to_s.downcase, ref.to_s)
89
+ end
90
+
60
91
  # Internal helpers
61
92
 
62
93
  def self.split_visibility(line)
@@ -75,10 +106,19 @@ module Ea
75
106
  end
76
107
  private_class_method :build_text
77
108
 
78
- def self.displayable_properties(classifier)
109
+ # Association-end properties render in the box only when
110
+ # their association is NOT drawn on the diagram (drawn ones
111
+ # render as connector role labels). exclude_association_ids
112
+ # carries the diagram's drawn association ids; nil preserves
113
+ # the legacy hide-all behavior.
114
+ def self.displayable_properties(classifier, exclude_association_ids)
79
115
  return nil unless classifier.properties
80
116
 
81
- classifier.properties.reject(&:association_id)
117
+ classifier.properties.reject do |prop|
118
+ prop.association_id &&
119
+ (exclude_association_ids.nil? ||
120
+ exclude_association_ids.include?(prop.association_id))
121
+ end
82
122
  end
83
123
  private_class_method :displayable_properties
84
124
 
@@ -27,10 +27,10 @@ module Ea
27
27
  def initialize(bounds:, size:, header_lines_count:,
28
28
  attr_lines_count:, op_lines_count:,
29
29
  tagged_values_count:,
30
- header_top_padding: 9,
30
+ header_top_padding: 12,
31
31
  header_line_offset: 6,
32
32
  divider_offset: 8,
33
- attr_line_offset: 4,
33
+ attr_line_offset: 6,
34
34
  attr_first_offset: 7)
35
35
  @bounds = bounds
36
36
  @size = size
@@ -46,7 +46,7 @@ module Ea
46
46
  end
47
47
 
48
48
  def header_first_y
49
- bounds.y + size + (header_top_padding || 9)
49
+ bounds.y + size + (header_top_padding || 12)
50
50
  end
51
51
 
52
52
  # Y of the divider line between header and the rest.
@@ -61,7 +61,7 @@ module Ea
61
61
  end
62
62
 
63
63
  def attr_first_y
64
- return bounds.y + size + (header_top_padding || 9) + 12 unless divider_y
64
+ return bounds.y + size + (header_top_padding || 12) + 12 unless divider_y
65
65
 
66
66
  divider_y + size + (attr_first_offset || 7)
67
67
  end
@@ -69,7 +69,7 @@ module Ea
69
69
  def attr_bottom_y
70
70
  return attr_first_y unless attr_lines_count&.positive?
71
71
 
72
- attr_first_y + (attr_lines_count - 1) * (size + (attr_line_offset || 4))
72
+ attr_first_y + (attr_lines_count - 1) * (size + (attr_line_offset || 6))
73
73
  end
74
74
 
75
75
  def op_divider_y
@@ -88,12 +88,14 @@ module Ea
88
88
  op_first_y + (op_lines_count - 1) * (size + 4)
89
89
  end
90
90
 
91
- def enum_divider_y
92
- op_bottom_y + size + 5
93
- end
94
-
91
+ # EA draws a single divider under the header and seats
92
+ # enumeration literals directly after it (+14 at 7pt),
93
+ # exactly like attribute rows - no second divider and no
94
+ # "literals" header. Verified against EA-published SVGs.
95
95
  def enum_literal_first_y
96
- enum_divider_y + size + 5
96
+ return nil unless divider_y
97
+
98
+ divider_y + size + (attr_first_offset || 7)
97
99
  end
98
100
 
99
101
  # Tagged values appear after attributes (or after ops if
@@ -51,22 +51,13 @@ module Ea
51
51
  (bounds_width - QUALIFIED_WRAP_PADDING)
52
52
  end
53
53
 
54
- def self.display_name(classifier, diagram_package_id = nil,
54
+ # EA renders the plain classifier name in element headers
55
+ # (element ownership qualification is a display option the
56
+ # corpus diagrams leave off - verified against EA-published
57
+ # reference SVGs).
58
+ def self.display_name(classifier, _diagram_package_id = nil,
55
59
  _visually_nested = false)
56
- name = if classifier.is_a?(Ea::Model::Classifier)
57
- (classifier.qualified_name || classifier.name).to_s
58
- else
59
- classifier.name.to_s
60
- end
61
- return name if diagram_package_id.nil?
62
- return name unless name.include?("::")
63
-
64
- if classifier.is_a?(Ea::Model::Classifier) &&
65
- classifier.package_id == diagram_package_id
66
- name.split("::").last
67
- else
68
- name
69
- end
60
+ classifier.name.to_s
70
61
  end
71
62
  end
72
63
  end