sevgi-graphics 0.73.2 → 0.93.1

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 (43) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +5 -0
  3. data/lib/sevgi/graphics/attribute.rb +58 -6
  4. data/lib/sevgi/graphics/auxiliary/canvas.rb +125 -0
  5. data/lib/sevgi/graphics/auxiliary/content.rb +145 -0
  6. data/lib/sevgi/graphics/auxiliary/margin.rb +93 -0
  7. data/lib/sevgi/graphics/auxiliary/paper.rb +163 -0
  8. data/lib/sevgi/graphics/auxiliary.rb +7 -0
  9. data/lib/sevgi/graphics/document/base.rb +6 -0
  10. data/lib/sevgi/graphics/document/default.rb +1 -0
  11. data/lib/sevgi/graphics/document/html.rb +1 -0
  12. data/lib/sevgi/graphics/document/inkscape.rb +1 -0
  13. data/lib/sevgi/graphics/document/minimal.rb +1 -0
  14. data/lib/sevgi/graphics/document.rb +137 -11
  15. data/lib/sevgi/graphics/element.rb +68 -8
  16. data/lib/sevgi/graphics/mixtures/call.rb +35 -3
  17. data/lib/sevgi/graphics/mixtures/core.rb +107 -18
  18. data/lib/sevgi/graphics/mixtures/duplicate.rb +14 -0
  19. data/lib/sevgi/graphics/mixtures/export.rb +29 -6
  20. data/lib/sevgi/graphics/mixtures/hatch.rb +24 -3
  21. data/lib/sevgi/graphics/mixtures/identify.rb +23 -1
  22. data/lib/sevgi/graphics/mixtures/include.rb +31 -4
  23. data/lib/sevgi/graphics/mixtures/inkscape.rb +51 -5
  24. data/lib/sevgi/graphics/mixtures/lint.rb +12 -0
  25. data/lib/sevgi/graphics/mixtures/polyfills.rb +11 -0
  26. data/lib/sevgi/graphics/mixtures/rdf.rb +40 -1
  27. data/lib/sevgi/graphics/mixtures/render.rb +90 -2
  28. data/lib/sevgi/graphics/mixtures/save.rb +21 -3
  29. data/lib/sevgi/graphics/mixtures/symbols.rb +7 -0
  30. data/lib/sevgi/graphics/mixtures/tile.rb +41 -6
  31. data/lib/sevgi/graphics/mixtures/transform.rb +54 -16
  32. data/lib/sevgi/graphics/mixtures/underscore.rb +10 -1
  33. data/lib/sevgi/graphics/mixtures/validate.rb +15 -1
  34. data/lib/sevgi/graphics/mixtures/wrappers.rb +54 -17
  35. data/lib/sevgi/graphics/mixtures.rb +35 -3
  36. data/lib/sevgi/graphics/version.rb +2 -1
  37. data/lib/sevgi/graphics.rb +57 -5
  38. metadata +9 -8
  39. data/lib/sevgi/graphics/auxilary/canvas.rb +0 -72
  40. data/lib/sevgi/graphics/auxilary/content.rb +0 -81
  41. data/lib/sevgi/graphics/auxilary/margin.rb +0 -49
  42. data/lib/sevgi/graphics/auxilary/paper.rb +0 -95
  43. data/lib/sevgi/graphics/auxilary.rb +0 -7
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f037bca5ff8f5c96deec6ac271ccb0b0649f5a9ee8c3cf9aa4f2ec04a14a593d
4
- data.tar.gz: e880612eb0dd48783a24e0ca71fbde7dbf10faa7b84d640230d8beeeb831144d
3
+ metadata.gz: 2aa7779435a4a7fb66f31fc412abac9eaf39f9f310d598a02a6fe0713e9aceab
4
+ data.tar.gz: 5f17372a0ff45b66c9388993c7270413b359d89889633bb775bac27747ff1128
5
5
  SHA512:
6
- metadata.gz: 95d77cf9671b69ea7cc16435187f3f700ef2062ea21e6d09b8261eef147c1ae533ce37f6bd403cbcdf5da98a6ad328e10be9a1fc1fc73597985715bc8767f490
7
- data.tar.gz: 2d376cdca8ce4f05ae58130590cabba02e88013ee3bd774f773cf8c0befab591bb2f1e5c4ed62fb0841748079df06961424f6646fb1163164ad26dd44f16e5dc
6
+ metadata.gz: 46fed1c304dc35516dc15e12eff33cfe5eea984ed070c675e62a8ecf659ad9f1aee3f5f1884525b98c622480c4d59ea72ab20b1d2d5b6704a30691026765bdfe
7
+ data.tar.gz: f35d041432cde1a165e8f753cbbe895dc2e90850a6f74cbcf89f4278ef60b022471072340959c9db21e96499ad45f09505416f7ef140b8ef620811319509a986
data/README.md ADDED
@@ -0,0 +1,5 @@
1
+ # Sevgi Graphics
2
+
3
+ Provides the core SVG DSL, document profiles, and rendering behavior.
4
+
5
+ See the root [README](../README.md) and the documentation site for usage.
@@ -4,20 +4,36 @@
4
4
 
5
5
  module Sevgi
6
6
  module Graphics
7
+ # Internal store syntax; not part of the SVG DSL command surface.
7
8
  ATTRIBUTE_INTERNAL_PREFIX = "-"
9
+
10
+ # Attribute suffix that merges new values into an existing attribute.
8
11
  ATTRIBUTE_UPDATE_SUFFIX = "+"
9
12
 
13
+ # Attribute name normalization helpers.
14
+ # @api private
10
15
  module Attribute
16
+ # Attribute identifier helpers.
17
+ # @api private
11
18
  module Ident
19
+ # Reports whether an attribute is internal to Sevgi rendering.
20
+ # @param given [String, Symbol] attribute name
21
+ # @return [Boolean]
12
22
  def internal?(given)
13
23
  (@internal ||= {})[given] ||= given.start_with?(ATTRIBUTE_INTERNAL_PREFIX)
14
24
  end
15
25
 
26
+ # Returns the normalized attribute id.
27
+ # @param given [String, Symbol] attribute name
28
+ # @return [Symbol]
16
29
  def id(given)
17
30
  (@id ||= {})[given] ||= (updateable?(given) ? given.to_s.delete_suffix(ATTRIBUTE_UPDATE_SUFFIX) : given)
18
31
  .to_sym
19
32
  end
20
33
 
34
+ # Reports whether an attribute uses merge/update syntax.
35
+ # @param given [String, Symbol] attribute name
36
+ # @return [Boolean]
21
37
  def updateable?(given)
22
38
  (@updateable ||= {})[given] ||= given.end_with?(ATTRIBUTE_UPDATE_SUFFIX)
23
39
  end
@@ -25,38 +41,59 @@ module Sevgi
25
41
 
26
42
  extend Ident
27
43
 
44
+ # Mutable SVG attribute store with Sevgi update syntax.
28
45
  class Store
46
+ # Creates an attribute store.
47
+ # @param attributes [Hash] initial attributes
48
+ # @return [void]
29
49
  def initialize(attributes = {})
30
50
  @store = {}
31
51
 
32
52
  import(attributes)
33
53
  end
34
54
 
55
+ # Imports attributes into the store.
56
+ # @param attributes [Hash] attributes to merge
57
+ # @return [Hash] internal store
35
58
  def import(attributes)
36
59
  hash = attributes
37
60
  .compact
38
61
  .to_a
39
62
  .to_h do |key, value|
40
- [key.to_sym, value.is_a?(::Hash) ? value.transform_keys!(&:to_sym) : value]
63
+ [key.to_sym, value.is_a?(::Hash) ? value.transform_keys(&:to_sym) : value]
41
64
  end
42
65
 
43
66
  @store.merge!(hash)
44
67
  end
45
68
 
69
+ # Returns an attribute by normalized key.
70
+ # @param key [String, Symbol] attribute key
71
+ # @return [Object, nil]
46
72
  def [](key)
47
73
  @store[Attribute.id(key)]
48
74
  end
49
75
 
76
+ # Assigns an attribute value.
77
+ # @param key [String, Symbol] attribute key
78
+ # @param value [Object, nil] attribute value; nil is ignored
79
+ # @return [Object, nil] assigned value or nil
80
+ # @raise [Sevgi::ArgumentError] when update syntax receives incompatible values
81
+ # @raise [Sevgi::ArgumentError] when update syntax receives an unsupported value type
50
82
  def []=(key, value)
51
83
  return if value.nil?
52
84
 
53
85
  @store[id = Attribute.id(key)] = @store.key?(id) && Attribute.updateable?(key) ? update(id, value) : value
54
86
  end
55
87
 
88
+ # Deletes an attribute by normalized key.
89
+ # @param key [String, Symbol] attribute key
90
+ # @return [Object, nil] deleted value
56
91
  def delete(key)
57
92
  @store.delete(Attribute.id(key))
58
93
  end
59
94
 
95
+ # Returns public attributes ready for rendering.
96
+ # @return [Hash]
60
97
  def export
61
98
  hash = @store.reject { |id, _| Attribute.internal?(id) }
62
99
  return hash unless hash.key?(:id)
@@ -65,10 +102,16 @@ module Sevgi
65
102
  {id: hash.delete(:id), **hash}
66
103
  end
67
104
 
105
+ # Reports whether an attribute exists.
106
+ # @param key [String, Symbol] attribute key
107
+ # @return [Boolean]
68
108
  def has?(key)
69
109
  @store.key?(Attribute.id(key))
70
110
  end
71
111
 
112
+ # Copies the attribute store.
113
+ # @param original [Sevgi::Graphics::Attribute::Store] store to copy
114
+ # @return [void]
72
115
  def initialize_copy(original)
73
116
  @store = {}
74
117
  original.store.each { |key, value| @store[key] = value.dup }
@@ -76,14 +119,20 @@ module Sevgi
76
119
  super
77
120
  end
78
121
 
122
+ # Returns public attribute names.
123
+ # @return [Array<Symbol>]
79
124
  def list
80
125
  export.keys
81
126
  end
82
127
 
128
+ # Returns the internal attribute hash.
129
+ # @return [Hash]
83
130
  def to_h
84
131
  @store
85
132
  end
86
133
 
134
+ # Returns rendered XML attribute lines.
135
+ # @return [Array<String>]
87
136
  def to_xml_lines
88
137
  export.map { |id, value| to_xml(id, value) }
89
138
  end
@@ -98,7 +147,7 @@ module Sevgi
98
147
  ::String => proc { |old_value, new_value| [old_value, new_value].reject(&:empty?).join(" ") },
99
148
  ::Symbol => proc { |old_value, new_value| [old_value, new_value].reject(&:empty?).join(" ").to_sym },
100
149
  ::Array => proc { |old_value, new_value| [old_value, new_value] },
101
- ::Hash => proc { |old_value, new_value| merge(old_value, new_value.transform_keys(&:to_sym)) }
150
+ ::Hash => proc { |old_value, new_value| old_value.merge(new_value.transform_keys(&:to_sym)) }
102
151
  }.freeze
103
152
 
104
153
  def update(id, new_value)
@@ -115,18 +164,21 @@ module Sevgi
115
164
  private_constant :UPDATER
116
165
 
117
166
  def to_xml(id, value)
118
- case value
167
+ text = case value
119
168
  when ::Hash
120
- "#{id}=\"#{value.map { |key, attr_value| "#{key}:#{attr_value}" }.join("; ")}\""
169
+ value.map { |key, attr_value| "#{key}:#{attr_value}" }.join("; ")
121
170
  when ::Array
122
- "#{id}=\"#{value.join(" ")}\""
171
+ value.join(" ")
123
172
  else
124
- "#{id}=#{value.to_s.encode(xml: :attr)}"
173
+ value.to_s
125
174
  end
175
+
176
+ "#{id}=#{text.encode(xml: :attr)}"
126
177
  end
127
178
  end
128
179
  end
129
180
 
181
+ # Public alias for the SVG attribute store.
130
182
  Attributes = Attribute::Store
131
183
  end
132
184
  end
@@ -0,0 +1,125 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "forwardable"
4
+
5
+ module Sevgi
6
+ module Graphics
7
+ # SVG canvas size, margins, viewport, and viewBox.
8
+ class Canvas
9
+ # @overload call(arg = Undefined, **kwargs)
10
+ # Builds a canvas from a paper profile or explicit size.
11
+ # @param arg [Sevgi::Graphics::Paper, Symbol, String, Sevgi::Undefined] paper profile or paper object
12
+ # @param kwargs [Hash] canvas keyword arguments
13
+ # @return [Sevgi::Graphics::Canvas]
14
+ # @raise [Sevgi::ArgumentError] when the paper profile is unknown
15
+ def self.call(...) = from_paper(...)
16
+
17
+ # Builds a canvas from a paper profile or explicit size.
18
+ # @param arg [Sevgi::Graphics::Paper, Symbol, String, Sevgi::Undefined] paper profile or paper object
19
+ # @param kwargs [Hash] canvas keyword arguments
20
+ # @return [Sevgi::Graphics::Canvas]
21
+ # @raise [Sevgi::ArgumentError] when the paper profile is unknown
22
+ def self.from_paper(arg = Undefined, **kwargs)
23
+ case arg
24
+ when Undefined
25
+ new(**kwargs)
26
+ else
27
+ new(**paper(arg).to_h, **kwargs)
28
+ end
29
+ end
30
+
31
+ def self.paper(arg)
32
+ case arg
33
+ when Paper
34
+ arg
35
+ when ::Symbol, ::String
36
+ ArgumentError.("Unknown paper profile: #{arg}") unless Paper.exist?(arg)
37
+
38
+ Paper.public_send(arg)
39
+ else
40
+ ArgumentError.("Argument must be a Paper symbol: #{arg}")
41
+ end
42
+ end
43
+
44
+ private_class_method :paper
45
+
46
+ extend Forwardable
47
+
48
+ def_delegators :@margin, *Margin.members
49
+ def_delegators :@size, *Paper.members
50
+
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
58
+
59
+ # Creates a canvas.
60
+ # @param width [Numeric] canvas width
61
+ # @param height [Numeric] canvas height
62
+ # @param unit [Symbol, String] SVG unit
63
+ # @param name [Symbol, String] paper name
64
+ # @param margins [Array<Numeric>] margin shorthand values
65
+ # @return [void]
66
+ # @raise [Sevgi::ArgumentError] when margins or numeric dimensions are invalid
67
+ def initialize(width:, height:, unit: "mm", name: :custom, margins: [])
68
+ @size = Paper[width, height, unit, name]
69
+ @margin = Margin[*margins]
70
+
71
+ compute
72
+ freeze
73
+ end
74
+
75
+ # @overload attributes(origin = Undefined)
76
+ # Returns SVG root viewport attributes.
77
+ # @param origin [Numeric, Array<Numeric>, nil, Sevgi::Undefined] viewBox origin
78
+ # @return [Hash]
79
+ # @raise [Sevgi::ArgumentError] when origin is invalid
80
+ def attributes(...) = {**viewport, viewBox: viewbox(...)}
81
+
82
+ # Returns SVG width and height attributes.
83
+ # @return [Hash]
84
+ def viewport = {width: "#{width}#{unit}", height: "#{height}#{unit}"}
85
+
86
+ # Returns the SVG viewBox string.
87
+ # @param origin [Numeric, Array<Numeric>, nil, Sevgi::Undefined] viewBox origin
88
+ # @return [String]
89
+ # @raise [Sevgi::ArgumentError] when origin is invalid
90
+ def viewbox(origin = Undefined) = prettify(*originate(origin), width, height).join(" ")
91
+
92
+ # Returns a canvas with selected fields replaced.
93
+ # @param kwargs [Hash] replacement options
94
+ # @return [Sevgi::Graphics::Canvas]
95
+ def with(**kwargs) = self.class.new(**size.to_h, margins: kwargs.fetch(:margins, margin.to_a))
96
+
97
+ private
98
+
99
+ def compute
100
+ @inner = size.with(width: width - margin.left - margin.right, height: height - margin.top - margin.bottom)
101
+ end
102
+
103
+ def originate(origin)
104
+ case origin
105
+ when Undefined
106
+ [-margin.left, -margin.top]
107
+ when ::Numeric, ::NilClass
108
+ [origin.to_f, origin.to_f]
109
+ when ::Array
110
+ pair(origin)
111
+ else
112
+ ArgumentError.("Canvas origin must be an Array")
113
+ end
114
+ end
115
+
116
+ def prettify(*floats)
117
+ floats.map { (it % 1).zero? ? it.to_i : it }
118
+ end
119
+
120
+ def pair(array)
121
+ array.size == 2 ? array.map(&:to_f) : ArgumentError.("Argument must be an Array of size 2")
122
+ end
123
+ end
124
+ end
125
+ end
@@ -0,0 +1,145 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sevgi
4
+ module Graphics
5
+ # Renderable text-like content inside an SVG element.
6
+ class Content
7
+ # @!attribute [r] content
8
+ # @return [Object] wrapped content
9
+ attr_reader :content
10
+
11
+ # Creates content.
12
+ # @param content [Object] wrapped content
13
+ # @return [void]
14
+ def initialize(content) = @content = content
15
+
16
+ # Renders content with a renderer.
17
+ # @abstract Subclasses implement content rendering.
18
+ # @param _renderer [Object] renderer receiving output
19
+ # @param _depth [Integer] current render depth
20
+ # @return [void]
21
+ # @raise [Sevgi::PanicError] when a subclass does not implement render
22
+ def render(_renderer, _depth) = PanicError.("#{self.class}#render must be implemented")
23
+
24
+ # Returns content as a string.
25
+ # @return [String]
26
+ def to_s = content.to_s
27
+
28
+ # @overload cdata(content)
29
+ # Builds CDATA content.
30
+ # @param content [Object] wrapped content
31
+ # @return [Sevgi::Graphics::Content::CData]
32
+ def self.cdata(...) = CData.new(...)
33
+
34
+ # Wraps content arguments, encoding non-content values.
35
+ # @param args [Array<Object>] content arguments
36
+ # @return [Array<Sevgi::Graphics::Content>]
37
+ def self.contents(*args) = args.map { it.is_a?(Content) ? it : encoded(it) }
38
+
39
+ # @overload css(content)
40
+ # Builds CSS content.
41
+ # @param content [Hash] CSS rules
42
+ # @return [Sevgi::Graphics::Content::CSS]
43
+ # @raise [Sevgi::ArgumentError] when content is not a hash
44
+ def self.css(...) = CSS.new(...)
45
+
46
+ # @overload encoded(content)
47
+ # Builds XML text-encoded content.
48
+ # @param content [Object] wrapped content
49
+ # @return [Sevgi::Graphics::Content::Encoded]
50
+ def self.encoded(...) = Encoded.new(...)
51
+
52
+ # Joins content lines with newlines.
53
+ # @param contents [Object, Array<Object>] content lines
54
+ # @return [String]
55
+ def self.text(contents) = Array(contents).join("\n")
56
+
57
+ # @overload verbatim(content)
58
+ # Builds verbatim content.
59
+ # @param content [Object] wrapped content
60
+ # @return [Sevgi::Graphics::Content::Verbatim]
61
+ def self.verbatim(...) = Verbatim.new(...)
62
+
63
+ # CDATA section content.
64
+ class CData < Content
65
+ # Renders CDATA content.
66
+ # @param renderer [Object] renderer receiving output
67
+ # @param depth [Integer] current render depth
68
+ # @return [void]
69
+ def render(renderer, depth)
70
+ depth += 1
71
+
72
+ renderer.append(depth, "<![CDATA[")
73
+ renderer.append(depth + 1, *Array(content))
74
+ renderer.append(depth, "]]>")
75
+ end
76
+ end
77
+
78
+ # CSS content rendered inside a CDATA section.
79
+ class CSS < Content
80
+ # Creates CSS content.
81
+ # @param content [Hash] CSS rules
82
+ # @return [void]
83
+ # @raise [Sevgi::ArgumentError] when content is not a hash
84
+ def initialize(content)
85
+ ArgumentError.("CSS content must be a hash: #{content}") unless content.is_a?(::Hash)
86
+
87
+ super
88
+ end
89
+
90
+ # rubocop:disable Metrics/MethodLength
91
+ # Renders CSS content.
92
+ # @param renderer [Object] renderer receiving output
93
+ # @param depth [Integer] current render depth
94
+ # @return [void]
95
+ # @raise [Sevgi::ArgumentError] when a style value cannot be rendered
96
+ def render(renderer, depth)
97
+ depth += 1
98
+
99
+ renderer.append(depth, "<![CDATA[")
100
+
101
+ depth += 1
102
+ content.each do |rule, styles|
103
+ case styles
104
+ when ::Hash
105
+ renderer.append(depth, "#{rule} {")
106
+ renderer.append(depth + 1, *styles.map { |key, value| "#{key}: #{value};" })
107
+ renderer.append(depth, "}")
108
+ when ::String, ::Symbol, ::Numeric
109
+ renderer.append(depth, "#{rule}: #{styles};")
110
+ else
111
+ ArgumentError.("Malformed style: #{styles}")
112
+ end
113
+ end
114
+
115
+ depth -= 1
116
+
117
+ renderer.append(depth, "]]>")
118
+ end
119
+ # rubocop:enable Metrics/MethodLength
120
+ end
121
+
122
+ # XML text-encoded content.
123
+ class Encoded < Content
124
+ # Returns XML text-encoded content.
125
+ # @return [String]
126
+ def to_s = content.encode(xml: :text)
127
+
128
+ # Renders encoded text content.
129
+ # @param renderer [Object] renderer receiving output
130
+ # @param depth [Integer] current render depth
131
+ # @return [void]
132
+ def render(renderer, depth) = renderer.append(depth + 1, to_s)
133
+ end
134
+
135
+ # Verbatim content rendered without XML text encoding.
136
+ class Verbatim < Content
137
+ # Renders verbatim content.
138
+ # @param renderer [Object] renderer receiving output
139
+ # @param depth [Integer] current render depth
140
+ # @return [void]
141
+ def render(renderer, depth) = renderer.append(depth + 1, to_s)
142
+ end
143
+ end
144
+ end
145
+ end
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sevgi
4
+ module Graphics
5
+ # Four-sided margin with CSS-like shorthand normalization.
6
+ Margin = Data.define(:top, :right, :bottom, :left) do
7
+ include Comparable
8
+
9
+ # @!attribute [r] top
10
+ # @return [Float] top margin
11
+ # @!attribute [r] right
12
+ # @return [Float] right margin
13
+ # @!attribute [r] bottom
14
+ # @return [Float] bottom margin
15
+ # @!attribute [r] left
16
+ # @return [Float] left margin
17
+
18
+ # Creates a margin from one to four shorthand values.
19
+ # @param top [Numeric, nil] top value or all-sides shorthand
20
+ # @param right [Numeric, nil] right value or horizontal shorthand
21
+ # @param bottom [Numeric, nil] bottom value
22
+ # @param left [Numeric, nil] left value
23
+ # @return [void]
24
+ def initialize(top: nil, right: nil, bottom: nil, left: nil)
25
+ super(**normalize(top, right, bottom, left))
26
+ end
27
+
28
+ # Compares margins by top, right, bottom, then left.
29
+ # @param other [Sevgi::Graphics::Margin] margin to compare
30
+ # @return [Integer, nil]
31
+ def <=>(other) = deconstruct <=> other.deconstruct
32
+
33
+ # Returns a margin inflated horizontally and vertically.
34
+ # @param h [Numeric] horizontal addition
35
+ # @param v [Numeric] vertical addition
36
+ # @return [Sevgi::Graphics::Margin]
37
+ def adjust(h, v) = self.class[top + v, right + h, bottom + v, left + h]
38
+
39
+ # Reports strict margin equality.
40
+ # @param other [Object] object to compare
41
+ # @return [Boolean]
42
+ def eql?(other) = self.class == other.class && deconstruct == other.deconstruct
43
+
44
+ # Returns a hash compatible with strict equality.
45
+ # @return [Integer]
46
+ def hash = [self.class, *deconstruct].hash
47
+
48
+ # Returns total horizontal margin.
49
+ # @return [Float]
50
+ def horizontal = left + right
51
+
52
+ # Returns total vertical margin.
53
+ # @return [Float]
54
+ def vertical = top + bottom
55
+
56
+ alias_method :==, :eql?
57
+ alias_method :to_a, :deconstruct
58
+
59
+ # Builds a margin from an array-like shorthand.
60
+ # @param array [Object] value converted with Array()
61
+ # @return [Sevgi::Graphics::Margin]
62
+ def self.margin(array)
63
+ self[
64
+ *(array = Array(array)[0...(size = Margin.members.size)]).fill(nil, array.size, size - array.size)
65
+ ]
66
+ end
67
+
68
+ # Returns a zero margin.
69
+ # @return [Sevgi::Graphics::Margin]
70
+ def self.zero = (@zero ||= self[0.0, 0.0, 0.0, 0.0])
71
+
72
+ private
73
+
74
+ def normalize(top, right, bottom, left)
75
+ values = [top, right, bottom, left]
76
+
77
+ Margin.members.zip(values_for(values.compact.size, values).map { Float(it) }).to_h
78
+ end
79
+
80
+ def values_for(size, values)
81
+ top, right, bottom = values
82
+
83
+ [
84
+ [0, 0, 0, 0],
85
+ [top, top, top, top],
86
+ [top, right, top, right],
87
+ [top, right, bottom, right]
88
+ ][size] ||
89
+ values
90
+ end
91
+ end
92
+ end
93
+ end