httpthumbnailer 0.3.1 → 1.0.0

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