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
@@ -8,8 +8,8 @@ require "dynamic_image/model/variants"
8
8
  module DynamicImage
9
9
  # = DynamicImage Model
10
10
  #
11
- # ActiveModel extension for the model holding image data. It assumes your
12
- # database table has at least the following attributes:
11
+ # ActiveModel extension for the model holding image data. The table needs at least the attributes in
12
+ # {DynamicImage::Schema::ATTRIBUTES}:
13
13
  #
14
14
  # create_table :images do |t|
15
15
  # t.string :content_hash
@@ -24,7 +24,7 @@ module DynamicImage
24
24
  # t.timestamps
25
25
  # end
26
26
  #
27
- # To use it, simply include it in your model:
27
+ # Include it in your model:
28
28
  #
29
29
  # class Image < ActiveRecord::Base
30
30
  # include DynamicImage::Model
@@ -32,23 +32,18 @@ module DynamicImage
32
32
  #
33
33
  # == Usage
34
34
  #
35
- # To save an image, simply assign to the +file+ attribute.
35
+ # To save an image, assign to the +file+ attribute. The image is parsed and validated when the record is saved.
36
36
  #
37
37
  # image = Image.create(file: params.permit(:file))
38
38
  #
39
- # This will automatically parse and validate the image when your record is
40
- # saved.
41
- #
42
- # To read back the image data, access the +data+ attribute. This will lazily
43
- # load the data from the store.
39
+ # To read back the image data, access the +data+ attribute. The data is loaded lazily from the store.
44
40
  #
45
41
  # data = image.data
46
42
  #
47
43
  # == Cropping
48
44
  #
49
- # Images can be pre-cropped by setting +crop_width+, +crop_height+,
50
- # +crop_start_x+ and +crop_start_y+. The crop dimensions cannot exceed the
51
- # image size.
45
+ # Images can be pre-cropped by setting +crop_width+, +crop_height+, +crop_start_x+ and +crop_start_y+. The crop
46
+ # dimensions cannot exceed the image size.
52
47
  #
53
48
  # image.update(
54
49
  # crop_start_x: 15, crop_start_y: 20,
@@ -56,10 +51,14 @@ module DynamicImage
56
51
  # )
57
52
  # image.size # => Vector2d(300, 200)
58
53
  #
59
- # By default, images will be cropped from the center. You can control this
60
- # by setting +crop_gravity_x+ and +crop_gravity_y+. DynamicImage will make
61
- # sure the pixel referred to by these coordinates are present in the cropped
62
- # image, and as close to the center as possible without zooming in.
54
+ # By default, images will be cropped from the center. You can control this by setting +crop_gravity_x+ and
55
+ # +crop_gravity_y+. DynamicImage will make sure the pixel referred to by these coordinates are present in the
56
+ # cropped image, and as close to the center as possible without zooming in.
57
+ #
58
+ # @see DynamicImage::Model::Dimensions
59
+ # @see DynamicImage::Model::Transformations
60
+ # @see DynamicImage::Model::Validations
61
+ # @see DynamicImage::Model::Variants
63
62
  module Model
64
63
  extend ActiveSupport::Concern
65
64
  include Dis::Model
@@ -72,33 +71,62 @@ module DynamicImage
72
71
  before_validation :read_image_metadata, if: :data_changed?
73
72
  end
74
73
 
75
- # Returns true if the image is in the CMYK colorspace
74
+ # Returns true if the image holds more than one frame.
75
+ #
76
+ # Images stored before +frame_count+ existed have none, and are taken to be still.
77
+ #
78
+ # @return [Boolean]
79
+ def animated?
80
+ has_attribute?(:frame_count) && frame_count.to_i > 1
81
+ end
82
+
83
+ # Returns the alt text for the image, or nil if none has been set.
84
+ #
85
+ # DynamicImage doesn't add this column by default. Either create it yourself or override the method to
86
+ # provide your own implementation.
87
+ #
88
+ # Note that there is a distinction between nil and a blank string. <tt>alt=""</tt> means the image is
89
+ # purely decorative, while a missing attribute is an accessibility defect.
90
+ #
91
+ # @return [String, nil]
92
+ # @see DynamicImage::Helper#dynamic_image_tag
93
+ def alt_text
94
+ self[:alt_text] if has_attribute?(:alt_text)
95
+ end
96
+
97
+ # Returns true if the image is in the CMYK colorspace.
98
+ #
99
+ # @return [Boolean]
76
100
  def cmyk?
77
101
  colorspace == "cmyk"
78
102
  end
79
103
 
80
- # Returns true if the image is in the grayscale colorspace
104
+ # Returns true if the image is in the grayscale colorspace.
105
+ #
106
+ # @return [Boolean]
81
107
  def gray?
82
108
  colorspace == "gray"
83
109
  end
84
110
 
85
- # Returns true if the image is in the RGB colorspace
111
+ # Returns true if the image is in the RGB colorspace.
112
+ #
113
+ # @return [Boolean]
86
114
  def rgb?
87
115
  colorspace == "rgb"
88
116
  end
89
117
 
90
- # Finds a web safe content type. GIF, JPEG and PNG images are allowed,
91
- # any other formats should be converted to JPEG.
118
+ # Finds a web safe content type, negotiated against {DynamicImage.default_formats}.
119
+ #
120
+ # @return [String]
121
+ # @see DynamicImage::FormatNegotiator
92
122
  def safe_content_type
93
- if safe_content_types.include?(content_type)
94
- content_type
95
- else
96
- "image/jpeg"
97
- end
123
+ DynamicImage::FormatNegotiator
124
+ .new(self).negotiate(DynamicImage.default_formats).content_type
98
125
  end
99
126
 
100
- # Includes a timestamp fingerprint in the URL param, so
101
- # that rendered images can be cached indefinitely.
127
+ # Includes a timestamp fingerprint in the URL param, so rendered images can be cached indefinitely.
128
+ #
129
+ # @return [String] the id and an +updated_at+ fingerprint
102
130
  def to_param
103
131
  [id, updated_at.utc.to_fs(cache_timestamp_format)].join("-")
104
132
  end
@@ -106,25 +134,26 @@ module DynamicImage
106
134
  private
107
135
 
108
136
  def read_image_metadata
109
- metadata = DynamicImage::Metadata.new(Pathname(data_file_path))
110
137
  @valid_image = false
138
+ with_data_file do |path|
139
+ apply_image_metadata(DynamicImage::Metadata.new(path))
140
+ end
141
+ end
142
+
143
+ def apply_image_metadata(metadata)
111
144
  return unless metadata.valid?
112
145
 
113
146
  self.colorspace = metadata.colorspace
114
147
  self.real_width = metadata.width
115
148
  self.real_height = metadata.height
116
149
  self.content_type = metadata.content_type
150
+ self.frame_count = metadata.frame_count if has_attribute?(:frame_count)
151
+ self.alpha = metadata.alpha? if has_attribute?(:alpha)
117
152
  @valid_image = true
118
153
  end
119
154
 
120
155
  def valid_image?
121
156
  @valid_image ? true : false
122
157
  end
123
-
124
- def safe_content_types
125
- %w[image/png
126
- image/gif
127
- image/jpeg]
128
- end
129
158
  end
130
159
  end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DynamicImage
4
+ class Picture
5
+ # = DynamicImage Picture Format Policy
6
+ #
7
+ # Decides what a responsive image is rendered in. The <tt>source</tt> gets the best format available for
8
+ # output, transcoded unconditionally. The <tt>img</tt> gets one of {DynamicImage::COMPATIBLE_FORMATS}.
9
+ #
10
+ # @see DynamicImage::FormatNegotiator
11
+ class FormatPolicy
12
+ # The format the candidates are rendered in when the view doesn't ask for one.
13
+ DEFAULT_SOURCE_FORMAT = :webp
14
+
15
+ # @!attribute [r] record
16
+ # @return [DynamicImage::Model]
17
+ # @!attribute [r] requested
18
+ # @return [Symbol, Array<Symbol>, nil]
19
+ attr_reader :record, :requested
20
+
21
+ # @param record [DynamicImage::Model] the image
22
+ # @param requested [Symbol, Array<Symbol>, nil] the format the candidates are rendered in. A symbol forces
23
+ # that format, an array is negotiated, nil takes the default.
24
+ def initialize(record, requested = nil)
25
+ @record = record
26
+ @requested = requested
27
+ end
28
+
29
+ # The format the candidates are rendered in.
30
+ #
31
+ # @return [DynamicImage::Format]
32
+ def source
33
+ @source ||= resolve(requested || default_source)
34
+ end
35
+
36
+ # The format the fallback image is rendered in.
37
+ #
38
+ # @return [DynamicImage::Format]
39
+ def fallback
40
+ @fallback ||= resolve(fallback_formats)
41
+ end
42
+
43
+ private
44
+
45
+ def default_source
46
+ record.animated? ? fallback_formats : DEFAULT_SOURCE_FORMAT
47
+ end
48
+
49
+ def resolve(value)
50
+ return negotiate(value) if value.is_a?(Array)
51
+
52
+ DynamicImage::Format.find(value) ||
53
+ raise(ArgumentError, "unknown format: #{value.inspect}")
54
+ end
55
+
56
+ def negotiate(formats)
57
+ DynamicImage::FormatNegotiator.new(record).negotiate(formats)
58
+ end
59
+
60
+ # An animated image keeps its own format if it can.
61
+ def fallback_formats
62
+ return DynamicImage::COMPATIBLE_FORMATS unless record.animated?
63
+
64
+ [stored_format_name, *DynamicImage::COMPATIBLE_FORMATS].compact
65
+ end
66
+
67
+ def stored_format_name
68
+ DynamicImage::Format.content_type(record.content_type)&.name
69
+ end
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,215 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DynamicImage
4
+ # = DynamicImage Picture
5
+ #
6
+ # Everything needed to render an image responsively: the candidate widths, the signed URLs behind them, and the
7
+ # fallback the <tt>img</tt> points at.
8
+ #
9
+ # Sizing is expressed as an optional ratio instead of a size.
10
+ #
11
+ # @example
12
+ # picture = DynamicImage::Picture.new(self, image, ratio: "16:9")
13
+ # picture.srcset # => "/images/… 420w, /images/… 590w, …"
14
+ # picture.src # => "/images/…/1200x675/…jpg"
15
+ #
16
+ # @see DynamicImage::Breakpoints for how the widths are chosen
17
+ # @see DynamicImage::Helper#dynamic_picture_tag
18
+ class Picture
19
+ # The options this class consumes. Anything else is passed on to the router.
20
+ OPTIONS = %i[ratio sizes breakpoints step fallback_width format uncropped].freeze
21
+
22
+ # @!attribute [r] record
23
+ # @return [DynamicImage::Model]
24
+ # @!attribute [r] ratio
25
+ # @return [Float, nil]
26
+ # @!attribute [r] sizes
27
+ # @return [String] the +sizes+ attribute
28
+ # @!attribute [r] breakpoints
29
+ # @return [DynamicImage::Breakpoints] the candidate widths
30
+ # @!attribute [r] fallback_width
31
+ # @return [Integer] the width asked for the fallback image
32
+ attr_reader :template, :record_or_array, :ratio, :sizes, :breakpoints, :fallback_width, :url_options
33
+
34
+ # @param template [ActionView::Base] the view context, for routing
35
+ # @param record_or_array [DynamicImage::Model, Array] the record, or an array of records for a nested route
36
+ # @param options [Hash]
37
+ # @option options [Numeric, Vector2d, String, nil] :ratio The aspect ratio to crop to, as a number, a vector, or a
38
+ # string like <tt>"16:9"</tt>. Implies cropping. Omit for the image's own.
39
+ # @option options [String] :sizes The +sizes+ attribute
40
+ # @option options [Range, Array<Integer>, Integer] :breakpoints The widths to offer, overriding
41
+ # {DynamicImage.default_breakpoints}
42
+ # @option options [Numeric] :step The step between breakpoints, overriding {DynamicImage.breakpoint_step}
43
+ # @option options [Integer] :fallback_width The width to ask for the fallback image, overriding
44
+ # {DynamicImage.picture_fallback_width}
45
+ # @option options [Symbol, Array<Symbol>] :format The format the candidates are rendered in. A symbol forces
46
+ # that format, an array is negotiated. Defaults to WebP, or the image's own format if it is animated.
47
+ # @option options [Boolean] :uncropped Size against the whole image, ignoring any pre-cropping. Defaults to
48
+ # whether the URL points at the +uncropped+ action.
49
+ #
50
+ # Any options supported by +polymorphic_url+ are also accepted, and passed on to the router.
51
+ def initialize(template, record_or_array, options = {})
52
+ options = options.symbolize_keys
53
+ @template = template
54
+ @record_or_array = Array(record_or_array)
55
+ @ratio = DynamicImage::Ratio.parse(options[:ratio])
56
+ @sizes = options[:sizes] || "100vw"
57
+ @requested_format = options[:format]
58
+ @fallback_width = fallback_width_from(options)
59
+ @breakpoints = breakpoints_from(options)
60
+ @url_options = options.except(*OPTIONS)
61
+ @uncropped = options.fetch(:uncropped) { url_options[:action].to_s == "uncropped" }
62
+ end
63
+
64
+ # Returns true if the image is cropped, which it is whenever a ratio is given.
65
+ #
66
+ # @return [Boolean]
67
+ def crop?
68
+ !ratio.nil?
69
+ end
70
+
71
+ # The widest the image can be rendered at: its own width, or the width of the largest crop matching the ratio.
72
+ #
73
+ # @return [Integer]
74
+ def available_width
75
+ @available_width ||= sizing.available_width(ratio)
76
+ end
77
+
78
+ # The candidate widths, smallest first.
79
+ #
80
+ # @return [Array<Integer>]
81
+ def widths
82
+ @widths ||= breakpoints.widths(available_width)
83
+ end
84
+
85
+ # Every candidate, as the URL and the size it is actually rendered at.
86
+ #
87
+ # @return [Array<Hash>]
88
+ def variants
89
+ @variants ||= widths.map { |width| variant(width) }
90
+ end
91
+
92
+ # The +srcset+ attribute for the candidates, or nil if there are none. It belongs on the <tt>source</tt> when
93
+ # {#sources} has one, and on the <tt>img</tt> when it doesn't.
94
+ #
95
+ # @return [String, nil]
96
+ def srcset
97
+ return if variants.empty?
98
+
99
+ variants.map { |v| "#{v[:url]} #{v[:width]}w" }.join(", ")
100
+ end
101
+
102
+ # The sources to render, as +type+ and +srcset+ pairs.
103
+ #
104
+ # Empty when the candidates are in the same format as the fallback; {#srcset} goes on the <tt>img</tt> instead.
105
+ # That is what happens to an animated image, which keeps its own format.
106
+ #
107
+ # @return [Array<Hash>]
108
+ def sources
109
+ candidates = srcset
110
+ return [] if candidates.nil? || format == fallback_format
111
+
112
+ [{ type:, srcset: candidates }]
113
+ end
114
+
115
+ # The content type the candidates are rendered as, for the +type+ attribute.
116
+ #
117
+ # @return [String]
118
+ def type
119
+ format.content_type
120
+ end
121
+
122
+ # The format the fallback image is rendered in, negotiated from {DynamicImage::COMPATIBLE_FORMATS}.
123
+ #
124
+ # @return [DynamicImage::Format]
125
+ def fallback_format
126
+ format_policy.fallback
127
+ end
128
+
129
+ # The size asked for the fallback image, as a <tt>"{width}x{height}"</tt> string.
130
+ #
131
+ # @return [String]
132
+ def fallback_size
133
+ @fallback_size ||= size_for(fallback_width)
134
+ end
135
+
136
+ # The size the fallback image is actually rendered at. Smaller than {#fallback_size} when the image is.
137
+ #
138
+ # @return [Vector2d]
139
+ def dimensions
140
+ @dimensions ||= sizing.fit(fallback_size, crop: crop?).floor
141
+ end
142
+
143
+ # The URL for the fallback image.
144
+ #
145
+ # @return [String]
146
+ def src
147
+ url_for(fallback_size, fallback_format)
148
+ end
149
+
150
+ # The width of the fallback image.
151
+ #
152
+ # @return [Integer]
153
+ def width
154
+ dimensions.x.to_i
155
+ end
156
+
157
+ # The height of the fallback image.
158
+ #
159
+ # @return [Integer]
160
+ def height
161
+ dimensions.y.to_i
162
+ end
163
+
164
+ private
165
+
166
+ def fallback_width_from(options)
167
+ (options[:fallback_width] || DynamicImage.picture_fallback_width).to_i
168
+ end
169
+
170
+ def breakpoints_from(options)
171
+ DynamicImage::Breakpoints.new(options[:breakpoints], step: options[:step])
172
+ end
173
+
174
+ def record
175
+ record_or_array.last
176
+ end
177
+
178
+ def sizing
179
+ @sizing ||= DynamicImage::ImageSizing.new(record, uncropped: @uncropped)
180
+ end
181
+
182
+ def variant(width)
183
+ requested = size_for(width)
184
+ size = sizing.fit(requested, crop: crop?).floor
185
+ # {#url_for} fits the size it is given. +size+ has been floored to whole pixels, so it is no longer exactly
186
+ # proportional and would fit smaller a second time. Pass the request instead.
187
+ { url: url_for(requested, format),
188
+ width: size.x.to_i,
189
+ height: size.y.to_i }
190
+ end
191
+
192
+ def size_for(width)
193
+ return "#{width}x" unless ratio
194
+
195
+ "#{width}x#{(width / ratio).round}"
196
+ end
197
+
198
+ def url_for(requested_size, image_format)
199
+ template.dynamic_image_path(record_or_array,
200
+ url_options.merge(
201
+ size: requested_size,
202
+ crop: crop?,
203
+ format: image_format.mime_type.to_sym
204
+ ))
205
+ end
206
+
207
+ def format
208
+ format_policy.source
209
+ end
210
+
211
+ def format_policy
212
+ @format_policy ||= FormatPolicy.new(record, @requested_format)
213
+ end
214
+ end
215
+ end
@@ -3,11 +3,22 @@
3
3
  module DynamicImage
4
4
  # = DynamicImage Processed Image
5
5
  #
6
- # Handles all processing of images. Takes an instance of
7
- # +DynamicImage::Model+ as argument.
6
+ # Crops, resizes and converts images, and reads back the processed data.
7
+ #
8
+ # Each processed size is stored as a variant, so subsequent calls for the same size are cheap.
9
+ #
10
+ # @example
11
+ # size = DynamicImage::ImageSizing.new(image).fit("800x800")
12
+ # data = DynamicImage::ProcessedImage.new(image, format: :jpg)
13
+ # .cropped_and_resized(size)
8
14
  class ProcessedImage
15
+ # @return [DynamicImage::Model]
9
16
  attr_reader :record
10
17
 
18
+ # @param record [DynamicImage::Model] the image to process
19
+ # @param options [Hash]
20
+ # @option options [Boolean] :uncropped Ignore any crop stored on the record
21
+ # @option options [Symbol, String] :format Format to convert to. Defaults to the format of the stored image.
11
22
  def initialize(record, options = {})
12
23
  @record = record
13
24
  @uncropped = options[:uncropped] ? true : false
@@ -15,28 +26,40 @@ module DynamicImage
15
26
  @format_name = "JPEG" if defined?(@format_name) && @format_name == "JPG"
16
27
  end
17
28
 
18
- # Crops and resizes the image. Normalization is performed as well.
29
+ # Crops, resizes and normalizes the image.
19
30
  #
20
- # ==== Example
31
+ # @param size [Vector2d] the size to render, in pixels
32
+ # @return [String] the image data as a binary string
33
+ # @raise [DynamicImage::Errors::InvalidImage] if the record isn't a valid image, or if the stored data can't be
34
+ # processed
21
35
  #
36
+ # @example
22
37
  # processed = DynamicImage::ProcessedImage.new(image)
23
38
  # image_data = processed.cropped_and_resized(Vector2d.new(200, 200))
24
- #
25
- # Returns a binary string.
26
39
  def cropped_and_resized(size)
27
40
  return crop_and_resize(size) unless record.persisted?
28
41
 
29
42
  find_or_create_variant(size).data
30
43
  end
31
44
 
32
- # Find or create a variant with the given size.
45
+ # Returns the variant for the given size, creating it if it doesn't already exist.
46
+ #
47
+ # @param size [Vector2d] the size to render, in pixels
48
+ # @return [DynamicImage::Variant]
49
+ # @raise [DynamicImage::Errors::InvalidImage] if the record isn't a valid image, or if the stored data can't be
50
+ # processed
33
51
  def find_or_create_variant(size)
34
52
  find_variant(size) || create_variant(size)
35
53
  rescue ActiveRecord::RecordNotUnique
36
54
  find_variant(size)
37
55
  end
38
56
 
39
- # Find a variant with the given size.
57
+ # Returns the variant for the given size, if one exists.
58
+ #
59
+ # Variants whose data has gone missing from storage are destroyed and treated as absent.
60
+ #
61
+ # @param size [Vector2d] the size to look for, in pixels
62
+ # @return [DynamicImage::Variant, nil] the variant, if one exists
40
63
  def find_variant(size)
41
64
  return nil unless record.persisted?
42
65
 
@@ -51,8 +74,10 @@ module DynamicImage
51
74
  end
52
75
  end
53
76
 
54
- # Find or create a variant for the given size, returning the variant
55
- # record (not data). Returns nil if the record is not persisted.
77
+ # Same as {#cropped_and_resized}, but returns the variant record instead of its data.
78
+ #
79
+ # @param size [Vector2d] the size to render, in pixels
80
+ # @return [DynamicImage::Variant, nil] the variant, or nil if the record isn't persisted
56
81
  def variant_for(size)
57
82
  return nil unless record.persisted?
58
83
 
@@ -61,6 +86,9 @@ module DynamicImage
61
86
  find_variant(size)
62
87
  end
63
88
 
89
+ # The format the image is rendered in. Defaults to the format of the stored image.
90
+ #
91
+ # @return [DynamicImage::Format]
64
92
  def format
65
93
  DynamicImage::Format.find(@format_name) || record_format
66
94
  end
@@ -73,18 +101,26 @@ module DynamicImage
73
101
  # * Optimizes GIFs
74
102
  # * Performs format conversion if the requested format is different
75
103
  #
76
- # ==== Example
104
+ # @yield [image] an optional block to transform the image before it is written out
105
+ # @yieldparam image [DynamicImage::ImageProcessor] the processor
106
+ # @yieldreturn [DynamicImage::ImageProcessor] the transformed processor
107
+ # @return [String] the image data as a binary string
108
+ # @raise [DynamicImage::Errors::InvalidImage] if the record isn't a valid image, or if the stored data can't be
109
+ # processed
77
110
  #
78
- # processed = DynamicImage::ProcessedImage.new(image, :jpeg)
111
+ # @example
112
+ # processed = DynamicImage::ProcessedImage.new(image, format: :jpeg)
79
113
  # jpg_data = processed.normalized
80
- #
81
- # Returns a binary string.
82
114
  def normalized
83
115
  require_valid_image!
84
116
 
85
- image = DynamicImage::ImageProcessor.new(Pathname(record.data_file_path))
86
- image = yield(image) if block_given?
87
- image.convert(format).read
117
+ record.with_data_file do |path|
118
+ image = DynamicImage::ImageProcessor.new(path)
119
+ image = yield(image) if block_given?
120
+ image.convert(format).read
121
+ end
122
+ rescue Vips::Error => e
123
+ raise DynamicImage::Errors::InvalidImage, e.message
88
124
  end
89
125
 
90
126
  private
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DynamicImage
4
+ # = DynamicImage Ratio
5
+ #
6
+ # Coerces an aspect ratio into a float. Strings, rationals, floats and vectors are all accepted.
7
+ #
8
+ # @example
9
+ # DynamicImage::Ratio.parse("16:9") # => 1.7777..
10
+ # DynamicImage::Ratio.parse("16x9") # => 1.7777..
11
+ # DynamicImage::Ratio.parse(Rational(16, 9)) # => 1.7777..
12
+ # DynamicImage::Ratio.parse(Vector2d(16, 9)) # => 1.7777..
13
+ module Ratio
14
+ class << self
15
+ # Returns the ratio as width divided by height.
16
+ #
17
+ # @param value [Numeric, Vector2d, String, nil] the ratio, as a number, a vector, or a string written with
18
+ # <tt>:</tt>, <tt>x</tt> or <tt>/</tt> between the two sides
19
+ # @return [Float, nil]
20
+ # @raise [ArgumentError] if the value isn't a usable ratio
21
+ def parse(value)
22
+ return if value.nil?
23
+
24
+ ratio = coerce(value)
25
+ unless ratio.is_a?(Float) && ratio.positive? && ratio.finite?
26
+ raise ArgumentError, "invalid ratio: #{value.inspect}"
27
+ end
28
+
29
+ ratio
30
+ end
31
+
32
+ private
33
+
34
+ def coerce(value)
35
+ case value
36
+ when Numeric then value.to_f
37
+ when Vector2d then value.x.to_f / value.y
38
+ when String then value.split(%r{[:x/]}).map(&:to_f).reduce(:/)
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end
@@ -3,12 +3,23 @@
3
3
  module DynamicImage
4
4
  # = DynamicImage Routing
5
5
  #
6
- # Extends +ActionDispatch::Routing::Mapper+ and provides a shortcut for
7
- # defining routes for +DynamicImage::Controller+.
6
+ # Extends +ActionDispatch::Routing::Mapper+ and provides a shortcut for defining routes for
7
+ # +DynamicImage::Controller+.
8
8
  module Routing
9
- # Declares an image resource.
9
+ # Declares an image resource, routing +show+ along with the +uncropped+, +original+ and +download+ member actions.
10
10
  #
11
+ # The default path includes the digest and an optional size. Pass +:path+ to change it if the resource collides
12
+ # with a directory in +public+.
13
+ #
14
+ # @param resource_name [Symbol, String] the resource
15
+ # @param options [Hash] options passed to +resources+, merged over the defaults
16
+ # @return [void]
17
+ #
18
+ # @example
11
19
  # image_resources :avatars
20
+ #
21
+ # @example Keeping clear of public/images
22
+ # image_resources :images, path: "dynamic_images/:digest(/:size)"
12
23
  def image_resources(resource_name, options = {})
13
24
  options = {
14
25
  path: "#{resource_name}/:digest(/:size)",