ea 0.6.140 → 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.
Files changed (71) hide show
  1. checksums.yaml +4 -4
  2. data/config/themes/default.yml +7 -0
  3. data/lib/ea/cli/app.rb +10 -0
  4. data/lib/ea/cli/command/diagrams.rb +4 -1
  5. data/lib/ea/cli/command/render.rb +8 -1
  6. data/lib/ea/cli/command/spa.rb +2 -1
  7. data/lib/ea/cli/command/svg.rb +9 -6
  8. data/lib/ea/fonts/metrics.rb +44 -16
  9. data/lib/ea/model/diagram.rb +6 -0
  10. data/lib/ea/qea/services/database_loader.rb +4 -1
  11. data/lib/ea/sources/qea/diagram_builder/routing.rb +64 -22
  12. data/lib/ea/sources/qea/diagram_builder.rb +4 -1
  13. data/lib/ea/sources/xmi/adapter.rb +20 -0
  14. data/lib/ea/sources/xmi/connector_index.rb +30 -0
  15. data/lib/ea/sources/xmi/constraint_builder.rb +81 -0
  16. data/lib/ea/sources/xmi/diagram_builder.rb +22 -11
  17. data/lib/ea/sources/xmi/extension_style_parser.rb +1 -1
  18. data/lib/ea/sources/xmi/extension_types.rb +9 -2
  19. data/lib/ea/sources/xmi/relationship_builder.rb +17 -11
  20. data/lib/ea/sources/xmi/stored_route/reversed_routes.rb +10 -26
  21. data/lib/ea/sources/xmi.rb +2 -0
  22. data/lib/ea/spa/generator.rb +4 -2
  23. data/lib/ea/spa/projector.rb +4 -2
  24. data/lib/ea/svg/ea_emitter/bottom_margin.rb +16 -12
  25. data/lib/ea/svg/ea_emitter/bounds_calculator.rb +22 -14
  26. data/lib/ea/svg/ea_emitter/canvas.rb +43 -5
  27. data/lib/ea/svg/ea_emitter/canvas_anchor.rb +12 -4
  28. data/lib/ea/svg/ea_emitter/compartment/attributes.rb +5 -0
  29. data/lib/ea/svg/ea_emitter/compartment/constraints.rb +5 -0
  30. data/lib/ea/svg/ea_emitter/compartment/enum_literals.rb +5 -0
  31. data/lib/ea/svg/ea_emitter/compartment/header.rb +8 -1
  32. data/lib/ea/svg/ea_emitter/compartment/header_divider.rb +6 -1
  33. data/lib/ea/svg/ea_emitter/compartment/instance_slots.rb +5 -0
  34. data/lib/ea/svg/ea_emitter/compartment/instance_underline.rb +6 -1
  35. data/lib/ea/svg/ea_emitter/compartment/marker.rb +5 -0
  36. data/lib/ea/svg/ea_emitter/compartment/note_body.rb +7 -2
  37. data/lib/ea/svg/ea_emitter/compartment/note_text.rb +5 -0
  38. data/lib/ea/svg/ea_emitter/compartment/operations.rb +5 -0
  39. data/lib/ea/svg/ea_emitter/compartment/package_contents.rb +5 -0
  40. data/lib/ea/svg/ea_emitter/compartment/package_from_parent.rb +12 -1
  41. data/lib/ea/svg/ea_emitter/compartment/shape.rb +14 -1
  42. data/lib/ea/svg/ea_emitter/compartment/stereotype_icon.rb +6 -0
  43. data/lib/ea/svg/ea_emitter/compartment/tagged_values.rb +5 -0
  44. data/lib/ea/svg/ea_emitter/compartment.rb +13 -0
  45. data/lib/ea/svg/ea_emitter/connector_bridges.rb +18 -4
  46. data/lib/ea/svg/ea_emitter/connector_redock.rb +13 -6
  47. data/lib/ea/svg/ea_emitter/content_extent.rb +11 -2
  48. data/lib/ea/svg/ea_emitter/diagram_frame.rb +7 -0
  49. data/lib/ea/svg/ea_emitter/document.rb +22 -6
  50. data/lib/ea/svg/ea_emitter/element/attribute_renderer.rb +6 -1
  51. data/lib/ea/svg/ea_emitter/element/compartment_geometry.rb +9 -0
  52. data/lib/ea/svg/ea_emitter/element/header_renderer.rb +7 -0
  53. data/lib/ea/svg/ea_emitter/element/legend_renderer.rb +18 -14
  54. data/lib/ea/svg/ea_emitter/element/note_shape_renderer.rb +1 -8
  55. data/lib/ea/svg/ea_emitter/elements.rb +36 -12
  56. data/lib/ea/svg/ea_emitter/font_resolver.rb +3 -1
  57. data/lib/ea/svg/ea_emitter/label/end_label.rb +113 -19
  58. data/lib/ea/svg/ea_emitter/label/midpoint_label.rb +66 -0
  59. data/lib/ea/svg/ea_emitter/label/plain_source_seat.rb +25 -2
  60. data/lib/ea/svg/ea_emitter/label/plain_target_seat.rb +64 -1
  61. data/lib/ea/svg/ea_emitter/labels.rb +64 -35
  62. data/lib/ea/svg/ea_emitter/layer_sequencer.rb +14 -7
  63. data/lib/ea/svg/ea_emitter/markers.rb +1 -1
  64. data/lib/ea/svg/ea_emitter/paint_order.rb +5 -4
  65. data/lib/ea/svg/ea_emitter/pen.rb +19 -0
  66. data/lib/ea/svg/ea_emitter/render_context.rb +1 -1
  67. data/lib/ea/svg/ea_emitter/text_renderer.rb +24 -8
  68. data/lib/ea/svg/ea_emitter.rb +1 -0
  69. data/lib/ea/svg/parity/source.rb +1 -1
  70. data/lib/ea/version.rb +1 -1
  71. metadata +5 -2
@@ -197,9 +197,11 @@ module Ea
197
197
 
198
198
  def build_connectors(placed_connectors, placed_elements, owner_id, context)
199
199
  # Index elements by DUID so we can resolve SOID/EOID.
200
- by_duid = placed_elements.each_with_object({}) do |pe, acc|
201
- acc[pe.style.duid] = pe if pe.style.duid
202
- end
200
+ # A DUID shared by several placements names none of them (QEA
201
+ # placement_order_reversed? needs a unique match too).
202
+ by_duid = placed_elements.select { |pe| pe.style.duid }.group_by { |pe| pe.style.duid }
203
+ .select { |_duid, placements| placements.one? }
204
+ .transform_values(&:first)
203
205
  by_subject = placed_elements.group_by { |pe| guid_key(pe.subject) }
204
206
 
205
207
  placed_connectors.each_with_index.map do |placed, index|
@@ -368,14 +370,28 @@ module Ea
368
370
  # The saved SOID/EOID can name the model connector's ends in reverse
369
371
  # order; the model connector decides which placement is the source.
370
372
  def placed_ends(style, by_duid, by_subject, edge_source, edge_target)
371
- source = by_duid[style.soid] || sole_placement(by_subject, edge_source)
372
- target = by_duid[style.eoid] || sole_placement(by_subject, edge_target)
373
+ named = [by_duid[style.soid], by_duid[style.eoid]]
374
+ source = named[0] || sole_placement(by_subject, edge_source)
375
+ target = named[1] || sole_placement(by_subject, edge_target)
376
+ if source.equal?(target) && named.one?
377
+ source, target = subject_placements(by_subject, named, edge_source, edge_target)
378
+ end
373
379
  return [source, target] unless source && target &&
374
380
  reversed_ends?(source, target, edge_source, edge_target)
375
381
 
376
382
  [target, source]
377
383
  end
378
384
 
385
+ # One stale DUID falls back to the relationship's own end, which can
386
+ # be the very placement the other DUID named; two distinct ends never
387
+ # share a box, so each end then takes its own subject's placement. A
388
+ # link whose SOID and EOID are one DUID is a genuine self link.
389
+ def subject_placements(by_subject, named, edge_source, edge_target)
390
+ [edge_source, edge_target].zip(named).map do |subject, placement|
391
+ sole_placement(by_subject, subject) || (placement if guid_key(placement&.subject) == guid_key(subject))
392
+ end
393
+ end
394
+
379
395
  def reversed_ends?(source, target, edge_source, edge_target)
380
396
  guid_key(edge_source) != guid_key(edge_target) &&
381
397
  guid_key(source.subject) == guid_key(edge_target) &&
@@ -399,12 +415,7 @@ module Ea
399
415
  end
400
416
 
401
417
  def connector_index
402
- @connector_index ||= begin
403
- conns = root.extension&.connectors&.connector || []
404
- conns.each_with_object({}) do |c, acc|
405
- acc[c.idref] = c if c.idref
406
- end
407
- end
418
+ @connector_index ||= ConnectorIndex.build(root)
408
419
  end
409
420
 
410
421
  def umldi_extractor
@@ -51,7 +51,7 @@ module Ea
51
51
  next nil unless pair.include?("=")
52
52
 
53
53
  key, value = pair.split("=", 2)
54
- [key, value] if key && value && !value.empty?
54
+ [key.strip, value] if key && value && !value.empty?
55
55
  end.to_h
56
56
  end
57
57
 
@@ -26,6 +26,7 @@ module Ea
26
26
 
27
27
  def initialize(document)
28
28
  @attributes = index(document, "attribute", "properties")
29
+ @attribute_stereotypes = index(document, "attribute", "stereotype", "stereotype")
29
30
  @returns = index(document, "operation", "type")
30
31
  @parameters = index(document, "parameter", "properties")
31
32
  end
@@ -34,6 +35,12 @@ module Ea
34
35
  @attributes[id]
35
36
  end
36
37
 
38
+ # Stereotype EA stores on the attribute row
39
+ # (<attribute><stereotype stereotype="voidable"/>).
40
+ def attribute_stereotype(id)
41
+ @attribute_stereotypes[id]
42
+ end
43
+
37
44
  def return_type(operation_id)
38
45
  @returns[operation_id]
39
46
  end
@@ -44,11 +51,11 @@ module Ea
44
51
 
45
52
  private
46
53
 
47
- def index(document, owner, child)
54
+ def index(document, owner, child, attribute = "type")
48
55
  return {} unless document
49
56
 
50
57
  document.xpath(%(//*[local-name()="#{owner}"][@*[local-name()="idref"]])).each_with_object({}) do |node, acc|
51
- type = node.at_xpath(%(*[local-name()="#{child}"]/@type))&.value
58
+ type = node.at_xpath(%(*[local-name()="#{child}"]/@#{attribute}))&.value
52
59
  idref = node.at_xpath("@*[local-name()='idref']").value
53
60
  acc[idref] = type unless type.nil? || type.empty?
54
61
  end
@@ -51,12 +51,21 @@ module Ea
51
51
  end
52
52
  end
53
53
 
54
+ # Extension connector type => handler; a nil-returning handler skips
55
+ # the connector (Dependency counts only when stereotyped import).
56
+ EXTENSION_IMPORTS = {
57
+ "Package" => :package_import_relationship,
58
+ "Nesting" => :package_import_relationship,
59
+ "Dependency" => :stereotyped_dependency_import
60
+ }.freeze
61
+
54
62
  def extension_import_relationship(id, connector)
55
- case connector.properties&.ea_type
56
- when "Package" then package_import_relationship(id, connector)
57
- when "Dependency"
58
- dependency_import_relationship(id, connector) if connector_stereotype(connector) == "import"
59
- end
63
+ handler = EXTENSION_IMPORTS[connector.properties&.ea_type]
64
+ handler && method(handler).call(id, connector)
65
+ end
66
+
67
+ def stereotyped_dependency_import(id, connector)
68
+ dependency_import_relationship(id, connector) if connector_stereotype(connector) == "import"
60
69
  end
61
70
 
62
71
  # EA exports elementImport outside packagedElement, so the model walk
@@ -73,6 +82,8 @@ module Ea
73
82
  connector&.xrefs&.value.to_s[CONNECTOR_STEREOTYPE]
74
83
  end
75
84
 
85
+ # Nesting connectors, like package imports, exist only in the
86
+ # extension; QEA models both as an Association between the ends.
76
87
  def package_import_relationship(id, connector)
77
88
  Ea::Model::Association.new(
78
89
  id: id,
@@ -245,12 +256,7 @@ module Ea
245
256
  end
246
257
 
247
258
  def connector_index
248
- @connector_index ||= begin
249
- connectors = root.extension&.connectors&.connector || []
250
- connectors.each_with_object({}) do |connector, index|
251
- index[connector.idref] = connector if connector.idref
252
- end
253
- end
259
+ @connector_index ||= ConnectorIndex.build(root)
254
260
  end
255
261
 
256
262
  def navigable?(id, endpoint)
@@ -18,21 +18,17 @@ module Ea
18
18
  return {} unless reversed_tree_candidate?(model, link, bounds, offsets)
19
19
 
20
20
  start, finish = bounds.values_at(:source, :target)
21
- bus, start_edge, finish_edge = reversed_tree_levels(start, finish)
22
- return {} unless bus
21
+ route = reversed_tree_points(start, finish, ray_geometry(offsets, edge), stored_bus_levels(link))
22
+ return {} unless route
23
23
 
24
- geom = ray_geometry(offsets, edge)
25
- first = reversed_tree_leg(start, geom[:sx], geom[:sy], start_edge, finish)
26
- last = reversed_tree_leg(finish, geom[:ex], geom[:ey], finish_edge, start)
27
-
28
- reversed_tree_fields(model, first, last, bus, first[1] != start_edge)
24
+ reversed_tree_fields(model, route[0], route[1])
29
25
  end
30
26
 
31
- def reversed_tree_fields(model, first, last, bus, beside)
27
+ def reversed_tree_fields(model, points, beside)
32
28
  style = model.style.to_h.except(:umldi_route, :reversed_ends)
33
29
  style[:left_seated_source] = true if beside
34
- ends = [point_for(first), point_for(last)]
35
- { waypoints: points_to_waypoints([first, [first[0], bus], [last[0], bus], last]),
30
+ ends = [point_for(points.first), point_for(points.last)]
31
+ { waypoints: points_to_waypoints(points),
36
32
  source_anchor: ends[0], target_anchor: ends[1], source_port: ends[0], target_port: ends[1],
37
33
  style: style }
38
34
  end
@@ -49,26 +45,14 @@ module Ea
49
45
  def reversed_corner_metadata(model, link, bounds, offsets, edge)
50
46
  return {} unless reversed_corner_candidate?(model, link, bounds, offsets, edge)
51
47
 
52
- boxes = bounds.values_at(:source, :target)
53
- origins = corner_origins(boxes, ray_geometry(offsets, edge))
54
- bend = Ea::Model::Point.new(**%i[x y].zip(intermediate_waypoints(link).first).to_h)
55
- ends = origins.zip(boxes).map { |origin, box| corner_exit(origin, bend, box) }
56
- ends.all? ? corner_fields(origins, bend, ends) : {}
57
- end
48
+ origins, points = reversed_corner_points(bounds.values_at(:source, :target), ray_geometry(offsets, edge),
49
+ intermediate_waypoints(link).first)
50
+ return {} unless points
58
51
 
59
- def corner_fields(origins, bend, ends)
60
- { waypoints: points_to_waypoints([ends[0], bend, ends[1]].map { |point| [point.x, point.y] }),
52
+ { waypoints: points_to_waypoints(points.map { |point| [point.x, point.y] }),
61
53
  source_anchor: origins[0], target_anchor: origins[1] }
62
54
  end
63
55
 
64
- def corner_origins(boxes, geom)
65
- [stored_ray_origin(boxes[0], geom[:sx], geom[:sy]), stored_ray_origin(boxes[1], geom[:ex], geom[:ey])]
66
- end
67
-
68
- def corner_exit(origin, bend, box)
69
- inside_box?(origin, box) ? diagonal_corner_exit(origin, bend, box) : origin
70
- end
71
-
72
56
  def reversed_corner_candidate?(model, link, bounds, offsets, edge)
73
57
  model.connector_type == "Association" && model.style[:reversed_ends] && edge == 2 &&
74
58
  model.style.to_h.values_at(:mode, :lwidth, :tree) == ["3", "0", nil] &&
@@ -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"
@@ -17,7 +17,8 @@ module Ea
17
17
  attr_reader :document, :mode, :output_path, :shard_url_for, :configuration
18
18
 
19
19
  def initialize(document, output:, mode: nil, shard_url_for: nil,
20
- configuration: nil)
20
+ configuration: nil, frame: false)
21
+ @frame = frame
21
22
  @document = document
22
23
  @output_path = output
23
24
  @mode = (mode || DEFAULT_MODE).to_sym
@@ -32,7 +33,8 @@ module Ea
32
33
  def projector
33
34
  @projector ||= Projector.new(document,
34
35
  shard_url_for: shard_url_for,
35
- configuration: configuration)
36
+ configuration: configuration,
37
+ frame: @frame)
36
38
  end
37
39
 
38
40
  def strategy_class
@@ -15,8 +15,9 @@ module Ea
15
15
  class Projector
16
16
  attr_reader :document, :shard_url_for, :configuration
17
17
 
18
- def initialize(document, shard_url_for: nil, configuration: nil)
18
+ def initialize(document, shard_url_for: nil, configuration: nil, frame: false)
19
19
  @document = document
20
+ @frame = frame
20
21
  @shard_url_for = shard_url_for || default_shard_url
21
22
  @configuration = configuration
22
23
  end
@@ -288,7 +289,8 @@ module Ea
288
289
  payload["svg"] = Ea::Svg::EaEmitter::Document.new(
289
290
  diagram,
290
291
  model_index: document.index_by_id,
291
- document: document
292
+ document: document,
293
+ frame: @frame
292
294
  ).render
293
295
  payload
294
296
  rescue StandardError => e
@@ -12,7 +12,7 @@ module Ea
12
12
  # the 7pt default): 31 for fontsz 100, 30 for 90, 28 for the
13
13
  # default. A plain Note, and a Package that draws no "(from
14
14
  # Parent)" subtitle, keep the bare 10 margin, as does a classifier
15
- # of the diagram's own package. Misses: 2 outliers.
15
+ # or instance of the diagram's own package. Misses: 2 outliers.
16
16
  class BottomMargin
17
17
  BARE = 10
18
18
  LINE_ALLOWANCE = 21
@@ -41,7 +41,7 @@ module Ea
41
41
  model = model_index && model_index[element.model_element_ref]
42
42
  return home_text_margin(element) if home_text?(model, diagram)
43
43
  return BARE if plain_note?(model) || bare_package?(model, diagram) ||
44
- home_classifier?(model, diagram)
44
+ home_element?(model, diagram)
45
45
 
46
46
  LINE_ALLOWANCE + points(element)
47
47
  end
@@ -60,17 +60,21 @@ module Ea
60
60
  model.is_a?(Ea::Model::Note) && model.note_type != "Text"
61
61
  end
62
62
 
63
- # A classifier that lives in the diagram's own package draws no
64
- # "(from Package)" row under its name (nor an owner-class
65
- # qualifier), so EA keeps only the bare margin below it. Checked
66
- # on the 7 PLATEAU diagrams whose lowest class is in the
67
- # diagram's package (all natively 10) against the 84 that are
68
- # foreign (all 28..31).
69
- def self.home_classifier?(model, diagram)
70
- model.is_a?(Ea::Model::Classifier) &&
63
+ # An element of the diagram's own package (a classifier, nested
64
+ # in an owner class or not, or an instance) draws no "(from
65
+ # Package)" row under its name, so EA keeps only the bare margin
66
+ # below it. EMPIRICAL FIT, checked on: the 7 PLATEAU diagrams
67
+ # whose lowest class is in the diagram's package (all natively
68
+ # 10) against the 84 foreign ones (all 28..31); basic.qea
69
+ # 3B8BD087 and D14AA320 (lowest an own-package "Object B"
70
+ # instance) and 372F7F7B, 4FC1D352, 6D0349D5 (lowest a Klass
71
+ # nested in "Class A.x" / "Class B" of the own package), all
72
+ # natively 10; and the foreign basic instances, natively 28.
73
+ def self.home_element?(model, diagram)
74
+ (model.is_a?(Ea::Model::Classifier) ||
75
+ model.is_a?(Ea::Model::InstanceSpecification)) &&
71
76
  !diagram.package_id.to_s.empty? &&
72
- model.package_id == diagram.package_id &&
73
- model.nested_parent_name.to_s.empty?
77
+ model.package_id == diagram.package_id
74
78
  end
75
79
 
76
80
  # A Text element of the diagram's own package keeps 13 + its
@@ -33,6 +33,13 @@ module Ea
33
33
 
34
34
  attr_reader :diagram, :model_index
35
35
 
36
+ # Where the leftmost model x lands: 30 + min_x capped at 35. A
37
+ # negative min_x keeps the 30 of min_x 0 (the corpus has none),
38
+ # so content left of the origin is not drawn off the canvas.
39
+ def self.inset_left_for(min_x)
40
+ [[INSET_LEFT - 5 + min_x, INSET_LEFT - 5].max, INSET_LEFT].min
41
+ end
42
+
36
43
  def initialize(diagram, model_index: nil)
37
44
  @diagram = diagram
38
45
  @model_index = model_index
@@ -53,7 +60,7 @@ module Ea
53
60
  # Left inset scales from 30 up to the 35 cap with the
54
61
  # stored leftmost x: leftmost drawn x = 30 + min_x capped
55
62
  # at 35 (corpus: min_x 0 -> 30, 2 -> 32, 4 -> 34, >=5 -> 35).
56
- @effective_inset_left = [INSET_LEFT - 5 + min_x, INSET_LEFT].min
63
+ @effective_inset_left = self.class.inset_left_for(min_x)
57
64
  [
58
65
  min_x,
59
66
  min_y,
@@ -62,19 +69,7 @@ module Ea
62
69
  ]
63
70
  end
64
71
 
65
- private
66
-
67
- # Element x-extent: logical bounds only (matches EA's canvas
68
- # left/right exactly).
69
- # Element y-extent: union of logical + image_bounds (image
70
- # extends below bounds for shadow / image padding), plus the
71
- # "(from Parent)" subtitle extent: EA draws the subtitle 15px
72
- # below the body and its text descent adds ~4 more, reserving
73
- # 19px of canvas below subtitle-bearing packages (the +19
74
- # height family: 05DF5000 Main, Metadata All, package
75
- # Clients/Suppliers contexts).
76
- SUBTITLE_CANVAS_EXTRA = 19
77
-
72
+ # Canvas points reserved below "(from Parent)" subtitles.
78
73
  def subtitle_points
79
74
  pts = []
80
75
  (diagram.elements || []).each do |e|
@@ -88,6 +83,19 @@ module Ea
88
83
  pts
89
84
  end
90
85
 
86
+ private
87
+
88
+ # Element x-extent: logical bounds only (matches EA's canvas
89
+ # left/right exactly).
90
+ # Element y-extent: union of logical + image_bounds (image
91
+ # extends below bounds for shadow / image padding), plus the
92
+ # "(from Parent)" subtitle extent: EA draws the subtitle 15px
93
+ # below the body and its text descent adds ~4 more, reserving
94
+ # 19px of canvas below subtitle-bearing packages (the +19
95
+ # height family: 05DF5000 Main, Metadata All, package
96
+ # Clients/Suppliers contexts).
97
+ SUBTITLE_CANVAS_EXTRA = 19
98
+
91
99
  def element_points
92
100
  pts = []
93
101
  (diagram.elements || []).each do |e|
@@ -23,6 +23,8 @@ module Ea
23
23
  FRAME_INSET_LEFT = 35
24
24
  INSET_TOUCHING_ORIGIN = 30
25
25
  FRAME_INSET_TOP = 40
26
+ # Where the diagram frame's border line sits from the canvas edge.
27
+ FRAME_BORDER_INSET = 6
26
28
 
27
29
  attr_reader :min_x, :min_y, :width, :height, :inset_left, :inset_top
28
30
 
@@ -32,8 +34,8 @@ module Ea
32
34
  insets: [FRAME_INSET_LEFT, FRAME_INSET_TOP])
33
35
  @min_x = min_x
34
36
  @min_y = min_y
35
- @width = width
36
- @height = height
37
+ @width = whole(width)
38
+ @height = whole(height)
37
39
  @inset_left, @inset_top = insets
38
40
  end
39
41
 
@@ -60,11 +62,23 @@ module Ea
60
62
  bottom: BottomMargin.for(diagram, model_index))
61
63
  end
62
64
 
65
+ return empty_framed(diagram, min_x, min_y) unless bc.max_x
66
+
63
67
  new(min_x: min_x, min_y: min_y, width: width, height: height,
64
68
  insets: [bc.effective_inset_left || FRAME_INSET_LEFT,
65
69
  FRAME_INSET_TOP])
66
70
  end
67
71
 
72
+ # No element to size on: the frame alone, wide enough for its
73
+ # name tab.
74
+ def self.empty_framed(diagram, min_x, min_y)
75
+ width = [INSET_TOUCHING_ORIGIN + BoundsCalculator::INSET_RIGHT,
76
+ DiagramFrame.new(canvas: nil).min_width(diagram)].max
77
+ new(min_x: min_x, min_y: min_y, width: width,
78
+ height: BoundsCalculator::INSET_TOP + BoundsCalculator::INSET_BOTTOM,
79
+ insets: [BoundsCalculator.inset_left_for(min_x), FRAME_INSET_TOP])
80
+ end
81
+
68
82
  # Second-pass canvas over the DRAWN extents captured by the
69
83
  # element pass (grown classifiers, autosized packages, plus
70
84
  # BoundsCalculator's subtitle reservation). EA sizes the
@@ -79,7 +93,7 @@ module Ea
79
93
  pts << [b.x, b.y]
80
94
  pts << [b.x + b.width, b.y + b.height]
81
95
  end
82
- extra = base.send(:subtitle_points)
96
+ extra = base.subtitle_points
83
97
  min_x = (pts + extra).map(&:first).min || 0
84
98
  min_y = (pts + extra).map(&:last).min || 0
85
99
  max_x = (pts + extra).map(&:first).max || 0
@@ -95,8 +109,7 @@ module Ea
95
109
 
96
110
  # Same scaled rule as BoundsCalculator: drawn leftmost =
97
111
  # min(35, 30 + min_x).
98
- inset_left = [BoundsCalculator::INSET_LEFT - 5 + min_x,
99
- BoundsCalculator::INSET_LEFT].min
112
+ inset_left = BoundsCalculator.inset_left_for(min_x)
100
113
  new(min_x: min_x, min_y: min_y,
101
114
  width: (max_x - min_x) + inset_left +
102
115
  BoundsCalculator::INSET_RIGHT,
@@ -105,6 +118,25 @@ module Ea
105
118
  insets: [inset_left, FRAME_INSET_TOP])
106
119
  end
107
120
 
121
+ # This canvas when every point of `shapes` ([min_x, min_y, max_x,
122
+ # max_y] in canvas coordinates) lies inside its frame border (the
123
+ # FRAME_BORDER_INSET line), otherwise a canvas grown so the shapes
124
+ # sit CONTENT_MARGIN inside the border on the overflowing sides.
125
+ def holding(shapes)
126
+ left, top, right, bottom = shapes
127
+ reach = CanvasAnchor::CONTENT_MARGIN
128
+ grow_left = left < FRAME_BORDER_INSET ? reach - left : 0
129
+ grow_top = top < FRAME_BORDER_INSET ? reach - top : 0
130
+ new_width = right > width - FRAME_BORDER_INSET ? right + reach : width
131
+ new_height = bottom > height - FRAME_BORDER_INSET ? bottom + reach : height
132
+ return self if [grow_left, grow_top].all?(&:zero?) &&
133
+ new_width == width && new_height == height
134
+
135
+ Canvas.new(min_x: min_x - grow_left, min_y: min_y - grow_top,
136
+ width: new_width + grow_left, height: new_height + grow_top,
137
+ insets: [inset_left, inset_top])
138
+ end
139
+
108
140
  def view_box
109
141
  "0 0 #{width} #{height}"
110
142
  end
@@ -139,6 +171,12 @@ module Ea
139
171
 
140
172
  private
141
173
 
174
+ # Measured extents arrive as Floats ("508.0"); EA writes whole
175
+ # pixels into the viewBox and background rect.
176
+ def whole(pixels)
177
+ pixels.is_a?(Float) && pixels == pixels.to_i ? pixels.to_i : pixels
178
+ end
179
+
142
180
  def format_cm(px)
143
181
  return "0cm" if px.nil? || px.zero?
144
182
 
@@ -25,6 +25,12 @@ module Ea
25
25
  # 4881CEEE, 70C77F92, 846DD800, 88AF6BD7, AC2191E3). Below, the
26
26
  # same box (its top is the first baseline less the font height,
27
27
  # its height the stored CY) + 5 holds the canvas open on 95246DCF.
28
+ #
29
+ # Drawn text also holds the right and bottom edges open (text
30
+ # right/bottom + TEXT_MARGIN, symmetric with the origin rule), so
31
+ # a name wider than its box is not clipped. Checked on the 188
32
+ # PLATEAU natives: widths/heights unchanged (185/188, 188/188);
33
+ # 8DAB169F moves 13 closer; no native contradicts it.
28
34
  module CanvasAnchor
29
35
  CONTENT_MARGIN = 10
30
36
  TEXT_MARGIN = 5
@@ -66,18 +72,20 @@ module Ea
66
72
  origin_x = origin(shapes && shapes[0], texts && texts[0])
67
73
  origin_y = origin(shapes && shapes[1], texts && texts[1])
68
74
  Canvas.new(min_x: origin_x, min_y: origin_y,
69
- width: right_edge(shapes, label_reach) - origin_x,
70
- height: bottom_edge(shapes, label_reach, bottom) - origin_y,
75
+ width: right_edge(shapes, texts, label_reach) - origin_x,
76
+ height: bottom_edge(shapes, texts, label_reach, bottom) - origin_y,
71
77
  insets: [0, 0])
72
78
  end
73
79
 
74
- def right_edge(shapes, label_reach)
80
+ def right_edge(shapes, texts, label_reach)
75
81
  [shapes && shapes[2] + CONTENT_MARGIN,
82
+ texts && (texts[2] + TEXT_MARGIN).ceil,
76
83
  label_reach && label_reach[0] + LABEL_BOX_MARGIN].compact.max
77
84
  end
78
85
 
79
- def bottom_edge(shapes, label_reach, bottom)
86
+ def bottom_edge(shapes, texts, label_reach, bottom)
80
87
  [shapes[3] + bottom,
88
+ texts && (texts[3] + TEXT_MARGIN).ceil,
81
89
  label_reach && label_reach[1] + LABEL_BOX_MARGIN].compact.max
82
90
  end
83
91
 
@@ -6,6 +6,11 @@ module Ea
6
6
  module Compartment
7
7
  # Attribute compartment. Skipped when no attribute lines.
8
8
  module Attributes
9
+ # The pen the first group of this compartment leaves behind.
10
+ def self.pen(_context)
11
+ Pen::ROUND_BEVEL
12
+ end
13
+
9
14
  module_function
10
15
 
11
16
  def render(context)
@@ -11,6 +11,11 @@ module Ea
11
11
  # content and suppresses them when they would overflow the
12
12
  # stored box height (never truncates rows).
13
13
  module Constraints
14
+ # The pen the first group of this compartment leaves behind.
15
+ def self.pen(_context)
16
+ Pen::ROUND_BEVEL
17
+ end
18
+
14
19
  module_function
15
20
 
16
21
  def render(context)
@@ -8,6 +8,11 @@ module Ea
8
8
  # italic header + each literal name. Skipped when the
9
9
  # classifier has no literals or no content geometry.
10
10
  module EnumLiterals
11
+ # The pen the first group of this compartment leaves behind.
12
+ def self.pen(_context)
13
+ Pen::ROUND_BEVEL
14
+ end
15
+
11
16
  module_function
12
17
 
13
18
  # Caption and literal rows share the same baseline spacing.
@@ -7,6 +7,11 @@ module Ea
7
7
  # Header compartment: stereotype label + class name. Skips
8
8
  # rendering when the classifier produces no header lines.
9
9
  module Header
10
+ # The pen the first group of this compartment leaves behind.
11
+ def self.pen(_context)
12
+ Pen::ROUND_BEVEL
13
+ end
14
+
10
15
  module_function
11
16
 
12
17
  def render(context)
@@ -29,10 +34,12 @@ module Ea
29
34
  )
30
35
  end
31
36
 
37
+ # No element font size of its own: QEA marks it inherited, XMI
38
+ # records the missing size without the flag.
32
39
  def default_signal_header?(context)
33
40
  context.classifier.is_a?(Ea::Model::Signal) &&
34
41
  context.classifier.stereotype_refs.empty? &&
35
- context.element.font_size_inherited == true &&
42
+ context.element.font_size.nil? &&
36
43
  context.element.font_family.nil? &&
37
44
  !context.element.font_bold && !context.element.font_italic
38
45
  end
@@ -8,6 +8,11 @@ module Ea
8
8
  # compartment (attributes / operations / literals / tagged
9
9
  # values). Skipped when there is no content below.
10
10
  module HeaderDivider
11
+ # The pen the first group of this compartment leaves behind.
12
+ def self.pen(_context)
13
+ Pen::ROUND_BEVEL
14
+ end
15
+
11
16
  module_function
12
17
 
13
18
  def render(context)
@@ -40,7 +45,7 @@ module Ea
40
45
  end
41
46
 
42
47
  def object_diagram?(context)
43
- context.diagram && context.diagram.diagram_type == "Object"
48
+ context.diagram&.object_diagram?
44
49
  end
45
50
 
46
51
  def hand_draw?(context)
@@ -12,6 +12,11 @@ module Ea
12
12
  # InstanceSpecification or has neither slots nor a
13
13
  # classifier.
14
14
  module InstanceSlots
15
+ # The pen the first group of this compartment leaves behind.
16
+ def self.pen(_context)
17
+ Pen::ROUND_BEVEL
18
+ end
19
+
15
20
  ROW_OFFSET = 13
16
21
  SUBTITLE_OFFSET_FROM_BOTTOM = 15
17
22
 
@@ -8,6 +8,11 @@ module Ea
8
8
  # short horizontal line below each instance name to denote
9
9
  # that it is an instance (UML convention).
10
10
  module InstanceUnderline
11
+ # The pen the first group of this compartment leaves behind.
12
+ def self.pen(_context)
13
+ Pen::SQUARE_MITER
14
+ end
15
+
11
16
  Y_OFFSET = 1
12
17
 
13
18
  module_function
@@ -36,7 +41,7 @@ module Ea
36
41
  end
37
42
 
38
43
  def object_diagram?(context)
39
- context.diagram && context.diagram.diagram_type == "Object"
44
+ context.diagram&.object_diagram?
40
45
  end
41
46
  module_function :object_diagram?
42
47
 
@@ -10,6 +10,11 @@ module Ea
10
10
  # Real OCL constraints render trailing, after the content —
11
11
  # the two are distinct compartments.
12
12
  module Marker
13
+ # The pen the first group of this compartment leaves behind.
14
+ def self.pen(_context)
15
+ Pen::ROUND_BEVEL
16
+ end
17
+
13
18
  module_function
14
19
 
15
20
  def render(context)