norman / has_image

A lightweight and opinionated but hackable library for attaching images to ActiveRecord models.

This URL has Read+Write access

has_image / lib / has_image.rb
100644 260 lines (223 sloc) 10.268 kb
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
require 'has_image/processor'
require 'has_image/storage'
require 'has_image/view_helpers'
 
module HasImage
 
  class ProcessorError < StandardError ; end
  class StorageError < StandardError ; end
  class FileTooBigError < StorageError ; end
  class FileTooSmallError < StorageError ; end
  class InvalidGeometryError < ProcessorError ; end
  
  class << self
    
    def included(base) # :nodoc:
      base.extend(ClassMethods)
    end
 
    # Enables has_image functionality. You probably don't need to ever invoke
    # this.
    def enable # :nodoc:
      return if ActiveRecord::Base.respond_to? :has_image
      ActiveRecord::Base.send(:include, HasImage)
      return if ActionView::Base.respond_to? :image_tag_for
      ActionView::Base.send(:include, ViewHelpers)
    end
 
    # If you're invoking this method, you need to pass in the class for which
    # you want to get default options; this is used to determine the path where
    # the images will be stored in the file system. Take a look at
    # HasImage::ClassMethods#has_image to see examples of how to set the options
    # in your model.
    #
    # This method is called by your model when you call has_image. It's
    # placed here rather than in the model's class methods to make it easier
    # to access for testing. Unless you're working on the code, it's unlikely
    # you'll ever need to invoke this method.
    #
    # * :resize_to => "200x200",
    # * :thumbnails => {},
    # * :max_size => 12.megabytes,
    # * :min_size => 4.kilobytes,
    # * :path_prefix => klass.to_s.tableize,
    # * :base_path => File.join(RAILS_ROOT, 'public'),
    # * :column => :has_image_file,
    # * :convert_to => "JPEG",
    # * :output_quality => "85",
    # * :invalid_image_message => "Can't process the image.",
    # * :image_too_small_message => "The image is too small.",
    # * :image_too_big_message => "The image is too big.",
    def default_options_for(klass)
      {
        :resize_to => "200x200",
        :thumbnails => {},
        :auto_generate_thumbnails => true,
        :max_size => 12.megabytes,
        :min_size => 4.kilobytes,
        :path_prefix => klass.table_name,
        :base_path => File.join(RAILS_ROOT, 'public'),
        :column => :has_image_file,
        :convert_to => "JPEG",
        :output_quality => "85",
        :invalid_image_message => "Can't process the image.",
        :image_too_small_message => "The image is too small.",
        :image_too_big_message => "The image is too big."
      }
    end
    
  end
 
  module ClassMethods
    # To use HasImage with a Rails model, all you have to do is add a column
    # named "has_image_file." For configuration defaults, you might want to take
    # a look at the default options specified in HasImage#default_options_for.
    # The different setting options are described below.
    #
    # Options:
    # * <tt>:resize_to</tt> - Dimensions to resize to. This should be an ImageMagick {geometry string}[http://www.imagemagick.org/script/command-line-options.php#resize]. Fixed sizes are recommended.
    # * <tt>:thumbnails</tt> - A hash of thumbnail names and dimensions. The dimensions should be ImageMagick {geometry strings}[http://www.imagemagick.org/script/command-line-options.php#resize]. Fixed sized are recommended.
    # * <tt>:min_size</tt> - Minimum file size allowed. It's recommended that you set this size in kilobytes.
    # * <tt>:max_size</tt> - Maximum file size allowed. It's recommended that you set this size in megabytes.
    # * <tt>:base_path</tt> - Where to install the images. You should probably leave this alone, except for tests.
    # * <tt>:path_prefix</tt> - Where to install the images, relative to basepath. You should probably leave this alone.
    # * <tt>:convert_to</tt> - An ImageMagick format to convert images to. Recommended formats: JPEG, PNG, GIF.
    # * <tt>:output_quality</tt> - Image output quality passed to ImageMagick.
    # * <tt>:invalid_image_message</tt> - The message that will be shown when the image data can't be processed.
    # * <tt>:image_too_small_message</tt> - The message that will be shown when the image file is too small. You should ideally set this to something that tells the user what the minimum is.
    # * <tt>:image_too_big_message</tt> - The message that will be shown when the image file is too big. You should ideally set this to something that tells the user what the maximum is.
    #
    # Examples:
    # has_image # uses all default options
    # has_image :resize_to "800x800", :thumbnails => {:square => "150x150"}
    # has_image :resize_to "100x150", :max_size => 500.kilobytes
    # has_image :invalid_image_message => "No se puede procesar la imagen."
    def has_image(options = {})
      options.assert_valid_keys(HasImage.default_options_for(self).keys)
      options = HasImage.default_options_for(self).merge(options)
      class_inheritable_accessor :has_image_options
      write_inheritable_attribute(:has_image_options, options)
      
      after_create :install_images
      after_save :update_images
      after_destroy :remove_images
      
      validate_on_create :image_data_valid?
      
      include ModelInstanceMethods
      extend ModelClassMethods
    
    end
    
  end
 
  module ModelInstanceMethods
    
    # Does the object have an image?
    def has_image?
      !send(has_image_options[:column]).blank?
    end
    
    # Sets the uploaded image data. Image data can be an instance of Tempfile,
    # or an instance of any class than inherits from IO.
    # aliased as uploaded_data= for compatibility with attachment_fu
    def image_data=(image_data)
      return if image_data.blank?
      storage.image_data = image_data
    end
    alias_method :uploaded_data=, :image_data=
    # nil placeholder in case this field is used in a form.
    # Aliased as uploaded_data for compatibility with attachment_fu
    def image_data
      nil
    end
    alias_method :uploaded_data, :image_data
    
    # Is the image data a file that ImageMagick can process, and is it within
    # the allowed minimum and maximum sizes?
    def image_data_valid?
      return if !storage.temp_file
      if storage.image_too_big?
        errors.add_to_base(self.class.has_image_options[:image_too_big_message])
      elsif storage.image_too_small?
        errors.add_to_base(self.class.has_image_options[:image_too_small_message])
      elsif !HasImage::Processor.valid?(storage.temp_file)
        errors.add_to_base(self.class.has_image_options[:invalid_image_message])
      end
    end
    
    # Gets the "web path" for the image, or optionally, its thumbnail.
    # Aliased as +public_filename+ for compatibility with attachment-Fu
    def public_path(thumbnail = nil)
      storage.public_path_for(self, thumbnail)
    end
    alias_method :public_filename, :public_path
 
    # Gets the absolute filesystem path for the image, or optionally, its
    # thumbnail.
    def absolute_path(thumbnail = nil)
      storage.filesystem_path_for(self, thumbnail)
    end
    
    # Regenerates the thumbails from the main image.
    def regenerate_thumbnails!
      storage.generate_thumbnails(has_image_id, send(has_image_options[:column]))
    end
    alias_method :regenerate_thumbnails, :regenerate_thumbnails! #Backwards compat
    
    def generate_thumbnail!(thumb_name)
      storage.generate_thumbnail(has_image_id, send(has_image_options[:column]), thumb_name)
    end
    
    def width
      self[:width] || storage.measure(absolute_path, :width)
    end
    
    def height
      self[:height] || storage.measure(absolute_path, :height)
    end
    
    def image_size
      [width, height] * 'x'
    end
    
    # Deletes the image from the storage.
    def remove_images
      return if send(has_image_options[:column]).blank?
      self.class.transaction do
        begin
          storage.remove_images(self, send(has_image_options[:column]))
          # The record will be frozen if we're being called after destroy.
          unless frozen?
            # Resorting to SQL here to avoid triggering callbacks. There must be
            # a better way to do this.
            self.connection.execute("UPDATE #{self.class.table_name} SET #{has_image_options[:column]} = NULL WHERE id = #{id}")
            self.send("#{has_image_options[:column]}=", nil)
          end
        rescue Errno::ENOENT
          logger.warn("Could not delete files for #{self.class.to_s} #{to_param}")
        end
      end
    end
    
    # Creates new images and removes the old ones when image_data has been
    # set.
    def update_images
      return if storage.temp_file.blank?
      remove_images
      populate_attributes
    end
 
    # Processes and installs the image and its thumbnails.
    def install_images
      return if !storage.temp_file
      populate_attributes
    end
    
    def populate_attributes
      send("#{has_image_options[:column]}=", storage.install_images(self))
      self[:width] = storage.measure(absolute_path, :width) if self.class.column_names.include?('width')
      self[:height] = storage.measure(absolute_path, :height) if self.class.column_names.include?('height')
      save!
    end
    private :populate_attributes
    
    # Gets an instance of the underlying storage functionality. See
    # HasImage::Storage.
    def storage
      @storage ||= HasImage::Storage.new(has_image_options)
    end
    
    # By default, just returns the model's id. Since this id is used to divide
    # the images up in directories, you can override this to return a related
    # model's id if you want the images to be grouped differently. For example,
    # if a "member" has_many "photos" you can override this to return
    # member.id to group images by member.
    def has_image_id
      id
    end
    
  end
 
  module ModelClassMethods
 
    # Get the hash of thumbnails set by the options specified when invoking
    # HasImage::ClassMethods#has_image.
    def thumbnails
      has_image_options[:thumbnails]
    end
    
    def from_partitioned_path(path)
      find HasImage::Storage.id_from_path(path)
    end
    
  end
 
end
 
if defined?(Rails) and defined?(ActiveRecord) and defined?(ActionController)
  HasImage.enable
end