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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +152 -0
- data/LICENSE +674 -0
- data/README.md +34 -2
- data/lib/sevgi/graphics/attribute.rb +146 -32
- data/lib/sevgi/graphics/auxiliary/canvas.rb +64 -14
- data/lib/sevgi/graphics/auxiliary/content.rb +156 -21
- data/lib/sevgi/graphics/auxiliary/margin.rb +14 -2
- data/lib/sevgi/graphics/auxiliary/paper.rb +91 -84
- data/lib/sevgi/graphics/auxiliary/scalar.rb +40 -0
- data/lib/sevgi/graphics/auxiliary/sizes.rb +62 -0
- data/lib/sevgi/graphics/auxiliary.rb +2 -0
- data/lib/sevgi/graphics/document.rb +234 -56
- data/lib/sevgi/graphics/element.rb +42 -15
- data/lib/sevgi/graphics/mixtures/call.rb +29 -3
- data/lib/sevgi/graphics/mixtures/core.rb +138 -35
- data/lib/sevgi/graphics/mixtures/duplicate.rb +66 -16
- data/lib/sevgi/graphics/mixtures/export.rb +10 -2
- data/lib/sevgi/graphics/mixtures/identify.rb +3 -1
- data/lib/sevgi/graphics/mixtures/include.rb +28 -2
- data/lib/sevgi/graphics/mixtures/inkscape.rb +15 -0
- data/lib/sevgi/graphics/mixtures/rdf.rb +20 -0
- data/lib/sevgi/graphics/mixtures/render.rb +103 -24
- data/lib/sevgi/graphics/mixtures/save.rb +9 -0
- data/lib/sevgi/graphics/mixtures/symbols.rb +3 -0
- data/lib/sevgi/graphics/mixtures/tile.rb +17 -3
- data/lib/sevgi/graphics/mixtures/underscore.rb +14 -3
- data/lib/sevgi/graphics/mixtures/validate.rb +12 -0
- data/lib/sevgi/graphics/mixtures/wrappers.rb +4 -1
- data/lib/sevgi/graphics/mixtures.rb +4 -0
- data/lib/sevgi/graphics/version.rb +1 -1
- data/lib/sevgi/graphics/xml.rb +127 -0
- data/lib/sevgi/graphics.rb +33 -18
- metadata +10 -5
data/README.md
CHANGED
|
@@ -1,5 +1,37 @@
|
|
|
1
1
|
# Sevgi Graphics
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Core SVG DSL, document profiles, and rendering behavior.
|
|
4
4
|
|
|
5
|
-
|
|
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] ||=
|
|
31
|
-
.
|
|
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
|
-
#
|
|
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
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
|
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
|
|
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]
|
|
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
|
-
|
|
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
|
|
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
|
|
129
|
-
#
|
|
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
|
-
|
|
168
|
-
|
|
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
|
-
#
|
|
52
|
-
#
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
#
|
|
56
|
-
#
|
|
57
|
-
attr_reader :
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|