sevgi-graphics 0.93.1 → 0.95.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 (34) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +152 -0
  3. data/LICENSE +674 -0
  4. data/README.md +34 -2
  5. data/lib/sevgi/graphics/attribute.rb +146 -32
  6. data/lib/sevgi/graphics/auxiliary/canvas.rb +64 -14
  7. data/lib/sevgi/graphics/auxiliary/content.rb +156 -21
  8. data/lib/sevgi/graphics/auxiliary/margin.rb +14 -2
  9. data/lib/sevgi/graphics/auxiliary/paper.rb +91 -84
  10. data/lib/sevgi/graphics/auxiliary/scalar.rb +40 -0
  11. data/lib/sevgi/graphics/auxiliary/sizes.rb +62 -0
  12. data/lib/sevgi/graphics/auxiliary.rb +2 -0
  13. data/lib/sevgi/graphics/document.rb +234 -56
  14. data/lib/sevgi/graphics/element.rb +42 -15
  15. data/lib/sevgi/graphics/mixtures/call.rb +29 -3
  16. data/lib/sevgi/graphics/mixtures/core.rb +138 -35
  17. data/lib/sevgi/graphics/mixtures/duplicate.rb +66 -16
  18. data/lib/sevgi/graphics/mixtures/export.rb +10 -2
  19. data/lib/sevgi/graphics/mixtures/identify.rb +3 -1
  20. data/lib/sevgi/graphics/mixtures/include.rb +28 -2
  21. data/lib/sevgi/graphics/mixtures/inkscape.rb +15 -0
  22. data/lib/sevgi/graphics/mixtures/rdf.rb +20 -0
  23. data/lib/sevgi/graphics/mixtures/render.rb +103 -24
  24. data/lib/sevgi/graphics/mixtures/save.rb +9 -0
  25. data/lib/sevgi/graphics/mixtures/symbols.rb +3 -0
  26. data/lib/sevgi/graphics/mixtures/tile.rb +17 -3
  27. data/lib/sevgi/graphics/mixtures/underscore.rb +14 -3
  28. data/lib/sevgi/graphics/mixtures/validate.rb +12 -0
  29. data/lib/sevgi/graphics/mixtures/wrappers.rb +4 -1
  30. data/lib/sevgi/graphics/mixtures.rb +4 -0
  31. data/lib/sevgi/graphics/version.rb +1 -1
  32. data/lib/sevgi/graphics/xml.rb +127 -0
  33. data/lib/sevgi/graphics.rb +33 -18
  34. metadata +10 -5
data/README.md CHANGED
@@ -1,5 +1,37 @@
1
1
  # Sevgi Graphics
2
2
 
3
- Provides the core SVG DSL, document profiles, and rendering behavior.
3
+ Core SVG DSL, document profiles, and rendering behavior.
4
4
 
5
- See the root [README](../README.md) and the documentation site for usage.
5
+ ## Install
6
+
7
+ ```sh
8
+ gem install sevgi-graphics
9
+ ```
10
+
11
+ ## Require
12
+
13
+ ```ruby
14
+ require "sevgi/graphics"
15
+ ```
16
+
17
+ ## Example
18
+
19
+ ```ruby
20
+ doc = Sevgi::Graphics.SVG(:minimal) { rect(width: 3, height: 5) }
21
+ doc.call
22
+ ```
23
+
24
+ ## Ruby compatibility
25
+
26
+ Requires Ruby 3.4.0 or newer. CI verifies Ruby 3.4.0 and the current development Ruby from `.ruby-version`.
27
+
28
+ ## Native prerequisites
29
+
30
+ None beyond Ruby and this gem's Ruby dependencies.
31
+
32
+ ## Links
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
@@ -20,80 +20,198 @@ module Sevgi
20
20
  # @param given [String, Symbol] attribute name
21
21
  # @return [Boolean]
22
22
  def internal?(given)
23
- (@internal ||= {})[given] ||= given.start_with?(ATTRIBUTE_INTERNAL_PREFIX)
23
+ (@internal ||= {})[given] ||= key(given).start_with?(ATTRIBUTE_INTERNAL_PREFIX)
24
24
  end
25
25
 
26
26
  # Returns the normalized attribute id.
27
27
  # @param given [String, Symbol] attribute name
28
28
  # @return [Symbol]
29
29
  def id(given)
30
- (@id ||= {})[given] ||= (updateable?(given) ? given.to_s.delete_suffix(ATTRIBUTE_UPDATE_SUFFIX) : given)
31
- .to_sym
30
+ (@id ||= {})[given] ||= begin
31
+ name = updateable?(given) ? key(given).delete_suffix(ATTRIBUTE_UPDATE_SUFFIX) : key(given)
32
+ XML.name(name, context: "XML attribute name") unless internal?(given)
33
+ name.to_sym
34
+ end
32
35
  end
33
36
 
34
37
  # Reports whether an attribute uses merge/update syntax.
35
38
  # @param given [String, Symbol] attribute name
36
39
  # @return [Boolean]
37
40
  def updateable?(given)
38
- (@updateable ||= {})[given] ||= given.end_with?(ATTRIBUTE_UPDATE_SUFFIX)
41
+ (@updateable ||= {})[given] ||= key(given).end_with?(ATTRIBUTE_UPDATE_SUFFIX)
42
+ end
43
+
44
+ private
45
+
46
+ def key(given)
47
+ return given.to_s if given.is_a?(::String) || given.is_a?(::Symbol)
48
+
49
+ ArgumentError.("XML attribute name must be a String or Symbol")
39
50
  end
40
51
  end
41
52
 
42
53
  extend Ident
43
54
 
55
+ # Owned mutable snapshots for values entering an attribute store.
56
+ # @api private
57
+ module Snapshot
58
+ class << self
59
+ def capture(value, normalize_keys: false, seen: {}.compare_by_identity)
60
+ case value
61
+ when ::Hash
62
+ nested(value, seen) { capture_hash(value, normalize_keys:, seen:) }
63
+ when ::Array
64
+ nested(value, seen) { value.map { capture(it, seen:) } }
65
+ else
66
+ capture_value(value)
67
+ end
68
+ end
69
+
70
+ private
71
+
72
+ def capture_hash(value, normalize_keys:, seen:)
73
+ identities = {}
74
+ value.each_with_object({}) do |(key, item), captured|
75
+ key = normalize_keys ? normalize_key(key) : capture(key, seen:)
76
+ identity = normalize_keys ? key : XML.snapshot(key, context: "XML attribute value")
77
+ if captured.key?(key) || identities.key?(identity)
78
+ ArgumentError.("Attribute keys collide after normalization or stringification")
79
+ end
80
+
81
+ identities[identity] = true
82
+ captured[key] = capture(item, seen:)
83
+ end
84
+ end
85
+
86
+ def capture_value(value)
87
+ 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
94
+ end
95
+
96
+ def nested(value, seen)
97
+ ArgumentError.("Cyclic XML attribute value is not supported") if seen.key?(value)
98
+
99
+ seen[value] = true
100
+ yield
101
+ ensure
102
+ seen.delete(value)
103
+ end
104
+
105
+ def normalize_key(key)
106
+ normalized = key.to_sym if key.respond_to?(:to_sym)
107
+ return normalized if normalized.is_a?(::Symbol)
108
+
109
+ ArgumentError.("Attribute Hash keys must normalize to Symbols")
110
+ rescue Sevgi::ArgumentError
111
+ raise
112
+ rescue ::StandardError => e
113
+ ArgumentError.("Attribute Hash key cannot be normalized: #{e.class}: #{e.message}")
114
+ end
115
+ end
116
+ end
117
+
118
+ private_constant :Snapshot
119
+
120
+ # Captures a caller-independent attribute value.
121
+ # @param value [Object] value to capture
122
+ # @param normalize_keys [Boolean] normalize direct Hash keys to Symbols
123
+ # @return [Object] owned mutable snapshot
124
+ # @raise [Sevgi::ArgumentError] when value is invalid, cyclic, collides, or cannot be converted
125
+ # @api private
126
+ def self.capture(value, normalize_keys: false) = Snapshot.capture(value, normalize_keys:)
127
+
128
+ # Returns the text form used for an XML attribute value before escaping.
129
+ # @param value [Object] attribute value
130
+ # @return [String]
131
+ # @raise [Sevgi::ArgumentError] when value is invalid, cyclic, or cannot be stringified as XML
132
+ # @api private
133
+ def self.xml_text(value)
134
+ value = XML.snapshot(value, context: "XML attribute value")
135
+ text = case value
136
+ when ::Hash
137
+ value.map { |key, attr_value| "#{key}:#{attr_value}" }.join("; ")
138
+ when ::Array
139
+ value.join(" ")
140
+ else
141
+ value.to_s
142
+ end
143
+
144
+ XML.text(text, context: "XML attribute value")
145
+ end
146
+
44
147
  # Mutable SVG attribute store with Sevgi update syntax.
45
148
  class Store
46
- # Creates an attribute store.
149
+ # Creates an attribute store from recursively owned snapshots. Mutable non-container leaves are stringified
150
+ # once; later caller mutation cannot change the store.
47
151
  # @param attributes [Hash] initial attributes
48
152
  # @return [void]
153
+ # @raise [Sevgi::ArgumentError] when input is not a Hash or a name/value is invalid, cyclic, colliding, or cannot
154
+ # be converted
49
155
  def initialize(attributes = {})
50
156
  @store = {}
51
157
 
52
158
  import(attributes)
53
159
  end
54
160
 
55
- # Imports attributes into the store.
161
+ # Atomically imports recursively owned attribute snapshots.
56
162
  # @param attributes [Hash] attributes to merge
57
163
  # @return [Hash] internal store
164
+ # @raise [Sevgi::ArgumentError] when input is not a Hash or a name/value is invalid, cyclic, colliding, or cannot
165
+ # be converted
58
166
  def import(attributes)
59
- hash = attributes
60
- .compact
61
- .to_a
62
- .to_h do |key, value|
63
- [key.to_sym, value.is_a?(::Hash) ? value.transform_keys(&:to_sym) : value]
64
- end
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
65
177
 
66
178
  @store.merge!(hash)
67
179
  end
68
180
 
69
- # Returns an attribute by normalized key.
181
+ # Returns a live stored attribute value. Mutating a returned container intentionally mutates this store; rendering
182
+ # revalidates the resulting value.
70
183
  # @param key [String, Symbol] attribute key
71
184
  # @return [Object, nil]
185
+ # @raise [Sevgi::ArgumentError] when key is not a valid XML attribute name
72
186
  def [](key)
73
187
  @store[Attribute.id(key)]
74
188
  end
75
189
 
76
- # Assigns an attribute value.
190
+ # Assigns a recursively owned attribute snapshot. Mutable non-container leaves are stringified once.
77
191
  # @param key [String, Symbol] attribute key
78
192
  # @param value [Object, nil] attribute value; nil is ignored
79
- # @return [Object, nil] assigned value or nil
193
+ # @return [Object, nil] stored snapshot or nil
80
194
  # @raise [Sevgi::ArgumentError] when update syntax receives incompatible values
81
195
  # @raise [Sevgi::ArgumentError] when update syntax receives an unsupported value type
196
+ # @raise [Sevgi::ArgumentError] when a name/value is invalid, cyclic, colliding, or cannot be converted
82
197
  def []=(key, value)
83
198
  return if value.nil?
84
199
 
85
- @store[id = Attribute.id(key)] = @store.key?(id) && Attribute.updateable?(key) ? update(id, value) : value
200
+ id = Attribute.id(key)
201
+ value = Attribute.capture(value, normalize_keys: value.is_a?(::Hash))
202
+ @store[id] = @store.key?(id) && Attribute.updateable?(key) ? update(id, value) : value
86
203
  end
87
204
 
88
205
  # Deletes an attribute by normalized key.
89
206
  # @param key [String, Symbol] attribute key
90
207
  # @return [Object, nil] deleted value
208
+ # @raise [Sevgi::ArgumentError] when key is not a valid XML attribute name
91
209
  def delete(key)
92
210
  @store.delete(Attribute.id(key))
93
211
  end
94
212
 
95
- # Returns public attributes ready for rendering.
96
- # @return [Hash]
213
+ # Returns public attributes ready for rendering. Nested values remain live store values.
214
+ # @return [Hash] shallow attribute view
97
215
  def export
98
216
  hash = @store.reject { |id, _| Attribute.internal?(id) }
99
217
  return hash unless hash.key?(:id)
@@ -105,16 +223,18 @@ module Sevgi
105
223
  # Reports whether an attribute exists.
106
224
  # @param key [String, Symbol] attribute key
107
225
  # @return [Boolean]
226
+ # @raise [Sevgi::ArgumentError] when key is not a valid XML attribute name
108
227
  def has?(key)
109
228
  @store.key?(Attribute.id(key))
110
229
  end
111
230
 
112
- # Copies the attribute store.
231
+ # Copies the attribute store with recursively independent values.
113
232
  # @param original [Sevgi::Graphics::Attribute::Store] store to copy
114
233
  # @return [void]
234
+ # @raise [Sevgi::ArgumentError] when live stored values became cyclic or invalid
115
235
  def initialize_copy(original)
116
236
  @store = {}
117
- original.store.each { |key, value| @store[key] = value.dup }
237
+ original.store.each { |key, value| @store[key] = Attribute.capture(value) }
118
238
 
119
239
  super
120
240
  end
@@ -125,14 +245,16 @@ module Sevgi
125
245
  export.keys
126
246
  end
127
247
 
128
- # Returns the internal attribute hash.
129
- # @return [Hash]
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
130
251
  def to_h
131
252
  @store
132
253
  end
133
254
 
134
255
  # Returns rendered XML attribute lines.
135
256
  # @return [Array<String>]
257
+ # @raise [Sevgi::ArgumentError] when stored names or values are invalid, cyclic, or cannot be stringified
136
258
  def to_xml_lines
137
259
  export.map { |id, value| to_xml(id, value) }
138
260
  end
@@ -164,16 +286,8 @@ module Sevgi
164
286
  private_constant :UPDATER
165
287
 
166
288
  def to_xml(id, value)
167
- text = case value
168
- when ::Hash
169
- value.map { |key, attr_value| "#{key}:#{attr_value}" }.join("; ")
170
- when ::Array
171
- value.join(" ")
172
- else
173
- value.to_s
174
- end
175
-
176
- "#{id}=#{text.encode(xml: :attr)}"
289
+ name = XML.name(id, context: "XML attribute name")
290
+ "#{name}=#{Attribute.xml_text(value).encode(xml: :attr)}"
177
291
  end
178
292
  end
179
293
  end
@@ -6,6 +6,10 @@ module Sevgi
6
6
  module Graphics
7
7
  # SVG canvas size, margins, viewport, and viewBox.
8
8
  class Canvas
9
+ ORIGIN_FIELDS = %i[x y].freeze
10
+ REPLACEMENTS = %i[width height unit name margins].freeze
11
+ private_constant :ORIGIN_FIELDS, :REPLACEMENTS
12
+
9
13
  # @overload call(arg = Undefined, **kwargs)
10
14
  # Builds a canvas from a paper profile or explicit size.
11
15
  # @param arg [Sevgi::Graphics::Paper, Symbol, String, Sevgi::Undefined] paper profile or paper object
@@ -48,15 +52,19 @@ module Sevgi
48
52
  def_delegators :@margin, *Margin.members
49
53
  def_delegators :@size, *Paper.members
50
54
 
51
- # @!attribute [r] size
52
- # @return [Sevgi::Graphics::Paper] paper size object
53
- # @!attribute [r] margin
54
- # @return [Sevgi::Graphics::Margin] canvas margins
55
- # @!attribute [r] inner
56
- # @return [Sevgi::Graphics::Paper] inner paper after margins
57
- attr_reader :size, :margin, :inner
55
+ # Returns the paper size object.
56
+ # @return [Sevgi::Graphics::Paper]
57
+ attr_reader :size
58
+
59
+ # Returns the canvas margins.
60
+ # @return [Sevgi::Graphics::Margin]
61
+ attr_reader :margin
62
+
63
+ # Returns the inner paper after margins.
64
+ # @return [Sevgi::Graphics::Paper]
65
+ attr_reader :inner
58
66
 
59
- # Creates a canvas.
67
+ # Creates a canvas with finite real dimensions greater than zero and margins that leave a positive inner area.
60
68
  # @param width [Numeric] canvas width
61
69
  # @param height [Numeric] canvas height
62
70
  # @param unit [Symbol, String] SVG unit
@@ -75,12 +83,12 @@ module Sevgi
75
83
  # @overload attributes(origin = Undefined)
76
84
  # Returns SVG root viewport attributes.
77
85
  # @param origin [Numeric, Array<Numeric>, nil, Sevgi::Undefined] viewBox origin
78
- # @return [Hash]
86
+ # @return [Hash{Symbol => String}] SVG viewport and viewBox attributes
79
87
  # @raise [Sevgi::ArgumentError] when origin is invalid
80
88
  def attributes(...) = {**viewport, viewBox: viewbox(...)}
81
89
 
82
90
  # Returns SVG width and height attributes.
83
- # @return [Hash]
91
+ # @return [Hash{Symbol => String}] SVG width and height attributes
84
92
  def viewport = {width: "#{width}#{unit}", height: "#{height}#{unit}"}
85
93
 
86
94
  # Returns the SVG viewBox string.
@@ -91,13 +99,43 @@ module Sevgi
91
99
 
92
100
  # Returns a canvas with selected fields replaced.
93
101
  # @param kwargs [Hash] replacement options
102
+ # @option kwargs [Numeric] :width replacement canvas width
103
+ # @option kwargs [Numeric] :height replacement canvas height
104
+ # @option kwargs [Symbol, String] :unit replacement SVG unit
105
+ # @option kwargs [Symbol, String] :name replacement paper name
106
+ # @option kwargs [Array<Numeric>] :margins replacement margin shorthand values
94
107
  # @return [Sevgi::Graphics::Canvas]
95
- def with(**kwargs) = self.class.new(**size.to_h, margins: kwargs.fetch(:margins, margin.to_a))
108
+ # @raise [Sevgi::ArgumentError] when an unknown option is supplied
109
+ # @raise [Sevgi::ArgumentError] when a replacement value is invalid
110
+ def with(**kwargs)
111
+ unknown = kwargs.keys - REPLACEMENTS
112
+
113
+ ArgumentError.("Unknown canvas option: #{unknown.first}") unless unknown.empty?
114
+
115
+ margins = kwargs.fetch(:margins, margin.to_a)
116
+ replacements = kwargs.dup.tap { it.delete(:margins) }
117
+
118
+ self.class.new(**size.to_h, **replacements, margins:)
119
+ end
96
120
 
97
121
  private
98
122
 
99
123
  def compute
100
- @inner = size.with(width: width - margin.left - margin.right, height: height - margin.top - margin.bottom)
124
+ @inner = size.with(**inner_size)
125
+ ensure_inner_area
126
+ end
127
+
128
+ def ensure_inner_area
129
+ return if @inner.width.positive? && @inner.height.positive?
130
+
131
+ ArgumentError.("Canvas margins must leave a positive inner area")
132
+ end
133
+
134
+ def inner_size
135
+ {
136
+ width: width - margin.left - margin.right,
137
+ height: height - margin.top - margin.bottom
138
+ }
101
139
  end
102
140
 
103
141
  def originate(origin)
@@ -105,7 +143,7 @@ module Sevgi
105
143
  when Undefined
106
144
  [-margin.left, -margin.top]
107
145
  when ::Numeric, ::NilClass
108
- [origin.to_f, origin.to_f]
146
+ scalar_origin(origin)
109
147
  when ::Array
110
148
  pair(origin)
111
149
  else
@@ -113,12 +151,24 @@ module Sevgi
113
151
  end
114
152
  end
115
153
 
154
+ def coordinate!(field, value)
155
+ return 0.0 if value.nil?
156
+
157
+ Scalar.finite(value, context: "canvas origin", field:)
158
+ end
159
+
160
+ def scalar_origin(origin)
161
+ coordinate!(:origin, origin || 0).then { [it, it] }
162
+ end
163
+
116
164
  def prettify(*floats)
117
165
  floats.map { (it % 1).zero? ? it.to_i : it }
118
166
  end
119
167
 
120
168
  def pair(array)
121
- array.size == 2 ? array.map(&:to_f) : ArgumentError.("Argument must be an Array of size 2")
169
+ ArgumentError.("Canvas origin must have exactly two coordinates") unless array.size == 2
170
+
171
+ ORIGIN_FIELDS.zip(array).map { |field, value| coordinate!(field, value) }
122
172
  end
123
173
  end
124
174
  end