Paperdragon

Explicit image processing.

Summary

Paperdragon gives you image processing as known from Paperclip, CarrierWave or Dragonfly. It allows uploading, cropping, resizing, watermarking, maintaining different versions of an image, and so on.

It provides a very explicit DSL: No magic is happening behind the scenes, paperdragon makes you implement the processing steps.

With only a little bit of more code you are fully in control of what gets uploaded where, which image version gets resized when and what gets sent to a background job.

Paperdragon uses the excellent Dragonfly gem for processing, resizing, storing, etc.

Paperdragon is database-agnostic, doesn't know anything about ActiveRecord and does not hook into AR's callbacks.

Installation

Add this line to your application's Gemfile:

gem 'paperdragon'

Example

This README only documents the public DSL. You're free to use the public API documented here if you don't like the DSL.

Model

Paperdragon has only one requirement for the model: It needs to have a column image_meta_data. This is a serialised hash where paperdragon saves UIDs for the different image versions. We'll learn about this in a minute.

class User < ActiveRecord::Base # this could be just anything.
  include Paperdragon::Model

  processable :image

  serialize :image_meta_data
end

Calling ::processable advises paperdragon to create a User#image reader to the attachment. Nothing else is added to the class.

Uploading

Processing and storing an uploaded image is an explicit step - you have to code it! This code usually goes to a separate class or an Operation in Trailblazer, don't leave it in the controller if you don't have to.

def create
  file = params.delete(:image)

  user = User.create(params) # this is your code.

  # upload code:
  user.image(file) do |v|
    v.process!(:original)                                      # save the unprocessed.
    v.process!(:thumb)   { |job| job.thumb!("75x75#") }        # resizing.
    v.process!(:cropped) { |job| job.thumb!("140x140+20+20") } # cropping.
    v.process!(:public)  { |job| job.watermark! }              # watermark.
  end

  user.save
end

This is a completely transparent process.

  1. Calling #image usually returns the image attachment. However, passing a file to it allows to create different versions of the uploaded image in the block.
  2. #process! requires you to pass in a name for that particular image version. It is a convention to call the unprocessed image :original.
  3. The job object is responsible for creating the final version. This is simply a Dragonfly::Job object and gives you everything that can be done with dragonfly.
  4. After the block is run, paperdragon pushes a hash with all the images meta data to the model via model.image_meta_data=.

For a better understanding and to see how simple it is, go and check out the image_meta_data field.

 user. #=> {original: {uid: "original-logo.jpg", width: 240, height: 800},
                      #    thumb:    {uid: "thumb-logo.jpg", width: 140, height: 140},
                      #   ..and so on..
                      #   }

Rendering Images

After processing, you may want to render those image versions in your app.

user.image[:thumb].url

This is all you need to retrieve the URL/path for a stored image. Use this for your image tags.

= img_tag user.image[:thumb].url

Internally, Paperdragon will call model#image_meta_data and use this hash to find the address of the image.

While gems like paperclip often use several fields of the model to compute UIDs (addresses) at run-time, paperdragon does that once and then dumps it to the database. This completely removes the dependency to the model.

Reprocessing And Cropping

Once an image has been processed to several versions, you might need to reprocess some of them. As an example, users could re-crop their thumbs.

def crop
  user = User.find(params[:id]) # this is your code.

  # reprocessing code:
  cropping = "#{params[:w]}x#{params[:h]}#"

  user.image do |v|
    v.reprocess!(:thumb, Time.now) { |job| job.thumb!(cropping) } # re-crop.
  end

  user.save
end

Only a few things have changed compared to the initial processing.

  1. We do not pass a file to #image anymore. This makes sense as reprocessing will re-use the existing original file.
  2. Note that it's not #process! but #reprocess! indicating a surprising reprocessing.
  3. As a second argument to #reprocess! a fingerprint string is required. To understand what this does, let's inspect image_meta_data once again. (The fingerprint feature is optional but extremely helpful.)
 user. #   ..original..
                      #    thumb:    {uid: "thumb-logo-1234567890.jpg", width: 48, height: 48},
                      #   ..and so on..
                      #   }

See how the file name has changed? Paperdragon will automatically append the fingerprint you pass into #reprocess! to the existing version's file name.

Renaming

Sometimes you just want to rename files without processing them. For instance, when a new fingerprint for an image is introduced, you want to apply that to all versions.

fingerprint = Time.now

user.image do |v|
  v.reprocess!(:thumb, fingerprint) { |job| job.thumb!(cropping) } # re-crop.
  v.rename!(:original, fingerprint) # just rename it.
end

This will re-crop the thumb and rename the original.

 user. #=> {original: {uid: "original-logo-1234567890.jpg", ..},
                      #    thumb:    {uid: "thumb-logo-1234567890.jpg", ..},
                      #   ..and so on..
                      #   }

Deleting

While making images is a wonderful thing, sometimes you need to destroy to create. This is why paperdragon gives you a deleting mechanism, too.

user.image do |v|
  v.delete!(:thumb)
end

This will also remove the associated metadata from the model.

You can delete all versions of an attachment by omitting the style.

user.image do |v|
  v.delete! # deletes :original and :thumb.
end

Replacing Images

It's ok to run #process! again on a model with an existing attachment.

user.  #=> {original: {uid: "original-logo-1234567890.jpg", ..},

Processing here will overwrite the existing attachment.

user.image(new_file) do |v|
  v.process!(:original) # overwrites the existing, deletes old.
end
user.  #=> {original: {uid: "original-new-file01.jpg", ..},

While replacing the old with the new upload, the old file also gets deleted.

Fingerprints

Paperdragon comes with a very simple built-in file naming.

Computing a file UID (or, name, or path) happens in the Attachment class. You need to provide your own implementation if you want to change things.

class User < ActiveRecord::Base
  include Paperdragon::Model

  class Attachment < Paperdragon::Attachment
    def build_uid(style, file)
      "/path/to/#{style}/#{obfuscator}/#{file.name}"
    end

    def obfuscator
      Obfuscator.call # this is your code.
    end
  end

  processable :image, Attachment # use the class you just wrote.

The Attachment#build_uid method is invoked when processing images.

user.image(file) do |v|
  v.process!(:thumb)   { |job| job.thumb!("75x75#") }
end

To create the image UID, your attachment is now being used.

 user. #   ..original..
                      #    thumb:    {uid: "/path/to/thumb/ac97dnxid8/logo.jpg", ..},
                      #   ..and so on..
                      #   }

What a beautiful, cryptic and mysterious filename you just created!

The same pattern applies for re-building UIDs when reprocessing images.

class Attachment < Paperdragon::Attachment
  # def build_uid and the other code from above..

  def rebuild_uid(file, fingerprint)
    file.uid.sub("logo.png", "logo-#{fingerprint}.png")
  end
end

This code is used to re-compute UIDs in #reprocess!.

That example is stupid, I know, but it shows how you have access to the Paperdragon::File instance that represents the existing version of the reprocessed image.

Local Rails Configuration

Configuration of paperdragon completely relies on configuring dragonfly. As an example, for a Rails app with a local file storage, I use the following configuration in config/initializers/paperdragon.rb.

Dragonfly.app.configure do
  plugin :imagemagick

  datastore :file,
    :server_root => 'public',
    :root_path => 'public/images'
end

This would result in image UIDs being prefixed accordingly.

user.image[:thumb].url #=> "/images/logo-1234567890.png"

S3

As dragonfly allows S3, using the amazon cloud service is straight-forward.

All you need to do is configuring your bucket. The API for paperdragon remains unchanged.

require 'dragonfly/s3_data_store'

Dragonfly.app.configure do
  datastore :s3,
    bucket_name: 'my-bucket',
    access_key_id: 'blahblahblah',
    secret_access_key: 'blublublublu'
end

Images will be stored "in the cloud" when using #process!, renaming, deleting and re-processing do the same!

Background Processing

The explicit design of paperdragon makes it incredibly simple to move all or certain processing steps to background jobs.

class Image::Processor
  include Sidekiq::Worker

  def perform(params)
    user = User.find(params[:id])

    user.image(params[:file]) do |v|
      v.process!(:original)
    end
  end
end

Documentation how to use Sidekiq and paperdragon in Traiblazer will be added shortly.

Validations

Validating uploads are discussed in the Callbacks chapter of the Trailblazer book. We use file_validators.

Model: Reader and Writer

If you don't like Paperdragon::Model#image's fuzzy API you can use Reader and Writer.

The Writer will usually be mixed into a form.

class AlbumForm < Reform::Form
  extend Paperdragon::Model::Writer
  processable_writer :image

This provides the image! writer for processing a file.

form.image!(file) { |v| v.thumb!("64x64") }

Likewise, Reader will usually be used in cells or decorators.

class AlbumCell < Cell::ViewModel
  extend Paperdragon::Model::Reader
  processable_reader :image
  property :image_meta_data

You can now access the Attachment via image.

cell.image[:thumb].url

Paperclip compatibility

I wrote paperdragon as an explicit alternative to paperclip. In the process of doing so, I step-wise replaced upload code, but left the rendering code unchanged. Paperclip has a slightly different API for rendering.

user.image.url(:thumb)

Allowing your paperdragon-backed model to expose this API is piece-of-cake.

class User < ActiveRecord::Base
  include Paperdragon::Paperclip::Model

This will allow both APIs for a smooth transition.