diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index f39c7c9..9496948 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -35,7 +35,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - ruby: ["3.3", "3.4", "4.0"] + ruby: ["3.4", "4.0"] services: postgres: image: postgres:latest diff --git a/.rubocop.yml b/.rubocop.yml index d146353..5928ff8 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -9,7 +9,7 @@ plugins: AllCops: NewCops: enable TargetRailsVersion: 7.2 - TargetRubyVersion: 3.3 + TargetRubyVersion: 3.4 Exclude: - 'spec/internal/config/**/*' - 'spec/internal/db/**/*' diff --git a/dynamic_image.gemspec b/dynamic_image.gemspec index 92b8769..586c0cf 100644 --- a/dynamic_image.gemspec +++ b/dynamic_image.gemspec @@ -24,7 +24,7 @@ Gem::Specification.new do |s| "README.md" ] - s.required_ruby_version = ">= 3.3.0" + s.required_ruby_version = ">= 3.4.0" s.add_dependency "dis", "~> 2.1" s.add_dependency "rails", "> 7.0" diff --git a/lib/dynamic_image/breakpoints.rb b/lib/dynamic_image/breakpoints.rb index e5539a4..e8f6361 100644 --- a/lib/dynamic_image/breakpoints.rb +++ b/lib/dynamic_image/breakpoints.rb @@ -61,15 +61,15 @@ def widths(available) private def fixed(available) - widths = spec.map(&:to_i).select { _1 <= available }.uniq.sort + widths = spec.map(&:to_i).select { it <= available }.uniq.sort widths.any? ? widths : [available] end def stepped(available) top = [available, spec.end || available].min - rest = Enumerator.produce(top / step) { _1 / step } - .take_while { _1 >= spec.begin } - .map { snap(_1) } + rest = Enumerator.produce(top / step) { it / step } + .take_while { it >= spec.begin } + .map { snap(it) } [top, *rest].reverse.uniq end diff --git a/lib/dynamic_image/errors.rb b/lib/dynamic_image/errors.rb index b8bae16..7b386bf 100644 --- a/lib/dynamic_image/errors.rb +++ b/lib/dynamic_image/errors.rb @@ -28,9 +28,10 @@ class InvalidHeader < DynamicImage::Errors::Error; end # @see DynamicImage::DigestVerifier#verify class InvalidSignature < DynamicImage::Errors::Error; end - # Raised when a size can't be rendered. Either cropping was requested without both dimensions, as in + # Raised when a size can't be rendered. Cropping was requested without both dimensions, as in # size: "400x", crop: true — there is no way to crop to an exact size when one of them is unknown — or - # the size works out to less than a pixel, as "0x0" does, or "1x1" against a very wide image. + # the size is zero on both axes, as "0x0" is, and constrains nothing, or the size works out to less + # than a pixel, as "1x1" does against a very wide image. # # @see DynamicImage::ImageSizing#fit # @see DynamicImage::Model::Transformations#resize diff --git a/lib/dynamic_image/image_sizing.rb b/lib/dynamic_image/image_sizing.rb index 48b4a19..f4cbfdb 100644 --- a/lib/dynamic_image/image_sizing.rb +++ b/lib/dynamic_image/image_sizing.rb @@ -27,6 +27,7 @@ def initialize(record, options = {}) # # @param ratio_vector [Vector2d] the aspect ratio to crop to # @return [Array(Vector2d, Vector2d)] the crop size and crop start + # @raise [DynamicImage::Errors::InvalidSizeOptions] if the vector is zero on both axes # # @example # image = Image.find(params[:id]) # 320x200 image @@ -35,6 +36,8 @@ def initialize(record, options = {}) # sizing.crop_geometry(Vector2d(100, 100)) # # => [Vector2d(200, 200), Vector2d(60, 0)] def crop_geometry(ratio_vector) + require_nonzero!(ratio_vector) + # Maximize the crop area to fit the image size crop_size = ratio_vector.fit(size).round @@ -66,7 +69,7 @@ def available_width(ratio = nil) ratio = DynamicImage::Ratio.parse(ratio) return size.x.floor unless ratio - crop_geometry(vector(ratio, 1)).first.x.floor + crop_geometry(Vector2d.new(ratio, 1)).first.x.floor end # Adjusts +fit_size+ to fit the image dimensions. Any dimension set to zero will be ignored. @@ -80,7 +83,7 @@ def available_width(ratio = nil) # be scaled up. # @return [Vector2d] the resulting size # @raise [DynamicImage::Errors::InvalidSizeOptions] if crop: true is given and either dimension is zero, - # or if the result is less than a pixel in either dimension + # if the size is zero on both axes, or if the result is less than a pixel in either dimension # # @example # image = Image.find(params[:id]) # 320x200 image @@ -100,6 +103,7 @@ def available_width(ratio = nil) def fit(fit_size, options = {}) fit_size = parse_vector(fit_size) require_dimensions!(fit_size) if options[:crop] + require_nonzero!(fit_size) fit_size = size.fit(fit_size) unless options[:crop] fit_size = contain(fit_size) unless options[:upscale] fit_size = snap(fit_size) @@ -163,8 +167,16 @@ def require_dimensions!(vector) "both dimensions are required when cropping" end + # Rejects a vector that is zero on both axes. A single zero axis means the axis is unconstrained, but a vector + # that is zero throughout constrains nothing and describes no image. + def require_nonzero!(vector) + return unless vector.x.zero? && vector.y.zero? + + raise DynamicImage::Errors::InvalidSizeOptions, "#{vector} has no size" + end + # Rejects sizes that don't round to at least one pixel in each dimension, since there is no image to render at - # that point. A NaN, which is what an empty size fits to, fails this too. + # that point. def require_pixels!(vector) return if vector.x >= 1 && vector.y >= 1 @@ -173,7 +185,7 @@ def require_pixels!(vector) end def shift_vector(vect) - vector( + Vector2d.new( vect.x.negative? ? vect.x.abs : 0, vect.y.negative? ? vect.y.abs : 0 ) @@ -183,7 +195,7 @@ def shift_vector(vect) # point, so an axis that should land exactly on a pixel can come out a few ulps below it, and callers flooring # the result would lose that pixel. def snap(scaled) - vector(snap_axis(scaled.x), snap_axis(scaled.y)) + Vector2d.new(snap_axis(scaled.x), snap_axis(scaled.y)) end def snap_axis(value) @@ -201,9 +213,5 @@ def str_to_vector(str) def uncropped? @uncropped end - - def vector(width, height) - Vector2d.new(width, height) - end end end diff --git a/lib/dynamic_image/model/transformations.rb b/lib/dynamic_image/model/transformations.rb index 4f8dafe..fcc0ad1 100644 --- a/lib/dynamic_image/model/transformations.rb +++ b/lib/dynamic_image/model/transformations.rb @@ -17,7 +17,8 @@ module Transformations # @param max_size [Vector2d, String] the size to fit within, either a vector or a "{width}x{height}" # string. Either dimension may be omitted to scale by the other alone. # @return [self] - # @raise [DynamicImage::Errors::InvalidSizeOptions] if the result is less than a pixel in either dimension + # @raise [DynamicImage::Errors::InvalidSizeOptions] if the size is zero on both axes, or if the result is less + # than a pixel in either dimension # @raise [DynamicImage::Errors::InvalidImage] if the stored data can't be processed def resize(max_size) transform_image do |image| @@ -55,9 +56,14 @@ def rotate(degrees = 90) private - # Scales +max_size+ against the image, rejecting a result smaller than a pixel in either dimension, as - # {DynamicImage::ImageSizing#fit} does for the sizes rendered on request. + # Scales +max_size+ against the image, rejecting a size that is zero on both axes and a result smaller than a + # pixel in either dimension, as {DynamicImage::ImageSizing#fit} does for the sizes rendered on request. def fit_size(max_size) + max_size = Vector2d.parse(max_size) + if max_size.x.zero? && max_size.y.zero? + raise DynamicImage::Errors::InvalidSizeOptions, "#{max_size} has no size" + end + new_size = real_size.fit(max_size) return new_size if new_size.x >= 1 && new_size.y >= 1 diff --git a/spec/dynamic_image/image_sizing_spec.rb b/spec/dynamic_image/image_sizing_spec.rb index 12fc265..11d37f6 100644 --- a/spec/dynamic_image/image_sizing_spec.rb +++ b/spec/dynamic_image/image_sizing_spec.rb @@ -58,6 +58,14 @@ def v(width, height) it { is_expected.to eq([v(200, 200), v(120, 0)]) } end + + context "with both dimensions zero" do + it "raises an error" do + expect { crop_geometry(0, 0) }.to( + raise_error(DynamicImage::Errors::InvalidSizeOptions) + ) + end + end end describe "#crop_geometry (cropped image)" do diff --git a/spec/dynamic_image/model/transformations_spec.rb b/spec/dynamic_image/model/transformations_spec.rb index 23ecc22..257cd76 100644 --- a/spec/dynamic_image/model/transformations_spec.rb +++ b/spec/dynamic_image/model/transformations_spec.rb @@ -79,6 +79,16 @@ end context "when the size is smaller than a pixel" do + subject(:resized) { image.resize("1x1") } + + it "raises an error" do + expect { resized }.to( + raise_error(DynamicImage::Errors::InvalidSizeOptions) + ) + end + end + + context "with both dimensions zero" do subject(:resized) { image.resize("0x0") } it "raises an error" do