Add support for per-test fuzzy matching

Adds support for a new SUPPRESSIONS_EXACT_MATCHING file that
"suppresses" exact matching for individual tests, instead falling back
to fuzzy matching.

Fixed: pdfium:1991
Change-Id: I2658bb51d97f465f42dbb13650842998546090c1
Reviewed-on: https://pdfium-review.googlesource.com/c/pdfium/+/104350
Reviewed-by: Nigi <nigi@chromium.org>
Commit-Queue: K. Moon <kmoon@chromium.org>
diff --git a/testing/SUPPRESSIONS_EXACT_MATCHING b/testing/SUPPRESSIONS_EXACT_MATCHING
new file mode 100644
index 0000000..374549d
--- /dev/null
+++ b/testing/SUPPRESSIONS_EXACT_MATCHING
@@ -0,0 +1,20 @@
+# Copyright 2023 The PDFium Authors
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
+#
+# List of tests to use fuzzy instead of exact matching, one per line.
+# There are four space-separated columns per line
+# Each column (except column 0) can contain a comma-separated list of values.
+#
+# Column 0: test file name
+# Column 1: platform: *, win, mac, linux
+# Column 2: v8 support: *, nov8, v8
+# Column 3: xfa support: *, noxfa, xfa
+# Column 4: rendering support: *, agg, skia
+#
+# All columns on a line on a line must match, but filenames may be repeated
+# on subsequent lines to suppress more cases.  Within each column, any one of
+# the comma-separated values must match in order for the colum to "match".
+# The filenames and keywords are case-sensitive.
+#
+# Try to keep the file alphabetized within each category of test.
diff --git a/testing/tools/pngdiffer.py b/testing/tools/pngdiffer.py
index 91468bb..2044a34 100755
--- a/testing/tools/pngdiffer.py
+++ b/testing/tools/pngdiffer.py
@@ -10,6 +10,9 @@
 import subprocess
 import sys
 
+EXACT_MATCHING = 'exact'
+FUZZY_MATCHING = 'fuzzy'
+
 _PNG_OPTIMIZER = 'optipng'
 
 _COMMON_SUFFIX_ORDER = ('_{os}', '')
@@ -68,10 +71,12 @@
     except subprocess.CalledProcessError as e:
       return e
 
-  def _RunImageCompareCommand(self, image_diff):
+  def _RunImageCompareCommand(self, image_diff, image_matching_algorithm):
     cmd = [self.pdfium_diff_path]
     if self.reverse_byte_order:
       cmd.append('--reverse-byte-order')
+    if image_matching_algorithm == FUZZY_MATCHING:
+      cmd.append('--fuzzy')
     cmd.extend([image_diff.actual_path, image_diff.expected_path])
     return self._RunCommand(cmd)
 
@@ -82,7 +87,8 @@
         image_diff.expected_path, image_diff.diff_path
     ])
 
-  def ComputeDifferences(self, input_filename, source_dir, working_dir):
+  def ComputeDifferences(self, input_filename, source_dir, working_dir,
+                         image_matching_algorithm):
     """Computes differences between actual and expected image files.
 
     Returns:
@@ -102,7 +108,8 @@
       if os.path.exists(expected_path):
         page_diff.expected_path = expected_path
 
-        compare_error = self._RunImageCompareCommand(page_diff)
+        compare_error = self._RunImageCompareCommand(page_diff,
+                                                     image_matching_algorithm)
         if compare_error:
           page_diff.reason = str(compare_error)
 
@@ -115,7 +122,8 @@
           # Validate that no other paths match.
           for unexpected_path in path_templates.GetExpectedPaths(page)[1:]:
             page_diff.expected_path = unexpected_path
-            if not self._RunImageCompareCommand(page_diff):
+            if not self._RunImageCompareCommand(page_diff,
+                                                image_matching_algorithm):
               page_diff.reason = f'Also matches {unexpected_path}'
               break
           page_diff.expected_path = expected_path
@@ -129,7 +137,8 @@
 
     return image_diffs
 
-  def Regenerate(self, input_filename, source_dir, working_dir):
+  def Regenerate(self, input_filename, source_dir, working_dir,
+                 image_matching_algorithm):
     path_templates = _PathTemplates(input_filename, source_dir, working_dir,
                                     self.os_name, self.suffix_order)
     for page in itertools.count():
@@ -142,7 +151,8 @@
         # Match against all expected page images.
         for index, expected_path in enumerate(expected_paths):
           page_diff.expected_path = expected_path
-          if not self._RunImageCompareCommand(page_diff):
+          if not self._RunImageCompareCommand(page_diff,
+                                              image_matching_algorithm):
             if first_match is None:
               first_match = index
             last_match = index
diff --git a/testing/tools/suppressor.py b/testing/tools/suppressor.py
index a7146c5..989f4dd 100755
--- a/testing/tools/suppressor.py
+++ b/testing/tools/suppressor.py
@@ -6,6 +6,7 @@
 import os
 
 import common
+import pngdiffer
 
 
 class Suppressor:
@@ -17,6 +18,8 @@
     self.suppression_set = self._LoadSuppressedSet('SUPPRESSIONS', finder)
     self.image_suppression_set = self._LoadSuppressedSet(
         'SUPPRESSIONS_IMAGE_DIFF', finder)
+    self.exact_matching_suppression_set = self._LoadSuppressedSet(
+        'SUPPRESSIONS_EXACT_MATCHING', finder)
 
   def _LoadSuppressedSet(self, suppressions_filename, finder):
     v8_option = "v8" if self.has_v8 else "nov8"
@@ -70,3 +73,9 @@
       print("%s image diff comparison is suppressed" % input_filename)
       return True
     return False
+
+  def GetImageMatchingAlgorithm(self, input_filename):
+    if input_filename in self.exact_matching_suppression_set:
+      print(f"{input_filename} image diff comparison is fuzzy")
+      return pngdiffer.FUZZY_MATCHING
+    return pngdiffer.EXACT_MATCHING
diff --git a/testing/tools/test_runner.py b/testing/tools/test_runner.py
index 8ff288d..ad348aa 100644
--- a/testing/tools/test_runner.py
+++ b/testing/tools/test_runner.py
@@ -539,6 +539,10 @@
     return _per_process_state.test_suppressor.IsImageDiffSuppressed(
         self.input_filename)
 
+  def GetImageMatchingAlgorithm(self):
+    return _per_process_state.test_suppressor.GetImageMatchingAlgorithm(
+        self.input_filename)
+
   def RunCommand(self, command, stdout=None):
     """Runs a test command.
 
@@ -609,9 +613,11 @@
       return
     if self.IsResultSuppressed() or self.IsImageDiffSuppressed():
       return
-    _per_process_state.image_differ.Regenerate(self.input_filename,
-                                               self.source_dir,
-                                               self.working_dir)
+    _per_process_state.image_differ.Regenerate(
+        self.input_filename,
+        self.source_dir,
+        self.working_dir,
+        image_matching_algorithm=self.GetImageMatchingAlgorithm())
 
   def Generate(self):
     input_event_path = os.path.join(self.source_dir, f'{self.test_id}.evt')
@@ -727,7 +733,10 @@
 
     if self.actual_images:
       image_diffs = _per_process_state.image_differ.ComputeDifferences(
-          self.input_filename, self.source_dir, self.working_dir)
+          self.input_filename,
+          self.source_dir,
+          self.working_dir,
+          image_matching_algorithm=self.GetImageMatchingAlgorithm())
       if image_diffs:
         test_result.status = result_types.FAIL
         test_result.reason = 'Images differ'