sevgi-graphics 0.73.2 → 0.94.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 (45) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +4 -0
  3. data/LICENSE +5 -0
  4. data/README.md +37 -0
  5. data/lib/sevgi/graphics/attribute.rb +82 -10
  6. data/lib/sevgi/graphics/auxiliary/canvas.rb +153 -0
  7. data/lib/sevgi/graphics/auxiliary/content.rb +157 -0
  8. data/lib/sevgi/graphics/auxiliary/margin.rb +93 -0
  9. data/lib/sevgi/graphics/auxiliary/paper.rb +163 -0
  10. data/lib/sevgi/graphics/auxiliary.rb +7 -0
  11. data/lib/sevgi/graphics/document/base.rb +6 -0
  12. data/lib/sevgi/graphics/document/default.rb +1 -0
  13. data/lib/sevgi/graphics/document/html.rb +1 -0
  14. data/lib/sevgi/graphics/document/inkscape.rb +1 -0
  15. data/lib/sevgi/graphics/document/minimal.rb +1 -0
  16. data/lib/sevgi/graphics/document.rb +205 -13
  17. data/lib/sevgi/graphics/element.rb +69 -9
  18. data/lib/sevgi/graphics/mixtures/call.rb +62 -4
  19. data/lib/sevgi/graphics/mixtures/core.rb +146 -25
  20. data/lib/sevgi/graphics/mixtures/duplicate.rb +47 -3
  21. data/lib/sevgi/graphics/mixtures/export.rb +31 -6
  22. data/lib/sevgi/graphics/mixtures/hatch.rb +24 -3
  23. data/lib/sevgi/graphics/mixtures/identify.rb +26 -2
  24. data/lib/sevgi/graphics/mixtures/include.rb +40 -4
  25. data/lib/sevgi/graphics/mixtures/inkscape.rb +51 -5
  26. data/lib/sevgi/graphics/mixtures/lint.rb +12 -0
  27. data/lib/sevgi/graphics/mixtures/polyfills.rb +11 -0
  28. data/lib/sevgi/graphics/mixtures/rdf.rb +40 -1
  29. data/lib/sevgi/graphics/mixtures/render.rb +136 -14
  30. data/lib/sevgi/graphics/mixtures/save.rb +21 -3
  31. data/lib/sevgi/graphics/mixtures/symbols.rb +7 -0
  32. data/lib/sevgi/graphics/mixtures/tile.rb +52 -9
  33. data/lib/sevgi/graphics/mixtures/transform.rb +54 -16
  34. data/lib/sevgi/graphics/mixtures/underscore.rb +21 -1
  35. data/lib/sevgi/graphics/mixtures/validate.rb +15 -1
  36. data/lib/sevgi/graphics/mixtures/wrappers.rb +54 -17
  37. data/lib/sevgi/graphics/mixtures.rb +35 -3
  38. data/lib/sevgi/graphics/version.rb +2 -1
  39. data/lib/sevgi/graphics.rb +71 -5
  40. metadata +12 -9
  41. data/lib/sevgi/graphics/auxilary/canvas.rb +0 -72
  42. data/lib/sevgi/graphics/auxilary/content.rb +0 -81
  43. data/lib/sevgi/graphics/auxilary/margin.rb +0 -49
  44. data/lib/sevgi/graphics/auxilary/paper.rb +0 -95
  45. 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: 74dd7631f2aeaa252c236f7dd1c372bd4860ebca07f23b787329aebdc04862b1
4
+ data.tar.gz: 04617a19db7b7e6485037ddd534f115f8c89d21dfd62cfa69de9fd8a6ff5a08f
5
5
  SHA512:
6
- metadata.gz: 95d77cf9671b69ea7cc16435187f3f700ef2062ea21e6d09b8261eef147c1ae533ce37f6bd403cbcdf5da98a6ad328e10be9a1fc1fc73597985715bc8767f490
7
- data.tar.gz: 2d376cdca8ce4f05ae58130590cabba02e88013ee3bd774f773cf8c0befab591bb2f1e5c4ed62fb0841748079df06961424f6646fb1163164ad26dd44f16e5dc
6
+ metadata.gz: 0acdf02ba08e49a7c3673e3457befc3c5270acb5fe05fd9985ec9d02b32f1231c79bc4127176ea3ebead9e3b9cd6e628af024e5de0aee951578eb72ff58e68d2
7
+ data.tar.gz: 90794ba32fd574c0747c61d71a7f0a47914bbf67a43e4239492cd97a8d9beec70cb482a9f2da5e095bf61e725f1dcafd2d4e0054561b884e0a6c32be5b8e2ee7
data/CHANGELOG.md ADDED
@@ -0,0 +1,4 @@
1
+ # Changelog
2
+
3
+ Sevgi Graphics follows the root Sevgi release notes:
4
+ https://github.com/roktas/sevgi/blob/main/CHANGELOG.md
data/LICENSE ADDED
@@ -0,0 +1,5 @@
1
+ Sevgi Graphics is distributed under the GNU General Public License v3.0 or later.
2
+
3
+ SPDX-License-Identifier: GPL-3.0-or-later
4
+
5
+ Full project license: https://github.com/roktas/sevgi/blob/main/LICENSE
data/README.md ADDED
@@ -0,0 +1,37 @@
1
+ # Sevgi Graphics
2
+
3
+ Core SVG DSL, document profiles, and rendering behavior.
4
+
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 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
@@ -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,74 @@ module Sevgi
25
41
 
26
42
  extend Ident
27
43
 
44
+ # Returns the text form used for an XML attribute value before escaping.
45
+ # @param value [Object] attribute value
46
+ # @return [String]
47
+ # @api private
48
+ def self.xml_text(value)
49
+ case value
50
+ when ::Hash
51
+ value.map { |key, attr_value| "#{key}:#{attr_value}" }.join("; ")
52
+ when ::Array
53
+ value.join(" ")
54
+ else
55
+ value.to_s
56
+ end
57
+ end
58
+
59
+ # Mutable SVG attribute store with Sevgi update syntax.
28
60
  class Store
61
+ # Creates an attribute store.
62
+ # @param attributes [Hash] initial attributes
63
+ # @return [void]
29
64
  def initialize(attributes = {})
30
65
  @store = {}
31
66
 
32
67
  import(attributes)
33
68
  end
34
69
 
70
+ # Imports attributes into the store.
71
+ # @param attributes [Hash] attributes to merge
72
+ # @return [Hash] internal store
35
73
  def import(attributes)
36
74
  hash = attributes
37
75
  .compact
38
76
  .to_a
39
77
  .to_h do |key, value|
40
- [key.to_sym, value.is_a?(::Hash) ? value.transform_keys!(&:to_sym) : value]
78
+ [key.to_sym, import_value(value)]
41
79
  end
42
80
 
43
81
  @store.merge!(hash)
44
82
  end
45
83
 
84
+ # Returns an attribute by normalized key.
85
+ # @param key [String, Symbol] attribute key
86
+ # @return [Object, nil]
46
87
  def [](key)
47
88
  @store[Attribute.id(key)]
48
89
  end
49
90
 
91
+ # Assigns an attribute value.
92
+ # @param key [String, Symbol] attribute key
93
+ # @param value [Object, nil] attribute value; nil is ignored
94
+ # @return [Object, nil] assigned value or nil
95
+ # @raise [Sevgi::ArgumentError] when update syntax receives incompatible values
96
+ # @raise [Sevgi::ArgumentError] when update syntax receives an unsupported value type
50
97
  def []=(key, value)
51
98
  return if value.nil?
52
99
 
53
100
  @store[id = Attribute.id(key)] = @store.key?(id) && Attribute.updateable?(key) ? update(id, value) : value
54
101
  end
55
102
 
103
+ # Deletes an attribute by normalized key.
104
+ # @param key [String, Symbol] attribute key
105
+ # @return [Object, nil] deleted value
56
106
  def delete(key)
57
107
  @store.delete(Attribute.id(key))
58
108
  end
59
109
 
110
+ # Returns public attributes ready for rendering.
111
+ # @return [Hash]
60
112
  def export
61
113
  hash = @store.reject { |id, _| Attribute.internal?(id) }
62
114
  return hash unless hash.key?(:id)
@@ -65,10 +117,16 @@ module Sevgi
65
117
  {id: hash.delete(:id), **hash}
66
118
  end
67
119
 
120
+ # Reports whether an attribute exists.
121
+ # @param key [String, Symbol] attribute key
122
+ # @return [Boolean]
68
123
  def has?(key)
69
124
  @store.key?(Attribute.id(key))
70
125
  end
71
126
 
127
+ # Copies the attribute store.
128
+ # @param original [Sevgi::Graphics::Attribute::Store] store to copy
129
+ # @return [void]
72
130
  def initialize_copy(original)
73
131
  @store = {}
74
132
  original.store.each { |key, value| @store[key] = value.dup }
@@ -76,14 +134,20 @@ module Sevgi
76
134
  super
77
135
  end
78
136
 
137
+ # Returns public attribute names.
138
+ # @return [Array<Symbol>]
79
139
  def list
80
140
  export.keys
81
141
  end
82
142
 
143
+ # Returns the internal attribute hash.
144
+ # @return [Hash]
83
145
  def to_h
84
146
  @store
85
147
  end
86
148
 
149
+ # Returns rendered XML attribute lines.
150
+ # @return [Array<String>]
87
151
  def to_xml_lines
88
152
  export.map { |id, value| to_xml(id, value) }
89
153
  end
@@ -94,11 +158,25 @@ module Sevgi
94
158
 
95
159
  private
96
160
 
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
+
97
175
  UPDATER = {
98
176
  ::String => proc { |old_value, new_value| [old_value, new_value].reject(&:empty?).join(" ") },
99
177
  ::Symbol => proc { |old_value, new_value| [old_value, new_value].reject(&:empty?).join(" ").to_sym },
100
178
  ::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)) }
179
+ ::Hash => proc { |old_value, new_value| old_value.merge(new_value.transform_keys(&:to_sym)) }
102
180
  }.freeze
103
181
 
104
182
  def update(id, new_value)
@@ -115,18 +193,12 @@ module Sevgi
115
193
  private_constant :UPDATER
116
194
 
117
195
  def to_xml(id, value)
118
- case value
119
- when ::Hash
120
- "#{id}=\"#{value.map { |key, attr_value| "#{key}:#{attr_value}" }.join("; ")}\""
121
- when ::Array
122
- "#{id}=\"#{value.join(" ")}\""
123
- else
124
- "#{id}=#{value.to_s.encode(xml: :attr)}"
125
- end
196
+ "#{id}=#{Attribute.xml_text(value).encode(xml: :attr)}"
126
197
  end
127
198
  end
128
199
  end
129
200
 
201
+ # Public alias for the SVG attribute store.
130
202
  Attributes = Attribute::Store
131
203
  end
132
204
  end
@@ -0,0 +1,153 @@
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
+ ORIGIN_FIELDS = %i[x y].freeze
10
+ REPLACEMENTS = %i[width height unit name margins].freeze
11
+ private_constant :ORIGIN_FIELDS, :REPLACEMENTS
12
+
13
+ # @overload call(arg = Undefined, **kwargs)
14
+ # Builds a canvas from a paper profile or explicit size.
15
+ # @param arg [Sevgi::Graphics::Paper, Symbol, String, Sevgi::Undefined] paper profile or paper object
16
+ # @param kwargs [Hash] canvas keyword arguments
17
+ # @return [Sevgi::Graphics::Canvas]
18
+ # @raise [Sevgi::ArgumentError] when the paper profile is unknown
19
+ def self.call(...) = from_paper(...)
20
+
21
+ # Builds a canvas from a paper profile or explicit size.
22
+ # @param arg [Sevgi::Graphics::Paper, Symbol, String, Sevgi::Undefined] paper profile or paper object
23
+ # @param kwargs [Hash] canvas keyword arguments
24
+ # @return [Sevgi::Graphics::Canvas]
25
+ # @raise [Sevgi::ArgumentError] when the paper profile is unknown
26
+ def self.from_paper(arg = Undefined, **kwargs)
27
+ case arg
28
+ when Undefined
29
+ new(**kwargs)
30
+ else
31
+ new(**paper(arg).to_h, **kwargs)
32
+ end
33
+ end
34
+
35
+ def self.paper(arg)
36
+ case arg
37
+ when Paper
38
+ arg
39
+ when ::Symbol, ::String
40
+ ArgumentError.("Unknown paper profile: #{arg}") unless Paper.exist?(arg)
41
+
42
+ Paper.public_send(arg)
43
+ else
44
+ ArgumentError.("Argument must be a Paper symbol: #{arg}")
45
+ end
46
+ end
47
+
48
+ private_class_method :paper
49
+
50
+ extend Forwardable
51
+
52
+ def_delegators :@margin, *Margin.members
53
+ def_delegators :@size, *Paper.members
54
+
55
+ # @!attribute [r] size
56
+ # @return [Sevgi::Graphics::Paper] paper size object
57
+ # @!attribute [r] margin
58
+ # @return [Sevgi::Graphics::Margin] canvas margins
59
+ # @!attribute [r] inner
60
+ # @return [Sevgi::Graphics::Paper] inner paper after margins
61
+ attr_reader :size, :margin, :inner
62
+
63
+ # Creates a canvas.
64
+ # @param width [Numeric] canvas width
65
+ # @param height [Numeric] canvas height
66
+ # @param unit [Symbol, String] SVG unit
67
+ # @param name [Symbol, String] paper name
68
+ # @param margins [Array<Numeric>] margin shorthand values
69
+ # @return [void]
70
+ # @raise [Sevgi::ArgumentError] when margins or numeric dimensions are invalid
71
+ def initialize(width:, height:, unit: "mm", name: :custom, margins: [])
72
+ @size = Paper[width, height, unit, name]
73
+ @margin = Margin[*margins]
74
+
75
+ compute
76
+ freeze
77
+ end
78
+
79
+ # @overload attributes(origin = Undefined)
80
+ # Returns SVG root viewport attributes.
81
+ # @param origin [Numeric, Array<Numeric>, nil, Sevgi::Undefined] viewBox origin
82
+ # @return [Hash]
83
+ # @raise [Sevgi::ArgumentError] when origin is invalid
84
+ def attributes(...) = {**viewport, viewBox: viewbox(...)}
85
+
86
+ # Returns SVG width and height attributes.
87
+ # @return [Hash]
88
+ def viewport = {width: "#{width}#{unit}", height: "#{height}#{unit}"}
89
+
90
+ # Returns the SVG viewBox string.
91
+ # @param origin [Numeric, Array<Numeric>, nil, Sevgi::Undefined] viewBox origin
92
+ # @return [String]
93
+ # @raise [Sevgi::ArgumentError] when origin is invalid
94
+ def viewbox(origin = Undefined) = prettify(*originate(origin), width, height).join(" ")
95
+
96
+ # Returns a canvas with selected fields replaced.
97
+ # @param kwargs [Hash] replacement options
98
+ # @option kwargs [Numeric] :width replacement canvas width
99
+ # @option kwargs [Numeric] :height replacement canvas height
100
+ # @option kwargs [Symbol, String] :unit replacement SVG unit
101
+ # @option kwargs [Symbol, String] :name replacement paper name
102
+ # @option kwargs [Array<Numeric>] :margins replacement margin shorthand values
103
+ # @return [Sevgi::Graphics::Canvas]
104
+ # @raise [Sevgi::ArgumentError] when an unknown option is supplied
105
+ # @raise [Sevgi::ArgumentError] when a replacement value is invalid
106
+ def with(**kwargs)
107
+ unknown = kwargs.keys - REPLACEMENTS
108
+
109
+ ArgumentError.("Unknown canvas option: #{unknown.first}") unless unknown.empty?
110
+
111
+ margins = kwargs.fetch(:margins, margin.to_a)
112
+ replacements = kwargs.dup.tap { it.delete(:margins) }
113
+
114
+ self.class.new(**size.to_h, **replacements, margins:)
115
+ end
116
+
117
+ private
118
+
119
+ def compute
120
+ @inner = size.with(width: width - margin.left - margin.right, height: height - margin.top - margin.bottom)
121
+ end
122
+
123
+ def originate(origin)
124
+ case origin
125
+ when Undefined
126
+ [-margin.left, -margin.top]
127
+ when ::Numeric, ::NilClass
128
+ [origin.to_f, origin.to_f]
129
+ when ::Array
130
+ pair(origin)
131
+ else
132
+ ArgumentError.("Canvas origin must be an Array")
133
+ end
134
+ end
135
+
136
+ def coordinate!(field, value)
137
+ Float(value)
138
+ rescue ::ArgumentError, ::TypeError
139
+ ArgumentError.("Invalid canvas origin #{field}: #{value.inspect}")
140
+ end
141
+
142
+ def prettify(*floats)
143
+ floats.map { (it % 1).zero? ? it.to_i : it }
144
+ end
145
+
146
+ def pair(array)
147
+ ArgumentError.("Canvas origin must have exactly two coordinates") unless array.size == 2
148
+
149
+ ORIGIN_FIELDS.zip(array).map { |field, value| coordinate!(field, value) }
150
+ end
151
+ end
152
+ end
153
+ end
@@ -0,0 +1,157 @@
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
+ # Content values are stringified during rendering, and embedded CDATA terminators are split safely.
31
+ # @param content [Object] wrapped content
32
+ # @return [Sevgi::Graphics::Content::CData]
33
+ def self.cdata(...) = CData.new(...)
34
+
35
+ # Wraps content arguments, encoding non-content values.
36
+ # Non-content values are stringified by encoded content before XML text escaping.
37
+ # @param args [Array<Object>] content arguments
38
+ # @return [Array<Sevgi::Graphics::Content>]
39
+ def self.contents(*args) = args.map { it.is_a?(Content) ? it : encoded(it) }
40
+
41
+ # @overload css(content)
42
+ # Builds CSS content.
43
+ # @param content [Hash] CSS rules
44
+ # @return [Sevgi::Graphics::Content::CSS]
45
+ # @raise [Sevgi::ArgumentError] when content is not a hash
46
+ def self.css(...) = CSS.new(...)
47
+
48
+ # @overload encoded(content)
49
+ # Builds XML text-encoded content.
50
+ # Arbitrary objects are stringified before XML text escaping.
51
+ # @param content [Object] wrapped content
52
+ # @return [Sevgi::Graphics::Content::Encoded]
53
+ def self.encoded(...) = Encoded.new(...)
54
+
55
+ # Joins content lines with newlines.
56
+ # @param contents [Object, Array<Object>] content lines
57
+ # @return [String]
58
+ def self.text(contents) = Array(contents).join("\n")
59
+
60
+ # @overload verbatim(content)
61
+ # Builds verbatim content.
62
+ # @param content [Object] wrapped content
63
+ # @return [Sevgi::Graphics::Content::Verbatim]
64
+ def self.verbatim(...) = Verbatim.new(...)
65
+
66
+ # CDATA section content.
67
+ class CData < Content
68
+ TERMINATOR = "]]>"
69
+ TERMINATOR_SPLIT = "]]]]><![CDATA[>"
70
+ private_constant :TERMINATOR, :TERMINATOR_SPLIT
71
+
72
+ # Renders CDATA content.
73
+ # Embedded `]]>` terminators are split across adjacent CDATA sections so the output remains valid XML.
74
+ # @param renderer [Object] renderer receiving output
75
+ # @param depth [Integer] current render depth
76
+ # @return [void]
77
+ def render(renderer, depth)
78
+ depth += 1
79
+
80
+ renderer.append(depth, "<![CDATA[")
81
+ renderer.append(depth + 1, *Array(content).map { safe(it) })
82
+ renderer.append(depth, "]]>")
83
+ end
84
+
85
+ private
86
+
87
+ def safe(value) = value.to_s.gsub(TERMINATOR, TERMINATOR_SPLIT)
88
+ end
89
+
90
+ # CSS content rendered inside a CDATA section.
91
+ class CSS < Content
92
+ # Creates CSS content.
93
+ # @param content [Hash] CSS rules
94
+ # @return [void]
95
+ # @raise [Sevgi::ArgumentError] when content is not a hash
96
+ def initialize(content)
97
+ ArgumentError.("CSS content must be a hash: #{content}") unless content.is_a?(::Hash)
98
+
99
+ super
100
+ end
101
+
102
+ # rubocop:disable Metrics/MethodLength
103
+ # Renders CSS content.
104
+ # @param renderer [Object] renderer receiving output
105
+ # @param depth [Integer] current render depth
106
+ # @return [void]
107
+ # @raise [Sevgi::ArgumentError] when a style value cannot be rendered
108
+ def render(renderer, depth)
109
+ depth += 1
110
+
111
+ renderer.append(depth, "<![CDATA[")
112
+
113
+ depth += 1
114
+ content.each do |rule, styles|
115
+ case styles
116
+ when ::Hash
117
+ renderer.append(depth, "#{rule} {")
118
+ renderer.append(depth + 1, *styles.map { |key, value| "#{key}: #{value};" })
119
+ renderer.append(depth, "}")
120
+ when ::String, ::Symbol, ::Numeric
121
+ renderer.append(depth, "#{rule}: #{styles};")
122
+ else
123
+ ArgumentError.("Malformed style: #{styles}")
124
+ end
125
+ end
126
+
127
+ depth -= 1
128
+
129
+ renderer.append(depth, "]]>")
130
+ end
131
+ # rubocop:enable Metrics/MethodLength
132
+ end
133
+
134
+ # XML text-encoded content.
135
+ class Encoded < Content
136
+ # Returns XML text-encoded content.
137
+ # @return [String]
138
+ def to_s = content.to_s.encode(xml: :text)
139
+
140
+ # Renders encoded text content.
141
+ # @param renderer [Object] renderer receiving output
142
+ # @param depth [Integer] current render depth
143
+ # @return [void]
144
+ def render(renderer, depth) = renderer.append(depth + 1, to_s)
145
+ end
146
+
147
+ # Verbatim content rendered without XML text encoding.
148
+ class Verbatim < Content
149
+ # Renders verbatim content.
150
+ # @param renderer [Object] renderer receiving output
151
+ # @param depth [Integer] current render depth
152
+ # @return [void]
153
+ def render(renderer, depth) = renderer.append(depth + 1, to_s)
154
+ end
155
+ end
156
+ end
157
+ 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