sevgi-graphics 0.95.0 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +221 -2
  3. data/README.md +12 -9
  4. data/lib/sevgi/graphics/attribute.rb +166 -45
  5. data/lib/sevgi/graphics/auxiliary/canvas.rb +100 -43
  6. data/lib/sevgi/graphics/auxiliary/content.rb +56 -47
  7. data/lib/sevgi/graphics/auxiliary/margin.rb +19 -12
  8. data/lib/sevgi/graphics/auxiliary/paper.rb +74 -49
  9. data/lib/sevgi/graphics/auxiliary/path.rb +44 -0
  10. data/lib/sevgi/graphics/auxiliary/scalar.rb +36 -7
  11. data/lib/sevgi/graphics/auxiliary.rb +1 -0
  12. data/lib/sevgi/graphics/document/base.rb +6 -2
  13. data/lib/sevgi/graphics/document/default.rb +1 -1
  14. data/lib/sevgi/graphics/document.rb +239 -117
  15. data/lib/sevgi/graphics/element.rb +132 -34
  16. data/lib/sevgi/graphics/mixtures/call.rb +234 -88
  17. data/lib/sevgi/graphics/mixtures/core.rb +67 -27
  18. data/lib/sevgi/graphics/mixtures/duplicate.rb +47 -25
  19. data/lib/sevgi/graphics/mixtures/export.rb +54 -12
  20. data/lib/sevgi/graphics/mixtures/hatch.rb +49 -7
  21. data/lib/sevgi/graphics/mixtures/identify.rb +30 -17
  22. data/lib/sevgi/graphics/mixtures/include.rb +26 -8
  23. data/lib/sevgi/graphics/mixtures/inkscape.rb +214 -47
  24. data/lib/sevgi/graphics/mixtures/rdf.rb +59 -7
  25. data/lib/sevgi/graphics/mixtures/render.rb +60 -120
  26. data/lib/sevgi/graphics/mixtures/save.rb +79 -35
  27. data/lib/sevgi/graphics/mixtures/symbols.rb +81 -12
  28. data/lib/sevgi/graphics/mixtures/tile.rb +85 -67
  29. data/lib/sevgi/graphics/mixtures/transform.rb +89 -30
  30. data/lib/sevgi/graphics/mixtures/underscore.rb +16 -7
  31. data/lib/sevgi/graphics/mixtures/validate.rb +2 -2
  32. data/lib/sevgi/graphics/mixtures/wrappers.rb +111 -23
  33. data/lib/sevgi/graphics/mixtures.rb +15 -13
  34. data/lib/sevgi/graphics/version.rb +1 -1
  35. data/lib/sevgi/graphics/xml.rb +4 -9
  36. data/lib/sevgi/graphics.rb +69 -18
  37. metadata +7 -6
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d7f7d46384f89f99b807f703fdec7a4519d9504899fa6ad6e08133d4cf2cbba7
4
- data.tar.gz: 74822aa3dbed9221c2caebd5d163d6c2ad96427a6957e73bbba19612d930070c
3
+ metadata.gz: 7965ed7aa4c5960cca6a4ed927258d13a5b5be7d9e327f4bf7da5b3480b66b1f
4
+ data.tar.gz: 88224f9179c50ceca0ae8e4ee7a92e5d8a6a0d2634ec3912356dc5e5d2d27053
5
5
  SHA512:
6
- metadata.gz: baaeb127304fd553d7fb49f119d9179e4ff6023f24181d61f282faafdcebc0fb2174c7a076d8483044917a17987994e8fd87b60995be14354a6543ba40a1d302
7
- data.tar.gz: 53101304229cde02e71911654b14b28cffb17d69ddd4d02afa1ae513abb4d999e28aedb3b28c76849c74f452a841ae9d2cf2c4204edf2e671cbec60bd33a8463
6
+ metadata.gz: 063d2529fc5869ef25f27ed44b927d28ae3ba6c48aca2365c0be78dbda65b895eb065b0c61ca960e66f955e0a8dbea8cc7b581ddf9ad4ec437c727e0541b9e43
7
+ data.tar.gz: fa1a68f7962e62bd991462e87bbbae0778321076af509ca2ede6b179a5eb1c133d707c3450d46688645ddf37050df49c63df608236fc1cfb3bcc811d20ff25a5
data/CHANGELOG.md CHANGED
@@ -3,7 +3,226 @@
3
3
  This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html) and follows
4
4
  [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
5
5
 
6
- ## Unreleased
6
+ ## 1.0.0 - 2026-09-16
7
+
8
+ ### Added
9
+
10
+ - Added Hatch and Orb Showcase examples and displayed both arc lengths in the Arc example.
11
+
12
+ ### Changed
13
+
14
+ - Matched documentation page widths to the homepage Showcase.
15
+ - Aligned Derender user guidance with UTF-8 normalization and safe subtree selection rules.
16
+ - Clarified the packaged skill's script invocation, subtree copying, and Derender selection and encoding rules.
17
+ - Allowed Canvas and Paper values as the first `SVG` argument when using the default document profile.
18
+ - Made element `dup` and `clone` own independent subtrees while retaining IDs and Ruby copy semantics.
19
+ - Made Segment equality compare length and direction; ordering still compares length only.
20
+ - Limited Tile cell, row, and column queries to the requested geometry and accelerated XML character validation.
21
+ - Simplified the first-run guide and aligned library constructor examples.
22
+ - Updated the packaged skill for canvas origins, alignment, Paper lookup, curved geometry, and export constraints.
23
+ - Revised DSL catalog examples and packaged Sevgi skill guidance to use statement-style calls and show inspection results.
24
+ - Refined Showcase example tabs, made Ruby and XML views fill their panels, and hid executable preambles from Ruby views.
25
+ - Documented non-rendering element data and clarified the related DSL catalog examples.
26
+ - Refined Showcase heading spacing, page-title colors, and DSL catalog cards across light and dark themes.
27
+ - Removed an obsolete ruler example and unused site code.
28
+ - Kept the Squared and Copperplate guidesheet examples together at the end of the Showcase.
29
+ - Updated the packaged skill to avoid duplicate semantic shapes and unnecessary paint passes.
30
+
31
+ ### Fixed
32
+
33
+ - Kept horizontal scrollbars visible in Showcase example code panels.
34
+ - Read Derender input in its declared XML encoding and normalized generated source, XML declarations, and markup to UTF-8.
35
+ - Preserved open-path endpoints and derived edges independently of display precision.
36
+ - Kept near-axis and near-parallel line equations and intersections independent of angle and slope rounding.
37
+ - Corrected the Showcase social image dimensions.
38
+ - Added SVG namespaces to standalone drawing examples and regenerated their outputs.
39
+ - Made failed Sevgi Rake tasks stop dependent tasks and captured required-library failures in executor results.
40
+ - Removed executor signal-handler replacement so the host and shell retain SIGINT ownership.
41
+ - Escaped late export CSS as XML text and inserted it only at a supported final root closing tag.
42
+ - Preserved Derender processing instructions and rejected custom entity references and unsupported trailing document nodes before inclusion.
43
+ - Included both box origins in `Align`, unified Tile index validation, and isolated custom numeric attribute snapshots.
44
+ - Prevented avoidable overflow and underflow in point distances.
45
+ - Corrected browser source-panel expectations and added executable documentation, YARD, and packaged-skill checks.
46
+ - Rendered inline code in DSL catalog summaries and kept linked task themes visible below the sticky header.
47
+ - Restored Showcase tab icons with embedded Font Awesome SVGs.
48
+
49
+ ## 0.100.0 - 2026-09-09
50
+
51
+ ### Added
52
+
53
+ - Added `ArcTo` and `ArcBy` for SVG-native elliptical arc paths.
54
+ - Added Geometry `Arc`, `Ellipse`, and `Circle` with bounds, length, predicates, affine transformations, and
55
+ linear/quadratic intersections. Closed ellipses and circles support sweeps and hatching.
56
+ - Added Geometry `Point.midpoint`, `Element#closed?`, lined `vertices`, open-path traversal, and `Rect#center`.
57
+ - Added Geometry aggregate bounding boxes through `Operation.box`, point collinearity through `Point.collinear?`, and
58
+ polygon `simple?`, `convex?`, and `concave?` predicates.
59
+ - Added Protractor and Arc examples and documented the drawing and calculation APIs.
60
+
61
+ ### Changed
62
+
63
+ - Removed redundant parent scans while duplicating element subtrees.
64
+ - Reused source bounds across Tile cells to reduce repeated calculations and object allocations.
65
+
66
+ ### Fixed
67
+
68
+ - Preserved Showcase card HTML through Markdown rendering so all examples and their tabs remain visible.
69
+ - Preserved XML declarations, pre-root comments, CDATA sections, inline comments, and namespace-only leaves in
70
+ Derender output and evaluation.
71
+ - Limited PDF stamp recoloring to replaced text and carried graphics state across a page's content streams.
72
+ - Corrected decimal interval fitting and made Grid point queries honor the active numeric precision.
73
+ - Prevented implicit shell interpretation of single executable arguments in `F.sh` and `F.sh!`, including paths with spaces or shell metacharacters.
74
+ - Preserved inherited namespaces on detached Derender nodes in generated source and direct evaluation, including imports through `EvaluateChildren` and `IncludeChildren`.
75
+ - Prevented sweep and Hatch lines from crossing the exterior between two tangent vertices of a concave polygon.
76
+ - Aligned Tile cells with their declared bounds for circles, rotated shapes, and polygons whose first vertex differs from their bounding-box origin.
77
+ - Limited `Load` chains to 128 active sources with `Executor::LoadDepthError`, preserving cycle detection and nested load diagnostics.
78
+ - Rejected nil and false aggregate-box inputs with the documented geometry error.
79
+ - Restored precision-aware rejection of zero-length Triangle sides.
80
+ - Made point-set collinearity independent of input order and removed eager polygon edge-pair storage.
81
+ - Applied the collinearity tolerance to every three-point subset, removing coordinate-axis-dependent baseline selection.
82
+
83
+ ## 0.99.0 - 2026-08-09
84
+
85
+ ### Added
86
+
87
+ - Added `SVG.Module` for building anonymous callable drawing modules with their contract installed before definition.
88
+ - Added responsive Mermaid diagrams for the Derender round trip and SVG validation lifecycle.
89
+
90
+ ### Changed
91
+
92
+ - Reorganized the documentation around getting started, usage, composition, documents, layout, output, and examples.
93
+ - Documented how the `igves` and `igsev` command names reflect their conversion directions.
94
+ - Normalized DSL catalog provider labels to lowercase and simplified polyfill-backed editor entries to `inkscape`.
95
+
96
+ ### Fixed
97
+
98
+ - Allowed A5-on-A4 pdfcpu conversion to replace an existing output file.
99
+ - Pre-rendered Mermaid diagrams as inline SVG instead of parsing them in site visitors' browsers.
100
+ - Removed duplicate provider and context labels from DSL catalog entries.
101
+
102
+ ## 0.98.2 - 2026-07-25
103
+
104
+ ### Fixed
105
+
106
+ - Restored the `sevgi` CLI's main-object scope so bare toolkit calls remain available inside script helper classes.
107
+
108
+ ## 0.98.1 - 2026-07-20
109
+
110
+ ### Changed
111
+
112
+ - Changed the `sevgi` CLI to use the executor's isolated scope by default.
113
+
114
+ ### Removed
115
+
116
+ - Removed the redundant `-n` and `--nomain` CLI options; library consumers can still select `main: true` through
117
+ `Sevgi.execute` and `Sevgi.execute_file` when needed.
118
+
119
+ ## 0.98.0 - 2026-07-20
120
+
121
+ ### Added
122
+
123
+ - Added standard-input support to `sevgi`, `igves`, and `igsev`; `sevgi --as NAME` and
124
+ `Sevgi.execute_file(..., as: NAME)` supply the logical source basename used by implicit SVG, PDF, and PNG
125
+ destinations without changing physical load identity.
126
+
127
+ ## 0.97.0 - 2026-07-19
128
+
129
+ ### Added
130
+
131
+ - Added `sevgi-appendix`, containing the Sevgi agent skill and a RuboCop plugin for preserving readable `.sevgi` DSL
132
+ source alongside rubyfmt-formatted Ruby; the umbrella `sevgi` gem installs the matching Appendix version.
133
+ - Added `sevgi --skill` to report the validated path of the matching packaged agent skill for product-neutral setup.
134
+ - Added `igsev` to the umbrella gem for normalizing an SVG file through a complete SVG-to-Sevgi-to-SVG round trip.
135
+
136
+ ### Changed
137
+
138
+ - Made Homebrew the recommended complete CLI installation while retaining Bundler and focused component gems for Ruby
139
+ application dependencies.
140
+
141
+ ## 0.96.0 - 2026-07-18
142
+
143
+ ### Added
144
+
145
+ - Added exact, subtree-wide attribute omission to Derender content/file conversion, evaluation, and inclusion APIs;
146
+ `igves --omit` exposes the same behavior from the command line.
147
+ - Added `Sevgi.SVG` as the explicit namespaced form of the top-level SVG document entrypoint.
148
+ - Added the opt-in recursive `SVG::Modules` contract for callable drawing namespaces.
149
+ - Added callable drawing-module `base` blocks, document and paper registry introspection, Canvas structural equality,
150
+ renderer options on `RenderChildren`, axis translation helpers, and a CC BY RDF helper.
151
+ - Formalized non-rendering `-` metadata and `+` attribute updates as `Attributes::META_PREFIX` and
152
+ `Attributes::UPDATE_SUFFIX`; repeated Array updates now concatenate into a stable flat value.
153
+
154
+ ### Changed
155
+
156
+ - Breaking: replaced the `SVG = Sevgi::Graphics` alias with an independent SVG facade. Facade operations now use
157
+ capitalized names such as `SVG.Canvas` and `SVG.Document`; lowercase component helpers remain on
158
+ `Sevgi::Graphics`, and the stuttering `SVG.SVG` form was removed.
159
+ - Breaking: made low-level `Executor.execute` and `execute_file` runners private; consumers use
160
+ `Sevgi.execute` and `Sevgi.execute_file` while retaining the public result and error types.
161
+ - Breaking: removed the abstract `Document::Base` layer from the selectable document-profile registry; `Minimal` and
162
+ `Default` are now sibling concrete profiles, and custom profile hierarchies should derive from `Base`.
163
+ - Breaking: renamed Parallelogram segment and constraint arguments by geometric role: `base`, `side`, and
164
+ `constraint` replace axis-implying constructor names.
165
+ - Made degree-based sine and cosine exact at integer quarter turns, eliminating cardinal Geometry residue.
166
+ - Documented complete file-system failure families for file comparison, output, touch, and upward location helpers.
167
+ - Formalized generated Tile ids, positional CSS classes, template placement, and per-use callback signatures.
168
+ - Formalized String and Symbol ids across Derender selection APIs and documented file-read failures consistently.
169
+ - Breaking: made top-level `Decompile`, `Derender`, `Evaluate`, and `EvaluateChildren` consume inline SVG/XML;
170
+ file inputs now use the corresponding `File`-suffixed entrypoints.
171
+ - Breaking: replaced callable drawing module `call` block registration with argument-independent `base` blocks;
172
+ inherited bases run parent-first, and modules with one drawing method conventionally name it `call`.
173
+ - Breaking: made bracket and call notation the canonical Geometry constructors. Among Data value types, bracket notation
174
+ is public only for Point, Segment, LengthAngle, Margin, and Paper; use `.new` for Location and result carriers, and
175
+ `Stay` for traversal stop tokens.
176
+ - Breaking: aligned Point and Segment comparison with Ruby `Comparable`; malformed or unrelated comparison operands now
177
+ return nil from `<=>`.
178
+ - Breaking: executor entrypoints now return immutable `Executor::Result` values and expose only `execute` and
179
+ `execute_file`; inspect `result.error`, `result.stack`, and `result.value` instead of executor scopes or lifecycle state.
180
+ - Breaking: wrapper attributes and callable arguments use distinct channels, `With` rejects parentless receivers, and
181
+ unsupported direct constructors are private; use the documented factories for Content, concrete Geometry lined
182
+ elements, and Grid query values.
183
+ - Breaking: Canvas uses `.new` for explicit fields, `.from_paper(paper, **overrides)` for paper conversion, and
184
+ `.call`/`Graphics.canvas` for dispatch; the misleading keyword-only `.from_paper(width:, height:)` form was removed.
185
+ - Breaking: `Save`, `Write`, `PNG`, `PDF`, and Sundries native export normalize successful destinations to expanded
186
+ String paths and create missing parent directories; change-aware SVG writes still return nil when unchanged.
187
+ - Breaking: renamed `Derender.evaluate_file_children` to `evaluate_children_file`; decompiled nodes now own immutable
188
+ public state while parser, strategy, and construction plumbing remain private.
189
+ - Expanded runtime/YARD parity checks and exact contracts for inherited, extended, generated, and dynamic API surfaces,
190
+ forwarded options, error channels, path ownership, whitespace, namespaces, nil behavior, and return values.
191
+
192
+ ### Fixed
193
+
194
+ - Rejected directories from default file discovery while preserving custom locator matchers.
195
+ - Kept `Ancestral` context in non-rendering `-context` metadata instead of leaking it into SVG attributes.
196
+ - Preserved source Canvas units and names when deriving fitted Grid canvases.
197
+ - Prevented stale documentation assets from mixing old layouts with new HTML, and moved sidebar navigation into the
198
+ tablet menu at 1024px and below.
199
+ - Normalized numeric slots owned by path, shape, transform, tile, and Inkscape page helpers to finite SVG number spelling;
200
+ arbitrary user-supplied attributes remain untouched.
201
+ - Made named callable wrapper and symbol ids stable while omitting defaults for anonymous modules, and indexed every
202
+ rendered id value, including false and numeric values, through its serialized string.
203
+ - Preserved signed Geometry constraint directions, rejected invalid sweep/export channels through Sevgi error families,
204
+ and validated raw output paths before expansion or rendering.
205
+ - Made callable module configuration copy-owned and freeze-aware, document subclasses inherit their nearest profile, and
206
+ false executor boot receivers remain explicit rather than defaulting to an internal scope.
207
+ - Made element trees, attributes, identifiers, locator results, document profiles, Derender nodes, shell results, and
208
+ executor results retain owned immutable snapshots where their public contracts promise value semantics.
209
+ - Corrected Shell combined-output separators, nil export density errors, document render-option routing, executor source
210
+ snapshots, selected-node namespace/whitespace documentation, and Standard character-data validation.
211
+
212
+ ### Removed
213
+
214
+ - Removed public access to pluralization tables and the internal SVG save extension; pluralization rules are now deeply
215
+ immutable.
216
+ - Removed public documentation and constant access for command-line implementation modules; the `sevgi` and `igves`
217
+ executables remain unchanged.
218
+ - Removed eager loading and public documentation of private Showcase build/test support; explicit support entrypoints
219
+ now keep the harness under the Showcase namespace.
220
+ - Removed public access to document profile name normalizers; registry operations retain them as private plumbing.
221
+ - Removed the redundant `Margin.margin` constructor; use canonical bracket notation.
222
+ - Removed public executor orchestration, obsolete callable-module hooks, the old public attribute syntax constants, and
223
+ direct construction of abstract or internally wired Content, Element, and Grid query types.
224
+ - Removed accidental public access to abstract Lined factories, internal element-name/export maps, result/location
225
+ bracket constructors, and direct Stop construction.
7
226
 
8
227
  ## 0.95.0 - 2026-07-11
9
228
 
@@ -87,7 +306,7 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
87
306
 
88
307
  ### Changed
89
308
 
90
- - Added the checker board example to the documentation showcase flow.
309
+ - Added the checkerboard example to the documentation showcase flow.
91
310
  - Consolidated Victor Book attribution for adapted showcase examples into a single documentation note.
92
311
 
93
312
  ### Fixed
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,24 @@ require "sevgi/graphics"
17
17
  ## Example
18
18
 
19
19
  ```ruby
20
- doc = Sevgi::Graphics.SVG(:minimal) { rect(width: 3, height: 5) }
21
- doc.call
20
+ doc = Sevgi::Graphics.SVG(:minimal) { rect width: 3, height: 5 }
21
+ puts doc.Render
22
22
  ```
23
23
 
24
+ This focused gem exposes `Sevgi::Graphics.SVG` and lowercase component constructors. Install the umbrella `sevgi` gem
25
+ when you need the global `SVG` facade or the `.sevgi` script runner.
26
+
24
27
  ## Ruby compatibility
25
28
 
26
- Requires Ruby 3.4.0 or newer. CI verifies Ruby 3.4.0 and the current development Ruby from `.ruby-version`.
29
+ Requires Ruby 3.4.0 or newer. CI verifies the current Ruby 3.4 release and the development Ruby from `.ruby-version`.
27
30
 
28
31
  ## Native prerequisites
29
32
 
30
- None beyond Ruby and this gem's Ruby dependencies.
33
+ This gem needs only Ruby and its Ruby dependencies.
31
34
 
32
35
  ## Links
33
36
 
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
37
+ - Documentation: <https://sevgi.roktas.dev>
38
+ - API documentation: <https://www.rubydoc.info/gems/sevgi-graphics>
39
+ - Source: <https://github.com/roktas/sevgi/tree/main/graphics>
40
+ - 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
@@ -55,6 +60,9 @@ module Sevgi
55
60
  # Owned mutable snapshots for values entering an attribute store.
56
61
  # @api private
57
62
  module Snapshot
63
+ SCALARS = [::NilClass, ::TrueClass, ::FalseClass, ::Symbol, ::Integer, ::Float, ::Rational, ::Complex].freeze
64
+ private_constant :SCALARS
65
+
58
66
  class << self
59
67
  def capture(value, normalize_keys: false, seen: {}.compare_by_identity)
60
68
  case value
@@ -85,12 +93,7 @@ module Sevgi
85
93
 
86
94
  def capture_value(value)
87
95
  text = XML.text(value, context: "XML attribute value")
88
- case value
89
- when ::Numeric, ::Symbol, ::NilClass, ::TrueClass, ::FalseClass
90
- value
91
- else
92
- text
93
- end
96
+ SCALARS.include?(value.class) ? value : text
94
97
  end
95
98
 
96
99
  def nested(value, seen)
@@ -125,6 +128,25 @@ module Sevgi
125
128
  # @api private
126
129
  def self.capture(value, normalize_keys: false) = Snapshot.capture(value, normalize_keys:)
127
130
 
131
+ # Returns normalized owned attributes with defaults for absent names.
132
+ # @param attributes [Hash] source attributes
133
+ # @param defaults [Hash] values inserted only when their normalized names are absent
134
+ # @return [Hash{Symbol => Object}] normalized owned attributes
135
+ # @raise [Sevgi::ArgumentError] when input contains an invalid, colliding, or unsupported attribute
136
+ # @api private
137
+ def self.defaults(attributes, **defaults)
138
+ attributes = Attributes.new(attributes)
139
+ defaults.each { |name, value| attributes[name] = value unless attributes.has?(name) }
140
+ attributes.to_h
141
+ end
142
+
143
+ # Returns normalized owned attributes.
144
+ # @param attributes [Hash] source attributes
145
+ # @return [Hash{Symbol => Object}] normalized owned attributes
146
+ # @raise [Sevgi::ArgumentError] when input contains an invalid, colliding, or unsupported attribute
147
+ # @api private
148
+ def self.normalize(attributes) = Attributes.new(attributes).to_h
149
+
128
150
  # Returns the text form used for an XML attribute value before escaping.
129
151
  # @param value [Object] attribute value
130
152
  # @return [String]
@@ -144,10 +166,11 @@ module Sevgi
144
166
  XML.text(text, context: "XML attribute value")
145
167
  end
146
168
 
147
- # Mutable SVG attribute store with Sevgi update syntax.
169
+ # Mutable backing store for SVG attributes.
170
+ # @api private
148
171
  class Store
149
172
  # Creates an attribute store from recursively owned snapshots. Mutable non-container leaves are stringified
150
- # once; later caller mutation cannot change the store.
173
+ # once. Later caller mutation cannot change the store.
151
174
  # @param attributes [Hash] initial attributes
152
175
  # @return [void]
153
176
  # @raise [Sevgi::ArgumentError] when input is not a Hash or a name/value is invalid, cyclic, colliding, or cannot
@@ -164,22 +187,13 @@ module Sevgi
164
187
  # @raise [Sevgi::ArgumentError] when input is not a Hash or a name/value is invalid, cyclic, colliding, or cannot
165
188
  # be converted
166
189
  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?
171
-
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
190
+ updated = @store.dup
191
+ entries(attributes).each { |entry| assign(updated, *entry) }
177
192
 
178
- @store.merge!(hash)
193
+ @store.replace(updated)
179
194
  end
180
195
 
181
- # Returns a live stored attribute value. Mutating a returned container intentionally mutates this store; rendering
182
- # revalidates the resulting value.
196
+ # Returns a stored attribute value for the public facade.
183
197
  # @param key [String, Symbol] attribute key
184
198
  # @return [Object, nil]
185
199
  # @raise [Sevgi::ArgumentError] when key is not a valid XML attribute name
@@ -189,7 +203,7 @@ module Sevgi
189
203
 
190
204
  # Assigns a recursively owned attribute snapshot. Mutable non-container leaves are stringified once.
191
205
  # @param key [String, Symbol] attribute key
192
- # @param value [Object, nil] attribute value; nil is ignored
206
+ # @param value [Object, nil] attribute value. Nil is ignored
193
207
  # @return [Object, nil] stored snapshot or nil
194
208
  # @raise [Sevgi::ArgumentError] when update syntax receives incompatible values
195
209
  # @raise [Sevgi::ArgumentError] when update syntax receives an unsupported value type
@@ -199,7 +213,7 @@ module Sevgi
199
213
 
200
214
  id = Attribute.id(key)
201
215
  value = Attribute.capture(value, normalize_keys: value.is_a?(::Hash))
202
- @store[id] = @store.key?(id) && Attribute.updateable?(key) ? update(id, value) : value
216
+ @store[id] = @store.key?(id) && Attribute.updateable?(key) ? update(@store[id], value) : value
203
217
  end
204
218
 
205
219
  # Deletes an attribute by normalized key.
@@ -210,13 +224,13 @@ module Sevgi
210
224
  @store.delete(Attribute.id(key))
211
225
  end
212
226
 
213
- # Returns public attributes ready for rendering. Nested values remain live store values.
227
+ # Returns rendering attributes, excluding non-rendering metadata. Nested values remain live store values.
214
228
  # @return [Hash] shallow attribute view
215
229
  def export
216
230
  hash = @store.reject { |id, _| Attribute.internal?(id) }
217
231
  return hash unless hash.key?(:id)
218
232
 
219
- # A small aesthetic touch: always keep the id attribute first
233
+ # Keep id first for stable, readable SVG output.
220
234
  {id: hash.delete(:id), **hash}
221
235
  end
222
236
 
@@ -229,9 +243,8 @@ module Sevgi
229
243
  end
230
244
 
231
245
  # Copies the attribute store with recursively independent values.
232
- # @param original [Sevgi::Graphics::Attribute::Store] store to copy
246
+ # @param original [Sevgi::Graphics::Attributes] store to copy
233
247
  # @return [void]
234
- # @raise [Sevgi::ArgumentError] when live stored values became cyclic or invalid
235
248
  def initialize_copy(original)
236
249
  @store = {}
237
250
  original.store.each { |key, value| @store[key] = Attribute.capture(value) }
@@ -239,15 +252,14 @@ module Sevgi
239
252
  super
240
253
  end
241
254
 
242
- # Returns public attribute names.
255
+ # Returns rendering attribute names, excluding non-rendering metadata.
243
256
  # @return [Array<Symbol>]
244
257
  def list
245
258
  export.keys
246
259
  end
247
260
 
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
261
+ # Returns the internal attribute and metadata Hash.
262
+ # @return [Hash] backing store
251
263
  def to_h
252
264
  @store
253
265
  end
@@ -268,12 +280,32 @@ module Sevgi
268
280
  UPDATER = {
269
281
  ::String => proc { |old_value, new_value| [old_value, new_value].reject(&:empty?).join(" ") },
270
282
  ::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)) }
283
+ ::Array => proc { |old_value, new_value| old_value + new_value },
284
+ ::Hash => proc { |old_value, new_value| old_value.merge(new_value) }
273
285
  }.freeze
274
286
 
275
- def update(id, new_value)
276
- (old_value = @store[id]).nil? ? new_value : UPDATER[new_value.class].call(*sanitized(old_value, new_value))
287
+ def assign(store, key, id, value)
288
+ store[id] = store.key?(id) && Attribute.updateable?(key) ? update(store[id], value) : value
289
+ end
290
+
291
+ def entries(attributes)
292
+ ArgumentError.("Attributes must be imported from a Hash") unless attributes.is_a?(::Hash)
293
+
294
+ identities = {}
295
+ attributes.filter_map do |key, value|
296
+ next if value.nil?
297
+
298
+ id = Attribute.id(key)
299
+ ArgumentError.("Attribute names collide after normalization: #{id}") if identities.key?(id)
300
+
301
+ identities[id] = true
302
+ value = Attribute.capture(value, normalize_keys: value.is_a?(::Hash))
303
+ [key, id, value]
304
+ end
305
+ end
306
+
307
+ def update(old_value, new_value)
308
+ UPDATER[new_value.class].call(*sanitized(old_value, new_value))
277
309
  end
278
310
 
279
311
  def sanitized(old_value, new_value)
@@ -292,7 +324,96 @@ module Sevgi
292
324
  end
293
325
  end
294
326
 
295
- # Public alias for the SVG attribute store.
296
- Attributes = Attribute::Store
327
+ # Names beginning with {META_PREFIX} can be read, assigned, deleted, merged, and copied like ordinary
328
+ # attributes. They appear in {#to_h}, but public SVG attribute enumeration and rendered XML omit them. All values
329
+ # entering or leaving this facade are recursively owned snapshots. Appending {UPDATE_SUFFIX} to a name requests a
330
+ # same-family update: Strings and Symbols are space-joined, Arrays are concatenated, and Hashes are merged. An update
331
+ # to an absent name behaves as replacement, and nil assignments are ignored.
332
+ #
333
+ # @example Inspect and update element attributes
334
+ # element = Sevgi::Graphics.SVG { rect id: "copy", "-source": "original" }.children.first
335
+ # element.attributes[:"-source"] # => "original"
336
+ # element.attributes.merge!(fill: "red")
337
+ # element.attributes.to_h # => { id: "copy", :"-source" => "original", fill: "red" }
338
+ class Attributes
339
+ # Creates an attribute facade from recursively owned snapshots.
340
+ # @param attributes [Hash] initial attributes and non-rendering metadata
341
+ # @return [void]
342
+ # @raise [Sevgi::ArgumentError] when input is not a Hash or contains an invalid name or value
343
+ def initialize(attributes = {})
344
+ @store = Attribute::Store.new(attributes)
345
+ end
346
+
347
+ # Returns an owned snapshot of an attribute value.
348
+ # @param key [String, Symbol] attribute key
349
+ # @return [Object, nil] recursively owned value or nil when absent
350
+ # @raise [Sevgi::ArgumentError] when key is not a valid attribute name
351
+ def [](key) = snapshot(@store[key])
352
+
353
+ # Assigns or updates a recursively owned attribute value.
354
+ # @param key [String, Symbol] attribute key, optionally ending in {UPDATE_SUFFIX}
355
+ # @param value [Object, nil] attribute value. Nil is ignored
356
+ # @return [Object, nil] recursively owned resulting value or nil when absent
357
+ # @raise [Sevgi::ArgumentError] when the name or value is invalid, or an existing update uses incompatible or
358
+ # unsupported value families
359
+ def []=(key, value)
360
+ @store[key] = value
361
+ snapshot(@store[key])
362
+ end
363
+
364
+ # Deletes an attribute and returns an owned snapshot of its value.
365
+ # @param key [String, Symbol] attribute key
366
+ # @return [Object, nil] deleted value or nil when absent
367
+ # @raise [Sevgi::ArgumentError] when key is not a valid attribute name
368
+ def delete(key) = snapshot(@store.delete(key))
369
+
370
+ # Reports whether an attribute exists.
371
+ # @param key [String, Symbol] attribute key
372
+ # @return [Boolean]
373
+ # @raise [Sevgi::ArgumentError] when key is not a valid attribute name
374
+ def has?(key) = @store.has?(key)
375
+
376
+ # Copies the facade with recursively independent values.
377
+ # @param original [Sevgi::Graphics::Attributes] facade to copy
378
+ # @return [void]
379
+ # @raise [Sevgi::ArgumentError] when stored values became invalid
380
+ # @api private
381
+ def initialize_copy(original)
382
+ super
383
+ @store = original.store.dup
384
+ end
385
+
386
+ private :initialize_copy
387
+
388
+ # Returns rendering attribute names, excluding non-rendering metadata.
389
+ # @return [Array<Symbol>] frozen name snapshot
390
+ def keys = @store.list.freeze
391
+
392
+ # Atomically assigns or updates recursively owned attributes.
393
+ # @param attributes [Hash] attributes and non-rendering metadata. Names can end in {UPDATE_SUFFIX}
394
+ # @return [Sevgi::Graphics::Attributes] self
395
+ # @raise [Sevgi::ArgumentError] when input is not a Hash, names collide, a name or value is invalid, or an existing
396
+ # update uses incompatible or unsupported value families
397
+ def merge!(attributes)
398
+ @store.import(attributes)
399
+ self
400
+ end
401
+
402
+ # Returns a recursively owned Hash snapshot including non-rendering metadata.
403
+ # @return [Hash{Symbol => Object}] owned attribute and metadata snapshot
404
+ def to_h = snapshot(@store.to_h)
405
+
406
+ private
407
+
408
+ def snapshot(value) = Attribute.capture(value)
409
+
410
+ def xml_lines = @store.to_xml_lines
411
+
412
+ protected
413
+
414
+ attr_reader :store
415
+ end
416
+
417
+ private_constant :Attribute
297
418
  end
298
419
  end