snap_diff-capybara 0.0.1 → 2.0.0.alpha1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (95) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +167 -0
  3. data/CODE_OF_CONDUCT.md +129 -0
  4. data/LICENSE.txt +21 -0
  5. data/Rakefile +65 -0
  6. data/capybara-screenshot-diff.gemspec +29 -0
  7. data/docs/RELEASE_PREP.md +44 -0
  8. data/docs/UPGRADING.md +475 -0
  9. data/docs/architecture.md +267 -0
  10. data/docs/ci-integration.md +238 -0
  11. data/docs/configuration.md +395 -0
  12. data/docs/docker-testing.md +24 -0
  13. data/docs/drivers.md +102 -0
  14. data/docs/framework-setup.md +87 -0
  15. data/docs/images/snap_diff_annotated.png +0 -0
  16. data/docs/images/snap_diff_web_ui.png +0 -0
  17. data/docs/migration-guide.md +286 -0
  18. data/docs/organization.md +204 -0
  19. data/docs/reporters.md +46 -0
  20. data/docs/thread_safety.md +112 -0
  21. data/gems.rb +39 -0
  22. data/lib/capybara/screenshot/diff/annotation_service.rb +7 -0
  23. data/lib/capybara/screenshot/diff/area_calculator.rb +7 -0
  24. data/lib/capybara/screenshot/diff/browser_helpers.rb +7 -0
  25. data/lib/capybara/screenshot/diff/config_legacy.rb +113 -0
  26. data/lib/capybara/screenshot/diff/cucumber.rb +3 -0
  27. data/lib/capybara/screenshot/diff/difference.rb +8 -0
  28. data/lib/capybara/screenshot/diff/drivers/base_driver.rb +9 -0
  29. data/lib/capybara/screenshot/diff/drivers/chunky_png_driver.rb +7 -0
  30. data/lib/capybara/screenshot/diff/drivers/vips_driver.rb +7 -0
  31. data/lib/capybara/screenshot/diff/drivers.rb +9 -0
  32. data/lib/capybara/screenshot/diff/image_compare.rb +12 -0
  33. data/lib/capybara/screenshot/diff/image_preprocessor.rb +7 -0
  34. data/lib/capybara/screenshot/diff/os.rb +14 -0
  35. data/lib/capybara/screenshot/diff/region.rb +107 -0
  36. data/lib/capybara/screenshot/diff/reporters/default.rb +109 -0
  37. data/lib/capybara/screenshot/diff/screenshot_matcher.rb +7 -0
  38. data/lib/capybara/screenshot/diff/screenshoter.rb +7 -0
  39. data/lib/capybara/screenshot/diff/stable_screenshoter.rb +7 -0
  40. data/lib/capybara/screenshot/diff/utils.rb +7 -0
  41. data/lib/capybara/screenshot/diff/vcs.rb +7 -0
  42. data/lib/capybara/screenshot/diff/version.rb +15 -0
  43. data/lib/capybara/screenshot/diff.rb +3 -0
  44. data/lib/capybara-screenshot-diff.rb +3 -0
  45. data/lib/capybara_screenshot_diff/attempts_reporter.rb +7 -0
  46. data/lib/capybara_screenshot_diff/cucumber.rb +8 -0
  47. data/lib/capybara_screenshot_diff/dsl.rb +12 -0
  48. data/lib/capybara_screenshot_diff/error_with_filtered_backtrace.rb +8 -0
  49. data/lib/capybara_screenshot_diff/minitest.rb +14 -0
  50. data/lib/capybara_screenshot_diff/reporters/html.rb +7 -0
  51. data/lib/capybara_screenshot_diff/rspec.rb +8 -0
  52. data/lib/capybara_screenshot_diff/screenshot_assertion.rb +71 -0
  53. data/lib/capybara_screenshot_diff/screenshot_namer.rb +7 -0
  54. data/lib/capybara_screenshot_diff/snap.rb +7 -0
  55. data/lib/capybara_screenshot_diff/snap_manager.rb +7 -0
  56. data/lib/capybara_screenshot_diff/static.rb +9 -0
  57. data/lib/capybara_screenshot_diff.rb +50 -0
  58. data/lib/snap_diff/annotation_service.rb +84 -0
  59. data/lib/snap_diff/area_calculator.rb +56 -0
  60. data/lib/snap_diff/attempts_reporter.rb +51 -0
  61. data/lib/snap_diff/browser_helpers.rb +121 -0
  62. data/lib/snap_diff/capture/viewport.rb +40 -0
  63. data/lib/snap_diff/comparison.rb +238 -0
  64. data/lib/snap_diff/comparison_result.rb +104 -0
  65. data/lib/snap_diff/config.rb +78 -0
  66. data/lib/snap_diff/deprecation.rb +84 -0
  67. data/lib/snap_diff/driver.rb +37 -0
  68. data/lib/snap_diff/drivers/chunky_png_driver.rb +298 -0
  69. data/lib/snap_diff/drivers/vips_driver.rb +171 -0
  70. data/lib/snap_diff/drivers.rb +14 -0
  71. data/lib/snap_diff/dsl.rb +143 -0
  72. data/lib/snap_diff/error_with_filtered_backtrace.rb +32 -0
  73. data/lib/snap_diff/image_preprocessor.rb +68 -0
  74. data/lib/snap_diff/integrations/cucumber.rb +22 -0
  75. data/lib/snap_diff/integrations/minitest.rb +70 -0
  76. data/lib/snap_diff/integrations/rspec.rb +68 -0
  77. data/lib/snap_diff/legacy_shims.rb +99 -0
  78. data/lib/snap_diff/os.rb +17 -0
  79. data/lib/snap_diff/reporters/html.rb +143 -0
  80. data/lib/snap_diff/reporters/templates/report.html.erb +463 -0
  81. data/lib/snap_diff/reporting.rb +53 -0
  82. data/lib/snap_diff/screenshot_assertion.rb +143 -0
  83. data/lib/snap_diff/screenshot_matcher.rb +113 -0
  84. data/lib/snap_diff/screenshot_namer.rb +81 -0
  85. data/lib/snap_diff/screenshoter.rb +129 -0
  86. data/lib/snap_diff/snap.rb +66 -0
  87. data/lib/snap_diff/snap_manager.rb +125 -0
  88. data/lib/snap_diff/stable_screenshoter.rb +103 -0
  89. data/lib/snap_diff/static.rb +11 -0
  90. data/lib/snap_diff/utils.rb +38 -0
  91. data/lib/snap_diff/vcs.rb +35 -0
  92. data/lib/snap_diff/version.rb +5 -0
  93. data/lib/snap_diff.rb +65 -0
  94. metadata +160 -10
  95. data/README.md +0 -3
@@ -0,0 +1,104 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module SnapDiff
6
+ # Represents the result of comparing two images
7
+ #
8
+ # This value object encapsulates the result of an image comparison operation.
9
+ # It follows the Single Responsibility Principle by focusing solely on representing
10
+ # the difference state, including:
11
+ # - Whether images are different or equal
12
+ # - Why they differ (dimensions, pixels, etc.)
13
+ # - The specific region of difference
14
+ # - Whether differences are tolerable based on configured thresholds
15
+ #
16
+ # As part of the layered comparison architecture, this class represents the final
17
+ # output of the comparison process, containing all data needed for reporting.
18
+ class ComparisonResult < Struct.new(:region, :meta, :comparison, :failed_by, :base_image_path, :image_path, keyword_init: nil)
19
+ def self.build_null(comparison, base_image_path, new_image_path, failed_by = nil)
20
+ ComparisonResult.new(
21
+ nil,
22
+ {difference_level: nil, max_color_distance: 0},
23
+ comparison,
24
+ failed_by,
25
+ base_image_path,
26
+ new_image_path
27
+ ).freeze
28
+ end
29
+
30
+ def different?
31
+ failed? || !(blank? || tolerable?)
32
+ end
33
+
34
+ def equal?
35
+ !different?
36
+ end
37
+
38
+ def failed?
39
+ !!failed_by
40
+ end
41
+
42
+ def options
43
+ comparison.options
44
+ end
45
+
46
+ def tolerance
47
+ options[:tolerance]
48
+ end
49
+
50
+ def skip_area
51
+ comparison.skip_area
52
+ end
53
+
54
+ def area_size_limit
55
+ options[:area_size_limit]
56
+ end
57
+
58
+ def blank?
59
+ region.nil? || region_area_size.zero?
60
+ end
61
+
62
+ def region_area_size
63
+ @region_area_size ||= region&.size || 0
64
+ end
65
+
66
+ def ratio
67
+ meta[:difference_level]
68
+ end
69
+
70
+ def to_h
71
+ {area_size: region_area_size, region: coordinates}.merge!(meta)
72
+ end
73
+
74
+ def coordinates
75
+ region&.to_edge_coordinates
76
+ end
77
+
78
+ # One-line debugging summary with the difference metrics.
79
+ # (Error messages use #to_h — see Reporters::Default#build_error_message.)
80
+ def inspect
81
+ "#<#{self.class.name} different=#{different?} failed_by=#{failed_by.inspect} " \
82
+ "area_size=#{region_area_size} region=#{coordinates.inspect} " \
83
+ "difference_level=#{ratio.inspect} " \
84
+ "base=#{original_image_path} new=#{new_image_path}>"
85
+ end
86
+
87
+ def tolerable?
88
+ !!((area_size_limit && area_size_limit >= region_area_size) || (tolerance && tolerance >= ratio))
89
+ end
90
+
91
+ # Path accessors for backward compatibility
92
+ def new_image_path
93
+ image_path || comparison&.new_image_path
94
+ end
95
+
96
+ def original_image_path
97
+ base_image_path || comparison&.base_image_path
98
+ end
99
+
100
+ def diff_mask
101
+ meta[:diff_mask]
102
+ end
103
+ end
104
+ end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ # The MAPPING below references the legacy modules, so they must be loaded
4
+ # first. Requires config_legacy directly (a leaf, see its own header
5
+ # comment), not the capybara_screenshot_diff umbrella -- config.rb is
6
+ # required by both snap_diff.rb and (indirectly) capybara_screenshot_diff.rb,
7
+ # and neither of those may lead back here.
8
+ require "capybara/screenshot/diff/config_legacy"
9
+
10
+ module SnapDiff
11
+ # Flat, additive consolidation of every existing
12
+ # +Capybara::Screenshot+ / +Capybara::Screenshot::Diff+ +mattr_accessor+
13
+ # setting behind one object: <tt>SnapDiff.config.<em>attr</em></tt>.
14
+ #
15
+ # Storage ownership: the OLD mattr_accessors remain the single source of
16
+ # truth. Config holds no state of its own -- every reader/writer defined
17
+ # from {MAPPING} simply forwards to the existing accessor. This is
18
+ # deliberate, not just simplest: several of those accessors carry default
19
+ # logic with observable timing (+fail_if_new+ derives its default from
20
+ # +ENV["CI"]+, +root+ falls back to +Rails.root+) that Rails' +mattr_accessor+
21
+ # evaluates once, at class-body-eval time, when +capybara_screenshot_diff.rb+
22
+ # first loads. Re-implementing that logic here -- even faithfully -- would
23
+ # mean evaluating it again, at a different moment (Config#new time), which
24
+ # is exactly the kind of "when defaults are evaluated" divergence the v2
25
+ # consolidation must not introduce. Delegating sidesteps the question
26
+ # entirely: Config never evaluates a default, it only ever forwards to
27
+ # whichever accessor already owns one. Bidirectional consistency (a write
28
+ # through either the old accessor or Config is visible through the other)
29
+ # falls out for free, because both paths read and write the exact same
30
+ # class-variable-backed storage.
31
+ class Config
32
+ # config attr name => [owning module, mattr_accessor name].
33
+ #
34
+ # The two names differ only for +screenshot_enabled+:
35
+ # +Capybara::Screenshot.enabled+ and +Capybara::Screenshot::Diff.enabled+
36
+ # are independent settings (see +Capybara::Screenshot.active?+, which
37
+ # reads both) that happen to share a bare name in their own modules. A
38
+ # flat Config can't expose two attributes both called +enabled+, so the
39
+ # Screenshot-side one is renamed here; Diff's keeps the bare +enabled+
40
+ # name since it's the one most existing configuration touches directly.
41
+ MAPPING = {
42
+ # Capybara::Screenshot
43
+ add_driver_path: [Capybara::Screenshot, :add_driver_path],
44
+ add_os_path: [Capybara::Screenshot, :add_os_path],
45
+ blur_active_element: [Capybara::Screenshot, :blur_active_element],
46
+ screenshot_enabled: [Capybara::Screenshot, :enabled],
47
+ hide_caret: [Capybara::Screenshot, :hide_caret],
48
+ disable_animations: [Capybara::Screenshot, :disable_animations],
49
+ root: [Capybara::Screenshot, :root],
50
+ stability_time_limit: [Capybara::Screenshot, :stability_time_limit],
51
+ window_size: [Capybara::Screenshot, :window_size],
52
+ save_path: [Capybara::Screenshot, :save_path],
53
+ use_lfs: [Capybara::Screenshot, :use_lfs],
54
+ screenshot_format: [Capybara::Screenshot, :screenshot_format],
55
+ capybara_screenshot_options: [Capybara::Screenshot, :capybara_screenshot_options],
56
+ # Capybara::Screenshot::Diff
57
+ delayed: [Capybara::Screenshot::Diff, :delayed],
58
+ area_size_limit: [Capybara::Screenshot::Diff, :area_size_limit],
59
+ fail_if_new: [Capybara::Screenshot::Diff, :fail_if_new],
60
+ pending_if_new: [Capybara::Screenshot::Diff, :pending_if_new],
61
+ fail_on_difference: [Capybara::Screenshot::Diff, :fail_on_difference],
62
+ color_distance_limit: [Capybara::Screenshot::Diff, :color_distance_limit],
63
+ enabled: [Capybara::Screenshot::Diff, :enabled],
64
+ shift_distance_limit: [Capybara::Screenshot::Diff, :shift_distance_limit],
65
+ skip_area: [Capybara::Screenshot::Diff, :skip_area],
66
+ driver: [Capybara::Screenshot::Diff, :driver],
67
+ tolerance: [Capybara::Screenshot::Diff, :tolerance],
68
+ perceptual_threshold: [Capybara::Screenshot::Diff, :perceptual_threshold],
69
+ screenshoter: [Capybara::Screenshot::Diff, :screenshoter],
70
+ manager: [Capybara::Screenshot::Diff, :manager]
71
+ }.freeze
72
+
73
+ MAPPING.each do |name, (mod, mattr)|
74
+ define_method(name) { mod.public_send(mattr) }
75
+ define_method(:"#{name}=") { |value| mod.public_send(:"#{mattr}=", value) }
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SnapDiff
4
+ # @api private
5
+ #
6
+ # Internal until the v2 namespace transition; not a public contract.
7
+ #
8
+ # Warn-once-per-subject deprecation helper. Dormant: nothing in the
9
+ # current codebase calls this yet -- it exists so the gated v2 shim layer
10
+ # (ADR-004's +const_missing+-based legacy constant shim and the
11
+ # mattr_accessor-to-Config method shims) has pre-tested warning
12
+ # machinery to call into once it lands.
13
+ module Deprecation
14
+ # Emission channel: Kernel#warn, not a direct +$stderr.puts+.
15
+ #
16
+ # Kernel#warn delegates to +Warning.warn+ (Ruby >= 2.4), so anything
17
+ # that hooks +Warning.warn+ -- a test suite that raises on warnings, a
18
+ # custom log formatter, Ruby's own -W flag -- sees these messages the
19
+ # same way it sees every other Ruby warning. Writing straight to
20
+ # +$stderr+ would bypass that hook entirely and be invisible to any
21
+ # caller who has customized +Warning+ behavior.
22
+ MUTEX = Mutex.new
23
+ @seen = {}
24
+
25
+ class << self
26
+ # Emit a deprecation warning for +subject+, exactly once per unique
27
+ # +subject+ per process.
28
+ #
29
+ # @param subject [String] the deprecated old-namespace name being
30
+ # referenced, e.g. "Capybara::Screenshot::Diff::ImageCompare"
31
+ # @param replacement [String] the new-namespace name to use instead
32
+ # @param category [Symbol] what kind of thing moved, for the human
33
+ # reading the message (e.g. :constant, :config)
34
+ # @return [void]
35
+ def warn(subject, replacement, category:)
36
+ return if SnapDiff.silence_deprecations?
37
+
38
+ first_time = MUTEX.synchronize do
39
+ @seen.key?(subject) ? false : (@seen[subject] = true)
40
+ end
41
+ return unless first_time
42
+
43
+ Kernel.warn(message_for(subject, replacement, category))
44
+ end
45
+
46
+ # @api private
47
+ #
48
+ # Clears the seen-set. For tests only -- lets each example assert
49
+ # "warns once" from a clean slate instead of leaking state across
50
+ # the suite.
51
+ # @return [void]
52
+ def reset!
53
+ MUTEX.synchronize { @seen.clear }
54
+ end
55
+
56
+ private
57
+
58
+ def message_for(subject, replacement, category)
59
+ "[snap_diff deprecation] `#{subject}` is deprecated (#{category}); " \
60
+ "use `#{replacement}` instead."
61
+ end
62
+ end
63
+ end
64
+
65
+ class << self
66
+ # @api private
67
+ attr_accessor :silence_deprecations
68
+
69
+ # @api private
70
+ #
71
+ # @return [Boolean] true if deprecation warnings should be suppressed,
72
+ # either via the {silence_deprecations} accessor or the
73
+ # SNAP_DIFF_SILENCE_DEPRECATIONS env var (truthy = "1"/"true").
74
+ def silence_deprecations?
75
+ !!silence_deprecations || truthy_env?(ENV["SNAP_DIFF_SILENCE_DEPRECATIONS"])
76
+ end
77
+
78
+ private
79
+
80
+ def truthy_env?(value)
81
+ %w[1 true].include?(value.to_s.downcase)
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SnapDiff
4
+ # Shared default behavior for image-processing drivers.
5
+ #
6
+ # Replaces the old +Capybara::Screenshot::Diff::Drivers::BaseDriver+
7
+ # superclass (ADR-004 v2 step 4): concrete drivers +include Driver+
8
+ # instead of inheriting. Method names are intentionally unchanged from
9
+ # v1 — see dissent #4 in the v2 architecture design.
10
+ module Driver
11
+ PNG_EXTENSION = ".png"
12
+
13
+ def same_dimension?(comparison)
14
+ dimension(comparison.base_image) == dimension(comparison.new_image)
15
+ end
16
+
17
+ def height_for(image)
18
+ image.height
19
+ end
20
+
21
+ def width_for(image)
22
+ image.width
23
+ end
24
+
25
+ def image_area_size(image)
26
+ width_for(image) * height_for(image)
27
+ end
28
+
29
+ def dimension(image)
30
+ [width_for(image), height_for(image)]
31
+ end
32
+
33
+ def supports?(feature)
34
+ respond_to?(feature)
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,298 @@
1
+ # frozen_string_literal: true
2
+
3
+ begin
4
+ require "chunky_png"
5
+ rescue LoadError => e
6
+ raise 'Required chunky_png gem is missing. Add `gem "chunky_png"` to Gemfile' if e.message.match?(/chunky_png/i)
7
+ raise
8
+ end
9
+
10
+ require "snap_diff/driver"
11
+ require "snap_diff/drivers"
12
+ require "snap_diff/comparison_result"
13
+
14
+ module SnapDiff
15
+ module Drivers
16
+ class ChunkyPNGDriver
17
+ include SnapDiff::Driver
18
+ include ChunkyPNG::Color
19
+
20
+ def load_images(old_file_name, new_file_name)
21
+ old_bytes, new_bytes = load_image_files(old_file_name, new_file_name)
22
+
23
+ _load_images(old_bytes, new_bytes)
24
+ end
25
+
26
+ def add_black_box(image, _region)
27
+ image
28
+ end
29
+
30
+ def find_difference_region(comparison)
31
+ DifferenceRegionFinder.new(comparison, self).perform
32
+ end
33
+
34
+ def crop(region, i)
35
+ i.crop(*region.to_top_left_corner_coordinates)
36
+ end
37
+
38
+ def from_file(filename_or_path)
39
+ ChunkyPNG::Image.from_file(filename_or_path.to_s)
40
+ end
41
+
42
+ def save_image_to(image, filename)
43
+ image.save(filename, :fast_rgba)
44
+ end
45
+
46
+ def resize_image_to(image, new_width, new_height)
47
+ image.resample_bilinear(new_width, new_height)
48
+ end
49
+
50
+ def load_image_files(old_file_name, file_name)
51
+ [old_file_name.binread, file_name.binread]
52
+ end
53
+
54
+ def draw_rectangles(images, region, (r, g, b), offset: 0)
55
+ border_color = ChunkyPNG::Color.rgb(r, g, b)
56
+ border_shadow = ChunkyPNG::Color.rgba(r, g, b, 100)
57
+
58
+ images.map do |image|
59
+ new_img = image.dup
60
+ new_img.rect(region.left - offset, region.top - offset, region.right + offset, region.bottom + offset, border_color)
61
+ new_img.rect(region.left, region.top, region.right, region.bottom, border_shadow)
62
+ new_img
63
+ end
64
+ end
65
+
66
+ def same_pixels?(comparison)
67
+ comparison.new_image == comparison.base_image
68
+ end
69
+
70
+ private
71
+
72
+ def _load_images(old_file, new_file)
73
+ [ChunkyPNG::Image.from_blob(old_file), ChunkyPNG::Image.from_blob(new_file)]
74
+ end
75
+
76
+ class DifferenceRegionFinder
77
+ attr_accessor :skip_area, :color_distance_limit, :shift_distance_limit
78
+
79
+ def initialize(comparison, driver = nil)
80
+ @comparison = comparison
81
+ @driver = driver
82
+
83
+ @color_distance_limit = comparison.options[:color_distance_limit]
84
+ @shift_distance_limit = comparison.options[:shift_distance_limit]
85
+ @skip_area = comparison.options[:skip_area]
86
+ end
87
+
88
+ def perform
89
+ find_difference_region(@comparison)
90
+ end
91
+
92
+ def find_difference_region(comparison)
93
+ new_image, base_image, = comparison.new_image, comparison.base_image
94
+
95
+ meta = {}
96
+ meta[:max_color_distance] = 0
97
+ meta[:max_shift_distance] = 0 if shift_distance_limit
98
+
99
+ region = find_top(base_image, new_image, cache: meta)
100
+ region = if region.nil? || region[1].nil?
101
+ nil
102
+ else
103
+ find_diff_rectangle(base_image, new_image, region, cache: meta)
104
+ end
105
+
106
+ result = ComparisonResult.new(region, meta, comparison)
107
+
108
+ unless result.blank?
109
+ meta[:max_color_distance] = meta[:max_color_distance].ceil(1) if meta[:max_color_distance]
110
+
111
+ if comparison.options[:tolerance]
112
+ meta[:difference_level] = difference_level(nil, base_image, region)
113
+ end
114
+ end
115
+
116
+ result
117
+ end
118
+
119
+ def difference_level(_diff_mask, base_image, region)
120
+ image_area_size = @driver.image_area_size(base_image)
121
+ return nil if image_area_size.zero?
122
+
123
+ region.size.to_f / image_area_size
124
+ end
125
+
126
+ def find_diff_rectangle(org_img, new_img, area_coordinates, cache:)
127
+ left, top, right, bottom = find_left_right_and_top(org_img, new_img, area_coordinates, cache: cache)
128
+ bottom = find_bottom(org_img, new_img, left, right, bottom, cache: cache)
129
+
130
+ Region.from_edge_coordinates(left, top, right, bottom)
131
+ end
132
+
133
+ def find_top(old_img, new_img, cache:)
134
+ old_img.height.times do |y|
135
+ old_img.width.times do |x|
136
+ return [x, y, x, y] unless same_color?(old_img, new_img, x, y, cache: cache)
137
+ end
138
+ end
139
+ nil
140
+ end
141
+
142
+ def find_left_right_and_top(old_img, new_img, region, cache:)
143
+ region = region.is_a?(Region) ? region.to_edge_coordinates : region
144
+
145
+ left = region[0] || old_img.width - 1
146
+ top = region[1]
147
+ right = region[2] || 0
148
+ bottom = region[3]
149
+
150
+ old_img.height.times do |y|
151
+ (0...left).find do |x|
152
+ next if same_color?(old_img, new_img, x, y, cache: cache)
153
+
154
+ top ||= y
155
+ bottom = y
156
+ left = x
157
+ right = x if x > right
158
+ x
159
+ end
160
+ (old_img.width - 1).step(right + 1, -1).find do |x|
161
+ unless same_color?(old_img, new_img, x, y, cache: cache)
162
+ bottom = y
163
+ right = x
164
+ end
165
+ end
166
+ end
167
+
168
+ [left, top, right, bottom]
169
+ end
170
+
171
+ def find_bottom(old_img, new_img, left, right, bottom, cache:)
172
+ if bottom
173
+ (old_img.height - 1).step(bottom + 1, -1).find do |y|
174
+ (left..right).find do |x|
175
+ bottom = y unless same_color?(old_img, new_img, x, y, cache: cache)
176
+ end
177
+ end
178
+ end
179
+
180
+ bottom
181
+ end
182
+
183
+ def same_color?(old_img, new_img, x, y, cache:)
184
+ return true if skipped_region?(x, y)
185
+
186
+ color_distance =
187
+ color_distance_at(new_img, old_img, x, y, shift_distance_limit: @shift_distance_limit)
188
+
189
+ if color_distance > cache[:max_color_distance]
190
+ cache[:max_color_distance] = color_distance
191
+ end
192
+
193
+ color_matches = color_distance == 0 ||
194
+ (!!@color_distance_limit && @color_distance_limit > 0 && color_distance <= @color_distance_limit)
195
+
196
+ return color_matches if !@shift_distance_limit || cache[:max_shift_distance] == Float::INFINITY
197
+
198
+ shift_distance = (color_matches && 0) ||
199
+ shift_distance_at(new_img, old_img, x, y, color_distance_limit: @color_distance_limit)
200
+ if shift_distance && (cache[:max_shift_distance].nil? || shift_distance > cache[:max_shift_distance])
201
+ cache[:max_shift_distance] = shift_distance
202
+ end
203
+
204
+ color_matches
205
+ end
206
+
207
+ def skipped_region?(x, y)
208
+ return false unless @skip_area
209
+
210
+ @skip_area.any? { |region| region.cover?(x, y) }
211
+ end
212
+
213
+ def color_distance_at(new_img, old_img, x, y, shift_distance_limit:)
214
+ org_color = old_img[x, y]
215
+ unless shift_distance_limit
216
+ return ChunkyPNG::Color.euclidean_distance_rgba(org_color, new_img[x, y])
217
+ end
218
+
219
+ start_x = [0, x - shift_distance_limit].max
220
+ end_x = [x + shift_distance_limit, new_img.width - 1].min
221
+ start_y = [0, y - shift_distance_limit].max
222
+ end_y = [y + shift_distance_limit, new_img.height - 1].min
223
+
224
+ min_distance = Float::INFINITY
225
+ (start_y..end_y).each do |dy|
226
+ (start_x..end_x).each do |dx|
227
+ distance = ChunkyPNG::Color.euclidean_distance_rgba(org_color, new_img[dx, dy])
228
+ return 0 if distance == 0
229
+ min_distance = distance if distance < min_distance
230
+ end
231
+ end
232
+ min_distance
233
+ end
234
+
235
+ def shift_distance_at(new_img, old_img, x, y, color_distance_limit:)
236
+ org_color = old_img[x, y]
237
+ shift_distance = 0
238
+ loop do
239
+ bounds_breached = 0
240
+ top_row = y - shift_distance
241
+ if top_row >= 0 # top
242
+ ([0, x - shift_distance].max..[x + shift_distance, new_img.width - 1].min).each do |dx|
243
+ if color_matches(new_img, org_color, dx, top_row, color_distance_limit)
244
+ return shift_distance
245
+ end
246
+ end
247
+ else
248
+ bounds_breached += 1
249
+ end
250
+ if shift_distance > 0
251
+ if (x - shift_distance) >= 0 # left
252
+ ([0, top_row + 1].max..[y + shift_distance, new_img.height - 2].min)
253
+ .each do |dy|
254
+ if color_matches(new_img, org_color, x - shift_distance, dy, color_distance_limit)
255
+ return shift_distance
256
+ end
257
+ end
258
+ else
259
+ bounds_breached += 1
260
+ end
261
+ if (y + shift_distance) < new_img.height # bottom
262
+ ([0, x - shift_distance].max..[x + shift_distance, new_img.width - 1].min).each do |dx|
263
+ if color_matches(new_img, org_color, dx, y + shift_distance, color_distance_limit)
264
+ return shift_distance
265
+ end
266
+ end
267
+ else
268
+ bounds_breached += 1
269
+ end
270
+ if (x + shift_distance) < new_img.width # right
271
+ ([0, top_row + 1].max..[y + shift_distance, new_img.height - 2].min)
272
+ .each do |dy|
273
+ if color_matches(new_img, org_color, x + shift_distance, dy, color_distance_limit)
274
+ return shift_distance
275
+ end
276
+ end
277
+ else
278
+ bounds_breached += 1
279
+ end
280
+ end
281
+ break if bounds_breached == 4
282
+
283
+ shift_distance += 1
284
+ end
285
+ Float::INFINITY
286
+ end
287
+
288
+ def color_matches(new_img, org_color, x, y, color_distance_limit)
289
+ new_color = new_img[x, y]
290
+ return new_color == org_color unless color_distance_limit
291
+
292
+ color_distance = ChunkyPNG::Color.euclidean_distance_rgba(org_color, new_color)
293
+ color_distance <= color_distance_limit
294
+ end
295
+ end
296
+ end
297
+ end
298
+ end