sevgi-graphics 0.94.0 → 0.98.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +275 -2
  3. data/LICENSE +672 -3
  4. data/README.md +8 -8
  5. data/lib/sevgi/graphics/attribute.rb +271 -54
  6. data/lib/sevgi/graphics/auxiliary/canvas.rb +137 -58
  7. data/lib/sevgi/graphics/auxiliary/content.rb +200 -57
  8. data/lib/sevgi/graphics/auxiliary/margin.rb +33 -14
  9. data/lib/sevgi/graphics/auxiliary/paper.rb +118 -86
  10. data/lib/sevgi/graphics/auxiliary/path.rb +44 -0
  11. data/lib/sevgi/graphics/auxiliary/scalar.rb +69 -0
  12. data/lib/sevgi/graphics/auxiliary/sizes.rb +62 -0
  13. data/lib/sevgi/graphics/auxiliary.rb +3 -0
  14. data/lib/sevgi/graphics/document/base.rb +6 -2
  15. data/lib/sevgi/graphics/document/default.rb +1 -1
  16. data/lib/sevgi/graphics/document.rb +370 -124
  17. data/lib/sevgi/graphics/element.rb +143 -33
  18. data/lib/sevgi/graphics/mixtures/call.rb +249 -88
  19. data/lib/sevgi/graphics/mixtures/core.rb +143 -33
  20. data/lib/sevgi/graphics/mixtures/duplicate.rb +76 -22
  21. data/lib/sevgi/graphics/mixtures/export.rb +58 -12
  22. data/lib/sevgi/graphics/mixtures/hatch.rb +49 -7
  23. data/lib/sevgi/graphics/mixtures/identify.rb +30 -17
  24. data/lib/sevgi/graphics/mixtures/include.rb +40 -5
  25. data/lib/sevgi/graphics/mixtures/inkscape.rb +215 -46
  26. data/lib/sevgi/graphics/mixtures/rdf.rb +79 -7
  27. data/lib/sevgi/graphics/mixtures/render.rb +88 -19
  28. data/lib/sevgi/graphics/mixtures/save.rb +79 -26
  29. data/lib/sevgi/graphics/mixtures/symbols.rb +81 -9
  30. data/lib/sevgi/graphics/mixtures/tile.rb +88 -64
  31. data/lib/sevgi/graphics/mixtures/transform.rb +68 -28
  32. data/lib/sevgi/graphics/mixtures/underscore.rb +16 -7
  33. data/lib/sevgi/graphics/mixtures/validate.rb +14 -2
  34. data/lib/sevgi/graphics/mixtures/wrappers.rb +66 -24
  35. data/lib/sevgi/graphics/mixtures.rb +19 -13
  36. data/lib/sevgi/graphics/version.rb +1 -1
  37. data/lib/sevgi/graphics/xml.rb +127 -0
  38. data/lib/sevgi/graphics.rb +75 -28
  39. metadata +10 -6
@@ -2,63 +2,140 @@
2
2
 
3
3
  module Sevgi
4
4
  module Graphics
5
- # SVG document profile factory.
5
+ # SVG document profile factory and process-global named-profile registry.
6
+ #
7
+ # A profile owns SVG root attributes and optional preamble lines, but not canvas size. Built-in and named profiles
8
+ # can be passed to {Sevgi::Graphics.SVG}; an anonymous profile class is useful when library code needs one-off
9
+ # metadata without adding a global name.
10
+ #
11
+ # | Profile | Preamble | Root metadata | Additional DSL |
12
+ # | --- | --- | --- | --- |
13
+ # | `:minimal` | none | none | common document DSL |
14
+ # | `:default` | XML declaration | SVG namespace | common document DSL |
15
+ # | `:html` | none | SVG namespace | common document DSL |
16
+ # | `:inkscape` | XML declaration | SVG and editor namespaces; crisp edges | `Draw`, `Hatch`, and editor/RDF helpers |
17
+ #
18
+ # The Inkscape root adds Sevgi, Inkscape, and Sodipodi namespaces plus `shape-rendering="crispEdges"`. Every
19
+ # selectable profile has the same validation and lint lifecycle; `:minimal` changes serialization metadata, not
20
+ # checking policy. {Base} is the public common extension layer rather than a selectable profile. {Minimal} and
21
+ # {Default} are sibling concrete profiles: Minimal contributes no metadata and is not the semantic base of the other
22
+ # profiles. Targeting Base through {Sevgi::Graphics::Mixtures.mixin} changes every descendant profile process-wide;
23
+ # subclass Base first when an extension should remain scoped.
24
+ #
25
+ # @see https://sevgi.roktas.dev/svg/#document-profiles Document profiles guide
6
26
  module Document
7
27
  # Defensive copy helper for profile metadata snapshots.
8
28
  # @api private
9
29
  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)
30
+ class << self
31
+ # Captures recursively immutable profile metadata. Mutable non-container values are stringified once.
32
+ # @param value [Object] value to capture
33
+ # @param seen [Hash] container identities on the current traversal path
34
+ # @return [Object] immutable captured value
35
+ # @raise [Sevgi::ArgumentError] when metadata is cyclic, has colliding keys, or cannot be stringified
36
+ def capture(value, seen = {}.compare_by_identity)
37
+ case value
38
+ when ::Hash
39
+ nested(value, seen) { capture_hash(value, seen) }.freeze
40
+ when ::Array
41
+ nested(value, seen) { value.map { capture(it, seen) } }.freeze
42
+ else
43
+ capture_value(value)
44
+ end
21
45
  end
22
- end
23
46
 
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
47
+ # Returns a recursively caller-owned copy of captured metadata.
48
+ # @param value [Object] captured value to copy
49
+ # @return [Object] caller-owned copy
50
+ def copy(value)
51
+ case value
52
+ when ::String
53
+ value.dup
54
+ when ::Hash
55
+ value.to_h { |key, item| [copy(key), copy(item)] }
56
+ when ::Array
57
+ value.map { copy(it) }
58
+ else
59
+ value
60
+ end
35
61
  end
36
- end
37
62
 
38
- def self.hash(value) = value.to_h { |key, item| [copy(key), copy(item)] }
63
+ private
64
+
65
+ def capture_hash(value, seen)
66
+ value.each_with_object({}) do |(key, item), captured|
67
+ key = capture(key, seen)
68
+ ArgumentError.("Document profile metadata keys collide after stringification") if captured.key?(key)
69
+
70
+ captured[key] = capture(item, seen)
71
+ end
72
+ end
73
+
74
+ def nested(value, seen)
75
+ ArgumentError.("Cyclic document profile metadata is not supported") if seen.key?(value)
76
+
77
+ seen[value] = true
78
+ yield
79
+ ensure
80
+ seen.delete(value)
81
+ end
82
+
83
+ def capture_value(value)
84
+ case value
85
+ when ::String
86
+ XML.text(value, context: "Document profile metadata").freeze
87
+ when ::Numeric, ::Symbol, ::NilClass, ::TrueClass, ::FalseClass
88
+ XML.text(value, context: "Document profile metadata")
89
+ value
90
+ else
91
+ stringify(value).freeze
92
+ end
93
+ end
39
94
 
40
- def self.duplicate(value)
41
- value.dup
42
- rescue ::TypeError
43
- value
95
+ def stringify(value)
96
+ XML.text(value, context: "Document profile metadata")
97
+ end
98
+ end
99
+ end
100
+
101
+ # Document profile name normalization.
102
+ # @api private
103
+ module Name
104
+ # Normalizes a profile name.
105
+ # @param name [Object] profile name
106
+ # @return [Symbol, nil]
107
+ def self.normalize(name)
108
+ normalized = name.to_sym if name.respond_to?(:to_sym)
109
+ normalized if normalized.is_a?(::Symbol)
110
+ rescue ::StandardError
111
+ nil
44
112
  end
45
113
 
46
- private_class_method :duplicate, :hash
114
+ # Normalizes a profile name or raises.
115
+ # @param name [Object] profile name
116
+ # @return [Symbol]
117
+ # @raise [Sevgi::ArgumentError] when name cannot be normalized
118
+ def self.normalize!(name) = normalize(name) || ArgumentError.("Invalid document profile: #{name}")
47
119
  end
48
120
 
49
- private_constant :Snapshot
121
+ private_constant :Name, :Snapshot
50
122
 
51
123
  # Builds a root SVG element from a document profile.
52
124
  # @param document [Symbol, String, Class] profile name or document class
53
125
  # @param canvas [Sevgi::Graphics::Canvas, Sevgi::Graphics::Paper, Symbol, String, Sevgi::Undefined, nil] canvas input
126
+ # @yield evaluates the drawing DSL in the new root element
127
+ # @yieldreturn [Object] ignored block result
54
128
  # @return [Sevgi::Graphics::Document::Proto] SVG root element
55
- # @raise [Sevgi::ArgumentError] when the document profile is unknown
129
+ # @raise [Sevgi::ArgumentError] when the document profile or root XML attributes are invalid
130
+ # @example Build from a scoped Base-derived profile
131
+ # Card = Class.new(Sevgi::Graphics::Document::Base)
132
+ # Sevgi::Graphics::Document.(Card) { rect width: 10, height: 5 }
56
133
  def self.call(document, canvas = Undefined, **, &block)
57
134
  klass = case document
58
135
  when ::Class
59
136
  document if document <= Proto
60
137
  else
61
- Profile[document]
138
+ fetch(document)
62
139
  end
63
140
 
64
141
  ArgumentError.("Unknown document profile: #{document}") unless klass
@@ -79,117 +156,210 @@ module Sevgi
79
156
 
80
157
  private_class_method :canvas_attributes
81
158
 
82
- # Defines or returns a document profile class.
159
+ # Returns a registered document class by profile name.
160
+ # @param name [Symbol, String] profile name
161
+ # @return [Class] registered subclass of {Sevgi::Graphics::Document::Proto}
162
+ # @raise [Sevgi::ArgumentError] when name is invalid or unknown
163
+ # @example Look up a document class and its metadata
164
+ # klass = Sevgi::Graphics::Document.fetch(:minimal)
165
+ # Sevgi::Graphics::Document.profile(:minimal) # => klass.profile
166
+ def self.fetch(name)
167
+ name = Name.normalize!(name)
168
+ Registry[name] || ArgumentError.("Unknown document profile: #{name}")
169
+ end
170
+
171
+ # Reports whether a normalizable document profile name is registered.
172
+ # Invalid converters return false and do not change the registry.
173
+ # @example Check a built-in profile
174
+ # Sevgi::Graphics::Document.exist?(:minimal) # => true
175
+ # @param name [Object] profile name
176
+ # @return [Boolean]
177
+ def self.exist?(name)
178
+ name = Name.normalize(name)
179
+ name ? !Registry[name].nil? : false
180
+ end
181
+
182
+ # Returns registered document profile names.
183
+ # @return [Array<Symbol>] frozen name snapshot
184
+ def self.keys = Registry.available.keys.freeze
185
+
186
+ # Returns immutable metadata for a registered document profile.
187
+ # @param name [Symbol, String] profile name
188
+ # @return [Sevgi::Graphics::Document::Profile] registered profile metadata
189
+ # @raise [Sevgi::ArgumentError] when name is invalid or unknown
190
+ def self.profile(name) = fetch(name).profile
191
+
192
+ # Defines, looks up, or returns an anonymous document profile class.
193
+ #
194
+ # A name without metadata performs lookup. A name plus either metadata
195
+ # keyword defines or compatibly reuses a named profile. Omitting the name
196
+ # creates an anonymous class and leaves the registry unchanged. Named
197
+ # profiles are process-global; use them for shared vocabulary rather than
198
+ # per-call configuration.
199
+ # Profile metadata is captured before class or thread-atomic registry mutation. Mutable non-container attribute
200
+ # values are stringified once, attribute names and nested Hash keys are normalized, and nil attributes are omitted
201
+ # during capture. Successful named definitions return the canonical class stored by the registry, including when
202
+ # identical definitions race.
83
203
  # @param name [Symbol, String, Sevgi::Undefined] profile name, or Undefined for an anonymous profile
84
204
  # @param preambles [Array<String>, nil, Sevgi::Undefined] document preamble lines
85
- # @param attributes [Hash, Sevgi::Undefined] default root attributes
205
+ # @param attributes [Hash, nil, Sevgi::Undefined] default root attributes
86
206
  # @param overwrite [Boolean] true to replace an existing profile
87
207
  # @return [Class] document class
88
- # @raise [Sevgi::ArgumentError] when a named profile conflicts with an existing profile
208
+ # @raise [Sevgi::ArgumentError] when overwrite is not Boolean, a name conflicts, or metadata is invalid XML,
209
+ # cyclic, or cannot be stringified
210
+ # @example Define a reusable library profile
211
+ # profile = Sevgi::Graphics::Document.define(
212
+ # :icon,
213
+ # preambles: [],
214
+ # attributes: {xmlns: "http://www.w3.org/2000/svg", viewBox: "0 0 24 24"}
215
+ # )
216
+ # Sevgi::Graphics::Document.(profile) { circle cx: 12, cy: 12, r: 10 }.Render
217
+ # @example Build an anonymous one-off profile
218
+ # profile = Sevgi::Graphics::Document.define(attributes: {viewBox: "0 0 10 10"})
219
+ # profile.profile.name # => nil
89
220
  def self.define(name = Undefined, preambles: Undefined, attributes: Undefined, overwrite: false)
221
+ overwrite!(overwrite)
90
222
  return anonymous(attributes:, preambles:) if name == Undefined
91
223
 
92
- if (current = Profile[name])
93
- reject_conflict(name, current, attributes:, preambles:) unless overwrite
94
- return current unless overwrite
95
- end
224
+ return lookup(name) if preambles == Undefined && attributes == Undefined
225
+
226
+ name = Name.normalize!(name)
227
+ current = reuse(name, attributes:, preambles:, overwrite:)
228
+ return current if current
96
229
 
97
230
  attributes, preambles = defaults(attributes:, preambles:)
98
231
  Class.new(Base) { document(name, preambles:, attributes:, overwrite:) }
232
+ Registry[name]
99
233
  end
100
234
 
101
- def self.anonymous(attributes:, preambles:)
102
- attributes, preambles = defaults(attributes:, preambles:)
103
- Class.new(Base) { document(Undefined, preambles:, attributes:, register: false) }
104
- end
235
+ class << self
236
+ private
105
237
 
106
- def self.defaults(attributes:, preambles:)
107
- [attributes == Undefined ? {} : attributes, preambles == Undefined ? nil : preambles]
108
- end
238
+ def anonymous(attributes:, preambles:)
239
+ attributes, preambles = defaults(attributes:, preambles:)
240
+ Class.new(Base) { document(Undefined, preambles:, attributes:, register: false) }
241
+ end
109
242
 
110
- def self.reject_conflict(name, current, attributes:, preambles:)
111
- return if compatible?(current, attributes:, preambles:)
243
+ def lookup(name)
244
+ fetch(name)
245
+ end
112
246
 
113
- ArgumentError.("Document profile already defined differently: #{name}")
114
- end
247
+ def defaults(attributes:, preambles:)
248
+ [attributes == Undefined ? {} : attributes, preambles == Undefined ? nil : preambles]
249
+ end
115
250
 
116
- def self.compatible?(klass, attributes:, preambles:)
117
- profile = klass.profile
251
+ def reject_conflict(name, current, attributes:, preambles:)
252
+ return if compatible?(current, attributes:, preambles:)
118
253
 
119
- (attributes == Undefined || Profile.new(nil, attributes:).attributes == profile.attributes) &&
120
- (preambles == Undefined || Profile.new(nil, preambles:).preambles == profile.preambles)
121
- end
254
+ ArgumentError.("Document profile already defined differently: #{name}")
255
+ end
256
+
257
+ def compatible?(klass, attributes:, preambles:)
258
+ profile = klass.profile
259
+
260
+ (attributes == Undefined || Profile.new(nil, attributes:).attributes == profile.attributes) &&
261
+ (preambles == Undefined || Profile.new(nil, preambles:).preambles == profile.preambles)
262
+ end
263
+
264
+ def reuse(name, attributes:, preambles:, overwrite:)
265
+ return unless (current = Registry[name])
266
+
267
+ reject_conflict(name, current, attributes:, preambles:) unless overwrite
268
+ current unless overwrite
269
+ end
270
+
271
+ def overwrite!(value)
272
+ return value if [true, false].include?(value)
122
273
 
123
- private_class_method :anonymous, :compatible?, :defaults, :reject_conflict
274
+ ArgumentError.("Document overwrite must be true or false")
275
+ end
276
+ end
124
277
 
125
- # Immutable document profile metadata.
278
+ # Process-global document profile registry.
126
279
  # @api private
127
- class Profile
280
+ module Registry
128
281
  @available = {}
282
+ @mutex = ::Mutex.new
129
283
 
130
284
  class << self
131
- # @return [Hash<Symbol, Class>] registered profile classes
132
- attr_reader :available
133
- end
285
+ def [](name) = @mutex.synchronize { @available[name] }
134
286
 
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]
287
+ def available = @mutex.synchronize { @available.dup.freeze }
139
288
 
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}")
289
+ def register(name, klass, profile: nil, overwrite: false)
290
+ overwrite = Document.send(:overwrite!, overwrite)
291
+ name = Name.normalize!(name)
292
+ validate!(name, klass, profile)
293
+
294
+ @mutex.synchronize { store(name, klass, profile, overwrite) }
295
+ end
296
+
297
+ private
298
+
299
+ def store(name, klass, profile, overwrite)
300
+ if (current = @available[name])
301
+ unless overwrite || current.profile == profile
302
+ ArgumentError.("Document profile already defined differently: #{name}")
303
+ end
304
+
305
+ return current unless overwrite
152
306
  end
153
307
 
154
- return current unless overwrite
308
+ klass.instance_variable_set(:@profile, profile)
309
+ @available[name] = klass
155
310
  end
156
311
 
157
- available[name] = klass
158
- end
312
+ def validate!(name, klass, profile)
313
+ unless klass.is_a?(::Class) && klass <= Proto
314
+ ArgumentError.("Document profile class must inherit Document::Proto: #{klass}")
315
+ end
159
316
 
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
317
+ return if profile.is_a?(Profile) && profile.name == name
164
318
 
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}")
319
+ ArgumentError.("Document profile class has invalid metadata: #{klass}")
320
+ end
321
+ end
322
+ end
323
+
324
+ private_constant :Registry
170
325
 
326
+ # Immutable, read-only document profile metadata exposed by document classes. Process-global lookup and registration
327
+ # are thread-atomic. Metadata containers and strings are captured recursively; other mutable attribute values are
328
+ # stringified once during construction. Attribute names and nested Hash keys are normalized to Symbols, nil values
329
+ # are omitted, and update-suffix intent is retained for inheritance.
330
+ # Returned attribute and preamble collections are caller-owned snapshots,
331
+ # so changing them does not alter the registered profile.
332
+ # @see Sevgi::Graphics.document
333
+ class Profile
171
334
  # @return [Symbol, nil] profile name
172
335
  attr_reader :name
173
336
 
174
337
  # Creates profile metadata.
175
338
  # @param name [Object, nil] profile name
176
- # @param attributes [Hash, nil] default root attributes
339
+ # @param attributes [Hash, nil] default root attributes; nil means an empty Hash
177
340
  # @param preambles [Array<String>, nil] preamble lines
178
341
  # @return [void]
179
- # @raise [Sevgi::ArgumentError] when name cannot be normalized
342
+ # @raise [Sevgi::ArgumentError] when name or metadata is invalid XML, cyclic, or cannot be stringified
180
343
  def initialize(name, attributes: nil, preambles: nil)
181
- @name = name.nil? ? nil : self.class.normalize!(name)
182
- @attributes = Snapshot.frozen(attributes || {})
183
- @preambles = preambles.nil? ? nil : Snapshot.frozen(preambles)
344
+ @name = name.nil? ? nil : Name.normalize!(name)
345
+ @attributes = capture_attributes(attributes)
346
+ @preambles = capture_preambles(preambles)
347
+ freeze
184
348
  end
185
349
 
186
350
  # Reports strict profile equality.
187
351
  # @param other [Object] object to compare
188
352
  # @return [Boolean]
189
- def ==(other) = self.class == other.class && deconstruct == other.deconstruct
353
+ def eql?(other) = self.class == other.class && deconstruct == other.deconstruct
190
354
 
191
- # Returns default root attributes.
192
- # @return [Hash] mutation-isolated attribute snapshot
355
+ # Returns a hash compatible with strict equality.
356
+ # @return [Integer]
357
+ def hash = [self.class, name, @attributes, @preambles].hash
358
+
359
+ # Returns canonical default root attributes for this profile.
360
+ # Names and nested Hash keys are Symbols, nil attributes are omitted, and update suffixes remain explicit for
361
+ # application by a document class.
362
+ # @return [Hash{Symbol => Object}] mutation-isolated attribute snapshot
193
363
  def attributes = Snapshot.copy(@attributes)
194
364
 
195
365
  # Returns profile components.
@@ -199,27 +369,68 @@ module Sevgi
199
369
  # Returns preamble lines.
200
370
  # @return [Array<String>, nil] mutation-isolated preamble snapshot
201
371
  def preambles = Snapshot.copy(@preambles)
202
- end
203
372
 
204
- private_constant :Profile
373
+ alias == eql?
374
+
375
+ private
376
+
377
+ def capture_attribute(key, value, identities)
378
+ update = Attribute.updateable?(key)
379
+ id = Attribute.id(key)
380
+ if identities.key?(id)
381
+ ArgumentError.("Document profile attribute names collide after normalization: #{id}")
382
+ end
383
+
384
+ identities[id] = true
385
+ key = update ? :"#{id}#{Attributes::UPDATE_SUFFIX}" : id
386
+ value = Snapshot.capture(value)
387
+ [key, Attribute.capture(value, normalize_keys: value.is_a?(::Hash))]
388
+ end
389
+
390
+ def capture_attributes(attributes)
391
+ attributes = {} if attributes.nil?
392
+ ArgumentError.("Document profile attributes must be a Hash") unless attributes.is_a?(::Hash)
393
+
394
+ identities = {}
395
+ captured = attributes.filter_map do |key, value|
396
+ next if value.nil?
397
+
398
+ capture_attribute(key, value, identities)
399
+ end
400
+
401
+ Snapshot.capture(captured.to_h)
402
+ end
403
+
404
+ def capture_preambles(preambles)
405
+ return if preambles.nil?
406
+
407
+ unless preambles.is_a?(::Array) && preambles.all?(::String)
408
+ ArgumentError.("Document profile preambles must be an Array of Strings")
409
+ end
410
+
411
+ Snapshot.capture(preambles)
412
+ end
413
+ end
205
414
 
206
415
  # Class-level DSL used while defining document classes.
207
416
  # @api private
208
417
  module DSL
209
- # @return [Sevgi::Graphics::Document::Profile] document profile metadata
210
- attr_reader :profile
211
-
212
418
  # Sets document profile metadata on a class.
213
419
  # @param name [Object] profile name
214
- # @param attributes [Hash] default root attributes
420
+ # @param attributes [Hash, nil] default root attributes
215
421
  # @param preambles [Array<String>, nil] preamble lines
216
422
  # @param register [Boolean] true to register the profile globally
217
423
  # @param overwrite [Boolean] true to replace an existing profile
218
- # @return [Sevgi::Graphics::Document::Profile]
219
- # @raise [Sevgi::ArgumentError] when registration fails
424
+ # @return [Sevgi::Graphics::Document::Profile] immutable document profile metadata
425
+ # @raise [Sevgi::ArgumentError] when registration fails or metadata is invalid XML, cyclic, or cannot be stringified
220
426
  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
427
+ overwrite = Document.send(:overwrite!, overwrite)
428
+ profile = Profile.new(register ? name : nil, attributes:, preambles:)
429
+ Attributes.new(superclass.attributes).merge!(profile.attributes)
430
+ return (@profile = profile) unless register
431
+
432
+ registered = Registry.register(name, self, profile:, overwrite:)
433
+ @profile = profile unless registered.equal?(self)
223
434
  @profile
224
435
  end
225
436
 
@@ -232,17 +443,19 @@ module Sevgi
232
443
  include(mod = ns.const_get(mixture))
233
444
  extend(mod::ClassMethods) if defined?(mod::ClassMethods)
234
445
  end
446
+
447
+ private :document, :mixture
235
448
  end
236
449
 
237
450
  private_constant :DSL
238
451
 
239
452
  # Default render-time checks.
453
+ # @api private
240
454
  DEFAULTS = {lint: true, validate: true}.freeze
455
+ private_constant :DEFAULTS
241
456
 
242
457
  # Base document root element class.
243
458
  class Proto < Element
244
- public_class_method :new
245
-
246
459
  extend DSL
247
460
 
248
461
  mixture :Core
@@ -250,25 +463,58 @@ module Sevgi
250
463
  mixture :Render
251
464
  mixture :Wrappers
252
465
 
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
258
- def call(*, **)
259
- options = DEFAULTS.merge(**)
466
+ # Returns the nearest immutable profile metadata in the class hierarchy.
467
+ # @return [Sevgi::Graphics::Document::Profile, nil] nearest profile, or nil when no ancestor is configured
468
+ def self.profile
469
+ return @profile if instance_variable_defined?(:@profile)
470
+
471
+ superclass.profile if superclass.respond_to?(:profile)
472
+ end
260
473
 
261
- self.PreRender(*, **options) if respond_to?(:PreRender)
262
- self.Render(*, **options)
474
+ # Renders this document after its optional pre-render checks.
475
+ # @example Render a document directly with separate check and renderer options
476
+ # document = Sevgi::Graphics.SVG(:minimal) { rect width: 3 }
477
+ # document.call(lint: false, style: :inline)
478
+ # @param options [Hash] `lint` and `validate` check switches plus renderer options accepted by
479
+ # {Sevgi::Graphics::Mixtures::Render#Render}
480
+ # @option options [Boolean] :lint (true) run document lint checks
481
+ # @option options [Boolean] :validate (true) run SVG standard validation
482
+ # @return [String] SVG document source
483
+ # @raise [Sevgi::ArgumentError] when an option or XML-bound value is invalid
484
+ # @raise [Sevgi::ValidationError] when validation is enabled and the document violates the SVG standard
485
+ # @raise [Sevgi::Graphics::LintError] when linting is enabled and the document has structural conflicts
486
+ # @see Sevgi::Graphics::Mixtures::Render#Render
487
+ def call(**options)
488
+ checks = DEFAULTS.merge(options.select { |key, _| DEFAULTS.key?(key) })
489
+ self.PreRender(**checks) if respond_to?(:PreRender)
490
+ render_options = options.reject { |key, _| DEFAULTS.key?(key) }
491
+ self.Render(**render_options)
263
492
  end
264
493
 
265
- # Returns inherited root attributes for this document class.
266
- # @return [Hash]
267
- def self.attributes = self == Proto ? {} : {**superclass.attributes, **profile.attributes}
494
+ # Returns effective inherited root attributes for this document class.
495
+ # Profile update suffixes are applied from the oldest configured ancestor to this class.
496
+ # @return [Hash{Symbol => Object}] inherited root attributes without update suffixes
497
+ # @raise [Sevgi::ArgumentError] when a non-Proto class has no configured ancestor
498
+ def self.attributes
499
+ return {} if self == Proto
500
+
501
+ ArgumentError.("Document class has no configured profile: #{self}") unless profile
502
+ return superclass.attributes unless instance_variable_defined?(:@profile)
503
+
504
+ Attributes.new(superclass.attributes).tap { it.merge!(@profile.attributes) }.to_h
505
+ end
268
506
 
269
507
  # Returns inherited preamble lines for this document class.
270
508
  # @return [Array<String>, nil]
271
- def self.preambles = self == Proto ? nil : profile.preambles || superclass.preambles
509
+ # @raise [Sevgi::ArgumentError] when a non-Proto class has no configured ancestor
510
+ def self.preambles
511
+ return if self == Proto
512
+
513
+ ArgumentError.("Document class has no configured profile: #{self}") unless profile
514
+ return superclass.preambles unless instance_variable_defined?(:@profile)
515
+
516
+ @profile.preambles || superclass.preambles
517
+ end
272
518
  end
273
519
 
274
520
  require_relative "document/base"