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