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
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 34c3f680cb51a88a683e57db2c059fcee8979e5b6b31cfaae4c3cbc3ac91ac86
4
- data.tar.gz: a4cbd25958c54c24d6d1801bf4e4cc83f76f42d652f371cf9d491865160d7725
3
+ metadata.gz: 4f4c5df621df87a4df4467c9cbe157f70ea49d0463a892ed7e57ada3550b1937
4
+ data.tar.gz: 69fdddf140a6553c565287ce099bfa7f3d70d6c981502c559368c2e9860e70db
5
5
  SHA512:
6
- metadata.gz: c87682bc64b73abb6502c0dfd71ca7b9209ab8bcd71b79762032d0c4008f80999e3785257b72f39d73a816247d1dbcc5580a70e64f526a8ee98ee1aab32b645a
7
- data.tar.gz: ed3c4dd24e1ed8b1bd9c371545d56392cda07b2e0f3223f27d84761b728963c5df7df67fab8da905bdd586150fb94750a5cbe3f2bd1bec9fd11adfd3ec18b9c6
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)
@@ -59,6 +59,8 @@ module Ea
59
59
  File.expand_path("cjk_fallback_widths.json", __dir__)
60
60
  )).fetch("widths").freeze
61
61
  CJK_FALLBACK_FAMILIES = %w[Carlito Cambria].freeze
62
+ # Logical height from which a bold Cambria CJK run uses regular advances.
63
+ CAMBRIA_REGULAR_BOLD_MIN_HEIGHT = 17
62
64
  # Carlito ASCII advances measured on Windows per "height:weight:style";
63
65
  # they take precedence over the quantized design advances.
64
66
  CARLITO_GDI_WIDTHS = JSON.parse(File.read(
@@ -71,7 +73,7 @@ module Ea
71
73
  )).fetch("advance").freeze
72
74
  private_constant :CAMBRIA_7_REGULAR_WIDTHS, :CAMBRIA_7_STYLED_WIDTHS,
73
75
  :CAMBRIA_7_GUILLEMET_PAIR_WIDTH, :VERTICAL_METRICS,
74
- :CJK_FALLBACK_WIDTHS, :CJK_FALLBACK_FAMILIES,
76
+ :CJK_FALLBACK_WIDTHS, :CJK_FALLBACK_FAMILIES, :CAMBRIA_REGULAR_BOLD_MIN_HEIGHT,
75
77
  :CARLITO_GDI_WIDTHS, :SOURCE_SANS_PRO_ADVANCES
76
78
 
77
79
  # Advance used for codepoints missing from the tables.
@@ -103,6 +105,7 @@ module Ea
103
105
  # Source sizes 90 and 100 both export as 9pt but retain
104
106
  # distinct 12px and 13px logical heights. Apply that same
105
107
  # source-height delta to the existing compartment spacing.
108
+ logical_height = source_logical_height(logical_height)
106
109
  adjustment = if logical_height && unit == "pt" &&
107
110
  GDI_HEIGHT_FAMILIES.include?(resolve_family(family))
108
111
  logical_height - gdi_logical_height(size)
@@ -112,6 +115,13 @@ module Ea
112
115
  size + offset + adjustment
113
116
  end
114
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
+
115
125
  def gdi_logical_height(size)
116
126
  (size.to_f * 10 / 7).round
117
127
  end
@@ -150,11 +160,17 @@ module Ea
150
160
  # textLength is the SUM of those per-glyph integers - corpus
151
161
  # fitted to 97-100% exact on 10k Carlito texts (bold 7pt
152
162
  # 1666/1671). The legacy sum-then-round model matched only 27%.
153
- 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)
154
168
  return nil unless supported_family?(family)
155
169
  return nil if text.nil? || text.empty?
156
170
 
157
- 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
158
174
 
159
175
  advance_text_length(text, size_pt, family, weight, style, logical_height)
160
176
  end
@@ -162,7 +178,7 @@ module Ea
162
178
  def advance_text_length(text, size_pt, family, weight, style, logical_height)
163
179
  resolved = resolve_family(family)
164
180
  if GDI_HEIGHT_FAMILIES.include?(resolved)
165
- height = logical_height || gdi_logical_height(size_pt)
181
+ height = source_logical_height(logical_height) || gdi_logical_height(size_pt)
166
182
  table = table_for(family, weight, style)
167
183
  fallback = cjk_fallback_widths(resolved, height, weight, style)
168
184
  measured_latin = measured_carlito_widths(resolved, height, weight, style)
@@ -204,8 +220,16 @@ module Ea
204
220
  end
205
221
  private_class_method :measured_carlito_widths
206
222
 
207
- def cambria_text_length(text, size_pt, weight, style)
208
- return nil 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
209
233
  return nil unless ["", "0", "400", "normal", "700", "bold"].include?(weight.to_s)
210
234
  return nil unless ["", "normal", "italic"].include?(style.to_s)
211
235
 
@@ -220,6 +244,30 @@ module Ea
220
244
  end
221
245
  private_class_method :cambria_text_length
222
246
 
247
+ # Cambria has no Latin table outside 7pt, but a run made only of
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)
265
+ widths = cjk_fallback_widths("Cambria", height, bold ? 700 : 400, style)
266
+ advances = text.each_char.map { |char| widths[char] }
267
+ advances.sum unless advances.include?(nil)
268
+ end
269
+ private_class_method :cambria_cjk_only_length
270
+
223
271
  def cambria_cjk_widths(weight, style)
224
272
  cjk_fallback_widths("Cambria", gdi_logical_height(7), weight, style)
225
273
  end
data/lib/ea/model/note.rb CHANGED
@@ -15,6 +15,7 @@ module Ea
15
15
  attribute :body, :string
16
16
  attribute :note_type, :string # "Note" | "Text" | "File"
17
17
  attribute :legend, Legend
18
+ attribute :package_id, :string # containing package
18
19
 
19
20
  json do
20
21
  map "id", to: :id
@@ -22,6 +23,7 @@ module Ea
22
23
  map "body", to: :body
23
24
  map "noteType", to: :note_type
24
25
  map "legend", to: :legend
26
+ map "packageId", to: :package_id
25
27
  end
26
28
  end
27
29
  end
@@ -6,6 +6,12 @@ module Ea
6
6
  class DiagramBuilder
7
7
  # Internal geometry for choosing the facing edges of stored routes.
8
8
  module Routing
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
14
+
9
15
  private
10
16
 
11
17
  # Offsets stay attached to semantic Start/End IDs even when the
@@ -17,21 +23,153 @@ module Ea
17
23
  connector = database.find_connector(link_row.connectorid)
18
24
  return model unless connector && reversed_corner_link?(link_row, diagram_row, connector)
19
25
 
20
- 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)
21
39
  origins = [stored_ray_origin(boxes[0], geom[:sx], geom[:sy]),
22
40
  stored_ray_origin(boxes[1], geom[:ex], geom[:ey])]
23
- x, y = intermediate_waypoints(link_row).first
24
- bend = Ea::Model::Point.new(x: x, y: y)
41
+ bend = Ea::Model::Point.new(x: corner[0], y: corner[1])
25
42
  ends = origins.zip(boxes).map do |origin, box|
26
43
  inside_box?(origin, box) ? diagonal_corner_exit(origin, bend, box) : origin
27
44
  end
28
- return model unless ends.all?
45
+ [origins, [ends[0], bend, ends[1]]] if ends.all?
46
+ end
47
+
48
+ # EMPIRICAL FIT (decision a), not a source-backed rule. A width-0
49
+ # Mode 3 vertical-tree Association whose saved SOID/EOID name the
50
+ # relationship's ends in reverse keeps a Path (bus height and
51
+ # bend order) that EA no longer draws: its native export routes
52
+ # from the semantic Start box to the End box, with the bus 50px
53
+ # beyond the End box's facing edge (the distance every stored tree
54
+ # bus in the corpus keeps from its End box, 600+ links), the Start
55
+ # leg at center + SX and the End leg at center + EX. Checked on
56
+ # plateau diagram 5038EEE3 connectors 208, 209 and 212 (exact);
57
+ # 211 and 902 follow the leg pull documented on reversed_tree_leg.
58
+ def reversed_tree_route(model, link_row, diagram_row, geom, context)
59
+ return model if context || !reversed_tree_link?(model, link_row, diagram_row, geom)
60
+
61
+ start, finish = stored_ray_boxes(link_row, diagram_row)
62
+ route = reversed_tree_points(start, finish, geom, stored_bus_levels(link_row))
63
+ return model unless route
64
+
65
+ points, beside = route
66
+ model.waypoints = points.map do |x, y|
67
+ Ea::Model::Waypoint.new(position: Ea::Model::Point.new(x: x, y: y))
68
+ end
69
+ model.source_anchor = model.source_port = model.waypoints.first.position
70
+ model.target_anchor = model.target_port = model.waypoints.last.position
71
+ # EMPIRICAL FIT: a leg that starts beside its box (5038EEE3
72
+ # connector 212) seats its source labels like a leftward leg.
73
+ model.style = model.style.to_h.merge(left_seated_source: true) if beside
74
+ model
75
+ end
29
76
 
30
- model.waypoints = [ends[0], bend, ends[1]].map { |point| Ea::Model::Waypoint.new(position: point) }
31
- model.source_anchor, model.target_anchor = origins
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
+
93
+ # EMPIRICAL FIT (decision a). EA seats the source labels of a
94
+ # vertical lateral tree ("LV") whose SOID/EOID name the ends in
95
+ # reverse as if the source leg left to the left, whichever way the
96
+ # route leaves the box. Measured on the only such link in the
97
+ # plateau corpus, 5038EEE3 connector 207 (+lod1MultiSurface).
98
+ def mark_reversed_lateral_tree(model, link_row, diagram_row, context)
99
+ return model if context
100
+
101
+ style = DiagramStyleParser.parse(link_row.style)
102
+ return model unless style[:tree] == "LV" && model.connector_type == "Association"
103
+ return model unless placement_order_reversed?(style, diagram_row,
104
+ database.find_connector(link_row.connectorid))
105
+
106
+ model.style = model.style.to_h.merge(left_seated_source: true)
32
107
  model
33
108
  end
34
109
 
110
+ def reversed_tree_link?(model, link_row, diagram_row, geom)
111
+ style = DiagramStyleParser.parse(link_row.style)
112
+ model.connector_type == "Association" &&
113
+ style.values_at(:mode, :lwidth, :tree) == %w[3 0 V] &&
114
+ geom.values_at(:sx, :sy, :ex, :ey).all? &&
115
+ stored_bus_levels(link_row) &&
116
+ placement_order_reversed?(style, diagram_row, database.find_connector(link_row.connectorid))
117
+ end
118
+
119
+ def stored_bus_levels(link_row)
120
+ bends = intermediate_waypoints(link_row)
121
+ bends.first[1] if bends.size == 2 && bends.first[1] == bends.last[1]
122
+ end
123
+
124
+ # [bus height, Start facing edge, End facing edge] for vertically
125
+ # separated boxes: the bus sits 50px past the End box's edge and
126
+ # must stay inside the gap. Nil otherwise.
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)
135
+ if finish.y + finish.height < start.y
136
+ bus = finish.y + finish.height + REVERSED_TREE_BUS_OFFSET
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)
140
+ elsif start.y + start.height < finish.y
141
+ bus = finish.y - REVERSED_TREE_BUS_OFFSET
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)
145
+ end
146
+ end
147
+
148
+ # A leg runs vertically at the stored origin's x. It starts on the
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.
152
+ #
153
+ # EMPIRICAL FIT (decision a): the leg's x is pulled toward the box
154
+ # centre by |offset_y| / (vertical centre distance to the other box).
155
+ # Checked on 5038EEE3 connectors 211 (SX 41, SY -125: native 13px
156
+ # left of centre+SX), 902 (SX 64, SY -13: 2px) and 212, 208, 209
157
+ # (SY within 5: the rounded-up pull leaves centre + SX unchanged);
158
+ # the offset is rounded away from zero, as all five require.
159
+ # Derived from these five SX/SY pairs, not from EA documentation.
160
+ def reversed_tree_leg(box, offset_x, offset_y, edge_y, other)
161
+ span = ((other.y + other.height / 2.0) - (box.y + box.height / 2.0)).abs
162
+ pull = span.positive? ? 1 - [offset_y.to_i.abs / span, 1].min : 1
163
+ pulled = (offset_x.to_i.abs * pull).ceil * (offset_x.to_i.negative? ? -1 : 1)
164
+ origin = stored_ray_origin(box, pulled, offset_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
171
+ end
172
+
35
173
  # Path follows SOID/EOID placement order, independently of the
36
174
  # relationship's Start/End IDs. Only a complete, unique DUID pair
37
175
  # can reverse the authored single-corner route.
@@ -42,6 +180,10 @@ module Ea
42
180
  parse_geometry_fields(link_row.geometry)[:edge] == 2 &&
43
181
  intermediate_waypoints(link_row).size == 1
44
182
 
183
+ placement_order_reversed?(style, diagram_row, connector)
184
+ end
185
+
186
+ def placement_order_reversed?(style, diagram_row, connector)
45
187
  ids = style.values_at(:soid, :eoid)
46
188
  return false if ids.any? { |id| id.to_s.empty? } || ids.uniq.size != 2
47
189
 
@@ -695,8 +837,7 @@ module Ea
695
837
  return nil
696
838
  end
697
839
 
698
- first = outline_dock(corners[0], corners[1], source)
699
- last = outline_dock(corners[-1], corners[-2], target)
840
+ first, last = stair_docks(link_row, corners, source, target)
700
841
  return nil unless distinct_stair_docks?(corners, first, last)
701
842
 
702
843
  [first, *corners, last]
@@ -728,6 +869,45 @@ module Ea
728
869
  :horizontal if from[1] == to[1] && from[0] != to[0]
729
870
  end
730
871
 
872
+ def stair_docks(link_row, corners, source, target)
873
+ geom = parse_geometry_fields(link_row.geometry)
874
+ [stair_dock(corners[0], corners[1], source, geom.values_at(:sx, :sy)),
875
+ stair_dock(corners[-1], corners[-2], target, geom.values_at(:ex, :ey))]
876
+ end
877
+
878
+ # A corner level with its box docks on the outline the leg points at.
879
+ # When the stored origin sits off that row, EA aims the last leg at
880
+ # the origin instead and the dock slants onto the same outline
881
+ # (30D62231 connector 601: bend row 430, origin row 425, dock row 428).
882
+ def stair_dock(corner, neighbour, box, offsets)
883
+ dock = outline_dock(corner, neighbour, box)
884
+ return dock unless dock && offsets.none?(&:nil?)
885
+
886
+ origin = stored_ray_origin(box, *offsets)
887
+ return dock unless inside_box?(origin, grown_bounds(box, 1))
888
+
889
+ slanted_dock([origin.x, origin.y], corner, dock) || dock
890
+ end
891
+
892
+ # Where the line from the stored origin to the corner meets the
893
+ # outline row/column of the orthogonal dock; the other axis floors.
894
+ def slanted_dock(origin, corner, dock)
895
+ axis = dock[0] == corner[0] ? 1 : 0
896
+ other = intercept(origin, corner, axis, dock[axis])
897
+ return unless other
898
+
899
+ axis.zero? ? [dock[0], other] : [other, dock[1]]
900
+ end
901
+
902
+ def intercept(origin, corner, axis, face)
903
+ run = corner[axis] - origin[axis]
904
+ fraction = Rational(face - origin[axis], run) unless run.zero?
905
+ return unless fraction&.between?(0, 1)
906
+
907
+ other = 1 - axis
908
+ (origin[other] + fraction * (corner[other] - origin[other])).floor
909
+ end
910
+
731
911
  # The dock runs perpendicular to the corner's own leg.
732
912
  def outline_dock(corner, neighbour, box)
733
913
  if corner[1] == neighbour[1]
@@ -128,6 +128,7 @@ module Ea
128
128
  @current_obj_row = obj_row
129
129
  style_hash = DiagramStyleParser.parse(obj_row.objectstyle)
130
130
  style_hash[:stored_attribute_order] = true
131
+ style_hash["connector_proxy"] = true if connector_proxy?(obj_row)
131
132
  fqname = stereotype_fqname_for(obj_row)
132
133
  style_hash[:stereotype_fqname] = fqname if fqname
133
134
  font_style = effective_font_style(obj_row, style_hash)
@@ -154,6 +155,12 @@ module Ea
154
155
  )
155
156
  end
156
157
 
158
+ # EA anchors association-class links on ProxyConnector stubs and
159
+ # never draws them (the XMI source flags them the same way).
160
+ def connector_proxy?(obj_row)
161
+ database.find_object(obj_row.ea_object_id)&.object_type == "ProxyConnector"
162
+ end
163
+
157
164
  # Negative widths inherit; QEA zero widths request a one-pixel cosmetic pen.
158
165
  def effective_line_width(obj_row, style)
159
166
  object = database.find_object(obj_row.ea_object_id)
@@ -195,7 +202,10 @@ module Ea
195
202
  end
196
203
 
197
204
  def build_connectors(diagram_row)
198
- 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) }
199
209
  context = ContextDiagramSupport.context?(diagram_row)
200
210
  # EA regenerates context diagrams at draw time; their
201
211
  # Hidden link rows are stale and never render.
@@ -475,6 +485,7 @@ module Ea
475
485
  style: context ? { direct: true } :
476
486
  DiagramStyleParser.parse(link_row.style),
477
487
  hidden: hidden?(link_row),
488
+ line_width: connector&.isbold,
478
489
  ghost_labels: ghost_labels_for(connector, diagram_row, link_row),
479
490
  has_geometry_offsets: geometry_has_offsets?(link_row.geometry),
480
491
  is_substitutable: substitutable_connector?(connector)
@@ -494,6 +505,8 @@ module Ea
494
505
  model = mark_default_square_ray(model, link_row, geom, context)
495
506
  model = restore_stored_square_ports(model, link_row, diagram_row, geom, context)
496
507
  model = reversed_stored_corner(model, link_row, diagram_row, geom, context)
508
+ model = reversed_tree_route(model, link_row, diagram_row, geom, context)
509
+ model = mark_reversed_lateral_tree(model, link_row, diagram_row, context)
497
510
  mark_association_class(model, connector, diagram_row)
498
511
  end
499
512
 
@@ -38,10 +38,16 @@ module Ea
38
38
  name: obj.name,
39
39
  body: obj.note,
40
40
  note_type: obj.object_type,
41
- legend: legend_for(obj)
41
+ legend: legend_for(obj),
42
+ package_id: package_id_for(obj)
42
43
  )
43
44
  end
44
45
 
46
+ def package_id_for(obj)
47
+ pkg = database.find_package(obj.package_id)
48
+ pkg && IdNormalizer.from_guid(pkg.ea_guid)
49
+ end
50
+
45
51
  def note_type?(object_type)
46
52
  object_type == NOTE_OBJECT_TYPE || object_type == TEXT_OBJECT_TYPE
47
53
  end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ea
4
+ module Sources
5
+ module Xmi
6
+ class DiagramBuilder
7
+ # An element placed without its own LWth takes the border width saved on
8
+ # the model element (<style appearance="...BorderWidth=n;...">), the same
9
+ # t_object.BorderWidth fallback the QEA source applies. Checked on
10
+ # AB281AC1, where the missing width left the Association 1px long.
11
+ class ElementBorders
12
+ def initialize(root)
13
+ @elements = root.extension&.elements&.element.to_a.to_h { |element| [element.idref, element] }
14
+ end
15
+
16
+ def width_for(subject)
17
+ appearance = @elements[subject]&.style&.appearance.to_s
18
+ raw = ExtensionStyleParser.split_pairs(appearance)["BorderWidth"]
19
+ return nil if raw.nil? || raw.to_i.negative?
20
+
21
+ [raw.to_i, 1].max
22
+ end
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end