ea 0.6.139 → 0.6.141

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 (61) hide show
  1. checksums.yaml +4 -4
  2. data/lib/ea/cli/app.rb +10 -0
  3. data/lib/ea/cli/command/diagrams.rb +4 -1
  4. data/lib/ea/cli/command/render.rb +8 -1
  5. data/lib/ea/cli/command/spa.rb +2 -1
  6. data/lib/ea/cli/command/svg.rb +9 -6
  7. data/lib/ea/fonts/metrics.rb +54 -6
  8. data/lib/ea/model/note.rb +2 -0
  9. data/lib/ea/sources/qea/diagram_builder/routing.rb +188 -8
  10. data/lib/ea/sources/qea/diagram_builder.rb +14 -1
  11. data/lib/ea/sources/qea/note_builder.rb +7 -1
  12. data/lib/ea/sources/xmi/diagram_builder/element_borders.rb +27 -0
  13. data/lib/ea/sources/xmi/diagram_builder.rb +58 -15
  14. data/lib/ea/sources/xmi/extension_style_parser.rb +1 -1
  15. data/lib/ea/sources/xmi/note_builder.rb +2 -1
  16. data/lib/ea/sources/xmi/stored_route/reversed_routes.rb +78 -0
  17. data/lib/ea/sources/xmi/stored_route.rb +83 -6
  18. data/lib/ea/spa/generator.rb +4 -2
  19. data/lib/ea/spa/projector.rb +4 -2
  20. data/lib/ea/svg/ea_emitter/bottom_margin.rb +104 -0
  21. data/lib/ea/svg/ea_emitter/bounds_calculator.rb +25 -15
  22. data/lib/ea/svg/ea_emitter/canvas.rb +80 -14
  23. data/lib/ea/svg/ea_emitter/canvas_anchor.rb +132 -0
  24. data/lib/ea/svg/ea_emitter/compartment/attributes.rb +5 -0
  25. data/lib/ea/svg/ea_emitter/compartment/constraints.rb +5 -0
  26. data/lib/ea/svg/ea_emitter/compartment/enum_literals.rb +5 -0
  27. data/lib/ea/svg/ea_emitter/compartment/header.rb +5 -0
  28. data/lib/ea/svg/ea_emitter/compartment/header_divider.rb +5 -0
  29. data/lib/ea/svg/ea_emitter/compartment/instance_slots.rb +5 -0
  30. data/lib/ea/svg/ea_emitter/compartment/instance_underline.rb +5 -0
  31. data/lib/ea/svg/ea_emitter/compartment/marker.rb +5 -0
  32. data/lib/ea/svg/ea_emitter/compartment/note_body.rb +11 -2
  33. data/lib/ea/svg/ea_emitter/compartment/note_text.rb +5 -0
  34. data/lib/ea/svg/ea_emitter/compartment/operations.rb +5 -0
  35. data/lib/ea/svg/ea_emitter/compartment/package_contents.rb +5 -0
  36. data/lib/ea/svg/ea_emitter/compartment/package_from_parent.rb +34 -4
  37. data/lib/ea/svg/ea_emitter/compartment/shape.rb +30 -5
  38. data/lib/ea/svg/ea_emitter/compartment/stereotype_icon.rb +6 -0
  39. data/lib/ea/svg/ea_emitter/compartment/tagged_values.rb +5 -0
  40. data/lib/ea/svg/ea_emitter/compartment.rb +13 -0
  41. data/lib/ea/svg/ea_emitter/connector_bridges.rb +117 -29
  42. data/lib/ea/svg/ea_emitter/connectors.rb +67 -17
  43. data/lib/ea/svg/ea_emitter/content_extent.rb +104 -0
  44. data/lib/ea/svg/ea_emitter/diagram_frame.rb +7 -0
  45. data/lib/ea/svg/ea_emitter/document.rb +76 -31
  46. data/lib/ea/svg/ea_emitter/element/legend_renderer.rb +22 -10
  47. data/lib/ea/svg/ea_emitter/element/note_shape_renderer.rb +57 -32
  48. data/lib/ea/svg/ea_emitter/elements.rb +24 -5
  49. data/lib/ea/svg/ea_emitter/font_resolver.rb +3 -1
  50. data/lib/ea/svg/ea_emitter/label/end_label.rb +134 -14
  51. data/lib/ea/svg/ea_emitter/labels.rb +16 -3
  52. data/lib/ea/svg/ea_emitter/layer_sequencer.rb +54 -33
  53. data/lib/ea/svg/ea_emitter/markers.rb +22 -10
  54. data/lib/ea/svg/ea_emitter/paint_order.rb +30 -0
  55. data/lib/ea/svg/ea_emitter/pen.rb +19 -0
  56. data/lib/ea/svg/ea_emitter/render_context.rb +14 -3
  57. data/lib/ea/svg/ea_emitter/text_renderer.rb +24 -8
  58. data/lib/ea/svg/ea_emitter.rb +5 -0
  59. data/lib/ea/svg/parity/source.rb +1 -1
  60. data/lib/ea/version.rb +1 -1
  61. metadata +9 -2
@@ -12,6 +12,7 @@ module Ea
12
12
  autoload :ConnectorMetadata, "ea/sources/xmi/diagram_builder/connector_metadata"
13
13
  autoload :AssociationClassMetadata, "ea/sources/xmi/diagram_builder/association_class_metadata"
14
14
  autoload :FontSettings, "ea/sources/xmi/diagram_builder/font_settings"
15
+ autoload :ElementBorders, "ea/sources/xmi/diagram_builder/element_borders"
15
16
  autoload :StereotypeProfiles, "ea/sources/xmi/diagram_builder/stereotype_profiles"
16
17
  autoload :UmldiEdges, "ea/sources/xmi/diagram_builder/umldi_edges"
17
18
 
@@ -135,7 +136,7 @@ module Ea
135
136
  image_bounds: (build_bounds(geom.img_left, geom.img_top, geom.img_right, geom.img_bottom) unless bounds),
136
137
  background_color: style.background_color,
137
138
  line_color: style.line_color,
138
- line_width: style.line_width,
139
+ line_width: placed_line_width(style, placed.subject),
139
140
  font_family: font[:family],
140
141
  font_size: font[:size],
141
142
  font_size_unit: "pt",
@@ -155,6 +156,17 @@ module Ea
155
156
 
156
157
  # XMI preserves UML member order in ownedAttribute. Carry that
157
158
  # provenance to the renderer so it does not replace it with a name sort.
159
+ # LWth on the placement, else the model element's saved border width.
160
+ # A negative LWth (-1) inherits; zero requests the one-pixel cosmetic
161
+ # pen, as in the QEA source. Checked on 01661E1B (LWth=-1 over
162
+ # BorderWidth=2) and 1A10EB6D (LWth=0, native stroke-width 1).
163
+ def placed_line_width(style, subject)
164
+ own = style.line_width
165
+ return [own, 1].max if own && own >= 0
166
+
167
+ (@element_borders ||= ElementBorders.new(root)).width_for(subject)
168
+ end
169
+
158
170
  def element_style(subject)
159
171
  style = (@stereotype_profiles ||= StereotypeProfiles.new(root)).for(subject)
160
172
  return style unless source_ordered_classifiers.include?(subject)
@@ -179,15 +191,17 @@ module Ea
179
191
  return nil unless left && top && right && bottom
180
192
 
181
193
  Ea::Model::Bounds.new(x: left, y: top,
182
- width: right - left,
183
- height: bottom - top)
194
+ width: right - left,
195
+ height: bottom - top)
184
196
  end
185
197
 
186
198
  def build_connectors(placed_connectors, placed_elements, owner_id, context)
187
199
  # Index elements by DUID so we can resolve SOID/EOID.
188
- by_duid = placed_elements.each_with_object({}) do |pe, acc|
189
- acc[pe.style.duid] = pe if pe.style.duid
190
- 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)
191
205
  by_subject = placed_elements.group_by { |pe| guid_key(pe.subject) }
192
206
 
193
207
  placed_connectors.each_with_index.map do |placed, index|
@@ -245,7 +259,7 @@ module Ea
245
259
  waypoints: route[:waypoints],
246
260
  style: route[:style],
247
261
  line_color: style.color,
248
- line_width: style.line_width,
262
+ line_width: ext_meta[:line_width],
249
263
  hidden: style.hidden,
250
264
  label_boxes: connector_label_boxes(placed, ext_meta[:ea_type],
251
265
  options.fetch(:context) ? nil : owner_id)
@@ -257,7 +271,10 @@ module Ea
257
271
  connector.label_boxes)
258
272
  end
259
273
  mark_stored_ray(connector, placed, source, target) unless options.fetch(:context)
260
- mark_association_route(connector, placed, source, target) unless options.fetch(:context)
274
+ unless options.fetch(:context)
275
+ reversed = reversed_placement?(style, by_duid, edge_source, edge_target)
276
+ mark_association_route(connector, placed, source, target, reversed)
277
+ end
261
278
  connector
262
279
  end
263
280
 
@@ -293,9 +310,20 @@ module Ea
293
310
  StoredRoute.new.public_send(stage, connector, link: link, bounds: bounds, offsets: offsets, edge: geom.edge)
294
311
  end
295
312
 
296
- def mark_association_route(connector, placed, source, target)
313
+ def mark_association_route(connector, placed, source, target, reversed)
314
+ connector.style[:reversed_ends] = true if reversed
297
315
  metadata = stored_ray_metadata(connector, placed, source, target, stage: :association_route)
298
316
  metadata.each { |name, value| connector.public_send("#{name}=", value) }
317
+ connector.style.delete(:reversed_ends)
318
+ end
319
+
320
+ # Mirrors QEA placement_order_reversed?: SOID and EOID must both name
321
+ # placements on this diagram and be crossed against the relationship's
322
+ # ends. A stale DUID resolves to nothing and is not a reversal.
323
+ def reversed_placement?(style, by_duid, edge_source, edge_target)
324
+ source = by_duid[style.soid]
325
+ target = by_duid[style.eoid]
326
+ source && target && reversed_ends?(source, target, edge_source, edge_target)
299
327
  end
300
328
 
301
329
  def mark_plain_source_seat(connector, placed, context)
@@ -342,14 +370,28 @@ module Ea
342
370
  # The saved SOID/EOID can name the model connector's ends in reverse
343
371
  # order; the model connector decides which placement is the source.
344
372
  def placed_ends(style, by_duid, by_subject, edge_source, edge_target)
345
- source = by_duid[style.soid] || sole_placement(by_subject, edge_source)
346
- 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
347
379
  return [source, target] unless source && target &&
348
380
  reversed_ends?(source, target, edge_source, edge_target)
349
381
 
350
382
  [target, source]
351
383
  end
352
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
+
353
395
  def reversed_ends?(source, target, edge_source, edge_target)
354
396
  guid_key(edge_source) != guid_key(edge_target) &&
355
397
  guid_key(source.subject) == guid_key(edge_target) &&
@@ -358,12 +400,13 @@ module Ea
358
400
 
359
401
  def connector_meta(subject_id)
360
402
  connector = connector_index[subject_id]
361
- return { ea_type: nil, direction: nil } unless connector
403
+ return { ea_type: nil, direction: nil, line_width: nil } unless connector
362
404
 
363
405
  props = connector.properties
364
406
  {
365
407
  ea_type: props&.ea_type,
366
- direction: props&.direction
408
+ direction: props&.direction,
409
+ line_width: connector.appearance&.linewidth
367
410
  }.merge(connector_metadata.for(connector))
368
411
  end
369
412
 
@@ -515,8 +558,8 @@ module Ea
515
558
  return nil unless g.left && g.top && g.right && g.bottom
516
559
 
517
560
  Ea::Model::Bounds.new(x: g.left, y: g.top,
518
- width: g.right - g.left,
519
- height: g.bottom - g.top)
561
+ width: g.right - g.left,
562
+ height: g.bottom - g.top)
520
563
  end
521
564
 
522
565
  def edge_point(placed, edge_code, _end_kind)
@@ -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
 
@@ -40,7 +40,8 @@ module Ea
40
40
  name: ext_element.name,
41
41
  body: ext_element.properties&.documentation,
42
42
  note_type: note_type,
43
- legend: legend_for(ext_element, note_type)
43
+ legend: legend_for(ext_element, note_type),
44
+ package_id: IdNormalizer.from_xmi_id(ext_element.model&.package)
44
45
  )
45
46
  end
46
47
 
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ea
4
+ module Sources
5
+ module Xmi
6
+ class StoredRoute < Qea::DiagramBuilder
7
+ # QEA's reversed-end Association rules for XMI. A connector is reversed
8
+ # when its SOID names a placement other than the relationship's source
9
+ # (builder flag :reversed_ends). Geometry helpers come from
10
+ # Qea::DiagramBuilder::Routing, so both sources share one rule.
11
+ module ReversedRoutes
12
+ private
13
+
14
+ # A width-0 vertical-tree Association takes the bus 50px past the End
15
+ # box's facing edge with legs at the stored origins (an empirical fit,
16
+ # see Routing#reversed_tree_route; checked on 5038EEE3).
17
+ def reversed_tree_metadata(model, link, bounds, offsets, edge)
18
+ return {} unless reversed_tree_candidate?(model, link, bounds, offsets)
19
+
20
+ start, finish = bounds.values_at(:source, :target)
21
+ route = reversed_tree_points(start, finish, ray_geometry(offsets, edge), stored_bus_levels(link))
22
+ return {} unless route
23
+
24
+ reversed_tree_fields(model, route[0], route[1])
25
+ end
26
+
27
+ def reversed_tree_fields(model, points, beside)
28
+ style = model.style.to_h.except(:umldi_route, :reversed_ends)
29
+ style[:left_seated_source] = true if beside
30
+ ends = [point_for(points.first), point_for(points.last)]
31
+ { waypoints: points_to_waypoints(points),
32
+ source_anchor: ends[0], target_anchor: ends[1], source_port: ends[0], target_port: ends[1],
33
+ style: style }
34
+ end
35
+
36
+ def reversed_tree_candidate?(model, link, bounds, offsets)
37
+ model.connector_type == "Association" && model.style[:reversed_ends] &&
38
+ model.style.to_h.values_at(:mode, :lwidth, :tree) == %w[3 0 V] &&
39
+ ray_inputs?(bounds, offsets) && stored_bus_levels(link)
40
+ end
41
+
42
+ # A thin Mode 3 EDGE 2 Association whose single saved corner follows
43
+ # the opposite placement order is clipped from each leg's stored
44
+ # origin, as QEA's reversed_stored_corner does (5038EEE3).
45
+ def reversed_corner_metadata(model, link, bounds, offsets, edge)
46
+ return {} unless reversed_corner_candidate?(model, link, bounds, offsets, edge)
47
+
48
+ origins, points = reversed_corner_points(bounds.values_at(:source, :target), ray_geometry(offsets, edge),
49
+ intermediate_waypoints(link).first)
50
+ return {} unless points
51
+
52
+ { waypoints: points_to_waypoints(points.map { |point| [point.x, point.y] }),
53
+ source_anchor: origins[0], target_anchor: origins[1] }
54
+ end
55
+
56
+ def reversed_corner_candidate?(model, link, bounds, offsets, edge)
57
+ model.connector_type == "Association" && model.style[:reversed_ends] && edge == 2 &&
58
+ model.style.to_h.values_at(:mode, :lwidth, :tree) == ["3", "0", nil] &&
59
+ ray_inputs?(bounds, offsets) && intermediate_waypoints(link).size == 1
60
+ end
61
+
62
+ # QEA seats the source labels of a reversed vertical lateral tree
63
+ # ("LV") as if its source leg left to the left (5038EEE3 connector 207).
64
+ def reversed_lateral_tree_metadata(model, _link, _bounds, _offsets, _edge)
65
+ return {} unless model.connector_type == "Association" && model.style[:reversed_ends] &&
66
+ model.style[:tree] == "LV"
67
+
68
+ { style: model.style.to_h.except(:reversed_ends).merge(left_seated_source: true) }
69
+ end
70
+
71
+ def points_to_waypoints(points)
72
+ points.map { |x, y| Ea::Model::Waypoint.new(position: Ea::Model::Point.new(x: x, y: y)) }
73
+ end
74
+ end
75
+ end
76
+ end
77
+ end
78
+ end
@@ -9,6 +9,9 @@ module Ea
9
9
  # boxes). Returns nil where QEA would fall back to bare SX/SY deltas,
10
10
  # which the generic router keeps handling.
11
11
  class StoredRoute < Qea::DiagramBuilder
12
+ autoload :ReversedRoutes, "ea/sources/xmi/stored_route/reversed_routes"
13
+ include ReversedRoutes
14
+
12
15
  Link = Struct.new(:style, :path, :geometry)
13
16
  Connector = Struct.new(:connector_type)
14
17
 
@@ -29,6 +32,9 @@ module Ea
29
32
  # Saved rays retain origins and computed boundary ports, matching QEA.
30
33
  def ray_metadata(model, link:, bounds:, offsets:, edge:)
31
34
  if model.connector_type == "Generalization"
35
+ overlap = mode2_overlap_metadata(model, link, bounds, offsets, edge)
36
+ return overlap unless overlap.empty?
37
+
32
38
  return generalization_ray_metadata(model, link, bounds, offsets, edge)
33
39
  end
34
40
 
@@ -49,11 +55,10 @@ module Ea
49
55
  # UMLDI fallback. A single stored corner and an empty-path stored ray
50
56
  # carry mathematical origins that the generic facing-edge route loses.
51
57
  def association_route(model, link:, bounds:, offsets:, edge:)
52
- overlap = umldi_association_metadata(model, link, bounds, offsets, edge)
53
- return overlap unless overlap.empty?
54
-
55
- partial = partial_port_metadata(model, link, bounds, offsets, edge)
56
- return partial unless partial.empty?
58
+ ROUTE_STAGES.each do |stage|
59
+ found = method(stage).call(model, link, bounds, offsets, edge)
60
+ return found unless found.empty?
61
+ end
57
62
 
58
63
  return {} unless stored_association_candidate?(model, bounds)
59
64
 
@@ -67,6 +72,69 @@ module Ea
67
72
 
68
73
  private
69
74
 
75
+ ROUTE_STAGES = %i[umldi_association_metadata shared_overlap_metadata
76
+ source_row_metadata auto_port_elbow_metadata reversed_tree_metadata
77
+ reversed_corner_metadata reversed_lateral_tree_metadata partial_port_metadata].freeze
78
+
79
+ # Overlap rules need both end boxes and every end offset.
80
+ def ray_inputs?(bounds, offsets)
81
+ bounds.values.all? && offsets.values.flatten.none?(&:nil?)
82
+ end
83
+
84
+ # A Mode 2 Generalization whose stored Path is a horizontal jog is EA's
85
+ # auto-route: QEA replaces the stored bends with one vertical ray, and
86
+ # the same QEA rule decides it here.
87
+ def mode2_overlap_metadata(model, link, bounds, offsets, edge)
88
+ return {} if link.path.to_s.empty? || !ray_inputs?(bounds, offsets)
89
+
90
+ candidate = association_model(model, bounds, offsets)
91
+ initial = candidate.waypoints
92
+ mode2_overlap_ray(candidate, link, bounds, ray_geometry(offsets, edge), false)
93
+ return {} if candidate.waypoints.equal?(initial)
94
+
95
+ { waypoints: candidate.waypoints, source_anchor: candidate.source_anchor,
96
+ target_anchor: candidate.target_anchor, has_geometry_offsets: true }
97
+ end
98
+
99
+ # A stored (non-UMLDI) Association through the shared overlap row takes
100
+ # QEA's overlap origins and flush ports, decided by the same QEA rule.
101
+ def shared_overlap_metadata(model, link, bounds, offsets, edge)
102
+ return {} if model.style[:umldi_route] || !ray_inputs?(bounds, offsets)
103
+
104
+ candidate = association_model(model, bounds, offsets)
105
+ geom = ray_geometry(offsets, edge)
106
+ association_stored_ray(candidate, link, bounds, geom, false) if association_ray_style?(model.style.to_h)
107
+ overlap_ray_origins(candidate, link, bounds, geom, false)
108
+ return {} unless candidate.style[:overlap_ray]
109
+
110
+ association_metadata(candidate)
111
+ end
112
+
113
+ # A Mode 2 Association with unequal end offsets and no (or a stale) Path
114
+ # rides the source row, as QEA's source_row_association_ray decides.
115
+ def source_row_metadata(model, link, bounds, offsets, edge)
116
+ return {} if model.style[:umldi_route] || !ray_inputs?(bounds, offsets)
117
+
118
+ candidate = association_model(model, bounds, offsets)
119
+ source_row_association_ray(candidate, link, bounds, ray_geometry(offsets, edge), false)
120
+ return {} unless candidate.style[:overlap_ray]
121
+
122
+ association_metadata(candidate)
123
+ end
124
+
125
+ # A Mode 2 Association with uniform offsets, EDGE 4 and no Path keeps its
126
+ # physical ports and takes QEA's elbow, even when UMLDI saved a straight
127
+ # route (30D62231, 934F4502).
128
+ def auto_port_elbow_metadata(model, link, bounds, offsets, edge)
129
+ return {} unless ray_inputs?(bounds, offsets)
130
+
131
+ candidate = association_model(model, bounds, offsets)
132
+ auto_port_elbow(candidate, link, bounds, ray_geometry(offsets, edge), false)
133
+ return {} unless candidate.style[:auto_port_elbow]
134
+
135
+ association_metadata(candidate)
136
+ end
137
+
70
138
  def umldi_association_metadata(model, link, bounds, offsets, edge)
71
139
  overlap = umldi_overlap_metadata(model, link, bounds, offsets, edge)
72
140
  return overlap unless overlap.empty?
@@ -99,7 +167,9 @@ module Ea
99
167
  return {} unless umldi_overlap_candidate?(model, bounds, offsets)
100
168
 
101
169
  candidate = association_model(model, bounds, offsets)
102
- overlap_ray_origins(candidate, link, bounds, ray_geometry(offsets, edge), false)
170
+ geom = ray_geometry(offsets, edge)
171
+ association_stored_ray(candidate, link, bounds, geom, false) if association_ray_style?(model.style.to_h)
172
+ overlap_ray_origins(candidate, link, bounds, geom, false)
103
173
  return {} unless candidate.style[:overlap_ray]
104
174
 
105
175
  { source_anchor: candidate.source_anchor, target_anchor: candidate.target_anchor,
@@ -162,6 +232,13 @@ module Ea
162
232
  y: box.y + box.height / 2 + offset[1].to_i)
163
233
  end
164
234
 
235
+ # XMI has no QEA database from which to infer a wider element outline,
236
+ # so the saved connector width stands. A border-aware variant changed no
237
+ # connector in the PLATEAU census.
238
+ def association_outline_width(model, _link, _bounds)
239
+ model.style[:lwidth]
240
+ end
241
+
165
242
  def stored_ray_boxes(_link, bounds)
166
243
  bounds.values_at(:source, :target)
167
244
  end
@@ -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
@@ -0,0 +1,104 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ea
4
+ module Svg
5
+ module EaEmitter
6
+ # Space EA leaves below the lowest element of an unframed export.
7
+ #
8
+ # EMPIRICAL FIT (no stored selector was found; measured on the 187
9
+ # PLATEAU QEA diagrams whose native SVG is available, 172 match):
10
+ # below the lowest element the canvas keeps 21 + its font size in
11
+ # points (the objectstyle fontsz is in tenths of a point, 0 meaning
12
+ # the 7pt default): 31 for fontsz 100, 30 for 90, 28 for the
13
+ # default. A plain Note, and a Package that draws no "(from
14
+ # Parent)" subtitle, keep the bare 10 margin, as does a classifier
15
+ # or instance of the diagram's own package. Misses: 2 outliers.
16
+ class BottomMargin
17
+ BARE = 10
18
+ LINE_ALLOWANCE = 21
19
+ HOME_TEXT_ALLOWANCE = 13
20
+ DEFAULT_POINTS = 7
21
+
22
+ def self.for(diagram, model_index)
23
+ lowest = lowest_elements(diagram)
24
+ return CanvasAnchor::BOTTOM_MARGIN if lowest.empty?
25
+
26
+ lowest.map { |element| of(element, diagram, model_index) }.max
27
+ end
28
+
29
+ def self.lowest_elements(diagram)
30
+ placed = (diagram.elements || []).select { |e| e.bounds || e.image_bounds }
31
+ bottom = placed.map { |e| edge(e) }.max
32
+ placed.select { |e| edge(e) == bottom }
33
+ end
34
+
35
+ def self.edge(element)
36
+ bounds = element.bounds || element.image_bounds
37
+ bounds.y + bounds.height
38
+ end
39
+
40
+ def self.of(element, diagram, model_index)
41
+ model = model_index && model_index[element.model_element_ref]
42
+ return home_text_margin(element) if home_text?(model, diagram)
43
+ return BARE if plain_note?(model) || bare_package?(model, diagram) ||
44
+ home_element?(model, diagram)
45
+
46
+ LINE_ALLOWANCE + points(element)
47
+ end
48
+
49
+ # QEA keeps the raw objectstyle fontsz; XMI keeps only the GDI
50
+ # pixel height it was rendered at (tenths * 96 / 720, floored).
51
+ def self.points(element)
52
+ tenths = element.style && element.style[:fontsz].to_i
53
+ return tenths / 10 if tenths.to_i.positive?
54
+
55
+ pixels = element.font_logical_height.to_i.abs
56
+ pixels.positive? ? (pixels * 0.75).round : DEFAULT_POINTS
57
+ end
58
+
59
+ def self.plain_note?(model)
60
+ model.is_a?(Ea::Model::Note) && model.note_type != "Text"
61
+ end
62
+
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)) &&
76
+ !diagram.package_id.to_s.empty? &&
77
+ model.package_id == diagram.package_id
78
+ end
79
+
80
+ # A Text element of the diagram's own package keeps 13 + its
81
+ # font size in points (22 at 9pt) instead of the foreign 21 + size.
82
+ # EMPIRICAL FIT on a single sample (PLATEAU 2E98EA19, fontsz 90,
83
+ # natively 22); the 8 foreign Text cases (28..30) are unaffected.
84
+ def self.home_text?(model, diagram)
85
+ model.is_a?(Ea::Model::Note) && model.note_type == "Text" &&
86
+ !diagram.package_id.to_s.empty? &&
87
+ model.package_id == diagram.package_id
88
+ end
89
+
90
+ def self.home_text_margin(element)
91
+ HOME_TEXT_ALLOWANCE + points(element)
92
+ end
93
+
94
+ def self.bare_package?(model, diagram)
95
+ return false unless model.is_a?(Ea::Model::Package)
96
+
97
+ parent = model.parent_id
98
+ !(diagram.show_foreign && !parent.to_s.empty? &&
99
+ parent != diagram.package_id)
100
+ end
101
+ end
102
+ end
103
+ end
104
+ end
@@ -33,12 +33,19 @@ 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
39
46
  end
40
47
 
41
- attr_reader :effective_inset_left
48
+ attr_reader :effective_inset_left, :max_x, :max_y
42
49
 
43
50
  def compute
44
51
  points = element_points
@@ -48,10 +55,12 @@ module Ea
48
55
  ys = points.map(&:last)
49
56
  min_x = xs.min || 0
50
57
  min_y = ys.min || 0
58
+ @max_x = xs.max
59
+ @max_y = ys.max
51
60
  # Left inset scales from 30 up to the 35 cap with the
52
61
  # stored leftmost x: leftmost drawn x = 30 + min_x capped
53
62
  # at 35 (corpus: min_x 0 -> 30, 2 -> 32, 4 -> 34, >=5 -> 35).
54
- @effective_inset_left = [INSET_LEFT - 5 + min_x, INSET_LEFT].min
63
+ @effective_inset_left = self.class.inset_left_for(min_x)
55
64
  [
56
65
  min_x,
57
66
  min_y,
@@ -60,19 +69,7 @@ module Ea
60
69
  ]
61
70
  end
62
71
 
63
- private
64
-
65
- # Element x-extent: logical bounds only (matches EA's canvas
66
- # left/right exactly).
67
- # Element y-extent: union of logical + image_bounds (image
68
- # extends below bounds for shadow / image padding), plus the
69
- # "(from Parent)" subtitle extent: EA draws the subtitle 15px
70
- # below the body and its text descent adds ~4 more, reserving
71
- # 19px of canvas below subtitle-bearing packages (the +19
72
- # height family: 05DF5000 Main, Metadata All, package
73
- # Clients/Suppliers contexts).
74
- SUBTITLE_CANVAS_EXTRA = 19
75
-
72
+ # Canvas points reserved below "(from Parent)" subtitles.
76
73
  def subtitle_points
77
74
  pts = []
78
75
  (diagram.elements || []).each do |e|
@@ -86,6 +83,19 @@ module Ea
86
83
  pts
87
84
  end
88
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
+
89
99
  def element_points
90
100
  pts = []
91
101
  (diagram.elements || []).each do |e|