module Fleximage # Container for Fleximage model method inclusion modules module Model class MasterImageNotFound < RuntimeError #:nodoc: end # Include acts_as_fleximage class method def self.included(base) #:nodoc: base.extend(ClassMethods) end # Provides class methods for Fleximage for use in model classes. The only class method is # acts_as_fleximage which integrates Fleximage functionality into a model class. # # The following class level accessors also get inserted. # # * +image_directory+: (String, no default) Where the master images are stored, directory path relative to your # app root. # * s3_bucket: Name of the bucket on Amazon S3 where your master images are stored. To use this you must # call establish_connection! on the aws/s3 gem form your app's initilization to authenticate with your # S3 account. # * +use_creation_date_based_directories+: (Boolean, default +true+) If true, master images will be stored in # directories based on creation date. For example: "#{image_directory}/2007/11/24/123.png" for an # image with an id of 123 and a creation date of November 24, 2007. Turing this off would cause the path # to be "#{image_directory}/123.png" instead. This helps keep the OS from having directories that are too # full. # * +image_storage_format+: (:png or :jpg, default :png) The format of your master images. Using :png will give # you the best quality, since the master images as stored as lossless version of the original upload. :jpg # will apply lossy compression, but the master image file sizes will be much smaller. If storage space is a # concern, us :jpg. # * +require_image+: (Boolean, default +true+) The model will raise a validation error if no image is uploaded # with the record. Setting to false allows record to be saved with no images. # * +missing_image_message+: (String, default "is required") Validation message to display when no image was uploaded for # a record. # * +invalid_image_message+: (String default "was not a readable image") Validation message when an image is uploaded, but is not an # image format that can be read by RMagick. # * +output_image_jpg_quality+: (Integer, default 85) When rendering JPGs, this represents the amount of # compression. Valid values are 0-100, where 0 is very small and very ugly, and 100 is near lossless but # very large in filesize. # * +default_image_path+: (String, nil default) If no image is present for this record, the image at this path will be # used instead. Useful for a placeholder graphic for new content that may not have an image just yet. # * +default_image+: A hash which defines an empty starting image. This hash look like: :size => '123x456', # :color => :transparent, where :size defines the dimensions of the default image, and :color # defines the fill. :color can be a named color as a string ('red'), :transparent, or a Magick::Pixel object. # * +preprocess_image+: (Block, no default) Call this class method just like you would call +operate+ in a view. # The image transoformation in the provided block will be run on every uploaded image before its saved as the # master image. # # Example: # # class Photo < ActiveRecord::Base # acts_as_fleximage do # image_directory 'public/images/uploaded' # use_creation_date_based_directories true # image_storage_format :png # require_image true # missing_image_message 'is required' # invalid_image_message 'was not a readable image' # default_image_path 'public/images/no_photo_yet.png' # default_image nil # output_image_jpg_quality 85 # # preprocess_image do |image| # image.resize '1024x768' # end # end # # # normal model methods... # end module ClassMethods # Use this method to include Fleximage functionality in your model. It takes an # options hash with a single required key, :+image_directory+. This key should # point to the directory you want your images stored on your server. Or # configure with a nice looking block. def acts_as_fleximage(options = {}) # Include the necesary instance methods include Fleximage::Model::InstanceMethods # Call this class method just like you would call +operate+ in a view. # The image transoformation in the provided block will be run on every uploaded image before its saved as the # master image. def self.preprocess_image(&block) preprocess_image_operation(block) end # Internal method to ask this class if it stores image in the DB. def self.db_store? return false if s3_store? if respond_to?(:columns) columns.find do |col| col.name == 'image_file_data' end else false end end def self.s3_store? !!s3_bucket end def self.file_store? !db_store? && !s3_store? end def self.has_store? respond_to?(:columns) && (db_store? || image_directory) end # validation callback validate :validate_image if respond_to?(:validate) # The filename of the temp image. Used for storing of good images when validation fails # and the form needs to be redisplayed. attr_reader :image_file_temp # Setter for jpg compression quality at the instance level attr_accessor :jpg_compression_quality # Where images get stored dsl_accessor :image_directory # Amazon S3 bucket where the master images are stored dsl_accessor :s3_bucket # Put uploads from different days into different subdirectories dsl_accessor :use_creation_date_based_directories, :default => true # The format are master images are stored in dsl_accessor :image_storage_format, :default => Proc.new { :png } # Require a valid image. Defaults to true. Set to false if its ok to have no image for dsl_accessor :require_image, :default => true def self.translate_error_message(name, fallback, options = {}) translation = I18n.translate "activerecord.errors.models.#{self.model_name.underscore}.#{name}", options if translation.match /translation missing:/ I18n.translate "activerecord.errors.messages.#{name}", options.merge({ :default => fallback }) end end # Missing image message #dsl_accessor :missing_image_message, :default => 'is required' def self.missing_image_message(str = nil) if str.nil? if @missing_image_message @missing_image_message else translate_error_message("missing_image", "is required") end else @missing_image_message = str end end # Invalid image message #dsl_accessor :invalid_image_message, :default => 'was not a readable image' def self.invalid_image_message(str = nil) if str.nil? if @invalid_image_message @invalid_image_message else translate_error_message("invalid_image", "was not a readable image") end else @invalid_image_message = str end end # Image too small message # Should include {{minimum}} def self.image_too_small_message(str = nil) fb = "is too small (Minimum: {{minimum}})" if str.nil? minimum_size = Fleximage::Operator::Base.size_to_xy(validates_image_size).join('x') if @image_too_small_message @image_too_small_message.gsub("{{minimum}}", minimum_size) else translate_error_message("image_too_small", fb.gsub("{{minimum}}", minimum_size), :minimum => minimum_size) end else @image_too_small_message = str end end # Sets the quality of rendered JPGs dsl_accessor :output_image_jpg_quality, :default => 85 # Set a default image to use when no image has been assigned to this record dsl_accessor :default_image_path # Set a default image based on a a size and fill dsl_accessor :default_image # A block that processes an image before it gets saved as the master image of a record. # Can be helpful to resize potentially huge images to something more manageable. Set via # the "preprocess_image { |image| ... }" class method. dsl_accessor :preprocess_image_operation # Set a minimum size ([x, y] e.g. 200, '800x600', [800, 600]) # Set '0x600' to just enforce y size or # '800x0' to just validate x size. dsl_accessor :validates_image_size # Image related save and destroy callbacks if respond_to?(:before_save) after_destroy :delete_image_file before_save :pre_save after_save :post_save end # execute configuration block yield if block_given? # Create S3 bucket if it's not present if s3_bucket begin AWS::S3::Bucket.find(s3_bucket) rescue AWS::S3::NoSuchBucket AWS::S3::Bucket.create(s3_bucket) end end # set the image directory from passed options image_directory options[:image_directory] if options[:image_directory] # Require the declaration of a master image storage directory if respond_to?(:validate) && !image_directory && !db_store? && !s3_store? && !default_image && !default_image_path raise "No place to put images! Declare this via the :image_directory => 'path/to/directory' option\n"+ "Or add a database column named image_file_data for DB storage\n"+ "Or set :virtual to true if this class has no image store at all\n"+ "Or set a default image to show with :default_image or :default_image_path" end end def image_file_exists(file) # File must be a valid object return false if file.nil? # Get the size of the file. file.size works for form-uploaded images, file.stat.size works # for file object created by File.open('foo.jpg', 'rb'). It must have a size > 0. return false if (file.respond_to?(:size) ? file.size : file.stat.size) <= 0 # object must respond to the read method to fetch its contents. return false if !file.respond_to?(:read) # file validation passed, return true true end end # Provides methods that every model instance that acts_as_fleximage needs. module InstanceMethods # Returns the path to the master image file for this record. # # @some_image.directory_path #=> /var/www/myapp/uploaded_images # # If this model has a created_at field, it will use a directory # structure based on the creation date, to prevent hitting the OS imposed # limit on the number files in a directory. # # @some_image.directory_path #=> /var/www/myapp/uploaded_images/2008/3/30 def directory_path directory = self.class.image_directory raise 'No image directory was defined, cannot generate path' unless directory # base directory directory = "#{RAILS_ROOT}/#{directory}" unless /^\// =~ directory # specific creation date based directory suffix. creation = self[:created_at] || self[:created_on] if self.class.use_creation_date_based_directories && creation "#{directory}/#{creation.year}/#{creation.month}/#{creation.day}" else directory end end # Returns the path to the master image file for this record. # # @some_image.file_path #=> /var/www/myapp/uploaded_images/123.png def file_path "#{directory_path}/#{id}.#{extension}" end # Returns original format of the image if the image_format column exists # otherwise returns the globally set format. def extension if self.respond_to?( :image_format) case image_format when "JPEG" "jpg" else image_format ? image_format.downcase : self.class.image_storage_format end else self.class.image_storage_format end end def url_format extension.to_sym end # Sets the image file for this record to an uploaded file. This can # be called directly, or passively like from an ActiveRecord mass # assignment. # # Rails will automatically call this method for you, in most of the # situations you would expect it to. # # # via mass assignment, the most common form you'll probably use # Photo.new(params[:photo]) # Photo.create(params[:photo]) # # # via explicit assignment hash # Photo.new(:image_file => params[:photo][:image_file]) # Photo.create(:image_file => params[:photo][:image_file]) # # # Direct Assignment, usually not needed # photo = Photo.new # photo.image_file = params[:photo][:image_file] # # # via an association proxy # p = Product.find(1) # p.images.create(params[:photo]) def image_file=(file) if self.class.image_file_exists(file) # Create RMagick Image object from uploaded file if file.path @uploaded_image = Magick::Image.read(file.path).first else @uploaded_image = Magick::Image.from_blob(file.read).first end # Sanitize image data @uploaded_image.colorspace = Magick::RGBColorspace @uploaded_image.density = '72' # Save meta data to database set_magic_attributes(file) # Success, make sure everything is valid @invalid_image = false save_temp_image(file) unless @dont_save_temp end rescue Magick::ImageMagickError => e error_strings = [ 'Improper image header', 'no decode delegate for this image format', 'UnableToOpenBlob', 'Must specify image size' ] if e.to_s =~ /#{error_strings.join('|')}/ @invalid_image = true else raise e end end def image_file has_image? end # Assign the image via a URL, which will make the plugin go # and fetch the image at the provided URL. The image will be stored # locally as a master image for that record from then on. This is # intended to be used along side the image upload to allow people the # choice to upload from their local machine, or pull from the internet. # # @photo.image_file_url = 'http://foo.com/bar.jpg' def image_file_url=(file_url) @image_file_url = file_url if file_url =~ %r{^(https?|ftp)://} file = open(file_url) # Force a URL based file to have an original_filename eval <<-CODE def file.original_filename "#{file_url}" end CODE self.image_file = file elsif file_url.empty? # Nothing to process, move along else # invalid URL, raise invalid image validation error @invalid_image = true end end # Set the image for this record by reading in file data as a string. # # data = File.read('my_image_file.jpg') # photo = Photo.find(123) # photo.image_file_string = data # photo.save def image_file_string=(data) self.image_file = StringIO.new(data) end # Set the image for this record by reading in a file as a base64 encoded string. # # data = Base64.encode64(File.read('my_image_file.jpg')) # photo = Photo.find(123) # photo.image_file_base64 = data # photo.save def image_file_base64=(data) self.image_file_string = Base64.decode64(data) end # Sets the uploaded image to the name of a file in RAILS_ROOT/tmp that was just # uploaded. Use as a hidden field in your forms to keep an uploaded image when # validation fails and the form needs to be redisplayed def image_file_temp=(file_name) if !@uploaded_image && file_name && file_name.present? && file_name !~ %r{\.\./} @image_file_temp = file_name file_path = "#{RAILS_ROOT}/tmp/fleximage/#{file_name}" @dont_save_temp = true if File.exists?(file_path) File.open(file_path, 'rb') do |f| self.image_file = f end end @dont_save_temp = false end end # Return the @image_file_url that was previously assigned. This is not saved # in the database, and only exists to make forms happy. def image_file_url @image_file_url end # Return true if this record has an image. def has_image? @uploaded_image || @output_image || has_saved_image? end def has_saved_image? if self.class.db_store? !!image_file_data elsif self.class.s3_store? AWS::S3::S3Object.exists?("#{id}.#{self.class.image_storage_format}", self.class.s3_bucket) elsif self.class.file_store? File.exists?(file_path) end end # Call from a .flexi view template. This enables the rendering of operators # so that you can transform your image. This is the method that is the foundation # of .flexi views. Every view should consist of image manipulation code inside a # block passed to this method. # # # app/views/photos/thumb.jpg.flexi # @photo.operate do |image| # image.resize '320x240' # end def operate(&block) returning self do proxy = ImageProxy.new(load_image, self) block.call(proxy) @output_image = proxy.image end end # Self destructive operate. This will modify the master image for this record with # the updated and processed result of the operation AND SAVES THE RECORD def operate!(&block) operate(&block) self.image_file_string = output_image save end # Load the image from disk/DB, or return the cached and potentially # processed output image. def load_image #:nodoc: @output_image ||= @uploaded_image # Return the current image if we have loaded it already return @output_image if @output_image # Load the image from disk if self.class.db_store? # Load the image from the database column if image_file_data && image_file_data.present? @output_image = Magick::Image.from_blob(image_file_data).first end elsif self.class.s3_store? # Load image from S3 filename = "#{id}.#{self.class.image_storage_format}" bucket = self.class.s3_bucket if AWS::S3::S3Object.exists?(filename, bucket) @output_image = Magick::Image.from_blob(AWS::S3::S3Object.value(filename, bucket)).first end else # Load the image from the disk @output_image = Magick::Image.read(file_path).first end if @output_image @output_image else master_image_not_found end rescue Magick::ImageMagickError => e if e.to_s =~ /unable to open (file|image)/ master_image_not_found else raise e end end # Convert the current output image to a jpg, and return it in binary form. options support a # :format key that can be :jpg, :gif or :png def output_image(options = {}) #:nodoc: format = (options[:format] || :jpg).to_s.upcase @output_image.format = format @output_image.strip! if format == 'JPG' quality = @jpg_compression_quality || self.class.output_image_jpg_quality @output_image.to_blob { self.quality = quality } else @output_image.to_blob end ensure GC.start end # Delete the image file for this record. This is automatically ran after this record gets # destroyed, but you can call it manually if you want to remove the image from the record. def delete_image_file return unless self.class.has_store? if self.class.db_store? update_attribute :image_file_data, nil unless frozen? elsif self.class.s3_store? AWS::S3::S3Object.delete "#{id}.#{self.class.image_storage_format}", self.class.s3_bucket else File.delete(file_path) if File.exists?(file_path) end clear_magic_attributes self end # Execute image presence and validity validations. def validate_image #:nodoc: field_name = (@image_file_url && @image_file_url.present?) ? :image_file_url : :image_file # Could not read the file as an image if @invalid_image errors.add field_name, self.class.invalid_image_message # no image uploaded and one is required elsif self.class.require_image && !has_image? errors.add field_name, self.class.missing_image_message # Image does not meet minimum size elsif self.class.validates_image_size && !@uploaded_image.nil? x, y = Fleximage::Operator::Base.size_to_xy(self.class.validates_image_size) if @uploaded_image.columns < x || @uploaded_image.rows < y errors.add field_name, self.class.image_too_small_message end end end private # Perform pre save tasks. Preprocess the image, and write it to DB. def pre_save if @uploaded_image # perform preprocessing perform_preprocess_operation # Convert to storage format @uploaded_image.format = self.class.image_storage_format.to_s.upcase unless respond_to?(:image_format) # Write image data to the DB field if self.class.db_store? self.image_file_data = @uploaded_image.to_blob end end end # Write image to file system/S3 and cleanup garbage. def post_save if @uploaded_image if self.class.file_store? # Make sure target directory exists FileUtils.mkdir_p(directory_path) # Write master image file @uploaded_image.write(file_path) elsif self.class.s3_store? blob = StringIO.new(@uploaded_image.to_blob) AWS::S3::S3Object.store("#{id}.#{self.class.image_storage_format}", blob, self.class.s3_bucket) end end # Cleanup temp files delete_temp_image # Start GC to close up memory leaks if @uploaded_image GC.start end end # Preprocess this image before saving def perform_preprocess_operation if self.class.preprocess_image_operation operate(&self.class.preprocess_image_operation) set_magic_attributes #update width and height magic columns @uploaded_image = @output_image end end def clear_magic_attributes unless frozen? self.image_filename = nil if respond_to?(:image_filename=) self.image_width = nil if respond_to?(:image_width=) self.image_height = nil if respond_to?(:image_height=) self.image_format = nil if respond_to?(:image_format=) end end # If any magic column names exists fill them with image meta data. def set_magic_attributes(file = nil) if file && self.respond_to?(:image_filename=) filename = file.original_filename if file.respond_to?(:original_filename) filename = file.basename if file.respond_to?(:basename) self.image_filename = filename end self.image_width = @uploaded_image.columns if self.respond_to?(:image_width=) self.image_height = @uploaded_image.rows if self.respond_to?(:image_height=) self.image_format = @uploaded_image.format if self.respond_to?(:image_format=) end # Save the image in the rails tmp directory def save_temp_image(file) file_name = file.respond_to?(:original_filename) ? file.original_filename : file.path @image_file_temp = Time.now.to_f.to_s.sub('.', '_') path = "#{RAILS_ROOT}/tmp/fleximage" FileUtils.mkdir_p(path) File.open("#{path}/#{@image_file_temp}", 'wb') do |f| file.rewind f.write file.read end end # Delete the temp image after its no longer needed def delete_temp_image FileUtils.rm_rf "#{RAILS_ROOT}/tmp/fleximage/#{@image_file_temp}" end # Load the default image, or raise an expection def master_image_not_found # Load the default image from a path if self.class.default_image_path @output_image = Magick::Image.read("#{RAILS_ROOT}/#{self.class.default_image_path}").first # Or create a default image elsif self.class.default_image x, y = Fleximage::Operator::Base.size_to_xy(self.class.default_image[:size]) color = self.class.default_image[:color] @output_image = Magick::Image.new(x, y) do self.background_color = color if color && color != :transparent end # No default, not master image, so raise exception else message = "Master image was not found for this record" if !self.class.db_store? message << "\nExpected image to be at:" message << "\n #{file_path}" end raise MasterImageNotFound, message end ensure GC.start end end end end