image_vise 0.0.16

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.
@@ -0,0 +1,54 @@
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.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)
48
+ @ops.each{|e| e.apply!(magick_image) }
49
+ end
50
+
51
+ def each(&b)
52
+ @ops.each(&b)
53
+ end
54
+ end
@@ -0,0 +1,210 @@
1
+ class ImageVise::RenderEngine
2
+ require_relative 'image_request'
3
+ require_relative 'file_response'
4
+ class UnsupportedInputFormat < StandardError; end
5
+ class EmptyRender < StandardError; end
6
+
7
+ # Codes that have to be sent through to the requester
8
+ PASSTHROUGH_STATUS_CODES = [404, 403, 503, 504, 500]
9
+
10
+ DEFAULT_HEADERS = {
11
+ 'Allow' => "GET"
12
+ }.freeze
13
+
14
+ # To prevent some string allocations
15
+ JSON_ERROR_HEADERS = DEFAULT_HEADERS.merge({
16
+ 'Content-Type' => 'application/json',
17
+ 'Cache-Control' => 'private, max-age=0, no-cache'
18
+ }).freeze
19
+
20
+ # How long is a render (the ImageMagick/write part) is allowed to
21
+ # take before we kill it
22
+ RENDER_TIMEOUT_SECONDS = 10
23
+
24
+ # Which input files we permit (based on extensions stored in MagicBytes)
25
+ PERMITTED_EXTENSIONS = %w( gif png jpg )
26
+
27
+ # How long should we wait when fetching the image from the external host
28
+ EXTERNAL_IMAGE_FETCH_TIMEOUT_SECONDS = 4
29
+
30
+ # The default file type for images with alpha
31
+ PNG_FILE_TYPE = Class.new do
32
+ def self.mime; 'image/png'; end
33
+ def self.ext; 'png'; end
34
+ end
35
+
36
+ # Fetch the given URL into a Tempfile and return the File object
37
+ def fetch_url_into_tempfile(source_image_uri)
38
+ tf = Tempfile.new('source-imagevise-image')
39
+ s = Patron::Session.new
40
+ s.automatic_content_encoding = true
41
+ s.timeout = EXTERNAL_IMAGE_FETCH_TIMEOUT_SECONDS
42
+ s.connect_timeout = EXTERNAL_IMAGE_FETCH_TIMEOUT_SECONDS
43
+ response = s.get_file(source_image_uri, tf.path)
44
+ if PASSTHROUGH_STATUS_CODES.include?(response.status)
45
+ tf.close; tf.unlink;
46
+ bail response.status, "Unfortunate upstream response: #{response.status}"
47
+ end
48
+ tf
49
+ rescue Exception => e
50
+ tf.close; tf.unlink;
51
+ raise e
52
+ end
53
+
54
+ def bail(status, *errors_array)
55
+ h = JSON_ERROR_HEADERS.dup # Needed because some upstream middleware migh be modifying headers
56
+ response = [status.to_i, h, [JSON.pretty_generate({errors: errors_array})]]
57
+ throw :__bail, response
58
+ end
59
+
60
+ # The main entry point URL, at the index so that the Sinatra app can be used
61
+ # in-place of a Rails controller (as opposed to having to mount it at the root
62
+ # of the Rails app or having all the URLs refer to a subpath)
63
+ def call(env)
64
+ catch(:__bail) { handle_request(env) }
65
+ end
66
+
67
+ def handle_request(env)
68
+ setup_error_handling(env)
69
+ render_destination_file = binary_tempfile
70
+
71
+ # Assume that if _any_ ETag is given the image is being requested anew as a refetch,
72
+ # and the client already has it. Just respond with a 304.
73
+ return [304, DEFAULT_HEADERS.dup, []] if env['HTTP_IF_NONE_MATCH']
74
+
75
+ req = Rack::Request.new(env)
76
+ bail(405, 'Only GET supported') unless req.get?
77
+
78
+ # Validate the inputs
79
+ image_request = ImageVise::ImageRequest.to_request(qs_params: req.params,
80
+ secrets: ImageVise.secret_keys,
81
+ permitted_source_hosts: ImageVise.allowed_hosts)
82
+
83
+ # Recover the source image URL and the pipeline instructions (all the image ops)
84
+ source_image_uri, pipeline = image_request.src_url, image_request.pipeline
85
+ raise 'Image pipeline has no operators' if pipeline.empty?
86
+
87
+ # Compute an ETag which describes this image transform + image source location.
88
+ # Assume the image URL contents does _never_ change.
89
+ etag = image_request.cache_etag
90
+
91
+ # Download the original into a Tempfile
92
+ source_file = fetch_url_into_tempfile(source_image_uri)
93
+
94
+ # Make sure we do not try to process something...questionable
95
+ source_file_type = detect_file_type(source_file)
96
+
97
+ # Perform the processing
98
+ if enable_forking?
99
+ require 'exceptional_fork'
100
+ ExceptionalFork.fork_and_wait { apply_pipeline(source_file.path, pipeline, source_file_type, render_destination_file.path) }
101
+ else
102
+ apply_pipeline(source_file.path, pipeline, source_file_type, render_destination_file.path)
103
+ end
104
+
105
+ # Catch this one early
106
+ raise EmptyRender, "The rendered image was empty" if render_destination_file.size.zero?
107
+
108
+ render_destination_file.rewind
109
+ render_file_type = detect_file_type(render_destination_file)
110
+
111
+ response_headers = DEFAULT_HEADERS.merge({
112
+ 'Content-Type' => render_file_type.mime,
113
+ 'Content-Length' => '%d' % render_destination_file.size,
114
+ 'Cache-Control' => 'public',
115
+ 'ETag' => etag
116
+ })
117
+
118
+ # Wrap the body Tempfile with a self-closing response.
119
+ # Once the response is read in full, the tempfile is going to be closed and unlinked.
120
+ [200, response_headers, ImageVise::FileResponse.new(render_destination_file)]
121
+ rescue *permanent_failures => e
122
+ handle_request_error(e)
123
+ raise_exception_or_error_response(e, 422)
124
+ rescue Exception => e
125
+ handle_generic_error(e)
126
+ raise_exception_or_error_response(e, 500)
127
+ ensure
128
+ close_and_unlink(source_file)
129
+ end
130
+
131
+ def raise_exception_or_error_response(exception, status_code)
132
+ if raise_exceptions?
133
+ raise exception
134
+ else
135
+ bail status_code, exception.message
136
+ end
137
+ end
138
+
139
+ def close_and_unlink(f)
140
+ return unless f
141
+ f.close unless f.closed?
142
+ f.unlink
143
+ end
144
+
145
+ def binary_tempfile
146
+ Tempfile.new('imagevise-tmp').tap{|f| f.binmode }
147
+ end
148
+
149
+ def detect_file_type(tempfile)
150
+ tempfile.rewind
151
+
152
+ file_info = MagicBytes.read_and_detect(tempfile)
153
+ return file_info if PERMITTED_EXTENSIONS.include?(file_info.ext)
154
+ raise UnsupportedInputFormat.new("Unsupported/unknown input file format .%s" %
155
+ file_info.ext)
156
+ end
157
+
158
+ # Lists exceptions that should lead to the request being flagged
159
+ # as invalid (and not 5xx). Decent clients should _not_ retry those requests.
160
+ def permanent_failures
161
+ [
162
+ Magick::ImageMagickError,
163
+ UnsupportedInputFormat,
164
+ ImageVise::ImageRequest::InvalidRequest
165
+ ]
166
+ end
167
+
168
+ # Is meant to be overridden by subclasses,
169
+ # will be called at the start of each reauest
170
+ def setup_error_handling(rack_env)
171
+ end
172
+
173
+ # Is meant to be overridden by subclasses,
174
+ # will be called when a request fails due to a malformed query string,
175
+ # unrecognized signature or other client-induced problems
176
+ def handle_request_error(err)
177
+ end
178
+
179
+ # Is meant to be overridden by subclasses,
180
+ # will be called when a request fails due to an error on the server
181
+ # (like an unexpected error in an image operator)
182
+ def handle_generic_error(err)
183
+ end
184
+
185
+ # Tells whether the engine must raise the exceptions further up the Rack stack,
186
+ # or they should be suppressed and a JSON response must be returned.
187
+ def raise_exceptions?
188
+ false
189
+ end
190
+
191
+ def enable_forking?
192
+ ENV.key? 'IMAGE_VISE_ENABLE_FORK'
193
+ end
194
+
195
+ def apply_pipeline(source_file_path, pipeline, source_file_type, render_to_path)
196
+ render_file_type = source_file_type
197
+ magick_image = Magick::Image.read(source_file_path)[0]
198
+ pipeline.apply!(magick_image)
199
+
200
+ # If processing the image has created an alpha channel, use PNG always.
201
+ # Otherwise, keep the original format for as far as the supported formats list goes.
202
+ render_file_type = PNG_FILE_TYPE if magick_image.alpha?
203
+
204
+ magick_image.format = render_file_type.ext
205
+ magick_image.write(render_to_path)
206
+ ensure
207
+ ImageVise.destroy(magick_image)
208
+ end
209
+
210
+ 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
data/lib/image_vise.rb ADDED
@@ -0,0 +1,118 @@
1
+ require 'ks'
2
+ require 'json'
3
+ require 'patron'
4
+ require 'rmagick'
5
+ require 'magic_bytes'
6
+
7
+ class ImageVise
8
+ VERSION = '0.0.16'
9
+
10
+ @allowed_hosts = Set.new
11
+ @keys = Set.new
12
+ @operators = {}
13
+
14
+ class << self
15
+ # Resets all allowed hosts
16
+ def reset_allowed_hosts!
17
+ @allowed_hosts.clear
18
+ end
19
+
20
+ # Add an allowed host
21
+ def add_allowed_host!(hostname)
22
+ @allowed_hosts << hostname
23
+ end
24
+
25
+ # Returns both the allowed hosts added at runtime and the ones set in the constant
26
+ def allowed_hosts
27
+ @allowed_hosts.to_a
28
+ end
29
+
30
+ # Removes all set keys
31
+ def reset_secret_keys!
32
+ @keys.clear
33
+ end
34
+
35
+ # Adds a key against which the parameters are going to be verified.
36
+ # Multiple applications may have their own different keys,
37
+ # so we need to have multiple keys.
38
+ def add_secret_key!(key)
39
+ @keys << key; self
40
+ end
41
+
42
+ # Returns the array of defined keys or raises an exception if no keys have been set yet
43
+ def secret_keys
44
+ (@keys.any? && @keys.to_a) or raise "No keys set, add a key using `ImageVise.add_secret_key!(key)'"
45
+ end
46
+
47
+ # Generate a set of querystring params for a resized image. Yields a Pipeline object that
48
+ # will receive method calls for adding image operations to a stack.
49
+ #
50
+ # ImageVise.image_params(src_url: image_url_on_s3, secret: '...') do |p|
51
+ # p.center_fit width: 128, height: 128
52
+ # p.elliptic_stencil
53
+ # end #=> {q: '...', sig: '...'}
54
+ #
55
+ # The query string elements can be then passed on to RenderEngine for validation and execution.
56
+ #
57
+ # @yields {ImageVise::Pipeline}
58
+ # @return [Hash]
59
+ def image_params(src_url:, secret:)
60
+ p = Pipeline.new
61
+ yield(p)
62
+ raise ArgumentError, "Image pipeline has no steps defined" if p.empty?
63
+ ImageRequest.new(src_url: src_url, pipeline: p).to_query_string_params(secret)
64
+ end
65
+
66
+ # Adds an operator
67
+ def add_operator(operator_name, object_responding_to_new)
68
+ @operators[operator_name.to_s] = object_responding_to_new
69
+ end
70
+
71
+ # Gets an operator by name
72
+ def operator_from(operator_name)
73
+ @operators.fetch(operator_name.to_s)
74
+ end
75
+
76
+ def defined_operator_names
77
+ @operators.keys
78
+ end
79
+
80
+ def operator_name_for(operator)
81
+ @operators.key(operator.class) or raise "Operator #{operator.inspect} not registered using ImageVise.add_operator"
82
+ end
83
+ end
84
+
85
+ # Made available since the object that is used with `mount()` in Rails
86
+ # has to, by itself, to respond to `call`.
87
+ #
88
+ # Thanks to this method you can do this:
89
+ #
90
+ # mount ImageVise => '/thumbnails'
91
+ #
92
+ # instead of having to do
93
+ #
94
+ # mount ImageVise.new => '/thumbnails'
95
+ #
96
+ def self.call(rack_env)
97
+ ImageVise::RenderEngine.new.call(rack_env)
98
+ end
99
+
100
+ def call(rack_env)
101
+ ImageVise::RenderEngine.new.call(rack_env)
102
+ end
103
+
104
+ # Used as a shorthand to force-destroy Magick images in ensure() blocks. Since
105
+ # ensure blocks sometimes deal with variables in inconsistent states (variable
106
+ # in scope but not yet set to an image) we take the possibility of nils into account.
107
+ # We also deal with Magick::Image objects that already have been destroyed in a clean manner.
108
+ def self.destroy(maybe_image)
109
+ return unless maybe_image
110
+ return unless maybe_image.respond_to?(:destroy!)
111
+ return if maybe_image.destroyed?
112
+ maybe_image.destroy!
113
+ end
114
+ end
115
+
116
+ Dir.glob(__dir__ + '/**/*.rb').sort.each do |f|
117
+ require f unless f == File.expand_path(__FILE__)
118
+ end
@@ -0,0 +1,10 @@
1
+ require 'spec_helper'
2
+
3
+ describe ImageVise::AutoOrient do
4
+ it 'applies auto orient to the image' do
5
+ image = Magick::Image.read(test_image_path)[0]
6
+ orient = described_class.new
7
+ expect(image).to receive(:auto_orient!).and_call_original
8
+ orient.apply!(image)
9
+ end
10
+ end
@@ -0,0 +1,20 @@
1
+ require 'spec_helper'
2
+
3
+ describe ImageVise::Crop do
4
+ it 'refuses invalid parameters' do
5
+ expect { described_class.new(width: 0, height: -1, gravity: '') }.to raise_error(ArgumentError)
6
+ end
7
+
8
+ it 'applies the crop with different gravities' do
9
+ %w( s sw se n ne nw c).each do |gravity|
10
+ image = Magick::Image.read(test_image_path)[0]
11
+ crop = described_class.new(width: 120, height: 220, gravity: gravity)
12
+
13
+ crop.apply!(image)
14
+
15
+ expect(image.columns).to eq(120)
16
+ expect(image.rows).to eq(220)
17
+ examine_image(image, "gravity-%s-" % gravity)
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,10 @@
1
+ require 'spec_helper'
2
+
3
+ describe ImageVise::EllipseStencil do
4
+ it 'applies the circle stencil' do
5
+ image = Magick::Image.read(test_image_path)[0]
6
+ stencil = described_class.new
7
+ stencil.apply!(image)
8
+ examine_image(image, "circle-stencil")
9
+ end
10
+ end
@@ -0,0 +1,45 @@
1
+ require_relative '../spec_helper'
2
+
3
+ describe ImageVise::FileResponse do
4
+ it 'reads the file in binary mode, closes and unlinks the tempfile when close() is called' do
5
+ random_data = SecureRandom.random_bytes(1024 * 2048)
6
+ f = Tempfile.new("experiment")
7
+ f.binmode
8
+ f << random_data
9
+
10
+ response = described_class.new(f)
11
+ readback = ''.encode(Encoding::BINARY)
12
+ response.each do | chunk |
13
+ expect(chunk.encoding).to eq(Encoding::BINARY)
14
+ readback << chunk
15
+ end
16
+
17
+ response.close
18
+
19
+ expect(readback).to eq(random_data)
20
+ expect(f).to be_closed
21
+ expect(f.path).to be_nil
22
+ end
23
+
24
+ it 'only asks for the path of the tempfile and uses a separate file descriptor' do
25
+ f = Tempfile.new("experiment")
26
+ f.binmode
27
+ f << SecureRandom.random_bytes(2048)
28
+ f.flush
29
+
30
+ # Use a double so that all the methods except the ones we mock raise an assertion
31
+ double = double(path: f.path)
32
+ expect(double).to receive(:flush)
33
+
34
+ read_from_response = ''.encode(Encoding::BINARY)
35
+ response = described_class.new(double)
36
+ response.each{|b| read_from_response << b }
37
+
38
+ f.rewind
39
+
40
+ expect(f.read).to eq(read_from_response)
41
+
42
+ f.close
43
+ f.unlink
44
+ end
45
+ end
@@ -0,0 +1,20 @@
1
+ require 'spec_helper'
2
+
3
+ describe ImageVise::FitCrop do
4
+ it 'refuses invalid arguments' do
5
+ expect { described_class.new(width: 0, height: -1, gravity: '') }.to raise_error(ArgumentError)
6
+ end
7
+
8
+ it 'applies the crop with different gravities' do
9
+ %w( s sw se n ne nw c).each do |gravity|
10
+ image = Magick::Image.read(test_image_path)[0]
11
+ crop = described_class.new(width: 120, height: 220, gravity: gravity)
12
+
13
+ crop.apply!(image)
14
+
15
+ expect(image.columns).to eq(120)
16
+ expect(image.rows).to eq(220)
17
+ examine_image(image, "gravity-%s-" % gravity)
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,17 @@
1
+ require 'spec_helper'
2
+
3
+ describe ImageVise::Geom do
4
+ it 'refuses invalid parameters' do
5
+ expect { described_class.new(geometry_string: nil) }.to raise_error(ArgumentError)
6
+ end
7
+
8
+ it 'applies various geometry strings' do
9
+ %w( ^220x110 !20x20 !10x100 ).each do |geom_string|
10
+ image = Magick::Image.read(test_image_path)[0]
11
+ crop = described_class.new(geometry_string: geom_string)
12
+
13
+ crop.apply!(image)
14
+ examine_image(image, 'geom-%s' % geom_string)
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,53 @@
1
+ require 'spec_helper'
2
+
3
+ describe ImageVise::ImageRequest do
4
+ it 'accepts a set of params, secrets and permitted hosts and returns a Pipeline' do
5
+ img_params = {src_url: 'http://bucket.s3.aws.com/image.jpg', pipeline: [[:crop, {width: 10, height: 10, gravity: 's'}]]}
6
+ img_params_json = JSON.dump(img_params)
7
+ signature = OpenSSL::HMAC.hexdigest(OpenSSL::Digest::SHA256.new, 'this is a secret', img_params_json)
8
+ params = {
9
+ q: Base64.encode64(img_params_json),
10
+ sig: signature
11
+ }
12
+
13
+ image_request = described_class.to_request(qs_params: params, secrets: ['this is a secret'], permitted_source_hosts: ['bucket.s3.aws.com'])
14
+ request_qs_params = image_request.to_query_string_params('this is a secret')
15
+ expect(request_qs_params).to be_kind_of(Hash)
16
+
17
+ image_request_roundtrip = described_class.to_request(qs_params: request_qs_params, secrets: ['this is a secret'], permitted_source_hosts: ['bucket.s3.aws.com'])
18
+ end
19
+
20
+ describe 'fails with an invalid pipeline' do
21
+ it 'when the pipe param is missing'
22
+ it 'when the pipe param is empty'
23
+ it 'when the pipe param cannot be parsed into a Pipeline'
24
+ it 'when the pipe param parses into a Pipeline with zero operators'
25
+ end
26
+
27
+ describe 'fails with an invalid URL' do
28
+ it 'when the URL param is missing'
29
+ it 'when the URL param is empty'
30
+ it 'when the URL is referencing a non-permitted host'
31
+ it 'when the URL refers to a non-HTTP(S) scheme'
32
+ end
33
+
34
+ describe 'fails with an invalid signature' do
35
+ it 'when the sig param is missing'
36
+ it 'when the sig param is empty'
37
+ it 'when the sig is invalid' do
38
+ img_params = {src_url: 'http://bucket.s3.aws.com/image.jpg',
39
+ pipeline: [[:crop, {width: 10, height: 10, gravity: 's'}]]}
40
+ img_params_json = JSON.dump(img_params)
41
+ signature = OpenSSL::HMAC.hexdigest(OpenSSL::Digest::SHA256.new, 'a', img_params_json)
42
+ params = {
43
+ q: Base64.encode64(img_params_json),
44
+ sig: signature
45
+ }
46
+
47
+ expect {
48
+ described_class.to_request(qs_params: params,
49
+ secrets: ['b'], permitted_source_hosts: ['bucket.s3.aws.com'])
50
+ }.to raise_error(/Invalid or missing signature/)
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,70 @@
1
+ require 'spec_helper'
2
+
3
+ describe ImageVise::Pipeline do
4
+ it 'is empty by default' do
5
+ expect(subject).to be_empty
6
+ end
7
+
8
+ it 'reinstates the pipeline from the operator list parameters' do
9
+ params = [
10
+ ["geom", {:geometry_string=>"10x10"}],
11
+ ["crop", {:width=>5, :height=>5, :gravity=>"se"}],
12
+ ["auto_orient", {}],
13
+ ["fit_crop", {:width=>10, :height=>32, :gravity=>"c"}]
14
+ ]
15
+ pipeline = described_class.from_param(params)
16
+ expect(pipeline).not_to be_empty
17
+ end
18
+
19
+ it 'produces a usable operator parameter list that can be roundtripped' do
20
+ operator_list = subject.geom(geometry_string: '10x10').
21
+ crop(width: 5, height: 5, gravity: 'se').
22
+ auto_orient.
23
+ fit_crop(width: 10, height: 32, gravity: 'c').to_params
24
+
25
+ expect(operator_list).to eq([
26
+ ["geom", {:geometry_string=>"10x10"}],
27
+ ["crop", {:width=>5, :height=>5, :gravity=>"se"}],
28
+ ["auto_orient", {}],
29
+ ["fit_crop", {:width=>10, :height=>32, :gravity=>"c"}]
30
+ ])
31
+
32
+ pipeline = described_class.from_param(operator_list)
33
+ expect(pipeline).not_to be_empty
34
+ end
35
+
36
+ it 'applies itself to the image' do
37
+ pipeline = subject.
38
+ auto_orient.
39
+ fit_crop(width: 48, height: 48, gravity: 'c').
40
+ sharpen(radius: 2, sigma: 0.5).
41
+ ellipse_stencil
42
+
43
+ image = Magick::Image.read(test_image_path)[0]
44
+ pipeline.apply! image
45
+ examine_image(image, "stenciled")
46
+ end
47
+
48
+ it 'raises an exception when an attempt is made to serialize an unknown operator' do
49
+ unknown_op_class = Class.new
50
+ subject << unknown_op_class.new
51
+ expect {
52
+ subject.to_params
53
+ }.to raise_error(/not registered/)
54
+ end
55
+
56
+ it 'composes parameters even if one of the operators does not support to_h' do
57
+ class AnonOp
58
+ end
59
+ class ParametricOp
60
+ def to_h; {a: 133}; end
61
+ end
62
+
63
+ ImageVise.add_operator('t_anon', AnonOp)
64
+ ImageVise.add_operator('t_parametric', ParametricOp)
65
+
66
+ subject << AnonOp.new
67
+ subject << ParametricOp.new
68
+ expect(subject.to_params).to eq([["t_anon", {}], ["t_parametric", {:a=>133}]])
69
+ end
70
+ end