sevgi-graphics 0.95.0 → 0.98.2

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/CHANGELOG.md +125 -0
  3. data/README.md +8 -8
  4. data/lib/sevgi/graphics/attribute.rb +160 -37
  5. data/lib/sevgi/graphics/auxiliary/canvas.rb +100 -43
  6. data/lib/sevgi/graphics/auxiliary/content.rb +54 -34
  7. data/lib/sevgi/graphics/auxiliary/margin.rb +19 -12
  8. data/lib/sevgi/graphics/auxiliary/paper.rb +74 -49
  9. data/lib/sevgi/graphics/auxiliary/path.rb +44 -0
  10. data/lib/sevgi/graphics/auxiliary/scalar.rb +36 -7
  11. data/lib/sevgi/graphics/auxiliary.rb +1 -0
  12. data/lib/sevgi/graphics/document/base.rb +6 -2
  13. data/lib/sevgi/graphics/document/default.rb +1 -1
  14. data/lib/sevgi/graphics/document.rb +236 -102
  15. data/lib/sevgi/graphics/element.rb +114 -31
  16. data/lib/sevgi/graphics/mixtures/call.rb +249 -88
  17. data/lib/sevgi/graphics/mixtures/core.rb +59 -20
  18. data/lib/sevgi/graphics/mixtures/duplicate.rb +49 -15
  19. data/lib/sevgi/graphics/mixtures/export.rb +52 -12
  20. data/lib/sevgi/graphics/mixtures/hatch.rb +49 -7
  21. data/lib/sevgi/graphics/mixtures/identify.rb +30 -17
  22. data/lib/sevgi/graphics/mixtures/include.rb +26 -8
  23. data/lib/sevgi/graphics/mixtures/inkscape.rb +201 -47
  24. data/lib/sevgi/graphics/mixtures/rdf.rb +59 -7
  25. data/lib/sevgi/graphics/mixtures/render.rb +52 -28
  26. data/lib/sevgi/graphics/mixtures/save.rb +79 -35
  27. data/lib/sevgi/graphics/mixtures/symbols.rb +81 -12
  28. data/lib/sevgi/graphics/mixtures/tile.rb +85 -67
  29. data/lib/sevgi/graphics/mixtures/transform.rb +68 -28
  30. data/lib/sevgi/graphics/mixtures/underscore.rb +14 -5
  31. data/lib/sevgi/graphics/mixtures/validate.rb +2 -2
  32. data/lib/sevgi/graphics/mixtures/wrappers.rb +62 -23
  33. data/lib/sevgi/graphics/mixtures.rb +15 -13
  34. data/lib/sevgi/graphics/version.rb +1 -1
  35. data/lib/sevgi/graphics.rb +58 -12
  36. metadata +7 -6
@@ -8,52 +8,96 @@ module Sevgi
8
8
  # DSL helpers for writing rendered SVG output.
9
9
  module Save
10
10
  # Default SVG extension.
11
+ # @api private
11
12
  EXT = ".svg"
12
13
 
14
+ private_constant :EXT
15
+
16
+ # Change-aware file writer with optional backup support.
17
+ # @api private
18
+ class Writer
19
+ # Writes content when it differs from the destination.
20
+ # @param path [String] expanded output path
21
+ # @param content [String] rendered content
22
+ # @param backup_suffix [String, nil] suffix used for an existing-file backup
23
+ # @yield [content] optionally normalizes old and new content for change detection
24
+ # @yieldparam content [String] old or new content
25
+ # @yieldreturn [String] normalized content
26
+ # @return [String, nil] expanded path when written, otherwise nil
27
+ # @raise [SystemCallError] when the destination or backup cannot be created, read, or written
28
+ def self.call(path, content, backup_suffix: nil, &filter)
29
+ output = "#{content.chomp}\n"
30
+
31
+ return unless F.changed?(path, output, &filter)
32
+
33
+ ::FileUtils.mkdir_p(::File.dirname(path))
34
+ if backup_suffix && !backup_suffix.empty? && ::File.exist?(path)
35
+ ::FileUtils.cp(path, "#{path}#{backup_suffix}")
36
+ end
37
+
38
+ path.tap { ::File.write(path, output) }
39
+ end
40
+ end
41
+
42
+ private_constant :Writer
43
+
13
44
  # Writes rendered SVG to standard output.
14
- # @param kwargs [Hash] render options
15
- # @yield [content] optionally transforms rendered content before output
16
- # @yieldparam content [String] rendered SVG source
17
- # @yieldreturn [String] transformed SVG source
18
- # @return [Object] F.out return value
19
- def Out(**kwargs, &filter)
20
- F.out(self.(**kwargs), &filter)
45
+ # @param kwargs [Hash] pre-render and renderer options accepted by {Sevgi::Graphics::Document::Proto#call}
46
+ # @return [nil]
47
+ # @raise [Sevgi::ArgumentError] when a render option or XML-bound value is invalid
48
+ # @raise [Sevgi::ValidationError] when validation is enabled and the document violates the SVG standard
49
+ # @raise [Sevgi::Graphics::LintError] when linting is enabled and the document has structural conflicts
50
+ # @see Sevgi::Graphics::Document::Proto#call
51
+ def Out(**kwargs)
52
+ F.out(self.(**kwargs))
21
53
  end
22
54
 
23
- # Saves rendered SVG to a path derived from the caller by default.
24
- # @param path [String, nil] output path or directory
25
- # @param default [String, nil] default output path
55
+ # Saves rendered SVG when its content differs from the destination.
56
+ # Relative destinations are expanded before being returned. When a non-empty backup suffix is given, an
57
+ # existing destination is copied immediately before replacement; unchanged saves leave both files untouched.
58
+ # Missing parent directories are created. An existing directory target uses the default file name.
59
+ # @example Save to a relative destination
60
+ # path = Sevgi::Graphics.SVG(:minimal).Save("build/drawing.svg")
61
+ # path == File.expand_path("build/drawing.svg") # => true
62
+ # @param path [String, #to_path, nil] output path or existing directory
63
+ # @param default [String, #to_path, nil] default output path
26
64
  # @param backup_suffix [String, nil] suffix used for an existing-file backup
27
- # @yield [content] optionally transforms rendered content before output
28
- # @yieldparam content [String] rendered SVG source
29
- # @yieldreturn [String] transformed SVG source
30
- # @return [Object] F.out return value
31
- def Save(path = nil, default: nil, backup_suffix: nil, &filter)
32
- default ||= F.subext(EXT, caller_locations(1..1).first.path)
33
-
34
- if path
35
- ::File.directory?(path) ? ::File.join(path, ::File.basename(default)) : path
36
- else
37
- default
38
- end => path
39
-
40
- ::FileUtils.mkdir_p(::File.dirname(path))
41
- if backup_suffix && !backup_suffix.empty? && ::File.exist?(path)
42
- ::FileUtils.cp(path, "#{path}#{backup_suffix}")
43
- end
65
+ # @param kwargs [Hash] pre-render and renderer options accepted by {Sevgi::Graphics::Document::Proto#call}
66
+ # @yield [content] optionally normalizes old and new content for change detection
67
+ # @yieldparam content [String] old or new SVG source
68
+ # @yieldreturn [String] normalized SVG source
69
+ # @return [String, nil] expanded path when written, or nil when unchanged
70
+ # @raise [Sevgi::ArgumentError] when a selected path/default, render option, or XML-bound value is invalid
71
+ # @raise [Sevgi::ValidationError] when validation is enabled and the document violates the SVG standard
72
+ # @raise [Sevgi::Graphics::LintError] when linting is enabled and the document has structural conflicts
73
+ # @raise [SystemCallError] when the destination or backup cannot be created, read, or written
74
+ # @see Sevgi::Graphics::Document::Proto#call
75
+ def Save(path = nil, default: nil, backup_suffix: nil, **kwargs, &filter)
76
+ default = F.subext(EXT, caller_locations(1..1).first.path) if default.nil?
77
+ path = Path.resolve(path, default:, context: "Save")
44
78
 
45
- Write(path, &filter)
79
+ Writer.(path, self.(**kwargs), backup_suffix:, &filter)
46
80
  end
47
81
 
48
82
  # Writes rendered SVG to a path.
49
- # @param path [String] output path
50
- # @param kwargs [Hash] render options
51
- # @yield [content] optionally transforms rendered content before output
52
- # @yieldparam content [String] rendered SVG source
53
- # @yieldreturn [String] transformed SVG source
54
- # @return [Object] F.out return value
83
+ # Missing parent directories are created. Unlike {#Save}, a directory is not treated as a request for a default
84
+ # file name.
85
+ # @param path [String, #to_path] output file path
86
+ # @param kwargs [Hash] pre-render and renderer options accepted by {Sevgi::Graphics::Document::Proto#call}
87
+ # @yield [content] optionally normalizes old and new content for change detection
88
+ # @yieldparam content [String] old or new SVG source
89
+ # @yieldreturn [String] normalized SVG source
90
+ # @return [String, nil] expanded path when written, or nil when unchanged
91
+ # @raise [Sevgi::ArgumentError] when path, a render option, or an XML-bound value is invalid
92
+ # @raise [Sevgi::ValidationError] when validation is enabled and the document violates the SVG standard
93
+ # @raise [Sevgi::Graphics::LintError] when linting is enabled and the document has structural conflicts
94
+ # @raise [SystemCallError] when the destination cannot be read or written
95
+ # @see Sevgi::Graphics::Document::Proto#call
55
96
  def Write(path, **kwargs, &filter)
56
- F.out(self.(**kwargs), path, &filter)
97
+ path = Path.(path, context: "Write path")
98
+ ArgumentError.("Write path must name a file") if ::File.directory?(path)
99
+
100
+ Writer.(path, self.(**kwargs), &filter)
57
101
  end
58
102
  end
59
103
  end
@@ -5,20 +5,89 @@ module Sevgi
5
5
  module Mixtures
6
6
  # DSL helpers for expanding callable modules into SVG symbols.
7
7
  module Symbols
8
- # Renders module callables as symbols under defs.
9
- # @param mod [Module] callable drawing module
8
+ # Builds one symbol set without adding helper methods to the document DSL.
9
+ # @api private
10
+ class Expansion
11
+ # Creates a symbol expansion.
12
+ # @param receiver [Sevgi::Graphics::Element] parent element
13
+ # @param mod [Module] module extended with {Sevgi::Graphics::Module}
14
+ # @return [void]
15
+ def initialize(receiver, mod)
16
+ @receiver = receiver
17
+ @mod = mod
18
+ end
19
+
20
+ # Builds a defs container and populates it from the callable module.
21
+ # @param args [Array<Object>] callable positional arguments
22
+ # @param attributes [Hash] defs attributes
23
+ # @param ids [#call, nil] symbol id mapper
24
+ # @param kwargs [Hash] callable keyword arguments
25
+ # @param block [Proc, nil] callable block argument
26
+ # @return [Sevgi::Graphics::Element] defs element
27
+ # @raise [Sevgi::ArgumentError] when an input channel is invalid
28
+ def call(*args, attributes:, ids:, **kwargs, &block)
29
+ methods = Graphics::Module.__send__(:callables, @mod)
30
+ ArgumentError.("Defs attributes must be a Hash") unless attributes.is_a?(::Hash)
31
+ ArgumentError.("Symbol ids must respond to call") if ids && !ids.respond_to?(:call)
32
+
33
+ defaults = @mod.name ? {id: F.demodulize(@mod.name).to_sym} : {}
34
+ attributes = Attribute.defaults(attributes, **defaults)
35
+ @args, @kwargs, @block = args, kwargs, block
36
+ @receiver.defs(**attributes).tap { populate(it, methods, ids) }
37
+ end
38
+
39
+ private
40
+
41
+ # Adds bases and symbols to a defs element.
42
+ # @param defs [Sevgi::Graphics::Element] defs element
43
+ # @param methods [Array<UnboundMethod>] callable methods
44
+ # @param ids [#call, nil] symbol id mapper
45
+ # @return [void]
46
+ def populate(defs, methods, ids)
47
+ context = Graphics::Module.__send__(:context, @mod, defs)
48
+ Graphics::Module.__send__(:bases, @mod).each { context.instance_exec(&it) }
49
+ methods.each { draw(defs, it, ids) }
50
+ end
51
+
52
+ # Adds one callable symbol.
53
+ # @param defs [Sevgi::Graphics::Element] defs element
54
+ # @param method [UnboundMethod] callable method
55
+ # @param ids [#call, nil] symbol id mapper
56
+ # @return [Object, nil] callable return value
57
+ def draw(defs, method, ids)
58
+ name = method.name
59
+ symbol = defs.symbol(id: ids ? ids.call(name) : name.to_s.tr("_", "-"))
60
+ symbol.title(name.to_s.split("_").map(&:capitalize).join(" "))
61
+ context = Graphics::Module.__send__(:context, @mod, symbol)
62
+ Graphics::Module.__send__(:invoke, context, symbol, [method], *@args, **@kwargs, &@block)
63
+ end
64
+ end
65
+
66
+ private_constant :Expansion
67
+
68
+ # Renders module callables as symbols under defs. Named modules default the defs id to their final constant name;
69
+ # anonymous modules omit the id unless supplied.
70
+ # @param mod [Module] module extended with {Sevgi::Graphics::Module}
10
71
  # @param args [Array<Object>] callable arguments
11
- # @param kwargs [Hash] defs attributes
12
- # @yield [name] converts each callable name to a symbol id
13
- # @yieldparam name [Symbol] callable method name
14
- # @yieldreturn [String, Symbol] symbol id
72
+ # Base blocks run once in the defs element before symbols are created. Positional arguments, keyword arguments,
73
+ # and the block are forwarded to each callable.
74
+ # @example Expand named drawing methods into reusable symbols
75
+ # icons = Module.new do
76
+ # extend Sevgi::Graphics::Module
77
+ # def dot = circle r: 2
78
+ # def tick = path d: "M 0 2 L 2 4 L 6 0"
79
+ # end
80
+ # Sevgi::Graphics.SVG(:minimal) { Symbols icons }
81
+ # @param attributes [Hash] defs attributes; String and Symbol names are normalized and must not collide
82
+ # @param ids [#call, nil] optional callable mapping each method name to a symbol id
83
+ # @param kwargs [Hash] callable keyword arguments
84
+ # @yield forwarded to each callable
85
+ # @yieldreturn [Object] callable-defined block result
15
86
  # @return [Sevgi::Graphics::Element] defs element
16
- # @raise [Sevgi::ArgumentError] when mod is not a plain module
17
- def Symbols(mod, *args, **kwargs, &block)
18
- CallWithin(mod, :defs, :symbol, *args, **kwargs) do |name, element|
19
- element[:id] = block ? block.call(name) : name.to_s.split("_").join("-")
20
- title(name.to_s.split("_").map(&:capitalize).join(" "))
21
- end
87
+ # @raise [Sevgi::ArgumentError] when mod is not a callable drawing module, attributes is not a Hash, or ids is
88
+ # not callable
89
+ def Symbols(mod, *args, attributes: {}, ids: nil, **kwargs, &block)
90
+ Expansion.new(self, mod).call(*args, attributes:, ids:, **kwargs, &block)
22
91
  end
23
92
  end
24
93
  end
@@ -3,22 +3,35 @@
3
3
  module Sevgi
4
4
  module Graphics
5
5
  module Mixtures
6
- # rubocop:disable Metrics/MethodLength
7
- # DSL helpers for repeated SVG use elements.
6
+ # DSL helpers for defining one SVG template and repeating it through `use` elements.
7
+ #
8
+ # Use {Sevgi::Sundries::Tile} instead when Ruby code needs inspectable repeated geometry or row/column bounds
9
+ # rather than SVG references.
10
+ # @see Sevgi::Sundries::Tile
11
+ # @see https://sevgi.roktas.dev/sundries/#choose-a-layout-model Choosing a layout model
8
12
  module Tile
9
- # Prefix used for generated tile CSS classes.
13
+ # Stable prefix used for generated tile CSS classes.
10
14
  PREFIX = "tile"
11
15
 
12
16
  # Builds a two-dimensional tile grid.
17
+ # Each use id has the form `id-row-column`, with one-based row and column numbers. Generated classes identify
18
+ # the one-based row and column and mark their first and last positions. A block defines the referenced template
19
+ # as a group under `defs` before the uses are added.
20
+ # @example Define and customize a tile grid
21
+ # customize = proc { |use, x:, y:, nx:, ny:| use[:opacity] = (x + y + 1).fdiv(nx + ny) }
22
+ # Sevgi::Graphics.SVG(:minimal) do
23
+ # Tile("dot", nx: 2, dx: 10, ny: 2, dy: 10, proc: customize) { circle r: 2 }
24
+ # end
13
25
  # @param id [String] referenced template id
14
26
  # @param nx [Integer] number of columns
15
- # @param dx [Numeric] horizontal spacing
16
- # @param ox [Numeric] horizontal offset
27
+ # @param dx [Numeric] finite horizontal spacing, normalized before coordinates are rendered
28
+ # @param ox [Numeric] finite horizontal offset, normalized before coordinates are rendered
17
29
  # @param ny [Integer] number of rows
18
- # @param dy [Numeric] vertical spacing
19
- # @param oy [Numeric] vertical offset
20
- # @param proc [Proc, nil] optional coordinate/customization proc
21
- # @yield evaluates the template drawing DSL in a generated group
30
+ # @param dy [Numeric] finite vertical spacing, normalized before coordinates are rendered
31
+ # @param oy [Numeric] finite vertical offset, normalized before coordinates are rendered
32
+ # @param proc [Proc, nil] optional callback invoked for each use as `(element, x:, y:, nx:, ny:)`, with
33
+ # zero-based coordinates and total counts; the callback may mutate the element and its return value is ignored
34
+ # @yield evaluates the template drawing DSL in a generated `defs` group named by id
22
35
  # @yieldreturn [Object] ignored block result
23
36
  # @return [Sevgi::Graphics::Element] self
24
37
  # @raise [Sevgi::ArgumentError] when a required tile argument is missing or invalid
@@ -33,14 +46,9 @@ module Sevgi
33
46
  proc: nil,
34
47
  &block
35
48
  )
36
- Helper.assert(id:, nx:, dx:, ox:, ny:, dy:, oy:, proc:)
37
-
38
- href, coords = id, proc do |x, y|
39
- # rubocop:disable Style/NestedTernaryOperator
40
- # for pretty kwargs handling
41
- x.zero? ? (y.zero? ? {} : {y:}) : (y.zero? ? {x:} : {x:, y:})
42
- # rubocop:enable Style/NestedTernaryOperator
43
- end
49
+ id, nx, dx, ox, ny, dy, oy, callback = Helper
50
+ .normalize(id:, nx:, dx:, ox:, ny:, dy:, oy:, proc:)
51
+ .values_at(:id, :nx, :dx, :ox, :ny, :dy, :oy, :proc)
44
52
 
45
53
  defs { g(id:, &block) } if block
46
54
 
@@ -52,34 +60,36 @@ module Sevgi
52
60
  cs = Helper.classify(as: "col", index: x, upper: nx)
53
61
 
54
62
  element = use(
55
- id: [href, y + 1, x + 1].join("-"),
56
- href: "##{href}",
63
+ id: [id, y + 1, x + 1].join("-"),
64
+ href: "##{id}",
57
65
  class: [*rs, *cs].join(" "),
58
- **coords.((x * dx) + ox, (y * dy) + oy)
66
+ **Helper.coordinates(
67
+ x: Scalar.number((x * dx) + ox, context: "tile", field: :x),
68
+ y: Scalar.number((y * dy) + oy, context: "tile", field: :y)
69
+ )
59
70
  )
60
- proc&.call(element, x:, y:, nx:, ny:)
71
+ callback&.call(element, x:, y:, nx:, ny:)
61
72
  end
62
73
  end
63
74
  end
64
75
  end
65
76
 
66
77
  # Builds a one-dimensional horizontal tile row.
78
+ # Each use id has the form `id-column`, with a one-based column number. Generated classes identify the column and
79
+ # mark its first and last positions. A block defines the referenced template as a group under `defs` before the
80
+ # uses are added.
67
81
  # @param id [String] referenced template id
68
82
  # @param n [Integer] number of instances
69
- # @param d [Numeric] horizontal spacing
70
- # @param o [Numeric] horizontal offset
71
- # @param proc [Proc, nil] optional coordinate/customization proc
72
- # @yield evaluates the template drawing DSL in a generated group
83
+ # @param d [Numeric] finite horizontal spacing, normalized before coordinates are rendered
84
+ # @param o [Numeric] finite horizontal offset, normalized before coordinates are rendered
85
+ # @param proc [Proc, nil] optional callback invoked for each use as `(element, x:, n:)`, with a zero-based column
86
+ # and total count; the callback may mutate the element and its return value is ignored
87
+ # @yield evaluates the template drawing DSL in a generated `defs` group named by id
73
88
  # @yieldreturn [Object] ignored block result
74
89
  # @return [Sevgi::Graphics::Element] self
75
90
  # @raise [Sevgi::ArgumentError] when a required tile argument is missing or invalid
76
91
  def TileX(id = Undefined, n: Undefined, d: Undefined, o: 0, proc: nil, &block)
77
- Helper.assert(id:, n:, d:, o:, proc:)
78
-
79
- href, coords = id, proc do |x|
80
- # for pretty kwargs handling
81
- x.zero? ? {} : {x:}
82
- end
92
+ id, n, d, o, callback = Helper.normalize(id:, n:, d:, o:, proc:).values_at(:id, :n, :d, :o, :proc)
83
93
 
84
94
  defs { g(id:, &block) } if block
85
95
 
@@ -88,33 +98,32 @@ module Sevgi
88
98
  cs = Helper.classify(as: "col", index: x, upper: n)
89
99
 
90
100
  element = use(
91
- id: [href, x + 1].join("-"),
92
- href: "##{href}",
101
+ id: [id, x + 1].join("-"),
102
+ href: "##{id}",
93
103
  class: cs.join(" "),
94
- **coords.((x * d) + o)
104
+ **Helper.coordinates(x: Scalar.number((x * d) + o, context: "tile", field: :x))
95
105
  )
96
- proc&.call(element, x:, n:)
106
+ callback&.call(element, x:, n:)
97
107
  end
98
108
  end
99
109
  end
100
110
 
101
111
  # Builds a one-dimensional vertical tile column.
112
+ # Each use id has the form `id-row`, with a one-based row number. Generated classes identify the row and mark its
113
+ # first and last positions. A block defines the referenced template as a group under `defs` before the uses are
114
+ # added.
102
115
  # @param id [String] referenced template id
103
116
  # @param n [Integer] number of instances
104
- # @param d [Numeric] vertical spacing
105
- # @param o [Numeric] vertical offset
106
- # @param proc [Proc, nil] optional coordinate/customization proc
107
- # @yield evaluates the template drawing DSL in a generated group
117
+ # @param d [Numeric] finite vertical spacing, normalized before coordinates are rendered
118
+ # @param o [Numeric] finite vertical offset, normalized before coordinates are rendered
119
+ # @param proc [Proc, nil] optional callback invoked for each use as `(element, y:, n:)`, with a zero-based row and
120
+ # total count; the callback may mutate the element and its return value is ignored
121
+ # @yield evaluates the template drawing DSL in a generated `defs` group named by id
108
122
  # @yieldreturn [Object] ignored block result
109
123
  # @return [Sevgi::Graphics::Element] self
110
124
  # @raise [Sevgi::ArgumentError] when a required tile argument is missing or invalid
111
125
  def TileY(id = Undefined, n: Undefined, d: Undefined, o: 0, proc: nil, &block)
112
- Helper.assert(id:, n:, d:, o:, proc:)
113
-
114
- href, coords = id, proc do |y|
115
- # for pretty kwargs handling
116
- y.zero? ? {} : {y:}
117
- end
126
+ id, n, d, o, callback = Helper.normalize(id:, n:, d:, o:, proc:).values_at(:id, :n, :d, :o, :proc)
118
127
 
119
128
  defs { g(id:, &block) } if block
120
129
 
@@ -123,12 +132,12 @@ module Sevgi
123
132
  rs = Helper.classify(as: "row", index: y, upper: n)
124
133
 
125
134
  element = use(
126
- id: [href, y + 1].join("-"),
127
- href: "##{href}",
135
+ id: [id, y + 1].join("-"),
136
+ href: "##{id}",
128
137
  class: rs.join(" "),
129
- **coords.((y * d) + o)
138
+ **Helper.coordinates(y: Scalar.number((y * d) + o, context: "tile", field: :y))
130
139
  )
131
- proc&.call(element, y:, n:)
140
+ callback&.call(element, y:, n:)
132
141
  end
133
142
  end
134
143
  end
@@ -138,18 +147,14 @@ module Sevgi
138
147
  module Helper
139
148
  extend self
140
149
 
150
+ FINITE = %i[d dx dy o ox oy].freeze
151
+
141
152
  # Argument validators for tile helpers.
142
153
  ASSERTION = {
143
154
  id: proc { |name, value| "Argument '#{name}' must be a string" unless value.is_a?(::String) },
144
155
  n: proc { |name, value| positive_integer_issue(name, value) },
145
156
  nx: proc { |name, value| positive_integer_issue(name, value) },
146
157
  ny: proc { |name, value| positive_integer_issue(name, value) },
147
- d: proc { |name, value| "Argument '#{name}' must be a number" unless value.is_a?(::Numeric) },
148
- dx: proc { |name, value| "Argument '#{name}' must be a number" unless value.is_a?(::Numeric) },
149
- dy: proc { |name, value| "Argument '#{name}' must be a number" unless value.is_a?(::Numeric) },
150
- o: proc { |name, value| "Argument '#{name}' must be a number" unless value.is_a?(::Numeric) },
151
- ox: proc { |name, value| "Argument '#{name}' must be a number" unless value.is_a?(::Numeric) },
152
- oy: proc { |name, value| "Argument '#{name}' must be a number" unless value.is_a?(::Numeric) },
153
158
  proc: proc { |name, value| "Argument '#{name}' must be a proc" unless value.nil? || value.is_a?(::Proc) }
154
159
  }.freeze
155
160
 
@@ -161,22 +166,33 @@ module Sevgi
161
166
  "Argument '#{name}' must be a positive integer" unless value.is_a?(::Integer) && value.positive?
162
167
  end
163
168
 
164
- # Validates tile arguments.
169
+ # Validates and normalizes tile arguments.
165
170
  # @param kwargs [Hash] tile arguments
166
- # @return [nil]
171
+ # @return [Hash] independent arguments with spacing and offsets normalized to SVG numbers
167
172
  # @raise [Sevgi::ArgumentError] when an argument is missing or invalid
168
- def assert(**kwargs)
169
- kwargs.each do |name, value|
170
- issue = "Argument '#{name}' required" if value == Undefined
173
+ def normalize(**kwargs)
174
+ kwargs.to_h do |name, value|
175
+ ArgumentError.("Argument '#{name}' required") if value == Undefined
176
+ [name, normalize_value(name, value)]
177
+ end
178
+ end
171
179
 
172
- unless issue
173
- next unless (assertion = ASSERTION[name])
174
- next unless (issue = assertion.call(name, value))
175
- end
180
+ def coordinates(**coordinates)
181
+ coordinates.reject { |_, value| value.zero? }
182
+ end
176
183
 
177
- ArgumentError.(issue)
178
- end
179
- # rubocop:enable Metrics/MethodLength
184
+ def normalize_value(name, value)
185
+ return number(name, value) if FINITE.include?(name)
186
+ return value unless (assertion = ASSERTION[name])
187
+ return value unless (issue = assertion.call(name, value))
188
+
189
+ ArgumentError.(issue)
190
+ end
191
+
192
+ def number(name, value)
193
+ Scalar.number(value, context: "tile", field: name)
194
+ rescue ::Sevgi::ArgumentError
195
+ ArgumentError.("Argument '#{name}' must be a finite real number")
180
196
  end
181
197
 
182
198
  # Returns positional tile CSS classes.
@@ -191,6 +207,8 @@ module Sevgi
191
207
  classes << "#{PREFIX}-#{as}-last" if index + 1 == upper
192
208
  end
193
209
  end
210
+
211
+ private :normalize_value, :number
194
212
  end
195
213
 
196
214
  private_constant :Helper