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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +4 -0
- data/LICENSE +5 -0
- data/README.md +37 -0
- data/lib/sevgi/graphics/attribute.rb +82 -10
- data/lib/sevgi/graphics/auxiliary/canvas.rb +153 -0
- data/lib/sevgi/graphics/auxiliary/content.rb +157 -0
- data/lib/sevgi/graphics/auxiliary/margin.rb +93 -0
- data/lib/sevgi/graphics/auxiliary/paper.rb +163 -0
- data/lib/sevgi/graphics/auxiliary.rb +7 -0
- data/lib/sevgi/graphics/document/base.rb +6 -0
- data/lib/sevgi/graphics/document/default.rb +1 -0
- data/lib/sevgi/graphics/document/html.rb +1 -0
- data/lib/sevgi/graphics/document/inkscape.rb +1 -0
- data/lib/sevgi/graphics/document/minimal.rb +1 -0
- data/lib/sevgi/graphics/document.rb +205 -13
- data/lib/sevgi/graphics/element.rb +69 -9
- data/lib/sevgi/graphics/mixtures/call.rb +62 -4
- data/lib/sevgi/graphics/mixtures/core.rb +146 -25
- data/lib/sevgi/graphics/mixtures/duplicate.rb +47 -3
- data/lib/sevgi/graphics/mixtures/export.rb +31 -6
- data/lib/sevgi/graphics/mixtures/hatch.rb +24 -3
- data/lib/sevgi/graphics/mixtures/identify.rb +26 -2
- data/lib/sevgi/graphics/mixtures/include.rb +40 -4
- data/lib/sevgi/graphics/mixtures/inkscape.rb +51 -5
- data/lib/sevgi/graphics/mixtures/lint.rb +12 -0
- data/lib/sevgi/graphics/mixtures/polyfills.rb +11 -0
- data/lib/sevgi/graphics/mixtures/rdf.rb +40 -1
- data/lib/sevgi/graphics/mixtures/render.rb +136 -14
- data/lib/sevgi/graphics/mixtures/save.rb +21 -3
- data/lib/sevgi/graphics/mixtures/symbols.rb +7 -0
- data/lib/sevgi/graphics/mixtures/tile.rb +52 -9
- data/lib/sevgi/graphics/mixtures/transform.rb +54 -16
- data/lib/sevgi/graphics/mixtures/underscore.rb +21 -1
- data/lib/sevgi/graphics/mixtures/validate.rb +15 -1
- data/lib/sevgi/graphics/mixtures/wrappers.rb +54 -17
- data/lib/sevgi/graphics/mixtures.rb +35 -3
- data/lib/sevgi/graphics/version.rb +2 -1
- data/lib/sevgi/graphics.rb +71 -5
- metadata +12 -9
- data/lib/sevgi/graphics/auxilary/canvas.rb +0 -72
- data/lib/sevgi/graphics/auxilary/content.rb +0 -81
- data/lib/sevgi/graphics/auxilary/margin.rb +0 -49
- data/lib/sevgi/graphics/auxilary/paper.rb +0 -95
- data/lib/sevgi/graphics/auxilary.rb +0 -7
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 74dd7631f2aeaa252c236f7dd1c372bd4860ebca07f23b787329aebdc04862b1
|
|
4
|
+
data.tar.gz: 04617a19db7b7e6485037ddd534f115f8c89d21dfd62cfa69de9fd8a6ff5a08f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 0acdf02ba08e49a7c3673e3457befc3c5270acb5fe05fd9985ec9d02b32f1231c79bc4127176ea3ebead9e3b9cd6e628af024e5de0aee951578eb72ff58e68d2
|
|
7
|
+
data.tar.gz: 90794ba32fd574c0747c61d71a7f0a47914bbf67a43e4239492cd97a8d9beec70cb482a9f2da5e095bf61e725f1dcafd2d4e0054561b884e0a6c32be5b8e2ee7
|
data/CHANGELOG.md
ADDED
data/LICENSE
ADDED
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,
|
|
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(
|
|
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
|
-
|
|
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
|