diff --git a/Tests/test_file_apng.py b/Tests/test_file_apng.py index 1b393a3ff5d..93f6ebaa95e 100644 --- a/Tests/test_file_apng.py +++ b/Tests/test_file_apng.py @@ -1,5 +1,6 @@ from __future__ import annotations +from io import BytesIO from pathlib import Path import pytest @@ -668,6 +669,58 @@ def test_apng_save_blend(tmp_path: Path) -> None: assert im.getpixel((0, 0)) == (0, 255, 0, 255) +def test_save_all_progress() -> None: + out = BytesIO() + progress = [] + + def callback(state): + if state["image_filename"]: + state["image_filename"] = ( + state["image_filename"].replace("\\", "/").split("Tests/images/")[-1] + ) + progress.append(state) + + Image.new("RGB", (1, 1)).save(out, "PNG", save_all=True, progress=callback) + assert progress == [ + { + "image_index": 0, + "image_filename": None, + "completed_frames": 1, + "total_frames": 1, + } + ] + + out = BytesIO() + progress = [] + + with Image.open("Tests/images/apng/single_frame.png") as im: + with Image.open("Tests/images/apng/delay.png") as im2: + im.save( + out, "PNG", save_all=True, append_images=[im, im2], progress=callback + ) + + expected = [] + for i in range(2): + expected.append( + { + "image_index": i, + "image_filename": "apng/single_frame.png", + "completed_frames": i + 1, + "total_frames": 7, + } + ) + for i in range(5): + expected.append( + { + "image_index": 2, + "image_filename": "apng/delay.png", + "completed_frames": i + 3, + "total_frames": 7, + } + ) + assert progress == expected + + def test_apng_save_size(tmp_path: Path) -> None: test_file = str(tmp_path / "temp.png") diff --git a/Tests/test_file_gif.py b/Tests/test_file_gif.py index 48c70db8a89..0ddf4a2f82d 100644 --- a/Tests/test_file_gif.py +++ b/Tests/test_file_gif.py @@ -292,6 +292,54 @@ def test_roundtrip_save_all_1(tmp_path: Path) -> None: assert reloaded.getpixel((0, 0)) == 255 +def test_save_all_progress(): + out = BytesIO() + progress = [] + + def callback(state): + if state["image_filename"]: + state["image_filename"] = ( + state["image_filename"].replace("\\", "/").split("Tests/images/")[-1] + ) + progress.append(state) + + Image.new("RGB", (1, 1)).save(out, "GIF", save_all=True, progress=callback) + assert progress == [ + { + "image_index": 0, + "image_filename": None, + "completed_frames": 1, + "total_frames": 1, + } + ] + + out = BytesIO() + progress = [] + + with Image.open("Tests/images/chi.gif") as im2: + im = Image.new("RGB", im2.size) + im.save(out, "GIF", save_all=True, append_images=[im2], progress=callback) + + expected = [ + { + "image_index": 0, + "image_filename": None, + "completed_frames": 1, + "total_frames": 32, + } + ] + for i in range(31): + expected.append( + { + "image_index": 1, + "image_filename": "chi.gif", + "completed_frames": i + 2, + "total_frames": 32, + } + ) + assert progress == expected + + @pytest.mark.parametrize( "path, mode", ( diff --git a/Tests/test_file_mpo.py b/Tests/test_file_mpo.py index a5018870058..bb9440e08a1 100644 --- a/Tests/test_file_mpo.py +++ b/Tests/test_file_mpo.py @@ -278,3 +278,45 @@ def test_save_all() -> None: # Test that a single frame image will not be saved as an MPO jpg = roundtrip(im, save_all=True) assert "mp" not in jpg.info + + +def test_save_all_progress(): + out = BytesIO() + progress = [] + + def callback(state): + if state["image_filename"]: + state["image_filename"] = ( + state["image_filename"].replace("\\", "/").split("Tests/images/")[-1] + ) + progress.append(state) + + Image.new("RGB", (1, 1)).save(out, "MPO", save_all=True, progress=callback) + assert progress == [ + { + "image_index": 0, + "image_filename": None, + "completed_frames": 1, + "total_frames": 1, + } + ] + + out = BytesIO() + progress = [] + + with Image.open("Tests/images/sugarshack.mpo") as im: + with Image.open("Tests/images/frozenpond.mpo") as im2: + im.save(out, "MPO", save_all=True, append_images=[im2], progress=callback) + + expected = [] + for i, filename in enumerate(["sugarshack.mpo", "frozenpond.mpo"]): + for j in range(2): + expected.append( + { + "image_index": i, + "image_filename": filename, + "completed_frames": i * 2 + j + 1, + "total_frames": 4, + } + ) + assert progress == expected diff --git a/Tests/test_file_pdf.py b/Tests/test_file_pdf.py index d39a86565ce..999050e2c4a 100644 --- a/Tests/test_file_pdf.py +++ b/Tests/test_file_pdf.py @@ -1,10 +1,10 @@ from __future__ import annotations -import io import os import os.path import tempfile import time +from io import BytesIO from pathlib import Path from typing import Any, Generator @@ -173,6 +173,48 @@ def im_generator(ims: list[Image.Image]) -> Generator[Image.Image, None, None]: assert os.path.getsize(outfile) > 0 +def test_save_all_progress() -> None: + out = BytesIO() + progress = [] + + def callback(state): + if state["image_filename"]: + state["image_filename"] = ( + state["image_filename"].replace("\\", "/").split("Tests/images/")[-1] + ) + progress.append(state) + + Image.new("RGB", (1, 1)).save(out, "PDF", save_all=True, progress=callback) + assert progress == [ + { + "image_index": 0, + "image_filename": None, + "completed_frames": 1, + "total_frames": 1, + } + ] + + out = BytesIO() + progress = [] + + with Image.open("Tests/images/sugarshack.mpo") as im: + with Image.open("Tests/images/frozenpond.mpo") as im2: + im.save(out, "PDF", save_all=True, append_images=[im2], progress=callback) + + expected = [] + for i, filename in enumerate(["sugarshack.mpo", "frozenpond.mpo"]): + for j in range(2): + expected.append( + { + "image_index": i, + "image_filename": filename, + "completed_frames": i * 2 + j + 1, + "total_frames": 4, + } + ) + assert progress == expected + + def test_multiframe_normal_save(tmp_path: Path) -> None: # Test saving a multiframe image without save_all with Image.open("Tests/images/dispose_bgnd.gif") as im: @@ -327,12 +369,12 @@ def test_pdf_info(tmp_path: Path) -> None: def test_pdf_append_to_bytesio() -> None: im = hopper("RGB") - f = io.BytesIO() + f = BytesIO() im.save(f, format="PDF") initial_size = len(f.getvalue()) assert initial_size > 0 im = hopper("P") - f = io.BytesIO(f.getvalue()) + f = BytesIO(f.getvalue()) im.save(f, format="PDF", append=True) assert len(f.getvalue()) > initial_size diff --git a/Tests/test_file_tiff.py b/Tests/test_file_tiff.py index 8821fb46a84..271fbfec101 100644 --- a/Tests/test_file_tiff.py +++ b/Tests/test_file_tiff.py @@ -677,7 +677,7 @@ def test_palette(self, mode: str, tmp_path: Path) -> None: with Image.open(outfile) as reloaded: assert_image_equal(im.convert("RGB"), reloaded.convert("RGB")) - def test_tiff_save_all(self) -> None: + def test_save_all(self) -> None: mp = BytesIO() with Image.open("Tests/images/multipage.tiff") as im: im.save(mp, format="tiff", save_all=True) @@ -707,6 +707,57 @@ def im_generator(ims: list[Image.Image]) -> Generator[Image.Image, None, None]: with Image.open(mp) as reread: assert reread.n_frames == 3 + def test_save_all_progress(self) -> None: + out = BytesIO() + progress = [] + + def callback(state): + if state["image_filename"]: + state["image_filename"] = ( + state["image_filename"] + .replace("\\", "/") + .split("Tests/images/")[-1] + ) + progress.append(state) + + Image.new("RGB", (1, 1)).save(out, "TIFF", save_all=True, progress=callback) + assert progress == [ + { + "image_index": 0, + "image_filename": None, + "completed_frames": 1, + "total_frames": 1, + } + ] + + out = BytesIO() + progress = [] + + with Image.open("Tests/images/hopper.tif") as im: + with Image.open("Tests/images/multipage.tiff") as im2: + im.save( + out, "TIFF", save_all=True, append_images=[im2], progress=callback + ) + + expected = [ + { + "image_index": 0, + "image_filename": "hopper.tif", + "completed_frames": 1, + "total_frames": 4, + } + ] + for i in range(3): + expected.append( + { + "image_index": 1, + "image_filename": "multipage.tiff", + "completed_frames": i + 2, + "total_frames": 4, + } + ) + assert progress == expected + def test_saving_icc_profile(self, tmp_path: Path) -> None: # Tests saving TIFF with icc_profile set. # At the time of writing this will only work for non-compressed tiffs diff --git a/Tests/test_file_webp.py b/Tests/test_file_webp.py index 249846da481..1595be59a5a 100644 --- a/Tests/test_file_webp.py +++ b/Tests/test_file_webp.py @@ -1,9 +1,9 @@ from __future__ import annotations -import io import re import sys import warnings +from io import BytesIO from pathlib import Path import pytest @@ -105,10 +105,10 @@ def test_write_rgb(self, tmp_path: Path) -> None: def test_write_method(self, tmp_path: Path) -> None: self._roundtrip(tmp_path, self.rgb_mode, 12.0, {"method": 6}) - buffer_no_args = io.BytesIO() + buffer_no_args = BytesIO() hopper().save(buffer_no_args, format="WEBP") - buffer_method = io.BytesIO() + buffer_method = BytesIO() hopper().save(buffer_method, format="WEBP", method=6) assert buffer_no_args.getbuffer() != buffer_method.getbuffer() @@ -125,6 +125,57 @@ def test_save_all(self, tmp_path: Path) -> None: reloaded.seek(1) assert_image_similar(im2, reloaded, 1) + @skip_unless_feature("webp_anim") + def test_save_all_progress(self) -> None: + out = BytesIO() + progress = [] + + def callback(state): + if state["image_filename"]: + state["image_filename"] = ( + state["image_filename"] + .replace("\\", "/") + .split("Tests/images/")[-1] + ) + progress.append(state) + + Image.new("RGB", (1, 1)).save(out, "WEBP", save_all=True, progress=callback) + assert progress == [ + { + "image_index": 0, + "image_filename": None, + "completed_frames": 1, + "total_frames": 1, + } + ] + + out = BytesIO() + progress = [] + + with Image.open("Tests/images/iss634.webp") as im: + im2 = Image.new("RGB", im.size) + im.save(out, "WEBP", save_all=True, append_images=[im2], progress=callback) + + expected = [] + for i in range(42): + expected.append( + { + "image_index": 0, + "image_filename": "iss634.webp", + "completed_frames": i + 1, + "total_frames": 43, + } + ) + expected.append( + { + "image_index": 1, + "image_filename": None, + "completed_frames": 43, + "total_frames": 43, + } + ) + assert progress == expected + def test_icc_profile(self, tmp_path: Path) -> None: self._roundtrip(tmp_path, self.rgb_mode, 12.5, {"icc_profile": None}) if _webp.HAVE_WEBPANIM: diff --git a/src/PIL/GifImagePlugin.py b/src/PIL/GifImagePlugin.py index 6b415d2384a..0b1079a620d 100644 --- a/src/PIL/GifImagePlugin.py +++ b/src/PIL/GifImagePlugin.py @@ -25,7 +25,6 @@ # from __future__ import annotations -import itertools import math import os import subprocess @@ -592,11 +591,18 @@ def _write_multiple_frames(im, fp, palette): duration = im.encoderinfo.get("duration") disposal = im.encoderinfo.get("disposal", im.info.get("disposal")) + imSequences = [im] + list(im.encoderinfo.get("append_images", [])) + progress = im.encoderinfo.get("progress") + if progress: + total = 0 + for imSequence in imSequences: + total += getattr(imSequence, "n_frames", 1) + im_frames = [] previous_im = None frame_count = 0 background_im = None - for imSequence in itertools.chain([im], im.encoderinfo.get("append_images", [])): + for i, imSequence in enumerate(imSequences): for im_frame in ImageSequence.Iterator(imSequence): # a copy is required here since seek can still mutate the image im_frame = _normalize_mode(im_frame.copy()) @@ -628,6 +634,8 @@ def _write_multiple_frames(im, fp, palette): im_frames[-1]["encoderinfo"]["duration"] += encoderinfo[ "duration" ] + if progress: + im._save_all_progress(imSequence, i, frame_count, total) continue if im_frames[-1]["encoderinfo"].get("disposal") == 2: if background_im is None: @@ -685,6 +693,8 @@ def _write_multiple_frames(im, fp, palette): im_frames.append( {"im": diff_frame or im_frame, "bbox": bbox, "encoderinfo": encoderinfo} ) + if progress: + im._save_all_progress(imSequence, i, frame_count, total) if len(im_frames) == 1: if "duration" in im.encoderinfo: diff --git a/src/PIL/Image.py b/src/PIL/Image.py index baef0aa112e..43ae61bf333 100644 --- a/src/PIL/Image.py +++ b/src/PIL/Image.py @@ -2469,6 +2469,23 @@ def save(self, fp, format=None, **params) -> None: if open_fp: fp.close() + def _save_all_progress( + self, im=None, im_index=0, completed=1, total=1, progress=None + ): + if not progress: + progress = self.encoderinfo.get("progress") + if not progress: + return + + progress( + { + "image_index": im_index, + "image_filename": getattr(im or self, "filename", None), + "completed_frames": completed, + "total_frames": total, + } + ) + def seek(self, frame: int) -> None: """ Seeks to the given frame in this sequence file. If you seek diff --git a/src/PIL/MpoImagePlugin.py b/src/PIL/MpoImagePlugin.py index ac9820bbf68..2c823b49350 100644 --- a/src/PIL/MpoImagePlugin.py +++ b/src/PIL/MpoImagePlugin.py @@ -19,7 +19,6 @@ # from __future__ import annotations -import itertools import os import struct @@ -45,11 +44,19 @@ def _save_all(im, fp, filename): animated = False if not animated: _save(im, fp, filename) + im._save_all_progress() return mpf_offset = 28 offsets = [] - for imSequence in itertools.chain([im], append_images): + imSequences = [im] + list(append_images) + progress = im.encoderinfo.get("progress") + if progress: + completed = 0 + total = 0 + for imSequence in imSequences: + total += getattr(imSequence, "n_frames", 1) + for i, imSequence in enumerate(imSequences): for im_frame in ImageSequence.Iterator(imSequence): if not offsets: # APP2 marker @@ -68,6 +75,9 @@ def _save_all(im, fp, filename): else: im_frame.save(fp, "JPEG") offsets.append(fp.tell() - offsets[-1]) + if progress: + completed += 1 + im._save_all_progress(imSequence, i, completed, total, progress) ifd = TiffImagePlugin.ImageFileDirectory_v2() ifd[0xB000] = b"0100" diff --git a/src/PIL/PdfImagePlugin.py b/src/PIL/PdfImagePlugin.py index 1777f1f20db..9aece20b86d 100644 --- a/src/PIL/PdfImagePlugin.py +++ b/src/PIL/PdfImagePlugin.py @@ -248,7 +248,7 @@ def _save(im, fp, filename, save_all=False): existing_pdf.write_catalog() page_number = 0 - for im_sequence in ims: + for i, im_sequence in enumerate(ims): im_pages = ImageSequence.Iterator(im_sequence) if save_all else [im_sequence] for im in im_pages: image_ref, procset = _write_image(im, filename, existing_pdf, image_refs) @@ -282,6 +282,7 @@ def _save(im, fp, filename, save_all=False): existing_pdf.write_obj(contents_refs[page_number], stream=page_contents) page_number += 1 + im._save_all_progress(im_sequence, i, page_number, number_of_pages) # # trailer diff --git a/src/PIL/PngImagePlugin.py b/src/PIL/PngImagePlugin.py index d922bacfb9c..8b6b2ab4057 100644 --- a/src/PIL/PngImagePlugin.py +++ b/src/PIL/PngImagePlugin.py @@ -1101,16 +1101,21 @@ def _write_multiple_frames(im, fp, chunk, rawmode, default_image, append_images) loop = im.encoderinfo.get("loop", im.info.get("loop", 0)) disposal = im.encoderinfo.get("disposal", im.info.get("disposal", Disposal.OP_NONE)) blend = im.encoderinfo.get("blend", im.info.get("blend", Blend.OP_SOURCE)) + progress = im.encoderinfo.get("progress") - if default_image: - chain = itertools.chain(append_images) - else: - chain = itertools.chain([im], append_images) + imSequences = [] + if not default_image: + imSequences.append(im) + imSequences += append_images + if progress: + total = 0 + for imSequence in imSequences: + total += getattr(imSequence, "n_frames", 1) im_frames = [] frame_count = 0 - for im_seq in chain: - for im_frame in ImageSequence.Iterator(im_seq): + for i, imSequence in enumerate(imSequences): + for im_frame in ImageSequence.Iterator(imSequence): if im_frame.mode == rawmode: im_frame = im_frame.copy() else: @@ -1156,12 +1161,16 @@ def _write_multiple_frames(im, fp, chunk, rawmode, default_image, append_images) previous["encoderinfo"]["duration"] += encoderinfo.get( "duration", duration ) + if progress: + im._save_all_progress(imSequence, i, frame_count, total) continue else: bbox = None if "duration" not in encoderinfo: encoderinfo["duration"] = duration im_frames.append({"im": im_frame, "bbox": bbox, "encoderinfo": encoderinfo}) + if progress: + im._save_all_progress(imSequence, i, frame_count, total) if len(im_frames) == 1 and not default_image: return im_frames[0]["im"] diff --git a/src/PIL/TiffImagePlugin.py b/src/PIL/TiffImagePlugin.py index 8bfcd29075e..6f155d85e7a 100644 --- a/src/PIL/TiffImagePlugin.py +++ b/src/PIL/TiffImagePlugin.py @@ -2149,12 +2149,21 @@ def _save_all(im, fp, filename): encoderconfig = im.encoderconfig append_images = list(encoderinfo.get("append_images", [])) if not hasattr(im, "n_frames") and not append_images: - return _save(im, fp, filename) + _save(im, fp, filename) + im._save_all_progress() + return cur_idx = im.tell() + imSequences = [im] + append_images + progress = encoderinfo.get("progress") + if progress: + completed = 0 + total = 0 + for ims in imSequences: + total += getattr(ims, "n_frames", 1) try: with AppendingTiffWriter(fp) as tf: - for ims in [im] + append_images: + for i, ims in enumerate(imSequences): ims.encoderinfo = encoderinfo ims.encoderconfig = encoderconfig if not hasattr(ims, "n_frames"): @@ -2166,6 +2175,10 @@ def _save_all(im, fp, filename): ims.seek(idx) ims.load() _save(ims, tf, filename) + if progress: + completed += 1 + im._save_all_progress(ims, i, completed, total) + tf.newFrame() finally: im.seek(cur_idx) diff --git a/src/PIL/WebPImagePlugin.py b/src/PIL/WebPImagePlugin.py index c07abcaf928..5752140a2d2 100644 --- a/src/PIL/WebPImagePlugin.py +++ b/src/PIL/WebPImagePlugin.py @@ -191,6 +191,7 @@ def _save_all(im, fp, filename): total += getattr(ims, "n_frames", 1) if total == 1: _save(im, fp, filename) + im._save_all_progress() return background = (0, 0, 0, 0) @@ -264,7 +265,7 @@ def _save_all(im, fp, filename): timestamp = 0 cur_idx = im.tell() try: - for ims in [im] + append_images: + for i, ims in enumerate([im] + append_images): # Get # of frames in this image nfr = getattr(ims, "n_frames", 1) @@ -307,6 +308,7 @@ def _save_all(im, fp, filename): else: timestamp += duration frame_idx += 1 + im._save_all_progress(ims, i, frame_idx, total) finally: im.seek(cur_idx)