sevgi-graphics 0.95.0 → 1.0.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 (37) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +221 -2
  3. data/README.md +12 -9
  4. data/lib/sevgi/graphics/attribute.rb +166 -45
  5. data/lib/sevgi/graphics/auxiliary/canvas.rb +100 -43
  6. data/lib/sevgi/graphics/auxiliary/content.rb +56 -47
  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 +239 -117
  15. data/lib/sevgi/graphics/element.rb +132 -34
  16. data/lib/sevgi/graphics/mixtures/call.rb +234 -88
  17. data/lib/sevgi/graphics/mixtures/core.rb +67 -27
  18. data/lib/sevgi/graphics/mixtures/duplicate.rb +47 -25
  19. data/lib/sevgi/graphics/mixtures/export.rb +54 -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 +214 -47
  24. data/lib/sevgi/graphics/mixtures/rdf.rb +59 -7
  25. data/lib/sevgi/graphics/mixtures/render.rb +60 -120
  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 +89 -30
  30. data/lib/sevgi/graphics/mixtures/underscore.rb +16 -7
  31. data/lib/sevgi/graphics/mixtures/validate.rb +2 -2
  32. data/lib/sevgi/graphics/mixtures/wrappers.rb +111 -23
  33. data/lib/sevgi/graphics/mixtures.rb +15 -13
  34. data/lib/sevgi/graphics/version.rb +1 -1
  35. data/lib/sevgi/graphics/xml.rb +4 -9
  36. data/lib/sevgi/graphics.rb +69 -18
  37. metadata +7 -6
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sevgi
4
+ module Graphics
5
+ # Output path normalization shared by Graphics writers and exporters.
6
+ # @api private
7
+ module Path
8
+ # Converts a non-blank path to an expanded String.
9
+ # @param value [String, #to_path] raw path value
10
+ # @param context [String] public operation named in errors
11
+ # @return [String] expanded path
12
+ # @raise [Sevgi::ArgumentError] when value is blank, has an invalid type, or path conversion fails
13
+ def self.call(value, context:)
14
+ path = value.respond_to?(:to_path) ? value.to_path : value
15
+ ArgumentError.("#{context} must be a String or path-like object") unless path.is_a?(::String)
16
+ ArgumentError.("#{context} must be provided") if path.strip.empty?
17
+
18
+ ::File.expand_path(path)
19
+ rescue ::Sevgi::ArgumentError
20
+ raise
21
+ rescue ::StandardError => e
22
+ ArgumentError.("#{context} must be a String or path-like object: #{e.message}")
23
+ end
24
+
25
+ # Resolves an optional path with the writer/exporter directory convention.
26
+ # @param value [String, #to_path, nil] explicit path or directory
27
+ # @param default [String, #to_path] default output path
28
+ # @param context [String] public operation named in errors
29
+ # @return [String] expanded output file path
30
+ # @raise [Sevgi::ArgumentError] when a selected path/default is blank, invalid, or cannot be converted
31
+ def self.resolve(value, default:, context:)
32
+ return call(default, context: "#{context} default") if value.nil?
33
+
34
+ path = call(value, context: "#{context} path")
35
+ return path unless ::File.directory?(path)
36
+
37
+ default = call(default, context: "#{context} default")
38
+ ::File.join(path, ::File.basename(default))
39
+ end
40
+ end
41
+
42
+ private_constant :Path
43
+ end
44
+ end
@@ -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 can target this class through {Sevgi::Graphics::Mixtures.mixin}. This
8
+ # changes every descendant profile process-wide. Subclass it first to keep an extension 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,11 +2,34 @@
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 with 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 to keep an extension scoped.
24
+ #
25
+ # @see https://sevgi.roktas.dev/documents/#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
30
+ SCALARS = [::NilClass, ::TrueClass, ::FalseClass, ::Symbol, ::Integer, ::Float, ::Rational, ::Complex].freeze
31
+ private_constant :SCALARS
32
+
10
33
  class << self
11
34
  # Captures recursively immutable profile metadata. Mutable non-container values are stringified once.
12
35
  # @param value [Object] value to capture
@@ -61,24 +84,33 @@ module Sevgi
61
84
  end
62
85
 
63
86
  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
87
+ text = XML.text(value, context: "Document profile metadata")
88
+ SCALARS.include?(value.class) ? value : text.freeze
73
89
  end
90
+ end
91
+ end
74
92
 
75
- def stringify(value)
76
- XML.text(value, context: "Document profile metadata")
77
- end
93
+ # Document profile name normalization.
94
+ # @api private
95
+ module Name
96
+ # Normalizes a profile name.
97
+ # @param name [Object] profile name
98
+ # @return [Symbol, nil]
99
+ def self.normalize(name)
100
+ normalized = name.to_sym if name.respond_to?(:to_sym)
101
+ normalized if normalized.is_a?(::Symbol)
102
+ rescue ::StandardError
103
+ nil
78
104
  end
105
+
106
+ # Normalizes a profile name or raises.
107
+ # @param name [Object] profile name
108
+ # @return [Symbol]
109
+ # @raise [Sevgi::ArgumentError] when name cannot be normalized
110
+ def self.normalize!(name) = normalize(name) || ArgumentError.("Invalid document profile: #{name}")
79
111
  end
80
112
 
81
- private_constant :Snapshot
113
+ private_constant :Name, :Snapshot
82
114
 
83
115
  # Builds a root SVG element from a document profile.
84
116
  # @param document [Symbol, String, Class] profile name or document class
@@ -87,12 +119,15 @@ module Sevgi
87
119
  # @yieldreturn [Object] ignored block result
88
120
  # @return [Sevgi::Graphics::Document::Proto] SVG root element
89
121
  # @raise [Sevgi::ArgumentError] when the document profile or root XML attributes are invalid
122
+ # @example Build from a scoped Base-derived profile
123
+ # Card = Class.new(Sevgi::Graphics::Document::Base)
124
+ # Sevgi::Graphics::Document.(Card) { rect width: 10, height: 5 }
90
125
  def self.call(document, canvas = Undefined, **, &block)
91
126
  klass = case document
92
127
  when ::Class
93
128
  document if document <= Proto
94
129
  else
95
- Profile[document]
130
+ fetch(document)
96
131
  end
97
132
 
98
133
  ArgumentError.("Unknown document profile: #{document}") unless klass
@@ -113,61 +148,120 @@ module Sevgi
113
148
 
114
149
  private_class_method :canvas_attributes
115
150
 
116
- # Defines or returns a document profile class.
151
+ # Returns a registered document class by profile name.
152
+ # @param name [Symbol, String] profile name
153
+ # @return [Class] registered subclass of {Sevgi::Graphics::Document::Proto}
154
+ # @raise [Sevgi::ArgumentError] when name is invalid or unknown
155
+ # @example Look up a document class and its metadata
156
+ # klass = Sevgi::Graphics::Document.fetch(:minimal)
157
+ # Sevgi::Graphics::Document.profile(:minimal) # => klass.profile
158
+ def self.fetch(name)
159
+ name = Name.normalize!(name)
160
+ Registry[name] || ArgumentError.("Unknown document profile: #{name}")
161
+ end
162
+
163
+ # Reports whether a normalizable document profile name is registered.
164
+ # Invalid converters return false and do not change the registry.
165
+ # @example Check a built-in profile
166
+ # Sevgi::Graphics::Document.exist?(:minimal) # => true
167
+ # @param name [Object] profile name
168
+ # @return [Boolean]
169
+ def self.exist?(name)
170
+ name = Name.normalize(name)
171
+ name ? !Registry[name].nil? : false
172
+ end
173
+
174
+ # Returns registered document profile names.
175
+ # @return [Array<Symbol>] frozen name snapshot
176
+ def self.keys = Registry.available.keys.freeze
177
+
178
+ # Returns immutable metadata for a registered document profile.
179
+ # @param name [Symbol, String] profile name
180
+ # @return [Sevgi::Graphics::Document::Profile] registered profile metadata
181
+ # @raise [Sevgi::ArgumentError] when name is invalid or unknown
182
+ def self.profile(name) = fetch(name).profile
183
+
184
+ # Defines, looks up, or returns an anonymous document profile class.
185
+ #
186
+ # A name without metadata performs lookup. A name plus either metadata
187
+ # keyword defines or compatibly reuses a named profile. Omitting the name
188
+ # creates an anonymous class and leaves the registry unchanged. Named
189
+ # profiles are process-global. Use them for shared vocabulary rather than
190
+ # per-call configuration.
117
191
  # 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.
192
+ # values are stringified once, attribute names and nested Hash keys are normalized, and nil attributes are omitted
193
+ # during capture. Successful named definitions return the canonical class stored by the registry, including when
194
+ # identical definitions race.
120
195
  # @param name [Symbol, String, Sevgi::Undefined] profile name, or Undefined for an anonymous profile
121
196
  # @param preambles [Array<String>, nil, Sevgi::Undefined] document preamble lines
122
197
  # @param attributes [Hash, nil, Sevgi::Undefined] default root attributes
123
198
  # @param overwrite [Boolean] true to replace an existing profile
124
199
  # @return [Class] document class
125
- # @raise [Sevgi::ArgumentError] when a name conflicts or metadata is invalid XML, cyclic, or cannot be stringified
200
+ # @raise [Sevgi::ArgumentError] when overwrite is not Boolean, a name conflicts, or metadata is invalid XML,
201
+ # cyclic, or cannot be stringified
202
+ # @example Define a reusable library profile
203
+ # profile = Sevgi::Graphics::Document.define(
204
+ # :icon,
205
+ # preambles: [],
206
+ # attributes: {xmlns: "http://www.w3.org/2000/svg", viewBox: "0 0 24 24"}
207
+ # )
208
+ # Sevgi::Graphics::Document.(profile) { circle cx: 12, cy: 12, r: 10 }.Render
209
+ # @example Build an anonymous one-off profile
210
+ # profile = Sevgi::Graphics::Document.define(attributes: {viewBox: "0 0 10 10"})
211
+ # profile.profile.name # => nil
126
212
  def self.define(name = Undefined, preambles: Undefined, attributes: Undefined, overwrite: false)
213
+ overwrite!(overwrite)
127
214
  return anonymous(attributes:, preambles:) if name == Undefined
128
215
 
129
- return lookup(name) if preambles == Undefined && attributes == Undefined
216
+ return fetch(name) if preambles == Undefined && attributes == Undefined
130
217
 
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
218
+ name = Name.normalize!(name)
219
+ current = reuse(name, attributes:, preambles:, overwrite:)
220
+ return current if current
137
221
 
138
222
  attributes, preambles = defaults(attributes:, preambles:)
139
223
  Class.new(Base) { document(name, preambles:, attributes:, overwrite:) }
140
224
  Registry[name]
141
225
  end
142
226
 
143
- def self.anonymous(attributes:, preambles:)
144
- attributes, preambles = defaults(attributes:, preambles:)
145
- Class.new(Base) { document(Undefined, preambles:, attributes:, register: false) }
146
- end
227
+ class << self
228
+ private
147
229
 
148
- def self.lookup(name)
149
- name = Profile.normalize!(name)
150
- Profile[name] || ArgumentError.("Unknown document profile: #{name}")
151
- end
230
+ def anonymous(attributes:, preambles:)
231
+ attributes, preambles = defaults(attributes:, preambles:)
232
+ Class.new(Base) { document(Undefined, preambles:, attributes:, register: false) }
233
+ end
152
234
 
153
- def self.defaults(attributes:, preambles:)
154
- [attributes == Undefined ? {} : attributes, preambles == Undefined ? nil : preambles]
155
- end
235
+ def defaults(attributes:, preambles:)
236
+ [attributes == Undefined ? {} : attributes, preambles == Undefined ? nil : preambles]
237
+ end
156
238
 
157
- def self.reject_conflict(name, current, attributes:, preambles:)
158
- return if compatible?(current, attributes:, preambles:)
239
+ def reject_conflict(name, current, attributes:, preambles:)
240
+ return if compatible?(current, attributes:, preambles:)
159
241
 
160
- ArgumentError.("Document profile already defined differently: #{name}")
161
- end
242
+ ArgumentError.("Document profile already defined differently: #{name}")
243
+ end
162
244
 
163
- def self.compatible?(klass, attributes:, preambles:)
164
- profile = klass.profile
245
+ def compatible?(klass, attributes:, preambles:)
246
+ profile = klass.profile
165
247
 
166
- (attributes == Undefined || Profile.new(nil, attributes:).attributes == profile.attributes) &&
167
- (preambles == Undefined || Profile.new(nil, preambles:).preambles == profile.preambles)
168
- end
248
+ (attributes == Undefined || Profile.new(nil, attributes:).attributes == profile.attributes) &&
249
+ (preambles == Undefined || Profile.new(nil, preambles:).preambles == profile.preambles)
250
+ end
251
+
252
+ def reuse(name, attributes:, preambles:, overwrite:)
253
+ return unless (current = Registry[name])
254
+
255
+ reject_conflict(name, current, attributes:, preambles:) unless overwrite
256
+ current unless overwrite
257
+ end
258
+
259
+ def overwrite!(value)
260
+ return value if [true, false].include?(value)
169
261
 
170
- private_class_method :anonymous, :compatible?, :defaults, :lookup, :reject_conflict
262
+ ArgumentError.("Document overwrite must be true or false")
263
+ end
264
+ end
171
265
 
172
266
  # Process-global document profile registry.
173
267
  # @api private
@@ -181,7 +275,8 @@ module Sevgi
181
275
  def available = @mutex.synchronize { @available.dup.freeze }
182
276
 
183
277
  def register(name, klass, profile: nil, overwrite: false)
184
- name = Profile.normalize!(name)
278
+ overwrite = Document.send(:overwrite!, overwrite)
279
+ name = Name.normalize!(name)
185
280
  validate!(name, klass, profile)
186
281
 
187
282
  @mutex.synchronize { store(name, klass, profile, overwrite) }
@@ -198,6 +293,7 @@ module Sevgi
198
293
  return current unless overwrite
199
294
  end
200
295
 
296
+ klass.instance_variable_set(:@profile, profile)
201
297
  @available[name] = klass
202
298
  end
203
299
 
@@ -216,58 +312,42 @@ module Sevgi
216
312
  private_constant :Registry
217
313
 
218
314
  # 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.
315
+ # are thread-atomic. Metadata containers and strings are captured recursively. Other mutable attribute values are
316
+ # stringified once during construction. Attribute names and nested Hash keys are normalized to Symbols, nil values
317
+ # are omitted, and update-suffix intent is retained for inheritance.
318
+ # Returned attribute and preamble collections are caller-owned snapshots,
319
+ # so changing them does not alter the registered profile.
221
320
  # @see Sevgi::Graphics.document
222
321
  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
322
  # @return [Symbol, nil] profile name
249
323
  attr_reader :name
250
324
 
251
325
  # Creates profile metadata.
252
326
  # @param name [Object, nil] profile name
253
- # @param attributes [Hash, nil] default root attributes; nil means an empty Hash
327
+ # @param attributes [Hash, nil] default root attributes. Nil means an empty Hash
254
328
  # @param preambles [Array<String>, nil] preamble lines
255
329
  # @return [void]
256
330
  # @raise [Sevgi::ArgumentError] when name or metadata is invalid XML, cyclic, or cannot be stringified
257
331
  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 || {})
332
+ @name = name.nil? ? nil : Name.normalize!(name)
333
+ @attributes = capture_attributes(attributes)
261
334
  @preambles = capture_preambles(preambles)
335
+ freeze
262
336
  end
263
337
 
264
338
  # Reports strict profile equality.
265
339
  # @param other [Object] object to compare
266
340
  # @return [Boolean]
267
- def ==(other) = self.class == other.class && deconstruct == other.deconstruct
341
+ def eql?(other) = self.class == other.class && deconstruct == other.deconstruct
268
342
 
269
- # Returns default root attributes.
270
- # @return [Hash] mutation-isolated attribute snapshot
343
+ # Returns a hash compatible with strict equality.
344
+ # @return [Integer]
345
+ def hash = [self.class, name, @attributes, @preambles].hash
346
+
347
+ # Returns canonical default root attributes for this profile.
348
+ # Names and nested Hash keys are Symbols, nil attributes are omitted, and update suffixes remain explicit for
349
+ # application by a document class.
350
+ # @return [Hash{Symbol => Object}] mutation-isolated attribute snapshot
271
351
  def attributes = Snapshot.copy(@attributes)
272
352
 
273
353
  # Returns profile components.
@@ -278,28 +358,35 @@ module Sevgi
278
358
  # @return [Array<String>, nil] mutation-isolated preamble snapshot
279
359
  def preambles = Snapshot.copy(@preambles)
280
360
 
361
+ alias == eql?
362
+
281
363
  private
282
364
 
283
- def validate_attributes!(attributes)
284
- return if attributes.nil?
365
+ def capture_attribute(key, value, identities)
366
+ update = Attribute.updateable?(key)
367
+ id = Attribute.id(key)
368
+ if identities.key?(id)
369
+ ArgumentError.("Document profile attribute names collide after normalization: #{id}")
370
+ end
371
+
372
+ identities[id] = true
373
+ key = update ? :"#{id}#{Attributes::UPDATE_SUFFIX}" : id
374
+ value = Snapshot.capture(value)
375
+ [key, Attribute.capture(value, normalize_keys: value.is_a?(::Hash))]
376
+ end
285
377
 
378
+ def capture_attributes(attributes)
379
+ attributes = {} if attributes.nil?
286
380
  ArgumentError.("Document profile attributes must be a Hash") unless attributes.is_a?(::Hash)
287
381
 
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)
382
+ identities = {}
383
+ captured = attributes.filter_map do |key, value|
384
+ next if value.nil?
292
385
 
293
- normalized[id] = true
386
+ capture_attribute(key, value, identities)
294
387
  end
295
- end
296
388
 
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")
389
+ Snapshot.capture(captured.to_h)
303
390
  end
304
391
 
305
392
  def capture_preambles(preambles)
@@ -316,9 +403,6 @@ module Sevgi
316
403
  # Class-level DSL used while defining document classes.
317
404
  # @api private
318
405
  module DSL
319
- # @return [Sevgi::Graphics::Document::Profile] immutable document profile metadata
320
- attr_reader :profile
321
-
322
406
  # Sets document profile metadata on a class.
323
407
  # @param name [Object] profile name
324
408
  # @param attributes [Hash, nil] default root attributes
@@ -328,9 +412,14 @@ module Sevgi
328
412
  # @return [Sevgi::Graphics::Document::Profile] immutable document profile metadata
329
413
  # @raise [Sevgi::ArgumentError] when registration fails or metadata is invalid XML, cyclic, or cannot be stringified
330
414
  def document(name, attributes: {}, preambles: nil, register: true, overwrite: false)
415
+ overwrite = Document.send(:overwrite!, overwrite)
331
416
  profile = Profile.new(register ? name : nil, attributes:, preambles:)
332
- Registry.register(name, self, profile:, overwrite:) if register
333
- @profile = profile
417
+ Attributes.new(superclass.attributes).merge!(profile.attributes)
418
+ return (@profile = profile) unless register
419
+
420
+ registered = Registry.register(name, self, profile:, overwrite:)
421
+ @profile = profile unless registered.equal?(self)
422
+ @profile
334
423
  end
335
424
 
336
425
  # Includes a graphics mixture into the document class.
@@ -342,17 +431,19 @@ module Sevgi
342
431
  include(mod = ns.const_get(mixture))
343
432
  extend(mod::ClassMethods) if defined?(mod::ClassMethods)
344
433
  end
434
+
435
+ private :document, :mixture
345
436
  end
346
437
 
347
438
  private_constant :DSL
348
439
 
349
440
  # Default render-time checks.
441
+ # @api private
350
442
  DEFAULTS = {lint: true, validate: true}.freeze
443
+ private_constant :DEFAULTS
351
444
 
352
445
  # Base document root element class.
353
446
  class Proto < Element
354
- public_class_method :new
355
-
356
447
  extend DSL
357
448
 
358
449
  mixture :Core
@@ -360,27 +451,58 @@ module Sevgi
360
451
  mixture :Render
361
452
  mixture :Wrappers
362
453
 
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(**)
454
+ # Returns the nearest immutable profile metadata in the class hierarchy.
455
+ # @return [Sevgi::Graphics::Document::Profile, nil] nearest profile, or nil when no ancestor is configured
456
+ def self.profile
457
+ return @profile if instance_variable_defined?(:@profile)
371
458
 
372
- self.PreRender(*, **options) if respond_to?(:PreRender)
459
+ superclass.profile if superclass.respond_to?(:profile)
460
+ end
461
+
462
+ # Renders this document after its optional pre-render checks.
463
+ # @example Render a document directly with separate check and renderer options
464
+ # document = Sevgi::Graphics.SVG(:minimal) { rect width: 3 }
465
+ # document.call(lint: false, style: :inline)
466
+ # @param options [Hash] `lint` and `validate` check switches plus renderer options accepted by
467
+ # {Sevgi::Graphics::Mixtures::Render#Render}
468
+ # @option options [Boolean] :lint (true) run document lint checks
469
+ # @option options [Boolean] :validate (true) run SVG standard validation
470
+ # @return [String] SVG document source
471
+ # @raise [Sevgi::ArgumentError] when an option or XML-bound value is invalid
472
+ # @raise [Sevgi::ValidationError] when validation is enabled and the document violates the SVG standard
473
+ # @raise [Sevgi::Graphics::LintError] when linting is enabled and the document has structural conflicts
474
+ # @see Sevgi::Graphics::Mixtures::Render#Render
475
+ def call(**options)
476
+ checks = DEFAULTS.merge(options.select { |key, _| DEFAULTS.key?(key) })
477
+ self.PreRender(**checks) if respond_to?(:PreRender)
373
478
  render_options = options.reject { |key, _| DEFAULTS.key?(key) }
374
- self.Render(*, **render_options)
479
+ self.Render(**render_options)
375
480
  end
376
481
 
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}
482
+ # Returns effective inherited root attributes for this document class.
483
+ # Profile update suffixes are applied from the oldest configured ancestor to this class.
484
+ # @return [Hash{Symbol => Object}] inherited root attributes without update suffixes
485
+ # @raise [Sevgi::ArgumentError] when a non-Proto class has no configured ancestor
486
+ def self.attributes
487
+ return {} if self == Proto
488
+
489
+ ArgumentError.("Document class has no configured profile: #{self}") unless profile
490
+ return superclass.attributes unless instance_variable_defined?(:@profile)
491
+
492
+ Attributes.new(superclass.attributes).tap { it.merge!(@profile.attributes) }.to_h
493
+ end
380
494
 
381
495
  # Returns inherited preamble lines for this document class.
382
496
  # @return [Array<String>, nil]
383
- def self.preambles = self == Proto ? nil : profile.preambles || superclass.preambles
497
+ # @raise [Sevgi::ArgumentError] when a non-Proto class has no configured ancestor
498
+ def self.preambles
499
+ return if self == Proto
500
+
501
+ ArgumentError.("Document class has no configured profile: #{self}") unless profile
502
+ return superclass.preambles unless instance_variable_defined?(:@profile)
503
+
504
+ @profile.preambles || superclass.preambles
505
+ end
384
506
  end
385
507
 
386
508
  require_relative "document/base"