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.
Files changed (39) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +275 -2
  3. data/LICENSE +672 -3
  4. data/README.md +8 -8
  5. data/lib/sevgi/graphics/attribute.rb +271 -54
  6. data/lib/sevgi/graphics/auxiliary/canvas.rb +137 -58
  7. data/lib/sevgi/graphics/auxiliary/content.rb +200 -57
  8. data/lib/sevgi/graphics/auxiliary/margin.rb +33 -14
  9. data/lib/sevgi/graphics/auxiliary/paper.rb +118 -86
  10. data/lib/sevgi/graphics/auxiliary/path.rb +44 -0
  11. data/lib/sevgi/graphics/auxiliary/scalar.rb +69 -0
  12. data/lib/sevgi/graphics/auxiliary/sizes.rb +62 -0
  13. data/lib/sevgi/graphics/auxiliary.rb +3 -0
  14. data/lib/sevgi/graphics/document/base.rb +6 -2
  15. data/lib/sevgi/graphics/document/default.rb +1 -1
  16. data/lib/sevgi/graphics/document.rb +370 -124
  17. data/lib/sevgi/graphics/element.rb +143 -33
  18. data/lib/sevgi/graphics/mixtures/call.rb +249 -88
  19. data/lib/sevgi/graphics/mixtures/core.rb +143 -33
  20. data/lib/sevgi/graphics/mixtures/duplicate.rb +76 -22
  21. data/lib/sevgi/graphics/mixtures/export.rb +58 -12
  22. data/lib/sevgi/graphics/mixtures/hatch.rb +49 -7
  23. data/lib/sevgi/graphics/mixtures/identify.rb +30 -17
  24. data/lib/sevgi/graphics/mixtures/include.rb +40 -5
  25. data/lib/sevgi/graphics/mixtures/inkscape.rb +215 -46
  26. data/lib/sevgi/graphics/mixtures/rdf.rb +79 -7
  27. data/lib/sevgi/graphics/mixtures/render.rb +88 -19
  28. data/lib/sevgi/graphics/mixtures/save.rb +79 -26
  29. data/lib/sevgi/graphics/mixtures/symbols.rb +81 -9
  30. data/lib/sevgi/graphics/mixtures/tile.rb +88 -64
  31. data/lib/sevgi/graphics/mixtures/transform.rb +68 -28
  32. data/lib/sevgi/graphics/mixtures/underscore.rb +16 -7
  33. data/lib/sevgi/graphics/mixtures/validate.rb +14 -2
  34. data/lib/sevgi/graphics/mixtures/wrappers.rb +66 -24
  35. data/lib/sevgi/graphics/mixtures.rb +19 -13
  36. data/lib/sevgi/graphics/version.rb +1 -1
  37. data/lib/sevgi/graphics/xml.rb +127 -0
  38. data/lib/sevgi/graphics.rb +75 -28
  39. metadata +10 -6
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 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,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?(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.
27
32
  # @param given [String, Symbol] attribute name
28
33
  # @return [Symbol]
29
34
  def id(given)
30
- (@id ||= {})[given] ||= (updateable?(given) ? given.to_s.delete_suffix(ATTRIBUTE_UPDATE_SUFFIX) : given)
31
- .to_sym
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?(ATTRIBUTE_UPDATE_SUFFIX)
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
- case value
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 SVG attribute store with Sevgi update syntax.
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
- # Imports attributes into the store.
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
- hash = attributes
75
- .compact
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.merge!(hash)
195
+ @store.replace(updated)
82
196
  end
83
197
 
84
- # Returns an attribute by normalized key.
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 an attribute value.
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] assigned value or 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
- @store[id = Attribute.id(key)] = @store.key?(id) && Attribute.updateable?(key) ? update(id, value) : value
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 public attributes ready for rendering.
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
- # A small aesthetic touch: always keep the id attribute first
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::Attribute::Store] store to copy
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.dup }
252
+ original.store.each { |key, value| @store[key] = Attribute.capture(value) }
133
253
 
134
254
  super
135
255
  end
136
256
 
137
- # Returns public attribute names.
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 hash.
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| [old_value, new_value] },
179
- ::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) }
180
287
  }.freeze
181
288
 
182
- def update(id, new_value)
183
- (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))
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
- "#{id}=#{Attribute.xml_text(value).encode(xml: :attr)}"
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
- # Public alias for the SVG attribute store.
202
- 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
203
420
  end
204
421
  end