Skip to content

Fix BMP save of a P image with an empty palette - #9746

Open
gaoflow wants to merge 2 commits into
python-pillow:mainfrom
gaoflow:fix-bmp-empty-palette
Open

Fix BMP save of a P image with an empty palette#9746
gaoflow wants to merge 2 commits into
python-pillow:mainfrom
gaoflow:fix-bmp-empty-palette

Conversation

@gaoflow

@gaoflow gaoflow commented Jun 30, 2026

Copy link
Copy Markdown

Saving a P-mode image whose palette is empty produces a BMP that Pillow cannot reopen:

import io
from PIL import Image

im = Image.new("P", (8, 8))          # empty palette, no putpalette()
im.frombytes(bytes(range(64)))
buf = io.BytesIO()
im.save(buf, "BMP")
buf.seek(0)
Image.open(buf).load()               # OSError: image file is truncated (0 bytes not processed)

PNG, GIF and TIFF all round-trip the same image; only BMP fails, and it fails to read back its own output.

Cause

In BmpImagePlugin._save, the P branch computes colors = len(palette) // 4, which is 0 for an empty palette. The writer then sets biClrUsed = 0 and writes no color table, with the pixel-data offset at 14 + 40 = 54. On read, biClrUsed == 0 at 8 bpp is interpreted as 1 << 8 = 256 entries, so the reader advances the pixel offset by 256 * 4 bytes past a table that was never written — it reads past EOF and reports the file as truncated.

Fix

When the palette is empty, write a full 256-entry table (mirroring the existing L mode branch) so the header is self-consistent and the file reads back. An all-zero table is used rather than a grayscale ramp so the image reopens as P (a ramp would be detected as grayscale and downgraded to L). A populated palette is unaffected.

Tests

test_save_empty_palette parametrizes (1, 1), (7, 5), (8, 8), (16, 16), asserting the written header has a non-zero biClrUsed (pinning the root cause, not just the symptom) and that the image reopens as P with its pixel indices intact. test_save_empty_palette_round_trips_like_other_formats pins the cross-format invariant that BMP reopens the image like PNG/GIF/TIFF do. Both fail on main (OSError / biClrUsed == 0) and pass with the fix; the full Tests/test_file_bmp.py suite stays green (37 passed); ruff format, ruff check and mypy are clean.

@codspeed-hq

codspeed-hq Bot commented Jun 30, 2026

Copy link
Copy Markdown

Merging this PR will not alter performance

✅ 345 untouched benchmarks
⏩ 2 skipped benchmarks1


Comparing gaoflow:fix-bmp-empty-palette (10b57cf) with main (abb8ba0)

Open in CodSpeed

Footnotes

  1. 2 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports.

Comment thread Tests/test_file_bmp.py Outdated
@radarhere

Copy link
Copy Markdown
Member

You have assert that if you save a P mode image as a BMP, it should be re-opened as a P mode image.

BmpImagePlugin is actively trying to move to the simpler mode though.

# ----------------- Check if grayscale and ignore palette if so
for ind, val in enumerate(indices):
rgb = palette[ind * padding : ind * padding + 3]
if rgb != o8(val) * 3:
grayscale = False
# ------- If all colors are gray, white or black, ditch palette
if grayscale:
self._mode = "1" if file_info["colors"] == 2 else "L"

I don't think that all image formats need to behave exactly the same. The PNG format actually has a 'Color type' that distinguishes whether the data is grayscale or palette. The BMP format doesn't have that information.

So for a P mode image with an empty palette, I would be inclined to save only 1 palette entry, so that the final file is as small as possible.

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.
@gaoflow
gaoflow force-pushed the fix-bmp-empty-palette branch from 004b6d9 to c78c84c Compare July 27, 2026 07:09
@gaoflow

gaoflow commented Jul 27, 2026

Copy link
Copy Markdown
Author

Fair point on both. Switched to a single palette entry — 122 bytes now instead of 1142 — and dropped the mode assertion and the cross-format test, since BMP has no way to record the grayscale/palette distinction and the reader is right to simplify. It reopens as L with the pixel indices intact.

The test now just pins biClrUsed == 1 and the round-trip.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants