From 4b9cb1012de2f38f02217ec08e304930842c2ede Mon Sep 17 00:00:00 2001 From: Charles Oliver Nutter Date: Fri, 7 Aug 2026 17:00:17 -0500 Subject: [PATCH] Java2D support for JRuby This patch adds a third option: Java2D image-processing APIs on JRuby. This option has no external dependencies and works without external tools or native libraries. This code was written using Codex but directly controlled and heavily directed by me down to individual names and code styles. I have reviewed every line of change and made several fixes and improvements manually. Documentation changes are provided only as an example and can be massaged to fit your preferred style and verbiage. This code is provided free-of-charge by Headius Enterprises in support of JRuby users. See https://headius.com/services. --- CHANGELOG.md | 4 + README.md | 10 + doc/java2d.md | 39 ++++ image_processing.gemspec | 4 +- lib/image_processing.rb | 3 + lib/image_processing/java2d.rb | 385 +++++++++++++++++++++++++++++++++ test/java2d_test.rb | 126 +++++++++++ 7 files changed, 569 insertions(+), 2 deletions(-) create mode 100644 doc/java2d.md create mode 100644 lib/image_processing/java2d.rb create mode 100644 test/java2d_test.rb diff --git a/CHANGELOG.md b/CHANGELOG.md index b71a361..807c39c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +## Next + +* Add a JRuby-only Java2D/ImageIO processor available as `ImageProcessing::Java2D` + ## 2.0.3 (2026-06-08) * Prevent remote code execution when operation names come from user input, closing bypasses through the `#operation` meta-builder, `#method_missing`, and nested `#send` calls (reported by @szymonsec) diff --git a/README.md b/README.md index ee20a07..9bf4b1d 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,7 @@ This gem can process images with [ImageMagick] or [libvips]. ImageMagick is a good default choice, especially if you are migrating from another gem or library that uses ImageMagick. Libvips is a newer library that can process images [very rapidly][libvips performance] (often multiple times faster than ImageMagick). +On JRuby, it can also process images with the Java2D image APIs. ## Goal @@ -50,9 +51,16 @@ In a Mac terminal: gem "ruby-vips", "~> 2.0" # if using libvips ``` +On JRuby, `ImageProcessing::Java2D` uses only the bundled Java2D image APIs, so +it needs no additional gem or native library. MiniMagick and Vips can still be +selected. + ## Usage +On JRuby, **[`ImageProcessing::Java2D`]** provides the same chainable API using +the Java2D image APIs. + Processing is performed through **[`ImageProcessing::Vips`]** or **[`ImageProcessing::MiniMagick`]** modules. Both modules share the same chainable API for defining the processing pipeline: @@ -155,6 +163,7 @@ You can continue reading the API documentation for specific modules: * **[`ImageProcessing::Vips`]** * **[`ImageProcessing::MiniMagick`]** +* **[`ImageProcessing::Java2D`]** (JRuby only; no additional dependency) See the **[wiki]** for additional "How To" guides for common scenarios. The wiki is publicly editable, so you're encouraged to add your own guides. @@ -219,6 +228,7 @@ The `ImageProcessing::MiniMagick` functionality was extracted from [GraphicsMagick]: http://www.graphicsmagick.org [`ImageProcessing::Vips`]: doc/vips.md#readme [`ImageProcessing::MiniMagick`]: doc/minimagick.md#readme +[`ImageProcessing::Java2D`]: doc/java2d.md#readme [refile-mini_magick]: https://github.com/refile/refile-mini_magick [wiki]: https://github.com/janko/image_processing/wiki [HTTP.rb]: https://github.com/httprb/http diff --git a/doc/java2d.md b/doc/java2d.md new file mode 100644 index 0000000..e2501bf --- /dev/null +++ b/doc/java2d.md @@ -0,0 +1,39 @@ +# ImageProcessing::Java2D + +`ImageProcessing::Java2D` is a JRuby image processor implemented with the JDK's +ImageIO and Java2D APIs. It has no native library or additional gem dependency. +MiniMagick and Vips remain available on JRuby and are not replaced automatically. + +## Usage + +```rb +require "image_processing/java2d" + +processed = ImageProcessing::Java2D + .source(image) + .resize_to_limit(400, 400) + .convert("png") + .call +``` + +In Rails, select it with `config.active_storage.variant_processor = :java2d`. +The canonical Ruby API remains `ImageProcessing::Java2D`. + +The processor implements `resize_to_limit`, `resize_to_fit`, `resize_to_fill`, +`resize_and_pad`, `resize_to_cover`, `crop`, `rotate`, `flip`, `composite`, and +`strip`. JPEG EXIF orientation is applied on load by default; pass +`loader(auto_orient: false)` to retain the stored orientation. + +The Java2D-specific operation keywords are `gravity` on `resize_to_fill`, +`background` and `gravity` on `resize_and_pad`, `background` on `rotate`, and +`mode`, `gravity`, and `offset` on `composite`. Other operation keywords are +rejected instead of being silently ignored. + +Image formats are limited to the ImageIO readers and writers installed in the +JVM. Standard JDKs include JPEG, PNG, GIF, BMP, and WBMP. The `saver` options +are `quality` (either `0.0..1.0` or `0..100`), `saver` for an explicit ImageIO +format name, and `background` for flattening transparent images to JPEG. + +`composite` supports the `over`, `src`, `clear`, and `dest-over` modes. Java2D +does not provide equivalents for every ImageMagick or libvips operation; use +`custom` to work directly with the `java.awt.image.BufferedImage` accumulator. diff --git a/image_processing.gemspec b/image_processing.gemspec index 9ac9ded..ea4c194 100644 --- a/image_processing.gemspec +++ b/image_processing.gemspec @@ -6,8 +6,8 @@ Gem::Specification.new do |spec| spec.required_ruby_version = ">= 3.0" - spec.summary = "High-level wrapper for processing images for the web with ImageMagick or libvips." - spec.description = "High-level wrapper for processing images for the web with ImageMagick or libvips." + spec.summary = "High-level wrapper for processing images for the web with ImageMagick or libvips. Supports Java2D on JRuby." + spec.description = "High-level wrapper for processing images for the web with ImageMagick or libvips. It also supports the Java2D image APIs on JRuby." spec.homepage = "https://github.com/janko/image_processing" spec.authors = ["Janko Marohnić"] spec.email = ["janko.marohnic@gmail.com"] diff --git a/lib/image_processing.rb b/lib/image_processing.rb index fcd2310..93bc176 100644 --- a/lib/image_processing.rb +++ b/lib/image_processing.rb @@ -28,4 +28,7 @@ def self.unsafe_method?(receiver, name) autoload :MiniMagick, "image_processing/mini_magick" autoload :Vips, "image_processing/vips" + autoload :Java2D, "image_processing/java2d" + # ActiveSupport camelizes the Rails processor name :java2d as Java2d. + autoload :Java2d, "image_processing/java2d" end diff --git a/lib/image_processing/java2d.rb b/lib/image_processing/java2d.rb new file mode 100644 index 0000000..634e47a --- /dev/null +++ b/lib/image_processing/java2d.rb @@ -0,0 +1,385 @@ +# frozen_string_literal: true + +require 'image_processing' + +raise LoadError, 'ImageProcessing::Java2D requires JRuby.' unless RUBY_ENGINE == 'jruby' + +require 'java' +java.lang.System.set_property('java.awt.headless', 'true') unless java.lang.System.get_property('java.awt.headless') + +module ImageProcessing + # Image processing backed only by ImageIO and Java2D from the JDK. + module Java2D + extend Chainable + + java_import java.awt.AlphaComposite + java_import java.awt.Color + java_import java.awt.RenderingHints + java_import java.awt.image.BufferedImage + java_import javax.imageio.IIOImage + java_import javax.imageio.ImageIO + java_import javax.imageio.ImageWriteParam + + JavaFile = java.io.File + + def self.valid_image?(file) + Utils.read_image(file.path) + true + rescue StandardError + false + end + + # Executes ImageProcessing operations with a BufferedImage accumulator. + class Processor < ImageProcessing::Processor + accumulator :image, BufferedImage + + def self.load_image(path_or_image, auto_orient: true) + return path_or_image if path_or_image.is_a?(BufferedImage) + + image = Utils.read_image(path_or_image.to_s) + auto_orient ? Utils.orient(image, Utils.exif_orientation(path_or_image.to_s)) : image + end + + def self.save_image(image, path, saver: nil, quality: nil, background: 'white') + format = (saver || ::File.extname(path).delete_prefix('.')).to_s.downcase + format = 'jpeg' if %w[jpg jpe].include?(format) + image = Utils.flatten(image, Utils.color(background)) if format == 'jpeg' && image.color_model.has_alpha + + writers = ImageIO.get_image_writers_by_format_name(format) + raise Error, "unsupported output format: #{format}" unless writers.has_next + + output = ImageIO.create_image_output_stream(JavaFile.new(path)) + writer = writers.next + writer.set_output(output) + params = writer.default_write_param + if quality && params.can_write_compressed + params.set_compression_mode(ImageWriteParam::MODE_EXPLICIT) + params.set_compression_quality(quality.to_f / (quality.to_f > 1 ? 100 : 1)) + end + writer.write(nil, IIOImage.new(image, nil, nil), params) + ensure + writer&.dispose + output&.close + end + + def resize_to_limit(width, height) + resize(width, height, limit: true) + end + + def resize_to_fit(width, height) + resize(width, height) + end + + def resize_to_fill(width, height, gravity: 'center') + scaled = scale([width.to_f / image.width, height.to_f / image.height].max) + crop_image(scaled, *position(scaled, width, height, gravity), width, height) + end + + def resize_and_pad(width, height, background: :transparent, gravity: 'center') + scaled = resize(width, height) + canvas(scaled, width, height, background, gravity) + end + + def resize_to_cover(width, height) + scale([width.to_f / image.width, height.to_f / image.height].max) + end + + def crop(*args) + geometry = args.first.to_s.match(/\A(\d+)x(\d+)\+(-?\d+)\+(-?\d+)\z/) if args.one? + + if geometry + width, height, left, top = geometry.captures.map(&:to_i) + elsif args.length == 4 + left, top, width, height = args.map(&:to_i) + else + raise ArgumentError, 'wrong crop arguments (expected geometry or left, top, width, height)' + end + crop_image(image, left, top, width, height) + end + + def rotate(degrees, background: :transparent) + radians = degrees.to_f * Math::PI / 180 + sin = Math.sin(radians).abs + cos = Math.cos(radians).abs + sin = 0 if sin < 1e-12 + cos = 0 if cos < 1e-12 + width = (image.width * cos + image.height * sin).ceil + height = (image.width * sin + image.height * cos).ceil + result = BufferedImage.new(width, height, BufferedImage::TYPE_INT_ARGB) + Utils.graphics(result) do |graphics| + graphics.set_color(Utils.color(background)) + graphics.fill_rect(0, 0, width, height) + graphics.translate(width / 2.0, height / 2.0) + graphics.rotate(radians) + graphics.draw_image(image, -image.width / 2.0, -image.height / 2.0, nil) + end + result + end + + def flip(direction = :horizontal) + result = blank(image.width, image.height) + Utils.graphics(result) do |graphics| + if direction.to_s == 'vertical' + graphics.draw_image(image, 0, image.height, image.width, -image.height, nil) + else + graphics.draw_image(image, image.width, 0, -image.width, image.height, nil) + end + end + result + end + + def composite(overlay, mode: 'over', gravity: 'north-west', offset: nil) + overlay = convert_to_image(overlay) + left, top = position(image, overlay.width, overlay.height, gravity) + left += offset[0] if offset + top += offset[1] if offset + result = blank(image.width, image.height) + Utils.graphics(result) do |graphics| + graphics.draw_image(image, 0, 0, nil) + rule = Utils.composite_mode(mode) + raise ArgumentError, "unsupported composite mode: #{mode}" unless rule + + graphics.set_composite(AlphaComposite.get_instance(rule)) + graphics.draw_image(overlay, left, top, nil) + end + result + end + + # ImageIO does not carry source metadata into the new BufferedImage. + def strip + image + end + + private + + def resize(width, height, limit: false) + raise Error, 'either width or height must be specified' unless width || height + + factors = [] + factors << width.to_f / image.width if width + factors << height.to_f / image.height if height + factor = factors.min + factor = 1 if limit && factor > 1 + scale(factor) + end + + def scale(factor) + width = [(image.width * factor).round, 1].max + height = [(image.height * factor).round, 1].max + return image if width == image.width && height == image.height + + result = blank(width, height) + Utils.graphics(result, quality: true) do |graphics| + graphics.draw_image(image, 0, 0, width, height, nil) + end + result + end + + def crop_image(source, left, top, width, height) + outside = left.negative? || top.negative? || left + width > source.width || top + height > source.height + raise ArgumentError, 'crop is outside image bounds' if outside + + result = blank(width, height) + Utils.graphics(result) do |graphics| + graphics.draw_image(source, 0, 0, width, height, left, top, left + width, top + height, nil) + end + result + end + + def canvas(source, width, height, background, gravity) + result = blank(width, height) + left, top = position(result, source.width, source.height, gravity) + Utils.graphics(result) do |graphics| + graphics.set_color(Utils.color(background)) + graphics.fill_rect(0, 0, width, height) + graphics.draw_image(source, left, top, nil) + end + result + end + + def position(container, width, height, gravity) + gravity = gravity.to_s.downcase.tr('_', '-') + x = gravity.include?('west') || gravity.include?('left') ? 0 : container.width - width + y = gravity.include?('north') || gravity.include?('top') ? 0 : container.height - height + x /= 2 unless gravity.match?(/west|east|left|right/) + y /= 2 unless gravity.match?(/north|south|top|bottom/) + [x, y] + end + + def blank(width, height) + BufferedImage.new(width, height, BufferedImage::TYPE_INT_ARGB) + end + + def convert_to_image(object) + return object if object.is_a?(BufferedImage) + + path = ::File.path(object) + Processor.load_image(path, auto_orient: false) + rescue TypeError + raise ArgumentError, 'overlay must be a BufferedImage or path-like object' + end + end + + # Internal Java2D and ImageIO helpers shared by the processor entry points. + module Utils + COMPOSITE_MODES = { + 'clear' => AlphaComposite::CLEAR, + 'dest-over' => AlphaComposite::DST_OVER, + 'over' => AlphaComposite::SRC_OVER, + 'src' => AlphaComposite::SRC + }.freeze + + COLORS = { + 'black' => Color::BLACK, + 'blue' => Color::BLUE, + 'cyan' => Color::CYAN, + 'gray' => Color::GRAY, + 'green' => Color::GREEN, + 'grey' => Color::GRAY, + 'magenta' => Color::MAGENTA, + 'red' => Color::RED, + 'white' => Color::WHITE, + 'yellow' => Color::YELLOW + }.freeze + + module_function + + def composite_mode(mode) + COMPOSITE_MODES[mode.to_s] + end + + def read_image(path) + image = begin + ::File.open(path, 'rb') { |file| ImageIO.read(file.to_inputstream) } + rescue StandardError + raise Error, "unsupported or invalid image: #{path}" + end + + # ImageIO returns null when no registered ImageReader accepts the stream. + raise Error, "unsupported or invalid image: #{path}" unless image + + image + end + + def color(value) + name = value.to_s.downcase + + if name == 'transparent' + Color.new(0, 0, 0, 0) + elsif value.is_a?(Array) && (3..4).cover?(value.length) + alpha = value[3] || 255 + alpha = (alpha * 255).round if alpha.to_f <= 1 + Color.new(*value.first(3).map(&:to_i), alpha.to_i) + elsif COLORS.key?(name) + COLORS.fetch(name) + elsif value.is_a?(String) && value.match?(/\A(?:#|0x)[0-9a-f]{6}\z/i) + Color.decode(value) + else + raise ArgumentError, "unrecognized color format: #{value.inspect}" + end + end + + def graphics(image, quality: false) + graphics = image.create_graphics + if quality + graphics.set_rendering_hint( + RenderingHints::KEY_INTERPOLATION, + RenderingHints::VALUE_INTERPOLATION_BICUBIC + ) + graphics.set_rendering_hint(RenderingHints::KEY_RENDERING, RenderingHints::VALUE_RENDER_QUALITY) + end + yield graphics + ensure + graphics&.dispose + end + + def flatten(image, color) + result = BufferedImage.new(image.width, image.height, BufferedImage::TYPE_INT_RGB) + Utils.graphics(result) do |graphics| + graphics.set_color(color) + graphics.fill_rect(0, 0, image.width, image.height) + graphics.draw_image(image, 0, 0, nil) + end + result + end + + def exif_orientation(path) + data = ::File.binread(path, 131_072) + start = data.index("Exif\0\0") + + if start + tiff = start + 6 + order = data.byteslice(tiff, 2) + + if %w[II MM].include?(order) + short_format, long_format = order == 'II' ? %w[v V] : %w[n N] + offset = data.byteslice(tiff + 4, 4).unpack1(long_format) + count = data.byteslice(tiff + offset, 2).unpack1(short_format) + count.times do |index| + entry = data.byteslice(tiff + offset + 2 + index * 12, 12) + return entry.byteslice(8, 2).unpack1(short_format) if entry.unpack1(short_format) == 0x0112 + end + end + end + 1 + rescue StandardError + 1 + end + + def orient(image, orientation) + case orientation + when 2 + Utils.transform(image) do |graphics| + graphics.translate(image.width, 0) + graphics.scale(-1, 1) + end + when 3 + Utils.transform(image) do |graphics| + graphics.translate(image.width, image.height) + graphics.rotate(Math::PI) + end + when 4 + Utils.transform(image) do |graphics| + graphics.translate(0, image.height) + graphics.scale(1, -1) + end + when 5 + Utils.transform(image, swap: true) do |graphics| + graphics.transform(java.awt.geom.AffineTransform.new(0, 1, 1, 0, 0, 0)) + end + when 6 + Utils.transform(image, swap: true) do |graphics| + graphics.translate(image.height, 0) + graphics.rotate(Math::PI / 2) + end + when 7 + Utils.transform(image, swap: true) do |graphics| + graphics.transform(java.awt.geom.AffineTransform.new(0, -1, -1, 0, image.height, image.width)) + end + when 8 + Utils.transform(image, swap: true) do |graphics| + graphics.translate(0, image.width) + graphics.rotate(-Math::PI / 2) + end + else + image + end + end + + def transform(image, swap: false) + width, height = swap ? [image.height, image.width] : [image.width, image.height] + result = BufferedImage.new(width, height, BufferedImage::TYPE_INT_ARGB) + Utils.graphics(result) do |graphics| + yield graphics + graphics.draw_image(image, 0, 0, nil) + end + result + end + end + + private_constant :Utils + end + + # ActiveSupport camelizes :java2d as Java2d. + Java2d = Java2D +end diff --git a/test/java2d_test.rb b/test/java2d_test.rb new file mode 100644 index 0000000..bb71f1b --- /dev/null +++ b/test/java2d_test.rb @@ -0,0 +1,126 @@ +require "minitest/autorun" +require "pathname" +require "tempfile" +require "image_processing" + +describe "ImageProcessing::Java2D" do + if RUBY_ENGINE != "jruby" + it "is only available on JRuby" do + assert_raises(LoadError) { ImageProcessing::Java2D } + end + else + require "image_processing/java2d" + + it "supports ActiveSupport's camelization of :java2d" do + assert_same ImageProcessing::Java2D, ImageProcessing::Java2d + end + + it "keeps processor utilities private" do + refute_respond_to ImageProcessing::Java2D::Processor, :graphics + assert_raises(NameError) { ImageProcessing::Java2D::Utils } + end + + def fixture(name) + ::File.expand_path("fixtures/#{name}", __dir__) + end + + def dimensions(image) + image = ImageProcessing::Java2D::Processor.load_image(image.path) if image.respond_to?(:path) + [image.width, image.height] + end + + it "validates images with ImageIO" do + ::File.open(fixture("portrait.jpg")) { |file| assert ImageProcessing::Java2D.valid_image?(file) } + ::File.open(fixture("invalid.jpg")) { |file| refute ImageProcessing::Java2D.valid_image?(file) } + + error = assert_raises(ImageProcessing::Error) do + ImageProcessing::Java2D::Processor.load_image(fixture("invalid.jpg")) + end + assert_match(/unsupported or invalid image/, error.message) + end + + it "does not propagate backend errors for invalid images" do + Tempfile.create(["invalid", ".jpg"]) do |file| + file.binmode + file.write("\xff\xd8") + file.flush + + assert_raises(Java::JavaxImageio::IIOException) do + ImageProcessing::Java2D::ImageIO.read(ImageProcessing::Java2D::JavaFile.new(file.path)) + end + refute ImageProcessing::Java2D.valid_image?(file) + + error = assert_raises(ImageProcessing::Error) do + ImageProcessing::Java2D::Processor.load_image(file.path) + end + assert_kind_of Java::JavaxImageio::IIOException, error.cause + end + end + + it "auto-orients images by default" do + assert_equal [600, 800], dimensions(ImageProcessing::Java2D.call(fixture("rotated.jpg"), save: false)) + result = ImageProcessing::Java2D.loader(auto_orient: false).call(fixture("rotated.jpg"), save: false) + assert_equal [800, 600], dimensions(result) + end + + it "implements the resize macros" do + pipeline = ImageProcessing::Java2D.source(fixture("portrait.jpg")) + assert_equal [300, 400], dimensions(pipeline.resize_to_limit(400, 400).call(save: false)) + assert_equal [750, 1000], dimensions(pipeline.resize_to_fit(1000, 1000).call(save: false)) + assert_equal [400, 400], dimensions(pipeline.resize_to_fill(400, 400).call(save: false)) + assert_equal [400, 400], dimensions(pipeline.resize_and_pad(400, 400).call(save: false)) + assert_equal [300, 400], dimensions(pipeline.resize_to_cover(300, 200).call(save: false)) + end + + it "applies chained operations in order" do + result = ImageProcessing::Java2D + .source(fixture("portrait.jpg")) + .resize_to_fit(300, 300) + .rotate(90) + .crop(0, 0, 200, 200) + .call(save: false) + + assert_equal [200, 200], dimensions(result) + end + + it "rejects unpublished operation keywords" do + pipeline = ImageProcessing::Java2D.source(fixture("portrait.jpg")) + + assert_raises(ArgumentError) do + pipeline.resize_to_fit(300, 300, sharpen: true).call(save: false) + end + end + + it "crops, rotates, and flips" do + pipeline = ImageProcessing::Java2D.source(fixture("portrait.jpg")) + assert_equal [300, 300], dimensions(pipeline.crop(0, 0, 300, 300).call(save: false)) + assert_equal [800, 600], dimensions(pipeline.rotate(90).call(save: false)) + assert_equal [600, 800], dimensions(pipeline.flip.call(save: false)) + end + + it "composites and writes images" do + result = ImageProcessing::Java2D + .source(fixture("portrait.jpg")) + .composite(fixture("landscape.jpg"), gravity: "center") + .convert("png") + .call + + assert_equal [600, 800], dimensions(result) + assert_operator result.size, :>, 0 + end + + it "accepts Ruby path-like objects for overlays" do + pipeline = ImageProcessing::Java2D.source(fixture("portrait.jpg")) + + assert_equal [600, 800], dimensions(pipeline.composite(Pathname(fixture("landscape.jpg"))).call(save: false)) + ::File.open(fixture("landscape.jpg")) do |overlay| + assert_equal [600, 800], dimensions(pipeline.composite(overlay).call(save: false)) + end + end + + it "accepts a BufferedImage source" do + image = ImageProcessing::Java2D::Processor.load_image(fixture("portrait.jpg")) + assert_same image, ImageProcessing::Java2D.call(image, save: false) + end + end +end