Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 6 additions & 8 deletions lib/capybara/screenshot/diff/drivers.rb
Original file line number Diff line number Diff line change
@@ -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
47 changes: 8 additions & 39 deletions lib/capybara/screenshot/diff/drivers/base_driver.rb
Original file line number Diff line number Diff line change
@@ -1,41 +1,10 @@
# frozen_string_literal: true

require "capybara/screenshot/diff/difference"

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
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
end
end
end
require "capybara/screenshot/diff/drivers"
require "snap_diff/driver"

# 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
304 changes: 5 additions & 299 deletions lib/capybara/screenshot/diff/drivers/chunky_png_driver.rb
Original file line number Diff line number Diff line change
@@ -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"
Loading
Loading