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 diff --git a/CHANGES.rst b/CHANGES.rst index f19bc346e..e752f66c4 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -5,6 +5,21 @@ Changelog (Pillow) 10.1.0 (unreleased) ------------------- +- Read WebP duration after opening #7311 + [k128, radarhere] + +- Allow "loop=None" when saving GIF images #7329 + [radarhere] + +- Fixed transparency when saving P mode images to PDF #7323 + [radarhere] + +- 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] 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/Tests/test_file_pdf.py b/Tests/test_file_pdf.py index 9c8e90b7e..ffc392d6b 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"\n/SMask " in contents + + def test_monochrome(tmp_path): # Arrange mode = "1" 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/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 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) 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/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 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. 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 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: diff --git a/src/PIL/PdfImagePlugin.py b/src/PIL/PdfImagePlugin.py index 07f67d465..09fc0c7e6 100644 --- a/src/PIL/PdfImagePlugin.py +++ b/src/PIL/PdfImagePlugin.py @@ -46,6 +46,132 @@ 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 + + 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") + 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(width / 8) * height, + ) + elif filter == "DCTDecode": + Image.SAVE["JPEG"](im, op, filename) + elif filter == "JPXDecode": + del dict_obj["BitsPerComponent"] + Image.SAVE["JPEG2000"](im, op, filename) + 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) + + image_ref = image_refs.pop(0) + existing_pdf.write_obj( + image_ref, + 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 image_ref, procset + + def _save(im, fp, filename, save_all=False): is_appending = im.encoderinfo.get("append", False) if is_appending: @@ -109,6 +235,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]) @@ -121,123 +250,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, - ) + image_ref, procset = _write_image(im, filename, existing_pdf, image_refs) # # page @@ -246,13 +259,13 @@ 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, 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 +274,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) diff --git a/src/PIL/WebPImagePlugin.py b/src/PIL/WebPImagePlugin.py index 028e5d2bd..a6e1a2a00 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 + 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 = [] @@ -90,7 +93,7 @@ class WebPImageFile(ImageFile.ImageFile): self.info["xmp"] = xmp # Initialize seek state - self._reset(reset=False) + self._reset() def _getexif(self): if "exif" not in self.info: @@ -113,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 diff --git a/winbuild/build_prepare.py b/winbuild/build_prepare.py index f24a013a4..c94d8a363 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": [ @@ -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(