lutaml-lml 0.2.0 → 0.3.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.
- checksums.yaml +4 -4
- data/lib/lutaml/lml/cli.rb +84 -18
- data/lib/lutaml/lml/data_processor/value_processing.rb +6 -1
- data/lib/lutaml/lml/document_builder.rb +72 -3
- data/lib/lutaml/lml/format/adapter/mapping.rb +12 -0
- data/lib/lutaml/lml/format/adapter/standard_adapter.rb +41 -12
- data/lib/lutaml/lml/formatter/elk.rb +45 -0
- data/lib/lutaml/lml/formatter/graphviz/node_formatter.rb +2 -1
- data/lib/lutaml/lml/formatter/svg_pdf/path_parser.rb +134 -0
- data/lib/lutaml/lml/formatter/svg_pdf.rb +281 -0
- data/lib/lutaml/lml/formatter.rb +2 -0
- data/lib/lutaml/lml/grammar/lml.artifact.json +7265 -4550
- data/lib/lutaml/lml/grammar/lml.parg +230 -115
- data/lib/lutaml/lml/layout/elk_engine.rb +15 -0
- data/lib/lutaml/lml/layout/elk_graph_builder.rb +129 -0
- data/lib/lutaml/lml/layout/elk_svg_markers.rb +45 -0
- data/lib/lutaml/lml/layout/elk_svg_renderer.rb +119 -0
- data/lib/lutaml/lml/layout/graph_viz_engine.rb +1 -1
- data/lib/lutaml/lml/layout.rb +4 -0
- data/lib/lutaml/lml/model_compiler/enums.rb +172 -0
- data/lib/lutaml/lml/model_compiler/hydration.rb +123 -0
- data/lib/lutaml/lml/model_compiler/serialization_mappings.rb +162 -0
- data/lib/lutaml/lml/model_compiler/string_formats.rb +42 -0
- data/lib/lutaml/lml/model_compiler/validation.rb +65 -0
- data/lib/lutaml/lml/model_compiler.rb +46 -453
- data/lib/lutaml/lml/models/document.rb +29 -0
- data/lib/lutaml/lml/models/operation_parameter.rb +1 -0
- data/lib/lutaml/lml/models/reference.rb +23 -0
- data/lib/lutaml/lml/models/top_element_attribute.rb +12 -0
- data/lib/lutaml/lml/models/uml_class.rb +1 -1
- data/lib/lutaml/lml/preprocessor.rb +44 -79
- data/lib/lutaml/lml/transform.rb +54 -6
- data/lib/lutaml/lml/validator.rb +88 -19
- data/lib/lutaml/lml/version.rb +1 -1
- data/lib/lutaml/lml.rb +19 -3
- metadata +17 -4
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: c44e08a243084f376e006dfa845a878267d5867d1c07e9cb2f957cadfdf593af
|
|
4
|
+
data.tar.gz: 56b169b4f31489f51713071e48002ec2b0babc8f6b510aa6660e7b02976b0f93
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 12590d775d46b14bbefe7733610b6b60c08ddaa169e6aa3e4010b13979e565d36bde270adffc5e7ef6cdb37c9fbcadedb16d66b0bbc1e6404d74c46da5b2c1ec
|
|
7
|
+
data.tar.gz: 1dcf78fc5244594d03992cdb8dd4b2e286c0069741b3985bad2667ea1752eab19534aa16878e9e2c80ac3ab4b0c5c2381756634590a99df1070e72a0e6b7bf83
|
data/lib/lutaml/lml/cli.rb
CHANGED
|
@@ -30,14 +30,19 @@ module Lutaml
|
|
|
30
30
|
'exp' => lambda { |path|
|
|
31
31
|
require 'lutaml/express'
|
|
32
32
|
Lutaml::Express::Parsers::Exp.parse(File.new(path))
|
|
33
|
-
}
|
|
33
|
+
},
|
|
34
34
|
}.freeze
|
|
35
35
|
|
|
36
36
|
FORMATTER_ATTR_TARGETS = %i[graph edge node].freeze
|
|
37
37
|
|
|
38
38
|
def initialize(*args)
|
|
39
39
|
super
|
|
40
|
-
@formatter =
|
|
40
|
+
@formatter = case options[:layout]
|
|
41
|
+
when 'elk'
|
|
42
|
+
::Lutaml::Formatter::Elk.new
|
|
43
|
+
else
|
|
44
|
+
::Lutaml::Formatter::Graphviz.new
|
|
45
|
+
end
|
|
41
46
|
@out_object = $stdout
|
|
42
47
|
end
|
|
43
48
|
|
|
@@ -71,6 +76,8 @@ module Lutaml
|
|
|
71
76
|
desc: 'Node attributes (key=value,key2=value2)'
|
|
72
77
|
method_option :all, type: :string, aliases: '-a',
|
|
73
78
|
desc: 'Set attributes for graph, edge, and node'
|
|
79
|
+
method_option :layout, type: :string, enum: %w[graphviz elk], default: 'graphviz',
|
|
80
|
+
desc: 'Layout engine (elk requires the elkrb gem)'
|
|
74
81
|
def generate(*paths)
|
|
75
82
|
assert_input_paths(paths)
|
|
76
83
|
|
|
@@ -108,36 +115,95 @@ module Lutaml
|
|
|
108
115
|
Parse an LML file containing model definitions and compile them
|
|
109
116
|
into Ruby classes that can be used to instantiate and validate data.
|
|
110
117
|
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
118
|
+
With --namespace, classes are registered as constants in the given
|
|
119
|
+
module (created if absent).
|
|
120
|
+
|
|
121
|
+
With --schema, emits a JSON Schema or YAML Schema document for every
|
|
122
|
+
compiled model instead of the compile listing. Schema output requires
|
|
123
|
+
named classes, so --namespace defaults to Lutaml::Lml::Generated when omitted.
|
|
114
124
|
|
|
115
125
|
Examples:
|
|
116
126
|
lutaml lml compile models.lml
|
|
117
127
|
|
|
118
128
|
lutaml lml compile models.lml --namespace MyModels
|
|
129
|
+
|
|
130
|
+
lutaml lml compile models.lml --schema json -o schemas/
|
|
131
|
+
|
|
132
|
+
lutaml lml compile models.lml --schema yaml -o widget.schema.yaml
|
|
119
133
|
DESC
|
|
120
134
|
method_option :namespace, type: :string, aliases: '-n',
|
|
121
|
-
|
|
135
|
+
desc: 'Register compiled classes in a module (created if absent)'
|
|
136
|
+
method_option :schema, type: :string, enum: %w[json yaml],
|
|
137
|
+
desc: 'Emit JSON Schema or YAML Schema for the compiled models'
|
|
138
|
+
method_option :output, type: :string, aliases: '-o',
|
|
139
|
+
desc: 'Schema output path (file for one model, directory for many)'
|
|
122
140
|
def compile(path)
|
|
123
141
|
raise Thor::Error, "File does not exist: #{path}" unless File.exist?(path)
|
|
124
142
|
|
|
125
|
-
ns = options[:namespace]
|
|
126
|
-
result = Lutaml::Lml::ModelCompiler.new(namespace: ns).compile(
|
|
143
|
+
ns = options[:namespace] || (::Lutaml::Lml::ModelCompiler::DEFAULT_NAMESPACE if options[:schema])
|
|
144
|
+
result = File.open(path) { |file| Lutaml::Lml::ModelCompiler.new(namespace: ns).compile(file) }
|
|
145
|
+
return emit_schemas(result) if options[:schema]
|
|
127
146
|
|
|
128
|
-
result
|
|
129
|
-
say " compiled: #{name}", :green
|
|
130
|
-
end
|
|
131
|
-
say "\n#{result.size} class(es) compiled", :green
|
|
147
|
+
report_compilation(result)
|
|
132
148
|
end
|
|
133
149
|
|
|
134
150
|
no_commands do
|
|
135
|
-
def
|
|
136
|
-
|
|
137
|
-
|
|
151
|
+
def report_compilation(result)
|
|
152
|
+
result.each_key do |name|
|
|
153
|
+
say " compiled: #{name}", :green
|
|
154
|
+
end
|
|
155
|
+
say "\n#{result.size} class(es) compiled", :green
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def emit_schemas(result)
|
|
159
|
+
require 'lutaml/model/schema'
|
|
160
|
+
if options[:output] && Pathname.new(options[:output]).directory?
|
|
161
|
+
generator = "to_#{options[:schema]}"
|
|
162
|
+
schemas = result.transform_values do |klass|
|
|
163
|
+
Lutaml::Model::Schema.public_send(generator, klass, pretty: true)
|
|
164
|
+
end
|
|
165
|
+
write_schemas(schemas)
|
|
166
|
+
else
|
|
167
|
+
# One multi-root document covers every compiled model; each
|
|
168
|
+
# root stays addressable under $defs by class name.
|
|
169
|
+
schema = generate_multi_root_schema(result)
|
|
170
|
+
return puts schema unless options[:output]
|
|
171
|
+
|
|
172
|
+
File.write(options[:output], schema)
|
|
173
|
+
say "Generated: #{options[:output]}", :green
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def generate_multi_root_schema(result)
|
|
178
|
+
klass = options[:schema] == 'yaml' ? Lutaml::Model::Schema::YamlSchema : Lutaml::Model::Schema::JsonSchema
|
|
179
|
+
klass.generate_many(result.values, pretty: true)
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def write_schemas(schemas)
|
|
183
|
+
target = Pathname.new(options[:output])
|
|
184
|
+
if schemas.size > 1
|
|
185
|
+
unless target.directory?
|
|
186
|
+
raise Thor::Error,
|
|
187
|
+
'Output path must be a directory if multiple schemas ' \
|
|
188
|
+
'are generated'
|
|
189
|
+
end
|
|
190
|
+
write_schema_files(target, schemas)
|
|
191
|
+
else
|
|
192
|
+
target.write(schemas.values.first)
|
|
193
|
+
say "Generated: #{target}", :green
|
|
138
194
|
end
|
|
139
|
-
|
|
140
|
-
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def write_schema_files(dir, schemas)
|
|
198
|
+
schemas.each do |name, schema|
|
|
199
|
+
file = dir.join("#{schema_file_stem(name)}.#{options[:schema]}")
|
|
200
|
+
file.write(schema)
|
|
201
|
+
say "Generated: #{file}", :green
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def schema_file_stem(name)
|
|
206
|
+
name.split('::').last.gsub(/[^a-zA-Z0-9]+/, '_').downcase
|
|
141
207
|
end
|
|
142
208
|
|
|
143
209
|
def assert_input_paths(paths)
|
|
@@ -175,7 +241,7 @@ module Lutaml
|
|
|
175
241
|
def write_output(input_path, result)
|
|
176
242
|
target = resolve_output_path(input_path)
|
|
177
243
|
if target.is_a?(Pathname)
|
|
178
|
-
target.open('
|
|
244
|
+
target.open('wb+') { |f| f.write(result) }
|
|
179
245
|
say "Generated: #{target}", :green
|
|
180
246
|
else
|
|
181
247
|
target.puts(result)
|
|
@@ -4,13 +4,14 @@ module Lutaml
|
|
|
4
4
|
module Lml
|
|
5
5
|
class DataProcessor
|
|
6
6
|
module ValueProcessing
|
|
7
|
-
VALUE_TYPE_KEYS = %i[instance list string boolean key_value_map number float condition require].freeze
|
|
7
|
+
VALUE_TYPE_KEYS = %i[instance list string boolean null key_value_map number float condition require].freeze
|
|
8
8
|
|
|
9
9
|
VALUE_TYPE_HANDLERS = {
|
|
10
10
|
instance: :handle_instance_value,
|
|
11
11
|
list: :handle_list_value,
|
|
12
12
|
string: :handle_string_value,
|
|
13
13
|
boolean: :handle_boolean_value,
|
|
14
|
+
null: :handle_null_value,
|
|
14
15
|
key_value_map: :handle_key_value_map,
|
|
15
16
|
number: :handle_number_value,
|
|
16
17
|
float: :handle_float_value,
|
|
@@ -50,6 +51,10 @@ module Lutaml
|
|
|
50
51
|
['Boolean', value[:boolean] == 'true']
|
|
51
52
|
end
|
|
52
53
|
|
|
54
|
+
def handle_null_value(_key, _value)
|
|
55
|
+
['Null', nil]
|
|
56
|
+
end
|
|
57
|
+
|
|
53
58
|
def handle_key_value_map(_key, value)
|
|
54
59
|
process_key_value_map(value[:key_value_map])
|
|
55
60
|
end
|
|
@@ -72,6 +72,16 @@ module Lutaml
|
|
|
72
72
|
def set_model(model, hash)
|
|
73
73
|
hash = build_members(model, hash)
|
|
74
74
|
set_model_attributes(model, hash)
|
|
75
|
+
expand_class_specializer(model, hash)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# The prefix specializer (`abstract class Foo`) expands to the
|
|
79
|
+
# canonical `abstract true` declaration (RS 3001 par. Declaration
|
|
80
|
+
# modifiers); the class-level modifier capture is not a block member.
|
|
81
|
+
def expand_class_specializer(model, hash)
|
|
82
|
+
return unless hash[:modifier].to_s == 'abstract' && model.class.attributes.key?(:is_abstract)
|
|
83
|
+
|
|
84
|
+
model.is_abstract = true
|
|
75
85
|
end
|
|
76
86
|
|
|
77
87
|
def set_model_attributes(model, hash)
|
|
@@ -83,12 +93,24 @@ module Lutaml
|
|
|
83
93
|
def build_members(model, hash)
|
|
84
94
|
members = hash.delete(:members)
|
|
85
95
|
members.to_a.each do |member_hash|
|
|
96
|
+
record_model_version(model, member_hash)
|
|
86
97
|
add_members(model, member_hash)
|
|
87
98
|
set_model_attributes(model, member_hash)
|
|
88
99
|
end
|
|
89
100
|
hash
|
|
90
101
|
end
|
|
91
102
|
|
|
103
|
+
# A `models <Name> version "x.y.z"` header travels with the block's
|
|
104
|
+
# member hash; the block itself flattens into the document, so the
|
|
105
|
+
# version is recorded on the document before the name is dropped.
|
|
106
|
+
def record_model_version(model, hash)
|
|
107
|
+
return unless model.class.attributes.key?(:model_versions)
|
|
108
|
+
return unless hash.key?(:version)
|
|
109
|
+
|
|
110
|
+
name = hash[:name].to_s
|
|
111
|
+
model.model_versions[name] = unquote(hash.delete(:version)).to_s
|
|
112
|
+
end
|
|
113
|
+
|
|
92
114
|
def apply_attribute(model, key, value)
|
|
93
115
|
return unless model.class.attributes.key?(key.to_sym)
|
|
94
116
|
|
|
@@ -159,9 +181,7 @@ module Lutaml
|
|
|
159
181
|
rules = rules.filter_map do |entry|
|
|
160
182
|
next nil unless entry.is_a?(Hash)
|
|
161
183
|
|
|
162
|
-
if entry[:wire] && entry[:to]
|
|
163
|
-
next { kind: 'map', wire: unquote(entry[:wire]), field: unquote(entry[:to]) }
|
|
164
|
-
end
|
|
184
|
+
next { kind: 'map', wire: unquote(entry[:wire]), field: unquote(entry[:to]) } if entry[:wire] && entry[:to]
|
|
165
185
|
|
|
166
186
|
first_key = entry.keys.first
|
|
167
187
|
kind = first_key.to_s
|
|
@@ -172,6 +192,7 @@ module Lutaml
|
|
|
172
192
|
|
|
173
193
|
term = unquote(body)
|
|
174
194
|
next { kind: kind_n, wire: term, field: term } if %w[element attribute map].include?(kind_n)
|
|
195
|
+
|
|
175
196
|
if kind_n == 'namespace'
|
|
176
197
|
mapping[:namespace_ref] = term
|
|
177
198
|
next nil
|
|
@@ -237,6 +258,7 @@ module Lutaml
|
|
|
237
258
|
# `value` members, mapping sections, class declarations, enum
|
|
238
259
|
# member_fields and from_table all arrive as bare member hashes.
|
|
239
260
|
def expand_declared_members(model, hash)
|
|
261
|
+
expand_abstract_declaration(model, hash)
|
|
240
262
|
expand_definition_text(model, hash)
|
|
241
263
|
expand_value_declaration(model, hash)
|
|
242
264
|
expand_value_set_declaration(model, hash)
|
|
@@ -244,10 +266,57 @@ module Lutaml
|
|
|
244
266
|
expand_class_declarations(model, hash)
|
|
245
267
|
expand_enum_member_fields(model, hash)
|
|
246
268
|
expand_from_table(model, hash)
|
|
269
|
+
expand_operation_members(model, hash)
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
# Operation members arrive with grammar capture names
|
|
273
|
+
# (`visibility_modifier`, `param_default`) and per-iteration
|
|
274
|
+
# stereotype hashes; normalize to the model's field names.
|
|
275
|
+
def expand_operation_members(model, hash)
|
|
276
|
+
ops = hash.delete(:operations)
|
|
277
|
+
return unless ops && model.class.attributes.key?(:operations)
|
|
278
|
+
|
|
279
|
+
Array(ops.is_a?(Hash) ? [ops] : ops).each do |op|
|
|
280
|
+
model.operations << build(:operation, normalize_operation_hash(op))
|
|
281
|
+
end
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
def normalize_operation_hash(op)
|
|
285
|
+
op = op.dup
|
|
286
|
+
if (vis = op.delete(:visibility_modifier))
|
|
287
|
+
op[:visibility] = Transform::VISIBILITY_MAP.fetch(vis.to_s, 'public')
|
|
288
|
+
end
|
|
289
|
+
stereo = op[:stereotype]
|
|
290
|
+
if stereo
|
|
291
|
+
items = stereo.is_a?(Array) ? stereo : [stereo]
|
|
292
|
+
op[:stereotype] = items.map { |x| x.is_a?(Hash) ? x[:stereotype].to_s : x.to_s }
|
|
293
|
+
end
|
|
294
|
+
params = op[:owned_parameter]
|
|
295
|
+
if params
|
|
296
|
+
list = params.is_a?(Array) ? params : [params]
|
|
297
|
+
op[:owned_parameter] = list.map { |param| normalize_operation_param(param) }
|
|
298
|
+
end
|
|
299
|
+
op
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
def normalize_operation_param(param)
|
|
303
|
+
param = param.dup
|
|
304
|
+
if (default = param.delete(:param_default))
|
|
305
|
+
param[:default] = default.is_a?(Hash) ? default[:string].to_s : default.to_s
|
|
306
|
+
end
|
|
307
|
+
param
|
|
247
308
|
end
|
|
248
309
|
|
|
249
310
|
# RS 3001 §Definition single-line form (`definition "text"`) arrives
|
|
250
311
|
# as a quoted-string capture; unwrap it for the entity's definition.
|
|
312
|
+
# RS 3001 declaration modifiers: `abstract true` maps onto the
|
|
313
|
+
# is_abstract field (the visibility declaration flows through the
|
|
314
|
+
# generic attribute path).
|
|
315
|
+
def expand_abstract_declaration(model, hash)
|
|
316
|
+
value = hash.delete(:abstract)
|
|
317
|
+
model.is_abstract = true if value.to_s == 'true' && model.class.attributes.key?(:is_abstract)
|
|
318
|
+
end
|
|
319
|
+
|
|
251
320
|
def expand_definition_text(model, hash)
|
|
252
321
|
return unless hash.key?(:definition) && model.class.attributes.key?(:definition)
|
|
253
322
|
|
|
@@ -12,6 +12,18 @@ module Lutaml
|
|
|
12
12
|
def dup_instance
|
|
13
13
|
self.class.new
|
|
14
14
|
end
|
|
15
|
+
|
|
16
|
+
# RS 3001 §String values: a present value — including a
|
|
17
|
+
# zero-length string — is distinct from an absent one, so the
|
|
18
|
+
# lml format emits empty values. Nil stays omitted: absence is
|
|
19
|
+
# the unassigned line, not a rendered null.
|
|
20
|
+
def map(name = nil, to: nil, render_empty: true, **options, &block)
|
|
21
|
+
super(name, to: to, render_empty: render_empty, **options, &block)
|
|
22
|
+
end
|
|
23
|
+
# The parent's `alias map_element map` binds the alias to the
|
|
24
|
+
# parent method at definition time, so it bypasses the map
|
|
25
|
+
# override above; default_mappings goes through map_element.
|
|
26
|
+
alias map_element map
|
|
15
27
|
end
|
|
16
28
|
end
|
|
17
29
|
end
|
|
@@ -4,9 +4,21 @@ module Lutaml
|
|
|
4
4
|
module Lml
|
|
5
5
|
module Format
|
|
6
6
|
module Adapter
|
|
7
|
+
# Serializes instance data to and from LML instance syntax on
|
|
8
|
+
# behalf of the lutaml-model format registry (to_lml/from_lml on
|
|
9
|
+
# compiled classes).
|
|
7
10
|
class StandardAdapter < Document
|
|
8
11
|
TYPE_KEY = "__type__"
|
|
9
12
|
|
|
13
|
+
# The grammar's `word` rule is [A-Za-z0-9_] only (lml.parg) and
|
|
14
|
+
# `variable = quoted_string / 1*word` — a bare value is a run of
|
|
15
|
+
# word characters and nothing else. Anything else (a dash, a
|
|
16
|
+
# space, a digit-leading date...) commits the value parser to
|
|
17
|
+
# `number` or fails re-parsing, so it must be emitted quoted.
|
|
18
|
+
# This predicate is the single source of truth for the emit
|
|
19
|
+
# side; the parse side is pinned by the grammar itself.
|
|
20
|
+
BARE_WORD = /\A\w+\z/
|
|
21
|
+
|
|
10
22
|
def self.parse(data, _options = {})
|
|
11
23
|
return data if data.is_a?(Hash)
|
|
12
24
|
|
|
@@ -22,8 +34,6 @@ module Lutaml
|
|
|
22
34
|
"instance #{type_name} {\n#{body}\n}"
|
|
23
35
|
end
|
|
24
36
|
|
|
25
|
-
private
|
|
26
|
-
|
|
27
37
|
def self.instance_to_hash(instance)
|
|
28
38
|
return nil unless instance
|
|
29
39
|
|
|
@@ -41,20 +51,18 @@ module Lutaml
|
|
|
41
51
|
end
|
|
42
52
|
end
|
|
43
53
|
|
|
44
|
-
if instance.instance
|
|
45
|
-
hash.merge!(instance_to_hash(instance.instance))
|
|
46
|
-
end
|
|
54
|
+
hash.merge!(instance_to_hash(instance.instance)) if instance.instance
|
|
47
55
|
|
|
48
56
|
hash
|
|
49
57
|
end
|
|
50
58
|
|
|
51
59
|
def self.primitive_value(val)
|
|
52
60
|
case val
|
|
53
|
-
when TrueClass, FalseClass then val
|
|
54
|
-
when Integer, Float then val
|
|
61
|
+
when TrueClass, FalseClass, Integer, Float then val
|
|
55
62
|
else val.to_s
|
|
56
63
|
end
|
|
57
64
|
end
|
|
65
|
+
private_class_method :primitive_value
|
|
58
66
|
|
|
59
67
|
def hash_to_lml_body(hash, indent = 1)
|
|
60
68
|
lines = []
|
|
@@ -70,6 +78,8 @@ module Lutaml
|
|
|
70
78
|
end
|
|
71
79
|
|
|
72
80
|
def format_value(prefix, key, value, indent)
|
|
81
|
+
return "#{prefix}#{key} = ref:(#{value[:reference]})" if reference_value?(value)
|
|
82
|
+
|
|
73
83
|
case value
|
|
74
84
|
when Array
|
|
75
85
|
format_array(prefix, key, value, indent)
|
|
@@ -84,11 +94,15 @@ module Lutaml
|
|
|
84
94
|
|
|
85
95
|
def format_array(prefix, key, items, indent)
|
|
86
96
|
elements = items.map do |item|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
format_nested_instance(item, indent + 1)
|
|
97
|
+
if reference_value?(item)
|
|
98
|
+
" ref:(#{item[:reference]})"
|
|
90
99
|
else
|
|
91
|
-
|
|
100
|
+
case item
|
|
101
|
+
when Hash
|
|
102
|
+
format_nested_instance(item, indent + 1)
|
|
103
|
+
else
|
|
104
|
+
" #{quote_value(item)}"
|
|
105
|
+
end
|
|
92
106
|
end
|
|
93
107
|
end
|
|
94
108
|
|
|
@@ -111,10 +125,25 @@ module Lutaml
|
|
|
111
125
|
"#{prefix}instance#{type_clause} {\n#{inner}\n#{prefix}}"
|
|
112
126
|
end
|
|
113
127
|
|
|
128
|
+
# RS 3001 par. Value type: a `reference` map is the wire form of a
|
|
129
|
+
# reference literal; it emits as `ref:(path)`, never as a nested
|
|
130
|
+
# instance.
|
|
131
|
+
def reference_value?(value)
|
|
132
|
+
value.is_a?(Hash) && value.keys == [:reference]
|
|
133
|
+
end
|
|
134
|
+
|
|
114
135
|
def quote_value(val)
|
|
115
136
|
return val.to_s if val.is_a?(Numeric) || val.is_a?(TrueClass) || val.is_a?(FalseClass)
|
|
137
|
+
|
|
116
138
|
str = val.to_s
|
|
117
|
-
str.match?(
|
|
139
|
+
return str if str.match?(BARE_WORD)
|
|
140
|
+
|
|
141
|
+
# RS 3001 §String values: escape backslash and double quote;
|
|
142
|
+
# every value is representable in double quotes.
|
|
143
|
+
# gsub replacement strings re-interpret backslash pairs, so
|
|
144
|
+
# the backslash doubling uses the block form.
|
|
145
|
+
escaped = str.gsub('\\') { |bs| bs + bs }.gsub('"') { |q| '\\' + q }
|
|
146
|
+
"\"#{escaped}\""
|
|
118
147
|
end
|
|
119
148
|
end
|
|
120
149
|
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Lutaml
|
|
4
|
+
module Formatter
|
|
5
|
+
# ELK-layout diagram formatter: builds an Elkrb::Graph from the
|
|
6
|
+
# document, lays it out in-process (no external tools) and emits SVG,
|
|
7
|
+
# PS/EPS/EMF through vectory, or PDF through the pdfrb-backed
|
|
8
|
+
# SvgPdf renderer.
|
|
9
|
+
class Elk
|
|
10
|
+
VALID_TYPES = %i[svg ps eps emf pdf].freeze
|
|
11
|
+
|
|
12
|
+
attr_reader :type
|
|
13
|
+
|
|
14
|
+
def initialize(_attributes = {})
|
|
15
|
+
@type = :svg
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def type=(value)
|
|
19
|
+
@type = value.to_sym
|
|
20
|
+
return if VALID_TYPES.include?(@type)
|
|
21
|
+
|
|
22
|
+
raise ArgumentError,
|
|
23
|
+
"unsupported output type #{@type.inspect} (valid: #{VALID_TYPES.join(', ')})"
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def format(document)
|
|
27
|
+
graph = Lutaml::Layout::ElkGraphBuilder.new.build(document)
|
|
28
|
+
svg = Lutaml::Layout::ElkEngine.new(input: graph).render(:svg)
|
|
29
|
+
@type == :svg ? svg : convert(svg)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
def convert(svg)
|
|
35
|
+
return SvgPdf.new(svg).render if @type == :pdf
|
|
36
|
+
|
|
37
|
+
require 'vectory'
|
|
38
|
+
Vectory::Svg.new(svg).public_send(:"to_#{@type}").content
|
|
39
|
+
rescue LoadError
|
|
40
|
+
raise Error, 'The vectory gem is required for PS/EPS/EMF output: ' \
|
|
41
|
+
'add `gem "vectory"` to your Gemfile'
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Lutaml
|
|
4
|
+
module Formatter
|
|
5
|
+
class SvgPdf
|
|
6
|
+
# Minimal SVG path data parser: absolute and relative M/m, L/l,
|
|
7
|
+
# H/h, V/v, C/c, Z/z — enough for ELK geometry and arrow shapes.
|
|
8
|
+
class PathParser
|
|
9
|
+
Point = Struct.new(:x, :y)
|
|
10
|
+
Line = Struct.new(:point) do
|
|
11
|
+
def last_point
|
|
12
|
+
point
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
Curve = Struct.new(:c1, :c2, :point) do
|
|
16
|
+
def last_point
|
|
17
|
+
point
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
Close = Struct.new(:last_point)
|
|
21
|
+
|
|
22
|
+
attr_reader :subpaths
|
|
23
|
+
|
|
24
|
+
def initialize(d, dx, dy)
|
|
25
|
+
@dx = dx
|
|
26
|
+
@dy = dy
|
|
27
|
+
parse(d)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
def parse(d)
|
|
33
|
+
tokens = d.scan(/[MmLlHhVvCcZz]|-?\d*\.?\d+(?:[eE][-+]?\d+)?/)
|
|
34
|
+
current = Point.new(0.0, 0.0)
|
|
35
|
+
start = current
|
|
36
|
+
@subpaths = [[]]
|
|
37
|
+
i = 0
|
|
38
|
+
while i < tokens.size
|
|
39
|
+
cmd = tokens[i]
|
|
40
|
+
i += 1
|
|
41
|
+
case cmd
|
|
42
|
+
when 'Z', 'z'
|
|
43
|
+
@subpaths.last << Close.new(start)
|
|
44
|
+
current = start.dup
|
|
45
|
+
when 'M', 'm'
|
|
46
|
+
current, start = move_to(tokens, i, current, cmd == 'm')
|
|
47
|
+
i += 2
|
|
48
|
+
@subpaths << [] if cmd == 'm'
|
|
49
|
+
when 'L', 'l'
|
|
50
|
+
current = line_to(tokens, i, current, cmd == 'l')
|
|
51
|
+
i += 2
|
|
52
|
+
when 'H', 'h'
|
|
53
|
+
current = h_line(tokens, i, current, cmd == 'h')
|
|
54
|
+
i += 1
|
|
55
|
+
when 'V', 'v'
|
|
56
|
+
current = v_line(tokens, i, current, cmd == 'v')
|
|
57
|
+
i += 1
|
|
58
|
+
when 'C', 'c'
|
|
59
|
+
current = curve_to(tokens, i, current, cmd == 'c')
|
|
60
|
+
i += 6
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
finish
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def move_to(tokens, i, current, relative)
|
|
67
|
+
x = num(tokens, i)
|
|
68
|
+
y = num(tokens, i + 1)
|
|
69
|
+
point = relative ? Point.new(current.x + x, current.y + y) : Point.new(x, y)
|
|
70
|
+
[point, point.dup]
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def line_to(tokens, i, current, relative)
|
|
74
|
+
x = num(tokens, i)
|
|
75
|
+
y = num(tokens, i + 1)
|
|
76
|
+
point = relative ? Point.new(current.x + x, current.y + y) : Point.new(x, y)
|
|
77
|
+
@subpaths.last << Line.new(point.dup)
|
|
78
|
+
point
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def h_line(tokens, i, current, relative)
|
|
82
|
+
x = num(tokens, i) + (relative ? current.x : 0)
|
|
83
|
+
point = Point.new(x, current.y)
|
|
84
|
+
@subpaths.last << Line.new(point.dup)
|
|
85
|
+
point
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def v_line(tokens, i, current, relative)
|
|
89
|
+
y = num(tokens, i) + (relative ? current.y : 0)
|
|
90
|
+
point = Point.new(current.x, y)
|
|
91
|
+
@subpaths.last << Line.new(point.dup)
|
|
92
|
+
point
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def curve_to(tokens, i, current, relative)
|
|
96
|
+
c1 = Point.new(num(tokens, i), num(tokens, i + 1))
|
|
97
|
+
c2 = Point.new(num(tokens, i + 2), num(tokens, i + 3))
|
|
98
|
+
to = Point.new(num(tokens, i + 4), num(tokens, i + 5))
|
|
99
|
+
if relative
|
|
100
|
+
c1 = Point.new(c1.x + current.x, c1.y + current.y)
|
|
101
|
+
c2 = Point.new(c2.x + current.x, c2.y + current.y)
|
|
102
|
+
to = Point.new(to.x + current.x, to.y + current.y)
|
|
103
|
+
end
|
|
104
|
+
@subpaths.last << Curve.new(c1, c2, to)
|
|
105
|
+
to
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def num(tokens, i)
|
|
109
|
+
tokens[i].to_f
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def finish
|
|
113
|
+
@subpaths.map { |segments| segments.map { |s| offset(s) } }
|
|
114
|
+
.reject(&:empty?)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def offset(segment)
|
|
118
|
+
case segment
|
|
119
|
+
when Line
|
|
120
|
+
Line.new(shift(segment.point))
|
|
121
|
+
when Curve
|
|
122
|
+
Curve.new(shift(segment.c1), shift(segment.c2), shift(segment.point))
|
|
123
|
+
else
|
|
124
|
+
segment
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def shift(point)
|
|
129
|
+
Point.new(point.x + @dx, point.y + @dy)
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|