sevgi-graphics 0.95.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 (36) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +125 -0
  3. data/README.md +8 -8
  4. data/lib/sevgi/graphics/attribute.rb +160 -37
  5. data/lib/sevgi/graphics/auxiliary/canvas.rb +100 -43
  6. data/lib/sevgi/graphics/auxiliary/content.rb +54 -34
  7. data/lib/sevgi/graphics/auxiliary/margin.rb +19 -12
  8. data/lib/sevgi/graphics/auxiliary/paper.rb +74 -49
  9. data/lib/sevgi/graphics/auxiliary/path.rb +44 -0
  10. data/lib/sevgi/graphics/auxiliary/scalar.rb +36 -7
  11. data/lib/sevgi/graphics/auxiliary.rb +1 -0
  12. data/lib/sevgi/graphics/document/base.rb +6 -2
  13. data/lib/sevgi/graphics/document/default.rb +1 -1
  14. data/lib/sevgi/graphics/document.rb +236 -102
  15. data/lib/sevgi/graphics/element.rb +114 -31
  16. data/lib/sevgi/graphics/mixtures/call.rb +249 -88
  17. data/lib/sevgi/graphics/mixtures/core.rb +59 -20
  18. data/lib/sevgi/graphics/mixtures/duplicate.rb +49 -15
  19. data/lib/sevgi/graphics/mixtures/export.rb +52 -12
  20. data/lib/sevgi/graphics/mixtures/hatch.rb +49 -7
  21. data/lib/sevgi/graphics/mixtures/identify.rb +30 -17
  22. data/lib/sevgi/graphics/mixtures/include.rb +26 -8
  23. data/lib/sevgi/graphics/mixtures/inkscape.rb +201 -47
  24. data/lib/sevgi/graphics/mixtures/rdf.rb +59 -7
  25. data/lib/sevgi/graphics/mixtures/render.rb +52 -28
  26. data/lib/sevgi/graphics/mixtures/save.rb +79 -35
  27. data/lib/sevgi/graphics/mixtures/symbols.rb +81 -12
  28. data/lib/sevgi/graphics/mixtures/tile.rb +85 -67
  29. data/lib/sevgi/graphics/mixtures/transform.rb +68 -28
  30. data/lib/sevgi/graphics/mixtures/underscore.rb +14 -5
  31. data/lib/sevgi/graphics/mixtures/validate.rb +2 -2
  32. data/lib/sevgi/graphics/mixtures/wrappers.rb +62 -23
  33. data/lib/sevgi/graphics/mixtures.rb +15 -13
  34. data/lib/sevgi/graphics/version.rb +1 -1
  35. data/lib/sevgi/graphics.rb +58 -12
  36. metadata +7 -6
@@ -2,13 +2,13 @@
2
2
 
3
3
  module Sevgi
4
4
  module Graphics
5
- # Validates finite real numeric values used by graphics auxiliaries.
5
+ # Validates finite real numeric values used by Graphics APIs.
6
6
  # @api private
7
7
  module Scalar
8
8
  # Converts one finite real value.
9
9
  # @param value [Numeric] value to validate
10
10
  # @param context [String] error context
11
- # @param field [Symbol] field name
11
+ # @param field [Symbol, Integer] field name or position
12
12
  # @param positive [Boolean] require a strictly positive value
13
13
  # @param nonnegative [Boolean] require a non-negative value
14
14
  # @return [Float] validated value
@@ -24,15 +24,44 @@ module Sevgi
24
24
  invalid(context, field, value)
25
25
  end
26
26
 
27
- def self.real?(value) = value.is_a?(::Numeric) && !value.is_a?(::Complex)
27
+ # Converts one finite real value to an SVG number.
28
+ # @param value [Numeric] value to validate
29
+ # @param context [String] error context
30
+ # @param field [Symbol, Integer] field name or position
31
+ # @param positive [Boolean] require a strictly positive value
32
+ # @param nonnegative [Boolean] require a non-negative value
33
+ # @return [Integer, Float] normalized number with integral values represented as Integer
34
+ # @raise [Sevgi::ArgumentError] when value is not a finite real number
35
+ def self.number(value, context:, field:, positive: false, nonnegative: false)
36
+ if value.is_a?(::Integer)
37
+ invalid(context, field, value) unless valid?(value, positive:, nonnegative:)
38
+ return value
39
+ end
40
+
41
+ value = finite(value, context:, field:, positive:, nonnegative:)
42
+ value == value.to_i ? value.to_i : value
43
+ end
28
44
 
29
- def self.valid?(number, positive:, nonnegative:)
30
- number.finite? && (!positive || number.positive?) && (!nonnegative || number >= 0)
45
+ # Converts indexed finite real values to SVG numbers.
46
+ # @param values [Array<Numeric>] values to normalize
47
+ # @param context [String] error context
48
+ # @return [Array<(Integer, Float)>] normalized SVG numbers
49
+ # @raise [Sevgi::ArgumentError] when a value is not a finite real number
50
+ def self.numbers(values, context:)
51
+ values.each_with_index.map { |value, index| number(value, context:, field: index) }
31
52
  end
32
53
 
33
- def self.invalid(context, field, value) = ArgumentError.("Invalid #{context} #{field}: #{value.inspect}")
54
+ class << self
55
+ private
56
+
57
+ def real?(value) = value.is_a?(::Numeric) && !value.is_a?(::Complex)
34
58
 
35
- private_class_method :invalid, :real?, :valid?
59
+ def valid?(number, positive:, nonnegative:)
60
+ number.finite? && (!positive || number.positive?) && (!nonnegative || number >= 0)
61
+ end
62
+
63
+ def invalid(context, field, value) = ArgumentError.("Invalid #{context} #{field}: #{value.inspect}")
64
+ end
36
65
  end
37
66
 
38
67
  private_constant :Scalar
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "auxiliary/scalar"
4
+ require_relative "auxiliary/path"
4
5
  require_relative "auxiliary/margin"
5
6
  require_relative "auxiliary/sizes"
6
7
  require_relative "auxiliary/paper"
@@ -3,9 +3,11 @@
3
3
  module Sevgi
4
4
  module Graphics
5
5
  module Document
6
- # Standard document profile with the full common DSL mixture set.
6
+ # Abstract common document layer with the profile-independent DSL mixture set. It is not registered as a
7
+ # selectable profile. Advanced extensions may target this class through {Sevgi::Graphics::Mixtures.mixin}; doing
8
+ # so changes every descendant profile process-wide. Subclass it first when an extension should remain scoped.
7
9
  class Base < Proto
8
- document :base
10
+ document nil, register: false
9
11
 
10
12
  mixture :Call
11
13
  mixture :Duplicate
@@ -21,6 +23,8 @@ module Sevgi
21
23
 
22
24
  # Runs pre-render validation and lint checks.
23
25
  # @param options [Hash] pre-render options
26
+ # @option options [Boolean] :validate run SVG standard validation
27
+ # @option options [Boolean] :lint run document lint checks
24
28
  # @return [void]
25
29
  # @raise [Sevgi::ValidationError] when validation fails
26
30
  # @raise [Sevgi::Graphics::LintError] when linting fails
@@ -4,7 +4,7 @@ module Sevgi
4
4
  module Graphics
5
5
  module Document
6
6
  # Default SVG document profile with XML preamble and SVG namespace.
7
- class Default < Minimal
7
+ class Default < Base
8
8
  document(
9
9
  :default,
10
10
  attributes: {
@@ -2,7 +2,27 @@
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
@@ -78,7 +98,27 @@ module Sevgi
78
98
  end
79
99
  end
80
100
 
81
- private_constant :Snapshot
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
112
+ end
113
+
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}")
119
+ end
120
+
121
+ private_constant :Name, :Snapshot
82
122
 
83
123
  # Builds a root SVG element from a document profile.
84
124
  # @param document [Symbol, String, Class] profile name or document class
@@ -87,12 +127,15 @@ module Sevgi
87
127
  # @yieldreturn [Object] ignored block result
88
128
  # @return [Sevgi::Graphics::Document::Proto] SVG root element
89
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 }
90
133
  def self.call(document, canvas = Undefined, **, &block)
91
134
  klass = case document
92
135
  when ::Class
93
136
  document if document <= Proto
94
137
  else
95
- Profile[document]
138
+ fetch(document)
96
139
  end
97
140
 
98
141
  ArgumentError.("Unknown document profile: #{document}") unless klass
@@ -113,61 +156,124 @@ module Sevgi
113
156
 
114
157
  private_class_method :canvas_attributes
115
158
 
116
- # 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.
117
199
  # 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.
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.
120
203
  # @param name [Symbol, String, Sevgi::Undefined] profile name, or Undefined for an anonymous profile
121
204
  # @param preambles [Array<String>, nil, Sevgi::Undefined] document preamble lines
122
205
  # @param attributes [Hash, nil, Sevgi::Undefined] default root attributes
123
206
  # @param overwrite [Boolean] true to replace an existing profile
124
207
  # @return [Class] document class
125
- # @raise [Sevgi::ArgumentError] when a name conflicts or metadata is invalid XML, cyclic, or cannot be stringified
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
126
220
  def self.define(name = Undefined, preambles: Undefined, attributes: Undefined, overwrite: false)
221
+ overwrite!(overwrite)
127
222
  return anonymous(attributes:, preambles:) if name == Undefined
128
223
 
129
224
  return lookup(name) if preambles == Undefined && attributes == Undefined
130
225
 
131
- name = Profile.normalize!(name)
132
-
133
- if (current = Profile[name])
134
- reject_conflict(name, current, attributes:, preambles:) unless overwrite
135
- return current unless overwrite
136
- end
226
+ name = Name.normalize!(name)
227
+ current = reuse(name, attributes:, preambles:, overwrite:)
228
+ return current if current
137
229
 
138
230
  attributes, preambles = defaults(attributes:, preambles:)
139
231
  Class.new(Base) { document(name, preambles:, attributes:, overwrite:) }
140
232
  Registry[name]
141
233
  end
142
234
 
143
- def self.anonymous(attributes:, preambles:)
144
- attributes, preambles = defaults(attributes:, preambles:)
145
- Class.new(Base) { document(Undefined, preambles:, attributes:, register: false) }
146
- end
235
+ class << self
236
+ private
147
237
 
148
- def self.lookup(name)
149
- name = Profile.normalize!(name)
150
- Profile[name] || ArgumentError.("Unknown document profile: #{name}")
151
- end
238
+ def anonymous(attributes:, preambles:)
239
+ attributes, preambles = defaults(attributes:, preambles:)
240
+ Class.new(Base) { document(Undefined, preambles:, attributes:, register: false) }
241
+ end
152
242
 
153
- def self.defaults(attributes:, preambles:)
154
- [attributes == Undefined ? {} : attributes, preambles == Undefined ? nil : preambles]
155
- end
243
+ def lookup(name)
244
+ fetch(name)
245
+ end
156
246
 
157
- def self.reject_conflict(name, current, attributes:, preambles:)
158
- return if compatible?(current, attributes:, preambles:)
247
+ def defaults(attributes:, preambles:)
248
+ [attributes == Undefined ? {} : attributes, preambles == Undefined ? nil : preambles]
249
+ end
159
250
 
160
- ArgumentError.("Document profile already defined differently: #{name}")
161
- end
251
+ def reject_conflict(name, current, attributes:, preambles:)
252
+ return if compatible?(current, attributes:, preambles:)
162
253
 
163
- def self.compatible?(klass, attributes:, preambles:)
164
- profile = klass.profile
254
+ ArgumentError.("Document profile already defined differently: #{name}")
255
+ end
165
256
 
166
- (attributes == Undefined || Profile.new(nil, attributes:).attributes == profile.attributes) &&
167
- (preambles == Undefined || Profile.new(nil, preambles:).preambles == profile.preambles)
168
- end
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)
169
273
 
170
- private_class_method :anonymous, :compatible?, :defaults, :lookup, :reject_conflict
274
+ ArgumentError.("Document overwrite must be true or false")
275
+ end
276
+ end
171
277
 
172
278
  # Process-global document profile registry.
173
279
  # @api private
@@ -181,7 +287,8 @@ module Sevgi
181
287
  def available = @mutex.synchronize { @available.dup.freeze }
182
288
 
183
289
  def register(name, klass, profile: nil, overwrite: false)
184
- name = Profile.normalize!(name)
290
+ overwrite = Document.send(:overwrite!, overwrite)
291
+ name = Name.normalize!(name)
185
292
  validate!(name, klass, profile)
186
293
 
187
294
  @mutex.synchronize { store(name, klass, profile, overwrite) }
@@ -198,6 +305,7 @@ module Sevgi
198
305
  return current unless overwrite
199
306
  end
200
307
 
308
+ klass.instance_variable_set(:@profile, profile)
201
309
  @available[name] = klass
202
310
  end
203
311
 
@@ -217,34 +325,12 @@ module Sevgi
217
325
 
218
326
  # Immutable, read-only document profile metadata exposed by document classes. Process-global lookup and registration
219
327
  # are thread-atomic. Metadata containers and strings are captured recursively; other mutable attribute values are
220
- # stringified once during construction.
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.
221
332
  # @see Sevgi::Graphics.document
222
333
  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]
231
-
232
- # Normalizes a profile name.
233
- # @param name [Object] profile name
234
- # @return [Symbol, 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
241
-
242
- # Normalizes a profile name or raises.
243
- # @param name [Object] profile name
244
- # @return [Symbol]
245
- # @raise [Sevgi::ArgumentError] when name cannot be normalized
246
- def self.normalize!(name) = normalize(name) || ArgumentError.("Invalid document profile: #{name}")
247
-
248
334
  # @return [Symbol, nil] profile name
249
335
  attr_reader :name
250
336
 
@@ -255,19 +341,25 @@ module Sevgi
255
341
  # @return [void]
256
342
  # @raise [Sevgi::ArgumentError] when name or metadata is invalid XML, cyclic, or cannot be stringified
257
343
  def initialize(name, attributes: nil, preambles: nil)
258
- @name = name.nil? ? nil : self.class.normalize!(name)
259
- validate_attributes!(attributes)
260
- @attributes = Snapshot.capture(attributes || {})
344
+ @name = name.nil? ? nil : Name.normalize!(name)
345
+ @attributes = capture_attributes(attributes)
261
346
  @preambles = capture_preambles(preambles)
347
+ freeze
262
348
  end
263
349
 
264
350
  # Reports strict profile equality.
265
351
  # @param other [Object] object to compare
266
352
  # @return [Boolean]
267
- def ==(other) = self.class == other.class && deconstruct == other.deconstruct
353
+ def eql?(other) = self.class == other.class && deconstruct == other.deconstruct
354
+
355
+ # Returns a hash compatible with strict equality.
356
+ # @return [Integer]
357
+ def hash = [self.class, name, @attributes, @preambles].hash
268
358
 
269
- # Returns default root attributes.
270
- # @return [Hash] mutation-isolated attribute snapshot
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
271
363
  def attributes = Snapshot.copy(@attributes)
272
364
 
273
365
  # Returns profile components.
@@ -278,28 +370,35 @@ module Sevgi
278
370
  # @return [Array<String>, nil] mutation-isolated preamble snapshot
279
371
  def preambles = Snapshot.copy(@preambles)
280
372
 
373
+ alias == eql?
374
+
281
375
  private
282
376
 
283
- def validate_attributes!(attributes)
284
- return if attributes.nil?
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
285
389
 
390
+ def capture_attributes(attributes)
391
+ attributes = {} if attributes.nil?
286
392
  ArgumentError.("Document profile attributes must be a Hash") unless attributes.is_a?(::Hash)
287
393
 
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)
394
+ identities = {}
395
+ captured = attributes.filter_map do |key, value|
396
+ next if value.nil?
292
397
 
293
- normalized[id] = true
398
+ capture_attribute(key, value, identities)
294
399
  end
295
- end
296
400
 
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")
401
+ Snapshot.capture(captured.to_h)
303
402
  end
304
403
 
305
404
  def capture_preambles(preambles)
@@ -316,9 +415,6 @@ module Sevgi
316
415
  # Class-level DSL used while defining document classes.
317
416
  # @api private
318
417
  module DSL
319
- # @return [Sevgi::Graphics::Document::Profile] immutable document profile metadata
320
- attr_reader :profile
321
-
322
418
  # Sets document profile metadata on a class.
323
419
  # @param name [Object] profile name
324
420
  # @param attributes [Hash, nil] default root attributes
@@ -328,9 +424,14 @@ module Sevgi
328
424
  # @return [Sevgi::Graphics::Document::Profile] immutable document profile metadata
329
425
  # @raise [Sevgi::ArgumentError] when registration fails or metadata is invalid XML, cyclic, or cannot be stringified
330
426
  def document(name, attributes: {}, preambles: nil, register: true, overwrite: false)
427
+ overwrite = Document.send(:overwrite!, overwrite)
331
428
  profile = Profile.new(register ? name : nil, attributes:, preambles:)
332
- Registry.register(name, self, profile:, overwrite:) if register
333
- @profile = profile
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)
434
+ @profile
334
435
  end
335
436
 
336
437
  # Includes a graphics mixture into the document class.
@@ -342,17 +443,19 @@ module Sevgi
342
443
  include(mod = ns.const_get(mixture))
343
444
  extend(mod::ClassMethods) if defined?(mod::ClassMethods)
344
445
  end
446
+
447
+ private :document, :mixture
345
448
  end
346
449
 
347
450
  private_constant :DSL
348
451
 
349
452
  # Default render-time checks.
453
+ # @api private
350
454
  DEFAULTS = {lint: true, validate: true}.freeze
455
+ private_constant :DEFAULTS
351
456
 
352
457
  # Base document root element class.
353
458
  class Proto < Element
354
- public_class_method :new
355
-
356
459
  extend DSL
357
460
 
358
461
  mixture :Core
@@ -360,27 +463,58 @@ module Sevgi
360
463
  mixture :Render
361
464
  mixture :Wrappers
362
465
 
363
- # @overload call(*objects, **options)
364
- # Renders the document.
365
- # @param objects [Array<Object>] optional renderer arguments
366
- # @param options [Hash] render options
367
- # @return [String] SVG document source
368
- # @raise [Sevgi::ArgumentError] when renderer options or XML-bound values are invalid
369
- def call(*, **)
370
- 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)
371
470
 
372
- self.PreRender(*, **options) if respond_to?(:PreRender)
471
+ superclass.profile if superclass.respond_to?(:profile)
472
+ end
473
+
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)
373
490
  render_options = options.reject { |key, _| DEFAULTS.key?(key) }
374
- self.Render(*, **render_options)
491
+ self.Render(**render_options)
375
492
  end
376
493
 
377
- # Returns inherited root attributes for this document class.
378
- # @return [Hash{Symbol => Object}] inherited root attributes
379
- 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
380
506
 
381
507
  # Returns inherited preamble lines for this document class.
382
508
  # @return [Array<String>, nil]
383
- 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
384
518
  end
385
519
 
386
520
  require_relative "document/base"