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.
- Calling
#image
usually returns the image attachment. However, passing afile
to it allows to create different versions of the uploaded image in the block. #process!
requires you to pass in a name for that particular image version. It is a convention to call the unprocessed image:original
.- The
job
object is responsible for creating the final version. This is simply aDragonfly::Job
object and gives you everything that can be done with dragonfly. - 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.
- We do not pass a file to
#image
anymore. This makes sense as reprocessing will re-use the existing original file. - Note that it's not
#process!
but#reprocess!
indicating a surprising reprocessing. - As a second argument to
#reprocess!
a fingerprint string is required. To understand what this does, let's inspectimage_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.