dynamic_image 3.0.8 → 3.1.0

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 (46) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +393 -67
  3. data/Rakefile +3 -0
  4. data/app/models/dynamic_image/variant.rb +10 -0
  5. data/lib/dynamic_image/backfill.rb +85 -0
  6. data/lib/dynamic_image/belongs_to.rb +22 -0
  7. data/lib/dynamic_image/breakpoints.rb +93 -0
  8. data/lib/dynamic_image/controller.rb +57 -12
  9. data/lib/dynamic_image/digest_verifier.rb +20 -9
  10. data/lib/dynamic_image/engine.rb +4 -1
  11. data/lib/dynamic_image/errors.rb +30 -0
  12. data/lib/dynamic_image/format.rb +149 -8
  13. data/lib/dynamic_image/format_negotiator.rb +84 -0
  14. data/lib/dynamic_image/helper/formats.rb +57 -0
  15. data/lib/dynamic_image/helper/pictures.rb +124 -0
  16. data/lib/dynamic_image/helper.rb +100 -60
  17. data/lib/dynamic_image/image_processor/colors.rb +3 -7
  18. data/lib/dynamic_image/image_processor/frames.rb +10 -1
  19. data/lib/dynamic_image/image_processor/transform.rb +17 -2
  20. data/lib/dynamic_image/image_processor.rb +33 -6
  21. data/lib/dynamic_image/image_reader.rb +19 -1
  22. data/lib/dynamic_image/image_sizing.rb +51 -19
  23. data/lib/dynamic_image/metadata.rb +56 -5
  24. data/lib/dynamic_image/model/dimensions.rb +32 -13
  25. data/lib/dynamic_image/model/transformations.rb +50 -12
  26. data/lib/dynamic_image/model/validations.rb +15 -8
  27. data/lib/dynamic_image/model/variants.rb +6 -3
  28. data/lib/dynamic_image/model.rb +64 -35
  29. data/lib/dynamic_image/picture/format_policy.rb +72 -0
  30. data/lib/dynamic_image/picture.rb +215 -0
  31. data/lib/dynamic_image/processed_image.rb +53 -17
  32. data/lib/dynamic_image/ratio.rb +43 -0
  33. data/lib/dynamic_image/routing.rb +14 -3
  34. data/lib/dynamic_image/schema.rb +47 -0
  35. data/lib/dynamic_image/version.rb +1 -1
  36. data/lib/dynamic_image.rb +61 -1
  37. data/lib/rails/generators/dynamic_image/resource/USAGE +17 -0
  38. data/lib/rails/generators/dynamic_image/resource/resource_generator.rb +57 -21
  39. data/lib/rails/generators/dynamic_image/resource/templates/create_table_migration.rb.tt +32 -0
  40. data/lib/rails/generators/dynamic_image/upgrade/USAGE +17 -0
  41. data/lib/rails/generators/dynamic_image/upgrade/templates/upgrade_migration.rb.tt +10 -0
  42. data/lib/rails/generators/dynamic_image/upgrade/upgrade_generator.rb +111 -0
  43. data/lib/tasks/dynamic_image.rake +25 -0
  44. metadata +26 -15
  45. data/lib/dynamic_image/jobs/create_variant.rb +0 -22
  46. data/lib/dynamic_image/jobs.rb +0 -3
@@ -7,10 +7,17 @@ require "dynamic_image/image_processor/transform"
7
7
  module DynamicImage
8
8
  # = ImageProcessor
9
9
  #
10
- # This is the image processing pipeline.
10
+ # The image processing pipeline.
11
11
  #
12
- # ==== Example:
12
+ # Every operation returns a new processor instead of modifying the one it was called on, so operations chain.
13
+ # Images are converted to sRGB and EXIF rotation is applied when the processor is built, so the pipeline always
14
+ # starts from a normalized image.
13
15
  #
16
+ # @see DynamicImage::ImageProcessor::Colors
17
+ # @see DynamicImage::ImageProcessor::Frames
18
+ # @see DynamicImage::ImageProcessor::Transform
19
+ #
20
+ # @example
14
21
  # DynamicImage::ImageProcessor
15
22
  # .new(file)
16
23
  # .crop(crop_start, crop_size)
@@ -22,8 +29,16 @@ module DynamicImage
22
29
  include DynamicImage::ImageProcessor::Frames
23
30
  include DynamicImage::ImageProcessor::Transform
24
31
 
32
+ # @!attribute [r] image
33
+ # @return [Vips::Image]
34
+ # @!attribute [r] target_format
35
+ # @return [DynamicImage::Format] the format it will be written in
25
36
  attr_reader :image, :target_format
26
37
 
38
+ # @param image [Vips::Image, Pathname, IO, String] the image, either an already loaded vips image or something
39
+ # {DynamicImage::ImageReader} can read
40
+ # @param target_format [DynamicImage::Format, nil] the format to write in, defaulting to the format the image was
41
+ # read from
27
42
  def initialize(image, target_format: nil)
28
43
  if image.is_a?(Vips::Image)
29
44
  @image = image
@@ -36,10 +51,14 @@ module DynamicImage
36
51
  end
37
52
 
38
53
  # Convert the image to a different format.
54
+ #
55
+ # Converting a multi-frame image to a format that doesn't support animation keeps the first frame.
56
+ #
57
+ # @param new_format [DynamicImage::Format, Symbol, String] the format to convert to
58
+ # @return [DynamicImage::ImageProcessor] a new processor
39
59
  def convert(new_format)
40
- unless new_format.is_a?(DynamicImage::Format)
41
- new_format = DynamicImage::Format.find(new_format)
42
- end
60
+ new_format = DynamicImage::Format.find(new_format) unless new_format.is_a?(DynamicImage::Format)
61
+
43
62
  if frame_count > 1 && !new_format.animated?
44
63
  self.class.new(extract_frame(0), target_format: new_format)
45
64
  else
@@ -48,12 +67,17 @@ module DynamicImage
48
67
  end
49
68
 
50
69
  # Returns the image data as a binary string.
70
+ #
71
+ # @return [String] the encoded image
51
72
  def read
52
73
  image.write_to_buffer(target_format.extension,
53
74
  **target_format.save_options)
54
75
  end
55
76
 
56
- # Returns the image size as a Vector2d.
77
+ # Returns the image size as a Vector2d. For multi-frame images this is the size of a single frame, not of the
78
+ # filmstrip vips holds them in.
79
+ #
80
+ # @return [Vector2d]
57
81
  def size
58
82
  Vector2d.new(
59
83
  image.get("width"),
@@ -64,6 +88,9 @@ module DynamicImage
64
88
  end
65
89
 
66
90
  # Write the image to a file.
91
+ #
92
+ # @param path [String] the path to write to
93
+ # @return [void]
67
94
  def write(path)
68
95
  image.write_to_file(path, **target_format.save_options)
69
96
  end
@@ -1,17 +1,32 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module DynamicImage
4
+ # = DynamicImage Image Reader
5
+ #
6
+ # Reads an image into vips, identifying the format from the file header and not from anything the client claims.
7
+ # Accepts a Pathname, an IO object or a binary string.
8
+ #
9
+ # Animated formats are opened with all their frames.
4
10
  class ImageReader
5
- HEADER_BYTES = 12
11
+ # Number of bytes needed to identify a format.
12
+ HEADER_BYTES = 32
6
13
 
14
+ # @param data [Pathname, IO, String] the image
7
15
  def initialize(data)
8
16
  @data = data
9
17
  end
10
18
 
19
+ # The format of the image, sniffed from its header.
20
+ #
21
+ # @return [DynamicImage::Format, nil] the format, if recognized
11
22
  def format
12
23
  DynamicImage::Format.sniff(file_header)
13
24
  end
14
25
 
26
+ # Reads the image.
27
+ #
28
+ # @return [Vips::Image]
29
+ # @raise [DynamicImage::Errors::InvalidHeader] if the data isn't in a recognized format
15
30
  def read
16
31
  raise DynamicImage::Errors::InvalidHeader unless valid_header?
17
32
 
@@ -23,6 +38,9 @@ module DynamicImage
23
38
  end
24
39
  end
25
40
 
41
+ # Returns true if the header belongs to a supported format.
42
+ #
43
+ # @return [Boolean]
26
44
  def valid_header?
27
45
  format ? true : false
28
46
  end
@@ -3,26 +3,37 @@
3
3
  module DynamicImage
4
4
  # = DynamicImage Image Sizing
5
5
  #
6
- # Calculates cropping and fitting for image sizes.
6
+ # Calculates cropping and fitting for image sizes. The helpers use it to work out the dimensions of a rendered
7
+ # image, and it can be used on its own when you need the size without rendering anything: reserving space in a
8
+ # layout, filling in <tt>og:image:width</tt>, or laying out a PDF.
9
+ #
10
+ # @example
11
+ # sizing = DynamicImage::ImageSizing.new(image)
12
+ # sizing.fit("400x400") # => Vector2d(400.0, 250.0)
7
13
  class ImageSizing
14
+ # @param record [DynamicImage::Model] the image
15
+ # @param options [Hash]
16
+ # @option options [Boolean] :uncropped Ignore any crop stored on the record and size against the original image
8
17
  def initialize(record, options = {})
9
18
  @record = record
10
19
  @uncropped = options[:uncropped] ? true : false
11
20
  end
12
21
 
13
- # Calculates crop geometry. The given vector is scaled
14
- # to match the image size, since DynamicImage performs
15
- # cropping before resizing.
22
+ # Calculates crop geometry. The given vector is scaled to match the image size, since cropping happens before
23
+ # resizing.
24
+ #
25
+ # The crop is positioned to keep the record's crop gravity as close to the center as possible, clamped to the
26
+ # bounds of the image.
16
27
  #
17
- # ==== Example
28
+ # @param ratio_vector [Vector2d] the aspect ratio to crop to
29
+ # @return [Array(Vector2d, Vector2d)] the crop size and crop start
18
30
  #
31
+ # @example
19
32
  # image = Image.find(params[:id]) # 320x200 image
20
33
  # sizing = DynamicImage::ImageSizing.new(image)
21
34
  #
22
35
  # sizing.crop_geometry(Vector2d(100, 100))
23
36
  # # => [Vector2d(200, 200), Vector2d(60, 0)]
24
- #
25
- # Returns a tuple with crop size and crop start vectors.
26
37
  def crop_geometry(ratio_vector)
27
38
  # Maximize the crop area to fit the image size
28
39
  crop_size = ratio_vector.fit(size).round
@@ -36,18 +47,41 @@ module DynamicImage
36
47
  [crop_size, (start + crop_start)]
37
48
  end
38
49
 
39
- # Adjusts +fit_size+ to fit the image dimensions.
40
- # Any dimension set to zero will be ignored.
50
+ # Returns the widest the image can be rendered at, in pixels.
51
+ #
52
+ # Without a ratio this is the image's own width. With one it is the width of the largest crop matching that
53
+ # ratio.
54
+ #
55
+ # @param ratio [Numeric, Vector2d, String, nil] the aspect ratio, in any form {DynamicImage::Ratio} understands
56
+ # @return [Integer]
41
57
  #
42
- # ==== Options
58
+ # @example
59
+ # image = Image.find(params[:id]) # 320x200 image
60
+ # sizing = DynamicImage::ImageSizing.new(image)
43
61
  #
44
- # * <tt>:crop</tt> - Don't keep aspect ratio. This will allow
45
- # the image to be cropped to the requested size.
46
- # * <tt>:upscale</tt> - Don't limit to the size of the image.
47
- # Images smaller than the given size will be scaled up.
62
+ # sizing.available_width # => 320
63
+ # sizing.available_width(16.0 / 9) # => 320
64
+ # sizing.available_width(9.0 / 16) # => 113
65
+ def available_width(ratio = nil)
66
+ ratio = DynamicImage::Ratio.parse(ratio)
67
+ return size.x.floor unless ratio
68
+
69
+ crop_geometry(vector(ratio, 1)).first.x.floor
70
+ end
71
+
72
+ # Adjusts +fit_size+ to fit the image dimensions. Any dimension set to zero will be ignored.
48
73
  #
49
- # ==== Examples
74
+ # @param fit_size [Vector2d, String] the size to fit within, either a vector or a <tt>"{width}x{height}"</tt>
75
+ # string. Either dimension may be omitted for a fixed width or height.
76
+ # @param options [Hash]
77
+ # @option options [Boolean] :crop Don't keep aspect ratio. This will allow the image to be cropped to the
78
+ # requested size.
79
+ # @option options [Boolean] :upscale Don't limit to the size of the image. Images smaller than the given size will
80
+ # be scaled up.
81
+ # @return [Vector2d] the resulting size
82
+ # @raise [DynamicImage::Errors::InvalidSizeOptions] if <tt>crop: true</tt> is given and either dimension is zero
50
83
  #
84
+ # @example
51
85
  # image = Image.find(params[:id]) # 320x200 image
52
86
  # sizing = DynamicImage::ImageSizing.new(image)
53
87
  #
@@ -62,7 +96,6 @@ module DynamicImage
62
96
  #
63
97
  # sizing.fit(Vector2d(500, 500), upscale: true)
64
98
  # # => Vector2d(500.0, 312.5)
65
- #
66
99
  def fit(fit_size, options = {})
67
100
  fit_size = parse_vector(fit_size)
68
101
  require_dimensions!(fit_size) if options[:crop]
@@ -97,9 +130,8 @@ module DynamicImage
97
130
  end
98
131
  end
99
132
 
100
- # Clamps the rectangle defined by +start+ and +size+
101
- # to fit inside 0, 0 and +max_size+. It is assumed
102
- # that +size+ will always be smaller than +max_size+.
133
+ # Clamps the rectangle defined by +start+ and +size+ to fit inside 0, 0 and +max_size+. It is assumed that +size+
134
+ # will always be smaller than +max_size+.
103
135
  #
104
136
  # Returns the start vector.
105
137
  def clamp(start, size, max_size)
@@ -4,13 +4,24 @@ module DynamicImage
4
4
  # = DynamicImage Metadata
5
5
  #
6
6
  # Parses metadata from an image. Accepts a Pathname, IO, or binary string.
7
+ #
8
+ # Every reader returns nil for data that isn't a readable image.
9
+ #
10
+ # @example
11
+ # metadata = DynamicImage::Metadata.new(Pathname.new("image.jpg"))
12
+ # metadata.valid? # => true
13
+ # metadata.content_type # => "image/jpeg"
14
+ # metadata.dimensions # => Vector2d(320, 200)
7
15
  class Metadata
16
+ # @param data [Pathname, IO, String] the image, as a path, an open file or a binary string
8
17
  def initialize(data)
9
18
  @data = data
10
19
  end
11
20
 
12
- # Returns the color space of the image as a string. The result will be one
13
- # of the following: "rgb", "cmyk", "gray".
21
+ # Returns the color space of the image as a string. The result will be one of the following: "rgb", "cmyk",
22
+ # "gray".
23
+ #
24
+ # @return [String, nil]
14
25
  def colorspace
15
26
  return unless valid?
16
27
 
@@ -25,30 +36,61 @@ module DynamicImage
25
36
  end
26
37
 
27
38
  # Returns the content type of the image.
39
+ #
40
+ # @return [String, nil]
28
41
  def content_type
29
42
  reader.format.content_type if valid?
30
43
  end
31
44
 
45
+ # Returns the name of the detected format.
46
+ #
47
+ # @return [String, nil] the format name, such as "JPEG"
32
48
  def format
33
49
  reader.format.name if valid?
34
50
  end
35
51
 
36
- # Returns the dimensions of the image as a vector.
52
+ # Returns the dimensions of the image as a vector. EXIF rotation is applied, so these are the dimensions the
53
+ # image has once normalized.
54
+ #
55
+ # @return [Vector2d, nil]
37
56
  def dimensions
38
57
  Vector2d.new(metadata[:width], metadata[:height]) if valid?
39
58
  end
40
59
 
41
60
  # Returns the width of the image.
61
+ #
62
+ # @return [Integer, nil] the width in pixels
42
63
  def width
43
64
  metadata[:width] if valid?
44
65
  end
45
66
 
46
67
  # Returns the height of the image.
68
+ #
69
+ # @return [Integer, nil] the height in pixels
47
70
  def height
48
71
  metadata[:height] if valid?
49
72
  end
50
73
 
51
- # Returns true if the image is valid.
74
+ # Returns the number of frames. Animated formats can have more than one; everything else has a single frame.
75
+ #
76
+ # @return [Integer, nil] the number of frames
77
+ def frame_count
78
+ metadata[:frame_count] if valid?
79
+ end
80
+
81
+ # Returns true if the image has an alpha channel.
82
+ #
83
+ # This is the presence of the channel, not of actual transparency: an image can carry a fully opaque alpha
84
+ # channel. Reading it costs nothing, where scanning the channel for transparency would mean decoding every pixel.
85
+ #
86
+ # @return [Boolean, nil] true if the image has an alpha channel
87
+ def alpha?
88
+ metadata[:alpha] if valid?
89
+ end
90
+
91
+ # Returns true if the data is a readable image in a supported format.
92
+ #
93
+ # @return [Boolean]
52
94
  def valid?
53
95
  @data && reader.valid_header? && metadata != :invalid
54
96
  end
@@ -67,11 +109,20 @@ module DynamicImage
67
109
  image = reader.read
68
110
  width, height = dimensions_from(image)
69
111
  width, height = height, width if rotated?(image)
70
- { width:, height:, colorspace: image.get("interpretation") }
112
+ { width:, height:,
113
+ colorspace: image.get("interpretation"),
114
+ frame_count: frame_count_from(image),
115
+ alpha: image.has_alpha? }
71
116
  rescue Vips::Error
72
117
  :invalid
73
118
  end
74
119
 
120
+ def frame_count_from(image)
121
+ return 1 unless image.get_fields.include?("n-pages")
122
+
123
+ image.get("n-pages")
124
+ end
125
+
75
126
  def dimensions_from(image)
76
127
  width = image.get("width")
77
128
  height = if image.get_fields.include?("page-height")
@@ -4,17 +4,17 @@ module DynamicImage
4
4
  module Model
5
5
  # = DynamicImage Model Dimensions
6
6
  #
7
+ # Vector accessors over the +real_*+ and +crop_*+ columns.
8
+ #
9
+ # +real_size+ is the size of the stored image. +size+ is the size after cropping, or +real_size+ when no crop
10
+ # is set.
7
11
  module Dimensions
8
- # Returns the crop gravity.
9
- #
10
- # DynamicImage will try to keep the pixel represented by
11
- # crop_gravity as close to the center as possible when cropping
12
- # images.
12
+ # Returns the crop gravity, the focal point kept as close to the center as possible when cropping.
13
13
  #
14
- # It is relative to 0,0 on the original image.
14
+ # The coordinates are relative to 0,0 on the original image. Unless set explicitly, the gravity is the center
15
+ # of the cropped image.
15
16
  #
16
- # Unless crop_gravity has been explicitely set, it defaults to
17
- # the center of the cropped image.
17
+ # @return [Vector2d, nil]
18
18
  def crop_gravity
19
19
  if crop_gravity?
20
20
  vector(crop_gravity_x, crop_gravity_y)
@@ -25,22 +25,30 @@ module DynamicImage
25
25
  end
26
26
  end
27
27
 
28
- # Returns true if crop gravity has been explicitely set.
28
+ # Returns true if the crop gravity has been set explicitly.
29
+ #
30
+ # @return [Boolean]
29
31
  def crop_gravity?
30
32
  crop_gravity_x.present? && crop_gravity_y.present?
31
33
  end
32
34
 
33
35
  # Returns the crop size, or nil if no cropping is applied.
36
+ #
37
+ # @return [Vector2d, nil]
34
38
  def crop_size
35
39
  vector(crop_width, crop_height) if crop_size?
36
40
  end
37
41
 
38
42
  # Returns true if crop size has been set.
43
+ #
44
+ # @return [Boolean]
39
45
  def crop_size?
40
46
  crop_width? && crop_height?
41
47
  end
42
48
 
43
49
  # Returns the crop start if set, or Vector2d(0, 0) if not.
50
+ #
51
+ # @return [Vector2d] the top left corner of the crop
44
52
  def crop_start
45
53
  if crop_start?
46
54
  vector(crop_start_x, crop_start_y)
@@ -50,32 +58,43 @@ module DynamicImage
50
58
  end
51
59
 
52
60
  # Returns true if crop start has been set.
61
+ #
62
+ # @return [Boolean]
53
63
  def crop_start?
54
64
  crop_start_x.present? && crop_start_y.present?
55
65
  end
56
66
 
57
67
  # Returns true if the image is cropped.
68
+ #
69
+ # @return [Boolean]
58
70
  def cropped?
59
71
  crop_size? && real_size? && crop_size != real_size
60
72
  end
61
73
 
62
74
  # Returns the real size of the image, without any cropping applied.
75
+ #
76
+ # @return [Vector2d, nil] the size of the stored image
63
77
  def real_size
64
78
  vector(real_width, real_height) if real_size?
65
79
  end
66
80
 
67
- # Returns true if the size has been set.
81
+ # Returns true if the real size has been set.
82
+ #
83
+ # @return [Boolean]
68
84
  def real_size?
69
85
  real_width? && real_height?
70
86
  end
71
87
 
72
- # Returns the cropped size if the image has been cropped. If not,
73
- # it returns the actual size.
88
+ # Returns the cropped size, or the real size if the image isn't cropped.
89
+ #
90
+ # @return [Vector2d, nil] the visible size of the image
74
91
  def size
75
92
  crop_size || real_size
76
93
  end
77
94
 
78
- # Returns true if the image has size set.
95
+ # Returns true if the image has a size.
96
+ #
97
+ # @return [Boolean]
79
98
  def size?
80
99
  size ? true : false
81
100
  end
@@ -4,20 +4,32 @@ module DynamicImage
4
4
  module Model
5
5
  # = DynamicImage Model Transformations
6
6
  #
7
+ # Transformations that rewrite the stored file. Per-request processing never touches it.
8
+ #
9
+ # Both methods replace the data and update the stored dimensions, adjusting the crop to match. Neither saves
10
+ # the record.
7
11
  module Transformations
8
- # Resizes the image
12
+ # Resizes the image, replacing the stored file with a smaller one. The crop is scaled along with it.
13
+ #
14
+ # @param max_size [Vector2d] the size to scale down to
15
+ # @return [self]
9
16
  def resize(max_size)
10
17
  transform_image do |image|
11
- new_size = real_size.constrain_both(max_size)
12
- scale = new_size.x / real_size.x
13
- crop_attributes.each do |attr|
14
- self[attr] = self[attr] * scale if self[attr]
15
- end
16
- image.resize(new_size)
18
+ resized = image.resize(real_size.constrain_both(max_size))
19
+ scale_crop(resized.size)
20
+ resized
17
21
  end
18
22
  end
19
23
 
20
- # Rotates the image
24
+ # Rotates the image, taking the crop and crop gravity with it.
25
+ #
26
+ # @param degrees [Integer] the angle, which must be a multiple of 90. Rotating by 0 is a no-op.
27
+ # @return [self]
28
+ # @raise [DynamicImage::Errors::InvalidTransformation] if the angle isn't a multiple of 90
29
+ #
30
+ # @example
31
+ # image.rotate(90)
32
+ # image.save
21
33
  def rotate(degrees = 90)
22
34
  degrees = degrees.to_i % 360
23
35
 
@@ -36,9 +48,33 @@ module DynamicImage
36
48
 
37
49
  private
38
50
 
39
- def crop_attributes
40
- %i[crop_width crop_height crop_start_x crop_start_y
41
- crop_gravity_x crop_gravity_y]
51
+ def scale_crop(new_size)
52
+ scale = new_size.to_f_vector / real_size
53
+
54
+ scale_crop_start(scale, new_size)
55
+ scale_crop_size(scale, new_size)
56
+ scale_crop_gravity(scale, new_size)
57
+ end
58
+
59
+ def scale_crop_start(scale, new_size)
60
+ return unless crop_start?
61
+
62
+ self.crop_start_x, self.crop_start_y =
63
+ (crop_start * scale).floor.clamp(0, new_size - 1).to_a
64
+ end
65
+
66
+ def scale_crop_size(scale, new_size)
67
+ return unless crop_size?
68
+
69
+ self.crop_width, self.crop_height =
70
+ (crop_size * scale).round.clamp(1, new_size - crop_start).to_a
71
+ end
72
+
73
+ def scale_crop_gravity(scale, new_size)
74
+ return unless crop_gravity?
75
+
76
+ self.crop_gravity_x, self.crop_gravity_y =
77
+ (crop_gravity * scale).round.clamp(1, new_size).to_a
42
78
  end
43
79
 
44
80
  def rotate_dimensions(width, height, degrees)
@@ -76,7 +112,9 @@ module DynamicImage
76
112
 
77
113
  def transform_image(&block)
78
114
  read_image_metadata if data_changed?
79
- self.data = block.call(DynamicImage::ImageProcessor.new(Pathname(data_file_path))).read
115
+ self.data = with_data_file do |path|
116
+ block.call(DynamicImage::ImageProcessor.new(path)).read
117
+ end
80
118
  read_image_metadata
81
119
  self
82
120
  end
@@ -4,9 +4,10 @@ module DynamicImage
4
4
  module Model
5
5
  # = DynamicImage Model Validations
6
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.
7
+ # Validates the image attributes. +DynamicImage::Model+ manages all of them, so these mostly enforce integrity.
8
+ #
9
+ # The two that can fail on ordinary input are the image itself, which is rejected if the data isn't readable in
10
+ # a supported format, and the crop, which has to fit within the image.
10
11
  module Validations
11
12
  extend ActiveSupport::Concern
12
13
 
@@ -30,9 +31,7 @@ module DynamicImage
30
31
  length: { maximum: 255 }
31
32
 
32
33
  validates :real_width, :real_height,
33
- numericality: { greater_than: 0, only_integer: true }
34
-
35
- validates :real_width, :real_height,
34
+ presence: true,
36
35
  numericality: { greater_than: 0, only_integer: true }
37
36
 
38
37
  validates :crop_width, :crop_height,
@@ -40,8 +39,10 @@ module DynamicImage
40
39
  numericality: { greater_than: 0, only_integer: true },
41
40
  allow_nil: true
42
41
 
43
- validates :real_width, :real_height,
44
- presence: true
42
+ validates :crop_start_x, :crop_start_y,
43
+ numericality: { greater_than_or_equal_to: 0,
44
+ only_integer: true },
45
+ allow_nil: true
45
46
 
46
47
  validates :crop_width, presence: true, if: :crop_height?
47
48
  validates :crop_height, presence: true, if: :crop_width?
@@ -57,10 +58,16 @@ module DynamicImage
57
58
  end
58
59
 
59
60
  module ClassMethods
61
+ # Colorspaces an image is allowed to be in.
62
+ #
63
+ # @return [Array<String>] the colorspace names
60
64
  def allowed_colorspaces
61
65
  %w[rgb cmyk gray]
62
66
  end
63
67
 
68
+ # Content types an image is allowed to have, taken from the registered formats.
69
+ #
70
+ # @return [Array<String>]
64
71
  def allowed_content_types
65
72
  DynamicImage::Format.content_types
66
73
  end
@@ -4,9 +4,12 @@ module DynamicImage
4
4
  module Model
5
5
  # = DynamicImage Model Variants
6
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.
7
+ # Associates the image with its cached renderings.
8
+ #
9
+ # Each processed size is stored as a {DynamicImage::Variant}, so cropping and resizing is done once. Variants
10
+ # are destroyed with the image, and cleared whenever its data changes.
11
+ #
12
+ # @see DynamicImage::ProcessedImage
10
13
  module Variants
11
14
  extend ActiveSupport::Concern
12
15