Skip to content

Preserve transparency when padding and expanding images - #9995

Draft
lllleolin-max wants to merge 2 commits into
python-pillow:mainfrom
lllleolin-max:fix/transparency
Draft

lllleolin-max wants to merge 2 commits into
python-pillow:mainfrom
lllleolin-max:fix/transparency

Conversation

@lllleolin-max

@lllleolin-max lllleolin-max commented Sep 13, 2026

Copy link
Copy Markdown
Contributor

ImageOps.pad() and ImageOps.expand() discard info["transparency"] when creating an image with a border. Transparent pixels consequently become opaque in P, L and RGB images, including when the result is saved as PNG.

Changes proposed in this pull request:

  • Preserve the transparency value in the shared _new_with_fill() helper and pass the source image when allocating a new palette color, so reserved transparency indices are not reused for the border.
  • Make palette allocation also avoid transparent and translucent entries in byte-valued transparency data, both when extending a palette and when reusing an unused slot in a full palette.
  • Test palette transparency indices and transparency bytes, grayscale/RGB transparent colors, horizontal/vertical padding and expansion. Check the original pixels, border color, PNG round trips and that the source metadata/palette remain unchanged.
  • Add a release note.

Reproduction:

from PIL import Image, ImageOps

im = Image.new("P", (2, 2))
im.putpalette([255, 0, 0, 0, 255, 0])
im.info["transparency"] = 0
out = ImageOps.expand(im, 1, fill=1)
print(im.convert("RGBA").getpixel((0, 0)))   # (255, 0, 0, 0)
print(out.convert("RGBA").getpixel((1, 1)))  # previously (255, 0, 0, 255)

Validation:

  • The final tests against unchanged main: 15 failed, 90 passed. Twelve failures show the output pixels losing transparency; three cover allocation into transparent palette entries.
  • After the fix, Tests/test_imageops.py, Tests/test_imagepalette.py, Tests/test_imagecolor.py and Tests/test_imagestat.py: 105 passed, no skips. The existing Tests/test_imagedraw.py::test_new_color also passes; selftest.py: 59 passed.
  • Additional regressions cover padding and expansion when a newly allocated border color would otherwise occupy an index reserved by an integer or byte-valued transparency entry.
  • Black, Ruff, mypy, Bandit, Sphinx-lint and whitespace/end-of-file/line-ending checks passed for the changed files.

Tests ran on Windows/Python 3.12.11 using the checkout's ImageOps, ImagePalette, ImageColor and ImageStat Python modules with Pillow 12.3.0's prebuilt native extensions and remaining Python modules. Native extensions were not rebuilt; the full test suite and full pre-commit suite were not run.

Prepared with AI assistance.

CI follow-up (2026-09-14): The Windows Python 3.13 and 3.14 x64 checks passed on the maintainer-triggered second attempt of run 34751277259, on the unchanged commit 2d53304. All 53 reported checks passed before the release-note follow-up. The earlier PNG decoding worker crashes have not been root-caused; a successful rerun does not establish that the underlying cause has been fixed. No source or tests were changed to suppress those failures. On the documentation-only follow-up de0892e, Windows Python 3.14 failed again in the same two RGB round-trip tests (2 failed, 4107 passed), while Python 3.13 passed. The failure remains unresolved; this PR remains in draft.

CI comparison (2026-09-14): The passing Windows 3.14 rerun and the subsequent failure both used Python 3.14.7, Windows image 20260907.229.1, zlib-ng 2.3.3, pytest 9.1.1, pytest-cov 7.1.0 and pytest-xdist 3.8.0 with four workers. Their actual checked-out merge commits, f15c554e3d80b79288707cb6dc0e7051f4af45b6 and 8c12e4c14b7121d5f34b17d413c2fd3bf1a73729, have identical top-level Git tree entries except docs, including identical source, tests, build and CI configuration. Both also checked out cached dependencies at 0a1e459d4dbd36ef4f6b411124441f96bdef1110. Static review found the PNG save/seek/open sequence and buffer lifetime consistent with the existing PNG tests. This narrows the comparison but does not identify the crash cause; no tests or dependencies have been changed to suppress it.

@lllleolin-max
lllleolin-max marked this pull request as draft September 13, 2026 10:35
@radarhere radarhere added the 🤖-assisted AI-assisted label Sep 13, 2026
Comment thread docs/releasenotes/13.0.0.rst Outdated
Comment thread docs/releasenotes/13.0.0.rst Outdated

:py:func:`~PIL.ImageOps.pad` and :py:func:`~PIL.ImageOps.expand` now preserve the
``transparency`` value from the source image's ``info`` dictionary. Transparent
pixels in P, L and RGB images previously became opaque when a border was added.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
pixels in P, L and RGB images previously became opaque when a border was added.
pixels in L, P and RGB images previously became opaque when a border was added.

Nitpick suggestion. I would rather list the modes in increasing order of complexity.

Comment thread Tests/test_imageops.py
) -> None:
im = Image.new("P", (1, 1))
im.putpalette([0, 0, 0])
im.info["transparency"] = transparency

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe the use of bytes in im.info["transparency"] is limited to the PNG format, with its tRNS chunk. If there are other formats you're aware of, let me know.

You are testing an image here with 1 palette entry ([0, 0, 0]), but two transparency bytes (b"\xff\x00"). That would make for an invalid PNG.

https://www.libpng.org/pub/png/spec/1.2/PNG-Chunks.html#C.tRNS

The tRNS chunk must not contain more alpha values than there are palette entries

From this, I conclude that your changes to ImagePalette are not needed.

@lllleolin-max

Copy link
Copy Markdown
Contributor Author

Thank you for catching this. You're right that the one-entry palette with two transparency bytes does not represent a valid PNG input. I overlooked that constraint, and this fixture should not have been used to justify the allocation change.

I rechecked the original issue using valid PNGs saved and reopened before the operation. With a two-entry palette (black and red) and transparency index 0, ImageOps.expand(image, 1, fill=1) on the base commit changes the original black pixel from RGBA (0, 0, 0, 0) to (0, 0, 0, 255). The ImageOps transparency-preservation change retains its alpha. The same loss also reproduces with valid grayscale input.

Would you prefer that I remove the ImagePalette changes and the out-of-range fixtures, and keep this PR focused on preserving transparency in ImageOps? I can leave full-palette allocation behavior for a separate discussion.

Sorry for overlooking the invalid fixture, and thank you for the careful review.

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

Labels

🤖-assisted AI-assisted

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants