diff --git a/CHANGES.rst b/CHANGES.rst index 8d5d7001a..c9b4a9f49 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -5,6 +5,36 @@ Changelog (Pillow) 9.2.0 (unreleased) ------------------ +- Fixed null check for fribidi_version_info in FriBiDi shim #6376 + [nulano] + +- Added GIF decompression bomb check #6402 + [radarhere] + +- Handle PCF fonts files with less than 256 characters #6386 + [dawidcrivelli, radarhere] + +- Improved GIF optimize condition #6378 + [raygard, radarhere] + +- Reverted to __array_interface__ with the release of NumPy 1.23 #6394 + [radarhere] + +- Pad PCX palette to 768 bytes when saving #6391 + [radarhere] + +- Fixed bug with rounding pixels to palette colors #6377 + [btrekkie, radarhere] + +- Use gnome-screenshot on Linux if available #6361 + [radarhere, nulano] + +- Fixed loading L mode BMP RLE8 images #6384 + [radarhere] + +- Fixed incorrect operator in ImageCms error #6370 + [LostBenjamin, hugovk, radarhere] + - Limit FPX tile size to avoid extending outside image #6368 [radarhere] diff --git a/Tests/fonts/10x20-ISO8859-1-fewer-characters.pcf b/Tests/fonts/10x20-ISO8859-1-fewer-characters.pcf new file mode 100644 index 000000000..c065f59a9 Binary files /dev/null and b/Tests/fonts/10x20-ISO8859-1-fewer-characters.pcf differ diff --git a/Tests/images/decompression_bomb_extents.gif b/Tests/images/decompression_bomb_extents.gif new file mode 100644 index 000000000..0d5ff03f5 Binary files /dev/null and b/Tests/images/decompression_bomb_extents.gif differ diff --git a/Tests/images/hopper_rle8_greyscale.bmp b/Tests/images/hopper_rle8_greyscale.bmp new file mode 100644 index 000000000..ead32ff95 Binary files /dev/null and b/Tests/images/hopper_rle8_greyscale.bmp differ diff --git a/Tests/images/palette_negative.png b/Tests/images/palette_negative.png index 938a7285f..7fcfd29a0 100644 Binary files a/Tests/images/palette_negative.png and b/Tests/images/palette_negative.png differ diff --git a/Tests/images/palette_sepia.png b/Tests/images/palette_sepia.png index f3fc93253..9e7d6b034 100644 Binary files a/Tests/images/palette_sepia.png and b/Tests/images/palette_sepia.png differ diff --git a/Tests/images/palette_wedge.png b/Tests/images/palette_wedge.png index 23fb7940d..4b3d9ff3a 100644 Binary files a/Tests/images/palette_wedge.png and b/Tests/images/palette_wedge.png differ diff --git a/Tests/test_decompression_bomb.py b/Tests/test_decompression_bomb.py index d85d1f3c2..63071b78c 100644 --- a/Tests/test_decompression_bomb.py +++ b/Tests/test_decompression_bomb.py @@ -61,6 +61,11 @@ class TestDecompressionBomb: with Image.open("Tests/images/decompression_bomb.gif"): pass + def test_exception_gif_extents(self): + with Image.open("Tests/images/decompression_bomb_extents.gif") as im: + with pytest.raises(Image.DecompressionBombError): + im.seek(1) + def test_exception_bmp(self): with pytest.raises(Image.DecompressionBombError): with Image.open("Tests/images/bmp/b/reallybig.bmp"): diff --git a/Tests/test_file_bmp.py b/Tests/test_file_bmp.py index f214fd6bd..776b499e0 100644 --- a/Tests/test_file_bmp.py +++ b/Tests/test_file_bmp.py @@ -134,6 +134,9 @@ def test_rle8(): with Image.open("Tests/images/hopper_rle8.bmp") as im: assert_image_similar_tofile(im.convert("RGB"), "Tests/images/hopper.bmp", 12) + with Image.open("Tests/images/hopper_rle8_greyscale.bmp") as im: + assert_image_equal_tofile(im, "Tests/images/bw_gradient.png") + # This test image has been manually hexedited # to have rows with too much data with Image.open("Tests/images/hopper_rle8_row_overflow.bmp") as im: diff --git a/Tests/test_file_gif.py b/Tests/test_file_gif.py index c261cfb97..dbbd3bf9d 100644 --- a/Tests/test_file_gif.py +++ b/Tests/test_file_gif.py @@ -158,6 +158,9 @@ def test_optimize_correctness(): assert_image_equal(im.convert("RGB"), reloaded.convert("RGB")) # These do optimize the palette + check(256, 511, 256) + check(255, 511, 255) + check(129, 511, 129) check(128, 511, 128) check(64, 511, 64) check(4, 511, 4) @@ -167,11 +170,6 @@ def test_optimize_correctness(): check(64, 513, 256) check(4, 513, 256) - # Other limits that don't optimize the palette - check(129, 511, 256) - check(255, 511, 256) - check(256, 511, 256) - def test_optimize_full_l(): im = Image.frombytes("L", (16, 16), bytes(range(256))) @@ -180,6 +178,19 @@ def test_optimize_full_l(): assert im.mode == "L" +def test_optimize_if_palette_can_be_reduced_by_half(): + with Image.open("Tests/images/test.colors.gif") as im: + # Reduce dimensions because original is too big for _get_optimize() + im = im.resize((591, 443)) + im_rgb = im.convert("RGB") + + for (optimize, colors) in ((False, 256), (True, 8)): + out = BytesIO() + im_rgb.save(out, "GIF", optimize=optimize) + with Image.open(out) as reloaded: + assert len(reloaded.palette.palette) // 3 == colors + + def test_roundtrip(tmp_path): out = str(tmp_path / "temp.gif") im = hopper() @@ -982,8 +993,8 @@ def test_append_images(tmp_path): def test_transparent_optimize(tmp_path): # From issue #2195, if the transparent color is incorrectly optimized out, GIF loses # transparency. - # Need a palette that isn't using the 0 color, and one that's > 128 items where the - # transparent color is actually the top palette entry to trigger the bug. + # Need a palette that isn't using the 0 color, + # where the transparent color is actually the top palette entry to trigger the bug. data = bytes(range(1, 254)) palette = ImagePalette.ImagePalette("RGB", list(range(256)) * 3) @@ -993,10 +1004,10 @@ def test_transparent_optimize(tmp_path): im.putpalette(palette) out = str(tmp_path / "temp.gif") - im.save(out, transparency=253) - with Image.open(out) as reloaded: + im.save(out, transparency=im.getpixel((252, 0))) - assert reloaded.info["transparency"] == 253 + with Image.open(out) as reloaded: + assert reloaded.info["transparency"] == reloaded.getpixel((252, 0)) def test_rgb_transparency(tmp_path): diff --git a/Tests/test_file_pcx.py b/Tests/test_file_pcx.py index 61e33a57b..ba6663cd3 100644 --- a/Tests/test_file_pcx.py +++ b/Tests/test_file_pcx.py @@ -20,6 +20,11 @@ def test_sanity(tmp_path): for mode in ("1", "L", "P", "RGB"): _roundtrip(tmp_path, hopper(mode)) + # Test a palette with less than 256 colors + im = Image.new("P", (1, 1)) + im.putpalette((255, 0, 0)) + _roundtrip(tmp_path, im) + # Test an unsupported mode f = str(tmp_path / "temp.pcx") im = hopper("RGBA") diff --git a/Tests/test_font_pcf.py b/Tests/test_font_pcf.py index 885ef8433..815ef1d92 100644 --- a/Tests/test_font_pcf.py +++ b/Tests/test_font_pcf.py @@ -49,6 +49,14 @@ def test_sanity(request, tmp_path): save_font(request, tmp_path) +def test_less_than_256_characters(): + with open("Tests/fonts/10x20-ISO8859-1-fewer-characters.pcf", "rb") as test_file: + font = PcfFontFile.PcfFontFile(test_file) + assert isinstance(font, FontFile.FontFile) + # check the number of characters in the font + assert len([_f for _f in font.glyph if _f]) == 127 + + def test_invalid_file(): with open("Tests/images/flower.jpg", "rb") as fp: with pytest.raises(SyntaxError): diff --git a/Tests/test_image_array.py b/Tests/test_image_array.py index 7168c4265..7e5fd6fe1 100644 --- a/Tests/test_image_array.py +++ b/Tests/test_image_array.py @@ -1,4 +1,5 @@ import pytest +from packaging.version import parse as parse_version from PIL import Image @@ -34,9 +35,10 @@ def test_toarray(): 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) + if parse_version(numpy.__version__) >= parse_version("1.23"): + with Image.open("Tests/images/truncated_jpeg.jpg") as im_truncated: + with pytest.raises(OSError): + numpy.array(im_truncated) def test_fromarray(): diff --git a/Tests/test_image_entropy.py b/Tests/test_image_entropy.py index 876d676fe..ea5886e72 100644 --- a/Tests/test_image_entropy.py +++ b/Tests/test_image_entropy.py @@ -9,7 +9,7 @@ def test_entropy(): assert round(abs(entropy("L") - 7.063008716585465), 7) == 0 assert round(abs(entropy("I") - 7.063008716585465), 7) == 0 assert round(abs(entropy("F") - 7.063008716585465), 7) == 0 - assert round(abs(entropy("P") - 5.0530452472519745), 7) == 0 + assert round(abs(entropy("P") - 5.082506854662517), 7) == 0 assert round(abs(entropy("RGB") - 8.821286587714319), 7) == 0 assert round(abs(entropy("RGBA") - 7.42724306524488), 7) == 0 assert round(abs(entropy("CMYK") - 7.4272430652448795), 7) == 0 diff --git a/Tests/test_image_getcolors.py b/Tests/test_image_getcolors.py index e5b6a7724..7fd0398f9 100644 --- a/Tests/test_image_getcolors.py +++ b/Tests/test_image_getcolors.py @@ -16,7 +16,7 @@ def test_getcolors(): assert getcolors("L") == 255 assert getcolors("I") == 255 assert getcolors("F") == 255 - assert getcolors("P") == 90 # fixed palette + assert getcolors("P") == 96 # fixed palette assert getcolors("RGB") is None assert getcolors("RGBA") is None assert getcolors("CMYK") is None diff --git a/Tests/test_image_histogram.py b/Tests/test_image_histogram.py index 91e02973d..0ee52e724 100644 --- a/Tests/test_image_histogram.py +++ b/Tests/test_image_histogram.py @@ -10,7 +10,7 @@ def test_histogram(): assert histogram("L") == (256, 0, 662) assert histogram("I") == (256, 0, 662) assert histogram("F") == (256, 0, 662) - assert histogram("P") == (256, 0, 1871) + assert histogram("P") == (256, 0, 1551) assert histogram("RGB") == (768, 4, 675) assert histogram("RGBA") == (1024, 0, 16384) assert histogram("CMYK") == (1024, 0, 16384) diff --git a/Tests/test_image_quantize.py b/Tests/test_image_quantize.py index e9afd9118..981753eb9 100644 --- a/Tests/test_image_quantize.py +++ b/Tests/test_image_quantize.py @@ -65,6 +65,22 @@ def test_quantize_no_dither(): assert converted.palette.palette == palette.palette.palette +def test_quantize_no_dither2(): + im = Image.new("RGB", (9, 1)) + im.putdata(list((p,) * 3 for p in range(0, 36, 4))) + + palette = Image.new("P", (1, 1)) + data = (0, 0, 0, 32, 32, 32) + palette.putpalette(data) + quantized = im.quantize(dither=Image.Dither.NONE, palette=palette) + + assert tuple(quantized.palette.palette) == data + + px = quantized.load() + for x in range(9): + assert px[x, 0] == (0 if x < 5 else 1) + + def test_quantize_dither_diff(): image = hopper() with Image.open("Tests/images/caption_6_33_22.png") as palette: diff --git a/docs/index.rst b/docs/index.rst index 5e886c2e8..c731e2746 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -94,4 +94,3 @@ Indices and tables * :ref:`genindex` * :ref:`modindex` -* :ref:`search` diff --git a/docs/reference/ImageGrab.rst b/docs/reference/ImageGrab.rst index ac83b2255..3086ba8c3 100644 --- a/docs/reference/ImageGrab.rst +++ b/docs/reference/ImageGrab.rst @@ -15,7 +15,10 @@ or the clipboard to a PIL image memory. returned as an "RGBA" on macOS, or an "RGB" image otherwise. If the bounding box is omitted, the entire screen is copied. - .. versionadded:: 1.1.3 (Windows), 3.0.0 (macOS), 7.1.0 (Linux (X11)) + On Linux, if ``xdisplay`` is ``None`` then ``gnome-screenshot`` will be used if it + is installed. To capture the default X11 display instead, pass ``xdisplay=""``. + + .. versionadded:: 1.1.3 (Windows), 3.0.0 (macOS), 7.1.0 (Linux) :param bbox: What region to copy. Default is the entire screen. Note that on Windows OS, the top-left point may be negative if ``all_screens=True`` is used. diff --git a/docs/releasenotes/9.2.0.rst b/docs/releasenotes/9.2.0.rst index 424fd487a..ca52f6ab9 100644 --- a/docs/releasenotes/9.2.0.rst +++ b/docs/releasenotes/9.2.0.rst @@ -1,12 +1,6 @@ 9.2.0 ----- -Backwards Incompatible Changes -============================== - -TODO -^^^^ - Deprecations ============ @@ -46,14 +40,6 @@ Image.coerce_e This undocumented method has been deprecated and will be removed in Pillow 10 (2023-07-01). -API Changes -=========== - -TODO -^^^^ - -TODO - API Additions ============= @@ -68,15 +54,14 @@ The image's palette mode will become "RGBA", and "transparency" will be removed Security ======== -TODO -^^^^ - -TODO +An additional decompression bomb check has been added for the GIF format. Other Changes ============= -TODO -^^^^ +Using gnome-screenshot on Linux +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -TODO +In :py:meth:`~PIL.ImageGrab.grab` on Linux, if ``xdisplay`` is ``None`` then +``gnome-screenshot`` will be used to capture the display if it is installed. To capture +the default X11 display instead, pass ``xdisplay=""``. diff --git a/src/PIL/BmpImagePlugin.py b/src/PIL/BmpImagePlugin.py index 4dc2b93c3..5aacb10da 100644 --- a/src/PIL/BmpImagePlugin.py +++ b/src/PIL/BmpImagePlugin.py @@ -321,7 +321,8 @@ class BmpRleDecoder(ImageFile.PyDecoder): # align to 16-bit word boundary if self.fd.tell() % 2 != 0: self.fd.seek(1, os.SEEK_CUR) - self.set_as_raw(bytes(data), ("P", 0, self.args[-1])) + rawmode = "L" if self.mode == "L" else "P" + self.set_as_raw(bytes(data), (rawmode, 0, self.args[-1])) return -1, 0 diff --git a/src/PIL/GifImagePlugin.py b/src/PIL/GifImagePlugin.py index 3469199ca..c239a6a2b 100644 --- a/src/PIL/GifImagePlugin.py +++ b/src/PIL/GifImagePlugin.py @@ -265,6 +265,7 @@ class GifImageFile(ImageFile.ImageFile): x1, y1 = x0 + i16(s, 4), y0 + i16(s, 6) if (x1 > self.size[0] or y1 > self.size[1]) and update_image: self._size = max(x1, self.size[0]), max(y1, self.size[1]) + Image._decompression_bomb_check(self._size) frame_dispose_extent = x0, y0, x1, y1 flags = s[8] @@ -824,9 +825,18 @@ def _get_optimize(im, info): if count: used_palette_colors.append(i) - if optimise or ( - len(used_palette_colors) <= 128 - and max(used_palette_colors) > len(used_palette_colors) + if optimise or max(used_palette_colors) >= len(used_palette_colors): + return used_palette_colors + + num_palette_colors = len(im.palette.palette) // Image.getmodebands( + im.palette.mode + ) + current_palette_size = 1 << (num_palette_colors - 1).bit_length() + if ( + # check that the palette would become smaller when saved + len(used_palette_colors) <= current_palette_size // 2 + # check that the palette is not already the smallest possible size + and current_palette_size > 2 ): return used_palette_colors diff --git a/src/PIL/Image.py b/src/PIL/Image.py index 3cf429735..8d1b3e956 100644 --- a/src/PIL/Image.py +++ b/src/PIL/Image.py @@ -671,14 +671,9 @@ class Image: raise ValueError("Could not save to PNG for display") from e return b.getvalue() - class _ArrayData: - def __init__(self, new): - self.__array_interface__ = new - - def __array__(self, dtype=None): + @property + def __array_interface__(self): # numpy array interface support - import numpy as np - new = {} shape, typestr = _conv_type_shape(self) new["shape"] = shape @@ -690,8 +685,7 @@ class Image: new["data"] = self.tobytes("raw", "L") else: new["data"] = self.tobytes() - - return np.array(self._ArrayData(new), dtype) + return new def __getstate__(self): return [self.info, self.mode, self.size, self.getpalette(), self.tobytes()] diff --git a/src/PIL/ImageGrab.py b/src/PIL/ImageGrab.py index eb21ac399..38074cb1b 100644 --- a/src/PIL/ImageGrab.py +++ b/src/PIL/ImageGrab.py @@ -15,15 +15,14 @@ # See the README file for information on usage and redistribution. # +import os +import shutil +import subprocess import sys +import tempfile from . import Image -if sys.platform == "darwin": - import os - import subprocess - import tempfile - def grab(bbox=None, include_layered_windows=False, all_screens=False, xdisplay=None): if xdisplay is None: @@ -62,6 +61,18 @@ def grab(bbox=None, include_layered_windows=False, all_screens=False, xdisplay=N left, top, right, bottom = bbox im = im.crop((left - x0, top - y0, right - x0, bottom - y0)) return im + elif shutil.which("gnome-screenshot"): + fh, filepath = tempfile.mkstemp(".png") + os.close(fh) + subprocess.call(["gnome-screenshot", "-f", filepath]) + im = Image.open(filepath) + im.load() + os.unlink(filepath) + if bbox: + im_cropped = im.crop(bbox) + im.close() + return im_cropped + return im # use xdisplay=None for default display on non-win32/macOS systems if not Image.core.HAVE_XCB: raise OSError("Pillow was built without XCB support") diff --git a/src/PIL/PcfFontFile.py b/src/PIL/PcfFontFile.py index 0556c2bbc..442ac70c4 100644 --- a/src/PIL/PcfFontFile.py +++ b/src/PIL/PcfFontFile.py @@ -84,8 +84,7 @@ class PcfFontFile(FontFile.FontFile): # # create glyph structure - for ch in range(256): - ix = encoding[ch] + for ch, ix in enumerate(encoding): if ix is not None: x, y, l, r, w, a, d, f = metrics[ix] glyph = (w, 0), (l, d - y, x + l, d), (0, 0, x, y), bitmaps[ix] @@ -219,10 +218,6 @@ class PcfFontFile(FontFile.FontFile): return bitmaps def _load_encoding(self): - - # map character code to bitmap index - encoding = [None] * 256 - fp, format, i16, i32 = self._getformat(PCF_BDF_ENCODINGS) first_col, last_col = i16(fp.read(2)), i16(fp.read(2)) @@ -232,6 +227,9 @@ class PcfFontFile(FontFile.FontFile): nencoding = (last_col - first_col + 1) * (last_row - first_row + 1) + # map character code to bitmap index + encoding = [None] * min(256, nencoding) + encoding_offsets = [i16(fp.read(2)) for _ in range(nencoding)] for i in range(first_col, len(encoding)): diff --git a/src/PIL/PcxImagePlugin.py b/src/PIL/PcxImagePlugin.py index d2e166bdd..841c18a22 100644 --- a/src/PIL/PcxImagePlugin.py +++ b/src/PIL/PcxImagePlugin.py @@ -198,7 +198,9 @@ def _save(im, fp, filename): if im.mode == "P": # colour palette fp.write(o8(12)) - fp.write(im.im.getpalette("RGB", "RGB")) # 768 bytes + palette = im.im.getpalette("RGB", "RGB") + palette += b"\x00" * (768 - len(palette)) + fp.write(palette) # 768 bytes elif im.mode == "L": # greyscale palette fp.write(o8(12)) diff --git a/src/libImaging/Palette.c b/src/libImaging/Palette.c index 20c6bc84b..71a095c2c 100644 --- a/src/libImaging/Palette.c +++ b/src/libImaging/Palette.c @@ -200,15 +200,15 @@ ImagingPaletteCacheUpdate(ImagingPalette palette, int r, int g, int b) { /* Find min and max distances to any point in the box */ r = palette->palette[i * 4 + 0]; - tmin = (r < r0) ? RDIST(r, r1) : (r > r1) ? RDIST(r, r0) : 0; + tmin = (r < r0) ? RDIST(r, r0) : (r > r1) ? RDIST(r, r1) : 0; tmax = (r <= rc) ? RDIST(r, r1) : RDIST(r, r0); g = palette->palette[i * 4 + 1]; - tmin += (g < g0) ? GDIST(g, g1) : (g > g1) ? GDIST(g, g0) : 0; + tmin += (g < g0) ? GDIST(g, g0) : (g > g1) ? GDIST(g, g1) : 0; tmax += (g <= gc) ? GDIST(g, g1) : GDIST(g, g0); b = palette->palette[i * 4 + 2]; - tmin += (b < b0) ? BDIST(b, b1) : (b > b1) ? BDIST(b, b0) : 0; + tmin += (b < b0) ? BDIST(b, b0) : (b > b1) ? BDIST(b, b1) : 0; tmax += (b <= bc) ? BDIST(b, b1) : BDIST(b, b0); dmin[i] = tmin; diff --git a/src/thirdparty/fribidi-shim/fribidi.c b/src/thirdparty/fribidi-shim/fribidi.c index 04491e17f..5663da86b 100644 --- a/src/thirdparty/fribidi-shim/fribidi.c +++ b/src/thirdparty/fribidi-shim/fribidi.c @@ -33,6 +33,7 @@ static void fribidi_get_bracket_types_compat( int load_fribidi(void) { int error = 0; + const char **p_fribidi_version_info = 0; p_fribidi = 0; @@ -87,20 +88,21 @@ int load_fribidi(void) { LOAD_FUNCTION(fribidi_get_par_embedding_levels); #ifndef _WIN32 - fribidi_version_info = *(const char**)dlsym(p_fribidi, "fribidi_version_info"); - if (error || (fribidi_version_info == 0)) { + p_fribidi_version_info = (const char**)dlsym(p_fribidi, "fribidi_version_info"); + if (error || (p_fribidi_version_info == 0) || (*p_fribidi_version_info == 0)) { dlclose(p_fribidi); p_fribidi = 0; return 2; } #else - fribidi_version_info = *(const char**)GetProcAddress(p_fribidi, "fribidi_version_info"); - if (error || (fribidi_version_info == 0)) { + p_fribidi_version_info = (const char**)GetProcAddress(p_fribidi, "fribidi_version_info"); + if (error || (p_fribidi_version_info == 0) || (*p_fribidi_version_info == 0)) { FreeLibrary(p_fribidi); p_fribidi = 0; return 2; } #endif + fribidi_version_info = *p_fribidi_version_info; return 0; } diff --git a/winbuild/build_prepare.py b/winbuild/build_prepare.py index d7e88ca40..2e7c84ad0 100644 --- a/winbuild/build_prepare.py +++ b/winbuild/build_prepare.py @@ -281,9 +281,9 @@ deps = { "libs": [r"imagequant.lib"], }, "harfbuzz": { - "url": "https://github.com/harfbuzz/harfbuzz/archive/4.3.0.zip", - "filename": "harfbuzz-4.3.0.zip", - "dir": "harfbuzz-4.3.0", + "url": "https://github.com/harfbuzz/harfbuzz/archive/4.4.1.zip", + "filename": "harfbuzz-4.4.1.zip", + "dir": "harfbuzz-4.4.1", "build": [ cmd_cmake("-DHB_HAVE_FREETYPE:BOOL=TRUE"), cmd_nmake(target="clean"),