sevgi-graphics 0.95.0 → 0.98.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +125 -0
  3. data/README.md +8 -8
  4. data/lib/sevgi/graphics/attribute.rb +160 -37
  5. data/lib/sevgi/graphics/auxiliary/canvas.rb +100 -43
  6. data/lib/sevgi/graphics/auxiliary/content.rb +54 -34
  7. data/lib/sevgi/graphics/auxiliary/margin.rb +19 -12
  8. data/lib/sevgi/graphics/auxiliary/paper.rb +74 -49
  9. data/lib/sevgi/graphics/auxiliary/path.rb +44 -0
  10. data/lib/sevgi/graphics/auxiliary/scalar.rb +36 -7
  11. data/lib/sevgi/graphics/auxiliary.rb +1 -0
  12. data/lib/sevgi/graphics/document/base.rb +6 -2
  13. data/lib/sevgi/graphics/document/default.rb +1 -1
  14. data/lib/sevgi/graphics/document.rb +236 -102
  15. data/lib/sevgi/graphics/element.rb +114 -31
  16. data/lib/sevgi/graphics/mixtures/call.rb +249 -88
  17. data/lib/sevgi/graphics/mixtures/core.rb +59 -20
  18. data/lib/sevgi/graphics/mixtures/duplicate.rb +49 -15
  19. data/lib/sevgi/graphics/mixtures/export.rb +52 -12
  20. data/lib/sevgi/graphics/mixtures/hatch.rb +49 -7
  21. data/lib/sevgi/graphics/mixtures/identify.rb +30 -17
  22. data/lib/sevgi/graphics/mixtures/include.rb +26 -8
  23. data/lib/sevgi/graphics/mixtures/inkscape.rb +201 -47
  24. data/lib/sevgi/graphics/mixtures/rdf.rb +59 -7
  25. data/lib/sevgi/graphics/mixtures/render.rb +52 -28
  26. data/lib/sevgi/graphics/mixtures/save.rb +79 -35
  27. data/lib/sevgi/graphics/mixtures/symbols.rb +81 -12
  28. data/lib/sevgi/graphics/mixtures/tile.rb +85 -67
  29. data/lib/sevgi/graphics/mixtures/transform.rb +68 -28
  30. data/lib/sevgi/graphics/mixtures/underscore.rb +14 -5
  31. data/lib/sevgi/graphics/mixtures/validate.rb +2 -2
  32. data/lib/sevgi/graphics/mixtures/wrappers.rb +62 -23
  33. data/lib/sevgi/graphics/mixtures.rb +15 -13
  34. data/lib/sevgi/graphics/version.rb +1 -1
  35. data/lib/sevgi/graphics.rb +58 -12
  36. metadata +7 -6
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d7f7d46384f89f99b807f703fdec7a4519d9504899fa6ad6e08133d4cf2cbba7
4
- data.tar.gz: 74822aa3dbed9221c2caebd5d163d6c2ad96427a6957e73bbba19612d930070c
3
+ metadata.gz: 753194f4865e2c8b099f788588a684a72a116004b3e56bb40cfd626a7b14043f
4
+ data.tar.gz: d443e433a8c86e4d902b31a4dadb71a244905f83aca4b67d8d56c64fdd7cb6c7
5
5
  SHA512:
6
- metadata.gz: baaeb127304fd553d7fb49f119d9179e4ff6023f24181d61f282faafdcebc0fb2174c7a076d8483044917a17987994e8fd87b60995be14354a6543ba40a1d302
7
- data.tar.gz: 53101304229cde02e71911654b14b28cffb17d69ddd4d02afa1ae513abb4d999e28aedb3b28c76849c74f452a841ae9d2cf2c4204edf2e671cbec60bd33a8463
6
+ metadata.gz: 05517e6f426cc1cd7ce0077705f147db602346bfba518500551dfce4008d669935f1fe3f21fc83b4531f33a44e4eec38e5b3ff82ccceff1bf1968a30f7ce8998
7
+ data.tar.gz: d19ec2a6d3e5e093876ab72eaa594220241f5ff8f5c744124330ee69adff71f12a67159779b9894a6bd4589ea0b3af78ad16ed768d41b422ffc18bca77248075
data/CHANGELOG.md CHANGED
@@ -5,6 +5,131 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
5
5
 
6
6
  ## Unreleased
7
7
 
8
+ ## 0.98.2 - 2026-07-25
9
+
10
+ ### Fixed
11
+
12
+ - Restored the `sevgi` CLI's main-object scope so bare toolkit calls remain available inside script helper classes.
13
+
14
+ ## 0.98.1 - 2026-07-20
15
+
16
+ ### Changed
17
+
18
+ - Changed the `sevgi` CLI to use the executor's isolated scope by default.
19
+
20
+ ### Removed
21
+
22
+ - Removed the redundant `-n` and `--nomain` CLI options; library consumers can still select `main: true` through
23
+ `Sevgi.execute` and `Sevgi.execute_file` when needed.
24
+
25
+ ## 0.98.0 - 2026-07-20
26
+
27
+ ### Added
28
+
29
+ - Added standard-input support to `sevgi`, `igves`, and `igsev`; `sevgi --as NAME` and
30
+ `Sevgi.execute_file(..., as: NAME)` supply the logical source basename used by implicit SVG, PDF, and PNG
31
+ destinations without changing physical load identity.
32
+
33
+ ## 0.97.0 - 2026-07-19
34
+
35
+ ### Added
36
+
37
+ - Added `sevgi-appendix`, containing the Sevgi agent skill and a RuboCop plugin for preserving readable `.sevgi` DSL
38
+ source alongside rubyfmt-formatted Ruby; the umbrella `sevgi` gem installs the matching Appendix version.
39
+ - Added `sevgi --skill` to report the validated path of the matching packaged agent skill for product-neutral setup.
40
+ - Added `igsev` to the umbrella gem for normalizing an SVG file through a complete SVG-to-Sevgi-to-SVG round trip.
41
+
42
+ ### Changed
43
+
44
+ - Made Homebrew the recommended complete CLI installation while retaining Bundler and focused component gems for Ruby
45
+ application dependencies.
46
+
47
+ ## 0.96.0 - 2026-07-18
48
+
49
+ ### Added
50
+
51
+ - Added exact, subtree-wide attribute omission to Derender content/file conversion, evaluation, and inclusion APIs;
52
+ `igves --omit` exposes the same behavior from the command line.
53
+ - Added `Sevgi.SVG` as the explicit namespaced form of the top-level SVG document entrypoint.
54
+ - Added the opt-in recursive `SVG::Modules` contract for callable drawing namespaces.
55
+ - Added callable drawing-module `base` blocks, document and paper registry introspection, Canvas structural equality,
56
+ renderer options on `RenderChildren`, axis translation helpers, and a CC BY RDF helper.
57
+ - Formalized non-rendering `-` metadata and `+` attribute updates as `Attributes::META_PREFIX` and
58
+ `Attributes::UPDATE_SUFFIX`; repeated Array updates now concatenate into a stable flat value.
59
+
60
+ ### Changed
61
+
62
+ - Breaking: replaced the `SVG = Sevgi::Graphics` alias with an independent SVG facade. Facade operations now use
63
+ capitalized names such as `SVG.Canvas` and `SVG.Document`; lowercase component helpers remain on
64
+ `Sevgi::Graphics`, and the stuttering `SVG.SVG` form was removed.
65
+ - Breaking: made low-level `Executor.execute` and `execute_file` runners private; consumers use
66
+ `Sevgi.execute` and `Sevgi.execute_file` while retaining the public result and error types.
67
+ - Breaking: removed the abstract `Document::Base` layer from the selectable document-profile registry; `Minimal` and
68
+ `Default` are now sibling concrete profiles, and custom profile hierarchies should derive from `Base`.
69
+ - Breaking: renamed Parallelogram segment and constraint arguments by geometric role: `base`, `side`, and
70
+ `constraint` replace axis-implying constructor names.
71
+ - Made degree-based sine and cosine exact at integer quarter turns, eliminating cardinal Geometry residue.
72
+ - Documented complete file-system failure families for file comparison, output, touch, and upward location helpers.
73
+ - Formalized generated Tile ids, positional CSS classes, template placement, and per-use callback signatures.
74
+ - Formalized String and Symbol ids across Derender selection APIs and documented file-read failures consistently.
75
+ - Breaking: made top-level `Decompile`, `Derender`, `Evaluate`, and `EvaluateChildren` consume inline SVG/XML;
76
+ file inputs now use the corresponding `File`-suffixed entrypoints.
77
+ - Breaking: replaced callable drawing module `call` block registration with argument-independent `base` blocks;
78
+ inherited bases run parent-first, and modules with one drawing method conventionally name it `call`.
79
+ - Breaking: made bracket and call notation the canonical Geometry constructors. Among Data value types, bracket notation
80
+ is public only for Point, Segment, LengthAngle, Margin, and Paper; use `.new` for Location and result carriers, and
81
+ `Stay` for traversal stop tokens.
82
+ - Breaking: aligned Point and Segment comparison with Ruby `Comparable`; malformed or unrelated comparison operands now
83
+ return nil from `<=>`.
84
+ - Breaking: executor entrypoints now return immutable `Executor::Result` values and expose only `execute` and
85
+ `execute_file`; inspect `result.error`, `result.stack`, and `result.value` instead of executor scopes or lifecycle state.
86
+ - Breaking: wrapper attributes and callable arguments use distinct channels, `With` rejects parentless receivers, and
87
+ unsupported direct constructors are private; use the documented factories for Content, concrete Geometry lined
88
+ elements, and Grid query values.
89
+ - Breaking: Canvas uses `.new` for explicit fields, `.from_paper(paper, **overrides)` for paper conversion, and
90
+ `.call`/`Graphics.canvas` for dispatch; the misleading keyword-only `.from_paper(width:, height:)` form was removed.
91
+ - Breaking: `Save`, `Write`, `PNG`, `PDF`, and Sundries native export normalize successful destinations to expanded
92
+ String paths and create missing parent directories; change-aware SVG writes still return nil when unchanged.
93
+ - Breaking: renamed `Derender.evaluate_file_children` to `evaluate_children_file`; decompiled nodes now own immutable
94
+ public state while parser, strategy, and construction plumbing remain private.
95
+ - Expanded runtime/YARD parity checks and exact contracts for inherited, extended, generated, and dynamic API surfaces,
96
+ forwarded options, error channels, path ownership, whitespace, namespaces, nil behavior, and return values.
97
+
98
+ ### Fixed
99
+
100
+ - Rejected directories from default file discovery while preserving custom locator matchers.
101
+ - Kept `Ancestral` context in non-rendering `-context` metadata instead of leaking it into SVG attributes.
102
+ - Preserved source Canvas units and names when deriving fitted Grid canvases.
103
+ - Prevented stale documentation assets from mixing old layouts with new HTML, and moved sidebar navigation into the
104
+ tablet menu at 1024px and below.
105
+ - Normalized numeric slots owned by path, shape, transform, tile, and Inkscape page helpers to finite SVG number spelling;
106
+ arbitrary user-supplied attributes remain untouched.
107
+ - Made named callable wrapper and symbol ids stable while omitting defaults for anonymous modules, and indexed every
108
+ rendered id value, including false and numeric values, through its serialized string.
109
+ - Preserved signed Geometry constraint directions, rejected invalid sweep/export channels through Sevgi error families,
110
+ and validated raw output paths before expansion or rendering.
111
+ - Made callable module configuration copy-owned and freeze-aware, document subclasses inherit their nearest profile, and
112
+ false executor boot receivers remain explicit rather than defaulting to an internal scope.
113
+ - Made element trees, attributes, identifiers, locator results, document profiles, Derender nodes, shell results, and
114
+ executor results retain owned immutable snapshots where their public contracts promise value semantics.
115
+ - Corrected Shell combined-output separators, nil export density errors, document render-option routing, executor source
116
+ snapshots, selected-node namespace/whitespace documentation, and Standard character-data validation.
117
+
118
+ ### Removed
119
+
120
+ - Removed public access to pluralization tables and the internal SVG save extension; pluralization rules are now deeply
121
+ immutable.
122
+ - Removed public documentation and constant access for command-line implementation modules; the `sevgi` and `igves`
123
+ executables remain unchanged.
124
+ - Removed eager loading and public documentation of private Showcase build/test support; explicit support entrypoints
125
+ now keep the harness under the Showcase namespace.
126
+ - Removed public access to document profile name normalizers; registry operations retain them as private plumbing.
127
+ - Removed the redundant `Margin.margin` constructor; use canonical bracket notation.
128
+ - Removed public executor orchestration, obsolete callable-module hooks, the old public attribute syntax constants, and
129
+ direct construction of abstract or internally wired Content, Element, and Grid query types.
130
+ - Removed accidental public access to abstract Lined factories, internal element-name/export maps, result/location
131
+ bracket constructors, and direct Stop construction.
132
+
8
133
  ## 0.95.0 - 2026-07-11
9
134
 
10
135
  ### Security
data/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Sevgi Graphics
2
2
 
3
- Core SVG DSL, document profiles, and rendering behavior.
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(width: 3, height: 5) }
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.0 and the current development Ruby from `.ruby-version`.
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
- None beyond Ruby and this gem's Ruby dependencies.
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
- # Internal store syntax; not part of the SVG DSL command surface.
8
- ATTRIBUTE_INTERNAL_PREFIX = "-"
9
-
10
- # Attribute suffix that merges new values into an existing attribute.
11
- ATTRIBUTE_UPDATE_SUFFIX = "+"
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,7 +25,7 @@ module Sevgi
20
25
  # @param given [String, Symbol] attribute name
21
26
  # @return [Boolean]
22
27
  def internal?(given)
23
- (@internal ||= {})[given] ||= key(given).start_with?(ATTRIBUTE_INTERNAL_PREFIX)
28
+ (@internal ||= {})[given] ||= key(given).start_with?(Attributes::META_PREFIX)
24
29
  end
25
30
 
26
31
  # Returns the normalized attribute id.
@@ -28,7 +33,7 @@ module Sevgi
28
33
  # @return [Symbol]
29
34
  def id(given)
30
35
  (@id ||= {})[given] ||= begin
31
- name = updateable?(given) ? key(given).delete_suffix(ATTRIBUTE_UPDATE_SUFFIX) : key(given)
36
+ name = updateable?(given) ? key(given).delete_suffix(Attributes::UPDATE_SUFFIX) : key(given)
32
37
  XML.name(name, context: "XML attribute name") unless internal?(given)
33
38
  name.to_sym
34
39
  end
@@ -38,7 +43,7 @@ module Sevgi
38
43
  # @param given [String, Symbol] attribute name
39
44
  # @return [Boolean]
40
45
  def updateable?(given)
41
- (@updateable ||= {})[given] ||= key(given).end_with?(ATTRIBUTE_UPDATE_SUFFIX)
46
+ (@updateable ||= {})[given] ||= key(given).end_with?(Attributes::UPDATE_SUFFIX)
42
47
  end
43
48
 
44
49
  private
@@ -125,6 +130,25 @@ module Sevgi
125
130
  # @api private
126
131
  def self.capture(value, normalize_keys: false) = Snapshot.capture(value, normalize_keys:)
127
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
+
128
152
  # Returns the text form used for an XML attribute value before escaping.
129
153
  # @param value [Object] attribute value
130
154
  # @return [String]
@@ -144,7 +168,8 @@ module Sevgi
144
168
  XML.text(text, context: "XML attribute value")
145
169
  end
146
170
 
147
- # Mutable SVG attribute store with Sevgi update syntax.
171
+ # Mutable backing store for SVG attributes.
172
+ # @api private
148
173
  class Store
149
174
  # Creates an attribute store from recursively owned snapshots. Mutable non-container leaves are stringified
150
175
  # once; later caller mutation cannot change the store.
@@ -164,22 +189,13 @@ module Sevgi
164
189
  # @raise [Sevgi::ArgumentError] when input is not a Hash or a name/value is invalid, cyclic, colliding, or cannot
165
190
  # be converted
166
191
  def import(attributes)
167
- ArgumentError.("Attributes must be imported from a Hash") unless attributes.is_a?(::Hash)
168
-
169
- hash = attributes.each_with_object({}) do |(key, value), captured|
170
- next if value.nil?
192
+ updated = @store.dup
193
+ entries(attributes).each { |entry| assign(updated, *entry) }
171
194
 
172
- id = Attribute.id(key)
173
- ArgumentError.("Attribute names collide after normalization: #{id}") if captured.key?(id)
174
-
175
- captured[id] = Attribute.capture(value, normalize_keys: value.is_a?(::Hash))
176
- end
177
-
178
- @store.merge!(hash)
195
+ @store.replace(updated)
179
196
  end
180
197
 
181
- # Returns a live stored attribute value. Mutating a returned container intentionally mutates this store; rendering
182
- # revalidates the resulting value.
198
+ # Returns a stored attribute value for the public facade.
183
199
  # @param key [String, Symbol] attribute key
184
200
  # @return [Object, nil]
185
201
  # @raise [Sevgi::ArgumentError] when key is not a valid XML attribute name
@@ -199,7 +215,7 @@ module Sevgi
199
215
 
200
216
  id = Attribute.id(key)
201
217
  value = Attribute.capture(value, normalize_keys: value.is_a?(::Hash))
202
- @store[id] = @store.key?(id) && Attribute.updateable?(key) ? update(id, value) : value
218
+ @store[id] = @store.key?(id) && Attribute.updateable?(key) ? update(@store[id], value) : value
203
219
  end
204
220
 
205
221
  # Deletes an attribute by normalized key.
@@ -210,13 +226,13 @@ module Sevgi
210
226
  @store.delete(Attribute.id(key))
211
227
  end
212
228
 
213
- # Returns public attributes ready for rendering. Nested values remain live store values.
229
+ # Returns rendering attributes, excluding non-rendering metadata. Nested values remain live store values.
214
230
  # @return [Hash] shallow attribute view
215
231
  def export
216
232
  hash = @store.reject { |id, _| Attribute.internal?(id) }
217
233
  return hash unless hash.key?(:id)
218
234
 
219
- # A small aesthetic touch: always keep the id attribute first
235
+ # Keep id first for stable, readable SVG output.
220
236
  {id: hash.delete(:id), **hash}
221
237
  end
222
238
 
@@ -229,9 +245,8 @@ module Sevgi
229
245
  end
230
246
 
231
247
  # Copies the attribute store with recursively independent values.
232
- # @param original [Sevgi::Graphics::Attribute::Store] store to copy
248
+ # @param original [Sevgi::Graphics::Attributes] store to copy
233
249
  # @return [void]
234
- # @raise [Sevgi::ArgumentError] when live stored values became cyclic or invalid
235
250
  def initialize_copy(original)
236
251
  @store = {}
237
252
  original.store.each { |key, value| @store[key] = Attribute.capture(value) }
@@ -239,15 +254,14 @@ module Sevgi
239
254
  super
240
255
  end
241
256
 
242
- # Returns public attribute names.
257
+ # Returns rendering attribute names, excluding non-rendering metadata.
243
258
  # @return [Array<Symbol>]
244
259
  def list
245
260
  export.keys
246
261
  end
247
262
 
248
- # Returns the live internal attribute Hash. Mutating it intentionally mutates this store; rendering revalidates
249
- # names and values.
250
- # @return [Hash] live internal store
263
+ # Returns the internal attribute and metadata Hash.
264
+ # @return [Hash] backing store
251
265
  def to_h
252
266
  @store
253
267
  end
@@ -268,12 +282,32 @@ module Sevgi
268
282
  UPDATER = {
269
283
  ::String => proc { |old_value, new_value| [old_value, new_value].reject(&:empty?).join(" ") },
270
284
  ::Symbol => proc { |old_value, new_value| [old_value, new_value].reject(&:empty?).join(" ").to_sym },
271
- ::Array => proc { |old_value, new_value| [old_value, new_value] },
272
- ::Hash => proc { |old_value, new_value| old_value.merge(new_value.transform_keys(&:to_sym)) }
285
+ ::Array => proc { |old_value, new_value| old_value + new_value },
286
+ ::Hash => proc { |old_value, new_value| old_value.merge(new_value) }
273
287
  }.freeze
274
288
 
275
- def update(id, new_value)
276
- (old_value = @store[id]).nil? ? new_value : UPDATER[new_value.class].call(*sanitized(old_value, new_value))
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))
277
311
  end
278
312
 
279
313
  def sanitized(old_value, new_value)
@@ -292,7 +326,96 @@ module Sevgi
292
326
  end
293
327
  end
294
328
 
295
- # Public alias for the SVG attribute store.
296
- Attributes = Attribute::Store
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
297
420
  end
298
421
  end