From 27a0339d647a1368424aebf092219141bf478ee4 Mon Sep 17 00:00:00 2001 From: k128 Date: Mon, 31 Jul 2023 15:14:22 -0400 Subject: [PATCH 01/22] Update WebPImagePlugin.py Automatically load duration --- src/PIL/WebPImagePlugin.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/PIL/WebPImagePlugin.py b/src/PIL/WebPImagePlugin.py index 028e5d2bd..f6b6332a8 100644 --- a/src/PIL/WebPImagePlugin.py +++ b/src/PIL/WebPImagePlugin.py @@ -74,6 +74,9 @@ class WebPImageFile(ImageFile.ImageFile): self.info["background"] = (bg_r, bg_g, bg_b, bg_a) self.n_frames = frame_count self.is_animated = self.n_frames > 1 + _,ts=self._decoder.get_next() + if ts: + self.info["duration"]=ts self._mode = "RGB" if mode == "RGBX" else mode self.rawmode = mode self.tile = [] From 2f5493a5f0a7bdecf728b32839a5ce6809c3052c Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 31 Jul 2023 19:19:54 +0000 Subject: [PATCH 02/22] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- src/PIL/WebPImagePlugin.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/PIL/WebPImagePlugin.py b/src/PIL/WebPImagePlugin.py index f6b6332a8..fc3515003 100644 --- a/src/PIL/WebPImagePlugin.py +++ b/src/PIL/WebPImagePlugin.py @@ -74,9 +74,9 @@ class WebPImageFile(ImageFile.ImageFile): self.info["background"] = (bg_r, bg_g, bg_b, bg_a) self.n_frames = frame_count self.is_animated = self.n_frames > 1 - _,ts=self._decoder.get_next() + _, ts = self._decoder.get_next() if ts: - self.info["duration"]=ts + self.info["duration"] = ts self._mode = "RGB" if mode == "RGBX" else mode self.rawmode = mode self.tile = [] From 15e52290303008c0888dc17aab069edb5cff3a25 Mon Sep 17 00:00:00 2001 From: k128 Date: Mon, 31 Jul 2023 15:32:05 -0400 Subject: [PATCH 03/22] Update WebPImagePlugin.py --- src/PIL/WebPImagePlugin.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/PIL/WebPImagePlugin.py b/src/PIL/WebPImagePlugin.py index fc3515003..47227651b 100644 --- a/src/PIL/WebPImagePlugin.py +++ b/src/PIL/WebPImagePlugin.py @@ -93,7 +93,7 @@ class WebPImageFile(ImageFile.ImageFile): self.info["xmp"] = xmp # Initialize seek state - self._reset(reset=False) + self._reset(reset=True) def _getexif(self): if "exif" not in self.info: From 230a2e3a339e9f6f73c08ea4e16429aa68eef723 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Tue, 1 Aug 2023 19:15:15 +1000 Subject: [PATCH 04/22] If "reset" is always true, then the argument can be removed --- src/PIL/WebPImagePlugin.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/PIL/WebPImagePlugin.py b/src/PIL/WebPImagePlugin.py index 47227651b..aadc71f62 100644 --- a/src/PIL/WebPImagePlugin.py +++ b/src/PIL/WebPImagePlugin.py @@ -93,7 +93,7 @@ class WebPImageFile(ImageFile.ImageFile): self.info["xmp"] = xmp # Initialize seek state - self._reset(reset=True) + self._reset() def _getexif(self): if "exif" not in self.info: @@ -116,9 +116,8 @@ class WebPImageFile(ImageFile.ImageFile): # Set logical frame to requested position self.__logical_frame = frame - def _reset(self, reset=True): - if reset: - self._decoder.reset() + def _reset(self): + self._decoder.reset() self.__physical_frame = 0 self.__loaded = -1 self.__timestamp = 0 From 6115d5957f3a53a33675fdede51469cc02533649 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Tue, 1 Aug 2023 19:16:28 +1000 Subject: [PATCH 05/22] _decoder.get_next() may return None --- src/PIL/WebPImagePlugin.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/PIL/WebPImagePlugin.py b/src/PIL/WebPImagePlugin.py index aadc71f62..a6e1a2a00 100644 --- a/src/PIL/WebPImagePlugin.py +++ b/src/PIL/WebPImagePlugin.py @@ -74,9 +74,9 @@ class WebPImageFile(ImageFile.ImageFile): self.info["background"] = (bg_r, bg_g, bg_b, bg_a) self.n_frames = frame_count self.is_animated = self.n_frames > 1 - _, ts = self._decoder.get_next() - if ts: - self.info["duration"] = ts + ret = self._decoder.get_next() + if ret is not None: + self.info["duration"] = ret[1] self._mode = "RGB" if mode == "RGBX" else mode self.rawmode = mode self.tile = [] From 60433d5f37e93e462584b0e5997dad9b43cd853a Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Tue, 1 Aug 2023 19:17:54 +1000 Subject: [PATCH 06/22] Added test --- Tests/test_file_webp.py | 1 - 1 file changed, 1 deletion(-) diff --git a/Tests/test_file_webp.py b/Tests/test_file_webp.py index a7b6c735a..3832441c0 100644 --- a/Tests/test_file_webp.py +++ b/Tests/test_file_webp.py @@ -233,5 +233,4 @@ class TestFileWebp: im.save(out_webp, save_all=True) with Image.open(out_webp) as reloaded: - reloaded.load() assert reloaded.info["duration"] == 1000 From c54f57c93fecc9ae608f97fa85195fec4746c00b Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Thu, 3 Aug 2023 08:57:55 +1000 Subject: [PATCH 07/22] Updated harfbuzz to 8.1.1 --- winbuild/build_prepare.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/winbuild/build_prepare.py b/winbuild/build_prepare.py index df834a387..9f7fd4f69 100644 --- a/winbuild/build_prepare.py +++ b/winbuild/build_prepare.py @@ -335,9 +335,9 @@ deps = { "libs": [r"imagequant.lib"], }, "harfbuzz": { - "url": "https://github.com/harfbuzz/harfbuzz/archive/8.0.0.zip", - "filename": "harfbuzz-8.0.0.zip", - "dir": "harfbuzz-8.0.0", + "url": "https://github.com/harfbuzz/harfbuzz/archive/8.1.1.zip", + "filename": "harfbuzz-8.1.1.zip", + "dir": "harfbuzz-8.1.1", "license": "COPYING", "build": [ *cmds_cmake( From a5b025629023477ec62410ce77fd717c372d9fa2 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Fri, 4 Aug 2023 23:57:56 +1000 Subject: [PATCH 08/22] Update CHANGES.rst [ci skip] --- CHANGES.rst | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGES.rst b/CHANGES.rst index f19bc346e..de5141d87 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -5,6 +5,12 @@ Changelog (Pillow) 10.1.0 (unreleased) ------------------- +- Added saving LA images as PDFs #7299 + [radarhere] + +- Set SMaskInData to 1 for PDFs with alpha #7316, #7317 + [radarhere] + - Changed Image mode property to be read-only by default #7307 [radarhere] From c9147c9c85818c526ed2d8def7d39dcfc48a8cf0 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Sun, 6 Aug 2023 22:14:32 +1000 Subject: [PATCH 09/22] Moved writing of object into separate function --- src/PIL/PdfImagePlugin.py | 248 +++++++++++++++++++------------------- 1 file changed, 127 insertions(+), 121 deletions(-) diff --git a/src/PIL/PdfImagePlugin.py b/src/PIL/PdfImagePlugin.py index 07f67d465..be39f4d16 100644 --- a/src/PIL/PdfImagePlugin.py +++ b/src/PIL/PdfImagePlugin.py @@ -46,6 +46,128 @@ def _save_all(im, fp, filename): # (Internal) Image save plugin for the PDF format. +def _write_image(im, filename, existing_pdf, image_refs): + # FIXME: Should replace ASCIIHexDecode with RunLengthDecode + # (packbits) or LZWDecode (tiff/lzw compression). Note that + # PDF 1.2 also supports Flatedecode (zip compression). + + params = None + decode = None + + # + # Get image characteristics + + width, height = im.size + + dict_obj = {"BitsPerComponent": 8} + if im.mode == "1": + if features.check("libtiff"): + filter = "CCITTFaxDecode" + dict_obj["BitsPerComponent"] = 1 + params = PdfParser.PdfArray( + [ + PdfParser.PdfDict( + { + "K": -1, + "BlackIs1": True, + "Columns": width, + "Rows": height, + } + ) + ] + ) + else: + filter = "DCTDecode" + dict_obj["ColorSpace"] = PdfParser.PdfName("DeviceGray") + procset = "ImageB" # grayscale + elif im.mode == "L": + filter = "DCTDecode" + # params = f"<< /Predictor 15 /Columns {width-2} >>" + dict_obj["ColorSpace"] = PdfParser.PdfName("DeviceGray") + procset = "ImageB" # grayscale + elif im.mode == "LA": + filter = "JPXDecode" + # params = f"<< /Predictor 15 /Columns {width-2} >>" + procset = "ImageB" # grayscale + dict_obj["SMaskInData"] = 1 + elif im.mode == "P": + filter = "ASCIIHexDecode" + palette = im.getpalette() + dict_obj["ColorSpace"] = [ + PdfParser.PdfName("Indexed"), + PdfParser.PdfName("DeviceRGB"), + 255, + PdfParser.PdfBinary(palette), + ] + procset = "ImageI" # indexed color + elif im.mode == "RGB": + filter = "DCTDecode" + dict_obj["ColorSpace"] = PdfParser.PdfName("DeviceRGB") + procset = "ImageC" # color images + elif im.mode == "RGBA": + filter = "JPXDecode" + procset = "ImageC" # color images + dict_obj["SMaskInData"] = 1 + elif im.mode == "CMYK": + filter = "DCTDecode" + dict_obj["ColorSpace"] = PdfParser.PdfName("DeviceCMYK") + procset = "ImageC" # color images + decode = [1, 0, 1, 0, 1, 0, 1, 0] + else: + msg = f"cannot save mode {im.mode}" + raise ValueError(msg) + + # + # image + + op = io.BytesIO() + + if filter == "ASCIIHexDecode": + ImageFile._save(im, op, [("hex", (0, 0) + im.size, 0, im.mode)]) + elif filter == "CCITTFaxDecode": + im.save( + op, + "TIFF", + compression="group4", + # use a single strip + strip_size=math.ceil(im.width / 8) * im.height, + ) + elif filter == "DCTDecode": + Image.SAVE["JPEG"](im, op, filename) + elif filter == "JPXDecode": + del dict_obj["BitsPerComponent"] + Image.SAVE["JPEG2000"](im, op, filename) + elif filter == "FlateDecode": + ImageFile._save(im, op, [("zip", (0, 0) + im.size, 0, im.mode)]) + elif filter == "RunLengthDecode": + ImageFile._save(im, op, [("packbits", (0, 0) + im.size, 0, im.mode)]) + else: + msg = f"unsupported PDF filter ({filter})" + raise ValueError(msg) + + stream = op.getvalue() + if filter == "CCITTFaxDecode": + stream = stream[8:] + filter = PdfParser.PdfArray([PdfParser.PdfName(filter)]) + else: + filter = PdfParser.PdfName(filter) + + existing_pdf.write_obj( + image_refs[page_number], + stream=stream, + Type=PdfParser.PdfName("XObject"), + Subtype=PdfParser.PdfName("Image"), + Width=width, # * 72.0 / x_resolution, + Height=height, # * 72.0 / y_resolution, + Filter=filter, + Decode=decode, + DecodeParms=params, + **dict_obj, + ) + + return procset + + def _save(im, fp, filename, save_all=False): is_appending = im.encoderinfo.get("append", False) if is_appending: @@ -121,123 +243,7 @@ def _save(im, fp, filename, save_all=False): for im_sequence in ims: im_pages = ImageSequence.Iterator(im_sequence) if save_all else [im_sequence] for im in im_pages: - # FIXME: Should replace ASCIIHexDecode with RunLengthDecode - # (packbits) or LZWDecode (tiff/lzw compression). Note that - # PDF 1.2 also supports Flatedecode (zip compression). - - params = None - decode = None - - # - # Get image characteristics - - width, height = im.size - - dict_obj = {"BitsPerComponent": 8} - if im.mode == "1": - if features.check("libtiff"): - filter = "CCITTFaxDecode" - dict_obj["BitsPerComponent"] = 1 - params = PdfParser.PdfArray( - [ - PdfParser.PdfDict( - { - "K": -1, - "BlackIs1": True, - "Columns": width, - "Rows": height, - } - ) - ] - ) - else: - filter = "DCTDecode" - dict_obj["ColorSpace"] = PdfParser.PdfName("DeviceGray") - procset = "ImageB" # grayscale - elif im.mode == "L": - filter = "DCTDecode" - # params = f"<< /Predictor 15 /Columns {width-2} >>" - dict_obj["ColorSpace"] = PdfParser.PdfName("DeviceGray") - procset = "ImageB" # grayscale - elif im.mode == "LA": - filter = "JPXDecode" - # params = f"<< /Predictor 15 /Columns {width-2} >>" - procset = "ImageB" # grayscale - dict_obj["SMaskInData"] = 1 - elif im.mode == "P": - filter = "ASCIIHexDecode" - palette = im.getpalette() - dict_obj["ColorSpace"] = [ - PdfParser.PdfName("Indexed"), - PdfParser.PdfName("DeviceRGB"), - 255, - PdfParser.PdfBinary(palette), - ] - procset = "ImageI" # indexed color - elif im.mode == "RGB": - filter = "DCTDecode" - dict_obj["ColorSpace"] = PdfParser.PdfName("DeviceRGB") - procset = "ImageC" # color images - elif im.mode == "RGBA": - filter = "JPXDecode" - procset = "ImageC" # color images - dict_obj["SMaskInData"] = 1 - elif im.mode == "CMYK": - filter = "DCTDecode" - dict_obj["ColorSpace"] = PdfParser.PdfName("DeviceCMYK") - procset = "ImageC" # color images - decode = [1, 0, 1, 0, 1, 0, 1, 0] - else: - msg = f"cannot save mode {im.mode}" - raise ValueError(msg) - - # - # image - - op = io.BytesIO() - - if filter == "ASCIIHexDecode": - ImageFile._save(im, op, [("hex", (0, 0) + im.size, 0, im.mode)]) - elif filter == "CCITTFaxDecode": - im.save( - op, - "TIFF", - compression="group4", - # use a single strip - strip_size=math.ceil(im.width / 8) * im.height, - ) - elif filter == "DCTDecode": - Image.SAVE["JPEG"](im, op, filename) - elif filter == "JPXDecode": - del dict_obj["BitsPerComponent"] - Image.SAVE["JPEG2000"](im, op, filename) - elif filter == "FlateDecode": - ImageFile._save(im, op, [("zip", (0, 0) + im.size, 0, im.mode)]) - elif filter == "RunLengthDecode": - ImageFile._save(im, op, [("packbits", (0, 0) + im.size, 0, im.mode)]) - else: - msg = f"unsupported PDF filter ({filter})" - raise ValueError(msg) - - stream = op.getvalue() - if filter == "CCITTFaxDecode": - stream = stream[8:] - filter = PdfParser.PdfArray([PdfParser.PdfName(filter)]) - else: - filter = PdfParser.PdfName(filter) - - existing_pdf.write_obj( - image_refs[page_number], - stream=stream, - Type=PdfParser.PdfName("XObject"), - Subtype=PdfParser.PdfName("Image"), - Width=width, # * 72.0 / x_resolution, - Height=height, # * 72.0 / y_resolution, - Filter=filter, - Decode=decode, - DecodeParms=params, - **dict_obj, - ) + procset = _write_image(im, filename, existing_pdfs, image_refs) # # page @@ -251,8 +257,8 @@ def _save(im, fp, filename, save_all=False): MediaBox=[ 0, 0, - width * 72.0 / x_resolution, - height * 72.0 / y_resolution, + im.width * 72.0 / x_resolution, + im.height * 72.0 / y_resolution, ], Contents=contents_refs[page_number], ) @@ -261,8 +267,8 @@ def _save(im, fp, filename, save_all=False): # page contents page_contents = b"q %f 0 0 %f 0 0 cm /image Do Q\n" % ( - width * 72.0 / x_resolution, - height * 72.0 / y_resolution, + im.width * 72.0 / x_resolution, + im.height * 72.0 / y_resolution, ) existing_pdf.write_obj(contents_refs[page_number], stream=page_contents) From a70ea82eb5c2cd073e1fe7cab0dca32b93fdcb9f Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Mon, 7 Aug 2023 13:53:19 +1000 Subject: [PATCH 10/22] Write P transparency as SMask --- Tests/test_file_pdf.py | 16 ++++++++++++++++ src/PIL/PdfImagePlugin.py | 21 ++++++++++++++++----- 2 files changed, 32 insertions(+), 5 deletions(-) diff --git a/Tests/test_file_pdf.py b/Tests/test_file_pdf.py index 9c8e90b7e..4f7b09af2 100644 --- a/Tests/test_file_pdf.py +++ b/Tests/test_file_pdf.py @@ -48,6 +48,22 @@ def test_save_alpha(tmp_path, mode): helper_save_as_pdf(tmp_path, mode) +def test_p_alpha(tmp_path): + # Arrange + outfile = str(tmp_path / "temp.pdf") + with Image.open("Tests/images/pil123p.png") as im: + assert im.mode == "P" + assert isinstance(im.info["transparency"], bytes) + + # Act + im.save(outfile) + + # Assert + with open(outfile, "rb") as fp: + contents = fp.read() + assert b"SMask" in contents + + def test_monochrome(tmp_path): # Arrange mode = "1" diff --git a/src/PIL/PdfImagePlugin.py b/src/PIL/PdfImagePlugin.py index be39f4d16..e3af1b452 100644 --- a/src/PIL/PdfImagePlugin.py +++ b/src/PIL/PdfImagePlugin.py @@ -100,6 +100,13 @@ def _write_image(im, filename, existing_pdf, image_refs): PdfParser.PdfBinary(palette), ] procset = "ImageI" # indexed color + + if "transparency" in im.info: + smask = im.convert("LA").getchannel("A") + smask.encoderinfo = {} + + image_ref = _write_image(smask, filename, existing_pdf, image_refs)[0] + dict_obj["SMask"] = image_ref elif im.mode == "RGB": filter = "DCTDecode" dict_obj["ColorSpace"] = PdfParser.PdfName("DeviceRGB") @@ -130,7 +137,7 @@ def _write_image(im, filename, existing_pdf, image_refs): "TIFF", compression="group4", # use a single strip - strip_size=math.ceil(im.width / 8) * im.height, + strip_size=math.ceil(width / 8) * height, ) elif filter == "DCTDecode": Image.SAVE["JPEG"](im, op, filename) @@ -152,8 +159,9 @@ def _write_image(im, filename, existing_pdf, image_refs): else: filter = PdfParser.PdfName(filter) + image_ref = image_refs.pop(0) existing_pdf.write_obj( - image_refs[page_number], + image_ref, stream=stream, Type=PdfParser.PdfName("XObject"), Subtype=PdfParser.PdfName("Image"), @@ -165,7 +173,7 @@ def _write_image(im, filename, existing_pdf, image_refs): **dict_obj, ) - return procset + return image_ref, procset def _save(im, fp, filename, save_all=False): @@ -231,6 +239,9 @@ def _save(im, fp, filename, save_all=False): number_of_pages += im_number_of_pages for i in range(im_number_of_pages): image_refs.append(existing_pdf.next_object_id(0)) + if im.mode == "P" and "transparency" in im.info: + image_refs.append(existing_pdf.next_object_id(0)) + page_refs.append(existing_pdf.next_object_id(0)) contents_refs.append(existing_pdf.next_object_id(0)) existing_pdf.pages.append(page_refs[-1]) @@ -243,7 +254,7 @@ def _save(im, fp, filename, save_all=False): for im_sequence in ims: im_pages = ImageSequence.Iterator(im_sequence) if save_all else [im_sequence] for im in im_pages: - procset = _write_image(im, filename, existing_pdfs, image_refs) + image_ref, procset = _write_image(im, filename, existing_pdf, image_refs) # # page @@ -252,7 +263,7 @@ def _save(im, fp, filename, save_all=False): page_refs[page_number], Resources=PdfParser.PdfDict( ProcSet=[PdfParser.PdfName("PDF"), PdfParser.PdfName(procset)], - XObject=PdfParser.PdfDict(image=image_refs[page_number]), + XObject=PdfParser.PdfDict(image=image_ref), ), MediaBox=[ 0, From 5c5980721665a6a5ee64a5bf24efd26514b0b7eb Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Mon, 7 Aug 2023 13:54:11 +1000 Subject: [PATCH 11/22] Removed unused decoders --- src/PIL/PdfImagePlugin.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/PIL/PdfImagePlugin.py b/src/PIL/PdfImagePlugin.py index e3af1b452..09fc0c7e6 100644 --- a/src/PIL/PdfImagePlugin.py +++ b/src/PIL/PdfImagePlugin.py @@ -144,10 +144,6 @@ def _write_image(im, filename, existing_pdf, image_refs): elif filter == "JPXDecode": del dict_obj["BitsPerComponent"] Image.SAVE["JPEG2000"](im, op, filename) - elif filter == "FlateDecode": - ImageFile._save(im, op, [("zip", (0, 0) + im.size, 0, im.mode)]) - elif filter == "RunLengthDecode": - ImageFile._save(im, op, [("packbits", (0, 0) + im.size, 0, im.mode)]) else: msg = f"unsupported PDF filter ({filter})" raise ValueError(msg) From 73bd40babe644fcd402f0b7d3ae8be4894ca66f2 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Mon, 7 Aug 2023 20:49:29 +1000 Subject: [PATCH 12/22] Test for relevant characters before and after "SMask" --- Tests/test_file_pdf.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Tests/test_file_pdf.py b/Tests/test_file_pdf.py index 4f7b09af2..ffc392d6b 100644 --- a/Tests/test_file_pdf.py +++ b/Tests/test_file_pdf.py @@ -61,7 +61,7 @@ def test_p_alpha(tmp_path): # Assert with open(outfile, "rb") as fp: contents = fp.read() - assert b"SMask" in contents + assert b"\n/SMask " in contents def test_monochrome(tmp_path): From 5b6b6346bb2736143df90249a6a6f3a985352d9a Mon Sep 17 00:00:00 2001 From: Yay295 Date: Mon, 7 Aug 2023 09:49:20 -0500 Subject: [PATCH 13/22] Fix param in test_image.py function --- Tests/test_image.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/Tests/test_image.py b/Tests/test_image.py index 36f24379a..7df1916ef 100644 --- a/Tests/test_image.py +++ b/Tests/test_image.py @@ -661,15 +661,15 @@ class TestImage: blank_p.palette = None blank_pa.palette = None - def _make_new(base_image, im, palette_result=None): - new_im = base_image._new(im) - assert new_im.mode == im.mode - assert new_im.size == im.size - assert new_im.info == base_image.info + def _make_new(base_image, image, palette_result=None): + new_image = base_image._new(image.im) + assert new_image.mode == image.mode + assert new_image.size == image.size + assert new_image.info == base_image.info if palette_result is not None: - assert new_im.palette.tobytes() == palette_result.tobytes() + assert new_image.palette.tobytes() == palette_result.tobytes() else: - assert new_im.palette is None + assert new_image.palette is None _make_new(im, im_p, ImagePalette.ImagePalette(list(range(256)) * 3)) _make_new(im_p, im, None) From 24a19e5b37be9553584ba51d28e7aed8283c9d2d Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 7 Aug 2023 19:39:13 +0000 Subject: [PATCH 14/22] [pre-commit.ci] pre-commit autoupdate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - [github.com/psf/black: 23.3.0 → 23.7.0](https://github.com/psf/black/compare/23.3.0...23.7.0) - [github.com/Lucas-C/pre-commit-hooks: v1.5.1 → v1.5.3](https://github.com/Lucas-C/pre-commit-hooks/compare/v1.5.1...v1.5.3) - [github.com/PyCQA/flake8: 6.0.0 → 6.1.0](https://github.com/PyCQA/flake8/compare/6.0.0...6.1.0) - [github.com/tox-dev/pyproject-fmt: 0.12.1 → 0.13.0](https://github.com/tox-dev/pyproject-fmt/compare/0.12.1...0.13.0) --- .pre-commit-config.yaml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 320a77f55..5354509d4 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/psf/black - rev: 23.3.0 + rev: 23.7.0 hooks: - id: black args: [--target-version=py38] @@ -23,13 +23,13 @@ repos: - id: yesqa - repo: https://github.com/Lucas-C/pre-commit-hooks - rev: v1.5.1 + rev: v1.5.3 hooks: - id: remove-tabs exclude: (Makefile$|\.bat$|\.cmake$|\.eps$|\.fits$|\.gd$|\.opt$) - repo: https://github.com/PyCQA/flake8 - rev: 6.0.0 + rev: 6.1.0 hooks: - id: flake8 additional_dependencies: @@ -55,7 +55,7 @@ repos: - id: sphinx-lint - repo: https://github.com/tox-dev/pyproject-fmt - rev: 0.12.1 + rev: 0.13.0 hooks: - id: pyproject-fmt From 15930be644d97dd5d1c133b50c92d5ae411306f6 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Tue, 8 Aug 2023 08:44:03 +1000 Subject: [PATCH 15/22] Use "is" when comparing types --- src/PIL/ImageMath.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/PIL/ImageMath.py b/src/PIL/ImageMath.py index ac7d36b69..eb6bbe6c6 100644 --- a/src/PIL/ImageMath.py +++ b/src/PIL/ImageMath.py @@ -247,7 +247,7 @@ def eval(expression, _dict={}, **kw): def scan(code): for const in code.co_consts: - if type(const) == type(compiled_code): + if type(const) is type(compiled_code): scan(const) for name in code.co_names: From c98a7994da83a416bfd0efc068bc7ebf5fc1d133 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Tue, 8 Aug 2023 21:33:55 +1000 Subject: [PATCH 16/22] Update CHANGES.rst [ci skip] --- CHANGES.rst | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGES.rst b/CHANGES.rst index de5141d87..937103c66 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -5,6 +5,9 @@ Changelog (Pillow) 10.1.0 (unreleased) ------------------- +- Fixed transparency when saving P mode images to PDF #7323 + [radarhere] + - Added saving LA images as PDFs #7299 [radarhere] From bfafa460e3e2710e840960a8d928fe44dc326874 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Wed, 9 Aug 2023 10:31:34 +1000 Subject: [PATCH 17/22] Allow "loop=None" when saving --- Tests/test_file_gif.py | 8 ++++++++ docs/handbook/image-file-formats.rst | 2 +- src/PIL/GifImagePlugin.py | 4 ++-- 3 files changed, 11 insertions(+), 3 deletions(-) diff --git a/Tests/test_file_gif.py b/Tests/test_file_gif.py index b1c9f731f..d571692b1 100644 --- a/Tests/test_file_gif.py +++ b/Tests/test_file_gif.py @@ -875,6 +875,14 @@ def test_identical_frames_to_single_frame(duration, tmp_path): assert reread.info["duration"] == 8500 +def test_loop_none(tmp_path): + out = str(tmp_path / "temp.gif") + im = Image.new("L", (100, 100), "#000") + im.save(out, loop=None) + with Image.open(out) as reread: + assert "loop" not in reread.info + + def test_number_of_loops(tmp_path): number_of_loops = 2 diff --git a/docs/handbook/image-file-formats.rst b/docs/handbook/image-file-formats.rst index 0d8d9bc10..2a42bdacb 100644 --- a/docs/handbook/image-file-formats.rst +++ b/docs/handbook/image-file-formats.rst @@ -253,7 +253,7 @@ their :py:attr:`~PIL.Image.Image.info` values. **loop** Integer number of times the GIF should loop. 0 means that it will loop - forever. By default, the image will not loop. + forever. If omitted or ``None``, the image will not loop. **comment** A comment about the image. diff --git a/src/PIL/GifImagePlugin.py b/src/PIL/GifImagePlugin.py index 943842f77..92074b0d4 100644 --- a/src/PIL/GifImagePlugin.py +++ b/src/PIL/GifImagePlugin.py @@ -912,7 +912,7 @@ def _get_global_header(im, info): info and ( "transparency" in info - or "loop" in info + or info.get("loop") is not None or info.get("duration") or info.get("comment") ) @@ -937,7 +937,7 @@ def _get_global_header(im, info): # Global Color Table _get_header_palette(palette_bytes), ] - if "loop" in info: + if info.get("loop") is not None: header.append( b"!" + o8(255) # extension intro From f39f74fb82348ce87dfc9e4766ee473132ce84d3 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Fri, 11 Aug 2023 07:42:24 +1000 Subject: [PATCH 18/22] Update CHANGES.rst [ci skip] --- CHANGES.rst | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGES.rst b/CHANGES.rst index 937103c66..b02c33ac3 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -5,6 +5,9 @@ Changelog (Pillow) 10.1.0 (unreleased) ------------------- +- Allow "loop=None" when saving GIF images #7329 + [radarhere] + - Fixed transparency when saving P mode images to PDF #7323 [radarhere] From 08b538780d09303d617e5e4fb68ba1d9e34f5629 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Fri, 11 Aug 2023 19:49:02 +1000 Subject: [PATCH 19/22] Update CHANGES.rst [ci skip] --- CHANGES.rst | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGES.rst b/CHANGES.rst index b02c33ac3..e752f66c4 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -5,6 +5,9 @@ Changelog (Pillow) 10.1.0 (unreleased) ------------------- +- Read WebP duration after opening #7311 + [k128, radarhere] + - Allow "loop=None" when saving GIF images #7329 [radarhere] From 8587121932afff43c12eef05d6f212721f9ea5e5 Mon Sep 17 00:00:00 2001 From: Tommy <4850853+wx00@users.noreply.github.com> Date: Fri, 18 Aug 2023 11:20:18 +0800 Subject: [PATCH 20/22] Fixed a typo in 10.0.0 release note --- docs/releasenotes/10.0.0.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/releasenotes/10.0.0.rst b/docs/releasenotes/10.0.0.rst index 4cd629322..06acfc7af 100644 --- a/docs/releasenotes/10.0.0.rst +++ b/docs/releasenotes/10.0.0.rst @@ -206,4 +206,4 @@ Support reading signed 8-bit TIFF images ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ TIFF images with signed integer data, 8 bits per sample and a photometric -interpretaton of BlackIsZero can now be read. +interpretation of BlackIsZero can now be read. From 9ef7cb39def45b0fe1cdf4828ca20838a1fc39d1 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Fri, 18 Aug 2023 22:22:51 +1000 Subject: [PATCH 21/22] Updated zlib to 1.3 --- Tests/test_file_png.py | 2 +- winbuild/build_prepare.py | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Tests/test_file_png.py b/Tests/test_file_png.py index 3ffe93c6d..f8df88d67 100644 --- a/Tests/test_file_png.py +++ b/Tests/test_file_png.py @@ -79,7 +79,7 @@ class TestFilePng: def test_sanity(self, tmp_path): # internal version number - assert re.search(r"\d+\.\d+\.\d+(\.\d+)?$", features.version_codec("zlib")) + assert re.search(r"\d+(\.\d+){1,3}$", features.version_codec("zlib")) test_file = str(tmp_path / "temp.png") diff --git a/winbuild/build_prepare.py b/winbuild/build_prepare.py index f24a013a4..412f30026 100644 --- a/winbuild/build_prepare.py +++ b/winbuild/build_prepare.py @@ -130,9 +130,9 @@ deps = { "bins": ["cjpeg.exe", "djpeg.exe"], }, "zlib": { - "url": "https://zlib.net/zlib1213.zip", - "filename": "zlib1213.zip", - "dir": "zlib-1.2.13", + "url": "https://zlib.net/zlib13.zip", + "filename": "zlib13.zip", + "dir": "zlib-1.3", "license": "README", "license_pattern": "Copyright notice:\n\n(.+)$", "build": [ From 115ef3a36df58588d53127c0bbe717af13ca691c Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Mon, 21 Aug 2023 15:17:30 +1000 Subject: [PATCH 22/22] 32-bit Windows wheels are no longer provided --- docs/installation.rst | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/docs/installation.rst b/docs/installation.rst index a4d22316d..724348c13 100644 --- a/docs/installation.rst +++ b/docs/installation.rst @@ -83,10 +83,9 @@ Install Pillow with :command:`pip`:: .. tab:: Windows We provide Pillow binaries for Windows compiled for the matrix of - supported Pythons in both 32 and 64-bit versions in the wheel format. - These binaries include support for all optional libraries except - libimagequant and libxcb. Raqm support requires - FriBiDi to be installed separately:: + supported Pythons in 64-bit versions in the wheel format. These binaries include + support for all optional libraries except libimagequant and libxcb. Raqm support + requires FriBiDi to be installed separately:: python3 -m pip install --upgrade pip python3 -m pip install --upgrade Pillow