From eb2d6560a4f81bb4b323b7baa79c259663201c84 Mon Sep 17 00:00:00 2001 From: Hugo Date: Mon, 17 Feb 2020 00:03:27 +0200 Subject: [PATCH 01/15] Replace unittest with pytest --- Tests/test_file_im.py | 129 ++++++++++++++++++++++-------------------- 1 file changed, 69 insertions(+), 60 deletions(-) diff --git a/Tests/test_file_im.py b/Tests/test_file_im.py index ec046020e..cfdb46097 100644 --- a/Tests/test_file_im.py +++ b/Tests/test_file_im.py @@ -1,89 +1,98 @@ -import unittest - import pytest from PIL import Image, ImImagePlugin -from .helper import PillowTestCase, assert_image_equal, hopper, is_pypy +from .helper import assert_image_equal, hopper, is_pypy # sample im TEST_IM = "Tests/images/hopper.im" -class TestFileIm(PillowTestCase): - def test_sanity(self): +def test_sanity(): + with Image.open(TEST_IM) as im: + im.load() + assert im.mode == "RGB" + assert im.size == (128, 128) + assert im.format == "IM" + + +@pytest.mark.skipif(is_pypy(), reason="Requires CPython") +def test_unclosed_file(): + def open(): + im = Image.open(TEST_IM) + im.load() + + pytest.warns(ResourceWarning, open) + + +def test_closed_file(): + def open(): + im = Image.open(TEST_IM) + im.load() + im.close() + + pytest.warns(None, open) + + +def test_context_manager(): + def open(): with Image.open(TEST_IM) as im: im.load() - self.assertEqual(im.mode, "RGB") - self.assertEqual(im.size, (128, 128)) - self.assertEqual(im.format, "IM") - @unittest.skipIf(is_pypy(), "Requires CPython") - def test_unclosed_file(self): - def open(): - im = Image.open(TEST_IM) - im.load() + pytest.warns(None, open) - pytest.warns(ResourceWarning, open) - def test_closed_file(self): - def open(): - im = Image.open(TEST_IM) - im.load() - im.close() +def test_tell(): + # Arrange + with Image.open(TEST_IM) as im: - pytest.warns(None, open) + # Act + frame = im.tell() - def test_context_manager(self): - def open(): - with Image.open(TEST_IM) as im: - im.load() + # Assert + assert frame == 0 - pytest.warns(None, open) - def test_tell(self): - # Arrange - with Image.open(TEST_IM) as im: +def test_n_frames(): + with Image.open(TEST_IM) as im: + assert im.n_frames == 1 + assert not im.is_animated - # Act - frame = im.tell() - # Assert - self.assertEqual(frame, 0) +def test_eoferror(): + with Image.open(TEST_IM) as im: + n_frames = im.n_frames - def test_n_frames(self): - with Image.open(TEST_IM) as im: - self.assertEqual(im.n_frames, 1) - self.assertFalse(im.is_animated) + # Test seeking past the last frame + with pytest.raises(EOFError): + im.seek(n_frames) + assert im.tell() < n_frames - def test_eoferror(self): - with Image.open(TEST_IM) as im: - n_frames = im.n_frames + # Test that seeking to the last frame does not raise an error + im.seek(n_frames - 1) - # Test seeking past the last frame - self.assertRaises(EOFError, im.seek, n_frames) - self.assertLess(im.tell(), n_frames) - # Test that seeking to the last frame does not raise an error - im.seek(n_frames - 1) +def test_roundtrip(tmp_path): + for mode in ["RGB", "P", "PA"]: + out = str(tmp_path / "temp.im") + im = hopper(mode) + im.save(out) + with Image.open(out) as reread: + assert_image_equal(reread, im) - def test_roundtrip(self): - for mode in ["RGB", "P", "PA"]: - out = self.tempfile("temp.im") - im = hopper(mode) - im.save(out) - with Image.open(out) as reread: - assert_image_equal(reread, im) +def test_save_unsupported_mode(tmp_path): + out = str(tmp_path / "temp.im") + im = hopper("HSV") + with pytest.raises(ValueError): + im.save(out) - def test_save_unsupported_mode(self): - out = self.tempfile("temp.im") - im = hopper("HSV") - self.assertRaises(ValueError, im.save, out) - def test_invalid_file(self): - invalid_file = "Tests/images/flower.jpg" +def test_invalid_file(): + invalid_file = "Tests/images/flower.jpg" - self.assertRaises(SyntaxError, ImImagePlugin.ImImageFile, invalid_file) + with pytest.raises(SyntaxError): + ImImagePlugin.ImImageFile(invalid_file) - def test_number(self): - self.assertEqual(1.2, ImImagePlugin.number("1.2")) + +def test_number(): + assert ImImagePlugin.number("1.2") == 1.2 From 85e06057e273d1dd56d80465cf097ba333c0b2b6 Mon Sep 17 00:00:00 2001 From: Hugo Date: Mon, 17 Feb 2020 00:57:58 +0200 Subject: [PATCH 02/15] The 'Name: ' field must be less than length 100 --- src/PIL/ImImagePlugin.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/PIL/ImImagePlugin.py b/src/PIL/ImImagePlugin.py index 12c9237f0..fe67b3076 100644 --- a/src/PIL/ImImagePlugin.py +++ b/src/PIL/ImImagePlugin.py @@ -347,7 +347,11 @@ def _save(im, fp, filename): fp.write(("Image type: %s image\r\n" % image_type).encode("ascii")) if filename: - fp.write(("Name: %s\r\n" % filename).encode("ascii")) + # Each line must be under length 100, or: SyntaxError("not an IM file") + name_format = "Name: %s\r\n" + max = 100 - len(name_format % "") + # Keep the last part of the string, will hold the filename.ext + fp.write((name_format % filename[-max:]).encode("ascii")) fp.write(("Image size (x*y): %d*%d\r\n" % im.size).encode("ascii")) fp.write(("File size (no of images): %d\r\n" % frames).encode("ascii")) if im.mode in ["P", "PA"]: From 946a038b13fb6045a5ed8cfe1ea9fbaeaadfe4e4 Mon Sep 17 00:00:00 2001 From: Hugo Date: Mon, 17 Feb 2020 10:42:33 +0200 Subject: [PATCH 03/15] Replace unittest with pytest --- Tests/test_file_bmp.py | 215 ++++++++++++++++++++++------------------- 1 file changed, 113 insertions(+), 102 deletions(-) diff --git a/Tests/test_file_bmp.py b/Tests/test_file_bmp.py index b3a01b95a..8bb58794c 100644 --- a/Tests/test_file_bmp.py +++ b/Tests/test_file_bmp.py @@ -1,128 +1,139 @@ import io +import pytest from PIL import BmpImagePlugin, Image -from .helper import PillowTestCase, assert_image_equal, hopper +from .helper import assert_image_equal, hopper -class TestFileBmp(PillowTestCase): - def roundtrip(self, im): - outfile = self.tempfile("temp.bmp") +def test_sanity(tmp_path): + def roundtrip(im): + outfile = str(tmp_path / "temp.bmp") im.save(outfile, "BMP") with Image.open(outfile) as reloaded: reloaded.load() - self.assertEqual(im.mode, reloaded.mode) - self.assertEqual(im.size, reloaded.size) - self.assertEqual(reloaded.format, "BMP") - self.assertEqual(reloaded.get_format_mimetype(), "image/bmp") + assert im.mode == reloaded.mode + assert im.size == reloaded.size + assert reloaded.format == "BMP" + assert reloaded.get_format_mimetype() == "image/bmp" - def test_sanity(self): - self.roundtrip(hopper()) + roundtrip(hopper()) - self.roundtrip(hopper("1")) - self.roundtrip(hopper("L")) - self.roundtrip(hopper("P")) - self.roundtrip(hopper("RGB")) + roundtrip(hopper("1")) + roundtrip(hopper("L")) + roundtrip(hopper("P")) + roundtrip(hopper("RGB")) - def test_invalid_file(self): - with open("Tests/images/flower.jpg", "rb") as fp: - self.assertRaises(SyntaxError, BmpImagePlugin.BmpImageFile, fp) - def test_save_to_bytes(self): - output = io.BytesIO() - im = hopper() - im.save(output, "BMP") +def test_invalid_file(): + with open("Tests/images/flower.jpg", "rb") as fp: + with pytest.raises(SyntaxError): + BmpImagePlugin.BmpImageFile(fp) - output.seek(0) - with Image.open(output) as reloaded: - self.assertEqual(im.mode, reloaded.mode) - self.assertEqual(im.size, reloaded.size) - self.assertEqual(reloaded.format, "BMP") - def test_save_too_large(self): - outfile = self.tempfile("temp.bmp") - with Image.new("RGB", (1, 1)) as im: - im._size = (37838, 37838) - with self.assertRaises(ValueError): - im.save(outfile) +def test_save_to_bytes(): + output = io.BytesIO() + im = hopper() + im.save(output, "BMP") - def test_dpi(self): - dpi = (72, 72) + output.seek(0) + with Image.open(output) as reloaded: + assert im.mode == reloaded.mode + assert im.size == reloaded.size + assert reloaded.format == "BMP" - output = io.BytesIO() - with hopper() as im: - im.save(output, "BMP", dpi=dpi) - output.seek(0) - with Image.open(output) as reloaded: - self.assertEqual(reloaded.info["dpi"], dpi) - - def test_save_bmp_with_dpi(self): - # Test for #1301 - # Arrange - outfile = self.tempfile("temp.jpg") - with Image.open("Tests/images/hopper.bmp") as im: - - # Act - im.save(outfile, "JPEG", dpi=im.info["dpi"]) - - # Assert - with Image.open(outfile) as reloaded: - reloaded.load() - self.assertEqual(im.info["dpi"], reloaded.info["dpi"]) - self.assertEqual(im.size, reloaded.size) - self.assertEqual(reloaded.format, "JPEG") - - def test_load_dpi_rounding(self): - # Round up - with Image.open("Tests/images/hopper.bmp") as im: - self.assertEqual(im.info["dpi"], (96, 96)) - - # Round down - with Image.open("Tests/images/hopper_roundDown.bmp") as im: - self.assertEqual(im.info["dpi"], (72, 72)) - - def test_save_dpi_rounding(self): - outfile = self.tempfile("temp.bmp") - with Image.open("Tests/images/hopper.bmp") as im: - im.save(outfile, dpi=(72.2, 72.2)) - with Image.open(outfile) as reloaded: - self.assertEqual(reloaded.info["dpi"], (72, 72)) - - im.save(outfile, dpi=(72.8, 72.8)) - with Image.open(outfile) as reloaded: - self.assertEqual(reloaded.info["dpi"], (73, 73)) - - def test_load_dib(self): - # test for #1293, Imagegrab returning Unsupported Bitfields Format - with Image.open("Tests/images/clipboard.dib") as im: - self.assertEqual(im.format, "DIB") - self.assertEqual(im.get_format_mimetype(), "image/bmp") - - with Image.open("Tests/images/clipboard_target.png") as target: - assert_image_equal(im, target) - - def test_save_dib(self): - outfile = self.tempfile("temp.dib") - - with Image.open("Tests/images/clipboard.dib") as im: +def test_save_too_large(tmp_path): + outfile = str(tmp_path / "temp.bmp") + with Image.new("RGB", (1, 1)) as im: + im._size = (37838, 37838) + with pytest.raises(ValueError): im.save(outfile) - with Image.open(outfile) as reloaded: - self.assertEqual(reloaded.format, "DIB") - self.assertEqual(reloaded.get_format_mimetype(), "image/bmp") - assert_image_equal(im, reloaded) - def test_rgba_bitfields(self): - # This test image has been manually hexedited - # to change the bitfield compression in the header from XBGR to RGBA - with Image.open("Tests/images/rgb32bf-rgba.bmp") as im: +def test_dpi(): + dpi = (72, 72) - # So before the comparing the image, swap the channels - b, g, r = im.split()[1:] - im = Image.merge("RGB", (r, g, b)) + output = io.BytesIO() + with hopper() as im: + im.save(output, "BMP", dpi=dpi) - with Image.open("Tests/images/bmp/q/rgb32bf-xbgr.bmp") as target: + output.seek(0) + with Image.open(output) as reloaded: + assert reloaded.info["dpi"] == dpi + + +def test_save_bmp_with_dpi(tmp_path): + # Test for #1301 + # Arrange + outfile = str(tmp_path / "temp.jpg") + with Image.open("Tests/images/hopper.bmp") as im: + + # Act + im.save(outfile, "JPEG", dpi=im.info["dpi"]) + + # Assert + with Image.open(outfile) as reloaded: + reloaded.load() + assert im.info["dpi"] == reloaded.info["dpi"] + assert im.size == reloaded.size + assert reloaded.format == "JPEG" + + +def test_load_dpi_rounding(): + # Round up + with Image.open("Tests/images/hopper.bmp") as im: + assert im.info["dpi"] == (96, 96) + + # Round down + with Image.open("Tests/images/hopper_roundDown.bmp") as im: + assert im.info["dpi"] == (72, 72) + + +def test_save_dpi_rounding(tmp_path): + outfile = str(tmp_path / "temp.bmp") + with Image.open("Tests/images/hopper.bmp") as im: + im.save(outfile, dpi=(72.2, 72.2)) + with Image.open(outfile) as reloaded: + assert reloaded.info["dpi"] == (72, 72) + + im.save(outfile, dpi=(72.8, 72.8)) + with Image.open(outfile) as reloaded: + assert reloaded.info["dpi"] == (73, 73) + + +def test_load_dib(): + # test for #1293, Imagegrab returning Unsupported Bitfields Format + with Image.open("Tests/images/clipboard.dib") as im: + assert im.format == "DIB" + assert im.get_format_mimetype() == "image/bmp" + + with Image.open("Tests/images/clipboard_target.png") as target: assert_image_equal(im, target) + + +def test_save_dib(tmp_path): + outfile = str(tmp_path / "temp.dib") + + with Image.open("Tests/images/clipboard.dib") as im: + im.save(outfile) + + with Image.open(outfile) as reloaded: + assert reloaded.format == "DIB" + assert reloaded.get_format_mimetype() == "image/bmp" + assert_image_equal(im, reloaded) + + +def test_rgba_bitfields(): + # This test image has been manually hexedited + # to change the bitfield compression in the header from XBGR to RGBA + with Image.open("Tests/images/rgb32bf-rgba.bmp") as im: + + # So before the comparing the image, swap the channels + b, g, r = im.split()[1:] + im = Image.merge("RGB", (r, g, b)) + + with Image.open("Tests/images/bmp/q/rgb32bf-xbgr.bmp") as target: + assert_image_equal(im, target) From c5161348906a84e43650a69405439cbc5d46bd64 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Wed, 19 Feb 2020 20:56:23 +1100 Subject: [PATCH 04/15] Rearranged code for Windows --- Tests/test_file_im.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/Tests/test_file_im.py b/Tests/test_file_im.py index cfdb46097..38ee6c6fb 100644 --- a/Tests/test_file_im.py +++ b/Tests/test_file_im.py @@ -72,13 +72,16 @@ def test_eoferror(): def test_roundtrip(tmp_path): - for mode in ["RGB", "P", "PA"]: + def roundtrip(mode): out = str(tmp_path / "temp.im") im = hopper(mode) im.save(out) with Image.open(out) as reread: assert_image_equal(reread, im) + for mode in ["RGB", "P", "PA"]: + roundtrip(mode) + def test_save_unsupported_mode(tmp_path): out = str(tmp_path / "temp.im") From a8c07941074a16ee06879071f42ea5b624210f5f Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Fri, 21 Feb 2020 22:05:44 +1100 Subject: [PATCH 05/15] Allow saving of zero quality JPEG images --- Tests/test_file_jpeg.py | 4 ++++ docs/handbook/image-file-formats.rst | 2 +- src/PIL/JpegImagePlugin.py | 10 +++++----- src/libImaging/Jpeg.h | 2 +- src/libImaging/JpegEncode.c | 4 ++-- 5 files changed, 13 insertions(+), 9 deletions(-) diff --git a/Tests/test_file_jpeg.py b/Tests/test_file_jpeg.py index a2a848b41..fd79e03c8 100644 --- a/Tests/test_file_jpeg.py +++ b/Tests/test_file_jpeg.py @@ -299,6 +299,10 @@ class TestFileJpeg(PillowTestCase): assert_image(im1, im2.mode, im2.size) self.assertGreaterEqual(im1.bytes, im2.bytes) + im3 = self.roundtrip(hopper(), quality=0) + assert_image(im1, im3.mode, im3.size) + self.assertGreater(im2.bytes, im3.bytes) + def test_smooth(self): im1 = self.roundtrip(hopper()) im2 = self.roundtrip(hopper(), smooth=100) diff --git a/docs/handbook/image-file-formats.rst b/docs/handbook/image-file-formats.rst index 0e068c1e4..7ce685ed2 100644 --- a/docs/handbook/image-file-formats.rst +++ b/docs/handbook/image-file-formats.rst @@ -302,7 +302,7 @@ The :py:meth:`~PIL.Image.Image.open` method may set the following The :py:meth:`~PIL.Image.Image.save` method supports the following options: **quality** - The image quality, on a scale from 1 (worst) to 95 (best). The default is + The image quality, on a scale from 0 (worst) to 95 (best). The default is 75. Values above 95 should be avoided; 100 disables portions of the JPEG compression algorithm, and results in large files with hardly any gain in image quality. diff --git a/src/PIL/JpegImagePlugin.py b/src/PIL/JpegImagePlugin.py index 9cba544de..b5371db73 100644 --- a/src/PIL/JpegImagePlugin.py +++ b/src/PIL/JpegImagePlugin.py @@ -616,17 +616,17 @@ def _save(im, fp, filename): dpi = [round(x) for x in info.get("dpi", (0, 0))] - quality = info.get("quality", 0) + quality = info.get("quality", -1) subsampling = info.get("subsampling", -1) qtables = info.get("qtables") if quality == "keep": - quality = 0 + quality = -1 subsampling = "keep" qtables = "keep" elif quality in presets: preset = presets[quality] - quality = 0 + quality = -1 subsampling = preset.get("subsampling", -1) qtables = preset.get("quantization") elif not isinstance(quality, int): @@ -749,8 +749,8 @@ def _save(im, fp, filename): # CMYK can be bigger if im.mode == "CMYK": bufsize = 4 * im.size[0] * im.size[1] - # keep sets quality to 0, but the actual value may be high. - elif quality >= 95 or quality == 0: + # keep sets quality to -1, but the actual value may be high. + elif quality >= 95 or quality == -1: bufsize = 2 * im.size[0] * im.size[1] else: bufsize = im.size[0] * im.size[1] diff --git a/src/libImaging/Jpeg.h b/src/libImaging/Jpeg.h index 82e1b449f..7ff658c32 100644 --- a/src/libImaging/Jpeg.h +++ b/src/libImaging/Jpeg.h @@ -67,7 +67,7 @@ typedef struct { /* CONFIGURATION */ - /* Quality (1-100, 0 means default) */ + /* Quality (0-100, -1 means default) */ int quality; /* Progressive mode */ diff --git a/src/libImaging/JpegEncode.c b/src/libImaging/JpegEncode.c index 10ad886e0..9d8382791 100644 --- a/src/libImaging/JpegEncode.c +++ b/src/libImaging/JpegEncode.c @@ -153,7 +153,7 @@ ImagingJpegEncode(Imaging im, ImagingCodecState state, UINT8* buf, int bytes) int i; int quality = 100; int last_q = 0; - if (context->quality > 0) { + if (context->quality != -1) { quality = context->quality; } for (i = 0; i < context->qtablesLen; i++) { @@ -171,7 +171,7 @@ ImagingJpegEncode(Imaging im, ImagingCodecState state, UINT8* buf, int bytes) for (i = last_q; i < context->cinfo.num_components; i++) { context->cinfo.comp_info[i].quant_tbl_no = last_q; } - } else if (context->quality > 0) { + } else if (context->quality != -1) { jpeg_set_quality(&context->cinfo, context->quality, 1); } From a99b9d63f4d832bfbe729801846466e009507022 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Fri, 21 Feb 2020 22:17:56 +1100 Subject: [PATCH 06/15] Document quality parameter change when saving JPEGs [ci skip] --- docs/releasenotes/7.1.0.rst | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 docs/releasenotes/7.1.0.rst diff --git a/docs/releasenotes/7.1.0.rst b/docs/releasenotes/7.1.0.rst new file mode 100644 index 000000000..f9639a636 --- /dev/null +++ b/docs/releasenotes/7.1.0.rst @@ -0,0 +1,16 @@ +7.0.0 +----- + +Allow saving of zero quality JPEG images +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +If no quality was specified when saving a JPEG, Pillow internally used a value +of zero to indicate that the default quality should be used. However, this +removed the ability to actually save a JPEG with zero quality. This has now +been resolved. + +.. code-block:: python + + from PIL import Image + im = Image.open("hopper.jpg") + im.save("out.jpg", quality=0) From a82ba5b2c25f48e2d40de3dc801a7683e4011306 Mon Sep 17 00:00:00 2001 From: Hugo Date: Sat, 22 Feb 2020 13:00:25 +0200 Subject: [PATCH 07/15] Save IM: use only filename as name, ditch potentially overlong path --- src/PIL/ImImagePlugin.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/PIL/ImImagePlugin.py b/src/PIL/ImImagePlugin.py index fe67b3076..427d6e986 100644 --- a/src/PIL/ImImagePlugin.py +++ b/src/PIL/ImImagePlugin.py @@ -26,6 +26,7 @@ # +import os import re from . import Image, ImageFile, ImagePalette @@ -348,10 +349,8 @@ def _save(im, fp, filename): fp.write(("Image type: %s image\r\n" % image_type).encode("ascii")) if filename: # Each line must be under length 100, or: SyntaxError("not an IM file") - name_format = "Name: %s\r\n" - max = 100 - len(name_format % "") - # Keep the last part of the string, will hold the filename.ext - fp.write((name_format % filename[-max:]).encode("ascii")) + # Keep just the filename, ditch the potentially overlong path + fp.write(("Name: %s\r\n" % os.path.basename(filename)).encode("ascii")) fp.write(("Image size (x*y): %d*%d\r\n" % im.size).encode("ascii")) fp.write(("File size (no of images): %d\r\n" % frames).encode("ascii")) if im.mode in ["P", "PA"]: From 699a9dadf1523fca451c6ea4aa3fd5fb7074e60b Mon Sep 17 00:00:00 2001 From: Hugo Date: Sat, 22 Feb 2020 18:07:04 +0200 Subject: [PATCH 08/15] Convert asserts --- Tests/bench_cffi_access.py | 2 +- Tests/check_imaging_leaks.py | 2 +- Tests/test_color_lut.py | 64 ++++++++++++++++---------------- Tests/test_file_jpeg.py | 3 +- Tests/test_file_jpeg2k.py | 3 +- Tests/test_file_libtiff.py | 4 +- Tests/test_file_png.py | 3 +- Tests/test_file_tiff_metadata.py | 4 +- Tests/test_image_access.py | 5 +-- Tests/test_image_resample.py | 20 +++++----- Tests/test_imagefont.py | 2 +- 11 files changed, 58 insertions(+), 54 deletions(-) diff --git a/Tests/bench_cffi_access.py b/Tests/bench_cffi_access.py index 1797d34fc..8b172343c 100644 --- a/Tests/bench_cffi_access.py +++ b/Tests/bench_cffi_access.py @@ -49,7 +49,7 @@ class BenchCffiAccess(PillowTestCase): caccess = im.im.pixel_access(False) access = PyAccess.new(im, False) - self.assertEqual(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) diff --git a/Tests/check_imaging_leaks.py b/Tests/check_imaging_leaks.py index 2c1793a4f..8ca955ac7 100755 --- a/Tests/check_imaging_leaks.py +++ b/Tests/check_imaging_leaks.py @@ -26,7 +26,7 @@ class TestImagingLeaks(PillowTestCase): mem_limit = mem + 1 continue msg = "memory usage limit exceeded after %d iterations" % (i + 1) - self.assertLessEqual(mem, mem_limit, msg) + assert mem <= mem_limit, msg def test_leak_putdata(self): im = Image.new("RGB", (25, 25)) diff --git a/Tests/test_color_lut.py b/Tests/test_color_lut.py index 82a3bd1aa..6f8cb05b7 100644 --- a/Tests/test_color_lut.py +++ b/Tests/test_color_lut.py @@ -42,37 +42,37 @@ class TestColorLut3DCoreAPI(PillowTestCase): def test_wrong_args(self): im = Image.new("RGB", (10, 10), 0) - with self.assertRaisesRegex(ValueError, "filter"): + with pytest.raises(ValueError, match="filter"): im.im.color_lut_3d("RGB", Image.CUBIC, *self.generate_identity_table(3, 3)) - with self.assertRaisesRegex(ValueError, "image mode"): + with pytest.raises(ValueError, match="image mode"): im.im.color_lut_3d( "wrong", Image.LINEAR, *self.generate_identity_table(3, 3) ) - with self.assertRaisesRegex(ValueError, "table_channels"): + with pytest.raises(ValueError, match="table_channels"): im.im.color_lut_3d("RGB", Image.LINEAR, *self.generate_identity_table(5, 3)) - with self.assertRaisesRegex(ValueError, "table_channels"): + with pytest.raises(ValueError, match="table_channels"): im.im.color_lut_3d("RGB", Image.LINEAR, *self.generate_identity_table(1, 3)) - with self.assertRaisesRegex(ValueError, "table_channels"): + with pytest.raises(ValueError, match="table_channels"): im.im.color_lut_3d("RGB", Image.LINEAR, *self.generate_identity_table(2, 3)) - with self.assertRaisesRegex(ValueError, "Table size"): + with pytest.raises(ValueError, match="Table size"): im.im.color_lut_3d( "RGB", Image.LINEAR, *self.generate_identity_table(3, (1, 3, 3)) ) - with self.assertRaisesRegex(ValueError, "Table size"): + with pytest.raises(ValueError, match="Table size"): im.im.color_lut_3d( "RGB", Image.LINEAR, *self.generate_identity_table(3, (66, 3, 3)) ) - with self.assertRaisesRegex(ValueError, r"size1D \* size2D \* size3D"): + with pytest.raises(ValueError, match=r"size1D \* size2D \* size3D"): im.im.color_lut_3d("RGB", Image.LINEAR, 3, 2, 2, 2, [0, 0, 0] * 7) - with self.assertRaisesRegex(ValueError, r"size1D \* size2D \* size3D"): + with pytest.raises(ValueError, match=r"size1D \* size2D \* size3D"): im.im.color_lut_3d("RGB", Image.LINEAR, 3, 2, 2, 2, [0, 0, 0] * 9) with pytest.raises(TypeError): @@ -105,25 +105,25 @@ class TestColorLut3DCoreAPI(PillowTestCase): ) def test_wrong_mode(self): - with self.assertRaisesRegex(ValueError, "wrong mode"): + with pytest.raises(ValueError, match="wrong mode"): im = Image.new("L", (10, 10), 0) im.im.color_lut_3d("RGB", Image.LINEAR, *self.generate_identity_table(3, 3)) - with self.assertRaisesRegex(ValueError, "wrong mode"): + with pytest.raises(ValueError, match="wrong mode"): im = Image.new("RGB", (10, 10), 0) im.im.color_lut_3d("L", Image.LINEAR, *self.generate_identity_table(3, 3)) - with self.assertRaisesRegex(ValueError, "wrong mode"): + with pytest.raises(ValueError, match="wrong mode"): im = Image.new("L", (10, 10), 0) im.im.color_lut_3d("L", Image.LINEAR, *self.generate_identity_table(3, 3)) - with self.assertRaisesRegex(ValueError, "wrong mode"): + with pytest.raises(ValueError, match="wrong mode"): im = Image.new("RGB", (10, 10), 0) im.im.color_lut_3d( "RGBA", Image.LINEAR, *self.generate_identity_table(3, 3) ) - with self.assertRaisesRegex(ValueError, "wrong mode"): + with pytest.raises(ValueError, match="wrong mode"): im = Image.new("RGB", (10, 10), 0) im.im.color_lut_3d("RGB", Image.LINEAR, *self.generate_identity_table(4, 3)) @@ -273,31 +273,31 @@ class TestColorLut3DCoreAPI(PillowTestCase): class TestColorLut3DFilter(PillowTestCase): def test_wrong_args(self): - with self.assertRaisesRegex(ValueError, "should be either an integer"): + with pytest.raises(ValueError, match="should be either an integer"): ImageFilter.Color3DLUT("small", [1]) - with self.assertRaisesRegex(ValueError, "should be either an integer"): + with pytest.raises(ValueError, match="should be either an integer"): ImageFilter.Color3DLUT((11, 11), [1]) - with self.assertRaisesRegex(ValueError, r"in \[2, 65\] range"): + with pytest.raises(ValueError, match=r"in \[2, 65\] range"): ImageFilter.Color3DLUT((11, 11, 1), [1]) - with self.assertRaisesRegex(ValueError, r"in \[2, 65\] range"): + with pytest.raises(ValueError, match=r"in \[2, 65\] range"): ImageFilter.Color3DLUT((11, 11, 66), [1]) - with self.assertRaisesRegex(ValueError, "table should have .+ items"): + with pytest.raises(ValueError, match="table should have .+ items"): ImageFilter.Color3DLUT((3, 3, 3), [1, 1, 1]) - with self.assertRaisesRegex(ValueError, "table should have .+ items"): + with pytest.raises(ValueError, match="table should have .+ items"): ImageFilter.Color3DLUT((3, 3, 3), [[1, 1, 1]] * 2) - with self.assertRaisesRegex(ValueError, "should have a length of 4"): + with pytest.raises(ValueError, match="should have a length of 4"): ImageFilter.Color3DLUT((3, 3, 3), [[1, 1, 1]] * 27, channels=4) - with self.assertRaisesRegex(ValueError, "should have a length of 3"): + with pytest.raises(ValueError, match="should have a length of 3"): ImageFilter.Color3DLUT((2, 2, 2), [[1, 1]] * 8) - with self.assertRaisesRegex(ValueError, "Only 3 or 4 output"): + with pytest.raises(ValueError, match="Only 3 or 4 output"): ImageFilter.Color3DLUT((2, 2, 2), [[1, 1]] * 8, channels=2) def test_convert_table(self): @@ -320,7 +320,7 @@ class TestColorLut3DFilter(PillowTestCase): @unittest.skipIf(numpy is None, "Numpy is not installed") def test_numpy_sources(self): table = numpy.ones((5, 6, 7, 3), dtype=numpy.float16) - with self.assertRaisesRegex(ValueError, "should have either channels"): + with pytest.raises(ValueError, match="should have either channels"): lut = ImageFilter.Color3DLUT((5, 6, 7), table) table = numpy.ones((7, 6, 5, 3), dtype=numpy.float16) @@ -359,12 +359,12 @@ class TestColorLut3DFilter(PillowTestCase): lut = ImageFilter.Color3DLUT.generate((7, 9, 11), lambda r, g, b: (r, g, b)) lut.table = numpy.array(lut.table, dtype=numpy.float32)[:-1] - with self.assertRaisesRegex(ValueError, "should have table_channels"): + with pytest.raises(ValueError, match="should have table_channels"): im.filter(lut) lut = ImageFilter.Color3DLUT.generate((7, 9, 11), lambda r, g, b: (r, g, b)) lut.table = numpy.array(lut.table, dtype=numpy.float32).reshape((7 * 9 * 11), 3) - with self.assertRaisesRegex(ValueError, "should have table_channels"): + with pytest.raises(ValueError, match="should have table_channels"): im.filter(lut) lut = ImageFilter.Color3DLUT.generate((7, 9, 11), lambda r, g, b: (r, g, b)) @@ -404,15 +404,15 @@ class TestColorLut3DFilter(PillowTestCase): class TestGenerateColorLut3D(PillowTestCase): def test_wrong_channels_count(self): - with self.assertRaisesRegex(ValueError, "3 or 4 output channels"): + with pytest.raises(ValueError, match="3 or 4 output channels"): ImageFilter.Color3DLUT.generate( 5, channels=2, callback=lambda r, g, b: (r, g, b) ) - with self.assertRaisesRegex(ValueError, "should have either channels"): + with pytest.raises(ValueError, match="should have either channels"): ImageFilter.Color3DLUT.generate(5, lambda r, g, b: (r, g, b, r)) - with self.assertRaisesRegex(ValueError, "should have either channels"): + with pytest.raises(ValueError, match="should have either channels"): ImageFilter.Color3DLUT.generate( 5, channels=4, callback=lambda r, g, b: (r, g, b) ) @@ -454,13 +454,13 @@ class TestTransformColorLut3D(PillowTestCase): def test_wrong_args(self): source = ImageFilter.Color3DLUT.generate(5, lambda r, g, b: (r, g, b)) - with self.assertRaisesRegex(ValueError, "Only 3 or 4 output"): + with pytest.raises(ValueError, match="Only 3 or 4 output"): source.transform(lambda r, g, b: (r, g, b), channels=8) - with self.assertRaisesRegex(ValueError, "should have either channels"): + with pytest.raises(ValueError, match="should have either channels"): source.transform(lambda r, g, b: (r, g, b), channels=4) - with self.assertRaisesRegex(ValueError, "should have either channels"): + with pytest.raises(ValueError, match="should have either channels"): source.transform(lambda r, g, b: (r, g, b, 1)) with pytest.raises(TypeError): diff --git a/Tests/test_file_jpeg.py b/Tests/test_file_jpeg.py index be1188a63..7a934fc16 100644 --- a/Tests/test_file_jpeg.py +++ b/Tests/test_file_jpeg.py @@ -1,4 +1,5 @@ import os +import re from io import BytesIO import pytest @@ -42,7 +43,7 @@ class TestFileJpeg(PillowTestCase): def test_sanity(self): # internal version number - self.assertRegex(Image.core.jpeglib_version, r"\d+\.\d+$") + assert re.search(r"\d+\.\d+$", Image.core.jpeglib_version) with Image.open(TEST_FILE) as im: im.load() diff --git a/Tests/test_file_jpeg2k.py b/Tests/test_file_jpeg2k.py index 339b54c46..1a0e9a358 100644 --- a/Tests/test_file_jpeg2k.py +++ b/Tests/test_file_jpeg2k.py @@ -1,3 +1,4 @@ +import re from io import BytesIO import pytest @@ -34,7 +35,7 @@ class TestFileJpeg2k(PillowTestCase): def test_sanity(self): # Internal version number - self.assertRegex(Image.core.jp2klib_version, r"\d+\.\d+\.\d+$") + assert re.search(r"\d+\.\d+\.\d+$", Image.core.jp2klib_version) with Image.open("Tests/images/test-card-lossless.jp2") as im: px = im.load() diff --git a/Tests/test_file_libtiff.py b/Tests/test_file_libtiff.py index 988effee7..175ae987f 100644 --- a/Tests/test_file_libtiff.py +++ b/Tests/test_file_libtiff.py @@ -295,7 +295,9 @@ class TestFileLibTiff(LibTiffTestCase): and libtiff ): # libtiff does not support real RATIONALS - self.assertAlmostEqual(float(reloaded_value), float(value)) + assert ( + round(abs(float(reloaded_value) - float(value)), 7) == 0 + ) continue if libtiff and isinstance(value, bytes): diff --git a/Tests/test_file_png.py b/Tests/test_file_png.py index c434d836c..6fbb4e414 100644 --- a/Tests/test_file_png.py +++ b/Tests/test_file_png.py @@ -1,3 +1,4 @@ +import re import unittest import zlib from io import BytesIO @@ -75,7 +76,7 @@ class TestFilePng(PillowTestCase): def test_sanity(self): # internal version number - self.assertRegex(Image.core.zlib_version, r"\d+\.\d+\.\d+(\.\d+)?$") + assert re.search(r"\d+\.\d+\.\d+(\.\d+)?$", Image.core.zlib_version) test_file = self.tempfile("temp.png") diff --git a/Tests/test_file_tiff_metadata.py b/Tests/test_file_tiff_metadata.py index a00bd1c73..5554a25e9 100644 --- a/Tests/test_file_tiff_metadata.py +++ b/Tests/test_file_tiff_metadata.py @@ -65,9 +65,9 @@ class TestFileTiffMetadata(PillowTestCase): assert loaded.tag_v2[ImageDescription] == reloaded_textdata loaded_float = loaded.tag[tag_ids["RollAngle"]][0] - self.assertAlmostEqual(loaded_float, floatdata, places=5) + assert round(abs(loaded_float - floatdata), 5) == 0 loaded_double = loaded.tag[tag_ids["YawAngle"]][0] - self.assertAlmostEqual(loaded_double, doubledata) + assert round(abs(loaded_double - doubledata), 7) == 0 # check with 2 element ImageJMetaDataByteCounts, issue #2006 diff --git a/Tests/test_image_access.py b/Tests/test_image_access.py index 721b2e7fd..35d61f904 100644 --- a/Tests/test_image_access.py +++ b/Tests/test_image_access.py @@ -228,9 +228,8 @@ class TestCffi(AccessTest): assert access[(x, y)] == caccess[(x, y)] # Access an out-of-range pixel - self.assertRaises( - ValueError, lambda: access[(access.xsize + 1, access.ysize + 1)] - ) + with pytest.raises(ValueError): + access[(access.xsize + 1, access.ysize + 1)] def test_get_vs_c(self): rgb = hopper("RGB") diff --git a/Tests/test_image_resample.py b/Tests/test_image_resample.py index 7ed6fce07..d3813b90a 100644 --- a/Tests/test_image_resample.py +++ b/Tests/test_image_resample.py @@ -418,24 +418,24 @@ class CoreResampleBoxTest(PillowTestCase): im.resize((32, 32), resample, (20, 20, 20, 100)) im.resize((32, 32), resample, (20, 20, 100, 20)) - with self.assertRaisesRegex(TypeError, "must be sequence of length 4"): + with pytest.raises(TypeError, match="must be sequence of length 4"): im.resize((32, 32), resample, (im.width, im.height)) - with self.assertRaisesRegex(ValueError, "can't be negative"): + with pytest.raises(ValueError, match="can't be negative"): im.resize((32, 32), resample, (-20, 20, 100, 100)) - with self.assertRaisesRegex(ValueError, "can't be negative"): + with pytest.raises(ValueError, match="can't be negative"): im.resize((32, 32), resample, (20, -20, 100, 100)) - with self.assertRaisesRegex(ValueError, "can't be empty"): + with pytest.raises(ValueError, match="can't be empty"): im.resize((32, 32), resample, (20.1, 20, 20, 100)) - with self.assertRaisesRegex(ValueError, "can't be empty"): + with pytest.raises(ValueError, match="can't be empty"): im.resize((32, 32), resample, (20, 20.1, 100, 20)) - with self.assertRaisesRegex(ValueError, "can't be empty"): + with pytest.raises(ValueError, match="can't be empty"): im.resize((32, 32), resample, (20.1, 20.1, 20, 20)) - with self.assertRaisesRegex(ValueError, "can't exceed"): + with pytest.raises(ValueError, match="can't exceed"): im.resize((32, 32), resample, (0, 0, im.width + 1, im.height)) - with self.assertRaisesRegex(ValueError, "can't exceed"): + with pytest.raises(ValueError, match="can't exceed"): im.resize((32, 32), resample, (0, 0, im.width, im.height + 1)) def resize_tiled(self, im, dst_size, xtiles, ytiles): @@ -480,7 +480,7 @@ class CoreResampleBoxTest(PillowTestCase): # error with box should be much smaller than without assert_image_similar(reference, with_box, 6) - with self.assertRaisesRegex(AssertionError, r"difference 29\."): + with pytest.raises(AssertionError, match=r"difference 29\."): assert_image_similar(reference, without_box, 5) def test_formats(self): @@ -518,7 +518,7 @@ class CoreResampleBoxTest(PillowTestCase): ]: res = im.resize(size, Image.LANCZOS, box) assert res.size == size - with self.assertRaisesRegex(AssertionError, r"difference \d"): + with pytest.raises(AssertionError, match=r"difference \d"): # check that the difference at least that much assert_image_similar( res, im.crop(box), 20, ">>> {} {}".format(size, box) diff --git a/Tests/test_imagefont.py b/Tests/test_imagefont.py index 29da67f08..690f624d3 100644 --- a/Tests/test_imagefont.py +++ b/Tests/test_imagefont.py @@ -66,7 +66,7 @@ class TestImageFont(PillowTestCase): ) def test_sanity(self): - self.assertRegex(ImageFont.core.freetype2_version, r"\d+\.\d+\.\d+$") + assert re.search(r"\d+\.\d+\.\d+$", ImageFont.core.freetype2_version) def test_font_properties(self): ttf = self.get_font() From 44e661f25a55fd967090afcf247d2dd4082a21a1 Mon Sep 17 00:00:00 2001 From: Hugo Date: Sat, 22 Feb 2020 23:03:01 +0200 Subject: [PATCH 09/15] Convert to use pytest --- Tests/test_color_lut.py | 15 +- Tests/test_file_eps.py | 375 ++++----- Tests/test_file_gif.py | 1321 ++++++++++++++++---------------- Tests/test_file_icns.py | 201 ++--- Tests/test_file_ico.py | 160 ++-- Tests/test_file_msp.py | 121 +-- Tests/test_file_spider.py | 233 +++--- Tests/test_image.py | 43 +- Tests/test_image_resample.py | 21 +- Tests/test_imagefont_bitmap.py | 61 +- 10 files changed, 1310 insertions(+), 1241 deletions(-) diff --git a/Tests/test_color_lut.py b/Tests/test_color_lut.py index 6f8cb05b7..b34dbadb6 100644 --- a/Tests/test_color_lut.py +++ b/Tests/test_color_lut.py @@ -1,10 +1,9 @@ -import unittest from array import array import pytest from PIL import Image, ImageFilter -from .helper import PillowTestCase, assert_image_equal +from .helper import assert_image_equal try: import numpy @@ -12,7 +11,7 @@ except ImportError: numpy = None -class TestColorLut3DCoreAPI(PillowTestCase): +class TestColorLut3DCoreAPI: def generate_identity_table(self, channels, size): if isinstance(size, tuple): size1D, size2D, size3D = size @@ -271,7 +270,7 @@ class TestColorLut3DCoreAPI(PillowTestCase): assert transformed[205, 205] == (255, 255, 0) -class TestColorLut3DFilter(PillowTestCase): +class TestColorLut3DFilter: def test_wrong_args(self): with pytest.raises(ValueError, match="should be either an integer"): ImageFilter.Color3DLUT("small", [1]) @@ -317,7 +316,7 @@ class TestColorLut3DFilter(PillowTestCase): assert tuple(lut.size) == (2, 2, 2) assert lut.table == list(range(4)) * 8 - @unittest.skipIf(numpy is None, "Numpy is not installed") + @pytest.mark.skipif(numpy is None, reason="NumPy not installed") def test_numpy_sources(self): table = numpy.ones((5, 6, 7, 3), dtype=numpy.float16) with pytest.raises(ValueError, match="should have either channels"): @@ -350,7 +349,7 @@ class TestColorLut3DFilter(PillowTestCase): table[0] = 33 assert lut.table[0] == 33 - @unittest.skipIf(numpy is None, "Numpy is not installed") + @pytest.mark.skipif(numpy is None, reason="NumPy not installed") def test_numpy_formats(self): g = Image.linear_gradient("L") im = Image.merge( @@ -402,7 +401,7 @@ class TestColorLut3DFilter(PillowTestCase): ) -class TestGenerateColorLut3D(PillowTestCase): +class TestGenerateColorLut3D: def test_wrong_channels_count(self): with pytest.raises(ValueError, match="3 or 4 output channels"): ImageFilter.Color3DLUT.generate( @@ -450,7 +449,7 @@ class TestGenerateColorLut3D(PillowTestCase): assert im == im.filter(lut) -class TestTransformColorLut3D(PillowTestCase): +class TestTransformColorLut3D: def test_wrong_args(self): source = ImageFilter.Color3DLUT.generate(5, lambda r, g, b: (r, g, b)) diff --git a/Tests/test_file_eps.py b/Tests/test_file_eps.py index af980b94a..4729f4a9a 100644 --- a/Tests/test_file_eps.py +++ b/Tests/test_file_eps.py @@ -1,190 +1,207 @@ import io -import unittest import pytest from PIL import EpsImagePlugin, Image, features -from .helper import PillowTestCase, assert_image_similar, hopper, skip_unless_feature +from .helper import assert_image_similar, hopper, skip_unless_feature HAS_GHOSTSCRIPT = EpsImagePlugin.has_ghostscript() # Our two EPS test files (they are identical except for their bounding boxes) -file1 = "Tests/images/zero_bb.eps" -file2 = "Tests/images/non_zero_bb.eps" +FILE1 = "Tests/images/zero_bb.eps" +FILE2 = "Tests/images/non_zero_bb.eps" # Due to palletization, we'll need to convert these to RGB after load -file1_compare = "Tests/images/zero_bb.png" -file1_compare_scale2 = "Tests/images/zero_bb_scale2.png" +FILE1_COMPARE = "Tests/images/zero_bb.png" +FILE1_COMPARE_SCALE2 = "Tests/images/zero_bb_scale2.png" -file2_compare = "Tests/images/non_zero_bb.png" -file2_compare_scale2 = "Tests/images/non_zero_bb_scale2.png" +FILE2_COMPARE = "Tests/images/non_zero_bb.png" +FILE2_COMPARE_SCALE2 = "Tests/images/non_zero_bb_scale2.png" # EPS test files with binary preview -file3 = "Tests/images/binary_preview_map.eps" +FILE3 = "Tests/images/binary_preview_map.eps" -class TestFileEps(PillowTestCase): - @unittest.skipUnless(HAS_GHOSTSCRIPT, "Ghostscript not available") - def test_sanity(self): - # Regular scale - with Image.open(file1) as image1: - image1.load() - assert image1.mode == "RGB" - assert image1.size == (460, 352) - assert image1.format == "EPS" +@pytest.mark.skipif(not HAS_GHOSTSCRIPT, reason="Ghostscript not available") +def test_sanity(): + # Regular scale + with Image.open(FILE1) as image1: + image1.load() + assert image1.mode == "RGB" + assert image1.size == (460, 352) + assert image1.format == "EPS" - with Image.open(file2) as image2: - image2.load() - assert image2.mode == "RGB" - assert image2.size == (360, 252) - assert image2.format == "EPS" + with Image.open(FILE2) as image2: + image2.load() + assert image2.mode == "RGB" + assert image2.size == (360, 252) + assert image2.format == "EPS" - # Double scale - with Image.open(file1) as image1_scale2: - image1_scale2.load(scale=2) - assert image1_scale2.mode == "RGB" - assert image1_scale2.size == (920, 704) - assert image1_scale2.format == "EPS" + # Double scale + with Image.open(FILE1) as image1_scale2: + image1_scale2.load(scale=2) + assert image1_scale2.mode == "RGB" + assert image1_scale2.size == (920, 704) + assert image1_scale2.format == "EPS" - with Image.open(file2) as image2_scale2: - image2_scale2.load(scale=2) - assert image2_scale2.mode == "RGB" - assert image2_scale2.size == (720, 504) - assert image2_scale2.format == "EPS" + with Image.open(FILE2) as image2_scale2: + image2_scale2.load(scale=2) + assert image2_scale2.mode == "RGB" + assert image2_scale2.size == (720, 504) + assert image2_scale2.format == "EPS" - def test_invalid_file(self): - invalid_file = "Tests/images/flower.jpg" - with pytest.raises(SyntaxError): - EpsImagePlugin.EpsImageFile(invalid_file) +def test_invalid_file(): + invalid_file = "Tests/images/flower.jpg" - @unittest.skipUnless(HAS_GHOSTSCRIPT, "Ghostscript not available") - def test_cmyk(self): - with Image.open("Tests/images/pil_sample_cmyk.eps") as cmyk_image: + with pytest.raises(SyntaxError): + EpsImagePlugin.EpsImageFile(invalid_file) - assert cmyk_image.mode == "CMYK" - assert cmyk_image.size == (100, 100) - assert cmyk_image.format == "EPS" - cmyk_image.load() - assert cmyk_image.mode == "RGB" +@pytest.mark.skipif(not HAS_GHOSTSCRIPT, reason="Ghostscript not available") +def test_cmyk(): + with Image.open("Tests/images/pil_sample_cmyk.eps") as cmyk_image: - if features.check("jpg"): - with Image.open("Tests/images/pil_sample_rgb.jpg") as target: - assert_image_similar(cmyk_image, target, 10) + assert cmyk_image.mode == "CMYK" + assert cmyk_image.size == (100, 100) + assert cmyk_image.format == "EPS" - @unittest.skipUnless(HAS_GHOSTSCRIPT, "Ghostscript not available") - def test_showpage(self): - # See https://github.com/python-pillow/Pillow/issues/2615 - with Image.open("Tests/images/reqd_showpage.eps") as plot_image: - with Image.open("Tests/images/reqd_showpage.png") as target: - # should not crash/hang - plot_image.load() - # fonts could be slightly different - assert_image_similar(plot_image, target, 6) + cmyk_image.load() + assert cmyk_image.mode == "RGB" - @unittest.skipUnless(HAS_GHOSTSCRIPT, "Ghostscript not available") - def test_file_object(self): - # issue 479 - with Image.open(file1) as image1: - with open(self.tempfile("temp_file.eps"), "wb") as fh: - image1.save(fh, "EPS") + if features.check("jpg"): + with Image.open("Tests/images/pil_sample_rgb.jpg") as target: + assert_image_similar(cmyk_image, target, 10) - @unittest.skipUnless(HAS_GHOSTSCRIPT, "Ghostscript not available") - def test_iobase_object(self): - # issue 479 - with Image.open(file1) as image1: - with open(self.tempfile("temp_iobase.eps"), "wb") as fh: - image1.save(fh, "EPS") - @unittest.skipUnless(HAS_GHOSTSCRIPT, "Ghostscript not available") - def test_bytesio_object(self): - with open(file1, "rb") as f: - img_bytes = io.BytesIO(f.read()) +@pytest.mark.skipif(not HAS_GHOSTSCRIPT, reason="Ghostscript not available") +def test_showpage(): + # See https://github.com/python-pillow/Pillow/issues/2615 + with Image.open("Tests/images/reqd_showpage.eps") as plot_image: + with Image.open("Tests/images/reqd_showpage.png") as target: + # should not crash/hang + plot_image.load() + # fonts could be slightly different + assert_image_similar(plot_image, target, 6) - with Image.open(img_bytes) as img: - img.load() - with Image.open(file1_compare) as image1_scale1_compare: - image1_scale1_compare = image1_scale1_compare.convert("RGB") - image1_scale1_compare.load() - assert_image_similar(img, image1_scale1_compare, 5) +@pytest.mark.skipif(not HAS_GHOSTSCRIPT, reason="Ghostscript not available") +def test_file_object(tmp_path): + # issue 479 + with Image.open(FILE1) as image1: + with open(str(tmp_path / "temp.eps"), "wb") as fh: + image1.save(fh, "EPS") - def test_image_mode_not_supported(self): - im = hopper("RGBA") - tmpfile = self.tempfile("temp.eps") - with pytest.raises(ValueError): - im.save(tmpfile) - @unittest.skipUnless(HAS_GHOSTSCRIPT, "Ghostscript not available") - @skip_unless_feature("zlib") - def test_render_scale1(self): - # We need png support for these render test +@pytest.mark.skipif(not HAS_GHOSTSCRIPT, reason="Ghostscript not available") +def test_iobase_object(tmp_path): + # issue 479 + with Image.open(FILE1) as image1: + with open(str(tmp_path / "temp_iobase.eps"), "wb") as fh: + image1.save(fh, "EPS") - # Zero bounding box - with Image.open(file1) as image1_scale1: - image1_scale1.load() - with Image.open(file1_compare) as image1_scale1_compare: - image1_scale1_compare = image1_scale1_compare.convert("RGB") - image1_scale1_compare.load() - assert_image_similar(image1_scale1, image1_scale1_compare, 5) - # Non-Zero bounding box - with Image.open(file2) as image2_scale1: - image2_scale1.load() - with Image.open(file2_compare) as image2_scale1_compare: - image2_scale1_compare = image2_scale1_compare.convert("RGB") - image2_scale1_compare.load() - assert_image_similar(image2_scale1, image2_scale1_compare, 10) +@pytest.mark.skipif(not HAS_GHOSTSCRIPT, reason="Ghostscript not available") +def test_bytesio_object(): + with open(FILE1, "rb") as f: + img_bytes = io.BytesIO(f.read()) - @unittest.skipUnless(HAS_GHOSTSCRIPT, "Ghostscript not available") - @skip_unless_feature("zlib") - def test_render_scale2(self): - # We need png support for these render test + with Image.open(img_bytes) as img: + img.load() - # Zero bounding box - with Image.open(file1) as image1_scale2: - image1_scale2.load(scale=2) - with Image.open(file1_compare_scale2) as image1_scale2_compare: - image1_scale2_compare = image1_scale2_compare.convert("RGB") - image1_scale2_compare.load() - assert_image_similar(image1_scale2, image1_scale2_compare, 5) + with Image.open(FILE1_COMPARE) as image1_scale1_compare: + image1_scale1_compare = image1_scale1_compare.convert("RGB") + image1_scale1_compare.load() + assert_image_similar(img, image1_scale1_compare, 5) - # Non-Zero bounding box - with Image.open(file2) as image2_scale2: - image2_scale2.load(scale=2) - with Image.open(file2_compare_scale2) as image2_scale2_compare: - image2_scale2_compare = image2_scale2_compare.convert("RGB") - image2_scale2_compare.load() - assert_image_similar(image2_scale2, image2_scale2_compare, 10) - @unittest.skipUnless(HAS_GHOSTSCRIPT, "Ghostscript not available") - def test_resize(self): - files = [file1, file2, "Tests/images/illu10_preview.eps"] - for fn in files: - with Image.open(fn) as im: - new_size = (100, 100) - im = im.resize(new_size) - assert im.size == new_size +def test_image_mode_not_supported(tmp_path): + im = hopper("RGBA") + tmpfile = str(tmp_path / "temp.eps") + with pytest.raises(ValueError): + im.save(tmpfile) - @unittest.skipUnless(HAS_GHOSTSCRIPT, "Ghostscript not available") - def test_thumbnail(self): - # Issue #619 - # Arrange - files = [file1, file2] - for fn in files: - with Image.open(file1) as im: - new_size = (100, 100) - im.thumbnail(new_size) - assert max(im.size) == max(new_size) - def test_read_binary_preview(self): - # Issue 302 - # open image with binary preview - with Image.open(file3): - pass +@pytest.mark.skipif(not HAS_GHOSTSCRIPT, reason="Ghostscript not available") +@skip_unless_feature("zlib") +def test_render_scale1(): + # We need png support for these render test - def _test_readline(self, t, ending): + # Zero bounding box + with Image.open(FILE1) as image1_scale1: + image1_scale1.load() + with Image.open(FILE1_COMPARE) as image1_scale1_compare: + image1_scale1_compare = image1_scale1_compare.convert("RGB") + image1_scale1_compare.load() + assert_image_similar(image1_scale1, image1_scale1_compare, 5) + + # Non-Zero bounding box + with Image.open(FILE2) as image2_scale1: + image2_scale1.load() + with Image.open(FILE2_COMPARE) as image2_scale1_compare: + image2_scale1_compare = image2_scale1_compare.convert("RGB") + image2_scale1_compare.load() + assert_image_similar(image2_scale1, image2_scale1_compare, 10) + + +@pytest.mark.skipif(not HAS_GHOSTSCRIPT, reason="Ghostscript not available") +@skip_unless_feature("zlib") +def test_render_scale2(): + # We need png support for these render test + + # Zero bounding box + with Image.open(FILE1) as image1_scale2: + image1_scale2.load(scale=2) + with Image.open(FILE1_COMPARE_SCALE2) as image1_scale2_compare: + image1_scale2_compare = image1_scale2_compare.convert("RGB") + image1_scale2_compare.load() + assert_image_similar(image1_scale2, image1_scale2_compare, 5) + + # Non-Zero bounding box + with Image.open(FILE2) as image2_scale2: + image2_scale2.load(scale=2) + with Image.open(FILE2_COMPARE_SCALE2) as image2_scale2_compare: + image2_scale2_compare = image2_scale2_compare.convert("RGB") + image2_scale2_compare.load() + assert_image_similar(image2_scale2, image2_scale2_compare, 10) + + +@pytest.mark.skipif(not HAS_GHOSTSCRIPT, reason="Ghostscript not available") +def test_resize(): + files = [FILE1, FILE2, "Tests/images/illu10_preview.eps"] + for fn in files: + with Image.open(fn) as im: + new_size = (100, 100) + im = im.resize(new_size) + assert im.size == new_size + + +@pytest.mark.skipif(not HAS_GHOSTSCRIPT, reason="Ghostscript not available") +def test_thumbnail(): + # Issue #619 + # Arrange + files = [FILE1, FILE2] + for fn in files: + with Image.open(FILE1) as im: + new_size = (100, 100) + im.thumbnail(new_size) + assert max(im.size) == max(new_size) + + +def test_read_binary_preview(): + # Issue 302 + # open image with binary preview + with Image.open(FILE3): + pass + + +def test_readline(tmp_path): + # check all the freaking line endings possible from the spec + # test_string = u'something\r\nelse\n\rbaz\rbif\n' + line_endings = ["\r\n", "\n", "\n\r", "\r"] + strings = ["something", "else", "baz", "bif"] + + def _test_readline(t, ending): ending = "Failure with line ending: %s" % ( "".join("%s" % ord(s) for s in ending) ) @@ -193,53 +210,49 @@ class TestFileEps(PillowTestCase): assert t.readline().strip("\r\n") == "baz", ending assert t.readline().strip("\r\n") == "bif", ending - def _test_readline_io_psfile(self, test_string, ending): + def _test_readline_io_psfile(test_string, ending): f = io.BytesIO(test_string.encode("latin-1")) t = EpsImagePlugin.PSFile(f) - self._test_readline(t, ending) + _test_readline(t, ending) - def _test_readline_file_psfile(self, test_string, ending): - f = self.tempfile("temp.txt") + def _test_readline_file_psfile(test_string, ending): + f = str(tmp_path / "temp.bufr") with open(f, "wb") as w: w.write(test_string.encode("latin-1")) with open(f, "rb") as r: t = EpsImagePlugin.PSFile(r) - self._test_readline(t, ending) + _test_readline(t, ending) - def test_readline(self): - # check all the freaking line endings possible from the spec - # test_string = u'something\r\nelse\n\rbaz\rbif\n' - line_endings = ["\r\n", "\n", "\n\r", "\r"] - strings = ["something", "else", "baz", "bif"] + for ending in line_endings: + s = ending.join(strings) + _test_readline_io_psfile(s, ending) + _test_readline_file_psfile(s, ending) - for ending in line_endings: - s = ending.join(strings) - self._test_readline_io_psfile(s, ending) - self._test_readline_file_psfile(s, ending) - def test_open_eps(self): - # https://github.com/python-pillow/Pillow/issues/1104 - # Arrange - FILES = [ - "Tests/images/illu10_no_preview.eps", - "Tests/images/illu10_preview.eps", - "Tests/images/illuCS6_no_preview.eps", - "Tests/images/illuCS6_preview.eps", - ] +def test_open_eps(): + # https://github.com/python-pillow/Pillow/issues/1104 + # Arrange + FILES = [ + "Tests/images/illu10_no_preview.eps", + "Tests/images/illu10_preview.eps", + "Tests/images/illuCS6_no_preview.eps", + "Tests/images/illuCS6_preview.eps", + ] - # Act / Assert - for filename in FILES: - with Image.open(filename) as img: - assert img.mode == "RGB" + # Act / Assert + for filename in FILES: + with Image.open(filename) as img: + assert img.mode == "RGB" - @unittest.skipUnless(HAS_GHOSTSCRIPT, "Ghostscript not available") - def test_emptyline(self): - # Test file includes an empty line in the header data - emptyline_file = "Tests/images/zero_bb_emptyline.eps" - with Image.open(emptyline_file) as image: - image.load() - assert image.mode == "RGB" - assert image.size == (460, 352) - assert image.format == "EPS" +@pytest.mark.skipif(not HAS_GHOSTSCRIPT, reason="Ghostscript not available") +def test_emptyline(): + # Test file includes an empty line in the header data + emptyline_file = "Tests/images/zero_bb_emptyline.eps" + + with Image.open(emptyline_file) as image: + image.load() + assert image.mode == "RGB" + assert image.size == (460, 352) + assert image.format == "EPS" diff --git a/Tests/test_file_gif.py b/Tests/test_file_gif.py index 096aa872c..455e30f71 100644 --- a/Tests/test_file_gif.py +++ b/Tests/test_file_gif.py @@ -1,11 +1,9 @@ -import unittest from io import BytesIO import pytest from PIL import GifImagePlugin, Image, ImageDraw, ImagePalette, features from .helper import ( - PillowTestCase, assert_image_equal, assert_image_similar, hopper, @@ -20,772 +18,811 @@ with open(TEST_GIF, "rb") as f: data = f.read() -class TestFileGif(PillowTestCase): - def test_sanity(self): +def test_sanity(): + with Image.open(TEST_GIF) as im: + im.load() + assert im.mode == "P" + assert im.size == (128, 128) + assert im.format == "GIF" + assert im.info["version"] == b"GIF89a" + + +@pytest.mark.skipif(is_pypy(), reason="Requires CPython") +def test_unclosed_file(): + def open(): + im = Image.open(TEST_GIF) + im.load() + + pytest.warns(ResourceWarning, open) + + +def test_closed_file(): + def open(): + im = Image.open(TEST_GIF) + im.load() + im.close() + + pytest.warns(None, open) + + +def test_context_manager(): + def open(): with Image.open(TEST_GIF) as im: im.load() - assert im.mode == "P" - assert im.size == (128, 128) - assert im.format == "GIF" - assert im.info["version"] == b"GIF89a" - @unittest.skipIf(is_pypy(), "Requires CPython") - def test_unclosed_file(self): - def open(): - im = Image.open(TEST_GIF) - im.load() + pytest.warns(None, open) - pytest.warns(ResourceWarning, open) - def test_closed_file(self): - def open(): - im = Image.open(TEST_GIF) - im.load() - im.close() +def test_invalid_file(): + invalid_file = "Tests/images/flower.jpg" - pytest.warns(None, open) + with pytest.raises(SyntaxError): + GifImagePlugin.GifImageFile(invalid_file) - def test_context_manager(self): - def open(): - with Image.open(TEST_GIF) as im: - im.load() - pytest.warns(None, open) +def test_optimize(): + def test_grayscale(optimize): + im = Image.new("L", (1, 1), 0) + filename = BytesIO() + im.save(filename, "GIF", optimize=optimize) + return len(filename.getvalue()) - def test_invalid_file(self): - invalid_file = "Tests/images/flower.jpg" - - with pytest.raises(SyntaxError): - GifImagePlugin.GifImageFile(invalid_file) - - def test_optimize(self): - def test_grayscale(optimize): - im = Image.new("L", (1, 1), 0) - filename = BytesIO() - im.save(filename, "GIF", optimize=optimize) - return len(filename.getvalue()) - - def test_bilevel(optimize): - im = Image.new("1", (1, 1), 0) - test_file = BytesIO() - im.save(test_file, "GIF", optimize=optimize) - return len(test_file.getvalue()) - - assert test_grayscale(0) == 800 - assert test_grayscale(1) == 44 - assert test_bilevel(0) == 800 - assert test_bilevel(1) == 800 - - def test_optimize_correctness(self): - # 256 color Palette image, posterize to > 128 and < 128 levels - # Size bigger and smaller than 512x512 - # Check the palette for number of colors allocated. - # Check for correctness after conversion back to RGB - def check(colors, size, expected_palette_length): - # make an image with empty colors in the start of the palette range - im = Image.frombytes( - "P", (colors, colors), bytes(range(256 - colors, 256)) * colors - ) - im = im.resize((size, size)) - outfile = BytesIO() - im.save(outfile, "GIF") - outfile.seek(0) - with Image.open(outfile) as reloaded: - # check palette length - palette_length = max( - i + 1 for i, v in enumerate(reloaded.histogram()) if v - ) - assert expected_palette_length == palette_length - - assert_image_equal(im.convert("RGB"), reloaded.convert("RGB")) - - # These do optimize the palette - check(128, 511, 128) - check(64, 511, 64) - check(4, 511, 4) - - # These don't optimize the palette - check(128, 513, 256) - check(64, 513, 256) - check(4, 513, 256) - - # other limits that don't optimize the palette - check(129, 511, 256) - check(255, 511, 256) - check(256, 511, 256) - - def test_optimize_full_l(self): - im = Image.frombytes("L", (16, 16), bytes(range(256))) + def test_bilevel(optimize): + im = Image.new("1", (1, 1), 0) test_file = BytesIO() - im.save(test_file, "GIF", optimize=True) - assert im.mode == "L" + im.save(test_file, "GIF", optimize=optimize) + return len(test_file.getvalue()) - def test_roundtrip(self): - out = self.tempfile("temp.gif") - im = hopper() - im.save(out) - with Image.open(out) as reread: + assert test_grayscale(0) == 800 + assert test_grayscale(1) == 44 + assert test_bilevel(0) == 800 + assert test_bilevel(1) == 800 - assert_image_similar(reread.convert("RGB"), im, 50) - def test_roundtrip2(self): - # see https://github.com/python-pillow/Pillow/issues/403 - out = self.tempfile("temp.gif") - with Image.open(TEST_GIF) as im: - im2 = im.copy() - im2.save(out) - with Image.open(out) as reread: +def test_optimize_correctness(): + # 256 color Palette image, posterize to > 128 and < 128 levels + # Size bigger and smaller than 512x512 + # Check the palette for number of colors allocated. + # Check for correctness after conversion back to RGB + def check(colors, size, expected_palette_length): + # make an image with empty colors in the start of the palette range + im = Image.frombytes( + "P", (colors, colors), bytes(range(256 - colors, 256)) * colors + ) + im = im.resize((size, size)) + outfile = BytesIO() + im.save(outfile, "GIF") + outfile.seek(0) + with Image.open(outfile) as reloaded: + # check palette length + palette_length = max(i + 1 for i, v in enumerate(reloaded.histogram()) if v) + assert expected_palette_length == palette_length - assert_image_similar(reread.convert("RGB"), hopper(), 50) + assert_image_equal(im.convert("RGB"), reloaded.convert("RGB")) - def test_roundtrip_save_all(self): - # Single frame image - out = self.tempfile("temp.gif") - im = hopper() + # These do optimize the palette + check(128, 511, 128) + check(64, 511, 64) + check(4, 511, 4) + + # These don't optimize the palette + check(128, 513, 256) + check(64, 513, 256) + check(4, 513, 256) + + # Other limits that don't optimize the palette + check(129, 511, 256) + check(255, 511, 256) + check(256, 511, 256) + + +def test_optimize_full_l(): + im = Image.frombytes("L", (16, 16), bytes(range(256))) + test_file = BytesIO() + im.save(test_file, "GIF", optimize=True) + assert im.mode == "L" + + +def test_roundtrip(tmp_path): + out = str(tmp_path / "temp.gif") + im = hopper() + im.save(out) + with Image.open(out) as reread: + + assert_image_similar(reread.convert("RGB"), im, 50) + + +def test_roundtrip2(tmp_path): + # see https://github.com/python-pillow/Pillow/issues/403 + out = str(tmp_path / "temp.gif") + with Image.open(TEST_GIF) as im: + im2 = im.copy() + im2.save(out) + with Image.open(out) as reread: + + assert_image_similar(reread.convert("RGB"), hopper(), 50) + + +def test_roundtrip_save_all(tmp_path): + # Single frame image + out = str(tmp_path / "temp.gif") + im = hopper() + im.save(out, save_all=True) + with Image.open(out) as reread: + + assert_image_similar(reread.convert("RGB"), im, 50) + + # Multiframe image + with Image.open("Tests/images/dispose_bgnd.gif") as im: + out = str(tmp_path / "temp.gif") im.save(out, save_all=True) - with Image.open(out) as reread: - assert_image_similar(reread.convert("RGB"), im, 50) + with Image.open(out) as reread: + assert reread.n_frames == 5 - # Multiframe image - with Image.open("Tests/images/dispose_bgnd.gif") as im: - out = self.tempfile("temp.gif") - im.save(out, save_all=True) - with Image.open(out) as reread: +def test_headers_saving_for_animated_gifs(tmp_path): + important_headers = ["background", "version", "duration", "loop"] + # Multiframe image + with Image.open("Tests/images/dispose_bgnd.gif") as im: - assert reread.n_frames == 5 + info = im.info.copy() - def test_headers_saving_for_animated_gifs(self): - important_headers = ["background", "version", "duration", "loop"] - # Multiframe image - with Image.open("Tests/images/dispose_bgnd.gif") as im: + out = str(tmp_path / "temp.gif") + im.save(out, save_all=True) + with Image.open(out) as reread: - info = im.info.copy() + for header in important_headers: + assert info[header] == reread.info[header] - out = self.tempfile("temp.gif") - im.save(out, save_all=True) - with Image.open(out) as reread: - for header in important_headers: - assert info[header] == reread.info[header] +def test_palette_handling(tmp_path): + # see https://github.com/python-pillow/Pillow/issues/513 - def test_palette_handling(self): - # see https://github.com/python-pillow/Pillow/issues/513 + with Image.open(TEST_GIF) as im: + im = im.convert("RGB") - with Image.open(TEST_GIF) as im: - im = im.convert("RGB") + im = im.resize((100, 100), Image.LANCZOS) + im2 = im.convert("P", palette=Image.ADAPTIVE, colors=256) - im = im.resize((100, 100), Image.LANCZOS) - im2 = im.convert("P", palette=Image.ADAPTIVE, colors=256) + f = str(tmp_path / "temp.gif") + im2.save(f, optimize=True) - f = self.tempfile("temp.gif") - im2.save(f, optimize=True) + with Image.open(f) as reloaded: - with Image.open(f) as reloaded: + assert_image_similar(im, reloaded.convert("RGB"), 10) - assert_image_similar(im, reloaded.convert("RGB"), 10) - def test_palette_434(self): - # see https://github.com/python-pillow/Pillow/issues/434 +def test_palette_434(tmp_path): + # see https://github.com/python-pillow/Pillow/issues/434 - def roundtrip(im, *args, **kwargs): - out = self.tempfile("temp.gif") - im.copy().save(out, *args, **kwargs) - reloaded = Image.open(out) + def roundtrip(im, *args, **kwargs): + out = str(tmp_path / "temp.gif") + im.copy().save(out, *args, **kwargs) + reloaded = Image.open(out) - return reloaded + return reloaded - orig = "Tests/images/test.colors.gif" - with Image.open(orig) as im: + orig = "Tests/images/test.colors.gif" + with Image.open(orig) as im: - with roundtrip(im) as reloaded: - assert_image_similar(im, reloaded, 1) - with roundtrip(im, optimize=True) as reloaded: - assert_image_similar(im, reloaded, 1) + with roundtrip(im) as reloaded: + assert_image_similar(im, reloaded, 1) + with roundtrip(im, optimize=True) as reloaded: + assert_image_similar(im, reloaded, 1) - im = im.convert("RGB") - # check automatic P conversion - with roundtrip(im) as reloaded: - reloaded = reloaded.convert("RGB") - assert_image_equal(im, reloaded) + im = im.convert("RGB") + # check automatic P conversion + with roundtrip(im) as reloaded: + reloaded = reloaded.convert("RGB") + assert_image_equal(im, reloaded) - @unittest.skipUnless(netpbm_available(), "netpbm not available") - def test_save_netpbm_bmp_mode(self): - with Image.open(TEST_GIF) as img: - img = img.convert("RGB") - tempfile = self.tempfile("temp.gif") - GifImagePlugin._save_netpbm(img, 0, tempfile) - with Image.open(tempfile) as reloaded: - assert_image_similar(img, reloaded.convert("RGB"), 0) +@pytest.mark.skipif(not netpbm_available(), reason="Netpbm not available") +def test_save_netpbm_bmp_mode(tmp_path): + with Image.open(TEST_GIF) as img: + img = img.convert("RGB") - @unittest.skipUnless(netpbm_available(), "netpbm not available") - def test_save_netpbm_l_mode(self): - with Image.open(TEST_GIF) as img: - img = img.convert("L") + tempfile = str(tmp_path / "temp.gif") + GifImagePlugin._save_netpbm(img, 0, tempfile) + with Image.open(tempfile) as reloaded: + assert_image_similar(img, reloaded.convert("RGB"), 0) - tempfile = self.tempfile("temp.gif") - GifImagePlugin._save_netpbm(img, 0, tempfile) - with Image.open(tempfile) as reloaded: - assert_image_similar(img, reloaded.convert("L"), 0) - def test_seek(self): - with Image.open("Tests/images/dispose_none.gif") as img: - framecount = 0 - try: - while True: - framecount += 1 - img.seek(img.tell() + 1) - except EOFError: - assert framecount == 5 +@pytest.mark.skipif(not netpbm_available(), reason="Netpbm not available") +def test_save_netpbm_l_mode(tmp_path): + with Image.open(TEST_GIF) as img: + img = img.convert("L") - def test_seek_info(self): - with Image.open("Tests/images/iss634.gif") as im: - info = im.info.copy() + tempfile = str(tmp_path / "temp.gif") + GifImagePlugin._save_netpbm(img, 0, tempfile) + with Image.open(tempfile) as reloaded: + assert_image_similar(img, reloaded.convert("L"), 0) - im.seek(1) - im.seek(0) - assert im.info == info - - def test_seek_rewind(self): - with Image.open("Tests/images/iss634.gif") as im: - im.seek(2) - im.seek(1) - - with Image.open("Tests/images/iss634.gif") as expected: - expected.seek(1) - assert_image_equal(im, expected) - - def test_n_frames(self): - for path, n_frames in [[TEST_GIF, 1], ["Tests/images/iss634.gif", 42]]: - # Test is_animated before n_frames - with Image.open(path) as im: - assert im.is_animated == (n_frames != 1) - - # Test is_animated after n_frames - with Image.open(path) as im: - assert im.n_frames == n_frames - assert im.is_animated == (n_frames != 1) - - def test_eoferror(self): - with Image.open(TEST_GIF) as im: - n_frames = im.n_frames - - # Test seeking past the last frame - with pytest.raises(EOFError): - im.seek(n_frames) - assert im.tell() < n_frames - - # Test that seeking to the last frame does not raise an error - im.seek(n_frames - 1) - - def test_dispose_none(self): - with Image.open("Tests/images/dispose_none.gif") as img: - try: - while True: - img.seek(img.tell() + 1) - assert img.disposal_method == 1 - except EOFError: - pass - - def test_dispose_background(self): - with Image.open("Tests/images/dispose_bgnd.gif") as img: - try: - while True: - img.seek(img.tell() + 1) - assert img.disposal_method == 2 - except EOFError: - pass - - def test_dispose_previous(self): - with Image.open("Tests/images/dispose_prev.gif") as img: - try: - while True: - img.seek(img.tell() + 1) - assert img.disposal_method == 3 - except EOFError: - pass - - def test_save_dispose(self): - out = self.tempfile("temp.gif") - im_list = [ - Image.new("L", (100, 100), "#000"), - Image.new("L", (100, 100), "#111"), - Image.new("L", (100, 100), "#222"), - ] - for method in range(0, 4): - im_list[0].save( - out, save_all=True, append_images=im_list[1:], disposal=method - ) - with Image.open(out) as img: - for _ in range(2): - img.seek(img.tell() + 1) - assert img.disposal_method == method - - # check per frame disposal - im_list[0].save( - out, - save_all=True, - append_images=im_list[1:], - disposal=tuple(range(len(im_list))), - ) - - with Image.open(out) as img: - - for i in range(2): +def test_seek(): + with Image.open("Tests/images/dispose_none.gif") as img: + frame_count = 0 + try: + while True: + frame_count += 1 img.seek(img.tell() + 1) - assert img.disposal_method == i + 1 + except EOFError: + assert frame_count == 5 - def test_dispose2_palette(self): - out = self.tempfile("temp.gif") - # 4 backgrounds: White, Grey, Black, Red - circles = [(255, 255, 255), (153, 153, 153), (0, 0, 0), (255, 0, 0)] +def test_seek_info(): + with Image.open("Tests/images/iss634.gif") as im: + info = im.info.copy() - im_list = [] - for circle in circles: - img = Image.new("RGB", (100, 100), (255, 0, 0)) + im.seek(1) + im.seek(0) - # Red circle in center of each frame - d = ImageDraw.Draw(img) - d.ellipse([(40, 40), (60, 60)], fill=circle) + assert im.info == info - im_list.append(img) - im_list[0].save(out, save_all=True, append_images=im_list[1:], disposal=2) +def test_seek_rewind(): + with Image.open("Tests/images/iss634.gif") as im: + im.seek(2) + im.seek(1) + with Image.open("Tests/images/iss634.gif") as expected: + expected.seek(1) + assert_image_equal(im, expected) + + +def test_n_frames(): + for path, n_frames in [[TEST_GIF, 1], ["Tests/images/iss634.gif", 42]]: + # Test is_animated before n_frames + with Image.open(path) as im: + assert im.is_animated == (n_frames != 1) + + # Test is_animated after n_frames + with Image.open(path) as im: + assert im.n_frames == n_frames + assert im.is_animated == (n_frames != 1) + + +def test_eoferror(): + with Image.open(TEST_GIF) as im: + n_frames = im.n_frames + + # Test seeking past the last frame + with pytest.raises(EOFError): + im.seek(n_frames) + assert im.tell() < n_frames + + # Test that seeking to the last frame does not raise an error + im.seek(n_frames - 1) + + +def test_dispose_none(): + with Image.open("Tests/images/dispose_none.gif") as img: + try: + while True: + img.seek(img.tell() + 1) + assert img.disposal_method == 1 + except EOFError: + pass + + +def test_dispose_background(): + with Image.open("Tests/images/dispose_bgnd.gif") as img: + try: + while True: + img.seek(img.tell() + 1) + assert img.disposal_method == 2 + except EOFError: + pass + + +def test_dispose_previous(): + with Image.open("Tests/images/dispose_prev.gif") as img: + try: + while True: + img.seek(img.tell() + 1) + assert img.disposal_method == 3 + except EOFError: + pass + + +def test_save_dispose(tmp_path): + out = str(tmp_path / "temp.gif") + im_list = [ + Image.new("L", (100, 100), "#000"), + Image.new("L", (100, 100), "#111"), + Image.new("L", (100, 100), "#222"), + ] + for method in range(0, 4): + im_list[0].save(out, save_all=True, append_images=im_list[1:], disposal=method) with Image.open(out) as img: - for i, circle in enumerate(circles): - img.seek(i) - rgb_img = img.convert("RGB") + for _ in range(2): + img.seek(img.tell() + 1) + assert img.disposal_method == method - # Check top left pixel matches background - assert rgb_img.getpixel((0, 0)) == (255, 0, 0) + # Check per frame disposal + im_list[0].save( + out, + save_all=True, + append_images=im_list[1:], + disposal=tuple(range(len(im_list))), + ) - # Center remains red every frame - assert rgb_img.getpixel((50, 50)) == circle + with Image.open(out) as img: - def test_dispose2_diff(self): - out = self.tempfile("temp.gif") - - # 4 frames: red/blue, red/red, blue/blue, red/blue - circles = [ - ((255, 0, 0, 255), (0, 0, 255, 255)), - ((255, 0, 0, 255), (255, 0, 0, 255)), - ((0, 0, 255, 255), (0, 0, 255, 255)), - ((255, 0, 0, 255), (0, 0, 255, 255)), - ] - - im_list = [] - for i in range(len(circles)): - # Transparent BG - img = Image.new("RGBA", (100, 100), (255, 255, 255, 0)) - - # Two circles per frame - d = ImageDraw.Draw(img) - d.ellipse([(0, 30), (40, 70)], fill=circles[i][0]) - d.ellipse([(60, 30), (100, 70)], fill=circles[i][1]) - - im_list.append(img) - - im_list[0].save( - out, save_all=True, append_images=im_list[1:], disposal=2, transparency=0 - ) - - with Image.open(out) as img: - for i, colours in enumerate(circles): - img.seek(i) - rgb_img = img.convert("RGBA") - - # Check left circle is correct colour - assert rgb_img.getpixel((20, 50)) == colours[0] - - # Check right circle is correct colour - assert rgb_img.getpixel((80, 50)) == colours[1] - - # Check BG is correct colour - assert rgb_img.getpixel((1, 1)) == (255, 255, 255, 0) - - def test_dispose2_background(self): - out = self.tempfile("temp.gif") - - im_list = [] - - im = Image.new("P", (100, 100)) - d = ImageDraw.Draw(im) - d.rectangle([(50, 0), (100, 100)], fill="#f00") - d.rectangle([(0, 0), (50, 100)], fill="#0f0") - im_list.append(im) - - im = Image.new("P", (100, 100)) - d = ImageDraw.Draw(im) - d.rectangle([(0, 0), (100, 50)], fill="#f00") - d.rectangle([(0, 50), (100, 100)], fill="#0f0") - im_list.append(im) - - im_list[0].save( - out, save_all=True, append_images=im_list[1:], disposal=[0, 2], background=1 - ) - - with Image.open(out) as im: - im.seek(1) - assert im.getpixel((0, 0)) == 0 - - def test_iss634(self): - with Image.open("Tests/images/iss634.gif") as img: - # seek to the second frame + for i in range(2): img.seek(img.tell() + 1) - # all transparent pixels should be replaced with the color from the - # first frame - assert img.histogram()[img.info["transparency"]] == 0 + assert img.disposal_method == i + 1 - def test_duration(self): - duration = 1000 - out = self.tempfile("temp.gif") - im = Image.new("L", (100, 100), "#000") +def test_dispose2_palette(tmp_path): + out = str(tmp_path / "temp.gif") - # Check that the argument has priority over the info settings - im.info["duration"] = 100 - im.save(out, duration=duration) + # 4 backgrounds: White, Grey, Black, Red + circles = [(255, 255, 255), (153, 153, 153), (0, 0, 0), (255, 0, 0)] - with Image.open(out) as reread: + im_list = [] + for circle in circles: + img = Image.new("RGB", (100, 100), (255, 0, 0)) + + # Red circle in center of each frame + d = ImageDraw.Draw(img) + d.ellipse([(40, 40), (60, 60)], fill=circle) + + im_list.append(img) + + im_list[0].save(out, save_all=True, append_images=im_list[1:], disposal=2) + + with Image.open(out) as img: + for i, circle in enumerate(circles): + img.seek(i) + rgb_img = img.convert("RGB") + + # Check top left pixel matches background + assert rgb_img.getpixel((0, 0)) == (255, 0, 0) + + # Center remains red every frame + assert rgb_img.getpixel((50, 50)) == circle + + +def test_dispose2_diff(tmp_path): + out = str(tmp_path / "temp.gif") + + # 4 frames: red/blue, red/red, blue/blue, red/blue + circles = [ + ((255, 0, 0, 255), (0, 0, 255, 255)), + ((255, 0, 0, 255), (255, 0, 0, 255)), + ((0, 0, 255, 255), (0, 0, 255, 255)), + ((255, 0, 0, 255), (0, 0, 255, 255)), + ] + + im_list = [] + for i in range(len(circles)): + # Transparent BG + img = Image.new("RGBA", (100, 100), (255, 255, 255, 0)) + + # Two circles per frame + d = ImageDraw.Draw(img) + d.ellipse([(0, 30), (40, 70)], fill=circles[i][0]) + d.ellipse([(60, 30), (100, 70)], fill=circles[i][1]) + + im_list.append(img) + + im_list[0].save( + out, save_all=True, append_images=im_list[1:], disposal=2, transparency=0 + ) + + with Image.open(out) as img: + for i, colours in enumerate(circles): + img.seek(i) + rgb_img = img.convert("RGBA") + + # Check left circle is correct colour + assert rgb_img.getpixel((20, 50)) == colours[0] + + # Check right circle is correct colour + assert rgb_img.getpixel((80, 50)) == colours[1] + + # Check BG is correct colour + assert rgb_img.getpixel((1, 1)) == (255, 255, 255, 0) + + +def test_dispose2_background(tmp_path): + out = str(tmp_path / "temp.gif") + + im_list = [] + + im = Image.new("P", (100, 100)) + d = ImageDraw.Draw(im) + d.rectangle([(50, 0), (100, 100)], fill="#f00") + d.rectangle([(0, 0), (50, 100)], fill="#0f0") + im_list.append(im) + + im = Image.new("P", (100, 100)) + d = ImageDraw.Draw(im) + d.rectangle([(0, 0), (100, 50)], fill="#f00") + d.rectangle([(0, 50), (100, 100)], fill="#0f0") + im_list.append(im) + + im_list[0].save( + out, save_all=True, append_images=im_list[1:], disposal=[0, 2], background=1 + ) + + with Image.open(out) as im: + im.seek(1) + assert im.getpixel((0, 0)) == 0 + + +def test_iss634(): + with Image.open("Tests/images/iss634.gif") as img: + # Seek to the second frame + img.seek(img.tell() + 1) + # All transparent pixels should be replaced with the color from the first frame + assert img.histogram()[img.info["transparency"]] == 0 + + +def test_duration(tmp_path): + duration = 1000 + + out = str(tmp_path / "temp.gif") + im = Image.new("L", (100, 100), "#000") + + # Check that the argument has priority over the info settings + im.info["duration"] = 100 + im.save(out, duration=duration) + + with Image.open(out) as reread: + assert reread.info["duration"] == duration + + +def test_multiple_duration(tmp_path): + duration_list = [1000, 2000, 3000] + + out = str(tmp_path / "temp.gif") + im_list = [ + Image.new("L", (100, 100), "#000"), + Image.new("L", (100, 100), "#111"), + Image.new("L", (100, 100), "#222"), + ] + + # Duration as list + im_list[0].save( + out, save_all=True, append_images=im_list[1:], duration=duration_list + ) + with Image.open(out) as reread: + + for duration in duration_list: assert reread.info["duration"] == duration + try: + reread.seek(reread.tell() + 1) + except EOFError: + pass - def test_multiple_duration(self): - duration_list = [1000, 2000, 3000] + # Duration as tuple + im_list[0].save( + out, save_all=True, append_images=im_list[1:], duration=tuple(duration_list) + ) + with Image.open(out) as reread: - out = self.tempfile("temp.gif") - im_list = [ - Image.new("L", (100, 100), "#000"), - Image.new("L", (100, 100), "#111"), - Image.new("L", (100, 100), "#222"), - ] + for duration in duration_list: + assert reread.info["duration"] == duration + try: + reread.seek(reread.tell() + 1) + except EOFError: + pass - # duration as list - im_list[0].save( - out, save_all=True, append_images=im_list[1:], duration=duration_list - ) - with Image.open(out) as reread: - for duration in duration_list: - assert reread.info["duration"] == duration - try: - reread.seek(reread.tell() + 1) - except EOFError: - pass +def test_identical_frames(tmp_path): + duration_list = [1000, 1500, 2000, 4000] - # duration as tuple - im_list[0].save( - out, save_all=True, append_images=im_list[1:], duration=tuple(duration_list) - ) - with Image.open(out) as reread: + out = str(tmp_path / "temp.gif") + im_list = [ + Image.new("L", (100, 100), "#000"), + Image.new("L", (100, 100), "#000"), + Image.new("L", (100, 100), "#000"), + Image.new("L", (100, 100), "#111"), + ] - for duration in duration_list: - assert reread.info["duration"] == duration - try: - reread.seek(reread.tell() + 1) - except EOFError: - pass + # Duration as list + im_list[0].save( + out, save_all=True, append_images=im_list[1:], duration=duration_list + ) + with Image.open(out) as reread: - def test_identical_frames(self): - duration_list = [1000, 1500, 2000, 4000] + # Assert that the first three frames were combined + assert reread.n_frames == 2 - out = self.tempfile("temp.gif") + # Assert that the new duration is the total of the identical frames + assert reread.info["duration"] == 4500 + + +def test_identical_frames_to_single_frame(tmp_path): + for duration in ([1000, 1500, 2000, 4000], (1000, 1500, 2000, 4000), 8500): + out = str(tmp_path / "temp.gif") im_list = [ Image.new("L", (100, 100), "#000"), Image.new("L", (100, 100), "#000"), Image.new("L", (100, 100), "#000"), - Image.new("L", (100, 100), "#111"), ] - # duration as list im_list[0].save( - out, save_all=True, append_images=im_list[1:], duration=duration_list + out, save_all=True, append_images=im_list[1:], duration=duration ) with Image.open(out) as reread: - - # Assert that the first three frames were combined - assert reread.n_frames == 2 + # Assert that all frames were combined + assert reread.n_frames == 1 # Assert that the new duration is the total of the identical frames - assert reread.info["duration"] == 4500 + assert reread.info["duration"] == 8500 - def test_identical_frames_to_single_frame(self): - for duration in ([1000, 1500, 2000, 4000], (1000, 1500, 2000, 4000), 8500): - out = self.tempfile("temp.gif") - im_list = [ - Image.new("L", (100, 100), "#000"), - Image.new("L", (100, 100), "#000"), - Image.new("L", (100, 100), "#000"), - ] - im_list[0].save( - out, save_all=True, append_images=im_list[1:], duration=duration - ) - with Image.open(out) as reread: - # Assert that all frames were combined - assert reread.n_frames == 1 +def test_number_of_loops(tmp_path): + number_of_loops = 2 - # Assert that the new duration is the total of the identical frames - assert reread.info["duration"] == 8500 + out = str(tmp_path / "temp.gif") + im = Image.new("L", (100, 100), "#000") + im.save(out, loop=number_of_loops) + with Image.open(out) as reread: - def test_number_of_loops(self): - number_of_loops = 2 + assert reread.info["loop"] == number_of_loops - out = self.tempfile("temp.gif") - im = Image.new("L", (100, 100), "#000") - im.save(out, loop=number_of_loops) - with Image.open(out) as reread: - assert reread.info["loop"] == number_of_loops +def test_background(tmp_path): + out = str(tmp_path / "temp.gif") + im = Image.new("L", (100, 100), "#000") + im.info["background"] = 1 + im.save(out) + with Image.open(out) as reread: - def test_background(self): - out = self.tempfile("temp.gif") - im = Image.new("L", (100, 100), "#000") - im.info["background"] = 1 - im.save(out) - with Image.open(out) as reread: + assert reread.info["background"] == im.info["background"] - assert reread.info["background"] == im.info["background"] - - if features.check("webp") and features.check("webp_anim"): - with Image.open("Tests/images/hopper.webp") as im: - assert isinstance(im.info["background"], tuple) - im.save(out) - - def test_comment(self): - with Image.open(TEST_GIF) as im: - assert im.info["comment"] == b"File written by Adobe Photoshop\xa8 4.0" - - out = self.tempfile("temp.gif") - im = Image.new("L", (100, 100), "#000") - im.info["comment"] = b"Test comment text" - im.save(out) - with Image.open(out) as reread: - assert reread.info["comment"] == im.info["comment"] - - im.info["comment"] = "Test comment text" - im.save(out) - with Image.open(out) as reread: - assert reread.info["comment"] == im.info["comment"].encode() - - def test_comment_over_255(self): - out = self.tempfile("temp.gif") - im = Image.new("L", (100, 100), "#000") - comment = b"Test comment text" - while len(comment) < 256: - comment += comment - im.info["comment"] = comment - im.save(out) - with Image.open(out) as reread: - - assert reread.info["comment"] == comment - - def test_zero_comment_subblocks(self): - with Image.open("Tests/images/hopper_zero_comment_subblocks.gif") as im: - with Image.open(TEST_GIF) as expected: - assert_image_equal(im, expected) - - def test_version(self): - out = self.tempfile("temp.gif") - - def assertVersionAfterSave(im, version): + if features.check("webp") and features.check("webp_anim"): + with Image.open("Tests/images/hopper.webp") as im: + assert isinstance(im.info["background"], tuple) im.save(out) - with Image.open(out) as reread: - assert reread.info["version"] == version - # Test that GIF87a is used by default - im = Image.new("L", (100, 100), "#000") + +def test_comment(tmp_path): + with Image.open(TEST_GIF) as im: + assert im.info["comment"] == b"File written by Adobe Photoshop\xa8 4.0" + + out = str(tmp_path / "temp.gif") + im = Image.new("L", (100, 100), "#000") + im.info["comment"] = b"Test comment text" + im.save(out) + with Image.open(out) as reread: + assert reread.info["comment"] == im.info["comment"] + + im.info["comment"] = "Test comment text" + im.save(out) + with Image.open(out) as reread: + assert reread.info["comment"] == im.info["comment"].encode() + + +def test_comment_over_255(tmp_path): + out = str(tmp_path / "temp.gif") + im = Image.new("L", (100, 100), "#000") + comment = b"Test comment text" + while len(comment) < 256: + comment += comment + im.info["comment"] = comment + im.save(out) + with Image.open(out) as reread: + + assert reread.info["comment"] == comment + + +def test_zero_comment_subblocks(): + with Image.open("Tests/images/hopper_zero_comment_subblocks.gif") as im: + with Image.open(TEST_GIF) as expected: + assert_image_equal(im, expected) + + +def test_version(tmp_path): + out = str(tmp_path / "temp.gif") + + def assertVersionAfterSave(im, version): + im.save(out) + with Image.open(out) as reread: + assert reread.info["version"] == version + + # Test that GIF87a is used by default + im = Image.new("L", (100, 100), "#000") + assertVersionAfterSave(im, b"GIF87a") + + # Test setting the version to 89a + im = Image.new("L", (100, 100), "#000") + im.info["version"] = b"89a" + assertVersionAfterSave(im, b"GIF89a") + + # Test that adding a GIF89a feature changes the version + im.info["transparency"] = 1 + assertVersionAfterSave(im, b"GIF89a") + + # Test that a GIF87a image is also saved in that format + with Image.open("Tests/images/test.colors.gif") as im: assertVersionAfterSave(im, b"GIF87a") - # Test setting the version to 89a - im = Image.new("L", (100, 100), "#000") - im.info["version"] = b"89a" - assertVersionAfterSave(im, b"GIF89a") + # Test that a GIF89a image is also saved in that format + im.info["version"] = b"GIF89a" + assertVersionAfterSave(im, b"GIF87a") - # Test that adding a GIF89a feature changes the version - im.info["transparency"] = 1 - assertVersionAfterSave(im, b"GIF89a") - # Test that a GIF87a image is also saved in that format - with Image.open("Tests/images/test.colors.gif") as im: - assertVersionAfterSave(im, b"GIF87a") +def test_append_images(tmp_path): + out = str(tmp_path / "temp.gif") - # Test that a GIF89a image is also saved in that format - im.info["version"] = b"GIF89a" - assertVersionAfterSave(im, b"GIF87a") + # Test appending single frame images + im = Image.new("RGB", (100, 100), "#f00") + ims = [Image.new("RGB", (100, 100), color) for color in ["#0f0", "#00f"]] + im.copy().save(out, save_all=True, append_images=ims) - def test_append_images(self): - out = self.tempfile("temp.gif") + with Image.open(out) as reread: + assert reread.n_frames == 3 - # Test appending single frame images - im = Image.new("RGB", (100, 100), "#f00") - ims = [Image.new("RGB", (100, 100), color) for color in ["#0f0", "#00f"]] - im.copy().save(out, save_all=True, append_images=ims) + # Tests appending using a generator + def imGenerator(ims): + yield from ims - with Image.open(out) as reread: - assert reread.n_frames == 3 + im.save(out, save_all=True, append_images=imGenerator(ims)) - # Tests appending using a generator - def imGenerator(ims): - yield from ims + with Image.open(out) as reread: + assert reread.n_frames == 3 - im.save(out, save_all=True, append_images=imGenerator(ims)) + # Tests appending single and multiple frame images + with Image.open("Tests/images/dispose_none.gif") as im: + with Image.open("Tests/images/dispose_prev.gif") as im2: + im.save(out, save_all=True, append_images=[im2]) - with Image.open(out) as reread: - assert reread.n_frames == 3 + with Image.open(out) as reread: + assert reread.n_frames == 10 - # Tests appending single and multiple frame images - with Image.open("Tests/images/dispose_none.gif") as im: - with Image.open("Tests/images/dispose_prev.gif") as im2: - im.save(out, save_all=True, append_images=[im2]) - with Image.open(out) as reread: - assert reread.n_frames == 10 +def test_transparent_optimize(tmp_path): + # From issue #2195, if the transparent color is incorrectly optimized out, GIF loses + # transparency. + # Need a palette that isn't using the 0 color, and one that's > 128 items where the + # transparent color is actually the top palette entry to trigger the bug. - def test_transparent_optimize(self): - # from issue #2195, if the transparent color is incorrectly - # optimized out, gif loses transparency - # Need a palette that isn't using the 0 color, and one - # that's > 128 items where the transparent color is actually - # the top palette entry to trigger the bug. + data = bytes(range(1, 254)) + palette = ImagePalette.ImagePalette("RGB", list(range(256)) * 3) - data = bytes(range(1, 254)) - palette = ImagePalette.ImagePalette("RGB", list(range(256)) * 3) + im = Image.new("L", (253, 1)) + im.frombytes(data) + im.putpalette(palette) - im = Image.new("L", (253, 1)) - im.frombytes(data) + out = str(tmp_path / "temp.gif") + im.save(out, transparency=253) + with Image.open(out) as reloaded: + + assert reloaded.info["transparency"] == 253 + + +def test_rgb_transparency(tmp_path): + out = str(tmp_path / "temp.gif") + + # Single frame + im = Image.new("RGB", (1, 1)) + im.info["transparency"] = (255, 0, 0) + pytest.warns(UserWarning, im.save, out) + + with Image.open(out) as reloaded: + assert "transparency" not in reloaded.info + + # Multiple frames + im = Image.new("RGB", (1, 1)) + im.info["transparency"] = b"" + ims = [Image.new("RGB", (1, 1))] + pytest.warns(UserWarning, im.save, out, save_all=True, append_images=ims) + + with Image.open(out) as reloaded: + assert "transparency" not in reloaded.info + + +def test_bbox(tmp_path): + out = str(tmp_path / "temp.gif") + + im = Image.new("RGB", (100, 100), "#fff") + ims = [Image.new("RGB", (100, 100), "#000")] + im.save(out, save_all=True, append_images=ims) + + with Image.open(out) as reread: + assert reread.n_frames == 2 + + +def test_palette_save_L(tmp_path): + # Generate an L mode image with a separate palette + + im = hopper("P") + im_l = Image.frombytes("L", im.size, im.tobytes()) + palette = bytes(im.getpalette()) + + out = str(tmp_path / "temp.gif") + im_l.save(out, palette=palette) + + with Image.open(out) as reloaded: + assert_image_equal(reloaded.convert("RGB"), im.convert("RGB")) + + +def test_palette_save_P(tmp_path): + # Pass in a different palette, then construct what the image would look like. + # Forcing a non-straight grayscale palette. + + im = hopper("P") + palette = bytes([255 - i // 3 for i in range(768)]) + + out = str(tmp_path / "temp.gif") + im.save(out, palette=palette) + + with Image.open(out) as reloaded: im.putpalette(palette) + assert_image_equal(reloaded, im) - out = self.tempfile("temp.gif") - im.save(out, transparency=253) - with Image.open(out) as reloaded: - assert reloaded.info["transparency"] == 253 +def test_palette_save_ImagePalette(tmp_path): + # Pass in a different palette, as an ImagePalette.ImagePalette + # effectively the same as test_palette_save_P - def test_rgb_transparency(self): - out = self.tempfile("temp.gif") + im = hopper("P") + palette = ImagePalette.ImagePalette("RGB", list(range(256))[::-1] * 3) - # Single frame - im = Image.new("RGB", (1, 1)) - im.info["transparency"] = (255, 0, 0) - pytest.warns(UserWarning, im.save, out) + out = str(tmp_path / "temp.gif") + im.save(out, palette=palette) - with Image.open(out) as reloaded: - assert "transparency" not in reloaded.info + with Image.open(out) as reloaded: + im.putpalette(palette) + assert_image_equal(reloaded, im) - # Multiple frames - im = Image.new("RGB", (1, 1)) - im.info["transparency"] = b"" - ims = [Image.new("RGB", (1, 1))] - pytest.warns(UserWarning, im.save, out, save_all=True, append_images=ims) - with Image.open(out) as reloaded: - assert "transparency" not in reloaded.info +def test_save_I(tmp_path): + # Test saving something that would trigger the auto-convert to 'L' - def test_bbox(self): - out = self.tempfile("temp.gif") + im = hopper("I") - im = Image.new("RGB", (100, 100), "#fff") - ims = [Image.new("RGB", (100, 100), "#000")] - im.save(out, save_all=True, append_images=ims) + out = str(tmp_path / "temp.gif") + im.save(out) - with Image.open(out) as reread: - assert reread.n_frames == 2 + with Image.open(out) as reloaded: + assert_image_equal(reloaded.convert("L"), im.convert("L")) - def test_palette_save_L(self): - # generate an L mode image with a separate palette - im = hopper("P") - im_l = Image.frombytes("L", im.size, im.tobytes()) - palette = bytes(im.getpalette()) +def test_getdata(): + # Test getheader/getdata against legacy values. + # Create a 'P' image with holes in the palette. + im = Image._wedge().resize((16, 16), Image.NEAREST) + im.putpalette(ImagePalette.ImagePalette("RGB")) + im.info = {"background": 0} - out = self.tempfile("temp.gif") - im_l.save(out, palette=palette) + passed_palette = bytes([255 - i // 3 for i in range(768)]) - with Image.open(out) as reloaded: + GifImagePlugin._FORCE_OPTIMIZE = True + try: + h = GifImagePlugin.getheader(im, passed_palette) + d = GifImagePlugin.getdata(im) - assert_image_equal(reloaded.convert("RGB"), im.convert("RGB")) + import pickle - def test_palette_save_P(self): - # pass in a different palette, then construct what the image - # would look like. - # Forcing a non-straight grayscale palette. + # Enable to get target values on pre-refactor version + # with open('Tests/images/gif_header_data.pkl', 'wb') as f: + # pickle.dump((h, d), f, 1) + with open("Tests/images/gif_header_data.pkl", "rb") as f: + (h_target, d_target) = pickle.load(f) - im = hopper("P") - palette = bytes([255 - i // 3 for i in range(768)]) + assert h == h_target + assert d == d_target + finally: + GifImagePlugin._FORCE_OPTIMIZE = False - out = self.tempfile("temp.gif") - im.save(out, palette=palette) - with Image.open(out) as reloaded: - im.putpalette(palette) - assert_image_equal(reloaded, im) +def test_lzw_bits(): + # see https://github.com/python-pillow/Pillow/issues/2811 + with Image.open("Tests/images/issue_2811.gif") as im: + assert im.tile[0][3][0] == 11 # LZW bits + # codec error prepatch + im.load() - def test_palette_save_ImagePalette(self): - # pass in a different palette, as an ImagePalette.ImagePalette - # effectively the same as test_palette_save_P - im = hopper("P") - palette = ImagePalette.ImagePalette("RGB", list(range(256))[::-1] * 3) - - out = self.tempfile("temp.gif") - im.save(out, palette=palette) - - with Image.open(out) as reloaded: - im.putpalette(palette) - assert_image_equal(reloaded, im) - - def test_save_I(self): - # Test saving something that would trigger the auto-convert to 'L' - - im = hopper("I") - - out = self.tempfile("temp.gif") - im.save(out) - - with Image.open(out) as reloaded: - assert_image_equal(reloaded.convert("L"), im.convert("L")) - - def test_getdata(self): - # test getheader/getdata against legacy values - # Create a 'P' image with holes in the palette - im = Image._wedge().resize((16, 16), Image.NEAREST) - im.putpalette(ImagePalette.ImagePalette("RGB")) - im.info = {"background": 0} - - passed_palette = bytes([255 - i // 3 for i in range(768)]) - - GifImagePlugin._FORCE_OPTIMIZE = True - try: - h = GifImagePlugin.getheader(im, passed_palette) - d = GifImagePlugin.getdata(im) - - import pickle - - # Enable to get target values on pre-refactor version - # with open('Tests/images/gif_header_data.pkl', 'wb') as f: - # pickle.dump((h, d), f, 1) - with open("Tests/images/gif_header_data.pkl", "rb") as f: - (h_target, d_target) = pickle.load(f) - - assert h == h_target - assert d == d_target - finally: - GifImagePlugin._FORCE_OPTIMIZE = False - - def test_lzw_bits(self): - # see https://github.com/python-pillow/Pillow/issues/2811 - with Image.open("Tests/images/issue_2811.gif") as im: - assert im.tile[0][3][0] == 11 # LZW bits - # codec error prepatch - im.load() - - def test_extents(self): - with Image.open("Tests/images/test_extents.gif") as im: - assert im.size == (100, 100) - im.seek(1) - assert im.size == (150, 150) +def test_extents(): + with Image.open("Tests/images/test_extents.gif") as im: + assert im.size == (100, 100) + im.seek(1) + assert im.size == (150, 150) diff --git a/Tests/test_file_icns.py b/Tests/test_file_icns.py index 855cc9e08..aeb146f7e 100644 --- a/Tests/test_file_icns.py +++ b/Tests/test_file_icns.py @@ -1,122 +1,127 @@ import io import sys -import unittest import pytest from PIL import IcnsImagePlugin, Image -from .helper import PillowTestCase, assert_image_equal, assert_image_similar +from .helper import assert_image_equal, assert_image_similar # sample icon file TEST_FILE = "Tests/images/pillow.icns" -enable_jpeg2k = hasattr(Image.core, "jp2klib_version") +ENABLE_JPEG2K = hasattr(Image.core, "jp2klib_version") -class TestFileIcns(PillowTestCase): - def test_sanity(self): - # Loading this icon by default should result in the largest size - # (512x512@2x) being loaded - with Image.open(TEST_FILE) as im: +def test_sanity(): + # Loading this icon by default should result in the largest size + # (512x512@2x) being loaded + with Image.open(TEST_FILE) as im: - # Assert that there is no unclosed file warning - pytest.warns(None, im.load) + # Assert that there is no unclosed file warning + pytest.warns(None, im.load) - assert im.mode == "RGBA" - assert im.size == (1024, 1024) - assert im.format == "ICNS" + assert im.mode == "RGBA" + assert im.size == (1024, 1024) + assert im.format == "ICNS" - @unittest.skipIf(sys.platform != "darwin", "requires macOS") - def test_save(self): - temp_file = self.tempfile("temp.icns") - with Image.open(TEST_FILE) as im: - im.save(temp_file) +@pytest.mark.skipif(sys.platform != "darwin", reason="Requires macOS") +def test_save(tmp_path): + temp_file = str(tmp_path / "temp.icns") + + with Image.open(TEST_FILE) as im: + im.save(temp_file) + + with Image.open(temp_file) as reread: + assert reread.mode == "RGBA" + assert reread.size == (1024, 1024) + assert reread.format == "ICNS" + + +@pytest.mark.skipif(sys.platform != "darwin", reason="Requires macOS") +def test_save_append_images(tmp_path): + temp_file = str(tmp_path / "temp.icns") + provided_im = Image.new("RGBA", (32, 32), (255, 0, 0, 128)) + + with Image.open(TEST_FILE) as im: + im.save(temp_file, append_images=[provided_im]) with Image.open(temp_file) as reread: - assert reread.mode == "RGBA" - assert reread.size == (1024, 1024) - assert reread.format == "ICNS" + assert_image_similar(reread, im, 1) - @unittest.skipIf(sys.platform != "darwin", "requires macOS") - def test_save_append_images(self): - temp_file = self.tempfile("temp.icns") - provided_im = Image.new("RGBA", (32, 32), (255, 0, 0, 128)) + with Image.open(temp_file) as reread: + reread.size = (16, 16, 2) + reread.load() + assert_image_equal(reread, provided_im) - with Image.open(TEST_FILE) as im: - im.save(temp_file, append_images=[provided_im]) - with Image.open(temp_file) as reread: - assert_image_similar(reread, im, 1) - - with Image.open(temp_file) as reread: - reread.size = (16, 16, 2) - reread.load() - assert_image_equal(reread, provided_im) - - def test_sizes(self): - # Check that we can load all of the sizes, and that the final pixel - # dimensions are as expected - with Image.open(TEST_FILE) as im: - for w, h, r in im.info["sizes"]: - wr = w * r - hr = h * r - im.size = (w, h, r) - im.load() - assert im.mode == "RGBA" - assert im.size == (wr, hr) - - # Check that we cannot load an incorrect size - with pytest.raises(ValueError): - im.size = (1, 1) - - def test_older_icon(self): - # This icon was made with Icon Composer rather than iconutil; it still - # uses PNG rather than JP2, however (since it was made on 10.9). - with Image.open("Tests/images/pillow2.icns") as im: - for w, h, r in im.info["sizes"]: - wr = w * r - hr = h * r - with Image.open("Tests/images/pillow2.icns") as im2: - im2.size = (w, h, r) - im2.load() - assert im2.mode == "RGBA" - assert im2.size == (wr, hr) - - def test_jp2_icon(self): - # This icon was made by using Uli Kusterer's oldiconutil to replace - # the PNG images with JPEG 2000 ones. The advantage of doing this is - # that OS X 10.5 supports JPEG 2000 but not PNG; some commercial - # software therefore does just this. - - # (oldiconutil is here: https://github.com/uliwitness/oldiconutil) - - if not enable_jpeg2k: - return - - with Image.open("Tests/images/pillow3.icns") as im: - for w, h, r in im.info["sizes"]: - wr = w * r - hr = h * r - with Image.open("Tests/images/pillow3.icns") as im2: - im2.size = (w, h, r) - im2.load() - assert im2.mode == "RGBA" - assert im2.size == (wr, hr) - - def test_getimage(self): - with open(TEST_FILE, "rb") as fp: - icns_file = IcnsImagePlugin.IcnsFile(fp) - - im = icns_file.getimage() +def test_sizes(): + # Check that we can load all of the sizes, and that the final pixel + # dimensions are as expected + with Image.open(TEST_FILE) as im: + for w, h, r in im.info["sizes"]: + wr = w * r + hr = h * r + im.size = (w, h, r) + im.load() assert im.mode == "RGBA" - assert im.size == (1024, 1024) + assert im.size == (wr, hr) - im = icns_file.getimage((512, 512)) - assert im.mode == "RGBA" - assert im.size == (512, 512) + # Check that we cannot load an incorrect size + with pytest.raises(ValueError): + im.size = (1, 1) - def test_not_an_icns_file(self): - with io.BytesIO(b"invalid\n") as fp: - with pytest.raises(SyntaxError): - IcnsImagePlugin.IcnsFile(fp) + +def test_older_icon(): + # This icon was made with Icon Composer rather than iconutil; it still + # uses PNG rather than JP2, however (since it was made on 10.9). + with Image.open("Tests/images/pillow2.icns") as im: + for w, h, r in im.info["sizes"]: + wr = w * r + hr = h * r + with Image.open("Tests/images/pillow2.icns") as im2: + im2.size = (w, h, r) + im2.load() + assert im2.mode == "RGBA" + assert im2.size == (wr, hr) + + +def test_jp2_icon(): + # This icon was made by using Uli Kusterer's oldiconutil to replace + # the PNG images with JPEG 2000 ones. The advantage of doing this is + # that OS X 10.5 supports JPEG 2000 but not PNG; some commercial + # software therefore does just this. + + # (oldiconutil is here: https://github.com/uliwitness/oldiconutil) + + if not ENABLE_JPEG2K: + return + + with Image.open("Tests/images/pillow3.icns") as im: + for w, h, r in im.info["sizes"]: + wr = w * r + hr = h * r + with Image.open("Tests/images/pillow3.icns") as im2: + im2.size = (w, h, r) + im2.load() + assert im2.mode == "RGBA" + assert im2.size == (wr, hr) + + +def test_getimage(): + with open(TEST_FILE, "rb") as fp: + icns_file = IcnsImagePlugin.IcnsFile(fp) + + im = icns_file.getimage() + assert im.mode == "RGBA" + assert im.size == (1024, 1024) + + im = icns_file.getimage((512, 512)) + assert im.mode == "RGBA" + assert im.size == (512, 512) + + +def test_not_an_icns_file(): + with io.BytesIO(b"invalid\n") as fp: + with pytest.raises(SyntaxError): + IcnsImagePlugin.IcnsFile(fp) diff --git a/Tests/test_file_ico.py b/Tests/test_file_ico.py index 822b07532..9ed1ffcb7 100644 --- a/Tests/test_file_ico.py +++ b/Tests/test_file_ico.py @@ -3,101 +3,107 @@ import io import pytest from PIL import IcoImagePlugin, Image, ImageDraw -from .helper import PillowTestCase, assert_image_equal, hopper +from .helper import assert_image_equal, hopper TEST_ICO_FILE = "Tests/images/hopper.ico" -class TestFileIco(PillowTestCase): - def test_sanity(self): - with Image.open(TEST_ICO_FILE) as im: - im.load() - assert im.mode == "RGBA" - assert im.size == (16, 16) - assert im.format == "ICO" - assert im.get_format_mimetype() == "image/x-icon" +def test_sanity(): + with Image.open(TEST_ICO_FILE) as im: + im.load() + assert im.mode == "RGBA" + assert im.size == (16, 16) + assert im.format == "ICO" + assert im.get_format_mimetype() == "image/x-icon" - def test_invalid_file(self): - with open("Tests/images/flower.jpg", "rb") as fp: - with pytest.raises(SyntaxError): - IcoImagePlugin.IcoImageFile(fp) - def test_save_to_bytes(self): - output = io.BytesIO() - im = hopper() - im.save(output, "ico", sizes=[(32, 32), (64, 64)]) +def test_invalid_file(): + with open("Tests/images/flower.jpg", "rb") as fp: + with pytest.raises(SyntaxError): + IcoImagePlugin.IcoImageFile(fp) - # the default image - output.seek(0) - with Image.open(output) as reloaded: - assert reloaded.info["sizes"] == {(32, 32), (64, 64)} - assert im.mode == reloaded.mode - assert (64, 64) == reloaded.size - assert reloaded.format == "ICO" - assert_image_equal(reloaded, hopper().resize((64, 64), Image.LANCZOS)) +def test_save_to_bytes(): + output = io.BytesIO() + im = hopper() + im.save(output, "ico", sizes=[(32, 32), (64, 64)]) - # the other one - output.seek(0) - with Image.open(output) as reloaded: - reloaded.size = (32, 32) + # The default image + output.seek(0) + with Image.open(output) as reloaded: + assert reloaded.info["sizes"] == {(32, 32), (64, 64)} - assert im.mode == reloaded.mode - assert (32, 32) == reloaded.size - assert reloaded.format == "ICO" - assert_image_equal(reloaded, hopper().resize((32, 32), Image.LANCZOS)) + assert im.mode == reloaded.mode + assert (64, 64) == reloaded.size + assert reloaded.format == "ICO" + assert_image_equal(reloaded, hopper().resize((64, 64), Image.LANCZOS)) - def test_incorrect_size(self): - with Image.open(TEST_ICO_FILE) as im: - with pytest.raises(ValueError): - im.size = (1, 1) + # The other one + output.seek(0) + with Image.open(output) as reloaded: + reloaded.size = (32, 32) - def test_save_256x256(self): - """Issue #2264 https://github.com/python-pillow/Pillow/issues/2264""" - # Arrange - with Image.open("Tests/images/hopper_256x256.ico") as im: - outfile = self.tempfile("temp_saved_hopper_256x256.ico") + assert im.mode == reloaded.mode + assert (32, 32) == reloaded.size + assert reloaded.format == "ICO" + assert_image_equal(reloaded, hopper().resize((32, 32), Image.LANCZOS)) - # Act - im.save(outfile) - with Image.open(outfile) as im_saved: - # Assert - assert im_saved.size == (256, 256) +def test_incorrect_size(): + with Image.open(TEST_ICO_FILE) as im: + with pytest.raises(ValueError): + im.size = (1, 1) - def test_only_save_relevant_sizes(self): - """Issue #2266 https://github.com/python-pillow/Pillow/issues/2266 - Should save in 16x16, 24x24, 32x32, 48x48 sizes - and not in 16x16, 24x24, 32x32, 48x48, 48x48, 48x48, 48x48 sizes - """ - # Arrange - with Image.open("Tests/images/python.ico") as im: # 16x16, 32x32, 48x48 - outfile = self.tempfile("temp_saved_python.ico") - # Act - im.save(outfile) - with Image.open(outfile) as im_saved: - # Assert - assert im_saved.info["sizes"] == {(16, 16), (24, 24), (32, 32), (48, 48)} +def test_save_256x256(tmp_path): + """Issue #2264 https://github.com/python-pillow/Pillow/issues/2264""" + # Arrange + with Image.open("Tests/images/hopper_256x256.ico") as im: + outfile = str(tmp_path / "temp_saved_hopper_256x256.ico") - def test_unexpected_size(self): - # This image has been manually hexedited to state that it is 16x32 - # while the image within is still 16x16 - def open(): - with Image.open("Tests/images/hopper_unexpected.ico") as im: - assert im.size == (16, 16) + # Act + im.save(outfile) + with Image.open(outfile) as im_saved: - pytest.warns(UserWarning, open) + # Assert + assert im_saved.size == (256, 256) - def test_draw_reloaded(self): - with Image.open(TEST_ICO_FILE) as im: - outfile = self.tempfile("temp_saved_hopper_draw.ico") - draw = ImageDraw.Draw(im) - draw.line((0, 0) + im.size, "#f00") - im.save(outfile) +def test_only_save_relevant_sizes(tmp_path): + """Issue #2266 https://github.com/python-pillow/Pillow/issues/2266 + Should save in 16x16, 24x24, 32x32, 48x48 sizes + and not in 16x16, 24x24, 32x32, 48x48, 48x48, 48x48, 48x48 sizes + """ + # Arrange + with Image.open("Tests/images/python.ico") as im: # 16x16, 32x32, 48x48 + outfile = str(tmp_path / "temp_saved_python.ico") + # Act + im.save(outfile) - with Image.open(outfile) as im: - im.save("Tests/images/hopper_draw.ico") - with Image.open("Tests/images/hopper_draw.ico") as reloaded: - assert_image_equal(im, reloaded) + with Image.open(outfile) as im_saved: + # Assert + assert im_saved.info["sizes"] == {(16, 16), (24, 24), (32, 32), (48, 48)} + + +def test_unexpected_size(): + # This image has been manually hexedited to state that it is 16x32 + # while the image within is still 16x16 + def open(): + with Image.open("Tests/images/hopper_unexpected.ico") as im: + assert im.size == (16, 16) + + pytest.warns(UserWarning, open) + + +def test_draw_reloaded(tmp_path): + with Image.open(TEST_ICO_FILE) as im: + outfile = str(tmp_path / "temp_saved_hopper_draw.ico") + + draw = ImageDraw.Draw(im) + draw.line((0, 0) + im.size, "#f00") + im.save(outfile) + + with Image.open(outfile) as im: + im.save("Tests/images/hopper_draw.ico") + with Image.open("Tests/images/hopper_draw.ico") as reloaded: + assert_image_equal(im, reloaded) diff --git a/Tests/test_file_msp.py b/Tests/test_file_msp.py index 84fd4a0e2..8f261388e 100644 --- a/Tests/test_file_msp.py +++ b/Tests/test_file_msp.py @@ -1,83 +1,90 @@ import os -import unittest import pytest from PIL import Image, MspImagePlugin -from .helper import PillowTestCase, assert_image_equal, hopper +from .helper import assert_image_equal, hopper TEST_FILE = "Tests/images/hopper.msp" EXTRA_DIR = "Tests/images/picins" YA_EXTRA_DIR = "Tests/images/msp" -class TestFileMsp(PillowTestCase): - def test_sanity(self): - test_file = self.tempfile("temp.msp") +def test_sanity(tmp_path): + test_file = str(tmp_path / "temp.msp") - hopper("1").save(test_file) + hopper("1").save(test_file) - with Image.open(test_file) as im: - im.load() - assert im.mode == "1" - assert im.size == (128, 128) - assert im.format == "MSP" + with Image.open(test_file) as im: + im.load() + assert im.mode == "1" + assert im.size == (128, 128) + assert im.format == "MSP" - def test_invalid_file(self): - invalid_file = "Tests/images/flower.jpg" - with pytest.raises(SyntaxError): - MspImagePlugin.MspImageFile(invalid_file) +def test_invalid_file(): + invalid_file = "Tests/images/flower.jpg" - def test_bad_checksum(self): - # Arrange - # This was created by forcing Pillow to save with checksum=0 - bad_checksum = "Tests/images/hopper_bad_checksum.msp" + with pytest.raises(SyntaxError): + MspImagePlugin.MspImageFile(invalid_file) - # Act / Assert - with pytest.raises(SyntaxError): - MspImagePlugin.MspImageFile(bad_checksum) - def test_open_windows_v1(self): - # Arrange - # Act - with Image.open(TEST_FILE) as im: +def test_bad_checksum(): + # Arrange + # This was created by forcing Pillow to save with checksum=0 + bad_checksum = "Tests/images/hopper_bad_checksum.msp" - # Assert - assert_image_equal(im, hopper("1")) - assert isinstance(im, MspImagePlugin.MspImageFile) + # Act / Assert + with pytest.raises(SyntaxError): + MspImagePlugin.MspImageFile(bad_checksum) - def _assert_file_image_equal(self, source_path, target_path): - with Image.open(source_path) as im: - with Image.open(target_path) as target: - assert_image_equal(im, target) - @unittest.skipUnless(os.path.exists(EXTRA_DIR), "Extra image files not installed") - def test_open_windows_v2(self): +def test_open_windows_v1(): + # Arrange + # Act + with Image.open(TEST_FILE) as im: - files = ( - os.path.join(EXTRA_DIR, f) - for f in os.listdir(EXTRA_DIR) - if os.path.splitext(f)[1] == ".msp" - ) - for path in files: - self._assert_file_image_equal(path, path.replace(".msp", ".png")) + # Assert + assert_image_equal(im, hopper("1")) + assert isinstance(im, MspImagePlugin.MspImageFile) - @unittest.skipIf( - not os.path.exists(YA_EXTRA_DIR), "Even More Extra image files not installed" + +def _assert_file_image_equal(source_path, target_path): + with Image.open(source_path) as im: + with Image.open(target_path) as target: + assert_image_equal(im, target) + + +@pytest.mark.skipif( + not os.path.exists(EXTRA_DIR), reason="Extra image files not installed" +) +def test_open_windows_v2(): + + files = ( + os.path.join(EXTRA_DIR, f) + for f in os.listdir(EXTRA_DIR) + if os.path.splitext(f)[1] == ".msp" ) - def test_msp_v2(self): - for f in os.listdir(YA_EXTRA_DIR): - if ".MSP" not in f: - continue - path = os.path.join(YA_EXTRA_DIR, f) - self._assert_file_image_equal(path, path.replace(".MSP", ".png")) + for path in files: + _assert_file_image_equal(path, path.replace(".msp", ".png")) - def test_cannot_save_wrong_mode(self): - # Arrange - im = hopper() - filename = self.tempfile("temp.msp") - # Act/Assert - with pytest.raises(IOError): - im.save(filename) +@pytest.mark.skipif( + not os.path.exists(YA_EXTRA_DIR), reason="Even More Extra image files not installed" +) +def test_msp_v2(): + for f in os.listdir(YA_EXTRA_DIR): + if ".MSP" not in f: + continue + path = os.path.join(YA_EXTRA_DIR, f) + _assert_file_image_equal(path, path.replace(".MSP", ".png")) + + +def test_cannot_save_wrong_mode(tmp_path): + # Arrange + im = hopper() + filename = str(tmp_path / "temp.msp") + + # Act/Assert + with pytest.raises(IOError): + im.save(filename) diff --git a/Tests/test_file_spider.py b/Tests/test_file_spider.py index fa299dad5..c7446e161 100644 --- a/Tests/test_file_spider.py +++ b/Tests/test_file_spider.py @@ -1,149 +1,162 @@ import tempfile -import unittest from io import BytesIO import pytest from PIL import Image, ImageSequence, SpiderImagePlugin -from .helper import PillowTestCase, assert_image_equal, hopper, is_pypy +from .helper import assert_image_equal, hopper, is_pypy TEST_FILE = "Tests/images/hopper.spider" -class TestImageSpider(PillowTestCase): - def test_sanity(self): +def test_sanity(): + with Image.open(TEST_FILE) as im: + im.load() + assert im.mode == "F" + assert im.size == (128, 128) + assert im.format == "SPIDER" + + +@pytest.mark.skipif(is_pypy(), reason="Requires CPython") +def test_unclosed_file(): + def open(): + im = Image.open(TEST_FILE) + im.load() + + pytest.warns(ResourceWarning, open) + + +def test_closed_file(): + def open(): + im = Image.open(TEST_FILE) + im.load() + im.close() + + pytest.warns(None, open) + + +def test_context_manager(): + def open(): with Image.open(TEST_FILE) as im: im.load() - assert im.mode == "F" - assert im.size == (128, 128) - assert im.format == "SPIDER" - @unittest.skipIf(is_pypy(), "Requires CPython") - def test_unclosed_file(self): - def open(): - im = Image.open(TEST_FILE) - im.load() + pytest.warns(None, open) - pytest.warns(ResourceWarning, open) - def test_closed_file(self): - def open(): - im = Image.open(TEST_FILE) - im.load() - im.close() +def test_save(tmp_path): + # Arrange + temp = str(tmp_path / "temp.spider") + im = hopper() - pytest.warns(None, open) + # Act + im.save(temp, "SPIDER") - def test_context_manager(self): - def open(): - with Image.open(TEST_FILE) as im: - im.load() + # Assert + with Image.open(temp) as im2: + assert im2.mode == "F" + assert im2.size == (128, 128) + assert im2.format == "SPIDER" - pytest.warns(None, open) - def test_save(self): - # Arrange - temp = self.tempfile("temp.spider") - im = hopper() +def test_tempfile(): + # Arrange + im = hopper() - # Act - im.save(temp, "SPIDER") + # Act + with tempfile.TemporaryFile() as fp: + im.save(fp, "SPIDER") # Assert - with Image.open(temp) as im2: - assert im2.mode == "F" - assert im2.size == (128, 128) - assert im2.format == "SPIDER" + fp.seek(0) + with Image.open(fp) as reloaded: + assert reloaded.mode == "F" + assert reloaded.size == (128, 128) + assert reloaded.format == "SPIDER" - def test_tempfile(self): - # Arrange - im = hopper() + +def test_is_spider_image(): + assert SpiderImagePlugin.isSpiderImage(TEST_FILE) + + +def test_tell(): + # Arrange + with Image.open(TEST_FILE) as im: # Act - with tempfile.TemporaryFile() as fp: - im.save(fp, "SPIDER") - - # Assert - fp.seek(0) - with Image.open(fp) as reloaded: - assert reloaded.mode == "F" - assert reloaded.size == (128, 128) - assert reloaded.format == "SPIDER" - - def test_isSpiderImage(self): - assert SpiderImagePlugin.isSpiderImage(TEST_FILE) - - def test_tell(self): - # Arrange - with Image.open(TEST_FILE) as im: - - # Act - index = im.tell() - - # Assert - assert index == 0 - - def test_n_frames(self): - with Image.open(TEST_FILE) as im: - assert im.n_frames == 1 - assert not im.is_animated - - def test_loadImageSeries(self): - # Arrange - not_spider_file = "Tests/images/hopper.ppm" - file_list = [TEST_FILE, not_spider_file, "path/not_found.ext"] - - # Act - img_list = SpiderImagePlugin.loadImageSeries(file_list) + index = im.tell() # Assert - assert len(img_list) == 1 - assert isinstance(img_list[0], Image.Image) - assert img_list[0].size == (128, 128) + assert index == 0 - def test_loadImageSeries_no_input(self): - # Arrange - file_list = None - # Act - img_list = SpiderImagePlugin.loadImageSeries(file_list) +def test_n_frames(): + with Image.open(TEST_FILE) as im: + assert im.n_frames == 1 + assert not im.is_animated - # Assert - assert img_list is None - def test_isInt_not_a_number(self): - # Arrange - not_a_number = "a" +def test_load_image_series(): + # Arrange + not_spider_file = "Tests/images/hopper.ppm" + file_list = [TEST_FILE, not_spider_file, "path/not_found.ext"] - # Act - ret = SpiderImagePlugin.isInt(not_a_number) + # Act + img_list = SpiderImagePlugin.loadImageSeries(file_list) - # Assert - assert ret == 0 + # Assert + assert len(img_list) == 1 + assert isinstance(img_list[0], Image.Image) + assert img_list[0].size == (128, 128) - def test_invalid_file(self): - invalid_file = "Tests/images/invalid.spider" - with pytest.raises(IOError): - Image.open(invalid_file) +def test_load_image_series_no_input(): + # Arrange + file_list = None - def test_nonstack_file(self): - with Image.open(TEST_FILE) as im: - with pytest.raises(EOFError): - im.seek(0) + # Act + img_list = SpiderImagePlugin.loadImageSeries(file_list) - def test_nonstack_dos(self): - with Image.open(TEST_FILE) as im: - for i, frame in enumerate(ImageSequence.Iterator(im)): - assert i <= 1, "Non-stack DOS file test failed" + # Assert + assert img_list is None - # for issue #4093 - def test_odd_size(self): - data = BytesIO() - width = 100 - im = Image.new("F", (width, 64)) - im.save(data, format="SPIDER") - data.seek(0) - with Image.open(data) as im2: - assert_image_equal(im, im2) +def test_is_int_not_a_number(): + # Arrange + not_a_number = "a" + + # Act + ret = SpiderImagePlugin.isInt(not_a_number) + + # Assert + assert ret == 0 + + +def test_invalid_file(): + invalid_file = "Tests/images/invalid.spider" + + with pytest.raises(IOError): + Image.open(invalid_file) + + +def test_nonstack_file(): + with Image.open(TEST_FILE) as im: + with pytest.raises(EOFError): + im.seek(0) + + +def test_nonstack_dos(): + with Image.open(TEST_FILE) as im: + for i, frame in enumerate(ImageSequence.Iterator(im)): + assert i <= 1, "Non-stack DOS file test failed" + + +# for issue #4093 +def test_odd_size(): + data = BytesIO() + width = 100 + im = Image.new("F", (width, 64)) + im.save(data, format="SPIDER") + + data.seek(0) + with Image.open(data) as im2: + assert_image_equal(im, im2) diff --git a/Tests/test_image.py b/Tests/test_image.py index 55ee1a9fe..55e70a326 100644 --- a/Tests/test_image.py +++ b/Tests/test_image.py @@ -2,13 +2,11 @@ import io import os import shutil import tempfile -import unittest import pytest from PIL import Image, ImageDraw, ImagePalette, UnidentifiedImageError from .helper import ( - PillowTestCase, assert_image_equal, assert_image_similar, assert_not_all_same, @@ -17,7 +15,7 @@ from .helper import ( ) -class TestImage(PillowTestCase): +class TestImage: def test_image_modes_success(self): for mode in [ "1", @@ -109,7 +107,7 @@ class TestImage(PillowTestCase): with pytest.raises(ValueError): Image.open(io.StringIO()) - def test_pathlib(self): + def test_pathlib(self, tmp_path): from PIL.Image import Path with Image.open(Path("Tests/images/multipage-mmap.tiff")) as im: @@ -120,13 +118,13 @@ class TestImage(PillowTestCase): assert im.mode == "RGB" assert im.size == (128, 128) - temp_file = self.tempfile("temp.jpg") + temp_file = str(tmp_path / "temp.jpg") if os.path.exists(temp_file): os.remove(temp_file) im.save(Path(temp_file)) - def test_fp_name(self): - temp_file = self.tempfile("temp.jpg") + def test_fp_name(self, tmp_path): + temp_file = str(tmp_path / "temp.jpg") class FP: def write(a, b): @@ -148,9 +146,9 @@ class TestImage(PillowTestCase): with Image.open(fp) as reloaded: assert_image_similar(im, reloaded, 20) - def test_unknown_extension(self): + def test_unknown_extension(self, tmp_path): im = hopper() - temp_file = self.tempfile("temp.unknown") + temp_file = str(tmp_path / "temp.unknown") with pytest.raises(ValueError): im.save(temp_file) @@ -164,25 +162,25 @@ class TestImage(PillowTestCase): im.paste(0, (0, 0, 100, 100)) assert not im.readonly - @unittest.skipIf(is_win32(), "Test requires opening tempfile twice") - def test_readonly_save(self): - temp_file = self.tempfile("temp.bmp") + @pytest.mark.skipif(is_win32(), reason="Test requires opening tempfile twice") + def test_readonly_save(self, tmp_path): + temp_file = str(tmp_path / "temp.bmp") shutil.copy("Tests/images/rgb32bf-rgba.bmp", temp_file) with Image.open(temp_file) as im: assert im.readonly im.save(temp_file) - def test_dump(self): + def test_dump(self, tmp_path): im = Image.new("L", (10, 10)) - im._dump(self.tempfile("temp_L.ppm")) + im._dump(str(tmp_path / "temp_L.ppm")) im = Image.new("RGB", (10, 10)) - im._dump(self.tempfile("temp_RGB.ppm")) + im._dump(str(tmp_path / "temp_RGB.ppm")) im = Image.new("HSV", (10, 10)) with pytest.raises(ValueError): - im._dump(self.tempfile("temp_HSV.ppm")) + im._dump(str(tmp_path / "temp_HSV.ppm")) def test_comparison_with_other_type(self): # Arrange @@ -434,8 +432,7 @@ class TestImage(PillowTestCase): assert_image_similar(im2, im3, 110) def test_check_size(self): - # Checking that the _check_size function throws value errors - # when we want it to. + # Checking that the _check_size function throws value errors when we want it to with pytest.raises(ValueError): Image.new("RGB", 0) # not a tuple with pytest.raises(ValueError): @@ -587,11 +584,11 @@ class TestImage(PillowTestCase): expected = Image.new(mode, (100, 100), color) assert_image_equal(im.convert(mode), expected) - def test_no_resource_warning_on_save(self): + def test_no_resource_warning_on_save(self, tmp_path): # https://github.com/python-pillow/Pillow/issues/835 # Arrange test_file = "Tests/images/hopper.png" - temp_file = self.tempfile("temp.jpg") + temp_file = str(tmp_path / "temp.jpg") # Act/Assert with Image.open(test_file) as im: @@ -623,14 +620,14 @@ class TestImage(PillowTestCase): with Image.open(os.path.join("Tests/images", file)) as im: try: im.load() - self.assertFail() + assert False except OSError as e: assert str(e) == "buffer overrun when reading image file" with Image.open("Tests/images/fli_overrun2.bin") as im: try: im.seek(1) - self.assertFail() + assert False except OSError as e: assert str(e) == "buffer overrun when reading image file" @@ -645,7 +642,7 @@ def mock_encode(*args): return encoder -class TestRegistry(PillowTestCase): +class TestRegistry: def test_encode_registry(self): Image.register_encoder("MOCK", mock_encode) diff --git a/Tests/test_image_resample.py b/Tests/test_image_resample.py index d3813b90a..764a3ca49 100644 --- a/Tests/test_image_resample.py +++ b/Tests/test_image_resample.py @@ -1,13 +1,12 @@ -import unittest from contextlib import contextmanager import pytest from PIL import Image, ImageDraw -from .helper import PillowTestCase, assert_image_equal, assert_image_similar, hopper +from .helper import assert_image_equal, assert_image_similar, hopper -class TestImagingResampleVulnerability(PillowTestCase): +class TestImagingResampleVulnerability: # see https://github.com/python-pillow/Pillow/issues/1710 def test_overflow(self): im = hopper("L") @@ -43,7 +42,7 @@ class TestImagingResampleVulnerability(PillowTestCase): assert im.tobytes() != copy.tobytes() -class TestImagingCoreResampleAccuracy(PillowTestCase): +class TestImagingCoreResampleAccuracy: def make_case(self, mode, size, color): """Makes a sample image with two dark and two bright squares. For example: @@ -219,7 +218,7 @@ class TestImagingCoreResampleAccuracy(PillowTestCase): assert_image_equal(im, ref) -class CoreResampleConsistencyTest(PillowTestCase): +class CoreResampleConsistencyTest: def make_case(self, mode, fill): im = Image.new(mode, (512, 9), fill) return im.resize((9, 512), Image.LANCZOS), im.load()[0, 0] @@ -254,7 +253,7 @@ class CoreResampleConsistencyTest(PillowTestCase): self.run_case(self.make_case("F", 1.192093e-07)) -class CoreResampleAlphaCorrectTest(PillowTestCase): +class CoreResampleAlphaCorrectTest: def make_levels_case(self, mode): i = Image.new(mode, (256, 16)) px = i.load() @@ -275,7 +274,7 @@ class CoreResampleAlphaCorrectTest(PillowTestCase): len(used_colors), y ) - @unittest.skip("current implementation isn't precise enough") + @pytest.mark.skip("Current implementation isn't precise enough") def test_levels_rgba(self): case = self.make_levels_case("RGBA") self.run_levels_case(case.resize((512, 32), Image.BOX)) @@ -284,7 +283,7 @@ class CoreResampleAlphaCorrectTest(PillowTestCase): self.run_levels_case(case.resize((512, 32), Image.BICUBIC)) self.run_levels_case(case.resize((512, 32), Image.LANCZOS)) - @unittest.skip("current implementation isn't precise enough") + @pytest.mark.skip("Current implementation isn't precise enough") def test_levels_la(self): case = self.make_levels_case("LA") self.run_levels_case(case.resize((512, 32), Image.BOX)) @@ -330,7 +329,7 @@ class CoreResampleAlphaCorrectTest(PillowTestCase): self.run_dirty_case(case.resize((20, 20), Image.LANCZOS), (255,)) -class CoreResamplePassesTest(PillowTestCase): +class CoreResamplePassesTest: @contextmanager def count(self, diff): count = Image.core.get_stats()["new_count"] @@ -373,7 +372,7 @@ class CoreResamplePassesTest(PillowTestCase): assert_image_similar(with_box, cropped, 0.1) -class CoreResampleCoefficientsTest(PillowTestCase): +class CoreResampleCoefficientsTest: def test_reduce(self): test_color = 254 @@ -402,7 +401,7 @@ class CoreResampleCoefficientsTest(PillowTestCase): assert histogram[0x100 * 3 + 0xFF] == 0x10000 -class CoreResampleBoxTest(PillowTestCase): +class CoreResampleBoxTest: def test_wrong_arguments(self): im = hopper() for resample in ( diff --git a/Tests/test_imagefont_bitmap.py b/Tests/test_imagefont_bitmap.py index 437bd4bf6..c4032d55d 100644 --- a/Tests/test_imagefont_bitmap.py +++ b/Tests/test_imagefont_bitmap.py @@ -1,8 +1,7 @@ -import unittest - +import pytest from PIL import Image, ImageDraw, ImageFont -from .helper import PillowTestCase, assert_image_similar +from .helper import assert_image_similar image_font_installed = True try: @@ -11,35 +10,29 @@ except ImportError: image_font_installed = False -@unittest.skipUnless(image_font_installed, "image font not installed") -class TestImageFontBitmap(PillowTestCase): - def test_similar(self): - text = "EmbeddedBitmap" - font_outline = ImageFont.truetype(font="Tests/fonts/DejaVuSans.ttf", size=24) - font_bitmap = ImageFont.truetype( - font="Tests/fonts/DejaVuSans-bitmap.ttf", size=24 - ) - size_outline = font_outline.getsize(text) - size_bitmap = font_bitmap.getsize(text) - size_final = ( - max(size_outline[0], size_bitmap[0]), - max(size_outline[1], size_bitmap[1]), - ) - im_bitmap = Image.new("RGB", size_final, (255, 255, 255)) - im_outline = im_bitmap.copy() - draw_bitmap = ImageDraw.Draw(im_bitmap) - draw_outline = ImageDraw.Draw(im_outline) +@pytest.mark.skipif(not image_font_installed, reason="Image font not installed") +def test_similar(): + text = "EmbeddedBitmap" + font_outline = ImageFont.truetype(font="Tests/fonts/DejaVuSans.ttf", size=24) + font_bitmap = ImageFont.truetype(font="Tests/fonts/DejaVuSans-bitmap.ttf", size=24) + size_outline = font_outline.getsize(text) + size_bitmap = font_bitmap.getsize(text) + size_final = ( + max(size_outline[0], size_bitmap[0]), + max(size_outline[1], size_bitmap[1]), + ) + im_bitmap = Image.new("RGB", size_final, (255, 255, 255)) + im_outline = im_bitmap.copy() + draw_bitmap = ImageDraw.Draw(im_bitmap) + draw_outline = ImageDraw.Draw(im_outline) - # Metrics are different on the bitmap and ttf fonts, - # more so on some platforms and versions of freetype than others. - # Mac has a 1px difference, linux doesn't. - draw_bitmap.text( - (0, size_final[1] - size_bitmap[1]), text, fill=(0, 0, 0), font=font_bitmap - ) - draw_outline.text( - (0, size_final[1] - size_outline[1]), - text, - fill=(0, 0, 0), - font=font_outline, - ) - assert_image_similar(im_bitmap, im_outline, 20) + # Metrics are different on the bitmap and TTF fonts, + # more so on some platforms and versions of FreeType than others. + # Mac has a 1px difference, Linux doesn't. + draw_bitmap.text( + (0, size_final[1] - size_bitmap[1]), text, fill=(0, 0, 0), font=font_bitmap + ) + draw_outline.text( + (0, size_final[1] - size_outline[1]), text, fill=(0, 0, 0), font=font_outline, + ) + assert_image_similar(im_bitmap, im_outline, 20) From 12f66f44f414bedd257d9cee61075a1dd1c219bc Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade Date: Sun, 23 Feb 2020 10:31:54 +0200 Subject: [PATCH 10/15] Fix regression Co-Authored-By: Andrew Murray <3112309+radarhere@users.noreply.github.com> --- Tests/test_file_eps.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Tests/test_file_eps.py b/Tests/test_file_eps.py index 4729f4a9a..504c09db1 100644 --- a/Tests/test_file_eps.py +++ b/Tests/test_file_eps.py @@ -216,7 +216,7 @@ def test_readline(tmp_path): _test_readline(t, ending) def _test_readline_file_psfile(test_string, ending): - f = str(tmp_path / "temp.bufr") + f = str(tmp_path / "temp.txt") with open(f, "wb") as w: w.write(test_string.encode("latin-1")) From a529f0f39c977f5ac6565e5b512138da6f21e0bb Mon Sep 17 00:00:00 2001 From: Hugo Date: Sat, 22 Feb 2020 13:47:28 +0200 Subject: [PATCH 11/15] Use Codecov bash because the Action times out on macOS --- .github/workflows/test.yml | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index ba0e18cf4..137cc750a 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -106,8 +106,7 @@ jobs: - name: Upload coverage if: success() - uses: codecov/codecov-action@v1 - with: - token: ${{ secrets.CODECOV_TOKEN }} - flags: ${{ matrix.codecov-flag }} - name: ${{ matrix.os }} Python ${{ matrix.python-version }} + run: bash <(curl -s https://codecov.io/bash) -F ${{ matrix.codecov-flag }} + env: + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} + CODECOV_NAME: ${{ matrix.os }} Python ${{ matrix.python-version }} From ce23acee89d8f56357e89c66311305cc4302adbb Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Tue, 25 Feb 2020 21:22:05 +1100 Subject: [PATCH 12/15] Added 7.1.0 release notes --- docs/releasenotes/index.rst | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/releasenotes/index.rst b/docs/releasenotes/index.rst index d329257a9..1838803de 100644 --- a/docs/releasenotes/index.rst +++ b/docs/releasenotes/index.rst @@ -6,6 +6,7 @@ Release Notes .. toctree:: :maxdepth: 2 + 7.1.0 7.0.0 6.2.2 6.2.1 From 40a4a04b88e2c41ff8e89cf1178f31c677a22b35 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Wed, 26 Feb 2020 05:57:41 +1100 Subject: [PATCH 13/15] Updated CHANGES.rst [ci skip] --- CHANGES.rst | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGES.rst b/CHANGES.rst index 614b09ce3..82f965449 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -5,6 +5,9 @@ Changelog (Pillow) 7.1.0 (unreleased) ------------------ +- Allow saving of zero quality JPEG images #4440 + [radarhere] + - Allow explicit zero width to hide outline #4334 [radarhere] From 9ba25f8135702410e791acbd189e55cc5c6fba0c Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Wed, 26 Feb 2020 20:15:23 +1100 Subject: [PATCH 14/15] Trim end of name, keeping ext --- Tests/images/hopper_long_name.im | Bin 0 -> 49664 bytes Tests/test_file_im.py | 9 +++++++++ src/PIL/ImImagePlugin.py | 9 +++++++-- 3 files changed, 16 insertions(+), 2 deletions(-) create mode 100644 Tests/images/hopper_long_name.im diff --git a/Tests/images/hopper_long_name.im b/Tests/images/hopper_long_name.im new file mode 100644 index 0000000000000000000000000000000000000000..ff45b7c75390f1be3b134b09ee78f4edd1bef737 GIT binary patch literal 49664 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 literal 0 HcmV?d00001 diff --git a/Tests/test_file_im.py b/Tests/test_file_im.py index 38ee6c6fb..30a9fd52a 100644 --- a/Tests/test_file_im.py +++ b/Tests/test_file_im.py @@ -1,3 +1,5 @@ +import filecmp + import pytest from PIL import Image, ImImagePlugin @@ -15,6 +17,13 @@ def test_sanity(): assert im.format == "IM" +def test_name_limit(tmp_path): + out = str(tmp_path / ("name_limit_test" * 7 + ".im")) + with Image.open(TEST_IM) as im: + im.save(out) + assert filecmp.cmp(out, "Tests/images/hopper_long_name.im") + + @pytest.mark.skipif(is_pypy(), reason="Requires CPython") def test_unclosed_file(): def open(): diff --git a/src/PIL/ImImagePlugin.py b/src/PIL/ImImagePlugin.py index 427d6e986..8b03f35da 100644 --- a/src/PIL/ImImagePlugin.py +++ b/src/PIL/ImImagePlugin.py @@ -348,9 +348,14 @@ def _save(im, fp, filename): fp.write(("Image type: %s image\r\n" % image_type).encode("ascii")) if filename: - # Each line must be under length 100, or: SyntaxError("not an IM file") + # Each line must be 100 characters or less, + # or: SyntaxError("not an IM file") + # 8 characters are used for "Name: " and "\r\n" # Keep just the filename, ditch the potentially overlong path - fp.write(("Name: %s\r\n" % os.path.basename(filename)).encode("ascii")) + name, ext = os.path.splitext(os.path.basename(filename)) + name = "".join([name[: 92 - len(ext)], ext]) + + fp.write(("Name: %s\r\n" % name).encode("ascii")) fp.write(("Image size (x*y): %d*%d\r\n" % im.size).encode("ascii")) fp.write(("File size (no of images): %d\r\n" % frames).encode("ascii")) if im.mode in ["P", "PA"]: From b73e3dddcc9e6b3039db366b202254f693ccd13d Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Sat, 29 Feb 2020 11:06:23 +1100 Subject: [PATCH 15/15] Updated CHANGES.rst [ci skip] --- CHANGES.rst | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGES.rst b/CHANGES.rst index 82f965449..21a4957da 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -5,6 +5,9 @@ Changelog (Pillow) 7.1.0 (unreleased) ------------------ +- Fix Name field length when saving IM images #4424 + [hugovk, radarhere] + - Allow saving of zero quality JPEG images #4440 [radarhere]