From c78c84c01ec69cb2ca90e9e1d5873b249621e943 Mon Sep 17 00:00:00 2001 From: Vincent Gao Date: Tue, 30 Jun 2026 23:55:41 +0200 Subject: [PATCH 1/2] Fix BMP save of a P image with an empty palette A "P" image with an empty palette was written with biClrUsed=0 and no color table. A reader treats biClrUsed=0 at 8bpp as 256 entries, so the pixel offset pointed past a table that was never written and the file could not be reopened. Write a single palette entry so the header is self-consistent. --- Tests/test_file_bmp.py | 17 +++++++++++++++++ src/PIL/BmpImagePlugin.py | 5 +++++ 2 files changed, 22 insertions(+) diff --git a/Tests/test_file_bmp.py b/Tests/test_file_bmp.py index 3fc50ced227..d1d5bb1a347 100644 --- a/Tests/test_file_bmp.py +++ b/Tests/test_file_bmp.py @@ -69,6 +69,23 @@ def test_small_palette(tmp_path: Path) -> None: assert reloaded.getpalette() == colors +def test_save_empty_palette() -> None: + indices = bytes(range(64)) + im = Image.new("P", (8, 8)) + im.frombytes(indices) + + output = io.BytesIO() + im.save(output, "BMP") + + # biClrUsed, at info header offset 32 + assert _binary.i32le(output.getvalue(), 46) == 1 + + output.seek(0) + with Image.open(output) as reloaded: + assert reloaded.size == (8, 8) + assert reloaded.tobytes() == indices + + def test_save_too_large(tmp_path: Path) -> None: outfile = tmp_path / "temp.bmp" with Image.new("RGB", (1, 1)) as im: diff --git a/src/PIL/BmpImagePlugin.py b/src/PIL/BmpImagePlugin.py index 907931b926e..d3ab6be1057 100644 --- a/src/PIL/BmpImagePlugin.py +++ b/src/PIL/BmpImagePlugin.py @@ -451,6 +451,11 @@ def _save( elif im.mode == "P": palette = im.im.getpalette("RGB", "BGRX") colors = len(palette) // 4 + if colors == 0: + # biClrUsed=0 reads as 256 entries, skipping the offset past a table + # that was never written + palette = b"\x00\x00\x00\x00" + colors = 1 else: palette = None From 10b57cfbff7ed929c016579366ceb3dbf7efff84 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Tue, 28 Jul 2026 20:33:52 +1000 Subject: [PATCH 2/2] Simplified code --- Tests/test_file_bmp.py | 17 +++++------------ src/PIL/BmpImagePlugin.py | 8 ++------ 2 files changed, 7 insertions(+), 18 deletions(-) diff --git a/Tests/test_file_bmp.py b/Tests/test_file_bmp.py index d1d5bb1a347..fb3775d4ae7 100644 --- a/Tests/test_file_bmp.py +++ b/Tests/test_file_bmp.py @@ -69,21 +69,14 @@ def test_small_palette(tmp_path: Path) -> None: assert reloaded.getpalette() == colors -def test_save_empty_palette() -> None: - indices = bytes(range(64)) +def test_empty_palette(tmp_path: Path) -> None: im = Image.new("P", (8, 8)) - im.frombytes(indices) - output = io.BytesIO() - im.save(output, "BMP") - - # biClrUsed, at info header offset 32 - assert _binary.i32le(output.getvalue(), 46) == 1 + out = tmp_path / "temp.bmp" + im.save(out) - output.seek(0) - with Image.open(output) as reloaded: - assert reloaded.size == (8, 8) - assert reloaded.tobytes() == indices + with Image.open(out) as reloaded: + assert_image_equal(im.convert("L"), reloaded) def test_save_too_large(tmp_path: Path) -> None: diff --git a/src/PIL/BmpImagePlugin.py b/src/PIL/BmpImagePlugin.py index d3ab6be1057..c3a5dc72b50 100644 --- a/src/PIL/BmpImagePlugin.py +++ b/src/PIL/BmpImagePlugin.py @@ -449,13 +449,9 @@ def _save( elif im.mode == "L": palette = b"".join(o8(i) * 3 + b"\x00" for i in range(256)) elif im.mode == "P": - palette = im.im.getpalette("RGB", "BGRX") + # Colors used should not be zero, as that is treated as the maximum + palette = im.im.getpalette("RGB", "BGRX") or b"\x00\x00\x00\x00" colors = len(palette) // 4 - if colors == 0: - # biClrUsed=0 reads as 256 entries, skipping the offset past a table - # that was never written - palette = b"\x00\x00\x00\x00" - colors = 1 else: palette = None