From f2345f3836846c3e9b9c72b3bdc4369c6371237e Mon Sep 17 00:00:00 2001 From: Paul Keen <125715+pftg@users.noreply.github.com> Date: Sat, 22 Aug 2026 19:36:54 +0200 Subject: [PATCH 1/3] refactor: extract SnapDiff::Driver mixin from BaseDriver BaseDriver's shared defaults (same_dimension?, dimension, width_for, height_for, image_area_size, supports?, PNG_EXTENSION) move verbatim into the SnapDiff::Driver module; BaseDriver becomes a shell that includes it. Behavior-preserving; method names unchanged (v2 design dissent #4). --- .../screenshot/diff/drivers/base_driver.rb | 27 +------------- lib/snap_diff/driver.rb | 37 +++++++++++++++++++ 2 files changed, 39 insertions(+), 25 deletions(-) create mode 100644 lib/snap_diff/driver.rb diff --git a/lib/capybara/screenshot/diff/drivers/base_driver.rb b/lib/capybara/screenshot/diff/drivers/base_driver.rb index 0ae22958..6055c018 100644 --- a/lib/capybara/screenshot/diff/drivers/base_driver.rb +++ b/lib/capybara/screenshot/diff/drivers/base_driver.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true require "capybara/screenshot/diff/difference" +require "snap_diff/driver" module Capybara module Screenshot @@ -9,31 +10,7 @@ module Diff # range considering color values and difference area size. module Drivers class BaseDriver - PNG_EXTENSION = ".png" - - def same_dimension?(comparison) - dimension(comparison.base_image) == dimension(comparison.new_image) - end - - def height_for(image) - image.height - end - - def width_for(image) - image.width - end - - def image_area_size(image) - width_for(image) * height_for(image) - end - - def dimension(image) - [width_for(image), height_for(image)] - end - - def supports?(feature) - respond_to?(feature) - end + include SnapDiff::Driver end end end diff --git a/lib/snap_diff/driver.rb b/lib/snap_diff/driver.rb new file mode 100644 index 00000000..34035fbb --- /dev/null +++ b/lib/snap_diff/driver.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +module SnapDiff + # Shared default behavior for image-processing drivers. + # + # Replaces the old +Capybara::Screenshot::Diff::Drivers::BaseDriver+ + # superclass (ADR-004 v2 step 4): concrete drivers +include Driver+ + # instead of inheriting. Method names are intentionally unchanged from + # v1 — see dissent #4 in the v2 architecture design. + module Driver + PNG_EXTENSION = ".png" + + def same_dimension?(comparison) + dimension(comparison.base_image) == dimension(comparison.new_image) + end + + def height_for(image) + image.height + end + + def width_for(image) + image.width + end + + def image_area_size(image) + width_for(image) * height_for(image) + end + + def dimension(image) + [width_for(image), height_for(image)] + end + + def supports?(feature) + respond_to?(feature) + end + end +end From 93258385894c668cf9b071ef00942604f5960955 Mon Sep 17 00:00:00 2001 From: Paul Keen <125715+pftg@users.noreply.github.com> Date: Sat, 22 Aug 2026 19:38:55 +0200 Subject: [PATCH 2/3] refactor: move concrete drivers to SnapDiff::Drivers, include Driver mixin VipsDriver and ChunkyPNGDriver move to lib/snap_diff/drivers/ with 'include SnapDiff::Driver' replacing '< BaseDriver'. Class and method names unchanged (v2 design dissent #4). Old namespace forwards same-object: Drivers is a whole-module alias (covering .for and the lazily-required driver constants), BaseDriver aliases the mixin, and the old driver require paths stay loadable. Utils.find_driver_class_for now requires/returns the SnapDiff paths. Namespace forwarding test gains the 4 new pairs (25 total), vips pair skipping on vips-less runners. --- lib/capybara/screenshot/diff/drivers.rb | 14 +- .../screenshot/diff/drivers/base_driver.rb | 20 +- .../diff/drivers/chunky_png_driver.rb | 304 +----------------- .../screenshot/diff/drivers/vips_driver.rb | 174 +--------- lib/snap_diff/drivers.rb | 14 + lib/snap_diff/drivers/chunky_png_driver.rb | 298 +++++++++++++++++ lib/snap_diff/drivers/vips_driver.rb | 169 ++++++++++ lib/snap_diff/utils.rb | 8 +- test/unit/namespace_forwarding_test.rb | 23 +- 9 files changed, 526 insertions(+), 498 deletions(-) create mode 100644 lib/snap_diff/drivers.rb create mode 100644 lib/snap_diff/drivers/chunky_png_driver.rb create mode 100644 lib/snap_diff/drivers/vips_driver.rb diff --git a/lib/capybara/screenshot/diff/drivers.rb b/lib/capybara/screenshot/diff/drivers.rb index 70097466..26e4be59 100644 --- a/lib/capybara/screenshot/diff/drivers.rb +++ b/lib/capybara/screenshot/diff/drivers.rb @@ -1,16 +1,14 @@ # frozen_string_literal: true +require "snap_diff/drivers" + module Capybara module Screenshot module Diff - module Drivers - def self.for(driver_options = {}) - driver_option = driver_options.is_a?(Hash) ? driver_options.fetch(:driver, :chunky_png) : driver_options - return driver_option unless driver_option.is_a?(Symbol) - - Utils.find_driver_class_for(driver_option).new - end - end + # Same-object forwarder (ADR-004 v2 step 4): aliasing the whole module + # keeps Drivers.for and the lazily-loaded Drivers::VipsDriver / + # Drivers::ChunkyPNGDriver constants resolving through the old name. + Drivers = SnapDiff::Drivers end end end diff --git a/lib/capybara/screenshot/diff/drivers/base_driver.rb b/lib/capybara/screenshot/diff/drivers/base_driver.rb index 6055c018..71801ad6 100644 --- a/lib/capybara/screenshot/diff/drivers/base_driver.rb +++ b/lib/capybara/screenshot/diff/drivers/base_driver.rb @@ -1,18 +1,10 @@ # frozen_string_literal: true -require "capybara/screenshot/diff/difference" +require "capybara/screenshot/diff/drivers" require "snap_diff/driver" -module Capybara - module Screenshot - module Diff - # Compare two images and determine if they are equal, different, or within some comparison - # range considering color values and difference area size. - module Drivers - class BaseDriver - include SnapDiff::Driver - end - end - end - end -end +# BaseDriver was dissolved into the SnapDiff::Driver mixin (ADR-004 v2 +# step 4). Alias kept so existing requires of this file keep resolving; +# note it is now a module — `class MyDriver < BaseDriver` becomes +# `include SnapDiff::Driver`. +Capybara::Screenshot::Diff::Drivers::BaseDriver = SnapDiff::Driver diff --git a/lib/capybara/screenshot/diff/drivers/chunky_png_driver.rb b/lib/capybara/screenshot/diff/drivers/chunky_png_driver.rb index 1c90c043..8711728c 100644 --- a/lib/capybara/screenshot/diff/drivers/chunky_png_driver.rb +++ b/lib/capybara/screenshot/diff/drivers/chunky_png_driver.rb @@ -1,301 +1,7 @@ # frozen_string_literal: true -begin - require "chunky_png" -rescue LoadError => e - raise 'Required chunky_png gem is missing. Add `gem "chunky_png"` to Gemfile' if e.message.match?(/chunky_png/i) - raise -end - -require "capybara/screenshot/diff/drivers/base_driver" - -module Capybara - module Screenshot - module Diff - # Compare two images and determine if they are equal, different, or within some comparison - # range considering color values and difference area size. - module Drivers - class ChunkyPNGDriver < BaseDriver - include ChunkyPNG::Color - - def load_images(old_file_name, new_file_name) - old_bytes, new_bytes = load_image_files(old_file_name, new_file_name) - - _load_images(old_bytes, new_bytes) - end - - def add_black_box(image, _region) - image - end - - def find_difference_region(comparison) - DifferenceRegionFinder.new(comparison, self).perform - end - - def crop(region, i) - i.crop(*region.to_top_left_corner_coordinates) - end - - def from_file(filename_or_path) - ChunkyPNG::Image.from_file(filename_or_path.to_s) - end - - def save_image_to(image, filename) - image.save(filename, :fast_rgba) - end - - def resize_image_to(image, new_width, new_height) - image.resample_bilinear(new_width, new_height) - end - - def load_image_files(old_file_name, file_name) - [old_file_name.binread, file_name.binread] - end - - def draw_rectangles(images, region, (r, g, b), offset: 0) - border_color = ChunkyPNG::Color.rgb(r, g, b) - border_shadow = ChunkyPNG::Color.rgba(r, g, b, 100) - - images.map do |image| - new_img = image.dup - new_img.rect(region.left - offset, region.top - offset, region.right + offset, region.bottom + offset, border_color) - new_img.rect(region.left, region.top, region.right, region.bottom, border_shadow) - new_img - end - end - - def same_pixels?(comparison) - comparison.new_image == comparison.base_image - end - - private - - def _load_images(old_file, new_file) - [ChunkyPNG::Image.from_blob(old_file), ChunkyPNG::Image.from_blob(new_file)] - end - - class DifferenceRegionFinder - attr_accessor :skip_area, :color_distance_limit, :shift_distance_limit - - def initialize(comparison, driver = nil) - @comparison = comparison - @driver = driver - - @color_distance_limit = comparison.options[:color_distance_limit] - @shift_distance_limit = comparison.options[:shift_distance_limit] - @skip_area = comparison.options[:skip_area] - end - - def perform - find_difference_region(@comparison) - end - - def find_difference_region(comparison) - new_image, base_image, = comparison.new_image, comparison.base_image - - meta = {} - meta[:max_color_distance] = 0 - meta[:max_shift_distance] = 0 if shift_distance_limit - - region = find_top(base_image, new_image, cache: meta) - region = if region.nil? || region[1].nil? - nil - else - find_diff_rectangle(base_image, new_image, region, cache: meta) - end - - result = Difference.new(region, meta, comparison) - - unless result.blank? - meta[:max_color_distance] = meta[:max_color_distance].ceil(1) if meta[:max_color_distance] - - if comparison.options[:tolerance] - meta[:difference_level] = difference_level(nil, base_image, region) - end - end - - result - end - - def difference_level(_diff_mask, base_image, region) - image_area_size = @driver.image_area_size(base_image) - return nil if image_area_size.zero? - - region.size.to_f / image_area_size - end - - def find_diff_rectangle(org_img, new_img, area_coordinates, cache:) - left, top, right, bottom = find_left_right_and_top(org_img, new_img, area_coordinates, cache: cache) - bottom = find_bottom(org_img, new_img, left, right, bottom, cache: cache) - - Region.from_edge_coordinates(left, top, right, bottom) - end - - def find_top(old_img, new_img, cache:) - old_img.height.times do |y| - old_img.width.times do |x| - return [x, y, x, y] unless same_color?(old_img, new_img, x, y, cache: cache) - end - end - nil - end - - def find_left_right_and_top(old_img, new_img, region, cache:) - region = region.is_a?(Region) ? region.to_edge_coordinates : region - - left = region[0] || old_img.width - 1 - top = region[1] - right = region[2] || 0 - bottom = region[3] - - old_img.height.times do |y| - (0...left).find do |x| - next if same_color?(old_img, new_img, x, y, cache: cache) - - top ||= y - bottom = y - left = x - right = x if x > right - x - end - (old_img.width - 1).step(right + 1, -1).find do |x| - unless same_color?(old_img, new_img, x, y, cache: cache) - bottom = y - right = x - end - end - end - - [left, top, right, bottom] - end - - def find_bottom(old_img, new_img, left, right, bottom, cache:) - if bottom - (old_img.height - 1).step(bottom + 1, -1).find do |y| - (left..right).find do |x| - bottom = y unless same_color?(old_img, new_img, x, y, cache: cache) - end - end - end - - bottom - end - - def same_color?(old_img, new_img, x, y, cache:) - return true if skipped_region?(x, y) - - color_distance = - color_distance_at(new_img, old_img, x, y, shift_distance_limit: @shift_distance_limit) - - if color_distance > cache[:max_color_distance] - cache[:max_color_distance] = color_distance - end - - color_matches = color_distance == 0 || - (!!@color_distance_limit && @color_distance_limit > 0 && color_distance <= @color_distance_limit) - - return color_matches if !@shift_distance_limit || cache[:max_shift_distance] == Float::INFINITY - - shift_distance = (color_matches && 0) || - shift_distance_at(new_img, old_img, x, y, color_distance_limit: @color_distance_limit) - if shift_distance && (cache[:max_shift_distance].nil? || shift_distance > cache[:max_shift_distance]) - cache[:max_shift_distance] = shift_distance - end - - color_matches - end - - def skipped_region?(x, y) - return false unless @skip_area - - @skip_area.any? { |region| region.cover?(x, y) } - end - - def color_distance_at(new_img, old_img, x, y, shift_distance_limit:) - org_color = old_img[x, y] - unless shift_distance_limit - return ChunkyPNG::Color.euclidean_distance_rgba(org_color, new_img[x, y]) - end - - start_x = [0, x - shift_distance_limit].max - end_x = [x + shift_distance_limit, new_img.width - 1].min - start_y = [0, y - shift_distance_limit].max - end_y = [y + shift_distance_limit, new_img.height - 1].min - - min_distance = Float::INFINITY - (start_y..end_y).each do |dy| - (start_x..end_x).each do |dx| - distance = ChunkyPNG::Color.euclidean_distance_rgba(org_color, new_img[dx, dy]) - return 0 if distance == 0 - min_distance = distance if distance < min_distance - end - end - min_distance - end - - def shift_distance_at(new_img, old_img, x, y, color_distance_limit:) - org_color = old_img[x, y] - shift_distance = 0 - loop do - bounds_breached = 0 - top_row = y - shift_distance - if top_row >= 0 # top - ([0, x - shift_distance].max..[x + shift_distance, new_img.width - 1].min).each do |dx| - if color_matches(new_img, org_color, dx, top_row, color_distance_limit) - return shift_distance - end - end - else - bounds_breached += 1 - end - if shift_distance > 0 - if (x - shift_distance) >= 0 # left - ([0, top_row + 1].max..[y + shift_distance, new_img.height - 2].min) - .each do |dy| - if color_matches(new_img, org_color, x - shift_distance, dy, color_distance_limit) - return shift_distance - end - end - else - bounds_breached += 1 - end - if (y + shift_distance) < new_img.height # bottom - ([0, x - shift_distance].max..[x + shift_distance, new_img.width - 1].min).each do |dx| - if color_matches(new_img, org_color, dx, y + shift_distance, color_distance_limit) - return shift_distance - end - end - else - bounds_breached += 1 - end - if (x + shift_distance) < new_img.width # right - ([0, top_row + 1].max..[y + shift_distance, new_img.height - 2].min) - .each do |dy| - if color_matches(new_img, org_color, x + shift_distance, dy, color_distance_limit) - return shift_distance - end - end - else - bounds_breached += 1 - end - end - break if bounds_breached == 4 - - shift_distance += 1 - end - Float::INFINITY - end - - def color_matches(new_img, org_color, x, y, color_distance_limit) - new_color = new_img[x, y] - return new_color == org_color unless color_distance_limit - - color_distance = ChunkyPNG::Color.euclidean_distance_rgba(org_color, new_color) - color_distance <= color_distance_limit - end - end - end - end - end - end -end +# Forwarder (ADR-004 v2 step 4): ChunkyPNGDriver lives in SnapDiff::Drivers +# now; the module alias in capybara/screenshot/diff/drivers.rb makes it +# reachable as Capybara::Screenshot::Diff::Drivers::ChunkyPNGDriver. +require "capybara/screenshot/diff/drivers" +require "snap_diff/drivers/chunky_png_driver" diff --git a/lib/capybara/screenshot/diff/drivers/vips_driver.rb b/lib/capybara/screenshot/diff/drivers/vips_driver.rb index 992168f9..7183f225 100644 --- a/lib/capybara/screenshot/diff/drivers/vips_driver.rb +++ b/lib/capybara/screenshot/diff/drivers/vips_driver.rb @@ -1,171 +1,7 @@ # frozen_string_literal: true -begin - require "vips" -rescue LoadError => e - raise 'Required ruby-vips gem is missing. Add `gem "ruby-vips"` to Gemfile' if e.message.match?(/vips/i) - raise -end - -require "capybara/screenshot/diff/drivers/base_driver" - -module Capybara - module Screenshot - module Diff - # Compare two images and determine if they are equal, different, or within some comparison - # range considering color values and difference area size. - module Drivers - class VipsDriver < BaseDriver - def find_difference_region(comparison) - new_image, base_image, options = comparison.new_image, comparison.base_image, comparison.options - - diff_mask = if options[:perceptual_threshold] - self.class.perceptual_difference_mask(base_image, new_image, options[:perceptual_threshold]) - else - self.class.difference_mask(base_image, new_image, options[:color_distance_limit]) - end - region = self.class.difference_region_by(diff_mask) - # TODO: schedule research when we got this case for VIPs - # region = nil if region && region_covers_entire_image?(region, base_image) - - result = Difference.new(region, {}, comparison) - - unless result.blank? - result.meta[:difference_level] = difference_level(diff_mask, base_image) if comparison.options[:tolerance] - result.meta[:diff_mask] = diff_mask - end - - result - end - - def crop(region, i) - i.crop(*region.to_top_left_corner_coordinates) - rescue Vips::Error => e - warn( - "[capybara-screenshot-diff] Crop has been failed for " \ - "{ region: #{region.to_top_left_corner_coordinates.inspect}, image: #{dimension(i).join("x")} }" - ) - raise e - end - - def filter_image_with_median(image, median_filter_window_size) - image.median(median_filter_window_size) - end - - def add_black_box(memo, region) - return memo unless region - - memo.draw_rect([0, 0, 0, 0], *region.to_top_left_corner_coordinates, fill: true) - end - - def difference_level(diff_mask, old_img, _region = nil) - self.class.difference_area_size_by(diff_mask).to_f / image_area_size(old_img) - end - - MAX_FILENAME_LENGTH = 200 - - # Vips could not work with the same file. Per each process we require to create new file - def save_image_to(image, filename) - # Dir::Tmpname will happily produce tempfile names that are too long for most unix filesystems, - # which leads to "unix error: File name too long". Apply a limit to avoid this. - limited_filename = filename.to_s[-MAX_FILENAME_LENGTH..] || filename.to_s - ::Dir::Tmpname.create([limited_filename, PNG_EXTENSION]) do |tmp_image_filename| - image.write_to_file(tmp_image_filename) - FileUtils.mv(tmp_image_filename, filename) - end - end - - def resize_image_to(image, new_width, new_height) - image.resize(new_width.to_f / image.width, vscale: new_height.to_f / image.height) - end - - def load_images(old_file_name, new_file_name) - [from_file(old_file_name), from_file(new_file_name)] - end - - def from_file(filename) - result = ::Vips::Image.new_from_file(filename.to_s) - - result = result.colourspace(:srgb) if result.bands < 3 - result = result.bandjoin(255) if result.bands == 3 - - result - end - - def draw_rectangles(images, region, rgba, offset: 0) - images.map do |image| - image.draw_rect(rgba, region.left - offset, region.top - offset, region.width + (offset * 2), region.height + (offset * 2)) - end - end - - def same_pixels?(comparison) - (comparison.new_image == comparison.base_image).min == 255 - end - - def merge(new_image, base_image) - base_image.composite2(new_image, :over) - end - - def highlight_mask(diff_mask, merged_image, color: CapybaraScreenshotDiff::RED_RGBA) - diff_mask.ifthenelse(color, merged_image * 0.75) - end - - private - - class << self - def difference_area(old_image, new_image, color_distance: 0) - mask = difference_mask(new_image, old_image, color_distance) - difference_area_size_by(mask) - end - - def difference_area_size_by(difference_mask) - diff_mask = difference_mask == 0 - diff_mask.hist_find.to_a[0][0].max - end - - def difference_mask(base_image, new_image, color_distance = nil) - result = (new_image - base_image).abs - color_distance ? result > color_distance : result - end - - def perceptual_difference_mask(base_image, new_image, threshold = 2.0) - color_diff = perceptual_color_diff(base_image, new_image) > threshold - alpha_diff = alpha_channel_diff(base_image, new_image) - alpha_diff ? (color_diff | alpha_diff) : color_diff - end - - def perceptual_color_diff(base_image, new_image) - base_rgb = (base_image.bands > 3) ? base_image.extract_band(0, n: 3) : base_image - new_rgb = (new_image.bands > 3) ? new_image.extract_band(0, n: 3) : new_image - base_lab = base_rgb.colourspace(:lab) - new_lab = new_rgb.colourspace(:lab) - base_lab.dE00(new_lab) - rescue Vips::Error - base_lab.dE76(new_lab) - end - - def alpha_channel_diff(base_image, new_image) - return unless base_image.bands > 3 && new_image.bands > 3 - - (base_image.extract_band(3) - new_image.extract_band(3)).abs > 0 - end - - def difference_region_by(diff_mask) - columns, rows = diff_mask.bandor.project - - left = columns.profile[1].min - right = columns.width - columns.flip(:horizontal).profile[1].min - - top = rows.profile[0].min - bottom = rows.height - rows.flip(:vertical).profile[0].min - - return nil if right < left || bottom < top - - Region.from_edge_coordinates(left, top, right, bottom) - end - end - end - end - end - end -end +# Forwarder (ADR-004 v2 step 4): VipsDriver lives in SnapDiff::Drivers now; +# the module alias in capybara/screenshot/diff/drivers.rb makes it reachable +# as Capybara::Screenshot::Diff::Drivers::VipsDriver. +require "capybara/screenshot/diff/drivers" +require "snap_diff/drivers/vips_driver" diff --git a/lib/snap_diff/drivers.rb b/lib/snap_diff/drivers.rb new file mode 100644 index 00000000..d6c26539 --- /dev/null +++ b/lib/snap_diff/drivers.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +module SnapDiff + # Compare two images and determine if they are equal, different, or within some comparison + # range considering color values and difference area size. + module Drivers + def self.for(driver_options = {}) + driver_option = driver_options.is_a?(Hash) ? driver_options.fetch(:driver, :chunky_png) : driver_options + return driver_option unless driver_option.is_a?(Symbol) + + Utils.find_driver_class_for(driver_option).new + end + end +end diff --git a/lib/snap_diff/drivers/chunky_png_driver.rb b/lib/snap_diff/drivers/chunky_png_driver.rb new file mode 100644 index 00000000..af76e4ae --- /dev/null +++ b/lib/snap_diff/drivers/chunky_png_driver.rb @@ -0,0 +1,298 @@ +# frozen_string_literal: true + +begin + require "chunky_png" +rescue LoadError => e + raise 'Required chunky_png gem is missing. Add `gem "chunky_png"` to Gemfile' if e.message.match?(/chunky_png/i) + raise +end + +require "snap_diff/driver" +require "snap_diff/drivers" +require "capybara/screenshot/diff/difference" + +module SnapDiff + module Drivers + class ChunkyPNGDriver + include SnapDiff::Driver + include ChunkyPNG::Color + + def load_images(old_file_name, new_file_name) + old_bytes, new_bytes = load_image_files(old_file_name, new_file_name) + + _load_images(old_bytes, new_bytes) + end + + def add_black_box(image, _region) + image + end + + def find_difference_region(comparison) + DifferenceRegionFinder.new(comparison, self).perform + end + + def crop(region, i) + i.crop(*region.to_top_left_corner_coordinates) + end + + def from_file(filename_or_path) + ChunkyPNG::Image.from_file(filename_or_path.to_s) + end + + def save_image_to(image, filename) + image.save(filename, :fast_rgba) + end + + def resize_image_to(image, new_width, new_height) + image.resample_bilinear(new_width, new_height) + end + + def load_image_files(old_file_name, file_name) + [old_file_name.binread, file_name.binread] + end + + def draw_rectangles(images, region, (r, g, b), offset: 0) + border_color = ChunkyPNG::Color.rgb(r, g, b) + border_shadow = ChunkyPNG::Color.rgba(r, g, b, 100) + + images.map do |image| + new_img = image.dup + new_img.rect(region.left - offset, region.top - offset, region.right + offset, region.bottom + offset, border_color) + new_img.rect(region.left, region.top, region.right, region.bottom, border_shadow) + new_img + end + end + + def same_pixels?(comparison) + comparison.new_image == comparison.base_image + end + + private + + def _load_images(old_file, new_file) + [ChunkyPNG::Image.from_blob(old_file), ChunkyPNG::Image.from_blob(new_file)] + end + + class DifferenceRegionFinder + attr_accessor :skip_area, :color_distance_limit, :shift_distance_limit + + def initialize(comparison, driver = nil) + @comparison = comparison + @driver = driver + + @color_distance_limit = comparison.options[:color_distance_limit] + @shift_distance_limit = comparison.options[:shift_distance_limit] + @skip_area = comparison.options[:skip_area] + end + + def perform + find_difference_region(@comparison) + end + + def find_difference_region(comparison) + new_image, base_image, = comparison.new_image, comparison.base_image + + meta = {} + meta[:max_color_distance] = 0 + meta[:max_shift_distance] = 0 if shift_distance_limit + + region = find_top(base_image, new_image, cache: meta) + region = if region.nil? || region[1].nil? + nil + else + find_diff_rectangle(base_image, new_image, region, cache: meta) + end + + result = Capybara::Screenshot::Diff::Difference.new(region, meta, comparison) + + unless result.blank? + meta[:max_color_distance] = meta[:max_color_distance].ceil(1) if meta[:max_color_distance] + + if comparison.options[:tolerance] + meta[:difference_level] = difference_level(nil, base_image, region) + end + end + + result + end + + def difference_level(_diff_mask, base_image, region) + image_area_size = @driver.image_area_size(base_image) + return nil if image_area_size.zero? + + region.size.to_f / image_area_size + end + + def find_diff_rectangle(org_img, new_img, area_coordinates, cache:) + left, top, right, bottom = find_left_right_and_top(org_img, new_img, area_coordinates, cache: cache) + bottom = find_bottom(org_img, new_img, left, right, bottom, cache: cache) + + Region.from_edge_coordinates(left, top, right, bottom) + end + + def find_top(old_img, new_img, cache:) + old_img.height.times do |y| + old_img.width.times do |x| + return [x, y, x, y] unless same_color?(old_img, new_img, x, y, cache: cache) + end + end + nil + end + + def find_left_right_and_top(old_img, new_img, region, cache:) + region = region.is_a?(Region) ? region.to_edge_coordinates : region + + left = region[0] || old_img.width - 1 + top = region[1] + right = region[2] || 0 + bottom = region[3] + + old_img.height.times do |y| + (0...left).find do |x| + next if same_color?(old_img, new_img, x, y, cache: cache) + + top ||= y + bottom = y + left = x + right = x if x > right + x + end + (old_img.width - 1).step(right + 1, -1).find do |x| + unless same_color?(old_img, new_img, x, y, cache: cache) + bottom = y + right = x + end + end + end + + [left, top, right, bottom] + end + + def find_bottom(old_img, new_img, left, right, bottom, cache:) + if bottom + (old_img.height - 1).step(bottom + 1, -1).find do |y| + (left..right).find do |x| + bottom = y unless same_color?(old_img, new_img, x, y, cache: cache) + end + end + end + + bottom + end + + def same_color?(old_img, new_img, x, y, cache:) + return true if skipped_region?(x, y) + + color_distance = + color_distance_at(new_img, old_img, x, y, shift_distance_limit: @shift_distance_limit) + + if color_distance > cache[:max_color_distance] + cache[:max_color_distance] = color_distance + end + + color_matches = color_distance == 0 || + (!!@color_distance_limit && @color_distance_limit > 0 && color_distance <= @color_distance_limit) + + return color_matches if !@shift_distance_limit || cache[:max_shift_distance] == Float::INFINITY + + shift_distance = (color_matches && 0) || + shift_distance_at(new_img, old_img, x, y, color_distance_limit: @color_distance_limit) + if shift_distance && (cache[:max_shift_distance].nil? || shift_distance > cache[:max_shift_distance]) + cache[:max_shift_distance] = shift_distance + end + + color_matches + end + + def skipped_region?(x, y) + return false unless @skip_area + + @skip_area.any? { |region| region.cover?(x, y) } + end + + def color_distance_at(new_img, old_img, x, y, shift_distance_limit:) + org_color = old_img[x, y] + unless shift_distance_limit + return ChunkyPNG::Color.euclidean_distance_rgba(org_color, new_img[x, y]) + end + + start_x = [0, x - shift_distance_limit].max + end_x = [x + shift_distance_limit, new_img.width - 1].min + start_y = [0, y - shift_distance_limit].max + end_y = [y + shift_distance_limit, new_img.height - 1].min + + min_distance = Float::INFINITY + (start_y..end_y).each do |dy| + (start_x..end_x).each do |dx| + distance = ChunkyPNG::Color.euclidean_distance_rgba(org_color, new_img[dx, dy]) + return 0 if distance == 0 + min_distance = distance if distance < min_distance + end + end + min_distance + end + + def shift_distance_at(new_img, old_img, x, y, color_distance_limit:) + org_color = old_img[x, y] + shift_distance = 0 + loop do + bounds_breached = 0 + top_row = y - shift_distance + if top_row >= 0 # top + ([0, x - shift_distance].max..[x + shift_distance, new_img.width - 1].min).each do |dx| + if color_matches(new_img, org_color, dx, top_row, color_distance_limit) + return shift_distance + end + end + else + bounds_breached += 1 + end + if shift_distance > 0 + if (x - shift_distance) >= 0 # left + ([0, top_row + 1].max..[y + shift_distance, new_img.height - 2].min) + .each do |dy| + if color_matches(new_img, org_color, x - shift_distance, dy, color_distance_limit) + return shift_distance + end + end + else + bounds_breached += 1 + end + if (y + shift_distance) < new_img.height # bottom + ([0, x - shift_distance].max..[x + shift_distance, new_img.width - 1].min).each do |dx| + if color_matches(new_img, org_color, dx, y + shift_distance, color_distance_limit) + return shift_distance + end + end + else + bounds_breached += 1 + end + if (x + shift_distance) < new_img.width # right + ([0, top_row + 1].max..[y + shift_distance, new_img.height - 2].min) + .each do |dy| + if color_matches(new_img, org_color, x + shift_distance, dy, color_distance_limit) + return shift_distance + end + end + else + bounds_breached += 1 + end + end + break if bounds_breached == 4 + + shift_distance += 1 + end + Float::INFINITY + end + + def color_matches(new_img, org_color, x, y, color_distance_limit) + new_color = new_img[x, y] + return new_color == org_color unless color_distance_limit + + color_distance = ChunkyPNG::Color.euclidean_distance_rgba(org_color, new_color) + color_distance <= color_distance_limit + end + end + end + end +end diff --git a/lib/snap_diff/drivers/vips_driver.rb b/lib/snap_diff/drivers/vips_driver.rb new file mode 100644 index 00000000..c40cf540 --- /dev/null +++ b/lib/snap_diff/drivers/vips_driver.rb @@ -0,0 +1,169 @@ +# frozen_string_literal: true + +begin + require "vips" +rescue LoadError => e + raise 'Required ruby-vips gem is missing. Add `gem "ruby-vips"` to Gemfile' if e.message.match?(/vips/i) + raise +end + +require "snap_diff/driver" +require "snap_diff/drivers" +require "capybara/screenshot/diff/difference" + +module SnapDiff + module Drivers + class VipsDriver + include SnapDiff::Driver + + def find_difference_region(comparison) + new_image, base_image, options = comparison.new_image, comparison.base_image, comparison.options + + diff_mask = if options[:perceptual_threshold] + self.class.perceptual_difference_mask(base_image, new_image, options[:perceptual_threshold]) + else + self.class.difference_mask(base_image, new_image, options[:color_distance_limit]) + end + region = self.class.difference_region_by(diff_mask) + # TODO: schedule research when we got this case for VIPs + # region = nil if region && region_covers_entire_image?(region, base_image) + + result = Capybara::Screenshot::Diff::Difference.new(region, {}, comparison) + + unless result.blank? + result.meta[:difference_level] = difference_level(diff_mask, base_image) if comparison.options[:tolerance] + result.meta[:diff_mask] = diff_mask + end + + result + end + + def crop(region, i) + i.crop(*region.to_top_left_corner_coordinates) + rescue Vips::Error => e + warn( + "[capybara-screenshot-diff] Crop has been failed for " \ + "{ region: #{region.to_top_left_corner_coordinates.inspect}, image: #{dimension(i).join("x")} }" + ) + raise e + end + + def filter_image_with_median(image, median_filter_window_size) + image.median(median_filter_window_size) + end + + def add_black_box(memo, region) + return memo unless region + + memo.draw_rect([0, 0, 0, 0], *region.to_top_left_corner_coordinates, fill: true) + end + + def difference_level(diff_mask, old_img, _region = nil) + self.class.difference_area_size_by(diff_mask).to_f / image_area_size(old_img) + end + + MAX_FILENAME_LENGTH = 200 + + # Vips could not work with the same file. Per each process we require to create new file + def save_image_to(image, filename) + # Dir::Tmpname will happily produce tempfile names that are too long for most unix filesystems, + # which leads to "unix error: File name too long". Apply a limit to avoid this. + limited_filename = filename.to_s[-MAX_FILENAME_LENGTH..] || filename.to_s + ::Dir::Tmpname.create([limited_filename, PNG_EXTENSION]) do |tmp_image_filename| + image.write_to_file(tmp_image_filename) + FileUtils.mv(tmp_image_filename, filename) + end + end + + def resize_image_to(image, new_width, new_height) + image.resize(new_width.to_f / image.width, vscale: new_height.to_f / image.height) + end + + def load_images(old_file_name, new_file_name) + [from_file(old_file_name), from_file(new_file_name)] + end + + def from_file(filename) + result = ::Vips::Image.new_from_file(filename.to_s) + + result = result.colourspace(:srgb) if result.bands < 3 + result = result.bandjoin(255) if result.bands == 3 + + result + end + + def draw_rectangles(images, region, rgba, offset: 0) + images.map do |image| + image.draw_rect(rgba, region.left - offset, region.top - offset, region.width + (offset * 2), region.height + (offset * 2)) + end + end + + def same_pixels?(comparison) + (comparison.new_image == comparison.base_image).min == 255 + end + + def merge(new_image, base_image) + base_image.composite2(new_image, :over) + end + + def highlight_mask(diff_mask, merged_image, color: CapybaraScreenshotDiff::RED_RGBA) + diff_mask.ifthenelse(color, merged_image * 0.75) + end + + private + + class << self + def difference_area(old_image, new_image, color_distance: 0) + mask = difference_mask(new_image, old_image, color_distance) + difference_area_size_by(mask) + end + + def difference_area_size_by(difference_mask) + diff_mask = difference_mask == 0 + diff_mask.hist_find.to_a[0][0].max + end + + def difference_mask(base_image, new_image, color_distance = nil) + result = (new_image - base_image).abs + color_distance ? result > color_distance : result + end + + def perceptual_difference_mask(base_image, new_image, threshold = 2.0) + color_diff = perceptual_color_diff(base_image, new_image) > threshold + alpha_diff = alpha_channel_diff(base_image, new_image) + alpha_diff ? (color_diff | alpha_diff) : color_diff + end + + def perceptual_color_diff(base_image, new_image) + base_rgb = (base_image.bands > 3) ? base_image.extract_band(0, n: 3) : base_image + new_rgb = (new_image.bands > 3) ? new_image.extract_band(0, n: 3) : new_image + base_lab = base_rgb.colourspace(:lab) + new_lab = new_rgb.colourspace(:lab) + base_lab.dE00(new_lab) + rescue Vips::Error + base_lab.dE76(new_lab) + end + + def alpha_channel_diff(base_image, new_image) + return unless base_image.bands > 3 && new_image.bands > 3 + + (base_image.extract_band(3) - new_image.extract_band(3)).abs > 0 + end + + def difference_region_by(diff_mask) + columns, rows = diff_mask.bandor.project + + left = columns.profile[1].min + right = columns.width - columns.flip(:horizontal).profile[1].min + + top = rows.profile[0].min + bottom = rows.height - rows.flip(:vertical).profile[0].min + + return nil if right < left || bottom < top + + Region.from_edge_coordinates(left, top, right, bottom) + end + end + end + end +end diff --git a/lib/snap_diff/utils.rb b/lib/snap_diff/utils.rb index e259c202..57178b54 100644 --- a/lib/snap_diff/utils.rb +++ b/lib/snap_diff/utils.rb @@ -25,11 +25,11 @@ def self.find_driver_class_for(driver) Capybara::Screenshot::Diff::LOADED_DRIVERS[driver] ||= case driver when :chunky_png - require "capybara/screenshot/diff/drivers/chunky_png_driver" - Capybara::Screenshot::Diff::Drivers::ChunkyPNGDriver + require "snap_diff/drivers/chunky_png_driver" + SnapDiff::Drivers::ChunkyPNGDriver when :vips - require "capybara/screenshot/diff/drivers/vips_driver" - Capybara::Screenshot::Diff::Drivers::VipsDriver + require "snap_diff/drivers/vips_driver" + SnapDiff::Drivers::VipsDriver else fail "Wrong adapter #{driver.inspect}. Available adapters: #{Capybara::Screenshot::Diff::AVAILABLE_DRIVERS.inspect}" end diff --git a/test/unit/namespace_forwarding_test.rb b/test/unit/namespace_forwarding_test.rb index b458a9d9..d04714d3 100644 --- a/test/unit/namespace_forwarding_test.rb +++ b/test/unit/namespace_forwarding_test.rb @@ -31,13 +31,17 @@ class NamespaceForwardingTest < ActiveSupport::TestCase "CapybaraScreenshotDiff::ErrorWithFilteredBacktrace" => "SnapDiff::ErrorWithFilteredBacktrace", "CapybaraScreenshotDiff::Reporters::HTML" => "SnapDiff::Reporters::HTML", "CapybaraScreenshotDiff::ScreenshotAssertion" => "SnapDiff::ScreenshotAssertion", - "CapybaraScreenshotDiff::AssertionRegistry" => "SnapDiff::AssertionRegistry" + "CapybaraScreenshotDiff::AssertionRegistry" => "SnapDiff::AssertionRegistry", + "Capybara::Screenshot::Diff::Drivers" => "SnapDiff::Drivers", + "Capybara::Screenshot::Diff::Drivers::BaseDriver" => "SnapDiff::Driver", + "Capybara::Screenshot::Diff::Drivers::ChunkyPNGDriver" => "SnapDiff::Drivers::ChunkyPNGDriver", + "Capybara::Screenshot::Diff::Drivers::VipsDriver" => "SnapDiff::Drivers::VipsDriver" }.freeze # Explicit requires: a dedicated forwarder-identity test shouldn't rely # on incidental transitive loads from other test files (or on rake's # file-load order within a single process) to make every one of these - # 21 constants resolvable. Most of these are already pulled in by + # 25 constants resolvable. Most of these are already pulled in by # test_helper's own "capybara_screenshot_diff/minitest" require; listed # here anyway so this file passes standalone. require "capybara/screenshot/diff/os" @@ -59,9 +63,20 @@ class NamespaceForwardingTest < ActiveSupport::TestCase require "capybara_screenshot_diff/error_with_filtered_backtrace" require "capybara_screenshot_diff/reporters/html" require "capybara_screenshot_diff/screenshot_assertion" + require "capybara/screenshot/diff/drivers" + require "capybara/screenshot/diff/drivers/base_driver" + require "capybara/screenshot/diff/drivers/chunky_png_driver" + begin + require "capybara/screenshot/diff/drivers/vips_driver" + rescue LoadError + # vips-less runner: the VipsDriver pair reports as a skip below, + # mirroring test/unit/drivers/vips_driver_test.rb. + end MAPPING.each do |old_name, new_name| define_method(:"test_#{old_name}_forwards_to_#{new_name}") do + skip "vips not available on this runner" if new_name.include?("Vips") && !defined?(SnapDiff::Drivers::VipsDriver) + old_const = Object.const_get(old_name) new_const = Object.const_get(new_name) @@ -71,7 +86,7 @@ class NamespaceForwardingTest < ActiveSupport::TestCase end end - test "MAPPING covers all 21 documented forwarders" do - assert_equal 21, MAPPING.size + test "MAPPING covers all 25 documented forwarders" do + assert_equal 25, MAPPING.size end end From c009100e5e05ba55b9523597c9a39c057c4a8138 Mon Sep 17 00:00:00 2001 From: Paul Keen <125715+pftg@users.noreply.github.com> Date: Sat, 22 Aug 2026 19:52:01 +0200 Subject: [PATCH 3/3] test: rescue RuntimeError in vips-less forwarding guard (review F1) vips_driver.rb re-raises the missing-gem LoadError as RuntimeError, so the LoadError-only rescue let a vips-less run crash at file load instead of skipping the vips pairs. --- test/unit/namespace_forwarding_test.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/unit/namespace_forwarding_test.rb b/test/unit/namespace_forwarding_test.rb index d04714d3..0fb9b0d7 100644 --- a/test/unit/namespace_forwarding_test.rb +++ b/test/unit/namespace_forwarding_test.rb @@ -68,7 +68,7 @@ class NamespaceForwardingTest < ActiveSupport::TestCase require "capybara/screenshot/diff/drivers/chunky_png_driver" begin require "capybara/screenshot/diff/drivers/vips_driver" - rescue LoadError + rescue LoadError, RuntimeError # vips_driver.rb re-raises missing-gem LoadError as RuntimeError # vips-less runner: the VipsDriver pair reports as a skip below, # mirroring test/unit/drivers/vips_driver_test.rb. end