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
|
@@ -2,16 +2,101 @@
|
|
|
2
2
|
|
|
3
3
|
module Sevgi
|
|
4
4
|
module Graphics
|
|
5
|
-
# Renderable text-like content inside an SVG element.
|
|
5
|
+
# Renderable text-like content inside an SVG element. Content owns an immutable deep snapshot: strings and containers
|
|
6
|
+
# are copied, mutable leaf objects are stringified once during construction, and {#content} returns caller-owned
|
|
7
|
+
# copies. Later mutations cannot change rendering or invalidate the construction-time XML checks.
|
|
6
8
|
class Content
|
|
7
|
-
#
|
|
8
|
-
#
|
|
9
|
-
|
|
9
|
+
# Immutable payload capture and caller-owned copy helpers.
|
|
10
|
+
# @api private
|
|
11
|
+
module Snapshot
|
|
12
|
+
SCALARS = [::NilClass, ::TrueClass, ::FalseClass, ::Symbol, ::Integer, ::Float, ::Rational, ::Complex].freeze
|
|
13
|
+
private_constant :SCALARS
|
|
10
14
|
|
|
11
|
-
|
|
15
|
+
class << self
|
|
16
|
+
def capture(value, seen = {}.compare_by_identity)
|
|
17
|
+
case value
|
|
18
|
+
when ::String
|
|
19
|
+
XML.text(value).freeze
|
|
20
|
+
when ::Hash
|
|
21
|
+
nested(value, seen) { capture_hash(value, seen) }.freeze
|
|
22
|
+
when ::Array
|
|
23
|
+
nested(value, seen) { value.map { capture(it, seen) } }.freeze
|
|
24
|
+
else
|
|
25
|
+
SCALARS.include?(value.class) ? value : stringify(value).freeze
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def copy(value)
|
|
30
|
+
case value
|
|
31
|
+
when ::String
|
|
32
|
+
value.dup
|
|
33
|
+
when ::Hash
|
|
34
|
+
value.to_h { |key, item| [copy(key), copy(item)] }
|
|
35
|
+
when ::Array
|
|
36
|
+
value.map { copy(it) }
|
|
37
|
+
else
|
|
38
|
+
value
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
def capture_hash(value, seen)
|
|
45
|
+
value.each_with_object({}) do |(key, item), captured|
|
|
46
|
+
key = capture(key, seen)
|
|
47
|
+
ArgumentError.("XML content keys collide after stringification") if captured.key?(key)
|
|
48
|
+
|
|
49
|
+
captured[key] = capture(item, seen)
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def nested(value, seen)
|
|
54
|
+
ArgumentError.("Cyclic XML content is not supported") if seen.key?(value)
|
|
55
|
+
|
|
56
|
+
seen[value] = true
|
|
57
|
+
yield
|
|
58
|
+
ensure
|
|
59
|
+
seen.delete(value)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def stringify(value)
|
|
63
|
+
text = value.to_s
|
|
64
|
+
ArgumentError.("XML content stringification must return a String") unless text.is_a?(::String)
|
|
65
|
+
|
|
66
|
+
XML.text(text)
|
|
67
|
+
rescue Sevgi::ArgumentError
|
|
68
|
+
raise
|
|
69
|
+
rescue ::StandardError => e
|
|
70
|
+
ArgumentError.("XML content cannot be stringified: #{e.class}: #{e.message}")
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
private_constant :Snapshot
|
|
76
|
+
|
|
77
|
+
# Returns a recursively independent, caller-owned payload snapshot.
|
|
78
|
+
# @return [Object] wrapped content snapshot
|
|
79
|
+
def content = Snapshot.copy(@content)
|
|
80
|
+
|
|
81
|
+
# Creates immutable content from a deep payload snapshot. Strings and containers are copied recursively; mutable
|
|
82
|
+
# non-container objects are stringified once during construction. The caller's objects are never retained.
|
|
12
83
|
# @param content [Object] wrapped content
|
|
13
84
|
# @return [void]
|
|
14
|
-
|
|
85
|
+
# @raise [Sevgi::ArgumentError] when content cannot be stringified, contains invalid encoding or illegal XML
|
|
86
|
+
# characters, contains cycles, or has keys that collide after stringification
|
|
87
|
+
def initialize(content)
|
|
88
|
+
@content = Snapshot.capture(content)
|
|
89
|
+
XML.validate(@content)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Copies content payload ownership for duplicated element trees.
|
|
93
|
+
# @param original [Sevgi::Graphics::Content] source content
|
|
94
|
+
# @return [void]
|
|
95
|
+
# @api private
|
|
96
|
+
def initialize_copy(original)
|
|
97
|
+
@content = Snapshot.capture(original.content)
|
|
98
|
+
super
|
|
99
|
+
end
|
|
15
100
|
|
|
16
101
|
# Renders content with a renderer.
|
|
17
102
|
# @abstract Subclasses implement content rendering.
|
|
@@ -23,30 +108,45 @@ module Sevgi
|
|
|
23
108
|
|
|
24
109
|
# Returns content as a string.
|
|
25
110
|
# @return [String]
|
|
26
|
-
def to_s =
|
|
111
|
+
def to_s = XML.text(payload)
|
|
112
|
+
|
|
113
|
+
def payload = @content
|
|
114
|
+
|
|
115
|
+
private :payload
|
|
27
116
|
|
|
28
117
|
# @overload cdata(content)
|
|
29
118
|
# Builds CDATA content.
|
|
119
|
+
# Mutable objects are stringified during construction, and embedded CDATA terminators are split safely.
|
|
30
120
|
# @param content [Object] wrapped content
|
|
31
121
|
# @return [Sevgi::Graphics::Content::CData]
|
|
122
|
+
# @raise [Sevgi::ArgumentError] when content cannot be stringified, contains invalid encoding or illegal XML 1.0
|
|
123
|
+
# characters, contains cycles, or has keys that collide after stringification
|
|
32
124
|
def self.cdata(...) = CData.new(...)
|
|
33
125
|
|
|
34
126
|
# Wraps content arguments, encoding non-content values.
|
|
127
|
+
# Mutable non-content values are stringified by encoded content during construction, before XML text escaping.
|
|
35
128
|
# @param args [Array<Object>] content arguments
|
|
36
129
|
# @return [Array<Sevgi::Graphics::Content>]
|
|
130
|
+
# @raise [Sevgi::ArgumentError] when an argument cannot be stringified, contains invalid encoding or illegal XML
|
|
131
|
+
# 1.0 characters, contains cycles, or has keys that collide after stringification
|
|
37
132
|
def self.contents(*args) = args.map { it.is_a?(Content) ? it : encoded(it) }
|
|
38
133
|
|
|
39
134
|
# @overload css(content)
|
|
40
135
|
# Builds CSS content.
|
|
41
136
|
# @param content [Hash] CSS rules
|
|
42
137
|
# @return [Sevgi::Graphics::Content::CSS]
|
|
43
|
-
# @raise [Sevgi::ArgumentError] when content is not a hash
|
|
138
|
+
# @raise [Sevgi::ArgumentError] when content is not a hash, contains a malformed style, cannot be stringified,
|
|
139
|
+
# contains invalid encoding or illegal XML 1.0 characters, contains cycles, or has keys that collide after
|
|
140
|
+
# stringification
|
|
44
141
|
def self.css(...) = CSS.new(...)
|
|
45
142
|
|
|
46
143
|
# @overload encoded(content)
|
|
47
144
|
# Builds XML text-encoded content.
|
|
145
|
+
# Mutable objects are stringified during construction before XML text escaping.
|
|
48
146
|
# @param content [Object] wrapped content
|
|
49
147
|
# @return [Sevgi::Graphics::Content::Encoded]
|
|
148
|
+
# @raise [Sevgi::ArgumentError] when content cannot be stringified, contains invalid encoding or illegal XML 1.0
|
|
149
|
+
# characters, contains cycles, or has keys that collide after stringification
|
|
50
150
|
def self.encoded(...) = Encoded.new(...)
|
|
51
151
|
|
|
52
152
|
# Joins content lines with newlines.
|
|
@@ -56,13 +156,19 @@ module Sevgi
|
|
|
56
156
|
|
|
57
157
|
# @overload verbatim(content)
|
|
58
158
|
# Builds verbatim content.
|
|
159
|
+
# Mutable objects are stringified during construction.
|
|
59
160
|
# @param content [Object] wrapped content
|
|
60
161
|
# @return [Sevgi::Graphics::Content::Verbatim]
|
|
162
|
+
# @raise [Sevgi::ArgumentError] when content cannot be stringified, contains invalid encoding or illegal XML 1.0
|
|
163
|
+
# characters, contains cycles, or has keys that collide after stringification
|
|
61
164
|
def self.verbatim(...) = Verbatim.new(...)
|
|
62
165
|
|
|
63
|
-
# CDATA section content.
|
|
166
|
+
# CDATA section content backed by an immutable payload snapshot. Mutable leaf objects are stringified during
|
|
167
|
+
# construction; embedded terminators are split during rendering.
|
|
168
|
+
# @see Content.cdata
|
|
64
169
|
class CData < Content
|
|
65
170
|
# Renders CDATA content.
|
|
171
|
+
# Embedded `]]>` terminators are split across adjacent CDATA sections so the output remains valid XML.
|
|
66
172
|
# @param renderer [Object] renderer receiving output
|
|
67
173
|
# @param depth [Integer] current render depth
|
|
68
174
|
# @return [void]
|
|
@@ -70,19 +176,28 @@ module Sevgi
|
|
|
70
176
|
depth += 1
|
|
71
177
|
|
|
72
178
|
renderer.append(depth, "<![CDATA[")
|
|
73
|
-
renderer.append(depth + 1, *Array(
|
|
179
|
+
renderer.append(depth + 1, *Array(payload).map { safe(it) })
|
|
74
180
|
renderer.append(depth, "]]>")
|
|
75
181
|
end
|
|
182
|
+
|
|
183
|
+
private
|
|
184
|
+
|
|
185
|
+
def safe(value) = XML.cdata(value)
|
|
76
186
|
end
|
|
77
187
|
|
|
78
|
-
# CSS content rendered inside a CDATA section.
|
|
188
|
+
# CSS content rendered inside a CDATA section. Rules are captured recursively during construction; mutable
|
|
189
|
+
# selectors, property names, and values are stringified once, and embedded CDATA terminators are split safely.
|
|
190
|
+
# @see Content.css
|
|
79
191
|
class CSS < Content
|
|
80
192
|
# Creates CSS content.
|
|
81
193
|
# @param content [Hash] CSS rules
|
|
82
194
|
# @return [void]
|
|
83
|
-
# @raise [Sevgi::ArgumentError] when content is not a hash
|
|
195
|
+
# @raise [Sevgi::ArgumentError] when content is not a hash, contains a malformed style, cannot be stringified,
|
|
196
|
+
# contains invalid encoding or illegal XML 1.0 characters, contains cycles, or has keys that collide after
|
|
197
|
+
# stringification
|
|
84
198
|
def initialize(content)
|
|
85
|
-
ArgumentError.("CSS content must be a hash
|
|
199
|
+
ArgumentError.("CSS content must be a hash") unless content.is_a?(::Hash)
|
|
200
|
+
validate_styles(content)
|
|
86
201
|
|
|
87
202
|
super
|
|
88
203
|
end
|
|
@@ -92,21 +207,20 @@ module Sevgi
|
|
|
92
207
|
# @param renderer [Object] renderer receiving output
|
|
93
208
|
# @param depth [Integer] current render depth
|
|
94
209
|
# @return [void]
|
|
95
|
-
# @raise [Sevgi::ArgumentError] when a style value cannot be rendered
|
|
96
210
|
def render(renderer, depth)
|
|
97
211
|
depth += 1
|
|
98
212
|
|
|
99
213
|
renderer.append(depth, "<![CDATA[")
|
|
100
214
|
|
|
101
215
|
depth += 1
|
|
102
|
-
|
|
216
|
+
payload.each do |rule, styles|
|
|
103
217
|
case styles
|
|
104
218
|
when ::Hash
|
|
105
|
-
renderer.append(depth, "#{rule} {")
|
|
106
|
-
renderer.append(depth + 1, *styles.map { |key, value| "#{key}: #{value};" })
|
|
219
|
+
renderer.append(depth, safe("#{rule} {"))
|
|
220
|
+
renderer.append(depth + 1, *styles.map { |key, value| safe("#{key}: #{value};") })
|
|
107
221
|
renderer.append(depth, "}")
|
|
108
222
|
when ::String, ::Symbol, ::Numeric
|
|
109
|
-
renderer.append(depth, "#{rule}: #{styles};")
|
|
223
|
+
renderer.append(depth, safe("#{rule}: #{styles};"))
|
|
110
224
|
else
|
|
111
225
|
ArgumentError.("Malformed style: #{styles}")
|
|
112
226
|
end
|
|
@@ -117,13 +231,27 @@ module Sevgi
|
|
|
117
231
|
renderer.append(depth, "]]>")
|
|
118
232
|
end
|
|
119
233
|
# rubocop:enable Metrics/MethodLength
|
|
234
|
+
|
|
235
|
+
private
|
|
236
|
+
|
|
237
|
+
def safe(value) = XML.cdata(value)
|
|
238
|
+
|
|
239
|
+
def validate_styles(content)
|
|
240
|
+
content.each_value do |styles|
|
|
241
|
+
next if styles.is_a?(::Hash) || styles.is_a?(::String) || styles.is_a?(::Symbol) || styles.is_a?(::Numeric)
|
|
242
|
+
|
|
243
|
+
ArgumentError.("Malformed style type: #{styles.class}")
|
|
244
|
+
end
|
|
245
|
+
end
|
|
120
246
|
end
|
|
121
247
|
|
|
122
|
-
# XML text-encoded content.
|
|
248
|
+
# XML text-encoded content backed by an immutable payload snapshot. Mutable leaf objects are stringified during
|
|
249
|
+
# construction, before XML escaping.
|
|
250
|
+
# @see Content.encoded
|
|
123
251
|
class Encoded < Content
|
|
124
252
|
# Returns XML text-encoded content.
|
|
125
253
|
# @return [String]
|
|
126
|
-
def to_s =
|
|
254
|
+
def to_s = XML.text(payload).encode(xml: :text)
|
|
127
255
|
|
|
128
256
|
# Renders encoded text content.
|
|
129
257
|
# @param renderer [Object] renderer receiving output
|
|
@@ -132,8 +260,15 @@ module Sevgi
|
|
|
132
260
|
def render(renderer, depth) = renderer.append(depth + 1, to_s)
|
|
133
261
|
end
|
|
134
262
|
|
|
135
|
-
# Verbatim content
|
|
263
|
+
# Verbatim content backed by an immutable payload snapshot. Mutable leaf objects are stringified during
|
|
264
|
+
# construction. Verbatim content bypasses XML escaping; validation guarantees encoding and legal XML 1.0 code
|
|
265
|
+
# points, not well-formed markup supplied by the caller.
|
|
266
|
+
# @see Content.verbatim
|
|
136
267
|
class Verbatim < Content
|
|
268
|
+
# Returns validated verbatim content.
|
|
269
|
+
# @return [String]
|
|
270
|
+
def to_s = XML.text(payload)
|
|
271
|
+
|
|
137
272
|
# Renders verbatim content.
|
|
138
273
|
# @param renderer [Object] renderer receiving output
|
|
139
274
|
# @param depth [Integer] current render depth
|
|
@@ -15,12 +15,14 @@ module Sevgi
|
|
|
15
15
|
# @!attribute [r] left
|
|
16
16
|
# @return [Float] left margin
|
|
17
17
|
|
|
18
|
-
# Creates a margin from one to four shorthand values.
|
|
18
|
+
# Creates a margin from one to four shorthand values. Values must be finite real numbers greater than or equal to
|
|
19
|
+
# zero.
|
|
19
20
|
# @param top [Numeric, nil] top value or all-sides shorthand
|
|
20
21
|
# @param right [Numeric, nil] right value or horizontal shorthand
|
|
21
22
|
# @param bottom [Numeric, nil] bottom value
|
|
22
23
|
# @param left [Numeric, nil] left value
|
|
23
24
|
# @return [void]
|
|
25
|
+
# @raise [Sevgi::ArgumentError] when a supplied margin is not a finite, non-negative real number
|
|
24
26
|
def initialize(top: nil, right: nil, bottom: nil, left: nil)
|
|
25
27
|
super(**normalize(top, right, bottom, left))
|
|
26
28
|
end
|
|
@@ -54,6 +56,9 @@ module Sevgi
|
|
|
54
56
|
def vertical = top + bottom
|
|
55
57
|
|
|
56
58
|
alias_method :==, :eql?
|
|
59
|
+
|
|
60
|
+
# Returns margin values in top, right, bottom, left order.
|
|
61
|
+
# @return [Array<Float>]
|
|
57
62
|
alias_method :to_a, :deconstruct
|
|
58
63
|
|
|
59
64
|
# Builds a margin from an array-like shorthand.
|
|
@@ -74,7 +79,14 @@ module Sevgi
|
|
|
74
79
|
def normalize(top, right, bottom, left)
|
|
75
80
|
values = [top, right, bottom, left]
|
|
76
81
|
|
|
77
|
-
Margin
|
|
82
|
+
Margin
|
|
83
|
+
.members
|
|
84
|
+
.zip(
|
|
85
|
+
values_for(values.compact.size, values).map do |value|
|
|
86
|
+
Scalar.finite(value, context: "margin", field: :value, nonnegative: true)
|
|
87
|
+
end
|
|
88
|
+
)
|
|
89
|
+
.to_h
|
|
78
90
|
end
|
|
79
91
|
|
|
80
92
|
def values_for(size, values)
|
|
@@ -15,26 +15,30 @@ module Sevgi
|
|
|
15
15
|
# @!attribute [r] name
|
|
16
16
|
# @return [Symbol] profile name
|
|
17
17
|
|
|
18
|
-
# Creates a paper profile.
|
|
18
|
+
# Creates a paper profile. Dimensions must be finite real numbers greater than zero.
|
|
19
19
|
# @param width [Numeric] paper width
|
|
20
20
|
# @param height [Numeric] paper height
|
|
21
21
|
# @param unit [Symbol, String] SVG unit
|
|
22
22
|
# @param name [Symbol, String] profile name
|
|
23
|
+
# @param options [Hash] unsupported extra options
|
|
23
24
|
# @return [void]
|
|
24
|
-
# @raise [Sevgi::ArgumentError] when dimensions, unit, or
|
|
25
|
-
def initialize(width:, height:, unit: "mm", name: :custom)
|
|
25
|
+
# @raise [Sevgi::ArgumentError] when dimensions, unit, name, or options are invalid
|
|
26
|
+
def initialize(width:, height:, unit: "mm", name: :custom, **options)
|
|
27
|
+
self.class.send(:options!, options)
|
|
26
28
|
super(
|
|
27
29
|
width: self.class.send(:dimension!, :width, width),
|
|
28
30
|
height: self.class.send(:dimension!, :height, height),
|
|
29
|
-
unit: self.class.send(:
|
|
30
|
-
name: self.class.send(:
|
|
31
|
+
unit: self.class.send(:normalize!, :unit, unit),
|
|
32
|
+
name: self.class.send(:normalize!, :name, name)
|
|
31
33
|
)
|
|
32
34
|
end
|
|
33
35
|
|
|
34
36
|
# Compares papers by width, height, unit, then name.
|
|
35
37
|
# @param other [Sevgi::Graphics::Paper] paper to compare
|
|
36
|
-
# @return [Integer, nil]
|
|
37
|
-
def <=>(other)
|
|
38
|
+
# @return [Integer, nil] comparison result, or nil for a non-Paper operand
|
|
39
|
+
def <=>(other)
|
|
40
|
+
deconstruct <=> other.deconstruct if other.is_a?(self.class)
|
|
41
|
+
end
|
|
38
42
|
|
|
39
43
|
# Reports strict paper equality.
|
|
40
44
|
# @param other [Object] object to compare
|
|
@@ -56,108 +60,111 @@ module Sevgi
|
|
|
56
60
|
alias_method :==, :eql?
|
|
57
61
|
|
|
58
62
|
@profiles = {}
|
|
63
|
+
@accessors = {}
|
|
64
|
+
@mutex = ::Mutex.new
|
|
59
65
|
|
|
60
|
-
# Reports whether a named paper profile exists.
|
|
66
|
+
# Reports whether a normalizable named paper profile exists. Invalid converters return false.
|
|
61
67
|
# @param name [Object] profile name
|
|
62
68
|
# @return [Boolean]
|
|
63
|
-
def self.exist?(name)
|
|
69
|
+
def self.exist?(name)
|
|
70
|
+
name = normalize(name)
|
|
71
|
+
name ? @mutex.synchronize { @profiles.key?(name) } : false
|
|
72
|
+
end
|
|
64
73
|
|
|
65
|
-
# Defines
|
|
74
|
+
# Defines a named paper profile after complete validation. Registration is process-global and thread-atomic.
|
|
75
|
+
# Existing profiles are replaced by default; with `overwrite: false`, identical definitions return the canonical
|
|
76
|
+
# profile and conflicting definitions raise. Names that are not Ruby call syntax remain accessible through
|
|
77
|
+
# `public_send`.
|
|
66
78
|
# @param name [Symbol, String] profile name
|
|
79
|
+
# @param overwrite [Boolean] true to replace an existing profile
|
|
67
80
|
# @param spec [Hash] paper dimensions and unit
|
|
68
81
|
# @option spec [Numeric] :width paper width
|
|
69
82
|
# @option spec [Numeric] :height paper height
|
|
70
83
|
# @option spec [Symbol, String] :unit SVG unit
|
|
71
84
|
# @return [Sevgi::Graphics::Paper]
|
|
72
|
-
# @raise [Sevgi::ArgumentError] when the
|
|
73
|
-
|
|
74
|
-
|
|
85
|
+
# @raise [Sevgi::ArgumentError] when the name, dimensions, unit, overwrite flag, or options are reserved or invalid,
|
|
86
|
+
# or a non-bang definition conflicts with the registered profile
|
|
87
|
+
def self.define(name, overwrite: true, **spec)
|
|
88
|
+
name = normalize!(:name, name)
|
|
89
|
+
ArgumentError.("Paper name is reserved: #{name}") if reserved?(name)
|
|
90
|
+
overwrite = overwrite!(overwrite)
|
|
91
|
+
profile = new(name:, **spec)
|
|
92
|
+
|
|
93
|
+
register(name, profile, overwrite:)
|
|
94
|
+
end
|
|
75
95
|
|
|
76
|
-
|
|
96
|
+
def self.install(name)
|
|
97
|
+
return if @accessors.key?(name)
|
|
77
98
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
profiles[name] = instance_variable_set("@#{name}", new(name:, **spec))
|
|
99
|
+
define_singleton_method(name) { @mutex.synchronize { @profiles.fetch(name) } }
|
|
100
|
+
@accessors[name] = true
|
|
81
101
|
end
|
|
82
102
|
|
|
83
|
-
def self.
|
|
103
|
+
def self.register(name, profile, overwrite:)
|
|
104
|
+
@mutex.synchronize do
|
|
105
|
+
if !overwrite && (current = @profiles[name])
|
|
106
|
+
ArgumentError.("Paper already defined differently: #{name}") unless current == profile
|
|
84
107
|
|
|
85
|
-
|
|
108
|
+
next current
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
install(name)
|
|
112
|
+
@profiles[name] = profile
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def self.reserved?(name) = @reserved.include?(name)
|
|
86
117
|
|
|
87
118
|
def self.dimension!(field, value)
|
|
88
|
-
|
|
89
|
-
rescue ::ArgumentError, ::TypeError
|
|
90
|
-
ArgumentError.("Invalid paper #{field}: #{value.inspect}")
|
|
119
|
+
Scalar.finite(value, context: "paper", field:, positive: true)
|
|
91
120
|
end
|
|
92
121
|
|
|
93
|
-
def self.
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
ArgumentError.("
|
|
122
|
+
def self.options!(options)
|
|
123
|
+
return if options.empty?
|
|
124
|
+
|
|
125
|
+
ArgumentError.("Unknown paper options: #{options.keys.join(", ")}")
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def self.overwrite!(value)
|
|
129
|
+
return value if [true, false].include?(value)
|
|
130
|
+
|
|
131
|
+
ArgumentError.("Paper overwrite must be true or false")
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def self.normalize(value)
|
|
135
|
+
normalized = value.to_sym if value.respond_to?(:to_sym)
|
|
136
|
+
normalized if normalized.is_a?(::Symbol)
|
|
137
|
+
rescue ::StandardError
|
|
138
|
+
nil
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def self.normalize!(field, value)
|
|
142
|
+
normalize(value) || ArgumentError.("Invalid paper #{field}")
|
|
97
143
|
end
|
|
98
144
|
|
|
99
145
|
@reserved = methods.map(&:to_sym).freeze
|
|
100
146
|
|
|
101
|
-
private_class_method
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
b2: [500, 707, "mm"],
|
|
119
|
-
b3: [353, 500, "mm"],
|
|
120
|
-
b4: [250, 353, "mm"],
|
|
121
|
-
b5: [176, 250, "mm"],
|
|
122
|
-
b6: [125, 176, "mm"],
|
|
123
|
-
b7: [88, 125, "mm"],
|
|
124
|
-
b8: [62, 88, "mm"],
|
|
125
|
-
b9: [44, 62, "mm"],
|
|
126
|
-
b10: [31, 44, "mm"],
|
|
127
|
-
|
|
128
|
-
c0: [917, 1297, "mm"],
|
|
129
|
-
c1: [648, 917, "mm"],
|
|
130
|
-
c2: [458, 648, "mm"],
|
|
131
|
-
c3: [324, 458, "mm"],
|
|
132
|
-
c4: [229, 324, "mm"],
|
|
133
|
-
c5: [162, 229, "mm"],
|
|
134
|
-
c6: [114, 162, "mm"],
|
|
135
|
-
c7: [81, 114, "mm"],
|
|
136
|
-
c8: [57, 81, "mm"],
|
|
137
|
-
c9: [40, 57, "mm"],
|
|
138
|
-
c10: [28, 40, "mm"],
|
|
139
|
-
|
|
140
|
-
business: [85, 55, "mm"],
|
|
141
|
-
large: [130, 210, "mm"],
|
|
142
|
-
passport: [88, 125, "mm"],
|
|
143
|
-
pocket: [90, 140, "mm"],
|
|
144
|
-
travelers: [110, 210, "mm"],
|
|
145
|
-
us: [216, 279, "mm"],
|
|
146
|
-
xlarge: [190, 250, "mm"],
|
|
147
|
-
|
|
148
|
-
icon16: [16, 16, "px"],
|
|
149
|
-
icon32: [32, 32, "px"],
|
|
150
|
-
icon64: [64, 64, "px"],
|
|
151
|
-
icon128: [128, 128, "px"],
|
|
152
|
-
icon256: [256, 256, "px"],
|
|
153
|
-
icon512: [512, 512, "px"]
|
|
154
|
-
}.each { |name, (width, height, unit)| define(name, width:, height:, unit:) }
|
|
155
|
-
|
|
156
|
-
class << self
|
|
157
|
-
alias_method :default, :a4
|
|
147
|
+
private_class_method(
|
|
148
|
+
:dimension!,
|
|
149
|
+
:install,
|
|
150
|
+
:normalize,
|
|
151
|
+
:normalize!,
|
|
152
|
+
:options!,
|
|
153
|
+
:overwrite!,
|
|
154
|
+
:register,
|
|
155
|
+
:reserved?
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
PAPER_SIZES.each { |name, (width, height, unit)| define(name, width:, height:, unit:) }
|
|
159
|
+
|
|
160
|
+
# Returns the default paper profile.
|
|
161
|
+
# @return [Sevgi::Graphics::Paper]
|
|
162
|
+
def self.default
|
|
163
|
+
@mutex.synchronize { @profiles.fetch(:default) }
|
|
158
164
|
end
|
|
159
165
|
|
|
160
|
-
|
|
166
|
+
@accessors[:default] = true
|
|
167
|
+
@profiles[:default] = @profiles.fetch(:a4)
|
|
161
168
|
end
|
|
162
169
|
end
|
|
163
170
|
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Sevgi
|
|
4
|
+
module Graphics
|
|
5
|
+
# Validates finite real numeric values used by graphics auxiliaries.
|
|
6
|
+
# @api private
|
|
7
|
+
module Scalar
|
|
8
|
+
# Converts one finite real value.
|
|
9
|
+
# @param value [Numeric] value to validate
|
|
10
|
+
# @param context [String] error context
|
|
11
|
+
# @param field [Symbol] field name
|
|
12
|
+
# @param positive [Boolean] require a strictly positive value
|
|
13
|
+
# @param nonnegative [Boolean] require a non-negative value
|
|
14
|
+
# @return [Float] validated value
|
|
15
|
+
# @raise [Sevgi::ArgumentError] when value is not a finite real number in the requested range
|
|
16
|
+
def self.finite(value, context:, field:, positive: false, nonnegative: false)
|
|
17
|
+
invalid(context, field, value) unless real?(value)
|
|
18
|
+
|
|
19
|
+
number = Float(value)
|
|
20
|
+
invalid(context, field, value) unless valid?(number, positive:, nonnegative:)
|
|
21
|
+
|
|
22
|
+
number
|
|
23
|
+
rescue ::ArgumentError, ::RangeError, ::TypeError
|
|
24
|
+
invalid(context, field, value)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def self.real?(value) = value.is_a?(::Numeric) && !value.is_a?(::Complex)
|
|
28
|
+
|
|
29
|
+
def self.valid?(number, positive:, nonnegative:)
|
|
30
|
+
number.finite? && (!positive || number.positive?) && (!nonnegative || number >= 0)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def self.invalid(context, field, value) = ArgumentError.("Invalid #{context} #{field}: #{value.inspect}")
|
|
34
|
+
|
|
35
|
+
private_class_method :invalid, :real?, :valid?
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private_constant :Scalar
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Sevgi
|
|
4
|
+
module Graphics
|
|
5
|
+
# Built-in paper dimensions and units.
|
|
6
|
+
# @api private
|
|
7
|
+
PAPER_SIZES = {
|
|
8
|
+
a0: [841, 1189, "mm"],
|
|
9
|
+
a1: [594, 841, "mm"],
|
|
10
|
+
a2: [420, 594, "mm"],
|
|
11
|
+
a3: [297, 420, "mm"],
|
|
12
|
+
a4: [210, 297, "mm"],
|
|
13
|
+
a5: [148, 210, "mm"],
|
|
14
|
+
a6: [105, 148, "mm"],
|
|
15
|
+
a7: [74, 105, "mm"],
|
|
16
|
+
a8: [52, 74, "mm"],
|
|
17
|
+
a9: [37, 52, "mm"],
|
|
18
|
+
a10: [26, 37, "mm"],
|
|
19
|
+
|
|
20
|
+
b0: [1000, 1414, "mm"],
|
|
21
|
+
b1: [707, 1000, "mm"],
|
|
22
|
+
b2: [500, 707, "mm"],
|
|
23
|
+
b3: [353, 500, "mm"],
|
|
24
|
+
b4: [250, 353, "mm"],
|
|
25
|
+
b5: [176, 250, "mm"],
|
|
26
|
+
b6: [125, 176, "mm"],
|
|
27
|
+
b7: [88, 125, "mm"],
|
|
28
|
+
b8: [62, 88, "mm"],
|
|
29
|
+
b9: [44, 62, "mm"],
|
|
30
|
+
b10: [31, 44, "mm"],
|
|
31
|
+
|
|
32
|
+
c0: [917, 1297, "mm"],
|
|
33
|
+
c1: [648, 917, "mm"],
|
|
34
|
+
c2: [458, 648, "mm"],
|
|
35
|
+
c3: [324, 458, "mm"],
|
|
36
|
+
c4: [229, 324, "mm"],
|
|
37
|
+
c5: [162, 229, "mm"],
|
|
38
|
+
c6: [114, 162, "mm"],
|
|
39
|
+
c7: [81, 114, "mm"],
|
|
40
|
+
c8: [57, 81, "mm"],
|
|
41
|
+
c9: [40, 57, "mm"],
|
|
42
|
+
c10: [28, 40, "mm"],
|
|
43
|
+
|
|
44
|
+
business: [85, 55, "mm"],
|
|
45
|
+
large: [130, 210, "mm"],
|
|
46
|
+
passport: [88, 125, "mm"],
|
|
47
|
+
pocket: [90, 140, "mm"],
|
|
48
|
+
travelers: [110, 210, "mm"],
|
|
49
|
+
us: [216, 279, "mm"],
|
|
50
|
+
xlarge: [190, 250, "mm"],
|
|
51
|
+
|
|
52
|
+
icon16: [16, 16, "px"],
|
|
53
|
+
icon32: [32, 32, "px"],
|
|
54
|
+
icon64: [64, 64, "px"],
|
|
55
|
+
icon128: [128, 128, "px"],
|
|
56
|
+
icon256: [256, 256, "px"],
|
|
57
|
+
icon512: [512, 512, "px"]
|
|
58
|
+
}.transform_values(&:freeze).freeze
|
|
59
|
+
|
|
60
|
+
private_constant :PAPER_SIZES
|
|
61
|
+
end
|
|
62
|
+
end
|