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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +275 -2
- data/LICENSE +672 -3
- data/README.md +8 -8
- data/lib/sevgi/graphics/attribute.rb +271 -54
- data/lib/sevgi/graphics/auxiliary/canvas.rb +137 -58
- data/lib/sevgi/graphics/auxiliary/content.rb +200 -57
- data/lib/sevgi/graphics/auxiliary/margin.rb +33 -14
- data/lib/sevgi/graphics/auxiliary/paper.rb +118 -86
- data/lib/sevgi/graphics/auxiliary/path.rb +44 -0
- data/lib/sevgi/graphics/auxiliary/scalar.rb +69 -0
- data/lib/sevgi/graphics/auxiliary/sizes.rb +62 -0
- data/lib/sevgi/graphics/auxiliary.rb +3 -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 +370 -124
- data/lib/sevgi/graphics/element.rb +143 -33
- data/lib/sevgi/graphics/mixtures/call.rb +249 -88
- data/lib/sevgi/graphics/mixtures/core.rb +143 -33
- data/lib/sevgi/graphics/mixtures/duplicate.rb +76 -22
- data/lib/sevgi/graphics/mixtures/export.rb +58 -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 +40 -5
- data/lib/sevgi/graphics/mixtures/inkscape.rb +215 -46
- data/lib/sevgi/graphics/mixtures/rdf.rb +79 -7
- data/lib/sevgi/graphics/mixtures/render.rb +88 -19
- data/lib/sevgi/graphics/mixtures/save.rb +79 -26
- data/lib/sevgi/graphics/mixtures/symbols.rb +81 -9
- data/lib/sevgi/graphics/mixtures/tile.rb +88 -64
- data/lib/sevgi/graphics/mixtures/transform.rb +68 -28
- data/lib/sevgi/graphics/mixtures/underscore.rb +16 -7
- data/lib/sevgi/graphics/mixtures/validate.rb +14 -2
- data/lib/sevgi/graphics/mixtures/wrappers.rb +66 -24
- data/lib/sevgi/graphics/mixtures.rb +19 -13
- data/lib/sevgi/graphics/version.rb +1 -1
- data/lib/sevgi/graphics/xml.rb +127 -0
- data/lib/sevgi/graphics.rb +75 -28
- 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
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
when
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
|
|
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
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
#
|
|
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
|
|
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
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
-
|
|
102
|
-
|
|
103
|
-
Class.new(Base) { document(Undefined, preambles:, attributes:, register: false) }
|
|
104
|
-
end
|
|
235
|
+
class << self
|
|
236
|
+
private
|
|
105
237
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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
|
-
|
|
111
|
-
|
|
243
|
+
def lookup(name)
|
|
244
|
+
fetch(name)
|
|
245
|
+
end
|
|
112
246
|
|
|
113
|
-
|
|
114
|
-
|
|
247
|
+
def defaults(attributes:, preambles:)
|
|
248
|
+
[attributes == Undefined ? {} : attributes, preambles == Undefined ? nil : preambles]
|
|
249
|
+
end
|
|
115
250
|
|
|
116
|
-
|
|
117
|
-
|
|
251
|
+
def reject_conflict(name, current, attributes:, preambles:)
|
|
252
|
+
return if compatible?(current, attributes:, preambles:)
|
|
118
253
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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
|
-
|
|
274
|
+
ArgumentError.("Document overwrite must be true or false")
|
|
275
|
+
end
|
|
276
|
+
end
|
|
124
277
|
|
|
125
|
-
#
|
|
278
|
+
# Process-global document profile registry.
|
|
126
279
|
# @api private
|
|
127
|
-
|
|
280
|
+
module Registry
|
|
128
281
|
@available = {}
|
|
282
|
+
@mutex = ::Mutex.new
|
|
129
283
|
|
|
130
284
|
class << self
|
|
131
|
-
|
|
132
|
-
attr_reader :available
|
|
133
|
-
end
|
|
285
|
+
def [](name) = @mutex.synchronize { @available[name] }
|
|
134
286
|
|
|
135
|
-
|
|
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
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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
|
-
|
|
308
|
+
klass.instance_variable_set(:@profile, profile)
|
|
309
|
+
@available[name] = klass
|
|
155
310
|
end
|
|
156
311
|
|
|
157
|
-
|
|
158
|
-
|
|
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
|
-
|
|
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
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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
|
|
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 :
|
|
182
|
-
@attributes =
|
|
183
|
-
@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
|
|
353
|
+
def eql?(other) = self.class == other.class && deconstruct == other.deconstruct
|
|
190
354
|
|
|
191
|
-
# Returns
|
|
192
|
-
# @return [
|
|
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
|
-
|
|
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
|
-
|
|
222
|
-
Profile.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
|
-
#
|
|
254
|
-
#
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
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
|
-
|
|
262
|
-
|
|
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
|
-
#
|
|
267
|
-
|
|
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
|
-
|
|
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"
|