diff --git a/.github/problem-matchers/gcc.json b/.github/problem-matchers/gcc.json new file mode 100644 index 00000000000..8e2866afe23 --- /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/release-drafter.yml b/.github/release-drafter.yml index 4d855469a12..3711d91f0d5 100644 --- a/.github/release-drafter.yml +++ b/.github/release-drafter.yml @@ -13,6 +13,8 @@ categories: label: "Removal" - title: "Testing" label: "Testing" + - title: "Type hints" + label: "Type hints" exclude-labels: - "changelog: skip" diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index aa0e2513825..b7e112f4364 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/CHANGES.rst b/CHANGES.rst index df4e11e0e48..1dc8e9aaaa6 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -5,6 +5,18 @@ Changelog (Pillow) 10.2.0 (unreleased) ------------------- +- Apply ImageFont.MAX_STRING_LENGTH to ImageFont.getmask() #7662 + [radarhere] + +- Optimise ``ImageColor`` using ``functools.lru_cache`` #7657 + [hugovk] + +- Restricted environment keys for ImageMath.eval() #7655 + [wiredfool, radarhere] + +- Optimise ``ImageMode.getmode`` using ``functools.lru_cache`` #7641 + [hugovk, radarhere] + - Fix incorrect color blending for overlapping glyphs #7497 [ZachNagengast, nulano, radarhere] diff --git a/LICENSE b/LICENSE index cf65e86d734..0069eb5bcec 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 74f427f0376..b3fd72a520e 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -20,12 +20,7 @@ Released quarterly on January 2nd, April 1st, July 1st and October 15th. git tag 5.2.0 git push --tags ``` -* [ ] Create [source and binary distributions](https://github.com/python-pillow/Pillow/blob/main/RELEASING.md#source-and-binary-distributions) -* [ ] Check and upload all source and binary distributions e.g.: - ```bash - python3 -m twine check --strict dist/* - python3 -m twine upload dist/Pillow-5.2.0* - ``` +* [ ] Create and upload all [source and binary distributions](https://github.com/python-pillow/Pillow/blob/main/RELEASING.md#source-and-binary-distributions) * [ ] Publish the [release on GitHub](https://github.com/python-pillow/Pillow/releases) * [ ] In compliance with [PEP 440](https://peps.python.org/pep-0440/), increment and append `.dev0` to version identifier in `src/PIL/_version.py` and then: @@ -55,12 +50,7 @@ Released as needed for security, installation or critical bug fixes. ```bash make sdist ``` -* [ ] Create [source and binary distributions](https://github.com/python-pillow/Pillow/blob/main/RELEASING.md#source-and-binary-distributions) -* [ ] Check and upload all source and binary distributions e.g.: - ```bash - python3 -m twine check --strict dist/* - python3 -m twine upload dist/Pillow-5.2.1* - ``` +* [ ] Create and upload all [source and binary distributions](https://github.com/python-pillow/Pillow/blob/main/RELEASING.md#source-and-binary-distributions) * [ ] Publish the [release on GitHub](https://github.com/python-pillow/Pillow/releases) and then: ```bash git push @@ -82,11 +72,7 @@ Released as needed privately to individual vendors for critical security-related git tag 2.5.3 git push origin --tags ``` -* [ ] Create and check source distribution: - ```bash - make sdist - ``` -* [ ] Create [source and binary distributions](https://github.com/python-pillow/Pillow/blob/main/RELEASING.md#source-and-binary-distributions) +* [ ] Create and upload all [source and binary distributions](https://github.com/python-pillow/Pillow/blob/main/RELEASING.md#source-and-binary-distributions) * [ ] Publish the [release on GitHub](https://github.com/python-pillow/Pillow/releases) and then: ```bash git push origin 2.5.x @@ -94,14 +80,15 @@ Released as needed privately to individual vendors for critical security-related ## Source and Binary Distributions -* [ ] Download sdist and wheels from the [GitHub Actions "Wheels" workflow](https://github.com/python-pillow/Pillow/actions/workflows/wheels.yml) - and copy into `dist/`. For example using [GitHub CLI](https://github.com/cli/cli): +* [ ] Check the [GitHub Actions "Wheels" workflow](https://github.com/python-pillow/Pillow/actions/workflows/wheels.yml) + has passed, including the "Upload release to PyPI" job. This will have been triggered + by the new tag. +* [ ] Download the Linux aarch64 wheels created by Travis CI from [GitHub releases](https://github.com/python-pillow/Pillow/releases) + and copy into `dist`. Check and upload them e.g.: ```bash - gh run download --dir dist - # select dist + python3 -m twine check --strict dist/* + python3 -m twine upload dist/Pillow-5.2.0* ``` -* [ ] Download the Linux aarch64 wheels created by Travis CI from [GitHub releases](https://github.com/python-pillow/Pillow/releases) - and copy into `dist`. ## Publicize Release diff --git a/Tests/images/bgr15.dds b/Tests/images/bgr15.dds new file mode 100644 index 00000000000..ba3bbddcae4 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 00000000000..a15ab5ad256 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 00000000000..32e0aa301a9 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 77d527507f5..00000000000 Binary files a/Tests/images/unsupported_bitcount_rgb.dds and /dev/null differ diff --git a/Tests/oss-fuzz/fuzz_font.py b/Tests/oss-fuzz/fuzz_font.py index 024117c56d0..bc2ba9a7e27 100755 --- a/Tests/oss-fuzz/fuzz_font.py +++ b/Tests/oss-fuzz/fuzz_font.py @@ -1,7 +1,5 @@ #!/usr/bin/python3 -from __future__ import annotations - # Copyright 2020 Google LLC # # Licensed under the Apache License, Version 2.0 (the "License"); diff --git a/Tests/oss-fuzz/fuzz_pillow.py b/Tests/oss-fuzz/fuzz_pillow.py index c1ab42e5651..545daccb680 100644 --- a/Tests/oss-fuzz/fuzz_pillow.py +++ b/Tests/oss-fuzz/fuzz_pillow.py @@ -1,7 +1,5 @@ #!/usr/bin/python3 -from __future__ import annotations - # Copyright 2020 Google LLC # # Licensed under the Apache License, Version 2.0 (the "License"); diff --git a/Tests/test_file_dds.py b/Tests/test_file_dds.py index 2d60fbb6460..a605c8399d8 100644 --- a/Tests/test_file_dds.py +++ b/Tests/test_file_dds.py @@ -32,6 +32,7 @@ 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 d0ecde393a4..e1a8c92c713 100644 --- a/Tests/test_file_iptc.py +++ b/Tests/test_file_iptc.py @@ -11,6 +11,15 @@ TEST_FILE = "Tests/images/iptc.jpg" +def test_open(): + f = BytesIO( + b"\x1c\x03<\x00\x02\x01\x00\x1c\x03x\x00\x01\x01\x1c" + b"\x03\x14\x00\x01\x01\x1c\x03\x1e\x00\x01\x01\x1c\x08\n\x00\x00" + ) + with Image.open(f) as im: + assert im.tile == [("iptc", (0, 0, 1, 1), 25, "raw")] + + def test_getiptcinfo_jpg_none(): # Arrange with hopper() as im: diff --git a/Tests/test_file_jpeg.py b/Tests/test_file_jpeg.py index ffaea6296ef..e60ceb18a6d 100644 --- a/Tests/test_file_jpeg.py +++ b/Tests/test_file_jpeg.py @@ -840,6 +840,10 @@ def test_ifd_offset_exif(self): # 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 73a1223d75c..a50f50e5e96 100644 --- a/Tests/test_file_tiff.py +++ b/Tests/test_file_tiff.py @@ -612,6 +612,14 @@ def test_roundtrip_tiff_uint16(self, tmp_path): 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: diff --git a/Tests/test_file_tiff_metadata.py b/Tests/test_file_tiff_metadata.py index ed90031fa50..ee69681854e 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,6 +160,7 @@ 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)) diff --git a/Tests/test_image.py b/Tests/test_image.py index 615e00e40da..80f6583d8d9 100644 --- a/Tests/test_image.py +++ b/Tests/test_image.py @@ -1016,6 +1016,11 @@ def test_fli_overrun2(self): 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_imagefont.py b/Tests/test_imagefont.py index 6e04cddc748..807d581edf0 100644 --- a/Tests/test_imagefont.py +++ b/Tests/test_imagefont.py @@ -1053,11 +1053,13 @@ def test_too_many_characters(font): with pytest.raises(ValueError): transposed_font.getlength("A" * 1_000_001) - default_font = ImageFont.load_default() + imagefont = ImageFont.ImageFont() + with pytest.raises(ValueError): + imagefont.getlength("A" * 1_000_001) with pytest.raises(ValueError): - default_font.getlength("A" * 1_000_001) + imagefont.getbbox("A" * 1_000_001) with pytest.raises(ValueError): - default_font.getbbox("A" * 1_000_001) + imagefont.getmask("A" * 1_000_001) @pytest.mark.parametrize( diff --git a/Tests/test_imagemath.py b/Tests/test_imagemath.py index 22de86c7cab..9281de6f66a 100644 --- a/Tests/test_imagemath.py +++ b/Tests/test_imagemath.py @@ -64,6 +64,16 @@ def test_prevent_exec(expression): ImageMath.eval(expression) +def test_prevent_double_underscores(): + with pytest.raises(ValueError): + ImageMath.eval("1", {"__": None}) + + +def test_prevent_builtins(): + with pytest.raises(ValueError): + ImageMath.eval("(lambda: exec('exit()'))()", {"exec": None}) + + def test_logical(): assert pixel(ImageMath.eval("not A", images)) == 0 assert pixel(ImageMath.eval("A and B", images)) == "L 2" diff --git a/docs/COPYING b/docs/COPYING index bc44ba388a6..73af6d99c0f 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/conf.py b/docs/conf.py index a70dece7469..9ae7ae605fd 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -54,7 +54,7 @@ # 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/releasenotes/10.2.0.rst b/docs/releasenotes/10.2.0.rst index 9883f10baf3..6ab139b560b 100644 --- a/docs/releasenotes/10.2.0.rst +++ b/docs/releasenotes/10.2.0.rst @@ -62,10 +62,24 @@ output only the quantization and Huffman tables for the image. Security ======== -TODO -^^^^ +ImageFont.getmask: Applied ImageFont.MAX_STRING_LENGTH +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -TODO +To protect against potential DOS attacks when using arbitrary strings as text input, +Pillow will now raise a :py:exc:`ValueError` if the number of characters passed into +:py:meth:`PIL.ImageFont.ImageFont.getmask` is over a certain limit, +:py:data:`PIL.ImageFont.MAX_STRING_LENGTH`. + +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``. + +ImageMath.eval: Restricted environment keys +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:cve:`2023-50447`: If an attacker has control over the keys passed to the +``environment`` argument of :py:meth:`PIL.ImageMath.eval`, they may be able to execute +arbitrary code. To prevent this, keys matching the names of builtins and keys +containing double underscores will now raise a :py:exc:`ValueError`. Other Changes ============= diff --git a/pyproject.toml b/pyproject.toml index 193e8c9b247..6e26ff4f913 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -117,6 +117,8 @@ extend-ignore = [ [tool.ruff.per-file-ignores] "Tests/*.py" = ["I001"] +"Tests/oss-fuzz/fuzz_font.py" = ["I002"] +"Tests/oss-fuzz/fuzz_pillow.py" = ["I002"] [tool.ruff.isort] known-first-party = ["PIL"] diff --git a/src/PIL/ContainerIO.py b/src/PIL/ContainerIO.py index 64d04242639..0035296a45c 100644 --- a/src/PIL/ContainerIO.py +++ b/src/PIL/ContainerIO.py @@ -16,15 +16,16 @@ from __future__ import annotations import io +from typing import IO, AnyStr, Generic, Literal -class ContainerIO: +class ContainerIO(Generic[AnyStr]): """ A file object that provides read access to a part of an existing file (for example a TAR file). """ - def __init__(self, file, offset, length) -> None: + def __init__(self, file: IO[AnyStr], offset: int, length: int) -> None: """ Create file object. @@ -32,7 +33,7 @@ def __init__(self, file, offset, length) -> None: :param offset: Start of region, in bytes. :param length: Size of region, in bytes. """ - self.fh = file + self.fh: IO[AnyStr] = file self.pos = 0 self.offset = offset self.length = length @@ -41,10 +42,10 @@ def __init__(self, file, offset, length) -> None: ## # Always false. - def isatty(self): + def isatty(self) -> bool: return False - def seek(self, offset, mode=io.SEEK_SET): + def seek(self, offset: int, mode: Literal[0, 1, 2] = io.SEEK_SET) -> None: """ Move file pointer. @@ -63,7 +64,7 @@ def seek(self, offset, mode=io.SEEK_SET): self.pos = max(0, min(self.pos, self.length)) self.fh.seek(self.offset + self.pos) - def tell(self): + def tell(self) -> int: """ Get current file pointer. @@ -71,7 +72,7 @@ def tell(self): """ return self.pos - def read(self, n=0): + def read(self, n: int = 0) -> AnyStr: """ Read data. @@ -84,17 +85,17 @@ def read(self, n=0): else: n = self.length - self.pos if not n: # EOF - return b"" if "b" in self.fh.mode else "" + return b"" if "b" in self.fh.mode else "" # type: ignore[return-value] self.pos = self.pos + n return self.fh.read(n) - def readline(self): + def readline(self) -> AnyStr: """ Read a line of text. :returns: An 8-bit string. """ - s = b"" if "b" in self.fh.mode else "" + s: AnyStr = b"" if "b" in self.fh.mode else "" # type: ignore[assignment] newline_character = b"\n" if "b" in self.fh.mode else "\n" while True: c = self.read(1) @@ -105,7 +106,7 @@ def readline(self): break return s - def readlines(self): + def readlines(self) -> list[AnyStr]: """ Read multiple lines of text. diff --git a/src/PIL/DdsImagePlugin.py b/src/PIL/DdsImagePlugin.py index 5b6ac2ead50..eb4c8f557af 100644 --- a/src/PIL/DdsImagePlugin.py +++ b/src/PIL/DdsImagePlugin.py @@ -18,6 +18,7 @@ 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 @@ def _open(self): 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 @@ def _open(self): 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 @@ def _open(self): 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 @@ def load_seek(self, pos): 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 045a06080e9..613d9462a60 100644 --- a/src/PIL/Image.py +++ b/src/PIL/Image.py @@ -530,15 +530,19 @@ def _new(self, im): 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 @@ def close(self): """ 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) diff --git a/src/PIL/ImageColor.py b/src/PIL/ImageColor.py index bfad27c82d6..ad59b066779 100644 --- a/src/PIL/ImageColor.py +++ b/src/PIL/ImageColor.py @@ -19,10 +19,12 @@ from __future__ import annotations import re +from functools import lru_cache from . import Image +@lru_cache def getrgb(color): """ Convert a color string to an RGB or RGBA tuple. If the string cannot be @@ -121,6 +123,7 @@ def getrgb(color): raise ValueError(msg) +@lru_cache def getcolor(color, mode): """ Same as :py:func:`~PIL.ImageColor.getrgb` for most modes. However, if diff --git a/src/PIL/ImageFont.py b/src/PIL/ImageFont.py index 6db7cc4eccb..7f0366ddb5d 100644 --- a/src/PIL/ImageFont.py +++ b/src/PIL/ImageFont.py @@ -149,6 +149,7 @@ def getmask(self, text, mode="", *args, **kwargs): :return: An internal PIL storage memory instance as defined by the :py:mod:`PIL.Image.core` interface module. """ + _string_length_check(text) return self.font.getmask(text, mode) def getbbox(self, text, *args, **kwargs): diff --git a/src/PIL/ImageMath.py b/src/PIL/ImageMath.py index 7ca512e7568..b77f4bce567 100644 --- a/src/PIL/ImageMath.py +++ b/src/PIL/ImageMath.py @@ -234,6 +234,11 @@ def eval(expression, _dict={}, **kw): # build execution namespace args = ops.copy() + for k in list(_dict.keys()) + list(kw.keys()): + if "__" in k or hasattr(builtins, k): + msg = f"'{k}' not allowed" + raise ValueError(msg) + args.update(_dict) args.update(kw) for k, v in args.items(): diff --git a/src/PIL/IptcImagePlugin.py b/src/PIL/IptcImagePlugin.py index e7dc3e4e4d2..3a028de2d47 100644 --- a/src/PIL/IptcImagePlugin.py +++ b/src/PIL/IptcImagePlugin.py @@ -20,26 +20,29 @@ import tempfile from . import Image, ImageFile -from ._binary import i8, o8 from ._binary import i16be as i16 from ._binary import i32be as i32 COMPRESSION = {1: "raw", 5: "jpeg"} -PAD = o8(0) * 4 +PAD = b"\0\0\0\0" # # Helpers +def _i8(c: int | bytes) -> int: + return c if isinstance(c, int) else c[0] + + def i(c): return i32((PAD + c)[-4:]) def dump(c): for i in c: - print("%02x" % i8(i), end=" ") + print("%02x" % _i8(i), end=" ") print() @@ -103,10 +106,10 @@ def _open(self): self.info[tag] = tagdata # mode - layers = i8(self.info[(3, 60)][0]) - component = i8(self.info[(3, 60)][1]) + layers = self.info[(3, 60)][0] + component = self.info[(3, 60)][1] if (3, 65) in self.info: - id = i8(self.info[(3, 65)][0]) - 1 + id = self.info[(3, 65)][0] - 1 else: id = 0 if layers == 1 and not component: @@ -128,24 +131,20 @@ def _open(self): # 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": + if compression == "raw": # To simplify access to the extracted file, # prepend a PPM header o.write("P5\n%d %d\n255\n" % self.size) diff --git a/src/PIL/JpegImagePlugin.py b/src/PIL/JpegImagePlugin.py index 59bade303f8..e6b8320402a 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) diff --git a/src/PIL/TarIO.py b/src/PIL/TarIO.py index c9923487d5e..7470663b4a1 100644 --- a/src/PIL/TarIO.py +++ b/src/PIL/TarIO.py @@ -21,7 +21,7 @@ from . import ContainerIO -class TarIO(ContainerIO.ContainerIO): +class TarIO(ContainerIO.ContainerIO[bytes]): """A file object that provides read access to a given member of a TAR file.""" def __init__(self, tarfile: str, file: str) -> None: diff --git a/src/PIL/TiffImagePlugin.py b/src/PIL/TiffImagePlugin.py index f9da3e6495e..e20d4d5ea81 100644 --- a/src/PIL/TiffImagePlugin.py +++ b/src/PIL/TiffImagePlugin.py @@ -1706,20 +1706,21 @@ def _save(im, fp, filename): # data orientation w, h = ifd[IMAGEWIDTH], ifd[IMAGELENGTH] stride = len(bits) * ((w * 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, 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 - strip_byte_counts = 1 if stride == 0 else stride * rows_per_strip - strips_per_image = (h + rows_per_strip - 1) // rows_per_strip - ifd[ROWSPERSTRIP] = rows_per_strip + 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) + ( diff --git a/src/PIL/_binary.py b/src/PIL/_binary.py index 9bb4260a464..0a07e8d0e12 100644 --- a/src/PIL/_binary.py +++ b/src/PIL/_binary.py @@ -18,16 +18,16 @@ from struct import pack, unpack_from -def i8(c) -> int: - return c if c.__class__ is int else c[0] +def i8(c: bytes) -> int: + return c[0] -def o8(i): +def o8(i: int) -> bytes: return bytes((i & 255,)) # Input, le = little endian, be = big endian -def i16le(c, o=0): +def i16le(c: bytes, o: int = 0) -> int: """ Converts a 2-bytes (16 bits) string to an unsigned integer. @@ -37,7 +37,7 @@ def i16le(c, o=0): return unpack_from(" int: """ Converts a 2-bytes (16 bits) string to a signed integer. @@ -47,7 +47,7 @@ def si16le(c, o=0): return unpack_from(" int: """ Converts a 2-bytes (16 bits) string to a signed integer, big endian. @@ -57,7 +57,7 @@ def si16be(c, o=0): return unpack_from(">h", c, o)[0] -def i32le(c, o=0) -> int: +def i32le(c: bytes, o: int = 0) -> int: """ Converts a 4-bytes (32 bits) string to an unsigned integer. @@ -67,7 +67,7 @@ def i32le(c, o=0) -> int: return unpack_from(" int: """ Converts a 4-bytes (32 bits) string to a signed integer. @@ -77,26 +77,26 @@ def si32le(c, o=0): return unpack_from(" int: return unpack_from(">H", c, o)[0] -def i32be(c, o=0): +def i32be(c: bytes, o: int = 0) -> int: return unpack_from(">I", c, o)[0] # Output, le = little endian, be = big endian -def o16le(i): +def o16le(i: int) -> bytes: return pack(" bytes: return pack(" bytes: +def o16be(i: int) -> bytes: return pack(">H", i) -def o32be(i): +def o32be(i: int) -> bytes: return pack(">I", i)