From 1a36f70ff9e1ab7c6a480cdd01f2248f0efa97ee Mon Sep 17 00:00:00 2001 From: qodelicious <162776175+qodelicious@users.noreply.github.com> Date: Wed, 21 Oct 2026 00:21:21 +0000 Subject: [PATCH 1/3] Fix saving L images as Palm Since the colormap change in #8685, an L image saved with bpp went through the P mode branch because its mode is switched to P before writing, and failed with 'image has no palette'. Only write the custom colormap for real P images. Also document the bpp option. --- Tests/test_file_palm.py | 33 ++++++++++++++++++++++++++++ docs/handbook/image-file-formats.rst | 14 ++++++++++++ src/PIL/PalmImagePlugin.py | 2 +- 3 files changed, 48 insertions(+), 1 deletion(-) diff --git a/Tests/test_file_palm.py b/Tests/test_file_palm.py index ffac42e889d..3753520aa1d 100644 --- a/Tests/test_file_palm.py +++ b/Tests/test_file_palm.py @@ -2,6 +2,7 @@ import os.path import subprocess +from io import BytesIO import pytest @@ -72,6 +73,38 @@ def test_p_mode(tmp_path: Path) -> None: roundtrip(tmp_path, mode) +@pytest.mark.parametrize("bpp", (1, 2, 4)) +def test_l_mode(bpp: int) -> None: + im = Image.new("L", (4, 1)) + im.putdata([0, 85, 170, 255]) + + out = BytesIO() + im.save(out, "PALM", bpp=bpp) + + data = out.getvalue() + # No custom colormap flag, and the requested bits per pixel + assert data[6:8] == b"\x00\x00" + assert data[8] == bpp + assert len(data) == 16 + 2 + + # Palm grayscale goes from white (0) to black + expected = {1: b"\xc0\x00", 2: b"\xe4\x00", 4: b"\xfa\x50"} + assert data[16:] == expected[bpp] + + +def test_l_mode_info_bpp() -> None: + im = Image.new("L", (4, 1)) + im.putdata([0, 1, 2, 3]) + im.info["bpp"] = 2 + + out = BytesIO() + im.save(out, "PALM") + + data = out.getvalue() + assert data[8] == 2 + assert data[16:] == b"\xe4\x00" + + @pytest.mark.parametrize("mode", ("L", "RGB")) def test_oserror(tmp_path: Path, mode: str) -> None: with pytest.raises(OSError): diff --git a/docs/handbook/image-file-formats.rst b/docs/handbook/image-file-formats.rst index 1cc01838909..c650a799308 100644 --- a/docs/handbook/image-file-formats.rst +++ b/docs/handbook/image-file-formats.rst @@ -1788,6 +1788,20 @@ Pillow provides write-only support for PALM pixmap files. The format code is ``Palm``, the extension is ``.palm``. +``1``, ``L`` and ``P`` images can be saved. + +Saving +~~~~~~ + +The :py:meth:`~PIL.Image.Image.save` method can take the following keyword arguments: + +**bpp** + For ``L`` images, the number of bits per pixel to write, either 1, 2 or 4. + The image is reduced to that many shades of gray. If it is not given, the + ``bpp`` value from :py:attr:`~PIL.Image.Image.info` is used instead, and + only the lowest bits of each pixel are kept. ``L`` images cannot be saved + without one of these. + PDF ^^^ diff --git a/src/PIL/PalmImagePlugin.py b/src/PIL/PalmImagePlugin.py index bf8e81a5f5e..80efa6e032f 100644 --- a/src/PIL/PalmImagePlugin.py +++ b/src/PIL/PalmImagePlugin.py @@ -172,7 +172,7 @@ def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: compression_type = _COMPRESSION_TYPES["none"] flags = 0 - if im.mode == "P": + if rawmode == "P": flags |= _FLAGS["custom-colormap"] colormap = im.im.getpalette() colors = len(colormap) // 3 From fb7397622e4245c4e58a7ba9ebb3fa2c5b389af0 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Wed, 21 Oct 2026 12:21:57 +1100 Subject: [PATCH 2/3] Do not change original image mode --- src/PIL/PalmImagePlugin.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/PIL/PalmImagePlugin.py b/src/PIL/PalmImagePlugin.py index 80efa6e032f..8e8a92e944c 100644 --- a/src/PIL/PalmImagePlugin.py +++ b/src/PIL/PalmImagePlugin.py @@ -144,8 +144,7 @@ def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: raise OSError(msg) # we ignore the palette here - im._mode = "P" - rawmode = f"P;{bpp}" + rawmode = f"L;{bpp}" version = 1 elif im.mode == "1": @@ -172,7 +171,7 @@ def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: compression_type = _COMPRESSION_TYPES["none"] flags = 0 - if rawmode == "P": + if im.mode == "P": flags |= _FLAGS["custom-colormap"] colormap = im.im.getpalette() colors = len(colormap) // 3 From 16ab90286e23202db9ffcefe6daa09c3ff5a74aa Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Wed, 21 Oct 2026 13:05:22 +1100 Subject: [PATCH 3/3] Test image similarity, rather than specific bytes --- Tests/test_file_palm.py | 78 ++++++++++------------------ docs/handbook/image-file-formats.rst | 10 ++-- src/PIL/PalmImagePlugin.py | 1 - 3 files changed, 32 insertions(+), 57 deletions(-) diff --git a/Tests/test_file_palm.py b/Tests/test_file_palm.py index 3753520aa1d..f43a9ee2cff 100644 --- a/Tests/test_file_palm.py +++ b/Tests/test_file_palm.py @@ -2,17 +2,17 @@ import os.path import subprocess -from io import BytesIO import pytest from PIL import Image -from .helper import assert_image_equal, hopper, magick_command +from .helper import assert_image_equal, assert_image_similar, hopper, magick_command TYPE_CHECKING = False if TYPE_CHECKING: from pathlib import Path + from typing import Any def helper_save_as_palm(tmp_path: Path, mode: str) -> None: @@ -37,72 +37,48 @@ def open_with_magick(magick: list[str], tmp_path: Path, f: str) -> Image.Image: return Image.open(outfile) -def roundtrip(tmp_path: Path, mode: str) -> None: +def roundtrip(tmp_path: Path, im: Image.Image, **options: Any) -> Image.Image: magick = magick_command() if not magick: pytest.skip("ImageMagick not available") - im = hopper(mode) outfile = str(tmp_path / "temp.palm") - im.save(outfile) - converted = open_with_magick(magick, tmp_path, outfile) - if mode == "P": - assert converted.mode == "P" - - im = im.convert("RGB") - converted = converted.convert("RGB") - assert_image_equal(converted, im) + im.save(outfile, **options) + return open_with_magick(magick, tmp_path, outfile) def test_monochrome(tmp_path: Path) -> None: - # Arrange - mode = "1" + helper_save_as_palm(tmp_path, "1") - # Act / Assert - helper_save_as_palm(tmp_path, mode) - roundtrip(tmp_path, mode) + im = hopper("1") + assert_image_equal(roundtrip(tmp_path, im), im) def test_p_mode(tmp_path: Path) -> None: - # Arrange - mode = "P" + helper_save_as_palm(tmp_path, "P") - # Act / Assert - helper_save_as_palm(tmp_path, mode) - roundtrip(tmp_path, mode) + im = hopper("P") + converted = roundtrip(tmp_path, im) + assert converted.mode == "P" + assert_image_equal(converted.convert("RGB"), im.convert("RGB")) @pytest.mark.parametrize("bpp", (1, 2, 4)) -def test_l_mode(bpp: int) -> None: - im = Image.new("L", (4, 1)) - im.putdata([0, 85, 170, 255]) - - out = BytesIO() - im.save(out, "PALM", bpp=bpp) - - data = out.getvalue() - # No custom colormap flag, and the requested bits per pixel - assert data[6:8] == b"\x00\x00" - assert data[8] == bpp - assert len(data) == 16 + 2 - - # Palm grayscale goes from white (0) to black - expected = {1: b"\xc0\x00", 2: b"\xe4\x00", 4: b"\xfa\x50"} - assert data[16:] == expected[bpp] - - -def test_l_mode_info_bpp() -> None: - im = Image.new("L", (4, 1)) - im.putdata([0, 1, 2, 3]) - im.info["bpp"] = 2 - - out = BytesIO() - im.save(out, "PALM") - - data = out.getvalue() - assert data[8] == 2 - assert data[16:] == b"\xe4\x00" +@pytest.mark.parametrize("info", (True, False)) +def test_l_mode(tmp_path: Path, bpp: int, info: bool) -> None: + im = hopper("L") + if info: + reloaded = roundtrip(tmp_path, im, bpp=bpp) + else: + im.info["bpp"] = bpp + reloaded = roundtrip(tmp_path, im) + if bpp == 1: + assert reloaded.mode == "1" + else: + assert reloaded.mode == "L" + if info: + assert_image_similar(reloaded, im, 23 if bpp == 2 else 6) @pytest.mark.parametrize("mode", ("L", "RGB")) diff --git a/docs/handbook/image-file-formats.rst b/docs/handbook/image-file-formats.rst index c650a799308..518bb4faa8e 100644 --- a/docs/handbook/image-file-formats.rst +++ b/docs/handbook/image-file-formats.rst @@ -1796,11 +1796,11 @@ Saving The :py:meth:`~PIL.Image.Image.save` method can take the following keyword arguments: **bpp** - For ``L`` images, the number of bits per pixel to write, either 1, 2 or 4. - The image is reduced to that many shades of gray. If it is not given, the - ``bpp`` value from :py:attr:`~PIL.Image.Image.info` is used instead, and - only the lowest bits of each pixel are kept. ``L`` images cannot be saved - without one of these. + For ``L`` images, the number of bits per pixel to write, either 1, 2 or 4. Shades + in the image are adjusted to fit within this restriction. If omitted, the ``bpp`` + value from :py:attr:`~PIL.Image.Image.info` can be used instead, and only the + lowest bits of each pixel are kept. If there is no ``bpp`` setting at all, ``L`` + mode images cannot be saved. PDF ^^^ diff --git a/src/PIL/PalmImagePlugin.py b/src/PIL/PalmImagePlugin.py index 8e8a92e944c..8b4473892f0 100644 --- a/src/PIL/PalmImagePlugin.py +++ b/src/PIL/PalmImagePlugin.py @@ -143,7 +143,6 @@ def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: msg = f"cannot write mode {im.mode} as Palm" raise OSError(msg) - # we ignore the palette here rawmode = f"L;{bpp}" version = 1