sevgi-graphics 0.73.2 → 0.94.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 (45) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +4 -0
  3. data/LICENSE +5 -0
  4. data/README.md +37 -0
  5. data/lib/sevgi/graphics/attribute.rb +82 -10
  6. data/lib/sevgi/graphics/auxiliary/canvas.rb +153 -0
  7. data/lib/sevgi/graphics/auxiliary/content.rb +157 -0
  8. data/lib/sevgi/graphics/auxiliary/margin.rb +93 -0
  9. data/lib/sevgi/graphics/auxiliary/paper.rb +163 -0
  10. data/lib/sevgi/graphics/auxiliary.rb +7 -0
  11. data/lib/sevgi/graphics/document/base.rb +6 -0
  12. data/lib/sevgi/graphics/document/default.rb +1 -0
  13. data/lib/sevgi/graphics/document/html.rb +1 -0
  14. data/lib/sevgi/graphics/document/inkscape.rb +1 -0
  15. data/lib/sevgi/graphics/document/minimal.rb +1 -0
  16. data/lib/sevgi/graphics/document.rb +205 -13
  17. data/lib/sevgi/graphics/element.rb +69 -9
  18. data/lib/sevgi/graphics/mixtures/call.rb +62 -4
  19. data/lib/sevgi/graphics/mixtures/core.rb +146 -25
  20. data/lib/sevgi/graphics/mixtures/duplicate.rb +47 -3
  21. data/lib/sevgi/graphics/mixtures/export.rb +31 -6
  22. data/lib/sevgi/graphics/mixtures/hatch.rb +24 -3
  23. data/lib/sevgi/graphics/mixtures/identify.rb +26 -2
  24. data/lib/sevgi/graphics/mixtures/include.rb +40 -4
  25. data/lib/sevgi/graphics/mixtures/inkscape.rb +51 -5
  26. data/lib/sevgi/graphics/mixtures/lint.rb +12 -0
  27. data/lib/sevgi/graphics/mixtures/polyfills.rb +11 -0
  28. data/lib/sevgi/graphics/mixtures/rdf.rb +40 -1
  29. data/lib/sevgi/graphics/mixtures/render.rb +136 -14
  30. data/lib/sevgi/graphics/mixtures/save.rb +21 -3
  31. data/lib/sevgi/graphics/mixtures/symbols.rb +7 -0
  32. data/lib/sevgi/graphics/mixtures/tile.rb +52 -9
  33. data/lib/sevgi/graphics/mixtures/transform.rb +54 -16
  34. data/lib/sevgi/graphics/mixtures/underscore.rb +21 -1
  35. data/lib/sevgi/graphics/mixtures/validate.rb +15 -1
  36. data/lib/sevgi/graphics/mixtures/wrappers.rb +54 -17
  37. data/lib/sevgi/graphics/mixtures.rb +35 -3
  38. data/lib/sevgi/graphics/version.rb +2 -1
  39. data/lib/sevgi/graphics.rb +71 -5
  40. metadata +12 -9
  41. data/lib/sevgi/graphics/auxilary/canvas.rb +0 -72
  42. data/lib/sevgi/graphics/auxilary/content.rb +0 -81
  43. data/lib/sevgi/graphics/auxilary/margin.rb +0 -49
  44. data/lib/sevgi/graphics/auxilary/paper.rb +0 -95
  45. 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,232 @@
2
2
 
3
3
  module Sevgi
4
4
  module Graphics
5
+ # SVG document profile factory.
5
6
  module Document
7
+ # Defensive copy helper for profile metadata snapshots.
8
+ # @api private
9
+ module Snapshot
10
+ # Returns a recursively independent copy of a value.
11
+ # @param value [Object] value to copy
12
+ # @return [Object] copied value
13
+ def self.copy(value)
14
+ case value
15
+ when ::Hash
16
+ hash(value)
17
+ when ::Array
18
+ value.map { copy(it) }
19
+ else
20
+ duplicate(value)
21
+ end
22
+ end
23
+
24
+ # Returns a recursively frozen independent copy of a value.
25
+ # @param value [Object] value to copy and freeze
26
+ # @return [Object] frozen copied value
27
+ def self.frozen(value)
28
+ case value
29
+ when ::Hash
30
+ value.to_h { |key, item| [frozen(key), frozen(item)] }.freeze
31
+ when ::Array
32
+ value.map { frozen(it) }.freeze
33
+ else
34
+ duplicate(value).freeze
35
+ end
36
+ end
37
+
38
+ def self.hash(value) = value.to_h { |key, item| [copy(key), copy(item)] }
39
+
40
+ def self.duplicate(value)
41
+ value.dup
42
+ rescue ::TypeError
43
+ value
44
+ end
45
+
46
+ private_class_method :duplicate, :hash
47
+ end
48
+
49
+ private_constant :Snapshot
50
+
51
+ # Builds a root SVG element from a document profile.
52
+ # @param document [Symbol, String, Class] profile name or document class
53
+ # @param canvas [Sevgi::Graphics::Canvas, Sevgi::Graphics::Paper, Symbol, String, Sevgi::Undefined, nil] canvas input
54
+ # @return [Sevgi::Graphics::Document::Proto] SVG root element
55
+ # @raise [Sevgi::ArgumentError] when the document profile is unknown
6
56
  def self.call(document, canvas = Undefined, **, &block)
57
+ klass = case document
58
+ when ::Class
59
+ document if document <= Proto
60
+ else
61
+ Profile[document]
62
+ end
63
+
64
+ ArgumentError.("Unknown document profile: #{document}") unless klass
65
+
66
+ klass.root(**klass.attributes, **canvas_attributes(canvas), **, &block)
67
+ end
68
+
69
+ def self.canvas_attributes(canvas)
7
70
  case canvas
8
71
  when Undefined, ::NilClass
9
72
  {}
10
73
  when Canvas
11
74
  canvas.attributes
12
75
  else
13
- Canvas.(canvas).attributes
14
- end => attributes
76
+ Canvas.from_paper(canvas).attributes
77
+ end
78
+ end
79
+
80
+ private_class_method :canvas_attributes
81
+
82
+ # Defines or returns a document profile class.
83
+ # @param name [Symbol, String, Sevgi::Undefined] profile name, or Undefined for an anonymous profile
84
+ # @param preambles [Array<String>, nil, Sevgi::Undefined] document preamble lines
85
+ # @param attributes [Hash, Sevgi::Undefined] default root attributes
86
+ # @param overwrite [Boolean] true to replace an existing profile
87
+ # @return [Class] document class
88
+ # @raise [Sevgi::ArgumentError] when a named profile conflicts with an existing profile
89
+ def self.define(name = Undefined, preambles: Undefined, attributes: Undefined, overwrite: false)
90
+ return anonymous(attributes:, preambles:) if name == Undefined
91
+
92
+ if (current = Profile[name])
93
+ reject_conflict(name, current, attributes:, preambles:) unless overwrite
94
+ return current unless overwrite
95
+ end
15
96
 
16
- (klass = Profile[document]).root(**klass.attributes, **attributes, **, &block)
97
+ attributes, preambles = defaults(attributes:, preambles:)
98
+ Class.new(Base) { document(name, preambles:, attributes:, overwrite:) }
17
99
  end
18
100
 
101
+ def self.anonymous(attributes:, preambles:)
102
+ attributes, preambles = defaults(attributes:, preambles:)
103
+ Class.new(Base) { document(Undefined, preambles:, attributes:, register: false) }
104
+ end
105
+
106
+ def self.defaults(attributes:, preambles:)
107
+ [attributes == Undefined ? {} : attributes, preambles == Undefined ? nil : preambles]
108
+ end
109
+
110
+ def self.reject_conflict(name, current, attributes:, preambles:)
111
+ return if compatible?(current, attributes:, preambles:)
112
+
113
+ ArgumentError.("Document profile already defined differently: #{name}")
114
+ end
115
+
116
+ def self.compatible?(klass, attributes:, preambles:)
117
+ profile = klass.profile
118
+
119
+ (attributes == Undefined || Profile.new(nil, attributes:).attributes == profile.attributes) &&
120
+ (preambles == Undefined || Profile.new(nil, preambles:).preambles == profile.preambles)
121
+ end
122
+
123
+ private_class_method :anonymous, :compatible?, :defaults, :reject_conflict
124
+
125
+ # Immutable document profile metadata.
126
+ # @api private
19
127
  class Profile
20
128
  @available = {}
21
129
 
22
130
  class << self
131
+ # @return [Hash<Symbol, Class>] registered profile classes
23
132
  attr_reader :available
24
133
  end
25
134
 
26
- def self.[](name) = available[name]
135
+ # Returns a profile class by name.
136
+ # @param name [Object] profile name
137
+ # @return [Class, nil]
138
+ def self.[](name) = (name = normalize(name)) && available[name]
27
139
 
28
- def self.register(name, klass) = (available[name] = klass)
140
+ # Registers a profile class.
141
+ # @param name [Object] profile name
142
+ # @param klass [Class] document class
143
+ # @param overwrite [Boolean] true to replace an existing profile
144
+ # @return [Class] registered class
145
+ # @raise [Sevgi::ArgumentError] when name is invalid or conflicts with an existing profile
146
+ def self.register(name, klass, overwrite: false)
147
+ name = normalize!(name)
29
148
 
30
- attr_reader :name, :attributes, :preambles
149
+ if (current = available[name])
150
+ unless overwrite || current.profile == klass.profile
151
+ ArgumentError.("Document profile already defined differently: #{name}")
152
+ end
31
153
 
154
+ return current unless overwrite
155
+ end
156
+
157
+ available[name] = klass
158
+ end
159
+
160
+ # Normalizes a profile name.
161
+ # @param name [Object] profile name
162
+ # @return [Symbol, nil]
163
+ def self.normalize(name) = name.respond_to?(:to_sym) ? name.to_sym : nil
164
+
165
+ # Normalizes a profile name or raises.
166
+ # @param name [Object] profile name
167
+ # @return [Symbol]
168
+ # @raise [Sevgi::ArgumentError] when name cannot be normalized
169
+ def self.normalize!(name) = normalize(name) || ArgumentError.("Invalid document profile: #{name}")
170
+
171
+ # @return [Symbol, nil] profile name
172
+ attr_reader :name
173
+
174
+ # Creates profile metadata.
175
+ # @param name [Object, nil] profile name
176
+ # @param attributes [Hash, nil] default root attributes
177
+ # @param preambles [Array<String>, nil] preamble lines
178
+ # @return [void]
179
+ # @raise [Sevgi::ArgumentError] when name cannot be normalized
32
180
  def initialize(name, attributes: nil, preambles: nil)
33
- @name = name
34
- @attributes = attributes || {}
35
- @preambles = preambles
181
+ @name = name.nil? ? nil : self.class.normalize!(name)
182
+ @attributes = Snapshot.frozen(attributes || {})
183
+ @preambles = preambles.nil? ? nil : Snapshot.frozen(preambles)
36
184
  end
185
+
186
+ # Reports strict profile equality.
187
+ # @param other [Object] object to compare
188
+ # @return [Boolean]
189
+ def ==(other) = self.class == other.class && deconstruct == other.deconstruct
190
+
191
+ # Returns default root attributes.
192
+ # @return [Hash] mutation-isolated attribute snapshot
193
+ def attributes = Snapshot.copy(@attributes)
194
+
195
+ # Returns profile components.
196
+ # @return [Array<(Symbol, nil), Hash, (Array<String>, nil)>]
197
+ def deconstruct = [name, attributes, preambles]
198
+
199
+ # Returns preamble lines.
200
+ # @return [Array<String>, nil] mutation-isolated preamble snapshot
201
+ def preambles = Snapshot.copy(@preambles)
37
202
  end
38
203
 
39
204
  private_constant :Profile
40
205
 
206
+ # Class-level DSL used while defining document classes.
207
+ # @api private
41
208
  module DSL
209
+ # @return [Sevgi::Graphics::Document::Profile] document profile metadata
42
210
  attr_reader :profile
43
211
 
44
- def document(name, attributes: {}, preambles: nil)
45
- @profile = Profile.new(name, attributes:, preambles:).tap do
46
- Profile.register(name, self)
47
- end
212
+ # Sets document profile metadata on a class.
213
+ # @param name [Object] profile name
214
+ # @param attributes [Hash] default root attributes
215
+ # @param preambles [Array<String>, nil] preamble lines
216
+ # @param register [Boolean] true to register the profile globally
217
+ # @param overwrite [Boolean] true to replace an existing profile
218
+ # @return [Sevgi::Graphics::Document::Profile]
219
+ # @raise [Sevgi::ArgumentError] when registration fails
220
+ def document(name, attributes: {}, preambles: nil, register: true, overwrite: false)
221
+ @profile = Profile.new(register ? name : nil, attributes:, preambles:)
222
+ Profile.register(name, self, overwrite:) if register
223
+ @profile
48
224
  end
49
225
 
226
+ # Includes a graphics mixture into the document class.
227
+ # @param mixture [Symbol, String] mixture constant name
228
+ # @param ns [Module] namespace containing mixture modules
229
+ # @return [Module] included mixture module
230
+ # @raise [NameError] when the mixture does not exist
50
231
  def mixture(mixture, ns: Graphics::Mixtures)
51
232
  include(mod = ns.const_get(mixture))
52
233
  extend(mod::ClassMethods) if defined?(mod::ClassMethods)
@@ -55,8 +236,10 @@ module Sevgi
55
236
 
56
237
  private_constant :DSL
57
238
 
239
+ # Default render-time checks.
58
240
  DEFAULTS = {lint: true, validate: true}.freeze
59
241
 
242
+ # Base document root element class.
60
243
  class Proto < Element
61
244
  public_class_method :new
62
245
 
@@ -67,6 +250,11 @@ module Sevgi
67
250
  mixture :Render
68
251
  mixture :Wrappers
69
252
 
253
+ # @overload call(*objects, **options)
254
+ # Renders the document.
255
+ # @param objects [Array<Object>] optional renderer arguments
256
+ # @param options [Hash] render options
257
+ # @return [String] SVG document source
70
258
  def call(*, **)
71
259
  options = DEFAULTS.merge(**)
72
260
 
@@ -74,8 +262,12 @@ module Sevgi
74
262
  self.Render(*, **options)
75
263
  end
76
264
 
265
+ # Returns inherited root attributes for this document class.
266
+ # @return [Hash]
77
267
  def self.attributes = self == Proto ? {} : {**superclass.attributes, **profile.attributes}
78
268
 
269
+ # Returns inherited preamble lines for this document class.
270
+ # @return [Array<String>, nil]
79
271
  def self.preambles = self == Proto ? nil : profile.preambles || superclass.preambles
80
272
  end
81
273
 
@@ -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
 
@@ -89,7 +149,7 @@ module Sevgi
89
149
 
90
150
  protected
91
151
 
92
- attr_writer :children, :attributes
152
+ attr_writer :attributes, :children, :contents, :parent
93
153
  end
94
154
  end
95
155
  end