diff --git a/.github/workflows/test-windows.yml b/.github/workflows/test-windows.yml index 6ae410a64..705c61e50 100644 --- a/.github/workflows/test-windows.yml +++ b/.github/workflows/test-windows.yml @@ -251,7 +251,7 @@ jobs: set BUILD=%GITHUB_WORKSPACE%\winbuild\build set INCLUDE=%INCLUDE%;%INCLIB% set LIB=%LIB%;%INCLIB% - cd /D %BUILD%\harfbuzz-2.6.1 + cd /D %BUILD%\harfbuzz-2.6.4 call "C:\Program Files (x86)\Microsoft Visual Studio\2019\Enterprise\VC\Auxiliary\Build\vcvarsall.bat" ${{ matrix.platform-vcvars }} echo on set CMAKE=cmake.exe -DCMAKE_VERBOSE_MAKEFILE=ON -DCMAKE_RULE_MESSAGES:BOOL=OFF @@ -269,7 +269,7 @@ jobs: set INCLUDE=C:\Program Files (x86)\Microsoft SDKs\Windows\V7.1A\Include set INCLIB=%GITHUB_WORKSPACE%\winbuild\depends\msvcr10-x32 set BUILD=%GITHUB_WORKSPACE%\winbuild\build - cd /D %BUILD%\fribidi-1.0.7 + cd /D %BUILD%\fribidi-1.0.9 call "C:\Program Files (x86)\Microsoft Visual Studio\2019\Enterprise\VC\Auxiliary\Build\vcvarsall.bat" ${{ matrix.platform-vcvars }} echo on copy /Y /B %GITHUB_WORKSPACE%\winbuild\fribidi.cmake CMakeLists.txt diff --git a/CHANGES.rst b/CHANGES.rst index 3c0355f91..869038a97 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -5,6 +5,12 @@ Changelog (Pillow) 7.1.0 (unreleased) ------------------ +- Added three new channel operations #4230 + [dwastberg, radarhere] + +- Prevent masking of Image reduce method in Jpeg2KImagePlugin #4474 + [radarhere, homm] + - Added reading of earlier ImageMagick PNG EXIF data #4471 [radarhere] diff --git a/Tests/bench_cffi_access.py b/Tests/bench_cffi_access.py index 8b172343c..f196757dc 100644 --- a/Tests/bench_cffi_access.py +++ b/Tests/bench_cffi_access.py @@ -1,9 +1,8 @@ import time -import unittest from PIL import PyAccess -from .helper import PillowTestCase, hopper +from .helper import hopper # Not running this test by default. No DOS against Travis CI. @@ -41,22 +40,17 @@ def timer(func, label, *args): ) -class BenchCffiAccess(PillowTestCase): - def test_direct(self): - im = hopper() - im.load() - # im = Image.new( "RGB", (2000, 2000), (1, 3, 2)) - caccess = im.im.pixel_access(False) - access = PyAccess.new(im, False) +def test_direct(): + im = hopper() + im.load() + # im = Image.new( "RGB", (2000, 2000), (1, 3, 2)) + caccess = im.im.pixel_access(False) + access = PyAccess.new(im, False) - assert caccess[(0, 0)] == access[(0, 0)] + assert caccess[(0, 0)] == access[(0, 0)] - print("Size: %sx%s" % im.size) - timer(iterate_get, "PyAccess - get", im.size, access) - timer(iterate_set, "PyAccess - set", im.size, access) - timer(iterate_get, "C-api - get", im.size, caccess) - timer(iterate_set, "C-api - set", im.size, caccess) - - -if __name__ == "__main__": - unittest.main() + print("Size: %sx%s" % im.size) + timer(iterate_get, "PyAccess - get", im.size, access) + timer(iterate_set, "PyAccess - set", im.size, access) + timer(iterate_get, "C-api - get", im.size, caccess) + timer(iterate_set, "C-api - set", im.size, caccess) diff --git a/Tests/check_fli_overflow.py b/Tests/check_fli_overflow.py index 206a86007..08a55d349 100644 --- a/Tests/check_fli_overflow.py +++ b/Tests/check_fli_overflow.py @@ -1,19 +1,10 @@ -import unittest - from PIL import Image -from .helper import PillowTestCase - TEST_FILE = "Tests/images/fli_overflow.fli" -class TestFliOverflow(PillowTestCase): - def test_fli_overflow(self): +def test_fli_overflow(): - # this should not crash with a malloc error or access violation - with Image.open(TEST_FILE) as im: - im.load() - - -if __name__ == "__main__": - unittest.main() + # this should not crash with a malloc error or access violation + with Image.open(TEST_FILE) as im: + im.load() diff --git a/Tests/check_imaging_leaks.py b/Tests/check_imaging_leaks.py index 8ca955ac7..db12d00e3 100755 --- a/Tests/check_imaging_leaks.py +++ b/Tests/check_imaging_leaks.py @@ -1,46 +1,44 @@ #!/usr/bin/env python -import unittest - +import pytest from PIL import Image -from .helper import PillowTestCase, is_win32 +from .helper import is_win32 min_iterations = 100 max_iterations = 10000 - -@unittest.skipIf(is_win32(), "requires Unix or macOS") -class TestImagingLeaks(PillowTestCase): - def _get_mem_usage(self): - from resource import getpagesize, getrusage, RUSAGE_SELF - - mem = getrusage(RUSAGE_SELF).ru_maxrss - return mem * getpagesize() / 1024 / 1024 - - def _test_leak(self, min_iterations, max_iterations, fn, *args, **kwargs): - mem_limit = None - for i in range(max_iterations): - fn(*args, **kwargs) - mem = self._get_mem_usage() - if i < min_iterations: - mem_limit = mem + 1 - continue - msg = "memory usage limit exceeded after %d iterations" % (i + 1) - assert mem <= mem_limit, msg - - def test_leak_putdata(self): - im = Image.new("RGB", (25, 25)) - self._test_leak(min_iterations, max_iterations, im.putdata, im.getdata()) - - def test_leak_getlist(self): - im = Image.new("P", (25, 25)) - self._test_leak( - min_iterations, - max_iterations, - # Pass a new list at each iteration. - lambda: im.point(range(256)), - ) +pytestmark = pytest.mark.skipif(is_win32(), reason="requires Unix or macOS") -if __name__ == "__main__": - unittest.main() +def _get_mem_usage(): + from resource import getpagesize, getrusage, RUSAGE_SELF + + mem = getrusage(RUSAGE_SELF).ru_maxrss + return mem * getpagesize() / 1024 / 1024 + + +def _test_leak(min_iterations, max_iterations, fn, *args, **kwargs): + mem_limit = None + for i in range(max_iterations): + fn(*args, **kwargs) + mem = _get_mem_usage() + if i < min_iterations: + mem_limit = mem + 1 + continue + msg = "memory usage limit exceeded after %d iterations" % (i + 1) + assert mem <= mem_limit, msg + + +def test_leak_putdata(): + im = Image.new("RGB", (25, 25)) + _test_leak(min_iterations, max_iterations, im.putdata, im.getdata()) + + +def test_leak_getlist(): + im = Image.new("P", (25, 25)) + _test_leak( + min_iterations, + max_iterations, + # Pass a new list at each iteration. + lambda: im.point(range(256)), + ) diff --git a/Tests/check_j2k_leaks.py b/Tests/check_j2k_leaks.py index a7a91f782..5cef4b544 100755 --- a/Tests/check_j2k_leaks.py +++ b/Tests/check_j2k_leaks.py @@ -1,9 +1,9 @@ -import unittest from io import BytesIO +import pytest from PIL import Image -from .helper import PillowTestCase, is_win32, skip_unless_feature +from .helper import is_win32, skip_unless_feature # Limits for testing the leak mem_limit = 1024 * 1048576 @@ -11,32 +11,31 @@ stack_size = 8 * 1048576 iterations = int((mem_limit / stack_size) * 2) test_file = "Tests/images/rgb_trns_ycbc.jp2" - -@unittest.skipIf(is_win32(), "requires Unix or macOS") -@skip_unless_feature("jpg_2000") -class TestJpegLeaks(PillowTestCase): - def test_leak_load(self): - from resource import setrlimit, RLIMIT_AS, RLIMIT_STACK - - setrlimit(RLIMIT_STACK, (stack_size, stack_size)) - setrlimit(RLIMIT_AS, (mem_limit, mem_limit)) - for _ in range(iterations): - with Image.open(test_file) as im: - im.load() - - def test_leak_save(self): - from resource import setrlimit, RLIMIT_AS, RLIMIT_STACK - - setrlimit(RLIMIT_STACK, (stack_size, stack_size)) - setrlimit(RLIMIT_AS, (mem_limit, mem_limit)) - for _ in range(iterations): - with Image.open(test_file) as im: - im.load() - test_output = BytesIO() - im.save(test_output, "JPEG2000") - test_output.seek(0) - test_output.read() +pytestmark = [ + pytest.mark.skipif(is_win32(), reason="requires Unix or macOS"), + skip_unless_feature("jpg_2000"), +] -if __name__ == "__main__": - unittest.main() +def test_leak_load(): + from resource import setrlimit, RLIMIT_AS, RLIMIT_STACK + + setrlimit(RLIMIT_STACK, (stack_size, stack_size)) + setrlimit(RLIMIT_AS, (mem_limit, mem_limit)) + for _ in range(iterations): + with Image.open(test_file) as im: + im.load() + + +def test_leak_save(): + from resource import setrlimit, RLIMIT_AS, RLIMIT_STACK + + setrlimit(RLIMIT_STACK, (stack_size, stack_size)) + setrlimit(RLIMIT_AS, (mem_limit, mem_limit)) + for _ in range(iterations): + with Image.open(test_file) as im: + im.load() + test_output = BytesIO() + im.save(test_output, "JPEG2000") + test_output.seek(0) + test_output.read() diff --git a/Tests/check_j2k_overflow.py b/Tests/check_j2k_overflow.py index d5b6e455f..f20ad6748 100644 --- a/Tests/check_j2k_overflow.py +++ b/Tests/check_j2k_overflow.py @@ -1,18 +1,9 @@ -import unittest - +import pytest from PIL import Image -from .helper import PillowTestCase - -class TestJ2kEncodeOverflow(PillowTestCase): - def test_j2k_overflow(self): - - im = Image.new("RGBA", (1024, 131584)) - target = self.tempfile("temp.jpc") - with self.assertRaises(IOError): - im.save(target) - - -if __name__ == "__main__": - unittest.main() +def test_j2k_overflow(tmp_path): + im = Image.new("RGBA", (1024, 131584)) + target = str(tmp_path / "temp.jpc") + with pytest.raises(IOError): + im.save(target) diff --git a/Tests/check_jpeg_leaks.py b/Tests/check_jpeg_leaks.py index 6b2801a21..b63fa2a1e 100644 --- a/Tests/check_jpeg_leaks.py +++ b/Tests/check_jpeg_leaks.py @@ -1,7 +1,8 @@ -import unittest from io import BytesIO -from .helper import PillowTestCase, hopper, is_win32 +import pytest + +from .helper import hopper, is_win32 iterations = 5000 @@ -15,10 +16,9 @@ valgrind --tool=massif python test-installed.py -s -v Tests/check_jpeg_leaks.py """ -@unittest.skipIf(is_win32(), "requires Unix or macOS") -class TestJpegLeaks(PillowTestCase): +pytestmark = pytest.mark.skipif(is_win32(), reason="requires Unix or macOS") - """ +""" pre patch: MB @@ -74,49 +74,51 @@ post-patch: """ - def test_qtables_leak(self): - im = hopper("RGB") - standard_l_qtable = [ - int(s) - for s in """ - 16 11 10 16 24 40 51 61 - 12 12 14 19 26 58 60 55 - 14 13 16 24 40 57 69 56 - 14 17 22 29 51 87 80 62 - 18 22 37 56 68 109 103 77 - 24 35 55 64 81 104 113 92 - 49 64 78 87 103 121 120 101 - 72 92 95 98 112 100 103 99 - """.split( - None - ) - ] +def test_qtables_leak(): + im = hopper("RGB") - standard_chrominance_qtable = [ - int(s) - for s in """ - 17 18 24 47 99 99 99 99 - 18 21 26 66 99 99 99 99 - 24 26 56 99 99 99 99 99 - 47 66 99 99 99 99 99 99 - 99 99 99 99 99 99 99 99 - 99 99 99 99 99 99 99 99 - 99 99 99 99 99 99 99 99 - 99 99 99 99 99 99 99 99 - """.split( - None - ) - ] + standard_l_qtable = [ + int(s) + for s in """ + 16 11 10 16 24 40 51 61 + 12 12 14 19 26 58 60 55 + 14 13 16 24 40 57 69 56 + 14 17 22 29 51 87 80 62 + 18 22 37 56 68 109 103 77 + 24 35 55 64 81 104 113 92 + 49 64 78 87 103 121 120 101 + 72 92 95 98 112 100 103 99 + """.split( + None + ) + ] - qtables = [standard_l_qtable, standard_chrominance_qtable] + standard_chrominance_qtable = [ + int(s) + for s in """ + 17 18 24 47 99 99 99 99 + 18 21 26 66 99 99 99 99 + 24 26 56 99 99 99 99 99 + 47 66 99 99 99 99 99 99 + 99 99 99 99 99 99 99 99 + 99 99 99 99 99 99 99 99 + 99 99 99 99 99 99 99 99 + 99 99 99 99 99 99 99 99 + """.split( + None + ) + ] - for _ in range(iterations): - test_output = BytesIO() - im.save(test_output, "JPEG", qtables=qtables) + qtables = [standard_l_qtable, standard_chrominance_qtable] - def test_exif_leak(self): - """ + for _ in range(iterations): + test_output = BytesIO() + im.save(test_output, "JPEG", qtables=qtables) + + +def test_exif_leak(): + """ pre patch: MB @@ -171,15 +173,16 @@ post patch: 0 11.33 """ - im = hopper("RGB") - exif = b"12345678" * 4096 + im = hopper("RGB") + exif = b"12345678" * 4096 - for _ in range(iterations): - test_output = BytesIO() - im.save(test_output, "JPEG", exif=exif) + for _ in range(iterations): + test_output = BytesIO() + im.save(test_output, "JPEG", exif=exif) - def test_base_save(self): - """ + +def test_base_save(): + """ base case: MB 20.99^ ::::: :::::::::::::::::::::::::::::::::::::::::::@::: @@ -205,12 +208,8 @@ base case: 0 +----------------------------------------------------------------------->Gi 0 7.882 """ - im = hopper("RGB") + im = hopper("RGB") - for _ in range(iterations): - test_output = BytesIO() - im.save(test_output, "JPEG") - - -if __name__ == "__main__": - unittest.main() + for _ in range(iterations): + test_output = BytesIO() + im.save(test_output, "JPEG") diff --git a/Tests/check_large_memory.py b/Tests/check_large_memory.py index 7fcaa4cf9..f44a5a5bb 100644 --- a/Tests/check_large_memory.py +++ b/Tests/check_large_memory.py @@ -1,10 +1,8 @@ import sys -import unittest +import pytest from PIL import Image -from .helper import PillowTestCase - # This test is not run automatically. # # It requires > 2gb memory for the >2 gigapixel image generated in the @@ -24,26 +22,26 @@ YDIM = 32769 XDIM = 48000 -@unittest.skipIf(sys.maxsize <= 2 ** 32, "requires 64-bit system") -class LargeMemoryTest(PillowTestCase): - def _write_png(self, xdim, ydim): - f = self.tempfile("temp.png") - im = Image.new("L", (xdim, ydim), 0) - im.save(f) - - def test_large(self): - """ succeeded prepatch""" - self._write_png(XDIM, YDIM) - - def test_2gpx(self): - """failed prepatch""" - self._write_png(XDIM, XDIM) - - @unittest.skipIf(numpy is None, "Numpy is not installed") - def test_size_greater_than_int(self): - arr = numpy.ndarray(shape=(16394, 16394)) - Image.fromarray(arr) +pytestmark = pytest.mark.skipif(sys.maxsize <= 2 ** 32, reason="requires 64-bit system") -if __name__ == "__main__": - unittest.main() +def _write_png(tmp_path, xdim, ydim): + f = str(tmp_path / "temp.png") + im = Image.new("L", (xdim, ydim), 0) + im.save(f) + + +def test_large(tmp_path): + """ succeeded prepatch""" + _write_png(tmp_path, XDIM, YDIM) + + +def test_2gpx(tmp_path): + """failed prepatch""" + _write_png(tmp_path, XDIM, XDIM) + + +@pytest.mark.skipif(numpy is None, reason="Numpy is not installed") +def test_size_greater_than_int(): + arr = numpy.ndarray(shape=(16394, 16394)) + Image.fromarray(arr) diff --git a/Tests/check_large_memory_numpy.py b/Tests/check_large_memory_numpy.py index 8e65dc1cb..de6f4571c 100644 --- a/Tests/check_large_memory_numpy.py +++ b/Tests/check_large_memory_numpy.py @@ -1,10 +1,8 @@ import sys -import unittest +import pytest from PIL import Image -from .helper import PillowTestCase - # This test is not run automatically. # # It requires > 2gb memory for the >2 gigapixel image generated in the @@ -14,32 +12,28 @@ from .helper import PillowTestCase # Raspberry Pis). -try: - import numpy as np -except ImportError: - raise unittest.SkipTest("numpy not installed") +np = pytest.importorskip("numpy", reason="NumPy not installed") YDIM = 32769 XDIM = 48000 -@unittest.skipIf(sys.maxsize <= 2 ** 32, "requires 64-bit system") -class LargeMemoryNumpyTest(PillowTestCase): - def _write_png(self, xdim, ydim): - dtype = np.uint8 - a = np.zeros((xdim, ydim), dtype=dtype) - f = self.tempfile("temp.png") - im = Image.fromarray(a, "L") - im.save(f) - - def test_large(self): - """ succeeded prepatch""" - self._write_png(XDIM, YDIM) - - def test_2gpx(self): - """failed prepatch""" - self._write_png(XDIM, XDIM) +pytestmark = pytest.mark.skipif(sys.maxsize <= 2 ** 32, reason="requires 64-bit system") -if __name__ == "__main__": - unittest.main() +def _write_png(tmp_path, xdim, ydim): + dtype = np.uint8 + a = np.zeros((xdim, ydim), dtype=dtype) + f = str(tmp_path / "temp.png") + im = Image.fromarray(a, "L") + im.save(f) + + +def test_large(tmp_path): + """ succeeded prepatch""" + _write_png(tmp_path, XDIM, YDIM) + + +def test_2gpx(tmp_path): + """failed prepatch""" + _write_png(tmp_path, XDIM, XDIM) diff --git a/Tests/check_libtiff_segfault.py b/Tests/check_libtiff_segfault.py index 711168f65..5187385d6 100644 --- a/Tests/check_libtiff_segfault.py +++ b/Tests/check_libtiff_segfault.py @@ -1,23 +1,14 @@ -import unittest - import pytest from PIL import Image -from .helper import PillowTestCase - TEST_FILE = "Tests/images/libtiff_segfault.tif" -class TestLibtiffSegfault(PillowTestCase): - def test_segfault(self): - """ This test should not segfault. It will on Pillow <= 3.1.0 and - libtiff >= 4.0.0 - """ +def test_libtiff_segfault(): + """ This test should not segfault. It will on Pillow <= 3.1.0 and + libtiff >= 4.0.0 + """ - with pytest.raises(IOError): - with Image.open(TEST_FILE) as im: - im.load() - - -if __name__ == "__main__": - unittest.main() + with pytest.raises(IOError): + with Image.open(TEST_FILE) as im: + im.load() diff --git a/Tests/check_png_dos.py b/Tests/check_png_dos.py index b981d36bf..86eb937e9 100644 --- a/Tests/check_png_dos.py +++ b/Tests/check_png_dos.py @@ -1,67 +1,61 @@ -import unittest import zlib from io import BytesIO from PIL import Image, ImageFile, PngImagePlugin -from .helper import PillowTestCase - TEST_FILE = "Tests/images/png_decompression_dos.png" -class TestPngDos(PillowTestCase): - def test_ignore_dos_text(self): - ImageFile.LOAD_TRUNCATED_IMAGES = True +def test_ignore_dos_text(): + ImageFile.LOAD_TRUNCATED_IMAGES = True - try: - im = Image.open(TEST_FILE) - im.load() - finally: - ImageFile.LOAD_TRUNCATED_IMAGES = False + try: + im = Image.open(TEST_FILE) + im.load() + finally: + ImageFile.LOAD_TRUNCATED_IMAGES = False - for s in im.text.values(): - assert len(s) < 1024 * 1024, "Text chunk larger than 1M" + for s in im.text.values(): + assert len(s) < 1024 * 1024, "Text chunk larger than 1M" - for s in im.info.values(): - assert len(s) < 1024 * 1024, "Text chunk larger than 1M" - - def test_dos_text(self): - - try: - im = Image.open(TEST_FILE) - im.load() - except ValueError as msg: - assert msg, "Decompressed Data Too Large" - return - - for s in im.text.values(): - assert len(s) < 1024 * 1024, "Text chunk larger than 1M" - - def test_dos_total_memory(self): - im = Image.new("L", (1, 1)) - compressed_data = zlib.compress(b"a" * 1024 * 1023) - - info = PngImagePlugin.PngInfo() - - for x in range(64): - info.add_text("t%s" % x, compressed_data, zip=True) - info.add_itxt("i%s" % x, compressed_data, zip=True) - - b = BytesIO() - im.save(b, "PNG", pnginfo=info) - b.seek(0) - - try: - im2 = Image.open(b) - except ValueError as msg: - assert "Too much memory" in msg - return - - total_len = 0 - for txt in im2.text.values(): - total_len += len(txt) - assert total_len < 64 * 1024 * 1024, "Total text chunks greater than 64M" + for s in im.info.values(): + assert len(s) < 1024 * 1024, "Text chunk larger than 1M" -if __name__ == "__main__": - unittest.main() +def test_dos_text(): + + try: + im = Image.open(TEST_FILE) + im.load() + except ValueError as msg: + assert msg, "Decompressed Data Too Large" + return + + for s in im.text.values(): + assert len(s) < 1024 * 1024, "Text chunk larger than 1M" + + +def test_dos_total_memory(): + im = Image.new("L", (1, 1)) + compressed_data = zlib.compress(b"a" * 1024 * 1023) + + info = PngImagePlugin.PngInfo() + + for x in range(64): + info.add_text("t%s" % x, compressed_data, zip=True) + info.add_itxt("i%s" % x, compressed_data, zip=True) + + b = BytesIO() + im.save(b, "PNG", pnginfo=info) + b.seek(0) + + try: + im2 = Image.open(b) + except ValueError as msg: + assert "Too much memory" in msg + return + + total_len = 0 + for txt in im2.text.values(): + total_len += len(txt) + assert total_len < 64 * 1024 * 1024, "Total text chunks greater than 64M" diff --git a/Tests/helper.py b/Tests/helper.py index 39d3ed482..15a51ccd1 100644 --- a/Tests/helper.py +++ b/Tests/helper.py @@ -7,7 +7,6 @@ import os import shutil import sys import tempfile -import unittest from io import BytesIO import pytest @@ -176,22 +175,6 @@ def skip_unless_feature(feature): return pytest.mark.skipif(not features.check(feature), reason=reason) -class PillowTestCase(unittest.TestCase): - def delete_tempfile(self, path): - try: - os.remove(path) - except OSError: - pass # report? - - def tempfile(self, template): - assert template[:5] in ("temp.", "temp_") - fd, path = tempfile.mkstemp(template[4:], template[:4]) - os.close(fd) - - self.addCleanup(self.delete_tempfile, path) - return path - - @pytest.mark.skipif(sys.platform.startswith("win32"), reason="Requires Unix or macOS") class PillowLeakTestCase: # requires unix/macOS diff --git a/Tests/test_decompression_bomb.py b/Tests/test_decompression_bomb.py index f3981b3ec..1704400b4 100644 --- a/Tests/test_decompression_bomb.py +++ b/Tests/test_decompression_bomb.py @@ -1,15 +1,16 @@ import pytest from PIL import Image -from .helper import PillowTestCase, hopper +from .helper import hopper TEST_FILE = "Tests/images/hopper.ppm" ORIGINAL_LIMIT = Image.MAX_IMAGE_PIXELS -class TestDecompressionBomb(PillowTestCase): - def tearDown(self): +class TestDecompressionBomb: + @classmethod + def teardown_class(self): Image.MAX_IMAGE_PIXELS = ORIGINAL_LIMIT def test_no_warning_small_file(self): @@ -59,20 +60,22 @@ class TestDecompressionBomb(PillowTestCase): Image.open("Tests/images/decompression_bomb.gif") -class TestDecompressionCrop(PillowTestCase): - def setUp(self): - self.src = hopper() - self.addCleanup(self.src.close) - Image.MAX_IMAGE_PIXELS = self.src.height * self.src.width * 4 - 1 +class TestDecompressionCrop: + @classmethod + def setup_class(self): + width, height = 128, 128 + Image.MAX_IMAGE_PIXELS = height * width * 4 - 1 - def tearDown(self): + @classmethod + def teardown_class(self): Image.MAX_IMAGE_PIXELS = ORIGINAL_LIMIT def testEnlargeCrop(self): # Crops can extend the extents, therefore we should have the # same decompression bomb warnings on them. - box = (0, 0, self.src.width * 2, self.src.height * 2) - pytest.warns(Image.DecompressionBombWarning, self.src.crop, box) + with hopper() as src: + box = (0, 0, src.width * 2, src.height * 2) + pytest.warns(Image.DecompressionBombWarning, src.crop, box) def test_crop_decompression_checks(self): diff --git a/Tests/test_file_jpeg2k.py b/Tests/test_file_jpeg2k.py index e37b46a41..72bc7df67 100644 --- a/Tests/test_file_jpeg2k.py +++ b/Tests/test_file_jpeg2k.py @@ -127,10 +127,17 @@ def test_prog_res_rt(): def test_reduce(): with Image.open("Tests/images/test-card-lossless.jp2") as im: + assert callable(im.reduce) + im.reduce = 2 + assert im.reduce == 2 + im.load() assert im.size == (160, 120) + im.thumbnail((40, 40)) + assert im.size == (40, 30) + def test_layers_type(tmp_path): outfile = str(tmp_path / "temp_layers.jp2") diff --git a/Tests/test_file_wmf.py b/Tests/test_file_wmf.py index 1c2c0442b..03444eb9d 100644 --- a/Tests/test_file_wmf.py +++ b/Tests/test_file_wmf.py @@ -1,74 +1,78 @@ import pytest from PIL import Image, WmfImagePlugin -from .helper import PillowTestCase, assert_image_similar, hopper +from .helper import assert_image_similar, hopper -class TestFileWmf(PillowTestCase): - def test_load_raw(self): +def test_load_raw(): - # Test basic EMF open and rendering - with Image.open("Tests/images/drawing.emf") as im: - if hasattr(Image.core, "drawwmf"): - # Currently, support for WMF/EMF is Windows-only - im.load() - # Compare to reference rendering - with Image.open("Tests/images/drawing_emf_ref.png") as imref: - imref.load() - assert_image_similar(im, imref, 0) + # Test basic EMF open and rendering + with Image.open("Tests/images/drawing.emf") as im: + if hasattr(Image.core, "drawwmf"): + # Currently, support for WMF/EMF is Windows-only + im.load() + # Compare to reference rendering + with Image.open("Tests/images/drawing_emf_ref.png") as imref: + imref.load() + assert_image_similar(im, imref, 0) - # Test basic WMF open and rendering - with Image.open("Tests/images/drawing.wmf") as im: - if hasattr(Image.core, "drawwmf"): - # Currently, support for WMF/EMF is Windows-only - im.load() - # Compare to reference rendering - with Image.open("Tests/images/drawing_wmf_ref.png") as imref: - imref.load() - assert_image_similar(im, imref, 2.0) + # Test basic WMF open and rendering + with Image.open("Tests/images/drawing.wmf") as im: + if hasattr(Image.core, "drawwmf"): + # Currently, support for WMF/EMF is Windows-only + im.load() + # Compare to reference rendering + with Image.open("Tests/images/drawing_wmf_ref.png") as imref: + imref.load() + assert_image_similar(im, imref, 2.0) - def test_register_handler(self): - class TestHandler: - methodCalled = False - def save(self, im, fp, filename): - self.methodCalled = True +def test_register_handler(tmp_path): + class TestHandler: + methodCalled = False - handler = TestHandler() - WmfImagePlugin.register_handler(handler) + def save(self, im, fp, filename): + self.methodCalled = True - im = hopper() - tmpfile = self.tempfile("temp.wmf") - im.save(tmpfile) - assert handler.methodCalled + handler = TestHandler() + original_handler = WmfImagePlugin._handler + WmfImagePlugin.register_handler(handler) - # Restore the state before this test - WmfImagePlugin.register_handler(None) + im = hopper() + tmpfile = str(tmp_path / "temp.wmf") + im.save(tmpfile) + assert handler.methodCalled - def test_load_dpi_rounding(self): - # Round up - with Image.open("Tests/images/drawing.emf") as im: - assert im.info["dpi"] == 1424 + # Restore the state before this test + WmfImagePlugin.register_handler(original_handler) - # Round down - with Image.open("Tests/images/drawing_roundDown.emf") as im: - assert im.info["dpi"] == 1426 - def test_load_set_dpi(self): - with Image.open("Tests/images/drawing.wmf") as im: - assert im.size == (82, 82) +def test_load_dpi_rounding(): + # Round up + with Image.open("Tests/images/drawing.emf") as im: + assert im.info["dpi"] == 1424 - if hasattr(Image.core, "drawwmf"): - im.load(144) - assert im.size == (164, 164) + # Round down + with Image.open("Tests/images/drawing_roundDown.emf") as im: + assert im.info["dpi"] == 1426 - with Image.open("Tests/images/drawing_wmf_ref_144.png") as expected: - assert_image_similar(im, expected, 2.0) - def test_save(self): - im = hopper() +def test_load_set_dpi(): + with Image.open("Tests/images/drawing.wmf") as im: + assert im.size == (82, 82) - for ext in [".wmf", ".emf"]: - tmpfile = self.tempfile("temp" + ext) - with pytest.raises(IOError): - im.save(tmpfile) + if hasattr(Image.core, "drawwmf"): + im.load(144) + assert im.size == (164, 164) + + with Image.open("Tests/images/drawing_wmf_ref_144.png") as expected: + assert_image_similar(im, expected, 2.0) + + +def test_save(tmp_path): + im = hopper() + + for ext in [".wmf", ".emf"]: + tmpfile = str(tmp_path / ("temp" + ext)) + with pytest.raises(IOError): + im.save(tmpfile) diff --git a/Tests/test_font_pcf.py b/Tests/test_font_pcf.py index 95250e5ee..afd0c38b2 100644 --- a/Tests/test_font_pcf.py +++ b/Tests/test_font_pcf.py @@ -1,79 +1,90 @@ +import os + import pytest from PIL import FontFile, Image, ImageDraw, ImageFont, PcfFontFile -from .helper import ( - PillowTestCase, - assert_image_equal, - assert_image_similar, - skip_unless_feature, -) +from .helper import assert_image_equal, assert_image_similar, skip_unless_feature fontname = "Tests/fonts/10x20-ISO8859-1.pcf" message = "hello, world" -@skip_unless_feature("zlib") -class TestFontPcf(PillowTestCase): - def save_font(self): - with open(fontname, "rb") as test_file: - font = PcfFontFile.PcfFontFile(test_file) - assert isinstance(font, FontFile.FontFile) - # check the number of characters in the font - assert len([_f for _f in font.glyph if _f]) == 223 +pytestmark = skip_unless_feature("zlib") - tempname = self.tempfile("temp.pil") - self.addCleanup(self.delete_tempfile, tempname[:-4] + ".pbm") - font.save(tempname) - with Image.open(tempname.replace(".pil", ".pbm")) as loaded: - with Image.open("Tests/fonts/10x20.pbm") as target: - assert_image_equal(loaded, target) +def save_font(request, tmp_path): + with open(fontname, "rb") as test_file: + font = PcfFontFile.PcfFontFile(test_file) + assert isinstance(font, FontFile.FontFile) + # check the number of characters in the font + assert len([_f for _f in font.glyph if _f]) == 223 - with open(tempname, "rb") as f_loaded: - with open("Tests/fonts/10x20.pil", "rb") as f_target: - assert f_loaded.read() == f_target.read() - return tempname + tempname = str(tmp_path / "temp.pil") - def test_sanity(self): - self.save_font() + def delete_tempfile(): + try: + os.remove(tempname[:-4] + ".pbm") + except OSError: + pass # report? - def test_invalid_file(self): - with open("Tests/images/flower.jpg", "rb") as fp: - with pytest.raises(SyntaxError): - PcfFontFile.PcfFontFile(fp) + request.addfinalizer(delete_tempfile) + font.save(tempname) - def test_draw(self): - tempname = self.save_font() - font = ImageFont.load(tempname) - im = Image.new("L", (130, 30), "white") - draw = ImageDraw.Draw(im) - draw.text((0, 0), message, "black", font=font) - with Image.open("Tests/images/test_draw_pbm_target.png") as target: - assert_image_similar(im, target, 0) + with Image.open(tempname.replace(".pil", ".pbm")) as loaded: + with Image.open("Tests/fonts/10x20.pbm") as target: + assert_image_equal(loaded, target) - def test_textsize(self): - tempname = self.save_font() - font = ImageFont.load(tempname) - for i in range(255): - (dx, dy) = font.getsize(chr(i)) - assert dy == 20 - assert dx in (0, 10) - for l in range(len(message)): - msg = message[: l + 1] - assert font.getsize(msg) == (len(msg) * 10, 20) + with open(tempname, "rb") as f_loaded: + with open("Tests/fonts/10x20.pil", "rb") as f_target: + assert f_loaded.read() == f_target.read() + return tempname - def _test_high_characters(self, message): - tempname = self.save_font() - font = ImageFont.load(tempname) - im = Image.new("L", (750, 30), "white") - draw = ImageDraw.Draw(im) - draw.text((0, 0), message, "black", font=font) - with Image.open("Tests/images/high_ascii_chars.png") as target: - assert_image_similar(im, target, 0) - def test_high_characters(self): - message = "".join(chr(i + 1) for i in range(140, 232)) - self._test_high_characters(message) - # accept bytes instances. - self._test_high_characters(message.encode("latin1")) +def test_sanity(request, tmp_path): + save_font(request, tmp_path) + + +def test_invalid_file(): + with open("Tests/images/flower.jpg", "rb") as fp: + with pytest.raises(SyntaxError): + PcfFontFile.PcfFontFile(fp) + + +def test_draw(request, tmp_path): + tempname = save_font(request, tmp_path) + font = ImageFont.load(tempname) + im = Image.new("L", (130, 30), "white") + draw = ImageDraw.Draw(im) + draw.text((0, 0), message, "black", font=font) + with Image.open("Tests/images/test_draw_pbm_target.png") as target: + assert_image_similar(im, target, 0) + + +def test_textsize(request, tmp_path): + tempname = save_font(request, tmp_path) + font = ImageFont.load(tempname) + for i in range(255): + (dx, dy) = font.getsize(chr(i)) + assert dy == 20 + assert dx in (0, 10) + for l in range(len(message)): + msg = message[: l + 1] + assert font.getsize(msg) == (len(msg) * 10, 20) + + +def _test_high_characters(request, tmp_path, message): + tempname = save_font(request, tmp_path) + font = ImageFont.load(tempname) + im = Image.new("L", (750, 30), "white") + draw = ImageDraw.Draw(im) + draw.text((0, 0), message, "black", font=font) + with Image.open("Tests/images/high_ascii_chars.png") as target: + assert_image_similar(im, target, 0) + + +def test_high_characters(request, tmp_path): + message = "".join(chr(i + 1) for i in range(140, 232)) + _test_high_characters(request, tmp_path, message) + # accept bytes instances. + _test_high_characters(request, tmp_path, message.encode("latin1")) diff --git a/Tests/test_image_fromqimage.py b/Tests/test_image_fromqimage.py index ee26b75bc..170d49ae1 100644 --- a/Tests/test_image_fromqimage.py +++ b/Tests/test_image_fromqimage.py @@ -1,47 +1,59 @@ +import pytest from PIL import Image, ImageQt -from .helper import PillowTestCase, assert_image_equal, hopper -from .test_imageqt import PillowQtTestCase +from .helper import assert_image_equal, hopper + +pytestmark = pytest.mark.skipif( + not ImageQt.qt_is_installed, reason="Qt bindings are not installed" +) -class TestFromQImage(PillowQtTestCase, PillowTestCase): - def setUp(self): - super().setUp() - self.files_to_test = [ - hopper(), - Image.open("Tests/images/transparent.png"), - Image.open("Tests/images/7x13.png"), - ] - for im in self.files_to_test: - self.addCleanup(im.close) +@pytest.fixture +def test_images(): + ims = [ + hopper(), + Image.open("Tests/images/transparent.png"), + Image.open("Tests/images/7x13.png"), + ] + try: + yield ims + finally: + for im in ims: + im.close() - def roundtrip(self, expected): - # PIL -> Qt - intermediate = expected.toqimage() - # Qt -> PIL - result = ImageQt.fromqimage(intermediate) - if intermediate.hasAlphaChannel(): - assert_image_equal(result, expected.convert("RGBA")) - else: - assert_image_equal(result, expected.convert("RGB")) +def roundtrip(expected): + # PIL -> Qt + intermediate = expected.toqimage() + # Qt -> PIL + result = ImageQt.fromqimage(intermediate) - def test_sanity_1(self): - for im in self.files_to_test: - self.roundtrip(im.convert("1")) + if intermediate.hasAlphaChannel(): + assert_image_equal(result, expected.convert("RGBA")) + else: + assert_image_equal(result, expected.convert("RGB")) - def test_sanity_rgb(self): - for im in self.files_to_test: - self.roundtrip(im.convert("RGB")) - def test_sanity_rgba(self): - for im in self.files_to_test: - self.roundtrip(im.convert("RGBA")) +def test_sanity_1(test_images): + for im in test_images: + roundtrip(im.convert("1")) - def test_sanity_l(self): - for im in self.files_to_test: - self.roundtrip(im.convert("L")) - def test_sanity_p(self): - for im in self.files_to_test: - self.roundtrip(im.convert("P")) +def test_sanity_rgb(test_images): + for im in test_images: + roundtrip(im.convert("RGB")) + + +def test_sanity_rgba(test_images): + for im in test_images: + roundtrip(im.convert("RGBA")) + + +def test_sanity_l(test_images): + for im in test_images: + roundtrip(im.convert("L")) + + +def test_sanity_p(test_images): + for im in test_images: + roundtrip(im.convert("P")) diff --git a/Tests/test_image_paste.py b/Tests/test_image_paste.py index 4e97ee50b..1d3ca8135 100644 --- a/Tests/test_image_paste.py +++ b/Tests/test_image_paste.py @@ -1,9 +1,9 @@ from PIL import Image -from .helper import PillowTestCase, assert_image_equal, cached_property +from .helper import assert_image_equal, cached_property -class TestImagingPaste(PillowTestCase): +class TestImagingPaste: masks = {} size = 128 diff --git a/Tests/test_image_reduce.py b/Tests/test_image_reduce.py index 658a0f513..729645a0b 100644 --- a/Tests/test_image_reduce.py +++ b/Tests/test_image_reduce.py @@ -3,6 +3,9 @@ from PIL import Image, ImageMath, ImageMode from .helper import convert_to_comparable +codecs = dir(Image.core) + + # There are several internal implementations remarkable_factors = [ # special implementations @@ -247,3 +250,11 @@ def test_mode_F(): for factor in remarkable_factors: compare_reduce_with_reference(im, factor, 0, 0) compare_reduce_with_box(im, factor) + + +@pytest.mark.skipif( + "jpeg2k_decoder" not in codecs, reason="JPEG 2000 support not available" +) +def test_jpeg2k(): + with Image.open("Tests/images/test-card-lossless.jp2") as im: + assert im.reduce(2).size == (320, 240) diff --git a/Tests/test_image_resize.py b/Tests/test_image_resize.py index 3281836d5..ad4be135a 100644 --- a/Tests/test_image_resize.py +++ b/Tests/test_image_resize.py @@ -6,10 +6,10 @@ from itertools import permutations import pytest from PIL import Image -from .helper import PillowTestCase, assert_image_equal, assert_image_similar, hopper +from .helper import assert_image_equal, assert_image_similar, hopper -class TestImagingCoreResize(PillowTestCase): +class TestImagingCoreResize: def resize(self, im, size, f): # Image class independent version of resize. im.load() @@ -135,31 +135,36 @@ class TestImagingCoreResize(PillowTestCase): self.resize(hopper(), (10, 10), 9) -class TestReducingGapResize(PillowTestCase): - @classmethod - def setUpClass(cls): - cls.gradients_image = Image.open("Tests/images/radial_gradients.png") - cls.gradients_image.load() +@pytest.fixture +def gradients_image(): + im = Image.open("Tests/images/radial_gradients.png") + im.load() + try: + yield im + finally: + im.close() - def test_reducing_gap_values(self): - ref = self.gradients_image.resize((52, 34), Image.BICUBIC, reducing_gap=None) - im = self.gradients_image.resize((52, 34), Image.BICUBIC) + +class TestReducingGapResize: + def test_reducing_gap_values(self, gradients_image): + ref = gradients_image.resize((52, 34), Image.BICUBIC, reducing_gap=None) + im = gradients_image.resize((52, 34), Image.BICUBIC) assert_image_equal(ref, im) with pytest.raises(ValueError): - self.gradients_image.resize((52, 34), Image.BICUBIC, reducing_gap=0) + gradients_image.resize((52, 34), Image.BICUBIC, reducing_gap=0) with pytest.raises(ValueError): - self.gradients_image.resize((52, 34), Image.BICUBIC, reducing_gap=0.99) + gradients_image.resize((52, 34), Image.BICUBIC, reducing_gap=0.99) - def test_reducing_gap_1(self): + def test_reducing_gap_1(self, gradients_image): for box, epsilon in [ (None, 4), ((1.1, 2.2, 510.8, 510.9), 4), ((3, 10, 410, 256), 10), ]: - ref = self.gradients_image.resize((52, 34), Image.BICUBIC, box=box) - im = self.gradients_image.resize( + ref = gradients_image.resize((52, 34), Image.BICUBIC, box=box) + im = gradients_image.resize( (52, 34), Image.BICUBIC, box=box, reducing_gap=1.0 ) @@ -168,14 +173,14 @@ class TestReducingGapResize(PillowTestCase): assert_image_similar(ref, im, epsilon) - def test_reducing_gap_2(self): + def test_reducing_gap_2(self, gradients_image): for box, epsilon in [ (None, 1.5), ((1.1, 2.2, 510.8, 510.9), 1.5), ((3, 10, 410, 256), 1), ]: - ref = self.gradients_image.resize((52, 34), Image.BICUBIC, box=box) - im = self.gradients_image.resize( + ref = gradients_image.resize((52, 34), Image.BICUBIC, box=box) + im = gradients_image.resize( (52, 34), Image.BICUBIC, box=box, reducing_gap=2.0 ) @@ -184,14 +189,14 @@ class TestReducingGapResize(PillowTestCase): assert_image_similar(ref, im, epsilon) - def test_reducing_gap_3(self): + def test_reducing_gap_3(self, gradients_image): for box, epsilon in [ (None, 1), ((1.1, 2.2, 510.8, 510.9), 1), ((3, 10, 410, 256), 0.5), ]: - ref = self.gradients_image.resize((52, 34), Image.BICUBIC, box=box) - im = self.gradients_image.resize( + ref = gradients_image.resize((52, 34), Image.BICUBIC, box=box) + im = gradients_image.resize( (52, 34), Image.BICUBIC, box=box, reducing_gap=3.0 ) @@ -200,29 +205,27 @@ class TestReducingGapResize(PillowTestCase): assert_image_similar(ref, im, epsilon) - def test_reducing_gap_8(self): + def test_reducing_gap_8(self, gradients_image): for box in [None, (1.1, 2.2, 510.8, 510.9), (3, 10, 410, 256)]: - ref = self.gradients_image.resize((52, 34), Image.BICUBIC, box=box) - im = self.gradients_image.resize( + ref = gradients_image.resize((52, 34), Image.BICUBIC, box=box) + im = gradients_image.resize( (52, 34), Image.BICUBIC, box=box, reducing_gap=8.0 ) assert_image_equal(ref, im) - def test_box_filter(self): + def test_box_filter(self, gradients_image): for box, epsilon in [ ((0, 0, 512, 512), 5.5), ((0.9, 1.7, 128, 128), 9.5), ]: - ref = self.gradients_image.resize((52, 34), Image.BOX, box=box) - im = self.gradients_image.resize( - (52, 34), Image.BOX, box=box, reducing_gap=1.0 - ) + ref = gradients_image.resize((52, 34), Image.BOX, box=box) + im = gradients_image.resize((52, 34), Image.BOX, box=box, reducing_gap=1.0) assert_image_similar(ref, im, epsilon) -class TestImageResize(PillowTestCase): +class TestImageResize: def test_resize(self): def resize(mode, size): out = hopper(mode).resize(size) diff --git a/Tests/test_imagechops.py b/Tests/test_imagechops.py index 8dec6a1d5..7d042cb9f 100644 --- a/Tests/test_imagechops.py +++ b/Tests/test_imagechops.py @@ -38,6 +38,10 @@ def test_sanity(): ImageChops.blend(im, im, 0.5) ImageChops.composite(im, im, im) + ImageChops.soft_light(im, im) + ImageChops.hard_light(im, im) + ImageChops.overlay(im, im) + ImageChops.offset(im, 10) ImageChops.offset(im, 10, 20) @@ -209,8 +213,8 @@ def test_lighter_image(): # Act new = ImageChops.lighter(im1, im2) - # Assert - assert_image_equal(new, im1) + # Assert + assert_image_equal(new, im1) def test_lighter_pixel(): @@ -275,13 +279,13 @@ def test_offset(): # Act new = ImageChops.offset(im, xoffset, yoffset) - # Assert - assert new.getbbox() == (0, 45, 100, 96) - assert new.getpixel((50, 50)) == BLACK - assert new.getpixel((50 + xoffset, 50 + yoffset)) == DARK_GREEN + # Assert + assert new.getbbox() == (0, 45, 100, 96) + assert new.getpixel((50, 50)) == BLACK + assert new.getpixel((50 + xoffset, 50 + yoffset)) == DARK_GREEN - # Test no yoffset - assert ImageChops.offset(im, xoffset) == ImageChops.offset(im, xoffset, xoffset) + # Test no yoffset + assert ImageChops.offset(im, xoffset) == ImageChops.offset(im, xoffset, xoffset) def test_screen(): @@ -362,6 +366,45 @@ def test_subtract_modulo_no_clip(): assert new.getpixel((50, 50)) == (241, 167, 127) +def test_soft_light(): + # Arrange + im1 = Image.open("Tests/images/hopper.png") + im2 = Image.open("Tests/images/hopper-XYZ.png") + + # Act + new = ImageChops.soft_light(im1, im2) + + # Assert + assert new.getpixel((64, 64)) == (163, 54, 32) + assert new.getpixel((15, 100)) == (1, 1, 3) + + +def test_hard_light(): + # Arrange + im1 = Image.open("Tests/images/hopper.png") + im2 = Image.open("Tests/images/hopper-XYZ.png") + + # Act + new = ImageChops.hard_light(im1, im2) + + # Assert + assert new.getpixel((64, 64)) == (144, 50, 27) + assert new.getpixel((15, 100)) == (1, 1, 2) + + +def test_overlay(): + # Arrange + im1 = Image.open("Tests/images/hopper.png") + im2 = Image.open("Tests/images/hopper-XYZ.png") + + # Act + new = ImageChops.overlay(im1, im2) + + # Assert + assert new.getpixel((64, 64)) == (159, 50, 27) + assert new.getpixel((15, 100)) == (1, 1, 2) + + def test_logical(): def table(op, a, b): out = [] diff --git a/Tests/test_imagefont.py b/Tests/test_imagefont.py index b3686aea1..e93aff4b2 100644 --- a/Tests/test_imagefont.py +++ b/Tests/test_imagefont.py @@ -3,14 +3,13 @@ import distutils.version import os import re import shutil +import sys from io import BytesIO -from unittest import mock import pytest from PIL import Image, ImageDraw, ImageFont from .helper import ( - PillowTestCase, assert_image_equal, assert_image_similar, assert_image_similar_tofile, @@ -25,8 +24,10 @@ FONT_SIZE = 20 TEST_TEXT = "hey you\nyou are awesome\nthis looks awkward" -@skip_unless_feature("freetype2") -class TestImageFont(PillowTestCase): +pytestmark = skip_unless_feature("freetype2") + + +class TestImageFont: LAYOUT_ENGINE = ImageFont.LAYOUT_BASIC # Freetype has different metrics depending on the version. @@ -37,7 +38,8 @@ class TestImageFont(PillowTestCase): "Default": {"multiline": 0.5, "textsize": 0.5, "getters": (12, 16)}, } - def setUp(self): + @classmethod + def setup_class(self): freetype = distutils.version.StrictVersion(ImageFont.core.freetype2_version) self.metrics = self.METRICS["Default"] @@ -107,12 +109,12 @@ class TestImageFont(PillowTestCase): with open(FONT_PATH, "rb") as f: self._render(f) - def test_non_unicode_path(self): + def test_non_unicode_path(self, tmp_path): + tempfile = str(tmp_path / ("temp_" + chr(128) + ".ttf")) try: - tempfile = self.tempfile("temp_" + chr(128) + ".ttf") + shutil.copy(FONT_PATH, tempfile) except UnicodeEncodeError: - self.skipTest("Unicode path could not be created") - shutil.copy(FONT_PATH, tempfile) + pytest.skip("Unicode path could not be created") ImageFont.truetype(tempfile, FONT_SIZE) @@ -457,10 +459,11 @@ class TestImageFont(PillowTestCase): assert_image_similar_tofile(img, target, self.metrics["multiline"]) - def _test_fake_loading_font(self, path_to_fake, fontname): + def _test_fake_loading_font(self, monkeypatch, path_to_fake, fontname): # Make a copy of FreeTypeFont so we can patch the original free_type_font = copy.deepcopy(ImageFont.FreeTypeFont) - with mock.patch.object(ImageFont, "_FreeTypeFont", free_type_font, create=True): + with monkeypatch.context() as m: + m.setattr(ImageFont, "_FreeTypeFont", free_type_font, raising=False) def loadable_font(filepath, size, index, encoding, *args, **kwargs): if filepath == path_to_fake: @@ -471,87 +474,84 @@ class TestImageFont(PillowTestCase): filepath, size, index, encoding, *args, **kwargs ) - with mock.patch.object(ImageFont, "FreeTypeFont", loadable_font): - font = ImageFont.truetype(fontname) - # Make sure it's loaded - name = font.getname() - assert ("FreeMono", "Regular") == name + m.setattr(ImageFont, "FreeTypeFont", loadable_font) + font = ImageFont.truetype(fontname) + # Make sure it's loaded + name = font.getname() + assert ("FreeMono", "Regular") == name @pytest.mark.skipif(is_win32(), reason="requires Unix or macOS") - def test_find_linux_font(self): + def test_find_linux_font(self, monkeypatch): # A lot of mocking here - this is more for hitting code and # catching syntax like errors font_directory = "/usr/local/share/fonts" - with mock.patch("sys.platform", "linux"): - patched_env = {"XDG_DATA_DIRS": "/usr/share/:/usr/local/share/"} - with mock.patch.dict(os.environ, patched_env): + monkeypatch.setattr(sys, "platform", "linux") + monkeypatch.setenv("XDG_DATA_DIRS", "/usr/share/:/usr/local/share/") - def fake_walker(path): - if path == font_directory: - return [ - ( - path, - [], - [ - "Arial.ttf", - "Single.otf", - "Duplicate.otf", - "Duplicate.ttf", - ], - ) - ] - return [(path, [], ["some_random_font.ttf"])] - - with mock.patch("os.walk", fake_walker): - # Test that the font loads both with and without the - # extension - self._test_fake_loading_font( - font_directory + "/Arial.ttf", "Arial.ttf" + def fake_walker(path): + if path == font_directory: + return [ + ( + path, + [], + ["Arial.ttf", "Single.otf", "Duplicate.otf", "Duplicate.ttf"], ) - self._test_fake_loading_font(font_directory + "/Arial.ttf", "Arial") + ] + return [(path, [], ["some_random_font.ttf"])] - # Test that non-ttf fonts can be found without the - # extension - self._test_fake_loading_font( - font_directory + "/Single.otf", "Single" - ) + monkeypatch.setattr(os, "walk", fake_walker) + # Test that the font loads both with and without the + # extension + self._test_fake_loading_font( + monkeypatch, font_directory + "/Arial.ttf", "Arial.ttf" + ) + self._test_fake_loading_font( + monkeypatch, font_directory + "/Arial.ttf", "Arial" + ) - # Test that ttf fonts are preferred if the extension is - # not specified - self._test_fake_loading_font( - font_directory + "/Duplicate.ttf", "Duplicate" - ) + # Test that non-ttf fonts can be found without the + # extension + self._test_fake_loading_font( + monkeypatch, font_directory + "/Single.otf", "Single" + ) + + # Test that ttf fonts are preferred if the extension is + # not specified + self._test_fake_loading_font( + monkeypatch, font_directory + "/Duplicate.ttf", "Duplicate" + ) @pytest.mark.skipif(is_win32(), reason="requires Unix or macOS") - def test_find_macos_font(self): + def test_find_macos_font(self, monkeypatch): # Like the linux test, more cover hitting code rather than testing # correctness. font_directory = "/System/Library/Fonts" - with mock.patch("sys.platform", "darwin"): + monkeypatch.setattr(sys, "platform", "darwin") - def fake_walker(path): - if path == font_directory: - return [ - ( - path, - [], - [ - "Arial.ttf", - "Single.otf", - "Duplicate.otf", - "Duplicate.ttf", - ], - ) - ] - return [(path, [], ["some_random_font.ttf"])] + def fake_walker(path): + if path == font_directory: + return [ + ( + path, + [], + ["Arial.ttf", "Single.otf", "Duplicate.otf", "Duplicate.ttf"], + ) + ] + return [(path, [], ["some_random_font.ttf"])] - with mock.patch("os.walk", fake_walker): - self._test_fake_loading_font(font_directory + "/Arial.ttf", "Arial.ttf") - self._test_fake_loading_font(font_directory + "/Arial.ttf", "Arial") - self._test_fake_loading_font(font_directory + "/Single.otf", "Single") - self._test_fake_loading_font( - font_directory + "/Duplicate.ttf", "Duplicate" - ) + monkeypatch.setattr(os, "walk", fake_walker) + self._test_fake_loading_font( + monkeypatch, font_directory + "/Arial.ttf", "Arial.ttf" + ) + self._test_fake_loading_font( + monkeypatch, font_directory + "/Arial.ttf", "Arial" + ) + self._test_fake_loading_font( + monkeypatch, font_directory + "/Single.otf", "Single" + ) + self._test_fake_loading_font( + monkeypatch, font_directory + "/Duplicate.ttf", "Duplicate" + ) def test_imagefont_getters(self): # Arrange diff --git a/Tests/test_imageops_usm.py b/Tests/test_imageops_usm.py index 8bd01d588..61f8dc2ba 100644 --- a/Tests/test_imageops_usm.py +++ b/Tests/test_imageops_usm.py @@ -1,98 +1,110 @@ import pytest from PIL import Image, ImageFilter -from .helper import PillowTestCase + +@pytest.fixture +def test_images(): + ims = { + "im": Image.open("Tests/images/hopper.ppm"), + "snakes": Image.open("Tests/images/color_snakes.png"), + } + try: + yield ims + finally: + for im in ims.values(): + im.close() -class TestImageOpsUsm(PillowTestCase): - def setUp(self): - super().setUp() - self.im = Image.open("Tests/images/hopper.ppm") - self.addCleanup(self.im.close) - self.snakes = Image.open("Tests/images/color_snakes.png") - self.addCleanup(self.snakes.close) +def test_filter_api(test_images): + im = test_images["im"] - def test_filter_api(self): + test_filter = ImageFilter.GaussianBlur(2.0) + i = im.filter(test_filter) + assert i.mode == "RGB" + assert i.size == (128, 128) - test_filter = ImageFilter.GaussianBlur(2.0) - i = self.im.filter(test_filter) - assert i.mode == "RGB" - assert i.size == (128, 128) + test_filter = ImageFilter.UnsharpMask(2.0, 125, 8) + i = im.filter(test_filter) + assert i.mode == "RGB" + assert i.size == (128, 128) - test_filter = ImageFilter.UnsharpMask(2.0, 125, 8) - i = self.im.filter(test_filter) - assert i.mode == "RGB" - assert i.size == (128, 128) - def test_usm_formats(self): +def test_usm_formats(test_images): + im = test_images["im"] - usm = ImageFilter.UnsharpMask - with pytest.raises(ValueError): - self.im.convert("1").filter(usm) - self.im.convert("L").filter(usm) - with pytest.raises(ValueError): - self.im.convert("I").filter(usm) - with pytest.raises(ValueError): - self.im.convert("F").filter(usm) - self.im.convert("RGB").filter(usm) - self.im.convert("RGBA").filter(usm) - self.im.convert("CMYK").filter(usm) - with pytest.raises(ValueError): - self.im.convert("YCbCr").filter(usm) + usm = ImageFilter.UnsharpMask + with pytest.raises(ValueError): + im.convert("1").filter(usm) + im.convert("L").filter(usm) + with pytest.raises(ValueError): + im.convert("I").filter(usm) + with pytest.raises(ValueError): + im.convert("F").filter(usm) + im.convert("RGB").filter(usm) + im.convert("RGBA").filter(usm) + im.convert("CMYK").filter(usm) + with pytest.raises(ValueError): + im.convert("YCbCr").filter(usm) - def test_blur_formats(self): - blur = ImageFilter.GaussianBlur - with pytest.raises(ValueError): - self.im.convert("1").filter(blur) - blur(self.im.convert("L")) - with pytest.raises(ValueError): - self.im.convert("I").filter(blur) - with pytest.raises(ValueError): - self.im.convert("F").filter(blur) - self.im.convert("RGB").filter(blur) - self.im.convert("RGBA").filter(blur) - self.im.convert("CMYK").filter(blur) - with pytest.raises(ValueError): - self.im.convert("YCbCr").filter(blur) +def test_blur_formats(test_images): + im = test_images["im"] - def test_usm_accuracy(self): + blur = ImageFilter.GaussianBlur + with pytest.raises(ValueError): + im.convert("1").filter(blur) + blur(im.convert("L")) + with pytest.raises(ValueError): + im.convert("I").filter(blur) + with pytest.raises(ValueError): + im.convert("F").filter(blur) + im.convert("RGB").filter(blur) + im.convert("RGBA").filter(blur) + im.convert("CMYK").filter(blur) + with pytest.raises(ValueError): + im.convert("YCbCr").filter(blur) - src = self.snakes.convert("RGB") - i = src.filter(ImageFilter.UnsharpMask(5, 1024, 0)) - # Image should not be changed because it have only 0 and 255 levels. - assert i.tobytes() == src.tobytes() - def test_blur_accuracy(self): +def test_usm_accuracy(test_images): + snakes = test_images["snakes"] - i = self.snakes.filter(ImageFilter.GaussianBlur(0.4)) - # These pixels surrounded with pixels with 255 intensity. - # They must be very close to 255. - for x, y, c in [ - (1, 0, 1), - (2, 0, 1), - (7, 8, 1), - (8, 8, 1), - (2, 9, 1), - (7, 3, 0), - (8, 3, 0), - (5, 8, 0), - (5, 9, 0), - (1, 3, 0), - (4, 3, 2), - (4, 2, 2), - ]: - assert i.im.getpixel((x, y))[c] >= 250 - # Fuzzy match. + src = snakes.convert("RGB") + i = src.filter(ImageFilter.UnsharpMask(5, 1024, 0)) + # Image should not be changed because it have only 0 and 255 levels. + assert i.tobytes() == src.tobytes() - def gp(x, y): - return i.im.getpixel((x, y)) - assert 236 <= gp(7, 4)[0] <= 239 - assert 236 <= gp(7, 5)[2] <= 239 - assert 236 <= gp(7, 6)[2] <= 239 - assert 236 <= gp(7, 7)[1] <= 239 - assert 236 <= gp(8, 4)[0] <= 239 - assert 236 <= gp(8, 5)[2] <= 239 - assert 236 <= gp(8, 6)[2] <= 239 - assert 236 <= gp(8, 7)[1] <= 239 +def test_blur_accuracy(test_images): + snakes = test_images["snakes"] + + i = snakes.filter(ImageFilter.GaussianBlur(0.4)) + # These pixels surrounded with pixels with 255 intensity. + # They must be very close to 255. + for x, y, c in [ + (1, 0, 1), + (2, 0, 1), + (7, 8, 1), + (8, 8, 1), + (2, 9, 1), + (7, 3, 0), + (8, 3, 0), + (5, 8, 0), + (5, 9, 0), + (1, 3, 0), + (4, 3, 2), + (4, 2, 2), + ]: + assert i.im.getpixel((x, y))[c] >= 250 + # Fuzzy match. + + def gp(x, y): + return i.im.getpixel((x, y)) + + assert 236 <= gp(7, 4)[0] <= 239 + assert 236 <= gp(7, 5)[2] <= 239 + assert 236 <= gp(7, 6)[2] <= 239 + assert 236 <= gp(7, 7)[1] <= 239 + assert 236 <= gp(8, 4)[0] <= 239 + assert 236 <= gp(8, 5)[2] <= 239 + assert 236 <= gp(8, 6)[2] <= 239 + assert 236 <= gp(8, 7)[1] <= 239 diff --git a/Tests/test_imageqt.py b/Tests/test_imageqt.py index ef777374f..d723690ef 100644 --- a/Tests/test_imageqt.py +++ b/Tests/test_imageqt.py @@ -1,71 +1,60 @@ +import pytest from PIL import ImageQt -from .helper import PillowTestCase, hopper +from .helper import hopper if ImageQt.qt_is_installed: from PIL.ImageQt import qRgba - def skip_if_qt_is_not_installed(_): - pass - -else: - - def skip_if_qt_is_not_installed(test_case): - test_case.skipTest("Qt bindings are not installed") - - -class PillowQtTestCase: - def setUp(self): - skip_if_qt_is_not_installed(self) - - def tearDown(self): - pass - - -class PillowQPixmapTestCase(PillowQtTestCase): - def setUp(self): - super().setUp() +@pytest.mark.skipif(not ImageQt.qt_is_installed, reason="Qt bindings are not installed") +class PillowQPixmapTestCase: + @classmethod + def setup_class(self): try: if ImageQt.qt_version == "5": from PyQt5.QtGui import QGuiApplication elif ImageQt.qt_version == "side2": from PySide2.QtGui import QGuiApplication except ImportError: - self.skipTest("QGuiApplication not installed") + pytest.skip("QGuiApplication not installed") + return self.app = QGuiApplication([]) - def tearDown(self): - super().tearDown() + @classmethod + def teardown_class(self): self.app.quit() + self.app = None -class TestImageQt(PillowQtTestCase, PillowTestCase): - def test_rgb(self): - # from https://doc.qt.io/archives/qt-4.8/qcolor.html - # typedef QRgb - # An ARGB quadruplet on the format #AARRGGBB, - # equivalent to an unsigned int. - if ImageQt.qt_version == "5": - from PyQt5.QtGui import qRgb - elif ImageQt.qt_version == "side2": - from PySide2.QtGui import qRgb +@pytest.mark.skipif(not ImageQt.qt_is_installed, reason="Qt bindings are not installed") +def test_rgb(): + # from https://doc.qt.io/archives/qt-4.8/qcolor.html + # typedef QRgb + # An ARGB quadruplet on the format #AARRGGBB, + # equivalent to an unsigned int. + if ImageQt.qt_version == "5": + from PyQt5.QtGui import qRgb + elif ImageQt.qt_version == "side2": + from PySide2.QtGui import qRgb - assert qRgb(0, 0, 0) == qRgba(0, 0, 0, 255) + assert qRgb(0, 0, 0) == qRgba(0, 0, 0, 255) - def checkrgb(r, g, b): - val = ImageQt.rgb(r, g, b) - val = val % 2 ** 24 # drop the alpha - assert val >> 16 == r - assert ((val >> 8) % 2 ** 8) == g - assert val % 2 ** 8 == b + def checkrgb(r, g, b): + val = ImageQt.rgb(r, g, b) + val = val % 2 ** 24 # drop the alpha + assert val >> 16 == r + assert ((val >> 8) % 2 ** 8) == g + assert val % 2 ** 8 == b - checkrgb(0, 0, 0) - checkrgb(255, 0, 0) - checkrgb(0, 255, 0) - checkrgb(0, 0, 255) + checkrgb(0, 0, 0) + checkrgb(255, 0, 0) + checkrgb(0, 255, 0) + checkrgb(0, 0, 255) - def test_image(self): - for mode in ("1", "RGB", "RGBA", "L", "P"): - ImageQt.ImageQt(hopper(mode)) + +@pytest.mark.skipif(not ImageQt.qt_is_installed, reason="Qt bindings are not installed") +def test_image(): + for mode in ("1", "RGB", "RGBA", "L", "P"): + ImageQt.ImageQt(hopper(mode)) diff --git a/Tests/test_qt_image_fromqpixmap.py b/Tests/test_qt_image_fromqpixmap.py index 1c7184376..cb1b385ec 100644 --- a/Tests/test_qt_image_fromqpixmap.py +++ b/Tests/test_qt_image_fromqpixmap.py @@ -1,10 +1,10 @@ from PIL import ImageQt -from .helper import PillowTestCase, assert_image_equal, hopper +from .helper import assert_image_equal, hopper from .test_imageqt import PillowQPixmapTestCase -class TestFromQPixmap(PillowQPixmapTestCase, PillowTestCase): +class TestFromQPixmap(PillowQPixmapTestCase): def roundtrip(self, expected): result = ImageQt.fromqpixmap(ImageQt.toqpixmap(expected)) # Qt saves all pixmaps as rgb diff --git a/Tests/test_qt_image_toqimage.py b/Tests/test_qt_image_toqimage.py index 8283753b9..4c98bf0b4 100644 --- a/Tests/test_qt_image_toqimage.py +++ b/Tests/test_qt_image_toqimage.py @@ -1,7 +1,11 @@ +import pytest from PIL import Image, ImageQt -from .helper import PillowTestCase, assert_image_equal, hopper -from .test_imageqt import PillowQtTestCase +from .helper import assert_image_equal, hopper + +pytestmark = pytest.mark.skipif( + not ImageQt.qt_is_installed, reason="Qt bindings are not installed" +) if ImageQt.qt_is_installed: from PIL.ImageQt import QImage @@ -14,43 +18,43 @@ if ImageQt.qt_is_installed: from PySide2.QtWidgets import QWidget, QHBoxLayout, QLabel, QApplication -class TestToQImage(PillowQtTestCase, PillowTestCase): - def test_sanity(self): - for mode in ("RGB", "RGBA", "L", "P", "1"): - src = hopper(mode) - data = ImageQt.toqimage(src) +def test_sanity(tmp_path): + for mode in ("RGB", "RGBA", "L", "P", "1"): + src = hopper(mode) + data = ImageQt.toqimage(src) - assert isinstance(data, QImage) - assert not data.isNull() + assert isinstance(data, QImage) + assert not data.isNull() - # reload directly from the qimage - rt = ImageQt.fromqimage(data) - if mode in ("L", "P", "1"): - assert_image_equal(rt, src.convert("RGB")) - else: - assert_image_equal(rt, src) + # reload directly from the qimage + rt = ImageQt.fromqimage(data) + if mode in ("L", "P", "1"): + assert_image_equal(rt, src.convert("RGB")) + else: + assert_image_equal(rt, src) - if mode == "1": - # BW appears to not save correctly on QT4 and QT5 - # kicks out errors on console: - # libpng warning: Invalid color type/bit depth combination - # in IHDR - # libpng error: Invalid IHDR data - continue + if mode == "1": + # BW appears to not save correctly on QT4 and QT5 + # kicks out errors on console: + # libpng warning: Invalid color type/bit depth combination + # in IHDR + # libpng error: Invalid IHDR data + continue - # Test saving the file - tempfile = self.tempfile("temp_{}.png".format(mode)) - data.save(tempfile) + # Test saving the file + tempfile = str(tmp_path / "temp_{}.png".format(mode)) + data.save(tempfile) - # Check that it actually worked. - with Image.open(tempfile) as reloaded: - assert_image_equal(reloaded, src) + # Check that it actually worked. + with Image.open(tempfile) as reloaded: + assert_image_equal(reloaded, src) - def test_segfault(self): - app = QApplication([]) - ex = Example() - assert app # Silence warning - assert ex # Silence warning + +def test_segfault(): + app = QApplication([]) + ex = Example() + assert app # Silence warning + assert ex # Silence warning if ImageQt.qt_is_installed: diff --git a/Tests/test_qt_image_toqpixmap.py b/Tests/test_qt_image_toqpixmap.py index 70b0d6839..af281da69 100644 --- a/Tests/test_qt_image_toqpixmap.py +++ b/Tests/test_qt_image_toqpixmap.py @@ -1,14 +1,14 @@ from PIL import ImageQt -from .helper import PillowTestCase, hopper +from .helper import hopper from .test_imageqt import PillowQPixmapTestCase if ImageQt.qt_is_installed: from PIL.ImageQt import QPixmap -class TestToQPixmap(PillowQPixmapTestCase, PillowTestCase): - def test_sanity(self): +class TestToQPixmap(PillowQPixmapTestCase): + def test_sanity(self, tmp_path): for mode in ("1", "RGB", "RGBA", "L", "P"): data = ImageQt.toqpixmap(hopper(mode)) @@ -16,5 +16,5 @@ class TestToQPixmap(PillowQPixmapTestCase, PillowTestCase): assert not data.isNull() # Test saving the file - tempfile = self.tempfile("temp_{}.png".format(mode)) + tempfile = str(tmp_path / "temp_{}.png".format(mode)) data.save(tempfile) diff --git a/codecov.yml b/codecov.yml index e93896692..f3afccc1c 100644 --- a/codecov.yml +++ b/codecov.yml @@ -8,6 +8,12 @@ codecov: comment: false +coverage: + status: + project: + default: + threshold: 0.01% + # Matches 'omit:' in .coveragerc ignore: - "Tests/32bit_segfault_check.py" diff --git a/docs/reference/ImageChops.rst b/docs/reference/ImageChops.rst index 6c8f11253..fb7422549 100644 --- a/docs/reference/ImageChops.rst +++ b/docs/reference/ImageChops.rst @@ -36,6 +36,9 @@ operations in this module). .. autofunction:: PIL.ImageChops.logical_or .. autofunction:: PIL.ImageChops.logical_xor .. autofunction:: PIL.ImageChops.multiply +.. autofunction:: PIL.ImageChops.soft_light +.. autofunction:: PIL.ImageChops.hard_light +.. autofunction:: PIL.ImageChops.overlay .. py:method:: PIL.ImageChops.offset(image, xoffset, yoffset=None) Returns a copy of the image where data has been offset by the given diff --git a/docs/releasenotes/7.1.0.rst b/docs/releasenotes/7.1.0.rst index adc0fefcf..67aee69f6 100644 --- a/docs/releasenotes/7.1.0.rst +++ b/docs/releasenotes/7.1.0.rst @@ -21,11 +21,11 @@ been resolved. API Additions ============= -Reading JPEG comments -^^^^^^^^^^^^^^^^^^^^^ +New channel operations +^^^^^^^^^^^^^^^^^^^^^^ -When opening a JPEG image, the comment may now be read into -:py:attr:`~PIL.Image.Image.info`. +Three new channel operations have been added: :py:meth:`~PIL.ImageChops.soft_light`, +:py:meth:`~PIL.ImageChops.hard_light` and :py:meth:`~PIL.ImageChops.overlay`. PILLOW_VERSION constant ^^^^^^^^^^^^^^^^^^^^^^^ @@ -36,6 +36,13 @@ release. Use ``__version__`` instead. It was initially removed in Pillow 7.0.0, but brought back in 7.1.0 to give projects more time to upgrade. +Reading JPEG comments +^^^^^^^^^^^^^^^^^^^^^ + +When opening a JPEG image, the comment may now be read into +:py:attr:`~PIL.Image.Image.info`. + + Other Changes ============= diff --git a/src/PIL/Image.py b/src/PIL/Image.py index 7b96b14f4..3ced965e8 100644 --- a/src/PIL/Image.py +++ b/src/PIL/Image.py @@ -1890,7 +1890,11 @@ class Image: factor_y = int((box[3] - box[1]) / size[1] / reducing_gap) or 1 if factor_x > 1 or factor_y > 1: reduce_box = self._get_safe_box(size, resample, box) - self = self.reduce((factor_x, factor_y), box=reduce_box) + factor = (factor_x, factor_y) + if callable(self.reduce): + self = self.reduce(factor, box=reduce_box) + else: + self = Image.reduce(self, factor, box=reduce_box) box = ( (box[0] - reduce_box[0]) / factor_x, (box[1] - reduce_box[1]) / factor_y, diff --git a/src/PIL/ImageChops.py b/src/PIL/ImageChops.py index b1f71b5e7..2d13b529f 100644 --- a/src/PIL/ImageChops.py +++ b/src/PIL/ImageChops.py @@ -139,6 +139,42 @@ def screen(image1, image2): return image1._new(image1.im.chop_screen(image2.im)) +def soft_light(image1, image2): + """ + Superimposes two images on top of each other using the Soft Light algorithm + + :rtype: :py:class:`~PIL.Image.Image` + """ + + image1.load() + image2.load() + return image1._new(image1.im.chop_soft_light(image2.im)) + + +def hard_light(image1, image2): + """ + Superimposes two images on top of each other using the Hard Light algorithm + + :rtype: :py:class:`~PIL.Image.Image` + """ + + image1.load() + image2.load() + return image1._new(image1.im.chop_hard_light(image2.im)) + + +def overlay(image1, image2): + """ + Superimposes two images on top of each other using the Overlay algorithm + + :rtype: :py:class:`~PIL.Image.Image` + """ + + image1.load() + image2.load() + return image1._new(image1.im.chop_overlay(image2.im)) + + def add(image1, image2, scale=1.0, offset=0): """ Adds two images, dividing the result by scale and adding the diff --git a/src/PIL/Jpeg2KImagePlugin.py b/src/PIL/Jpeg2KImagePlugin.py index 2c51d3678..0b0d433db 100644 --- a/src/PIL/Jpeg2KImagePlugin.py +++ b/src/PIL/Jpeg2KImagePlugin.py @@ -176,7 +176,7 @@ class Jpeg2KImageFile(ImageFile.ImageFile): if self.size is None or self.mode is None: raise SyntaxError("unable to determine size/mode") - self.reduce = 0 + self._reduce = 0 self.layers = 0 fd = -1 @@ -200,23 +200,33 @@ class Jpeg2KImageFile(ImageFile.ImageFile): "jpeg2k", (0, 0) + self.size, 0, - (self.codec, self.reduce, self.layers, fd, length), + (self.codec, self._reduce, self.layers, fd, length), ) ] + @property + def reduce(self): + # https://github.com/python-pillow/Pillow/issues/4343 found that the + # new Image 'reduce' method was shadowed by this plugin's 'reduce' + # property. This attempts to allow for both scenarios + return self._reduce or super().reduce + + @reduce.setter + def reduce(self, value): + self._reduce = value + def load(self): - if self.reduce: - power = 1 << self.reduce + if self.tile and self._reduce: + power = 1 << self._reduce adjust = power >> 1 self._size = ( int((self.size[0] + adjust) / power), int((self.size[1] + adjust) / power), ) - if self.tile: # Update the reduce and layers settings t = self.tile[0] - t3 = (t[3][0], self.reduce, self.layers, t[3][3], t[3][4]) + t3 = (t[3][0], self._reduce, self.layers, t[3][3], t[3][4]) self.tile = [(t[0], (0, 0) + self.size, t[2], t3)] return ImageFile.ImageFile.load(self) diff --git a/src/_imaging.c b/src/_imaging.c index 190b312bc..f40b19e4d 100644 --- a/src/_imaging.c +++ b/src/_imaging.c @@ -2406,6 +2406,38 @@ _chop_subtract_modulo(ImagingObject* self, PyObject* args) return PyImagingNew(ImagingChopSubtractModulo(self->image, imagep->image)); } +static PyObject* +_chop_soft_light(ImagingObject* self, PyObject* args) +{ + ImagingObject* imagep; + + if (!PyArg_ParseTuple(args, "O!", &Imaging_Type, &imagep)) + return NULL; + + return PyImagingNew(ImagingChopSoftLight(self->image, imagep->image)); +} + +static PyObject* +_chop_hard_light(ImagingObject* self, PyObject* args) +{ + ImagingObject* imagep; + + if (!PyArg_ParseTuple(args, "O!", &Imaging_Type, &imagep)) + return NULL; + + return PyImagingNew(ImagingChopHardLight(self->image, imagep->image)); +} + +static PyObject* +_chop_overlay(ImagingObject* self, PyObject* args) +{ + ImagingObject* imagep; + + if (!PyArg_ParseTuple(args, "O!", &Imaging_Type, &imagep)) + return NULL; + + return PyImagingNew(ImagingOverlay(self->image, imagep->image)); +} #endif @@ -3325,6 +3357,10 @@ static struct PyMethodDef methods[] = { {"chop_and", (PyCFunction)_chop_and, 1}, {"chop_or", (PyCFunction)_chop_or, 1}, {"chop_xor", (PyCFunction)_chop_xor, 1}, + {"chop_soft_light", (PyCFunction)_chop_soft_light, 1}, + {"chop_hard_light", (PyCFunction)_chop_hard_light, 1}, + {"chop_overlay", (PyCFunction)_chop_overlay, 1}, + #endif #ifdef WITH_UNSHARPMASK diff --git a/src/libImaging/Chops.c b/src/libImaging/Chops.c index 8059b6ffb..a1673dff6 100644 --- a/src/libImaging/Chops.c +++ b/src/libImaging/Chops.c @@ -146,3 +146,27 @@ ImagingChopSubtractModulo(Imaging imIn1, Imaging imIn2) { CHOP2(in1[x] - in2[x], NULL); } + +Imaging +ImagingChopSoftLight(Imaging imIn1, Imaging imIn2) +{ + CHOP2( (((255-in1[x]) * (in1[x]*in2[x]) ) / 65536) + + (in1[x] * ( 255 - ( (255 - in1[x]) * (255 - in2[x] ) / 255) )) / 255 + , NULL ); +} + +Imaging +ImagingChopHardLight(Imaging imIn1, Imaging imIn2) +{ + CHOP2( (in2[x]<128) ? ( (in1[x]*in2[x])/127) + : 255 - ( ((255-in2[x]) * (255-in1[x])) / 127) + , NULL); +} + +Imaging +ImagingOverlay(Imaging imIn1, Imaging imIn2) +{ + CHOP2( (in1[x]<128) ? ( (in1[x]*in2[x])/127) + : 255 - ( ((255-in1[x]) * (255-in2[x])) / 127) + , NULL); +} diff --git a/src/libImaging/Imaging.h b/src/libImaging/Imaging.h index 71dc9c003..9032fcf07 100644 --- a/src/libImaging/Imaging.h +++ b/src/libImaging/Imaging.h @@ -339,6 +339,9 @@ extern Imaging ImagingChopSubtract( Imaging imIn1, Imaging imIn2, float scale, int offset); extern Imaging ImagingChopAddModulo(Imaging imIn1, Imaging imIn2); extern Imaging ImagingChopSubtractModulo(Imaging imIn1, Imaging imIn2); +extern Imaging ImagingChopSoftLight(Imaging imIn1, Imaging imIn2); +extern Imaging ImagingChopHardLight(Imaging imIn1, Imaging imIn2); +extern Imaging ImagingOverlay(Imaging imIn1, Imaging imIn2); /* "1" images only */ extern Imaging ImagingChopAnd(Imaging imIn1, Imaging imIn2); diff --git a/winbuild/config.py b/winbuild/config.py index f9b23b4ff..93413d1e5 100644 --- a/winbuild/config.py +++ b/winbuild/config.py @@ -98,14 +98,14 @@ libs = { "dir": "libimagequant-e5d454bc7f5eb63ee50c84a83a7fa5ac94f68ec4", }, "harfbuzz": { - "url": "https://github.com/harfbuzz/harfbuzz/archive/2.6.1.zip", - "filename": "harfbuzz-2.6.1.zip", - "dir": "harfbuzz-2.6.1", + "url": "https://github.com/harfbuzz/harfbuzz/archive/2.6.4.zip", + "filename": "harfbuzz-2.6.4.zip", + "dir": "harfbuzz-2.6.4", }, "fribidi": { - "url": "https://github.com/fribidi/fribidi/archive/v1.0.7.zip", - "filename": "fribidi-1.0.7.zip", - "dir": "fribidi-1.0.7", + "url": "https://github.com/fribidi/fribidi/archive/v1.0.9.zip", + "filename": "fribidi-1.0.9.zip", + "dir": "fribidi-1.0.9", }, "libraqm": { "url": "https://github.com/HOST-Oman/libraqm/archive/v0.7.0.zip",