diff --git a/doc/minimagick.md b/doc/minimagick.md index 1e2c13f..7cbf772 100644 --- a/doc/minimagick.md +++ b/doc/minimagick.md @@ -302,6 +302,10 @@ File.extname(result.path) By default the original format is retained when writing the image to a file. If the source file doesn't have a file extension, the format will default to JPEG. +When a destination path is given, its extension determines the format. When +the destination path has no extension, as with a `/dev/fd/N` path, the format +passed to `#convert` is used instead. + #### `#define` Adds coder/decoder options with [`-define`] from the specified Hash. diff --git a/doc/vips.md b/doc/vips.md index 240307b..9f0d185 100644 --- a/doc/vips.md +++ b/doc/vips.md @@ -350,6 +350,10 @@ File.extname(result.path) By default the original format is retained when writing the image to a file. If the source file doesn't have a file extension, the format will default to JPEG. +When a destination path is given, its extension determines the format. When +the destination path has no extension, as with a `/dev/fd/N` path, the format +passed to `#convert` is used instead. + NOTE: libvips 8.6 is able to normally read GIF images (and convert them to other formats), but it's not able to *save* to GIF format. If you need full GIF support, you need to use libvips 8.7+ compiled with ImageMagick support diff --git a/lib/image_processing/mini_magick.rb b/lib/image_processing/mini_magick.rb index b9ce362..8b6ac67 100644 --- a/lib/image_processing/mini_magick.rb +++ b/lib/image_processing/mini_magick.rb @@ -73,11 +73,21 @@ def self.load_image(path_or_magick, loader: nil, page: nil, geometry: nil, auto_ # Calls the built ImageMagick command to perform processing and save # the result to disk. Accepts additional options related to saving the - # image (e.g. quality). - def self.save_image(magick, destination_path, allow_splitting: false, **options) + # image (e.g. quality). `format` names the output format when given, + # otherwise ImageMagick infers it from the path's extension. + def self.save_image(magick, destination_path, format = nil, allow_splitting: false, **options) Utils.apply_options(magick, **options) - magick << destination_path + if format + # ImageMagick reads everything before the first colon as the + # format, so a format carrying a colon could redirect the output. + format = format.to_s + fail Error, "invalid format: #{format.inspect}" unless format.ascii_only? && format.match?(/\A\w+\z/) + + magick << "#{format}:#{destination_path}" + else + magick << destination_path + end magick.call Utils.disallow_split_layers!(destination_path) unless allow_splitting diff --git a/lib/image_processing/pipeline.rb b/lib/image_processing/pipeline.rb index 642fd50..621992c 100644 --- a/lib/image_processing/pipeline.rb +++ b/lib/image_processing/pipeline.rb @@ -21,7 +21,11 @@ def call(save: true) call_processor elsif destination handle_destination do - call_processor(destination: destination) + if format && !determine_format(destination) + call_processor(destination: destination, format: format) + else + call_processor(destination: destination) + end end else create_tempfile do |tempfile| diff --git a/lib/image_processing/processor.rb b/lib/image_processing/processor.rb index e666737..5844b30 100644 --- a/lib/image_processing/processor.rb +++ b/lib/image_processing/processor.rb @@ -1,7 +1,7 @@ module ImageProcessing # Abstract class inherited by individual processors. class Processor - def self.call(source:, loader:, operations:, saver:, destination: nil) + def self.call(source:, loader:, operations:, saver:, destination: nil, format: nil) unless source.is_a?(String) || source.is_a?(self::ACCUMULATOR_CLASS) fail Error, "invalid source: #{source.inspect}" end @@ -19,7 +19,9 @@ def self.call(source:, loader:, operations:, saver:, destination: nil) accumulator = apply_operation(accumulator, operation) end - if destination + if destination && format + save_image(accumulator, destination, format, **saver) + elsif destination save_image(accumulator, destination, **saver) else accumulator diff --git a/lib/image_processing/vips.rb b/lib/image_processing/vips.rb index c1ff19e..23238d1 100644 --- a/lib/image_processing/vips.rb +++ b/lib/image_processing/vips.rb @@ -56,12 +56,21 @@ def self.supports_resize_on_load? # Writes the Vips::Image object to disk. This starts the processing # pipeline defined in the Vips::Image object. Accepts additional - # saver-specific options (e.g. quality). - def self.save_image(image, path, saver: nil, quality: nil, **options) + # saver-specific options (e.g. quality). The saver is chosen by `format` + # when given, otherwise by the path's extension. + def self.save_image(image, path, format = nil, saver: nil, quality: nil, **options) options[:Q] = quality if quality if saver image.public_send(:"#{saver}save", path, **options) + elsif format + saver = ::Vips.vips_foreign_find_save(".#{format}") + fail ::Vips::Error, "No known saver for '#{format}'." unless saver + + filename = ::Vips.p2str(::Vips.vips_filename_get_filename(path)) + option_string = ::Vips.p2str(::Vips.vips_filename_get_options(path)) + options = Utils.select_valid_options(saver, options) + ::Vips::Operation.call(saver, [image, filename], options, option_string) else options = Utils.select_valid_saver_options(path, options) image.write_to_file(path, **options) diff --git a/test/mini_magick_test.rb b/test/mini_magick_test.rb index 8bad1bf..818e47d 100644 --- a/test/mini_magick_test.rb +++ b/test/mini_magick_test.rb @@ -59,6 +59,24 @@ def with_mini_magick_version(version) assert_type "PNG", result end + it "saves in the #convert format to a destination without an extension" do + destination = Tempfile.new("destination") + ImageProcessing::MiniMagick.convert("png").call(@portrait, destination: destination.path) + assert_type "PNG", destination + end + + it "refuses a #convert format that ImageMagick would read as more than a format" do + destination = Tempfile.new("destination") + pipeline = ImageProcessing::MiniMagick.convert("png:redirected-") + assert_raises(ImageProcessing::Error) { pipeline.call(@portrait, destination: destination.path) } + end + + it "saves in the destination's extension rather than the #convert format" do + destination = Tempfile.new(["destination", ".jpg"]) + ImageProcessing::MiniMagick.convert("png").call(@portrait, destination: destination.path) + assert_type "JPEG", destination + end + it "accepts page" do tiff = Tempfile.new(["file", ".tiff"]) ImageProcessing::MiniMagick.convert_shim do |convert| @@ -92,6 +110,17 @@ def with_mini_magick_version(version) end end + it "saves in the #convert format to a destination named as an inherited descriptor" do + destination = Tempfile.new("destination") + File.open(destination.path, "wb") do |file| + ImageProcessing::MiniMagick + .loader(inherit_fds: [file]) + .convert("png") + .call(@portrait, destination: "/dev/fd/#{file.fileno}") + end + assert_type "PNG", destination + end + it "applies loader, page and geometry to a source named as an inherited descriptor" do magick = ImageProcessing::MiniMagick .source("/dev/fd/3") @@ -147,6 +176,20 @@ def with_mini_magick_version(version) end end + it "disallows split layers in the #convert format to a destination without an extension" do + tiff = Tempfile.new(["file", ".tiff"]) + ImageProcessing::MiniMagick.convert_shim do |convert| + convert.merge! [@portrait.path, @portrait.path, @portrait.path] + convert << tiff.path + end + destination = Tempfile.new("destination") + + pipeline = ImageProcessing::MiniMagick.source(tiff).convert("jpg") + + assert_raises(ImageProcessing::Error) { pipeline.call(destination: destination.path) } + assert_empty Dir["#{destination.path}-*"] + end + it "allows resizing images without extension" do result1 = Tempfile.new("image") # no file extension diff --git a/test/pipeline_test.rb b/test/pipeline_test.rb index 3ac0319..274ee13 100644 --- a/test/pipeline_test.rb +++ b/test/pipeline_test.rb @@ -79,6 +79,17 @@ assert_raises(Vips::Error) { pipeline.call(destination: destination_path) } end + it "calls a processor whose .call predates the format keyword" do + processor = Class.new(ImageProcessing::Vips::Processor) do + def self.call(source:, loader:, operations:, saver:, destination: nil) + super + end + end + destination = Tempfile.new(["destination", ".png"]) + ImageProcessing::Vips.branch(processor: processor).convert("jpg").call(@portrait, destination: destination.path) + assert_type "PNG", destination + end + it "accepts loader options" do pipeline = ImageProcessing::Vips.loader(shrink: 2) assert_equal Hash[shrink: 2], pipeline.options[:loader] diff --git a/test/vips_test.rb b/test/vips_test.rb index 8e21f76..07630d4 100644 --- a/test/vips_test.rb +++ b/test/vips_test.rb @@ -126,6 +126,52 @@ assert_type "PNG", result end + it "saves in the #convert format to a destination without an extension" do + destination = Tempfile.new("destination") + ImageProcessing::Vips.convert("png").call(@portrait, destination: destination.path) + assert_type "PNG", destination + end + + it "saves in the #convert format to a destination named as /dev/fd/N" do + destination = Tempfile.new("destination") + File.open(destination.path, "wb") do |file| + ImageProcessing::Vips.convert("png").call(@portrait, destination: "/dev/fd/#{file.fileno}") + end + assert_type "PNG", destination + end + + it "ignores saver options that the #convert format's saver does not define" do + destination = Tempfile.new("destination") + ImageProcessing::Vips.saver(compression: 9).convert("jpg").call(@portrait, destination: destination.path) + assert_type "JPEG", destination + end + + it "applies saver options in the #convert format to a destination without an extension" do + low, high = Tempfile.new("low"), Tempfile.new("high") + ImageProcessing::Vips.saver(quality: 10).convert("jpg").call(@portrait, destination: low.path) + ImageProcessing::Vips.saver(quality: 100).convert("jpg").call(@portrait, destination: high.path) + assert File.size(low.path) < File.size(high.path) + end + + it "applies saver options named in a destination without an extension" do + low, high = Tempfile.new("low"), Tempfile.new("high") + ImageProcessing::Vips.convert("jpg").call(@portrait, destination: "#{low.path}[Q=10]") + ImageProcessing::Vips.convert("jpg").call(@portrait, destination: "#{high.path}[Q=100]") + assert File.size(low.path) < File.size(high.path) + end + + it "prefers :saver to the #convert format" do + destination = Tempfile.new("destination") + ImageProcessing::Vips.saver(saver: :png).convert("jpg").call(@portrait, destination: destination.path) + assert_type "PNG", destination + end + + it "saves in the destination's extension rather than the #convert format" do + destination = Tempfile.new(["destination", ".jpg"]) + ImageProcessing::Vips.convert("png").call(@portrait, destination: destination.path) + assert_type "JPEG", destination + end + describe ".valid_image?" do it "returns true for correct images" do assert ImageProcessing::Vips.valid_image?(@portrait)