dynamic_image 3.1.3 → 3.4.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: dd3ffefb6b55bdcc4893536be40a12711ee0cab2de28b847c64c8a969b268197
4
- data.tar.gz: 28ee34454f455efafb4204de4a4a82fa35da6f84f0d5aaacb96948f3bede3b8c
3
+ metadata.gz: 3053f66d3a18e5e0ebc6cc6ab5efe548d8fe296e80708fbde264d808184b54cf
4
+ data.tar.gz: 45d1d51fa23b0501d3af442abcac9d3bbc13e943ebe89343a2746cdda92e979d
5
5
  SHA512:
6
- metadata.gz: 0b2e45b8a4343a878d40ebc7a95fec1214b4b88249bdd4b3fa643fc2ff53d8f4b796a95dfb058f1209424732c2db8d00911b2a3b3d668eadd2e7bc0baf7ae1aa
7
- data.tar.gz: ca1e46c071da033e84438d39ece4d1cba5a302ef91ac1075f577045de9b0ed2e43f062e884f15e40c4c14dd7954846a0c3d4c69809657c5a5b460298b3defce0
6
+ metadata.gz: aaa7011dc360af36d61ec8435a11a37704a7062271edacea578c0f8c5d5322a913abebf3d11840702153d22f5f1308fcda74764ed120f4effb0fd8427f91febc
7
+ data.tar.gz: 96d997eb20a89e071e8dbcf6e4aee0289ecb11def3a089f03f1a87235a897b4c0b5a0b233d2d54792f729ea83bef3e90fa46e52e8e296d4e74a0b073bca734e8
data/README.md CHANGED
@@ -357,7 +357,9 @@ image.save
357
357
  `rotate` turns the image, taking the crop along with it. The angle must
358
358
  be a multiple of 90.
359
359
 
360
- `resize` scales the stored file down and replaces the original.
360
+ `resize` scales the stored file to fit within the given size and replaces
361
+ the original. Note that it scales up as well as down: an image smaller
362
+ than the given size will be upscaled.
361
363
 
362
364
  ### Outside of views
363
365
 
@@ -456,10 +458,12 @@ and the [changelog](CHANGELOG.md) for release notes.
456
458
 
457
459
  ## Contributing
458
460
 
459
- Bug reports and pull requests are welcome on
460
- [GitHub](https://github.com/elektronaut/dynamic_image). See
461
- [CONTRIBUTING.md](CONTRIBUTING.md) for how to run the tests and how
462
- commits are formatted, and note that this project ships with a
461
+ Bug reports, feature requests, and pull requests are welcome. Read
462
+ [CONTRIBUTING.md](CONTRIBUTING.md) first. Report security vulnerabilities
463
+ privately as described in [SECURITY.md](SECURITY.md).
464
+
465
+ CONTRIBUTING.md also covers how to run the tests and how commits are
466
+ formatted. Everyone participating is expected to follow the
463
467
  [code of conduct](CODE_OF_CONDUCT.md).
464
468
 
465
469
  ## License
@@ -61,15 +61,15 @@ module DynamicImage
61
61
  private
62
62
 
63
63
  def fixed(available)
64
- widths = spec.map(&:to_i).select { _1 <= available }.uniq.sort
64
+ widths = spec.map(&:to_i).select { it <= available }.uniq.sort
65
65
  widths.any? ? widths : [available]
66
66
  end
67
67
 
68
68
  def stepped(available)
69
69
  top = [available, spec.end || available].min
70
- rest = Enumerator.produce(top / step) { _1 / step }
71
- .take_while { _1 >= spec.begin }
72
- .map { snap(_1) }
70
+ rest = Enumerator.produce(top / step) { it / step }
71
+ .take_while { it >= spec.begin }
72
+ .map { snap(it) }
73
73
 
74
74
  [top, *rest].reverse.uniq
75
75
  end
@@ -37,6 +37,7 @@ module DynamicImage
37
37
  included do
38
38
  before_action :verify_signed_params
39
39
  before_action :find_record
40
+ before_action :redirect_to_canonical_id
40
41
  after_action :cache_expiration_header
41
42
  helper_method :requested_size
42
43
  end
@@ -97,6 +98,15 @@ module DynamicImage
97
98
  @record = model.find(params[:id])
98
99
  end
99
100
 
101
+ # Redirects permanently to the same URL with the record's +to_param+ as the id, unless the URL already uses it.
102
+ def redirect_to_canonical_id
103
+ return if params[:id] == @record.to_param
104
+
105
+ expires_now
106
+ redirect_to url_for(params.permit(:digest, :size, :format).merge(id: @record.to_param, only_path: true)),
107
+ status: :moved_permanently
108
+ end
109
+
100
110
  # The filename the image is served as, the record's own filename with the extension of the rendered format.
101
111
  # Override to name downloads something else.
102
112
  def filename(format = nil)
@@ -28,10 +28,13 @@ module DynamicImage
28
28
  # @see DynamicImage::DigestVerifier#verify
29
29
  class InvalidSignature < DynamicImage::Errors::Error; end
30
30
 
31
- # Raised when cropping is requested without both dimensions, as in <tt>size: "400x", crop: true</tt>. There is no
32
- # way to crop to an exact size when one of them is unknown.
31
+ # Raised when a size can't be rendered. Cropping was requested without both dimensions, as in
32
+ # <tt>size: "400x", crop: true</tt> — there is no way to crop to an exact size when one of them is unknown — or
33
+ # the size is zero on both axes, as <tt>"0x0"</tt> is, and constrains nothing, or the size works out to less
34
+ # than a pixel, as <tt>"1x1"</tt> does against a very wide image.
33
35
  #
34
36
  # @see DynamicImage::ImageSizing#fit
37
+ # @see DynamicImage::Model::Transformations#resize
35
38
  class InvalidSizeOptions < DynamicImage::Errors::Error; end
36
39
 
37
40
  # Raised when a transformation can't be applied, either because a rotation isn't a multiple of 90 degrees or
@@ -45,6 +45,8 @@ module DynamicImage
45
45
  # @option options [Integer] :fallback_width The width to ask for the <tt>img</tt>, overriding
46
46
  # {DynamicImage.picture_fallback_width}
47
47
  # @return [String] the picture element
48
+ # @raise [DynamicImage::Errors::InvalidSizeOptions] if the image has less than a pixel to render at the
49
+ # requested ratio
48
50
  #
49
51
  # @example
50
52
  # dynamic_picture_tag(image, sizes: "50vw", alt: "A kitten")
@@ -54,9 +54,11 @@ module DynamicImage
54
54
  # # => <img alt="Avatar" src="..." width="100" height="62" />
55
55
  def dynamic_image_tag(record_or_array, options = {})
56
56
  record = extract_dynamic_image_record(record_or_array)
57
+ requested = options[:size]
57
58
  size = fit_size!(record_or_array, options)
58
59
  url_options = options.extract!(*allowed_dynamic_image_url_options)
59
- html_options = { size:, alt: record.alt_text }.merge(options)
60
+ html_options = { size: display_size(size, requested), alt: record.alt_text }
61
+ .merge(options)
60
62
 
61
63
  image_tag(dynamic_image_path_with_size(record_or_array,
62
64
  size,
@@ -78,7 +80,8 @@ module DynamicImage
78
80
  # an array is negotiated against the image by {DynamicImage::FormatNegotiator}. Defaults to
79
81
  # {DynamicImage.default_formats}, or {DynamicImage.mailer_formats} in a mailer view.
80
82
  # @return [String]
81
- # @raise [DynamicImage::Errors::InvalidSizeOptions] if <tt>crop: true</tt> is given without both dimensions
83
+ # @raise [DynamicImage::Errors::InvalidSizeOptions] if the size is zero on both axes, or if <tt>crop: true</tt>
84
+ # is given without both dimensions or leaves less than a pixel to render
82
85
  #
83
86
  # Any options supported by +polymorphic_url+ are also accepted.
84
87
  #
@@ -217,7 +220,16 @@ module DynamicImage
217
220
  def image_sizing(record, size_opts, uncropped)
218
221
  ImageSizing
219
222
  .new(record, uncropped:)
220
- .fit(size_opts[:size], size_opts).floor.to_s
223
+ .fit_renderable(size_opts[:size], size_opts).floor.to_s
224
+ end
225
+
226
+ # Ensure either dimension is at least 1px
227
+ def display_size(size, requested)
228
+ return size unless requested
229
+
230
+ Vector2d.parse(size)
231
+ .fit(Vector2d.parse(requested), upscale: false)
232
+ .floor.max(1).to_s
221
233
  end
222
234
  end
223
235
  end
@@ -25,16 +25,30 @@ module DynamicImage
25
25
  end
26
26
  end
27
27
 
28
- # Resize the image to a new size. The aspect ratio is not preserved; crop first if that matters.
28
+ # Resizes the image to fit within +new_size+, retaining its aspect ratio. Crop first if the image needs to
29
+ # fill the size exactly.
29
30
  #
30
- # @param new_size [Vector2d] the size to resize to
31
+ # @param new_size [Vector2d] the size to fit within
31
32
  # @return [DynamicImage::ImageProcessor] a new processor
32
33
  def resize(new_size)
33
- new_size = Vector2d(new_size)
34
- apply image.thumbnail_image(new_size.x.to_i,
35
- height: new_size.y.to_i,
34
+ resize_exact(size.fit(Vector2d(new_size)))
35
+ end
36
+
37
+ # Resizes the image to exactly +new_size+, rounded to whole pixels. The aspect ratio is not retained, so the
38
+ # image is stretched if the size doesn't match its own ratio.
39
+ #
40
+ # Use this when the size has already been worked out and has to be honored as given, as it is for a size
41
+ # coming from a URL. {DynamicImage::ImageSizing} computed it against this image, the helper committed to it in
42
+ # the markup, and refitting it here would land on a different size than the one advertised.
43
+ #
44
+ # @param new_size [Vector2d] the size to render
45
+ # @return [DynamicImage::ImageProcessor] a new processor
46
+ def resize_exact(new_size)
47
+ new_size = Vector2d(new_size).round
48
+ apply image.thumbnail_image(new_size.x,
49
+ height: new_size.y,
36
50
  crop: :none,
37
- size: :both)
51
+ size: :force)
38
52
  end
39
53
 
40
54
  # Rotates the image. The rotation must be a multiple of 90 degrees.
@@ -21,7 +21,7 @@ module DynamicImage
21
21
  # DynamicImage::ImageProcessor
22
22
  # .new(file)
23
23
  # .crop(crop_start, crop_size)
24
- # .resize(size)
24
+ # .resize_exact(size)
25
25
  # .convert(:jpeg)
26
26
  # .read
27
27
  class ImageProcessor
@@ -7,6 +7,11 @@ module DynamicImage
7
7
  # image, and it can be used on its own when you need the size without rendering anything: reserving space in a
8
8
  # layout, filling in <tt>og:image:width</tt>, or laying out a PDF.
9
9
  #
10
+ # The size it returns is the size that gets rendered. The helper floors it into the URL and the markup, and
11
+ # {DynamicImage::ImageProcessor::Transform#resize_exact} honors that number instead of fitting it again. Scaling
12
+ # is done in floating point, so +snap+ pulls an axis that lands a rounding error below a whole pixel back onto it
13
+ # before the helper's floor can drop the pixel.
14
+ #
10
15
  # @example
11
16
  # sizing = DynamicImage::ImageSizing.new(image)
12
17
  # sizing.fit("400x400") # => Vector2d(400.0, 250.0)
@@ -27,6 +32,7 @@ module DynamicImage
27
32
  #
28
33
  # @param ratio_vector [Vector2d] the aspect ratio to crop to
29
34
  # @return [Array(Vector2d, Vector2d)] the crop size and crop start
35
+ # @raise [DynamicImage::Errors::InvalidSizeOptions] if the vector is zero on both axes
30
36
  #
31
37
  # @example
32
38
  # image = Image.find(params[:id]) # 320x200 image
@@ -35,6 +41,8 @@ module DynamicImage
35
41
  # sizing.crop_geometry(Vector2d(100, 100))
36
42
  # # => [Vector2d(200, 200), Vector2d(60, 0)]
37
43
  def crop_geometry(ratio_vector)
44
+ require_nonzero!(ratio_vector)
45
+
38
46
  # Maximize the crop area to fit the image size
39
47
  crop_size = ratio_vector.fit(size).round
40
48
 
@@ -66,7 +74,7 @@ module DynamicImage
66
74
  ratio = DynamicImage::Ratio.parse(ratio)
67
75
  return size.x.floor unless ratio
68
76
 
69
- crop_geometry(vector(ratio, 1)).first.x.floor
77
+ crop_geometry(Vector2d.new(ratio, 1)).first.x.floor
70
78
  end
71
79
 
72
80
  # Adjusts +fit_size+ to fit the image dimensions. Any dimension set to zero will be ignored.
@@ -79,7 +87,8 @@ module DynamicImage
79
87
  # @option options [Boolean] :upscale Don't limit to the size of the image. Images smaller than the given size will
80
88
  # be scaled up.
81
89
  # @return [Vector2d] the resulting size
82
- # @raise [DynamicImage::Errors::InvalidSizeOptions] if <tt>crop: true</tt> is given and either dimension is zero
90
+ # @raise [DynamicImage::Errors::InvalidSizeOptions] if <tt>crop: true</tt> is given and either dimension is zero,
91
+ # if the size is zero on both axes, or if the result is less than a pixel in either dimension
83
92
  #
84
93
  # @example
85
94
  # image = Image.find(params[:id]) # 320x200 image
@@ -89,7 +98,7 @@ module DynamicImage
89
98
  # # => Vector2d(160.0, 100.0)
90
99
  #
91
100
  # sizing.fit(Vector2d(500, 500))
92
- # # => Vector2d(320.0, 200.0)
101
+ # # => Vector2d(320, 200)
93
102
  #
94
103
  # sizing.fit(Vector2d(500, 500), crop: true)
95
104
  # # => Vector2d(200.0, 200.0)
@@ -98,36 +107,66 @@ module DynamicImage
98
107
  # # => Vector2d(500.0, 312.5)
99
108
  def fit(fit_size, options = {})
100
109
  fit_size = parse_vector(fit_size)
101
- require_dimensions!(fit_size) if options[:crop]
102
- fit_size = size.fit(fit_size) unless options[:crop]
103
- fit_size = size.contain(fit_size) unless options[:upscale]
110
+ require_dimensions!(fit_size) if options[:crop]
111
+ require_nonzero!(fit_size)
112
+ fit_size = snap(scale(fit_size, options))
113
+ require_pixels!(fit_size)
104
114
  fit_size
105
115
  end
106
116
 
117
+ # Fits the size like {#fit}, but returns the smallest size the image can be rendered at rather than raising when
118
+ # the result lands under a pixel.
119
+ #
120
+ # @param fit_size [Vector2d, String] the size to fit within, as taken by {#fit}
121
+ # @param options [Hash] as taken by {#fit}
122
+ # @return [Vector2d] the resulting size
123
+ # @raise [DynamicImage::Errors::InvalidSizeOptions] if the size is zero on both axes, or if <tt>crop: true</tt>
124
+ # is given and the crop is less than a pixel
125
+ #
126
+ # @example
127
+ # image = Image.find(params[:id]) # 2000x1 image
128
+ # sizing = DynamicImage::ImageSizing.new(image)
129
+ #
130
+ # sizing.fit_renderable("1200x") # => Vector2d(2000.0, 1.0)
131
+ def fit_renderable(fit_size, options = {})
132
+ require_nonzero!(parse_vector(fit_size))
133
+ return fit(fit_size, options) if options[:crop] || renderable?(fit_size, options)
134
+
135
+ size.cover(1).round
136
+ end
137
+
138
+ # Returns true if the image can be rendered at +fit_size+, false if {#fit} rejects it.
139
+ #
140
+ # @param fit_size [Vector2d, String] the size to fit within, as taken by {#fit}
141
+ # @param options [Hash] as taken by {#fit}
142
+ # @return [Boolean]
143
+ #
144
+ # @example
145
+ # image = Image.find(params[:id]) # 320x200 image
146
+ # sizing = DynamicImage::ImageSizing.new(image)
147
+ #
148
+ # sizing.renderable?(Vector2d(100, 0)) # => true
149
+ # sizing.renderable?(Vector2d(1, 0)) # => false
150
+ def renderable?(fit_size, options = {})
151
+ vector = parse_vector(fit_size)
152
+ return false if options[:crop] && !(vector.x.positive? && vector.y.positive?)
153
+ return false if vector.x.zero? && vector.y.zero?
154
+
155
+ pixels?(snap(scale(vector, options)))
156
+ end
157
+
107
158
  private
108
159
 
109
160
  def crop_gravity
110
- if uncropped? && !record.crop_gravity?
111
- size / 2
112
- else
113
- record.crop_gravity
114
- end
161
+ uncropped? && !record.crop_gravity? ? size / 2 : record.crop_gravity
115
162
  end
116
163
 
117
164
  def crop_start
118
- if uncropped?
119
- Vector2d.new(0, 0)
120
- else
121
- record.crop_start
122
- end
165
+ uncropped? ? Vector2d.new(0, 0) : record.crop_start
123
166
  end
124
167
 
125
168
  def size
126
- if uncropped?
127
- record.real_size
128
- else
129
- record.size
130
- end
169
+ uncropped? ? record.real_size : record.size
131
170
  end
132
171
 
133
172
  # Clamps the rectangle defined by +start+ and +size+ to fit inside 0, 0 and +max_size+. It is assumed that +size+
@@ -140,6 +179,14 @@ module DynamicImage
140
179
  start
141
180
  end
142
181
 
182
+ # Scales +fit_size+ against the image, honouring <tt>:crop</tt> and <tt>:upscale</tt>.
183
+ def scale(fit_size, options)
184
+ upscale = options[:upscale] ? true : false
185
+ return size.fit(fit_size, upscale:) unless options[:crop]
186
+
187
+ upscale ? fit_size : fit_size.fit(size, upscale: false)
188
+ end
189
+
143
190
  def parse_vector(vector)
144
191
  vector.is_a?(String) ? str_to_vector(vector) : vector
145
192
  end
@@ -149,16 +196,52 @@ module DynamicImage
149
196
  def require_dimensions!(vector)
150
197
  return if vector.x.positive? && vector.y.positive?
151
198
 
152
- raise DynamicImage::Errors::InvalidSizeOptions
199
+ raise DynamicImage::Errors::InvalidSizeOptions,
200
+ "both dimensions are required when cropping"
201
+ end
202
+
203
+ # Rejects a vector that is zero on both axes. A single zero axis means the axis is unconstrained, but a vector
204
+ # that is zero throughout constrains nothing and describes no image.
205
+ def require_nonzero!(vector)
206
+ return unless vector.x.zero? && vector.y.zero?
207
+
208
+ raise DynamicImage::Errors::InvalidSizeOptions, "#{vector} has no size"
209
+ end
210
+
211
+ # Rejects sizes that don't round to at least one pixel in each dimension, since there is no image to render at
212
+ # that point.
213
+ def require_pixels!(vector)
214
+ return if pixels?(vector)
215
+
216
+ raise DynamicImage::Errors::InvalidSizeOptions,
217
+ "#{vector} has a dimension smaller than one pixel"
218
+ end
219
+
220
+ def pixels?(vector)
221
+ vector.x >= 1 && vector.y >= 1
153
222
  end
154
223
 
155
224
  def shift_vector(vect)
156
- vector(
225
+ Vector2d.new(
157
226
  vect.x.negative? ? vect.x.abs : 0,
158
227
  vect.y.negative? ? vect.y.abs : 0
159
228
  )
160
229
  end
161
230
 
231
+ # Snaps each axis of +scaled+ onto the whole pixel it is a rounding error away from. Scaling is done in floating
232
+ # point, so an axis that should land exactly on a pixel can come out a few ulps below it, and callers flooring
233
+ # the result would lose that pixel.
234
+ def snap(scaled)
235
+ Vector2d.new(snap_axis(scaled.x), snap_axis(scaled.y))
236
+ end
237
+
238
+ def snap_axis(value)
239
+ return value unless value.is_a?(Float) && value.finite?
240
+
241
+ rounded = value.round
242
+ (value - rounded).abs <= value.abs * 8 * Float::EPSILON ? rounded.to_f : value
243
+ end
244
+
162
245
  def str_to_vector(str)
163
246
  x, y = str.match(/(\d*)x(\d*)/)[1, 2].map(&:to_i)
164
247
  Vector2d.new(x, y)
@@ -167,9 +250,5 @@ module DynamicImage
167
250
  def uncropped?
168
251
  @uncropped
169
252
  end
170
-
171
- def vector(width, height)
172
- Vector2d.new(width, height)
173
- end
174
253
  end
175
254
  end
@@ -9,13 +9,20 @@ module DynamicImage
9
9
  # Both methods replace the data and update the stored dimensions, adjusting the crop to match. Neither saves
10
10
  # the record.
11
11
  module Transformations
12
- # Resizes the image, replacing the stored file with a smaller one. The crop is scaled along with it.
12
+ # Resizes the image, replacing the stored file. The crop is scaled along with it.
13
13
  #
14
- # @param max_size [Vector2d] the size to scale down to
14
+ # The image is scaled to fit within +max_size+, retaining its aspect ratio. Images smaller than +max_size+
15
+ # will be scaled up.
16
+ #
17
+ # @param max_size [Vector2d, String] the size to fit within, either a vector or a <tt>"{width}x{height}"</tt>
18
+ # string. Either dimension may be omitted to scale by the other alone.
15
19
  # @return [self]
20
+ # @raise [DynamicImage::Errors::InvalidSizeOptions] if the size is zero on both axes, or if the result is less
21
+ # than a pixel in either dimension
22
+ # @raise [DynamicImage::Errors::InvalidImage] if the stored data can't be processed
16
23
  def resize(max_size)
17
24
  transform_image do |image|
18
- resized = image.resize(real_size.constrain_both(max_size))
25
+ resized = image.resize_exact(fit_size(max_size))
19
26
  scale_crop(resized.size)
20
27
  resized
21
28
  end
@@ -26,6 +33,7 @@ module DynamicImage
26
33
  # @param degrees [Integer] the angle, which must be a multiple of 90. Rotating by 0 is a no-op.
27
34
  # @return [self]
28
35
  # @raise [DynamicImage::Errors::InvalidTransformation] if the angle isn't a multiple of 90
36
+ # @raise [DynamicImage::Errors::InvalidImage] if the stored data can't be processed
29
37
  #
30
38
  # @example
31
39
  # image.rotate(90)
@@ -48,6 +56,21 @@ module DynamicImage
48
56
 
49
57
  private
50
58
 
59
+ # Scales +max_size+ against the image, rejecting a size that is zero on both axes and a result smaller than a
60
+ # pixel in either dimension, as {DynamicImage::ImageSizing#fit} does for the sizes rendered on request.
61
+ def fit_size(max_size)
62
+ max_size = Vector2d.parse(max_size)
63
+ if max_size.x.zero? && max_size.y.zero?
64
+ raise DynamicImage::Errors::InvalidSizeOptions, "#{max_size} has no size"
65
+ end
66
+
67
+ new_size = real_size.fit(max_size)
68
+ return new_size if new_size.x >= 1 && new_size.y >= 1
69
+
70
+ raise DynamicImage::Errors::InvalidSizeOptions,
71
+ "#{new_size} has a dimension smaller than one pixel"
72
+ end
73
+
51
74
  def scale_crop(new_size)
52
75
  scale = new_size.to_f_vector / real_size
53
76
 
@@ -117,6 +140,8 @@ module DynamicImage
117
140
  end
118
141
  read_image_metadata
119
142
  self
143
+ rescue Vips::Error => e
144
+ raise DynamicImage::Errors::InvalidImage, e.message
120
145
  end
121
146
  end
122
147
  end
@@ -28,7 +28,7 @@ module DynamicImage
28
28
  # @!attribute [r] breakpoints
29
29
  # @return [DynamicImage::Breakpoints] the candidate widths
30
30
  # @!attribute [r] fallback_width
31
- # @return [Integer] the width asked for the fallback image
31
+ # @return [Integer] the width configured for the fallback image
32
32
  attr_reader :template, :record_or_array, :ratio, :sizes, :breakpoints, :fallback_width, :url_options
33
33
 
34
34
  # @param template [ActionView::Base] the view context, for routing
@@ -77,9 +77,13 @@ module DynamicImage
77
77
 
78
78
  # The candidate widths, smallest first.
79
79
  #
80
+ # Widths the image can't be rendered at are left out. One that can't be rendered at any of them gets a single
81
+ # candidate at its available width.
82
+ #
80
83
  # @return [Array<Integer>]
81
84
  def widths
82
- @widths ||= breakpoints.widths(available_width)
85
+ @widths ||= breakpoints.widths(available_width).select { renderable?(it) }.presence ||
86
+ [available_width].select { renderable?(it) }
83
87
  end
84
88
 
85
89
  # Every candidate, as the URL and the size it is actually rendered at.
@@ -128,9 +132,12 @@ module DynamicImage
128
132
 
129
133
  # The size asked for the fallback image, as a <tt>"{width}x{height}"</tt> string.
130
134
  #
135
+ # This is {#fallback_width}, unless the image can't be rendered that wide, in which case the widest candidate
136
+ # it can be rendered at stands in.
137
+ #
131
138
  # @return [String]
132
139
  def fallback_size
133
- @fallback_size ||= size_for(fallback_width)
140
+ @fallback_size ||= size_for(renderable?(fallback_width) ? fallback_width : (widths.last || fallback_width))
134
141
  end
135
142
 
136
143
  # The size the fallback image is actually rendered at. Smaller than {#fallback_size} when the image is.
@@ -171,6 +178,10 @@ module DynamicImage
171
178
  DynamicImage::Breakpoints.new(options[:breakpoints], step: options[:step])
172
179
  end
173
180
 
181
+ def renderable?(width)
182
+ sizing.renderable?(size_for(width), crop: crop?)
183
+ end
184
+
174
185
  def record
175
186
  record_or_array.last
176
187
  end
@@ -135,7 +135,7 @@ module DynamicImage
135
135
 
136
136
  def crop_and_resize(size)
137
137
  normalized do |image|
138
- image.crop(*image_sizing.crop_geometry(size)).resize(size)
138
+ image.crop(*image_sizing.crop_geometry(size)).resize_exact(size)
139
139
  end
140
140
  end
141
141
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module DynamicImage
4
- VERSION = "3.1.3"
4
+ VERSION = "3.4.0"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: dynamic_image
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.1.3
4
+ version: 3.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Inge Jørgensen
@@ -61,16 +61,22 @@ dependencies:
61
61
  name: vector2d
62
62
  requirement: !ruby/object:Gem::Requirement
63
63
  requirements:
64
- - - "~>"
64
+ - - ">="
65
+ - !ruby/object:Gem::Version
66
+ version: '3.0'
67
+ - - "<"
65
68
  - !ruby/object:Gem::Version
66
- version: '2.3'
69
+ version: '4.0'
67
70
  type: :runtime
68
71
  prerelease: false
69
72
  version_requirements: !ruby/object:Gem::Requirement
70
73
  requirements:
71
- - - "~>"
74
+ - - ">="
75
+ - !ruby/object:Gem::Version
76
+ version: '3.0'
77
+ - - "<"
72
78
  - !ruby/object:Gem::Version
73
- version: '2.3'
79
+ version: '4.0'
74
80
  description: DynamicImage is a Rails plugin that simplifies image uploading and processing
75
81
  email:
76
82
  - inge@elektronaut.no
@@ -139,14 +145,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
139
145
  requirements:
140
146
  - - ">="
141
147
  - !ruby/object:Gem::Version
142
- version: 3.3.0
148
+ version: 3.4.0
143
149
  required_rubygems_version: !ruby/object:Gem::Requirement
144
150
  requirements:
145
151
  - - ">="
146
152
  - !ruby/object:Gem::Version
147
153
  version: '0'
148
154
  requirements: []
149
- rubygems_version: 4.0.16
155
+ rubygems_version: 4.0.20
150
156
  specification_version: 4
151
157
  summary: Rails plugin that simplifies image uploading and processing
152
158
  test_files: []