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.
Files changed (36) hide show
  1. checksums.yaml +4 -4
  2. data/lib/lutaml/lml/cli.rb +84 -18
  3. data/lib/lutaml/lml/data_processor/value_processing.rb +6 -1
  4. data/lib/lutaml/lml/document_builder.rb +72 -3
  5. data/lib/lutaml/lml/format/adapter/mapping.rb +12 -0
  6. data/lib/lutaml/lml/format/adapter/standard_adapter.rb +41 -12
  7. data/lib/lutaml/lml/formatter/elk.rb +45 -0
  8. data/lib/lutaml/lml/formatter/graphviz/node_formatter.rb +2 -1
  9. data/lib/lutaml/lml/formatter/svg_pdf/path_parser.rb +134 -0
  10. data/lib/lutaml/lml/formatter/svg_pdf.rb +281 -0
  11. data/lib/lutaml/lml/formatter.rb +2 -0
  12. data/lib/lutaml/lml/grammar/lml.artifact.json +7265 -4550
  13. data/lib/lutaml/lml/grammar/lml.parg +230 -115
  14. data/lib/lutaml/lml/layout/elk_engine.rb +15 -0
  15. data/lib/lutaml/lml/layout/elk_graph_builder.rb +129 -0
  16. data/lib/lutaml/lml/layout/elk_svg_markers.rb +45 -0
  17. data/lib/lutaml/lml/layout/elk_svg_renderer.rb +119 -0
  18. data/lib/lutaml/lml/layout/graph_viz_engine.rb +1 -1
  19. data/lib/lutaml/lml/layout.rb +4 -0
  20. data/lib/lutaml/lml/model_compiler/enums.rb +172 -0
  21. data/lib/lutaml/lml/model_compiler/hydration.rb +123 -0
  22. data/lib/lutaml/lml/model_compiler/serialization_mappings.rb +162 -0
  23. data/lib/lutaml/lml/model_compiler/string_formats.rb +42 -0
  24. data/lib/lutaml/lml/model_compiler/validation.rb +65 -0
  25. data/lib/lutaml/lml/model_compiler.rb +46 -453
  26. data/lib/lutaml/lml/models/document.rb +29 -0
  27. data/lib/lutaml/lml/models/operation_parameter.rb +1 -0
  28. data/lib/lutaml/lml/models/reference.rb +23 -0
  29. data/lib/lutaml/lml/models/top_element_attribute.rb +12 -0
  30. data/lib/lutaml/lml/models/uml_class.rb +1 -1
  31. data/lib/lutaml/lml/preprocessor.rb +44 -79
  32. data/lib/lutaml/lml/transform.rb +54 -6
  33. data/lib/lutaml/lml/validator.rb +88 -19
  34. data/lib/lutaml/lml/version.rb +1 -1
  35. data/lib/lutaml/lml.rb +19 -3
  36. metadata +17 -4
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8bdefb219dd99a2335669d57b9e7a79a21d822485ebc1a3748d915dee88d2901
4
- data.tar.gz: a63770177d2c8ff0b85e0d30caa6c9033529c75eee9738fbe59b25dc7c1d30eb
3
+ metadata.gz: c44e08a243084f376e006dfa845a878267d5867d1c07e9cb2f957cadfdf593af
4
+ data.tar.gz: 56b169b4f31489f51713071e48002ec2b0babc8f6b510aa6660e7b02976b0f93
5
5
  SHA512:
6
- metadata.gz: 3e12f920e7b614aa4902cd953e311fdde4dae2cd93a15bd9c5fe10b1c0ca234412a8228ca6cf2f0d22956ddb67cfd681127dedac417d43cd9b0c5b962e3e777a
7
- data.tar.gz: c7a727c6d617731e160d1ad6bebbba053cd6216a10c93c2d177f391881f0405f0304b3bd7a28230c18cd85cddb3383ff7639a348b36489f142d1e6c6e052ec76
6
+ metadata.gz: 12590d775d46b14bbefe7733610b6b60c08ddaa169e6aa3e4010b13979e565d36bde270adffc5e7ef6cdb37c9fbcadedb16d66b0bbc1e6404d74c46da5b2c1ec
7
+ data.tar.gz: 1dcf78fc5244594d03992cdb8dd4b2e286c0069741b3985bad2667ea1752eab19534aa16878e9e2c80ac3ab4b0c5c2381756634590a99df1070e72a0e6b7bf83
@@ -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 = ::Lutaml::Formatter::Graphviz.new if defined?(::Lutaml::Formatter::Graphviz)
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
- The compiled classes are anonymous Serializable subclasses registered
112
- in the ModelCompiler. With --namespace, classes are also registered
113
- as constants in the given module.
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
- desc: 'Register compiled classes in a module'
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] ? resolve_namespace(options[:namespace]) : nil
126
- result = Lutaml::Lml::ModelCompiler.new(namespace: ns).compile(File.new(path))
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.each_key do |name|
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 resolve_namespace(name)
136
- name.split('::').reduce(Object) do |mod, const_name|
137
- mod.const_get(const_name)
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
- rescue NameError
140
- raise Thor::Error, "Namespace '#{name}' not found"
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('w+') { |f| f.write(result) }
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
- case item
88
- when Hash
89
- format_nested_instance(item, indent + 1)
97
+ if reference_value?(item)
98
+ " ref:(#{item[:reference]})"
90
99
  else
91
- " #{quote_value(item)}"
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?(/^[\w-]+$/) ? str : "\"#{str}\""
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
@@ -7,7 +7,8 @@ module Lutaml
7
7
  ACCESS_SYMBOLS = {
8
8
  'public' => '+',
9
9
  'protected' => '#',
10
- 'private' => '-'
10
+ 'private' => '-',
11
+ 'package' => '~'
11
12
  }.freeze
12
13
 
13
14
  def format_attribute(node)
@@ -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