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.
@@ -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`. It only emits what the Fileboy image service
4
- # understands: resize, format, quality, background and invert.
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
- TYPES = %w[resize format quality background invert].freeze
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
- send("build_#{type}", t)
42
- end
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
- def build_invert(_t)
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(...). Anything already in hex(...) form is kept.
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?(/\Ahex\(([0-9a-f]{3}|[0-9a-f]{6}|[0-9a-f]{8})\)\z/i)
445
+ s = value.to_s.strip
446
+ return s if s.match?(HEX_CALL)
105
447
 
106
448
  hex = s.delete_prefix("#")
107
- raise InvalidArgumentError, "Colours must be hex, like #ff8800 (got #{value.inspect})" unless hex.match?(/\A([0-9a-f]{3}|[0-9a-f]{6}|[0-9a-f]{8})\z/i)
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(#{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)
@@ -1,3 +1,3 @@
1
1
  module Fileboy
2
- VERSION = "0.2.0"
2
+ VERSION = "0.3.0"
3
3
  end
data/lib/fileboy.rb CHANGED
@@ -5,6 +5,7 @@ require "fileboy/file_data"
5
5
  require "fileboy/transforms"
6
6
  require "fileboy/token"
7
7
  require "fileboy/webhook"
8
+ require "fileboy/signing"
8
9
  require "fileboy/client"
9
10
 
10
11
  # Official Ruby client for Fileboy (https://fileboy.io).
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.2.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