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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +125 -0
- data/README.md +8 -8
- data/lib/sevgi/graphics/attribute.rb +160 -37
- data/lib/sevgi/graphics/auxiliary/canvas.rb +100 -43
- data/lib/sevgi/graphics/auxiliary/content.rb +54 -34
- data/lib/sevgi/graphics/auxiliary/margin.rb +19 -12
- data/lib/sevgi/graphics/auxiliary/paper.rb +74 -49
- data/lib/sevgi/graphics/auxiliary/path.rb +44 -0
- data/lib/sevgi/graphics/auxiliary/scalar.rb +36 -7
- data/lib/sevgi/graphics/auxiliary.rb +1 -0
- data/lib/sevgi/graphics/document/base.rb +6 -2
- data/lib/sevgi/graphics/document/default.rb +1 -1
- data/lib/sevgi/graphics/document.rb +236 -102
- data/lib/sevgi/graphics/element.rb +114 -31
- data/lib/sevgi/graphics/mixtures/call.rb +249 -88
- data/lib/sevgi/graphics/mixtures/core.rb +59 -20
- data/lib/sevgi/graphics/mixtures/duplicate.rb +49 -15
- data/lib/sevgi/graphics/mixtures/export.rb +52 -12
- data/lib/sevgi/graphics/mixtures/hatch.rb +49 -7
- data/lib/sevgi/graphics/mixtures/identify.rb +30 -17
- data/lib/sevgi/graphics/mixtures/include.rb +26 -8
- data/lib/sevgi/graphics/mixtures/inkscape.rb +201 -47
- data/lib/sevgi/graphics/mixtures/rdf.rb +59 -7
- data/lib/sevgi/graphics/mixtures/render.rb +52 -28
- data/lib/sevgi/graphics/mixtures/save.rb +79 -35
- data/lib/sevgi/graphics/mixtures/symbols.rb +81 -12
- data/lib/sevgi/graphics/mixtures/tile.rb +85 -67
- data/lib/sevgi/graphics/mixtures/transform.rb +68 -28
- data/lib/sevgi/graphics/mixtures/underscore.rb +14 -5
- data/lib/sevgi/graphics/mixtures/validate.rb +2 -2
- data/lib/sevgi/graphics/mixtures/wrappers.rb +62 -23
- data/lib/sevgi/graphics/mixtures.rb +15 -13
- data/lib/sevgi/graphics/version.rb +1 -1
- data/lib/sevgi/graphics.rb +58 -12
- 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
|
|
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
|
-
|
|
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
|
-
|
|
30
|
-
|
|
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
|
-
|
|
54
|
+
class << self
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
def real?(value) = value.is_a?(::Numeric) && !value.is_a?(::Complex)
|
|
34
58
|
|
|
35
|
-
|
|
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
|
|
@@ -3,9 +3,11 @@
|
|
|
3
3
|
module Sevgi
|
|
4
4
|
module Graphics
|
|
5
5
|
module Document
|
|
6
|
-
#
|
|
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 :
|
|
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
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
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
|
|
119
|
-
# the registry, including when
|
|
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,
|
|
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 =
|
|
132
|
-
|
|
133
|
-
|
|
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
|
-
|
|
144
|
-
|
|
145
|
-
Class.new(Base) { document(Undefined, preambles:, attributes:, register: false) }
|
|
146
|
-
end
|
|
235
|
+
class << self
|
|
236
|
+
private
|
|
147
237
|
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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
|
-
|
|
154
|
-
|
|
155
|
-
|
|
243
|
+
def lookup(name)
|
|
244
|
+
fetch(name)
|
|
245
|
+
end
|
|
156
246
|
|
|
157
|
-
|
|
158
|
-
|
|
247
|
+
def defaults(attributes:, preambles:)
|
|
248
|
+
[attributes == Undefined ? {} : attributes, preambles == Undefined ? nil : preambles]
|
|
249
|
+
end
|
|
159
250
|
|
|
160
|
-
|
|
161
|
-
|
|
251
|
+
def reject_conflict(name, current, attributes:, preambles:)
|
|
252
|
+
return if compatible?(current, attributes:, preambles:)
|
|
162
253
|
|
|
163
|
-
|
|
164
|
-
|
|
254
|
+
ArgumentError.("Document profile already defined differently: #{name}")
|
|
255
|
+
end
|
|
165
256
|
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 :
|
|
259
|
-
|
|
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
|
|
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
|
-
#
|
|
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
|
|
284
|
-
|
|
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
|
-
|
|
289
|
-
attributes.
|
|
290
|
-
|
|
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
|
-
|
|
398
|
+
capture_attribute(key, value, identities)
|
|
294
399
|
end
|
|
295
|
-
end
|
|
296
400
|
|
|
297
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
364
|
-
#
|
|
365
|
-
|
|
366
|
-
|
|
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
|
-
|
|
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(
|
|
491
|
+
self.Render(**render_options)
|
|
375
492
|
end
|
|
376
493
|
|
|
377
|
-
# Returns inherited root attributes for this document class.
|
|
378
|
-
#
|
|
379
|
-
|
|
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
|
-
|
|
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"
|