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
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9db50e16de6c9f9faa698a872686600da15ba3eef0787de0b18b0ea56eca0394
4
- data.tar.gz: b537f2b76e1fdb3a5a6dcfd1581db9f651279c5aff4e35882144da2ace23cf26
3
+ metadata.gz: b51e9399fd02dbf1d891731d7a6cdcf754b1778efdbd3e3946479a02aa0761a2
4
+ data.tar.gz: 96c4a49164b75a78ac1a165bdd24f4a1a13e9e8199f84e1577fd111b7498b332
5
5
  SHA512:
6
- metadata.gz: 9a685c1155369f6800fbfd294feed39ea2b450f482245c30be940c18bb31098466be6dbb0f0e6ca28e90502798ceaf10c8a5bbac0f0739b619b1cc0cd69696cb
7
- data.tar.gz: 570de8eaf44e482c0330cc96f17eb2ab89e26a75542c613617b53468040b45dfec9ce597b308cd50b068d0a78e20e415a9cda9e897622f1cc1b1ab2a4e110baa
6
+ metadata.gz: 19e20b995ba1bcad98fb01f30b8cad1eec339994c57782f543e9b0014fe08f8bf4e2176ce4f5bea790e07c08bbe26f5745ee5609ec36ac1f1669e02e40f27973
7
+ data.tar.gz: ee051963f75748a2b32fb6021078146c76687d539e3f76cb52fd76281ef7abf6adeb355bfe48d528aa39244c400f5ea2a80064519f166c19098c3b5ecfef5dac
@@ -4,6 +4,13 @@ font:
4
4
  family:
5
5
  size:
6
6
  unit: px
7
+ # Unthemed diagrams draw connector labels in Cambria 7pt, like the
8
+ # themed ones (test B58D1A53 native; 0 of 220 published SVGs carry the
9
+ # former Yu Gothic UI 13px fallback).
10
+ label_font:
11
+ family: Cambria
12
+ size: 7
13
+ unit: pt
7
14
  text:
8
15
  color: "#000000"
9
16
  weight_normal: 400
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)
@@ -43,6 +43,12 @@ module Ea
43
43
  map "annotations", to: :annotations, render_empty: true
44
44
  end
45
45
 
46
+ # True for an object diagram. QEA stores "Object", the XMI adapter
47
+ # yields lowercase "object"; compare case-insensitively.
48
+ def object_diagram?
49
+ diagram_type.to_s.casecmp?("Object")
50
+ end
51
+
46
52
  # Parses style_ex into a Hash of flag => value. Returns empty
47
53
  # Hash when style_ex is nil/empty.
48
54
  #
@@ -102,7 +102,6 @@ module Ea
102
102
  # @raise [Errno::ENOENT] if QEA file not found
103
103
  # @raise [SQLite3::Exception] if database access fails
104
104
  def load
105
- # Connect but don't use with_connection - we need to keep it open
106
105
  @connection.connect unless @connection.connected?
107
106
  db = @connection.connection
108
107
 
@@ -117,6 +116,10 @@ module Ea
117
116
  end
118
117
 
119
118
  database.freeze
119
+ ensure
120
+ # All tables are in memory; nothing reads the connection after
121
+ # load, and an open handle locks the file on Windows.
122
+ @connection.close
120
123
  end
121
124
 
122
125
  # Load a single table
@@ -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.
@@ -37,6 +37,7 @@ module Ea
37
37
  )
38
38
  mark_connector_proxies(doc)
39
39
  apply_tagged_values(doc)
40
+ apply_constraints(doc)
40
41
  apply_receptions(doc)
41
42
  apply_stereotypes(doc)
42
43
  resolve_type_names(doc)
@@ -93,6 +94,17 @@ module Ea
93
94
  end
94
95
  end
95
96
 
97
+ # Attach the extension's element constraints to their classifiers,
98
+ # as the QEA adapter does from t_objectconstraint.
99
+ def apply_constraints(doc)
100
+ grouped = ConstraintBuilder.new(xmi_path).grouped_by_element
101
+ return if grouped.empty?
102
+
103
+ doc.classifiers.each do |c|
104
+ c.constraints = grouped[c.id] if c.id && grouped[c.id]
105
+ end
106
+ end
107
+
96
108
  # Attach tagged values parsed from the <xmi:Extension><tags>
97
109
  # block to their owning classifiers and packages.
98
110
  def apply_tagged_values(doc)
@@ -130,6 +142,7 @@ module Ea
130
142
  (classifier.properties || []).each do |prop|
131
143
  prop.type_name = types.attribute_type(prop.id) ||
132
144
  qualified_by_id[prop.type_ref] || prop.type_name
145
+ apply_attribute_stereotype(prop, types)
133
146
  end
134
147
  (classifier.operations || []).each do |op|
135
148
  resolve_operation_types(op, qualified_by_id, types)
@@ -137,6 +150,13 @@ module Ea
137
150
  end
138
151
  end
139
152
 
153
+ def apply_attribute_stereotype(prop, types)
154
+ stereotype = types.attribute_stereotype(prop.id)
155
+ return if stereotype.nil? || prop.stereotype_refs.to_a.any?
156
+
157
+ prop.stereotype_refs = [stereotype]
158
+ end
159
+
140
160
  def resolve_operation_types(operation, qualified_by_id, types)
141
161
  operation.return_type_name = types.return_type(operation.id) ||
142
162
  qualified_by_id[operation.return_type_name] ||
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ea
4
+ module Sources
5
+ module Xmi
6
+ # Indexes the extension's `<connector>` nodes by `xmi:idref`. EA never
7
+ # repeats `<source>`, `<target>`, `<properties>` or `<xrefs>` inside one
8
+ # connector; if a hand-edited file does, the parse yields an Array where
9
+ # a single node is read, so the first occurrence is kept.
10
+ module ConnectorIndex
11
+ SINGLE_NODES = %i[source target properties xrefs].freeze
12
+
13
+ def self.build(root)
14
+ connectors = root.extension&.connectors&.connector || []
15
+ connectors.each_with_object({}) do |connector, index|
16
+ next unless connector.idref
17
+
18
+ SINGLE_NODES.each { |name| keep_first(connector, name) }
19
+ index[connector.idref] = connector
20
+ end
21
+ end
22
+
23
+ def self.keep_first(connector, name)
24
+ value = connector.public_send(name)
25
+ connector.public_send(:"#{name}=", value.first) if value.is_a?(Array)
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "nokogiri"
4
+
5
+ module Ea
6
+ module Sources
7
+ module Xmi
8
+ # Extracts element constraints from the `<xmi:Extension><elements>`
9
+ # block (`<element><constraints><constraint/></constraints>`), the
10
+ # XMI image of t_objectconstraint. The xmi gem does not expose them,
11
+ # so the file is streamed once and grouped by owning element id.
12
+ class ConstraintBuilder
13
+ attr_reader :xmi_path
14
+
15
+ def initialize(xmi_path)
16
+ @xmi_path = xmi_path
17
+ end
18
+
19
+ # Returns Hash{element_id => Array<Ea::Model::Constraint>}
20
+ def grouped_by_element
21
+ return {} if xmi_path.nil?
22
+
23
+ @grouped_by_element ||= scan
24
+ end
25
+
26
+ private
27
+
28
+ def scan
29
+ @path = []
30
+ @owner = nil
31
+ @grouped = {}
32
+ @seen_owners = {}
33
+ @blocks_seen = {}
34
+ @skip_block = false
35
+ File.open(xmi_path) { |io| Nokogiri::XML::Reader(io).each { |node| visit(node) } }
36
+ @grouped
37
+ end
38
+
39
+ def visit(node)
40
+ case node.node_type
41
+ when Nokogiri::XML::Reader::TYPE_END_ELEMENT
42
+ @owner = nil if @path.pop == "element"
43
+ when Nokogiri::XML::Reader::TYPE_ELEMENT
44
+ enter(node)
45
+ @path.push(node.local_name) unless node.self_closing?
46
+ end
47
+ end
48
+
49
+ def enter(node)
50
+ if node.local_name == "element" && @path.last == "elements" && !node.self_closing?
51
+ @owner = first_block_owner(node.attribute("xmi:idref"))
52
+ elsif node.local_name == "constraints" && @owner && @path.last == "element"
53
+ @skip_block = @blocks_seen.key?(@owner)
54
+ @blocks_seen[@owner] = true
55
+ elsif node.local_name == "constraint" && @owner && !@skip_block && @path.last(2) == %w[element constraints]
56
+ (@grouped[@owner] ||= []) << constraint(node)
57
+ end
58
+ end
59
+
60
+ # A repeated `<element xmi:idref="X">` or `<constraints>` block would
61
+ # append X's constraints again, so only the first of each is read.
62
+ def first_block_owner(idref)
63
+ return nil if @seen_owners.key?(idref)
64
+
65
+ @seen_owners[idref] = true
66
+ idref
67
+ end
68
+
69
+ def constraint(node)
70
+ Ea::Model::Constraint.new(
71
+ id: IdNormalizer.synthetic_id(@owner, "constraint", @grouped[@owner].to_a.size.to_s),
72
+ name: node.attribute("name").to_s,
73
+ kind: node.attribute("type").to_s,
74
+ body: node.attribute("description").to_s,
75
+ status: node.attribute("status").to_s
76
+ )
77
+ end
78
+ end
79
+ end
80
+ end
81
+ end