sevgi-graphics 0.95.0 → 1.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 (37) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +221 -2
  3. data/README.md +12 -9
  4. data/lib/sevgi/graphics/attribute.rb +166 -45
  5. data/lib/sevgi/graphics/auxiliary/canvas.rb +100 -43
  6. data/lib/sevgi/graphics/auxiliary/content.rb +56 -47
  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 +239 -117
  15. data/lib/sevgi/graphics/element.rb +132 -34
  16. data/lib/sevgi/graphics/mixtures/call.rb +234 -88
  17. data/lib/sevgi/graphics/mixtures/core.rb +67 -27
  18. data/lib/sevgi/graphics/mixtures/duplicate.rb +47 -25
  19. data/lib/sevgi/graphics/mixtures/export.rb +54 -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 +214 -47
  24. data/lib/sevgi/graphics/mixtures/rdf.rb +59 -7
  25. data/lib/sevgi/graphics/mixtures/render.rb +60 -120
  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 +89 -30
  30. data/lib/sevgi/graphics/mixtures/underscore.rb +16 -7
  31. data/lib/sevgi/graphics/mixtures/validate.rb +2 -2
  32. data/lib/sevgi/graphics/mixtures/wrappers.rb +111 -23
  33. data/lib/sevgi/graphics/mixtures.rb +15 -13
  34. data/lib/sevgi/graphics/version.rb +1 -1
  35. data/lib/sevgi/graphics/xml.rb +4 -9
  36. data/lib/sevgi/graphics.rb +69 -18
  37. metadata +7 -6
@@ -10,75 +10,20 @@ module Sevgi
10
10
  class Renderer
11
11
  # Default renderer options.
12
12
  DEFAULTS = {indent: " ", linelength: 140, style: :hybrid}.freeze
13
+ STYLES = %i[hybrid inline block].freeze
13
14
  SVG_NAMESPACE = "http://www.w3.org/2000/svg"
14
15
 
15
- # Attribute rendering strategies.
16
- # @api private
17
- module Attributes
18
- # Block-style attribute renderer.
19
- # @api private
20
- module Block
21
- # Renders attributes in block form.
22
- # @param element [Sevgi::Graphics::Element] rendered element
23
- # @param depth [Integer] element depth
24
- # @return [void]
25
- def attributes(element, depth)
26
- attributes_block(element, depth, element.attributes.to_xml_lines)
27
- end
28
- end
29
-
30
- # Hybrid attribute renderer.
31
- # @api private
32
- module Hybrid
33
- # Renders attributes inline or in block form according to line length.
34
- # @param element [Sevgi::Graphics::Element] rendered element
35
- # @param depth [Integer] element depth
36
- # @return [void]
37
- def attributes(element, depth)
38
- if attributes_as_block?(lines = element.attributes.to_xml_lines, depth)
39
- attributes_block(element, depth, lines)
40
- else
41
- attributes_inline(element, depth, lines)
42
- end
43
- end
44
-
45
- # Reports whether attributes should be rendered in block form.
46
- # @param lines [Array<String>] rendered attribute lines
47
- # @param depth [Integer] element depth
48
- # @return [Boolean]
49
- def attributes_as_block?(lines, depth)
50
- linelength(lines, depth) > options[:linelength]
51
- end
52
-
53
- # Returns the effective inline line length.
54
- # @param lines [Array<String>] rendered attribute lines
55
- # @param depth [Integer] element depth
56
- # @return [Integer]
57
- def linelength(lines, depth)
58
- indent(depth).length + lines.sum(&:length)
59
- end
60
- end
61
-
62
- # Inline attribute renderer.
63
- # @api private
64
- module Inline
65
- # Renders attributes inline.
66
- # @param element [Sevgi::Graphics::Element] rendered element
67
- # @param depth [Integer] element depth
68
- # @return [void]
69
- def attributes(element, depth)
70
- attributes_inline(element, depth, element.attributes.to_xml_lines)
71
- end
72
- end
73
- end
74
-
75
- private_constant :Attributes
76
-
77
16
  ELEMENTS_WITH_INLINE_CONTENT = %i[title].freeze
78
17
  ELEMENTS_WITH_BLOCK_CONTENT = %i[style].freeze
79
18
  SEPARATOR = "\n"
80
19
 
81
- private_constant :ELEMENTS_WITH_INLINE_CONTENT, :ELEMENTS_WITH_BLOCK_CONTENT, :SEPARATOR, :SVG_NAMESPACE
20
+ private_constant(
21
+ :ELEMENTS_WITH_INLINE_CONTENT,
22
+ :ELEMENTS_WITH_BLOCK_CONTENT,
23
+ :SEPARATOR,
24
+ :STYLES,
25
+ :SVG_NAMESPACE
26
+ )
82
27
 
83
28
  # @return [Sevgi::Graphics::Element] root element
84
29
  attr_reader :root
@@ -95,17 +40,11 @@ module Sevgi
95
40
  # Inline mark with start, stop, and depth.
96
41
  Mark = Struct.new(:start, :stop, :depth)
97
42
 
98
- # Creates an inline splice tracker.
99
- # @return [void]
100
43
  def initialize
101
44
  @marks = []
102
45
  @stack = []
103
46
  end
104
47
 
105
- # Starts an inline splice range.
106
- # @param index [Integer] output index
107
- # @param depth [Integer] element depth
108
- # @return [Sevgi::Graphics::Mixtures::Render::Renderer::Inlines::Mark] opened mark
109
48
  def start(index, depth)
110
49
  Mark.new(start: index, depth:).tap do |mark|
111
50
  @marks << mark
@@ -113,10 +52,6 @@ module Sevgi
113
52
  end
114
53
  end
115
54
 
116
- # Ends the innermost inline splice range.
117
- # @param index [Integer] output index
118
- # @return [Integer]
119
- # @raise [Sevgi::PanicError] when no inline range is open
120
55
  def stop(index)
121
56
  mark = @stack.pop
122
57
  PanicError.("Inline content range was not opened") unless mark
@@ -124,12 +59,6 @@ module Sevgi
124
59
  mark.stop = index
125
60
  end
126
61
 
127
- # Joins marked output ranges into inline content.
128
- # @param output [Array<Array<String>, nil>] renderer output buffer
129
- # @param indent [String] indentation unit
130
- # @param separator [String] line separator
131
- # @return [Array<Array<String>, nil>, nil]
132
- # @raise [Sevgi::PanicError] when an inline range was not closed
133
62
  def join(output, indent:, separator:)
134
63
  return if @marks.empty?
135
64
 
@@ -165,11 +94,10 @@ module Sevgi
165
94
  # @raise [Sevgi::ArgumentError] when an option is unknown, malformed, missing, or unsupported
166
95
  def initialize(root, **)
167
96
  @root = root
168
- @options = DEFAULTS.merge(**)
97
+ @options = self.class.send(:validate, **)
169
98
  @output = []
170
99
  @inlines = Inlines.new
171
-
172
- build
100
+ unclosed
173
101
  end
174
102
 
175
103
  # @overload call(root, **options)
@@ -187,6 +115,29 @@ module Sevgi
187
115
  # @raise [Sevgi::ArgumentError] when options, names, attributes, or content are invalid XML
188
116
  def self.fragment(root, **options) = new(root, **options).call
189
117
 
118
+ def self.validate(**options)
119
+ options = DEFAULTS.merge(options)
120
+ unknown = options.keys - DEFAULTS.keys
121
+ ArgumentError.("Unknown renderer options: #{unknown.join(", ")}") unless unknown.empty?
122
+
123
+ indent = options[:indent]
124
+ unless indent.is_a?(::String) && /\A[\t\n\r ]*\z/.match?(indent)
125
+ ArgumentError.("Renderer indent must contain only XML whitespace")
126
+ end
127
+
128
+ options[:indent] = XML.text(indent, context: "Renderer indent")
129
+ linelength = options[:linelength]
130
+ unless linelength.is_a?(::Integer) && linelength >= 0
131
+ ArgumentError.("Renderer linelength must be a non-negative Integer")
132
+ end
133
+
134
+ ArgumentError.("Unrecognized style: #{options[:style]}") unless STYLES.include?(options[:style])
135
+
136
+ options.freeze
137
+ end
138
+
139
+ private_class_method :validate
140
+
190
141
  # Appends rendered lines to the output buffer.
191
142
  # @param depth [Integer, nil] indentation depth
192
143
  # @param lines [Array<String>] rendered lines
@@ -216,6 +167,14 @@ module Sevgi
216
167
 
217
168
  attr_reader :inlines
218
169
 
170
+ def attributes(element, depth)
171
+ lines = element.attributes.send(:xml_lines)
172
+ block = options[:style] == :block ||
173
+ (options[:style] == :hybrid && indent(depth).length + lines.sum(&:length) > options[:linelength])
174
+
175
+ block ? attributes_block(element, depth, lines) : attributes_inline(element, depth, lines)
176
+ end
177
+
219
178
  def attributes_block(element, depth, lines)
220
179
  return attributes_inline(element, depth, lines) if lines.empty?
221
180
 
@@ -235,24 +194,6 @@ module Sevgi
235
194
  end
236
195
  end
237
196
 
238
- def build
239
- validate_options!
240
- ArgumentError.("Missing style") unless options[:style]
241
-
242
- case options[:style]
243
- when :hybrid
244
- extend(Attributes::Hybrid)
245
- when :inline
246
- extend(Attributes::Inline)
247
- when :block
248
- extend(Attributes::Block)
249
- else
250
- ArgumentError.("Unrecognized style: #{options[:style]}")
251
- end
252
-
253
- unclosed
254
- end
255
-
256
197
  def childless?(element)
257
198
  element.children.empty? && element.contents.empty?
258
199
  end
@@ -330,32 +271,23 @@ module Sevgi
330
271
 
331
272
  def unclosed = @closed = false
332
273
 
333
- def validate_options!
334
- unknown = options.keys - DEFAULTS.keys
335
- ArgumentError.("Unknown renderer options: #{unknown.join(", ")}") unless unknown.empty?
336
-
337
- indent = options[:indent]
338
- unless indent.is_a?(::String) && /\A[\t\n\r ]*\z/.match?(indent)
339
- ArgumentError.("Renderer indent must contain only XML whitespace")
340
- end
341
-
342
- options[:indent] = XML.text(indent, context: "Renderer indent")
343
- linelength = options[:linelength]
344
- return if linelength.is_a?(::Integer) && linelength >= 0
345
-
346
- ArgumentError.("Renderer linelength must be a non-negative Integer")
347
- end
348
274
  end
349
275
 
350
276
  private_constant :Renderer
351
277
 
352
278
  # @overload Render(**options)
353
279
  # Renders this element as SVG source.
354
- # Elements with inline text content may also contain inline children such as `tspan`; the renderer keeps those
280
+ # Elements with inline text content can also contain inline children such as `tspan`. The renderer keeps those
355
281
  # descendants in the same text line. Whitespace inside content objects is preserved as given, and encoded
356
- # content is XML-escaped unless a verbatim content object is used. SVG `style` elements use block content;
282
+ # content is XML-escaped unless a verbatim content object is used. SVG `style` elements use block content.
357
283
  # same-named elements under a foreign default namespace retain ordinary inline text formatting.
284
+ # @example Keep every attribute on the element's opening line
285
+ # SVG(:minimal) { rect id: "card", width: 80, height: 40 }.Render(style: :inline)
358
286
  # @param options [Hash] renderer options
287
+ # @option options [String] :indent (" ") XML-whitespace indentation unit
288
+ # @option options [Integer] :linelength (140) non-negative line length that switches hybrid attributes to block
289
+ # style
290
+ # @option options [Symbol] :style (:hybrid) attribute layout: `:hybrid`, `:inline`, or `:block`
359
291
  # @return [String] SVG source
360
292
  # @raise [Sevgi::ArgumentError] when options, preambles, names, attributes, or content are invalid XML
361
293
  def Render(**) = Renderer.(self, **)
@@ -363,14 +295,22 @@ module Sevgi
363
295
  # Renders only this element's children.
364
296
  # Child render output omits document preambles and preserves each child's text whitespace and inline
365
297
  # mixed-content formatting.
298
+ # @example Render child fragments with block-style attributes
299
+ # Sevgi::Graphics.SVG(:minimal) { rect id: "one" }.RenderChildren(style: :block, indent: "\t")
366
300
  # @param separator [String] separator between child documents
301
+ # @param options [Hash] renderer options applied to every child fragment
302
+ # @option options [String] :indent (" ") XML-whitespace indentation unit
303
+ # @option options [Integer] :linelength (140) non-negative line length that switches hybrid attributes to block
304
+ # style
305
+ # @option options [Symbol] :style (:hybrid) attribute layout: `:hybrid`, `:inline`, or `:block`
367
306
  # @return [String] rendered child fragments
368
- # @raise [Sevgi::ArgumentError] when separator or rendered child data is not valid XML text
369
- def RenderChildren(separator = "\n\n")
307
+ # @raise [Sevgi::ArgumentError] when separator, options, or rendered child data is invalid
308
+ def RenderChildren(separator = "\n\n", **options)
370
309
  ArgumentError.("SVG fragment separator must be a String") unless separator.is_a?(::String)
371
310
 
372
311
  separator = XML.text(separator, context: "SVG fragment separator")
373
- children.map { Renderer.fragment(it) }.join(separator)
312
+ Renderer.send(:validate, **options)
313
+ children.map { Renderer.fragment(it, **options) }.join(separator)
374
314
  end
375
315
  end
376
316
  end
@@ -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.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