image_vise 0.2.1 → 0.2.2

Sign up to get free protection for your applications and to get access to all the features.
Files changed (63) hide show
  1. checksums.yaml +4 -4
  2. data/.gitignore +8 -0
  3. data/.travis.yml +13 -0
  4. data/DEVELOPMENT.md +111 -0
  5. data/Gemfile +4 -0
  6. data/LICENSE.txt +29 -0
  7. data/README.md +213 -0
  8. data/Rakefile +6 -0
  9. data/SECURITY.md +57 -0
  10. data/examples/config.ru +17 -0
  11. data/examples/custom_image_operator.rb +27 -0
  12. data/examples/error_handline_appsignal.rb +23 -0
  13. data/examples/error_handling_sentry.rb +25 -0
  14. data/image_vise.gemspec +43 -0
  15. data/lib/image_vise/fetchers/fetcher_file.rb +27 -0
  16. data/lib/image_vise/fetchers/fetcher_http.rb +42 -0
  17. data/lib/image_vise/file_response.rb +22 -0
  18. data/lib/image_vise/image_request.rb +70 -0
  19. data/lib/image_vise/operators/auto_orient.rb +10 -0
  20. data/lib/image_vise/operators/background_fill.rb +18 -0
  21. data/lib/image_vise/operators/crop.rb +32 -0
  22. data/lib/image_vise/operators/ellipse_stencil.rb +70 -0
  23. data/lib/image_vise/operators/fit_crop.rb +33 -0
  24. data/lib/image_vise/operators/force_jpg_out.rb +17 -0
  25. data/lib/image_vise/operators/geom.rb +16 -0
  26. data/lib/image_vise/operators/sRGB_v4_ICC_preference_displayclass.icc +0 -0
  27. data/lib/image_vise/operators/sharpen.rb +21 -0
  28. data/lib/image_vise/operators/srgb.rb +30 -0
  29. data/lib/image_vise/operators/strip_metadata.rb +10 -0
  30. data/lib/image_vise/pipeline.rb +64 -0
  31. data/lib/image_vise/render_engine.rb +298 -0
  32. data/lib/image_vise/version.rb +3 -0
  33. data/lib/image_vise/writers/auto_writer.rb +23 -0
  34. data/lib/image_vise/writers/jpeg_writer.rb +9 -0
  35. data/lib/image_vise.rb +177 -0
  36. data/spec/image_vise/auto_orient_spec.rb +10 -0
  37. data/spec/image_vise/background_fill_spec.rb +39 -0
  38. data/spec/image_vise/crop_spec.rb +20 -0
  39. data/spec/image_vise/ellipse_stencil_spec.rb +18 -0
  40. data/spec/image_vise/fetcher_file_spec.rb +48 -0
  41. data/spec/image_vise/fetcher_http_spec.rb +44 -0
  42. data/spec/image_vise/file_response_spec.rb +45 -0
  43. data/spec/image_vise/fit_crop_spec.rb +20 -0
  44. data/spec/image_vise/force_jpg_out_spec.rb +36 -0
  45. data/spec/image_vise/geom_spec.rb +33 -0
  46. data/spec/image_vise/image_request_spec.rb +62 -0
  47. data/spec/image_vise/pipeline_spec.rb +72 -0
  48. data/spec/image_vise/render_engine_spec.rb +336 -0
  49. data/spec/image_vise/sharpen_spec.rb +17 -0
  50. data/spec/image_vise/srgb_spec.rb +23 -0
  51. data/spec/image_vise/strip_metadata_spec.rb +14 -0
  52. data/spec/image_vise/writers/auto_writer_spec.rb +25 -0
  53. data/spec/image_vise/writers/jpeg_writer_spec.rb +32 -0
  54. data/spec/image_vise_spec.rb +110 -0
  55. data/spec/layers-with-blending.psd +0 -0
  56. data/spec/spec_helper.rb +112 -0
  57. data/spec/test_server.rb +61 -0
  58. data/spec/waterside_magic_hour.jpg +0 -0
  59. data/spec/waterside_magic_hour.psd +0 -0
  60. data/spec/waterside_magic_hour_adobergb.jpg +0 -0
  61. data/spec/waterside_magic_hour_gray.tif +0 -0
  62. data/spec/waterside_magic_hour_transp.png +0 -0
  63. metadata +63 -2
@@ -0,0 +1,22 @@
1
+ # Wrappers a given Tempfile for a Rack response.
2
+ # Will close _and_ unlink the Tempfile it contains.
3
+ class ImageVise::FileResponse
4
+ ONE_CHUNK_BYTES = 1024 * 1024 * 2
5
+ def initialize(file)
6
+ @file = file
7
+ end
8
+
9
+ def each
10
+ @file.flush # Make sure all the writes have been synchronized
11
+ # We can easily open another file descriptor
12
+ File.open(@file.path, 'rb') do |my_file_descriptor|
13
+ while data = my_file_descriptor.read(ONE_CHUNK_BYTES)
14
+ yield(data)
15
+ end
16
+ end
17
+ end
18
+
19
+ def close
20
+ ImageVise.close_and_unlink(@file)
21
+ end
22
+ end
@@ -0,0 +1,70 @@
1
+ require 'openssl'
2
+
3
+ class ImageVise::ImageRequest < Ks.strict(:src_url, :pipeline)
4
+ class InvalidRequest < ArgumentError; end
5
+ class SignatureError < InvalidRequest; end
6
+ class URLError < InvalidRequest; end
7
+ class MissingParameter < InvalidRequest; end
8
+
9
+ # Initializes a new ParamsChecker from given HTTP server framework
10
+ # params. The params can be symbol- or string-keyed, does not matter.
11
+ def self.from_params(qs_params:, secrets:)
12
+ base64_encoded_params = qs_params.fetch(:q) rescue qs_params.fetch('q')
13
+ given_signature = qs_params.fetch(:sig) rescue qs_params.fetch('sig')
14
+
15
+ # Unmask slashes and equals signs (if they are present)
16
+ base64_encoded_params = base64_encoded_params.tr('-', '/').tr('_', '+')
17
+
18
+ # Check the signature before decoding JSON (since we will be creating symbols)
19
+ unless valid_signature?(base64_encoded_params, given_signature, secrets)
20
+ raise SignatureError, "Invalid or missing signature"
21
+ end
22
+
23
+ # Decode the JSON
24
+ # (only AFTER the signature has been validated, so we can use symbol keys)
25
+ decoded_json = Base64.decode64(base64_encoded_params)
26
+ params = JSON.parse(decoded_json, symbolize_names: true)
27
+
28
+ # Pick up the URL and validate it
29
+ source_url_str = params.fetch(:src_url).to_s
30
+ raise URLError, "the :src_url parameter must be non-empty" if source_url_str.empty?
31
+ pipeline_definition = params.fetch(:pipeline)
32
+ new(src_url: URI(source_url_str), pipeline: ImageVise::Pipeline.from_param(pipeline_definition))
33
+ rescue KeyError => e
34
+ raise InvalidRequest.new(e.message)
35
+ end
36
+
37
+ def to_path_params(signed_with_secret)
38
+ qs = to_query_string_params(signed_with_secret)
39
+ q_masked = qs.fetch(:q).tr('/', '-').tr('+', '_')
40
+ '/%s/%s' % [q_masked, qs[:sig]]
41
+ end
42
+
43
+ def to_query_string_params(signed_with_secret)
44
+ payload = JSON.dump(to_h)
45
+ base64_enc = Base64.strict_encode64(payload).gsub(/\=+$/, '')
46
+ {q: base64_enc, sig: OpenSSL::HMAC.hexdigest(OpenSSL::Digest::SHA256.new, signed_with_secret, base64_enc)}
47
+ end
48
+
49
+ def to_h
50
+ {pipeline: pipeline.to_params, src_url: src_url.to_s}
51
+ end
52
+
53
+ def cache_etag
54
+ Digest::SHA1.hexdigest(JSON.dump(to_h))
55
+ end
56
+
57
+ private
58
+
59
+ def self.valid_signature?(for_payload, given_signature, secrets)
60
+ # Check the signature against every key that we have,
61
+ # since different apps might be using different keys
62
+ seen_valid_signature = false
63
+ secrets.each do | stored_secret |
64
+ expected_signature = OpenSSL::HMAC.hexdigest(OpenSSL::Digest::SHA256.new, stored_secret, for_payload)
65
+ result_for_this_key = Rack::Utils.secure_compare(expected_signature, given_signature)
66
+ seen_valid_signature ||= result_for_this_key
67
+ end
68
+ seen_valid_signature
69
+ end
70
+ end
@@ -0,0 +1,10 @@
1
+ # Applies ImageMagick auto_orient to the image, so that i.e. mobile photos
2
+ # can be oriented correctly. The operation is applied destructively (changes actual pixel data)
3
+ #
4
+ # The corresponding Pipeline method is `auto_orient`.
5
+ class ImageVise::AutoOrient
6
+ def apply!(magick_image)
7
+ magick_image.auto_orient!
8
+ end
9
+ ImageVise.add_operator 'auto_orient', self
10
+ end
@@ -0,0 +1,18 @@
1
+ # Applies a background fill color.
2
+ # Can handle most 'word' colors and hex color codes but not RGB values.
3
+ #
4
+ # The corresponding Pipeline method is `background_fill`.
5
+ class ImageVise::BackgroundFill < Ks.strict(:color)
6
+ def initialize(*)
7
+ super
8
+ self.color = color.to_s
9
+ raise ArgumentError, "the :color parameter must be present and not empty" if self.color.empty?
10
+ end
11
+
12
+ def apply!(image)
13
+ image.border!(0, 0, color)
14
+ image.alpha(Magick::DeactivateAlphaChannel)
15
+ end
16
+
17
+ ImageVise.add_operator 'background_fill', self
18
+ end
@@ -0,0 +1,32 @@
1
+ # Crops the image to the given dimensions with a given gravity. Gravities are shorthand versions
2
+ # of ImageMagick gravity parameters (see GRAVITY_PARAMS)
3
+ #
4
+ # The corresponding Pipeline method is `crop`.
5
+ class ImageVise::Crop < Ks.strict(:width, :height, :gravity)
6
+ GRAVITY_PARAMS = {
7
+ 'nw' => Magick::NorthWestGravity,
8
+ 'n' => Magick::NorthGravity,
9
+ 'ne' => Magick::NorthEastGravity,
10
+ 'w' => Magick::WestGravity,
11
+ 'c' => Magick::CenterGravity,
12
+ 'e' => Magick::EastGravity,
13
+ 'sw' => Magick::SouthWestGravity,
14
+ 's' => Magick::SouthGravity,
15
+ 'se' => Magick::SouthEastGravity,
16
+ }
17
+
18
+ def initialize(*)
19
+ super
20
+ self.width = width.to_i
21
+ self.height = height.to_i
22
+ raise ArgumentError, ":width must positive" unless width > 0
23
+ raise ArgumentError, ":height must positive" unless height > 0
24
+ raise ArgumentError, ":gravity must be within the permitted values" unless GRAVITY_PARAMS.key? gravity
25
+ end
26
+
27
+ def apply!(image)
28
+ image.crop!(GRAVITY_PARAMS.fetch(gravity), width, height, remove_padding_data_outside_window = true)
29
+ end
30
+
31
+ ImageVise.add_operator 'crop', self
32
+ end
@@ -0,0 +1,70 @@
1
+ # Applies an elliptic stencil around the entire image. The stencil will fit inside the image boundaries,
2
+ # with about 1 pixel cushion on each side to provide smooth anti-aliased edges. If the input image to be
3
+ # provessed is square, the ellipse will turn into a neat circle.
4
+ #
5
+ # This adds an alpha channel to the image being processed (and premultiplies the RGB channels by it). This
6
+ # will force the RenderEngine to return the processed image as a PNG in all cases, instead of keeping it
7
+ # in the original format.
8
+ #
9
+ # The corresponding Pipeline method is `ellipse_stencil`.
10
+ class ImageVise::EllipseStencil
11
+ C_black = 'black'.freeze
12
+ C_white = 'white'.freeze
13
+ private_constant :C_white, :C_black
14
+
15
+ def apply!(magick_image)
16
+ width, height = magick_image.columns, magick_image.rows
17
+
18
+ # This is a bit involved. We need to do a manual composite. Here is what it entails.
19
+ #
20
+ # Given a premultiplied RGB image B, and a grayscale mask A, we need to do the following
21
+ # operation:
22
+ #
23
+ # BrBgBb / Ba * (Ba * A)
24
+ #
25
+ # Since ImageMagick works with unpremultiplied alphas, it is doable - but special care
26
+ # must be taken not to overmult or overdivide.
27
+ #
28
+ # To begin,generate a black and white image for the stencil
29
+ mask = Magick::Image.new(width, height)
30
+ draw_circle(mask, width, height)
31
+
32
+ # At this stage the mask contains a B/W image of the circle, black outside, white inside.
33
+ # Retain the alpha of the original in a separate image
34
+ only_alpha = magick_image.copy
35
+ only_alpha.alpha(Magick::ExtractAlphaChannel)
36
+ mask.composite!(only_alpha, Magick::CenterGravity, Magick::MultiplyCompositeOp)
37
+
38
+ # With this composite op, enabling alpha on the destination image is
39
+ # not required - it will be enabled automatically.
40
+ # The CopyOpacityCompositeOp implies that we copy the grayscale version
41
+ # of the RGB channels as the alpha channel, so for some weird reason we need
42
+ # to disable the alpha on our mask image
43
+ mask.alpha(Magick::DeactivateAlphaChannel)
44
+ # And perform the operation (set gray(RGB) of mask as the A of magick_image)
45
+ magick_image.composite!(mask, Magick::CenterGravity, Magick::CopyOpacityCompositeOp)
46
+ ensure
47
+ [mask, only_alpha].each do |maybe_image|
48
+ ImageVise.destroy(maybe_image)
49
+ end
50
+ end
51
+
52
+ def draw_circle(into_image, width, height)
53
+ center_x = (width / 2.0)
54
+ center_y = (height / 2.0)
55
+ # Make sure all the edges are anti-aliased
56
+ radius_width = center_x - 1.5
57
+ radius_height = center_y - 1.5
58
+
59
+ gc = Magick::Draw.new
60
+ gc.fill C_black
61
+ gc.rectangle(0, 0, width, height)
62
+ gc.fill C_white
63
+ gc.ellipse(center_x, center_y, radius_width, radius_height, deg_start=0, deg_end=360)
64
+ gc.draw(into_image)
65
+ ensure
66
+ ImageVise.destroy(gc)
67
+ end
68
+
69
+ ImageVise.add_operator 'ellipse_stencil', self
70
+ end
@@ -0,0 +1,33 @@
1
+ # Fits the image based on the smaller-side fit. This means that the image is going to be fit
2
+ # into the requested rectangle so that all of the pixels of the rectangle are filled. The
3
+ # gravity parameter defines the crop gravity (on corners, sides, or in the middle).
4
+ #
5
+ # The corresponding Pipeline method is `fit_crop`.
6
+ class ImageVise::FitCrop < Ks.strict(:width, :height, :gravity)
7
+ GRAVITY_PARAMS = {
8
+ 'nw' => Magick::NorthWestGravity,
9
+ 'n' => Magick::NorthGravity,
10
+ 'ne' => Magick::NorthEastGravity,
11
+ 'w' => Magick::WestGravity,
12
+ 'c' => Magick::CenterGravity,
13
+ 'e' => Magick::EastGravity,
14
+ 'sw' => Magick::SouthWestGravity,
15
+ 's' => Magick::SouthGravity,
16
+ 'se' => Magick::SouthEastGravity,
17
+ }
18
+
19
+ def initialize(*)
20
+ super
21
+ self.width = width.to_i
22
+ self.height = height.to_i
23
+ raise ArgumentError, ":width must positive" unless width > 0
24
+ raise ArgumentError, ":height must positive" unless height > 0
25
+ raise ArgumentError, ":gravity must be within the permitted values" unless GRAVITY_PARAMS.key? gravity
26
+ end
27
+
28
+ def apply!(magick_image)
29
+ magick_image.resize_to_fill! width, height, GRAVITY_PARAMS.fetch(gravity)
30
+ end
31
+
32
+ ImageVise.add_operator 'fit_crop', self
33
+ end
@@ -0,0 +1,17 @@
1
+ # Forces the output format to be JPEG and specifies the quality factor to use when saving
2
+ #
3
+ # The corresponding Pipeline method is `force_jpg_out`.
4
+ class ImageVise::ForceJPGOut < Ks.strict(:quality)
5
+ def initialize(quality:)
6
+ unless (0..100).cover?(quality)
7
+ raise ArgumentError, "the :quality setting must be within 0..100, but was %d" % quality
8
+ end
9
+ self.quality = quality
10
+ end
11
+
12
+ def apply!(_, metadata)
13
+ metadata[:writer] = ImageVise::JPGWriter.new(quality: quality)
14
+ end
15
+
16
+ ImageVise.add_operator 'force_jpg_out', self
17
+ end
@@ -0,0 +1,16 @@
1
+ # Applies a transformation using an ImageMagick geometry string
2
+ #
3
+ # The corresponding Pipeline method is `geom`.
4
+ class ImageVise::Geom < Ks.strict(:geometry_string)
5
+ def initialize(*)
6
+ super
7
+ self.geometry_string = geometry_string.to_s
8
+ raise ArgumentError, "the :geom parameter must be present and not empty" if self.geometry_string.empty?
9
+ end
10
+
11
+ def apply!(image)
12
+ image.change_geometry(geometry_string) { |cols, rows, _| image.resize!(cols,rows) }
13
+ end
14
+
15
+ ImageVise.add_operator 'geom', self
16
+ end
@@ -0,0 +1,21 @@
1
+ # Applies a sharpening filter to the image.
2
+ #
3
+ # The corresponding Pipeline method is `sharpen`.
4
+ class ImageVise::Sharpen < Ks.strict(:radius, :sigma)
5
+ def initialize(*)
6
+ super
7
+ self.radius = radius.to_f
8
+ self.sigma = sigma.to_f
9
+ raise ArgumentError, ":radius must positive" unless sigma > 0
10
+ raise ArgumentError, ":sigma must positive" unless sigma > 0
11
+ end
12
+
13
+ def apply!(magick_image)
14
+ sharpened_image = magick_image.sharpen(radius, sigma)
15
+ magick_image.composite!(sharpened_image, Magick::CenterGravity, Magick::CopyCompositeOp)
16
+ ensure
17
+ ImageVise.destroy(sharpened_image)
18
+ end
19
+ end
20
+
21
+ ImageVise.add_operator 'sharpen', ImageVise::Sharpen
@@ -0,0 +1,30 @@
1
+ # Applies the sRGB profile to the image.
2
+ # For this to work, your ImageMagick must be built
3
+ # witl LCMS support. On OSX, you need to use the brew install
4
+ # command with the following options:
5
+ #
6
+ # $brew install imagemagick --with-little-cms --with-little-cms2
7
+ #
8
+ # You can verify if you do have LittleCMS support by checking the
9
+ # delegates list that `$convert --version` outputs:
10
+ #
11
+ # For instance, if you do not have it, the list will look like this:
12
+ #
13
+ # $ convert --version
14
+ # ...
15
+ # Delegates (built-in): bzlib freetype jng jpeg ltdl lzma png tiff xml zlib
16
+ #
17
+ # whereas if you do, the list will include the "lcms" delegate:
18
+ #
19
+ # $ convert --version
20
+ # ...
21
+ # Delegates (built-in): bzlib freetype jng jpeg lcms ltdl lzma png tiff xml zlib
22
+ #
23
+ # The corresponding Pipeline method is `srgb`.
24
+ class ImageVise::SRGB
25
+ PROFILE_PATH = File.expand_path(__dir__ + '/sRGB_v4_ICC_preference_displayclass.icc')
26
+ def apply!(magick_image)
27
+ magick_image.add_profile(PROFILE_PATH)
28
+ end
29
+ ImageVise.add_operator 'srgb', self
30
+ end
@@ -0,0 +1,10 @@
1
+ # Strips metadata from the image (EXIF, IPTC etc.) using the
2
+ # RMagick `strip!` method
3
+ #
4
+ # The corresponding Pipeline method is `strip_metadata`.
5
+ class ImageVise::StripMetadata
6
+ def apply!(magick_image)
7
+ magick_image.strip!
8
+ end
9
+ ImageVise.add_operator 'strip_metadata', self
10
+ end
@@ -0,0 +1,64 @@
1
+ class ImageVise::Pipeline
2
+ def self.operator_by_name(name)
3
+ operator = ImageVise.operator_from(name) or raise "Unknown operator #{name}"
4
+ end
5
+
6
+ def self.from_param(array_of_operator_names_to_operator_params)
7
+ operators = array_of_operator_names_to_operator_params.map do |(operator_name, operator_params)|
8
+ operator_class = operator_by_name(operator_name)
9
+ if operator_params && operator_params.any? && operator_class.method(:new).arity.nonzero?
10
+ operator_class.new(**operator_params)
11
+ else
12
+ operator_class.new
13
+ end
14
+ end
15
+ new(operators)
16
+ end
17
+
18
+ def initialize(operators = [])
19
+ @ops = operators.to_a
20
+ end
21
+
22
+ def <<(image_operator)
23
+ @ops << image_operator; self
24
+ end
25
+
26
+ def empty?
27
+ @ops.empty?
28
+ end
29
+
30
+ def method_missing(method_name, *a, &blk)
31
+ operator_builder = ImageVise.operator_from(method_name)
32
+ self << operator_builder.new(*a)
33
+ end
34
+
35
+ def respond_to_missing?(method_name, *a)
36
+ ImageVise.defined_operators.include?(method_name.to_s)
37
+ end
38
+
39
+ def to_params
40
+ @ops.map do |operator|
41
+ operator_name = ImageVise.operator_name_for(operator)
42
+ operator_params = operator.respond_to?(:to_h) ? operator.to_h : {}
43
+ [operator_name, operator_params]
44
+ end
45
+ end
46
+
47
+ def apply!(magick_image, image_metadata)
48
+ @ops.each do |operator|
49
+ apply_operator_passing_metadata(magick_image, operator, image_metadata)
50
+ end
51
+ end
52
+
53
+ def apply_operator_passing_metadata(magick_image, operator, image_metadata)
54
+ if operator.method(:apply!).arity == 1
55
+ operator.apply!(magick_image)
56
+ else
57
+ operator.apply!(magick_image, image_metadata)
58
+ end
59
+ end
60
+
61
+ def each(&b)
62
+ @ops.each(&b)
63
+ end
64
+ end