image_resizer 0.1.4

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 (43) hide show
  1. data/.rspec +1 -0
  2. data/.rvmrc +1 -0
  3. data/Gemfile +9 -0
  4. data/Gemfile.lock +45 -0
  5. data/LICENSE +22 -0
  6. data/README.md +56 -0
  7. data/Rakefile +53 -0
  8. data/VERSION +1 -0
  9. data/image_resizer.gemspec +93 -0
  10. data/lib/image_resizer/analyzer.rb +51 -0
  11. data/lib/image_resizer/configurable.rb +206 -0
  12. data/lib/image_resizer/encoder.rb +55 -0
  13. data/lib/image_resizer/has_filename.rb +24 -0
  14. data/lib/image_resizer/loggable.rb +28 -0
  15. data/lib/image_resizer/processor.rb +220 -0
  16. data/lib/image_resizer/shell.rb +48 -0
  17. data/lib/image_resizer/temp_object.rb +216 -0
  18. data/lib/image_resizer/utils.rb +44 -0
  19. data/lib/image_resizer.rb +10 -0
  20. data/samples/DSC02119.JPG +0 -0
  21. data/samples/a.jp2 +0 -0
  22. data/samples/beach.jpg +0 -0
  23. data/samples/beach.png +0 -0
  24. data/samples/egg.png +0 -0
  25. data/samples/landscape.png +0 -0
  26. data/samples/round.gif +0 -0
  27. data/samples/sample.docx +0 -0
  28. data/samples/taj.jpg +0 -0
  29. data/samples/white pixel.png +0 -0
  30. data/spec/image_resizer/analyzer_spec.rb +78 -0
  31. data/spec/image_resizer/configurable_spec.rb +479 -0
  32. data/spec/image_resizer/encoder_spec.rb +41 -0
  33. data/spec/image_resizer/has_filename_spec.rb +88 -0
  34. data/spec/image_resizer/loggable_spec.rb +80 -0
  35. data/spec/image_resizer/processor_spec.rb +590 -0
  36. data/spec/image_resizer/shell_spec.rb +34 -0
  37. data/spec/image_resizer/temp_object_spec.rb +442 -0
  38. data/spec/spec_helper.rb +61 -0
  39. data/spec/support/argument_matchers.rb +19 -0
  40. data/spec/support/image_matchers.rb +58 -0
  41. data/spec/support/simple_matchers.rb +53 -0
  42. data/tmp/test_file +1 -0
  43. metadata +147 -0
@@ -0,0 +1,220 @@
1
+ module ImageResizer
2
+ class Processor
3
+
4
+ GRAVITIES = {
5
+ 'nw' => 'NorthWest',
6
+ 'n' => 'North',
7
+ 'ne' => 'NorthEast',
8
+ 'w' => 'West',
9
+ 'c' => 'Center',
10
+ 'e' => 'East',
11
+ 'sw' => 'SouthWest',
12
+ 's' => 'South',
13
+ 'se' => 'SouthEast'
14
+ }
15
+
16
+ # Geometry string patterns
17
+ RESIZE_GEOMETRY = /^\d*x\d*[><%^!]?$|^\d+@$/ # e.g. '300x200!'
18
+ CROPPED_RESIZE_GEOMETRY = /^(\d+)x(\d+)#(\w{1,2})?$/ # e.g. '20x50#ne'
19
+ CROP_GEOMETRY = /^(\d+)x(\d+)([+-]\d+)?([+-]\d+)?(\w{1,2})?$/ # e.g. '30x30+10+10'
20
+ THUMB_GEOMETRY = Regexp.union RESIZE_GEOMETRY, CROPPED_RESIZE_GEOMETRY, CROP_GEOMETRY
21
+
22
+ include Configurable
23
+ include Utils
24
+
25
+
26
+ def resize(temp_object, options={})
27
+ width = options[:width].to_i
28
+ height = options[:height].to_i
29
+
30
+ if height == 0 && width == 0
31
+ temp_object.file
32
+ elsif height == 0
33
+ _resize(temp_object, "#{width}x")
34
+ elsif width == 0
35
+ _resize(temp_object, "x#{height}")
36
+ else
37
+ if options[:crop_from_top_if_portrait]
38
+ analyzer = ImageResizer::Analyzer.new
39
+ center_of_gravity = analyzer.aspect_ratio(temp_object) >= 1 ? 'c' : 'n'
40
+ else
41
+ center_of_gravity = 'c'
42
+ end
43
+
44
+ resize_and_crop(temp_object, :width => width.to_i, :height => height.to_i, :gravity => center_of_gravity)
45
+ end
46
+ end
47
+
48
+ def _resize(temp_object, geometry)
49
+ convert(temp_object, "-resize #{geometry}")
50
+ end
51
+
52
+ def auto_orient(temp_object)
53
+ convert(temp_object, "-auto-orient")
54
+ end
55
+
56
+ def crop(temp_object, opts={})
57
+ width = opts[:width]
58
+ height = opts[:height]
59
+ gravity = GRAVITIES[opts[:gravity]]
60
+ x = "#{opts[:x] || 0}"
61
+ x = '+' + x unless x[/^[+-]/]
62
+ y = "#{opts[:y] || 0}"
63
+ y = '+' + y unless y[/^[+-]/]
64
+ repage = opts[:repage] == false ? '' : '+repage'
65
+
66
+ resize = opts[:resize] ? "-resize #{opts[:resize]} " : ''
67
+ gravity = gravity ? "-gravity #{gravity} " : ''
68
+
69
+ convert(temp_object, "#{resize}#{gravity}-crop #{width}x#{height}#{x}#{y} #{repage}")
70
+ end
71
+
72
+ def resize_and_crop_around_point(temp_object, options)
73
+ analyzer = ImageResizer::Analyzer.new
74
+
75
+ desired_width = options[:width].to_i
76
+ desired_height = options[:height].to_i
77
+ desired_ratio = desired_height > 0 ? desired_width.to_f / desired_height : 0
78
+
79
+ original_width = analyzer.width(temp_object)
80
+ original_height = analyzer.height(temp_object)
81
+ original_ratio = original_width.to_f / original_height
82
+
83
+ if desired_ratio > original_ratio
84
+ width = original_width
85
+ height = width / desired_ratio
86
+ else
87
+ height = original_height
88
+ width = height * desired_ratio
89
+ end
90
+
91
+ focus_x = options[:point][0] * original_width
92
+ focus_y = options[:point][1] * original_height
93
+
94
+ half_width = width * 0.5
95
+ half_height = height * 0.5
96
+
97
+ upper_left_x = [focus_x - half_width, 0].max
98
+ upper_left_y = [focus_y - half_height, 0].max
99
+
100
+ lower_right_x = upper_left_x + width
101
+ lower_right_y = upper_left_y + height
102
+
103
+ x_offset = [lower_right_x - original_width, 0].max
104
+ y_offset = [lower_right_y - original_height, 0].max
105
+
106
+ upper_left_x -= x_offset
107
+ upper_left_y -= y_offset
108
+
109
+ lower_right_x -= x_offset
110
+ lower_right_y -= y_offset
111
+
112
+ upper_left_x_percent = upper_left_x / original_width
113
+ upper_left_y_percent = upper_left_y / original_height
114
+
115
+ lower_right_x_percent = lower_right_x / original_width
116
+ lower_right_y_percent = lower_right_y / original_height
117
+
118
+ crop_to_frame_and_resize(temp_object,
119
+ :upper_left => [upper_left_x_percent, upper_left_y_percent],
120
+ :lower_right => [lower_right_x_percent, lower_right_y_percent],
121
+ :width => desired_width,
122
+ :height => desired_height
123
+ )
124
+ end
125
+
126
+
127
+ def crop_to_frame_and_resize(temp_object, options)
128
+ analyzer = ImageResizer::Analyzer.new
129
+
130
+ desired_width = options[:width].to_i
131
+ desired_height = options[:height].to_i
132
+
133
+ upper_left_x_percent = options[:upper_left].first
134
+ upper_left_y_percent = options[:upper_left].last
135
+
136
+ lower_right_x_percent = options[:lower_right].first
137
+ lower_right_y_percent = options[:lower_right].last
138
+
139
+ original_width = analyzer.width(temp_object)
140
+ original_height = analyzer.height(temp_object)
141
+
142
+ upper_left_x = (original_width * upper_left_x_percent).round
143
+ upper_left_y = (original_height * upper_left_y_percent).round
144
+ frame_width = (original_width * (lower_right_x_percent - upper_left_x_percent)).round
145
+ frame_height = (original_height * (lower_right_y_percent - upper_left_y_percent)).round
146
+
147
+ if desired_width == 0 && frame_height > 0
148
+ ratio = frame_width.to_f / frame_height
149
+ desired_width = (desired_height * ratio).round
150
+ end
151
+
152
+ if desired_height == 0 && frame_width > 0
153
+ ratio = frame_height.to_f / frame_width
154
+ desired_height = (desired_width * ratio).round
155
+ end
156
+
157
+
158
+ convert(temp_object, "-crop #{frame_width}x#{frame_height}+#{upper_left_x}+#{upper_left_y} -resize #{desired_width}x#{desired_height} +repage")
159
+ end
160
+
161
+ def flip(temp_object)
162
+ convert(temp_object, "-flip")
163
+ end
164
+
165
+ def flop(temp_object)
166
+ convert(temp_object, "-flop")
167
+ end
168
+
169
+ def greyscale(temp_object)
170
+ convert(temp_object, "-colorspace Gray")
171
+ end
172
+ alias grayscale greyscale
173
+
174
+ def resize_and_crop(temp_object, opts={})
175
+ if !opts[:width] && !opts[:height]
176
+ return temp_object
177
+ elsif !opts[:width] || !opts[:height]
178
+ attrs = identify(temp_object)
179
+ opts[:width] ||= attrs[:width]
180
+ opts[:height] ||= attrs[:height]
181
+ end
182
+
183
+ opts[:gravity] ||= 'c'
184
+
185
+ opts[:resize] = "#{opts[:width]}x#{opts[:height]}^^"
186
+ crop(temp_object, opts)
187
+ end
188
+
189
+ def rotate(temp_object, amount, opts={})
190
+ convert(temp_object, "-rotate #{amount}#{opts[:qualifier]}")
191
+ end
192
+
193
+ def strip(temp_object)
194
+ convert(temp_object, "-strip")
195
+ end
196
+
197
+ def thumb(temp_object, geometry)
198
+ case geometry
199
+ when RESIZE_GEOMETRY
200
+ resize(temp_object, geometry)
201
+ when CROPPED_RESIZE_GEOMETRY
202
+ resize_and_crop(temp_object, :width => $1, :height => $2, :gravity => $3)
203
+ when CROP_GEOMETRY
204
+ crop(temp_object,
205
+ :width => $1,
206
+ :height => $2,
207
+ :x => $3,
208
+ :y => $4,
209
+ :gravity => $5
210
+ )
211
+ else raise ArgumentError, "Didn't recognise the geometry string #{geometry}"
212
+ end
213
+ end
214
+
215
+ def convert(temp_object, args='', format=nil)
216
+ format ? [super, {:format => format.to_sym}] : super
217
+ end
218
+
219
+ end
220
+ end
@@ -0,0 +1,48 @@
1
+ require 'shellwords'
2
+
3
+ module ImageResizer
4
+ module Shell
5
+
6
+ include Configurable
7
+ configurable_attr :log_commands, false
8
+
9
+ # Exceptions
10
+ class CommandFailed < RuntimeError; end
11
+
12
+ def run(command, args="")
13
+ full_command = "#{command} #{escape_args(args)}"
14
+ log.debug("Running command: #{full_command}") if log_commands
15
+ begin
16
+ result = `#{full_command}`
17
+ rescue Errno::ENOENT
18
+ raise_shell_command_failed(full_command)
19
+ end
20
+ if $?.exitstatus == 1
21
+ throw :unable_to_handle
22
+ elsif !$?.success?
23
+ raise_shell_command_failed(full_command)
24
+ end
25
+ result
26
+ end
27
+
28
+ def raise_shell_command_failed(command)
29
+ raise CommandFailed, "Command failed (#{command}) with exit status #{$?.exitstatus}"
30
+ end
31
+
32
+ def escape_args(args)
33
+ args.shellsplit.map do |arg|
34
+ quote arg.gsub(/\\?'/, %q('\\\\''))
35
+ end.join(' ')
36
+ end
37
+
38
+ def quote(string)
39
+ q = running_on_windows? ? '"' : "'"
40
+ q + string + q
41
+ end
42
+
43
+ def running_on_windows?
44
+ ENV['OS'] && ENV['OS'].downcase == 'windows_nt'
45
+ end
46
+
47
+ end
48
+ end
@@ -0,0 +1,216 @@
1
+ require 'stringio'
2
+ require 'tempfile'
3
+ require 'pathname'
4
+
5
+ module ImageResizer
6
+
7
+ # A TempObject is used for HOLDING DATA.
8
+ # It's the thing that is passed between the datastore, the processor and the encoder, and is useful
9
+ # for separating how the data was created and how it is later accessed.
10
+ #
11
+ # You can initialize it various ways:
12
+ #
13
+ # temp_object = ImageResizer::TempObject.new('this is the content') # with a String
14
+ # temp_object = ImageResizer::TempObject.new(Pathname.new('path/to/content')) # with a Pathname
15
+ # temp_object = ImageResizer::TempObject.new(File.new('path/to/content')) # with a File
16
+ # temp_object = ImageResizer::TempObject.new(some_tempfile) # with a Tempfile
17
+ # temp_object = ImageResizer::TempObject.new(some_other_temp_object) # with another TempObject
18
+ #
19
+ # However, no matter how it was initialized, you can always access the data a number of ways:
20
+ #
21
+ # temp_object.data # returns a data string
22
+ # temp_object.file # returns a file object holding the data
23
+ # temp_object.path # returns a path for the file
24
+ #
25
+ # The data/file are created lazily, something which you may wish to take advantage of.
26
+ #
27
+ # For example, if a TempObject is initialized with a file, and temp_object.data is never called, then
28
+ # the data string will never be loaded into memory.
29
+ #
30
+ # Conversely, if the TempObject is initialized with a data string, and neither temp_object.file nor temp_object.path
31
+ # are ever called, then the filesystem will never be hit.
32
+ #
33
+ class TempObject
34
+
35
+ include HasFilename
36
+
37
+ # Exceptions
38
+ class Closed < RuntimeError; end
39
+
40
+ # Instance Methods
41
+
42
+ def initialize(obj, meta={})
43
+ if obj.is_a? TempObject
44
+ @data = obj.get_data
45
+ @tempfile = obj.get_tempfile
46
+ @pathname = obj.get_pathname
47
+ elsif obj.is_a? String
48
+ @data = obj
49
+ elsif obj.is_a? Tempfile
50
+ @tempfile = obj
51
+ elsif obj.is_a? File
52
+ @pathname = Pathname.new(obj.path)
53
+ elsif obj.is_a? Pathname
54
+ @pathname = obj
55
+ elsif obj.respond_to?(:tempfile)
56
+ @tempfile = obj.tempfile
57
+ elsif obj.respond_to?(:path) # e.g. Rack::Test::UploadedFile
58
+ @pathname = Pathname.new(obj.path)
59
+ else
60
+ raise ArgumentError, "#{self.class.name} must be initialized with a String, a Pathname, a File, a Tempfile, another TempObject, something that responds to .tempfile, or something that responds to .path"
61
+ end
62
+
63
+ @tempfile.close if @tempfile
64
+
65
+ # Original filename
66
+ @original_filename = if obj.respond_to?(:original_filename)
67
+ obj.original_filename
68
+ elsif @pathname
69
+ @pathname.basename.to_s
70
+ end
71
+
72
+ # Meta
73
+ @meta = meta
74
+ @meta[:name] ||= @original_filename if @original_filename
75
+ end
76
+
77
+ attr_reader :original_filename
78
+ attr_accessor :meta
79
+
80
+ def name
81
+ meta[:name]
82
+ end
83
+
84
+ def name=(name)
85
+ meta[:name] = name
86
+ end
87
+
88
+ def data
89
+ raise Closed, "can't read data as TempObject has been closed" if closed?
90
+ @data ||= file{|f| f.read }
91
+ end
92
+
93
+ def tempfile
94
+ raise Closed, "can't read from tempfile as TempObject has been closed" if closed?
95
+ @tempfile ||= begin
96
+ case
97
+ when @data
98
+ @tempfile = new_tempfile(@data)
99
+ when @pathname
100
+ @tempfile = copy_to_tempfile(@pathname.expand_path)
101
+ end
102
+ @tempfile
103
+ end
104
+ end
105
+
106
+ def file(&block)
107
+ f = tempfile.open
108
+ tempfile.binmode
109
+ if block_given?
110
+ ret = yield f
111
+ tempfile.close unless tempfile.closed?
112
+ else
113
+ ret = f
114
+ end
115
+ ret
116
+ end
117
+
118
+ def path
119
+ @pathname ? @pathname.expand_path.to_s : tempfile.path
120
+ end
121
+
122
+ def size
123
+ @data ? @data.bytesize : File.size(path)
124
+ end
125
+
126
+ def each(&block)
127
+ to_io do |io|
128
+ while part = io.read(block_size)
129
+ yield part
130
+ end
131
+ end
132
+ end
133
+
134
+ def to_file(path, opts={})
135
+ mode = opts[:mode] || 0644
136
+ prepare_path(path) unless opts[:mkdirs] == false
137
+ if @data
138
+ File.open(path, 'wb', mode){|f| f.write(@data) }
139
+ else
140
+ FileUtils.cp(self.path, path)
141
+ File.chmod(mode, path)
142
+ end
143
+ File.new(path, 'rb')
144
+ end
145
+
146
+ def to_io(&block)
147
+ @data ? StringIO.open(@data, 'rb', &block) : file(&block)
148
+ end
149
+
150
+ def close
151
+ @tempfile.close! if @tempfile
152
+ @data = nil
153
+ @closed = true
154
+ end
155
+
156
+ def closed?
157
+ !!@closed
158
+ end
159
+
160
+ def inspect
161
+ content_string = case
162
+ when @data
163
+ data_string = size > 20 ? "#{@data[0..20]}..." : @data
164
+ "data=#{data_string.inspect}"
165
+ when @pathname then "pathname=#{@pathname.inspect}"
166
+ when @tempfile then "tempfile=#{@tempfile.inspect}"
167
+ end
168
+ "<#{self.class.name} #{content_string} >"
169
+ end
170
+
171
+ def unique_id
172
+ @unique_id ||= "#{object_id}#{rand(1000000)}"
173
+ end
174
+
175
+ protected
176
+
177
+ # We don't use normal accessors here because #data etc. do more than just return the instance var
178
+ def get_data
179
+ @data
180
+ end
181
+
182
+ def get_pathname
183
+ @pathname
184
+ end
185
+
186
+ def get_tempfile
187
+ @tempfile
188
+ end
189
+
190
+ private
191
+
192
+ def block_size
193
+ 8192
194
+ end
195
+
196
+ def copy_to_tempfile(path)
197
+ tempfile = new_tempfile
198
+ FileUtils.cp path, tempfile.path
199
+ tempfile
200
+ end
201
+
202
+ def new_tempfile(content=nil)
203
+ tempfile = ext ? Tempfile.new(['ImageResizer', ".#{ext}"]) : Tempfile.new('ImageResizer')
204
+ tempfile.binmode
205
+ tempfile.write(content) if content
206
+ tempfile.close
207
+ tempfile
208
+ end
209
+
210
+ def prepare_path(path)
211
+ dir = File.dirname(path)
212
+ FileUtils.mkdir_p(dir) unless File.exist?(dir)
213
+ end
214
+
215
+ end
216
+ end
@@ -0,0 +1,44 @@
1
+ require 'tempfile'
2
+
3
+ module ImageResizer
4
+ module Utils
5
+
6
+ include Shell
7
+ include Loggable
8
+ include Configurable
9
+ configurable_attr :convert_command, "convert"
10
+ configurable_attr :identify_command, "identify"
11
+
12
+ private
13
+
14
+ def convert(temp_object=nil, args='', format=nil)
15
+ tempfile = new_tempfile(format)
16
+ run convert_command, %(#{quote(temp_object.path) if temp_object} #{args} #{quote(tempfile.path)})
17
+ tempfile
18
+ end
19
+
20
+ def identify(temp_object)
21
+ # example of details string:
22
+ # myimage.png PNG 200x100 200x100+0+0 8-bit DirectClass 31.2kb
23
+ format, width, height, depth = raw_identify(temp_object).scan(/([A-Z0-9]+) (\d+)x(\d+) .+ (\d+)-bit/)[0]
24
+ {
25
+ :format => format.downcase.to_sym,
26
+ :width => width.to_i,
27
+ :height => height.to_i,
28
+ :depth => depth.to_i
29
+ }
30
+ end
31
+
32
+ def raw_identify(temp_object, args='')
33
+ run identify_command, "#{args} #{quote(temp_object.path)}"
34
+ end
35
+
36
+ def new_tempfile(ext=nil)
37
+ tempfile = ext ? Tempfile.new(['ImageResizer', ".#{ext}"]) : Tempfile.new('ImageResizer')
38
+ tempfile.binmode
39
+ tempfile.close
40
+ tempfile
41
+ end
42
+
43
+ end
44
+ end
@@ -0,0 +1,10 @@
1
+
2
+ require 'image_resizer/has_filename'
3
+ require 'image_resizer/loggable'
4
+ require 'image_resizer/configurable'
5
+ require 'image_resizer/shell'
6
+ require 'image_resizer/utils'
7
+ require 'image_resizer/analyzer'
8
+ require 'image_resizer/encoder'
9
+ require 'image_resizer/processor'
10
+ require 'image_resizer/temp_object'
Binary file
data/samples/a.jp2 ADDED
Binary file
data/samples/beach.jpg ADDED
Binary file
data/samples/beach.png ADDED
Binary file
data/samples/egg.png ADDED
Binary file
Binary file
data/samples/round.gif ADDED
Binary file
Binary file
data/samples/taj.jpg ADDED
Binary file
Binary file
@@ -0,0 +1,78 @@
1
+ require 'spec_helper'
2
+
3
+ describe ImageResizer::Analyzer do
4
+
5
+ before(:each) do
6
+ @image = ImageResizer::TempObject.new(SAMPLES_DIR.join('beach.png'))
7
+ @analyzer = ImageResizer::Analyzer.new
8
+ end
9
+
10
+ it "should return the width" do
11
+ @analyzer.width(@image).should == 280
12
+ end
13
+
14
+ it "should return the height" do
15
+ @analyzer.height(@image).should == 355
16
+ end
17
+
18
+ it "should return the aspect ratio" do
19
+ @analyzer.aspect_ratio(@image).should == (280.0/355.0)
20
+ end
21
+
22
+ it "should say if it's portrait" do
23
+ @analyzer.portrait?(@image).should be_true
24
+ end
25
+
26
+ it "should say if it's landscape" do
27
+ @analyzer.landscape?(@image).should be_false
28
+ end
29
+
30
+ it "should return the number of colours" do
31
+ @analyzer.number_of_colours(@image).should == 34703
32
+ end
33
+
34
+ it "should return the depth" do
35
+ @analyzer.depth(@image).should == 8
36
+ end
37
+
38
+ it "should return the format" do
39
+ @analyzer.format(@image).should == :png
40
+ end
41
+
42
+ %w(width height aspect_ratio number_of_colours depth format portrait? landscape?).each do |meth|
43
+ it "should throw unable_to_handle in #{meth.inspect} if it's not an image file" do
44
+ suppressing_stderr do
45
+ temp_object = ImageResizer::TempObject.new('blah')
46
+ lambda{
47
+ @analyzer.send(meth, temp_object)
48
+ }.should throw_symbol(:unable_to_handle)
49
+ end
50
+ end
51
+ end
52
+
53
+ it "should say if it's an image" do
54
+ @analyzer.image?(@image).should == true
55
+ end
56
+
57
+ it "should say if it's not an image" do
58
+ suppressing_stderr do
59
+ @analyzer.image?(ImageResizer::TempObject.new('blah')).should == false
60
+ end
61
+ end
62
+
63
+ it "should work for images with spaces in the filename" do
64
+ image = ImageResizer::TempObject.new(SAMPLES_DIR.join('white pixel.png'))
65
+ @analyzer.width(image).should == 1
66
+ end
67
+
68
+ it "should work (width) for images with capital letter extensions" do
69
+ image = ImageResizer::TempObject.new(SAMPLES_DIR.join('DSC02119.JPG'))
70
+ @analyzer.width(image).should == 1
71
+ end
72
+
73
+ it "should work (width) for images with numbers in the format" do
74
+ image = ImageResizer::TempObject.new(SAMPLES_DIR.join('a.jp2'))
75
+ @analyzer.width(image).should == 1
76
+ end
77
+
78
+ end