sevgi-graphics 0.73.2 → 0.93.1

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 (43) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +5 -0
  3. data/lib/sevgi/graphics/attribute.rb +58 -6
  4. data/lib/sevgi/graphics/auxiliary/canvas.rb +125 -0
  5. data/lib/sevgi/graphics/auxiliary/content.rb +145 -0
  6. data/lib/sevgi/graphics/auxiliary/margin.rb +93 -0
  7. data/lib/sevgi/graphics/auxiliary/paper.rb +163 -0
  8. data/lib/sevgi/graphics/auxiliary.rb +7 -0
  9. data/lib/sevgi/graphics/document/base.rb +6 -0
  10. data/lib/sevgi/graphics/document/default.rb +1 -0
  11. data/lib/sevgi/graphics/document/html.rb +1 -0
  12. data/lib/sevgi/graphics/document/inkscape.rb +1 -0
  13. data/lib/sevgi/graphics/document/minimal.rb +1 -0
  14. data/lib/sevgi/graphics/document.rb +137 -11
  15. data/lib/sevgi/graphics/element.rb +68 -8
  16. data/lib/sevgi/graphics/mixtures/call.rb +35 -3
  17. data/lib/sevgi/graphics/mixtures/core.rb +107 -18
  18. data/lib/sevgi/graphics/mixtures/duplicate.rb +14 -0
  19. data/lib/sevgi/graphics/mixtures/export.rb +29 -6
  20. data/lib/sevgi/graphics/mixtures/hatch.rb +24 -3
  21. data/lib/sevgi/graphics/mixtures/identify.rb +23 -1
  22. data/lib/sevgi/graphics/mixtures/include.rb +31 -4
  23. data/lib/sevgi/graphics/mixtures/inkscape.rb +51 -5
  24. data/lib/sevgi/graphics/mixtures/lint.rb +12 -0
  25. data/lib/sevgi/graphics/mixtures/polyfills.rb +11 -0
  26. data/lib/sevgi/graphics/mixtures/rdf.rb +40 -1
  27. data/lib/sevgi/graphics/mixtures/render.rb +90 -2
  28. data/lib/sevgi/graphics/mixtures/save.rb +21 -3
  29. data/lib/sevgi/graphics/mixtures/symbols.rb +7 -0
  30. data/lib/sevgi/graphics/mixtures/tile.rb +41 -6
  31. data/lib/sevgi/graphics/mixtures/transform.rb +54 -16
  32. data/lib/sevgi/graphics/mixtures/underscore.rb +10 -1
  33. data/lib/sevgi/graphics/mixtures/validate.rb +15 -1
  34. data/lib/sevgi/graphics/mixtures/wrappers.rb +54 -17
  35. data/lib/sevgi/graphics/mixtures.rb +35 -3
  36. data/lib/sevgi/graphics/version.rb +2 -1
  37. data/lib/sevgi/graphics.rb +57 -5
  38. metadata +9 -8
  39. data/lib/sevgi/graphics/auxilary/canvas.rb +0 -72
  40. data/lib/sevgi/graphics/auxilary/content.rb +0 -81
  41. data/lib/sevgi/graphics/auxilary/margin.rb +0 -49
  42. data/lib/sevgi/graphics/auxilary/paper.rb +0 -95
  43. data/lib/sevgi/graphics/auxilary.rb +0 -7
@@ -0,0 +1,163 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sevgi
4
+ module Graphics
5
+ # Paper size and unit profile.
6
+ Paper = Data.define(:width, :height, :unit, :name) do
7
+ include Comparable
8
+
9
+ # @!attribute [r] width
10
+ # @return [Float] paper width
11
+ # @!attribute [r] height
12
+ # @return [Float] paper height
13
+ # @!attribute [r] unit
14
+ # @return [Symbol] SVG unit
15
+ # @!attribute [r] name
16
+ # @return [Symbol] profile name
17
+
18
+ # Creates a paper profile.
19
+ # @param width [Numeric] paper width
20
+ # @param height [Numeric] paper height
21
+ # @param unit [Symbol, String] SVG unit
22
+ # @param name [Symbol, String] profile name
23
+ # @return [void]
24
+ # @raise [Sevgi::ArgumentError] when dimensions, unit, or profile name are invalid
25
+ def initialize(width:, height:, unit: "mm", name: :custom)
26
+ super(
27
+ width: self.class.send(:dimension!, :width, width),
28
+ height: self.class.send(:dimension!, :height, height),
29
+ unit: self.class.send(:symbol!, :unit, unit),
30
+ name: self.class.send(:symbol!, :name, name)
31
+ )
32
+ end
33
+
34
+ # Compares papers by width, height, unit, then name.
35
+ # @param other [Sevgi::Graphics::Paper] paper to compare
36
+ # @return [Integer, nil]
37
+ def <=>(other) = deconstruct <=> other.deconstruct
38
+
39
+ # Reports strict paper equality.
40
+ # @param other [Object] object to compare
41
+ # @return [Boolean]
42
+ def eql?(other) = self.class == other.class && deconstruct == other.deconstruct
43
+
44
+ # Returns a hash compatible with strict equality.
45
+ # @return [Integer]
46
+ def hash = [self.class, *deconstruct].hash
47
+
48
+ # Returns the longer side.
49
+ # @return [Float]
50
+ def longest = [width, height].max
51
+
52
+ # Returns the shorter side.
53
+ # @return [Float]
54
+ def shortest = [width, height].min
55
+
56
+ alias_method :==, :eql?
57
+
58
+ @profiles = {}
59
+
60
+ # Reports whether a named paper profile exists.
61
+ # @param name [Object] profile name
62
+ # @return [Boolean]
63
+ def self.exist?(name) = name.respond_to?(:to_sym) && profiles.key?(name.to_sym)
64
+
65
+ # Defines or replaces a named paper profile.
66
+ # @param name [Symbol, String] profile name
67
+ # @param spec [Hash] paper dimensions and unit
68
+ # @option spec [Numeric] :width paper width
69
+ # @option spec [Numeric] :height paper height
70
+ # @option spec [Symbol, String] :unit SVG unit
71
+ # @return [Sevgi::Graphics::Paper]
72
+ # @raise [Sevgi::ArgumentError] when the profile name is reserved or invalid
73
+ def self.define(name, **spec)
74
+ name = symbol!(:name, name)
75
+
76
+ ArgumentError.("Paper name is reserved: #{name}") if reserved?(name) && !exist?(name)
77
+
78
+ singleton_class.remove_method(name) if singleton_class.method_defined?(name, false)
79
+ singleton_class.attr_reader(name)
80
+ profiles[name] = instance_variable_set("@#{name}", new(name:, **spec))
81
+ end
82
+
83
+ def self.reserved?(name) = @reserved.include?(name.to_sym)
84
+
85
+ def self.profiles = @profiles
86
+
87
+ def self.dimension!(field, value)
88
+ Float(value)
89
+ rescue ::ArgumentError, ::TypeError
90
+ ArgumentError.("Invalid paper #{field}: #{value.inspect}")
91
+ end
92
+
93
+ def self.symbol!(field, value)
94
+ value.to_sym
95
+ rescue ::NoMethodError, ::TypeError
96
+ ArgumentError.("Invalid paper #{field}: #{value.inspect}")
97
+ end
98
+
99
+ @reserved = methods.map(&:to_sym).freeze
100
+
101
+ private_class_method :dimension!, :profiles, :reserved?, :symbol!
102
+
103
+ {
104
+ a0: [841, 1189, "mm"],
105
+ a1: [594, 841, "mm"],
106
+ a2: [420, 594, "mm"],
107
+ a3: [297, 420, "mm"],
108
+ a4: [210, 297, "mm"],
109
+ a5: [148, 210, "mm"],
110
+ a6: [105, 148, "mm"],
111
+ a7: [74, 105, "mm"],
112
+ a8: [52, 74, "mm"],
113
+ a9: [37, 52, "mm"],
114
+ a10: [26, 37, "mm"],
115
+
116
+ b0: [1000, 1414, "mm"],
117
+ b1: [707, 1000, "mm"],
118
+ b2: [500, 707, "mm"],
119
+ b3: [353, 500, "mm"],
120
+ b4: [250, 353, "mm"],
121
+ b5: [176, 250, "mm"],
122
+ b6: [125, 176, "mm"],
123
+ b7: [88, 125, "mm"],
124
+ b8: [62, 88, "mm"],
125
+ b9: [44, 62, "mm"],
126
+ b10: [31, 44, "mm"],
127
+
128
+ c0: [917, 1297, "mm"],
129
+ c1: [648, 917, "mm"],
130
+ c2: [458, 648, "mm"],
131
+ c3: [324, 458, "mm"],
132
+ c4: [229, 324, "mm"],
133
+ c5: [162, 229, "mm"],
134
+ c6: [114, 162, "mm"],
135
+ c7: [81, 114, "mm"],
136
+ c8: [57, 81, "mm"],
137
+ c9: [40, 57, "mm"],
138
+ c10: [28, 40, "mm"],
139
+
140
+ business: [85, 55, "mm"],
141
+ large: [130, 210, "mm"],
142
+ passport: [88, 125, "mm"],
143
+ pocket: [90, 140, "mm"],
144
+ travelers: [110, 210, "mm"],
145
+ us: [216, 279, "mm"],
146
+ xlarge: [190, 250, "mm"],
147
+
148
+ icon16: [16, 16, "px"],
149
+ icon32: [32, 32, "px"],
150
+ icon64: [64, 64, "px"],
151
+ icon128: [128, 128, "px"],
152
+ icon256: [256, 256, "px"],
153
+ icon512: [512, 512, "px"]
154
+ }.each { |name, (width, height, unit)| define(name, width:, height:, unit:) }
155
+
156
+ class << self
157
+ alias_method :default, :a4
158
+ end
159
+
160
+ profiles[:default] = profiles.fetch(:a4)
161
+ end
162
+ end
163
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "auxiliary/margin"
4
+ require_relative "auxiliary/paper"
5
+
6
+ require_relative "auxiliary/canvas"
7
+ require_relative "auxiliary/content"
@@ -3,6 +3,7 @@
3
3
  module Sevgi
4
4
  module Graphics
5
5
  module Document
6
+ # Standard document profile with the full common DSL mixture set.
6
7
  class Base < Proto
7
8
  document :base
8
9
 
@@ -18,6 +19,11 @@ module Sevgi
18
19
  mixture :Underscore
19
20
  mixture :Validate
20
21
 
22
+ # Runs pre-render validation and lint checks.
23
+ # @param options [Hash] pre-render options
24
+ # @return [void]
25
+ # @raise [Sevgi::ValidationError] when validation fails
26
+ # @raise [Sevgi::Graphics::LintError] when linting fails
21
27
  def PreRender(**options)
22
28
  self.Validate() if options[:validate]
23
29
  self.Lint() if options[:lint]
@@ -3,6 +3,7 @@
3
3
  module Sevgi
4
4
  module Graphics
5
5
  module Document
6
+ # Default SVG document profile with XML preamble and SVG namespace.
6
7
  class Default < Minimal
7
8
  document(
8
9
  :default,
@@ -3,6 +3,7 @@
3
3
  module Sevgi
4
4
  module Graphics
5
5
  module Document
6
+ # SVG document profile intended for embedding in HTML.
6
7
  class HTML < Default
7
8
  document(
8
9
  :html,
@@ -3,6 +3,7 @@
3
3
  module Sevgi
4
4
  module Graphics
5
5
  module Document
6
+ # SVG document profile with Inkscape-oriented namespaces and mixtures.
6
7
  class Inkscape < Default
7
8
  document(
8
9
  :inkscape,
@@ -3,6 +3,7 @@
3
3
  module Sevgi
4
4
  module Graphics
5
5
  module Document
6
+ # Minimal document profile without XML preambles or default namespace attributes.
6
7
  class Minimal < Base
7
8
  document :minimal
8
9
  end
@@ -2,51 +2,166 @@
2
2
 
3
3
  module Sevgi
4
4
  module Graphics
5
+ # SVG document profile factory.
5
6
  module Document
7
+ # Builds a root SVG element from a document profile.
8
+ # @param document [Symbol, String, Class] profile name or document class
9
+ # @param canvas [Sevgi::Graphics::Canvas, Sevgi::Graphics::Paper, Symbol, String, Sevgi::Undefined, nil] canvas input
10
+ # @return [Sevgi::Graphics::Document::Proto] SVG root element
11
+ # @raise [Sevgi::ArgumentError] when the document profile is unknown
6
12
  def self.call(document, canvas = Undefined, **, &block)
13
+ klass = case document
14
+ when ::Class
15
+ document if document <= Proto
16
+ else
17
+ Profile[document]
18
+ end
19
+
20
+ ArgumentError.("Unknown document profile: #{document}") unless klass
21
+
22
+ klass.root(**klass.attributes, **canvas_attributes(canvas), **, &block)
23
+ end
24
+
25
+ def self.canvas_attributes(canvas)
7
26
  case canvas
8
27
  when Undefined, ::NilClass
9
28
  {}
10
29
  when Canvas
11
30
  canvas.attributes
12
31
  else
13
- Canvas.(canvas).attributes
14
- end => attributes
32
+ Canvas.from_paper(canvas).attributes
33
+ end
34
+ end
35
+
36
+ private_class_method :canvas_attributes
37
+
38
+ # Defines or returns a document profile class.
39
+ # @param name [Symbol, String, Sevgi::Undefined] profile name, or Undefined for an anonymous profile
40
+ # @param preambles [Array<String>, nil] document preamble lines
41
+ # @param attributes [Hash] default root attributes
42
+ # @param overwrite [Boolean] true to replace an existing profile
43
+ # @return [Class] document class
44
+ # @raise [Sevgi::ArgumentError] when a named profile conflicts with an existing profile
45
+ def self.define(name = Undefined, preambles: [], attributes: {}, overwrite: false)
46
+ return Class.new(Base) { document(name, preambles:, attributes:, register: false) } if name == Undefined
47
+
48
+ profile = Profile.new(name, attributes:, preambles:)
49
+
50
+ if (current = Profile[name])
51
+ unless overwrite || current.profile == profile
52
+ ArgumentError.("Document profile already defined differently: #{name}")
53
+ end
54
+
55
+ return current unless overwrite
56
+ end
15
57
 
16
- (klass = Profile[document]).root(**klass.attributes, **attributes, **, &block)
58
+ Class.new(Base) { document(name, preambles:, attributes:, overwrite:) }
17
59
  end
18
60
 
61
+ # Immutable document profile metadata.
62
+ # @api private
19
63
  class Profile
20
64
  @available = {}
21
65
 
22
66
  class << self
67
+ # @return [Hash<Symbol, Class>] registered profile classes
23
68
  attr_reader :available
24
69
  end
25
70
 
26
- def self.[](name) = available[name]
71
+ # Returns a profile class by name.
72
+ # @param name [Object] profile name
73
+ # @return [Class, nil]
74
+ def self.[](name) = (name = normalize(name)) && available[name]
75
+
76
+ # Registers a profile class.
77
+ # @param name [Object] profile name
78
+ # @param klass [Class] document class
79
+ # @param overwrite [Boolean] true to replace an existing profile
80
+ # @return [Class] registered class
81
+ # @raise [Sevgi::ArgumentError] when name is invalid or conflicts with an existing profile
82
+ def self.register(name, klass, overwrite: false)
83
+ name = normalize!(name)
84
+
85
+ if (current = available[name])
86
+ unless overwrite || current.profile == klass.profile
87
+ ArgumentError.("Document profile already defined differently: #{name}")
88
+ end
89
+
90
+ return current unless overwrite
91
+ end
92
+
93
+ available[name] = klass
94
+ end
95
+
96
+ # Normalizes a profile name.
97
+ # @param name [Object] profile name
98
+ # @return [Symbol, nil]
99
+ def self.normalize(name) = name.respond_to?(:to_sym) ? name.to_sym : nil
100
+
101
+ # Normalizes a profile name or raises.
102
+ # @param name [Object] profile name
103
+ # @return [Symbol]
104
+ # @raise [Sevgi::ArgumentError] when name cannot be normalized
105
+ def self.normalize!(name) = normalize(name) || ArgumentError.("Invalid document profile: #{name}")
106
+
107
+ # @return [Symbol, nil] profile name
108
+ attr_reader :name
27
109
 
28
- def self.register(name, klass) = (available[name] = klass)
110
+ # @return [Hash] default root attributes
111
+ attr_reader :attributes
29
112
 
30
- attr_reader :name, :attributes, :preambles
113
+ # @return [Array<String>, nil] preamble lines
114
+ attr_reader :preambles
31
115
 
116
+ # Creates profile metadata.
117
+ # @param name [Object, nil] profile name
118
+ # @param attributes [Hash, nil] default root attributes
119
+ # @param preambles [Array<String>, nil] preamble lines
120
+ # @return [void]
121
+ # @raise [Sevgi::ArgumentError] when name cannot be normalized
32
122
  def initialize(name, attributes: nil, preambles: nil)
33
- @name = name
123
+ @name = name.nil? ? nil : self.class.normalize!(name)
34
124
  @attributes = attributes || {}
35
125
  @preambles = preambles
36
126
  end
127
+
128
+ # Reports strict profile equality.
129
+ # @param other [Object] object to compare
130
+ # @return [Boolean]
131
+ def ==(other) = self.class == other.class && deconstruct == other.deconstruct
132
+
133
+ # Returns profile components.
134
+ # @return [Array<(Symbol, nil), Hash, (Array<String>, nil)>]
135
+ def deconstruct = [name, attributes, preambles]
37
136
  end
38
137
 
39
138
  private_constant :Profile
40
139
 
140
+ # Class-level DSL used while defining document classes.
141
+ # @api private
41
142
  module DSL
143
+ # @return [Sevgi::Graphics::Document::Profile] document profile metadata
42
144
  attr_reader :profile
43
145
 
44
- def document(name, attributes: {}, preambles: nil)
45
- @profile = Profile.new(name, attributes:, preambles:).tap do
46
- Profile.register(name, self)
47
- end
146
+ # Sets document profile metadata on a class.
147
+ # @param name [Object] profile name
148
+ # @param attributes [Hash] default root attributes
149
+ # @param preambles [Array<String>, nil] preamble lines
150
+ # @param register [Boolean] true to register the profile globally
151
+ # @param overwrite [Boolean] true to replace an existing profile
152
+ # @return [Sevgi::Graphics::Document::Profile]
153
+ # @raise [Sevgi::ArgumentError] when registration fails
154
+ def document(name, attributes: {}, preambles: nil, register: true, overwrite: false)
155
+ @profile = Profile.new(register ? name : nil, attributes:, preambles:)
156
+ Profile.register(name, self, overwrite:) if register
157
+ @profile
48
158
  end
49
159
 
160
+ # Includes a graphics mixture into the document class.
161
+ # @param mixture [Symbol, String] mixture constant name
162
+ # @param ns [Module] namespace containing mixture modules
163
+ # @return [Module] included mixture module
164
+ # @raise [NameError] when the mixture does not exist
50
165
  def mixture(mixture, ns: Graphics::Mixtures)
51
166
  include(mod = ns.const_get(mixture))
52
167
  extend(mod::ClassMethods) if defined?(mod::ClassMethods)
@@ -55,8 +170,10 @@ module Sevgi
55
170
 
56
171
  private_constant :DSL
57
172
 
173
+ # Default render-time checks.
58
174
  DEFAULTS = {lint: true, validate: true}.freeze
59
175
 
176
+ # Base document root element class.
60
177
  class Proto < Element
61
178
  public_class_method :new
62
179
 
@@ -67,6 +184,11 @@ module Sevgi
67
184
  mixture :Render
68
185
  mixture :Wrappers
69
186
 
187
+ # @overload call(*objects, **options)
188
+ # Renders the document.
189
+ # @param objects [Array<Object>] optional renderer arguments
190
+ # @param options [Hash] render options
191
+ # @return [String] SVG document source
70
192
  def call(*, **)
71
193
  options = DEFAULTS.merge(**)
72
194
 
@@ -74,8 +196,12 @@ module Sevgi
74
196
  self.Render(*, **options)
75
197
  end
76
198
 
199
+ # Returns inherited root attributes for this document class.
200
+ # @return [Hash]
77
201
  def self.attributes = self == Proto ? {} : {**superclass.attributes, **profile.attributes}
78
202
 
203
+ # Returns inherited preamble lines for this document class.
204
+ # @return [Array<String>, nil]
79
205
  def self.preambles = self == Proto ? nil : profile.preambles || superclass.preambles
80
206
  end
81
207
 
@@ -1,36 +1,75 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "sevgi/standard"
4
-
5
3
  module Sevgi
6
4
  module Graphics
5
+ # SVG element node used by the graphics DSL.
7
6
  class Element
7
+ # Builds an element node.
8
+ # @param name [Symbol, String] SVG element name
9
+ # @param parent [Sevgi::Graphics::Element] parent element
10
+ # @return [Sevgi::Graphics::Element]
11
+ # @raise [Sevgi::ArgumentError] when an argument cannot be parsed as attributes or content
8
12
  def self.element(name, *, parent:, &block) = new(name, **Dispatch.parse(name, *), parent:, &block)
9
13
 
14
+ # Builds an SVG root element.
15
+ # @param block [Proc, nil] DSL block evaluated in the root element
16
+ # @return [Sevgi::Graphics::Element]
10
17
  def self.root(*, &block) = element(:svg, *, parent: RootParent, &block)
11
18
 
19
+ # Reports whether an element is the root element.
20
+ # @param element [Sevgi::Graphics::Element] element to test
21
+ # @return [Boolean]
12
22
  def self.root?(element) = element.parent == RootParent
13
23
 
14
24
  class << self
15
25
  require "sevgi/standard"
16
26
 
27
+ # Reports whether an SVG element name is known.
28
+ # @param name [Symbol] SVG element name
29
+ # @return [Boolean]
17
30
  def valid?(name) = Standard.element?(name)
18
- rescue ::LoadError
31
+ rescue ::LoadError => e
32
+ raise unless e.path == "sevgi/standard"
33
+
34
+ # Reports whether an SVG element name is known.
35
+ # @return [Boolean]
19
36
  def valid?(...) = true
20
37
  end
21
38
 
22
39
  private_class_method :new
23
40
 
41
+ # Sentinel parent used by root SVG elements.
24
42
  RootParent = Object.new.tap { def it.inspect = "RootParent" }.freeze
25
43
 
44
+ # SVG element method-name normalization.
45
+ # @api private
26
46
  module Ident
47
+ # Normalizes a Ruby method name into an SVG element id.
48
+ # @param given [Symbol, String] method name
49
+ # @return [Symbol]
27
50
  def id(given) = (@id ||= {})[given] ||= given.to_s.tr("_", "-").to_sym
28
51
  end
29
52
 
30
53
  extend Ident
31
54
 
55
+ # @!attribute [r] name
56
+ # @return [Symbol] SVG element name
57
+ # @!attribute [r] attributes
58
+ # @return [Sevgi::Graphics::Attributes] attribute store
59
+ # @!attribute [r] children
60
+ # @return [Array<Sevgi::Graphics::Element>] child elements
61
+ # @!attribute [r] contents
62
+ # @return [Array<Sevgi::Graphics::Content>] element content objects
63
+ # @!attribute [r] parent
64
+ # @return [Sevgi::Graphics::Element, Object] parent element or root sentinel
32
65
  attr_reader :name, :attributes, :children, :contents, :parent
33
66
 
67
+ # Creates an element.
68
+ # @param name [Symbol] SVG element name
69
+ # @param parent [Sevgi::Graphics::Element, Object] parent element or root sentinel
70
+ # @param attributes [Hash] SVG attributes
71
+ # @param contents [Array<Sevgi::Graphics::Content>] content objects
72
+ # @return [void]
34
73
  def initialize(name, parent:, attributes: {}, contents: [], &block)
35
74
  @name = name
36
75
  @attributes = Attributes.new(attributes)
@@ -43,28 +82,49 @@ module Sevgi
43
82
  instance_exec(&block) if block
44
83
  end
45
84
 
85
+ # Dispatches SVG element DSL calls and caches valid element methods.
86
+ # @param name [Symbol] missing method name
87
+ # @return [Sevgi::Graphics::Element]
88
+ # @raise [NameError] when the name is not a valid SVG element
89
+ # @raise [Sevgi::ArgumentError] when an argument cannot be parsed as attributes or content
46
90
  def method_missing(name, *, &block)
47
- Element.valid?(id = Element.id(name)) ? Dispatch.(self, id, *, &block) : super
91
+ Element.valid?(tag = Element.id(name)) ? Dispatch.(self, name, tag, *, &block) : super
48
92
  end
49
93
 
94
+ # Reports whether a missing method can dispatch to an SVG element.
95
+ # @param name [Symbol] queried method name
96
+ # @param include_private [Boolean] standard Ruby respond_to? flag
97
+ # @return [Boolean]
50
98
  def respond_to_missing?(name, include_private = false)
51
99
  Element.valid?(Element.id(name)) || super
52
100
  end
53
101
 
102
+ # Element method-missing parser and cache.
103
+ # @api private
54
104
  module Dispatch
55
105
  extend self
56
106
 
57
- def call(element, name, *, &)
107
+ # Dispatches an element DSL call.
108
+ # @param element [Sevgi::Graphics::Element] parent element
109
+ # @param method [Symbol] Ruby method name
110
+ # @param tag [Symbol] SVG element name
111
+ # @return [Sevgi::Graphics::Element]
112
+ def call(element, method, tag, *, &)
58
113
  # Low-hanging fruit optimization: define missing method to avoid dispatching cost
59
- unless Element.method_defined?(name)
114
+ unless Element.method_defined?(method)
60
115
  Element.class_exec do
61
- define_method(name) { |*args, &block| self.class.element(name, *args, parent: self, &block) }
116
+ define_method(method) { |*args, &block| self.class.element(tag, *args, parent: self, &block) }
62
117
  end
63
118
  end
64
119
 
65
- element.public_send(name, *, &)
120
+ element.public_send(method, *, &)
66
121
  end
67
122
 
123
+ # Parses element DSL arguments.
124
+ # @param name [Symbol] SVG element name
125
+ # @param args [Array<Object>] positional DSL arguments
126
+ # @return [Hash] parsed :attributes and :contents
127
+ # @raise [Sevgi::ArgumentError] when an argument is not a Hash, String, or Content
68
128
  def parse(name, *args)
69
129
  attributes, contents = {}, []
70
130
 
@@ -2,19 +2,32 @@
2
2
 
3
3
  module Sevgi
4
4
  module Graphics
5
+ # Callable drawing module support.
6
+ # @api private
5
7
  module Module
8
+ # Tracks newly defined public methods as callable drawing steps.
9
+ # @param method [Symbol] method name Ruby reports as added
10
+ # @return [Array<Symbol>, nil]
6
11
  def method_added(method)
7
12
  super
8
13
 
9
14
  _callables << method if public_method_defined?(method)
10
15
  end
11
16
 
17
+ # Class-level DSL for callable drawing modules.
18
+ # @api private
12
19
  module DSL
13
- def call(after = false, &block) = ((after ? _after : _befores) << block)
20
+ # Registers a before or after hook.
21
+ # @param after [Boolean] true to register an after hook
22
+ # @return [Array<Proc>] hook list
23
+ def call(after = false, &block) = ((after ? _afters : _befores) << block)
14
24
  end
15
25
 
16
26
  private_constant :DSL
17
27
 
28
+ # Initializes callable module state.
29
+ # @param base [Module] extended module
30
+ # @return [void]
18
31
  def self.extended(base)
19
32
  base.instance_exec do
20
33
  @_callables = []
@@ -29,6 +42,13 @@ module Sevgi
29
42
  end
30
43
  end
31
44
 
45
+ # @overload call(mod, receiver, *args, **kwargs)
46
+ # Runs module hooks and callables against a receiver.
47
+ # @param mod [Module] callable module
48
+ # @param receiver [Sevgi::Graphics::Element] receiver element
49
+ # @param args [Array<Object>] callable arguments
50
+ # @param kwargs [Hash] callable keyword arguments
51
+ # @return [Object, nil] last callable return value
32
52
  def self.call(mod, receiver, ...)
33
53
  mod._befores.each { receiver.Within(receiver, &it) } if mod.respond_to?(:_befores) && mod._befores
34
54
  # return last callable return value
@@ -37,15 +57,27 @@ module Sevgi
37
57
  end
38
58
  end
39
59
 
60
+ # Returns the methods that should be executed for a callable module.
61
+ # @param mod [Module] callable module
62
+ # @return [Array<UnboundMethod>]
63
+ # @raise [Sevgi::ArgumentError] when mod is not a plain module
40
64
  def self.callables(mod)
41
- raise ArgumentError, "Must be a module: #{mod}" unless mod.instance_of?(::Module)
65
+ ArgumentError.("Must be a module: #{mod}") unless mod.instance_of?(::Module)
42
66
 
43
67
  (mod.respond_to?(:_callables) ? mod._callables : mod.instance_methods).map { mod.instance_method(it) }
44
68
  end
45
69
  end
46
70
 
47
71
  module Mixtures
72
+ # DSL helpers for invoking callable drawing modules.
48
73
  module Call
74
+ # @overload Call(mod, *args, **kwargs)
75
+ # Runs a callable drawing module in the current element context.
76
+ # @param mod [Module] callable module
77
+ # @param args [Array<Object>] callable arguments
78
+ # @param kwargs [Hash] callable keyword arguments
79
+ # @return [Object, nil] last callable return value
80
+ # @raise [Sevgi::ArgumentError] when mod is not a plain module
49
81
  def Call(mod, ...)
50
82
  Graphics::Module.call(mod, self, ...)
51
83
  end
@@ -54,7 +86,7 @@ module Sevgi
54
86
 
55
87
  # rubocop:disable Metrics/MethodLength
56
88
  def CallWithin(mod, container, element, *args, **kwargs, &block)
57
- raise ArgumentError, "Must be a module: #{mod}" unless mod.instance_of?(::Module)
89
+ ArgumentError.("Must be a module: #{mod}") unless mod.instance_of?(::Module)
58
90
 
59
91
  kwargs = kwargs.merge(id: F.demodulize(mod).to_sym) unless kwargs.key?(:id)
60
92