diff --git a/.ci/install.sh b/.ci/install.sh index fbe85ded728..0dbf2d690c7 100755 --- a/.ci/install.sh +++ b/.ci/install.sh @@ -24,6 +24,7 @@ sudo apt-get -qq install libfreetype6-dev liblcms2-dev python3-tk\ python3 -m pip install --upgrade pip PYTHONOPTIMIZE=0 python3 -m pip install cffi python3 -m pip install coverage +python3 -m pip install defusedxml python3 -m pip install olefile python3 -m pip install -U pytest python3 -m pip install -U pytest-cov diff --git a/.ci/test.sh b/.ci/test.sh index 9d2c123da41..8ff7c5f6483 100755 --- a/.ci/test.sh +++ b/.ci/test.sh @@ -4,4 +4,4 @@ set -e python3 -c "from PIL import Image" -python3 -bb -m pytest -v -x -W always --cov PIL --cov Tests --cov-report term Tests +python3 -bb -m pytest -v -x -W always --cov PIL --cov Tests --cov-report term Tests $REVERSE diff --git a/.github/workflows/macos-install.sh b/.github/workflows/macos-install.sh index b20ecb4dc3d..3a70c8047e9 100755 --- a/.github/workflows/macos-install.sh +++ b/.github/workflows/macos-install.sh @@ -6,6 +6,7 @@ brew install libtiff libjpeg openjpeg libimagequant webp little-cms2 freetype op PYTHONOPTIMIZE=0 python3 -m pip install cffi python3 -m pip install coverage +python3 -m pip install defusedxml python3 -m pip install olefile python3 -m pip install -U pytest python3 -m pip install -U pytest-cov diff --git a/.github/workflows/test-windows.yml b/.github/workflows/test-windows.yml index cdb8493dc25..ce04ba5caa3 100644 --- a/.github/workflows/test-windows.yml +++ b/.github/workflows/test-windows.yml @@ -51,8 +51,8 @@ jobs: - name: Print build system information run: python .github/workflows/system-info.py - - name: python -m pip install wheel pytest pytest-cov pytest-timeout - run: python -m pip install wheel pytest pytest-cov pytest-timeout + - name: python -m pip install wheel pytest pytest-cov pytest-timeout defusedxml + run: python -m pip install wheel pytest pytest-cov pytest-timeout defusedxml - name: Install dependencies id: install diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index f2953072a36..042e6d83ecc 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -24,6 +24,7 @@ jobs: include: - python-version: "3.6" PYTHONOPTIMIZE: 1 + REVERSE: "--reverse" - python-version: "3.7" PYTHONOPTIMIZE: 2 # Include new variables for Codecov @@ -80,6 +81,9 @@ jobs: - name: Test run: | + if [ $REVERSE ]; then + python3 -m pip install pytest-reverse + fi if [ "${{ matrix.os }}" = "ubuntu-latest" ]; then xvfb-run -s '-screen 0 1024x768x24' .ci/test.sh else @@ -87,6 +91,7 @@ jobs: fi env: PYTHONOPTIMIZE: ${{ matrix.PYTHONOPTIMIZE }} + REVERSE: ${{ matrix.REVERSE }} - name: Prepare to upload errors if: failure() diff --git a/CHANGES.rst b/CHANGES.rst index 69d77fd68b8..fc683a110fa 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -5,6 +5,78 @@ Changelog (Pillow) 8.3.0 (unreleased) ------------------ +- Limit TIFF strip size when saving with LibTIFF #5514 + [kmilos] + +- Allow ICNS save on all operating systems #4526 + [baletu, radarhere, newpanjing, hugovk] + +- De-zigzag JPEG's DQT when loading; deprecate convert_dict_qtables #4989 + [gofr, radarhere] + +- Replaced xml.etree.ElementTree #5565 + [radarhere] + +- Moved CVE image to pillow-depends #5561 + [radarhere] + +- Added tag data for IFD groups #5554 + [radarhere] + +- Improved ImagePalette #5552 + [radarhere] + +- Add DDS saving #5402 + [radarhere] + +- Improved getxmp() #5455 + [radarhere] + +- Convert to float for comparison with float in IFDRational __eq__ #5412 + [radarhere] + +- Allow getexif() to access TIFF tag_v2 data #5416 + [radarhere] + +- Read FITS image mode and size #5405 + [radarhere] + +- Merge parallel horizontal edges in ImagingDrawPolygon #5347 + [radarhere, hrdrq] + +- Use transparency behind first GIF frame and when disposing to background #5557 + [radarhere, zewt] + +- Avoid unstable nature of qsort in Quant.c #5367 + [radarhere] + +- Copy palette to new images in ImageOps expand #5551 + [radarhere] + +- Ensure palette string matches RGB mode #5549 + [radarhere] + +- Do not modify EXIF of original image instance in exif_transpose() #5547 + [radarhere] + +- Fixed default numresolution for small JPEG2000 images #5540 + [radarhere] + +- Added DDS BC5 reading #5501 + [radarhere] + +- Raise an error if ImageDraw.textbbox is used without a TrueType font #5510 + [radarhere] + +- Added ICO saving in BMP format #5513 + [radarhere] + +- Ensure PNG seeks to end of previous chunk at start of load_end #5493 + [radarhere] + +- Do not allow TIFF to seek to a past frame #5473 + [radarhere] + - Avoid race condition when displaying images with eog #5507 [mconst] diff --git a/Tests/images/bc5_snorm.dds b/Tests/images/bc5_snorm.dds new file mode 100644 index 00000000000..7458c67c6ad Binary files /dev/null and b/Tests/images/bc5_snorm.dds differ diff --git a/Tests/images/bc5_typeless.dds b/Tests/images/bc5_typeless.dds new file mode 100644 index 00000000000..b5bae52bb95 Binary files /dev/null and b/Tests/images/bc5_typeless.dds differ diff --git a/Tests/images/bc5_unorm.dds b/Tests/images/bc5_unorm.dds new file mode 100644 index 00000000000..a04a026eb1f Binary files /dev/null and b/Tests/images/bc5_unorm.dds differ diff --git a/Tests/images/bc5_unorm.png b/Tests/images/bc5_unorm.png new file mode 100644 index 00000000000..05279ddfbe6 Binary files /dev/null and b/Tests/images/bc5_unorm.png differ diff --git a/Tests/images/bc5s.dds b/Tests/images/bc5s.dds new file mode 100644 index 00000000000..0b999eed320 Binary files /dev/null and b/Tests/images/bc5s.dds differ diff --git a/Tests/images/bc5s.png b/Tests/images/bc5s.png new file mode 100644 index 00000000000..39d7811bf2e Binary files /dev/null and b/Tests/images/bc5s.png differ diff --git a/Tests/images/crash-81154a65438ba5aaeca73fd502fa4850fbde60f8.tif b/Tests/images/crash-81154a65438ba5aaeca73fd502fa4850fbde60f8.tif deleted file mode 100644 index 56e82419968..00000000000 Binary files a/Tests/images/crash-81154a65438ba5aaeca73fd502fa4850fbde60f8.tif and /dev/null differ diff --git a/Tests/images/first_frame_transparency.gif b/Tests/images/first_frame_transparency.gif new file mode 100644 index 00000000000..86dc0de64a9 Binary files /dev/null and b/Tests/images/first_frame_transparency.gif differ diff --git a/Tests/images/hopper_naxis_zero.fits b/Tests/images/hopper_naxis_zero.fits new file mode 100644 index 00000000000..580cf3a2c00 Binary files /dev/null and b/Tests/images/hopper_naxis_zero.fits differ diff --git a/Tests/images/hopper_resized.gif b/Tests/images/hopper_resized.gif new file mode 100644 index 00000000000..f7be6c26298 Binary files /dev/null and b/Tests/images/hopper_resized.gif differ diff --git a/Tests/images/imagedraw/continuous_horizontal_edges_polygon.png b/Tests/images/imagedraw/continuous_horizontal_edges_polygon.png new file mode 100644 index 00000000000..beffed5b918 Binary files /dev/null and b/Tests/images/imagedraw/continuous_horizontal_edges_polygon.png differ diff --git a/Tests/images/multipage_multiple_frame_loop.tiff b/Tests/images/multipage_multiple_frame_loop.tiff new file mode 100644 index 00000000000..b6759b08023 Binary files /dev/null and b/Tests/images/multipage_multiple_frame_loop.tiff differ diff --git a/Tests/images/multipage_out_of_order.tiff b/Tests/images/multipage_out_of_order.tiff new file mode 100644 index 00000000000..1576a549b58 Binary files /dev/null and b/Tests/images/multipage_out_of_order.tiff differ diff --git a/Tests/images/multipage_single_frame_loop.tiff b/Tests/images/multipage_single_frame_loop.tiff new file mode 100644 index 00000000000..26f27c421cd Binary files /dev/null and b/Tests/images/multipage_single_frame_loop.tiff differ diff --git a/Tests/images/padded_idat.png b/Tests/images/padded_idat.png new file mode 100644 index 00000000000..18c5a4990cd Binary files /dev/null and b/Tests/images/padded_idat.png differ diff --git a/Tests/images/transparent_dispose.gif b/Tests/images/transparent_dispose.gif new file mode 100644 index 00000000000..92b615543de Binary files /dev/null and b/Tests/images/transparent_dispose.gif differ diff --git a/Tests/oss-fuzz/fuzz_font.py b/Tests/oss-fuzz/fuzz_font.py index bdfda7a13aa..e1147101187 100755 --- a/Tests/oss-fuzz/fuzz_font.py +++ b/Tests/oss-fuzz/fuzz_font.py @@ -34,6 +34,7 @@ def main(): fuzzers.enable_decompressionbomb_error() atheris.Setup(sys.argv, TestOneInput, enable_python_coverage=True) atheris.Fuzz() + fuzzers.disable_decompressionbomb_error() if __name__ == "__main__": diff --git a/Tests/oss-fuzz/fuzz_pillow.py b/Tests/oss-fuzz/fuzz_pillow.py index d816d535f8f..b3c55fe22ec 100644 --- a/Tests/oss-fuzz/fuzz_pillow.py +++ b/Tests/oss-fuzz/fuzz_pillow.py @@ -34,6 +34,7 @@ def main(): fuzzers.enable_decompressionbomb_error() atheris.Setup(sys.argv, TestOneInput, enable_python_coverage=True) atheris.Fuzz() + fuzzers.disable_decompressionbomb_error() if __name__ == "__main__": diff --git a/Tests/oss-fuzz/fuzzers.py b/Tests/oss-fuzz/fuzzers.py index 1e7a4e27df1..5786764a64d 100644 --- a/Tests/oss-fuzz/fuzzers.py +++ b/Tests/oss-fuzz/fuzzers.py @@ -10,6 +10,11 @@ def enable_decompressionbomb_error(): warnings.simplefilter("error", Image.DecompressionBombWarning) +def disable_decompressionbomb_error(): + ImageFile.LOAD_TRUNCATED_IMAGES = False + warnings.resetwarnings() + + def fuzz_image(data): # This will fail on some images in the corpus, as we have many # invalid images in the test suite. diff --git a/Tests/oss-fuzz/test_fuzzers.py b/Tests/oss-fuzz/test_fuzzers.py index 2ac1a0d7f97..629e9ac00d4 100644 --- a/Tests/oss-fuzz/test_fuzzers.py +++ b/Tests/oss-fuzz/test_fuzzers.py @@ -44,6 +44,8 @@ def test_fuzz_images(path): ): # Known Image.* exceptions assert True + finally: + fuzzers.disable_decompressionbomb_error() @pytest.mark.parametrize( diff --git a/Tests/test_decompression_bomb.py b/Tests/test_decompression_bomb.py index db431337568..d918ef9410c 100644 --- a/Tests/test_decompression_bomb.py +++ b/Tests/test_decompression_bomb.py @@ -10,8 +10,7 @@ class TestDecompressionBomb: - @classmethod - def teardown_class(cls): + def teardown_method(self, method): Image.MAX_IMAGE_PIXELS = ORIGINAL_LIMIT def test_no_warning_small_file(self): diff --git a/Tests/test_file_apng.py b/Tests/test_file_apng.py index 8348da4ebca..7fb6f59d493 100644 --- a/Tests/test_file_apng.py +++ b/Tests/test_file_apng.py @@ -249,8 +249,8 @@ def test_apng_mode(): assert im.mode == "P" im.seek(im.n_frames - 1) im = im.convert("RGBA") - assert im.getpixel((0, 0)) == (0, 255, 0, 255) - assert im.getpixel((64, 32)) == (0, 255, 0, 255) + assert im.getpixel((0, 0)) == (255, 0, 0, 0) + assert im.getpixel((64, 32)) == (255, 0, 0, 0) with Image.open("Tests/images/apng/mode_palette_1bit_alpha.png") as im: assert im.mode == "P" diff --git a/Tests/test_file_dds.py b/Tests/test_file_dds.py index bb9a5a07402..46ebcad0c1e 100644 --- a/Tests/test_file_dds.py +++ b/Tests/test_file_dds.py @@ -5,11 +5,15 @@ from PIL import DdsImagePlugin, Image -from .helper import assert_image_equal, assert_image_equal_tofile +from .helper import assert_image_equal, assert_image_equal_tofile, hopper TEST_FILE_DXT1 = "Tests/images/dxt1-rgb-4bbp-noalpha_MipMaps-1.dds" TEST_FILE_DXT3 = "Tests/images/dxt3-argb-8bbp-explicitalpha_MipMaps-1.dds" TEST_FILE_DXT5 = "Tests/images/dxt5-argb-8bbp-interpolatedalpha_MipMaps-1.dds" +TEST_FILE_DX10_BC5_TYPELESS = "Tests/images/bc5_typeless.dds" +TEST_FILE_DX10_BC5_UNORM = "Tests/images/bc5_unorm.dds" +TEST_FILE_DX10_BC5_SNORM = "Tests/images/bc5_snorm.dds" +TEST_FILE_BC5S = "Tests/images/bc5s.dds" TEST_FILE_DX10_BC7 = "Tests/images/bc7-argb-8bpp_MipMaps-1.dds" TEST_FILE_DX10_BC7_UNORM_SRGB = "Tests/images/DXGI_FORMAT_BC7_UNORM_SRGB.dds" TEST_FILE_DX10_R8G8B8A8 = "Tests/images/argb-32bpp_MipMaps-1.dds" @@ -32,6 +36,19 @@ def test_sanity_dxt1(): assert_image_equal(im, target) +def test_sanity_dxt3(): + """Check DXT3 images can be opened""" + + with Image.open(TEST_FILE_DXT3) as im: + im.load() + + assert im.format == "DDS" + assert im.mode == "RGBA" + assert im.size == (256, 256) + + assert_image_equal_tofile(im, TEST_FILE_DXT3.replace(".dds", ".png")) + + def test_sanity_dxt5(): """Check DXT5 images can be opened""" @@ -45,17 +62,28 @@ def test_sanity_dxt5(): assert_image_equal_tofile(im, TEST_FILE_DXT5.replace(".dds", ".png")) -def test_sanity_dxt3(): - """Check DXT3 images can be opened""" - - with Image.open(TEST_FILE_DXT3) as im: +@pytest.mark.parametrize( + ("image_path", "expected_path"), + ( + # hexeditted to be typeless + (TEST_FILE_DX10_BC5_TYPELESS, TEST_FILE_DX10_BC5_UNORM), + (TEST_FILE_DX10_BC5_UNORM, TEST_FILE_DX10_BC5_UNORM), + # hexeditted to use DX10 FourCC + (TEST_FILE_DX10_BC5_SNORM, TEST_FILE_BC5S), + (TEST_FILE_BC5S, TEST_FILE_BC5S), + ), +) +def test_dx10_bc5(image_path, expected_path): + """Check DX10 BC5 images can be opened""" + + with Image.open(image_path) as im: im.load() assert im.format == "DDS" - assert im.mode == "RGBA" + assert im.mode == "RGB" assert im.size == (256, 256) - assert_image_equal_tofile(im, TEST_FILE_DXT3.replace(".dds", ".png")) + assert_image_equal_tofile(im, expected_path.replace(".dds", ".png")) def test_dx10_bc7(): @@ -214,3 +242,27 @@ def test_unimplemented_pixel_format(): with pytest.raises(NotImplementedError): with Image.open("Tests/images/unimplemented_pixel_format.dds"): pass + + +def test_save_unsupported_mode(tmp_path): + out = str(tmp_path / "temp.dds") + im = hopper("HSV") + with pytest.raises(OSError): + im.save(out) + + +@pytest.mark.parametrize( + ("mode", "test_file"), + [ + ("RGB", "Tests/images/hopper.png"), + ("RGBA", "Tests/images/pil123rgba.png"), + ], +) +def test_save(mode, test_file, tmp_path): + out = str(tmp_path / "temp.dds") + with Image.open(test_file) as im: + assert im.mode == mode + im.save(out) + + with Image.open(out) as reloaded: + assert_image_equal(im, reloaded) diff --git a/Tests/test_file_fitsstub.py b/Tests/test_file_fitsstub.py index 6dc7c4602f5..c77457947ef 100644 --- a/Tests/test_file_fitsstub.py +++ b/Tests/test_file_fitsstub.py @@ -1,3 +1,5 @@ +from io import BytesIO + import pytest from PIL import FitsStubImagePlugin, Image @@ -11,10 +13,8 @@ def test_open(): # Assert assert im.format == "FITS" - - # Dummy data from the stub - assert im.mode == "F" - assert im.size == (1, 1) + assert im.size == (128, 128) + assert im.mode == "L" def test_invalid_file(): @@ -35,6 +35,21 @@ def test_load(): im.load() +def test_truncated_fits(): + # No END to headers + image_data = b"SIMPLE = T" + b" " * 50 + b"TRUNCATE" + with pytest.raises(OSError): + FitsStubImagePlugin.FITSStubImageFile(BytesIO(image_data)) + + +def test_naxis_zero(): + # This test image has been manually hexedited + # to set the number of data axes to zero + with pytest.raises(ValueError): + with Image.open("Tests/images/hopper_naxis_zero.fits"): + pass + + def test_save(): # Arrange with Image.open(TEST_FILE) as im: diff --git a/Tests/test_file_gif.py b/Tests/test_file_gif.py index df029dcebfb..2632ab7c04f 100644 --- a/Tests/test_file_gif.py +++ b/Tests/test_file_gif.py @@ -298,6 +298,12 @@ def test_eoferror(): im.seek(n_frames - 1) +def test_first_frame_transparency(): + with Image.open("Tests/images/first_frame_transparency.gif") as im: + px = im.load() + assert px[0, 0] == im.info["transparency"] + + def test_dispose_none(): with Image.open("Tests/images/dispose_none.gif") as img: try: @@ -331,6 +337,16 @@ def test_dispose_background(): pass +def test_transparent_dispose(): + expected_colors = [(2, 1, 2), (0, 1, 0), (2, 1, 2)] + with Image.open("Tests/images/transparent_dispose.gif") as img: + for frame in range(3): + img.seek(frame) + for x in range(3): + color = img.getpixel((x, 0)) + assert color == expected_colors[frame][x] + + def test_dispose_previous(): with Image.open("Tests/images/dispose_prev.gif") as img: try: @@ -392,14 +408,15 @@ def test_save_dispose(tmp_path): def test_dispose2_palette(tmp_path): out = str(tmp_path / "temp.gif") - # 4 backgrounds: White, Grey, Black, Red + # Four colors: white, grey, black, red circles = [(255, 255, 255), (153, 153, 153), (0, 0, 0), (255, 0, 0)] im_list = [] for circle in circles: + # Red background img = Image.new("RGB", (100, 100), (255, 0, 0)) - # Red circle in center of each frame + # Circle in center of each frame d = ImageDraw.Draw(img) d.ellipse([(40, 40), (60, 60)], fill=circle) @@ -749,10 +766,10 @@ def test_rgb_transparency(tmp_path): # Single frame im = Image.new("RGB", (1, 1)) im.info["transparency"] = (255, 0, 0) - pytest.warns(UserWarning, im.save, out) + im.save(out) with Image.open(out) as reloaded: - assert "transparency" not in reloaded.info + assert "transparency" in reloaded.info # Multiple frames im = Image.new("RGB", (1, 1)) diff --git a/Tests/test_file_icns.py b/Tests/test_file_icns.py index 30ec3dc72c0..47de38d06c0 100644 --- a/Tests/test_file_icns.py +++ b/Tests/test_file_icns.py @@ -1,5 +1,4 @@ import io -import sys import pytest @@ -28,7 +27,6 @@ def test_sanity(): assert im.format == "ICNS" -@pytest.mark.skipif(sys.platform != "darwin", reason="Requires macOS") def test_save(tmp_path): temp_file = str(tmp_path / "temp.icns") @@ -41,7 +39,6 @@ def test_save(tmp_path): 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)) @@ -57,7 +54,6 @@ def test_save_append_images(tmp_path): assert_image_equal(reread, provided_im) -@pytest.mark.skipif(sys.platform != "darwin", reason="Requires macOS") def test_save_fp(): fp = io.BytesIO() diff --git a/Tests/test_file_ico.py b/Tests/test_file_ico.py index 7b9e4e698b7..8060d1b763b 100644 --- a/Tests/test_file_ico.py +++ b/Tests/test_file_ico.py @@ -56,6 +56,35 @@ def test_save_to_bytes(): assert_image_equal(reloaded, hopper().resize((32, 32), Image.LANCZOS)) +@pytest.mark.parametrize("mode", ("1", "L", "P", "RGB", "RGBA")) +def test_save_to_bytes_bmp(mode): + output = io.BytesIO() + im = hopper(mode) + im.save(output, "ico", bitmap_format="bmp", sizes=[(32, 32), (64, 64)]) + + # The default image + output.seek(0) + with Image.open(output) as reloaded: + assert reloaded.info["sizes"] == {(32, 32), (64, 64)} + + assert "RGBA" == reloaded.mode + assert (64, 64) == reloaded.size + assert reloaded.format == "ICO" + im = hopper(mode).resize((64, 64), Image.LANCZOS).convert("RGBA") + assert_image_equal(reloaded, im) + + # The other one + output.seek(0) + with Image.open(output) as reloaded: + reloaded.size = (32, 32) + + assert "RGBA" == reloaded.mode + assert (32, 32) == reloaded.size + assert reloaded.format == "ICO" + im = hopper(mode).resize((32, 32), Image.LANCZOS).convert("RGBA") + assert_image_equal(reloaded, im) + + def test_incorrect_size(): with Image.open(TEST_ICO_FILE) as im: with pytest.raises(ValueError): diff --git a/Tests/test_file_jpeg.py b/Tests/test_file_jpeg.py index eb566e68730..68096e92d33 100644 --- a/Tests/test_file_jpeg.py +++ b/Tests/test_file_jpeg.py @@ -28,6 +28,11 @@ skip_unless_feature, ) +try: + import defusedxml.ElementTree as ElementTree +except ImportError: + ElementTree = None + TEST_FILE = "Tests/images/hopper.jpg" @@ -461,7 +466,7 @@ def _n_qtables_helper(n, test_file): assert len(im.quantization) == n reloaded = self.roundtrip(im, qtables="keep") assert im.quantization == reloaded.quantization - assert reloaded.quantization[0].typecode == "B" + assert max(reloaded.quantization[0]) <= 255 with Image.open("Tests/images/hopper.jpg") as im: qtables = im.quantization @@ -473,7 +478,8 @@ def _n_qtables_helper(n, test_file): # valid bounds for baseline qtable bounds_qtable = [int(s) for s in ("255 1 " * 32).split(None)] - self.roundtrip(im, qtables=[bounds_qtable]) + im2 = self.roundtrip(im, qtables=[bounds_qtable]) + assert im2.quantization == {0: bounds_qtable} # values from wizard.txt in jpeg9-a src package. standard_l_qtable = [ @@ -584,6 +590,12 @@ def test_save_low_quality_baseline_qtables(self): assert max(im2.quantization[0]) <= 255 assert max(im2.quantization[1]) <= 255 + def test_convert_dict_qtables_deprecation(self): + with pytest.warns(DeprecationWarning): + qtable = {0: [1, 2, 3, 4]} + qtable2 = JpegImagePlugin.convert_dict_qtables(qtable) + assert qtable == qtable2 + @pytest.mark.skipif(not djpeg_available(), reason="djpeg not available") def test_load_djpeg(self): with Image.open(TEST_FILE) as img: @@ -825,10 +837,29 @@ def read(n=-1): def test_getxmp(self): with Image.open("Tests/images/xmp_test.jpg") as im: - xmp = im.getxmp() - - assert isinstance(xmp, dict) - assert xmp["Description"]["Version"] == "10.4" + if ElementTree is None: + with pytest.warns(UserWarning): + assert im.getxmp() == {} + else: + xmp = im.getxmp() + + description = xmp["xmpmeta"]["RDF"]["Description"] + assert description["DerivedFrom"] == { + "documentID": "8367D410E636EA95B7DE7EBA1C43A412", + "originalDocumentID": "8367D410E636EA95B7DE7EBA1C43A412", + } + assert description["Look"]["Description"]["Group"]["Alt"]["li"] == { + "lang": "x-default", + "text": "Profiles", + } + assert description["ToneCurve"]["Seq"]["li"] == ["0, 0", "255, 255"] + + # Attribute + assert description["Version"] == "10.4" + + if ElementTree is not None: + with Image.open("Tests/images/hopper.jpg") as im: + assert im.getxmp() == {} @pytest.mark.skipif(not is_win32(), reason="Windows only") diff --git a/Tests/test_file_jpeg2k.py b/Tests/test_file_jpeg2k.py index 2173d245fd5..20280a57918 100644 --- a/Tests/test_file_jpeg2k.py +++ b/Tests/test_file_jpeg2k.py @@ -127,6 +127,16 @@ def test_prog_res_rt(): assert_image_equal(im, test_card) +def test_default_num_resolutions(): + for num_resolutions in range(2, 6): + d = 1 << (num_resolutions - 1) + im = test_card.resize((d - 1, d - 1)) + with pytest.raises(OSError): + roundtrip(im, num_resolutions=num_resolutions) + reloaded = roundtrip(im) + assert_image_equal(im, reloaded) + + def test_reduce(): with Image.open("Tests/images/test-card-lossless.jp2") as im: assert callable(im.reduce) diff --git a/Tests/test_file_libtiff.py b/Tests/test_file_libtiff.py index c2e3aab3b53..9a9750d83cc 100644 --- a/Tests/test_file_libtiff.py +++ b/Tests/test_file_libtiff.py @@ -974,3 +974,12 @@ def test_strip_chop(self): with Image.open("Tests/images/hopper.iccprofile.tif") as im: assert len(im.tag_v2[STRIPOFFSETS]) > 1 TiffImagePlugin.READ_LIBTIFF = False + + def test_save_multistrip(self, tmp_path): + im = hopper("RGB").resize((256, 256)) + out = str(tmp_path / "temp.tif") + im.save(out, compression="tiff_adobe_deflate") + + with Image.open(out) as im: + # Assert that there are multiple strips + assert len(im.tag_v2[STRIPOFFSETS]) > 1 \ No newline at end of file diff --git a/Tests/test_file_png.py b/Tests/test_file_png.py index 24880334aa2..ffacbbbf4f6 100644 --- a/Tests/test_file_png.py +++ b/Tests/test_file_png.py @@ -19,6 +19,11 @@ skip_unless_feature, ) +try: + import defusedxml.ElementTree as ElementTree +except ImportError: + ElementTree = None + # sample png stream TEST_PNG_FILE = "Tests/images/hopper.png" @@ -615,6 +620,23 @@ def test_textual_chunks_after_idat(self): with Image.open("Tests/images/hopper_idat_after_image_end.png") as im: assert im.text == {"TXT": "VALUE", "ZIP": "VALUE"} + def test_padded_idat(self): + # This image has been manually hexedited + # so that the IDAT chunk has padding at the end + # Set MAXBLOCK to the length of the actual data + # so that the decoder finishes reading before the chunk ends + MAXBLOCK = ImageFile.MAXBLOCK + ImageFile.MAXBLOCK = 45 + ImageFile.LOAD_TRUNCATED_IMAGES = True + + with Image.open("Tests/images/padded_idat.png") as im: + im.load() + + ImageFile.MAXBLOCK = MAXBLOCK + ImageFile.LOAD_TRUNCATED_IMAGES = False + + assert_image_equal_tofile(im, "Tests/images/bw_gradient.png") + def test_specify_bits(self, tmp_path): im = hopper("P") @@ -634,6 +656,18 @@ def test_plte_length(self, tmp_path): with Image.open(out) as reloaded: assert len(reloaded.png.im_palette[1]) == 3 + def test_getxmp(self): + with Image.open("Tests/images/color_snakes.png") as im: + if ElementTree is None: + with pytest.warns(UserWarning): + assert im.getxmp() == {} + else: + xmp = im.getxmp() + + description = xmp["xmpmeta"]["RDF"]["Description"] + assert description["PixelXDimension"] == "10" + assert description["subject"]["Seq"] is None + def test_exif(self): # With an EXIF chunk with Image.open("Tests/images/exif.png") as im: diff --git a/Tests/test_file_tga.py b/Tests/test_file_tga.py index a27b3ffba9b..3450c9274ce 100644 --- a/Tests/test_file_tga.py +++ b/Tests/test_file_tga.py @@ -65,10 +65,15 @@ def roundtrip(original_im): roundtrip(original_im) -def test_palette_depth_16(): +def test_palette_depth_16(tmp_path): with Image.open("Tests/images/p_16.tga") as im: assert_image_equal_tofile(im.convert("RGB"), "Tests/images/p_16.png") + out = str(tmp_path / "temp.png") + im.save(out) + with Image.open(out) as reloaded: + assert_image_equal_tofile(reloaded.convert("RGB"), "Tests/images/p_16.png") + def test_id_field(): # tga file with id field diff --git a/Tests/test_file_tiff.py b/Tests/test_file_tiff.py index 91506f98017..57f45bd09b2 100644 --- a/Tests/test_file_tiff.py +++ b/Tests/test_file_tiff.py @@ -16,6 +16,11 @@ is_win32, ) +try: + import defusedxml.ElementTree as ElementTree +except ImportError: + ElementTree = None + class TestFileTiff: def test_sanity(self, tmp_path): @@ -146,25 +151,24 @@ def test_load_float_dpi(self, resolutionUnit, dpi): "Tests/images/hopper_float_dpi_" + str(resolutionUnit) + ".tif" ) as im: assert im.tag_v2.get(RESOLUTION_UNIT) == resolutionUnit - for reloaded_dpi in im.info["dpi"]: - assert float(reloaded_dpi) == dpi + assert im.info["dpi"] == (dpi, dpi) def test_save_float_dpi(self, tmp_path): outfile = str(tmp_path / "temp.tif") with Image.open("Tests/images/hopper.tif") as im: - im.save(outfile, dpi=(72.2, 72.2)) + dpi = (72.2, 72.2) + im.save(outfile, dpi=dpi) with Image.open(outfile) as reloaded: - for dpi in reloaded.info["dpi"]: - assert float(dpi) == 72.2 + assert reloaded.info["dpi"] == dpi def test_save_setting_missing_resolution(self): b = BytesIO() with Image.open("Tests/images/10ct_32bit_128.tiff") as im: im.save(b, format="tiff", resolution=123.45) with Image.open(b) as im: - assert float(im.tag_v2[X_RESOLUTION]) == 123.45 - assert float(im.tag_v2[Y_RESOLUTION]) == 123.45 + assert im.tag_v2[X_RESOLUTION] == 123.45 + assert im.tag_v2[Y_RESOLUTION] == 123.45 def test_invalid_file(self): invalid_file = "Tests/images/flower.jpg" @@ -298,6 +302,19 @@ def test_multipage_last_frame(self): assert im.size == (20, 20) assert im.convert("RGB").getpixel((0, 0)) == (0, 0, 255) + def test_frame_order(self): + # A frame can't progress to itself after reading + with Image.open("Tests/images/multipage_single_frame_loop.tiff") as im: + assert im.n_frames == 1 + + # A frame can't progress to a frame that has already been read + with Image.open("Tests/images/multipage_multiple_frame_loop.tiff") as im: + assert im.n_frames == 2 + + # Frames don't have to be in sequence + with Image.open("Tests/images/multipage_out_of_order.tiff") as im: + assert im.n_frames == 3 + def test___str__(self): filename = "Tests/images/pil136.tiff" with Image.open(filename) as im: @@ -386,6 +403,50 @@ def test_ifd_tag_type(self): with Image.open("Tests/images/ifd_tag_type.tiff") as im: assert 0x8825 in im.tag_v2 + def test_exif(self): + with Image.open("Tests/images/ifd_tag_type.tiff") as im: + exif = im.getexif() + + assert sorted(exif.keys()) == [ + 256, + 257, + 258, + 259, + 262, + 271, + 272, + 273, + 277, + 278, + 279, + 282, + 283, + 284, + 296, + 297, + 305, + 339, + 700, + 34665, + 34853, + 50735, + ] + assert exif[256] == 640 + assert exif[271] == "FLIR" + + gps = exif.get_ifd(0x8825) + assert list(gps.keys()) == [0, 1, 2, 3, 4, 5, 6, 18] + assert gps[0] == b"\x03\x02\x00\x00" + assert gps[18] == "WGS-84" + + def test_exif_frames(self): + # Test that EXIF data can change across frames + with Image.open("Tests/images/g4-multi.tiff") as im: + assert im.getexif()[273] == (328, 815) + + im.seek(1) + assert im.getexif()[273] == (1408, 1907) + def test_seek(self): filename = "Tests/images/pil136.tiff" with Image.open(filename) as im: @@ -587,6 +648,18 @@ def test_discard_icc_profile(self, tmp_path): with Image.open(outfile) as reloaded: assert "icc_profile" not in reloaded.info + def test_getxmp(self): + with Image.open("Tests/images/lab.tif") as im: + if ElementTree is None: + with pytest.warns(UserWarning): + assert im.getxmp() == {} + else: + xmp = im.getxmp() + + description = xmp["xmpmeta"]["RDF"]["Description"] + assert description[0]["format"] == "image/tiff" + assert description[3]["BitsPerSample"]["Seq"]["li"] == ["8", "8", "8"] + def test_close_on_load_exclusive(self, tmp_path): # similar to test_fd_leak, but runs on unixlike os tmpfile = str(tmp_path / "temp.tif") diff --git a/Tests/test_file_tiff_metadata.py b/Tests/test_file_tiff_metadata.py index e2841163b31..0adbaf0161e 100644 --- a/Tests/test_file_tiff_metadata.py +++ b/Tests/test_file_tiff_metadata.py @@ -376,3 +376,30 @@ def test_too_many_entries(): # Should not raise ValueError. pytest.warns(UserWarning, lambda: ifd[277]) + + +def test_tag_group_data(): + base_ifd = TiffImagePlugin.ImageFileDirectory_v2() + interop_ifd = TiffImagePlugin.ImageFileDirectory_v2(group=40965) + for ifd in (base_ifd, interop_ifd): + ifd[2] = "test" + ifd[256] = 10 + + assert base_ifd.tagtype[256] == 4 + assert interop_ifd.tagtype[256] != base_ifd.tagtype[256] + + assert interop_ifd.tagtype[2] == 7 + assert base_ifd.tagtype[2] != interop_ifd.tagtype[256] + + +def test_empty_subifd(tmp_path): + out = str(tmp_path / "temp.jpg") + + im = hopper() + exif = im.getexif() + exif[TiffImagePlugin.EXIFIFD] = {} + im.save(out, exif=exif) + + with Image.open(out) as reloaded: + exif = reloaded.getexif() + assert exif.get_ifd(TiffImagePlugin.EXIFIFD) == {} diff --git a/Tests/test_image.py b/Tests/test_image.py index 82efefc1e6c..c4e6f8ade71 100644 --- a/Tests/test_image.py +++ b/Tests/test_image.py @@ -582,6 +582,10 @@ def test_register_extensions(self): assert ext_individual == ext_multiple def test_remap_palette(self): + # Test identity transform + with Image.open("Tests/images/hopper.gif") as im: + assert_image_equal(im, im.remap_palette(list(range(256)))) + # Test illegal image mode with hopper() as im: with pytest.raises(ValueError): @@ -606,7 +610,7 @@ def _make_new(base_image, im, palette_result=None): else: assert new_im.palette is None - _make_new(im, im_p, im_p.palette) + _make_new(im, im_p, ImagePalette.ImagePalette(list(range(256)) * 3)) _make_new(im_p, im, None) _make_new(im, blank_p, ImagePalette.ImagePalette()) _make_new(im, blank_pa, ImagePalette.ImagePalette()) @@ -773,6 +777,27 @@ def test_exif_ifd(self): reloaded_exif.load(exif.tobytes()) assert reloaded_exif.get_ifd(0x8769) == exif.get_ifd(0x8769) + def test_exif_load_from_fp(self): + with Image.open("Tests/images/flower.jpg") as im: + data = im.info["exif"] + if data.startswith(b"Exif\x00\x00"): + data = data[6:] + fp = io.BytesIO(data) + + exif = Image.Exif() + exif.load_from_fp(fp) + assert exif == { + 271: "Canon", + 272: "Canon PowerShot S40", + 274: 1, + 282: 180.0, + 283: 180.0, + 296: 2, + 306: "2003:12:14 12:01:44", + 531: 1, + 34665: 196, + } + @pytest.mark.skipif( sys.version_info < (3, 7), reason="Python 3.7 or greater required" ) diff --git a/Tests/test_image_convert.py b/Tests/test_image_convert.py index 6fe1bd962fd..5dcdac0e4e1 100644 --- a/Tests/test_image_convert.py +++ b/Tests/test_image_convert.py @@ -93,7 +93,7 @@ def test_trns_p(tmp_path): im_l.save(f) im_rgb = im.convert("RGB") - assert im_rgb.info["transparency"] == (0, 0, 0) # undone + assert im_rgb.info["transparency"] == (0, 1, 2) # undone im_rgb.save(f) @@ -128,8 +128,8 @@ def test_trns_l(tmp_path): assert "transparency" in im_p.info im_p.save(f) - im_p = pytest.warns(UserWarning, im.convert, "P", palette=Image.ADAPTIVE) - assert "transparency" not in im_p.info + im_p = im.convert("P", palette=Image.ADAPTIVE) + assert "transparency" in im_p.info im_p.save(f) @@ -155,13 +155,19 @@ def test_trns_RGB(tmp_path): assert "transparency" not in im_p.info im_p.save(f) + im = Image.new("RGB", (1, 1)) + im.info["transparency"] = im.getpixel((0, 0)) + im_p = im.convert("P", palette=Image.ADAPTIVE) + assert im_p.info["transparency"] == im_p.getpixel((0, 0)) + im_p.save(f) + def test_gif_with_rgba_palette_to_p(): # See https://github.com/python-pillow/Pillow/issues/2433 with Image.open("Tests/images/hopper.gif") as im: im.info["transparency"] = 255 im.load() - assert im.palette.mode == "RGBA" + assert im.palette.mode == "RGB" im_p = im.convert("P") # Should not raise ValueError: unrecognized raw mode diff --git a/Tests/test_image_resize.py b/Tests/test_image_resize.py index 50c5a99bdab..17490e1a8c0 100644 --- a/Tests/test_image_resize.py +++ b/Tests/test_image_resize.py @@ -7,7 +7,12 @@ from PIL import Image -from .helper import assert_image_equal, assert_image_similar, hopper +from .helper import ( + assert_image_equal, + assert_image_equal_tofile, + assert_image_similar, + hopper, +) class TestImagingCoreResize: @@ -135,6 +140,17 @@ def test_unknown_filter(self): with pytest.raises(ValueError): self.resize(hopper(), (10, 10), 9) + def test_cross_platform(self, tmp_path): + # This test is intended for only check for consistent behaviour across + # platforms. So if a future Pillow change requires that the test file + # be updated, that is okay. + im = hopper().resize((64, 64)) + temp_file = str(tmp_path / "temp.gif") + im.save(temp_file) + + with Image.open(temp_file) as reloaded: + assert_image_equal_tofile(reloaded, "Tests/images/hopper_resized.gif") + @pytest.fixture def gradients_image(): diff --git a/Tests/test_imagedraw.py b/Tests/test_imagedraw.py index dbdd34bc896..6be8fafa1e8 100644 --- a/Tests/test_imagedraw.py +++ b/Tests/test_imagedraw.py @@ -1379,3 +1379,22 @@ def test_compute_regular_polygon_vertices_input_error_handling( with pytest.raises(expected_error) as e: ImageDraw._compute_regular_polygon_vertices(bounding_circle, n_sides, rotation) assert str(e.value) == error_message + + +def test_continuous_horizontal_edges_polygon(): + xy = [ + (2, 6), + (6, 6), + (12, 6), + (12, 12), + (8, 12), + (8, 8), + (4, 8), + (2, 8), + ] + img, draw = create_base_image_draw((16, 16)) + draw.polygon(xy, BLACK) + expected = os.path.join(IMAGES_PATH, "continuous_horizontal_edges_polygon.png") + assert_image_equal_tofile( + img, expected, "continuous horizontal edges polygon failed" + ) diff --git a/Tests/test_imagefile.py b/Tests/test_imagefile.py index b4107e8e3a6..892087916b7 100644 --- a/Tests/test_imagefile.py +++ b/Tests/test_imagefile.py @@ -248,4 +248,4 @@ def test_no_format(self): def test_oserror(self): im = Image.new("RGB", (1, 1)) with pytest.raises(OSError): - im.save(BytesIO(), "JPEG2000") + im.save(BytesIO(), "JPEG2000", num_resolutions=2) diff --git a/Tests/test_imagefont.py b/Tests/test_imagefont.py index e843351abf8..892bd0ed1f5 100644 --- a/Tests/test_imagefont.py +++ b/Tests/test_imagefont.py @@ -719,6 +719,13 @@ def test_variation_set_by_axes(self): font.set_variation_by_axes([100]) self._check_text(font, "Tests/images/variation_tiny_axes.png", 32.5) + def test_textbbox_non_freetypefont(self): + im = Image.new("RGB", (200, 200)) + d = ImageDraw.Draw(im) + default_font = ImageFont.load_default() + with pytest.raises(ValueError): + d.textbbox((0, 0), "test", font=default_font) + @pytest.mark.parametrize( "anchor, left, left_old, top", ( diff --git a/Tests/test_imageops.py b/Tests/test_imageops.py index ff2445a5168..dc20d432f65 100644 --- a/Tests/test_imageops.py +++ b/Tests/test_imageops.py @@ -156,6 +156,25 @@ def test_scale(): assert newimg.size == (25, 25) +def test_expand_palette(): + im = Image.open("Tests/images/p_16.tga") + im_expanded = ImageOps.expand(im, 10, (255, 0, 0)) + + px = im_expanded.convert("RGB").load() + for b in range(10): + for x in range(im_expanded.width): + assert px[x, b] == (255, 0, 0) + assert px[x, im_expanded.height - 1 - b] == (255, 0, 0) + for y in range(im_expanded.height): + assert px[b, x] == (255, 0, 0) + assert px[b, im_expanded.width - 1 - b] == (255, 0, 0) + + im_cropped = im_expanded.crop( + (10, 10, im_expanded.width - 10, im_expanded.height - 10) + ) + assert_image_equal(im_cropped, im) + + def test_colorize_2color(): # Test the colorizing function with 2-color functionality @@ -302,6 +321,7 @@ def check(orientation_im): else: assert transposed_im.info["exif"] != original_exif + assert 0x0112 in im.getexif() assert 0x0112 not in transposed_im.getexif() # Repeat the operation to test that it does not keep transposing diff --git a/Tests/test_imagepalette.py b/Tests/test_imagepalette.py index 0ea2472a989..ecfbda1d89b 100644 --- a/Tests/test_imagepalette.py +++ b/Tests/test_imagepalette.py @@ -2,33 +2,76 @@ from PIL import Image, ImagePalette -from .helper import assert_image_equal_tofile +from .helper import assert_image_equal, assert_image_equal_tofile def test_sanity(): - ImagePalette.ImagePalette("RGB", list(range(256)) * 3) + palette = ImagePalette.ImagePalette("RGB", list(range(256)) * 3) + assert len(palette.colors) == 256 + with pytest.raises(ValueError): - ImagePalette.ImagePalette("RGB", list(range(256)) * 2) + ImagePalette.ImagePalette("RGB", list(range(256)) * 3, 10) + + +def test_reload(): + im = Image.open("Tests/images/hopper.gif") + original = im.copy() + im.palette.dirty = 1 + assert_image_equal(im.convert("RGB"), original.convert("RGB")) def test_getcolor(): palette = ImagePalette.ImagePalette() + assert len(palette.palette) == 0 + assert len(palette.colors) == 0 test_map = {} for i in range(256): test_map[palette.getcolor((i, i, i))] = i - assert len(test_map) == 256 + + # Colors can be converted between RGB and RGBA + rgba_palette = ImagePalette.ImagePalette("RGBA") + assert rgba_palette.getcolor((0, 0, 0)) == rgba_palette.getcolor((0, 0, 0, 255)) + + assert palette.getcolor((0, 0, 0)) == palette.getcolor((0, 0, 0, 255)) + + # An error is raised when the palette is full with pytest.raises(ValueError): palette.getcolor((1, 2, 3)) + # But not if the image is not using one of the palette entries + palette.getcolor((1, 2, 3), image=Image.new("P", (1, 1))) # Test unknown color specifier with pytest.raises(ValueError): palette.getcolor("unknown") +@pytest.mark.parametrize( + "index, palette", + [ + # Test when the palette is not full + (0, ImagePalette.ImagePalette()), + # Test when the palette is full + (255, ImagePalette.ImagePalette("RGB", list(range(256)) * 3)), + ], +) +def test_getcolor_not_special(index, palette): + im = Image.new("P", (1, 1)) + + # Do not use transparency index as a new color + im.info["transparency"] = index + index1 = palette.getcolor((0, 0, 0), im) + assert index1 != index + + # Do not use background index as a new color + im.info["background"] = index1 + index2 = palette.getcolor((0, 0, 1), im) + assert index2 not in (index, index1) + + def test_file(tmp_path): palette = ImagePalette.ImagePalette("RGB", list(range(256)) * 3) @@ -116,7 +159,7 @@ def test_getdata(): mode, data_out = palette.getdata() # Assert - assert mode == "RGB;L" + assert mode == "RGB" def test_rawmode_getdata(): diff --git a/Tests/test_tiff_ifdrational.py b/Tests/test_tiff_ifdrational.py index 1697a8d4946..12f475df036 100644 --- a/Tests/test_tiff_ifdrational.py +++ b/Tests/test_tiff_ifdrational.py @@ -28,6 +28,8 @@ def test_sanity(): _test_equal(1, 2, Fraction(1, 2)) _test_equal(1, 2, IFDRational(1, 2)) + _test_equal(7, 5, 1.4) + def test_ranges(): for num in range(1, 10): diff --git a/docs/deprecations.rst b/docs/deprecations.rst index ef88afa237d..262ba79e0cc 100644 --- a/docs/deprecations.rst +++ b/docs/deprecations.rst @@ -25,26 +25,6 @@ vulnerability introduced in FreeType 2.6 (:cve:`CVE-2020-15999`). .. _2.10.4: https://sourceforge.net/projects/freetype/files/freetype2/2.10.4/ -Tk/Tcl 8.4 -~~~~~~~~~~ - -.. deprecated:: 8.2.0 - -Support for Tk/Tcl 8.4 is deprecated and will be removed in Pillow 10.0.0 (2023-01-02), -when Tk/Tcl 8.5 will be the minimum supported. - -Categories -~~~~~~~~~~ - -.. deprecated:: 8.2.0 - -``im.category`` is deprecated and will be removed in Pillow 10.0.0 (2023-01-02), -along with the related ``Image.NORMAL``, ``Image.SEQUENCE`` and -``Image.CONTAINER`` attributes. - -To determine if an image has multiple frames or not, -``getattr(im, "is_animated", False)`` can be used instead. - Image.show command parameter ~~~~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -82,6 +62,36 @@ Use ``__version__`` instead. It was initially removed in Pillow 7.0.0, but brought back in 7.1.0 to give projects more time to upgrade. +Tk/Tcl 8.4 +~~~~~~~~~~ + +.. deprecated:: 8.2.0 + +Support for Tk/Tcl 8.4 is deprecated and will be removed in Pillow 10.0.0 (2023-01-02), +when Tk/Tcl 8.5 will be the minimum supported. + +Categories +~~~~~~~~~~ + +.. deprecated:: 8.2.0 + +``im.category`` is deprecated and will be removed in Pillow 10.0.0 (2023-01-02), +along with the related ``Image.NORMAL``, ``Image.SEQUENCE`` and +``Image.CONTAINER`` attributes. + +To determine if an image has multiple frames or not, +``getattr(im, "is_animated", False)`` can be used instead. + +JpegImagePlugin.convert_dict_qtables +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. deprecated:: 8.3.0 + +JPEG ``quantization`` is now automatically converted, but still returned as a +dictionary. The :py:attr:`~PIL.JpegImagePlugin.convert_dict_qtables` method no longer +performs any operations on the data given to it, has been deprecated and will be +removed in Pillow 10.0.0 (2023-01-02). + Removed features ---------------- diff --git a/docs/handbook/image-file-formats.rst b/docs/handbook/image-file-formats.rst index ea40ef6d76c..5d4e8349445 100644 --- a/docs/handbook/image-file-formats.rst +++ b/docs/handbook/image-file-formats.rst @@ -13,6 +13,14 @@ contents, not their names, but the :py:meth:`~PIL.Image.Image.save` method looks at the name to determine which format to use, unless the format is given explicitly. +When an image is opened from a file, only that instance of the image is considered to +have the format. Copies of the image will contain data loaded from the file, but not +the file itself, meaning that it can no longer be considered to be in the original +format. So if :py:meth:`~PIL.Image.Image.copy` is called on an image, or another method +internally creates a copy of the image, the ``fp`` (file pointer), along with any +methods and attributes specific to a format. The :py:attr:`~PIL.Image.Image.format` +attribute will be ``None``. + Fully supported formats ----------------------- @@ -31,6 +39,13 @@ The :py:meth:`~PIL.Image.open` method sets the following **compression** Set to ``bmp_rle`` if the file is run-length encoded. +DDS +^^^ + +DDS is a popular container texture format used in video games and natively supported +by DirectX. Uncompressed RGB and RGBA can be read, and (since 8.3.0) written. DXT1, +DXT3 (since 3.4.0) and DXT5 pixel formats can be read, only in ``RGBA`` mode. + DIB ^^^ @@ -200,12 +215,16 @@ attributes before loading the file:: ICNS ^^^^ -Pillow reads and (macOS only) writes macOS ``.icns`` files. By default, the +Pillow reads and writes macOS ``.icns`` files. By default, the largest available icon is read, though you can override this by setting the :py:attr:`~PIL.Image.Image.size` property before calling :py:meth:`~PIL.Image.Image.load`. The :py:meth:`~PIL.Image.open` method sets the following :py:attr:`~PIL.Image.Image.info` property: +.. note:: + + Prior to version 8.3.0, Pillow could only write ICNS files on macOS. + **sizes** A list of supported sizes found in this icon file; these are a 3-tuple, ``(width, height, scale)``, where ``scale`` is 2 for a retina @@ -247,6 +266,12 @@ The :py:meth:`~PIL.Image.Image.save` method can take the following keyword argum .. versionadded:: 8.1.0 +**bitmap_format** + By default, the image data will be saved in PNG format. With a bitmap format of + "bmp", image data will be saved in BMP format instead. + + .. versionadded:: 8.3.0 + IM ^^ @@ -1028,17 +1053,6 @@ is commonly used in fax applications. The DCX decoder can read files containing When the file is opened, only the first image is read. You can use :py:meth:`~PIL.Image.Image.seek` or :py:mod:`~PIL.ImageSequence` to read other images. - -DDS -^^^ - -DDS is a popular container texture format used in video games and natively -supported by DirectX. -Currently, uncompressed RGB data and DXT1, DXT3, and DXT5 pixel formats are -supported, and only in ``RGBA`` mode. - -.. versionadded:: 3.4.0 DXT3 - FLI, FLC ^^^^^^^^ diff --git a/docs/installation.rst b/docs/installation.rst index b1ef585097d..06b1162c1b1 100644 --- a/docs/installation.rst +++ b/docs/installation.rst @@ -487,7 +487,7 @@ These platforms have been reported to work at the versions mentioned. +----------------------------------+------------------------------+--------------------------------+-----------------------+ |**Operating system** |**Tested Python versions** |**Latest tested Pillow version**|**Tested processors** | +----------------------------------+------------------------------+--------------------------------+-----------------------+ -| macOS 11.0 Big Sur | 3.8, 3.9 | 8.2.0 |arm | +| macOS 11.0 Big Sur | 3.7, 3.8, 3.9 | 8.2.0 |arm | | +------------------------------+--------------------------------+-----------------------+ | | 3.6, 3.7, 3.8, 3.9 | 8.2.0 |x86-64 | +----------------------------------+------------------------------+--------------------------------+-----------------------+ diff --git a/docs/releasenotes/8.3.0.rst b/docs/releasenotes/8.3.0.rst index a4b8cb88c86..0929d75b209 100644 --- a/docs/releasenotes/8.3.0.rst +++ b/docs/releasenotes/8.3.0.rst @@ -4,10 +4,13 @@ Deprecations ============ -TODO -^^^^ +JpegImagePlugin.convert_dict_qtables +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -TODO +JPEG ``quantization`` is now automatically converted, but still returned as a +dictionary. The :py:attr:`~PIL.JpegImagePlugin.convert_dict_qtables` method no longer +performs any operations on the data given to it, has been deprecated and will be +removed in Pillow 10.0.0 (2023-01-02). API Changes =========== @@ -31,6 +34,24 @@ ImageMorph incorrect mode errors For ``apply()``, ``match()`` and ``get_on_pixels()``, if the image mode is not L, an :py:exc:`Exception` was thrown. This has now been changed to a :py:exc:`ValueError`. +getxmp() +^^^^^^^^ + +`XMP data `_ can now be +returned for PNG and TIFF images, through ``getxmp()`` for each format. + +The returned dictionary will start from the base of the XML, meaning that the top level +should contain an "xmpmeta" key. JPEG's ``getxmp()`` method has also been updated to +this structure. + +TIFF getexif() +^^^^^^^^^^^^^^ + +TIFF :py:attr:`~PIL.TiffImagePlugin.TiffImageFile.tag_v2` data can now be accessed +through :py:meth:`~PIL.Image.Image.getexif`. This also provides access to the GPS and +EXIF IFDs, through ``im.getexif().get_ifd(0x8825)`` and +``im.getexif().get_ifd(0x8769)`` respectively. + API Additions ============= @@ -50,15 +71,36 @@ To compare it to other ImageOps methods: does not fill the extra space. Instead, the original aspect ratio is maintained. So unlike the other two methods, it is not guaranteed to return an image of ``size``. +ICO saving: bitmap_format argument +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +By default, Pillow saves ICO files in the PNG format. They can now also be saved in BMP +format, through the new ``bitmap_format`` argument:: + + im.save("out.ico", bitmap_format="bmp") + Security ======== -TODO +Parsing XML +^^^^^^^^^^^ + +Pillow previously parsed XMP data using Python's ``xml`` module. However, this module +is not secure. + +- :py:meth:`~PIL.Image.Image.getexif` has used ``xml`` to potentially retrieve + orientation data since Pillow 7.2.0. It has been refactored to use ``re`` instead. +- :py:meth:`~PIL.JpegImagePlugin.JpegImageFile.getxmp` was added in Pillow 8.2.0. It + will now use ``defusedxml`` instead. If the dependency is not present, an empty + dictionary will be returned and a warning raised. Other Changes ============= -TODO -^^^^ +Added DDS BC5 reading and uncompressed saving +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Support has been added to read the BC5 format of DDS images, whether UNORM, SNORM or +TYPELESS. -TODO +Support has also been added to write the uncompressed format of DDS images. diff --git a/docs/resources/css/dark.css b/docs/resources/css/dark.css index cc213d674f8..8866c07eabd 100644 --- a/docs/resources/css/dark.css +++ b/docs/resources/css/dark.css @@ -1275,7 +1275,7 @@ .wy-body-for-nav { background-image: initial; - background-color: rgb(26, 28, 29); + background-color: rgb(24, 26, 27); } .wy-nav-side { @@ -1333,11 +1333,6 @@ color: rgb(152, 143, 129); } - .wy-body-for-nav { - background-image: initial; - background-color: rgb(26, 28, 29); - } - @media screen and (min-width: 1100px) { .wy-nav-content-wrap { background-image: initial; diff --git a/requirements.txt b/requirements.txt index fd2ede5fd65..38011fd39ef 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,6 +2,7 @@ black check-manifest coverage +defusedxml markdown2 olefile packaging diff --git a/setup.py b/setup.py index c3b9eee4160..e75d8eea404 100755 --- a/setup.py +++ b/setup.py @@ -992,6 +992,7 @@ def debug_build(): "index.html", "Changelog": "https://github.com/python-pillow/Pillow/blob/master/" "CHANGES.rst", + "Twitter": "https://twitter.com/PythonPillow", }, classifiers=[ "Development Status :: 6 - Mature", diff --git a/src/PIL/DdsImagePlugin.py b/src/PIL/DdsImagePlugin.py index 1a7fe003503..260924fca0d 100644 --- a/src/PIL/DdsImagePlugin.py +++ b/src/PIL/DdsImagePlugin.py @@ -14,6 +14,7 @@ from io import BytesIO from . import Image, ImageFile +from ._binary import o32le as o32 # Magic ("DDS ") DDS_MAGIC = 0x20534444 @@ -97,6 +98,9 @@ DXGI_FORMAT_R8G8B8A8_TYPELESS = 27 DXGI_FORMAT_R8G8B8A8_UNORM = 28 DXGI_FORMAT_R8G8B8A8_UNORM_SRGB = 29 +DXGI_FORMAT_BC5_TYPELESS = 82 +DXGI_FORMAT_BC5_UNORM = 83 +DXGI_FORMAT_BC5_SNORM = 84 DXGI_FORMAT_BC7_TYPELESS = 97 DXGI_FORMAT_BC7_UNORM = 98 DXGI_FORMAT_BC7_UNORM_SRGB = 99 @@ -127,8 +131,8 @@ def _open(self): fourcc = header.read(4) (bitcount,) = struct.unpack("i", sum(entry["size"] for entry in entries))) - if retcode: - raise subprocess.CalledProcessError(retcode, convert_cmd) + # TOC + fp.write(b"TOC ") + fp.write(struct.pack(">i", HEADERSIZE + len(entries) * HEADERSIZE)) + for entry in entries: + fp.write(entry["type"]) + fp.write(struct.pack(">i", HEADERSIZE + entry["size"])) - if fp_only: - with open(filename, "rb") as f: - fp.write(f.read()) + # Data + for entry in entries: + fp.write(entry["type"]) + fp.write(struct.pack(">i", HEADERSIZE + entry["size"])) + fp.write(entry["stream"]) + + if hasattr(fp, "flush"): + fp.flush() def _accept(prefix): - return prefix[:4] == b"icns" + return prefix[:4] == MAGIC Image.register_open(IcnsImageFile.format, IcnsImageFile, _accept) Image.register_extension(IcnsImageFile.format, ".icns") -if sys.platform == "darwin": - Image.register_save(IcnsImageFile.format, _save) - - Image.register_mime(IcnsImageFile.format, "image/icns") - +Image.register_save(IcnsImageFile.format, _save) +Image.register_mime(IcnsImageFile.format, "image/icns") if __name__ == "__main__": - if len(sys.argv) < 2: print("Syntax: python3 IcnsImagePlugin.py [file]") sys.exit() diff --git a/src/PIL/IcoImagePlugin.py b/src/PIL/IcoImagePlugin.py index dbc108d7b43..ffb1e873d23 100644 --- a/src/PIL/IcoImagePlugin.py +++ b/src/PIL/IcoImagePlugin.py @@ -30,6 +30,7 @@ from . import BmpImagePlugin, Image, ImageFile, PngImagePlugin from ._binary import i16le as i16 from ._binary import i32le as i32 +from ._binary import o32le as o32 # # -------------------------------------------------------------------- @@ -53,6 +54,7 @@ def _save(im, fp, filename): sizes = list(sizes) fp.write(struct.pack(" - :param radius: Blur radius. + :param radius: Standard deviation of the Gaussian kernel. """ name = "GaussianBlur" diff --git a/src/PIL/ImageOps.py b/src/PIL/ImageOps.py index f9c35b2c69f..711a519fcdb 100644 --- a/src/PIL/ImageOps.py +++ b/src/PIL/ImageOps.py @@ -20,7 +20,7 @@ import functools import operator -from . import Image +from . import Image, ImageDraw # # helpers @@ -392,8 +392,17 @@ def expand(image, border=0, fill=0): left, top, right, bottom = _border(border) width = left + image.size[0] + right height = top + image.size[1] + bottom - out = Image.new(image.mode, (width, height), _color(fill, image.mode)) - out.paste(image, (left, top)) + color = _color(fill, image.mode) + if image.mode == "P" and image.palette: + out = Image.new(image.mode, (width, height)) + out.putpalette(image.palette) + out.paste(image, (left, top)) + + draw = ImageDraw.Draw(out) + draw.rectangle((0, 0, width - 1, height - 1), outline=color, width=border) + else: + out = Image.new(image.mode, (width, height), color) + out.paste(image, (left, top)) return out @@ -578,7 +587,8 @@ def exif_transpose(image): }.get(orientation) if method is not None: transposed_image = image.transpose(method) - del exif[0x0112] - transposed_image.info["exif"] = exif.tobytes() + transposed_exif = transposed_image.getexif() + del transposed_exif[0x0112] + transposed_image.info["exif"] = transposed_exif.tobytes() return transposed_image return image.copy() diff --git a/src/PIL/ImagePalette.py b/src/PIL/ImagePalette.py index d0604112fd9..b0c722b29af 100644 --- a/src/PIL/ImagePalette.py +++ b/src/PIL/ImagePalette.py @@ -39,14 +39,27 @@ class ImagePalette: def __init__(self, mode="RGB", palette=None, size=0): self.mode = mode self.rawmode = None # if set, palette contains raw data - self.palette = palette or bytearray(range(256)) * len(self.mode) - self.colors = {} + self.palette = palette or bytearray() self.dirty = None - if (size == 0 and len(self.mode) * 256 != len(self.palette)) or ( - size != 0 and size != len(self.palette) - ): + if size != 0 and size != len(self.palette): raise ValueError("wrong palette size") + @property + def palette(self): + return self._palette + + @palette.setter + def palette(self, palette): + self._palette = palette + + mode_len = len(self.mode) + self.colors = {} + for i in range(0, len(self.palette), mode_len): + color = tuple(self.palette[i : i + mode_len]) + if color in self.colors: + continue + self.colors[color] = i // mode_len + def copy(self): new = ImagePalette() @@ -54,7 +67,6 @@ def copy(self): new.rawmode = self.rawmode if self.palette is not None: new.palette = self.palette[:] - new.colors = self.colors.copy() new.dirty = self.dirty return new @@ -68,7 +80,7 @@ def getdata(self): """ if self.rawmode: return self.rawmode, self.palette - return self.mode + ";L", self.tobytes() + return self.mode, self.tobytes() def tobytes(self): """Convert palette to bytes. @@ -80,14 +92,12 @@ def tobytes(self): if isinstance(self.palette, bytes): return self.palette arr = array.array("B", self.palette) - if hasattr(arr, "tobytes"): - return arr.tobytes() - return arr.tostring() + return arr.tobytes() # Declare tostring as an alias for tobytes tostring = tobytes - def getcolor(self, color): + def getcolor(self, color, image=None): """Given an rgb tuple, allocate palette entry. .. warning:: This method is experimental. @@ -95,19 +105,45 @@ def getcolor(self, color): if self.rawmode: raise ValueError("palette contains raw palette data") if isinstance(color, tuple): + if self.mode == "RGB": + if len(color) == 4 and color[3] == 255: + color = color[:3] + elif self.mode == "RGBA": + if len(color) == 3: + color += (255,) try: return self.colors[color] except KeyError as e: # allocate new color slot - if isinstance(self.palette, bytes): - self.palette = bytearray(self.palette) - index = len(self.colors) + if not isinstance(self.palette, bytearray): + self._palette = bytearray(self.palette) + index = len(self.palette) // 3 + special_colors = () + if image: + special_colors = ( + image.info.get("background"), + image.info.get("transparency"), + ) + while index in special_colors: + index += 1 if index >= 256: - raise ValueError("cannot allocate more than 256 colors") from e + if image: + # Search for an unused index + for i, count in reversed(list(enumerate(image.histogram()))): + if count == 0 and i not in special_colors: + index = i + break + if index >= 256: + raise ValueError("cannot allocate more than 256 colors") from e self.colors[color] = index - self.palette[index] = color[0] - self.palette[index + 256] = color[1] - self.palette[index + 512] = color[2] + if index * 3 < len(self.palette): + self._palette = ( + self.palette[: index * 3] + + bytes(color) + + self.palette[index * 3 + 3 :] + ) + else: + self._palette += bytes(color) self.dirty = 1 return index else: diff --git a/src/PIL/JpegImagePlugin.py b/src/PIL/JpegImagePlugin.py index 6d4bc70c5f1..b18e8126fb4 100644 --- a/src/PIL/JpegImagePlugin.py +++ b/src/PIL/JpegImagePlugin.py @@ -40,7 +40,6 @@ import sys import tempfile import warnings -import xml.etree.ElementTree from . import Image, ImageFile, TiffImagePlugin from ._binary import i16be as i16 @@ -255,7 +254,7 @@ def DQT(self, marker): data = array.array("B" if precision == 1 else "H", s[1:qt_length]) if sys.byteorder == "little" and precision > 1: data.byteswap() # the values are always big-endian - self.quantization[v & 15] = data + self.quantization[v & 15] = [data[i] for i in zigzag_index] s = s[qt_length:] @@ -362,7 +361,6 @@ def _open(self): self.app = {} # compatibility self.applist = [] self.icclist = [] - self._xmp = None while True: @@ -482,23 +480,16 @@ def _getmp(self): def getxmp(self): """ Returns a dictionary containing the XMP tags. + Requires defusedxml to be installed. :returns: XMP tags in a dictionary. """ - if self._xmp is None: - self._xmp = {} - for segment, content in self.applist: if segment == "APP1": marker, xmp_tags = content.rsplit(b"\x00", 1) if marker == b"http://ns.adobe.com/xap/1.0/": - root = xml.etree.ElementTree.fromstring(xmp_tags) - for element in root.findall(".//"): - self._xmp[element.tag.split("}")[1]] = { - child.split("}")[1]: value - for child, value in element.attrib.items() - } - return self._xmp + return self._getxmp(xmp_tags) + return {} def _getexif(self): @@ -610,9 +601,11 @@ def _getmp(self): def convert_dict_qtables(qtables): - qtables = [qtables[key] for key in range(len(qtables)) if key in qtables] - for idx, table in enumerate(qtables): - qtables[idx] = [table[i] for i in zigzag_index] + warnings.warn( + "convert_dict_qtables is deprecated and will be removed in Pillow 10" + "(2023-01-02). Conversion is no longer needed.", + DeprecationWarning, + ) return qtables @@ -693,7 +686,9 @@ def validate_qtables(qtables): qtables = [lines[s : s + 64] for s in range(0, len(lines), 64)] if isinstance(qtables, (tuple, list, dict)): if isinstance(qtables, dict): - qtables = convert_dict_qtables(qtables) + qtables = [ + qtables[key] for key in range(len(qtables)) if key in qtables + ] elif isinstance(qtables, tuple): qtables = list(qtables) if not (0 < len(qtables) < 5): diff --git a/src/PIL/JpegPresets.py b/src/PIL/JpegPresets.py index 79d10ebb2c6..e5a5d178a16 100644 --- a/src/PIL/JpegPresets.py +++ b/src/PIL/JpegPresets.py @@ -52,19 +52,11 @@ im.quantization -This will return a dict with a number of arrays. You can pass this dict +This will return a dict with a number of lists. You can pass this dict directly as the qtables argument when saving a JPEG. -The tables format between im.quantization and quantization in presets differ in -3 ways: - -1. The base container of the preset is a list with sublists instead of dict. - dict[0] -> list[0], dict[1] -> list[1], ... -2. Each table in a preset is a list instead of an array. -3. The zigzag order is remove in the preset (needed by libjpeg >= 6a). - -You can convert the dict format to the preset format with the -:func:`.JpegImagePlugin.convert_dict_qtables()` function. +The quantization table format in presets is a list with sublists. These formats +are interchangeable. Libjpeg ref.: https://web.archive.org/web/20120328125543/http://www.jpegcameras.com/libjpeg/libjpeg-3.html diff --git a/src/PIL/PngImagePlugin.py b/src/PIL/PngImagePlugin.py index 9c256ca8f0a..bd886e2184c 100644 --- a/src/PIL/PngImagePlugin.py +++ b/src/PIL/PngImagePlugin.py @@ -920,6 +920,8 @@ def load_read(self, read_bytes): def load_end(self): """internal: finished reading image data""" + if self.__idat != 0: + self.fp.read(self.__idat) while True: self.fp.read(4) # CRC @@ -976,6 +978,18 @@ def getexif(self): return super().getexif() + def getxmp(self): + """ + Returns a dictionary containing the XMP tags. + Requires defusedxml to be installed. + :returns: XMP tags in a dictionary. + """ + return ( + self._getxmp(self.info["XML:com.adobe.xmp"]) + if "XML:com.adobe.xmp" in self.info + else {} + ) + def _close__fp(self): try: if self.__fp != self.fp: diff --git a/src/PIL/PyAccess.py b/src/PIL/PyAccess.py index 494f5f9f478..5ceaa238a8f 100644 --- a/src/PIL/PyAccess.py +++ b/src/PIL/PyAccess.py @@ -54,6 +54,7 @@ def __init__(self, img, readonly=False): self.image32 = ffi.cast("int **", vals["image32"]) self.image = ffi.cast("unsigned char **", vals["image"]) self.xsize, self.ysize = img.im.size + self._img = img # Keep pointer to im object to prevent dereferencing. self._im = img.im @@ -93,7 +94,7 @@ def __setitem__(self, xy, color): and len(color) in [3, 4] ): # RGB or RGBA value for a P image - color = self._palette.getcolor(color) + color = self._palette.getcolor(color, self._img) return self.set_pixel(x, y, color) diff --git a/src/PIL/TiffImagePlugin.py b/src/PIL/TiffImagePlugin.py index d74af284fcd..a5e2bb53d17 100644 --- a/src/PIL/TiffImagePlugin.py +++ b/src/PIL/TiffImagePlugin.py @@ -354,9 +354,12 @@ def __hash__(self): return self._val.__hash__() def __eq__(self, other): + val = self._val if isinstance(other, IFDRational): other = other._val - return self._val == other + if isinstance(other, float): + val = float(val) + return val == other def __getstate__(self): return [self._val, self._numerator, self._denominator] @@ -465,7 +468,7 @@ class ImageFileDirectory_v2(MutableMapping): """ - def __init__(self, ifh=b"II\052\0\0\0\0\0", prefix=None): + def __init__(self, ifh=b"II\052\0\0\0\0\0", prefix=None, group=None): """Initialize an ImageFileDirectory. To construct an ImageFileDirectory from a real file, pass the 8-byte @@ -485,6 +488,7 @@ def __init__(self, ifh=b"II\052\0\0\0\0\0", prefix=None): self._endian = "<" else: raise SyntaxError("not a TIFF IFD") + self.group = group self.tagtype = {} """ Dictionary of tag types """ self.reset() @@ -516,7 +520,10 @@ def named(self): Returns the complete tag dictionary, with named tags where possible. """ - return {TiffTags.lookup(code).name: value for code, value in self.items()} + return { + TiffTags.lookup(code, self.group).name: value + for code, value in self.items() + } def __len__(self): return len(set(self._tagdata) | set(self._tags_v2)) @@ -541,7 +548,7 @@ def __setitem__(self, tag, value): def _setitem(self, tag, value, legacy_api): basetypes = (Number, bytes, str) - info = TiffTags.lookup(tag) + info = TiffTags.lookup(tag, self.group) values = [value] if isinstance(value, basetypes) else value if tag not in self.tagtype: @@ -758,7 +765,7 @@ def load(self, fp): for i in range(self._unpack("H", self._ensure_read(fp, 2))[0]): tag, typ, count, data = self._unpack("HHL4s", self._ensure_read(fp, 12)) - tagname = TiffTags.lookup(tag).name + tagname = TiffTags.lookup(tag, self.group).name typname = TYPES.get(typ, "unknown") msg = f"tag: {tagname} ({tag}) - type: {typname} ({typ})" @@ -825,15 +832,16 @@ def tobytes(self, offset=0): ifh = b"II\x2A\x00\x08\x00\x00\x00" else: ifh = b"MM\x00\x2A\x00\x00\x00\x08" - ifd = ImageFileDirectory_v2(ifh) - for ifd_tag, ifd_value in self._tags_v2[tag].items(): + ifd = ImageFileDirectory_v2(ifh, group=tag) + values = self._tags_v2[tag] + for ifd_tag, ifd_value in values.items(): ifd[ifd_tag] = ifd_value data = ifd.tobytes(offset) else: values = value if isinstance(value, tuple) else (value,) data = self._write_dispatch[typ](self, *values) - tagname = TiffTags.lookup(tag).name + tagname = TiffTags.lookup(tag, self.group).name typname = "ifd" if is_ifd else TYPES.get(typ, "unknown") msg = f"save: {tagname} ({tag}) - type: {typname} ({typ})" msg += " - value: " + ( @@ -1079,7 +1087,12 @@ def _seek(self, frame): self._frame_pos.append(self.__next) logger.debug("Loading tags, location: %s" % self.fp.tell()) self.tag_v2.load(self.fp) - self.__next = self.tag_v2.next + if self.tag_v2.next in self._frame_pos: + # This IFD has already been processed + # Declare this to be the end of the image + self.__next = 0 + else: + self.__next = self.tag_v2.next if self.__next == 0: self._n_frames = frame + 1 if len(self._frame_pos) == 1: @@ -1096,6 +1109,14 @@ def tell(self): """Return the current frame number""" return self.__frame + def getxmp(self): + """ + Returns a dictionary containing the XMP tags. + Requires defusedxml to be installed. + :returns: XMP tags in a dictionary. + """ + return self._getxmp(self.tag_v2[700]) if 700 in self.tag_v2 else {} + def load(self): if self.tile and self.use_load_libtiff: return self._load_libtiff() @@ -1553,12 +1574,22 @@ def _save(im, fp, filename): ifd[COLORMAP] = tuple(v * 256 for v in lut) # data orientation stride = len(bits) * ((im.size[0] * bits[0] + 7) // 8) - ifd[ROWSPERSTRIP] = im.size[1] - strip_byte_counts = stride * im.size[1] + # aim for 64 KB strips when using libtiff writer + if libtiff: + rows_per_strip = min((2 ** 16 + stride - 1) // stride, im.size[1]) + else: + rows_per_strip = im.size[1] + strip_byte_counts = stride * rows_per_strip + strips_per_image = (im.size[1] + rows_per_strip - 1) // rows_per_strip + ifd[ROWSPERSTRIP] = rows_per_strip if strip_byte_counts >= 2 ** 16: ifd.tagtype[STRIPBYTECOUNTS] = TiffTags.LONG - ifd[STRIPBYTECOUNTS] = strip_byte_counts - ifd[STRIPOFFSETS] = 0 # this is adjusted by IFD writer + ifd[STRIPBYTECOUNTS] = (strip_byte_counts,) * (strips_per_image - 1) + ( + stride * im.size[1] - strip_byte_counts * (strips_per_image - 1), + ) + ifd[STRIPOFFSETS] = tuple( + range(0, strip_byte_counts * strips_per_image, strip_byte_counts) + ) # this is adjusted by IFD writer # no compression by default: ifd[COMPRESSION] = COMPRESSION_INFO_REV.get(compression, 1) diff --git a/src/PIL/TiffTags.py b/src/PIL/TiffTags.py index 774e6b346de..88856aa92d5 100644 --- a/src/PIL/TiffTags.py +++ b/src/PIL/TiffTags.py @@ -33,7 +33,7 @@ def cvt_enum(self, value): return self.enum.get(value, value) if self.enum else value -def lookup(tag): +def lookup(tag, group=None): """ :param tag: Integer tag number :returns: Taginfo namedtuple, From the TAGS_V2 info if possible, @@ -42,7 +42,11 @@ def lookup(tag): """ - return TAGS_V2.get(tag, TagInfo(tag, TAGS.get(tag, "unknown"))) + if group is not None: + info = TAGS_V2_GROUPS[group].get(tag) if group in TAGS_V2_GROUPS else None + else: + info = TAGS_V2.get(tag) + return info or TagInfo(tag, TAGS.get(tag, "unknown")) ## @@ -213,6 +217,19 @@ def lookup(tag): 50838: ("ImageJMetaDataByteCounts", LONG, 0), # Can be more than one 50839: ("ImageJMetaData", UNDEFINED, 1), # see Issue #2006 } +TAGS_V2_GROUPS = { + # ExifIFD + 34665: { + 36864: ("ExifVersion", UNDEFINED, 1), + 40960: ("FlashPixVersion", UNDEFINED, 1), + 40965: ("InteroperabilityIFD", LONG, 1), + 41730: ("CFAPattern", UNDEFINED, 1), + }, + # GPSInfoIFD + 34853: {}, + # InteroperabilityIFD + 40965: {1: ("InteropIndex", ASCII, 1), 2: ("InteropVersion", UNDEFINED, 1)}, +} # Legacy Tags structure # these tags aren't included above, but were in the previous versions @@ -371,6 +388,10 @@ def _populate(): TAGS_V2[k] = TagInfo(k, *v) + for group, tags in TAGS_V2_GROUPS.items(): + for k, v in tags.items(): + tags[k] = TagInfo(k, *v) + _populate() ## diff --git a/src/PIL/WebPImagePlugin.py b/src/PIL/WebPImagePlugin.py index 50b82feb048..b63a07ca8e3 100644 --- a/src/PIL/WebPImagePlugin.py +++ b/src/PIL/WebPImagePlugin.py @@ -320,7 +320,7 @@ def _save(im, fp, filename): alpha = ( "A" in im.mode or "a" in im.mode - or (im.mode == "P" and "A" in im.im.getpalettemode()) + or (im.mode == "P" and "transparency" in im.info) ) im = im.convert("RGBA" if alpha else "RGB") diff --git a/src/_imaging.c b/src/_imaging.c index 28acbf35a1a..e2193fec3c5 100644 --- a/src/_imaging.c +++ b/src/_imaging.c @@ -1663,8 +1663,7 @@ _putpalette(ImagingObject *self, PyObject *args) { unpack(self->image->palette->palette, palette, palettesize * 8 / bits); - Py_INCREF(Py_None); - return Py_None; + return PyLong_FromLong(palettesize * 8 / bits); } static PyObject * diff --git a/src/decode.c b/src/decode.c index 7bcbfdeef91..91bfabf34dd 100644 --- a/src/decode.c +++ b/src/decode.c @@ -34,9 +34,10 @@ #include "libImaging/Imaging.h" +#include "libImaging/Bit.h" +#include "libImaging/Bcn.h" #include "libImaging/Gif.h" #include "libImaging/Raw.h" -#include "libImaging/Bit.h" #include "libImaging/Sgi.h" /* -------------------------------------------------------------------- */ @@ -359,8 +360,8 @@ PyImaging_BcnDecoderNew(PyObject *self, PyObject *args) { char *mode; char *actual; int n = 0; - int ystep = 1; - if (!PyArg_ParseTuple(args, "s|ii", &mode, &n, &ystep)) { + char *pixel_format = ""; + if (!PyArg_ParseTuple(args, "si|s", &mode, &n, &pixel_format)) { return NULL; } @@ -368,13 +369,15 @@ PyImaging_BcnDecoderNew(PyObject *self, PyObject *args) { case 1: /* BC1: 565 color, 1-bit alpha */ case 2: /* BC2: 565 color, 4-bit alpha */ case 3: /* BC3: 565 color, 2-endpoint 8-bit interpolated alpha */ - case 5: /* BC5: 2-channel 8-bit via 2 BC3 alpha blocks */ case 7: /* BC7: 4-channel 8-bit via everything */ actual = "RGBA"; break; case 4: /* BC4: 1-channel 8-bit via 1 BC3 alpha block */ actual = "L"; break; + case 5: /* BC5: 2-channel 8-bit via 2 BC3 alpha blocks */ + actual = "RGB"; + break; case 6: /* BC6: 3-channel 16-bit float */ /* TODO: support 4-channel floating point images */ actual = "RGBAF"; @@ -389,14 +392,14 @@ PyImaging_BcnDecoderNew(PyObject *self, PyObject *args) { return NULL; } - decoder = PyImaging_DecoderNew(0); + decoder = PyImaging_DecoderNew(sizeof(char *)); if (decoder == NULL) { return NULL; } decoder->decode = ImagingBcnDecode; decoder->state.state = n; - decoder->state.ystep = ystep; + ((BCNSTATE *)decoder->state.context)->pixel_format = pixel_format; return (PyObject *)decoder; } diff --git a/src/libImaging/Bcn.h b/src/libImaging/Bcn.h new file mode 100644 index 00000000000..1a6fbee4576 --- /dev/null +++ b/src/libImaging/Bcn.h @@ -0,0 +1,3 @@ +typedef struct { + char *pixel_format; +} BCNSTATE; diff --git a/src/libImaging/BcnDecode.c b/src/libImaging/BcnDecode.c index 5e8b456b83e..22b36eb7acc 100644 --- a/src/libImaging/BcnDecode.c +++ b/src/libImaging/BcnDecode.c @@ -13,6 +13,8 @@ #include "Imaging.h" +#include "Bcn.h" + typedef struct { UINT8 r, g, b, a; } rgba; @@ -35,6 +37,11 @@ typedef struct { UINT8 lut[6]; } bc3_alpha; +typedef struct { + INT8 a0, a1; + UINT8 lut[6]; +} bc5s_alpha; + #define LOAD16(p) (p)[0] | ((p)[1] << 8) #define LOAD32(p) (p)[0] | ((p)[1] << 8) | ((p)[2] << 16) | ((p)[3] << 24) @@ -46,11 +53,6 @@ bc1_color_load(bc1_color *dst, const UINT8 *src) { dst->lut = LOAD32(src + 4); } -static void -bc3_alpha_load(bc3_alpha *dst, const UINT8 *src) { - memcpy(dst, src, sizeof(bc3_alpha)); -} - static rgba decode_565(UINT16 x) { rgba c; @@ -113,15 +115,26 @@ decode_bc1_color(rgba *dst, const UINT8 *src, int separate_alpha) { } static void -decode_bc3_alpha(char *dst, const UINT8 *src, int stride, int o) { - bc3_alpha b; +decode_bc3_alpha(char *dst, const UINT8 *src, int stride, int o, int sign) { UINT16 a0, a1; UINT8 a[8]; - int n, lut, aw; - bc3_alpha_load(&b, src); + int n, lut1, lut2, aw; + if (sign == 1) { + bc5s_alpha b; + memcpy(&b, src, sizeof(bc5s_alpha)); + a0 = (b.a0 + 255) / 2; + a1 = (b.a1 + 255) / 2; + lut1 = b.lut[0] | (b.lut[1] << 8) | (b.lut[2] << 16); + lut2 = b.lut[3] | (b.lut[4] << 8) | (b.lut[5] << 16); + } else { + bc3_alpha b; + memcpy(&b, src, sizeof(bc3_alpha)); + a0 = b.a0; + a1 = b.a1; + lut1 = b.lut[0] | (b.lut[1] << 8) | (b.lut[2] << 16); + lut2 = b.lut[3] | (b.lut[4] << 8) | (b.lut[5] << 16); + } - a0 = b.a0; - a1 = b.a1; a[0] = (UINT8)a0; a[1] = (UINT8)a1; if (a0 > a1) { @@ -139,14 +152,12 @@ decode_bc3_alpha(char *dst, const UINT8 *src, int stride, int o) { a[6] = 0; a[7] = 0xff; } - lut = b.lut[0] | (b.lut[1] << 8) | (b.lut[2] << 16); for (n = 0; n < 8; n++) { - aw = 7 & (lut >> (3 * n)); + aw = 7 & (lut1 >> (3 * n)); dst[stride * n + o] = a[aw]; } - lut = b.lut[3] | (b.lut[4] << 8) | (b.lut[5] << 16); for (n = 0; n < 8; n++) { - aw = 7 & (lut >> (3 * n)); + aw = 7 & (lut2 >> (3 * n)); dst[stride * (8 + n) + o] = a[aw]; } } @@ -172,18 +183,18 @@ decode_bc2_block(rgba *col, const UINT8 *src) { static void decode_bc3_block(rgba *col, const UINT8 *src) { decode_bc1_color(col, src + 8, 1); - decode_bc3_alpha((char *)col, src, sizeof(col[0]), 3); + decode_bc3_alpha((char *)col, src, sizeof(col[0]), 3, 0); } static void decode_bc4_block(lum *col, const UINT8 *src) { - decode_bc3_alpha((char *)col, src, sizeof(col[0]), 0); + decode_bc3_alpha((char *)col, src, sizeof(col[0]), 0, 0); } static void -decode_bc5_block(rgba *col, const UINT8 *src) { - decode_bc3_alpha((char *)col, src, sizeof(col[0]), 0); - decode_bc3_alpha((char *)col, src + 8, sizeof(col[0]), 1); +decode_bc5_block(rgba *col, const UINT8 *src, int sign) { + decode_bc3_alpha((char *)col, src, sizeof(col[0]), 0, sign); + decode_bc3_alpha((char *)col, src + 8, sizeof(col[0]), 1, sign); } /* BC6 and BC7 are described here: @@ -813,7 +824,7 @@ put_block(Imaging im, ImagingCodecState state, const char *col, int sz, int C) { static int decode_bcn( - Imaging im, ImagingCodecState state, const UINT8 *src, int bytes, int N, int C) { + Imaging im, ImagingCodecState state, const UINT8 *src, int bytes, int N, int C, char *pixel_format) { int ymax = state->ysize + state->yoff; const UINT8 *ptr = src; switch (N) { @@ -836,7 +847,19 @@ decode_bcn( DECODE_LOOP(2, 16, rgba); DECODE_LOOP(3, 16, rgba); DECODE_LOOP(4, 8, lum); - DECODE_LOOP(5, 16, rgba); + case 5: + while (bytes >= 16) { + rgba col[16]; + memset(col, 0, 16 * sizeof(col[0])); + decode_bc5_block(col, ptr, strcmp(pixel_format, "BC5S") == 0 ? 1 : 0); + put_block(im, state, (const char *)col, sizeof(col[0]), C); + ptr += 16; + bytes -= 16; + if (state->y >= ymax) { + return -1; + } + } + break; case 6: while (bytes >= 16) { rgb32f col[16]; @@ -849,7 +872,7 @@ decode_bcn( } } break; - DECODE_LOOP(7, 16, rgba); + DECODE_LOOP(7, 16, rgba); #undef DECODE_LOOP } return (int)(ptr - src); @@ -860,9 +883,7 @@ ImagingBcnDecode(Imaging im, ImagingCodecState state, UINT8 *buf, Py_ssize_t byt int N = state->state & 0xf; int width = state->xsize; int height = state->ysize; - if ((width & 3) | (height & 3)) { - return decode_bcn(im, state, buf, bytes, N, 1); - } else { - return decode_bcn(im, state, buf, bytes, N, 0); - } + int C = (width & 3) | (height & 3) ? 1 : 0; + char *pixel_format = ((BCNSTATE *)state->context)->pixel_format; + return decode_bcn(im, state, buf, bytes, N, C, pixel_format); } diff --git a/src/libImaging/Draw.c b/src/libImaging/Draw.c index 004ff0fe5d9..161895dc6b3 100644 --- a/src/libImaging/Draw.c +++ b/src/libImaging/Draw.c @@ -734,7 +734,7 @@ ImagingDrawRectangle( int ImagingDrawPolygon(Imaging im, int count, int *xy, const void *ink_, int fill, int op) { - int i, n; + int i, n, x0, y0, x1, y1; DRAW *draw; INT32 ink; @@ -753,10 +753,28 @@ ImagingDrawPolygon(Imaging im, int count, int *xy, const void *ink_, int fill, i return -1; } for (i = n = 0; i < count - 1; i++) { - add_edge(&e[n++], xy[i + i], xy[i + i + 1], xy[i + i + 2], xy[i + i + 3]); + x0 = xy[i * 2]; + y0 = xy[i * 2 + 1]; + x1 = xy[i * 2 + 2]; + y1 = xy[i * 2 + 3]; + if (y0 == y1 && i != 0 && y0 == xy[i * 2 - 1]) { + // This is a horizontal line, + // that immediately follows another horizontal line + Edge *last_e = &e[n-1]; + if (x1 > x0 && x0 > xy[i * 2 - 2]) { + // They are both increasing in x + last_e->xmax = x1; + continue; + } else if (x1 < x0 && x0 < xy[i * 2 - 2]) { + // They are both decreasing in x + last_e->xmin = x1; + continue; + } + } + add_edge(&e[n++], x0, y0, x1, y1); } - if (xy[i + i] != xy[0] || xy[i + i + 1] != xy[1]) { - add_edge(&e[n++], xy[i + i], xy[i + i + 1], xy[0], xy[1]); + if (xy[i * 2] != xy[0] || xy[i * 2 + 1] != xy[1]) { + add_edge(&e[n++], xy[i * 2], xy[i * 2 + 1], xy[0], xy[1]); } draw->polygon(im, n, e, ink, 0); free(e); @@ -764,9 +782,9 @@ ImagingDrawPolygon(Imaging im, int count, int *xy, const void *ink_, int fill, i } else { /* Outline */ for (i = 0; i < count - 1; i++) { - draw->line(im, xy[i + i], xy[i + i + 1], xy[i + i + 2], xy[i + i + 3], ink); + draw->line(im, xy[i * 2], xy[i * 2 + 1], xy[i * 2 + 2], xy[i * 2 + 3], ink); } - draw->line(im, xy[i + i], xy[i + i + 1], xy[0], xy[1], ink); + draw->line(im, xy[i * 2], xy[i * 2 + 1], xy[0], xy[1], ink); } return 0; diff --git a/src/libImaging/Imaging.h b/src/libImaging/Imaging.h index ae323f39001..6d18dee4ef5 100644 --- a/src/libImaging/Imaging.h +++ b/src/libImaging/Imaging.h @@ -370,7 +370,7 @@ ImagingTransform( int y0, int x1, int y1, - double *a, + double a[8], int filter, int fill); extern Imaging diff --git a/src/libImaging/Jpeg2KEncode.c b/src/libImaging/Jpeg2KEncode.c index 2e6b5daf0fe..70185315999 100644 --- a/src/libImaging/Jpeg2KEncode.c +++ b/src/libImaging/Jpeg2KEncode.c @@ -458,6 +458,12 @@ j2k_encode_entry(Imaging im, ImagingCodecState state) { break; } + if (!context->num_resolutions) { + while (tile_width < (1 << (params.numresolution - 1U)) || tile_height < (1 << (params.numresolution - 1U))) { + params.numresolution -= 1; + } + } + if (context->cinema_mode != OPJ_OFF) { j2k_set_cinema_params(im, components, ¶ms); } diff --git a/src/libImaging/Quant.c b/src/libImaging/Quant.c index b43c074d44c..1c6b9d6a2d7 100644 --- a/src/libImaging/Quant.c +++ b/src/libImaging/Quant.c @@ -753,11 +753,19 @@ annotate_hash_table(BoxNode *n, HashTable *h, uint32_t *box) { return 1; } +typedef struct { + uint32_t *distance; + uint32_t index; +} DistanceWithIndex; + static int -_sort_ulong_ptr_keys(const void *a, const void *b) { - uint32_t A = **(uint32_t **)a; - uint32_t B = **(uint32_t **)b; - return (A == B) ? 0 : ((A < B) ? -1 : +1); +_distance_index_cmp(const void *a, const void *b) { + DistanceWithIndex *A = (DistanceWithIndex *)a; + DistanceWithIndex *B = (DistanceWithIndex *)b; + if (*A->distance == *B->distance) { + return A->index < B->index ? -1 : +1; + } + return *A->distance < *B->distance ? -1 : +1; } static int @@ -789,10 +797,11 @@ resort_distance_tables( return 1; } -static void +static int build_distance_tables( uint32_t *avgDist, uint32_t **avgDistSortKey, Pixel *p, uint32_t nEntries) { uint32_t i, j; + DistanceWithIndex *dwi; for (i = 0; i < nEntries; i++) { avgDist[i * nEntries + i] = 0; @@ -804,13 +813,29 @@ build_distance_tables( avgDistSortKey[i * nEntries + j] = &(avgDist[i * nEntries + j]); } } + + dwi = calloc(nEntries, sizeof(DistanceWithIndex)); + if (!dwi) { + return 0; + } for (i = 0; i < nEntries; i++) { + for (j = 0; j < nEntries; j++) { + dwi[j] = (DistanceWithIndex){ + &(avgDist[i * nEntries + j]), + j + }; + } qsort( - avgDistSortKey + i * nEntries, + dwi, nEntries, - sizeof(uint32_t *), - _sort_ulong_ptr_keys); + sizeof(DistanceWithIndex), + _distance_index_cmp); + for (j = 0; j < nEntries; j++) { + avgDistSortKey[i * nEntries + j] = dwi[j].distance; + } } + free(dwi); + return 1; } static int @@ -1175,8 +1200,10 @@ k_means( if (!built) { compute_palette_from_quantized_pixels( pixelData, nPixels, paletteData, nPaletteEntries, avg, count, qp); - build_distance_tables( - avgDist, avgDistSortKey, paletteData, nPaletteEntries); + if (!build_distance_tables( + avgDist, avgDistSortKey, paletteData, nPaletteEntries)) { + goto error_3; + } built = 1; } else { recompute_palette_from_averages(paletteData, nPaletteEntries, avg, count); @@ -1372,7 +1399,9 @@ quantize( goto error_6; } - build_distance_tables(avgDist, avgDistSortKey, p, nPaletteEntries); + if (!build_distance_tables(avgDist, avgDistSortKey, p, nPaletteEntries)) { + goto error_7; + } if (!map_image_pixels_from_median_box( pixelData, nPixels, p, nPaletteEntries, h, avgDist, avgDistSortKey, qp)) { @@ -1577,7 +1606,9 @@ quantize2( goto error_3; } - build_distance_tables(avgDist, avgDistSortKey, p, nQuantPixels); + if (!build_distance_tables(avgDist, avgDistSortKey, p, nQuantPixels)) { + goto error_4; + } if (!map_image_pixels( pixelData, nPixels, p, nQuantPixels, avgDist, avgDistSortKey, qp)) { diff --git a/src/libImaging/TiffDecode.c b/src/libImaging/TiffDecode.c index 4de38595687..38deb53607e 100644 --- a/src/libImaging/TiffDecode.c +++ b/src/libImaging/TiffDecode.c @@ -56,7 +56,7 @@ _tiffReadProc(thandle_t hdata, tdata_t buf, tsize_t size) { dump_state(state); if (state->loc > state->eof) { - TIFFError("_tiffReadProc", "Invalid Read at loc %llu, eof: %llu", state->loc, state->eof); + TIFFError("_tiffReadProc", "Invalid Read at loc %" PRIu64 ", eof: %" PRIu64, state->loc, state->eof); return 0; } to_read = min(size, min(state->size, (tsize_t)state->eof) - (tsize_t)state->loc); diff --git a/winbuild/build_prepare.py b/winbuild/build_prepare.py index 6e8ba4ee871..63270d753f7 100644 --- a/winbuild/build_prepare.py +++ b/winbuild/build_prepare.py @@ -236,7 +236,9 @@ def cmd_msbuild( cmd_rmdir("Lib"), cmd_rmdir(r"Projects\VC2017\Release"), cmd_msbuild(r"Projects\VC2017\lcms2.sln", "Release", "Clean"), - cmd_msbuild(r"Projects\VC2017\lcms2.sln", "Release", "lcms2_static"), + cmd_msbuild( + r"Projects\VC2017\lcms2.sln", "Release", "lcms2_static:Rebuild" + ), cmd_xcopy("include", "{inc_dir}"), ], "libs": [r"Lib\MS\*.lib"],