How to allow users to crop images in Rails 4

For cropping images I use Jcrop, a plugin for jQuery. It works pretty well and there are already some tutorials for integrating it into a Rails app. So this post is almost a simple update to what’s already available in the internet. What follows is just my setup.

1: create an Upload model, i.e. a class for holding uploaded files, not only images. This is fairly straightforward.

class CreateUploads < ActiveRecord::Migration
  def change
    create_table :uploads do |t|
      t.string :name
      t.text :notes
      t.integer :owner_id

      t.timestamps
    end
  end
end

class Upload < ActiveRecord::Base

  public

  belongs_to :owner, class_name: User

  validates :owner, presence: true

  
  
  before_validation :clean_up

  def clean_up
    self.name  = self.name.strip   unless self.name.blank?
    self.notes = self.notes.strip  unless self.notes.blank?
  end

end

2: attach Paperclip to the Upload model.

gem 'paperclip', '~> 3.0'
=begin
$ bundle install
  Installing climate_control (0.0.3)
  Installing cocaine (0.5.1)
  Installing paperclip (3.5.0)

=end

class AddAttachmentCargoToUploads < ActiveRecord::Migration
  def self.up
    change_table :uploads do |t|
      t.attachment :cargo
    end
  end

  def self.down
    drop_attached_file :uploads, :cargo
  end
end

Note that in my setup, contrary to what’s generally advised, I’m trashing the really original image in favor of a (small and) predictable version of it. (width, height and weight)

class Upload < ActiveRecord::Base

  public

  has_attached_file :cargo,
                    styles:      lambda{ |attachment| attachment.instance.paperclip_styles },
                    default_url: lambda{ |attachment| attachment.instance.paperclip_default_url }

  validates_attachment :cargo, presence: true, size: { in:  0..5.megabytes }

  IMAGE_ORIGINAL_WIDTH  = 640  # 16 * 40
  IMAGE_ORIGINAL_HEIGHT = 480  # 16 * 30
  IMAGE_THUMB_SIDE = 144       # 16 * 9
  IMAGE_PINKY_SIDE = 48        # 16 * 3

  def paperclip_styles
    if image?
      {
          original: {
              geometry:        "#{IMAGE_ORIGINAL_WIDTH}x#{IMAGE_ORIGINAL_HEIGHT}>",
              format:          'jpg',
              convert_options: %w(-strip)
          },
          thumb: {
              geometry:        "#{IMAGE_THUMB_SIDE}x#{IMAGE_THUMB_SIDE}#",
              format:          'jpg',
              convert_options: %w(-strip -quality 75),
              processors:      [:manual_thumbnail]
          },
          pinky: {
              geometry:        "#{IMAGE_PINKY_SIDE}x#{IMAGE_PINKY_SIDE}#",
              format:          'jpg',
              convert_options: %w(-strip -quality 75),
              processors:      [:manual_thumbnail]
          },
      }
    else
      {}
    end
  end

  def paperclip_default_url
    if image?
      'picture_:style.png'
    else
      'document.png'
    end
  end

  before_cargo_post_process :process_if_image

  def image?
    cargo_content_type =~ %r{^image/}
  end

  private

  def process_if_image
    false unless image?
  end

end

3: add exif data support

gem 'exifr', '~> 1.1.3'
=begin
$ bundle install
  Installing exifr (1.1.3)

=end

class AddExifDataToUploads < ActiveRecord::Migration
  def change
    add_column :uploads, :exif_data, :text
  end
end

class Upload < ActiveRecord::Base

  public

  serialize :exif_data
  before_cargo_post_process :import_exif_data

  def image_jpeg?
    cargo_content_type =~ %r{/(jpeg|jpg|pjpeg)$}
  end

  def image_tiff?
    cargo_content_type =~ %r{/tiff$}
  end

  private

  def import_exif_data
    return unless image_jpeg? or image_tiff?  # skip unless it's an image with exif data
    return if self.exif_data                  # skip if it was previously imported
    original = cargo.queued_for_write[:original]
    exif_data = (image_jpeg? ? EXIFR::JPEG : EXIFR::TIFF).new(original.path)
    self.exif_data = if exif_data.exif?
                       {
                           width:         exif_data.width,                            # => 2272
                           height:        exif_data.height,                           # => 1704
                           model:         exif_data.model,                            # => "Canon PowerShot G3"
                           date_time:     exif_data.date_time,                        # => Fri Feb 09 16:48:54 +0100 2007
                           exposure_time: exif_data.exposure_time,                    # => 1/15
                           f_number:      exif_data.f_number,                         # => 29/5
                           latitude:      (exif_data.gps.latitude if exif_data.gps),  # => 52.7197888888889
                           longitude:     (exif_data.gps.longitude if exif_data.gps), # => 5.28397777777778
                       }
                     else
                       {} # this is needed to return ASAP on update even when no exif_data was found
                     end
  end

end

4: add cropping support

Jcrop must be installed in Rails.

  1. I added a /vendor/assets/jquery-jcrop/0.9.2 directory with all of its files in it.
  2. I copied js/jquery-Jcrop.js, css/jquery-Jcrop.css and Jcrop.gif into the /vendor/assets/jquery-jcrop directory.
  3. I added a = require jquery.Jcrop line into both the /app/assets/javascripts/application.js and /app/assets/stylesheets/application.css manifests.

module Paperclip

  class ManualThumbnail < Thumbnail

    def transformation_command
      result = super

      crop_arg = @attachment.instance.crop_arg
      return result unless crop_arg

      crop_index = result.index('-crop')
      result.slice!(crop_index, 2) if crop_index

      resize_index = result.index('-resize')
      result.insert(resize_index, '-crop', crop_arg)

      result
    end

  end

end

class AddCropArgToUploads < ActiveRecord::Migration
  def change
    add_column :uploads, :crop_arg, :string
  end
end

class Upload < ActiveRecord::Base

  public

  # the user sets crop_* attributes when editing an image
  attr_reader :crop_x, :crop_y, :crop_w, :crop_h
  def crop_x=(value); @crop_x = value.to_i end
  def crop_y=(value); @crop_y = value.to_i end
  def crop_w=(value); @crop_w = value.to_i end
  def crop_h=(value); @crop_h = value.to_i end

  def cropping?
    raise 'Expected an image.' unless image?
    crop_x && crop_y && crop_w && crop_h && crop_w > 0 && crop_h > 0
  end

  def geometry(style = :original)
    raise 'Expected an image.' unless image?
    @geometry ||= {}
    @geometry[style] ||= Paperclip::Geometry.from_file(cargo.path(style)) rescue Paperclip::Geometry.new(0, 0)
  end

  # this crops images when updating
  def crop
    raise 'Expected an image.' unless image?
    self.crop_arg = crop_arg_join(crop_x, crop_y, crop_w, crop_h) if cropping?
    self.cargo.reprocess!
  end
  
  def crop_arg_join(x, y, w, h)
    raise 'Expected an image.' unless image?
    "#{crop_w}x#{crop_h}+#{crop_x}+#{crop_y}"
  end
  
  def crop_arg_split(arg)
    raise 'Expected an image.' unless image?
    match = arg.match(/(d+)x(d+)+(d+)+(d+)/i)
    raise 'Expected a valid crop arg.' unless match
    w, h, x, y = match.captures
    [x.to_i, y.to_i, w.to_i, h.to_i]
  end

  def crop_select
    raise 'Expected an image.' unless image?
    default_size = ([geometry.width, geometry.height].min) / 5
    default_tl_x  = (geometry.width - default_size) / 2
    default_tl_y  = (geometry.height - default_size) / 2
    default = [default_tl_x, default_tl_y, default_size, default_size]
    result = if crop_arg
              crop_arg_split(crop_arg)
            else
              default
            end rescue default
    result
  end

end

5: modify the generated controller such that

  1. all necessary params are whitelisted
  2. upon image creation the user is given a chance to crop it
  3. upon image update it gets cropped

class UploadsController < ApplicationController

  #...

  # POST /uploads
  # POST /uploads.json
  def create
    @upload = Upload.new(upload_params)

    respond_to do |format|
      if @upload.save
        format.html { @upload.image? ? render(action: 'edit') : redirect_to(@upload) }
        format.json { render action: 'show', status: :created, location: @upload }
      else
        format.html { render action: 'new' }
        format.json { render json: @upload.errors, status: :unprocessable_entity }
      end
    end
  end

  # PATCH/PUT /uploads/1
  # PATCH/PUT /uploads/1.json
  def update
    respond_to do |format|
      if @upload.update(upload_params)

        # avoid infinite recursion, see # https://github.com/thoughtbot/paperclip/issues/866#issuecomment-8249786
        @upload.crop if @upload.cropping?

        format.html { redirect_to @upload, notice: 'Upload was successfully updated.' }
        format.json { head :no_content }
      else
        format.html { render action: 'edit' }
        format.json { render json: @upload.errors, status: :unprocessable_entity }
      end
    end
  end

  #...

  private
    #...

    # Never trust parameters from the scary internet, only allow the white list through.
    def upload_params
      params.require(:upload).permit(:name, :notes, :owner_id, :cargo, :crop_x, :crop_y, :crop_w, :crop_h)
    end
end

6: modify the form helper

<%= form_for(@upload) do |f| %>
    <% if @upload.errors.any? %>
        <div id="error_explanation">
          <h2><%= pluralize(@upload.errors.count, "error") %> prohibited this upload from being saved:</h2>

          <ul>
            <% @upload.errors.full_messages.each do |msg| %>
                <li><%= msg %></li>
            <% end %>
          </ul>
        </div>
    <% end %>

    <div class="field">
      <%= f.label :name %><br>
      <%= f.text_field :name %>
    </div>
    <div class="field">
      <%= f.label :notes %><br>
      <%= f.text_area :notes %>
    </div>
    <div class="field">
      <%= f.label :owner_id %><br>
      <%= f.number_field :owner_id %>
    </div>


    <% if @upload.new_record? %>

        <%= f.file_field :cargo %>

    <% elsif @upload.image? and File.exist?(@upload.cargo.path)
         x, y, w, h = @upload.crop_select
         select = [x, y, x + w, y + h].to_s
    %>

        <script type="text/javascript" charset="utf-8">
            $(function() {
                $('#cropbox').Jcrop({
                    onChange: update_crop,
                    onSelect: update_crop,
                    setSelect: <%= select.html_safe %>,
                    aspectRatio: 1
                });
            });

            function update_crop(coords) {
                var rx = 100/coords.w;
                var ry = 100/coords.h;
                $('#preview').css({
                    width: Math.round(rx * <%= @upload.geometry.width %>) + 'px',
                    height: Math.round(ry * <%= @upload.geometry.height %>) + 'px',
                    marginLeft: '-' + Math.round(rx * coords.x) + 'px',
                    marginTop: '-' + Math.round(ry * coords.y) + 'px'
                });
                $("#crop_x").val(coords.x);
                $("#crop_y").val(coords.y);
                $("#crop_w").val(coords.w);
                $("#crop_h").val(coords.h);
            }
        </script>

        <table>
          <tr>
            <td>
                <%= image_tag @upload.cargo.url, :id => "cropbox" %>
            </td>
            <td>
                <div style="width:100px; height:100px; overflow:hidden">
                  <%= image_tag @upload.cargo.url, :id => "preview" %>
                </div>
            </td>
          </tr>
        </table>

        <% for attribute in [:crop_x, :crop_y, :crop_w, :crop_h] %>
            <%= f.hidden_field attribute, :id => attribute %>
        <% end %>

    <% else %>

        <%= @upload.cargo_file_name %>

    <% end %>



    <div class="actions">
      <%= f.submit %>
    </div>
<% end %>

7: modify the show template

<p id="notice"><%= notice %></p>

<p>
  <strong>Name:</strong>
  <%= @upload.name %>
</p>

<p>
  <strong>Notes:</strong>
  <%= @upload.notes %>
</p>

<p>
  <strong>Owner:</strong>
  <%= @upload.owner_id %>
</p>

<p>
<% if @upload.image? and File.exist?(@upload.cargo.path) %>
    <%= image_tag @upload.cargo.url %>
    <%= image_tag @upload.cargo.url(:thumb) %>
    <%= image_tag @upload.cargo.url(:pinky) %>
<% else %>
    <%= @upload.cargo_file_name %>
<% end %>
</p>

<%= link_to 'Edit', edit_upload_path(@upload) %> |
<%= link_to 'Back', uploads_path %>

One Reply to “How to allow users to crop images in Rails 4”

Leave a Reply

Your email address will not be published. Required fields are marked *