diff --git a/.gitignore b/.gitignore index 4425c30..f62fe40 100644 --- a/.gitignore +++ b/.gitignore @@ -37,3 +37,5 @@ build/ .DS_Store /spec/examples.txt + +.byebug_history diff --git a/.ruby-version b/.ruby-version index 6a6a3d8..2714f53 100644 --- a/.ruby-version +++ b/.ruby-version @@ -1 +1 @@ -2.6.1 +2.6.4 diff --git a/Gemfile.lock b/Gemfile.lock new file mode 100644 index 0000000..faa09fc --- /dev/null +++ b/Gemfile.lock @@ -0,0 +1,64 @@ +PATH + remote: . + specs: + rszr (0.7.0) + +GEM + remote: https://rubygems.org/ + specs: + byebug (11.0.0) + diff-lcs (1.3) + docile (1.3.1) + ffi (1.10.0) + gd2-ffij (0.3.0) + ffi (>= 1.0.0) + image_processing (1.8.0) + mini_magick (>= 4.9.3, < 5) + ruby-vips (>= 2.0.13, < 3) + json (2.2.0) + memory_profiler (0.9.12) + mini_magick (4.9.3) + minitest (5.11.3) + rake (10.5.0) + rake-compiler (1.0.7) + rake + rspec (3.8.0) + rspec-core (~> 3.8.0) + rspec-expectations (~> 3.8.0) + rspec-mocks (~> 3.8.0) + rspec-core (3.8.0) + rspec-support (~> 3.8.0) + rspec-expectations (3.8.2) + diff-lcs (>= 1.2.0, < 2.0) + rspec-support (~> 3.8.0) + rspec-mocks (3.8.0) + diff-lcs (>= 1.2.0, < 2.0) + rspec-support (~> 3.8.0) + rspec-support (3.8.0) + ruby-vips (2.0.13) + ffi (~> 1.9) + simplecov (0.16.1) + docile (~> 1.1) + json (>= 1.8, < 3) + simplecov-html (~> 0.10.0) + simplecov-html (0.10.2) + +PLATFORMS + ruby + +DEPENDENCIES + bundler (~> 1.17) + byebug + gd2-ffij + image_processing + memory_profiler + mini_magick + minitest + rake (~> 10.0) + rake-compiler + rspec + rszr! + simplecov + +BUNDLED WITH + 1.17.2 diff --git a/benchmark/speed.rb b/benchmark/speed.rb index 8230d90..24c1ab5 100644 --- a/benchmark/speed.rb +++ b/benchmark/speed.rb @@ -1,36 +1,53 @@ require 'benchmark' +require 'fileutils' + require 'rszr' require 'mini_magick' require 'gd2-ffij' + +def root + Pathname.new(__FILE__).dirname +end + +def work_path(name) + root.join('work', name) +end + ITERATIONS = 100 +ORIGINAL = root.join('..', 'spec', 'images', 'test.jpg').to_s +WIDTH = 800 +HEIGHT = 532 + +puts 'Preparing ...' +(1..ITERATIONS).each { |i| FileUtils.cp ORIGINAL, work_path("#{i - 1}.jpg") } Benchmark.bm(100) do |x| - original = Pathname.new(__FILE__).dirname.join('../spec/images/test.jpg') + resized = Pathname.new(__FILE__).dirname.join('output.jpg') x.report 'MiniMagick' do - ITERATIONS.times do - image = MiniMagick::Image.open(original.to_s) - image.resize '800x532' + ITERATIONS.times do |i| + image = MiniMagick::Image.open(work_path("#{i}.jpg").to_s) + image.resize "#{WIDTH}x#{HEIGHT}" image.write resized.to_s image = nil end end x.report 'GD2' do - ITERATIONS.times do - image = GD2::Image.import(original.to_s) - image.resize! 800, 532 + ITERATIONS.times do |i| + image = GD2::Image.import(work_path("#{i}.jpg").to_s) + image.resize! WIDTH, HEIGHT image.export resized.to_s image = nil end end x.report 'Rszr' do - ITERATIONS.times do - image = Rszr::Image.load(original.to_s) - image.resize! 800, 532 + ITERATIONS.times do |i| + image = Rszr::Image.load(work_path("#{i}.jpg").to_s) + image.resize! WIDTH, HEIGHT image.save resized.to_s image = nil end diff --git a/ext/rszr/image.c b/ext/rszr/image.c index af68693..34e17ab 100644 --- a/ext/rszr/image.c +++ b/ext/rszr/image.c @@ -75,7 +75,7 @@ static VALUE rszr_image_s__load(VALUE klass, VALUE rb_path) } -static VALUE rszr_image_format(VALUE self) +static VALUE rszr_image_format_get(VALUE self) { rszr_image_handle * handle; char * format; @@ -93,6 +93,20 @@ static VALUE rszr_image_format(VALUE self) } +static VALUE rszr_image__format_set(VALUE self, VALUE rb_format) +{ + rszr_image_handle * handle; + char * format = StringValueCStr(rb_format); + + Data_Get_Struct(self, rszr_image_handle, handle); + + imlib_context_set_image(handle->image); + imlib_image_set_format(format); + + return self; +} + + static VALUE rszr_image_width(VALUE self) { rszr_image_handle * handle; @@ -120,6 +134,44 @@ static VALUE rszr_image_height(VALUE self) return INT2NUM(height); } +/* +static VALUE rszr_image_get_quality(VALUE self) +{ + rszr_image_handle * handle; + int quality; + + Data_Get_Struct(self, rszr_image_handle, handle); + + imlib_context_set_image(handle->image); + quality = imlib_image_get_attached_value("quality"); + + if (quality) { + return INT2NUM(quality); + } else { + return Qnil; + } +} + +static VALUE rszr_image_set_quality(VALUE self, VALUE rb_quality) +{ + rszr_image_handle * handle; + int quality; + + Check_Type(rb_quality, T_FIXNUM); + quality = FIX2INT(rb_quality); + if (quality <= 0) { + rb_raise(rb_eArgError, "quality must be >= 0"); + return Qnil; + } + + Data_Get_Struct(self, rszr_image_handle, handle); + + imlib_context_set_image(handle->image); + imlib_image_attach_data_value("quality", NULL, quality, NULL); + + return INT2NUM(quality); +} +*/ static VALUE rszr_image_dup(VALUE self) { @@ -159,6 +211,62 @@ static VALUE rszr_image__turn_bang(VALUE self, VALUE orientation) } +static VALUE rszr_image__rotate(VALUE self, VALUE bang, VALUE rb_angle) +{ + rszr_image_handle * handle; + rszr_image_handle * rotated_handle; + Imlib_Image rotated_image; + VALUE oRotatedImage; + double angle; + + angle = NUM2DBL(rb_angle); + + Data_Get_Struct(self, rszr_image_handle, handle); + + imlib_context_set_image(handle->image); + rotated_image = imlib_create_rotated_image(angle); + + if (!rotated_image) { + rb_raise(eRszrTransformationError, "error rotating image"); + return Qnil; + } + + if (RTEST(bang)) { + rszr_free_image(handle->image); + handle->image = rotated_image; + + return self; + } + else { + oRotatedImage = rszr_image_s_allocate(cImage); + Data_Get_Struct(oRotatedImage, rszr_image_handle, rotated_handle); + rotated_handle->image = rotated_image; + + return oRotatedImage; + } +} + + +static VALUE rszr_image__sharpen_bang(VALUE self, VALUE rb_radius) +{ + rszr_image_handle * handle; + int radius; + + radius = NUM2INT(rb_radius); + + Data_Get_Struct(self, rszr_image_handle, handle); + + imlib_context_set_image(handle->image); + if (radius >= 0) { + imlib_image_sharpen(radius); + } else { + imlib_image_blur(radius); + } + + return self; +} + + static Imlib_Image rszr_create_cropped_scaled_image(const Imlib_Image image, VALUE rb_src_x, VALUE rb_src_y, VALUE rb_src_w, VALUE rb_src_h, VALUE rb_dst_w, VALUE rb_dst_h) { Imlib_Image resized_image; @@ -262,20 +370,24 @@ static VALUE rszr_image__crop(VALUE self, VALUE bang, VALUE rb_x, VALUE rb_y, VA } -static VALUE rszr_image__save(VALUE self, VALUE rb_path, VALUE rb_format) +static VALUE rszr_image__save(VALUE self, VALUE rb_path, VALUE rb_format, VALUE rb_quality) { rszr_image_handle * handle; char * path; char * format; + int quality; Imlib_Load_Error save_error; path = StringValueCStr(rb_path); format = StringValueCStr(rb_format); - + quality = (NIL_P(rb_quality)) ? 0 : FIX2INT(rb_quality); + Data_Get_Struct(self, rszr_image_handle, handle); imlib_context_set_image(handle->image); imlib_image_set_format(format); + if (quality) + imlib_image_attach_data_value("quality", NULL, quality, NULL); imlib_save_image_with_error_return(path, &save_error); if (save_error) { @@ -298,12 +410,19 @@ void Init_rszr_image() rb_define_method(cImage, "initialize", rszr_image_initialize, 2); rb_define_method(cImage, "width", rszr_image_width, 0); rb_define_method(cImage, "height", rszr_image_height, 0); - rb_define_method(cImage, "format", rszr_image_format, 0); + rb_define_method(cImage, "format", rszr_image_format_get, 0); rb_define_method(cImage, "dup", rszr_image_dup, 0); + // rb_define_method(cImage, "quality", rszr_image_get_quality, 0); + // rb_define_method(cImage, "quality=", rszr_image_set_quality, 1); + + rb_define_protected_method(cImage, "_format=", rszr_image__format_set, 1); + rb_define_private_method(cImage, "_resize", rszr_image__resize, 7); rb_define_private_method(cImage, "_crop", rszr_image__crop, 5); rb_define_private_method(cImage, "_turn!", rszr_image__turn_bang, 1); - rb_define_private_method(cImage, "_save", rszr_image__save, 2); + rb_define_private_method(cImage, "_rotate", rszr_image__rotate, 2); + rb_define_private_method(cImage, "_sharpen!", rszr_image__sharpen_bang, 1); + rb_define_private_method(cImage, "_save", rszr_image__save, 3); } #endif diff --git a/lib/rszr/image.rb b/lib/rszr/image.rb index 16c3751..760f41d 100644 --- a/lib/rszr/image.rb +++ b/lib/rszr/image.rb @@ -3,7 +3,7 @@ class Image class << self - def load(path, options = {}) + def load(path, **opts) path = path.to_s raise FileNotFound unless File.exist?(path) _load(path) @@ -15,6 +15,11 @@ def load(path, options = {}) def dimensions [width, height] end + + def format=(fmt) + fmt = fmt.to_s if fmt.is_a?(Symbol) + self._format = fmt + end def inspect fmt = format @@ -22,30 +27,65 @@ def inspect "#<#{self.class.name}:0x#{object_id.to_s(16)} #{width}x#{height}#{fmt}>" end - def resize(*args) - _resize(false, *calculate_size(*args)) - end + module Transformations + def resize(*args) + _resize(false, *calculate_size(*args)) + end - def resize!(*args) - _resize(true, *calculate_size(*args)) - end + def resize!(*args) + _resize(true, *calculate_size(*args)) + end - def crop(x, y, width, height) - _crop(false, x, y, width, height) - end + def crop(x, y, width, height) + _crop(false, x, y, width, height) + end - def crop!(x, y, width, height) - _crop(true, x, y, width, height) - end + def crop!(x, y, width, height) + _crop(true, x, y, width, height) + end + + def turn(orientation) + dup.turn!(orientation) + end - def turn!(orientation) - orientation = orientation.abs + 2 if orientation.negative? - _turn!(orientation % 4) + def turn!(orientation) + orientation = orientation.abs + 2 if orientation.negative? + _turn!(orientation % 4) + end + + def rotate(deg) + _rotate(false, deg.to_f * Math::PI / 180.0) + end + + def rotate!(deg) + _rotate(true, deg.to_f * Math::PI / 180.0) + end + + def sharpen(radius) + dup.sharpen!(radius) + end + + def sharpen!(radius) + raise ArgumentError, 'illegal radius' if radius < 0 + _sharpen!(radius) + end + + def blur(radius) + dup.blur!(radius) + end + + def blur!(radius) + raise ArgumentError, 'illegal radius' if radius < 0 + _sharpen!(-radius) + end end + + include Transformations - def save(path, format = nil) - format ||= format_from_filename(path) || 'jpg' - _save(path.to_s, format.to_s) + def save(path, format: nil, quality: nil) + format ||= format_from_filename(path) || self.format || 'jpg' + raise ArgumentError, "invalid quality #{quality.inspect}" if quality && !(0..100).cover?(quality) + _save(path.to_s, format.to_s, quality) end private @@ -56,7 +96,7 @@ def save(path, format = nil) # :auto, 300 auto width, fit height # 400, 300, crop: :center_middle # 400, 300, background: rgba - # 400, 300, aspect: false + # 400, 300, skew: true def calculate_size(*args) options = args.last.is_a?(Hash) ? args.pop : {} diff --git a/lib/rszr/image_processing.rb b/lib/rszr/image_processing.rb new file mode 100644 index 0000000..619a8b5 --- /dev/null +++ b/lib/rszr/image_processing.rb @@ -0,0 +1,82 @@ +require 'rszr' +require 'image_processing' + +module ImageProcessing + module Rszr + extend Chainable + + class << self + + # Returns whether the given image file is processable. + def valid_image?(file) + ::Rszr::Image.load(file).width + true + rescue ::Rszr::Error + false + end + + end + + class Processor < ImageProcessing::Processor + accumulator :image, ::Rszr::Image + + class << self + + # Loads the image on disk into a Rszr::Image object + def load_image(path_or_image, **options) + if path_or_image.is_a?(::Rszr::Image) + path_or_image + else + ::Rszr::Image.load(path_or_image) + end + # TODO: image = image.autorot if autorot && !options.key?(:autorotate) + end + + # Writes the image object to disk. + # Accepts additional options (quality, format). + def save_image(image, destination_path, **options) + image.save(destination_path, **options) + end + + # Calls the operation to perform the processing. If the operation is + # defined on the processor (macro), calls it. Otherwise calls the + # bang variant of the method directly on the Rszr image object. + def apply_operation(accumulator, (name, args, block)) + return super if method_defined?(name) + accumulator.send("#{name}!", *args, &block) + end + + end + + # Resizes the image to not be larger than the specified dimensions. + def resize_to_limit(width, height, **options) + width, height = default_dimensions(width, height) + thumbnail(width, height, **options) + end + + # Resizes the image to fit within the specified dimensions. + def resize_to_fit(width, height, **options) + width, height = default_dimensions(width, height) + thumbnail(width, height, **options) + end + + # Resizes the image to fill the specified dimensions, applying any + # necessary cropping. + def resize_to_fill(width, height, **options) + thumbnail(width, height, crop: :center, **options) + end + + private + + def thumbnail(width, height, **options) + image.resize!(width, height, **options) + end + + def default_dimensions(width, height) + raise Error, 'either width or height must be specified' unless width || height + [width || :auto, height || :auto] + end + + end + end +end diff --git a/lib/rszr/version.rb b/lib/rszr/version.rb index 7c4c1f3..23a7cf0 100644 --- a/lib/rszr/version.rb +++ b/lib/rszr/version.rb @@ -1,3 +1,3 @@ module Rszr - VERSION = "0.5.2" + VERSION = '0.7.0' end diff --git a/rszr.gemspec b/rszr.gemspec index d0bf65d..11d1939 100644 --- a/rszr.gemspec +++ b/rszr.gemspec @@ -17,12 +17,14 @@ Gem::Specification.new do |s| s.require_paths = %w[lib ext] s.extensions = %w[ext/rszr/extconf.rb] - s.add_development_dependency 'bundler', '~> 1.9' + s.add_development_dependency 'bundler', '~> 1.17' s.add_development_dependency 'rake', '~> 10.0' s.add_development_dependency 'rake-compiler' s.add_development_dependency 'byebug' s.add_development_dependency 'rspec' + s.add_development_dependency 'minitest' s.add_development_dependency 'simplecov' + s.add_development_dependency 'image_processing' s.add_development_dependency 'gd2-ffij' s.add_development_dependency 'mini_magick' s.add_development_dependency 'memory_profiler' diff --git a/spec/image_processing_spec.rb b/spec/image_processing_spec.rb new file mode 100644 index 0000000..d3d8a6c --- /dev/null +++ b/spec/image_processing_spec.rb @@ -0,0 +1,33 @@ +require 'rszr/image_processing' + +RSpec.describe 'Rszr image processing' do + + it 'resizes image' do + pipeline = ImageProcessing::Rszr.source(fixture_image('bacon.png')) + resized = pipeline.resize(50, :auto) + expect(resized.call(save: false)).to have_dimensions(50, 42) + end + + it 'accepts convert option' do + pipeline = ImageProcessing::Rszr.source(fixture_image('test.jpg')) + converted = pipeline.convert(:png) + expect(converted.options[:format]).to eq(:png) + end + + it 'applies format' do + result = ImageProcessing::Rszr.convert('png').call(fixture_image('test.jpg')) + expect(File.extname(result.path)).to eq('.png') + expect(result.path).to have_format('png') + end + + describe 'image validation' do + it 'returns true for correct images' do + expect(ImageProcessing::Rszr.valid_image?(fixture_image('test.jpg'))).to be(true) + end + + it 'returns false for incorrect images' do + expect(ImageProcessing::Rszr.valid_image?(fixture_image('broken.jpg'))).to be(false) + end + end + +end diff --git a/spec/rszr_spec.rb b/spec/rszr_spec.rb index c971712..7468034 100644 --- a/spec/rszr_spec.rb +++ b/spec/rszr_spec.rb @@ -1,4 +1,4 @@ -RSpec.describe 'Rszr' do +RSpec.describe 'Rszr image processing' do it 'has a version number' do expect(Rszr::VERSION).to_not be_nil diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index ba950b6..73f3ec5 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -1,6 +1,12 @@ require 'simplecov' SimpleCov.start +module HelperMethods + def fixture_image(name) + RSpec.root.join('images', name) + end +end + RSpec.configure do |config| # rspec-expectations config goes here. You can use an alternate # assertion/expectation library such as wrong or the stdlib/minitest @@ -24,6 +30,8 @@ # `true` in RSpec 4. mocks.verify_partial_doubles = true end + + config.include HelperMethods # The settings below are suggested to provide a good initial experience # with RSpec, but feel free to customize to your heart's content. @@ -84,6 +92,18 @@ require 'tempfile' require 'tmpdir' +RSpec::Matchers.define :have_format do |expected| + match do |actual| + Rszr::Image.load(actual).format == expected + end +end + +RSpec::Matchers.define :have_dimensions do |expected_width, expected_height| + match do |actual| + actual.dimensions == [expected_width, expected_height] + end +end + RSpec.class_eval do def self.root @spec_root ||= Pathname.new(__FILE__).dirname