Merge branch 'main' into feature/png-bit-depth

This commit is contained in:
Andrew Murray 2026-05-04 08:13:57 +10:00 committed by GitHub
commit 9c7a5c9946
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 123 additions and 47 deletions

View File

@ -1 +1 @@
cibuildwheel==3.4.0
cibuildwheel==3.4.1

View File

@ -1,4 +1,4 @@
mypy==1.19.1
mypy==1.20.2
arro3-compute
arro3-core
IceSpringPySideStubs-PyQt6

View File

@ -3,12 +3,12 @@
"bzip2": "1.0.8",
"freetype": "2.14.3",
"fribidi": "1.0.16",
"harfbuzz": "13.2.1",
"harfbuzz": "14.2.0",
"jpegturbo": "3.1.4.1",
"lcms2": "2.18",
"lcms2": "2.19",
"libavif": "1.4.1",
"libimagequant": "4.4.1",
"libpng": "1.6.56",
"libpng": "1.6.58",
"libwebp": "1.6.0",
"libxcb": "1.17.0",
"openjpeg": "2.5.4",

View File

@ -30,14 +30,14 @@ jobs:
steps:
- name: Build Fuzzers
id: build
uses: google/oss-fuzz/infra/cifuzz/actions/build_fuzzers@e41e2f295eb18d630932fdd33d072527ba74c87b # master
uses: google/oss-fuzz/infra/cifuzz/actions/build_fuzzers@c11174f47deee98f260dede5d661614bda78ae39 # master
with:
oss-fuzz-project-name: 'pillow'
language: python
dry-run: false
- name: Run Fuzzers
id: run
uses: google/oss-fuzz/infra/cifuzz/actions/run_fuzzers@e41e2f295eb18d630932fdd33d072527ba74c87b # master
uses: google/oss-fuzz/infra/cifuzz/actions/run_fuzzers@c11174f47deee98f260dede5d661614bda78ae39 # master
with:
oss-fuzz-project-name: 'pillow'
fuzz-seconds: 600

View File

@ -25,7 +25,7 @@ jobs:
with:
python-version: "3.x"
- name: Install uv
uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
- name: Lint
run: uvx --with tox-uv tox -e lint
- name: Mypy

View File

@ -270,7 +270,7 @@ jobs:
path: dist
merge-multiple: true
- name: Upload wheels to scientific-python-nightly-wheels
uses: scientific-python/upload-nightly-action@5748273c71e2d8d3a61f3a11a16421c8954f9ecf # 0.6.3
uses: scientific-python/upload-nightly-action@e76cfec8a4611fd02808a801b0ff5a7d7c1b2d99 # 0.6.4
with:
artifacts_path: dist
anaconda_nightly_upload_token: ${{ secrets.ANACONDA_ORG_UPLOAD_TOKEN }}

View File

@ -516,8 +516,9 @@ class TestFilePng:
im = roundtrip(im)
assert im.info["transparency"] == (248, 248, 248)
im = roundtrip(im, transparency=(0, 1, 2))
assert im.info["transparency"] == (0, 1, 2)
for transparency in ((0, 1, 2), [0, 1, 2]):
im = roundtrip(im, transparency=transparency)
assert im.info["transparency"] == (0, 1, 2)
def test_trns_p(self, tmp_path: Path) -> None:
# Check writing a transparency of 0, issue #528
@ -532,6 +533,36 @@ class TestFilePng:
assert_image_equal(im2.convert("RGBA"), im.convert("RGBA"))
def test_trns_invalid(self, tmp_path: Path) -> None:
out = tmp_path / "temp.png"
for mode in ("1", "L", "I;16"):
im = Image.new(mode, (1, 1))
with pytest.raises(
ValueError, match=f"transparency for {mode} must be an integer"
):
im.save(out, transparency="invalid")
im = Image.new("I", (1, 1))
with pytest.warns(DeprecationWarning, match="Saving I mode images as PNG"):
with pytest.raises(ValueError):
im.save(out, transparency="invalid")
im = Image.new("P", (1, 1))
with pytest.raises(
ValueError, match="transparency for P must be an integer or bytes"
):
im.save(out, transparency="invalid")
im = Image.new("RGB", (1, 1))
with pytest.raises(
ValueError, match="transparency for RGB must be list or tuple"
):
im.save(out, transparency="invalid")
with pytest.raises(ValueError, match="transparency for RGB must have length 3"):
im.save(out, transparency=(1, 2))
def test_trns_null(self) -> None:
# Check reading images with null tRNS value, issue #1239
test_file = "Tests/images/tRNS_null_1x1.png"

View File

@ -862,7 +862,7 @@ class TestImage:
def test_exif_webp(self, tmp_path: Path) -> None:
with Image.open("Tests/images/hopper.webp") as im:
exif = im.getexif()
assert exif == {}
assert dict(exif) == {}
out = tmp_path / "temp.webp"
exif[258] = 8
@ -884,7 +884,7 @@ class TestImage:
def test_exif_png(self, tmp_path: Path) -> None:
with Image.open("Tests/images/exif.png") as im:
exif = im.getexif()
assert exif == {274: 1}
assert dict(exif) == {274: 1}
out = tmp_path / "temp.png"
exif[258] = 8

View File

@ -627,3 +627,37 @@ class TestCoreResampleBox:
0.4,
f">>> {size} {box} {flt}",
)
class TestCoreResample16bpc:
# Lanczos weighting during downsampling can push accumulated float sums
@pytest.mark.parametrize(
"offset",
(
# below 0. These must be clamped to 0, not corrupted byte-by-byte.
0, # Left half = 65535, right half = 0
# above 65535. These must be clamped to 65535, not corrupted byte-by-byte.
50, # # Left half = 0, right half = 65535
),
)
def test_resampling_clamp_overflow(self, offset: int) -> None:
ims = {}
width, height = 100, 10
for mode in ("I;16", "F"):
im = Image.new(mode, (width, height))
im.paste(65535, (offset, 0, offset + width // 2, height))
# 5x downsampling with Lanczos
# creates ~8.7% overshoot or undershoot at the step edge
ims[mode] = im.resize((20, height), Image.Resampling.LANCZOS)
for y in range(height):
for x in range(20):
v = ims["F"].getpixel((x, y))
assert isinstance(v, float)
expected = max(0, min(65535, round(v)))
value = ims["I;16"].getpixel((x, y))
assert (
value == expected
), f"Pixel ({x}, {y}): expected {expected}, got {value}"

View File

@ -51,7 +51,7 @@ Many of Pillow's features require external libraries:
* **littlecms** provides color management
* Pillow version 2.2.1 and below uses liblcms1, Pillow 2.3.0 and
above uses liblcms2. Tested with **1.19** and **2.7-2.18**.
above uses liblcms2. Tested with **1.19** and **2.7-2.19**.
* **libwebp** provides the WebP format.

View File

@ -2639,11 +2639,8 @@ class Image:
if is_path(fp):
filename = os.fspath(fp)
open_fp = True
elif fp == sys.stdout:
try:
fp = sys.stdout.buffer
except AttributeError:
pass
elif fp == sys.stdout and isinstance(sys.stdout, io.TextIOWrapper):
fp = sys.stdout.buffer
if not filename and hasattr(fp, "name") and is_path(fp.name):
# only set the name for metadata purposes
filename = os.fspath(fp.name)

View File

@ -383,7 +383,7 @@ class PdfParser:
msg = "specify buf or f or filename, but not both buf and f"
raise RuntimeError(msg)
self.filename = filename
self.buf: bytes | bytearray | mmap.mmap | None = buf
self.buf: bytes | bytearray | memoryview | mmap.mmap | None = buf
self.f = f
self.start_offset = start_offset
self.should_close_buf = False
@ -435,7 +435,9 @@ class PdfParser:
self.seek_end()
def close_buf(self) -> None:
if isinstance(self.buf, mmap.mmap):
if isinstance(self.buf, memoryview):
self.buf.release()
elif isinstance(self.buf, mmap.mmap):
self.buf.close()
self.buf = None

View File

@ -1446,35 +1446,47 @@ def _save(
palette_bytes += b"\0"
chunk(fp, b"PLTE", palette_bytes)
transparency = im.encoderinfo.get("transparency", im.info.get("transparency", None))
transparency = im.encoderinfo.get("transparency", im.info.get("transparency"))
if transparency or transparency == 0:
if transparency is not None:
if im.mode == "P":
# limit to actual palette size
alpha_bytes = colors
if isinstance(transparency, bytes):
chunk(fp, b"tRNS", transparency[:alpha_bytes])
else:
elif isinstance(transparency, int):
transparency = max(0, min(255, transparency))
alpha = b"\xff" * transparency + b"\0"
chunk(fp, b"tRNS", alpha[:alpha_bytes])
else:
msg = "transparency for P must be an integer or bytes"
raise ValueError(msg)
elif im.mode in ("1", "L", "I", "I;16"):
transparency = max(0, min(65535, transparency))
chunk(fp, b"tRNS", o16(transparency))
if isinstance(transparency, int):
transparency = max(0, min(65535, transparency))
chunk(fp, b"tRNS", o16(transparency))
else:
msg = f"transparency for {im.mode} must be an integer"
raise ValueError(msg)
elif im.mode == "RGB":
red, green, blue = transparency
chunk(fp, b"tRNS", o16(red) + o16(green) + o16(blue))
else:
if "transparency" in im.encoderinfo:
# don't bother with transparency if it's an RGBA
# and it's in the info dict. It's probably just stale.
msg = "cannot use transparency for this mode"
raise OSError(msg)
else:
if im.mode == "P" and im.im.getpalettemode() == "RGBA":
alpha = im.im.getpalette("RGBA", "A")
alpha_bytes = colors
chunk(fp, b"tRNS", alpha[:alpha_bytes])
if not isinstance(transparency, (list, tuple)):
msg = "transparency for RGB must be list or tuple"
raise ValueError(msg)
elif len(transparency) != 3:
msg = "transparency for RGB must have length 3"
raise ValueError(msg)
else:
red, green, blue = transparency
chunk(fp, b"tRNS", o16(red) + o16(green) + o16(blue))
elif im.encoderinfo.get("transparency") is not None:
# don't bother with transparency if it's an RGBA
# and it's in the info dict. It's probably just stale.
msg = "cannot use transparency for this mode"
raise OSError(msg)
elif im.mode == "P" and im.im.getpalettemode() == "RGBA":
alpha = im.im.getpalette("RGBA", "A")
alpha_bytes = colors
chunk(fp, b"tRNS", alpha[:alpha_bytes])
if dpi := im.encoderinfo.get("dpi"):
chunk(

View File

@ -37,8 +37,6 @@
#define MAX(a, b) (a) > (b) ? (a) : (b)
#define MIN(a, b) (a) < (b) ? (a) : (b)
#define CLIP16(v) ((v) <= 0 ? 0 : (v) >= 65535 ? 65535 : (v))
/* ITU-R Recommendation 601-2 (assuming nonlinear RGB) */
#define L(rgb) ((INT32)(rgb)[0] * 299 + (INT32)(rgb)[1] * 587 + (INT32)(rgb)[2] * 114)
#define L24(rgb) ((rgb)[0] * 19595 + (rgb)[1] * 38470 + (rgb)[2] * 7471 + 0x8000)

View File

@ -27,6 +27,8 @@
#define CLIP8(v) ((v) <= 0 ? 0 : (v) < 256 ? (v) : 255)
#define CLIP16(v) ((v) <= 0 ? 0 : (v) < 65536 ? (v) : 65535)
/* This is to work around a bug in GCC prior 4.9 in 64 bit mode.
GCC generates code with partial dependency which is 3 times slower.
See: https://stackoverflow.com/a/26588074/253146 */

View File

@ -492,9 +492,9 @@ ImagingResampleHorizontal_16bpc(
<< 8)) *
k[x];
}
ss_int = ROUND_UP(ss);
imOut->image8[yy][xx * 2 + (bigendian ? 1 : 0)] = CLIP8(ss_int % 256);
imOut->image8[yy][xx * 2 + (bigendian ? 0 : 1)] = CLIP8(ss_int >> 8);
ss_int = CLIP16(ROUND_UP(ss));
imOut->image8[yy][xx * 2 + (bigendian ? 1 : 0)] = ss_int & 0xFF;
imOut->image8[yy][xx * 2 + (bigendian ? 0 : 1)] = ss_int >> 8;
}
}
ImagingSectionLeave(&cookie);
@ -531,9 +531,9 @@ ImagingResampleVertical_16bpc(
(imIn->image8[y + ymin][xx * 2 + (bigendian ? 0 : 1)] << 8)) *
k[y];
}
ss_int = ROUND_UP(ss);
imOut->image8[yy][xx * 2 + (bigendian ? 1 : 0)] = CLIP8(ss_int % 256);
imOut->image8[yy][xx * 2 + (bigendian ? 0 : 1)] = CLIP8(ss_int >> 8);
ss_int = CLIP16(ROUND_UP(ss));
imOut->image8[yy][xx * 2 + (bigendian ? 1 : 0)] = ss_int & 0xFF;
imOut->image8[yy][xx * 2 + (bigendian ? 0 : 1)] = ss_int >> 8;
}
}
ImagingSectionLeave(&cookie);