ea 0.6.140 → 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 (54) 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 +44 -16
  8. data/lib/ea/sources/qea/diagram_builder/routing.rb +64 -22
  9. data/lib/ea/sources/qea/diagram_builder.rb +4 -1
  10. data/lib/ea/sources/xmi/diagram_builder.rb +21 -5
  11. data/lib/ea/sources/xmi/extension_style_parser.rb +1 -1
  12. data/lib/ea/sources/xmi/stored_route/reversed_routes.rb +10 -26
  13. data/lib/ea/spa/generator.rb +4 -2
  14. data/lib/ea/spa/projector.rb +4 -2
  15. data/lib/ea/svg/ea_emitter/bottom_margin.rb +16 -12
  16. data/lib/ea/svg/ea_emitter/bounds_calculator.rb +22 -14
  17. data/lib/ea/svg/ea_emitter/canvas.rb +43 -5
  18. data/lib/ea/svg/ea_emitter/canvas_anchor.rb +12 -4
  19. data/lib/ea/svg/ea_emitter/compartment/attributes.rb +5 -0
  20. data/lib/ea/svg/ea_emitter/compartment/constraints.rb +5 -0
  21. data/lib/ea/svg/ea_emitter/compartment/enum_literals.rb +5 -0
  22. data/lib/ea/svg/ea_emitter/compartment/header.rb +5 -0
  23. data/lib/ea/svg/ea_emitter/compartment/header_divider.rb +5 -0
  24. data/lib/ea/svg/ea_emitter/compartment/instance_slots.rb +5 -0
  25. data/lib/ea/svg/ea_emitter/compartment/instance_underline.rb +5 -0
  26. data/lib/ea/svg/ea_emitter/compartment/marker.rb +5 -0
  27. data/lib/ea/svg/ea_emitter/compartment/note_body.rb +7 -2
  28. data/lib/ea/svg/ea_emitter/compartment/note_text.rb +5 -0
  29. data/lib/ea/svg/ea_emitter/compartment/operations.rb +5 -0
  30. data/lib/ea/svg/ea_emitter/compartment/package_contents.rb +5 -0
  31. data/lib/ea/svg/ea_emitter/compartment/package_from_parent.rb +12 -1
  32. data/lib/ea/svg/ea_emitter/compartment/shape.rb +14 -1
  33. data/lib/ea/svg/ea_emitter/compartment/stereotype_icon.rb +6 -0
  34. data/lib/ea/svg/ea_emitter/compartment/tagged_values.rb +5 -0
  35. data/lib/ea/svg/ea_emitter/compartment.rb +13 -0
  36. data/lib/ea/svg/ea_emitter/connector_bridges.rb +18 -4
  37. data/lib/ea/svg/ea_emitter/content_extent.rb +11 -2
  38. data/lib/ea/svg/ea_emitter/diagram_frame.rb +7 -0
  39. data/lib/ea/svg/ea_emitter/document.rb +21 -5
  40. data/lib/ea/svg/ea_emitter/element/legend_renderer.rb +18 -14
  41. data/lib/ea/svg/ea_emitter/element/note_shape_renderer.rb +1 -8
  42. data/lib/ea/svg/ea_emitter/elements.rb +11 -9
  43. data/lib/ea/svg/ea_emitter/font_resolver.rb +3 -1
  44. data/lib/ea/svg/ea_emitter/label/end_label.rb +72 -10
  45. data/lib/ea/svg/ea_emitter/labels.rb +10 -3
  46. data/lib/ea/svg/ea_emitter/layer_sequencer.rb +14 -7
  47. data/lib/ea/svg/ea_emitter/paint_order.rb +5 -4
  48. data/lib/ea/svg/ea_emitter/pen.rb +19 -0
  49. data/lib/ea/svg/ea_emitter/render_context.rb +1 -1
  50. data/lib/ea/svg/ea_emitter/text_renderer.rb +24 -8
  51. data/lib/ea/svg/ea_emitter.rb +1 -0
  52. data/lib/ea/svg/parity/source.rb +1 -1
  53. data/lib/ea/version.rb +1 -1
  54. metadata +3 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9db50e16de6c9f9faa698a872686600da15ba3eef0787de0b18b0ea56eca0394
4
- data.tar.gz: b537f2b76e1fdb3a5a6dcfd1581db9f651279c5aff4e35882144da2ace23cf26
3
+ metadata.gz: 4f4c5df621df87a4df4467c9cbe157f70ea49d0463a892ed7e57ada3550b1937
4
+ data.tar.gz: 69fdddf140a6553c565287ce099bfa7f3d70d6c981502c559368c2e9860e70db
5
5
  SHA512:
6
- metadata.gz: 9a685c1155369f6800fbfd294feed39ea2b450f482245c30be940c18bb31098466be6dbb0f0e6ca28e90502798ceaf10c8a5bbac0f0739b619b1cc0cd69696cb
7
- data.tar.gz: 570de8eaf44e482c0330cc96f17eb2ab89e26a75542c613617b53468040b45dfec9ce597b308cd50b068d0a78e20e415a9cda9e897622f1cc1b1ab2a4e110baa
6
+ metadata.gz: b5d5331f2954387a2ff2e9a93981964421958bac715ca436630b7aca0cefd00107973219d571a01e162692f6037cc695e52f94f04ab8808e899f1d08bd9fdba7
7
+ data.tar.gz: 6c63841163568dc3b95d840ed14f4d18fd95e7bfc51bf111a8d40b32ae0aec920cf530a99012690f642c6ef4709e6df6adca3917e634f61a5088891fbf99d14c
data/lib/ea/cli/app.rb CHANGED
@@ -41,6 +41,8 @@ module Ea
41
41
  "Diagram operations: list FILE | extract NAME FILE"
42
42
  option :format, type: :string, default: "table"
43
43
  option :output, **OUTPUT_OPTION, desc: "Output path (extract only)"
44
+ option :frame, type: :boolean, default: false,
45
+ desc: "Draw EA's diagram frame (border, tab, label); default is unframed"
44
46
  def diagrams(action, file = nil, name = nil)
45
47
  Command::Diagrams
46
48
  .new(action: action, file: file, name: name, **symbolize(options))
@@ -81,6 +83,8 @@ module Ea
81
83
  desc: "Path to SPA YAML config (overrides model metadata: title, description, etc.)"
82
84
  option :mode, type: :string, default: "single_file",
83
85
  desc: "Output mode: single_file | sharded"
86
+ option :frame, type: :boolean, default: false,
87
+ desc: "Draw EA's diagram frame (border, tab, label); default is unframed"
84
88
  def spa(file)
85
89
  Command::Spa.new(file: file, **symbolize(options)).call
86
90
  end
@@ -92,7 +96,10 @@ module Ea
92
96
  desc: "Render every diagram in the source file (NAME ignored)"
93
97
  option :output_dir, type: :string,
94
98
  desc: "Directory for --all output (default: <basename>.svgs/)"
99
+ option :frame, type: :boolean, default: false,
100
+ desc: "Draw EA's diagram frame (border, tab, label); default is unframed"
95
101
  def svg(name = nil, file = nil)
102
+ name, file = nil, name if options[:all] && file.nil?
96
103
  Command::Svg.new(name: name, file: file, **symbolize(options)).call
97
104
  end
98
105
 
@@ -113,7 +120,10 @@ module Ea
113
120
  desc: "Output format: svg | png | pdf"
114
121
  option :output_dir, type: :string,
115
122
  desc: "Directory for --all output (default: <basename>.renders/)"
123
+ option :frame, type: :boolean, default: false,
124
+ desc: "Draw EA's diagram frame (border, tab, label); default is unframed"
116
125
  def render(name = nil, file = nil)
126
+ name, file = nil, name if options[:all] && file.nil?
117
127
  Command::Render.new(name: name, file: file, **symbolize(options)).call
118
128
  end
119
129
 
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "fileutils"
4
+
3
5
  module Ea
4
6
  module Cli
5
7
  module Command
@@ -51,7 +53,8 @@ module Ea
51
53
 
52
54
  svg = Ea::Svg::EaEmitter::Document.new(diagram,
53
55
  model_index: document.index_by_id,
54
- document: document).render
56
+ document: document,
57
+ frame: options[:frame] ? true : false).render
55
58
  path = options[:output] || begin
56
59
  base = File.basename(file_path, ".*")
57
60
  dir = File.dirname(file_path)
@@ -22,6 +22,12 @@ module Ea
22
22
 
23
23
  private
24
24
 
25
+ # `--format` names the image format here (svg|png|pdf), not the
26
+ # table/json output formatter, so the result listing is a table.
27
+ def formatter
28
+ Ea::Cli::Output.instance_for(:table)
29
+ end
30
+
25
31
  def render_one
26
32
  diagram = find_diagram
27
33
  write_diagram(diagram, resolve_output_path(diagram))
@@ -57,7 +63,8 @@ module Ea
57
63
  def render_svg(diagram)
58
64
  emitter_for_mode.new(diagram,
59
65
  model_index: document.index_by_id,
60
- document: document).render
66
+ document: document,
67
+ frame: options[:frame] ? true : false).render
61
68
  end
62
69
 
63
70
  def svg_to_png(svg)
@@ -22,7 +22,8 @@ module Ea
22
22
  document,
23
23
  output: output_path,
24
24
  mode: mode,
25
- configuration: spa_configuration
25
+ configuration: spa_configuration,
26
+ frame: options[:frame] ? true : false
26
27
  ).generate
27
28
 
28
29
  formatter.render([[output_path]], columns: [:written_to])
@@ -26,9 +26,7 @@ module Ea
26
26
 
27
27
  def render_one
28
28
  diagram = find_diagram
29
- svg = emitter_for_mode.new(diagram,
30
- model_index: document.index_by_id,
31
- document: document).render
29
+ svg = render_svg(diagram)
32
30
 
33
31
  output_path = resolve_output_path(diagram)
34
32
  FileUtils.mkdir_p(File.dirname(output_path))
@@ -41,9 +39,7 @@ module Ea
41
39
  out_dir = options[:output_dir] || default_output_dir
42
40
  FileUtils.mkdir_p(out_dir)
43
41
  paths = document.diagrams.map do |diagram|
44
- svg = emitter_for_mode.new(diagram,
45
- model_index: document.index_by_id,
46
- document: document).render
42
+ svg = render_svg(diagram)
47
43
  path = File.join(out_dir, "#{diagram.id}.svg")
48
44
  File.write(path, svg)
49
45
  path
@@ -52,6 +48,13 @@ module Ea
52
48
  formatter.render(paths.map { |p| [p] }, columns: [:written_to])
53
49
  end
54
50
 
51
+ def render_svg(diagram)
52
+ emitter_for_mode.new(diagram,
53
+ model_index: document.index_by_id,
54
+ document: document,
55
+ frame: options[:frame] ? true : false).render
56
+ end
57
+
55
58
  def default_output_dir
56
59
  base = File.basename(file_path, ".*")
57
60
  dir = File.dirname(file_path)
@@ -105,6 +105,7 @@ module Ea
105
105
  # Source sizes 90 and 100 both export as 9pt but retain
106
106
  # distinct 12px and 13px logical heights. Apply that same
107
107
  # source-height delta to the existing compartment spacing.
108
+ logical_height = source_logical_height(logical_height)
108
109
  adjustment = if logical_height && unit == "pt" &&
109
110
  GDI_HEIGHT_FAMILIES.include?(resolve_family(family))
110
111
  logical_height - gdi_logical_height(size)
@@ -114,6 +115,13 @@ module Ea
114
115
  size + offset + adjustment
115
116
  end
116
117
 
118
+ # A source logical height of 0 or less (QEA floors tiny fontsz to
119
+ # 0) carries no information: treat it as absent.
120
+ def source_logical_height(height)
121
+ height = height.to_i
122
+ height if height.positive?
123
+ end
124
+
117
125
  def gdi_logical_height(size)
118
126
  (size.to_f * 10 / 7).round
119
127
  end
@@ -152,11 +160,17 @@ module Ea
152
160
  # textLength is the SUM of those per-glyph integers - corpus
153
161
  # fitted to 97-100% exact on 10k Carlito texts (bold 7pt
154
162
  # 1666/1671). The legacy sum-then-round model matched only 27%.
155
- def text_length(text, size_pt, family: nil, weight: nil, style: nil, logical_height: nil)
163
+ #
164
+ # `legend_title` marks a diagram-legend title, the only context in
165
+ # which a bold Cambria CJK run takes regular-weight advances.
166
+ def text_length(text, size_pt, family: nil, weight: nil, style: nil, logical_height: nil,
167
+ legend_title: false)
156
168
  return nil unless supported_family?(family)
157
169
  return nil if text.nil? || text.empty?
158
170
 
159
- return cambria_text_length(text, size_pt, weight, style) if resolve_family(family) == "Cambria"
171
+ if resolve_family(family) == "Cambria"
172
+ return cambria_text_length(text, size_pt, weight, style, logical_height, legend_title: legend_title)
173
+ end
160
174
 
161
175
  advance_text_length(text, size_pt, family, weight, style, logical_height)
162
176
  end
@@ -164,7 +178,7 @@ module Ea
164
178
  def advance_text_length(text, size_pt, family, weight, style, logical_height)
165
179
  resolved = resolve_family(family)
166
180
  if GDI_HEIGHT_FAMILIES.include?(resolved)
167
- height = logical_height || gdi_logical_height(size_pt)
181
+ height = source_logical_height(logical_height) || gdi_logical_height(size_pt)
168
182
  table = table_for(family, weight, style)
169
183
  fallback = cjk_fallback_widths(resolved, height, weight, style)
170
184
  measured_latin = measured_carlito_widths(resolved, height, weight, style)
@@ -206,8 +220,16 @@ module Ea
206
220
  end
207
221
  private_class_method :measured_carlito_widths
208
222
 
209
- def cambria_text_length(text, size_pt, weight, style)
210
- return cambria_cjk_only_length(text, size_pt, weight, style) unless size_pt.to_f.to_r == 7
223
+ # True for Cambria outside the 7pt tables, where a run is measured
224
+ # character by character when it is not wholly in the CJK table.
225
+ def piecewise_family?(family, size_pt)
226
+ resolve_family(family) == "Cambria" && size_pt.to_f.to_r != 7
227
+ end
228
+
229
+ def cambria_text_length(text, size_pt, weight, style, logical_height = nil, legend_title: false)
230
+ unless size_pt.to_f.to_r == 7
231
+ return cambria_cjk_only_length(text, size_pt, weight, style, logical_height, legend_title: legend_title)
232
+ end
211
233
  return nil unless ["", "0", "400", "normal", "700", "bold"].include?(weight.to_s)
212
234
  return nil unless ["", "normal", "italic"].include?(style.to_s)
213
235
 
@@ -223,17 +245,23 @@ module Ea
223
245
  private_class_method :cambria_text_length
224
246
 
225
247
  # Cambria has no Latin table outside 7pt, but a run made only of
226
- # characters in the measured MS UI Gothic table is exact at any
227
- # measured height. EMPIRICAL FIT (decision a): EA draws a bold
228
- # Cambria CJK run at logical height >= 17 with the regular-weight
229
- # advances (no synthetic-bold pixel), although the Windows table
230
- # records one. Checked on the 116 native legend titles "凡例"
231
- # (Cambria bold 12pt, H17): native textLength 34 = 2 x 17, where
232
- # the 17:700 entry would give 36. Below H17 bold keeps the bold
233
- # table (native 7pt bold "都市計画データ" = 70).
234
- def cambria_cjk_only_length(text, size_pt, weight, style)
235
- height = gdi_logical_height(size_pt)
236
- bold = bold?(weight) && height < CAMBRIA_REGULAR_BOLD_MIN_HEIGHT
248
+ # characters in the measured MS UI Gothic table is exact at the
249
+ # measured height (the source logical height when it is positive,
250
+ # else the one derived from the point size). A run with any other
251
+ # character is nil here; TextRenderer.estimate_width then prices
252
+ # the characters one at a time.
253
+ #
254
+ # EMPIRICAL FIT (decision a), legend titles only: EA draws a bold
255
+ # Cambria legend title at logical height >= 17 with the
256
+ # regular-weight advances (no synthetic-bold pixel), although the
257
+ # Windows table records one. Checked on the 116 native legend
258
+ # titles "凡例" (Cambria bold 12pt, H17): native textLength 34 =
259
+ # 2 x 17, where the 17:700 entry would give 36. Every other bold
260
+ # run, and a title below H17, keeps the bold table (native 7pt
261
+ # bold "都市計画データ" = 70).
262
+ def cambria_cjk_only_length(text, size_pt, weight, style, logical_height = nil, legend_title: false)
263
+ height = source_logical_height(logical_height) || gdi_logical_height(size_pt)
264
+ bold = bold?(weight) && !(legend_title && height >= CAMBRIA_REGULAR_BOLD_MIN_HEIGHT)
237
265
  widths = cjk_fallback_widths("Cambria", height, bold ? 700 : 400, style)
238
266
  advances = text.each_char.map { |char| widths[char] }
239
267
  advances.sum unless advances.include?(nil)
@@ -7,6 +7,10 @@ module Ea
7
7
  # Internal geometry for choosing the facing edges of stored routes.
8
8
  module Routing
9
9
  REVERSED_TREE_BUS_OFFSET = 50
10
+ # A leg may start beside its box only this far from it (the one native
11
+ # case, 5038EEE3 connector 212, is 9px away); any other leg that
12
+ # leaves the box's x-span would be detached, so the fit yields.
13
+ REVERSED_TREE_BESIDE_REACH = 10
10
14
 
11
15
  private
12
16
 
@@ -19,19 +23,26 @@ module Ea
19
23
  connector = database.find_connector(link_row.connectorid)
20
24
  return model unless connector && reversed_corner_link?(link_row, diagram_row, connector)
21
25
 
22
- boxes = stored_ray_boxes(link_row, diagram_row)
26
+ origins, points = reversed_corner_points(stored_ray_boxes(link_row, diagram_row), geom,
27
+ intermediate_waypoints(link_row).first)
28
+ return model unless points
29
+
30
+ model.waypoints = points.map { |point| Ea::Model::Waypoint.new(position: point) }
31
+ model.source_anchor, model.target_anchor = origins
32
+ model
33
+ end
34
+
35
+ # Shared by QEA and XMI: clip each leg of the single saved bend from
36
+ # its stored origin, retaining origins outside their box. Returns
37
+ # [origins, [start, bend, end]], or nil when a leg cannot be clipped.
38
+ def reversed_corner_points(boxes, geom, corner)
23
39
  origins = [stored_ray_origin(boxes[0], geom[:sx], geom[:sy]),
24
40
  stored_ray_origin(boxes[1], geom[:ex], geom[:ey])]
25
- x, y = intermediate_waypoints(link_row).first
26
- bend = Ea::Model::Point.new(x: x, y: y)
41
+ bend = Ea::Model::Point.new(x: corner[0], y: corner[1])
27
42
  ends = origins.zip(boxes).map do |origin, box|
28
43
  inside_box?(origin, box) ? diagonal_corner_exit(origin, bend, box) : origin
29
44
  end
30
- return model unless ends.all?
31
-
32
- model.waypoints = [ends[0], bend, ends[1]].map { |point| Ea::Model::Waypoint.new(position: point) }
33
- model.source_anchor, model.target_anchor = origins
34
- model
45
+ [origins, [ends[0], bend, ends[1]]] if ends.all?
35
46
  end
36
47
 
37
48
  # EMPIRICAL FIT (decision a), not a source-backed rule. A width-0
@@ -48,22 +59,37 @@ module Ea
48
59
  return model if context || !reversed_tree_link?(model, link_row, diagram_row, geom)
49
60
 
50
61
  start, finish = stored_ray_boxes(link_row, diagram_row)
51
- bus, start_edge, finish_edge = reversed_tree_levels(start, finish)
52
- return model unless bus
62
+ route = reversed_tree_points(start, finish, geom, stored_bus_levels(link_row))
63
+ return model unless route
53
64
 
54
- first = reversed_tree_leg(start, geom[:sx], geom[:sy], start_edge, finish)
55
- last = reversed_tree_leg(finish, geom[:ex], geom[:ey], finish_edge, start)
56
- model.waypoints = [first, [first[0], bus], [last[0], bus], last].map do |x, y|
65
+ points, beside = route
66
+ model.waypoints = points.map do |x, y|
57
67
  Ea::Model::Waypoint.new(position: Ea::Model::Point.new(x: x, y: y))
58
68
  end
59
69
  model.source_anchor = model.source_port = model.waypoints.first.position
60
70
  model.target_anchor = model.target_port = model.waypoints.last.position
61
71
  # EMPIRICAL FIT: a leg that starts beside its box (5038EEE3
62
72
  # connector 212) seats its source labels like a leftward leg.
63
- model.style = model.style.to_h.merge(left_seated_source: true) if first[1] != start_edge
73
+ model.style = model.style.to_h.merge(left_seated_source: true) if beside
64
74
  model
65
75
  end
66
76
 
77
+ # The reversed-tree route points and whether the Start leg starts
78
+ # beside its box, or nil when the fit does not apply: no usable bus
79
+ # level, a stored bus that is not the discarded one (see
80
+ # reversed_tree_levels), or a leg that cannot stay attached to its box.
81
+ def reversed_tree_points(start, finish, geom, stored_bus)
82
+ bus, start_edge, finish_edge = reversed_tree_levels(start, finish, stored_bus)
83
+ return unless bus
84
+
85
+ first = reversed_tree_leg(start, geom[:sx], geom[:sy], start_edge, finish)
86
+ last = reversed_tree_leg(finish, geom[:ex], geom[:ey], finish_edge, start)
87
+ return unless first && last
88
+
89
+ points = [first, [first[0], bus], [last[0], bus], last].chunk_while { |a, b| a == b }.map(&:first)
90
+ [points, first[1] != start_edge]
91
+ end
92
+
67
93
  # EMPIRICAL FIT (decision a). EA seats the source labels of a
68
94
  # vertical lateral tree ("LV") whose SOID/EOID name the ends in
69
95
  # reverse as if the source leg left to the left, whichever way the
@@ -92,25 +118,37 @@ module Ea
92
118
 
93
119
  def stored_bus_levels(link_row)
94
120
  bends = intermediate_waypoints(link_row)
95
- bends.size == 2 && bends.first[1] == bends.last[1]
121
+ bends.first[1] if bends.size == 2 && bends.first[1] == bends.last[1]
96
122
  end
97
123
 
98
124
  # [bus height, Start facing edge, End facing edge] for vertically
99
125
  # separated boxes: the bus sits 50px past the End box's edge and
100
126
  # must stay inside the gap. Nil otherwise.
101
- def reversed_tree_levels(start, finish)
127
+ #
128
+ # EMPIRICAL FIT (decision a): a stored bus is replaced only when it
129
+ # is a 50px tree bus off one of the two facing edges, as EA lays
130
+ # them out: 50px past the Start box (the Path follows SOID/EOID
131
+ # order; the QEA y670 = Start edge 720 - 50 of all five 5038EEE3
132
+ # links) or already 50px past the End box (the XMI Path of three
133
+ # of them, y568). A stored bus anywhere else is a real route.
134
+ def reversed_tree_levels(start, finish, stored_bus)
102
135
  if finish.y + finish.height < start.y
103
136
  bus = finish.y + finish.height + REVERSED_TREE_BUS_OFFSET
104
- [bus, start.y, finish.y + finish.height] if bus < start.y
137
+ discarded = start.y - REVERSED_TREE_BUS_OFFSET
138
+ edges = [start.y, finish.y + finish.height]
139
+ [bus, *edges] if bus < start.y && [bus, discarded].include?(stored_bus)
105
140
  elsif start.y + start.height < finish.y
106
141
  bus = finish.y - REVERSED_TREE_BUS_OFFSET
107
- [bus, start.y + start.height, finish.y] if bus > start.y + start.height
142
+ discarded = start.y + start.height + REVERSED_TREE_BUS_OFFSET
143
+ edges = [start.y + start.height, finish.y]
144
+ [bus, *edges] if bus > edges.first && [bus, discarded].include?(stored_bus)
108
145
  end
109
146
  end
110
147
 
111
148
  # A leg runs vertically at the stored origin's x. It starts on the
112
- # facing edge, unless the origin lies beside the box (outside its
113
- # x-span but level with it), where the origin itself is kept.
149
+ # facing edge, unless the origin lies beside the box (just outside its
150
+ # x-span but level with it), where the origin itself is kept. Nil
151
+ # for an origin that would leave the leg detached.
114
152
  #
115
153
  # EMPIRICAL FIT (decision a): the leg's x is pulled toward the box
116
154
  # centre by |offset_y| / (vertical centre distance to the other box).
@@ -124,8 +162,12 @@ module Ea
124
162
  pull = span.positive? ? 1 - [offset_y.to_i.abs / span, 1].min : 1
125
163
  pulled = (offset_x.to_i.abs * pull).ceil * (offset_x.to_i.negative? ? -1 : 1)
126
164
  origin = stored_ray_origin(box, pulled, offset_y)
127
- beside = !origin.x.between?(box.x, box.x + box.width) && origin.y.between?(box.y, box.y + box.height)
128
- [origin.x, beside ? origin.y : edge_y]
165
+ return [origin.x, edge_y] if origin.x.between?(box.x, box.x + box.width)
166
+
167
+ reach = REVERSED_TREE_BESIDE_REACH
168
+ beside = origin.y.between?(box.y, box.y + box.height) &&
169
+ origin.x.between?(box.x - reach, box.x + box.width + reach)
170
+ [origin.x, origin.y] if beside
129
171
  end
130
172
 
131
173
  # Path follows SOID/EOID placement order, independently of the
@@ -202,7 +202,10 @@ module Ea
202
202
  end
203
203
 
204
204
  def build_connectors(diagram_row)
205
- links = database.diagram_links_for(diagram_row.diagram_id) || []
205
+ # A link row whose connector no longer exists (EA leaves them
206
+ # behind when a connector is deleted) has nothing to draw.
207
+ links = (database.diagram_links_for(diagram_row.diagram_id) || [])
208
+ .select { |link_row| database.find_connector(link_row.connectorid) }
206
209
  context = ContextDiagramSupport.context?(diagram_row)
207
210
  # EA regenerates context diagrams at draw time; their
208
211
  # Hidden link rows are stale and never render.
@@ -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) &&
@@ -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
 
@@ -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] &&
@@ -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