image_vise 0.2.1 → 0.2.2

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.
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