dynamic_image 1.0.4 → 2.0.0.beta1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. checksums.yaml +7 -0
  2. data/{LICENSE → MIT-LICENSE} +1 -1
  3. data/README.md +57 -80
  4. data/Rakefile +7 -30
  5. data/lib/dynamic_image/belongs_to.rb +22 -0
  6. data/lib/dynamic_image/controller.rb +94 -0
  7. data/lib/dynamic_image/digest_verifier.rb +62 -0
  8. data/lib/dynamic_image/errors.rb +10 -0
  9. data/lib/dynamic_image/helper.rb +139 -85
  10. data/lib/dynamic_image/image_sizing.rb +156 -0
  11. data/lib/dynamic_image/metadata.rb +84 -0
  12. data/lib/dynamic_image/model/dimensions.rb +95 -0
  13. data/lib/dynamic_image/model/validations.rb +94 -0
  14. data/lib/dynamic_image/model.rb +130 -0
  15. data/lib/dynamic_image/processed_image.rb +119 -0
  16. data/lib/dynamic_image/railtie.rb +18 -0
  17. data/lib/dynamic_image/routing.rb +24 -0
  18. data/lib/dynamic_image/version.rb +5 -0
  19. data/lib/dynamic_image.rb +14 -71
  20. data/lib/rails/generators/dynamic_image/resource/resource_generator.rb +74 -0
  21. metadata +130 -97
  22. data/VERSION +0 -1
  23. data/app/controllers/images_controller.rb +0 -79
  24. data/app/models/image.rb +0 -188
  25. data/config/routes.rb +0 -16
  26. data/dynamic_image.gemspec +0 -62
  27. data/dynamic_image.sublime-project +0 -9
  28. data/dynamic_image.sublime-workspace +0 -1599
  29. data/init.rb +0 -1
  30. data/install.rb +0 -1
  31. data/lib/binary_storage/active_record_extensions.rb +0 -144
  32. data/lib/binary_storage/blob.rb +0 -104
  33. data/lib/binary_storage.rb +0 -28
  34. data/lib/dynamic_image/active_record_extensions.rb +0 -60
  35. data/lib/dynamic_image/engine.rb +0 -6
  36. data/lib/dynamic_image/filterset.rb +0 -79
  37. data/lib/generators/dynamic_image/USAGE +0 -5
  38. data/lib/generators/dynamic_image/dynamic_image_generator.rb +0 -38
  39. data/lib/generators/dynamic_image/templates/migrations/create_images.rb +0 -21
  40. data/uninstall.rb +0 -1
@@ -0,0 +1,156 @@
1
+ # encoding: utf-8
2
+
3
+ module DynamicImage
4
+ # = DynamicImage Image Sizing
5
+ #
6
+ # Calculates cropping and fitting for image sizes.
7
+ class ImageSizing
8
+ def initialize(record, options={})
9
+ @record = record
10
+ @uncropped = options[:uncropped] ? true : false
11
+ end
12
+
13
+ # Calculates crop geometry. The given vector is scaled
14
+ # to match the image size, since DynamicImage performs
15
+ # cropping before resizing.
16
+ #
17
+ # ==== Example
18
+ #
19
+ # image = Image.find(params[:id]) # 320x200 image
20
+ # sizing = DynamicImage::ImageSizing.new(image)
21
+ #
22
+ # sizing.crop_geometry(Vector2d(100, 100))
23
+ # # => [Vector2d(200, 200), Vector2d(60, 0)]
24
+ #
25
+ # Returns a tuple with crop size and crop start vectors.
26
+ def crop_geometry(ratio_vector)
27
+ # Maximize the crop area to fit the image size
28
+ crop_size = ratio_vector.fit(size).round
29
+
30
+ # Ignore pixels outside the pre-cropped area for now
31
+ center = crop_gravity - crop_start
32
+
33
+ start = center - (crop_size / 2).floor
34
+ start = clamp(start, crop_size, size)
35
+
36
+ [crop_size, (start + crop_start)]
37
+ end
38
+
39
+ # Returns crop geometry as an ImageMagick compatible string.
40
+ #
41
+ # ==== Example
42
+ #
43
+ # image = Image.find(params[:id]) # 320x200 image
44
+ # sizing = DynamicImage::ImageSizing.new(image)
45
+ #
46
+ # sizing.crop_geometry(Vector2d(100, 100)) # => "200x200+60+0"
47
+ def crop_geometry_string(ratio_vector)
48
+ crop_size, start = crop_geometry(ratio_vector)
49
+ crop_size.floor.to_s + "+#{start.x.to_i}+#{start.y.to_i}"
50
+ end
51
+
52
+ # Adjusts +fit_size+ to fit the image dimensions.
53
+ # Any dimension set to zero will be ignored.
54
+ #
55
+ # ==== Options
56
+ #
57
+ # * <tt>:crop</tt> - Don't keep aspect ratio. This will allow
58
+ # the image to be cropped to the requested size.
59
+ # * <tt>:upscale</tt> - Don't limit to the size of the image.
60
+ # Images smaller than the given size will be scaled up.
61
+ #
62
+ # ==== Examples
63
+ #
64
+ # image = Image.find(params[:id]) # 320x200 image
65
+ # sizing = DynamicImage::ImageSizing.new(image)
66
+ #
67
+ # sizing.fit(Vector2d(0, 100))
68
+ # # => Vector2d(160.0, 100.0)
69
+ #
70
+ # sizing.fit(Vector2d(500, 500))
71
+ # # => Vector2d(320.0, 200.0)
72
+ #
73
+ # sizing.fit(Vector2d(500, 500), crop: true)
74
+ # # => Vector2d(200.0, 200.0)
75
+ #
76
+ # sizing.fit(Vector2d(500, 500), upscale: true)
77
+ # # => Vector2d(500.0, 312.5)
78
+ #
79
+ def fit(fit_size, options={})
80
+ fit_size = parse_vector(fit_size)
81
+ require_dimensions!(fit_size) if options[:crop]
82
+ fit_size = size.fit(fit_size) unless options[:crop]
83
+ fit_size = size.contain(fit_size) unless options[:upscale]
84
+ fit_size
85
+ end
86
+
87
+ private
88
+
89
+ def crop_gravity
90
+ if uncropped? && !record.crop_gravity?
91
+ size / 2
92
+ else
93
+ record.crop_gravity
94
+ end
95
+ end
96
+
97
+ def crop_start
98
+ if uncropped?
99
+ Vector2d.new(0, 0)
100
+ else
101
+ record.crop_start
102
+ end
103
+ end
104
+
105
+ def size
106
+ if uncropped?
107
+ record.real_size
108
+ else
109
+ record.size
110
+ end
111
+ end
112
+
113
+ # Clamps the rectangle defined by +start+ and +size+
114
+ # to fit inside 0, 0 and +max_size+. It is assumed
115
+ # that +size+ will always be smaller than +max_size+.
116
+ #
117
+ # Returns the start vector.
118
+ def clamp(start, size, max_size)
119
+ start += shift_vector(start)
120
+ start -= shift_vector(max_size - (start + size))
121
+ start
122
+ end
123
+
124
+ def parse_vector(v)
125
+ v.kind_of?(String) ? str_to_vector(v) : v
126
+ end
127
+
128
+ def record
129
+ @record
130
+ end
131
+
132
+ def require_dimensions!(v)
133
+ raise DynamicImage::Errors::InvalidSizeOptions unless v.x > 0 && v.y > 0
134
+ end
135
+
136
+ def shift_vector(vect)
137
+ vector(
138
+ vect.x < 0 ? vect.x.abs : 0,
139
+ vect.y < 0 ? vect.y.abs : 0
140
+ )
141
+ end
142
+
143
+ def str_to_vector(str)
144
+ x, y = str.match(/(\d*)x(\d*)/)[1,2].map(&:to_i)
145
+ Vector2d.new(x, y)
146
+ end
147
+
148
+ def uncropped?
149
+ @uncropped
150
+ end
151
+
152
+ def vector(x, y)
153
+ Vector2d.new(x, y)
154
+ end
155
+ end
156
+ end
@@ -0,0 +1,84 @@
1
+ # encoding: utf-8
2
+
3
+ module DynamicImage
4
+ # = DynamicImage Metadata
5
+ #
6
+ # Parses metadata from an image. Expects to receive image data as a
7
+ # binary string.
8
+ class Metadata
9
+ def initialize(data)
10
+ @data = data
11
+ end
12
+
13
+ # Returns the color space of the image as a string. The result will be one
14
+ # of the following: "rgb", "cmyk", "gray".
15
+ def colorspace
16
+ if valid?
17
+ case metadata[:colorspace]
18
+ when /rgb/i
19
+ "rgb"
20
+ when /cmyk/i
21
+ "cmyk"
22
+ when /gray/i
23
+ "gray"
24
+ end
25
+ end
26
+ end
27
+
28
+ # Returns the content type of the image.
29
+ def content_type
30
+ if valid?
31
+ "image/#{format.downcase}"
32
+ end
33
+ end
34
+
35
+ # Returns the dimensions of the image as a vector.
36
+ def dimensions
37
+ if valid?
38
+ Vector2d.new(*metadata[:dimensions])
39
+ end
40
+ end
41
+
42
+ # Returns the width of the image.
43
+ def width
44
+ dimensions.try(:x)
45
+ end
46
+
47
+ # Returns the height of the image.
48
+ def height
49
+ dimensions.try(:y)
50
+ end
51
+
52
+ # Returns the format of the image.
53
+ def format
54
+ if valid?
55
+ metadata[:format]
56
+ end
57
+ end
58
+
59
+ # Returns true if the image is valid.
60
+ def valid?
61
+ @data && metadata != :invalid
62
+ end
63
+
64
+ private
65
+
66
+ def metadata
67
+ @metadata ||= read_metadata
68
+ end
69
+
70
+ def read_metadata
71
+ image = MiniMagick::Image.read(@data)
72
+ image.auto_orient
73
+ metadata = {
74
+ colorspace: image[:colorspace],
75
+ dimensions: image[:dimensions],
76
+ format: image[:format]
77
+ }
78
+ image.destroy!
79
+ metadata
80
+ rescue MiniMagick::Invalid
81
+ :invalid
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,95 @@
1
+ # encoding: utf-8
2
+
3
+ module DynamicImage
4
+ module Model
5
+ # = DynamicImage Model Dimensions
6
+ #
7
+ module Dimensions
8
+
9
+ # Returns the crop gravity.
10
+ #
11
+ # DynamicImage will try to keep the pixel represented by
12
+ # crop_gravity as close to the center as possible when cropping
13
+ # images.
14
+ #
15
+ # It is relative to 0,0 on the original image.
16
+ #
17
+ # Unless crop_gravity has been explicitely set, it defaults to
18
+ # the center of the cropped image.
19
+ def crop_gravity
20
+ if crop_gravity?
21
+ vector(crop_gravity_x, crop_gravity_y)
22
+ elsif cropped?
23
+ crop_start + (crop_size / 2)
24
+ elsif size?
25
+ size / 2
26
+ end
27
+ end
28
+
29
+ # Returns true if crop gravity has been explicitely set.
30
+ def crop_gravity?
31
+ crop_gravity_x.present? && crop_gravity_y.present?
32
+ end
33
+
34
+ # Returns the crop size, or nil if no cropping is applied.
35
+ def crop_size
36
+ if crop_size?
37
+ vector(crop_width, crop_height)
38
+ end
39
+ end
40
+
41
+ # Returns true if crop size has been set.
42
+ def crop_size?
43
+ crop_width? && crop_height?
44
+ end
45
+
46
+ # Returns the crop start if set, or Vector2d(0, 0) if not.
47
+ def crop_start
48
+ if crop_start?
49
+ vector(crop_start_x, crop_start_y)
50
+ else
51
+ vector(0, 0)
52
+ end
53
+ end
54
+
55
+ # Returns true if crop start has been set.
56
+ def crop_start?
57
+ crop_start_x.present? && crop_start_y.present?
58
+ end
59
+
60
+ # Returns true if the image is cropped.
61
+ def cropped?
62
+ crop_size? && real_size? && crop_size != real_size
63
+ end
64
+
65
+ # Returns the real size of the image, without any cropping applied.
66
+ def real_size
67
+ if real_size?
68
+ vector(real_width, real_height)
69
+ end
70
+ end
71
+
72
+ # Returns true if the size has been set.
73
+ def real_size?
74
+ real_width? && real_height?
75
+ end
76
+
77
+ # Returns the cropped size if the image has been cropped. If not,
78
+ # it returns the actual size.
79
+ def size
80
+ crop_size || real_size
81
+ end
82
+
83
+ # Returns true if the image has size set.
84
+ def size?
85
+ size ? true : false
86
+ end
87
+
88
+ private
89
+
90
+ def vector(x, y)
91
+ Vector2d.new(x, y)
92
+ end
93
+ end
94
+ end
95
+ end
@@ -0,0 +1,94 @@
1
+ # encoding: utf-8
2
+
3
+ module DynamicImage
4
+ module Model
5
+ # = DynamicImage Model Validations
6
+ #
7
+ # Validates that all necessary attributes are valid. All of these are
8
+ # managed by +DynamicImage::Model+, so this is mostly for enforcing
9
+ # integrity.
10
+ module Validations
11
+ extend ActiveSupport::Concern
12
+ included do
13
+ validates_data_presence
14
+
15
+ validates :colorspace,
16
+ presence: true,
17
+ inclusion: { in: allowed_colorspaces }
18
+
19
+ validates :content_type,
20
+ presence: true,
21
+ inclusion: { in: allowed_content_types }
22
+
23
+ validates :content_length,
24
+ presence: true,
25
+ numericality: { greater_than: 0, only_integer: true }
26
+
27
+ validates :filename,
28
+ presence: true,
29
+ length: { maximum: 255 }
30
+
31
+ validates :real_width, :real_height,
32
+ numericality: { greater_than: 0, only_integer: true }
33
+
34
+ validates :real_width, :real_height,
35
+ numericality: { greater_than: 0, only_integer: true }
36
+
37
+ validates :crop_width, :crop_height,
38
+ :crop_gravity_x, :crop_gravity_y,
39
+ numericality: { greater_than: 0, only_integer: true },
40
+ allow_nil: true
41
+
42
+ validates :real_width, :real_height,
43
+ presence: true
44
+
45
+ validates :crop_width, presence: true, if: :crop_height?
46
+ validates :crop_height, presence: true, if: :crop_width?
47
+
48
+ validates :crop_start_x, presence: true, if: :crop_start_y?
49
+ validates :crop_start_y, presence: true, if: :crop_start_x?
50
+
51
+ validates :crop_gravity_x, presence: true, if: :crop_gravity_y?
52
+ validates :crop_gravity_y, presence: true, if: :crop_gravity_x?
53
+
54
+ validate :validate_crop_bounds, if: :cropped?
55
+ validate :validate_image, if: :data_changed?
56
+ end
57
+
58
+ module ClassMethods
59
+ def allowed_colorspaces
60
+ %w{
61
+ rgb
62
+ cmyk
63
+ gray
64
+ }
65
+ end
66
+
67
+ def allowed_content_types
68
+ %w{
69
+ image/gif
70
+ image/jpeg
71
+ image/pjpeg
72
+ image/png
73
+ image/tiff
74
+ }
75
+ end
76
+ end
77
+
78
+ private
79
+
80
+ def validate_crop_bounds
81
+ required_size = crop_start + crop_size
82
+ if required_size.x > real_size.x || required_size.y > real_size.y
83
+ self.errors.add(:crop_size, "is out of bounds")
84
+ end
85
+ end
86
+
87
+ def validate_image
88
+ unless valid_image?
89
+ self.errors.add(:data, :invalid)
90
+ end
91
+ end
92
+ end
93
+ end
94
+ end
@@ -0,0 +1,130 @@
1
+ # encoding: utf-8
2
+
3
+ require 'dynamic_image/model/dimensions'
4
+ require 'dynamic_image/model/validations'
5
+
6
+ module DynamicImage
7
+ # = DynamicImage Model
8
+ #
9
+ # ActiveModel extension for the model holding image data. It assumes your
10
+ # database table has at least the following attributes:
11
+ #
12
+ # create_table :images do |t|
13
+ # t.string :content_hash
14
+ # t.string :content_type
15
+ # t.integer :content_length
16
+ # t.string :filename
17
+ # t.string :colorspace
18
+ # t.integer :real_width, :real_height
19
+ # t.integer :crop_width, :crop_height
20
+ # t.integer :crop_start_x, :crop_start_y
21
+ # t.integer :crop_gravity_x, :crop_gravity_y
22
+ # t.timestamps
23
+ # end
24
+ #
25
+ # To use it, simply include it in your model:
26
+ #
27
+ # class Image < ActiveRecord::Base
28
+ # include DynamicImage::Model
29
+ # end
30
+ #
31
+ # == Usage
32
+ #
33
+ # To save an image, simply assign to the +file+ attribute.
34
+ #
35
+ # image = Image.create(file: params.permit(:file))
36
+ #
37
+ # This will automatically parse and validate the image when your record is
38
+ # saved.
39
+ #
40
+ # To read back the image data, access the +data+ attribute. This will lazily
41
+ # load the data from the store.
42
+ #
43
+ # data = image.data
44
+ #
45
+ # == Cropping
46
+ #
47
+ # Images can be pre-cropped by setting +crop_width+, +crop_height+,
48
+ # +crop_start_x+ and +crop_start_y+. The crop dimensions cannot exceed the
49
+ # image size.
50
+ #
51
+ # image.update(
52
+ # crop_start_x: 15, crop_start_y: 20,
53
+ # crop_width: 300, crop_height: 200
54
+ # )
55
+ # image.size # => Vector2d(300, 200)
56
+ #
57
+ # By default, images will be cropped from the center. You can control this
58
+ # by setting +crop_gravity_x+ and +crop_gravity_y+. DynamicImage will make
59
+ # sure the pixel referred to by these coordinates are present in the cropped
60
+ # image, and as close to the center as possible without zooming in.
61
+ module Model
62
+ extend ActiveSupport::Concern
63
+ include Dis::Model
64
+ include DynamicImage::Model::Dimensions
65
+ include DynamicImage::Model::Validations
66
+
67
+ included do
68
+ before_validation :read_image_metadata, if: :data_changed?
69
+ end
70
+
71
+ # Returns true if the image is in the CMYK colorspace
72
+ def cmyk?
73
+ colorspace == "cmyk"
74
+ end
75
+
76
+ # Returns true if the image is in the grayscale colorspace
77
+ def gray?
78
+ colorspace == "gray"
79
+ end
80
+
81
+ # Returns true if the image is in the RGB colorspace
82
+ def rgb?
83
+ colorspace == "rgb"
84
+ end
85
+
86
+ # Finds a web safe content type. GIF, JPEG and PNG images are allowed,
87
+ # any other formats should be converted to JPEG.
88
+ def safe_content_type
89
+ if safe_content_types.include?(content_type)
90
+ content_type
91
+ else
92
+ 'image/jpeg'
93
+ end
94
+ end
95
+
96
+ # Includes a timestamp fingerprint in the URL param, so
97
+ # that rendered images can be cached indefinitely.
98
+ def to_param
99
+ [id, updated_at.utc.to_s(cache_timestamp_format)].join('-')
100
+ end
101
+
102
+ private
103
+
104
+ def read_image_metadata
105
+ metadata = DynamicImage::Metadata.new(self.data)
106
+ if metadata.valid?
107
+ self.colorspace = metadata.colorspace
108
+ self.real_width = metadata.width
109
+ self.real_height = metadata.height
110
+ self.content_type = metadata.content_type
111
+ @valid_image = true
112
+ else
113
+ @valid_image = false
114
+ end
115
+ true
116
+ end
117
+
118
+ def valid_image?
119
+ @valid_image ? true : false
120
+ end
121
+
122
+ def safe_content_types
123
+ %w{
124
+ image/png
125
+ image/gif
126
+ image/jpeg
127
+ }
128
+ end
129
+ end
130
+ end
@@ -0,0 +1,119 @@
1
+ # encoding: utf-8
2
+
3
+ module DynamicImage
4
+ # = DynamicImage Processed Image
5
+ #
6
+ # Handles all processing of images. Takes an instance of
7
+ # +DynamicImage::Model+ as argument.
8
+ class ProcessedImage
9
+ def initialize(record, options={})
10
+ @record = record
11
+ @uncropped = options[:uncropped] ? true : false
12
+ @format = options[:format].to_s.upcase if options[:format]
13
+ @format = "JPEG" if @format == "JPG"
14
+ end
15
+
16
+ # Returns the content type of the processed image.
17
+ #
18
+ # ==== Example
19
+ #
20
+ # image = Image.find(params[:id])
21
+ # DynamicImage::ProcessedImage.new(image).content_type
22
+ # # => 'image/png'
23
+ # DynamicImage::ProcessedImage.new(image, :jpeg).content_type
24
+ # # => 'image/jpeg'
25
+ def content_type
26
+ "image/#{format}".downcase
27
+ end
28
+
29
+ # Crops and resizes the image. Normalization is performed as well.
30
+ #
31
+ # ==== Example
32
+ #
33
+ # processed = DynamicImage::ProcessedImage.new(image)
34
+ # image_data = processed.cropped_and_resized(Vector2d.new(200, 200))
35
+ #
36
+ # Returns a binary string.
37
+ def cropped_and_resized(size)
38
+ normalized do |image|
39
+ image.crop image_sizing.crop_geometry_string(size)
40
+ image.resize size
41
+ end
42
+ end
43
+
44
+ # Normalizes the image.
45
+ #
46
+ # * Applies EXIF rotation
47
+ # * CMYK images are converted to sRGB
48
+ # * Strips metadata
49
+ # * Performs format conversion if the requested format is different
50
+ #
51
+ # ==== Example
52
+ #
53
+ # processed = DynamicImage::ProcessedImage.new(image, :jpeg)
54
+ # jpg_data = processed.normalized
55
+ #
56
+ # Returns a binary string.
57
+ def normalized(&block)
58
+ require_valid_image!
59
+ process_data do |image|
60
+ image.combine_options do |combined|
61
+ image.auto_orient
62
+ image.colorspace('sRGB') if needs_colorspace_conversion?
63
+ yield(combined) if block_given?
64
+ image.strip
65
+ end
66
+ image.format(format) if needs_format_conversion?
67
+ end
68
+ end
69
+
70
+ private
71
+
72
+ def format
73
+ @format || record_format
74
+ end
75
+
76
+ def image_sizing
77
+ @image_sizing ||= DynamicImage::ImageSizing.new(record, uncropped: @uncropped)
78
+ end
79
+
80
+ def needs_colorspace_conversion?
81
+ record.cmyk?
82
+ end
83
+
84
+ def needs_format_conversion?
85
+ format != record_format
86
+ end
87
+
88
+ def process_data(&block)
89
+ image = MiniMagick::Image.read(record.data)
90
+ yield(image)
91
+ result = image.to_blob
92
+ image.destroy!
93
+ result
94
+ end
95
+
96
+ def record
97
+ @record
98
+ end
99
+
100
+ def record_format
101
+ case record.content_type
102
+ when 'image/png'
103
+ 'PNG'
104
+ when 'image/gif'
105
+ 'GIF'
106
+ when 'image/jpeg', 'image/pjpeg'
107
+ 'JPEG'
108
+ when 'image/tiff'
109
+ 'TIFF'
110
+ end
111
+ end
112
+
113
+ def require_valid_image!
114
+ unless record.valid?
115
+ raise DynamicImage::Errors::InvalidImage
116
+ end
117
+ end
118
+ end
119
+ end
@@ -0,0 +1,18 @@
1
+ # encoding: utf-8
2
+
3
+ module DynamicImage
4
+ class Railtie < ::Rails::Railtie
5
+ initializer "dynamic_image" do
6
+ ActionDispatch::Routing::Mapper.send :include, DynamicImage::Routing
7
+
8
+ config.after_initialize do |app|
9
+ secret = app.key_generator.generate_key('dynamic_image')
10
+ DynamicImage.digest_verifier = DynamicImage::DigestVerifier.new(secret)
11
+ end
12
+
13
+ ActiveSupport.on_load(:active_record) do
14
+ send :include, DynamicImage::BelongsTo
15
+ end
16
+ end
17
+ end
18
+ end