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
data/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Sevgi Graphics
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Sevgi Graphics implements the SVG DSL, document profiles, and rendering.
|
|
4
4
|
|
|
5
5
|
## Install
|
|
6
6
|
|
|
@@ -17,21 +17,21 @@ require "sevgi/graphics"
|
|
|
17
17
|
## Example
|
|
18
18
|
|
|
19
19
|
```ruby
|
|
20
|
-
doc = Sevgi::Graphics.SVG(:minimal) { rect
|
|
20
|
+
doc = Sevgi::Graphics.SVG(:minimal) { rect width: 3, height: 5 }
|
|
21
21
|
doc.call
|
|
22
22
|
```
|
|
23
23
|
|
|
24
24
|
## Ruby compatibility
|
|
25
25
|
|
|
26
|
-
Requires Ruby 3.4.0 or newer. CI verifies Ruby 3.4 and the
|
|
26
|
+
Requires Ruby 3.4.0 or newer. CI verifies the current Ruby 3.4 release and the development Ruby from `.ruby-version`.
|
|
27
27
|
|
|
28
28
|
## Native prerequisites
|
|
29
29
|
|
|
30
|
-
|
|
30
|
+
This gem needs only Ruby and its Ruby dependencies.
|
|
31
31
|
|
|
32
32
|
## Links
|
|
33
33
|
|
|
34
|
-
- Documentation: https://sevgi.roktas.dev
|
|
35
|
-
- API documentation: https://www.rubydoc.info/gems/sevgi-graphics
|
|
36
|
-
- Source: https://github.com/roktas/sevgi/tree/main/graphics
|
|
37
|
-
- Changelog: https://github.com/roktas/sevgi/blob/main/CHANGELOG.md
|
|
34
|
+
- Documentation: <https://sevgi.roktas.dev>
|
|
35
|
+
- API documentation: <https://www.rubydoc.info/gems/sevgi-graphics>
|
|
36
|
+
- Source: <https://github.com/roktas/sevgi/tree/main/graphics>
|
|
37
|
+
- Changelog: <https://github.com/roktas/sevgi/blob/main/CHANGELOG.md>
|
|
@@ -4,11 +4,16 @@
|
|
|
4
4
|
|
|
5
5
|
module Sevgi
|
|
6
6
|
module Graphics
|
|
7
|
-
#
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
7
|
+
# Mutable facade for SVG attributes and non-rendering element metadata.
|
|
8
|
+
class Attributes
|
|
9
|
+
# Prefix marking supported non-rendering metadata names. Prefixed entries remain available through the facade but
|
|
10
|
+
# are omitted from SVG output.
|
|
11
|
+
META_PREFIX = "-"
|
|
12
|
+
|
|
13
|
+
# Suffix requesting an update of an existing value. String and Symbol values are space-joined, Arrays are
|
|
14
|
+
# concatenated, and Hashes are merged. When the attribute is absent, assignment uses normal replacement behavior.
|
|
15
|
+
UPDATE_SUFFIX = "+"
|
|
16
|
+
end
|
|
12
17
|
|
|
13
18
|
# Attribute name normalization helpers.
|
|
14
19
|
# @api private
|
|
@@ -20,33 +25,138 @@ module Sevgi
|
|
|
20
25
|
# @param given [String, Symbol] attribute name
|
|
21
26
|
# @return [Boolean]
|
|
22
27
|
def internal?(given)
|
|
23
|
-
(@internal ||= {})[given] ||= given.start_with?(
|
|
28
|
+
(@internal ||= {})[given] ||= key(given).start_with?(Attributes::META_PREFIX)
|
|
24
29
|
end
|
|
25
30
|
|
|
26
31
|
# Returns the normalized attribute id.
|
|
27
32
|
# @param given [String, Symbol] attribute name
|
|
28
33
|
# @return [Symbol]
|
|
29
34
|
def id(given)
|
|
30
|
-
(@id ||= {})[given] ||=
|
|
31
|
-
.
|
|
35
|
+
(@id ||= {})[given] ||= begin
|
|
36
|
+
name = updateable?(given) ? key(given).delete_suffix(Attributes::UPDATE_SUFFIX) : key(given)
|
|
37
|
+
XML.name(name, context: "XML attribute name") unless internal?(given)
|
|
38
|
+
name.to_sym
|
|
39
|
+
end
|
|
32
40
|
end
|
|
33
41
|
|
|
34
42
|
# Reports whether an attribute uses merge/update syntax.
|
|
35
43
|
# @param given [String, Symbol] attribute name
|
|
36
44
|
# @return [Boolean]
|
|
37
45
|
def updateable?(given)
|
|
38
|
-
(@updateable ||= {})[given] ||= given.end_with?(
|
|
46
|
+
(@updateable ||= {})[given] ||= key(given).end_with?(Attributes::UPDATE_SUFFIX)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
def key(given)
|
|
52
|
+
return given.to_s if given.is_a?(::String) || given.is_a?(::Symbol)
|
|
53
|
+
|
|
54
|
+
ArgumentError.("XML attribute name must be a String or Symbol")
|
|
39
55
|
end
|
|
40
56
|
end
|
|
41
57
|
|
|
42
58
|
extend Ident
|
|
43
59
|
|
|
60
|
+
# Owned mutable snapshots for values entering an attribute store.
|
|
61
|
+
# @api private
|
|
62
|
+
module Snapshot
|
|
63
|
+
class << self
|
|
64
|
+
def capture(value, normalize_keys: false, seen: {}.compare_by_identity)
|
|
65
|
+
case value
|
|
66
|
+
when ::Hash
|
|
67
|
+
nested(value, seen) { capture_hash(value, normalize_keys:, seen:) }
|
|
68
|
+
when ::Array
|
|
69
|
+
nested(value, seen) { value.map { capture(it, seen:) } }
|
|
70
|
+
else
|
|
71
|
+
capture_value(value)
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
private
|
|
76
|
+
|
|
77
|
+
def capture_hash(value, normalize_keys:, seen:)
|
|
78
|
+
identities = {}
|
|
79
|
+
value.each_with_object({}) do |(key, item), captured|
|
|
80
|
+
key = normalize_keys ? normalize_key(key) : capture(key, seen:)
|
|
81
|
+
identity = normalize_keys ? key : XML.snapshot(key, context: "XML attribute value")
|
|
82
|
+
if captured.key?(key) || identities.key?(identity)
|
|
83
|
+
ArgumentError.("Attribute keys collide after normalization or stringification")
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
identities[identity] = true
|
|
87
|
+
captured[key] = capture(item, seen:)
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def capture_value(value)
|
|
92
|
+
text = XML.text(value, context: "XML attribute value")
|
|
93
|
+
case value
|
|
94
|
+
when ::Numeric, ::Symbol, ::NilClass, ::TrueClass, ::FalseClass
|
|
95
|
+
value
|
|
96
|
+
else
|
|
97
|
+
text
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def nested(value, seen)
|
|
102
|
+
ArgumentError.("Cyclic XML attribute value is not supported") if seen.key?(value)
|
|
103
|
+
|
|
104
|
+
seen[value] = true
|
|
105
|
+
yield
|
|
106
|
+
ensure
|
|
107
|
+
seen.delete(value)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def normalize_key(key)
|
|
111
|
+
normalized = key.to_sym if key.respond_to?(:to_sym)
|
|
112
|
+
return normalized if normalized.is_a?(::Symbol)
|
|
113
|
+
|
|
114
|
+
ArgumentError.("Attribute Hash keys must normalize to Symbols")
|
|
115
|
+
rescue Sevgi::ArgumentError
|
|
116
|
+
raise
|
|
117
|
+
rescue ::StandardError => e
|
|
118
|
+
ArgumentError.("Attribute Hash key cannot be normalized: #{e.class}: #{e.message}")
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
private_constant :Snapshot
|
|
124
|
+
|
|
125
|
+
# Captures a caller-independent attribute value.
|
|
126
|
+
# @param value [Object] value to capture
|
|
127
|
+
# @param normalize_keys [Boolean] normalize direct Hash keys to Symbols
|
|
128
|
+
# @return [Object] owned mutable snapshot
|
|
129
|
+
# @raise [Sevgi::ArgumentError] when value is invalid, cyclic, collides, or cannot be converted
|
|
130
|
+
# @api private
|
|
131
|
+
def self.capture(value, normalize_keys: false) = Snapshot.capture(value, normalize_keys:)
|
|
132
|
+
|
|
133
|
+
# Returns normalized owned attributes with defaults for absent names.
|
|
134
|
+
# @param attributes [Hash] source attributes
|
|
135
|
+
# @param defaults [Hash] values inserted only when their normalized names are absent
|
|
136
|
+
# @return [Hash{Symbol => Object}] normalized owned attributes
|
|
137
|
+
# @raise [Sevgi::ArgumentError] when input contains an invalid, colliding, or unsupported attribute
|
|
138
|
+
# @api private
|
|
139
|
+
def self.defaults(attributes, **defaults)
|
|
140
|
+
attributes = Attributes.new(attributes)
|
|
141
|
+
defaults.each { |name, value| attributes[name] = value unless attributes.has?(name) }
|
|
142
|
+
attributes.to_h
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# Returns normalized owned attributes.
|
|
146
|
+
# @param attributes [Hash] source attributes
|
|
147
|
+
# @return [Hash{Symbol => Object}] normalized owned attributes
|
|
148
|
+
# @raise [Sevgi::ArgumentError] when input contains an invalid, colliding, or unsupported attribute
|
|
149
|
+
# @api private
|
|
150
|
+
def self.normalize(attributes) = Attributes.new(attributes).to_h
|
|
151
|
+
|
|
44
152
|
# Returns the text form used for an XML attribute value before escaping.
|
|
45
153
|
# @param value [Object] attribute value
|
|
46
154
|
# @return [String]
|
|
155
|
+
# @raise [Sevgi::ArgumentError] when value is invalid, cyclic, or cannot be stringified as XML
|
|
47
156
|
# @api private
|
|
48
157
|
def self.xml_text(value)
|
|
49
|
-
|
|
158
|
+
value = XML.snapshot(value, context: "XML attribute value")
|
|
159
|
+
text = case value
|
|
50
160
|
when ::Hash
|
|
51
161
|
value.map { |key, attr_value| "#{key}:#{attr_value}" }.join("; ")
|
|
52
162
|
when ::Array
|
|
@@ -54,100 +164,111 @@ module Sevgi
|
|
|
54
164
|
else
|
|
55
165
|
value.to_s
|
|
56
166
|
end
|
|
167
|
+
|
|
168
|
+
XML.text(text, context: "XML attribute value")
|
|
57
169
|
end
|
|
58
170
|
|
|
59
|
-
# Mutable
|
|
171
|
+
# Mutable backing store for SVG attributes.
|
|
172
|
+
# @api private
|
|
60
173
|
class Store
|
|
61
|
-
# Creates an attribute store.
|
|
174
|
+
# Creates an attribute store from recursively owned snapshots. Mutable non-container leaves are stringified
|
|
175
|
+
# once; later caller mutation cannot change the store.
|
|
62
176
|
# @param attributes [Hash] initial attributes
|
|
63
177
|
# @return [void]
|
|
178
|
+
# @raise [Sevgi::ArgumentError] when input is not a Hash or a name/value is invalid, cyclic, colliding, or cannot
|
|
179
|
+
# be converted
|
|
64
180
|
def initialize(attributes = {})
|
|
65
181
|
@store = {}
|
|
66
182
|
|
|
67
183
|
import(attributes)
|
|
68
184
|
end
|
|
69
185
|
|
|
70
|
-
#
|
|
186
|
+
# Atomically imports recursively owned attribute snapshots.
|
|
71
187
|
# @param attributes [Hash] attributes to merge
|
|
72
188
|
# @return [Hash] internal store
|
|
189
|
+
# @raise [Sevgi::ArgumentError] when input is not a Hash or a name/value is invalid, cyclic, colliding, or cannot
|
|
190
|
+
# be converted
|
|
73
191
|
def import(attributes)
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
.to_a
|
|
77
|
-
.to_h do |key, value|
|
|
78
|
-
[key.to_sym, import_value(value)]
|
|
79
|
-
end
|
|
192
|
+
updated = @store.dup
|
|
193
|
+
entries(attributes).each { |entry| assign(updated, *entry) }
|
|
80
194
|
|
|
81
|
-
@store.
|
|
195
|
+
@store.replace(updated)
|
|
82
196
|
end
|
|
83
197
|
|
|
84
|
-
# Returns
|
|
198
|
+
# Returns a stored attribute value for the public facade.
|
|
85
199
|
# @param key [String, Symbol] attribute key
|
|
86
200
|
# @return [Object, nil]
|
|
201
|
+
# @raise [Sevgi::ArgumentError] when key is not a valid XML attribute name
|
|
87
202
|
def [](key)
|
|
88
203
|
@store[Attribute.id(key)]
|
|
89
204
|
end
|
|
90
205
|
|
|
91
|
-
# Assigns
|
|
206
|
+
# Assigns a recursively owned attribute snapshot. Mutable non-container leaves are stringified once.
|
|
92
207
|
# @param key [String, Symbol] attribute key
|
|
93
208
|
# @param value [Object, nil] attribute value; nil is ignored
|
|
94
|
-
# @return [Object, nil]
|
|
209
|
+
# @return [Object, nil] stored snapshot or nil
|
|
95
210
|
# @raise [Sevgi::ArgumentError] when update syntax receives incompatible values
|
|
96
211
|
# @raise [Sevgi::ArgumentError] when update syntax receives an unsupported value type
|
|
212
|
+
# @raise [Sevgi::ArgumentError] when a name/value is invalid, cyclic, colliding, or cannot be converted
|
|
97
213
|
def []=(key, value)
|
|
98
214
|
return if value.nil?
|
|
99
215
|
|
|
100
|
-
|
|
216
|
+
id = Attribute.id(key)
|
|
217
|
+
value = Attribute.capture(value, normalize_keys: value.is_a?(::Hash))
|
|
218
|
+
@store[id] = @store.key?(id) && Attribute.updateable?(key) ? update(@store[id], value) : value
|
|
101
219
|
end
|
|
102
220
|
|
|
103
221
|
# Deletes an attribute by normalized key.
|
|
104
222
|
# @param key [String, Symbol] attribute key
|
|
105
223
|
# @return [Object, nil] deleted value
|
|
224
|
+
# @raise [Sevgi::ArgumentError] when key is not a valid XML attribute name
|
|
106
225
|
def delete(key)
|
|
107
226
|
@store.delete(Attribute.id(key))
|
|
108
227
|
end
|
|
109
228
|
|
|
110
|
-
# Returns
|
|
111
|
-
# @return [Hash]
|
|
229
|
+
# Returns rendering attributes, excluding non-rendering metadata. Nested values remain live store values.
|
|
230
|
+
# @return [Hash] shallow attribute view
|
|
112
231
|
def export
|
|
113
232
|
hash = @store.reject { |id, _| Attribute.internal?(id) }
|
|
114
233
|
return hash unless hash.key?(:id)
|
|
115
234
|
|
|
116
|
-
#
|
|
235
|
+
# Keep id first for stable, readable SVG output.
|
|
117
236
|
{id: hash.delete(:id), **hash}
|
|
118
237
|
end
|
|
119
238
|
|
|
120
239
|
# Reports whether an attribute exists.
|
|
121
240
|
# @param key [String, Symbol] attribute key
|
|
122
241
|
# @return [Boolean]
|
|
242
|
+
# @raise [Sevgi::ArgumentError] when key is not a valid XML attribute name
|
|
123
243
|
def has?(key)
|
|
124
244
|
@store.key?(Attribute.id(key))
|
|
125
245
|
end
|
|
126
246
|
|
|
127
|
-
# Copies the attribute store.
|
|
128
|
-
# @param original [Sevgi::Graphics::
|
|
247
|
+
# Copies the attribute store with recursively independent values.
|
|
248
|
+
# @param original [Sevgi::Graphics::Attributes] store to copy
|
|
129
249
|
# @return [void]
|
|
130
250
|
def initialize_copy(original)
|
|
131
251
|
@store = {}
|
|
132
|
-
original.store.each { |key, value| @store[key] = value
|
|
252
|
+
original.store.each { |key, value| @store[key] = Attribute.capture(value) }
|
|
133
253
|
|
|
134
254
|
super
|
|
135
255
|
end
|
|
136
256
|
|
|
137
|
-
# Returns
|
|
257
|
+
# Returns rendering attribute names, excluding non-rendering metadata.
|
|
138
258
|
# @return [Array<Symbol>]
|
|
139
259
|
def list
|
|
140
260
|
export.keys
|
|
141
261
|
end
|
|
142
262
|
|
|
143
|
-
# Returns the internal attribute
|
|
144
|
-
# @return [Hash]
|
|
263
|
+
# Returns the internal attribute and metadata Hash.
|
|
264
|
+
# @return [Hash] backing store
|
|
145
265
|
def to_h
|
|
146
266
|
@store
|
|
147
267
|
end
|
|
148
268
|
|
|
149
269
|
# Returns rendered XML attribute lines.
|
|
150
270
|
# @return [Array<String>]
|
|
271
|
+
# @raise [Sevgi::ArgumentError] when stored names or values are invalid, cyclic, or cannot be stringified
|
|
151
272
|
def to_xml_lines
|
|
152
273
|
export.map { |id, value| to_xml(id, value) }
|
|
153
274
|
end
|
|
@@ -158,29 +279,35 @@ module Sevgi
|
|
|
158
279
|
|
|
159
280
|
private
|
|
160
281
|
|
|
161
|
-
# Returns a caller-owned value copy for import.
|
|
162
|
-
# @param value [Object] attribute value
|
|
163
|
-
# @return [Object] imported value
|
|
164
|
-
def import_value(value)
|
|
165
|
-
case value
|
|
166
|
-
when ::Hash
|
|
167
|
-
value.transform_keys(&:to_sym)
|
|
168
|
-
when ::Array
|
|
169
|
-
value.dup
|
|
170
|
-
else
|
|
171
|
-
value
|
|
172
|
-
end
|
|
173
|
-
end
|
|
174
|
-
|
|
175
282
|
UPDATER = {
|
|
176
283
|
::String => proc { |old_value, new_value| [old_value, new_value].reject(&:empty?).join(" ") },
|
|
177
284
|
::Symbol => proc { |old_value, new_value| [old_value, new_value].reject(&:empty?).join(" ").to_sym },
|
|
178
|
-
::Array => proc { |old_value, new_value|
|
|
179
|
-
::Hash => proc { |old_value, new_value| old_value.merge(new_value
|
|
285
|
+
::Array => proc { |old_value, new_value| old_value + new_value },
|
|
286
|
+
::Hash => proc { |old_value, new_value| old_value.merge(new_value) }
|
|
180
287
|
}.freeze
|
|
181
288
|
|
|
182
|
-
def
|
|
183
|
-
|
|
289
|
+
def assign(store, key, id, value)
|
|
290
|
+
store[id] = store.key?(id) && Attribute.updateable?(key) ? update(store[id], value) : value
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
def entries(attributes)
|
|
294
|
+
ArgumentError.("Attributes must be imported from a Hash") unless attributes.is_a?(::Hash)
|
|
295
|
+
|
|
296
|
+
identities = {}
|
|
297
|
+
attributes.filter_map do |key, value|
|
|
298
|
+
next if value.nil?
|
|
299
|
+
|
|
300
|
+
id = Attribute.id(key)
|
|
301
|
+
ArgumentError.("Attribute names collide after normalization: #{id}") if identities.key?(id)
|
|
302
|
+
|
|
303
|
+
identities[id] = true
|
|
304
|
+
value = Attribute.capture(value, normalize_keys: value.is_a?(::Hash))
|
|
305
|
+
[key, id, value]
|
|
306
|
+
end
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
def update(old_value, new_value)
|
|
310
|
+
UPDATER[new_value.class].call(*sanitized(old_value, new_value))
|
|
184
311
|
end
|
|
185
312
|
|
|
186
313
|
def sanitized(old_value, new_value)
|
|
@@ -193,12 +320,102 @@ module Sevgi
|
|
|
193
320
|
private_constant :UPDATER
|
|
194
321
|
|
|
195
322
|
def to_xml(id, value)
|
|
196
|
-
|
|
323
|
+
name = XML.name(id, context: "XML attribute name")
|
|
324
|
+
"#{name}=#{Attribute.xml_text(value).encode(xml: :attr)}"
|
|
197
325
|
end
|
|
198
326
|
end
|
|
199
327
|
end
|
|
200
328
|
|
|
201
|
-
#
|
|
202
|
-
|
|
329
|
+
# Names beginning with {META_PREFIX} can be read, assigned, deleted, merged, and copied like ordinary
|
|
330
|
+
# attributes. They appear in {#to_h}, but public SVG attribute enumeration and rendered XML omit them. All values
|
|
331
|
+
# entering or leaving this facade are recursively owned snapshots. Appending {UPDATE_SUFFIX} to a name requests a
|
|
332
|
+
# same-family update: Strings and Symbols are space-joined, Arrays are concatenated, and Hashes are merged. An update
|
|
333
|
+
# to an absent name behaves as replacement, and nil assignments are ignored.
|
|
334
|
+
#
|
|
335
|
+
# @example Inspect and update element attributes
|
|
336
|
+
# element = Sevgi::Graphics.SVG { rect id: "copy", "-source": "original" }.children.first
|
|
337
|
+
# element.attributes[:"-source"] # => "original"
|
|
338
|
+
# element.attributes.merge!(fill: "red")
|
|
339
|
+
# element.attributes.to_h # => { id: "copy", :"-source" => "original", fill: "red" }
|
|
340
|
+
class Attributes
|
|
341
|
+
# Creates an attribute facade from recursively owned snapshots.
|
|
342
|
+
# @param attributes [Hash] initial attributes and non-rendering metadata
|
|
343
|
+
# @return [void]
|
|
344
|
+
# @raise [Sevgi::ArgumentError] when input is not a Hash or contains an invalid name or value
|
|
345
|
+
def initialize(attributes = {})
|
|
346
|
+
@store = Attribute::Store.new(attributes)
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
# Returns an owned snapshot of an attribute value.
|
|
350
|
+
# @param key [String, Symbol] attribute key
|
|
351
|
+
# @return [Object, nil] recursively owned value or nil when absent
|
|
352
|
+
# @raise [Sevgi::ArgumentError] when key is not a valid attribute name
|
|
353
|
+
def [](key) = snapshot(@store[key])
|
|
354
|
+
|
|
355
|
+
# Assigns or updates a recursively owned attribute value.
|
|
356
|
+
# @param key [String, Symbol] attribute key, optionally ending in {UPDATE_SUFFIX}
|
|
357
|
+
# @param value [Object, nil] attribute value; nil is ignored
|
|
358
|
+
# @return [Object, nil] recursively owned resulting value or nil when absent
|
|
359
|
+
# @raise [Sevgi::ArgumentError] when the name or value is invalid, or an existing update uses incompatible or
|
|
360
|
+
# unsupported value families
|
|
361
|
+
def []=(key, value)
|
|
362
|
+
@store[key] = value
|
|
363
|
+
snapshot(@store[key])
|
|
364
|
+
end
|
|
365
|
+
|
|
366
|
+
# Deletes an attribute and returns an owned snapshot of its value.
|
|
367
|
+
# @param key [String, Symbol] attribute key
|
|
368
|
+
# @return [Object, nil] deleted value or nil when absent
|
|
369
|
+
# @raise [Sevgi::ArgumentError] when key is not a valid attribute name
|
|
370
|
+
def delete(key) = snapshot(@store.delete(key))
|
|
371
|
+
|
|
372
|
+
# Reports whether an attribute exists.
|
|
373
|
+
# @param key [String, Symbol] attribute key
|
|
374
|
+
# @return [Boolean]
|
|
375
|
+
# @raise [Sevgi::ArgumentError] when key is not a valid attribute name
|
|
376
|
+
def has?(key) = @store.has?(key)
|
|
377
|
+
|
|
378
|
+
# Copies the facade with recursively independent values.
|
|
379
|
+
# @param original [Sevgi::Graphics::Attributes] facade to copy
|
|
380
|
+
# @return [void]
|
|
381
|
+
# @raise [Sevgi::ArgumentError] when stored values became invalid
|
|
382
|
+
# @api private
|
|
383
|
+
def initialize_copy(original)
|
|
384
|
+
super
|
|
385
|
+
@store = original.store.dup
|
|
386
|
+
end
|
|
387
|
+
|
|
388
|
+
private :initialize_copy
|
|
389
|
+
|
|
390
|
+
# Returns rendering attribute names, excluding non-rendering metadata.
|
|
391
|
+
# @return [Array<Symbol>] frozen name snapshot
|
|
392
|
+
def keys = @store.list.freeze
|
|
393
|
+
|
|
394
|
+
# Atomically assigns or updates recursively owned attributes.
|
|
395
|
+
# @param attributes [Hash] attributes and non-rendering metadata; names may end in {UPDATE_SUFFIX}
|
|
396
|
+
# @return [Sevgi::Graphics::Attributes] self
|
|
397
|
+
# @raise [Sevgi::ArgumentError] when input is not a Hash, names collide, a name or value is invalid, or an existing
|
|
398
|
+
# update uses incompatible or unsupported value families
|
|
399
|
+
def merge!(attributes)
|
|
400
|
+
@store.import(attributes)
|
|
401
|
+
self
|
|
402
|
+
end
|
|
403
|
+
|
|
404
|
+
# Returns a recursively owned Hash snapshot including non-rendering metadata.
|
|
405
|
+
# @return [Hash{Symbol => Object}] owned attribute and metadata snapshot
|
|
406
|
+
def to_h = snapshot(@store.to_h)
|
|
407
|
+
|
|
408
|
+
private
|
|
409
|
+
|
|
410
|
+
def snapshot(value) = Attribute.capture(value)
|
|
411
|
+
|
|
412
|
+
def xml_lines = @store.to_xml_lines
|
|
413
|
+
|
|
414
|
+
protected
|
|
415
|
+
|
|
416
|
+
attr_reader :store
|
|
417
|
+
end
|
|
418
|
+
|
|
419
|
+
private_constant :Attribute
|
|
203
420
|
end
|
|
204
421
|
end
|