diff --git a/.ci/install.sh b/.ci/install.sh index 0dbf2d690..c48acf9ee 100755 --- a/.ci/install.sh +++ b/.ci/install.sh @@ -22,6 +22,7 @@ sudo apt-get -qq install libfreetype6-dev liblcms2-dev python3-tk\ cmake imagemagick libharfbuzz-dev libfribidi-dev python3 -m pip install --upgrade pip +python3 -m pip install --upgrade wheel PYTHONOPTIMIZE=0 python3 -m pip install cffi python3 -m pip install coverage python3 -m pip install defusedxml @@ -31,12 +32,10 @@ python3 -m pip install -U pytest-cov python3 -m pip install -U pytest-timeout python3 -m pip install pyroma python3 -m pip install test-image-results -# TODO Remove condition when numpy supports 3.10 -if ! [ "$GHA_PYTHON_VERSION" == "3.10-dev" ]; then python3 -m pip install numpy ; fi +python3 -m pip install numpy # PyQt5 doesn't support PyPy3 -# Wheel doesn't yet support 3.10 -if [[ $GHA_PYTHON_VERSION == 3.* && $GHA_PYTHON_VERSION != "3.10-dev" ]]; then +if [[ $GHA_PYTHON_VERSION == 3.* ]]; then # arm64, ppc64le, s390x CPUs: # "ERROR: Could not find a version that satisfies the requirement pyqt5" sudo apt-get -qq install libxcb-xinerama0 pyqt5-dev-tools diff --git a/.github/workflows/macos-install.sh b/.github/workflows/macos-install.sh index 3a70c8047..8260cf8d8 100755 --- a/.github/workflows/macos-install.sh +++ b/.github/workflows/macos-install.sh @@ -15,8 +15,7 @@ python3 -m pip install pyroma python3 -m pip install test-image-results echo -e "[openblas]\nlibraries = openblas\nlibrary_dirs = /usr/local/opt/openblas/lib" >> ~/.numpy-site.cfg -# TODO Remove condition when numpy supports 3.10 -if ! [ "$GHA_PYTHON_VERSION" == "3.10-dev" ]; then python3 -m pip install numpy ; fi +python3 -m pip install numpy # extra test images pushd depends && ./install_extra_test_images.sh && popd diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 042e6d83e..cd85bc537 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -72,8 +72,6 @@ jobs: if: startsWith(matrix.os, 'macOS') run: | .github/workflows/macos-install.sh - env: - GHA_PYTHON_VERSION: ${{ matrix.python-version }} - name: Build run: | diff --git a/.gitignore b/.gitignore index 5500ec037..790404535 100644 --- a/.gitignore +++ b/.gitignore @@ -83,6 +83,7 @@ docs/_build/ Tests/images/README.md Tests/images/crash_1.tif Tests/images/crash_2.tif +Tests/images/crash-81154a65438ba5aaeca73fd502fa4850fbde60f8.tif Tests/images/string_dimension.tiff Tests/images/jpeg2000 Tests/images/msp diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 8d38375f0..55fe9c4a7 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/psf/black - rev: e66be67b9b6811913470f70c28b4d50f94d05b22 # frozen: 20.8b1 + rev: e3000ace2fd1fcb1c181bb7a8285f1f976bcbdc7 # frozen: 21.7b0 hooks: - id: black args: ["--target-version", "py36"] @@ -9,35 +9,38 @@ repos: types: [] - repo: https://github.com/PyCQA/isort - rev: 377d260ffa6f746693f97b46d95025afc4bd8275 # frozen: 5.4.2 + rev: fd5ba70665a37ec301a1f714ed09336048b3be63 # frozen: 5.9.3 hooks: - id: isort - repo: https://github.com/asottile/yesqa - rev: 7a009f3ee493c796827ee334f9058b110a0e0db8 # frozen: v1.2.1 + rev: 644ede78511c02fc6f8e03e014cc1ddcfbf1e1f5 # frozen: v1.2.3 hooks: - id: yesqa - repo: https://github.com/Lucas-C/pre-commit-hooks - rev: f30f4974a08a6b2f6a1eeaf30a4d501cf909163a # frozen: v1.1.9 + rev: 3592548bbd98528887eeed63486cf6c9bae00b98 # frozen: v1.1.10 hooks: - id: remove-tabs exclude: (Makefile$|\.bat$|\.cmake$|\.eps$|\.fits$|\.opt$) - repo: https://gitlab.com/pycqa/flake8 - rev: 05f6544aef321e2fee03a1277ce2eef8880fb927 # frozen: 3.8.3 + rev: dcd740bc0ebaf2b3d43e59a0060d157c97de13f3 # frozen: 3.9.2 hooks: - id: flake8 additional_dependencies: [flake8-2020, flake8-implicit-str-concat] - repo: https://github.com/pre-commit/pygrep-hooks - rev: eae6397e4c259ed3d057511f6dd5330b92867e62 # frozen: v1.6.0 + rev: 6f51a66bba59954917140ec2eeeaa4d5e630e6ce # frozen: v1.9.0 hooks: - id: python-check-blanket-noqa - id: rst-backticks - repo: https://github.com/pre-commit/pre-commit-hooks - rev: e1668fe86af3810fbca72b8653fe478e66a0afdc # frozen: v3.2.0 + rev: 38b88246ccc552bffaaf54259d064beeee434539 # frozen: v4.0.1 hooks: - id: check-merge-conflict - id: check-yaml + +ci: + autoupdate_schedule: quarterly diff --git a/CHANGES.rst b/CHANGES.rst index 7c820e17a..f39612e70 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,114 @@ Changelog (Pillow) ================== +8.4.0 (unreleased) +------------------ + +- Copy Python palette to new image in quantize() #5696 + [radarhere] + +- Read ICO AND mask from end #5667 + [radarhere] + +- Actually check the framesize in FliDecode.c #5659 + [wiredfool] + +- Determine JPEG2000 mode purely from ihdr header box #5654 + [radarhere] + +- Fixed using info dictionary when writing multiple APNG frames #5611 + [radarhere] + +- Allow saving 1 and L mode TIFF with PhotometricInterpretation 0 #5655 + [radarhere] + +- For GIF save_all with palette, do not include palette with each frame #5603 + [radarhere] + +- Keep transparency when converting from P to LA or PA #5606 + [radarhere] + +- Copy palette to new image in transform() #5647 + [radarhere] + +- Added "transparency" argument to EpsImagePlugin load() #5620 + [radarhere] + +- Corrected pathlib.Path detection when saving #5633 + [radarhere] + +- Added WalImageFile class #5618 + [radarhere] + +- Consider I;16 pixel size when drawing text #5598 + [radarhere] + +- If default conversion from P is RGB with transparency, convert to RGBA #5594 + [radarhere] + +- Speed up rotating square images by 90 or 270 degrees #5646 + [radarhere] + +- Add support for reading DPI information from JPEG2000 images + [rogermb, radarhere] + +- Catch TypeError from corrupted DPI value in EXIF #5639 + [homm, radarhere] + +- Do not close file pointer when saving SGI images #5645 + [farizrahman4u, radarhere] + +- Deprecate ImagePalette size parameter #5641 + [radarhere, hugovk] + +- Prefer command line tools SDK on macOS #5624 + [radarhere] + +- Added tags when saving YCbCr TIFF #5597 + [radarhere] + +- PSD layer count may be negative #5613 + [radarhere] + +- Fixed ImageOps expand with tuple border on P image #5615 + [radarhere] + +- Fixed error saving APNG with duplicate frames and different duration times #5609 + [thak1411, radarhere] + +8.3.2 (2021-09-02) +------------------ + +- CVE-2021-23437 Raise ValueError if color specifier is too long + [hugovk, radarhere] + +- Fix 6-byte OOB read in FliDecode + [wiredfool] + +- Add support for Python 3.10 #5569, #5570 + [hugovk, radarhere] + +- Ensure TIFF ``RowsPerStrip`` is multiple of 8 for JPEG compression #5588 + [kmilos, radarhere] + +- Updates for ``ImagePalette`` channel order #5599 + [radarhere] + +- Hide FriBiDi shim symbols to avoid conflict with real FriBiDi library #5651 + [nulano] + +8.3.1 (2021-07-06) +------------------ + +- Catch OSError when checking if fp is sys.stdout #5585 + [radarhere] + +- Handle removing orientation from alternate types of EXIF data #5584 + [radarhere] + +- Make Image.__array__ take optional dtype argument #5572 + [t-vi, radarhere] + 8.3.0 (2021-07-01) ------------------ diff --git a/Tests/check_large_memory.py b/Tests/check_large_memory.py index 723a1a21e..c191ffc1e 100644 --- a/Tests/check_large_memory.py +++ b/Tests/check_large_memory.py @@ -33,7 +33,7 @@ def _write_png(tmp_path, xdim, ydim): def test_large(tmp_path): - """ succeeded prepatch""" + """succeeded prepatch""" _write_png(tmp_path, XDIM, YDIM) diff --git a/Tests/check_large_memory_numpy.py b/Tests/check_large_memory_numpy.py index 79d1cfd5b..70ae6d230 100644 --- a/Tests/check_large_memory_numpy.py +++ b/Tests/check_large_memory_numpy.py @@ -31,7 +31,7 @@ def _write_png(tmp_path, xdim, ydim): def test_large(tmp_path): - """ succeeded prepatch""" + """succeeded prepatch""" _write_png(tmp_path, XDIM, YDIM) diff --git a/Tests/images/balloon_eciRGBv2_aware.jp2 b/Tests/images/balloon_eciRGBv2_aware.jp2 new file mode 100644 index 000000000..18fd1e172 Binary files /dev/null and b/Tests/images/balloon_eciRGBv2_aware.jp2 differ diff --git a/Tests/images/broken_exif_dpi.jpg b/Tests/images/broken_exif_dpi.jpg new file mode 100644 index 000000000..2c88b9463 Binary files /dev/null and b/Tests/images/broken_exif_dpi.jpg differ diff --git a/Tests/images/crash-5762152299364352.fli b/Tests/images/crash-5762152299364352.fli new file mode 100644 index 000000000..944fe0b56 Binary files /dev/null and b/Tests/images/crash-5762152299364352.fli differ diff --git a/Tests/images/exif_imagemagick_orientation.png b/Tests/images/exif_imagemagick_orientation.png new file mode 100644 index 000000000..819a0703f Binary files /dev/null and b/Tests/images/exif_imagemagick_orientation.png differ diff --git a/Tests/images/expected_to_read.jp2 b/Tests/images/expected_to_read.jp2 new file mode 100644 index 000000000..d8029a0d3 Binary files /dev/null and b/Tests/images/expected_to_read.jp2 differ diff --git a/Tests/images/hopper_mask.ico b/Tests/images/hopper_mask.ico new file mode 100644 index 000000000..e8d66c689 Binary files /dev/null and b/Tests/images/hopper_mask.ico differ diff --git a/Tests/images/hopper_mask.png b/Tests/images/hopper_mask.png new file mode 100644 index 000000000..c7bd2f708 Binary files /dev/null and b/Tests/images/hopper_mask.png differ diff --git a/Tests/images/hopper_wal.png b/Tests/images/hopper_wal.png new file mode 100644 index 000000000..b6067c219 Binary files /dev/null and b/Tests/images/hopper_wal.png differ diff --git a/Tests/images/invalid_header_length.jp2 b/Tests/images/invalid_header_length.jp2 new file mode 100644 index 000000000..c0c14f421 Binary files /dev/null and b/Tests/images/invalid_header_length.jp2 differ diff --git a/Tests/images/negative_layer_count.psd b/Tests/images/negative_layer_count.psd new file mode 100644 index 000000000..b111c2d56 Binary files /dev/null and b/Tests/images/negative_layer_count.psd differ diff --git a/Tests/images/not_enough_data.jp2 b/Tests/images/not_enough_data.jp2 new file mode 100644 index 000000000..2d28bb5e9 Binary files /dev/null and b/Tests/images/not_enough_data.jp2 differ diff --git a/Tests/images/palette_negative.png b/Tests/images/palette_negative.png new file mode 100644 index 000000000..938a7285f Binary files /dev/null and b/Tests/images/palette_negative.png differ diff --git a/Tests/images/palette_sepia.png b/Tests/images/palette_sepia.png new file mode 100644 index 000000000..f3fc93253 Binary files /dev/null and b/Tests/images/palette_sepia.png differ diff --git a/Tests/images/palette_wedge.png b/Tests/images/palette_wedge.png new file mode 100644 index 000000000..23fb7940d Binary files /dev/null and b/Tests/images/palette_wedge.png differ diff --git a/Tests/images/reqd_showpage_transparency.png b/Tests/images/reqd_showpage_transparency.png new file mode 100644 index 000000000..3ce159d0f Binary files /dev/null and b/Tests/images/reqd_showpage_transparency.png differ diff --git a/Tests/images/zero_dpi.jp2 b/Tests/images/zero_dpi.jp2 new file mode 100644 index 000000000..079271fc6 Binary files /dev/null and b/Tests/images/zero_dpi.jp2 differ diff --git a/Tests/test_file_apng.py b/Tests/test_file_apng.py index 7fb6f59d4..d48e5ce07 100644 --- a/Tests/test_file_apng.py +++ b/Tests/test_file_apng.py @@ -433,12 +433,20 @@ def test_apng_save_duration_loop(tmp_path): # test removal of duplicated frames frame = Image.new("RGBA", (128, 64), (255, 0, 0, 255)) - frame.save(test_file, save_all=True, append_images=[frame], duration=[500, 250]) + frame.save( + test_file, save_all=True, append_images=[frame, frame], duration=[500, 100, 150] + ) with Image.open(test_file) as im: im.load() assert im.n_frames == 1 assert im.info.get("duration") == 750 + # test info duration + frame.info["duration"] = 750 + frame.save(test_file, save_all=True) + with Image.open(test_file) as im: + assert im.info.get("duration") == 750 + def test_apng_save_disposal(tmp_path): test_file = str(tmp_path / "temp.png") @@ -529,6 +537,17 @@ def test_apng_save_disposal(tmp_path): assert im.getpixel((0, 0)) == (0, 255, 0, 255) assert im.getpixel((64, 32)) == (0, 255, 0, 255) + # test info disposal + red.info["disposal"] = PngImagePlugin.APNG_DISPOSE_OP_BACKGROUND + red.save( + test_file, + save_all=True, + append_images=[Image.new("RGBA", (10, 10), (0, 255, 0, 255))], + ) + with Image.open(test_file) as im: + im.seek(1) + assert im.getpixel((64, 32)) == (0, 0, 0, 0) + def test_apng_save_disposal_previous(tmp_path): test_file = str(tmp_path / "temp.png") @@ -609,3 +628,10 @@ def test_apng_save_blend(tmp_path): im.seek(2) assert im.getpixel((0, 0)) == (0, 255, 0, 255) assert im.getpixel((64, 32)) == (0, 255, 0, 255) + + # test info blend + red.info["blend"] = PngImagePlugin.APNG_BLEND_OP_OVER + red.save(test_file, save_all=True, append_images=[green, transparent]) + with Image.open(test_file) as im: + im.seek(2) + assert im.getpixel((0, 0)) == (0, 255, 0, 255) diff --git a/Tests/test_file_dds.py b/Tests/test_file_dds.py index 46ebcad0c..2f46ed77e 100644 --- a/Tests/test_file_dds.py +++ b/Tests/test_file_dds.py @@ -197,7 +197,7 @@ def test__accept_false(): def test_short_header(): - """ Check a short header""" + """Check a short header""" with open(TEST_FILE_DXT5, "rb") as f: img_file = f.read() @@ -210,7 +210,7 @@ def test_short_header(): def test_short_file(): - """ Check that the appropriate error is thrown for a short file""" + """Check that the appropriate error is thrown for a short file""" with open(TEST_FILE_DXT5, "rb") as f: img_file = f.read() @@ -224,7 +224,7 @@ def test_short_file(): def test_dxt5_colorblock_alpha_issue_4142(): - """ Check that colorblocks are decoded correctly in DXT5""" + """Check that colorblocks are decoded correctly in DXT5""" with Image.open("Tests/images/dxt5-colorblock-alpha-issue-4142.dds") as im: px = im.getpixel((0, 0)) diff --git a/Tests/test_file_eps.py b/Tests/test_file_eps.py index 1994a124c..4c0b96f73 100644 --- a/Tests/test_file_eps.py +++ b/Tests/test_file_eps.py @@ -96,6 +96,17 @@ def test_showpage(): assert_image_similar(plot_image, target, 6) +@pytest.mark.skipif(not HAS_GHOSTSCRIPT, reason="Ghostscript not available") +def test_transparency(): + with Image.open("Tests/images/reqd_showpage.eps") as plot_image: + plot_image.load(transparency=True) + assert plot_image.mode == "RGBA" + + with Image.open("Tests/images/reqd_showpage_transparency.png") as target: + # fonts could be slightly different + assert_image_similar(plot_image, target, 6) + + @pytest.mark.skipif(not HAS_GHOSTSCRIPT, reason="Ghostscript not available") def test_file_object(tmp_path): # issue 479 diff --git a/Tests/test_file_fli.py b/Tests/test_file_fli.py index 1c1abf2b1..675e06bf8 100644 --- a/Tests/test_file_fli.py +++ b/Tests/test_file_fli.py @@ -138,3 +138,16 @@ def test_timeouts(test_file): with Image.open(f) as im: with pytest.raises(OSError): im.load() + + +@pytest.mark.parametrize( + "test_file", + [ + "Tests/images/crash-5762152299364352.fli", + ], +) +def test_crash(test_file): + with open(test_file, "rb") as f: + with Image.open(f) as im: + with pytest.raises(OSError): + im.load() diff --git a/Tests/test_file_gif.py b/Tests/test_file_gif.py index 2632ab7c0..46e2b5ab2 100644 --- a/Tests/test_file_gif.py +++ b/Tests/test_file_gif.py @@ -821,6 +821,29 @@ def test_palette_save_P(tmp_path): assert_image_equal(reloaded, im) +def test_palette_save_all_P(tmp_path): + frames = [] + colors = ((255, 0, 0), (0, 255, 0)) + for color in colors: + frame = Image.new("P", (100, 100)) + frame.putpalette(color) + frames.append(frame) + + out = str(tmp_path / "temp.gif") + frames[0].save( + out, save_all=True, palette=[255, 0, 0, 0, 255, 0], append_images=frames[1:] + ) + + with Image.open(out) as im: + # Assert that the frames are correct, and each frame has the same palette + assert_image_equal(im.convert("RGB"), frames[0].convert("RGB")) + assert im.palette.palette == im.global_palette.palette + + im.seek(1) + assert_image_equal(im.convert("RGB"), frames[1].convert("RGB")) + assert im.palette.palette == im.global_palette.palette + + def test_palette_save_ImagePalette(tmp_path): # Pass in a different palette, as an ImagePalette.ImagePalette # effectively the same as test_palette_save_P @@ -833,7 +856,7 @@ def test_palette_save_ImagePalette(tmp_path): with Image.open(out) as reloaded: im.putpalette(palette) - assert_image_equal(reloaded, im) + assert_image_equal(reloaded.convert("RGB"), im.convert("RGB")) def test_save_I(tmp_path): diff --git a/Tests/test_file_ico.py b/Tests/test_file_ico.py index 8060d1b76..317264db6 100644 --- a/Tests/test_file_ico.py +++ b/Tests/test_file_ico.py @@ -18,6 +18,11 @@ def test_sanity(): assert im.get_format_mimetype() == "image/x-icon" +def test_mask(): + with Image.open("Tests/images/hopper_mask.ico") as im: + assert_image_equal_tofile(im, "Tests/images/hopper_mask.png") + + def test_black_and_white(): with Image.open("Tests/images/black_and_white.ico") as im: assert im.mode == "RGBA" diff --git a/Tests/test_file_jpeg.py b/Tests/test_file_jpeg.py index 68096e92d..13d99c15d 100644 --- a/Tests/test_file_jpeg.py +++ b/Tests/test_file_jpeg.py @@ -630,7 +630,7 @@ class TestFileJpeg: reloaded.save(f, quality="keep", optimize=True) def test_bad_mpo_header(self): - """ Treat unknown MPO as JPEG """ + """Treat unknown MPO as JPEG""" # Arrange # Act @@ -718,6 +718,15 @@ class TestFileJpeg: # This should return the default, and not raise a ZeroDivisionError assert im.info.get("dpi") == (72, 72) + def test_dpi_exif_string(self): + # Arrange + # 0x011A tag in this exif contains string '300300\x02' + with Image.open("Tests/images/broken_exif_dpi.jpg") as im: + + # Act / Assert + # This should return the default + assert im.info.get("dpi") == (72, 72) + def test_no_dpi_in_exif(self): # Arrange # This is photoshop-200dpi.jpg with resolution removed from EXIF: diff --git a/Tests/test_file_jpeg2k.py b/Tests/test_file_jpeg2k.py index 20280a579..0b4af4524 100644 --- a/Tests/test_file_jpeg2k.py +++ b/Tests/test_file_jpeg2k.py @@ -4,7 +4,7 @@ from io import BytesIO import pytest -from PIL import Image, ImageFile, Jpeg2KImagePlugin, features +from PIL import Image, ImageFile, Jpeg2KImagePlugin, UnidentifiedImageError, features from .helper import ( assert_image_equal, @@ -151,6 +151,38 @@ def test_reduce(): assert im.size == (40, 30) +def test_load_dpi(): + with Image.open("Tests/images/test-card-lossless.jp2") as im: + assert im.info["dpi"] == (71.9836, 71.9836) + + with Image.open("Tests/images/zero_dpi.jp2") as im: + assert "dpi" not in im.info + + +def test_restricted_icc_profile(): + ImageFile.LOAD_TRUNCATED_IMAGES = True + try: + # JPEG2000 image with a restricted ICC profile and a known colorspace + with Image.open("Tests/images/balloon_eciRGBv2_aware.jp2") as im: + assert im.mode == "RGB" + finally: + ImageFile.LOAD_TRUNCATED_IMAGES = False + + +def test_header_errors(): + for path in ( + "Tests/images/invalid_header_length.jp2", + "Tests/images/not_enough_data.jp2", + ): + with pytest.raises(UnidentifiedImageError): + with Image.open(path): + pass + + with pytest.raises(OSError): + with Image.open("Tests/images/expected_to_read.jp2"): + pass + + def test_layers_type(tmp_path): outfile = str(tmp_path / "temp_layers.jp2") for quality_layers in [[100, 50, 10], (100, 50, 10), None]: diff --git a/Tests/test_file_libtiff.py b/Tests/test_file_libtiff.py index e2f0df84a..1d0c93f06 100644 --- a/Tests/test_file_libtiff.py +++ b/Tests/test_file_libtiff.py @@ -97,13 +97,13 @@ class TestFileLibTiff(LibTiffTestCase): self._assert_noerr(tmp_path, im) def test_g4_eq_png(self): - """ Checking that we're actually getting the data that we expect""" + """Checking that we're actually getting the data that we expect""" with Image.open("Tests/images/hopper_bw_500.png") as png: assert_image_equal_tofile(png, "Tests/images/hopper_g4_500.tif") # see https://github.com/python-pillow/Pillow/issues/279 def test_g4_fillorder_eq_png(self): - """ Checking that we're actually getting the data that we expect""" + """Checking that we're actually getting the data that we expect""" with Image.open("Tests/images/g4-fillorder-test.tif") as g4: assert_image_equal_tofile(g4, "Tests/images/g4-fillorder-test.png") @@ -137,7 +137,7 @@ class TestFileLibTiff(LibTiffTestCase): assert_image_equal_tofile(im, "Tests/images/tiff_adobe_deflate.png") def test_write_metadata(self, tmp_path): - """ Test metadata writing through libtiff """ + """Test metadata writing through libtiff""" for legacy_api in [False, True]: f = str(tmp_path / "temp.tiff") with Image.open("Tests/images/hopper_g4.tif") as img: @@ -670,6 +670,15 @@ class TestFileLibTiff(LibTiffTestCase): TiffImagePlugin.WRITE_LIBTIFF = False TiffImagePlugin.READ_LIBTIFF = False + def test_save_ycbcr(self, tmp_path): + im = hopper("YCbCr") + outfile = str(tmp_path / "temp.tif") + im.save(outfile, compression="jpeg") + + with Image.open(outfile) as reloaded: + assert reloaded.tag_v2[530] == (1, 1) + assert reloaded.tag_v2[532] == (0, 255, 128, 255, 128, 255) + def test_crashing_metadata(self, tmp_path): # issue 1597 with Image.open("Tests/images/rdf.tif") as im: @@ -968,10 +977,11 @@ class TestFileLibTiff(LibTiffTestCase): assert str(e.value) == "-9" TiffImagePlugin.READ_LIBTIFF = False - def test_save_multistrip(self, tmp_path): + @pytest.mark.parametrize("compression", ("tiff_adobe_deflate", "jpeg")) + def test_save_multistrip(self, compression, tmp_path): im = hopper("RGB").resize((256, 256)) out = str(tmp_path / "temp.tif") - im.save(out, compression="tiff_adobe_deflate") + im.save(out, compression=compression) with Image.open(out) as im: # Assert that there are multiple strips diff --git a/Tests/test_file_psd.py b/Tests/test_file_psd.py index bf2a5fea0..f50fe133f 100644 --- a/Tests/test_file_psd.py +++ b/Tests/test_file_psd.py @@ -57,9 +57,10 @@ def test_n_frames(): assert im.n_frames == 1 assert not im.is_animated - with Image.open(test_file) as im: - assert im.n_frames == 2 - assert im.is_animated + for path in [test_file, "Tests/images/negative_layer_count.psd"]: + with Image.open(path) as im: + assert im.n_frames == 2 + assert im.is_animated def test_eoferror(): diff --git a/Tests/test_file_sgi.py b/Tests/test_file_sgi.py index 0210dd4f1..6a5d8887d 100644 --- a/Tests/test_file_sgi.py +++ b/Tests/test_file_sgi.py @@ -73,6 +73,13 @@ def test_write(tmp_path): img.save(out, format="sgi") assert_image_equal_tofile(img, out) + out = str(tmp_path / "fp.sgi") + with open(out, "wb") as fp: + img.save(fp) + assert_image_equal_tofile(img, out) + + assert not fp.closed + for mode in ("L", "RGB", "RGBA"): roundtrip(hopper(mode)) diff --git a/Tests/test_file_tiff.py b/Tests/test_file_tiff.py index 4d4857340..072f7d401 100644 --- a/Tests/test_file_tiff.py +++ b/Tests/test_file_tiff.py @@ -463,6 +463,15 @@ class TestFileTiff: im.seek(1) assert im.getexif()[273] == (1408, 1907) + @pytest.mark.parametrize("mode", ("1", "L")) + def test_photometric(self, mode, tmp_path): + filename = str(tmp_path / "temp.tif") + im = hopper(mode) + im.save(filename, tiffinfo={262: 0}) + with Image.open(filename) as reloaded: + assert reloaded.tag_v2[262] == 0 + assert_image_equal(im, reloaded) + def test_seek(self): filename = "Tests/images/pil136.tiff" with Image.open(filename) as im: @@ -705,6 +714,8 @@ class TestFileTiff: # Ignore this UserWarning which triggers for four tags: # "Possibly corrupt EXIF data. Expecting to read 50404352 bytes but..." @pytest.mark.filterwarnings("ignore:Possibly corrupt EXIF data") + # Ignore this UserWarning: + @pytest.mark.filterwarnings("ignore:Truncated File Read") @pytest.mark.skipif( not os.path.exists("Tests/images/string_dimension.tiff"), reason="Extra image files not installed", diff --git a/Tests/test_file_tiff_metadata.py b/Tests/test_file_tiff_metadata.py index 0adbaf016..2213af5aa 100644 --- a/Tests/test_file_tiff_metadata.py +++ b/Tests/test_file_tiff_metadata.py @@ -122,7 +122,7 @@ def test_read_metadata(): def test_write_metadata(tmp_path): - """ Test metadata writing through the python code """ + """Test metadata writing through the python code""" with Image.open("Tests/images/hopper.tif") as img: f = str(tmp_path / "temp.tiff") img.save(f, tiffinfo=img.tag) diff --git a/Tests/test_file_wal.py b/Tests/test_file_wal.py index 60be1d5bc..f25b42fe0 100644 --- a/Tests/test_file_wal.py +++ b/Tests/test_file_wal.py @@ -1,15 +1,21 @@ from PIL import WalImageFile +from .helper import assert_image_equal_tofile + def test_open(): # Arrange TEST_FILE = "Tests/images/hopper.wal" # Act - im = WalImageFile.open(TEST_FILE) + with WalImageFile.open(TEST_FILE) as im: - # Assert - assert im.format == "WAL" - assert im.format_description == "Quake2 Texture" - assert im.mode == "P" - assert im.size == (128, 128) + # Assert + assert im.format == "WAL" + assert im.format_description == "Quake2 Texture" + assert im.mode == "P" + assert im.size == (128, 128) + + assert isinstance(im, WalImageFile.WalImageFile) + + assert_image_equal_tofile(im, "Tests/images/hopper_wal.png") diff --git a/Tests/test_file_webp.py b/Tests/test_file_webp.py index 7fdb32ef4..420594b0c 100644 --- a/Tests/test_file_webp.py +++ b/Tests/test_file_webp.py @@ -104,6 +104,13 @@ class TestFileWebp: hopper().save(buffer_method, format="WEBP", method=6) assert buffer_no_args.getbuffer() != buffer_method.getbuffer() + def test_icc_profile(self, tmp_path): + self._roundtrip(tmp_path, self.rgb_mode, 12.5, {"icc_profile": None}) + if _webp.HAVE_WEBPANIM: + self._roundtrip( + tmp_path, self.rgb_mode, 12.5, {"icc_profile": None, "save_all": True} + ) + def test_write_unsupported_mode_L(self, tmp_path): """ Saving a black-and-white file to WebP format should work, and be diff --git a/Tests/test_image.py b/Tests/test_image.py index c4e6f8ade..2d661a903 100644 --- a/Tests/test_image.py +++ b/Tests/test_image.py @@ -149,10 +149,11 @@ class TestImage: assert im.mode == "RGB" assert im.size == (128, 128) - temp_file = str(tmp_path / "temp.jpg") - if os.path.exists(temp_file): - os.remove(temp_file) - im.save(Path(temp_file)) + for ext in (".jpg", ".jp2"): + temp_file = str(tmp_path / ("temp." + ext)) + if os.path.exists(temp_file): + os.remove(temp_file) + im.save(Path(temp_file)) def test_fp_name(self, tmp_path): temp_file = str(tmp_path / "temp.jpg") diff --git a/Tests/test_image_array.py b/Tests/test_image_array.py index 4dbbdd218..5c9cdd7e0 100644 --- a/Tests/test_image_array.py +++ b/Tests/test_image_array.py @@ -14,6 +14,10 @@ def test_toarray(): ai = numpy.array(im.convert(mode)) return ai.shape, ai.dtype.str, ai.nbytes + def test_with_dtype(dtype): + ai = numpy.array(im, dtype=dtype) + assert ai.dtype == dtype + # assert test("1") == ((100, 128), '|b1', 1600)) assert test("L") == ((100, 128), "|u1", 12800) @@ -27,6 +31,9 @@ def test_toarray(): assert test("RGBA") == ((100, 128, 4), "|u1", 51200) assert test("RGBX") == ((100, 128, 4), "|u1", 51200) + test_with_dtype(numpy.float64) + test_with_dtype(numpy.uint8) + with Image.open("Tests/images/truncated_jpeg.jpg") as im_truncated: with pytest.raises(OSError): numpy.array(im_truncated) @@ -34,7 +41,7 @@ def test_toarray(): def test_fromarray(): class Wrapper: - """ Class with API matching Image.fromarray """ + """Class with API matching Image.fromarray""" def __init__(self, img, arr_params): self.img = img diff --git a/Tests/test_image_convert.py b/Tests/test_image_convert.py index 5dcdac0e4..436a417d1 100644 --- a/Tests/test_image_convert.py +++ b/Tests/test_image_convert.py @@ -42,10 +42,14 @@ def test_default(): im = hopper("P") assert_image(im, "P", im.size) - im = im.convert() - assert_image(im, "RGB", im.size) - im = im.convert() - assert_image(im, "RGB", im.size) + converted_im = im.convert() + assert_image(converted_im, "RGB", im.size) + converted_im = im.convert() + assert_image(converted_im, "RGB", im.size) + + im.info["transparency"] = 0 + converted_im = im.convert() + assert_image(converted_im, "RGBA", im.size) # ref https://github.com/python-pillow/Pillow/issues/274 @@ -100,18 +104,22 @@ def test_trns_p(tmp_path): # ref https://github.com/python-pillow/Pillow/issues/664 -def test_trns_p_rgba(): +@pytest.mark.parametrize("mode", ("LA", "PA", "RGBA")) +def test_trns_p_transparency(mode): # Arrange im = hopper("P") im.info["transparency"] = 128 # Act - im_rgba = im.convert("RGBA") + converted_im = im.convert(mode) # Assert - assert "transparency" not in im_rgba.info - # https://github.com/python-pillow/Pillow/issues/2702 - assert im_rgba.palette is None + assert "transparency" not in converted_im.info + if mode == "PA": + assert converted_im.palette is not None + else: + # https://github.com/python-pillow/Pillow/issues/2702 + assert converted_im.palette is None def test_trns_l(tmp_path): diff --git a/Tests/test_image_point.py b/Tests/test_image_point.py index 51108ead2..366f45854 100644 --- a/Tests/test_image_point.py +++ b/Tests/test_image_point.py @@ -32,7 +32,7 @@ def test_16bit_lut(): def test_f_lut(): - """ Tests for floating point lut of 8bit gray image """ + """Tests for floating point lut of 8bit gray image""" im = hopper("L") lut = [0.5 * float(x) for x in range(256)] diff --git a/Tests/test_image_putpalette.py b/Tests/test_image_putpalette.py index 5a9df11b1..012a57a09 100644 --- a/Tests/test_image_putpalette.py +++ b/Tests/test_image_putpalette.py @@ -2,7 +2,7 @@ import pytest from PIL import Image, ImagePalette -from .helper import assert_image_equal, hopper +from .helper import assert_image_equal, assert_image_equal_tofile, hopper def test_putpalette(): @@ -36,9 +36,15 @@ def test_putpalette(): def test_imagepalette(): im = hopper("P") im.putpalette(ImagePalette.negative()) + assert_image_equal_tofile(im.convert("RGB"), "Tests/images/palette_negative.png") + im.putpalette(ImagePalette.random()) + im.putpalette(ImagePalette.sepia()) + assert_image_equal_tofile(im.convert("RGB"), "Tests/images/palette_sepia.png") + im.putpalette(ImagePalette.wedge()) + assert_image_equal_tofile(im.convert("RGB"), "Tests/images/palette_wedge.png") def test_putpalette_with_alpha_values(): diff --git a/Tests/test_image_quantize.py b/Tests/test_image_quantize.py index 1ceff0842..bd9db362c 100644 --- a/Tests/test_image_quantize.py +++ b/Tests/test_image_quantize.py @@ -63,6 +63,7 @@ def test_quantize_no_dither(): converted = image.quantize(dither=0, palette=palette) assert_image(converted, "P", converted.size) + assert converted.palette.palette == palette.palette.palette def test_quantize_dither_diff(): diff --git a/Tests/test_image_rotate.py b/Tests/test_image_rotate.py index 79ed79042..2d72ffa68 100644 --- a/Tests/test_image_rotate.py +++ b/Tests/test_image_rotate.py @@ -33,6 +33,9 @@ def test_angle(): with Image.open("Tests/images/test-card.png") as im: rotate(im, im.mode, angle) + im = hopper() + assert_image_equal(im.rotate(angle), im.rotate(angle, expand=1)) + def test_zero(): for angle in (0, 45, 90, 180, 270): diff --git a/Tests/test_image_transform.py b/Tests/test_image_transform.py index 845900267..ea208362b 100644 --- a/Tests/test_image_transform.py +++ b/Tests/test_image_transform.py @@ -32,6 +32,11 @@ class TestImageTransform: new_im = im.transform((100, 100), transform) assert new_im.info["comment"] == comment + def test_palette(self): + with Image.open("Tests/images/hopper.gif") as im: + transformed = im.transform(im.size, Image.AFFINE, [1, 0, 0, 0, 1, 0]) + assert im.palette.palette == transformed.palette.palette + def test_extent(self): im = hopper("RGB") (w, h) = im.size diff --git a/Tests/test_imagecolor.py b/Tests/test_imagecolor.py index b5d693796..dcc44e6e3 100644 --- a/Tests/test_imagecolor.py +++ b/Tests/test_imagecolor.py @@ -191,3 +191,12 @@ def test_rounding_errors(): assert (255, 255) == ImageColor.getcolor("white", "LA") assert (163, 33) == ImageColor.getcolor("rgba(0, 255, 115, 33)", "LA") Image.new("LA", (1, 1), "white") + + +def test_color_too_long(): + # Arrange + color_too_long = "hsl(" + "1" * 40 + "," + "1" * 40 + "%," + "1" * 40 + "%)" + + # Act / Assert + with pytest.raises(ValueError): + ImageColor.getrgb(color_too_long) diff --git a/Tests/test_imagefont.py b/Tests/test_imagefont.py index 892bd0ed1..002641faa 100644 --- a/Tests/test_imagefont.py +++ b/Tests/test_imagefont.py @@ -134,6 +134,17 @@ class TestImageFont: target = "Tests/images/transparent_background_text_L.png" assert_image_similar_tofile(im.convert("L"), target, 0.01) + def test_I16(self): + im = Image.new(mode="I;16", size=(300, 100)) + draw = ImageDraw.Draw(im) + ttf = self.get_font() + + txt = "Hello World!" + draw.text((10, 10), txt, font=ttf) + + target = "Tests/images/transparent_background_text_L.png" + assert_image_similar_tofile(im.convert("L"), target, 0.01) + def test_textsize_equal(self): im = Image.new(mode="RGB", size=(300, 100)) draw = ImageDraw.Draw(im) diff --git a/Tests/test_imageops.py b/Tests/test_imageops.py index dc20d432f..6aa1cf35e 100644 --- a/Tests/test_imageops.py +++ b/Tests/test_imageops.py @@ -156,23 +156,31 @@ 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)) +@pytest.mark.parametrize("border", (10, (1, 2, 3, 4))) +def test_expand_palette(border): + with Image.open("Tests/images/p_16.tga") as im: + im_expanded = ImageOps.expand(im, border, (255, 0, 0)) - px = im_expanded.convert("RGB").load() - for b in range(10): + if isinstance(border, int): + left = top = right = bottom = border + else: + left, top, right, bottom = border + px = im_expanded.convert("RGB").load() 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 b in range(top): + assert px[x, b] == (255, 0, 0) + for b in range(bottom): + 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) + for b in range(left): + assert px[b, y] == (255, 0, 0) + for b in range(right): + assert px[im_expanded.width - 1 - b, y] == (255, 0, 0) - im_cropped = im_expanded.crop( - (10, 10, im_expanded.width - 10, im_expanded.height - 10) - ) - assert_image_equal(im_cropped, im) + im_cropped = im_expanded.crop( + (left, top, im_expanded.width - right, im_expanded.height - bottom) + ) + assert_image_equal(im_cropped, im) def test_colorize_2color(): @@ -335,6 +343,28 @@ def test_exif_transpose(): ) as orientation_im: check(orientation_im) + # Orientation from "XML:com.adobe.xmp" info key + with Image.open("Tests/images/xmp_tags_orientation.png") as im: + assert im.getexif()[0x0112] == 3 + + transposed_im = ImageOps.exif_transpose(im) + assert 0x0112 not in transposed_im.getexif() + + # Orientation from "Raw profile type exif" info key + # This test image has been manually hexedited from exif_imagemagick.png + # to have a different orientation + with Image.open("Tests/images/exif_imagemagick_orientation.png") as im: + assert im.getexif()[0x0112] == 3 + + transposed_im = ImageOps.exif_transpose(im) + assert 0x0112 not in transposed_im.getexif() + + # Orientation set directly on Image.Exif + im = hopper() + im.getexif()[0x0112] = 3 + transposed_im = ImageOps.exif_transpose(im) + assert 0x0112 not in transposed_im.getexif() + def test_autocontrast_cutoff(): # Test the cutoff argument of autocontrast diff --git a/Tests/test_imagepalette.py b/Tests/test_imagepalette.py index ecfbda1d8..475d249ed 100644 --- a/Tests/test_imagepalette.py +++ b/Tests/test_imagepalette.py @@ -10,15 +10,16 @@ def test_sanity(): palette = ImagePalette.ImagePalette("RGB", list(range(256)) * 3) assert len(palette.colors) == 256 - with pytest.raises(ValueError): - ImagePalette.ImagePalette("RGB", list(range(256)) * 3, 10) + with pytest.warns(DeprecationWarning): + with pytest.raises(ValueError): + 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")) + with Image.open("Tests/images/hopper.gif") as im: + original = im.copy() + im.palette.dirty = 1 + assert_image_equal(im.convert("RGB"), original.convert("RGB")) def test_getcolor(): diff --git a/Tests/test_map.py b/Tests/test_map.py index 752c5f268..42f3447eb 100644 --- a/Tests/test_map.py +++ b/Tests/test_map.py @@ -24,11 +24,17 @@ def test_overflow(): def test_tobytes(): + # Note that this image triggers the decompression bomb warning: + max_pixels = Image.MAX_IMAGE_PIXELS + Image.MAX_IMAGE_PIXELS = None + # Previously raised an access violation on Windows with Image.open("Tests/images/l2rgb_read.bmp") as im: with pytest.raises((ValueError, MemoryError, OSError)): im.tobytes() + Image.MAX_IMAGE_PIXELS = max_pixels + @pytest.mark.skipif(sys.maxsize <= 2 ** 32, reason="Requires 64-bit system") def test_ysize(): diff --git a/Tests/test_tiff_crashes.py b/Tests/test_tiff_crashes.py index 6cdb8e44d..143765b8e 100644 --- a/Tests/test_tiff_crashes.py +++ b/Tests/test_tiff_crashes.py @@ -40,6 +40,7 @@ from .helper import on_ci ) @pytest.mark.filterwarnings("ignore:Possibly corrupt EXIF data") @pytest.mark.filterwarnings("ignore:Metadata warning") +@pytest.mark.filterwarnings("ignore:Truncated File Read") def test_tiff_crashes(test_file): try: with Image.open(test_file) as im: diff --git a/depends/install_webp.sh b/depends/install_webp.sh index 568cb2df9..4a4e74305 100755 --- a/depends/install_webp.sh +++ b/depends/install_webp.sh @@ -1,7 +1,7 @@ #!/bin/bash # install webp -archive=libwebp-1.2.0 +archive=libwebp-1.2.1 ./download-and-extract.sh $archive https://raw.githubusercontent.com/python-pillow/pillow-depends/master/$archive.tar.gz diff --git a/docs/deprecations.rst b/docs/deprecations.rst index 9ce2fe7b3..45720ccc0 100644 --- a/docs/deprecations.rst +++ b/docs/deprecations.rst @@ -92,6 +92,17 @@ dictionary. The :py:attr:`~PIL.JpegImagePlugin.convert_dict_qtables` method no l performs any operations on the data given to it, has been deprecated and will be removed in Pillow 10.0.0 (2023-01-02). +ImagePalette size parameter +~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. deprecated:: 8.4.0 + +The ``size`` parameter will be removed in Pillow 10.0.0 (2023-01-02). + +Before Pillow 8.3.0, ``ImagePalette`` required palette data of particular lengths by +default, and the size parameter could be used to override that. Pillow 8.3.0 removed +the default required length, also removing the need for the size parameter. + Removed features ---------------- diff --git a/docs/handbook/image-file-formats.rst b/docs/handbook/image-file-formats.rst index 1b65ef236..1a5dee0b4 100644 --- a/docs/handbook/image-file-formats.rst +++ b/docs/handbook/image-file-formats.rst @@ -66,7 +66,7 @@ than leaving them in the original color space. The EPS driver can write images in ``L``, ``RGB`` and ``CMYK`` modes. If Ghostscript is available, you can call the :py:meth:`~PIL.Image.Image.load` -method with the following parameter to affect how Ghostscript renders the EPS +method with the following parameters to affect how Ghostscript renders the EPS **scale** Affects the scale of the resultant rasterized image. If the EPS suggests @@ -79,6 +79,11 @@ method with the following parameter to affect how Ghostscript renders the EPS im.load(scale=2) im.size #(200,200) +**transparency** + If true, generates an RGBA image with a transparent background, instead of + the default behaviour of an RGB image with a white background. + + GIF ^^^ @@ -839,7 +844,7 @@ Reading Multi-frame TIFF Images The TIFF loader supports the :py:meth:`~PIL.Image.Image.seek` and :py:meth:`~PIL.Image.Image.tell` methods, taking and returning frame numbers within the image file. You can combine these methods to seek to the next frame -(``im.seek(im.tell() + 1)``). Frames are numbered from 0 to ``im.num_frames - 1``, +(``im.seek(im.tell() + 1)``). Frames are numbered from 0 to ``im.n_frames - 1``, and can be accessed in any order. ``im.seek()`` raises an :py:exc:`EOFError` if you try to seek after the diff --git a/docs/installation.rst b/docs/installation.rst index 5fe1963c9..03f7157bb 100644 --- a/docs/installation.rst +++ b/docs/installation.rst @@ -18,9 +18,9 @@ Pillow supports these Python versions. +----------------------+-----+-----+-----+-----+-----+-----+-----+-----+ | Python |3.10 | 3.9 | 3.8 | 3.7 | 3.6 | 3.5 | 3.4 | 2.7 | +======================+=====+=====+=====+=====+=====+=====+=====+=====+ -| Pillow >= 8.3 | Yes | Yes | Yes | Yes | Yes | | | | +| Pillow >= 8.3.2 | Yes | Yes | Yes | Yes | Yes | | | | +----------------------+-----+-----+-----+-----+-----+-----+-----+-----+ -| Pillow 8.0 - 8.2 | | Yes | Yes | Yes | Yes | | | | +| Pillow 8.0 - 8.3.1 | | Yes | Yes | Yes | Yes | | | | +----------------------+-----+-----+-----+-----+-----+-----+-----+-----+ | Pillow 7.0 - 7.2 | | | Yes | Yes | Yes | Yes | | | +----------------------+-----+-----+-----+-----+-----+-----+-----+-----+ @@ -111,7 +111,7 @@ Pillow can be installed on FreeBSD via the official Ports or Packages systems: **Packages**:: - pkg install py36-pillow + pkg install py38-pillow .. note:: @@ -476,7 +476,7 @@ These platforms are built and tested for every change. | +---------------------------+---------------------+ | | PyPy3 | x86 | | +---------------------------+---------------------+ -| | 3.8/MinGW | x86, x86-64 | +| | 3.9/MinGW | x86, x86-64 | +----------------------------------+---------------------------+---------------------+ @@ -494,11 +494,11 @@ These platforms have been reported to work at the versions mentioned. | Operating system | | Tested Python | | Latest tested | | Tested | | | | versions | | Pillow version | | processors | +==================================+===========================+==================+==============+ -| macOS 11.0 Big Sur | 3.7, 3.8, 3.9 | 8.2.0 |arm | +| macOS 11.0 Big Sur | 3.7, 3.8, 3.9 | 8.3.1 |arm | | +---------------------------+------------------+--------------+ -| | 3.6, 3.7, 3.8, 3.9 | 8.2.0 |x86-64 | +| | 3.6, 3.7, 3.8, 3.9 | 8.3.1 |x86-64 | +----------------------------------+---------------------------+------------------+--------------+ -| macOS 10.15 Catalina | 3.6, 3.7, 3.8, 3.9 | 8.0.1 |x86-64 | +| macOS 10.15 Catalina | 3.6, 3.7, 3.8, 3.9 | 8.3.1 |x86-64 | | +---------------------------+------------------+ | | | 3.5 | 7.2.0 | | +----------------------------------+---------------------------+------------------+--------------+ diff --git a/docs/reference/ImagePalette.rst b/docs/reference/ImagePalette.rst index f14c1c3a4..72ccfac7d 100644 --- a/docs/reference/ImagePalette.rst +++ b/docs/reference/ImagePalette.rst @@ -9,10 +9,6 @@ represent the color palette of palette mapped images. .. note:: - This module was never well-documented. It hasn't changed since 2001, - though, so it's probably safe for you to read the source code and puzzle - out the internals if you need to. - The :py:class:`~PIL.ImagePalette.ImagePalette` class has several methods, but they are all marked as "experimental." Read that as you will. The ``[source]`` link is there for a reason. diff --git a/docs/reference/c_extension_debugging.rst b/docs/reference/c_extension_debugging.rst index 527b9d7bc..66175ea0c 100644 --- a/docs/reference/c_extension_debugging.rst +++ b/docs/reference/c_extension_debugging.rst @@ -339,7 +339,7 @@ Take your test image, and make a really simple harness. (vpy38-dbg) ubuntu@primary:~/Home/tests$ gdb python GNU gdb (Ubuntu 9.2-0ubuntu1~20.04) 9.2 Copyright (C) 2020 Free Software Foundation, Inc. - License GPLv3+: GNU GPL version 3 or later + License GPLv3+: GNU GPL version 3 or later This is free software: you are free to change and redistribute it. There is NO WARRANTY, to the extent permitted by law. Type "show copying" and "show warranty" for details. @@ -348,7 +348,7 @@ Take your test image, and make a really simple harness. For bug reporting instructions, please see: . Find the GDB manual and other documentation resources online at: - . + . For help, type "help". Type "apropos word" to search for commands related to "word"... diff --git a/docs/releasenotes/2.7.0.rst b/docs/releasenotes/2.7.0.rst index 03000528f..660d33164 100644 --- a/docs/releasenotes/2.7.0.rst +++ b/docs/releasenotes/2.7.0.rst @@ -14,7 +14,7 @@ Png text chunk size limits To prevent potential denial of service attacks using compressed text chunks, there are now limits to the decompressed size of text chunks decoded from PNG images. If the limits are exceeded when opening a PNG -image a ``ValueError`` will be raised. +image a :py:exc:`ValueError` will be raised. Individual text chunks are limited to :py:attr:`PIL.PngImagePlugin.MAX_TEXT_CHUNK`, set to 1MB by diff --git a/docs/releasenotes/8.3.1.rst b/docs/releasenotes/8.3.1.rst new file mode 100644 index 000000000..e97070c11 --- /dev/null +++ b/docs/releasenotes/8.3.1.rst @@ -0,0 +1,40 @@ +8.3.1 +----- + +Fixed regression converting to NumPy arrays +=========================================== + +This fixes a regression introduced in 8.3.0 when converting an image to a NumPy array +with a ``dtype`` argument. + +.. code-block:: pycon + + >>> from PIL import Image + >>> import numpy + >>> im = Image.new("RGB", (100, 100)) + >>> numpy.array(im, dtype=numpy.float64) + Traceback (most recent call last): + File "", line 1, in + TypeError: __array__() takes 1 positional argument but 2 were given + >>> + +Catch OSError when checking if destination is sys.stdout +======================================================== + +In 8.3.0, a check to see if the destination was ``sys.stdout`` when saving an image was +updated. This lead to an OSError being raised if the environment restricted access. + +The OSError is now silently caught. + +Fixed removing orientation in ImageOps.exif_transpose +===================================================== + +In 8.3.0, :py:meth:`~PIL.ImageOps.exif_transpose` was changed to ensure that the +original image EXIF data was not modified, and the orientation was only removed from +the modified copy. + +However, for certain images the orientation was already missing from the modified +image, leading to a KeyError. + +This error has been resolved, and the copying of metadata to the modified image +improved. diff --git a/docs/releasenotes/8.3.2.rst b/docs/releasenotes/8.3.2.rst new file mode 100644 index 000000000..6b5c759fc --- /dev/null +++ b/docs/releasenotes/8.3.2.rst @@ -0,0 +1,41 @@ +8.3.2 +----- + +Security +======== + +* :cve:`CVE-2021-23437`: Avoid a potential ReDoS (regular expression denial of service) + in :py:class:`~PIL.ImageColor`'s :py:meth:`~PIL.ImageColor.getrgb` by raising + :py:exc:`ValueError` if the color specifier is too long. Present since Pillow 5.2.0. + +* Fix 6-byte out-of-bounds (OOB) read. The previous bounds check in ``FliDecode.c`` + incorrectly calculated the required read buffer size when copying a chunk, potentially + reading six extra bytes off the end of the allocated buffer from the heap. Present + since Pillow 7.1.0. This bug was found by Google's `OSS-Fuzz`_ `CIFuzz`_ runs. + +Other Changes +============= + +Python 3.10 wheels +^^^^^^^^^^^^^^^^^^ + +Pillow now includes binary wheels for Python 3.10. + +The Python 3.10 release candidate was released on 2021-08-03 with the final release due +2021-10-04 (:pep:`619`). The CPython core team strongly encourages maintainers of +third-party Python projects to prepare for 3.10 compatibility. And as there are `no ABI +changes`_ planned we are releasing wheels to help others prepare for 3.10, and ensure +Pillow can be used immediately on release day of 3.10.0 final. + +Fixed regressions +^^^^^^^^^^^^^^^^^ + +* Ensure TIFF ``RowsPerStrip`` is multiple of 8 for JPEG compression (:pr:`5588`). + +* Updates for :py:class:`~PIL.ImagePalette` channel order (:pr:`5599`). + +* Hide FriBiDi shim symbols to avoid conflict with real FriBiDi library (:pr:`5651`). + +.. _OSS-Fuzz: https://github.com/google/oss-fuzz +.. _CIFuzz: https://google.github.io/oss-fuzz/getting-started/continuous-integration/ +.. _no ABI changes: https://www.python.org/downloads/release/python-3100rc1/ diff --git a/docs/releasenotes/8.4.0.rst b/docs/releasenotes/8.4.0.rst new file mode 100644 index 000000000..e23a1eefe --- /dev/null +++ b/docs/releasenotes/8.4.0.rst @@ -0,0 +1,61 @@ +8.4.0 +----- + +API Changes +=========== + +Deprecations +^^^^^^^^^^^^ + +ImagePalette size parameter +~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +The ``size`` parameter will be removed in Pillow 10.0.0 (2023-01-02). + +Before Pillow 8.3.0, ``ImagePalette`` required palette data of particular lengths by +default, and the size parameter could be used to override that. Pillow 8.3.0 removed +the default required length, also removing the need for the size parameter. + +API Additions +============= + +Added "transparency" argument for loading EPS images +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +This new argument switches the Ghostscript device from "ppmraw" to "pngalpha", +generating an RGBA image with a transparent background instead of an RGB image with a +white background. + +.. code-block:: python + + with Image.open("sample.eps") as im: + im.load(transparency=True) + +Added WalImageFile class +^^^^^^^^^^^^^^^^^^^^^^^^ + +:py:func:`PIL.WalImageFile.open()` previously returned a generic +:py:class:`PIL.Image.Image` instance. It now returns a dedicated +:py:class:`PIL.WalImageFile.WalImageFile` class. + +Security +======== + +TODO +^^^^ + +TODO + +Other Changes +============= + +Speed improvement when rotating square images +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Starting with Pillow 3.3.0, the speed of rotating images by 90 or 270 degrees was +improved by quickly returning :py:meth:`~PIL.Image.Image.transpose` instead, if the +rotate operation allowed for expansion and did not specify a center or post-rotate +translation. + +Since the ``expand`` flag makes no difference for square images though, Pillow now +uses this faster method for square images without the ``expand`` flag as well. diff --git a/docs/releasenotes/index.rst b/docs/releasenotes/index.rst index 3e23e43d3..f42ea72e8 100644 --- a/docs/releasenotes/index.rst +++ b/docs/releasenotes/index.rst @@ -14,6 +14,9 @@ expected to be backported to earlier versions. .. toctree:: :maxdepth: 2 + 8.4.0 + 8.3.2 + 8.3.1 8.3.0 8.2.0 8.1.2 diff --git a/setup.py b/setup.py index 6dc4e1b77..b56e90634 100755 --- a/setup.py +++ b/setup.py @@ -533,14 +533,16 @@ class pil_build_ext(build_ext): _add_directory(include_dirs, "/usr/X11/include") # SDK install path - try: - sdk_path = ( - subprocess.check_output(["xcrun", "--show-sdk-path"]) - .strip() - .decode("latin1") - ) - except Exception: - sdk_path = None + sdk_path = "/Library/Developer/CommandLineTools/SDKs/MacOSX.sdk" + if not os.path.exists(sdk_path): + try: + sdk_path = ( + subprocess.check_output(["xcrun", "--show-sdk-path"]) + .strip() + .decode("latin1") + ) + except Exception: + sdk_path = None if sdk_path: _add_directory(library_dirs, os.path.join(sdk_path, "usr", "lib")) _add_directory(include_dirs, os.path.join(sdk_path, "usr", "include")) diff --git a/src/PIL/BmpImagePlugin.py b/src/PIL/BmpImagePlugin.py index 821844484..7bfe733e5 100644 --- a/src/PIL/BmpImagePlugin.py +++ b/src/PIL/BmpImagePlugin.py @@ -58,7 +58,7 @@ def _dib_accept(prefix): # Image plugin for the Windows BMP format. # ============================================================================= class BmpImageFile(ImageFile.ImageFile): - """ Image plugin for the Windows Bitmap format (BMP) """ + """Image plugin for the Windows Bitmap format (BMP)""" # ------------------------------------------------------------- Description format_description = "Windows Bitmap" @@ -70,7 +70,7 @@ class BmpImageFile(ImageFile.ImageFile): vars()[k] = v def _bitmap(self, header=0, offset=0): - """ Read relevant info about the BMP """ + """Read relevant info about the BMP""" read, seek = self.fp.read, self.fp.seek if header: seek(header) @@ -257,7 +257,7 @@ class BmpImageFile(ImageFile.ImageFile): ] def _open(self): - """ Open file, check magic number and read header """ + """Open file, check magic number and read header""" # read 14 bytes: magic number, filesize, reserved, header final offset head_data = self.fp.read(14) # choke if the file does not have the required magic bytes diff --git a/src/PIL/EpsImagePlugin.py b/src/PIL/EpsImagePlugin.py index 3b7eacbfc..5d9920280 100644 --- a/src/PIL/EpsImagePlugin.py +++ b/src/PIL/EpsImagePlugin.py @@ -61,7 +61,7 @@ def has_ghostscript(): return False -def Ghostscript(tile, size, fp, scale=1): +def Ghostscript(tile, size, fp, scale=1, transparency=False): """Render an image using Ghostscript""" # Unpack decoder tile @@ -108,6 +108,8 @@ def Ghostscript(tile, size, fp, scale=1): lengthfile -= len(s) f.write(s) + device = "pngalpha" if transparency else "ppmraw" + # Build Ghostscript command command = [ "gs", @@ -117,7 +119,7 @@ def Ghostscript(tile, size, fp, scale=1): "-dBATCH", # exit after processing "-dNOPAUSE", # don't pause between pages "-dSAFER", # safe mode - "-sDEVICE=ppmraw", # ppm driver + f"-sDEVICE={device}", f"-sOutputFile={outfile}", # output file # adjust for image origin "-c", @@ -325,11 +327,11 @@ class EpsImageFile(ImageFile.ImageFile): return (length, offset) - def load(self, scale=1): + def load(self, scale=1, transparency=False): # Load EPS via Ghostscript if not self.tile: return - self.im = Ghostscript(self.tile, self.size, self.fp, scale) + self.im = Ghostscript(self.tile, self.size, self.fp, scale, transparency) self.mode = self.im.mode self._size = self.im.size self.tile = [] diff --git a/src/PIL/GifImagePlugin.py b/src/PIL/GifImagePlugin.py index 5db310809..db6944267 100644 --- a/src/PIL/GifImagePlugin.py +++ b/src/PIL/GifImagePlugin.py @@ -396,15 +396,7 @@ def _normalize_palette(im, palette, info): if isinstance(palette, (bytes, bytearray, list)): source_palette = bytearray(palette[:768]) if isinstance(palette, ImagePalette.ImagePalette): - source_palette = bytearray( - itertools.chain.from_iterable( - zip( - palette.palette[:256], - palette.palette[256:512], - palette.palette[512:768], - ) - ) - ) + source_palette = bytearray(palette.palette) if im.mode == "P": if not source_palette: @@ -414,9 +406,26 @@ def _normalize_palette(im, palette, info): source_palette = bytearray(i // 3 for i in range(768)) im.palette = ImagePalette.ImagePalette("RGB", palette=source_palette) - used_palette_colors = _get_optimize(im, info) - if used_palette_colors is not None: - return im.remap_palette(used_palette_colors, source_palette) + if palette: + used_palette_colors = [] + for i in range(0, len(source_palette), 3): + source_color = tuple(source_palette[i : i + 3]) + try: + index = im.palette.colors[source_color] + except KeyError: + index = None + used_palette_colors.append(index) + for i, index in enumerate(used_palette_colors): + if index is None: + for j in range(len(used_palette_colors)): + if j not in used_palette_colors: + used_palette_colors[i] = j + break + im = im.remap_palette(used_palette_colors) + else: + used_palette_colors = _get_optimize(im, info) + if used_palette_colors is not None: + return im.remap_palette(used_palette_colors, source_palette) im.palette.palette = source_palette return im @@ -507,7 +516,8 @@ def _write_multiple_frames(im, fp, palette): offset = (0, 0) else: # compress difference - frame_data["encoderinfo"]["include_color_table"] = True + if not palette: + frame_data["encoderinfo"]["include_color_table"] = True im_frame = im_frame.crop(frame_data["bbox"]) offset = frame_data["bbox"][:2] @@ -787,7 +797,7 @@ def _get_global_header(im, info): """Return a list of strings representing a GIF header""" # Header Block - # http://www.matthewflickinger.com/lab/whatsinagif/bits_and_bytes.asp + # https://www.matthewflickinger.com/lab/whatsinagif/bits_and_bytes.asp version = b"87a" for extensionKey in ["transparency", "duration", "loop", "comment"]: diff --git a/src/PIL/IcoImagePlugin.py b/src/PIL/IcoImagePlugin.py index ffb1e873d..d9ff9b5e7 100644 --- a/src/PIL/IcoImagePlugin.py +++ b/src/PIL/IcoImagePlugin.py @@ -235,8 +235,8 @@ class IcoFile: # the total mask data is # padded row size * height / bits per char - and_mask_offset = o + int(im.size[0] * im.size[1] * (bpp / 8.0)) total_bytes = int((w * im.size[1]) / 8) + and_mask_offset = header["offset"] + header["size"] - total_bytes self.buf.seek(and_mask_offset) mask_data = self.buf.read(total_bytes) diff --git a/src/PIL/Image.py b/src/PIL/Image.py index 9debddeec..7dd5b35bd 100644 --- a/src/PIL/Image.py +++ b/src/PIL/Image.py @@ -681,7 +681,7 @@ class Image: raise ValueError("Could not save to PNG for display") from e return b.getvalue() - def __array__(self): + def __array__(self, dtype=None): # numpy array interface support import numpy as np @@ -700,7 +700,7 @@ class Image: class ArrayData: __array_interface__ = new - return np.array(ArrayData()) + return np.array(ArrayData(), dtype) def __getstate__(self): return [self.info, self.mode, self.size, self.getpalette(), self.tobytes()] @@ -914,16 +914,18 @@ class Image: self.load() + has_transparency = self.info.get("transparency") is not None if not mode and self.mode == "P": # determine default mode if self.palette: mode = self.palette.mode else: mode = "RGB" + if mode == "RGB" and has_transparency: + mode = "RGBA" if not mode or (mode == self.mode and not matrix): return self.copy() - has_transparency = self.info.get("transparency") is not None if matrix: # matrix conversion if mode not in ("L", "RGB"): @@ -1005,7 +1007,7 @@ class Image: trns_im = trns_im.convert("RGB") trns = trns_im.getpixel((0, 0)) - elif self.mode == "P" and mode == "RGBA": + elif self.mode == "P" and mode in ("LA", "PA", "RGBA"): t = self.info["transparency"] delete_trns = True @@ -1128,7 +1130,9 @@ class Image: "only RGB or L mode images can be quantized to a palette" ) im = self.im.convert("P", dither, palette.im) - return self._new(im) + new_im = self._new(im) + new_im.palette = palette.palette.copy() + return new_im im = self._new(self.im.quantize(colors, method, kmeans)) @@ -1751,14 +1755,19 @@ class Image: Attaches a palette to this image. The image must be a "P", "PA", "L" or "LA" image. - The palette sequence must contain at most 768 integer values, or 1024 - integer values if alpha is included. Each group of values represents - the red, green, blue (and alpha if included) values for the - corresponding pixel index. Instead of an integer sequence, you can use - an 8-bit string. + The palette sequence must contain at most 256 colors, made up of one + integer value for each channel in the raw mode. + For example, if the raw mode is "RGB", then it can contain at most 768 + values, made up of red, green and blue values for the corresponding pixel + index in the 256 colors. + If the raw mode is "RGBA", then it can contain at most 1024 values, + containing red, green, blue and alpha values. + + Alternatively, an 8-bit string may be used instead of an integer sequence. :param data: A palette sequence (either a list or a string). - :param rawmode: The raw mode of the palette. + :param rawmode: The raw mode of the palette. Either "RGB", "RGBA", or a + mode that can be transformed to "RGB" (e.g. "R", "BGR;15", "RGBA;L"). """ from . import ImagePalette @@ -1832,18 +1841,16 @@ class Image: if source_palette is None: if self.mode == "P": self.load() - real_source_palette = self.im.getpalette("RGB")[:768] + source_palette = self.im.getpalette("RGB")[:768] else: # L-mode - real_source_palette = bytearray(i // 3 for i in range(768)) - else: - real_source_palette = source_palette + source_palette = bytearray(i // 3 for i in range(768)) palette_bytes = b"" new_positions = [0] * 256 # pick only the used colors from the palette for i, oldPosition in enumerate(dest_map): - palette_bytes += real_source_palette[oldPosition * 3 : oldPosition * 3 + 3] + palette_bytes += source_palette[oldPosition * 3 : oldPosition * 3 + 3] new_positions[oldPosition] = i # replace the palette color id of all pixel with the new id @@ -2076,10 +2083,8 @@ class Image: return self.copy() if angle == 180: return self.transpose(ROTATE_180) - if angle == 90 and expand: - return self.transpose(ROTATE_90) - if angle == 270 and expand: - return self.transpose(ROTATE_270) + if angle in (90, 270) and (expand or self.width == self.height): + return self.transpose(ROTATE_90 if angle == 90 else ROTATE_270) # Calculate the affine matrix. Note that this is the reverse # transformation (from destination image to source) because we @@ -2182,12 +2187,12 @@ class Image: filename = "" open_fp = False - if isPath(fp): - filename = fp - open_fp = True - elif isinstance(fp, Path): + if isinstance(fp, Path): filename = str(fp) open_fp = True + elif isPath(fp): + filename = fp + open_fp = True elif fp == sys.stdout: try: fp = sys.stdout.buffer @@ -2481,6 +2486,8 @@ class Image: raise ValueError("missing method data") im = new(self.mode, size, fillcolor) + if self.mode == "P" and self.palette: + im.palette = self.palette.copy() im.info = self.info.copy() if method == MESH: # list of quads diff --git a/src/PIL/ImageCms.py b/src/PIL/ImageCms.py index 8c4740ddc..369909590 100644 --- a/src/PIL/ImageCms.py +++ b/src/PIL/ImageCms.py @@ -37,7 +37,7 @@ pyCMS http://www.cazabon.com pyCMS home page: http://www.cazabon.com/pyCMS - littleCMS home page: http://www.littlecms.com + littleCMS home page: https://www.littlecms.com (littleCMS is Copyright (C) 1998-2001 Marti Maria) Originally released under LGPL. Graciously donated to PIL in diff --git a/src/PIL/ImageColor.py b/src/PIL/ImageColor.py index 51df44040..25f92f2c7 100644 --- a/src/PIL/ImageColor.py +++ b/src/PIL/ImageColor.py @@ -32,6 +32,8 @@ def getrgb(color): :param color: A color string :return: ``(red, green, blue[, alpha])`` """ + if len(color) > 100: + raise ValueError("color specifier is too long") color = color.lower() rgb = colormap.get(color, None) diff --git a/src/PIL/ImageFile.py b/src/PIL/ImageFile.py index daf732de1..43d2bf0cc 100644 --- a/src/PIL/ImageFile.py +++ b/src/PIL/ImageFile.py @@ -493,7 +493,11 @@ def _save(im, fp, tile, bufsize=0): # But, it would need at least the image size in most cases. RawEncode is # a tricky case. bufsize = max(MAXBLOCK, bufsize, im.size[0] * 4) # see RawEncode.c - if fp == sys.stdout or (hasattr(sys.stdout, "buffer") and fp == sys.stdout.buffer): + try: + stdout = fp == sys.stdout or fp == sys.stdout.buffer + except (OSError, AttributeError): + stdout = False + if stdout: fp.flush() return try: diff --git a/src/PIL/ImageOps.py b/src/PIL/ImageOps.py index 711a519fc..f0c932d33 100644 --- a/src/PIL/ImageOps.py +++ b/src/PIL/ImageOps.py @@ -19,8 +19,9 @@ import functools import operator +import re -from . import Image, ImageDraw +from . import Image # # helpers @@ -394,15 +395,16 @@ def expand(image, border=0, fill=0): height = top + image.size[1] + bottom 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) + image.load() + palette = image.palette.copy() + if isinstance(color, tuple): + color = palette.getcolor(color) else: - out = Image.new(image.mode, (width, height), color) - out.paste(image, (left, top)) + palette = None + out = Image.new(image.mode, (width, height), color) + if palette: + out.putpalette(palette.palette) + out.paste(image, (left, top)) return out @@ -588,7 +590,19 @@ def exif_transpose(image): if method is not None: transposed_image = image.transpose(method) transposed_exif = transposed_image.getexif() - del transposed_exif[0x0112] - transposed_image.info["exif"] = transposed_exif.tobytes() + if 0x0112 in transposed_exif: + del transposed_exif[0x0112] + if "exif" in transposed_image.info: + transposed_image.info["exif"] = transposed_exif.tobytes() + elif "Raw profile type exif" in transposed_image.info: + transposed_image.info[ + "Raw profile type exif" + ] = transposed_exif.tobytes().hex() + elif "XML:com.adobe.xmp" in transposed_image.info: + transposed_image.info["XML:com.adobe.xmp"] = re.sub( + r'tiff:Orientation="([0-9])"', + "", + transposed_image.info["XML:com.adobe.xmp"], + ) return transposed_image return image.copy() diff --git a/src/PIL/ImagePalette.py b/src/PIL/ImagePalette.py index b0c722b29..36826bdf3 100644 --- a/src/PIL/ImagePalette.py +++ b/src/PIL/ImagePalette.py @@ -17,6 +17,7 @@ # import array +import warnings from . import GimpGradientFile, GimpPaletteFile, ImageColor, PaletteFile @@ -25,15 +26,14 @@ class ImagePalette: """ Color palette for palette mapped images - :param mode: The mode to use for the Palette. See: + :param mode: The mode to use for the palette. See: :ref:`concept-modes`. Defaults to "RGB" :param palette: An optional palette. If given, it must be a bytearray, - an array or a list of ints between 0-255 and of length ``size`` - times the number of colors in ``mode``. The list must be aligned - by channel (All R values must be contiguous in the list before G - and B values.) Defaults to 0 through 255 per channel. - :param size: An optional palette size. If given, it cannot be equal to - or greater than 256. Defaults to 0. + an array or a list of ints between 0-255. The list must consist of + all channels for one color followed by the next color (e.g. RGBRGBRGB). + Defaults to an empty palette. + :param size: An optional palette size. If given, an error is raised + if ``palette`` is not of equal length. """ def __init__(self, mode="RGB", palette=None, size=0): @@ -41,8 +41,14 @@ class ImagePalette: self.rawmode = None # if set, palette contains raw data self.palette = palette or bytearray() self.dirty = None - if size != 0 and size != len(self.palette): - raise ValueError("wrong palette size") + if size != 0: + warnings.warn( + "The size parameter is deprecated and will be removed in Pillow 10 " + "(2023-01-02).", + DeprecationWarning, + ) + if size != len(self.palette): + raise ValueError("wrong palette size") @property def palette(self): @@ -205,9 +211,9 @@ def make_gamma_lut(exp): def negative(mode="RGB"): - palette = list(range(256)) + palette = list(range(256 * len(mode))) palette.reverse() - return ImagePalette(mode, palette * len(mode)) + return ImagePalette(mode, [i // len(mode) for i in palette]) def random(mode="RGB"): @@ -220,15 +226,13 @@ def random(mode="RGB"): def sepia(white="#fff0c0"): - r, g, b = ImageColor.getrgb(white) - r = make_linear_lut(0, r) - g = make_linear_lut(0, g) - b = make_linear_lut(0, b) - return ImagePalette("RGB", r + g + b) + bands = [make_linear_lut(0, band) for band in ImageColor.getrgb(white)] + return ImagePalette("RGB", [bands[i % 3][i // 3] for i in range(256 * 3)]) def wedge(mode="RGB"): - return ImagePalette(mode, list(range(256)) * len(mode)) + palette = list(range(256 * len(mode))) + return ImagePalette(mode, [i // len(mode) for i in palette]) def load(filename): diff --git a/src/PIL/Jpeg2KImagePlugin.py b/src/PIL/Jpeg2KImagePlugin.py index 0b0d433db..cc7980278 100644 --- a/src/PIL/Jpeg2KImagePlugin.py +++ b/src/PIL/Jpeg2KImagePlugin.py @@ -6,6 +6,7 @@ # # History: # 2014-03-12 ajh Created +# 2021-06-30 rogermb Extract dpi information from the 'resc' header box # # Copyright (c) 2014 Coriolis Systems Limited # Copyright (c) 2014 Alastair Houghton @@ -19,6 +20,79 @@ import struct from . import Image, ImageFile +class BoxReader: + """ + A small helper class to read fields stored in JPEG2000 header boxes + and to easily step into and read sub-boxes. + """ + + def __init__(self, fp, length=-1): + self.fp = fp + self.has_length = length >= 0 + self.length = length + self.remaining_in_box = -1 + + def _can_read(self, num_bytes): + if self.has_length and self.fp.tell() + num_bytes > self.length: + # Outside box: ensure we don't read past the known file length + return False + if self.remaining_in_box >= 0: + # Inside box contents: ensure read does not go past box boundaries + return num_bytes <= self.remaining_in_box + else: + return True # No length known, just read + + def _read_bytes(self, num_bytes): + if not self._can_read(num_bytes): + raise SyntaxError("Not enough data in header") + + data = self.fp.read(num_bytes) + if len(data) < num_bytes: + raise OSError( + f"Expected to read {num_bytes} bytes but only got {len(data)}." + ) + + if self.remaining_in_box > 0: + self.remaining_in_box -= num_bytes + return data + + def read_fields(self, field_format): + size = struct.calcsize(field_format) + data = self._read_bytes(size) + return struct.unpack(field_format, data) + + def read_boxes(self): + size = self.remaining_in_box + data = self._read_bytes(size) + return BoxReader(io.BytesIO(data), size) + + def has_next_box(self): + if self.has_length: + return self.fp.tell() + self.remaining_in_box < self.length + else: + return True + + def next_box_type(self): + # Skip the rest of the box if it has not been read + if self.remaining_in_box > 0: + self.fp.seek(self.remaining_in_box, os.SEEK_CUR) + self.remaining_in_box = -1 + + # Read the length and type of the next box + lbox, tbox = self.read_fields(">I4s") + if lbox == 1: + lbox = self.read_fields(">Q")[0] + hlen = 16 + else: + hlen = 8 + + if lbox < hlen or not self._can_read(lbox - hlen): + raise SyntaxError("Invalid header length") + + self.remaining_in_box = lbox - hlen + return tbox + + def _parse_codestream(fp): """Parse the JPEG 2000 codestream to extract the size and component count from the SIZ marker segment, returning a PIL (size, mode) tuple.""" @@ -53,101 +127,71 @@ def _parse_codestream(fp): return (size, mode) +def _res_to_dpi(num, denom, exp): + """Convert JPEG2000's (numerator, denominator, exponent-base-10) resolution, + calculated as (num / denom) * 10^exp and stored in dots per meter, + to floating-point dots per inch.""" + if denom != 0: + return (254 * num * (10 ** exp)) / (10000 * denom) + + def _parse_jp2_header(fp): - """Parse the JP2 header box to extract size, component count and - color space information, returning a (size, mode, mimetype) tuple.""" + """Parse the JP2 header box to extract size, component count, + color space information, and optionally DPI information, + returning a (size, mode, mimetype, dpi) tuple.""" # Find the JP2 header box + reader = BoxReader(fp) header = None mimetype = None - while True: - lbox, tbox = struct.unpack(">I4s", fp.read(8)) - if lbox == 1: - lbox = struct.unpack(">Q", fp.read(8))[0] - hlen = 16 - else: - hlen = 8 - - if lbox < hlen: - raise SyntaxError("Invalid JP2 header length") + while reader.has_next_box(): + tbox = reader.next_box_type() if tbox == b"jp2h": - header = fp.read(lbox - hlen) + header = reader.read_boxes() break elif tbox == b"ftyp": - if fp.read(4) == b"jpx ": + if reader.read_fields(">4s")[0] == b"jpx ": mimetype = "image/jpx" - fp.seek(lbox - hlen - 4, os.SEEK_CUR) - else: - fp.seek(lbox - hlen, os.SEEK_CUR) - - if header is None: - raise SyntaxError("could not find JP2 header") size = None mode = None bpc = None nc = None + dpi = None # 2-tuple of DPI info, or None - hio = io.BytesIO(header) - while True: - lbox, tbox = struct.unpack(">I4s", hio.read(8)) - if lbox == 1: - lbox = struct.unpack(">Q", hio.read(8))[0] - hlen = 16 - else: - hlen = 8 - - content = hio.read(lbox - hlen) + while header.has_next_box(): + tbox = header.next_box_type() if tbox == b"ihdr": - height, width, nc, bpc, c, unkc, ipr = struct.unpack(">IIHBBBB", content) + height, width, nc, bpc = header.read_fields(">IIHB") size = (width, height) - if unkc: - if nc == 1 and (bpc & 0x7F) > 8: - mode = "I;16" - elif nc == 1: - mode = "L" - elif nc == 2: - mode = "LA" - elif nc == 3: - mode = "RGB" - elif nc == 4: - mode = "RGBA" - break - elif tbox == b"colr": - meth, prec, approx = struct.unpack_from(">BBB", content) - if meth == 1: - cs = struct.unpack_from(">I", content, 3)[0] - if cs == 16: # sRGB - if nc == 1 and (bpc & 0x7F) > 8: - mode = "I;16" - elif nc == 1: - mode = "L" - elif nc == 3: - mode = "RGB" - elif nc == 4: - mode = "RGBA" - break - elif cs == 17: # grayscale - if nc == 1 and (bpc & 0x7F) > 8: - mode = "I;16" - elif nc == 1: - mode = "L" - elif nc == 2: - mode = "LA" - break - elif cs == 18: # sYCC - if nc == 3: - mode = "RGB" - elif nc == 4: - mode = "RGBA" + if nc == 1 and (bpc & 0x7F) > 8: + mode = "I;16" + elif nc == 1: + mode = "L" + elif nc == 2: + mode = "LA" + elif nc == 3: + mode = "RGB" + elif nc == 4: + mode = "RGBA" + elif tbox == b"res ": + res = header.read_boxes() + while res.has_next_box(): + tres = res.next_box_type() + if tres == b"resc": + vrcn, vrcd, hrcn, hrcd, vrce, hrce = res.read_fields(">HHHHBB") + hres = _res_to_dpi(hrcn, hrcd, hrce) + vres = _res_to_dpi(vrcn, vrcd, vrce) + if hres is not None and vres is not None: + dpi = (hres, vres) break if size is None or mode is None: - raise SyntaxError("Malformed jp2 header") + raise SyntaxError("Malformed JP2 header") - return (size, mode, mimetype) + return (size, mode, mimetype, dpi) ## @@ -169,7 +213,9 @@ class Jpeg2KImageFile(ImageFile.ImageFile): if sig == b"\x00\x00\x00\x0cjP \x0d\x0a\x87\x0a": self.codec = "jp2" header = _parse_jp2_header(self.fp) - self._size, self.mode, self.custom_mimetype = header + self._size, self.mode, self.custom_mimetype, dpi = header + if dpi is not None: + self.info["dpi"] = dpi else: raise SyntaxError("not a JPEG 2000 file") diff --git a/src/PIL/JpegImagePlugin.py b/src/PIL/JpegImagePlugin.py index b18e8126f..b8674eeed 100644 --- a/src/PIL/JpegImagePlugin.py +++ b/src/PIL/JpegImagePlugin.py @@ -168,11 +168,11 @@ def APP(self, marker): # 1 dpcm = 2.54 dpi dpi *= 2.54 self.info["dpi"] = dpi, dpi - except (KeyError, SyntaxError, ValueError, ZeroDivisionError): + except (TypeError, KeyError, SyntaxError, ValueError, ZeroDivisionError): # SyntaxError for invalid/unreadable EXIF # KeyError for dpi not included # ZeroDivisionError for invalid dpi rational value - # ValueError for dpi being an invalid float + # ValueError or TypeError for dpi being an invalid float self.info["dpi"] = 72, 72 diff --git a/src/PIL/MspImagePlugin.py b/src/PIL/MspImagePlugin.py index e1fdc1fdf..32b28d44d 100644 --- a/src/PIL/MspImagePlugin.py +++ b/src/PIL/MspImagePlugin.py @@ -21,7 +21,7 @@ # Figure 205. Windows Paint Version 1: "DanM" Format # Figure 206. Windows Paint Version 2: "LinS" Format. Used in Windows V2.03 # -# See also: http://www.fileformat.info/format/mspaint/egff.htm +# See also: https://www.fileformat.info/format/mspaint/egff.htm import io import struct @@ -73,7 +73,7 @@ class MspImageFile(ImageFile.ImageFile): class MspDecoder(ImageFile.PyDecoder): # The algo for the MSP decoder is from - # http://www.fileformat.info/format/mspaint/egff.htm + # https://www.fileformat.info/format/mspaint/egff.htm # cc-by-attribution -- That page references is taken from the # Encyclopedia of Graphics File Formats and is licensed by # O'Reilly under the Creative Common/Attribution license diff --git a/src/PIL/PngImagePlugin.py b/src/PIL/PngImagePlugin.py index bd886e218..0f596f1fd 100644 --- a/src/PIL/PngImagePlugin.py +++ b/src/PIL/PngImagePlugin.py @@ -1061,8 +1061,10 @@ def _write_multiple_frames(im, fp, chunk, rawmode): default_image = im.encoderinfo.get("default_image", im.info.get("default_image")) duration = im.encoderinfo.get("duration", im.info.get("duration", 0)) loop = im.encoderinfo.get("loop", im.info.get("loop", 0)) - disposal = im.encoderinfo.get("disposal", im.info.get("disposal")) - blend = im.encoderinfo.get("blend", im.info.get("blend")) + disposal = im.encoderinfo.get( + "disposal", im.info.get("disposal", APNG_DISPOSE_OP_NONE) + ) + blend = im.encoderinfo.get("blend", im.info.get("blend", APNG_BLEND_OP_SOURCE)) if default_image: chain = itertools.chain(im.encoderinfo.get("append_images", [])) @@ -1117,12 +1119,8 @@ def _write_multiple_frames(im, fp, chunk, rawmode): and prev_disposal == encoderinfo.get("disposal") and prev_blend == encoderinfo.get("blend") ): - duration = encoderinfo.get("duration", 0) - if duration: - if "duration" in previous["encoderinfo"]: - previous["encoderinfo"]["duration"] += duration - else: - previous["encoderinfo"]["duration"] = duration + if isinstance(duration, (list, tuple)): + previous["encoderinfo"]["duration"] += encoderinfo["duration"] continue else: bbox = None @@ -1149,9 +1147,10 @@ def _write_multiple_frames(im, fp, chunk, rawmode): bbox = frame_data["bbox"] im_frame = im_frame.crop(bbox) size = im_frame.size - duration = int(round(frame_data["encoderinfo"].get("duration", 0))) - disposal = frame_data["encoderinfo"].get("disposal", APNG_DISPOSE_OP_NONE) - blend = frame_data["encoderinfo"].get("blend", APNG_BLEND_OP_SOURCE) + encoderinfo = frame_data["encoderinfo"] + frame_duration = int(round(encoderinfo.get("duration", duration))) + frame_disposal = encoderinfo.get("disposal", disposal) + frame_blend = encoderinfo.get("blend", blend) # frame control chunk( fp, @@ -1161,10 +1160,10 @@ def _write_multiple_frames(im, fp, chunk, rawmode): o32(size[1]), # height o32(bbox[0]), # x_offset o32(bbox[1]), # y_offset - o16(duration), # delay_numerator + o16(frame_duration), # delay_numerator o16(1000), # delay_denominator - o8(disposal), # dispose_op - o8(blend), # blend_op + o8(frame_disposal), # dispose_op + o8(frame_blend), # blend_op ) seq_num += 1 # frame data diff --git a/src/PIL/PsdImagePlugin.py b/src/PIL/PsdImagePlugin.py index e7b884674..04b21e3de 100644 --- a/src/PIL/PsdImagePlugin.py +++ b/src/PIL/PsdImagePlugin.py @@ -22,6 +22,7 @@ from . import Image, ImageFile, ImagePalette from ._binary import i8 from ._binary import i16be as i16 from ._binary import i32be as i32 +from ._binary import si16be as si16 MODES = { # (photoshop mode, bits) -> (pil mode, required channels) @@ -179,7 +180,7 @@ def _layerinfo(fp, ct_bytes): def read(size): return ImageFile._safe_read(fp, size) - ct = i16(read(2)) + ct = si16(read(2)) # sanity check if ct_bytes < (abs(ct) * 20): diff --git a/src/PIL/PyAccess.py b/src/PIL/PyAccess.py index 5ceaa238a..eeaa0ccc4 100644 --- a/src/PIL/PyAccess.py +++ b/src/PIL/PyAccess.py @@ -128,7 +128,7 @@ class PyAccess: class _PyAccess32_2(PyAccess): - """ PA, LA, stored in first and last bytes of a 32 bit word """ + """PA, LA, stored in first and last bytes of a 32 bit word""" def _post_init(self, *args, **kwargs): self.pixels = ffi.cast("struct Pixel_RGBA **", self.image32) @@ -145,7 +145,7 @@ class _PyAccess32_2(PyAccess): class _PyAccess32_3(PyAccess): - """ RGB and friends, stored in the first three bytes of a 32 bit word """ + """RGB and friends, stored in the first three bytes of a 32 bit word""" def _post_init(self, *args, **kwargs): self.pixels = ffi.cast("struct Pixel_RGBA **", self.image32) @@ -164,7 +164,7 @@ class _PyAccess32_3(PyAccess): class _PyAccess32_4(PyAccess): - """ RGBA etc, all 4 bytes of a 32 bit word """ + """RGBA etc, all 4 bytes of a 32 bit word""" def _post_init(self, *args, **kwargs): self.pixels = ffi.cast("struct Pixel_RGBA **", self.image32) @@ -183,7 +183,7 @@ class _PyAccess32_4(PyAccess): class _PyAccess8(PyAccess): - """ 1, L, P, 8 bit images stored as uint8 """ + """1, L, P, 8 bit images stored as uint8""" def _post_init(self, *args, **kwargs): self.pixels = self.image8 @@ -201,7 +201,7 @@ class _PyAccess8(PyAccess): class _PyAccessI16_N(PyAccess): - """ I;16 access, native bitendian without conversion """ + """I;16 access, native bitendian without conversion""" def _post_init(self, *args, **kwargs): self.pixels = ffi.cast("unsigned short **", self.image) @@ -219,7 +219,7 @@ class _PyAccessI16_N(PyAccess): class _PyAccessI16_L(PyAccess): - """ I;16L access, with conversion """ + """I;16L access, with conversion""" def _post_init(self, *args, **kwargs): self.pixels = ffi.cast("struct Pixel_I16 **", self.image) @@ -240,7 +240,7 @@ class _PyAccessI16_L(PyAccess): class _PyAccessI16_B(PyAccess): - """ I;16B access, with conversion """ + """I;16B access, with conversion""" def _post_init(self, *args, **kwargs): self.pixels = ffi.cast("struct Pixel_I16 **", self.image) @@ -261,7 +261,7 @@ class _PyAccessI16_B(PyAccess): class _PyAccessI32_N(PyAccess): - """ Signed Int32 access, native endian """ + """Signed Int32 access, native endian""" def _post_init(self, *args, **kwargs): self.pixels = self.image32 @@ -274,7 +274,7 @@ class _PyAccessI32_N(PyAccess): class _PyAccessI32_Swap(PyAccess): - """ I;32L/B access, with byteswapping conversion """ + """I;32L/B access, with byteswapping conversion""" def _post_init(self, *args, **kwargs): self.pixels = self.image32 @@ -293,7 +293,7 @@ class _PyAccessI32_Swap(PyAccess): class _PyAccessF(PyAccess): - """ 32 bit float access """ + """32 bit float access""" def _post_init(self, *args, **kwargs): self.pixels = ffi.cast("float **", self.image32) diff --git a/src/PIL/SgiImagePlugin.py b/src/PIL/SgiImagePlugin.py index d0f7c9993..5f1ef6edc 100644 --- a/src/PIL/SgiImagePlugin.py +++ b/src/PIL/SgiImagePlugin.py @@ -193,7 +193,8 @@ def _save(im, fp, filename): for channel in im.split(): fp.write(channel.tobytes("raw", rawmode, 0, orientation)) - fp.close() + if hasattr(fp, "flush"): + fp.flush() class SGI16Decoder(ImageFile.PyDecoder): diff --git a/src/PIL/TiffImagePlugin.py b/src/PIL/TiffImagePlugin.py index b531e333c..d1e48ef81 100644 --- a/src/PIL/TiffImagePlugin.py +++ b/src/PIL/TiffImagePlugin.py @@ -48,7 +48,7 @@ from collections.abc import MutableMapping from fractions import Fraction from numbers import Number, Rational -from . import Image, ImageFile, ImagePalette, TiffTags +from . import Image, ImageFile, ImageOps, ImagePalette, TiffTags from ._binary import o8 from .TiffTags import TYPES @@ -93,6 +93,7 @@ SUBIFD = 330 EXTRASAMPLES = 338 SAMPLEFORMAT = 339 JPEGTABLES = 347 +YCBCRSUBSAMPLING = 530 REFERENCEBLACKWHITE = 532 COPYRIGHT = 33432 IPTC_NAA_CHUNK = 33723 # newsphoto properties @@ -1497,7 +1498,9 @@ def _save(im, fp, filename): ifd = ImageFileDirectory_v2(prefix=prefix) - compression = im.encoderinfo.get("compression", im.info.get("compression")) + encoderinfo = im.encoderinfo + encoderconfig = im.encoderconfig + compression = encoderinfo.get("compression", im.info.get("compression")) if compression is None: compression = "raw" elif compression == "tiff_jpeg": @@ -1515,10 +1518,10 @@ def _save(im, fp, filename): ifd[IMAGELENGTH] = im.size[1] # write any arbitrary tags passed in as an ImageFileDirectory - if "tiffinfo" in im.encoderinfo: - info = im.encoderinfo["tiffinfo"] - elif "exif" in im.encoderinfo: - info = im.encoderinfo["exif"] + if "tiffinfo" in encoderinfo: + info = encoderinfo["tiffinfo"] + elif "exif" in encoderinfo: + info = encoderinfo["exif"] if isinstance(info, bytes): exif = Image.Exif() exif.load(info) @@ -1556,7 +1559,7 @@ def _save(im, fp, filename): # preserve ICC profile (should also work when saving other formats # which support profiles as TIFF) -- 2008-06-06 Florian Hoech - icc = im.encoderinfo.get("icc_profile", im.info.get("icc_profile")) + icc = encoderinfo.get("icc_profile", im.info.get("icc_profile")) if icc: ifd[ICCPROFILE] = icc @@ -1572,10 +1575,10 @@ def _save(im, fp, filename): (ARTIST, "artist"), (COPYRIGHT, "copyright"), ]: - if name in im.encoderinfo: - ifd[key] = im.encoderinfo[name] + if name in encoderinfo: + ifd[key] = encoderinfo[name] - dpi = im.encoderinfo.get("dpi") + dpi = encoderinfo.get("dpi") if dpi: ifd[RESOLUTION_UNIT] = 2 ifd[X_RESOLUTION] = dpi[0] @@ -1590,7 +1593,18 @@ def _save(im, fp, filename): if format != 1: ifd[SAMPLEFORMAT] = format - ifd[PHOTOMETRIC_INTERPRETATION] = photo + if PHOTOMETRIC_INTERPRETATION not in ifd: + ifd[PHOTOMETRIC_INTERPRETATION] = photo + elif im.mode in ("1", "L") and ifd[PHOTOMETRIC_INTERPRETATION] == 0: + if im.mode == "1": + inverted_im = im.copy() + px = inverted_im.load() + for y in range(inverted_im.height): + for x in range(inverted_im.width): + px[x, y] = 0 if px[x, y] == 255 else 255 + im = inverted_im + else: + im = ImageOps.invert(im) if im.mode in ["P", "PA"]: lut = im.im.getpalette("RGB", "RGB;L") @@ -1600,6 +1614,9 @@ def _save(im, fp, filename): # aim for 64 KB strips when using libtiff writer if libtiff: rows_per_strip = min((2 ** 16 + stride - 1) // stride, im.size[1]) + # JPEG encoder expects multiple of 8 rows + if compression == "jpeg": + rows_per_strip = min(((rows_per_strip + 7) // 8) * 8, im.size[1]) else: rows_per_strip = im.size[1] strip_byte_counts = stride * rows_per_strip @@ -1616,9 +1633,16 @@ def _save(im, fp, filename): # no compression by default: ifd[COMPRESSION] = COMPRESSION_INFO_REV.get(compression, 1) + if im.mode == "YCbCr": + for tag, value in { + YCBCRSUBSAMPLING: (1, 1), + REFERENCEBLACKWHITE: (0, 255, 128, 255, 128, 255), + }.items(): + ifd.setdefault(tag, value) + if libtiff: - if "quality" in im.encoderinfo: - quality = im.encoderinfo["quality"] + if "quality" in encoderinfo: + quality = encoderinfo["quality"] if not isinstance(quality, int) or quality < 0 or quality > 100: raise ValueError("Invalid quality setting") if compression != "jpeg": @@ -1707,7 +1731,7 @@ def _save(im, fp, filename): tags = list(atts.items()) tags.sort() a = (rawmode, compression, _fp, filename, tags, types) - e = Image._getencoder(im.mode, "libtiff", a, im.encoderconfig) + e = Image._getencoder(im.mode, "libtiff", a, encoderconfig) e.setimage(im.im, (0, 0) + im.size) while True: # undone, change to self.decodermaxblock: @@ -1727,7 +1751,7 @@ def _save(im, fp, filename): ) # -- helper for multi-page save -- - if "_debug_multipage" in im.encoderinfo: + if "_debug_multipage" in encoderinfo: # just to access o32 and o16 (using correct byte order) im._debug_multipage = ifd diff --git a/src/PIL/WalImageFile.py b/src/PIL/WalImageFile.py index b578d6981..1354ad32b 100644 --- a/src/PIL/WalImageFile.py +++ b/src/PIL/WalImageFile.py @@ -23,12 +23,44 @@ and has been tested with a few sample files found using google. To open a WAL file, use the :py:func:`PIL.WalImageFile.open()` function instead. """ -import builtins - -from . import Image +from . import Image, ImageFile from ._binary import i32le as i32 +class WalImageFile(ImageFile.ImageFile): + + format = "WAL" + format_description = "Quake2 Texture" + + def _open(self): + self.mode = "P" + + # read header fields + header = self.fp.read(32 + 24 + 32 + 12) + self._size = i32(header, 32), i32(header, 36) + Image._decompression_bomb_check(self.size) + + # load pixel data + offset = i32(header, 40) + self.fp.seek(offset) + + # strings are null-terminated + self.info["name"] = header[:32].split(b"\0", 1)[0] + next_name = header[56 : 56 + 32].split(b"\0", 1)[0] + if next_name: + self.info["next_name"] = next_name + + def load(self): + if self.im: + # Already loaded + return + + self.im = Image.core.new(self.mode, self.size) + self.frombytes(self.fp.read(self.size[0] * self.size[1])) + self.putpalette(quake2palette) + Image.Image.load(self) + + def open(filename): """ Load texture from a Quake2 WAL texture file. @@ -39,38 +71,7 @@ def open(filename): :param filename: WAL file name, or an opened file handle. :returns: An image instance. """ - # FIXME: modify to return a WalImageFile instance instead of - # plain Image object ? - - def imopen(fp): - # read header fields - header = fp.read(32 + 24 + 32 + 12) - size = i32(header, 32), i32(header, 36) - offset = i32(header, 40) - - # load pixel data - fp.seek(offset) - - Image._decompression_bomb_check(size) - im = Image.frombytes("P", size, fp.read(size[0] * size[1])) - im.putpalette(quake2palette) - - im.format = "WAL" - im.format_description = "Quake2 Texture" - - # strings are null-terminated - im.info["name"] = header[:32].split(b"\0", 1)[0] - next_name = header[56 : 56 + 32].split(b"\0", 1)[0] - if next_name: - im.info["next_name"] = next_name - - return im - - if hasattr(filename, "read"): - return imopen(filename) - else: - with builtins.open(filename, "rb") as fp: - return imopen(fp) + return WalImageFile(filename) quake2palette = ( diff --git a/src/PIL/WebPImagePlugin.py b/src/PIL/WebPImagePlugin.py index b63a07ca8..590161f3e 100644 --- a/src/PIL/WebPImagePlugin.py +++ b/src/PIL/WebPImagePlugin.py @@ -202,7 +202,7 @@ def _save_all(im, fp, filename): lossless = im.encoderinfo.get("lossless", False) quality = im.encoderinfo.get("quality", 80) method = im.encoderinfo.get("method", 0) - icc_profile = im.encoderinfo.get("icc_profile", "") + icc_profile = im.encoderinfo.get("icc_profile") or "" exif = im.encoderinfo.get("exif", "") if isinstance(exif, Image.Exif): exif = exif.tobytes() @@ -309,7 +309,7 @@ def _save_all(im, fp, filename): def _save(im, fp, filename): lossless = im.encoderinfo.get("lossless", False) quality = im.encoderinfo.get("quality", 80) - icc_profile = im.encoderinfo.get("icc_profile", "") + icc_profile = im.encoderinfo.get("icc_profile") or "" exif = im.encoderinfo.get("exif", "") if isinstance(exif, Image.Exif): exif = exif.tobytes() diff --git a/src/PIL/_binary.py b/src/PIL/_binary.py index 5564f450d..a74ee9eb6 100644 --- a/src/PIL/_binary.py +++ b/src/PIL/_binary.py @@ -47,6 +47,16 @@ def si16le(c, o=0): return unpack_from("h", c, o)[0] + + def i32le(c, o=0): """ Converts a 4-bytes (32 bits) string to an unsigned integer. diff --git a/src/Tk/_tkmini.h b/src/Tk/_tkmini.h index b6945eb1a..9852fc9d6 100644 --- a/src/Tk/_tkmini.h +++ b/src/Tk/_tkmini.h @@ -1,7 +1,7 @@ /* Small excerpts from the Tcl / Tk 8.6 headers * * License terms copied from: - * http://www.tcl.tk/software/tcltk/license.html + * https://www.tcl.tk/software/tcltk/license.html * as of 20 May 2016. * * Copyright (c) 1987-1994 The Regents of the University of California. diff --git a/src/encode.c b/src/encode.c index daa806ff4..5933e79a5 100644 --- a/src/encode.c +++ b/src/encode.c @@ -808,6 +808,12 @@ PyImaging_LibTiffEncoderNew(PyObject *self, PyObject *args) { av + stride * 2); free(av); } + } else if (key_int == TIFFTAG_YCBCRSUBSAMPLING) { + status = ImagingLibTiffSetField( + &encoder->state, + (ttag_t)key_int, + (UINT16)PyLong_AsLong(PyTuple_GetItem(value, 0)), + (UINT16)PyLong_AsLong(PyTuple_GetItem(value, 1))); } else if (type == TIFF_SHORT) { UINT16 *av; /* malloc check ok, calloc checks for overflow */ diff --git a/src/libImaging/Access.c b/src/libImaging/Access.c index 6bb16fe3a..514fb2929 100644 --- a/src/libImaging/Access.c +++ b/src/libImaging/Access.c @@ -11,7 +11,7 @@ #include "Imaging.h" -/* use Tests/make_hash.py to calculate these values */ +/* use make_hash.py from the pillow-scripts repository to calculate these values */ #define ACCESS_TABLE_SIZE 27 #define ACCESS_TABLE_HASH 3078 diff --git a/src/libImaging/FliDecode.c b/src/libImaging/FliDecode.c index 3a6030703..d6e4ea0ff 100644 --- a/src/libImaging/FliDecode.c +++ b/src/libImaging/FliDecode.c @@ -46,7 +46,8 @@ ImagingFliDecode(Imaging im, ImagingCodecState state, UINT8 *buf, Py_ssize_t byt ptr = buf; framesize = I32(ptr); - if (framesize < I32(ptr)) { + // there can be one pad byte in the framesize + if (bytes + (bytes % 2) < framesize) { return 0; } @@ -223,8 +224,15 @@ ImagingFliDecode(Imaging im, ImagingCodecState state, UINT8 *buf, Py_ssize_t byt break; case 16: /* COPY chunk */ - if (state->xsize > bytes / state->ysize) { + if (INT32_MAX / state->xsize < state->ysize) { + /* Integer overflow, bail */ + state->errcode = IMAGING_CODEC_OVERRUN; + return -1; + } + /* Note, have to check Data + size, not just ptr + size) */ + if (data + (state->xsize * state->ysize) > ptr + bytes) { /* not enough data for frame */ + /* UNDONE Unclear that we're actually going to leave the buffer at the right place. */ return ptr - buf; /* bytes consumed */ } for (y = 0; y < state->ysize; y++) { diff --git a/src/libImaging/ImagingUtils.h b/src/libImaging/ImagingUtils.h index ad6f280ac..0c0c1eda9 100644 --- a/src/libImaging/ImagingUtils.h +++ b/src/libImaging/ImagingUtils.h @@ -29,7 +29,7 @@ /* This is to work around a bug in GCC prior 4.9 in 64 bit mode. GCC generates code with partial dependency which is 3 times slower. - See: http://stackoverflow.com/a/26588074/253146 */ + See: https://stackoverflow.com/a/26588074/253146 */ #if defined(__x86_64__) && defined(__SSE__) && !defined(__NO_INLINE__) && \ !defined(__clang__) && defined(GCC_VERSION) && (GCC_VERSION < 40900) static float __attribute__((always_inline)) inline _i2f(int v) { diff --git a/src/libImaging/Paste.c b/src/libImaging/Paste.c index a1bf18a92..be26cd260 100644 --- a/src/libImaging/Paste.c +++ b/src/libImaging/Paste.c @@ -417,9 +417,16 @@ fill_mask_L( if (imOut->image8) { for (y = 0; y < ysize; y++) { UINT8 *out = imOut->image8[y + dy] + dx; + if (strncmp(imOut->mode, "I;16", 4) == 0) { + out += dx; + } UINT8 *mask = imMask->image8[y + sy] + sx; for (x = 0; x < xsize; x++) { *out = BLEND(*mask, *out, ink[0], tmp1); + if (strncmp(imOut->mode, "I;16", 4) == 0) { + out++; + *out = BLEND(*mask, *out, ink[0], tmp1); + } out++, mask++; } } diff --git a/src/thirdparty/fribidi-shim/fribidi.c b/src/thirdparty/fribidi-shim/fribidi.c index abbab07b0..76fd5b9a4 100644 --- a/src/thirdparty/fribidi-shim/fribidi.c +++ b/src/thirdparty/fribidi-shim/fribidi.c @@ -12,7 +12,7 @@ /* FriBiDi>=1.0.0 adds bracket_types param, ignore and call legacy function */ -FriBidiLevel fribidi_get_par_embedding_levels_ex_compat( +static FriBidiLevel fribidi_get_par_embedding_levels_ex_compat( const FriBidiCharType *bidi_types, const FriBidiBracketType *bracket_types, const FriBidiStrIndex len, @@ -24,7 +24,7 @@ FriBidiLevel fribidi_get_par_embedding_levels_ex_compat( } /* FriBiDi>=1.0.0 gets bracket types here, ignore */ -void fribidi_get_bracket_types_compat( +static void fribidi_get_bracket_types_compat( const FriBidiChar *str, const FriBidiStrIndex len, const FriBidiCharType *types, diff --git a/src/thirdparty/fribidi-shim/fribidi.h b/src/thirdparty/fribidi-shim/fribidi.h index 7712a5b22..7e175c3db 100644 --- a/src/thirdparty/fribidi-shim/fribidi.h +++ b/src/thirdparty/fribidi-shim/fribidi.h @@ -63,8 +63,12 @@ typedef uint32_t FriBidiParType; /* functions */ #ifdef FRIBIDI_SHIM_IMPLEMENTATION +#ifdef _MSC_VER #define FRIBIDI_ENTRY #else +#define FRIBIDI_ENTRY __attribute__((visibility ("hidden"))) +#endif +#else #define FRIBIDI_ENTRY extern #endif diff --git a/src/thirdparty/raqm/raqm.c b/src/thirdparty/raqm/raqm.c index 5a0b2078e..9f6be676c 100644 --- a/src/thirdparty/raqm/raqm.c +++ b/src/thirdparty/raqm/raqm.c @@ -491,7 +491,7 @@ raqm_set_text_utf8 (raqm_t *rq, * * The default is #RAQM_DIRECTION_DEFAULT, which determines the paragraph * direction based on the first character with strong bidi type (see [rule - * P2](http://unicode.org/reports/tr9/#P2) in Unicode Bidirectional Algorithm), + * P2](https://unicode.org/reports/tr9/#P2) in Unicode Bidirectional Algorithm), * which can be good enough for many cases but has problems when a mainly * right-to-left paragraph starts with a left-to-right character and vice versa * as the detected paragraph direction will be the wrong one, or when text does diff --git a/winbuild/build_prepare.py b/winbuild/build_prepare.py index 63270d753..ffffaf662 100644 --- a/winbuild/build_prepare.py +++ b/winbuild/build_prepare.py @@ -105,9 +105,9 @@ header = [ # dependencies, listed in order of compilation deps = { "libjpeg": { - "url": SF_MIRROR + "/project/libjpeg-turbo/2.1.0/libjpeg-turbo-2.1.0.tar.gz", - "filename": "libjpeg-turbo-2.1.0.tar.gz", - "dir": "libjpeg-turbo-2.1.0", + "url": SF_MIRROR + "/project/libjpeg-turbo/2.1.1/libjpeg-turbo-2.1.1.tar.gz", + "filename": "libjpeg-turbo-2.1.1.tar.gz", + "dir": "libjpeg-turbo-2.1.1", "build": [ cmd_cmake( [ @@ -154,9 +154,9 @@ deps = { # "bins": [r"libtiff\*.dll"], }, "libwebp": { - "url": "http://downloads.webmproject.org/releases/webp/libwebp-1.2.0.tar.gz", - "filename": "libwebp-1.2.0.tar.gz", - "dir": "libwebp-1.2.0", + "url": "http://downloads.webmproject.org/releases/webp/libwebp-1.2.1.tar.gz", + "filename": "libwebp-1.2.1.tar.gz", + "dir": "libwebp-1.2.1", "build": [ cmd_rmdir(r"output\release-static"), # clean cmd_nmake( @@ -277,9 +277,9 @@ deps = { "libs": [r"*.lib"], }, "harfbuzz": { - "url": "https://github.com/harfbuzz/harfbuzz/archive/2.8.1.zip", - "filename": "harfbuzz-2.8.1.zip", - "dir": "harfbuzz-2.8.1", + "url": "https://github.com/harfbuzz/harfbuzz/archive/2.9.0.zip", + "filename": "harfbuzz-2.9.0.zip", + "dir": "harfbuzz-2.9.0", "build": [ cmd_cmake("-DHB_HAVE_FREETYPE:BOOL=TRUE"), cmd_nmake(target="clean"),