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,298 @@
1
+ class ImageVise::RenderEngine
2
+ class UnsupportedInputFormat < StandardError; end
3
+ class EmptyRender < StandardError; end
4
+
5
+ DEFAULT_HEADERS = {
6
+ 'Allow' => "GET"
7
+ }.freeze
8
+
9
+ # Headers for error responses that denote an invalid or
10
+ # an unsatisfiable request
11
+ JSON_ERROR_HEADERS_REQUEST = DEFAULT_HEADERS.merge({
12
+ 'Content-Type' => 'application/json',
13
+ 'Cache-Control' => 'public, max-age=600'
14
+ }).freeze
15
+
16
+ # Headers for error responses that denote
17
+ # an intermittent error (that permit retries)
18
+ JSON_ERROR_HEADERS_INTERMITTENT = DEFAULT_HEADERS.merge({
19
+ 'Content-Type' => 'application/json',
20
+ 'Cache-Control' => 'public, max-age=5'
21
+ }).freeze
22
+
23
+ # "public" of course. Add max-age so that there is _some_
24
+ # revalidation after a time (otherwise some proxies treat it
25
+ # as "must-revalidate" always), and "no-transform" so that
26
+ # various deflate schemes are not applied to it (does happen
27
+ # with Rack::Cache and leads Chrome to throw up on content
28
+ # decoding for example).
29
+ IMAGE_CACHE_CONTROL = 'public, no-transform, max-age=2592000'
30
+
31
+ # How long is a render (the ImageMagick/write part) is allowed to
32
+ # take before we kill it
33
+ RENDER_TIMEOUT_SECONDS = 10
34
+
35
+ # Which input files we permit (based on extensions stored in MagicBytes)
36
+ PERMITTED_SOURCE_FILE_EXTENSIONS = %w( gif png jpg psd tif)
37
+
38
+ # How long should we wait when fetching the image from the external host
39
+ EXTERNAL_IMAGE_FETCH_TIMEOUT_SECONDS = 4
40
+
41
+ def bail(status, *errors_array)
42
+ headers = if (300...500).cover?(status)
43
+ JSON_ERROR_HEADERS_REQUEST.dup
44
+ else
45
+ JSON_ERROR_HEADERS_INTERMITTENT.dup
46
+ end
47
+ response = [status.to_i, headers, [JSON.pretty_generate({errors: errors_array})]]
48
+ throw :__bail, response
49
+ end
50
+
51
+ # The main entry point for the Rack app. Wraps a call to {#handle_request} in a `catch{}` block
52
+ # so that any method can abort the request by calling {#bail}
53
+ #
54
+ # @param env[Hash] the Rack env
55
+ # @return [Array] the Rack response
56
+ def call(env)
57
+ catch(:__bail) { handle_request(env) }
58
+ end
59
+
60
+ # Hadles the Rack request. If one of the steps calls {#bail} the `:__bail` symbol will be
61
+ # thrown and the execution will abort. Any errors will cause either an error response in
62
+ # JSON format or an Exception will be raised (depending on the return value of `raise_exceptions?`)
63
+ #
64
+ # @param env[Hash] the Rack env
65
+ # @return [Array] the Rack response
66
+ def handle_request(env)
67
+ setup_error_handling(env)
68
+
69
+ # Assume that if _any_ ETag is given the image is being requested anew as a refetch,
70
+ # and the client already has it. Just respond with a 304.
71
+ return [304, DEFAULT_HEADERS.dup, []] if env['HTTP_IF_NONE_MATCH']
72
+
73
+ req = parse_env_into_request(env)
74
+ bail(405, 'Only GET supported') unless req.get?
75
+ params = extract_params_from_request(req)
76
+
77
+ image_request = ImageVise::ImageRequest.from_params(qs_params: params, secrets: ImageVise.secret_keys)
78
+ render_destination_file, render_file_type, etag = process_image_request(image_request)
79
+ image_rack_response(render_destination_file, render_file_type, etag)
80
+ rescue *permanent_failures => e
81
+ handle_request_error(e)
82
+ http_status_code = e.respond_to?(:http_status) ? e.http_status : 400
83
+ raise_exception_or_error_response(e, http_status_code)
84
+ rescue Exception => e
85
+ if http_status_code = (e.respond_to?(:http_status) && e.http_status)
86
+ handle_request_error(e)
87
+ raise_exception_or_error_response(e, http_status_code)
88
+ else
89
+ handle_generic_error(e)
90
+ raise_exception_or_error_response(e, 500)
91
+ end
92
+ end
93
+
94
+ # Parses the Rack environment into a Rack::Reqest. The following methods
95
+ # are going to be called on it: `#get?` and `#params`. You can use this
96
+ # method to override path-to-parameter translation for example.
97
+ #
98
+ # @param rack_env[Hash] the Rack environment
99
+ # @return [#get?, #params] the Rack request or a compatible object
100
+ def parse_env_into_request(rack_env)
101
+ Rack::Request.new(rack_env)
102
+ end
103
+
104
+ # Extracts the image params from the Rack::Request
105
+ #
106
+ # @param rack_request[#path_info] an object that has a path info
107
+ # @return [Hash] the params hash with `:q` and `:sig` keys
108
+ def extract_params_from_request(rack_request)
109
+ # Prevent cache bypass DOS attacks by only permitting :sig and :q
110
+ bail(400, 'Query strings are not supported') if rack_request.params.any?
111
+
112
+ # Extract the tail (signature) and the front (the Base64-encoded request).
113
+ # Slashes within :q are masked by ImageRequest already, so we don't have
114
+ # to worry about them.
115
+ *, q_from_path, sig_from_path = rack_request.path_info.split('/')
116
+
117
+ # Raise if any of them are empty or blank
118
+ nothing_recovered = [q_from_path, sig_from_path].all?{|v| v.nil? || v.empty? }
119
+ bail(400, 'Need 2 usable path components') if nothing_recovered
120
+
121
+ {q: q_from_path, sig: sig_from_path}
122
+ end
123
+
124
+ # Processes the ImageRequest object created from the request parameters,
125
+ # and returns a triplet of the File object containing the rendered image,
126
+ # the MagicBytes::FileType object of the render, and the cache ETag value
127
+ # representing the processing pipeline
128
+ #
129
+ # @param image_request[ImageVise::ImageRequest] the request for the image
130
+ # @return [Array<File, MagicBytes::FileType, String]
131
+ def process_image_request(image_request)
132
+ # Recover the source image URL and the pipeline instructions (all the image ops)
133
+ source_image_uri, pipeline = image_request.src_url, image_request.pipeline
134
+ raise 'Image pipeline has no operators' if pipeline.empty?
135
+
136
+ # Compute an ETag which describes this image transform + image source location.
137
+ # Assume the image URL contents does _never_ change.
138
+ etag = image_request.cache_etag
139
+
140
+ # Download/copy the original into a Tempfile
141
+ fetcher = ImageVise.fetcher_for(source_image_uri.scheme)
142
+ source_file = fetcher.fetch_uri_to_tempfile(source_image_uri)
143
+
144
+ # Make sure we do not try to process something...questionable
145
+ source_file_type = detect_file_type(source_file)
146
+ unless source_file_type_permitted?(source_file_type)
147
+ raise UnsupportedInputFormat.new("Unsupported/unknown input file format .%s" % source_file_type.ext)
148
+ end
149
+
150
+ render_destination_file = Tempfile.new('imagevise-render').tap{|f| f.binmode }
151
+
152
+ # Do the actual imaging stuff
153
+ apply_pipeline(source_file.path, pipeline, source_file_type, render_destination_file.path)
154
+
155
+ # Catch this one early
156
+ render_destination_file.rewind
157
+ raise EmptyRender, "The rendered image was empty" if render_destination_file.size.zero?
158
+
159
+ render_file_type = detect_file_type(render_destination_file)
160
+ [render_destination_file, render_file_type, etag]
161
+ ensure
162
+ ImageVise.close_and_unlink(source_file)
163
+ end
164
+
165
+ # Returns a Rack response triplet. Accepts the return value of
166
+ # `process_image_request` unsplatted, and returns a triplet that
167
+ # can be returned as a Rack response. The Rack response will contain
168
+ # an iterable body object that is designed to automatically delete
169
+ # the Tempfile it wraps on close.
170
+ #
171
+ # @param render_destination_file[File] the File handle to the rendered image
172
+ # @param render_file_type[MagicBytes::FileType] the rendered file type
173
+ # @param etag[String] the ETag for the response
174
+ def image_rack_response(render_destination_file, render_file_type, etag)
175
+ response_headers = DEFAULT_HEADERS.merge({
176
+ 'Content-Type' => render_file_type.mime,
177
+ 'Content-Length' => '%d' % render_destination_file.size,
178
+ 'Cache-Control' => IMAGE_CACHE_CONTROL,
179
+ 'ETag' => etag
180
+ })
181
+
182
+ # Wrap the body Tempfile with a self-closing response.
183
+ # Once the response is read in full, the tempfile is going to be closed and unlinked.
184
+ [200, response_headers, ImageVise::FileResponse.new(render_destination_file)]
185
+ end
186
+
187
+ # Depending on `raise_exceptions?` will either raise the passed Exception,
188
+ # or force the application to return the error in the Rack response.
189
+ #
190
+ # @param exception[Exception] the error that has to be captured
191
+ # @param status_code[Fixnum] the HTTP status code
192
+ def raise_exception_or_error_response(exception, status_code)
193
+ if raise_exceptions?
194
+ raise exception
195
+ else
196
+ bail status_code, exception.message
197
+ end
198
+ end
199
+
200
+ # Detects the file type of the given File and returns
201
+ # a MagicBytes::FileType object that contains the extension and
202
+ # the MIME type.
203
+ #
204
+ # @param tempfile[File] the file to perform detection on
205
+ # @return [MagicBytes::FileType] the detected file type
206
+ def detect_file_type(tempfile)
207
+ tempfile.rewind
208
+ MagicBytes.read_and_detect(tempfile).tap { tempfile.rewind }
209
+ end
210
+
211
+ # Tells whether the given file type may be loaded into the image processor.
212
+ #
213
+ # @param magic_bytes_file_info[MagicBytes::FileType] the filetype
214
+ # @return [Boolean]
215
+ def source_file_type_permitted?(magic_bytes_file_info)
216
+ PERMITTED_SOURCE_FILE_EXTENSIONS.include?(magic_bytes_file_info.ext)
217
+ end
218
+
219
+ # Lists exceptions that should lead to the request being flagged
220
+ # as invalid (4xx as opposed to 5xx for a generic server error).
221
+ # Decent clients should _not_ retry those requests.
222
+ def permanent_failures
223
+ [
224
+ Magick::ImageMagickError,
225
+ UnsupportedInputFormat,
226
+ ImageVise::ImageRequest::InvalidRequest
227
+ ]
228
+ end
229
+
230
+ # Is meant to be overridden by subclasses,
231
+ # will be called at the start of each request to set up the error handling
232
+ # library (Appsignal, Honeybadger, Sentry...)
233
+ #
234
+ # @param rack_env[Hash] the Rack env
235
+ # @return [void]
236
+ def setup_error_handling(rack_env)
237
+ end
238
+
239
+ # Is meant to be overridden by subclasses,
240
+ # will be called when a request fails due to a malformed query string,
241
+ # unrecognized signature or other client-induced problems. The method
242
+ # should _not_ re-raise the exception.
243
+ #
244
+ # @param exception[Exception] the exception to be handled
245
+ # @return [void]
246
+ def handle_request_error(exception)
247
+ end
248
+
249
+ # Is meant to be overridden by subclasses,
250
+ # will be called when a request fails due to an error on the server
251
+ # (like an unexpected error in an image operator). The method
252
+ # should _not_ re-raise the exception.
253
+ #
254
+ # @param exception[Exception] the exception to be handled
255
+ # @return [void]
256
+ def handle_generic_error(exception)
257
+ end
258
+
259
+ # Tells whether the engine must raise the exceptions further up the Rack stack,
260
+ # or they should be suppressed and a JSON response must be returned.
261
+ #
262
+ # @return [Boolean]
263
+ def raise_exceptions?
264
+ false
265
+ end
266
+
267
+ # Applies the given {ImageVise::Pipeline} to the image, and writes the render to
268
+ # the given path.
269
+ #
270
+ # @param source_file_path[String] the path to the file containing the source image
271
+ # @param pipeline[#apply!(Magick::Image)] the processing pipeline
272
+ # @param render_to_path[String] the path to write the rendered image to
273
+ # @return [void]
274
+ def apply_pipeline(source_file_path, pipeline, source_file_type, render_to_path)
275
+ render_file_type = source_file_type
276
+
277
+ # Load the first frame of the animated GIF _or_ the blended compatibility layer from Photoshop
278
+ image_list = Magick::Image.read(source_file_path)
279
+ magick_image = image_list.first # Picks up the "precomp" PSD layer in compatibility mode, or the first frame of a GIF
280
+
281
+ # If any operators want to stash some data for downstream use we use this Hash
282
+ metadata = {}
283
+
284
+ # Apply the pipeline (all the image operators)
285
+ pipeline.apply!(magick_image, metadata)
286
+
287
+ # Write out the file honoring the possible injected metadata. One of the metadata
288
+ # elements (that an operator might want to alter) is the :writer, we forcibly #fetch
289
+ # it so that we get a KeyError if some operator has deleted it without providing a replacement.
290
+ # If no operators touched the writer we are going to use the automatic format selection
291
+ writer = metadata.fetch(:writer, ImageVise::AutoWriter.new)
292
+ writer.write_image!(magick_image, metadata, render_to_path)
293
+ ensure
294
+ # destroy all the loaded images explicitly
295
+ (image_list || []).map {|img| ImageVise.destroy(img) }
296
+ end
297
+
298
+ end
@@ -0,0 +1,3 @@
1
+ class ImageVise
2
+ VERSION = '0.2.2'
3
+ end
@@ -0,0 +1,23 @@
1
+ # Picks the most reasonable "default" output format for web resources. In practice, if the
2
+ # image contains transparency (an alpha channel) PNG will be chosen, and if not - JPEG will
3
+ # be chosen. Since ImageVise URLs do not contain a file extension we are free to pick
4
+ # the suitable format at render time
5
+ class ImageVise::AutoWriter
6
+ # The default file type for images with alpha
7
+ PNG_FILE_TYPE = MagicBytes::FileType.new('png','image/png').freeze
8
+
9
+ # Renders the file as a jpg if the custom output filetype operator is used
10
+ JPG_FILE_TYPE = MagicBytes::FileType.new('jpg','image/jpeg').freeze
11
+
12
+ def write_image!(magick_image, _, render_to_path)
13
+ # If processing the image has created an alpha channel, use PNG always.
14
+ # Otherwise, keep the original format for as far as the supported formats list goes.
15
+ render_file_type = if magick_image.alpha?
16
+ PNG_FILE_TYPE
17
+ else
18
+ JPG_FILE_TYPE
19
+ end
20
+ magick_image.format = render_file_type.ext
21
+ magick_image.write(render_to_path)
22
+ end
23
+ end
@@ -0,0 +1,9 @@
1
+ class ImageVise::JPGWriter < Ks.strict(:quality)
2
+ JPG_FILE_TYPE = MagicBytes::FileType.new('jpg','image/jpeg').freeze
3
+
4
+ def write_image!(magick_image, _, render_to_path)
5
+ q = self.quality # to avoid the changing "self" context
6
+ magick_image.format = JPG_FILE_TYPE.ext
7
+ magick_image.write(render_to_path) { self.quality = q }
8
+ end
9
+ end
data/lib/image_vise.rb ADDED
@@ -0,0 +1,177 @@
1
+ require 'ks'
2
+ require 'json'
3
+ require 'patron'
4
+ require 'rmagick'
5
+ require 'magic_bytes'
6
+ require 'thread'
7
+ require 'base64'
8
+ require 'rack'
9
+
10
+ class ImageVise
11
+ require_relative 'image_vise/version'
12
+ S_MUTEX = Mutex.new
13
+ private_constant :S_MUTEX
14
+
15
+ @allowed_hosts = Set.new
16
+ @keys = Set.new
17
+ @operators = {}
18
+ @allowed_glob_patterns = Set.new
19
+ @fetchers = {}
20
+
21
+ class << self
22
+ # Resets all allowed hosts
23
+ def reset_allowed_hosts!
24
+ S_MUTEX.synchronize { @allowed_hosts.clear }
25
+ end
26
+
27
+ # Add an allowed host
28
+ def add_allowed_host!(hostname)
29
+ S_MUTEX.synchronize { @allowed_hosts << hostname }
30
+ end
31
+
32
+ # Returns both the allowed hosts added at runtime and the ones set in the constant
33
+ def allowed_hosts
34
+ S_MUTEX.synchronize { @allowed_hosts.to_a }
35
+ end
36
+
37
+ # Removes all set keys
38
+ def reset_secret_keys!
39
+ S_MUTEX.synchronize { @keys.clear }
40
+ end
41
+
42
+ def allow_filesystem_source!(glob_pattern)
43
+ S_MUTEX.synchronize { @allowed_glob_patterns << glob_pattern }
44
+ end
45
+
46
+ def allowed_filesystem_sources
47
+ S_MUTEX.synchronize { @allowed_glob_patterns.to_a }
48
+ end
49
+
50
+ def deny_filesystem_sources!
51
+ S_MUTEX.synchronize { @allowed_glob_patterns.clear }
52
+ end
53
+
54
+ # Adds a key against which the parameters are going to be verified.
55
+ # Multiple applications may have their own different keys,
56
+ # so we need to have multiple keys.
57
+ def add_secret_key!(key)
58
+ S_MUTEX.synchronize { @keys << key }
59
+ self
60
+ end
61
+
62
+ # Returns the array of defined keys or raises an exception if no keys have been set yet
63
+ def secret_keys
64
+ keys = S_MUTEX.synchronize { @keys.any? && @keys.to_a }
65
+ keys or raise "No keys set, add a key using `ImageVise.add_secret_key!(key)'"
66
+ end
67
+
68
+ # Generate a set of querystring params for a resized image. Yields a Pipeline object that
69
+ # will receive method calls for adding image operations to a stack.
70
+ #
71
+ # ImageVise.image_params(src_url: image_url_on_s3, secret: '...') do |p|
72
+ # p.center_fit width: 128, height: 128
73
+ # p.elliptic_stencil
74
+ # end #=> {q: '...', sig: '...'}
75
+ #
76
+ # The query string elements can be then passed on to RenderEngine for validation and execution.
77
+ #
78
+ # @yield {ImageVise::Pipeline}
79
+ # @return [Hash]
80
+ def image_params(src_url:, secret:)
81
+ p = Pipeline.new
82
+ yield(p)
83
+ raise ArgumentError, "Image pipeline has no steps defined" if p.empty?
84
+ ImageRequest.new(src_url: URI(src_url), pipeline: p).to_query_string_params(secret)
85
+ end
86
+
87
+ # Generate a path for a resized image. Yields a Pipeline object that
88
+ # will receive method calls for adding image operations to a stack.
89
+ #
90
+ # ImageVise.image_path(src_url: image_url_on_s3, secret: '...') do |p|
91
+ # p.center_fit width: 128, height: 128
92
+ # p.elliptic_stencil
93
+ # end #=> "/abcdef/xyz123"
94
+ #
95
+ # The query string elements can be then passed on to RenderEngine for validation and execution.
96
+ #
97
+ # @yield {ImageVise::Pipeline}
98
+ # @return [String]
99
+ def image_path(src_url:, secret:)
100
+ p = Pipeline.new
101
+ yield(p)
102
+ raise ArgumentError, "Image pipeline has no steps defined" if p.empty?
103
+ ImageRequest.new(src_url: URI(src_url), pipeline: p).to_path_params(secret)
104
+ end
105
+
106
+ # Adds an operator
107
+ def add_operator(operator_name, object_responding_to_new)
108
+ @operators[operator_name.to_s] = object_responding_to_new
109
+ end
110
+
111
+ # Gets an operator by name
112
+ def operator_from(operator_name)
113
+ @operators.fetch(operator_name.to_s)
114
+ end
115
+
116
+ def defined_operator_names
117
+ @operators.keys
118
+ end
119
+
120
+ def register_fetcher(scheme, fetcher)
121
+ S_MUTEX.synchronize { @fetchers[scheme.to_s] = fetcher }
122
+ end
123
+
124
+ def fetcher_for(scheme)
125
+ S_MUTEX.synchronize { @fetchers[scheme.to_s] or raise "No fetcher registered for #{scheme}" }
126
+ end
127
+
128
+ def operator_name_for(operator)
129
+ S_MUTEX.synchronize do
130
+ @operators.key(operator.class) or raise "Operator #{operator.inspect} not registered using ImageVise.add_operator"
131
+ end
132
+ end
133
+ end
134
+
135
+ # Made available since the object that is used with `mount()` in Rails
136
+ # has to, by itself, to respond to `call`.
137
+ #
138
+ # Thanks to this method you can do this:
139
+ #
140
+ # mount ImageVise => '/thumbnails'
141
+ #
142
+ # instead of having to do
143
+ #
144
+ # mount ImageVise.new => '/thumbnails'
145
+ #
146
+ def self.call(rack_env)
147
+ ImageVise::RenderEngine.new.call(rack_env)
148
+ end
149
+
150
+ def call(rack_env)
151
+ ImageVise::RenderEngine.new.call(rack_env)
152
+ end
153
+
154
+ # Used as a shorthand to force-destroy Magick images in ensure() blocks. Since
155
+ # ensure blocks sometimes deal with variables in inconsistent states (variable
156
+ # in scope but not yet set to an image) we take the possibility of nils into account.
157
+ # We also deal with Magick::Image objects that already have been destroyed in a clean manner.
158
+ def self.destroy(maybe_image)
159
+ return unless maybe_image
160
+ return unless maybe_image.respond_to?(:destroy!)
161
+ return if maybe_image.destroyed?
162
+ maybe_image.destroy!
163
+ end
164
+
165
+ # Used as a shorthand to force-dealloc Tempfiles in an ensure() blocks. Since
166
+ # ensure blocks sometimes deal with variables in inconsistent states (variable
167
+ # in scope but not yet set to an image) we take the possibility of nils into account.
168
+ def self.close_and_unlink(maybe_tempfile)
169
+ return unless maybe_tempfile
170
+ maybe_tempfile.close unless maybe_tempfile.closed?
171
+ maybe_tempfile.unlink
172
+ end
173
+ end
174
+
175
+ Dir.glob(__dir__ + '/**/*.rb').sort.each do |f|
176
+ require f unless f == File.expand_path(__FILE__)
177
+ 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,39 @@
1
+ require_relative '../spec_helper'
2
+
3
+ describe ImageVise::BackgroundFill do
4
+
5
+ it 'refuses empty parameters' do
6
+ expect { described_class.new(color:"") }.to raise_error(ArgumentError)
7
+ end
8
+
9
+ it 'successfully exports a png with a fill' do
10
+ image = Magick::Image.read(test_image_png_transparency)[0]
11
+ expect(image).to be_alpha
12
+ background_fill = described_class.new(color: 'white')
13
+ background_fill.apply!(image)
14
+
15
+ expect(image).to_not be_alpha
16
+ examine_image(image, "set-color-to-white")
17
+ end
18
+
19
+ it 'can be passed CSS word colors and process them consistently' do
20
+ image = Magick::Image.read(test_image_png_transparency)[0]
21
+
22
+ background_fill = described_class.new(color: 'green')
23
+ background_fill.apply!(image)
24
+ hex_color = image.pixel_color(720,248).to_color(Magick::AllCompliance,matte=false, 8, hex=true)
25
+
26
+ expect(hex_color).to eq("#008000")
27
+ examine_image(image, "set-color-to-green")
28
+ end
29
+
30
+ it 'can be passed hex colors and process them consistently' do
31
+ image = Magick::Image.read(test_image_png_transparency)[0]
32
+ background_fill = described_class.new(color: '#ffebcd')
33
+ background_fill.apply!(image)
34
+ hex_color = image.pixel_color(720,248).to_color(Magick::AllCompliance,matte=false, 8, hex=true)
35
+
36
+ expect(hex_color).to eq("#FFEBCD")
37
+ examine_image(image, "set-color-to-blanched-almond")
38
+ end
39
+ 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,18 @@
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
+
11
+ it 'applies the circle stencil to a png with transparency' do
12
+ png_transparent_path = File.expand_path(__dir__ + '/../waterside_magic_hour_transp.png')
13
+ image = Magick::Image.read(png_transparent_path)[0]
14
+ stencil = described_class.new
15
+ stencil.apply!(image)
16
+ examine_image(image, "circle-stencil-transparent-bg")
17
+ end
18
+ end
@@ -0,0 +1,48 @@
1
+ require_relative '../spec_helper'
2
+
3
+ describe ImageVise::FetcherFile do
4
+ it 'is a class (can be inherited from)' do
5
+ expect(ImageVise::FetcherFile).to be_kind_of(Class)
6
+ end
7
+
8
+ it 'is registered as a fetcher for file://' do
9
+ expect(ImageVise.fetcher_for('file')).to eq(ImageVise::FetcherFile)
10
+ end
11
+
12
+ it 'returns a Tempfile containing this test suite' do
13
+ path = File.expand_path(__FILE__)
14
+ ruby_files_in_this_directory = __dir__ + '/*.rb'
15
+ ImageVise.allow_filesystem_source! ruby_files_in_this_directory
16
+
17
+ uri = URI('file://' + URI.encode(path))
18
+ fetched = ImageVise::FetcherFile.fetch_uri_to_tempfile(uri)
19
+
20
+ expect(fetched).to be_kind_of(Tempfile)
21
+ expect(fetched.size).to eq(File.size(__FILE__))
22
+ expect(fetched.pos).to be_zero
23
+ end
24
+
25
+ it 'raises a meaningful exception if no file sources are permitted' do
26
+ path = File.expand_path(__FILE__)
27
+
28
+ ImageVise.deny_filesystem_sources!
29
+
30
+ uri = URI('file://' + URI.encode(path))
31
+ expect {
32
+ ImageVise::FetcherFile.fetch_uri_to_tempfile(uri)
33
+ }.to raise_error(ImageVise::FetcherFile::AccessError)
34
+ end
35
+
36
+ it 'raises a meaningful exception if this file is not permitted as source' do
37
+ path = File.expand_path(__FILE__)
38
+
39
+ text_files_in_this_directory = __dir__ + '/*.txt'
40
+ ImageVise.deny_filesystem_sources!
41
+ ImageVise.allow_filesystem_source! text_files_in_this_directory
42
+
43
+ uri = URI('file://' + URI.encode(path))
44
+ expect {
45
+ ImageVise::FetcherFile.fetch_uri_to_tempfile(uri)
46
+ }.to raise_error(ImageVise::FetcherFile::AccessError)
47
+ end
48
+ end