httpthumbnailer 0.3.1 → 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -5,11 +5,11 @@
5
5
 
6
6
  Gem::Specification.new do |s|
7
7
  s.name = "httpthumbnailer"
8
- s.version = "0.3.1"
8
+ s.version = "1.0.0"
9
9
 
10
10
  s.required_rubygems_version = Gem::Requirement.new(">= 0") if s.respond_to? :required_rubygems_version=
11
11
  s.authors = ["Jakub Pastuszek"]
12
- s.date = "2012-02-07"
12
+ s.date = "2013-07-16"
13
13
  s.description = "Provides HTTP API for thumbnailing images"
14
14
  s.email = "jpastuszek@gmail.com"
15
15
  s.executables = ["httpthumbnailer"]
@@ -35,81 +35,80 @@ Gem::Specification.new do |s|
35
35
  "features/support/test.jpg",
36
36
  "features/support/test.png",
37
37
  "features/support/test.txt",
38
+ "features/thumbnail.feature",
39
+ "features/thumbnails.feature",
38
40
  "httpthumbnailer.gemspec",
39
- "lib/httpthumbnailer/multipart_response.rb",
41
+ "lib/httpthumbnailer/error_reporter.rb",
42
+ "lib/httpthumbnailer/plugin/thumbnailer.rb",
40
43
  "lib/httpthumbnailer/thumbnail_specs.rb",
41
44
  "lib/httpthumbnailer/thumbnailer.rb",
42
45
  "load_test/extralarge.jpg",
43
46
  "load_test/large.jpg",
44
47
  "load_test/large.png",
48
+ "load_test/load_test-cd9679c.csv",
49
+ "load_test/load_test-v0.3.1.csv",
45
50
  "load_test/load_test.jmx",
46
51
  "load_test/medium.jpg",
47
52
  "load_test/small.jpg",
53
+ "load_test/soak_test-ac0c6bcbe5e-broken-libjpeg-tatoos.csv",
54
+ "load_test/soak_test-cd9679c.csv",
55
+ "load_test/soak_test-f98334a-tatoos.csv",
56
+ "load_test/soak_test.jmx",
48
57
  "load_test/tiny.jpg",
49
58
  "load_test/v0.0.13-loading.csv",
50
59
  "load_test/v0.0.13.csv",
51
60
  "load_test/v0.0.14-no-optimization.csv",
52
61
  "load_test/v0.0.14.csv",
53
- "spec/multipart_response_spec.rb",
62
+ "spec/image_processing_spec.rb",
54
63
  "spec/spec_helper.rb",
55
- "spec/thumbnail_specs_spec.rb",
56
- "spec/thumbnailer_spec.rb"
64
+ "spec/thumbnail_specs_spec.rb"
57
65
  ]
58
66
  s.homepage = "http://github.com/jpastuszek/httpthumbnailer"
59
67
  s.licenses = ["MIT"]
60
68
  s.require_paths = ["lib"]
61
- s.rubygems_version = "1.8.15"
69
+ s.rubygems_version = "1.8.25"
62
70
  s.summary = "HTTP thumbnailing server"
63
71
 
64
72
  if s.respond_to? :specification_version then
65
73
  s.specification_version = 3
66
74
 
67
75
  if Gem::Version.new(Gem::VERSION) >= Gem::Version.new('1.2.0') then
68
- s.add_runtime_dependency(%q<sinatra>, [">= 1.2.6"])
69
- s.add_runtime_dependency(%q<mongrel>, [">= 1.2.0.pre2"])
76
+ s.add_runtime_dependency(%q<unicorn-cuba-base>, ["~> 1.0"])
70
77
  s.add_runtime_dependency(%q<rmagick>, ["~> 2"])
71
- s.add_runtime_dependency(%q<haml>, ["~> 3"])
72
- s.add_runtime_dependency(%q<ruby-ip>, ["~> 0.9"])
73
- s.add_runtime_dependency(%q<cli>, ["~> 1.1.0"])
74
- s.add_development_dependency(%q<rspec>, ["~> 2.3.0"])
78
+ s.add_development_dependency(%q<rspec>, ["~> 2.13"])
79
+ s.add_development_dependency(%q<rspec-mocks>, ["~> 2.13"])
75
80
  s.add_development_dependency(%q<cucumber>, [">= 0"])
76
- s.add_development_dependency(%q<bundler>, ["~> 1.0.0"])
77
- s.add_development_dependency(%q<jeweler>, ["~> 1.6.4"])
78
- s.add_development_dependency(%q<rcov>, [">= 0"])
79
- s.add_development_dependency(%q<daemon>, ["~> 1"])
80
- s.add_development_dependency(%q<httpclient>, ["~> 2.2"])
81
+ s.add_development_dependency(%q<capybara>, ["~> 1.1"])
82
+ s.add_development_dependency(%q<jeweler>, ["~> 1.8.4"])
83
+ s.add_development_dependency(%q<httpclient>, ["~> 2.3"])
81
84
  s.add_development_dependency(%q<rdoc>, ["~> 3.9"])
85
+ s.add_development_dependency(%q<multipart-parser>, ["~> 0.1.1"])
86
+ s.add_development_dependency(%q<daemon>, ["~> 1.1"])
82
87
  else
83
- s.add_dependency(%q<sinatra>, [">= 1.2.6"])
84
- s.add_dependency(%q<mongrel>, [">= 1.2.0.pre2"])
88
+ s.add_dependency(%q<unicorn-cuba-base>, ["~> 1.0"])
85
89
  s.add_dependency(%q<rmagick>, ["~> 2"])
86
- s.add_dependency(%q<haml>, ["~> 3"])
87
- s.add_dependency(%q<ruby-ip>, ["~> 0.9"])
88
- s.add_dependency(%q<cli>, ["~> 1.1.0"])
89
- s.add_dependency(%q<rspec>, ["~> 2.3.0"])
90
+ s.add_dependency(%q<rspec>, ["~> 2.13"])
91
+ s.add_dependency(%q<rspec-mocks>, ["~> 2.13"])
90
92
  s.add_dependency(%q<cucumber>, [">= 0"])
91
- s.add_dependency(%q<bundler>, ["~> 1.0.0"])
92
- s.add_dependency(%q<jeweler>, ["~> 1.6.4"])
93
- s.add_dependency(%q<rcov>, [">= 0"])
94
- s.add_dependency(%q<daemon>, ["~> 1"])
95
- s.add_dependency(%q<httpclient>, ["~> 2.2"])
93
+ s.add_dependency(%q<capybara>, ["~> 1.1"])
94
+ s.add_dependency(%q<jeweler>, ["~> 1.8.4"])
95
+ s.add_dependency(%q<httpclient>, ["~> 2.3"])
96
96
  s.add_dependency(%q<rdoc>, ["~> 3.9"])
97
+ s.add_dependency(%q<multipart-parser>, ["~> 0.1.1"])
98
+ s.add_dependency(%q<daemon>, ["~> 1.1"])
97
99
  end
98
100
  else
99
- s.add_dependency(%q<sinatra>, [">= 1.2.6"])
100
- s.add_dependency(%q<mongrel>, [">= 1.2.0.pre2"])
101
+ s.add_dependency(%q<unicorn-cuba-base>, ["~> 1.0"])
101
102
  s.add_dependency(%q<rmagick>, ["~> 2"])
102
- s.add_dependency(%q<haml>, ["~> 3"])
103
- s.add_dependency(%q<ruby-ip>, ["~> 0.9"])
104
- s.add_dependency(%q<cli>, ["~> 1.1.0"])
105
- s.add_dependency(%q<rspec>, ["~> 2.3.0"])
103
+ s.add_dependency(%q<rspec>, ["~> 2.13"])
104
+ s.add_dependency(%q<rspec-mocks>, ["~> 2.13"])
106
105
  s.add_dependency(%q<cucumber>, [">= 0"])
107
- s.add_dependency(%q<bundler>, ["~> 1.0.0"])
108
- s.add_dependency(%q<jeweler>, ["~> 1.6.4"])
109
- s.add_dependency(%q<rcov>, [">= 0"])
110
- s.add_dependency(%q<daemon>, ["~> 1"])
111
- s.add_dependency(%q<httpclient>, ["~> 2.2"])
106
+ s.add_dependency(%q<capybara>, ["~> 1.1"])
107
+ s.add_dependency(%q<jeweler>, ["~> 1.8.4"])
108
+ s.add_dependency(%q<httpclient>, ["~> 2.3"])
112
109
  s.add_dependency(%q<rdoc>, ["~> 3.9"])
110
+ s.add_dependency(%q<multipart-parser>, ["~> 0.1.1"])
111
+ s.add_dependency(%q<daemon>, ["~> 1.1"])
113
112
  end
114
113
  end
115
114
 
@@ -0,0 +1,38 @@
1
+ class ErrorReporter < Controler
2
+ self.define do
3
+ on error Rack::UnhandledRequest::UnhandledRequestError do |error|
4
+ write_error 404, error
5
+ end
6
+
7
+ on error Plugin::Thumbnailer::UnsupportedMediaTypeError do |error|
8
+ write_error 415, error
9
+ end
10
+
11
+ on error(
12
+ Plugin::Thumbnailer::ImageTooLargeError,
13
+ MemoryLimit::MemoryLimitedExceededError
14
+ ) do |error|
15
+ write_error 413, error
16
+ end
17
+
18
+ on error(
19
+ ThumbnailSpec::BadThubnailSpecError,
20
+ Plugin::Thumbnailer::ZeroSizedImageError,
21
+ Plugin::Thumbnailer::UnsupportedMethodError
22
+ ) do |error|
23
+ write_error 400, error
24
+ end
25
+
26
+ on error StandardError do |error|
27
+ log.error "unhandled error while processing request: #{env['REQUEST_METHOD']} #{env['SCRIPT_NAME']}[#{env["PATH_INFO"]}]", error
28
+ log.debug {
29
+ out = StringIO.new
30
+ PP::pp(env, out, 200)
31
+ "Request: \n" + out.string
32
+ }
33
+
34
+ write_error 500, error
35
+ end
36
+ end
37
+ end
38
+
@@ -0,0 +1,396 @@
1
+ require 'RMagick'
2
+ require 'forwardable'
3
+
4
+ module Plugin
5
+ module Thumbnailer
6
+ class UnsupportedMethodError < ArgumentError
7
+ def initialize(method)
8
+ super("thumbnail method '#{method}' is not supported")
9
+ end
10
+ end
11
+
12
+ class UnsupportedMediaTypeError < ArgumentError
13
+ def initialize(error)
14
+ super("unsupported media type: #{error}")
15
+ end
16
+ end
17
+
18
+ class ImageTooLargeError < ArgumentError
19
+ def initialize(error)
20
+ super("image too large: #{error}")
21
+ end
22
+ end
23
+
24
+ class ZeroSizedImageError < ArgumentError
25
+ def initialize(width, height)
26
+ super("at least one image dimension is zero: #{width}x#{height}")
27
+ end
28
+ end
29
+
30
+ module ImageProcessing
31
+ def replace
32
+ @use_count ||= 0
33
+ processed = nil
34
+ begin
35
+ processed = yield self
36
+ processed = self unless processed
37
+ fail 'got destroyed image' if processed.destroyed?
38
+ ensure
39
+ self.destroy! if @use_count <= 0 unless processed.equal? self
40
+ end
41
+ processed
42
+ end
43
+
44
+ def use
45
+ @use_count ||= 0
46
+ @use_count += 1
47
+ begin
48
+ yield self
49
+ self
50
+ ensure
51
+ @use_count -=1
52
+ self.destroy! if @use_count <= 0
53
+ end
54
+ end
55
+ end
56
+
57
+ class InputImage
58
+ include ClassLogging
59
+ extend Forwardable
60
+
61
+ def initialize(image, processing_methods, options = {})
62
+ @image = image
63
+ @processing_methods = processing_methods
64
+ end
65
+
66
+ def thumbnail(spec)
67
+ spec = spec.dup
68
+ # default backgraud is white
69
+ spec.options['background-color'] = spec.options.fetch('background-color', 'white').sub(/^0x/, '#')
70
+
71
+ width = spec.width == :input ? @image.columns : spec.width
72
+ height = spec.height == :input ? @image.rows : spec.height
73
+
74
+ raise ZeroSizedImageError.new(width, height) if width == 0 or height == 0
75
+
76
+ begin
77
+ process_image(spec.method, width, height, spec.options).replace do |image|
78
+ if image.alpha?
79
+ log.info 'thumbnail has alpha, rendering on background'
80
+ image.render_on_background(spec.options['background-color'])
81
+ end
82
+ end.use do |image|
83
+ Service.stats.incr_total_thumbnails_created
84
+ image_format = spec.format == :input ? @image.format : spec.format
85
+
86
+ yield Thumbnail.new(image, image_format, spec.options)
87
+ end
88
+ rescue Magick::ImageMagickError => error
89
+ raise ImageTooLargeError, error.message if error.message =~ /cache resources exhausted/
90
+ raise
91
+ end
92
+ end
93
+
94
+ def process_image(method, width, height, options)
95
+ @image.replace do |image|
96
+ impl = @processing_methods[method] or raise UnsupportedMethodError, method
97
+ impl.call(image, width, height, options)
98
+ end
99
+ end
100
+
101
+ # behave as @image in processing
102
+ def use
103
+ @image.use do |image|
104
+ yield self
105
+ end
106
+ end
107
+
108
+ def_delegators :@image, :destroy!, :destroyed?, :mime_type
109
+
110
+ # needs to be seen as @image when returned in replace block
111
+ def equal?(image)
112
+ super image or @image.equal? image
113
+ end
114
+ end
115
+
116
+ class Thumbnail
117
+ include ClassLogging
118
+
119
+ def initialize(image, format, options = {})
120
+ @image = image
121
+ @format = format
122
+ @quality = (options['quality'] or default_quality(format))
123
+ @quality &&= @quality.to_i
124
+ end
125
+
126
+ def data
127
+ format = @format
128
+ quality = @quality
129
+ @image.to_blob do
130
+ self.format = format
131
+ self.quality = quality if quality
132
+ end
133
+ end
134
+
135
+ def mime_type
136
+ #@image.mime_type cannot be used since it is raw crated image
137
+ #TODO: how do I do it better?
138
+ mime = case @format
139
+ when 'JPG' then 'jpeg'
140
+ else @format.downcase
141
+ end
142
+ "image/#{mime}"
143
+ end
144
+
145
+ private
146
+
147
+ def default_quality(format)
148
+ case format
149
+ when /png/i
150
+ 95 # max zlib compression, adaptive filtering (photo)
151
+ when /jpeg|jpg/i
152
+ 85
153
+ else
154
+ nil
155
+ end
156
+ end
157
+ end
158
+
159
+ class Service
160
+ include ClassLogging
161
+
162
+ extend Stats
163
+ def_stats(
164
+ :total_images_loaded,
165
+ :total_images_prescaled,
166
+ :total_thumbnails_created,
167
+ :images_loaded,
168
+ :max_images_loaded,
169
+ :max_images_loaded_worker,
170
+ :total_images_created,
171
+ :total_images_destroyed,
172
+ :total_images_created_from_blob,
173
+ :total_images_created_initialize,
174
+ :total_images_created_resize,
175
+ :total_images_created_crop,
176
+ :total_images_created_sample
177
+ )
178
+
179
+ def self.input_formats
180
+ Magick.formats.select do |name, mode|
181
+ mode.include? 'r'
182
+ end.keys.map(&:downcase)
183
+ end
184
+
185
+ def self.output_formats
186
+ Magick.formats.select do |name, mode|
187
+ mode.include? 'w'
188
+ end.keys.map(&:downcase)
189
+ end
190
+
191
+ def self.rmagick_version
192
+ Magick::Version
193
+ end
194
+
195
+ def self.magick_version
196
+ Magick::Magick_version
197
+ end
198
+
199
+ def initialize(options = {})
200
+ @processing_methods = {}
201
+ @options = options
202
+ @images_loaded = 0
203
+
204
+ log.info "initializing thumbnailer: #{self.class.rmagick_version} #{self.class.magick_version}"
205
+
206
+ set_limit(:area, options[:limit_area]) if options.member?(:limit_area)
207
+ set_limit(:memory, options[:limit_memory]) if options.member?(:limit_memory)
208
+ set_limit(:map, options[:limit_map]) if options.member?(:limit_map)
209
+ set_limit(:disk, options[:limit_disk]) if options.member?(:limit_disk)
210
+
211
+ Magick.trace_proc = lambda do |which, description, id, method|
212
+ case which
213
+ when :c
214
+ Service.stats.incr_images_loaded
215
+ @images_loaded += 1
216
+ Service.stats.max_images_loaded = Service.stats.images_loaded if Service.stats.images_loaded > Service.stats.max_images_loaded
217
+ Service.stats.max_images_loaded_worker = @images_loaded if @images_loaded > Service.stats.max_images_loaded_worker
218
+ Service.stats.incr_total_images_created
219
+ case method
220
+ when :from_blob
221
+ Service.stats.incr_total_images_created_from_blob
222
+ when :initialize
223
+ Service.stats.incr_total_images_created_initialize
224
+ when :resize
225
+ Service.stats.incr_total_images_created_resize
226
+ when :resize!
227
+ Service.stats.incr_total_images_created_resize
228
+ when :crop
229
+ Service.stats.incr_total_images_created_crop
230
+ when :crop!
231
+ Service.stats.incr_total_images_created_crop
232
+ when :sample
233
+ Service.stats.incr_total_images_created_sample
234
+ else
235
+ log.warn "uncounted image creation method: #{method}"
236
+ end
237
+ when :d
238
+ Service.stats.decr_images_loaded
239
+ @images_loaded -= 1
240
+ Service.stats.incr_total_images_destroyed
241
+ end
242
+ log.debug{"image event: #{which}, #{description}, #{id}, #{method}: loaded images: #{Service.stats.images_loaded}"}
243
+ end
244
+ end
245
+
246
+ def load(io, options = {})
247
+ mw = options['max-width']
248
+ mh = options['max-height']
249
+ if mw and mh
250
+ mw = mw.to_i
251
+ mh = mh.to_i
252
+ log.info "using max size hint of: #{mw}x#{mh}"
253
+ end
254
+
255
+ begin
256
+ blob = io.read
257
+
258
+ old_memory_limit = nil
259
+ borrowed_memory_limit = nil
260
+ if options.member?(:limit_memory)
261
+ borrowed_memory_limit = options[:limit_memory].borrow(options[:limit_memory].limit)
262
+ old_memory_limit = set_limit(:memory, borrowed_memory_limit)
263
+ end
264
+
265
+ images = Magick::Image.from_blob(blob) do |info|
266
+ if mw and mh
267
+ define('jpeg', 'size', "#{mw*2}x#{mh*2}")
268
+ define('jbig', 'size', "#{mw*2}x#{mh*2}")
269
+ end
270
+ end
271
+
272
+ image = images.first
273
+ if image.columns > image.base_columns or image.rows > image.base_rows
274
+ log.warn "input image got upscaled from: #{image.base_columns}x#{image.base_rows} to #{image.columns}x#{image.rows}: reloading without max size hint!"
275
+ images.each do |other|
276
+ other.destroy!
277
+ end
278
+ images = Magick::Image.from_blob(blob)
279
+ end
280
+ blob = nil
281
+
282
+ images.shift.replace do |image|
283
+ images.each do |other|
284
+ other.destroy!
285
+ end
286
+ log.info "loaded image: #{image.inspect}"
287
+ Service.stats.incr_total_images_loaded
288
+ image.strip!
289
+ end.replace do |image|
290
+ if mw and mh
291
+ f = image.find_prescale_factor(mw, mh)
292
+ if f > 1
293
+ image = image.prescale(f)
294
+ log.info "prescaled image by factor of #{f}: #{image.inspect}"
295
+ Service.stats.incr_total_images_prescaled
296
+ end
297
+ end
298
+ InputImage.new(image, @processing_methods)
299
+ end
300
+ rescue Magick::ImageMagickError => error
301
+ raise ImageTooLargeError, error if error.message =~ /cache resources exhausted/
302
+ raise UnsupportedMediaTypeError, error
303
+ ensure
304
+ if old_memory_limit
305
+ set_limit(:memory, old_memory_limit)
306
+ options[:limit_memory].return(borrowed_memory_limit)
307
+ end
308
+ end
309
+ end
310
+
311
+ def processing_method(method, &impl)
312
+ @processing_methods[method] = impl
313
+ end
314
+
315
+ def set_limit(limit, value)
316
+ old = Magick.limit_resource(limit, value)
317
+ log.info "changed #{limit} limit from #{old} to #{value} bytes"
318
+ old
319
+ end
320
+ end
321
+
322
+ def self.setup(app)
323
+ Service.logger = app.logger_for(Service)
324
+ InputImage.logger = app.logger_for(InputImage)
325
+ Thumbnail.logger = app.logger_for(Thumbnail)
326
+
327
+ @@service = Service.new(
328
+ limit_memory: app.settings[:limit_memory],
329
+ limit_map: app.settings[:limit_map],
330
+ limit_disk: app.settings[:limit_disk]
331
+ )
332
+
333
+ @@service.processing_method('crop') do |image, width, height, options|
334
+ image.resize_to_fill(width, height) if image.columns != width or image.rows != height
335
+ end
336
+
337
+ @@service.processing_method('fit') do |image, width, height, options|
338
+ image.resize_to_fit(width, height) if image.columns != width or image.rows != height
339
+ end
340
+
341
+ @@service.processing_method('pad') do |image, width, height, options|
342
+ image.resize_to_fit(width, height).replace do |resize|
343
+ resize.render_on_background(options['background-color'], width, height)
344
+ end if image.columns != width or image.rows != height
345
+ end
346
+
347
+ @@service.processing_method('limit') do |image, width, height, options|
348
+ image.resize_to_fit(width, height) if image.columns > width or image.rows > height
349
+ end
350
+ end
351
+
352
+ def thumbnailer
353
+ @@service
354
+ end
355
+ end
356
+ end
357
+
358
+ class Magick::Image
359
+ include Plugin::Thumbnailer::ImageProcessing
360
+
361
+ def render_on_background(background_color, width = nil, height = nil)
362
+ Magick::Image.new(width || self.columns, height || self.rows) {
363
+ self.background_color = background_color
364
+ self.depth = 8
365
+ }.replace do |background|
366
+ background.composite!(self, Magick::CenterGravity, Magick::OverCompositeOp)
367
+ end
368
+ end
369
+
370
+ # non coping version
371
+ def resize_to_fill(ncols, nrows = nil, gravity = Magick::CenterGravity)
372
+ nrows ||= ncols
373
+ if ncols != columns or nrows != rows
374
+ scale = [ncols / columns.to_f, nrows / rows.to_f].max
375
+ resize(scale * columns + 0.5, scale * rows + 0.5).replace do |image|
376
+ image.crop(gravity, ncols, nrows, true) if ncols != columns or nrows != rows
377
+ end
378
+ else
379
+ crop(gravity, ncols, nrows, true) if ncols != columns or nrows != rows
380
+ end
381
+ end
382
+
383
+ def prescale(f)
384
+ sample(columns / f, rows / f)
385
+ end
386
+
387
+ def find_prescale_factor(max_width, max_height, factor = 1)
388
+ new_factor = factor * 2
389
+ if columns / new_factor > max_width * 2 and rows / new_factor > max_height * 2
390
+ find_prescale_factor(max_width, max_height, factor * 2)
391
+ else
392
+ factor
393
+ end
394
+ end
395
+ end
396
+
@@ -1,6 +1,28 @@
1
- require 'httpthumbnailer/thumbnailer'
2
-
3
1
  class ThumbnailSpecs < Array
2
+ def self.from_uri(specs)
3
+ ts = ThumbnailSpecs.new
4
+ specs.split('/').each do |spec|
5
+ ts << ThumbnailSpec.from_uri(spec)
6
+ end
7
+ ts
8
+ end
9
+
10
+ def max_width
11
+ map do |spec|
12
+ return nil unless spec.width.is_a? Integer
13
+ spec.width
14
+ end.max
15
+ end
16
+
17
+ def max_height
18
+ map do |spec|
19
+ return nil unless spec.height.is_a? Integer
20
+ spec.height
21
+ end.max
22
+ end
23
+ end
24
+
25
+ class ThumbnailSpec
4
26
  class BadThubnailSpecError < ArgumentError
5
27
  class MissingArgumentError < BadThubnailSpecError
6
28
  def initialize(spec)
@@ -14,67 +36,48 @@ class ThumbnailSpecs < Array
14
36
  end
15
37
  end
16
38
 
17
- class BadDimmensionValueError < BadThubnailSpecError
39
+ class BadDimensionValueError < BadThubnailSpecError
18
40
  def initialize(value)
19
- super "bad dimmension value: #{value}"
41
+ super "bad dimension value: #{value}"
20
42
  end
21
43
  end
22
44
  end
23
45
 
24
- class ThumbnailSpec
25
- def initialize(method, width, height, format, options = {})
26
- @method = method
27
- @width = cast_dimmension(width)
28
- @height = cast_dimmension(height)
29
- @format = (format == 'INPUT' ? :input : format.upcase)
30
- @options = options
31
- end
46
+ def initialize(method, width, height, format, options = {})
47
+ @method = method
48
+ @width = cast_dimension(width)
49
+ @height = cast_dimension(height)
50
+ @format = (format == 'input' ? :input : format.upcase)
51
+ @options = options
52
+ end
32
53
 
33
- attr_reader :method, :width, :height, :format, :options
54
+ def self.from_uri(spec)
55
+ method, width, height, format, *options = *spec.split(',')
56
+ raise BadThubnailSpecError::MissingArgumentError.new(spec) unless method and width and height and format
34
57
 
35
- def to_s
36
- "#{method} #{width}x#{height} (#{format}) #{options.inspect}"
58
+ opts = {}
59
+ options.each do |option|
60
+ key, value = option.split(':')
61
+ raise BadThubnailSpecError::MissingOptionKeyOrValueError.new(option) unless key and value
62
+ opts[key] = value
37
63
  end
38
64
 
39
- private
40
-
41
- def cast_dimmension(string)
42
- return :input if string == 'INPUT'
43
- raise BadThubnailSpecError::BadDimmensionValueError.new(string) unless string =~ /^\d+$/
44
- string.to_i
45
- end
65
+ ThumbnailSpec.new(method, width, height, format, opts)
46
66
  end
47
67
 
48
- def self.from_uri(specs)
49
- ts = ThumbnailSpecs.new
50
- specs.split('/').each do |spec|
51
- method, width, height, format, *options = *spec.split(',')
52
- raise BadThubnailSpecError::MissingArgumentError.new(spec) unless method and width and height and format
53
68
 
54
- opts = {}
55
- options.each do |option|
56
- key, value = option.split(':')
57
- raise BadThubnailSpecError::MissingOptionKeyOrValueError.new(option) unless key and value
58
- opts[key] = value
59
- end
69
+ attr_reader :method, :width, :height, :format, :options
60
70
 
61
- ts << ThumbnailSpec.new(method, width, height, format, opts)
62
- end
63
- ts
71
+ def to_s
72
+ "#{method} #{width}x#{height} (#{format.downcase}) #{options.inspect}"
64
73
  end
65
74
 
66
- def max_width
67
- map do |spec|
68
- return nil unless spec.width.is_a? Integer
69
- spec.width
70
- end.max
71
- end
75
+ private
72
76
 
73
- def max_height
74
- map do |spec|
75
- return nil unless spec.height.is_a? Integer
76
- spec.height
77
- end.max
77
+ def cast_dimension(string)
78
+ return :input if string == 'input'
79
+ raise BadThubnailSpecError::BadDimensionValueError.new(string) unless string =~ /^\d+$/
80
+ string.to_i
78
81
  end
79
82
  end
80
83