sevgi-graphics 0.94.0 → 0.95.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.
@@ -7,43 +7,75 @@ module Sevgi
7
7
  # Defensive copy helper for profile metadata snapshots.
8
8
  # @api private
9
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)
10
+ class << self
11
+ # Captures recursively immutable profile metadata. Mutable non-container values are stringified once.
12
+ # @param value [Object] value to capture
13
+ # @param seen [Hash] container identities on the current traversal path
14
+ # @return [Object] immutable captured value
15
+ # @raise [Sevgi::ArgumentError] when metadata is cyclic, has colliding keys, or cannot be stringified
16
+ def capture(value, seen = {}.compare_by_identity)
17
+ case value
18
+ when ::Hash
19
+ nested(value, seen) { capture_hash(value, seen) }.freeze
20
+ when ::Array
21
+ nested(value, seen) { value.map { capture(it, seen) } }.freeze
22
+ else
23
+ capture_value(value)
24
+ end
21
25
  end
22
- end
23
26
 
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
27
+ # Returns a recursively caller-owned copy of captured metadata.
28
+ # @param value [Object] captured value to copy
29
+ # @return [Object] caller-owned copy
30
+ def copy(value)
31
+ case value
32
+ when ::String
33
+ value.dup
34
+ when ::Hash
35
+ value.to_h { |key, item| [copy(key), copy(item)] }
36
+ when ::Array
37
+ value.map { copy(it) }
38
+ else
39
+ value
40
+ end
35
41
  end
36
- end
37
42
 
38
- def self.hash(value) = value.to_h { |key, item| [copy(key), copy(item)] }
43
+ private
39
44
 
40
- def self.duplicate(value)
41
- value.dup
42
- rescue ::TypeError
43
- value
44
- end
45
+ def capture_hash(value, seen)
46
+ value.each_with_object({}) do |(key, item), captured|
47
+ key = capture(key, seen)
48
+ ArgumentError.("Document profile metadata keys collide after stringification") if captured.key?(key)
49
+
50
+ captured[key] = capture(item, seen)
51
+ end
52
+ end
53
+
54
+ def nested(value, seen)
55
+ ArgumentError.("Cyclic document profile metadata is not supported") if seen.key?(value)
56
+
57
+ seen[value] = true
58
+ yield
59
+ ensure
60
+ seen.delete(value)
61
+ end
45
62
 
46
- private_class_method :duplicate, :hash
63
+ def capture_value(value)
64
+ case value
65
+ when ::String
66
+ XML.text(value, context: "Document profile metadata").freeze
67
+ when ::Numeric, ::Symbol, ::NilClass, ::TrueClass, ::FalseClass
68
+ XML.text(value, context: "Document profile metadata")
69
+ value
70
+ else
71
+ stringify(value).freeze
72
+ end
73
+ end
74
+
75
+ def stringify(value)
76
+ XML.text(value, context: "Document profile metadata")
77
+ end
78
+ end
47
79
  end
48
80
 
49
81
  private_constant :Snapshot
@@ -51,8 +83,10 @@ module Sevgi
51
83
  # Builds a root SVG element from a document profile.
52
84
  # @param document [Symbol, String, Class] profile name or document class
53
85
  # @param canvas [Sevgi::Graphics::Canvas, Sevgi::Graphics::Paper, Symbol, String, Sevgi::Undefined, nil] canvas input
86
+ # @yield evaluates the drawing DSL in the new root element
87
+ # @yieldreturn [Object] ignored block result
54
88
  # @return [Sevgi::Graphics::Document::Proto] SVG root element
55
- # @raise [Sevgi::ArgumentError] when the document profile is unknown
89
+ # @raise [Sevgi::ArgumentError] when the document profile or root XML attributes are invalid
56
90
  def self.call(document, canvas = Undefined, **, &block)
57
91
  klass = case document
58
92
  when ::Class
@@ -80,15 +114,22 @@ module Sevgi
80
114
  private_class_method :canvas_attributes
81
115
 
82
116
  # Defines or returns a document profile class.
117
+ # Profile metadata is captured before class or thread-atomic registry mutation. Mutable non-container attribute
118
+ # values are stringified once during capture. Successful named definitions return the canonical class stored by
119
+ # the registry, including when identical definitions race.
83
120
  # @param name [Symbol, String, Sevgi::Undefined] profile name, or Undefined for an anonymous profile
84
121
  # @param preambles [Array<String>, nil, Sevgi::Undefined] document preamble lines
85
- # @param attributes [Hash, Sevgi::Undefined] default root attributes
122
+ # @param attributes [Hash, nil, Sevgi::Undefined] default root attributes
86
123
  # @param overwrite [Boolean] true to replace an existing profile
87
124
  # @return [Class] document class
88
- # @raise [Sevgi::ArgumentError] when a named profile conflicts with an existing profile
125
+ # @raise [Sevgi::ArgumentError] when a name conflicts or metadata is invalid XML, cyclic, or cannot be stringified
89
126
  def self.define(name = Undefined, preambles: Undefined, attributes: Undefined, overwrite: false)
90
127
  return anonymous(attributes:, preambles:) if name == Undefined
91
128
 
129
+ return lookup(name) if preambles == Undefined && attributes == Undefined
130
+
131
+ name = Profile.normalize!(name)
132
+
92
133
  if (current = Profile[name])
93
134
  reject_conflict(name, current, attributes:, preambles:) unless overwrite
94
135
  return current unless overwrite
@@ -96,6 +137,7 @@ module Sevgi
96
137
 
97
138
  attributes, preambles = defaults(attributes:, preambles:)
98
139
  Class.new(Base) { document(name, preambles:, attributes:, overwrite:) }
140
+ Registry[name]
99
141
  end
100
142
 
101
143
  def self.anonymous(attributes:, preambles:)
@@ -103,6 +145,11 @@ module Sevgi
103
145
  Class.new(Base) { document(Undefined, preambles:, attributes:, register: false) }
104
146
  end
105
147
 
148
+ def self.lookup(name)
149
+ name = Profile.normalize!(name)
150
+ Profile[name] || ArgumentError.("Unknown document profile: #{name}")
151
+ end
152
+
106
153
  def self.defaults(attributes:, preambles:)
107
154
  [attributes == Undefined ? {} : attributes, preambles == Undefined ? nil : preambles]
108
155
  end
@@ -120,47 +167,77 @@ module Sevgi
120
167
  (preambles == Undefined || Profile.new(nil, preambles:).preambles == profile.preambles)
121
168
  end
122
169
 
123
- private_class_method :anonymous, :compatible?, :defaults, :reject_conflict
170
+ private_class_method :anonymous, :compatible?, :defaults, :lookup, :reject_conflict
124
171
 
125
- # Immutable document profile metadata.
172
+ # Process-global document profile registry.
126
173
  # @api private
127
- class Profile
174
+ module Registry
128
175
  @available = {}
176
+ @mutex = ::Mutex.new
129
177
 
130
178
  class << self
131
- # @return [Hash<Symbol, Class>] registered profile classes
132
- attr_reader :available
133
- end
179
+ def [](name) = @mutex.synchronize { @available[name] }
134
180
 
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]
181
+ def available = @mutex.synchronize { @available.dup.freeze }
139
182
 
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)
148
-
149
- if (current = available[name])
150
- unless overwrite || current.profile == klass.profile
151
- ArgumentError.("Document profile already defined differently: #{name}")
183
+ def register(name, klass, profile: nil, overwrite: false)
184
+ name = Profile.normalize!(name)
185
+ validate!(name, klass, profile)
186
+
187
+ @mutex.synchronize { store(name, klass, profile, overwrite) }
188
+ end
189
+
190
+ private
191
+
192
+ def store(name, klass, profile, overwrite)
193
+ if (current = @available[name])
194
+ unless overwrite || current.profile == profile
195
+ ArgumentError.("Document profile already defined differently: #{name}")
196
+ end
197
+
198
+ return current unless overwrite
152
199
  end
153
200
 
154
- return current unless overwrite
201
+ @available[name] = klass
155
202
  end
156
203
 
157
- available[name] = klass
204
+ def validate!(name, klass, profile)
205
+ unless klass.is_a?(::Class) && klass <= Proto
206
+ ArgumentError.("Document profile class must inherit Document::Proto: #{klass}")
207
+ end
208
+
209
+ return if profile.is_a?(Profile) && profile.name == name
210
+
211
+ ArgumentError.("Document profile class has invalid metadata: #{klass}")
212
+ end
158
213
  end
214
+ end
215
+
216
+ private_constant :Registry
217
+
218
+ # Immutable, read-only document profile metadata exposed by document classes. Process-global lookup and registration
219
+ # are thread-atomic. Metadata containers and strings are captured recursively; other mutable attribute values are
220
+ # stringified once during construction.
221
+ # @see Sevgi::Graphics.document
222
+ class Profile
223
+ # Returns a thread-coherent immutable snapshot of registered profile classes.
224
+ # @return [Hash<Symbol, Class>]
225
+ def self.available = Registry.available
226
+
227
+ # Returns a profile class by name from the process-global registry.
228
+ # @param name [Object] profile name
229
+ # @return [Class, nil]
230
+ def self.[](name) = (name = normalize(name)) && Registry[name]
159
231
 
160
232
  # Normalizes a profile name.
161
233
  # @param name [Object] profile name
162
234
  # @return [Symbol, nil]
163
- def self.normalize(name) = name.respond_to?(:to_sym) ? name.to_sym : nil
235
+ def self.normalize(name)
236
+ normalized = name.to_sym if name.respond_to?(:to_sym)
237
+ normalized if normalized.is_a?(::Symbol)
238
+ rescue ::StandardError
239
+ nil
240
+ end
164
241
 
165
242
  # Normalizes a profile name or raises.
166
243
  # @param name [Object] profile name
@@ -173,14 +250,15 @@ module Sevgi
173
250
 
174
251
  # Creates profile metadata.
175
252
  # @param name [Object, nil] profile name
176
- # @param attributes [Hash, nil] default root attributes
253
+ # @param attributes [Hash, nil] default root attributes; nil means an empty Hash
177
254
  # @param preambles [Array<String>, nil] preamble lines
178
255
  # @return [void]
179
- # @raise [Sevgi::ArgumentError] when name cannot be normalized
256
+ # @raise [Sevgi::ArgumentError] when name or metadata is invalid XML, cyclic, or cannot be stringified
180
257
  def initialize(name, attributes: nil, preambles: nil)
181
258
  @name = name.nil? ? nil : self.class.normalize!(name)
182
- @attributes = Snapshot.frozen(attributes || {})
183
- @preambles = preambles.nil? ? nil : Snapshot.frozen(preambles)
259
+ validate_attributes!(attributes)
260
+ @attributes = Snapshot.capture(attributes || {})
261
+ @preambles = capture_preambles(preambles)
184
262
  end
185
263
 
186
264
  # Reports strict profile equality.
@@ -199,28 +277,60 @@ module Sevgi
199
277
  # Returns preamble lines.
200
278
  # @return [Array<String>, nil] mutation-isolated preamble snapshot
201
279
  def preambles = Snapshot.copy(@preambles)
202
- end
203
280
 
204
- private_constant :Profile
281
+ private
282
+
283
+ def validate_attributes!(attributes)
284
+ return if attributes.nil?
285
+
286
+ ArgumentError.("Document profile attributes must be a Hash") unless attributes.is_a?(::Hash)
287
+
288
+ normalized = {}
289
+ attributes.each_key do |key|
290
+ id = normalize_attribute!(key)
291
+ ArgumentError.("Document profile attribute names collide after normalization: #{id}") if normalized.key?(id)
292
+
293
+ normalized[id] = true
294
+ end
295
+ end
296
+
297
+ def normalize_attribute!(key)
298
+ if key.is_a?(::String) || key.is_a?(::Symbol)
299
+ return XML.name(key, context: "Document profile attribute name").to_sym
300
+ end
301
+
302
+ ArgumentError.("Document profile attribute names must be Strings or Symbols")
303
+ end
304
+
305
+ def capture_preambles(preambles)
306
+ return if preambles.nil?
307
+
308
+ unless preambles.is_a?(::Array) && preambles.all?(::String)
309
+ ArgumentError.("Document profile preambles must be an Array of Strings")
310
+ end
311
+
312
+ Snapshot.capture(preambles)
313
+ end
314
+ end
205
315
 
206
316
  # Class-level DSL used while defining document classes.
207
317
  # @api private
208
318
  module DSL
209
- # @return [Sevgi::Graphics::Document::Profile] document profile metadata
319
+ # @return [Sevgi::Graphics::Document::Profile] immutable document profile metadata
210
320
  attr_reader :profile
211
321
 
212
322
  # Sets document profile metadata on a class.
213
323
  # @param name [Object] profile name
214
- # @param attributes [Hash] default root attributes
324
+ # @param attributes [Hash, nil] default root attributes
215
325
  # @param preambles [Array<String>, nil] preamble lines
216
326
  # @param register [Boolean] true to register the profile globally
217
327
  # @param overwrite [Boolean] true to replace an existing profile
218
- # @return [Sevgi::Graphics::Document::Profile]
219
- # @raise [Sevgi::ArgumentError] when registration fails
328
+ # @return [Sevgi::Graphics::Document::Profile] immutable document profile metadata
329
+ # @raise [Sevgi::ArgumentError] when registration fails or metadata is invalid XML, cyclic, or cannot be stringified
220
330
  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
331
+ profile = Profile.new(register ? name : nil, attributes:, preambles:)
332
+ Registry.register(name, self, profile:, overwrite:) if register
333
+ @profile = profile
224
334
  end
225
335
 
226
336
  # Includes a graphics mixture into the document class.
@@ -255,15 +365,17 @@ module Sevgi
255
365
  # @param objects [Array<Object>] optional renderer arguments
256
366
  # @param options [Hash] render options
257
367
  # @return [String] SVG document source
368
+ # @raise [Sevgi::ArgumentError] when renderer options or XML-bound values are invalid
258
369
  def call(*, **)
259
370
  options = DEFAULTS.merge(**)
260
371
 
261
372
  self.PreRender(*, **options) if respond_to?(:PreRender)
262
- self.Render(*, **options)
373
+ render_options = options.reject { |key, _| DEFAULTS.key?(key) }
374
+ self.Render(*, **render_options)
263
375
  end
264
376
 
265
377
  # Returns inherited root attributes for this document class.
266
- # @return [Hash]
378
+ # @return [Hash{Symbol => Object}] inherited root attributes
267
379
  def self.attributes = self == Proto ? {} : {**superclass.attributes, **profile.attributes}
268
380
 
269
381
  # Returns inherited preamble lines for this document class.
@@ -3,17 +3,27 @@
3
3
  module Sevgi
4
4
  module Graphics
5
5
  # SVG element node used by the graphics DSL.
6
+ #
7
+ # @!method self.valid?(name)
8
+ # Reports whether an SVG element name is known.
9
+ # @param name [Symbol] SVG element name
10
+ # @return [Boolean]
6
11
  class Element
7
12
  # Builds an element node.
8
13
  # @param name [Symbol, String] SVG element name
9
14
  # @param parent [Sevgi::Graphics::Element] parent element
15
+ # @yield evaluates the drawing DSL in the new element
16
+ # @yieldreturn [Object] ignored block result
10
17
  # @return [Sevgi::Graphics::Element]
11
18
  # @raise [Sevgi::ArgumentError] when an argument cannot be parsed as attributes or content
19
+ # @raise [Sevgi::ArgumentError] when the element name or an attribute is not valid XML
12
20
  def self.element(name, *, parent:, &block) = new(name, **Dispatch.parse(name, *), parent:, &block)
13
21
 
14
22
  # Builds an SVG root element.
15
- # @param block [Proc, nil] DSL block evaluated in the root element
23
+ # @yield evaluates the drawing DSL in the root element
24
+ # @yieldreturn [Object] ignored block result
16
25
  # @return [Sevgi::Graphics::Element]
26
+ # @raise [Sevgi::ArgumentError] when a root attribute is not valid XML
17
27
  def self.root(*, &block) = element(:svg, *, parent: RootParent, &block)
18
28
 
19
29
  # Reports whether an element is the root element.
@@ -52,26 +62,41 @@ module Sevgi
52
62
 
53
63
  extend Ident
54
64
 
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
65
- attr_reader :name, :attributes, :children, :contents, :parent
65
+ # Returns the SVG element name.
66
+ # @return [Symbol]
67
+ attr_reader :name
68
+
69
+ # Returns the attribute store.
70
+ # @return [Sevgi::Graphics::Attributes]
71
+ attr_reader :attributes
72
+
73
+ # Returns child elements.
74
+ # @return [Array<Sevgi::Graphics::Element>]
75
+ attr_reader :children
76
+
77
+ # Returns element content objects.
78
+ # @return [Array<Sevgi::Graphics::Content>]
79
+ attr_reader :contents
80
+
81
+ # Returns the parent element or root sentinel.
82
+ # @return [Sevgi::Graphics::Element, Object] parent element or root sentinel
83
+ attr_reader :parent
66
84
 
67
85
  # Creates an element.
68
86
  # @param name [Symbol] SVG element name
69
87
  # @param parent [Sevgi::Graphics::Element, Object] parent element or root sentinel
70
88
  # @param attributes [Hash] SVG attributes
71
89
  # @param contents [Array<Sevgi::Graphics::Content>] content objects
90
+ # @yield evaluates the drawing DSL in the new element
91
+ # @yieldreturn [Object] ignored block result
72
92
  # @return [void]
93
+ # @raise [Sevgi::ArgumentError] when the element name or an attribute is not valid XML
73
94
  def initialize(name, parent:, attributes: {}, contents: [], &block)
74
- @name = name
95
+ unless name.is_a?(::String) || name.is_a?(::Symbol)
96
+ ArgumentError.("XML element name must be a String or Symbol")
97
+ end
98
+
99
+ @name = XML.name(name, context: "XML element name").to_sym
75
100
  @attributes = Attributes.new(attributes)
76
101
  @children = []
77
102
  @contents = contents
@@ -84,6 +109,8 @@ module Sevgi
84
109
 
85
110
  # Dispatches SVG element DSL calls and caches valid element methods.
86
111
  # @param name [Symbol] missing method name
112
+ # @yield evaluates the drawing DSL in the dispatched child element
113
+ # @yieldreturn [Object] ignored block result
87
114
  # @return [Sevgi::Graphics::Element]
88
115
  # @raise [NameError] when the name is not a valid SVG element
89
116
  # @raise [Sevgi::ArgumentError] when an argument cannot be parsed as attributes or content
@@ -95,6 +122,7 @@ module Sevgi
95
122
  # @param name [Symbol] queried method name
96
123
  # @param include_private [Boolean] standard Ruby respond_to? flag
97
124
  # @return [Boolean]
125
+ # @api private
98
126
  def respond_to_missing?(name, include_private = false)
99
127
  Element.valid?(Element.id(name)) || super
100
128
  end
@@ -110,7 +138,6 @@ module Sevgi
110
138
  # @param tag [Symbol] SVG element name
111
139
  # @return [Sevgi::Graphics::Element]
112
140
  def call(element, method, tag, *, &)
113
- # Low-hanging fruit optimization: define missing method to avoid dispatching cost
114
141
  unless Element.method_defined?(method)
115
142
  Element.class_exec do
116
143
  define_method(method) { |*args, &block| self.class.element(tag, *args, parent: self, &block) }