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