httpthumbnailer 1.2.0 → 1.3.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.
Files changed (57) hide show
  1. checksums.yaml +15 -0
  2. data/Gemfile +4 -3
  3. data/Gemfile.lock +12 -12
  4. data/README.md +242 -68
  5. data/Rakefile +8 -2
  6. data/VERSION +1 -1
  7. data/bin/httpthumbnailer +35 -7
  8. data/lib/httpthumbnailer/error_reporter.rb +4 -2
  9. data/lib/httpthumbnailer/ownership.rb +54 -0
  10. data/lib/httpthumbnailer/plugin.rb +87 -0
  11. data/lib/httpthumbnailer/plugin/thumbnailer.rb +22 -427
  12. data/lib/httpthumbnailer/plugin/thumbnailer/service.rb +163 -0
  13. data/lib/httpthumbnailer/plugin/thumbnailer/service/built_in_plugins.rb +134 -0
  14. data/lib/httpthumbnailer/plugin/thumbnailer/service/images.rb +295 -0
  15. data/lib/httpthumbnailer/plugin/thumbnailer/service/magick.rb +208 -0
  16. data/lib/httpthumbnailer/thumbnail_specs.rb +130 -37
  17. data/lib/httpthumbnailer/thumbnailer.rb +29 -11
  18. metadata +30 -81
  19. data/.rspec +0 -1
  20. data/features/httpthumbnailer.feature +0 -24
  21. data/features/identify.feature +0 -31
  22. data/features/step_definitions/httpthumbnailer_steps.rb +0 -159
  23. data/features/support/env.rb +0 -106
  24. data/features/support/test-large.jpg +0 -0
  25. data/features/support/test-transparent.png +0 -0
  26. data/features/support/test.jpg +0 -0
  27. data/features/support/test.png +0 -0
  28. data/features/support/test.txt +0 -1
  29. data/features/thumbnail.feature +0 -269
  30. data/features/thumbnails.feature +0 -158
  31. data/httpthumbnailer.gemspec +0 -121
  32. data/load_test/extralarge.jpg +0 -0
  33. data/load_test/large.jpg +0 -0
  34. data/load_test/large.png +0 -0
  35. data/load_test/load_test-374846090-1.1.0-rc1-identify-only.csv +0 -3
  36. data/load_test/load_test-374846090-1.1.0-rc1.csv +0 -11
  37. data/load_test/load_test-cd9679c.csv +0 -10
  38. data/load_test/load_test-v0.3.1.csv +0 -10
  39. data/load_test/load_test.jmx +0 -733
  40. data/load_test/medium.jpg +0 -0
  41. data/load_test/small.jpg +0 -0
  42. data/load_test/soak_test-ac0c6bcbe5e-broken-libjpeg-tatoos.csv +0 -11
  43. data/load_test/soak_test-cd9679c.csv +0 -10
  44. data/load_test/soak_test-f98334a-tatoos.csv +0 -11
  45. data/load_test/soak_test.jmx +0 -754
  46. data/load_test/tiny.jpg +0 -0
  47. data/load_test/v0.0.13-loading.csv +0 -7
  48. data/load_test/v0.0.13.csv +0 -7
  49. data/load_test/v0.0.14-no-optimization.csv +0 -10
  50. data/load_test/v0.0.14.csv +0 -10
  51. data/spec/image_processing_spec.rb +0 -148
  52. data/spec/plugin_thumbnailer_spec.rb +0 -318
  53. data/spec/spec_helper.rb +0 -14
  54. data/spec/support/square_even.png +0 -0
  55. data/spec/support/square_odd.png +0 -0
  56. data/spec/support/test_image.rb +0 -16
  57. data/spec/thumbnail_specs_spec.rb +0 -43
@@ -0,0 +1,208 @@
1
+ require 'RMagick'
2
+ require 'httpthumbnailer/ownership'
3
+
4
+ ### WARNING: 'raise' is overwritten with an image operation method; use Kernel::raise instead!
5
+ class Magick::Image
6
+ include Ownership
7
+ include ClassLogging
8
+ include PerfStats
9
+
10
+ # use this on image before doing in-place (like composite!) edit so borrowed images are not changed
11
+ def get_for_inplace
12
+ get do |image|
13
+ if image.borrowed?
14
+ yield image.copy
15
+ else
16
+ yield image
17
+ end
18
+ end
19
+ end
20
+
21
+ def self.new_8bit(width, height, background_color = "none")
22
+ Magick::Image.new(width, height) {
23
+ self.depth = 8
24
+ begin
25
+ self.background_color = background_color
26
+ rescue ArgumentError
27
+ Kernel::raise Plugin::Thumbnailer::InvalidColorNameError.new(background_color)
28
+ end
29
+ }
30
+ end
31
+
32
+ def render_on_background(background_color, width = nil, height = nil, float_x = 0.5, float_y = 0.5)
33
+ # default to image size
34
+ width ||= self.columns
35
+ height ||= self.rows
36
+
37
+ # make sure we have enough background to fit image on top of it
38
+ width = self.columns if width < self.columns
39
+ height = self.rows if height < self.rows
40
+
41
+ self.class.new_8bit(width, height, background_color).get do |background|
42
+ background.composite!(self, *background.float_to_offset(self.columns, self.rows, float_x, float_y), Magick::OverCompositeOp)
43
+ end
44
+ end
45
+
46
+ # non coping version
47
+ def resize_to_fill(width, height = nil, float_x = 0.5, float_y = 0.5)
48
+ # default to square
49
+ height ||= width
50
+
51
+ return if width == columns and height == rows
52
+
53
+ scale = [width / columns.to_f, height / rows.to_f].max
54
+
55
+ get do |image| # this will comsume (destory) self just after resize
56
+ image.resize((scale * columns).ceil, (scale * rows).ceil)
57
+ end.get do |image|
58
+ next if width == image.width and height == image.height
59
+ image.crop(*image.float_to_offset(width, height, float_x, float_y), width, height, true)
60
+ end
61
+ end
62
+
63
+ # make rotate not to change image.page to avoid WTF moments
64
+ alias :rotate_orig :rotate
65
+ def rotate(*args)
66
+ out = rotate_orig(*args)
67
+ out.page = Magick::Rectangle.new(out.columns, out.rows, 0, 0)
68
+ out
69
+ end
70
+
71
+ def downsample(f)
72
+ sample(columns / f, rows / f)
73
+ end
74
+
75
+ def find_downsample_factor(max_width, max_height, factor = 1)
76
+ new_factor = factor * 2
77
+ if columns / new_factor > max_width * 2 and rows / new_factor > max_height * 2
78
+ find_downsample_factor(max_width, max_height, factor * 2)
79
+ else
80
+ factor
81
+ end
82
+ end
83
+
84
+ def float_to_offset(float_width, float_height, float_x = 0.5, float_y = 0.5)
85
+ base_width = self.columns
86
+ base_height = self.rows
87
+
88
+ x = ((base_width - float_width) * float_x).ceil
89
+ y = ((base_height - float_height) * float_y).ceil
90
+
91
+ x = 0 if x < 0
92
+ x = (base_width - float_width) if x > (base_width - float_width)
93
+
94
+ y = 0 if y < 0
95
+ y = (base_height - float_height) if y > (base_height - float_height)
96
+
97
+ [x, y]
98
+ end
99
+
100
+ def pixelate_region(x, y, w, h, size)
101
+ factor = 1.0 / size
102
+
103
+ # what happens here
104
+ # 1. get an required box cut form the image
105
+ # 2. resize it down by fracto
106
+ # 3. resize it back up by factor
107
+ # 4. since we may end up with bigger image (due to size of the pixelate pixel) crop it to required size again
108
+ # 5. composite over the original image in required position
109
+
110
+ crop(x, y, w, h, true).get do |work_space|
111
+ work_space.sample((factor * w).ceil, (factor * h).ceil)
112
+ end.get do |image|
113
+ image.sample(size)
114
+ end.get do |image|
115
+ image.crop(0, 0 , w, h, true)
116
+ end.get do |image|
117
+ get_for_inplace do |orig|
118
+ orig.composite!(image, x, y, Magick::OverCompositeOp)
119
+ end
120
+ end
121
+ end
122
+
123
+ def blur_region(x, y, w, h, radius, sigma)
124
+ # NOTE: we need to have bigger region to blure then the final regios to prevent edge artifacts
125
+ # TODO: how do I calculate margin better? See: https://github.com/trevor/ImageMagick/blob/82d683349c7a6adc977f6f638f1b340e01bf0ea9/branches/ImageMagick-6.5.9/magick/gem.c#L787
126
+ margin = [3, radius, sigma].max.ceil
127
+
128
+ mx = x - margin
129
+ my = y - margin
130
+ mw = w + margin
131
+ mh = h + margin
132
+
133
+ # limit the box with margin to available image size
134
+ mx = 0 if mx < 0
135
+ my = 0 if my < 0
136
+ mw = width - mx if mw + mx > width
137
+ mh = height - my if mh + my > height
138
+
139
+ crop(mx, my, mw, mh, true).get do |work_space|
140
+ work_space.blur_image(radius, sigma)
141
+ end.get do |blur|
142
+ blur.crop(x - mx, y - my, w, h, true)
143
+ end.get do |blur|
144
+ get_for_inplace do |orig|
145
+ orig.composite!(blur, x, y, Magick::OverCompositeOp)
146
+ end
147
+ end
148
+ end
149
+
150
+ def render_rectangle(x, y, w, h, color)
151
+ get_for_inplace do |orig|
152
+ gc = Magick::Draw.new
153
+ gc.fill = color
154
+ gc.rectangle(x, y, x + w - 1, y + h - 1)
155
+ gc.draw(orig)
156
+ orig
157
+ end
158
+ end
159
+
160
+ # helpers
161
+
162
+ def with_background_color(color)
163
+ if color
164
+ was = self.background_color
165
+ begin
166
+ begin
167
+ self.background_color = color
168
+ rescue ArgumentError
169
+ Kernel::raise Plugin::Thumbnailer::InvalidColorNameError.new(color)
170
+ end
171
+ yield
172
+ ensure
173
+ self.background_color = was
174
+ end
175
+ else
176
+ yield
177
+ end
178
+ end
179
+
180
+ def rel_to_px_pos(x, y)
181
+ [(x * columns).floor, (y * rows).floor]
182
+ end
183
+
184
+ def rel_to_px_dim(width, height)
185
+ [(width * columns).ceil, (height * rows).ceil]
186
+ end
187
+
188
+ def rel_to_px_box(x, y, width, height)
189
+ [*rel_to_px_pos(x, y), *rel_to_px_dim(width, height)]
190
+ end
191
+
192
+ def rel_to_diagonal(v)
193
+ (v * diagonal).ceil
194
+ end
195
+
196
+ def width
197
+ columns
198
+ end
199
+
200
+ def height
201
+ rows
202
+ end
203
+
204
+ def diagonal
205
+ @_diag ||= Math.sqrt(width ** 2 + height ** 2).ceil
206
+ end
207
+ end
208
+
@@ -1,8 +1,8 @@
1
1
  class ThumbnailSpecs < Array
2
- def self.from_uri(specs)
2
+ def self.from_string(specs)
3
3
  ts = ThumbnailSpecs.new
4
4
  specs.split('/').each do |spec|
5
- ts << ThumbnailSpec.from_uri(spec)
5
+ ts << ThumbnailSpec.from_string(spec)
6
6
  end
7
7
  ts
8
8
  end
@@ -23,61 +23,154 @@ class ThumbnailSpecs < Array
23
23
  end
24
24
 
25
25
  class ThumbnailSpec
26
- class BadThubnailSpecError < ArgumentError
27
- class MissingArgumentError < BadThubnailSpecError
28
- def initialize(spec)
29
- super "missing argument in: #{spec}"
30
- end
26
+ class InvalidFormatError < ArgumentError
27
+ def for_edit(name)
28
+ exception "#{message} for edit '#{name}'"
31
29
  end
32
30
 
33
- class MissingOptionKeyOrValueError < BadThubnailSpecError
34
- def initialize(option)
35
- super "missing option key or value in: #{option}"
36
- end
31
+ def in_spec(spec)
32
+ exception "#{message} in spec '#{spec}'"
37
33
  end
34
+ end
38
35
 
39
- class BadDimensionValueError < BadThubnailSpecError
40
- def initialize(value)
41
- super "bad dimension value: #{value}"
42
- end
36
+ class MissingArgumentError < InvalidFormatError
37
+ def initialize(argument)
38
+ super "missing #{argument} argument"
43
39
  end
44
40
  end
45
41
 
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
42
+ class InvalidArgumentValueError < InvalidFormatError
43
+ def initialize(name, value, reason)
44
+ super "#{name} value '#{value}' is not #{reason}"
45
+ end
46
+ end
47
+
48
+ class MissingOptionKeyValuePairError < InvalidFormatError
49
+ def initialize(index)
50
+ super "missing key-value pair on position #{index + 1}"
51
+ end
52
+ end
53
+
54
+ class MissingOptionKeyNameError < InvalidFormatError
55
+ def initialize(value)
56
+ super "missing option key name for value '#{value}'"
57
+ end
58
+ end
59
+
60
+ class MissingOptionKeyValueError < InvalidFormatError
61
+ def initialize(key)
62
+ super "missing option value for key '#{key}'"
63
+ end
52
64
  end
53
65
 
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
66
+ class EditSpec
67
+ attr_reader :name, :args, :options
68
+
69
+ def self.from_string(string)
70
+ args = ThumbnailSpec.split_args(string)
71
+ args, options = ThumbnailSpec.partition_args_options(args)
72
+ name = args.shift
73
+
74
+ begin
75
+ options = ThumbnailSpec.parse_options(options)
76
+ rescue InvalidFormatError => error
77
+ raise error.for_edit(name)
78
+ end
79
+ new(name, args, options)
80
+ end
81
+
82
+ def initialize(name, args, options = {})
83
+ name.nil? or name.empty? and raise MissingArgumentError, 'edit name'
84
+
85
+ @name = name
86
+ @args = args
87
+ @options = options
88
+ end
57
89
 
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
90
+ def to_s
91
+ begin
92
+ [@name, *@args, *ThumbnailSpec.options_to_s(@options)].join(',')
93
+ rescue InvalidFormatError => error
94
+ raise error.for_edit(name)
95
+ end
63
96
  end
97
+ end
98
+
99
+ attr_reader :method, :width, :height, :format, :options, :edits
100
+
101
+ def self.from_string(string)
102
+ edits = split_edits(string)
103
+ spec = edits.shift
104
+ args = split_args(spec)
105
+ method, width, height, format, *options = *args
106
+
107
+ options = parse_options(options)
108
+ edits = edits.map{|e| EditSpec.from_string(e)}
64
109
 
65
- ThumbnailSpec.new(method, width, height, format, opts)
110
+ new(method, width, height, format, options, edits)
111
+ rescue InvalidFormatError => error
112
+ raise error.in_spec(string)
66
113
  end
67
114
 
115
+ def initialize(method, width, height, format, options = {}, edits = [])
116
+ method.nil? or method.empty? and raise MissingArgumentError, 'method'
117
+ width.nil? or width.empty? and raise MissingArgumentError, 'width'
118
+ height.nil? or height.empty? and raise MissingArgumentError, 'height'
119
+ format.nil? or format.empty? and raise MissingArgumentError, 'format'
68
120
 
69
- attr_reader :method, :width, :height, :format, :options
121
+ width !~ /^([0-9]+|input)$/ and raise InvalidArgumentValueError.new('width', width, "an integer or 'input'")
122
+ height !~ /^([0-9]+|input)$/ and raise InvalidArgumentValueError.new('height', height, "an integer or 'input'")
123
+
124
+ width = width == 'input' ? :input : width.to_i
125
+ height = height == 'input' ? :input : height.to_i
126
+
127
+ format = format == 'input' ? :input : format.upcase
128
+
129
+ @method = method
130
+ @width = width
131
+ @height = height
132
+ @format = format
133
+ @options = options
134
+ @edits = edits
135
+ end
70
136
 
71
137
  def to_s
72
- "#{method} #{width}x#{height} (#{format.downcase}) #{options.inspect}"
138
+ [[@method, @width, @height, @format, *self.class.options_to_s(@options)].join(','), *@edits.map(&:to_s)].join('!')
139
+ end
140
+
141
+ def self.split_edits(string)
142
+ string.split('!')
73
143
  end
74
144
 
75
- private
145
+ def self.split_args(string)
146
+ string.split(',')
147
+ end
148
+
149
+ def self.partition_args_options(args)
150
+ options = args.drop_while{|a| not a.include?(':')}
151
+ args = args.take_while{|a| not a.include?(':')}
152
+ [args, options]
153
+ end
76
154
 
77
- def cast_dimension(string)
78
- return :input if string == 'input'
79
- raise BadThubnailSpecError::BadDimensionValueError.new(string) unless string =~ /^\d+$/
80
- string.to_i
155
+ def self.parse_options(options)
156
+ Hash[options.map.with_index do |pair, index|
157
+ pair.empty? and raise MissingOptionKeyValuePairError, index
158
+ pair.split(':', 2)
159
+ end].tap do |map|
160
+ map.each do |key, value|
161
+ key.nil? or key.empty? and raise MissingOptionKeyNameError, value
162
+ value.nil? or value.empty? and raise MissingOptionKeyValueError, key
163
+ end
164
+ end
165
+ end
166
+
167
+ def self.options_to_s(options)
168
+ options.sort_by{|k,v| k}.map do |key, value|
169
+ raise MissingOptionKeyNameError, value if key.nil? or key.to_s.empty?
170
+ raise MissingOptionKeyValueError, key if value.nil? or value.to_s.empty?
171
+ key = key.to_s.gsub('_', '-') if key.kind_of? Symbol
172
+ "#{key}:#{value}"
173
+ end
81
174
  end
82
175
  end
83
176
 
@@ -2,6 +2,7 @@ require 'httpthumbnailer/plugin/thumbnailer'
2
2
  require 'httpthumbnailer/thumbnail_specs'
3
3
 
4
4
  class Thumbnailer < Controller
5
+ Plugin::Thumbnailer.logger = logger_for(Plugin::Thumbnailer)
5
6
  self.plugin Plugin::Thumbnailer
6
7
 
7
8
  self.define do
@@ -9,7 +10,7 @@ class Thumbnailer < Controller
9
10
  opts[:limit_memory] = memory_limit
10
11
 
11
12
  on put, 'thumbnail', /(.*)/ do |spec|
12
- spec = ThumbnailSpec.from_uri(spec)
13
+ spec = ThumbnailSpec.from_string(spec)
13
14
  log.info "thumbnailing image to single spec: #{spec}"
14
15
 
15
16
  if settings[:optimization]
@@ -17,23 +18,32 @@ class Thumbnailer < Controller
17
18
  opts[:max_height] = spec.height if spec.height.is_a? Integer
18
19
  end
19
20
 
20
- thumbnailer.load(req.body, opts).use do |input_image|
21
+ opts[:reload] = settings[:reload]
22
+ opts[:no_upscale_fix] = settings[:no_upscale_fix]
23
+ opts[:no_downsample] = settings[:no_downsample]
24
+
25
+ thumbnailer.load(req.body, opts) do |input_image|
21
26
  log.info "original image loaded: #{input_image.mime_type}"
22
27
 
28
+ # take the values here since the input_image will be destroyed after thumbnail!
29
+ input_image_mime_type = input_image.mime_type
30
+ input_image_width = input_image.width
31
+ input_image_height = input_image.height
32
+
23
33
  log.info "generating thumbnail: #{spec}"
24
- input_image.thumbnail(spec) do |image|
34
+ input_image.thumbnail!(spec) do |image|
25
35
  write 200, image.mime_type, image.data,
26
36
  "X-Image-Width" => image.width,
27
37
  "X-Image-Height" => image.height,
28
- "X-Input-Image-Mime-Type" => input_image.mime_type,
29
- "X-Input-Image-Width" => input_image.width,
30
- "X-Input-Image-Height" => input_image.height
38
+ "X-Input-Image-Mime-Type" => input_image_mime_type,
39
+ "X-Input-Image-Width" => input_image_width,
40
+ "X-Input-Image-Height" => input_image_height
31
41
  end
32
42
  end
33
43
  end
34
44
 
35
45
  on put, 'thumbnails', /(.*)/ do |specs|
36
- thumbnail_specs = ThumbnailSpecs.from_uri(specs)
46
+ thumbnail_specs = ThumbnailSpecs.from_string(specs)
37
47
  log.info "thumbnailing image to multiple specs: #{thumbnail_specs.join(', ')}"
38
48
 
39
49
  if settings[:optimization]
@@ -41,7 +51,11 @@ class Thumbnailer < Controller
41
51
  opts[:max_height] = thumbnail_specs.max_height
42
52
  end
43
53
 
44
- thumbnailer.load(req.body, opts).use do |input_image|
54
+ opts[:reload] = settings[:reload]
55
+ opts[:no_upscale_fix] = settings[:no_upscale_fix]
56
+ opts[:no_downsample] = settings[:no_downsample]
57
+
58
+ thumbnailer.load(req.body, opts) do |input_image|
45
59
  log.info "original image loaded: #{input_image.mime_type}"
46
60
  write_preamble 200,
47
61
  "X-Input-Image-Mime-Type" => input_image.mime_type,
@@ -64,6 +78,10 @@ class Thumbnailer < Controller
64
78
  write_error_part 400, error
65
79
  when Plugin::Thumbnailer::ZeroSizedImageError
66
80
  write_error_part 400, error
81
+ when Plugin::Thumbnailer::ThumbnailArgumentError
82
+ write_error_part 400, error
83
+ when Plugin::Thumbnailer::EditArgumentError
84
+ write_error_part 400, error
67
85
  else
68
86
  log.error "unhandled error while generating multipart response for thumbnail spec: #{spec}", error
69
87
  write_error_part 500, error
@@ -83,11 +101,11 @@ class Thumbnailer < Controller
83
101
  end
84
102
 
85
103
  # disable preprocessing since we don't need them here
86
- opts[:no_reload] = true
87
- opts[:no_downscale] = true
104
+ opts[:no_upscale_fix] = true
105
+ opts[:no_downsample] = true
88
106
 
89
107
  # RMagick of v2.13.2 does not use ImageMagick's PingBlob so we have to actually load the image
90
- thumbnailer.load(req.body, opts).use do |input_image|
108
+ thumbnailer.load(req.body, opts) do |input_image|
91
109
  mime_type = input_image.mime_type
92
110
  log.info "image loaded and identified as: #{mime_type}"
93
111
  write_json 200, {