From 5bb1b359ae325c1cf73d455b0a1c686f1c7edb30 Mon Sep 17 00:00:00 2001 From: Vincent Gao Date: Tue, 30 Jun 2026 23:40:20 +0200 Subject: [PATCH] Fix ICO save producing an empty file for images smaller than 16x16 Saving an image smaller than the smallest default size (16x16) as ICO, without an explicit sizes argument, wrote a header-only 6-byte file with zero icon entries. Reopening it raised UnidentifiedImageError and the image was lost silently. The default sizes start at 16x16 and the save loop skips every candidate larger than the source image, so for a sub-16 image all candidates are skipped and no frame is written. Filter the requested sizes up front and, if none fit, fall back to the image's own size (capped at the 256x256 ICO maximum) so a valid, readable icon is always written. This also covers the case where every explicitly requested size is larger than the image. --- Tests/test_file_ico.py | 37 +++++++++++++++++++++++++++++++++++++ src/PIL/IcoImagePlugin.py | 16 ++++++++++++---- 2 files changed, 49 insertions(+), 4 deletions(-) diff --git a/Tests/test_file_ico.py b/Tests/test_file_ico.py index 36b608a0a98..e8f94b09d0d 100644 --- a/Tests/test_file_ico.py +++ b/Tests/test_file_ico.py @@ -88,6 +88,43 @@ def test_save_to_bytes() -> None: ) +@pytest.mark.parametrize("size", ((1, 1), (8, 8), (15, 15), (8, 12))) +def test_save_smaller_than_default_sizes(size: tuple[int, int]) -> None: + # An image smaller than the smallest default size (16x16) must still be + # saved as a valid, readable ICO rather than a header-only, empty file. + im = Image.new("RGBA", size, (10, 20, 30, 255)) + im.putpixel((size[0] - 1, size[1] - 1), (200, 100, 50, 255)) + + output = io.BytesIO() + im.save(output, "ico") + + output.seek(0) + with Image.open(output) as reloaded: + assert reloaded.format == "ICO" + assert reloaded.size == size + assert reloaded.info["sizes"] == {size} + assert reloaded.convert("RGBA").getpixel((size[0] - 1, size[1] - 1)) == ( + 200, + 100, + 50, + 255, + ) + + +def test_save_all_sizes_larger_than_image() -> None: + # When every requested size is larger than the image, all are ignored, so + # fall back to the image's own size instead of writing an empty file. + im = Image.new("RGBA", (8, 8), (10, 20, 30, 255)) + + output = io.BytesIO() + im.save(output, "ico", sizes=[(16, 16), (32, 32)]) + + output.seek(0) + with Image.open(output) as reloaded: + assert reloaded.format == "ICO" + assert reloaded.size == (8, 8) + + def test_getpixel(tmp_path: Path) -> None: temp_file = tmp_path / "temp.ico" diff --git a/src/PIL/IcoImagePlugin.py b/src/PIL/IcoImagePlugin.py index 8dd57ff858a..32cabeedfcf 100644 --- a/src/PIL/IcoImagePlugin.py +++ b/src/PIL/IcoImagePlugin.py @@ -64,10 +64,18 @@ def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: frames = [] provided_ims = [im] + im.encoderinfo.get("append_images", []) width, height = im.size - for size in sorted(set(sizes)): - if size[0] > width or size[1] > height or size[0] > 256 or size[1] > 256: - continue - + sizes = [ + size + for size in sorted(set(sizes)) + if size[0] <= width and size[1] <= height and size[0] <= 256 and size[1] <= 256 + ] + if not sizes: + # Every requested size is larger than the source image, so fall back to + # the image's own size (capped at the 256x256 ICO maximum). This avoids + # writing an empty, unreadable file when the image is smaller than the + # smallest default size. + sizes = [(min(width, 256), min(height, 256))] + for size in sizes: for provided_im in provided_ims: if provided_im.size != size: continue