elkrb 1.0.0 → 2.0.0

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 (144) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.adoc +364 -0
  3. data/LICENSE +25 -0
  4. data/README.adoc +695 -557
  5. data/exe/elkrb +36 -2
  6. data/lib/elkrb/atomic_destination.rb +53 -0
  7. data/lib/elkrb/best_effort_write.rb +49 -0
  8. data/lib/elkrb/cli/layout_flag_options.rb +54 -0
  9. data/lib/elkrb/cli.rb +155 -82
  10. data/lib/elkrb/command_resolver.rb +186 -0
  11. data/lib/elkrb/commands/batch_command.rb +28 -5
  12. data/lib/elkrb/commands/convert_command.rb +22 -52
  13. data/lib/elkrb/commands/diagram_command.rb +234 -102
  14. data/lib/elkrb/commands/options_command.rb +87 -0
  15. data/lib/elkrb/commands/render_command.rb +6 -1
  16. data/lib/elkrb/commands/validate_command.rb +146 -91
  17. data/lib/elkrb/errors.rb +20 -1
  18. data/lib/elkrb/format_sniffer.rb +362 -0
  19. data/lib/elkrb/geometry/bezier.rb +14 -4
  20. data/lib/elkrb/geometry/point.rb +5 -6
  21. data/lib/elkrb/geometry/rectangle.rb +2 -2
  22. data/lib/elkrb/graph/deep_stringify_keys.rb +30 -0
  23. data/lib/elkrb/graph/edge.rb +89 -3
  24. data/lib/elkrb/graph/graph.rb +43 -15
  25. data/lib/elkrb/graph/label.rb +16 -2
  26. data/lib/elkrb/graph/node.rb +13 -2
  27. data/lib/elkrb/graph/node_constraints.rb +139 -8
  28. data/lib/elkrb/graph/normalize_option_keys.rb +39 -0
  29. data/lib/elkrb/graph/port.rb +29 -21
  30. data/lib/elkrb/graph/read_only_mapping.rb +21 -0
  31. data/lib/elkrb/graphviz_wrapper.rb +152 -44
  32. data/lib/elkrb/layout/algorithm_registry.rb +65 -8
  33. data/lib/elkrb/layout/algorithms/base_algorithm.rb +74 -74
  34. data/lib/elkrb/layout/algorithms/box.rb +47 -20
  35. data/lib/elkrb/layout/algorithms/disco.rb +150 -61
  36. data/lib/elkrb/layout/algorithms/fixed.rb +70 -10
  37. data/lib/elkrb/layout/algorithms/force.rb +145 -86
  38. data/lib/elkrb/layout/algorithms/layered/bk/aligner.rb +104 -0
  39. data/lib/elkrb/layout/algorithms/layered/bk/compactor.rb +87 -0
  40. data/lib/elkrb/layout/algorithms/layered/bk/view.rb +103 -0
  41. data/lib/elkrb/layout/algorithms/layered/bk_node_placer.rb +170 -0
  42. data/lib/elkrb/layout/algorithms/layered/components.rb +170 -0
  43. data/lib/elkrb/layout/algorithms/layered/crossing_minimizer.rb +306 -0
  44. data/lib/elkrb/layout/algorithms/layered/cycle_breaker.rb +118 -49
  45. data/lib/elkrb/layout/algorithms/layered/discovery_order.rb +75 -0
  46. data/lib/elkrb/layout/algorithms/layered/dummy_slot.rb +31 -0
  47. data/lib/elkrb/layout/algorithms/layered/layer_assigner.rb +188 -48
  48. data/lib/elkrb/layout/algorithms/layered/layer_constraints.rb +82 -0
  49. data/lib/elkrb/layout/algorithms/layered/layer_sweep.rb +195 -0
  50. data/lib/elkrb/layout/algorithms/layered/node_placer.rb +214 -36
  51. data/lib/elkrb/layout/algorithms/layered/orthogonal/gap_solver.rb +219 -0
  52. data/lib/elkrb/layout/algorithms/layered/orthogonal/hyper_edge_segment.rb +152 -0
  53. data/lib/elkrb/layout/algorithms/layered/orthogonal/junction_points.rb +58 -0
  54. data/lib/elkrb/layout/algorithms/layered/orthogonal/segment_order.rb +134 -0
  55. data/lib/elkrb/layout/algorithms/layered/orthogonal_router.rb +253 -0
  56. data/lib/elkrb/layout/algorithms/layered/port_order.rb +188 -0
  57. data/lib/elkrb/layout/algorithms/layered/port_spread.rb +42 -0
  58. data/lib/elkrb/layout/algorithms/layered.rb +548 -13
  59. data/lib/elkrb/layout/algorithms/libavoid.rb +951 -187
  60. data/lib/elkrb/layout/algorithms/mrtree.rb +585 -65
  61. data/lib/elkrb/layout/algorithms/radial.rb +188 -29
  62. data/lib/elkrb/layout/algorithms/random.rb +131 -17
  63. data/lib/elkrb/layout/algorithms/rectpacking.rb +28 -40
  64. data/lib/elkrb/layout/algorithms/spore_compaction.rb +57 -100
  65. data/lib/elkrb/layout/algorithms/spore_overlap.rb +78 -84
  66. data/lib/elkrb/layout/algorithms/stress.rb +391 -84
  67. data/lib/elkrb/layout/algorithms/topdown_packing.rb +95 -31
  68. data/lib/elkrb/layout/algorithms/vertiflex.rb +12 -47
  69. data/lib/elkrb/layout/constraints/alignment_constraint.rb +49 -12
  70. data/lib/elkrb/layout/constraints/base_constraint.rb +3 -15
  71. data/lib/elkrb/layout/constraints/constraint_processor.rb +9 -23
  72. data/lib/elkrb/layout/constraints/fixed_position_constraint.rb +22 -13
  73. data/lib/elkrb/layout/constraints/layer_constraint.rb +10 -33
  74. data/lib/elkrb/layout/constraints/relative_position_constraint.rb +94 -50
  75. data/lib/elkrb/layout/edge_router.rb +533 -337
  76. data/lib/elkrb/layout/hierarchical_processor.rb +19 -276
  77. data/lib/elkrb/layout/java_random.rb +59 -0
  78. data/lib/elkrb/layout/label_placer.rb +165 -157
  79. data/lib/elkrb/layout/layout_engine.rb +46 -23
  80. data/lib/elkrb/layout/node_index.rb +149 -0
  81. data/lib/elkrb/layout/polyomino/component_compactor.rb +138 -0
  82. data/lib/elkrb/layout/polyomino/footprint.rb +94 -0
  83. data/lib/elkrb/layout/polyomino/grid.rb +106 -0
  84. data/lib/elkrb/layout/polyomino/packer.rb +95 -0
  85. data/lib/elkrb/layout/polyomino/piece.rb +52 -0
  86. data/lib/elkrb/layout/port_constraint_processor.rb +131 -44
  87. data/lib/elkrb/layout/spore/body.rb +65 -0
  88. data/lib/elkrb/layout/spore/depth_first_compaction.rb +127 -0
  89. data/lib/elkrb/layout/spore/edge_cost.rb +69 -0
  90. data/lib/elkrb/layout/spore/gap.rb +76 -0
  91. data/lib/elkrb/layout/spore/geometry.rb +104 -0
  92. data/lib/elkrb/layout/spore/overlap_sweep.rb +90 -0
  93. data/lib/elkrb/layout/spore/placement.rb +57 -0
  94. data/lib/elkrb/layout/spore/rays.rb +119 -0
  95. data/lib/elkrb/layout/spore/spanning_tree.rb +53 -0
  96. data/lib/elkrb/layout/spore/triangulation.rb +159 -0
  97. data/lib/elkrb/layout_flags.rb +69 -0
  98. data/lib/elkrb/options/decimal.rb +50 -0
  99. data/lib/elkrb/options/element_options.rb +43 -0
  100. data/lib/elkrb/options/elk_padding.rb +20 -14
  101. data/lib/elkrb/options/k_vector.rb +6 -7
  102. data/lib/elkrb/options/k_vector_chain.rb +12 -20
  103. data/lib/elkrb/options/option_map.rb +85 -0
  104. data/lib/elkrb/options/registry.rb +335 -0
  105. data/lib/elkrb/options/resolver.rb +151 -0
  106. data/lib/elkrb/options/spellings.rb +62 -0
  107. data/lib/elkrb/options/unhonoured_report.rb +194 -0
  108. data/lib/elkrb/parsers/elkt/lexer.rb +309 -0
  109. data/lib/elkrb/parsers/elkt/parser.rb +424 -0
  110. data/lib/elkrb/parsers/elkt/resolver.rb +142 -0
  111. data/lib/elkrb/parsers/elkt/token.rb +40 -0
  112. data/lib/elkrb/parsers/elkt_parser.rb +32 -230
  113. data/lib/elkrb/serializers/dot_serializer.rb +342 -54
  114. data/lib/elkrb/serializers/elkt_serializer.rb +386 -155
  115. data/lib/elkrb/version.rb +1 -1
  116. data/lib/elkrb.rb +60 -61
  117. metadata +71 -49
  118. data/.rspec +0 -3
  119. data/.rubocop.yml +0 -11
  120. data/Gemfile +0 -13
  121. data/Rakefile +0 -64
  122. data/benchmarks/README.md +0 -172
  123. data/benchmarks/elkjs_benchmark.js +0 -140
  124. data/benchmarks/elkrb_benchmark.rb +0 -145
  125. data/benchmarks/fixtures/graphs.json +0 -10777
  126. data/benchmarks/generate_report.rb +0 -241
  127. data/benchmarks/generate_test_graphs.rb +0 -154
  128. data/benchmarks/results/elkrb_results.json +0 -280
  129. data/benchmarks/results/elkrb_summary.json +0 -285
  130. data/elkrb.gemspec +0 -39
  131. data/examples/dot_export_demo.rb +0 -133
  132. data/examples/hierarchical_graph.rb +0 -19
  133. data/examples/layout_constraints_demo.rb +0 -272
  134. data/examples/port_constraints_demo.rb +0 -291
  135. data/examples/self_loop_demo.rb +0 -391
  136. data/examples/simple_graph.rb +0 -50
  137. data/examples/spline_routing_demo.rb +0 -235
  138. data/lib/elkrb/graph/layout_options.rb +0 -247
  139. data/sig/elkrb/constraints.rbs +0 -114
  140. data/sig/elkrb/geometry.rbs +0 -61
  141. data/sig/elkrb/graph.rbs +0 -112
  142. data/sig/elkrb/layout.rbs +0 -107
  143. data/sig/elkrb/options.rbs +0 -81
  144. data/sig/elkrb.rbs +0 -32
@@ -0,0 +1,362 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "yaml"
5
+
6
+ module Elkrb
7
+ # @api private
8
+ #
9
+ # Shared "sniff JSON/YAML, fall back to ELKT" input-format detection.
10
+ # Every command reads its input through here: Cli#read_input_file,
11
+ # ConvertCommand#load_any_format, ValidateCommand#load_any_format and
12
+ # DiagramCommand#load_graph -- add a new one through this module too,
13
+ # not a fifth private method, or its guards silently drift from the rest.
14
+ module FormatSniffer
15
+ BYTE_ORDER_MARK = "\xEF\xBB\xBF".b.freeze
16
+ private_constant :BYTE_ORDER_MARK
17
+
18
+ UNPARSEABLE =
19
+ "Unable to parse input file. Supported formats: JSON, YAML, ELKT"
20
+ private_constant :UNPARSEABLE
21
+
22
+ DOT_UNSUPPORTED =
23
+ "DOT format input not yet supported. Use JSON, YAML, or ELKT."
24
+ private_constant :DOT_UNSUPPORTED
25
+
26
+ NIL_WHEN_HOLLOW = %i[
27
+ id x y width height layout_options children edges properties
28
+ ].freeze
29
+ private_constant :NIL_WHEN_HOLLOW
30
+
31
+ # Psych's own nesting limit raises before the stack ever overflows, so
32
+ # the SystemStackError rescue below never sees it. Matched by message
33
+ # because the raising class, Psych::SyntaxError, is also what a
34
+ # genuinely malformed document raises -- only the text tells them apart.
35
+ NESTING_DEPTH_MESSAGE = "nesting depth exceeds the limit"
36
+ private_constant :NESTING_DEPTH_MESSAGE
37
+
38
+ class << self
39
+ # The single entry point every command reads input through -- a guard
40
+ # belongs here, not per command. String#strip does NOT remove a BOM,
41
+ # so strip it here rather than per branch.
42
+ #
43
+ # @param content [String] raw file content
44
+ # @param extension [String] the file's downcased extension
45
+ # @return [Elkrb::Graph::Graph, Hash] the parsed graph
46
+ # @raise [Lutaml::Model::InvalidFormatError] on unparseable JSON/YAML
47
+ # @raise [Psych::Exception] when .yml/.yaml is valid YAML Psych refuses
48
+ # @raise [ArgumentError] for .dot/.gv, an unusable JSON/YAML model,
49
+ # empty ELKT/sniffed content, or YAML nested past Psych's limit
50
+ def read(content, extension)
51
+ require_relative "graph/graph"
52
+
53
+ read_by_extension(strip_byte_order_mark(content), extension)
54
+ rescue SystemStackError
55
+ # Psych recurses once per nesting level, so a few thousand open
56
+ # brackets overflow the stack. SystemStackError is not a
57
+ # StandardError, so it walked past every rescue below AND the CLI's
58
+ # own, and the user got a raw trace. Caught here because this is
59
+ # where the module states what it raises, and both the sniffed and
60
+ # the declared YAML branch can reach it.
61
+ raise ArgumentError, UNPARSEABLE
62
+ end
63
+
64
+ private
65
+
66
+ # Every branch here runs inside #read's SystemStackError rescue, and
67
+ # needs to: the declared .yml/.yaml branch overflows Psych's recursion
68
+ # as readily as the sniffed one does.
69
+ def read_by_extension(text, extension)
70
+ case extension
71
+ when ".json", ".yml", ".yaml" then read_model(text, extension)
72
+ when ".elkt" then parse_elkt_declared!(text)
73
+ when ".dot", ".gv" then raise ArgumentError, DOT_UNSUPPORTED
74
+ else parse(text)
75
+ end
76
+ end
77
+
78
+ # The mark is the three bytes EF BB BF, so it comes off by byte rather
79
+ # than by character. String#delete_prefix compares CHARACTERS, and a
80
+ # UTF-8 literal raises Encoding::CompatibilityError against a receiver
81
+ # in another encoding that holds any non-ASCII byte -- File.read tags
82
+ # content with Encoding.default_external, which the locale sets.
83
+ #
84
+ # @return [String] the content without its mark, in its own encoding
85
+ def strip_byte_order_mark(content)
86
+ return content unless content.byteslice(0, 3).b == BYTE_ORDER_MARK
87
+
88
+ content.byteslice(3..)
89
+ end
90
+
91
+ # Rescue Psych's safe-load refusals (a !ruby/object tag, an alias)
92
+ # explicitly: they are not in lutaml-model's normalized-error list, and
93
+ # falling through to the ELKT parser would read `foo: 1` as a layout
94
+ # option and exit 0 on valid YAML we decline to load.
95
+ #
96
+ # @param content [String] raw file content
97
+ # @return [Elkrb::Graph::Graph, Hash] a parsed graph model, or an ELKT
98
+ # hash
99
+ # @raise [ArgumentError] when neither JSON/YAML nor ELKT yields real
100
+ # content
101
+ def parse(content)
102
+ sniff(content) || parse_elkt_or_fail(content)
103
+ rescue Psych::Exception
104
+ raise ArgumentError, UNPARSEABLE
105
+ end
106
+
107
+ # JSON and YAML differ only in the deserializer; both then need the same
108
+ # shape check. The charset is normalized before either deserializer
109
+ # sees the content: a file tagged with a real source encoding other
110
+ # than UTF-8 (ISO-8859-1, say) or with none at all (BINARY) otherwise
111
+ # surfaces the parser's own encoding complaint instead of being read.
112
+ #
113
+ # @raise [ArgumentError] when the document parses to an unusable model,
114
+ # or is nested deeper than Psych's own limit allows
115
+ def read_model(content, extension)
116
+ text = normalize_charset(content)
117
+ model = if extension == ".json"
118
+ Elkrb::Graph::Graph.from_json(text)
119
+ else
120
+ Elkrb::Graph::Graph.from_yaml(text)
121
+ end
122
+
123
+ validate_model!(model, text, extension)
124
+ rescue Lutaml::Model::InvalidFormatError => e
125
+ raise ArgumentError, UNPARSEABLE if nesting_depth_exceeded?(e)
126
+
127
+ raise
128
+ end
129
+
130
+ def nesting_depth_exceeded?(error)
131
+ (error.cause || error).message.include?(NESTING_DEPTH_MESSAGE)
132
+ end
133
+
134
+ # content with a real, named source encoding is transcoded via
135
+ # String#encode, which is what turns a Latin-1 0xE9 into the accented
136
+ # character it actually names. UTF-8 and ASCII-8BIT carry no source
137
+ # encoding to transcode FROM, so they are reinterpreted as UTF-8
138
+ # directly; bytes that are not valid UTF-8 either way (a lone 0xE9
139
+ # tagged BINARY, with no named encoding to recover one from) are
140
+ # treated as Latin-1 as a last resort, since every byte value is a
141
+ # valid Latin-1 character and the transcode can never itself fail.
142
+ NO_SOURCE_ENCODING_TO_TRANSCODE_FROM =
143
+ [Encoding::UTF_8, Encoding::ASCII_8BIT].freeze
144
+ private_constant :NO_SOURCE_ENCODING_TO_TRANSCODE_FROM
145
+
146
+ def normalize_charset(text)
147
+ if NO_SOURCE_ENCODING_TO_TRANSCODE_FROM.include?(text.encoding)
148
+ reinterpret_or_latin1(text)
149
+ else
150
+ begin
151
+ text.encode(Encoding::UTF_8)
152
+ rescue EncodingError
153
+ text.dup.force_encoding(Encoding::ISO_8859_1).encode(Encoding::UTF_8)
154
+ end
155
+ end
156
+ end
157
+
158
+ def reinterpret_or_latin1(text)
159
+ reinterpreted = text.dup.force_encoding(Encoding::UTF_8)
160
+ return reinterpreted if reinterpreted.valid_encoding?
161
+
162
+ text.dup.force_encoding(Encoding::ISO_8859_1).encode(Encoding::UTF_8)
163
+ end
164
+
165
+ # A file whose extension names the format skips sniffing, so it also
166
+ # skips the malformed-shape check the sniffer applies. Both paths need
167
+ # it: lutaml coerces a mapping where a sequence belongs into one
168
+ # coerced model, and every downstream reader then breaks on it.
169
+ #
170
+ # @raise [ArgumentError] when the parsed model is not usable
171
+ def validate_model!(graph, raw_content, extension)
172
+ usable = graph.is_a?(Elkrb::Graph::Graph) &&
173
+ !malformed_shape?(raw_content, extension) &&
174
+ !hollow_model?(graph)
175
+ raise ArgumentError, UNPARSEABLE unless usable
176
+
177
+ graph
178
+ end
179
+
180
+ # A JSON document can only open with `{` or `[`, so anything else goes
181
+ # straight to YAML. Flow-style YAML opens with `{` too, though, so a
182
+ # failed JSON parse has to fall through to YAML before ELKT gets a
183
+ # turn -- otherwise a flow-style graph is read as ELKT layout options
184
+ # and silently loses its children.
185
+ #
186
+ # Normalized here for the same reason #read_model normalizes before
187
+ # its own deserializer runs: a file with no extension to declare its
188
+ # format still carries whatever charset it was written in, and
189
+ # skipping this would read identical non-UTF-8 content differently
190
+ # depending only on whether the caller happened to name the extension.
191
+ def sniff(text)
192
+ text = normalize_charset(text)
193
+
194
+ if text.lstrip.start_with?("{", "[")
195
+ return try_json(text) || try_yaml(text)
196
+ end
197
+
198
+ try_yaml(text)
199
+ end
200
+
201
+ def try_json(text)
202
+ real_graph(Elkrb::Graph::Graph.from_json(text), text, ".json")
203
+ rescue Lutaml::Model::InvalidFormatError
204
+ nil
205
+ end
206
+
207
+ def try_yaml(text)
208
+ real_graph(Elkrb::Graph::Graph.from_yaml(text), text, ".yaml")
209
+ rescue Lutaml::Model::InvalidFormatError
210
+ nil
211
+ end
212
+
213
+ # A top-level sequence (`[]`, `[{}]`, `- id: g`) parses without raising
214
+ # but comes back an Array, and lutaml-model 0.8.19 turns any mapping at
215
+ # all into a Graph with every field nil. Neither is a real parse, and
216
+ # calling #id on the Array would blow up.
217
+ def real_graph(result, raw_content, extension)
218
+ return nil unless result.is_a?(Elkrb::Graph::Graph)
219
+ return nil if malformed_shape?(raw_content, extension)
220
+
221
+ result unless hollow_model?(result)
222
+ end
223
+
224
+ # Given a mapping where a sequence belongs (`"children": {"a": 1}`),
225
+ # lutaml-model's coercion of it can be indistinguishable from a
226
+ # legitimate value by the time it reaches the parsed MODEL -- a
227
+ # coerced `Geometry::Point` defaults its x/y to 0.0, not nil, so it
228
+ # reads exactly like a real point at the origin. The check runs
229
+ # against the RAW parsed document instead, before any of that
230
+ # coercion happens.
231
+ #
232
+ # The key list mirrors every `collection: true` attribute in graph/,
233
+ # by both its JSON and its YAML spelling.
234
+ COLLECTION_KEYS = %w[
235
+ children edges labels ports sections sources targets
236
+ bendPoints bend_points
237
+ incomingSections incoming_sections
238
+ outgoingSections outgoing_sections
239
+ junctionPoints junction_points
240
+ ].freeze
241
+ private_constant :COLLECTION_KEYS
242
+
243
+ # properties and layout_options are declared `:hash` on every model
244
+ # (Graph, Node, Edge, Label) -- an opaque caller-defined bag, not part
245
+ # of the graph schema. A key the caller chose for their own metadata
246
+ # can coincidentally match a COLLECTION_KEYS name ("properties":
247
+ # {"children": "metadata"}); checking inside the bag against the
248
+ # schema's own key list is a false positive, not a malformed document.
249
+ OPAQUE_METADATA_KEYS = %w[properties layoutOptions layout_options].freeze
250
+ private_constant :OPAQUE_METADATA_KEYS
251
+
252
+ # @return [Boolean] true when a collection key, at any depth, holds
253
+ # something other than a sequence or an absent/null value
254
+ def malformed_shape?(raw_content, extension)
255
+ raw = parse_raw_structure(raw_content, extension)
256
+ return false if raw.nil?
257
+
258
+ raw_shape_malformed?(raw)
259
+ end
260
+
261
+ # A parse failure here is not this check's to report: the real
262
+ # deserializer already ran (successfully, to reach this point) or its
263
+ # own failure is handled by its own caller. Either way, nothing to
264
+ # compare the model's shape against, so the model is taken as given.
265
+ def parse_raw_structure(content, extension)
266
+ extension == ".json" ? ::JSON.parse(content) : ::YAML.safe_load(content)
267
+ rescue StandardError
268
+ nil
269
+ end
270
+
271
+ def raw_shape_malformed?(node)
272
+ case node
273
+ when ::Hash
274
+ hash_shape_malformed?(node)
275
+ when ::Array
276
+ node.any? { |item| raw_shape_malformed?(item) }
277
+ else
278
+ false
279
+ end
280
+ end
281
+
282
+ def hash_shape_malformed?(node)
283
+ return true if collection_key_malformed?(node)
284
+
285
+ node.each_pair.any? do |key, value|
286
+ next false if OPAQUE_METADATA_KEYS.include?(key)
287
+
288
+ raw_shape_malformed?(value)
289
+ end
290
+ end
291
+
292
+ def collection_key_malformed?(node)
293
+ COLLECTION_KEYS.any? do |key|
294
+ node.key?(key) && !node[key].nil? && !node[key].is_a?(::Array)
295
+ end
296
+ end
297
+
298
+ # Mirrors the field list in Graph's json and yaml mapping blocks: when
299
+ # every recognized field comes back nil, lutaml-model matched nothing
300
+ # and the document was never understood. Present-but-empty is the
301
+ # opposite of that -- `"children": []` and `"layoutOptions": {}` were
302
+ # both recognized and are real content, so neither may read as hollow.
303
+ # Deserialization leaves an absent field nil, and an explicit `null`
304
+ # nil too, so a document carrying only those is still rejected.
305
+ def hollow_model?(graph)
306
+ NIL_WHEN_HOLLOW.all? { |field| graph.public_send(field).nil? }
307
+ end
308
+
309
+ # The sniffed path reaches ELKT only because nothing else could read
310
+ # the document, and no extension declared it to be ELKT either, so a
311
+ # childless, edgeless result is rejected as "probably not meant to be
312
+ # ELKT" -- that is what keeps `layout garbage.txt` exiting 1. Keep the
313
+ # generic UNPARSEABLE message even for a located Elkrb::ParseError:
314
+ # nothing here told the user this was ELKT, so a location inside a
315
+ # grammar they never invoked is not actionable.
316
+ def parse_elkt_or_fail(content)
317
+ graph = parse_elkt!(content)
318
+ return graph unless childless?(graph)
319
+
320
+ reject_unparseable!
321
+ rescue StandardError
322
+ reject_unparseable!
323
+ end
324
+
325
+ def reject_unparseable!
326
+ raise ArgumentError, UNPARSEABLE
327
+ end
328
+
329
+ def childless?(graph)
330
+ blank?(graph[:children]) && blank?(graph[:edges])
331
+ end
332
+
333
+ # Raises whatever the real parser raised, unwrapped: an
334
+ # Elkrb::ParseError (location-bearing) or the Lexer's `TypeError` for
335
+ # non-String input. Callers decide how much to keep --
336
+ # `parse_elkt_declared!` below keeps the location, `parse_elkt_or_fail`
337
+ # above discards it.
338
+ def parse_elkt!(content)
339
+ require_relative "parsers/elkt_parser"
340
+ Elkrb::Parsers::ElktParser.parse(content)
341
+ end
342
+
343
+ # The declared `.elkt` path: the extension names the format, so the
344
+ # located Elkrb::ParseError message ("...at line 4, column 9") is kept
345
+ # rather than replaced with the generic UNPARSEABLE text. Only
346
+ # ParseError gets this treatment -- the Lexer's `TypeError` for a
347
+ # non-String input has no location to report, so the generic message
348
+ # is the correct fallback for it.
349
+ def parse_elkt_declared!(content)
350
+ parse_elkt!(content)
351
+ rescue Elkrb::ParseError => e
352
+ raise ArgumentError, e.message
353
+ rescue StandardError
354
+ raise ArgumentError, UNPARSEABLE
355
+ end
356
+
357
+ def blank?(collection)
358
+ collection.nil? || collection.empty?
359
+ end
360
+ end
361
+ end
362
+ end
@@ -16,13 +16,23 @@ module Elkrb
16
16
  # @param end_point [Point] The ending point (P3)
17
17
  # @param control1 [Point] The first control point (P1)
18
18
  # @param control2 [Point] The second control point (P2)
19
- # @param segments [Integer] Number of line segments to generate
19
+ # @param sample_count [Integer] Number of sample points to generate, so
20
+ # the curve it describes has one fewer connecting segment: 20 yields
21
+ # 20 points and 19 segments. Fewer than two yields copies of the two
22
+ # endpoints, since a curve cannot be sampled from a single point.
20
23
  # @return [Array<Point>] Array of points along the curve
21
24
  def self.calculate_curve(start_point, end_point, control1, control2,
22
- segments = 20)
25
+ sample_count = 20)
26
+ # Copies, so changing a returned point never moves the caller's own
27
+ # start or end point.
28
+ if sample_count < 2
29
+ return [Point.new(x: start_point.x, y: start_point.y),
30
+ Point.new(x: end_point.x, y: end_point.y)]
31
+ end
32
+
23
33
  points = []
24
- segments.times do |i|
25
- t = i.to_f / (segments - 1)
34
+ sample_count.times do |i|
35
+ t = i.to_f / (sample_count - 1)
26
36
  points << bezier_point(t, start_point, control1, control2, end_point)
27
37
  end
28
38
  points
@@ -8,7 +8,7 @@ module Elkrb
8
8
  attribute :x, :float, default: -> { 0.0 }
9
9
  attribute :y, :float, default: -> { 0.0 }
10
10
 
11
- json do
11
+ key_value do
12
12
  map "x", to: :x
13
13
  map "y", to: :y
14
14
  end
@@ -18,15 +18,14 @@ module Elkrb
18
18
  map "y", to: :y
19
19
  end
20
20
 
21
+ # The explicit zeros matter: a lutaml default is not marked as set and so
22
+ # would not be rendered, making a bare Point serialize to {} instead of
23
+ # {"x":0.0,"y":0.0}.
21
24
  def initialize(**attributes)
22
- # Handle both keyword args and positional args
23
25
  if attributes.empty?
24
26
  super(x: 0.0, y: 0.0)
25
- elsif attributes.key?(:x) || attributes.key?(:y)
26
- super
27
27
  else
28
- # Handle case where first two positional args might be passed
29
- super(x: 0.0, y: 0.0)
28
+ super
30
29
  end
31
30
  end
32
31
 
@@ -16,7 +16,7 @@ module Elkrb
16
16
  end
17
17
 
18
18
  def position
19
- Point.new(@x, @y)
19
+ Point.new(x: @x, y: @y)
20
20
  end
21
21
 
22
22
  def position=(point)
@@ -50,7 +50,7 @@ module Elkrb
50
50
  end
51
51
 
52
52
  def center
53
- Point.new(@x + (@width / 2.0), @y + (@height / 2.0))
53
+ Point.new(x: @x + (@width / 2.0), y: @y + (@height / 2.0))
54
54
  end
55
55
 
56
56
  def contains?(point)
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Elkrb
4
+ # Reopened only to attach DeepStringifyKeys below. Graph is elkrb's data
5
+ # model namespace, collectively defined across lib/elkrb/graph/*.rb --
6
+ # there is no single file that owns it.
7
+ module Graph
8
+ # Recursively stringifies Hash/Array keys (Symbol -> String). Used only
9
+ # by the five models' `layout_options=` overrides, and only on the
10
+ # options map itself — never on a whole input tree, which would also
11
+ # rewrite opaque data the caller parked in `properties`. Verified
12
+ # against lutaml-model 0.8.19 that :hash-typed attributes otherwise
13
+ # store a raw Hash unchanged, Symbol keys and all.
14
+ module DeepStringifyKeys
15
+ module_function
16
+
17
+ def call(obj)
18
+ case obj
19
+ when ::Hash
20
+ obj.each_with_object({}) { |(k, v), h| h[k.to_s] = call(v) }
21
+ when ::Array
22
+ obj.map { |v| call(v) }
23
+ else
24
+ obj
25
+ end
26
+ end
27
+ end
28
+ private_constant :DeepStringifyKeys
29
+ end
30
+ end
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "lutaml/model"
4
4
  require_relative "../geometry/point"
5
+ require_relative "read_only_mapping"
5
6
 
6
7
  module Elkrb
7
8
  module Graph
@@ -13,14 +14,18 @@ module Elkrb
13
14
  attribute :bend_points, Geometry::Point, collection: true
14
15
  attribute :incoming_shape, :string
15
16
  attribute :outgoing_shape, :string
17
+ attribute :incoming_sections, :string, collection: true
18
+ attribute :outgoing_sections, :string, collection: true
16
19
 
17
- json do
20
+ key_value do
18
21
  map "id", to: :id
19
22
  map "startPoint", to: :start_point
20
23
  map "endPoint", to: :end_point
21
24
  map "bendPoints", to: :bend_points
22
25
  map "incomingShape", to: :incoming_shape
23
26
  map "outgoingShape", to: :outgoing_shape
27
+ map "incomingSections", to: :incoming_sections
28
+ map "outgoingSections", to: :outgoing_sections
24
29
  end
25
30
 
26
31
  yaml do
@@ -30,6 +35,8 @@ module Elkrb
30
35
  map "bend_points", to: :bend_points
31
36
  map "incoming_shape", to: :incoming_shape
32
37
  map "outgoing_shape", to: :outgoing_shape
38
+ map "incoming_sections", to: :incoming_sections
39
+ map "outgoing_sections", to: :outgoing_sections
33
40
  end
34
41
 
35
42
  def initialize(**attributes)
@@ -63,15 +70,32 @@ module Elkrb
63
70
  end
64
71
 
65
72
  class Edge < Lutaml::Model::Serializable
73
+ include ReadOnlyMapping
74
+
66
75
  attribute :id, :string
67
76
  attribute :sources, :string, collection: true
68
77
  attribute :targets, :string, collection: true
69
78
  attribute :labels, Label, collection: true
70
79
  attribute :sections, EdgeSection, collection: true
71
- attribute :layout_options, LayoutOptions
80
+ attribute :layout_options, :hash
72
81
  attribute :properties, :hash
82
+ attribute :junction_points, Geometry::Point, collection: true
83
+ attribute :container, :string
73
84
 
74
- json do
85
+ # The legacy elkjs endpoint keys mapped at the end of the block below are
86
+ # read-only, and they reach every key-value format -- JSON, Hash, TOML,
87
+ # JSONL, YAMLS -- but NOT YAML, which declares its own block after it.
88
+ # Precedence: a non-empty sources/targets wins, an explicit [] counts as
89
+ # absent, and a NONBLANK sourcePort precedes source (in ELK JSON the port
90
+ # id IS the endpoint) by declaration order rather than key order. The
91
+ # hooks drop nil and blank ids themselves, so `"sourcePort": ""` next to
92
+ # a real `source` leaves the edge connected to that source; targetPort
93
+ # and target behave the same way.
94
+ #
95
+ # Do NOT "simplify" the pairs into a second `map "source", to: :sources`:
96
+ # a plain rule fires even when its key is absent, clobbering sources with
97
+ # nil, and emits both spellings on write.
98
+ key_value do
75
99
  map "id", to: :id
76
100
  map "sources", to: :sources
77
101
  map "targets", to: :targets
@@ -79,6 +103,18 @@ module Elkrb
79
103
  map "sections", to: :sections
80
104
  map "layoutOptions", to: :layout_options
81
105
  map "properties", to: :properties
106
+ map "junctionPoints", to: :junction_points
107
+ map "container", to: :container
108
+
109
+ # Read-only legacy keys; see the note above the block. Order matters.
110
+ map "sourcePort", with: { from: :__elkrb_merge_legacy_source,
111
+ to: :__elkrb_omit_from_output }
112
+ map "source", with: { from: :__elkrb_merge_legacy_source,
113
+ to: :__elkrb_omit_from_output }
114
+ map "targetPort", with: { from: :__elkrb_merge_legacy_target,
115
+ to: :__elkrb_omit_from_output }
116
+ map "target", with: { from: :__elkrb_merge_legacy_target,
117
+ to: :__elkrb_omit_from_output }
82
118
  end
83
119
 
84
120
  yaml do
@@ -89,6 +125,56 @@ module Elkrb
89
125
  map "sections", to: :sections
90
126
  map "layout_options", to: :layout_options
91
127
  map "properties", to: :properties
128
+ map "junction_points", to: :junction_points
129
+ map "container", to: :container
130
+
131
+ # The same read-only legacy endpoint keys as the key_value block, so a
132
+ # YAML edge written the elkjs way keeps its endpoints. Order matters
133
+ # here too; see the note above the key_value block.
134
+ map "sourcePort", with: { from: :__elkrb_merge_legacy_source,
135
+ to: :__elkrb_omit_from_output }
136
+ map "source", with: { from: :__elkrb_merge_legacy_source,
137
+ to: :__elkrb_omit_from_output }
138
+ map "targetPort", with: { from: :__elkrb_merge_legacy_target,
139
+ to: :__elkrb_omit_from_output }
140
+ map "target", with: { from: :__elkrb_merge_legacy_target,
141
+ to: :__elkrb_omit_from_output }
142
+ end
143
+
144
+ # Normalizes a Symbol key however the options arrive — a constructor,
145
+ # a plain setter, or lutaml's own deserialization, which routes through
146
+ # here too. This is the single normalization point: a :hash-typed
147
+ # attribute otherwise stores a raw, unnormalized Hash.
148
+ def layout_options=(value)
149
+ value_set_for(:layout_options)
150
+ attr = self.class.attributes(lutaml_register)[:layout_options]
151
+ cast = attr.cast_value(DeepStringifyKeys.call(value), lutaml_register)
152
+ instance_variable_set(:@layout_options, NormalizeOptionKeys.call(cast))
153
+ end
154
+
155
+ # Serialization hooks for the legacy endpoint keys above. Public because
156
+ # lutaml invokes them with `public_send`; the `__elkrb_` prefix keeps a
157
+ # subclass from taking the name by accident. Not part of the supported
158
+ # API.
159
+ #
160
+ # @api private
161
+ def __elkrb_merge_legacy_source(model, value)
162
+ ids = __elkrb_endpoint_ids(value)
163
+ model.sources = ids if ids.any? && Array(model.sources).empty?
164
+ end
165
+
166
+ # @api private
167
+ def __elkrb_merge_legacy_target(model, value)
168
+ ids = __elkrb_endpoint_ids(value)
169
+ model.targets = ids if ids.any? && Array(model.targets).empty?
170
+ end
171
+
172
+ private
173
+
174
+ # Drops nil and blank ids. Without this a `"sourcePort": ""` would win
175
+ # over a real `"source"` and leave the edge with an empty endpoint.
176
+ def __elkrb_endpoint_ids(value)
177
+ Array(value).reject { |id| id.nil? || id.to_s.strip.empty? }
92
178
  end
93
179
  end
94
180
  end