diff --git a/.coveragerc b/.coveragerc index f71b6b1a2..46df3f90d 100644 --- a/.coveragerc +++ b/.coveragerc @@ -2,15 +2,14 @@ [report] # Regexes for lines to exclude from consideration -exclude_lines = - # Have to re-enable the standard pragma: - pragma: no cover - - # Don't complain if non-runnable code isn't run: +exclude_also = + # Don't complain if non-runnable code isn't run if 0: if __name__ == .__main__.: # Don't complain about debug code if DEBUG: + # Don't complain about compatibility code for missing optional dependencies + except ImportError [run] omit = diff --git a/.github/problem-matchers/gcc.json b/.github/problem-matchers/gcc.json new file mode 100644 index 000000000..8e2866afe --- /dev/null +++ b/.github/problem-matchers/gcc.json @@ -0,0 +1,18 @@ +{ + "__comment": "Based on vscode-cpptools' Extension/package.json gcc rule", + "problemMatcher": [ + { + "owner": "gcc-problem-matcher", + "pattern": [ + { + "regexp": "^\\s*(.*):(\\d+):(\\d+):\\s+(?:fatal\\s+)?(warning|error):\\s+(.*)$", + "file": 1, + "line": 2, + "column": 3, + "severity": 4, + "message": 5 + } + ] + } + ] +} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index aa0e25138..b7e112f43 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -86,6 +86,10 @@ jobs: env: GHA_PYTHON_VERSION: ${{ matrix.python-version }} + - name: Register gcc problem matcher + if: "matrix.os == 'ubuntu-latest' && matrix.python-version == '3.12'" + run: echo "::add-matcher::.github/problem-matchers/gcc.json" + - name: Build run: | .ci/build.sh diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index 060fc497e..5adff7ec1 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -143,6 +143,7 @@ jobs: CIBW_ARCHS: ${{ matrix.cibw_arch }} CIBW_BEFORE_ALL: "{package}\\winbuild\\build\\build_dep_all.cmd" CIBW_CACHE_PATH: "C:\\cibw" + CIBW_SKIP: pp38-* CIBW_TEST_SKIP: "*-win_arm64" CIBW_TEST_COMMAND: 'docker run --rm -v {project}:C:\pillow diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index d1c4b8015..6adc75b49 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,12 +1,12 @@ repos: - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.1.7 + rev: v0.1.9 hooks: - id: ruff args: [--fix, --exit-non-zero-on-fix] - repo: https://github.com/psf/black-pre-commit-mirror - rev: 23.12.0 + rev: 23.12.1 hooks: - id: black diff --git a/CHANGES.rst b/CHANGES.rst index 1dc8e9aaa..85036f642 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,9 +2,36 @@ Changelog (Pillow) ================== -10.2.0 (unreleased) +10.2.0 (2024-01-02) ------------------- +- Add ``keep_rgb`` option when saving JPEG to prevent conversion of RGB colorspace #7553 + [bgilbert, radarhere] + +- Trim glyph size in ImageFont.getmask() #7669, #7672 + [radarhere, nulano] + +- Deprecate IptcImagePlugin helpers #7664 + [nulano, hugovk, radarhere] + +- Allow uncompressed TIFF images to be saved in chunks #7650 + [radarhere] + +- Concatenate multiple JPEG EXIF markers #7496 + [radarhere] + +- Changed IPTC tile tuple to match other plugins #7661 + [radarhere] + +- Do not assign new fp attribute when exiting context manager #7566 + [radarhere] + +- Support arbitrary masks for uncompressed RGB DDS images #7589 + [radarhere, akx] + +- Support setting ROWSPERSTRIP tag #7654 + [radarhere] + - Apply ImageFont.MAX_STRING_LENGTH to ImageFont.getmask() #7662 [radarhere] diff --git a/LICENSE b/LICENSE index cf65e86d7..0069eb5bc 100644 --- a/LICENSE +++ b/LICENSE @@ -5,7 +5,7 @@ The Python Imaging Library (PIL) is Pillow is the friendly PIL fork. It is - Copyright © 2010-2023 by Jeffrey A. Clark (Alex) and contributors. + Copyright © 2010-2024 by Jeffrey A. Clark (Alex) and contributors. Like PIL, Pillow is licensed under the open source HPND License: diff --git a/RELEASING.md b/RELEASING.md index b3fd72a52..97f4f8dcd 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -87,7 +87,7 @@ Released as needed privately to individual vendors for critical security-related and copy into `dist`. Check and upload them e.g.: ```bash python3 -m twine check --strict dist/* - python3 -m twine upload dist/Pillow-5.2.0* + python3 -m twine upload dist/pillow-5.2.0* ``` ## Publicize Release diff --git a/Tests/images/bgr15.dds b/Tests/images/bgr15.dds new file mode 100644 index 000000000..ba3bbddca Binary files /dev/null and b/Tests/images/bgr15.dds differ diff --git a/Tests/images/bgr15.png b/Tests/images/bgr15.png new file mode 100644 index 000000000..a15ab5ad2 Binary files /dev/null and b/Tests/images/bgr15.png differ diff --git a/Tests/images/multiple_exif.jpg b/Tests/images/multiple_exif.jpg new file mode 100644 index 000000000..32e0aa301 Binary files /dev/null and b/Tests/images/multiple_exif.jpg differ diff --git a/Tests/images/unsupported_bitcount_luminance.dds b/Tests/images/unsupported_bitcount.dds similarity index 100% rename from Tests/images/unsupported_bitcount_luminance.dds rename to Tests/images/unsupported_bitcount.dds diff --git a/Tests/images/unsupported_bitcount_rgb.dds b/Tests/images/unsupported_bitcount_rgb.dds deleted file mode 100644 index 77d527507..000000000 Binary files a/Tests/images/unsupported_bitcount_rgb.dds and /dev/null differ diff --git a/Tests/test_file_dds.py b/Tests/test_file_dds.py index 2d60fbb64..a605c8399 100644 --- a/Tests/test_file_dds.py +++ b/Tests/test_file_dds.py @@ -32,6 +32,7 @@ TEST_FILE_DX10_R8G8B8A8_UNORM_SRGB = "Tests/images/DXGI_FORMAT_R8G8B8A8_UNORM_SR TEST_FILE_UNCOMPRESSED_L = "Tests/images/uncompressed_l.dds" TEST_FILE_UNCOMPRESSED_L_WITH_ALPHA = "Tests/images/uncompressed_la.dds" TEST_FILE_UNCOMPRESSED_RGB = "Tests/images/hopper.dds" +TEST_FILE_UNCOMPRESSED_BGR15 = "Tests/images/bgr15.dds" TEST_FILE_UNCOMPRESSED_RGB_WITH_ALPHA = "Tests/images/uncompressed_rgb.dds" @@ -249,6 +250,7 @@ def test_dx10_r8g8b8a8_unorm_srgb(): ("L", (128, 128), TEST_FILE_UNCOMPRESSED_L), ("LA", (128, 128), TEST_FILE_UNCOMPRESSED_L_WITH_ALPHA), ("RGB", (128, 128), TEST_FILE_UNCOMPRESSED_RGB), + ("RGB", (128, 128), TEST_FILE_UNCOMPRESSED_BGR15), ("RGBA", (800, 600), TEST_FILE_UNCOMPRESSED_RGB_WITH_ALPHA), ], ) @@ -341,16 +343,9 @@ def test_palette(): assert_image_equal_tofile(im, "Tests/images/transparent.gif") -@pytest.mark.parametrize( - "test_file", - ( - "Tests/images/unsupported_bitcount_rgb.dds", - "Tests/images/unsupported_bitcount_luminance.dds", - ), -) -def test_unsupported_bitcount(test_file): +def test_unsupported_bitcount(): with pytest.raises(OSError): - with Image.open(test_file): + with Image.open("Tests/images/unsupported_bitcount.dds"): pass diff --git a/Tests/test_file_iptc.py b/Tests/test_file_iptc.py index d0ecde393..c44a08f52 100644 --- a/Tests/test_file_iptc.py +++ b/Tests/test_file_iptc.py @@ -6,11 +6,23 @@ import pytest from PIL import Image, IptcImagePlugin -from .helper import hopper +from .helper import assert_image_equal, hopper TEST_FILE = "Tests/images/iptc.jpg" +def test_open(): + expected = Image.new("L", (1, 1)) + + f = BytesIO( + b"\x1c\x03<\x00\x02\x01\x00\x1c\x03x\x00\x01\x01\x1c\x03\x14\x00\x01\x01" + b"\x1c\x03\x1e\x00\x01\x01\x1c\x08\n\x00\x01\x00" + ) + with Image.open(f) as im: + assert im.tile == [("iptc", (0, 0, 1, 1), 25, "raw")] + assert_image_equal(im, expected) + + def test_getiptcinfo_jpg_none(): # Arrange with hopper() as im: @@ -78,24 +90,28 @@ def test_i(): c = b"a" # Act - ret = IptcImagePlugin.i(c) + with pytest.warns(DeprecationWarning): + ret = IptcImagePlugin.i(c) # Assert assert ret == 97 -def test_dump(): +def test_dump(monkeypatch): # Arrange c = b"abc" # Temporarily redirect stdout - old_stdout = sys.stdout - sys.stdout = mystdout = StringIO() + mystdout = StringIO() + monkeypatch.setattr(sys, "stdout", mystdout) # Act - IptcImagePlugin.dump(c) - - # Reset stdout - sys.stdout = old_stdout + with pytest.warns(DeprecationWarning): + IptcImagePlugin.dump(c) # Assert assert mystdout.getvalue() == "61 62 63 \n" + + +def test_pad_deprecation(): + with pytest.warns(DeprecationWarning): + assert IptcImagePlugin.PAD == b"\0\0\0\0" diff --git a/Tests/test_file_jpeg.py b/Tests/test_file_jpeg.py index ffaea6296..979c7e33d 100644 --- a/Tests/test_file_jpeg.py +++ b/Tests/test_file_jpeg.py @@ -142,6 +142,19 @@ class TestFileJpeg: ) assert k > 0.9 + def test_rgb(self): + def getchannels(im): + return tuple(v[0] for v in im.layer) + + im = hopper() + im_ycbcr = self.roundtrip(im) + assert getchannels(im_ycbcr) == (1, 2, 3) + assert_image_similar(im, im_ycbcr, 17) + + im_rgb = self.roundtrip(im, keep_rgb=True) + assert getchannels(im_rgb) == (ord("R"), ord("G"), ord("B")) + assert_image_similar(im, im_rgb, 12) + @pytest.mark.parametrize( "test_image_path", [TEST_FILE, "Tests/images/pil_sample_cmyk.jpg"], @@ -423,25 +436,28 @@ class TestFileJpeg: return layer[0][1:3] + layer[1][1:3] + layer[2][1:3] # experimental API - im = self.roundtrip(hopper(), subsampling=-1) # default - assert getsampling(im) == (2, 2, 1, 1, 1, 1) - im = self.roundtrip(hopper(), subsampling=0) # 4:4:4 - assert getsampling(im) == (1, 1, 1, 1, 1, 1) - im = self.roundtrip(hopper(), subsampling=1) # 4:2:2 - assert getsampling(im) == (2, 1, 1, 1, 1, 1) - im = self.roundtrip(hopper(), subsampling=2) # 4:2:0 - assert getsampling(im) == (2, 2, 1, 1, 1, 1) - im = self.roundtrip(hopper(), subsampling=3) # default (undefined) - assert getsampling(im) == (2, 2, 1, 1, 1, 1) + for subsampling in (-1, 3): # (default, invalid) + im = self.roundtrip(hopper(), subsampling=subsampling) + assert getsampling(im) == (2, 2, 1, 1, 1, 1) + for subsampling in (0, "4:4:4"): + im = self.roundtrip(hopper(), subsampling=subsampling) + assert getsampling(im) == (1, 1, 1, 1, 1, 1) + for subsampling in (1, "4:2:2"): + im = self.roundtrip(hopper(), subsampling=subsampling) + assert getsampling(im) == (2, 1, 1, 1, 1, 1) + for subsampling in (2, "4:2:0", "4:1:1"): + im = self.roundtrip(hopper(), subsampling=subsampling) + assert getsampling(im) == (2, 2, 1, 1, 1, 1) - im = self.roundtrip(hopper(), subsampling="4:4:4") - assert getsampling(im) == (1, 1, 1, 1, 1, 1) - im = self.roundtrip(hopper(), subsampling="4:2:2") - assert getsampling(im) == (2, 1, 1, 1, 1, 1) - im = self.roundtrip(hopper(), subsampling="4:2:0") - assert getsampling(im) == (2, 2, 1, 1, 1, 1) - im = self.roundtrip(hopper(), subsampling="4:1:1") - assert getsampling(im) == (2, 2, 1, 1, 1, 1) + # RGB colorspace + for subsampling in (-1, 0, "4:4:4"): + # "4:4:4" doesn't really make sense for RGB, but the conversion + # to an integer happens at a higher level + im = self.roundtrip(hopper(), keep_rgb=True, subsampling=subsampling) + assert getsampling(im) == (1, 1, 1, 1, 1, 1) + for subsampling in (1, "4:2:2", 2, "4:2:0", 3): + with pytest.raises(OSError): + self.roundtrip(hopper(), keep_rgb=True, subsampling=subsampling) with pytest.raises(TypeError): self.roundtrip(hopper(), subsampling="1:1:1") @@ -840,6 +856,10 @@ class TestFileJpeg: # Act / Assert assert im._getexif()[306] == "2017:03:13 23:03:09" + def test_multiple_exif(self): + with Image.open("Tests/images/multiple_exif.jpg") as im: + assert im.info["exif"] == b"Exif\x00\x00firstsecond" + @mark_if_feature_version( pytest.mark.valgrind_known_error, "libjpeg_turbo", "2.0", reason="Known Failing" ) diff --git a/Tests/test_file_tiff.py b/Tests/test_file_tiff.py index 0851796d0..a50f50e5e 100644 --- a/Tests/test_file_tiff.py +++ b/Tests/test_file_tiff.py @@ -484,13 +484,13 @@ class TestFileTiff: outfile = str(tmp_path / "temp.tif") with Image.open("Tests/images/ifd_tag_type.tiff") as im: exif = im.getexif() - exif[256] = 100 + exif[264] = 100 im.save(outfile, exif=exif) with Image.open(outfile) as im: exif = im.getexif() - assert exif[256] == 100 + assert exif[264] == 100 def test_reload_exif_after_seek(self): with Image.open("Tests/images/multipage.tiff") as im: @@ -612,6 +612,14 @@ class TestFileTiff: assert_image_equal_tofile(im, tmpfile) + def test_rowsperstrip(self, tmp_path): + outfile = str(tmp_path / "temp.tif") + im = hopper() + im.save(outfile, tiffinfo={278: 256}) + + with Image.open(outfile) as im: + assert im.tag_v2[278] == 256 + def test_strip_raw(self): infile = "Tests/images/tiff_strip_raw.tif" with Image.open(infile) as im: @@ -773,6 +781,27 @@ class TestFileTiff: 4001, ] + def test_tiff_chunks(self, tmp_path): + tmpfile = str(tmp_path / "temp.tif") + + im = hopper() + with open(tmpfile, "wb") as fp: + for y in range(0, 128, 32): + chunk = im.crop((0, y, 128, y + 32)) + if y == 0: + chunk.save( + fp, + "TIFF", + tiffinfo={ + TiffImagePlugin.IMAGEWIDTH: 128, + TiffImagePlugin.IMAGELENGTH: 128, + }, + ) + else: + fp.write(chunk.tobytes()) + + assert_image_equal_tofile(im, tmpfile) + def test_close_on_load_exclusive(self, tmp_path): # similar to test_fd_leak, but runs on unixlike os tmpfile = str(tmp_path / "temp.tif") diff --git a/Tests/test_file_tiff_metadata.py b/Tests/test_file_tiff_metadata.py index edd57e6b5..ee6968185 100644 --- a/Tests/test_file_tiff_metadata.py +++ b/Tests/test_file_tiff_metadata.py @@ -123,6 +123,7 @@ def test_write_metadata(tmp_path): """Test metadata writing through the python code""" with Image.open("Tests/images/hopper.tif") as img: f = str(tmp_path / "temp.tiff") + del img.tag[278] img.save(f, tiffinfo=img.tag) original = img.tag_v2.named() @@ -159,9 +160,11 @@ def test_change_stripbytecounts_tag_type(tmp_path): out = str(tmp_path / "temp.tiff") with Image.open("Tests/images/hopper.tif") as im: info = im.tag_v2 + del info[278] # Resize the image so that STRIPBYTECOUNTS will be larger than a SHORT im = im.resize((500, 500)) + info[TiffImagePlugin.IMAGEWIDTH] = im.width # STRIPBYTECOUNTS can be a SHORT or a LONG info.tagtype[TiffImagePlugin.STRIPBYTECOUNTS] = TiffTags.SHORT diff --git a/Tests/test_image.py b/Tests/test_image.py index 615e00e40..80f6583d8 100644 --- a/Tests/test_image.py +++ b/Tests/test_image.py @@ -1016,6 +1016,11 @@ class TestImage: except OSError as e: assert str(e) == "buffer overrun when reading image file" + def test_exit_fp(self): + with Image.new("L", (1, 1)) as im: + pass + assert not hasattr(im, "fp") + def test_close_graceful(self, caplog): with Image.open("Tests/images/hopper.jpg") as im: copy = im.copy() diff --git a/Tests/test_imagefontpil.py b/Tests/test_imagefontpil.py index 21b4dee3c..9e0855101 100644 --- a/Tests/test_imagefontpil.py +++ b/Tests/test_imagefontpil.py @@ -1,14 +1,22 @@ from __future__ import annotations +import struct import pytest +from io import BytesIO -from PIL import Image, ImageDraw, ImageFont, features +from PIL import Image, ImageDraw, ImageFont, features, _util from .helper import assert_image_equal_tofile -pytestmark = pytest.mark.skipif( - features.check_module("freetype2"), - reason="PILfont superseded if FreeType is supported", -) +original_core = ImageFont.core + + +def setup_module(): + if features.check_module("freetype2"): + ImageFont.core = _util.DeferredError(ImportError) + + +def teardown_module(): + ImageFont.core = original_core def test_default_font(): @@ -44,3 +52,25 @@ def test_textbbox(): default_font = ImageFont.load_default() assert d.textlength("test", font=default_font) == 24 assert d.textbbox((0, 0), "test", font=default_font) == (0, 0, 24, 11) + + +def test_decompression_bomb(): + glyph = struct.pack(">hhhhhhhhhh", 1, 0, 0, 0, 256, 256, 0, 0, 256, 256) + fp = BytesIO(b"PILfont\n\nDATA\n" + glyph * 256) + + font = ImageFont.ImageFont() + font._load_pilfont_data(fp, Image.new("L", (256, 256))) + with pytest.raises(Image.DecompressionBombError): + font.getmask("A" * 1_000_000) + + +@pytest.mark.timeout(4) +def test_oom(): + glyph = struct.pack( + ">hhhhhhhhhh", 1, 0, -32767, -32767, 32767, 32767, -32767, -32767, 32767, 32767 + ) + fp = BytesIO(b"PILfont\n\nDATA\n" + glyph * 256) + + font = ImageFont.ImageFont() + font._load_pilfont_data(fp, Image.new("L", (1, 1))) + font.getmask("A" * 1_000_000) diff --git a/Tests/test_imagemorph.py b/Tests/test_imagemorph.py index ec55aadf9..64a1785ea 100644 --- a/Tests/test_imagemorph.py +++ b/Tests/test_imagemorph.py @@ -57,15 +57,6 @@ def test_str_to_img(): assert_image_equal_tofile(A, "Tests/images/morph_a.png") -def create_lut(): - for op in ("corner", "dilation4", "dilation8", "erosion4", "erosion8", "edge"): - lb = ImageMorph.LutBuilder(op_name=op) - lut = lb.build_lut() - with open(f"Tests/images/{op}.lut", "wb") as f: - f.write(lut) - - -# create_lut() @pytest.mark.parametrize( "op", ("corner", "dilation4", "dilation8", "erosion4", "erosion8", "edge") ) diff --git a/Tests/test_util.py b/Tests/test_util.py index 1457d85f7..4a312beb4 100644 --- a/Tests/test_util.py +++ b/Tests/test_util.py @@ -66,7 +66,7 @@ def test_deferred_error(): # Arrange # Act - thing = _util.DeferredError(ValueError("Some error text")) + thing = _util.DeferredError.new(ValueError("Some error text")) # Assert with pytest.raises(ValueError): diff --git a/docs/COPYING b/docs/COPYING index bc44ba388..73af6d99c 100644 --- a/docs/COPYING +++ b/docs/COPYING @@ -5,7 +5,7 @@ The Python Imaging Library (PIL) is Pillow is the friendly PIL fork. It is - Copyright © 2010-2023 by Jeffrey A. Clark (Alex) and contributors + Copyright © 2010-2024 by Jeffrey A. Clark (Alex) and contributors Like PIL, Pillow is licensed under the open source PIL Software License: diff --git a/docs/PIL.rst b/docs/PIL.rst index fa036b9cc..b6944e234 100644 --- a/docs/PIL.rst +++ b/docs/PIL.rst @@ -69,6 +69,14 @@ can be found here. :undoc-members: :show-inheritance: +:mod:`~PIL.ImageMode` Module +---------------------------- + +.. automodule:: PIL.ImageMode + :members: + :undoc-members: + :show-inheritance: + :mod:`~PIL.ImageTransform` Module --------------------------------- diff --git a/docs/conf.py b/docs/conf.py index a70dece74..9ae7ae605 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -54,7 +54,7 @@ master_doc = "index" # General information about the project. project = "Pillow (PIL Fork)" copyright = ( - "1995-2011 Fredrik Lundh, 2010-2023 Jeffrey A. Clark (Alex) and contributors" + "1995-2011 Fredrik Lundh, 2010-2024 Jeffrey A. Clark (Alex) and contributors" ) author = "Fredrik Lundh, Jeffrey A. Clark (Alex), contributors" diff --git a/docs/deprecations.rst b/docs/deprecations.rst index 75c0b73eb..a42dc555f 100644 --- a/docs/deprecations.rst +++ b/docs/deprecations.rst @@ -44,6 +44,17 @@ ImageFile.raise_oserror error codes returned by a codec's ``decode()`` method, which ImageFile already does automatically. +IptcImageFile helper functions +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. deprecated:: 10.2.0 + +The functions ``IptcImageFile.dump`` and ``IptcImageFile.i``, and the constant +``IptcImageFile.PAD`` have been deprecated and will be removed in Pillow +12.0.0 (2025-10-15). These are undocumented helper functions intended +for internal use, so there is no replacement. They can each be replaced +by a single line of code using builtin functions in Python. + Removed features ---------------- diff --git a/docs/handbook/image-file-formats.rst b/docs/handbook/image-file-formats.rst index 53565dbbd..276838bed 100644 --- a/docs/handbook/image-file-formats.rst +++ b/docs/handbook/image-file-formats.rst @@ -487,6 +487,16 @@ The :py:meth:`~PIL.Image.Image.save` method supports the following options: **exif** If present, the image will be stored with the provided raw EXIF data. +**keep_rgb** + By default, libjpeg converts images with an RGB color space to YCbCr. + If this option is present and true, those images will be stored as RGB + instead. + + When this option is enabled, attempting to chroma-subsample RGB images + with the ``subsampling`` option will raise an :py:exc:`OSError`. + + .. versionadded:: 10.2.0 + **subsampling** If present, sets the subsampling for the encoder. diff --git a/docs/handbook/tutorial.rst b/docs/handbook/tutorial.rst index d79f2465f..523e2ad74 100644 --- a/docs/handbook/tutorial.rst +++ b/docs/handbook/tutorial.rst @@ -542,7 +542,7 @@ Reading from URL from PIL import Image from urllib.request import urlopen - url = "https://python-pillow.org/images/pillow-logo.png" + url = "https://python-pillow.org/assets/images/pillow-logo.png" img = Image.open(urlopen(url)) diff --git a/docs/reference/internal_modules.rst b/docs/reference/internal_modules.rst index 363a67d9b..f2932c322 100644 --- a/docs/reference/internal_modules.rst +++ b/docs/reference/internal_modules.rst @@ -25,6 +25,19 @@ Internal Modules :undoc-members: :show-inheritance: +:mod:`~PIL._typing` Module +-------------------------- + +.. module:: PIL._typing + +Provides a convenient way to import type hints that are not available +on some Python versions. + +.. py:data:: TypeGuard + :value: typing.TypeGuard + + See :py:obj:`typing.TypeGuard`. + :mod:`~PIL._util` Module ------------------------ diff --git a/docs/releasenotes/10.2.0.rst b/docs/releasenotes/10.2.0.rst index 6ab139b56..c3947f64c 100644 --- a/docs/releasenotes/10.2.0.rst +++ b/docs/releasenotes/10.2.0.rst @@ -1,14 +1,6 @@ 10.2.0 ------ -Backwards Incompatible Changes -============================== - -TODO -^^^^ - -TODO - Deprecations ============ @@ -20,10 +12,14 @@ ImageFile.raise_oserror error codes returned by a codec's ``decode()`` method, which ImageFile already does automatically. -TODO -^^^^ +IptcImageFile helper functions +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -TODO +The functions ``IptcImageFile.dump`` and ``IptcImageFile.i``, and the constant +``IptcImageFile.PAD`` have been deprecated and will be removed in Pillow +12.0.0 (2025-10-15). These are undocumented helper functions intended +for internal use, so there is no replacement. They can each be replaced +by a single line of code using builtin functions in Python. API Changes =========== @@ -46,6 +42,14 @@ Added DdsImagePlugin enums :py:class:`~PIL.DdsImagePlugin.DXGI_FORMAT` and :py:class:`~PIL.DdsImagePlugin.D3DFMT` enums have been added to :py:class:`PIL.DdsImagePlugin`. +JPEG RGB color space +^^^^^^^^^^^^^^^^^^^^ + +When saving JPEG files, ``keep_rgb`` can now be set to ``True``. This will store RGB +images in the RGB color space instead of being converted to YCbCr automatically by +libjpeg. When this option is enabled, attempting to chroma-subsample RGB images with +the ``subsampling`` option will raise an :py:exc:`OSError`. + JPEG restart marker interval ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ @@ -73,6 +77,16 @@ Pillow will now raise a :py:exc:`ValueError` if the number of characters passed This threshold can be changed by setting :py:data:`PIL.ImageFont.MAX_STRING_LENGTH`. It can be disabled by setting ``ImageFont.MAX_STRING_LENGTH = None``. +A decompression bomb check has also been added to +:py:meth:`PIL.ImageFont.ImageFont.getmask`. + +ImageFont.getmask: Trim glyph size +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +To protect against potential DOS attacks when using PIL fonts, +:py:class:`PIL.ImageFont.ImageFont` now trims the size of individual glyphs so that +they do not extend beyond the bitmap image. + ImageMath.eval: Restricted environment keys ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ @@ -92,16 +106,56 @@ Support has been added to read the BC4U format of DDS images. Support has also been added to read DX10 BC1 and BC4, whether UNORM or TYPELESS. +Support arbitrary masks for uncompressed RGB DDS images +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +All masks are now supported when reading DDS images with uncompressed RGB data, +allowing for bit counts other than 24 and 32. + +Saving TIFF tag RowsPerStrip +^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +When saving TIFF images, the TIFF tag RowsPerStrip can now be one of the tags set by +the user, rather than always being calculated by Pillow. + +Optimized ImageColor.getrgb and getcolor +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +The color calculations of :py:attr:`~PIL.ImageColor.getrgb` and +:py:attr:`~PIL.ImageColor.getcolor` are now cached using +:py:func:`functools.lru_cache`. Cached calls of ``getrgb`` are 3.1 - 91.4 times +as fast and ``getcolor`` are 5.1 - 19.6 times as fast. + +Optimized ImageMode.getmode +^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +The lookups made by :py:attr:`~PIL.ImageMode.getmode` are now cached using +:py:func:`functools.lru_cache` instead of a custom cache. Cached calls are 1.2 times as +fast. + Optimized ImageStat.Stat count and extrema ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Calculating the :py:attr:`~PIL.ImageStat.Stat.count` and :py:attr:`~PIL.ImageStat.Stat.extrema` statistics is now faster. After the -histogram is created in ``st = ImageStat.Stat(im)``, ``st.count`` is 3x as fast -on average and ``st.extrema`` is 12x as fast on average. +histogram is created in ``st = ImageStat.Stat(im)``, ``st.count`` is 3 times as fast on +average and ``st.extrema`` is 12 times as fast on average. Encoder errors now report error detail as string ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ :py:exc:`OSError` exceptions from image encoders now include a textual description of the error instead of a numeric error code. + +Type hints +^^^^^^^^^^ + +Work has begun to add type annotations to Pillow, including: + +* :py:mod:`~PIL.ContainerIO` +* :py:mod:`~PIL.FontFile`, :py:mod:`~PIL.BdfFontFile` and :py:mod:`~PIL.PcfFontFile` +* :py:mod:`~PIL.ImageChops` +* :py:mod:`~PIL.ImageMode` +* :py:mod:`~PIL.ImageSequence` +* :py:mod:`~PIL.ImageTransform` +* :py:mod:`~PIL.TarIO` diff --git a/pyproject.toml b/pyproject.toml index 6e26ff4f9..da2537b21 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -65,6 +65,9 @@ tests = [ "pytest-cov", "pytest-timeout", ] +typing = [ + 'typing-extensions; python_version < "3.10"', +] xmp = [ "defusedxml", ] @@ -143,9 +146,6 @@ exclude = [ '^src/PIL/DdsImagePlugin.py$', '^src/PIL/FpxImagePlugin.py$', '^src/PIL/Image.py$', - '^src/PIL/ImageCms.py$', - '^src/PIL/ImageFile.py$', - '^src/PIL/ImageFont.py$', '^src/PIL/ImageMath.py$', '^src/PIL/ImageMorph.py$', '^src/PIL/ImageQt.py$', diff --git a/src/PIL/DdsImagePlugin.py b/src/PIL/DdsImagePlugin.py index 5b6ac2ead..eb4c8f557 100644 --- a/src/PIL/DdsImagePlugin.py +++ b/src/PIL/DdsImagePlugin.py @@ -18,6 +18,7 @@ from enum import IntEnum, IntFlag from . import Image, ImageFile, ImagePalette from ._binary import i32le as i32 +from ._binary import o8 from ._binary import o32le as o32 # Magic ("DDS ") @@ -341,6 +342,7 @@ class DdsImageFile(ImageFile.ImageFile): flags, height, width = struct.unpack("<3I", header.read(12)) self._size = (width, height) + extents = (0, 0) + self.size pitch, depth, mipmaps = struct.unpack("<3I", header.read(12)) struct.unpack("<11I", header.read(44)) # reserved @@ -351,22 +353,16 @@ class DdsImageFile(ImageFile.ImageFile): rawmode = None if pfflags & DDPF.RGB: # Texture contains uncompressed RGB data - masks = struct.unpack("<4I", header.read(16)) - masks = {mask: ["R", "G", "B", "A"][i] for i, mask in enumerate(masks)} - if bitcount == 24: - self._mode = "RGB" - rawmode = masks[0x000000FF] + masks[0x0000FF00] + masks[0x00FF0000] - elif bitcount == 32 and pfflags & DDPF.ALPHAPIXELS: + if pfflags & DDPF.ALPHAPIXELS: self._mode = "RGBA" - rawmode = ( - masks[0x000000FF] - + masks[0x0000FF00] - + masks[0x00FF0000] - + masks[0xFF000000] - ) + mask_count = 4 else: - msg = f"Unsupported bitcount {bitcount} for {pfflags}" - raise OSError(msg) + self._mode = "RGB" + mask_count = 3 + + masks = struct.unpack(f"<{mask_count}I", header.read(mask_count * 4)) + self.tile = [("dds_rgb", extents, 0, (bitcount, masks))] + return elif pfflags & DDPF.LUMINANCE: if bitcount == 8: self._mode = "L" @@ -464,7 +460,6 @@ class DdsImageFile(ImageFile.ImageFile): msg = f"Unknown pixel format flags {pfflags}" raise NotImplementedError(msg) - extents = (0, 0) + self.size if n: self.tile = [ ImageFile._Tile("bcn", extents, offset, (n, self.pixel_format)) @@ -476,6 +471,39 @@ class DdsImageFile(ImageFile.ImageFile): pass +class DdsRgbDecoder(ImageFile.PyDecoder): + _pulls_fd = True + + def decode(self, buffer): + bitcount, masks = self.args + + # Some masks will be padded with zeros, e.g. R 0b11 G 0b1100 + # Calculate how many zeros each mask is padded with + mask_offsets = [] + # And the maximum value of each channel without the padding + mask_totals = [] + for mask in masks: + offset = 0 + if mask != 0: + while mask >> (offset + 1) << (offset + 1) == mask: + offset += 1 + mask_offsets.append(offset) + mask_totals.append(mask >> offset) + + data = bytearray() + bytecount = bitcount // 8 + while len(data) < self.state.xsize * self.state.ysize * len(masks): + value = int.from_bytes(self.fd.read(bytecount), "little") + for i, mask in enumerate(masks): + masked_value = value & mask + # Remove the zero padding, and scale it to 8 bits + data += o8( + int(((masked_value >> mask_offsets[i]) / mask_totals[i]) * 255) + ) + self.set_as_raw(bytes(data)) + return -1, 0 + + def _save(im, fp, filename): if im.mode not in ("RGB", "RGBA", "L", "LA"): msg = f"cannot write mode {im.mode} as DDS" @@ -533,5 +561,6 @@ def _accept(prefix): Image.register_open(DdsImageFile.format, DdsImageFile, _accept) +Image.register_decoder("dds_rgb", DdsRgbDecoder) Image.register_save(DdsImageFile.format, _save) Image.register_extension(DdsImageFile.format, ".dds") diff --git a/src/PIL/Image.py b/src/PIL/Image.py index 045a06080..1bba9aad2 100644 --- a/src/PIL/Image.py +++ b/src/PIL/Image.py @@ -92,7 +92,7 @@ try: raise ImportError(msg) except ImportError as v: - core = DeferredError(ImportError("The _imaging C module is not installed.")) + core = DeferredError.new(ImportError("The _imaging C module is not installed.")) # Explanations for ways that we know we might have an import error if str(v).startswith("Module use of python"): # The _imaging C module is present, but not compiled for @@ -530,15 +530,19 @@ class Image: def __enter__(self): return self + def _close_fp(self): + if getattr(self, "_fp", False): + if self._fp != self.fp: + self._fp.close() + self._fp = DeferredError(ValueError("Operation on closed image")) + if self.fp: + self.fp.close() + def __exit__(self, *args): - if hasattr(self, "fp") and getattr(self, "_exclusive_fp", False): - if getattr(self, "_fp", False): - if self._fp != self.fp: - self._fp.close() - self._fp = DeferredError(ValueError("Operation on closed image")) - if self.fp: - self.fp.close() - self.fp = None + if hasattr(self, "fp"): + if getattr(self, "_exclusive_fp", False): + self._close_fp() + self.fp = None def close(self): """ @@ -554,12 +558,7 @@ class Image: """ if hasattr(self, "fp"): try: - if getattr(self, "_fp", False): - if self._fp != self.fp: - self._fp.close() - self._fp = DeferredError(ValueError("Operation on closed image")) - if self.fp: - self.fp.close() + self._close_fp() self.fp = None except Exception as msg: logger.debug("Error closing: %s", msg) @@ -2644,7 +2643,7 @@ class Image: resample=Resampling.NEAREST, fill=1, fillcolor=None, - ): + ) -> Image: """ Transforms this image. This method creates a new image with the given size, and the same mode as the original, and copies data @@ -3191,7 +3190,7 @@ def _decompression_bomb_check(size): ) -def open(fp, mode="r", formats=None): +def open(fp, mode="r", formats=None) -> Image: """ Opens and identifies the given image file. @@ -3416,7 +3415,7 @@ def merge(mode, bands): # Plugin registry -def register_open(id, factory, accept=None): +def register_open(id, factory, accept=None) -> None: """ Register an image file plugin. This function should not be used in application code. @@ -3470,7 +3469,7 @@ def register_save_all(id, driver): SAVE_ALL[id.upper()] = driver -def register_extension(id, extension): +def register_extension(id, extension) -> None: """ Registers an image extension. This function should not be used in application code. diff --git a/src/PIL/ImageCms.py b/src/PIL/ImageCms.py index 9d27f2513..643fce830 100644 --- a/src/PIL/ImageCms.py +++ b/src/PIL/ImageCms.py @@ -28,7 +28,7 @@ except ImportError as ex: # anything in core. from ._util import DeferredError - _imagingcms = DeferredError(ex) + _imagingcms = DeferredError.new(ex) DESCRIPTION = """ pyCMS diff --git a/src/PIL/ImageFile.py b/src/PIL/ImageFile.py index ae4e23db1..0923979af 100644 --- a/src/PIL/ImageFile.py +++ b/src/PIL/ImageFile.py @@ -32,7 +32,7 @@ import io import itertools import struct import sys -from typing import NamedTuple +from typing import Any, NamedTuple from . import Image from ._deprecate import deprecate @@ -94,7 +94,7 @@ class _Tile(NamedTuple): encoder_name: str extents: tuple[int, int, int, int] offset: int - args: tuple | str | None + args: tuple[Any, ...] | str | None # diff --git a/src/PIL/ImageFilter.py b/src/PIL/ImageFilter.py index 021b40c0e..035b83c4d 100644 --- a/src/PIL/ImageFilter.py +++ b/src/PIL/ImageFilter.py @@ -396,7 +396,7 @@ class Color3DLUT(MultibandFilter): if hasattr(table, "shape"): try: import numpy - except ImportError: # pragma: no cover + except ImportError: pass if numpy and isinstance(table, numpy.ndarray): diff --git a/src/PIL/ImageFont.py b/src/PIL/ImageFont.py index 7f0366ddb..8213d030a 100644 --- a/src/PIL/ImageFont.py +++ b/src/PIL/ImageFont.py @@ -34,7 +34,7 @@ import warnings from enum import IntEnum from io import BytesIO from pathlib import Path -from typing import IO +from typing import BinaryIO from . import Image from ._util import is_directory, is_path @@ -53,7 +53,7 @@ try: except ImportError as ex: from ._util import DeferredError - core = DeferredError(ex) + core = DeferredError.new(ex) def _string_length_check(text): @@ -150,6 +150,7 @@ class ImageFont: :py:mod:`PIL.Image.core` interface module. """ _string_length_check(text) + Image._decompression_bomb_check(self.font.getsize(text)) return self.font.getmask(text, mode) def getbbox(self, text, *args, **kwargs): @@ -192,7 +193,7 @@ class FreeTypeFont: def __init__( self, - font: bytes | str | Path | IO | None = None, + font: bytes | str | Path | BinaryIO | None = None, size: float = 10, index: int = 0, encoding: str = "", diff --git a/src/PIL/ImageTransform.py b/src/PIL/ImageTransform.py index 1fdaa9140..84c81f184 100644 --- a/src/PIL/ImageTransform.py +++ b/src/PIL/ImageTransform.py @@ -14,17 +14,26 @@ # from __future__ import annotations +from typing import Sequence + from . import Image class Transform(Image.ImageTransformHandler): - def __init__(self, data): + method: Image.Transform + + def __init__(self, data: Sequence[int]) -> None: self.data = data - def getdata(self): + def getdata(self) -> tuple[int, Sequence[int]]: return self.method, self.data - def transform(self, size, image, **options): + def transform( + self, + size: tuple[int, int], + image: Image.Image, + **options: dict[str, str | int | tuple[int, ...] | list[int]], + ) -> Image.Image: # can be overridden method, data = self.getdata() return image.transform(size, method, data, **options) diff --git a/src/PIL/IptcImagePlugin.py b/src/PIL/IptcImagePlugin.py index cc39441b6..409609434 100644 --- a/src/PIL/IptcImagePlugin.py +++ b/src/PIL/IptcImagePlugin.py @@ -16,31 +16,46 @@ # from __future__ import annotations -import os -import tempfile +from io import BytesIO +from typing import Sequence from . import Image, ImageFile from ._binary import i16be as i16 from ._binary import i32be as i32 +from ._deprecate import deprecate COMPRESSION = {1: "raw", 5: "jpeg"} -PAD = b"\0\0\0\0" + +def __getattr__(name: str) -> bytes: + if name == "PAD": + deprecate("IptcImagePlugin.PAD", 12) + return b"\0\0\0\0" + msg = f"module '{__name__}' has no attribute '{name}'" + raise AttributeError(msg) # # Helpers +def _i(c: bytes) -> int: + return i32((b"\0\0\0\0" + c)[-4:]) + + def _i8(c: int | bytes) -> int: return c if isinstance(c, int) else c[0] -def i(c): - return i32((PAD + c)[-4:]) +def i(c: bytes) -> int: + """.. deprecated:: 10.2.0""" + deprecate("IptcImagePlugin.i", 12) + return _i(c) -def dump(c): +def dump(c: Sequence[int | bytes]) -> None: + """.. deprecated:: 10.2.0""" + deprecate("IptcImagePlugin.dump", 12) for i in c: print("%02x" % _i8(i), end=" ") print() @@ -55,10 +70,10 @@ class IptcImageFile(ImageFile.ImageFile): format = "IPTC" format_description = "IPTC/NAA" - def getint(self, key): - return i(self.info[key]) + def getint(self, key: tuple[int, int]) -> int: + return _i(self.info[key]) - def field(self): + def field(self) -> tuple[tuple[int, int] | None, int]: # # get a IPTC field header s = self.fp.read(5) @@ -80,13 +95,13 @@ class IptcImageFile(ImageFile.ImageFile): elif size == 128: size = 0 elif size > 128: - size = i(self.fp.read(size - 128)) + size = _i(self.fp.read(size - 128)) else: size = i16(s, 3) return tag, size - def _open(self): + def _open(self) -> None: # load descriptive fields while True: offset = self.fp.tell() @@ -131,27 +146,22 @@ class IptcImageFile(ImageFile.ImageFile): # tile if tag == (8, 10): - self.tile = [ - ("iptc", (compression, offset), (0, 0, self.size[0], self.size[1])) - ] + self.tile = [("iptc", (0, 0) + self.size, offset, compression)] def load(self): if len(self.tile) != 1 or self.tile[0][0] != "iptc": return ImageFile.ImageFile.load(self) - type, tile, box = self.tile[0] - - encoding, offset = tile + offset, compression = self.tile[0][2:] self.fp.seek(offset) # Copy image data to temporary file - o_fd, outfile = tempfile.mkstemp(text=False) - o = os.fdopen(o_fd) - if encoding == "raw": + o = BytesIO() + if compression == "raw": # To simplify access to the extracted file, # prepend a PPM header - o.write("P5\n%d %d\n255\n" % self.size) + o.write(b"P5\n%d %d\n255\n" % self.size) while True: type, size = self.field() if type != (8, 10): @@ -162,17 +172,10 @@ class IptcImageFile(ImageFile.ImageFile): break o.write(s) size -= len(s) - o.close() - try: - with Image.open(outfile) as _im: - _im.load() - self.im = _im.im - finally: - try: - os.unlink(outfile) - except OSError: - pass + with Image.open(o) as _im: + _im.load() + self.im = _im.im Image.register_open(IptcImageFile.format, IptcImageFile) @@ -188,8 +191,6 @@ def getiptcinfo(im): :returns: A dictionary containing IPTC information, or None if no IPTC information block was found. """ - import io - from . import JpegImagePlugin, TiffImagePlugin data = None @@ -224,7 +225,7 @@ def getiptcinfo(im): # parse the IPTC information chunk im.info = {} - im.fp = io.BytesIO(data) + im.fp = BytesIO(data) try: im._open() diff --git a/src/PIL/JpegImagePlugin.py b/src/PIL/JpegImagePlugin.py index 59bade303..81b8749a3 100644 --- a/src/PIL/JpegImagePlugin.py +++ b/src/PIL/JpegImagePlugin.py @@ -87,10 +87,12 @@ def APP(self, marker): self.info["dpi"] = jfif_density self.info["jfif_unit"] = jfif_unit self.info["jfif_density"] = jfif_density - elif marker == 0xFFE1 and s[:5] == b"Exif\0": - if "exif" not in self.info: - # extract EXIF information (incomplete) - self.info["exif"] = s # FIXME: value will change + elif marker == 0xFFE1 and s[:6] == b"Exif\0\0": + # extract EXIF information + if "exif" in self.info: + self.info["exif"] += s[6:] + else: + self.info["exif"] = s self._exif_offset = self.fp.tell() - n + 6 elif marker == 0xFFE2 and s[:5] == b"FPXR\0": # extract FlashPix information (incomplete) @@ -783,6 +785,7 @@ def _save(im, fp, filename): progressive, info.get("smooth", 0), optimize, + info.get("keep_rgb", False), info.get("streamtype", 0), dpi[0], dpi[1], diff --git a/src/PIL/PyAccess.py b/src/PIL/PyAccess.py index 23ff154f6..07bb712d8 100644 --- a/src/PIL/PyAccess.py +++ b/src/PIL/PyAccess.py @@ -43,7 +43,7 @@ except ImportError as ex: # anything in core. from ._util import DeferredError - FFI = ffi = DeferredError(ex) + FFI = ffi = DeferredError.new(ex) logger = logging.getLogger(__name__) diff --git a/src/PIL/TiffImagePlugin.py b/src/PIL/TiffImagePlugin.py index fc242ca64..e20d4d5ea 100644 --- a/src/PIL/TiffImagePlugin.py +++ b/src/PIL/TiffImagePlugin.py @@ -1704,25 +1704,27 @@ def _save(im, fp, filename): colormap += [0] * (256 - colors) ifd[COLORMAP] = colormap # data orientation - stride = len(bits) * ((im.size[0] * bits[0] + 7) // 8) - # aim for given strip size (64 KB by default) when using libtiff writer - if libtiff: - im_strip_size = encoderinfo.get("strip_size", STRIP_SIZE) - rows_per_strip = 1 if stride == 0 else min(im_strip_size // 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] - if rows_per_strip == 0: - rows_per_strip = 1 - strip_byte_counts = 1 if stride == 0 else stride * rows_per_strip - strips_per_image = (im.size[1] + rows_per_strip - 1) // rows_per_strip - ifd[ROWSPERSTRIP] = rows_per_strip + w, h = ifd[IMAGEWIDTH], ifd[IMAGELENGTH] + stride = len(bits) * ((w * bits[0] + 7) // 8) + if ROWSPERSTRIP not in ifd: + # aim for given strip size (64 KB by default) when using libtiff writer + if libtiff: + im_strip_size = encoderinfo.get("strip_size", STRIP_SIZE) + rows_per_strip = 1 if stride == 0 else min(im_strip_size // stride, h) + # JPEG encoder expects multiple of 8 rows + if compression == "jpeg": + rows_per_strip = min(((rows_per_strip + 7) // 8) * 8, h) + else: + rows_per_strip = h + if rows_per_strip == 0: + rows_per_strip = 1 + ifd[ROWSPERSTRIP] = rows_per_strip + strip_byte_counts = 1 if stride == 0 else stride * ifd[ROWSPERSTRIP] + strips_per_image = (h + ifd[ROWSPERSTRIP] - 1) // ifd[ROWSPERSTRIP] if strip_byte_counts >= 2**16: ifd.tagtype[STRIPBYTECOUNTS] = TiffTags.LONG ifd[STRIPBYTECOUNTS] = (strip_byte_counts,) * (strips_per_image - 1) + ( - stride * im.size[1] - strip_byte_counts * (strips_per_image - 1), + stride * h - strip_byte_counts * (strips_per_image - 1), ) ifd[STRIPOFFSETS] = tuple( range(0, strip_byte_counts * strips_per_image, strip_byte_counts) diff --git a/src/PIL/_imagingcms.pyi b/src/PIL/_imagingcms.pyi new file mode 100644 index 000000000..b0235555d --- /dev/null +++ b/src/PIL/_imagingcms.pyi @@ -0,0 +1,5 @@ +from __future__ import annotations + +from typing import Any + +def __getattr__(name: str) -> Any: ... diff --git a/src/PIL/_imagingft.pyi b/src/PIL/_imagingft.pyi new file mode 100644 index 000000000..b0235555d --- /dev/null +++ b/src/PIL/_imagingft.pyi @@ -0,0 +1,5 @@ +from __future__ import annotations + +from typing import Any + +def __getattr__(name: str) -> Any: ... diff --git a/src/PIL/_typing.py b/src/PIL/_typing.py new file mode 100644 index 000000000..608b2b41f --- /dev/null +++ b/src/PIL/_typing.py @@ -0,0 +1,18 @@ +from __future__ import annotations + +import sys + +if sys.version_info >= (3, 10): + from typing import TypeGuard +else: + try: + from typing_extensions import TypeGuard + except ImportError: + from typing import Any + + class TypeGuard: # type: ignore[no-redef] + def __class_getitem__(cls, item: Any) -> type[bool]: + return bool + + +__all__ = ["TypeGuard"] diff --git a/src/PIL/_util.py b/src/PIL/_util.py index 4634d335b..13f369cca 100644 --- a/src/PIL/_util.py +++ b/src/PIL/_util.py @@ -2,20 +2,31 @@ from __future__ import annotations import os from pathlib import Path +from typing import Any, NoReturn + +from ._typing import TypeGuard -def is_path(f): +def is_path(f: Any) -> TypeGuard[bytes | str | Path]: return isinstance(f, (bytes, str, Path)) -def is_directory(f): +def is_directory(f: Any) -> TypeGuard[bytes | str | Path]: """Checks if an object is a string, and that it points to a directory.""" return is_path(f) and os.path.isdir(f) class DeferredError: - def __init__(self, ex): + def __init__(self, ex: BaseException): self.ex = ex - def __getattr__(self, elt): + def __getattr__(self, elt: str) -> NoReturn: raise self.ex + + @staticmethod + def new(ex: BaseException) -> Any: + """ + Creates an object that raises the wrapped exception ``ex`` when used, + and casts it to :py:obj:`~typing.Any` type. + """ + return DeferredError(ex) diff --git a/src/PIL/_version.py b/src/PIL/_version.py index 7d994caf4..0568943b5 100644 --- a/src/PIL/_version.py +++ b/src/PIL/_version.py @@ -1,4 +1,4 @@ # Master version for Pillow from __future__ import annotations -__version__ = "10.2.0.dev0" +__version__ = "10.3.0.dev0" diff --git a/src/_imaging.c b/src/_imaging.c index 2270c77fe..59f80a354 100644 --- a/src/_imaging.c +++ b/src/_imaging.c @@ -2649,6 +2649,26 @@ _font_new(PyObject *self_, PyObject *args) { self->glyphs[i].sy0 = S16(B16(glyphdata, 14)); self->glyphs[i].sx1 = S16(B16(glyphdata, 16)); self->glyphs[i].sy1 = S16(B16(glyphdata, 18)); + + // Do not allow glyphs to extend beyond bitmap image + // Helps prevent DOS by stopping cropped images being larger than the original + if (self->glyphs[i].sx0 < 0) { + self->glyphs[i].dx0 -= self->glyphs[i].sx0; + self->glyphs[i].sx0 = 0; + } + if (self->glyphs[i].sy0 < 0) { + self->glyphs[i].dy0 -= self->glyphs[i].sy0; + self->glyphs[i].sy0 = 0; + } + if (self->glyphs[i].sx1 > self->bitmap->xsize) { + self->glyphs[i].dx1 -= self->glyphs[i].sx1 - self->bitmap->xsize; + self->glyphs[i].sx1 = self->bitmap->xsize; + } + if (self->glyphs[i].sy1 > self->bitmap->ysize) { + self->glyphs[i].dy1 -= self->glyphs[i].sy1 - self->bitmap->ysize; + self->glyphs[i].sy1 = self->bitmap->ysize; + } + if (self->glyphs[i].dy0 < y0) { y0 = self->glyphs[i].dy0; } @@ -2721,7 +2741,7 @@ _font_text_asBytes(PyObject *encoded_string, unsigned char **text) { static PyObject * _font_getmask(ImagingFontObject *self, PyObject *args) { Imaging im; - Imaging bitmap; + Imaging bitmap = NULL; int x, b; int i = 0; int status; @@ -2730,7 +2750,7 @@ _font_getmask(ImagingFontObject *self, PyObject *args) { PyObject *encoded_string; unsigned char *text; - char *mode = ""; + char *mode; if (!PyArg_ParseTuple(args, "O|s:getmask", &encoded_string, &mode)) { return NULL; @@ -2753,10 +2773,13 @@ _font_getmask(ImagingFontObject *self, PyObject *args) { b = self->baseline; for (x = 0; text[i]; i++) { glyph = &self->glyphs[text[i]]; - bitmap = - ImagingCrop(self->bitmap, glyph->sx0, glyph->sy0, glyph->sx1, glyph->sy1); - if (!bitmap) { - goto failed; + if (i == 0 || text[i] != text[i - 1]) { + ImagingDelete(bitmap); + bitmap = + ImagingCrop(self->bitmap, glyph->sx0, glyph->sy0, glyph->sx1, glyph->sy1); + if (!bitmap) { + goto failed; + } } status = ImagingPaste( im, @@ -2766,17 +2789,18 @@ _font_getmask(ImagingFontObject *self, PyObject *args) { glyph->dy0 + b, glyph->dx1 + x, glyph->dy1 + b); - ImagingDelete(bitmap); if (status < 0) { goto failed; } x = x + glyph->dx; b = b + glyph->dy; } + ImagingDelete(bitmap); free(text); return PyImagingNew(im); failed: + ImagingDelete(bitmap); free(text); ImagingDelete(im); Py_RETURN_NONE; diff --git a/src/encode.c b/src/encode.c index 4664ad0f3..c7dd51015 100644 --- a/src/encode.c +++ b/src/encode.c @@ -1042,6 +1042,7 @@ PyImaging_JpegEncoderNew(PyObject *self, PyObject *args) { Py_ssize_t progressive = 0; Py_ssize_t smooth = 0; Py_ssize_t optimize = 0; + int keep_rgb = 0; Py_ssize_t streamtype = 0; /* 0=interchange, 1=tables only, 2=image only */ Py_ssize_t xdpi = 0, ydpi = 0; Py_ssize_t subsampling = -1; /* -1=default, 0=none, 1=medium, 2=high */ @@ -1059,13 +1060,14 @@ PyImaging_JpegEncoderNew(PyObject *self, PyObject *args) { if (!PyArg_ParseTuple( args, - "ss|nnnnnnnnnnOz#y#y#", + "ss|nnnnpnnnnnnOz#y#y#", &mode, &rawmode, &quality, &progressive, &smooth, &optimize, + &keep_rgb, &streamtype, &xdpi, &ydpi, @@ -1150,6 +1152,7 @@ PyImaging_JpegEncoderNew(PyObject *self, PyObject *args) { strncpy(((JPEGENCODERSTATE *)encoder->state.context)->rawmode, rawmode, 8); + ((JPEGENCODERSTATE *)encoder->state.context)->keep_rgb = keep_rgb; ((JPEGENCODERSTATE *)encoder->state.context)->quality = quality; ((JPEGENCODERSTATE *)encoder->state.context)->qtables = qarrays; ((JPEGENCODERSTATE *)encoder->state.context)->qtablesLen = qtablesLen; diff --git a/src/libImaging/Jpeg.h b/src/libImaging/Jpeg.h index 5cc74e69b..98eaac28d 100644 --- a/src/libImaging/Jpeg.h +++ b/src/libImaging/Jpeg.h @@ -74,6 +74,9 @@ typedef struct { /* Optimize Huffman tables (slow) */ int optimize; + /* Disable automatic conversion of RGB images to YCbCr if nonzero */ + int keep_rgb; + /* Stream type (0=full, 1=tables only, 2=image only) */ int streamtype; diff --git a/src/libImaging/JpegEncode.c b/src/libImaging/JpegEncode.c index 9da830b18..00f3d5f74 100644 --- a/src/libImaging/JpegEncode.c +++ b/src/libImaging/JpegEncode.c @@ -137,6 +137,30 @@ ImagingJpegEncode(Imaging im, ImagingCodecState state, UINT8 *buf, int bytes) { /* Compressor configuration */ jpeg_set_defaults(&context->cinfo); + /* Prevent RGB -> YCbCr conversion */ + if (context->keep_rgb) { + switch (context->cinfo.in_color_space) { + case JCS_RGB: +#ifdef JCS_EXTENSIONS + case JCS_EXT_RGBX: +#endif + switch (context->subsampling) { + case -1: /* Default */ + case 0: /* No subsampling */ + break; + default: + /* Would subsample the green and blue + channels, which doesn't make sense */ + state->errcode = IMAGING_CODEC_CONFIG; + return -1; + } + jpeg_set_colorspace(&context->cinfo, JCS_RGB); + break; + default: + break; + } + } + /* Use custom quantization tables */ if (context->qtables) { int i; diff --git a/tox.ini b/tox.ini index 034d89372..d89d017e4 100644 --- a/tox.ini +++ b/tox.ini @@ -35,5 +35,7 @@ skip_install = true deps = mypy==1.7.1 numpy +extras = + typing commands = mypy src {posargs}