Skip to content

Commit ad3f1b1

Browse files
committed
Allow saving some PA images as PNG
1 parent 72f6e83 commit ad3f1b1

2 files changed

Lines changed: 94 additions & 0 deletions

File tree

Tests/test_file_png.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -893,3 +893,43 @@ def core() -> None:
893893
im.load()
894894

895895
self._test_leak(core)
896+
897+
898+
@skip_unless_feature("zlib")
899+
class TestFilePngPA:
900+
@pytest.mark.parametrize("palette_rawmode", ("RGB", "RGBA"))
901+
def test_save_pa(self, tmp_path: Path, palette_rawmode: str) -> None:
902+
test_file = tmp_path / "temp.png"
903+
image = Image.new("PA", (64, 64))
904+
palette: list[int] = []
905+
for color in ((255, 0, 0), (0, 255, 0), (0, 0, 255), (128, 128, 128)):
906+
palette += color
907+
if palette_rawmode == "RGBA":
908+
# This value is ignored for PA mode,
909+
# but we'll want to test palette rawmode RGBA too
910+
palette.append(42)
911+
image.putpalette(palette, palette_rawmode)
912+
px = image.load()
913+
assert px is not None
914+
for y in range(64):
915+
for x in range(64):
916+
index = (x // 16 + y // 16) % 4
917+
px[x, y] = (index, (255, 128, 64, 0)[index])
918+
image.save(test_file)
919+
with Image.open(test_file) as reloaded:
920+
# The image is loaded as P with an RGBA palette
921+
# (because there is no PA mode in PNG),
922+
# so we need to convert it back to PA for comparison.
923+
assert_image_equal(image, reloaded.convert("PA"))
924+
925+
def test_save_pa_incompatible_alpha(self, tmp_path: Path) -> None:
926+
# PA images can only be saved as PNG
927+
# if each palette index is used with a single alpha value
928+
image = Image.new("PA", (2, 1))
929+
image.putpalette([255, 0, 0])
930+
px = image.load()
931+
assert px is not None
932+
px[0, 0] = (0, 255)
933+
px[1, 0] = (0, 128) # same palette index, different alpha - this won't fly
934+
with pytest.raises(OSError, match="multiple alpha values"):
935+
image.save(tmp_path / "temp.png")

src/PIL/PngImagePlugin.py

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1317,6 +1317,57 @@ def _save_all(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None:
13171317
_save(im, fp, filename, save_all=True)
13181318

13191319

1320+
def _pa_to_p(im: Image.Image) -> Image.Image:
1321+
"""
1322+
Try to fold a `PA` image's alpha band into an RGBA palette.
1323+
1324+
:param im: A `PA` mode image.
1325+
:return: A `P` mode image with an RGBA palette.
1326+
"""
1327+
1328+
# PNG has no palette+alpha mode like Pillow's PA mode.
1329+
# However, palette entries may carry alpha via the tRNS chunk:
1330+
# > For color type 3 (indexed-color), the tRNS chunk contains
1331+
# > a series of one-byte alpha values,
1332+
# > corresponding to entries in the PLTE chunk.
1333+
# > Each entry indicates that pixels of the corresponding
1334+
# palette index shall be treated as having the specified alpha value.
1335+
# - https://www.w3.org/TR/png-3/#11tRNS
1336+
# This means that in some lucky cases of PA images,
1337+
# if every palette index is used with exactly a single alpha value,
1338+
# we can fold the alpha band into an RGBA palette, and save the image
1339+
# as P, so the RGBA palette gets split back to PLTE + tRNS.
1340+
1341+
assert im.mode == "PA"
1342+
indexes, alpha = im.split()
1343+
index_alpha: dict[int, int] = {}
1344+
for index, alpha_value in set(zip(indexes.tobytes(), alpha.tobytes())):
1345+
if index_alpha.setdefault(index, alpha_value) != alpha_value:
1346+
msg = (
1347+
"cannot write mode PA as PNG: "
1348+
"a palette entry is used with multiple alpha values"
1349+
)
1350+
raise OSError(msg)
1351+
1352+
# If the palette happened to be RGBA, we deliberately drop its alpha here,
1353+
# because the alpha we care about will be in the `alpha` band.
1354+
palette = im.getpalette("RGB") or []
1355+
colors = max(len(palette) // 3, max(index_alpha, default=-1) + 1, 1)
1356+
rgba_palette = bytearray()
1357+
for i in range(colors):
1358+
# The raster could (unfortunately) be using palette indexes
1359+
# that don't have entries, hence the fallback to black.
1360+
rgba_palette += bytes(palette[i * 3 : i * 3 + 3] or (0, 0, 0))
1361+
# Palette entries not referenced by any pixel stay opaque.
1362+
rgba_palette.append(index_alpha.get(i, 255))
1363+
1364+
indexes.putpalette(rgba_palette, "RGBA")
1365+
indexes.load() # sync the palette to the core image for the tRNS check
1366+
indexes.encoderinfo = im.encoderinfo
1367+
indexes.info = {k: v for k, v in im.info.items() if k != "transparency"}
1368+
return indexes
1369+
1370+
13201371
def _save(
13211372
im: Image.Image,
13221373
fp: IO[bytes],
@@ -1326,6 +1377,9 @@ def _save(
13261377
) -> None:
13271378
# save an image to disk (called by the save method)
13281379

1380+
if im.mode == "PA" and not save_all:
1381+
im = _pa_to_p(im)
1382+
13291383
if save_all:
13301384
default_image = im.encoderinfo.get(
13311385
"default_image", im.info.get("default_image")

0 commit comments

Comments
 (0)