fileboy 0.2.0 → 0.3.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 +28 -0
- data/README.md +179 -11
- data/lib/fileboy/client.rb +354 -19
- data/lib/fileboy/file_data.rb +193 -8
- data/lib/fileboy/signing.rb +198 -0
- data/lib/fileboy/transforms.rb +362 -15
- data/lib/fileboy/version.rb +1 -1
- data/lib/fileboy.rb +1 -0
- metadata +2 -1
data/lib/fileboy/transforms.rb
CHANGED
|
@@ -1,20 +1,97 @@
|
|
|
1
1
|
module Fileboy
|
|
2
2
|
# Builds the value of the `transform` query parameter on image URLs, for example
|
|
3
|
-
# `resize:300x_~fit:cover;format:webp;quality:75`.
|
|
4
|
-
#
|
|
3
|
+
# `resize:300x_~fit:cover;format:webp;quality:75`. Every transform the Fileboy image service
|
|
4
|
+
# supports can be built here; see https://fileboy.io/docs/delivery/image-transforms.
|
|
5
5
|
module Transforms
|
|
6
6
|
FITS = %w[contain cover fill inside outside].freeze
|
|
7
7
|
POSITIONS = %w[top-left top top-right left center right bottom-left bottom bottom-right].freeze
|
|
8
|
-
FORMATS = %w[jpeg jpg png bmp tiff tif webp].freeze
|
|
9
|
-
|
|
8
|
+
FORMATS = %w[auto jpeg jpg png bmp tiff tif webp avif json].freeze
|
|
9
|
+
# The formats a resize can convert to: every format except json, which only the format
|
|
10
|
+
# transform takes (it answers with the image's details as JSON instead of an image).
|
|
11
|
+
RESIZE_FORMATS = (FORMATS - %w[json]).freeze
|
|
12
|
+
FLIPS = %w[x y xy].freeze
|
|
13
|
+
FONTS = %w[sans sans-bold sans-italic mono mono-bold].freeze
|
|
14
|
+
ALIGNS = %w[left center right].freeze
|
|
15
|
+
TYPES = %w[
|
|
16
|
+
resize resizeAR crop smartCrop format quality background invert rotate flip blur sharpen
|
|
17
|
+
saturate grayscale brightness contrast hue gamma sepia tint opacity pixelate radius pad
|
|
18
|
+
border dpr strip trim lossless overlay text palette
|
|
19
|
+
].freeze
|
|
20
|
+
# Transforms that belong to the main chain, never an overlay's own.
|
|
21
|
+
NOT_IN_OVERLAY = %w[format quality dpr strip lossless background overlay text palette].freeze
|
|
22
|
+
FILE_ID = /\A[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\z/i
|
|
23
|
+
PALETTE_OUTPUTS = %w[json css].freeze
|
|
24
|
+
PALETTE_PREFIX = /\A[A-Za-z][A-Za-z0-9_-]{0,31}\z/
|
|
25
|
+
|
|
26
|
+
HEX = /\A([0-9a-f]{3}|[0-9a-f]{6}|[0-9a-f]{8})\z/i
|
|
27
|
+
HEX_CALL = /\Ahex\(([0-9a-f]{3}|[0-9a-f]{6}|[0-9a-f]{8})\)\z/i
|
|
28
|
+
RGB = /\Argb\((\d{1,3}),(\d{1,3}),(\d{1,3})\)\z/
|
|
29
|
+
RGBA = /\Argba\((\d{1,3}),(\d{1,3}),(\d{1,3}),(0|1|0?\.\d+|1\.0+)\)\z/
|
|
30
|
+
# The CSS colour names the image service understands, plus transparent.
|
|
31
|
+
CSS_COLORS = %w[
|
|
32
|
+
aliceblue antiquewhite aqua aquamarine azure beige bisque black blanchedalmond blue
|
|
33
|
+
blueviolet brown burlywood cadetblue chartreuse chocolate coral cornflowerblue cornsilk
|
|
34
|
+
crimson cyan darkblue darkcyan darkgoldenrod darkgray darkgreen darkgrey darkkhaki
|
|
35
|
+
darkmagenta darkolivegreen darkorange darkorchid darkred darksalmon darkseagreen
|
|
36
|
+
darkslateblue darkslategray darkslategrey darkturquoise darkviolet deeppink deepskyblue
|
|
37
|
+
dimgray dimgrey dodgerblue firebrick floralwhite forestgreen fuchsia gainsboro ghostwhite
|
|
38
|
+
gold goldenrod gray green greenyellow grey honeydew hotpink indianred indigo ivory khaki
|
|
39
|
+
lavender lavenderblush lawngreen lemonchiffon lightblue lightcoral lightcyan
|
|
40
|
+
lightgoldenrodyellow lightgray lightgreen lightgrey lightpink lightsalmon lightseagreen
|
|
41
|
+
lightskyblue lightslategray lightslategrey lightsteelblue lightyellow lime limegreen linen
|
|
42
|
+
magenta maroon mediumaquamarine mediumblue mediumorchid mediumpurple mediumseagreen
|
|
43
|
+
mediumslateblue mediumspringgreen mediumturquoise mediumvioletred midnightblue mintcream
|
|
44
|
+
mistyrose moccasin navajowhite navy oldlace olive olivedrab orange orangered orchid
|
|
45
|
+
palegoldenrod palegreen paleturquoise palevioletred papayawhip peachpuff peru pink plum
|
|
46
|
+
powderblue purple rebeccapurple red rosybrown royalblue saddlebrown salmon sandybrown
|
|
47
|
+
seagreen seashell sienna silver skyblue slateblue slategray slategrey snow springgreen
|
|
48
|
+
steelblue tan teal thistle tomato transparent turquoise violet wheat white whitesmoke yellow
|
|
49
|
+
yellowgreen
|
|
50
|
+
].freeze
|
|
51
|
+
|
|
52
|
+
# type => [query name, option key, minimum, exclusive minimum]
|
|
53
|
+
AMOUNTS = {
|
|
54
|
+
"blur" => ["blur", :amount, 0, nil],
|
|
55
|
+
"saturate" => ["saturate", :amount, 0, nil],
|
|
56
|
+
"brightness" => ["brightness", :amount, nil, nil],
|
|
57
|
+
"contrast" => ["contrast", :amount, nil, nil],
|
|
58
|
+
"hue" => ["hue", :degrees, nil, nil],
|
|
59
|
+
"gamma" => ["gamma", :value, nil, 0],
|
|
60
|
+
"opacity" => ["opacity", :amount, 0, nil],
|
|
61
|
+
"dpr" => ["dpr", :ratio, nil, 0]
|
|
62
|
+
}.freeze
|
|
63
|
+
OPTIONAL_AMOUNTS = { "sharpen" => :amount, "sepia" => :amount }.freeze
|
|
64
|
+
FLAGS = %w[invert grayscale strip lossless].freeze
|
|
10
65
|
|
|
11
66
|
module_function
|
|
12
67
|
|
|
13
68
|
# @param transforms [Array<Hash>, Hash, String, nil]
|
|
14
69
|
# * an Array of hashes like `{ type: :resize, width: 300, fit: :cover }`, applied in order
|
|
15
70
|
# * a Hash of `name => value`, like `{ resize: "300x_", format: "webp", quality: 75 }`
|
|
71
|
+
# (`true` adds a transform with no value, like `{ grayscale: true }`)
|
|
16
72
|
# * a String, passed through untouched
|
|
17
73
|
# @return [String] the query value, or "" when there is nothing to apply
|
|
74
|
+
# Escapes a transform string for the +transform+ query value. Characters that mean something in
|
|
75
|
+
# a URL (+, &, #, %, spaces and anything outside ASCII) are percent-encoded, so "1.0e+21" is not
|
|
76
|
+
# read as "1.0e 21"; the transform syntax (: ; ~ , @ ( )) is left as it is, so ordinary URLs
|
|
77
|
+
# stay readable and unchanged.
|
|
78
|
+
def encode_query(transform)
|
|
79
|
+
transform.to_s.gsub(/[^A-Za-z0-9_.\-~:;,@()!*']/) { |c| c.bytes.map { |b| format("%%%02X", b) }.join }
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Encodes the text of a text layer. Everything but letters, digits, -, _ and . is
|
|
83
|
+
# percent-encoded, so the transform syntax (; ~ : ( )) and % can appear in the text.
|
|
84
|
+
# encode_query then encodes the % again for the URL, which the image service expects.
|
|
85
|
+
def encode_text(text)
|
|
86
|
+
s = text.to_s
|
|
87
|
+
s = s.encoding == Encoding::BINARY ? s.dup.force_encoding(Encoding::UTF_8) : s.encode(Encoding::UTF_8)
|
|
88
|
+
raise InvalidArgumentError, "text is not valid Unicode" unless s.valid_encoding?
|
|
89
|
+
|
|
90
|
+
s.b.gsub(/[^A-Za-z0-9_.\-]/n) { |c| format("%%%02X", c.ord) }
|
|
91
|
+
rescue EncodingError
|
|
92
|
+
raise InvalidArgumentError, "text is not valid Unicode"
|
|
93
|
+
end
|
|
94
|
+
|
|
18
95
|
def build(transforms)
|
|
19
96
|
case transforms
|
|
20
97
|
when nil then ""
|
|
@@ -27,7 +104,7 @@ module Fileboy
|
|
|
27
104
|
end
|
|
28
105
|
|
|
29
106
|
def shorthand(name, value)
|
|
30
|
-
return nil if value.nil? || value.to_s.empty?
|
|
107
|
+
return nil if value.nil? || value == false || value.to_s.empty?
|
|
31
108
|
return name.to_s if value == true
|
|
32
109
|
|
|
33
110
|
"#{name}:#{value}"
|
|
@@ -38,11 +115,18 @@ module Fileboy
|
|
|
38
115
|
type = t[:type].to_s
|
|
39
116
|
raise InvalidArgumentError, "Unknown transform #{type.inspect}. Use one of: #{TYPES.join(', ')}" unless TYPES.include?(type)
|
|
40
117
|
|
|
41
|
-
|
|
42
|
-
|
|
118
|
+
return type if FLAGS.include?(type)
|
|
119
|
+
|
|
120
|
+
if AMOUNTS.key?(type)
|
|
121
|
+
name, key, min, above = AMOUNTS[type]
|
|
122
|
+
return "#{name}:#{num(t[key], name, min: min, above: above)}"
|
|
123
|
+
end
|
|
124
|
+
if OPTIONAL_AMOUNTS.key?(type)
|
|
125
|
+
key = OPTIONAL_AMOUNTS[type]
|
|
126
|
+
return t[key].nil? ? type : "#{type}:#{num(t[key], type, min: 0)}"
|
|
127
|
+
end
|
|
43
128
|
|
|
44
|
-
|
|
45
|
-
"invert"
|
|
129
|
+
send("build_#{type.gsub(/([A-Z])/) { "_#{Regexp.last_match(1).downcase}" }}", t)
|
|
46
130
|
end
|
|
47
131
|
|
|
48
132
|
def build_format(t)
|
|
@@ -72,13 +156,270 @@ module Fileboy
|
|
|
72
156
|
end
|
|
73
157
|
|
|
74
158
|
out = "resize:#{width || '_'}x#{height || '_'}"
|
|
159
|
+
out += fit_options(t)
|
|
160
|
+
out += "~format:#{choice(t[:format], RESIZE_FORMATS, 'resize format')}" if t[:format]
|
|
161
|
+
unless t[:focus].nil?
|
|
162
|
+
focus = t[:focus].respond_to?(:to_h) ? symbolize(t[:focus]) : {}
|
|
163
|
+
raise InvalidArgumentError, "resize focus must be { x: ..., y: ... }" unless focus.key?(:x) && focus.key?(:y)
|
|
164
|
+
|
|
165
|
+
out += "~focus:#{length(focus[:x], 'focus x')},#{length(focus[:y], 'focus y')}"
|
|
166
|
+
end
|
|
167
|
+
upscale = t[:upscale]
|
|
168
|
+
unless upscale.nil? || upscale == true || upscale == false
|
|
169
|
+
raise InvalidArgumentError, "resize upscale must be true or false (got #{upscale.inspect})"
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# Never make the image bigger than it is.
|
|
173
|
+
out += "~upscale:false" if upscale == false
|
|
174
|
+
out
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def fit_options(t)
|
|
178
|
+
out = ""
|
|
75
179
|
out += "~position:#{position(t[:position])}" if t[:position]
|
|
76
180
|
out += "~fit:#{choice(t[:fit], FITS, 'fit')}" if t[:fit]
|
|
77
181
|
out += "~background:#{color(t[:background])}" if t[:background]
|
|
78
|
-
out += "~format:#{format_name(t[:format])}" if t[:format]
|
|
79
182
|
out
|
|
80
183
|
end
|
|
81
184
|
|
|
185
|
+
def build_resize_a_r(t)
|
|
186
|
+
ratio = t[:ratio]
|
|
187
|
+
raise InvalidArgumentError, "resizeAR needs a ratio like [16, 9]" unless ratio.is_a?(Array) && ratio.size == 2
|
|
188
|
+
|
|
189
|
+
"resizeAR:#{num(ratio[0], 'resizeAR ratio', above: 0)}x#{num(ratio[1], 'resizeAR ratio', above: 0)}" + fit_options(t)
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def build_crop(t)
|
|
193
|
+
raise InvalidArgumentError, "crop needs a width and a height" if t[:width].nil? || t[:height].nil?
|
|
194
|
+
|
|
195
|
+
out = "crop:#{length(t[:width], 'crop width')}x#{length(t[:height], 'crop height')}"
|
|
196
|
+
if !t[:left].nil? || !t[:top].nil?
|
|
197
|
+
# An omitted side is centred.
|
|
198
|
+
left = t[:left].nil? ? "50pc" : length(t[:left], "crop left")
|
|
199
|
+
top = t[:top].nil? ? "50pc" : length(t[:top], "crop top")
|
|
200
|
+
out += "@#{left},#{top}"
|
|
201
|
+
elsif t[:position]
|
|
202
|
+
out += "~position:#{position(t[:position])}"
|
|
203
|
+
end
|
|
204
|
+
out
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def build_smart_crop(t)
|
|
208
|
+
"smartCrop:#{whole(t[:width], 'smartCrop width', 1)}x#{whole(t[:height], 'smartCrop height', 1)}"
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def build_rotate(t)
|
|
212
|
+
out = "rotate:#{num(t[:angle], 'rotate angle')}"
|
|
213
|
+
out += "~background:#{color(t[:background])}" if t[:background]
|
|
214
|
+
out
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def build_flip(t)
|
|
218
|
+
"flip:#{choice(t[:direction], FLIPS, 'flip direction')}"
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
def build_tint(t)
|
|
222
|
+
"tint:#{color(t[:color])}"
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def build_pixelate(t)
|
|
226
|
+
"pixelate:#{whole(t[:size], 'pixelate size', 1)}"
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
def build_radius(t)
|
|
230
|
+
r = t[:radius]
|
|
231
|
+
out = "radius:#{r.to_s == 'max' ? 'max' : whole(r, 'radius')}"
|
|
232
|
+
out += "~background:#{color(t[:background])}" if t[:background]
|
|
233
|
+
out
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
def build_pad(t)
|
|
237
|
+
values = Array(t[:padding])
|
|
238
|
+
values = [nil] if values.empty?
|
|
239
|
+
unless [1, 2, 4].include?(values.size)
|
|
240
|
+
raise InvalidArgumentError, "pad takes 1, 2 or 4 numbers: all, [vertical, horizontal] or [top, right, bottom, left]"
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
out = "pad:#{values.map { |v| whole(v, 'pad') }.join(',')}"
|
|
244
|
+
out += "~background:#{color(t[:background])}" if t[:background]
|
|
245
|
+
out
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
def build_border(t)
|
|
249
|
+
out = "border:#{whole(t[:width], 'border width')}"
|
|
250
|
+
out += "~color:#{color(t[:color])}" if t[:color]
|
|
251
|
+
out
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
def build_trim(t)
|
|
255
|
+
out = "trim"
|
|
256
|
+
out += ":#{num(t[:threshold], 'trim threshold', min: 0)}" unless t[:threshold].nil?
|
|
257
|
+
out += "~color:#{color(t[:color])}" if t[:color]
|
|
258
|
+
out
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
def build_overlay(t)
|
|
262
|
+
file = t[:file]
|
|
263
|
+
unless file.is_a?(String) && file.match?(FILE_ID)
|
|
264
|
+
raise InvalidArgumentError, "overlay file must be the id of an image file in the same app"
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
out = "overlay:#{file.downcase}"
|
|
268
|
+
out += "~width:#{positive_length(t[:width], 'overlay width')}" unless t[:width].nil?
|
|
269
|
+
out += "~height:#{positive_length(t[:height], 'overlay height')}" unless t[:height].nil?
|
|
270
|
+
out += placement(t)
|
|
271
|
+
unless t[:transforms].nil?
|
|
272
|
+
inner = t[:transforms]
|
|
273
|
+
raise InvalidArgumentError, "overlay transforms must be a list of transforms" unless inner.is_a?(Array) && !inner.empty?
|
|
274
|
+
|
|
275
|
+
parts = inner.map do |sub|
|
|
276
|
+
s = symbolize(sub)
|
|
277
|
+
type = s[:type].to_s
|
|
278
|
+
raise InvalidArgumentError, "#{type} cannot be used in an overlay's transforms; put it in the main list" if NOT_IN_OVERLAY.include?(type)
|
|
279
|
+
raise InvalidArgumentError, "an overlay's transforms cannot set a format" if type == "resize" && s[:format]
|
|
280
|
+
|
|
281
|
+
build_one(s)
|
|
282
|
+
end
|
|
283
|
+
out += "~transform:(#{parts.join(';')})"
|
|
284
|
+
end
|
|
285
|
+
out
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
def build_palette(t)
|
|
289
|
+
out = "palette"
|
|
290
|
+
output = t[:output].nil? ? "json" : choice(t[:output], PALETTE_OUTPUTS, "palette output")
|
|
291
|
+
out += ":#{output}" unless t[:output].nil?
|
|
292
|
+
unless t[:colors].nil?
|
|
293
|
+
unless t[:colors].is_a?(Integer) && t[:colors].between?(0, 16)
|
|
294
|
+
raise InvalidArgumentError, "palette colors must be a whole number from 0 to 16"
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
out += "~colors:#{t[:colors]}"
|
|
298
|
+
end
|
|
299
|
+
unless t[:prefix].nil?
|
|
300
|
+
raise InvalidArgumentError, "palette prefix only applies to output \"css\"" unless output == "css"
|
|
301
|
+
unless t[:prefix].to_s.match?(PALETTE_PREFIX)
|
|
302
|
+
raise InvalidArgumentError, "palette prefix must be a letter then up to 31 letters, digits, - or _"
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
out += "~prefix:#{t[:prefix]}"
|
|
306
|
+
end
|
|
307
|
+
out
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
def build_text(t)
|
|
311
|
+
text = t[:text]
|
|
312
|
+
raise InvalidArgumentError, "text needs something to write" unless text.is_a?(String) && !text.strip.empty?
|
|
313
|
+
|
|
314
|
+
out = "text:#{encode_text(text)}"
|
|
315
|
+
out += "~font:#{choice(t[:font], FONTS, 'font')}" if t[:font]
|
|
316
|
+
out += "~size:#{positive_length(t[:size], 'text size')}" unless t[:size].nil?
|
|
317
|
+
out += "~color:#{color(t[:color])}" if t[:color]
|
|
318
|
+
out += "~background:#{color(t[:background])}" if t[:background]
|
|
319
|
+
out += "~padding:#{whole(t[:padding], 'text padding')}" unless t[:padding].nil?
|
|
320
|
+
out += "~wrap:#{positive_length(t[:wrap], 'text wrap')}" unless t[:wrap].nil?
|
|
321
|
+
out += "~align:#{choice(t[:align], ALIGNS, 'align')}" if t[:align]
|
|
322
|
+
out + placement(t)
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
# Where and how an overlay or text layer is drawn.
|
|
326
|
+
def placement(t)
|
|
327
|
+
kind = t[:type]
|
|
328
|
+
out = ""
|
|
329
|
+
out += "~position:#{position(t[:position])}" if t[:position]
|
|
330
|
+
out += "~x:#{offset(t[:x], "#{kind} x")}" unless t[:x].nil?
|
|
331
|
+
out += "~y:#{offset(t[:y], "#{kind} y")}" unless t[:y].nil?
|
|
332
|
+
unless t[:opacity].nil?
|
|
333
|
+
value = num(t[:opacity], "#{kind} opacity", min: 0)
|
|
334
|
+
raise InvalidArgumentError, "#{kind} opacity must be from 0 to 100" if t[:opacity] > 100
|
|
335
|
+
|
|
336
|
+
out += "~opacity:#{value}"
|
|
337
|
+
end
|
|
338
|
+
out += "~rotate:#{num(t[:rotate], "#{kind} rotate")}" unless t[:rotate].nil?
|
|
339
|
+
tile = t[:tile]
|
|
340
|
+
raise InvalidArgumentError, "#{kind} tile must be true or false (got #{tile.inspect})" unless [nil, true, false].include?(tile)
|
|
341
|
+
|
|
342
|
+
out += "~tile:true" if tile
|
|
343
|
+
unless t[:gap].nil?
|
|
344
|
+
raise InvalidArgumentError, "#{kind} gap only works with tile: true" unless tile
|
|
345
|
+
|
|
346
|
+
out += "~gap:#{length(t[:gap], "#{kind} gap")}"
|
|
347
|
+
end
|
|
348
|
+
out
|
|
349
|
+
end
|
|
350
|
+
|
|
351
|
+
# Whole pixels, which may be negative, or a percentage from -100 to 100.
|
|
352
|
+
def offset(value, label)
|
|
353
|
+
pc = nil
|
|
354
|
+
if value.is_a?(Hash)
|
|
355
|
+
h = symbolize(value)
|
|
356
|
+
pc = h[:pc] if h.key?(:pc)
|
|
357
|
+
elsif value.is_a?(String) && value.match?(/(pc|%)\z/)
|
|
358
|
+
str = value.sub(/(pc|%)\z/, "")
|
|
359
|
+
raise InvalidArgumentError, "#{label} #{value.inspect} is not a percentage" unless str.match?(/\A-?\d+(\.\d+)?\z/)
|
|
360
|
+
|
|
361
|
+
pc = str.include?(".") ? str.to_f : str.to_i
|
|
362
|
+
end
|
|
363
|
+
unless pc.nil?
|
|
364
|
+
unless (pc.is_a?(Integer) || pc.is_a?(Float)) && pc.between?(-100, 100)
|
|
365
|
+
raise InvalidArgumentError, "#{label} percentage must be from -100 to 100"
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
return "#{num(pc, label)}pc"
|
|
369
|
+
end
|
|
370
|
+
raise InvalidArgumentError, "#{label} must be a whole number of pixels or a percentage (got #{value.inspect})" unless value.is_a?(Integer)
|
|
371
|
+
|
|
372
|
+
value.to_s
|
|
373
|
+
end
|
|
374
|
+
|
|
375
|
+
def positive_length(value, label)
|
|
376
|
+
s = length(value, label)
|
|
377
|
+
raise InvalidArgumentError, "#{label} must be more than 0" unless s.to_f.positive?
|
|
378
|
+
|
|
379
|
+
s
|
|
380
|
+
end
|
|
381
|
+
|
|
382
|
+
# A number as the image service writes it: whole numbers without a decimal point.
|
|
383
|
+
def num(value, label, min: nil, above: nil)
|
|
384
|
+
unless (value.is_a?(Integer) || value.is_a?(Float)) && value.finite?
|
|
385
|
+
raise InvalidArgumentError, "#{label} must be a number (got #{value.inspect})"
|
|
386
|
+
end
|
|
387
|
+
raise InvalidArgumentError, "#{label} must be at least #{min}" if min && value < min
|
|
388
|
+
raise InvalidArgumentError, "#{label} must be more than #{above}" if above && value <= above
|
|
389
|
+
|
|
390
|
+
value.is_a?(Float) && value == value.floor ? value.to_i.to_s : value.to_s
|
|
391
|
+
end
|
|
392
|
+
|
|
393
|
+
def whole(value, label, min = 0)
|
|
394
|
+
unless value.is_a?(Integer) && value >= min
|
|
395
|
+
raise InvalidArgumentError, "#{label} must be a whole number of at least #{min} (got #{value.inspect})"
|
|
396
|
+
end
|
|
397
|
+
|
|
398
|
+
value.to_s
|
|
399
|
+
end
|
|
400
|
+
|
|
401
|
+
# Pixels as a whole number, or a percentage as `{ pc: 50 }` or "50pc" / "50%".
|
|
402
|
+
def length(value, label)
|
|
403
|
+
pc = nil
|
|
404
|
+
if value.is_a?(Hash)
|
|
405
|
+
h = symbolize(value)
|
|
406
|
+
pc = h[:pc] if h.key?(:pc)
|
|
407
|
+
elsif value.is_a?(String) && value.match?(/(pc|%)\z/)
|
|
408
|
+
str = value.sub(/(pc|%)\z/, "")
|
|
409
|
+
raise InvalidArgumentError, "#{label} #{value.inspect} is not a percentage" unless str.match?(/\A\d+(\.\d+)?\z/)
|
|
410
|
+
|
|
411
|
+
pc = str.include?(".") ? str.to_f : str.to_i
|
|
412
|
+
end
|
|
413
|
+
unless pc.nil?
|
|
414
|
+
unless (pc.is_a?(Integer) || pc.is_a?(Float)) && pc.between?(0, 100)
|
|
415
|
+
raise InvalidArgumentError, "#{label} percentage must be from 0 to 100"
|
|
416
|
+
end
|
|
417
|
+
|
|
418
|
+
return "#{num(pc, label)}pc"
|
|
419
|
+
end
|
|
420
|
+
whole(value, label)
|
|
421
|
+
end
|
|
422
|
+
|
|
82
423
|
# "right top", "top right" and "top-right" all mean the same corner, which the image service
|
|
83
424
|
# spells top-right (vertical word first).
|
|
84
425
|
def position(value)
|
|
@@ -98,15 +439,21 @@ module Fileboy
|
|
|
98
439
|
v
|
|
99
440
|
end
|
|
100
441
|
|
|
101
|
-
# "#fff", "#ffffff" and "#ffffff80" become hex(...)
|
|
442
|
+
# "#fff", "#ffffff" and "#ffffff80" become hex(...); hex(...) is kept; rgb(...), rgba(...) and
|
|
443
|
+
# CSS colour names like "white" are passed on.
|
|
102
444
|
def color(value)
|
|
103
|
-
s = value.to_s
|
|
104
|
-
return s if s.match?(
|
|
445
|
+
s = value.to_s.strip
|
|
446
|
+
return s if s.match?(HEX_CALL)
|
|
105
447
|
|
|
106
448
|
hex = s.delete_prefix("#")
|
|
107
|
-
|
|
449
|
+
return "hex(#{hex})" if hex.match?(HEX)
|
|
450
|
+
|
|
451
|
+
compact = s.gsub(/\s+/, "").downcase
|
|
452
|
+
rgb = compact.match(RGB) || compact.match(RGBA)
|
|
453
|
+
return compact if rgb && rgb.captures.first(3).all? { |c| c.to_i <= 255 }
|
|
454
|
+
return s.downcase if CSS_COLORS.include?(s.downcase)
|
|
108
455
|
|
|
109
|
-
"hex(#{
|
|
456
|
+
raise InvalidArgumentError, "Colours must be hex like #ff8800, rgb(...), rgba(...) or a CSS colour name (got #{value.inspect})"
|
|
110
457
|
end
|
|
111
458
|
|
|
112
459
|
def symbolize(hash)
|
data/lib/fileboy/version.rb
CHANGED
data/lib/fileboy.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: fileboy
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.3.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- CD2 Solutions
|
|
@@ -68,6 +68,7 @@ files:
|
|
|
68
68
|
- lib/fileboy/errors.rb
|
|
69
69
|
- lib/fileboy/file_data.rb
|
|
70
70
|
- lib/fileboy/mime.rb
|
|
71
|
+
- lib/fileboy/signing.rb
|
|
71
72
|
- lib/fileboy/token.rb
|
|
72
73
|
- lib/fileboy/transforms.rb
|
|
73
74
|
- lib/fileboy/version.rb
|