From 04ee0cc3b1f3d5908efbe460097f133f3d73f2ec Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Tue, 2 Dec 2025 23:06:18 +1100 Subject: [PATCH 001/190] Raise error if subprocess gives non-zero returncode --- Tests/test_imagegrab.py | 18 +++++++++++++----- src/PIL/ImageGrab.py | 21 ++++++++++++++------- 2 files changed, 27 insertions(+), 12 deletions(-) diff --git a/Tests/test_imagegrab.py b/Tests/test_imagegrab.py index 01fa090dc..2f46d7c92 100644 --- a/Tests/test_imagegrab.py +++ b/Tests/test_imagegrab.py @@ -60,12 +60,20 @@ class TestImageGrab: ImageGrab.grab(xdisplay="error.test:0.0") assert str(e.value).startswith("X connection failed") - @pytest.mark.skipif(sys.platform != "win32", reason="Windows only") + @pytest.mark.skipif( + sys.platform not in ("darwin", "win32"), reason="macOS and Windows only" + ) def test_grab_invalid_handle(self) -> None: - with pytest.raises(OSError, match="unable to get device context for handle"): - ImageGrab.grab(window=-1) - with pytest.raises(OSError, match="screen grab failed"): - ImageGrab.grab(window=0) + if sys.platform == "darwin": + with pytest.raises(subprocess.CalledProcessError): + ImageGrab.grab(window=-1) + else: + with pytest.raises( + OSError, match="unable to get device context for handle" + ): + ImageGrab.grab(window=-1) + with pytest.raises(OSError, match="screen grab failed"): + ImageGrab.grab(window=0) def test_grabclipboard(self) -> None: if sys.platform == "darwin": diff --git a/src/PIL/ImageGrab.py b/src/PIL/ImageGrab.py index 4228078b1..66ee6dd33 100644 --- a/src/PIL/ImageGrab.py +++ b/src/PIL/ImageGrab.py @@ -43,25 +43,29 @@ def grab( fh, filepath = tempfile.mkstemp(".png") os.close(fh) args = ["screencapture"] - if window: + if window is not None: args += ["-l", str(window)] elif bbox: left, top, right, bottom = bbox args += ["-R", f"{left},{top},{right-left},{bottom-top}"] - subprocess.call(args + ["-x", filepath]) + args += ["-x", filepath] + retcode = subprocess.call(args) + if retcode: + raise subprocess.CalledProcessError(retcode, args) im = Image.open(filepath) im.load() os.unlink(filepath) if bbox: - if window: + if window is not None: # Determine if the window was in Retina mode or not # by capturing it without the shadow, # and checking how different the width is fh, filepath = tempfile.mkstemp(".png") os.close(fh) - subprocess.call( - ["screencapture", "-l", str(window), "-o", "-x", filepath] - ) + args = ["screencapture", "-l", str(window), "-o", "-x", filepath] + retcode = subprocess.call(args) + if retcode: + raise subprocess.CalledProcessError(retcode, args) with Image.open(filepath) as im_no_shadow: retina = im.width - im_no_shadow.width > 100 os.unlink(filepath) @@ -125,7 +129,10 @@ def grab( raise fh, filepath = tempfile.mkstemp(".png") os.close(fh) - subprocess.call(args + [filepath]) + args.append(filepath) + retcode = subprocess.call(args) + if retcode: + raise subprocess.CalledProcessError(retcode, args) im = Image.open(filepath) im.load() os.unlink(filepath) From b428f7209f81e7010ac419307f600a3995305043 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Tue, 2 Dec 2025 23:53:45 +1100 Subject: [PATCH 002/190] Open a macOS window on CI --- Tests/test_imagegrab.py | 26 +++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/Tests/test_imagegrab.py b/Tests/test_imagegrab.py index 2f46d7c92..07cb69719 100644 --- a/Tests/test_imagegrab.py +++ b/Tests/test_imagegrab.py @@ -9,7 +9,7 @@ import pytest from PIL import Image, ImageGrab -from .helper import assert_image_equal_tofile, skip_unless_feature +from .helper import assert_image_equal_tofile, on_ci, skip_unless_feature class TestImageGrab: @@ -60,6 +60,30 @@ class TestImageGrab: ImageGrab.grab(xdisplay="error.test:0.0") assert str(e.value).startswith("X connection failed") + @pytest.mark.skipif( + sys.platform != "darwin" or not on_ci(), reason="Only runs on macOS CI" + ) + def test_grab_handle(self) -> None: + p = subprocess.Popen( + [ + "osascript", + "-e", + 'tell application "Finder"\n' + 'open ("/" as POSIX file)\n' + "get id of front window\n" + "end tell", + ], + stdout=subprocess.PIPE, + ) + stdout = p.stdout + assert stdout is not None + window = int(stdout.read()) + + ImageGrab.grab(window=window) + + im = ImageGrab.grab((0, 0, 10, 10), window=window) + assert im.size == (10, 10) + @pytest.mark.skipif( sys.platform not in ("darwin", "win32"), reason="macOS and Windows only" ) From 9ac4edc54ba0ef720e91f28ba91b93c4f89ad4b8 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Fri, 24 Oct 2025 07:29:51 +1100 Subject: [PATCH 003/190] Added wrap() --- Tests/test_imagetext.py | 17 +++++++++++ src/PIL/ImageText.py | 66 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 83 insertions(+) diff --git a/Tests/test_imagetext.py b/Tests/test_imagetext.py index 2b424629d..e9d6d7886 100644 --- a/Tests/test_imagetext.py +++ b/Tests/test_imagetext.py @@ -108,3 +108,20 @@ def test_stroke() -> None: assert_image_similar_tofile( im, "Tests/images/imagedraw_stroke_" + suffix + ".png", 3.1 ) + + +def test_wrap() -> None: + # No wrap required + text = ImageText.Text("Hello World!") + text.wrap(100) + assert text.text == "Hello World!" + + # Wrap word to a new line + text = ImageText.Text("Hello World!") + text.wrap(50) + assert text.text == "Hello\nWorld!" + + # Split word across lines + text = ImageText.Text("Hello World!") + text.wrap(25) + assert text.text == "Hello\nWorl\nd!" diff --git a/src/PIL/ImageText.py b/src/PIL/ImageText.py index e6ccd8243..34c0336c8 100644 --- a/src/PIL/ImageText.py +++ b/src/PIL/ImageText.py @@ -1,5 +1,7 @@ from __future__ import annotations +from typing import cast + from . import ImageFont from ._typing import _Ink @@ -88,6 +90,70 @@ class Text: else: return "L" + def wrap(self, width: int) -> None: + str_type = isinstance(self.text, str) + wrapped_lines = [] + emptystring = "" if str_type else b"" + fontmode = self._get_fontmode() + for line in self.text.splitlines(): + wrapped_line = emptystring + words = line.split() + while words: + word = words[0] + + new_wrapped_line: str | bytes + if wrapped_line: + if str_type: + new_wrapped_line = ( + cast(str, wrapped_line) + " " + cast(str, word) + ) + else: + new_wrapped_line = ( + cast(bytes, wrapped_line) + b" " + cast(bytes, word) + ) + else: + new_wrapped_line = word + + def get_width(text) -> float: + left, _, right, _ = self.font.getbbox( + text, + fontmode, + self.direction, + self.features, + self.language, + self.stroke_width, + ) + return right - left + + if get_width(new_wrapped_line) > width: + if wrapped_line: + wrapped_lines.append(wrapped_line) + wrapped_line = emptystring + else: + # This word is too long for a single line, so split the word + characters = word + i = len(characters) + while i > 1 and get_width(characters[:i]) > width: + i -= 1 + wrapped_line = characters[:i] + if str_type: + cast(list[str], words)[0] = cast(str, characters[i:]) + else: + cast(list[bytes], words)[0] = cast(bytes, characters[i:]) + else: + words.pop(0) + wrapped_line = new_wrapped_line + if wrapped_line: + wrapped_lines.append(wrapped_line) + if str_type: + self.text = "\n".join( + [line for line in wrapped_lines if isinstance(line, str)] + ) + else: + self.text = b"\n".join( + [line for line in wrapped_lines if isinstance(line, bytes)] + ) + def get_length(self) -> float: """ Returns length (in pixels with 1/64 precision) of text. From 16691657cc6795889fa9712f48c1e36784d0c70e Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Sat, 1 Nov 2025 00:00:50 +1100 Subject: [PATCH 004/190] Added height argument to wrap() --- Tests/test_imagetext.py | 36 ++++--- src/PIL/ImageDraw.py | 25 +++-- src/PIL/ImageText.py | 229 +++++++++++++++++++++++++++------------- 3 files changed, 191 insertions(+), 99 deletions(-) diff --git a/Tests/test_imagetext.py b/Tests/test_imagetext.py index e9d6d7886..9b3c711f4 100644 --- a/Tests/test_imagetext.py +++ b/Tests/test_imagetext.py @@ -110,18 +110,28 @@ def test_stroke() -> None: ) -def test_wrap() -> None: - # No wrap required - text = ImageText.Text("Hello World!") - text.wrap(100) - assert text.text == "Hello World!" +@pytest.mark.parametrize( + "text, width, expected", + ( + ("Hello World!", 100, "Hello World!"), # No wrap required + ("Hello World!", 50, "Hello\nWorld!"), # Wrap word to a new line + ("Hello World!", 25, "Hello\nWorl\nd!"), # Split word across lines + # Keep multiple spaces within a line + ("Keep multiple spaces", 75, "Keep multiple\nspaces"), + ), +) +@pytest.mark.parametrize("string", (True, False)) +def test_wrap(text: str, width: int, expected: str, string: bool) -> None: + text = ImageText.Text(text if string else text.encode()) + assert text.wrap(width) is None + assert text.text == expected if string else expected.encode() - # Wrap word to a new line - text = ImageText.Text("Hello World!") - text.wrap(50) - assert text.text == "Hello\nWorld!" - # Split word across lines - text = ImageText.Text("Hello World!") - text.wrap(25) - assert text.text == "Hello\nWorl\nd!" +def test_wrap_height() -> None: + text = ImageText.Text("Text does not fit within height") + assert text.wrap(50, 25).text == " within height" + assert text.text == "Text does\nnot fit" + + text = ImageText.Text("Text does not fit singlelongword") + assert text.wrap(50, 25).text == " singlelongword" + assert text.text == "Text does\nnot fit" diff --git a/src/PIL/ImageDraw.py b/src/PIL/ImageDraw.py index 8bcf2d8ee..dfdbb622d 100644 --- a/src/PIL/ImageDraw.py +++ b/src/PIL/ImageDraw.py @@ -591,49 +591,49 @@ class ImageDraw: else ink ) - for xy, anchor, line in image_text._split(xy, anchor, align): + for line in image_text._split(xy, anchor, align): def draw_text(ink: int, stroke_width: float = 0) -> None: mode = self.fontmode if stroke_width == 0 and embedded_color: mode = "RGBA" - coord = [] - for i in range(2): - coord.append(int(xy[i])) - start = (math.modf(xy[0])[0], math.modf(xy[1])[0]) + x = int(line.x) + y = int(line.y) + start = (math.modf(line.x)[0], math.modf(line.y)[0]) try: mask, offset = image_text.font.getmask2( # type: ignore[union-attr,misc] - line, + line.text, mode, direction=direction, features=features, language=language, stroke_width=stroke_width, stroke_filled=True, - anchor=anchor, + anchor=line.anchor, ink=ink, start=start, *args, **kwargs, ) - coord = [coord[0] + offset[0], coord[1] + offset[1]] + x += offset[0] + y += offset[1] except AttributeError: try: mask = image_text.font.getmask( # type: ignore[misc] - line, + line.text, mode, direction, features, language, stroke_width, - anchor, + line.anchor, ink, start=start, *args, **kwargs, ) except TypeError: - mask = image_text.font.getmask(line) + mask = image_text.font.getmask(line.text) if mode == "RGBA": # image_text.font.getmask2(mode="RGBA") # returns color in RGB bands and mask in A @@ -641,13 +641,12 @@ class ImageDraw: color, mask = mask, mask.getband(3) ink_alpha = struct.pack("i", ink)[3] color.fillband(3, ink_alpha) - x, y = coord if self.im is not None: self.im.paste( color, (x, y, x + mask.size[0], y + mask.size[1]), mask ) else: - self.draw.draw_bitmap(coord, mask, ink) + self.draw.draw_bitmap((x, y), mask, ink) if stroke_ink is not None: # Draw stroked text diff --git a/src/PIL/ImageText.py b/src/PIL/ImageText.py index 34c0336c8..7cd5f957e 100644 --- a/src/PIL/ImageText.py +++ b/src/PIL/ImageText.py @@ -1,11 +1,18 @@ from __future__ import annotations -from typing import cast +from typing import NamedTuple, cast from . import ImageFont from ._typing import _Ink +class _Line(NamedTuple): + x: float + y: float + anchor: str + text: str | bytes + + class Text: def __init__( self, @@ -90,69 +97,140 @@ class Text: else: return "L" - def wrap(self, width: int) -> None: - str_type = isinstance(self.text, str) - wrapped_lines = [] - emptystring = "" if str_type else b"" + def wrap( + self, + width: int, + height: int | None = None, + ) -> Text | None: + wrapped_lines: list[str] | list[bytes] = [] + emptystring = "" if isinstance(self.text, str) else b"" + newline = "\n" if isinstance(self.text, str) else b"\n" fontmode = self._get_fontmode() - for line in self.text.splitlines(): - wrapped_line = emptystring - words = line.split() - while words: - word = words[0] - new_wrapped_line: str | bytes - if wrapped_line: - if str_type: - new_wrapped_line = ( - cast(str, wrapped_line) + " " + cast(str, word) - ) - else: - new_wrapped_line = ( - cast(bytes, wrapped_line) + b" " + cast(bytes, word) - ) + def getbbox(text) -> tuple[float, float]: + _, _, right, bottom = self.font.getbbox( + text, + fontmode, + self.direction, + self.features, + self.language, + self.stroke_width, + ) + return right, bottom + + wrapped_line = emptystring + word = emptystring + reached_end = False + remaining_position = 0 + + def join_text(a: str | bytes, b: str | bytes) -> str | bytes: + if isinstance(a, str): + return a + cast(str, b) + else: + return a + cast(bytes, b) + + for i in range(len(self.text)): + last_character = i == len(self.text) - 1 + + def add_line() -> bool: + nonlocal wrapped_lines, remaining_position + lines = cast( + list[str] | list[bytes], wrapped_lines + [wrapped_line.rstrip()] + ) + if height is not None: + last_line_y = self._split(lines=lines)[-1].y + last_line_height = getbbox(wrapped_line)[1] + if last_line_y + last_line_height > height: + return False + + wrapped_lines = lines + remaining_position = i - len(word) + if last_character: + remaining_position += 1 + return True + + character = self.text[i : i + 1] + if last_character: + word = join_text(word, character) + character = newline + if character.isspace(): + if not word or word.isspace(): + # Do not use whitespace until a non-whitespace character is reached + # Trimming whitespace from the end of the line + word = join_text(word, character) else: - new_wrapped_line = word + # Append the word to the current line + if not wrapped_line: + word = word.lstrip() + new_wrapped_line = join_text(wrapped_line, word) + if getbbox(new_wrapped_line)[0] > width: - def get_width(text) -> float: - left, _, right, _ = self.font.getbbox( - text, - fontmode, - self.direction, - self.features, - self.language, - self.stroke_width, - ) - return right - left + def split_word(): + nonlocal wrapped_line, word, reached_end + # This word is too long for a single line, so split the word + j = len(word) + while j > 1 and getbbox(word[:j])[0] > width: + j -= 1 + wrapped_line = word[:j] + if not add_line(): + reached_end = True + return + word = word[j:] + wrapped_line = word + if getbbox(wrapped_line)[0] > width: + split_word() - if get_width(new_wrapped_line) > width: - if wrapped_line: - wrapped_lines.append(wrapped_line) - wrapped_line = emptystring - else: - # This word is too long for a single line, so split the word - characters = word - i = len(characters) - while i > 1 and get_width(characters[:i]) > width: - i -= 1 - wrapped_line = characters[:i] - if str_type: - cast(list[str], words)[0] = cast(str, characters[i:]) + if wrapped_line: + # This word does not fit on the line + if not add_line(): + reached_end = True + break + word = word.lstrip() + if getbbox(word)[0] > width: + split_word() + else: + wrapped_line = word else: - cast(list[bytes], words)[0] = cast(bytes, characters[i:]) - else: - words.pop(0) - wrapped_line = new_wrapped_line - if wrapped_line: - wrapped_lines.append(wrapped_line) - if str_type: - self.text = "\n".join( - [line for line in wrapped_lines if isinstance(line, str)] + split_word() + if reached_end: + break + else: + # This word fits on the line + wrapped_line = new_wrapped_line + word = emptystring + + word = emptystring if character == newline else character + + if character == newline: + if not add_line(): + break + wrapped_line = emptystring + elif not character.isspace(): + # Word is not finished yet + word = join_text(word, character) + + remaining_text = self.text[remaining_position:] + if remaining_text: + text = Text( + text=remaining_text, + font=self.font, + mode=self.mode, + spacing=self.spacing, + direction=self.direction, + features=self.features, + language=self.language, ) + text.embedded_color = self.embedded_color + text.stroke_width = self.stroke_width + text.stroke_fill = self.stroke_fill else: - self.text = b"\n".join( - [line for line in wrapped_lines if isinstance(line, bytes)] - ) + text = None + + if isinstance(self.text, str): + self.text = "\n".join(cast(list[str], wrapped_lines)) + else: + self.text = b"\n".join(cast(list[bytes], wrapped_lines)) + return text def get_length(self) -> float: """ @@ -212,21 +290,26 @@ class Text: ) def _split( - self, xy: tuple[float, float], anchor: str | None, align: str - ) -> list[tuple[tuple[float, float], str, str | bytes]]: + self, + xy: tuple[float, float] = (0, 0), + anchor: str | None = None, + align: str = "left", + lines: list[str] | list[bytes] | None = None, + ) -> list[_Line]: if anchor is None: anchor = "lt" if self.direction == "ttb" else "la" elif len(anchor) != 2: msg = "anchor must be a 2 character string" raise ValueError(msg) - lines = ( - self.text.split("\n") - if isinstance(self.text, str) - else self.text.split(b"\n") - ) + if lines is None: + lines = ( + self.text.split("\n") + if isinstance(self.text, str) + else self.text.split(b"\n") + ) if len(lines) == 1: - return [(xy, anchor, self.text)] + return [_Line(xy[0], xy[1], anchor, lines[0])] if anchor[1] in "tb" and self.direction != "ttb": msg = "anchor not supported for multiline text" @@ -251,7 +334,7 @@ class Text: if self.direction == "ttb": left = xy[0] for line in lines: - parts.append(((left, top), anchor, line)) + parts.append(_Line(left, top, anchor, line)) left += line_spacing else: widths = [] @@ -314,7 +397,7 @@ class Text: width_difference = max_width - sum(word_widths) i = 0 for word in words: - parts.append(((left, top), word_anchor, word)) + parts.append(_Line(left, top, word_anchor, word)) left += word_widths[i] + width_difference / (len(words) - 1) i += 1 top += line_spacing @@ -325,7 +408,7 @@ class Text: left -= width_difference / 2.0 elif anchor[0] == "r": left -= width_difference - parts.append(((left, top), anchor, line)) + parts.append(_Line(left, top, anchor, line)) top += line_spacing return parts @@ -356,9 +439,9 @@ class Text: """ bbox: tuple[float, float, float, float] | None = None fontmode = self._get_fontmode() - for xy, anchor, line in self._split(xy, anchor, align): + for x, y, anchor, text in self._split(xy, anchor, align): bbox_line = self.font.getbbox( - line, + text, fontmode, self.direction, self.features, @@ -367,10 +450,10 @@ class Text: anchor, ) bbox_line = ( - bbox_line[0] + xy[0], - bbox_line[1] + xy[1], - bbox_line[2] + xy[0], - bbox_line[3] + xy[1], + bbox_line[0] + x, + bbox_line[1] + y, + bbox_line[2] + x, + bbox_line[3] + y, ) if bbox is None: bbox = bbox_line From 4b2d4811e18516469793210e41080d21bf5d33c6 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Wed, 5 Nov 2025 20:30:16 +1100 Subject: [PATCH 005/190] Added scaling argument to wrap() --- Tests/test_imagetext.py | 113 ++++++++++++++-- src/PIL/ImageDraw.py | 2 +- src/PIL/ImageText.py | 282 ++++++++++++++++++++++------------------ 3 files changed, 258 insertions(+), 139 deletions(-) diff --git a/Tests/test_imagetext.py b/Tests/test_imagetext.py index 9b3c711f4..507d82409 100644 --- a/Tests/test_imagetext.py +++ b/Tests/test_imagetext.py @@ -111,27 +111,120 @@ def test_stroke() -> None: @pytest.mark.parametrize( - "text, width, expected", + "data, width, expected", ( ("Hello World!", 100, "Hello World!"), # No wrap required ("Hello World!", 50, "Hello\nWorld!"), # Wrap word to a new line - ("Hello World!", 25, "Hello\nWorl\nd!"), # Split word across lines # Keep multiple spaces within a line - ("Keep multiple spaces", 75, "Keep multiple\nspaces"), + ("Keep multiple spaces", 90, "Keep multiple\nspaces"), + (" Keep\n leading space", 100, " Keep\n leading space"), ), ) @pytest.mark.parametrize("string", (True, False)) -def test_wrap(text: str, width: int, expected: str, string: bool) -> None: - text = ImageText.Text(text if string else text.encode()) - assert text.wrap(width) is None - assert text.text == expected if string else expected.encode() +def test_wrap(data: str, width: int, expected: str, string: bool) -> None: + if string: + text = ImageText.Text(data) + assert text.wrap(width) is None + assert text.text == expected + else: + text_bytes = ImageText.Text(data.encode()) + assert text_bytes.wrap(width) is None + assert text_bytes.text == expected.encode() + + +def test_wrap_long_word() -> None: + text = ImageText.Text("Hello World!") + with pytest.raises(ValueError, match="Word does not fit within line"): + text.wrap(25) + + +def test_wrap_unsupported(font: ImageFont.FreeTypeFont) -> None: + transposed_font = ImageFont.TransposedFont(font) + text = ImageText.Text("Hello World!", transposed_font) + with pytest.raises(ValueError, match="TransposedFont not supported"): + text.wrap(50) + + text = ImageText.Text("Hello World!", direction="ttb") + with pytest.raises(ValueError, match="Only ltr direction supported"): + text.wrap(50) def test_wrap_height() -> None: + width = 50 if features.check_module("freetype2") else 60 text = ImageText.Text("Text does not fit within height") - assert text.wrap(50, 25).text == " within height" + wrapped = text.wrap(width, 25 if features.check_module("freetype2") else 40) + assert wrapped is not None + assert wrapped.text == " within height" assert text.text == "Text does\nnot fit" - text = ImageText.Text("Text does not fit singlelongword") - assert text.wrap(50, 25).text == " singlelongword" + text = ImageText.Text("Text does not fit\nwithin height") + wrapped = text.wrap(width, 20) + assert wrapped is not None + assert wrapped.text == " not fit\nwithin height" + assert text.text == "Text does" + + text = ImageText.Text("Text does not fit\n\nwithin height") + wrapped = text.wrap(width, 25 if features.check_module("freetype2") else 40) + assert wrapped is not None + assert wrapped.text == "\nwithin height" assert text.text == "Text does\nnot fit" + + +def test_wrap_scaling_unsupported() -> None: + font = ImageFont.load_default_imagefont() + text = ImageText.Text("Hello World!", font) + with pytest.raises(ValueError, match="'scaling' only supports FreeTypeFont"): + text.wrap(50, scaling="shrink") + + if features.check_module("freetype2"): + text = ImageText.Text("Hello World!") + with pytest.raises(ValueError, match="'scaling' requires 'height'"): + text.wrap(50, scaling="shrink") + + +@skip_unless_feature("freetype2") +def test_wrap_shrink() -> None: + # No scaling required + text = ImageText.Text("Hello World!") + assert isinstance(text.font, ImageFont.FreeTypeFont) + assert text.font.size == 10 + assert text.wrap(50, 50, "shrink") is None + assert isinstance(text.font, ImageFont.FreeTypeFont) + assert text.font.size == 10 + + with pytest.raises(ValueError, match="Text could not be scaled"): + text.wrap(50, 15, ("shrink", 9)) + + assert text.wrap(50, 15, "shrink") is None + assert text.font.size == 8 + + text = ImageText.Text("Hello World!") + assert text.wrap(50, 15, ("shrink", 7)) is None + assert isinstance(text.font, ImageFont.FreeTypeFont) + assert text.font.size == 8 + + +@skip_unless_feature("freetype2") +def test_wrap_grow() -> None: + # No scaling required + text = ImageText.Text("Hello World!") + assert isinstance(text.font, ImageFont.FreeTypeFont) + assert text.font.size == 10 + assert text.wrap(58, 10, "grow") is None + assert isinstance(text.font, ImageFont.FreeTypeFont) + assert text.font.size == 10 + + with pytest.raises(ValueError, match="Text could not be scaled"): + text.wrap(50, 50, ("grow", 12)) + + assert text.wrap(50, 50, "grow") is None + assert text.font.size == 16 + + text = ImageText.Text("A\nB") + with pytest.raises(ValueError, match="Text could not be scaled"): + text.wrap(50, 10, "grow") + + text = ImageText.Text("Hello World!") + assert text.wrap(50, 50, ("grow", 18)) is None + assert isinstance(text.font, ImageFont.FreeTypeFont) + assert text.font.size == 16 diff --git a/src/PIL/ImageDraw.py b/src/PIL/ImageDraw.py index dfdbb622d..07fa43b06 100644 --- a/src/PIL/ImageDraw.py +++ b/src/PIL/ImageDraw.py @@ -538,7 +538,7 @@ class ImageDraw: def text( self, xy: tuple[float, float], - text: AnyStr | ImageText.Text, + text: AnyStr | ImageText.Text[AnyStr], fill: _Ink | None = None, font: ( ImageFont.ImageFont diff --git a/src/PIL/ImageText.py b/src/PIL/ImageText.py index 7cd5f957e..723ab9f8c 100644 --- a/src/PIL/ImageText.py +++ b/src/PIL/ImageText.py @@ -1,10 +1,14 @@ from __future__ import annotations -from typing import NamedTuple, cast +import math +import re +from typing import AnyStr, Generic, NamedTuple from . import ImageFont from ._typing import _Ink +Font = ImageFont.ImageFont | ImageFont.FreeTypeFont | ImageFont.TransposedFont + class _Line(NamedTuple): x: float @@ -13,16 +17,87 @@ class _Line(NamedTuple): text: str | bytes -class Text: +class _Wrap(Generic[AnyStr]): + lines: list[AnyStr] = [] + position = 0 + offset = 0 + def __init__( self, - text: str | bytes, - font: ( - ImageFont.ImageFont - | ImageFont.FreeTypeFont - | ImageFont.TransposedFont - | None - ) = None, + text: Text[AnyStr], + width: int, + height: int | None = None, + font: Font | None = None, + ) -> None: + self.text: Text[AnyStr] = text + self.width = width + self.height = height + self.font = font + + input_text = self.text.text + emptystring = "" if isinstance(input_text, str) else b"" + line = emptystring + + for word in re.findall( + r"\s*\S+" if isinstance(input_text, str) else rb"\s*\S+", input_text + ): + newlines = re.findall( + r"[^\S\n]*\n" if isinstance(input_text, str) else rb"[^\S\n]*\n", word + ) + if newlines: + if not self.add_line(line): + break + for i, line in enumerate(newlines): + if i != 0 and not self.add_line(emptystring): + break + self.position += len(line) + word = word[len(line) :] + line = emptystring + + new_line = line + word + if self.text._get_bbox(new_line, self.font)[2] <= width: + # This word fits on the line + line = new_line + continue + + # This word does not fit on the line + if line and not self.add_line(line): + break + + original_length = len(word) + word = word.lstrip() + self.offset = original_length - len(word) + + if self.text._get_bbox(word, self.font)[2] > width: + if font is None: + msg = "Word does not fit within line" + raise ValueError(msg) + break + line = word + else: + if line: + self.add_line(line) + self.remaining_text: AnyStr = input_text[self.position :] + + def add_line(self, line: AnyStr) -> bool: + lines = self.lines + [line] + if self.height is not None: + last_line_y = self.text._split(lines=lines)[-1].y + last_line_height = self.text._get_bbox(line, self.font)[3] + if last_line_y + last_line_height > self.height: + return False + + self.lines = lines + self.position += len(line) + self.offset + self.offset = 0 + return True + + +class Text(Generic[AnyStr]): + def __init__( + self, + text: AnyStr, + font: Font | None = None, mode: str = "RGB", spacing: float = 4, direction: str | None = None, @@ -56,7 +131,7 @@ class Text: It should be a `BCP 47 language code`_. Requires libraqm. """ - self.text = text + self.text: AnyStr = text self.font = font or ImageFont.load_default() self.mode = mode @@ -101,118 +176,67 @@ class Text: self, width: int, height: int | None = None, - ) -> Text | None: - wrapped_lines: list[str] | list[bytes] = [] - emptystring = "" if isinstance(self.text, str) else b"" - newline = "\n" if isinstance(self.text, str) else b"\n" - fontmode = self._get_fontmode() + scaling: str | tuple[str, int] | None = None, + ) -> Text[AnyStr] | None: + if isinstance(self.font, ImageFont.TransposedFont): + msg = "TransposedFont not supported" + raise ValueError(msg) + if self.direction not in (None, "ltr"): + msg = "Only ltr direction supported" + raise ValueError(msg) - def getbbox(text) -> tuple[float, float]: - _, _, right, bottom = self.font.getbbox( - text, - fontmode, - self.direction, - self.features, - self.language, - self.stroke_width, - ) - return right, bottom + if scaling is None: + wrap = _Wrap(self, width, height) + else: + if not isinstance(self.font, ImageFont.FreeTypeFont): + msg = "'scaling' only supports FreeTypeFont" + raise ValueError(msg) + if height is None: + msg = "'scaling' requires 'height'" + raise ValueError(msg) - wrapped_line = emptystring - word = emptystring - reached_end = False - remaining_position = 0 - - def join_text(a: str | bytes, b: str | bytes) -> str | bytes: - if isinstance(a, str): - return a + cast(str, b) + if isinstance(scaling, str): + limit = 1 else: - return a + cast(bytes, b) + scaling, limit = scaling - for i in range(len(self.text)): - last_character = i == len(self.text) - 1 + font = self.font + wrap = _Wrap(self, width, height, font) + if scaling == "shrink": + if not wrap.remaining_text: + return None - def add_line() -> bool: - nonlocal wrapped_lines, remaining_position - lines = cast( - list[str] | list[bytes], wrapped_lines + [wrapped_line.rstrip()] - ) - if height is not None: - last_line_y = self._split(lines=lines)[-1].y - last_line_height = getbbox(wrapped_line)[1] - if last_line_y + last_line_height > height: - return False + size = math.ceil(font.size) + while wrap.remaining_text: + if size == max(limit, 1): + msg = "Text could not be scaled" + raise ValueError(msg) + size -= 1 + font = self.font.font_variant(size=size) + wrap = _Wrap(self, width, height, font) + self.font = font + else: + if wrap.remaining_text: + msg = "Text could not be scaled" + raise ValueError(msg) - wrapped_lines = lines - remaining_position = i - len(word) - if last_character: - remaining_position += 1 - return True + size = math.floor(font.size) + while not wrap.remaining_text: + if size == limit: + msg = "Text could not be scaled" + raise ValueError(msg) + size += 1 + font = self.font.font_variant(size=size) + last_wrap = wrap + wrap = _Wrap(self, width, height, font) + size -= 1 + if size != self.font.size: + self.font = self.font.font_variant(size=size) + wrap = last_wrap - character = self.text[i : i + 1] - if last_character: - word = join_text(word, character) - character = newline - if character.isspace(): - if not word or word.isspace(): - # Do not use whitespace until a non-whitespace character is reached - # Trimming whitespace from the end of the line - word = join_text(word, character) - else: - # Append the word to the current line - if not wrapped_line: - word = word.lstrip() - new_wrapped_line = join_text(wrapped_line, word) - if getbbox(new_wrapped_line)[0] > width: - - def split_word(): - nonlocal wrapped_line, word, reached_end - # This word is too long for a single line, so split the word - j = len(word) - while j > 1 and getbbox(word[:j])[0] > width: - j -= 1 - wrapped_line = word[:j] - if not add_line(): - reached_end = True - return - word = word[j:] - wrapped_line = word - if getbbox(wrapped_line)[0] > width: - split_word() - - if wrapped_line: - # This word does not fit on the line - if not add_line(): - reached_end = True - break - word = word.lstrip() - if getbbox(word)[0] > width: - split_word() - else: - wrapped_line = word - else: - split_word() - if reached_end: - break - else: - # This word fits on the line - wrapped_line = new_wrapped_line - word = emptystring - - word = emptystring if character == newline else character - - if character == newline: - if not add_line(): - break - wrapped_line = emptystring - elif not character.isspace(): - # Word is not finished yet - word = join_text(word, character) - - remaining_text = self.text[remaining_position:] - if remaining_text: + if wrap.remaining_text: text = Text( - text=remaining_text, + text=wrap.remaining_text, font=self.font, mode=self.mode, spacing=self.spacing, @@ -226,10 +250,8 @@ class Text: else: text = None - if isinstance(self.text, str): - self.text = "\n".join(cast(list[str], wrapped_lines)) - else: - self.text = b"\n".join(cast(list[bytes], wrapped_lines)) + newline = "\n" if isinstance(self.text, str) else b"\n" + self.text = newline.join(wrap.lines) return text def get_length(self) -> float: @@ -413,6 +435,19 @@ class Text: return parts + def _get_bbox( + self, text: str | bytes, font: Font | None = None, anchor: str | None = None + ) -> tuple[float, float, float, float]: + return (font or self.font).getbbox( + text, + self._get_fontmode(), + self.direction, + self.features, + self.language, + self.stroke_width, + anchor, + ) + def get_bbox( self, xy: tuple[float, float] = (0, 0), @@ -438,17 +473,8 @@ class Text: :return: ``(left, top, right, bottom)`` bounding box """ bbox: tuple[float, float, float, float] | None = None - fontmode = self._get_fontmode() for x, y, anchor, text in self._split(xy, anchor, align): - bbox_line = self.font.getbbox( - text, - fontmode, - self.direction, - self.features, - self.language, - self.stroke_width, - anchor, - ) + bbox_line = self._get_bbox(text, anchor=anchor) bbox_line = ( bbox_line[0] + x, bbox_line[1] + y, From 11d599c798da46a84669a3f7e5b25dc3ebafb45e Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Thu, 11 Dec 2025 18:20:58 +1100 Subject: [PATCH 006/190] Added documentation --- docs/releasenotes/12.1.0.rst | 25 +++++++++++++++++++++++++ src/PIL/ImageText.py | 13 +++++++++++++ 2 files changed, 38 insertions(+) diff --git a/docs/releasenotes/12.1.0.rst b/docs/releasenotes/12.1.0.rst index b6e1810c6..5c363e239 100644 --- a/docs/releasenotes/12.1.0.rst +++ b/docs/releasenotes/12.1.0.rst @@ -41,6 +41,31 @@ TODO API additions ============= +ImageText.Text.wrap +^^^^^^^^^^^^^^^^^^^ + +:py:meth:`.ImageText.Text.wrap` has been added, to wrap text to fit within a given +width:: + + from PIL import ImageText + text = ImageText.Text("Hello World!") + text.wrap(50) + print(text.text) # "Hello\nWorld!" + +or within a certain width and height, returning a new :py:class:`.ImageText.Text` +instance if the text does not fit:: + + text = ImageText.Text("Text does not fit within height") + print(text.wrap(50, 25).text == " within height") + print(text.text) # "Text does\nnot fit" + +or scaling, optionally with a font size limit:: + + text.wrap(50, 15, "shrink") + text.wrap(50, 15, ("shrink", 7)) + text.wrap(58, 10, "grow") + text.wrap(50, 50, ("grow", 12)) + Specify window in ImageGrab on macOS ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ diff --git a/src/PIL/ImageText.py b/src/PIL/ImageText.py index 723ab9f8c..008d20d38 100644 --- a/src/PIL/ImageText.py +++ b/src/PIL/ImageText.py @@ -178,6 +178,19 @@ class Text(Generic[AnyStr]): height: int | None = None, scaling: str | tuple[str, int] | None = None, ) -> Text[AnyStr] | None: + """ + Wrap text to fit within a given width. + + :param width: The width to fit within. + :param height: An optional height limit. Any text that does not fit within this + will be returned as a new :py:class:`.Text` object. + :param scaling: An optional directive to scale the text, either "grow" as much + as possible within the given dimensions, or "shrink" until it + fits. It can also be a tuple of (direction, limit), with an + integer limit to stop scaling at. + + :returns: An :py:class:`.Text` object, or None. + """ if isinstance(self.font, ImageFont.TransposedFont): msg = "TransposedFont not supported" raise ValueError(msg) From 400ffbc18d43ea41b5d47c3dad3a2d7a4510ad56 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Sat, 10 Jan 2026 14:37:18 +1100 Subject: [PATCH 007/190] Raise EOFError when seeking too far --- Tests/test_file_psd.py | 5 +++++ src/PIL/PsdImagePlugin.py | 3 +++ 2 files changed, 8 insertions(+) diff --git a/Tests/test_file_psd.py b/Tests/test_file_psd.py index 8f2ca58a6..da572ae63 100644 --- a/Tests/test_file_psd.py +++ b/Tests/test_file_psd.py @@ -85,6 +85,11 @@ def test_eoferror() -> None: # Test that seeking to the last frame does not raise an error im.seek(n_frames - 1) + # Test seeking past the last frame without calling n_frames first + with Image.open(test_file) as im: + with pytest.raises(EOFError): + im.seek(3) + def test_seek_tell() -> None: with Image.open(test_file) as im: diff --git a/src/PIL/PsdImagePlugin.py b/src/PIL/PsdImagePlugin.py index 69a8703dd..dd3d5ab95 100644 --- a/src/PIL/PsdImagePlugin.py +++ b/src/PIL/PsdImagePlugin.py @@ -175,6 +175,9 @@ class PsdImageFile(ImageFile.ImageFile): raise self._fp.ex # seek to given layer (1..max) + if layer > len(self.layers): + msg = "no more images in PSD file" + raise EOFError(msg) _, mode, _, tile = self.layers[layer - 1] self._mode = mode self.tile = tile From 799564dd52a306bbe3f90aa080825e5f5ffe9fc0 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Thu, 29 Jan 2026 20:22:29 +1100 Subject: [PATCH 008/190] Always call StubHandler open() when opening StubImageFile --- src/PIL/BufrStubImagePlugin.py | 4 ---- src/PIL/GribStubImagePlugin.py | 4 ---- src/PIL/Hdf5StubImagePlugin.py | 4 ---- src/PIL/ImageFile.py | 4 ++++ src/PIL/WmfImagePlugin.py | 4 ---- 5 files changed, 4 insertions(+), 16 deletions(-) diff --git a/src/PIL/BufrStubImagePlugin.py b/src/PIL/BufrStubImagePlugin.py index 264564d2b..d82c4c746 100644 --- a/src/PIL/BufrStubImagePlugin.py +++ b/src/PIL/BufrStubImagePlugin.py @@ -52,10 +52,6 @@ class BufrStubImageFile(ImageFile.StubImageFile): self._mode = "F" self._size = 1, 1 - loader = self._load() - if loader: - loader.open(self) - def _load(self) -> ImageFile.StubHandler | None: return _handler diff --git a/src/PIL/GribStubImagePlugin.py b/src/PIL/GribStubImagePlugin.py index 146a6fa0d..3784ef2f1 100644 --- a/src/PIL/GribStubImagePlugin.py +++ b/src/PIL/GribStubImagePlugin.py @@ -52,10 +52,6 @@ class GribStubImageFile(ImageFile.StubImageFile): self._mode = "F" self._size = 1, 1 - loader = self._load() - if loader: - loader.open(self) - def _load(self) -> ImageFile.StubHandler | None: return _handler diff --git a/src/PIL/Hdf5StubImagePlugin.py b/src/PIL/Hdf5StubImagePlugin.py index 1523e95d5..1a56660f7 100644 --- a/src/PIL/Hdf5StubImagePlugin.py +++ b/src/PIL/Hdf5StubImagePlugin.py @@ -52,10 +52,6 @@ class HDF5StubImageFile(ImageFile.StubImageFile): self._mode = "F" self._size = 1, 1 - loader = self._load() - if loader: - loader.open(self) - def _load(self) -> ImageFile.StubHandler | None: return _handler diff --git a/src/PIL/ImageFile.py b/src/PIL/ImageFile.py index 78abe3c77..45df3be03 100644 --- a/src/PIL/ImageFile.py +++ b/src/PIL/ImageFile.py @@ -148,6 +148,10 @@ class ImageFile(Image.Image): try: try: self._open() + + if isinstance(self, StubImageFile): + if loader := self._load(): + loader.open(self) except ( IndexError, # end of data TypeError, # end of data (ord) diff --git a/src/PIL/WmfImagePlugin.py b/src/PIL/WmfImagePlugin.py index 3ae86242a..79d54df4e 100644 --- a/src/PIL/WmfImagePlugin.py +++ b/src/PIL/WmfImagePlugin.py @@ -148,10 +148,6 @@ class WmfStubImageFile(ImageFile.StubImageFile): self._mode = "RGB" self._size = size - loader = self._load() - if loader: - loader.open(self) - def _load(self) -> ImageFile.StubHandler | None: return _handler From f708c005276b126bd2a08b6f383fcc58ac299eb9 Mon Sep 17 00:00:00 2001 From: Varun Chawla Date: Fri, 13 Feb 2026 19:38:48 -0800 Subject: [PATCH 009/190] Fix UnboundLocalError in _border for invalid tuple lengths and document rgba() color format The _border helper in ImageOps raised UnboundLocalError when given a tuple with a length other than 2 or 4 (e.g. 1-tuple or 3-tuple). This changes it to raise a clear ValueError instead. Also adds documentation for the rgba() color format in ImageColor, which was supported in code and tested but missing from the docs. --- Tests/test_imageops.py | 7 +++++++ docs/reference/ImageColor.rst | 4 ++++ src/PIL/ImageOps.py | 3 +++ 3 files changed, 14 insertions(+) diff --git a/Tests/test_imageops.py b/Tests/test_imageops.py index 35fe3bb8a..31b7abecd 100644 --- a/Tests/test_imageops.py +++ b/Tests/test_imageops.py @@ -256,6 +256,13 @@ def test_expand_palette(border: int | tuple[int, int, int, int]) -> None: assert_image_equal(im_cropped, im) +@pytest.mark.parametrize("border", ((1,), (1, 2, 3), (1, 2, 3, 4, 5))) +def test_expand_invalid_border(border: tuple[int, ...]) -> None: + im = Image.new("1", (1, 1)) + with pytest.raises(ValueError): + ImageOps.expand(im, border) + + def test_colorize_2color() -> None: # Test the colorizing function with 2-color functionality diff --git a/docs/reference/ImageColor.rst b/docs/reference/ImageColor.rst index 68e228dba..82bd359bc 100644 --- a/docs/reference/ImageColor.rst +++ b/docs/reference/ImageColor.rst @@ -27,6 +27,10 @@ The ImageColor module supports the following string formats: as three percentages (0% to 100%). For example, ``rgb(255,0,0)`` and ``rgb(100%,0%,0%)`` both specify pure red. +* RGBA functions, given as ``rgba(red, green, blue, alpha)`` where the color + values and the alpha value are integers in the range 0 to 255. For example, + ``rgba(255,0,0,128)`` specifies pure red with 50% opacity. + * Hue-Saturation-Lightness (HSL) functions, given as ``hsl(hue, saturation%, lightness%)`` where hue is the color given as an angle between 0 and 360 (red=0, green=120, blue=240), saturation is a value between 0% and 100% diff --git a/src/PIL/ImageOps.py b/src/PIL/ImageOps.py index 42b10bd7b..8fdb9564d 100644 --- a/src/PIL/ImageOps.py +++ b/src/PIL/ImageOps.py @@ -36,6 +36,9 @@ def _border(border: int | tuple[int, ...]) -> tuple[int, int, int, int]: left, top = right, bottom = border elif len(border) == 4: left, top, right, bottom = border + else: + msg = "border must be an integer or a 2- or 4-tuple" + raise ValueError(msg) else: left = top = right = bottom = border return left, top, right, bottom From e50d8a51923aca66bc573c476b599d0072bfc158 Mon Sep 17 00:00:00 2001 From: Varun Chawla Date: Sun, 22 Feb 2026 18:50:14 -0800 Subject: [PATCH 010/190] Improve border validation error message wording --- src/PIL/ImageOps.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/PIL/ImageOps.py b/src/PIL/ImageOps.py index 8fdb9564d..f0ae142b9 100644 --- a/src/PIL/ImageOps.py +++ b/src/PIL/ImageOps.py @@ -37,7 +37,7 @@ def _border(border: int | tuple[int, ...]) -> tuple[int, int, int, int]: elif len(border) == 4: left, top, right, bottom = border else: - msg = "border must be an integer or a 2- or 4-tuple" + msg = "border must be an integer, or a tuple of two or four elements" raise ValueError(msg) else: left = top = right = bottom = border From 98c149f0307e077d9aa0c2018710c9354afe6907 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Thu, 19 Mar 2026 09:26:58 +1100 Subject: [PATCH 011/190] Simplified code --- src/PIL/BmpImagePlugin.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/src/PIL/BmpImagePlugin.py b/src/PIL/BmpImagePlugin.py index 5ee61b35b..00975624a 100644 --- a/src/PIL/BmpImagePlugin.py +++ b/src/PIL/BmpImagePlugin.py @@ -179,11 +179,8 @@ class BmpImageFile(ImageFile.ImageFile): # ------- If color count was not found in the header, compute from bits assert isinstance(file_info["bits"], int) - file_info["colors"] = ( - file_info["colors"] - if file_info.get("colors", 0) - else (1 << file_info["bits"]) - ) + if not file_info.get("colors", 0): + file_info["colors"] = 1 << file_info["bits"] assert isinstance(file_info["colors"], int) if offset == 14 + file_info["header_size"] and file_info["bits"] <= 8: offset += 4 * file_info["colors"] From 93de6a78d8cee9b2e9cdbe1f4d1da014d090ce8b Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Thu, 19 Mar 2026 10:09:38 +1100 Subject: [PATCH 012/190] Generate test image programmatically --- Tests/images/pal8_offset.bmp | Bin 9254 -> 0 bytes Tests/test_file_bmp.py | 12 +++++++----- 2 files changed, 7 insertions(+), 5 deletions(-) delete mode 100644 Tests/images/pal8_offset.bmp diff --git a/Tests/images/pal8_offset.bmp b/Tests/images/pal8_offset.bmp deleted file mode 100644 index 24be65f22c3c7b77adb011ab4d635d95b31ee15d..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 9254 zcmbuDUrbb29>*_#1ly_)t|lZlX5%)S6}4}?uj zWNA{v!;Xo}pm5nc<9gexJ|pd+tE`r!Rj0iKWJ29{<4l3+s=pB5OO3jNe+;Z$8rN-)bZMVDrLZ zxh#+6TUHfMRqR)>U&VeE`&I0pWd9`lC)q#A{z>*vvfsykANzgm_p#r{ejocc*uTO4 z4fb!ae}nxS>_2AzG5e3%f6V@4_8+s)f0oa_&%V#T&%WIBhs>IBhs>IBhs>IBhs>IBhs>IBhs> zIBhs>IBhs>IBhs>IH6XA4v!9;4xA304xA304xA304xA304xA304xA304xA304xA30 zP9OVlI&eC0I&eC0I&eC0I&eC0I&eC0I&eC0I&eC0I&eC0I&eC0I&eC0(pi>tQYdA5 zEcW4a;dJ42;dJ42;dJ42;dJ42;dJ42;dJ42;dJ42``Cxmh0}%8h0}%8h0}%8h0}%8 zh0}%8h0}%8h0}%8h0}%8h0}%8g_HDIlD2eGC}pw_rw6A8rw6A8rw6A8rw6A8rw6A8 zrw6A8rw6A8rw6Ck$3C1MoF1GWoF1GWoF1GWoF1GWoF1GWoF1GWoF1GWoF1GWoF1HP zv*bid+R{m(WWwpg>BH&6>BH&6>BH&6>BH&6>BH&6>BH&6>BH&6>BH&wu@9#Yrw^wO zrw^wOrw^wOrw^wOrw^wOCrB*z;q>A3;q+zGvdXM##^J+f&N$9x=kn#NjQii;XS{m# zO8$XWrN$}7H>bW)+@YOA{J zaP^t$Gl!isly|OP{{HHH0RD0R6#x{MS#?ULln3CZ%$q;Sq<*hj%?+zRUAR3HDqT%$4N8;g}C3aSs_@5Df=>Y!UUsd`!{!(6s=u3GS+TSV@ z|6SrQ9l-zNeM)~z{}@fti|^txD*!+kfOY_e@c#k-IokgkfHL?3_&4LEN@BfMaYy5fN3gFMgo{2vbdnW$sdi+E9 z*YBy{(|~{DzQ&Kg!2g8!n~8md>~p95GSvBpb^ezJO#Tvp_^SXo{{(P~0KVxbe*(Cv z0^s~ra=rM6bpCtmP5u&q_(#b)d&T)#uNcpiFVFICCx7v$1NY~Qzv_VTR~<0^sss2} z%k{>;j{L=+4jgDy{t0Crjp*k%&$Dk}pZ z5U2_Snol)1)BgVcZ~FUh-Mn=RfTvIK2cSGq9^4t)RY&{l_crWnp#2Kq;E6o@bSL6u zFU#Z~{6F&#{+ImoQa3$d*D5Nqipud1gb#+SsDI;=0LL= z`1jxH$DV{C^9Mkke*jeeL!5uf z9{?#I1c35^2mt-f{ryu0;28j_{Q#)*4}i+Qf%7l<10cEUrL&jZvljkEy?&kX2VujX4v4+>udJ@r*W=HquWw*9(ts}-Pn;0{+?f+ajQW8hX=661U?9S5ZoVWF`>Fn^TiD-vM*jnq5??3~3f6_{>+0(o z>OXI21c34YYc6DQ1Mu`&CHh(U6Tn9P%75$h zWBAALufabAe+2({d%S%Z{|7@4e)t#uZ&dynf1SVf$6ooL#y_mD4|UXa)OF$C)6nzz zpYgwd|9huZR9sY4UPFEn@+W}yIQbL6oC<*RhyM=jg8)Y8Km_|s08A0Ul=wH%fYa*w zP>2BPI`AieMiqe0U$QqVF8NFLdWrvb{A-MVl>Cjq{ASJ{CV#!1&i^a?^-eSt)i1;! z0PUZ1zh~e3kwlFA6+obd;}Z!-+oSjo4-M1){M!0w{4ZV7FQ=YOJ$okp0O*}iv`)Y1 zqWwLM1aRU)G(9lSO!W0I`KSG}^0%z@O@mLr1c1C{S(FE$sZ#RSJJC>we(^u#UtC;N zyuBt+QxggDdpaDA<3D_F=-z`u^{Hwpf2q$Az`v=fvgs@QWhW91=@(x&eBDC;J>s9B z0g``0?8%*Hm3=}QK>o!he*h>S9*#=>+yVNNbU)1_{h&zNe`@Nb_&4eN%Jyx;)# z0HE@Zn)9Fk9A`h4pnmc8;u=OMgugJxhZzs<6{xQyH~BYx)pR=T&-s)3U0ppi0RIc= z{0n6-4WNEaan1HnO{69g;_ov6hU52!9}FA+H@kOyCNmff(gfw7F!{g4|0VvXn@)eF z--<>|{#`u){JG}>0Pnf`W5%ES84>Z94&Z-pSm$q9Z+7h7v4iqK1t9eknf_Cr`XsR7 zAJHIRcPV}4FZoOMlKaNOT=GxEtrFUA1JD9M2LLYqN&Fw`Pr_eyfbyodk+lC^<=;#D zFB*T2nZ64DG1@;a{*rtClI-=$&tLmz^2dKS070hde~5U@Lir;hxn*V+#*9@` zQe5%@{&q{KrKKa%(Sg590OtP7Niux^bm7g9^7owjj>@@8?ciRp16$<3asJ;_?5@~- zZ1=Ix&ZY0a4S)5yFMCZzVdK|}{;6w>`+to4fBXU{3fsls1;VU2&+#Prmz0!zP+~KV z9KoNFNVtrLNqya0%2(|E?6YG`z2heMUxo6KvRp z#|Zu@?VtDkBaT*7)tBsNSMIK?O!Q9nPJVkEB=Mh7oyfmTUB$`I%p|XqGOdzQ@~32%%Xz^6L;P!ve<%5G_{&G; zk8{@mVAD-eehq+$-tX|Aym*Jr>DYAszt=y<<+qAjst4L$bwK<9uxst3M_OAtJ38^_ z^tvhT|IOnZL;1VP-uMG>aq=P&#HKlan+Kj-4wHY{KP&&_>ce*r-@V;maa`uHZkgCK z<#gb`Ce!4P|HV6hCI9$LJgLvm$o$yC*ups0G7Dv|@-HndDg6-t+SVhjt(`5MgZPik zy0fX&!&K_s+jkZFD=H{|jPmEsb)$cc2Bh#;0nq=6-ihzVWZA9Wef?1DX7l|8$HqjX&3qrL=!S+4HTTdywQ= z<)6^!zqGXU!_r#D(WCfx4$^=TMv4aD|4#e?I3}|je`&u=-e<0@tW8Wzd^a)4xP2S{ z=@}Z3Wa#S`78XqM%AVKx&ys&B_kS(-|55J$PVWCf=tok?)fD-^`xW_5aQ{z||84T0 zCjS|(Po_rZQ{=z!p6pHjG(h}Y#a}vr|Llm;7ytb-jx)NCcdK!Yk-8?`Nu}1-#Gm{b zcf?;hfd6b#UoZY*TxccPPbREwwEqwQZ2+7FV6<~&@W&CMPo>rYpnApe{WSkL=f9f( zx@rD3&VP#br`A>$Y5x-dmH>DT!18o*=I=@Ao?MT=o5GS51D{?HNskFxyFlE3)Vfz-V6|78Cs`@h8hyi5YP-hKVrBLE(y9`U~T|D5Ij zoczU~4y0D)dhM@sFVq3?2cY&4_kUaKS=v81I65*)`%?n&ZhhnY>-?qtssnNWO#T2& zKH>gfy7Qd&&&(_*muY`W02bDbx3_+yt^H~Iha{`|#U zC!halZQEA5?IZjTwHDnVx&N=2`+r{U|6h^+qPhQnG57xq zbN{dE^S@>G(hcz^e@2`5x1o>!kE7@_vi;A0nbCiAUHs{Q@+W`BlK3y7kN@A7(P!}f zR@o;h&Z0i;2cQiA%D0W49lb*NQB@uQ)qVn?yaKp>{`z&wD*y>VwI6^b04Tq-{CxQZ T<(E}?03>_8_`3k(yk-3da10=X diff --git a/Tests/test_file_bmp.py b/Tests/test_file_bmp.py index 2e0394b3b..b61497812 100644 --- a/Tests/test_file_bmp.py +++ b/Tests/test_file_bmp.py @@ -42,7 +42,7 @@ def test_fallback_if_mmap_errors() -> None: # This image has been truncated, # so that the buffer is not large enough when using mmap with Image.open("Tests/images/mmap_error.bmp") as im: - assert_image_equal_tofile(im, "Tests/images/pal8_offset.bmp") + assert_image_equal_tofile(im, "Tests/images/bmp/g/pal8.bmp") def test_save_to_bytes() -> None: @@ -239,10 +239,12 @@ def test_unsupported_bmp_bitfields_layout() -> None: def test_offset() -> None: - # This image has been hexedited - # to exclude the palette size from the pixel data offset - with Image.open("Tests/images/pal8_offset.bmp") as im: - assert_image_equal_tofile(im, "Tests/images/bmp/g/pal8.bmp") + # Exclude the palette size from the pixel data offset + with open("Tests/images/bmp/g/pal8.bmp", "rb") as fp: + data = fp.read() + data = data[:10] + o32(54) + data[14:] + with Image.open(io.BytesIO(data)) as im: + assert_image_equal_tofile(im, "Tests/images/bmp/g/pal8.bmp") def test_use_raw_alpha(monkeypatch: pytest.MonkeyPatch) -> None: From 735d02584b4db8a1b923d546b7be94de28f0876a Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Thu, 19 Mar 2026 10:38:28 +1100 Subject: [PATCH 013/190] Allow for different palette entry sizes when correcting offset --- Tests/test_file_bmp.py | 16 ++++++++++++---- src/PIL/BmpImagePlugin.py | 4 ++-- 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/Tests/test_file_bmp.py b/Tests/test_file_bmp.py index b61497812..c8ac46524 100644 --- a/Tests/test_file_bmp.py +++ b/Tests/test_file_bmp.py @@ -238,13 +238,21 @@ def test_unsupported_bmp_bitfields_layout() -> None: Image.open(fp) -def test_offset() -> None: +@pytest.mark.parametrize( + "offset, path", + ( + (26, "pal8os2.bmp"), + (54, "pal8.bmp"), + ), +) +def test_offset(offset: int, path: str) -> None: + image_path = "Tests/images/bmp/g/" + path # Exclude the palette size from the pixel data offset - with open("Tests/images/bmp/g/pal8.bmp", "rb") as fp: + with open(image_path, "rb") as fp: data = fp.read() - data = data[:10] + o32(54) + data[14:] + data = data[:10] + o32(offset) + data[14:] with Image.open(io.BytesIO(data)) as im: - assert_image_equal_tofile(im, "Tests/images/bmp/g/pal8.bmp") + assert_image_equal_tofile(im, image_path) def test_use_raw_alpha(monkeypatch: pytest.MonkeyPatch) -> None: diff --git a/src/PIL/BmpImagePlugin.py b/src/PIL/BmpImagePlugin.py index 00975624a..a6724cab4 100644 --- a/src/PIL/BmpImagePlugin.py +++ b/src/PIL/BmpImagePlugin.py @@ -181,9 +181,10 @@ class BmpImageFile(ImageFile.ImageFile): assert isinstance(file_info["bits"], int) if not file_info.get("colors", 0): file_info["colors"] = 1 << file_info["bits"] + assert isinstance(file_info["palette_padding"], int) assert isinstance(file_info["colors"], int) if offset == 14 + file_info["header_size"] and file_info["bits"] <= 8: - offset += 4 * file_info["colors"] + offset += file_info["palette_padding"] * file_info["colors"] # ---------------------- Check bit depth for unusual unsupported values self._mode, raw_mode = BIT2MODE.get(file_info["bits"], ("", "")) @@ -262,7 +263,6 @@ class BmpImageFile(ImageFile.ImageFile): msg = f"Unsupported BMP Palette size ({file_info['colors']})" raise OSError(msg) else: - assert isinstance(file_info["palette_padding"], int) padding = file_info["palette_padding"] palette = read(padding * file_info["colors"]) grayscale = True From ffd32a861a690a0dcb49d62379c20c4184810f19 Mon Sep 17 00:00:00 2001 From: wiredfool Date: Tue, 24 Mar 2026 21:14:16 +0000 Subject: [PATCH 014/190] Check all allocs in the Arrow tree * handle alloc failure * Ensure we're calling release so the refcount on the image is decremented * Ensure that release array/schema can handle partially allocated children arrays. --- src/_imaging.c | 3 ++ src/libImaging/Arrow.c | 76 ++++++++++++++++++++++++------------------ 2 files changed, 46 insertions(+), 33 deletions(-) diff --git a/src/_imaging.c b/src/_imaging.c index d2a195887..af8360368 100644 --- a/src/_imaging.c +++ b/src/_imaging.c @@ -286,6 +286,9 @@ PyObject * ExportArrowArrayPyCapsule(ImagingObject *self) { struct ArrowArray *array = (struct ArrowArray *)calloc(1, sizeof(struct ArrowArray)); + if (!array) { + return ArrowError(IMAGING_CODEC_MEMORY); + } int err = export_imaging_array(self->image, array); if (err == 0) { return PyCapsule_New(array, "arrow_array", ReleaseArrowArrayPyCapsule); diff --git a/src/libImaging/Arrow.c b/src/libImaging/Arrow.c index de4d3568e..3ca227d4f 100644 --- a/src/libImaging/Arrow.c +++ b/src/libImaging/Arrow.c @@ -10,8 +10,8 @@ static void ReleaseExportedSchema(struct ArrowSchema *array) { - // This should not be called on already released array - // assert(array->release != NULL); + // TODO here: release and/or deallocate all data directly owned by + // the ArrowArray struct, such as the private_data. if (!array->release) { return; @@ -30,31 +30,36 @@ ReleaseExportedSchema(struct ArrowSchema *array) { } // Release children - for (int64_t i = 0; i < array->n_children; ++i) { - struct ArrowSchema *child = array->children[i]; - if (child->release != NULL) { - child->release(child); - child->release = NULL; - } - free(array->children[i]); - } if (array->children) { + for (int64_t i = 0; i < array->n_children; ++i) { + struct ArrowSchema *child = array->children[i]; + if (child != NULL) { + if (child->release != NULL) { + child->release(child); + child->release = NULL; + } + free(array->children[i]); + } + } free(array->children); + array->children = NULL; } // Release dictionary struct ArrowSchema *dict = array->dictionary; - if (dict != NULL && dict->release != NULL) { - dict->release(dict); - dict->release = NULL; + if (dict != NULL) { + if (dict->release != NULL) { + dict->release(dict); + dict->release = NULL; + } + free(dict); + array->dictionary = NULL; } - // TODO here: release and/or deallocate all data directly owned by - // the ArrowArray struct, such as the private_data. - // Mark array released array->release = NULL; } + char * image_band_json(Imaging im) { char *format = "{\"bands\": [\"%s\", \"%s\", \"%s\", \"%s\"]}"; @@ -220,13 +225,19 @@ export_imaging_schema(Imaging im, struct ArrowSchema *schema) { // if it's not 1 band, it's an int32 at the moment. 4 uint8 bands. schema->n_children = 1; schema->children = calloc(1, sizeof(struct ArrowSchema *)); + if (!schema->children) { + schema->release(schema); + return IMAGING_CODEC_MEMORY; + } schema->children[0] = (struct ArrowSchema *)calloc(1, sizeof(struct ArrowSchema)); + if (!schema->children[0]) { + schema->release(schema); + return IMAGING_CODEC_MEMORY; + } retval = export_named_type( schema->children[0], im->arrow_band_format, getModeData(im->mode)->name ); if (retval != 0) { - free(schema->children[0]); - free(schema->children); schema->release(schema); return retval; } @@ -256,11 +267,12 @@ release_const_array(struct ArrowArray *array) { array->buffers = NULL; } if (array->children) { - // undone -- does arrow release all the children recursively? for (int i = 0; i < array->n_children; i++) { - if (array->children[i]->release) { - array->children[i]->release(array->children[i]); - array->children[i]->release = NULL; + if (array->children[i]) { + if (array->children[i]->release) { + array->children[i]->release(array->children[i]); + array->children[i]->release = NULL; + } free(array->children[i]); } } @@ -303,8 +315,11 @@ export_single_channel_array(Imaging im, struct ArrowArray *array) { }; // Allocate list of buffers - array->buffers = (const void **)malloc(sizeof(void *) * array->n_buffers); - // assert(array->buffers != NULL); + array->buffers = (const void **)calloc(1, sizeof(void *) * array->n_buffers); + if (!array->buffers) { + array->release(array); + return IMAGING_CODEC_MEMORY; + } array->buffers[0] = NULL; // no nulls, null bitmap can be omitted if (im->block) { @@ -386,6 +401,9 @@ export_fixed_pixel_array(Imaging im, struct ArrowArray *array) { array->children[0]->buffers = (const void **)calloc(2, sizeof(void *) * array->n_buffers); + if (!array->children[0]->buffers) { + goto err; + } if (im->block) { array->children[0]->buffers[1] = im->block; @@ -395,15 +413,7 @@ export_fixed_pixel_array(Imaging im, struct ArrowArray *array) { return 0; err: - if (array->children[0]) { - free(array->children[0]); - } - if (array->children) { - free(array->children); - } - if (array->buffers) { - free(array->buffers); - } + array->release(array); return IMAGING_CODEC_MEMORY; } From e4d72b53f5e885338ac55c570055d29e35c7c385 Mon Sep 17 00:00:00 2001 From: Sam Gross Date: Wed, 25 Mar 2026 14:50:05 -0400 Subject: [PATCH 015/190] Use critical sections to protect FontObject FreeType FT_Face objects are not thread-safe. Use per-object critical sections to protect FontObject methods that access the underlying FT_Face in the free-threaded build. Fixes #9497 --- src/_imagingft.c | 110 ++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 100 insertions(+), 10 deletions(-) diff --git a/src/_imagingft.c b/src/_imagingft.c index 3be1bcb9a..534dd0286 100644 --- a/src/_imagingft.c +++ b/src/_imagingft.c @@ -518,7 +518,7 @@ text_layout( } static PyObject * -font_getlength(FontObject *self, PyObject *args) { +font_getlength_impl(FontObject *self, PyObject *args) { int length; /* length along primary axis, in 26.6 precision */ GlyphInfo *glyph_info = NULL; /* computed text layout */ size_t i, count; /* glyph_info index and length */ @@ -567,6 +567,15 @@ font_getlength(FontObject *self, PyObject *args) { return PyLong_FromLong(length); } +static PyObject * +font_getlength(FontObject *self, PyObject *args) { + PyObject *result; + Py_BEGIN_CRITICAL_SECTION(self); + result = font_getlength_impl(self, args); + Py_END_CRITICAL_SECTION(); + return result; +} + static int bounding_box_and_anchors( FT_Face face, @@ -746,7 +755,7 @@ bad_anchor: } static PyObject * -font_getsize(FontObject *self, PyObject *args) { +font_getsize_impl(FontObject *self, PyObject *args) { int width, height, x_offset, y_offset; int load_flags; /* FreeType load_flags parameter */ int error; @@ -820,7 +829,16 @@ font_getsize(FontObject *self, PyObject *args) { } static PyObject * -font_render(FontObject *self, PyObject *args) { +font_getsize(FontObject *self, PyObject *args) { + PyObject *result; + Py_BEGIN_CRITICAL_SECTION(self); + result = font_getsize_impl(self, args); + Py_END_CRITICAL_SECTION(); + return result; +} + +static PyObject * +font_render_impl(FontObject *self, PyObject *args) { int x, y; /* pen position, in 26.6 precision */ int px, py; /* position of current glyph, in pixels */ int x_min, y_max; /* text offset in 26.6 precision */ @@ -1233,6 +1251,15 @@ glyph_error: return NULL; } +static PyObject * +font_render(FontObject *self, PyObject *args) { + PyObject *result; + Py_BEGIN_CRITICAL_SECTION(self); + result = font_render_impl(self, args); + Py_END_CRITICAL_SECTION(); + return result; +} + static PyObject * font_getvarnames(FontObject *self) { int error; @@ -1372,7 +1399,7 @@ font_getvaraxes(FontObject *self) { } static PyObject * -font_setvarname(FontObject *self, PyObject *args) { +font_setvarname_impl(FontObject *self, PyObject *args) { int error; int instance_index; @@ -1389,7 +1416,16 @@ font_setvarname(FontObject *self, PyObject *args) { } static PyObject * -font_setvaraxes(FontObject *self, PyObject *args) { +font_setvarname(FontObject *self, PyObject *args) { + PyObject *result; + Py_BEGIN_CRITICAL_SECTION(self); + result = font_setvarname_impl(self, args); + Py_END_CRITICAL_SECTION(); + return result; +} + +static PyObject * +font_setvaraxes_impl(FontObject *self, PyObject *args) { int error; PyObject *axes, *item; @@ -1442,6 +1478,15 @@ font_setvaraxes(FontObject *self, PyObject *args) { Py_RETURN_NONE; } +static PyObject * +font_setvaraxes(FontObject *self, PyObject *args) { + PyObject *result; + Py_BEGIN_CRITICAL_SECTION(self); + result = font_setvaraxes_impl(self, args); + Py_END_CRITICAL_SECTION(); + return result; +} + static void font_dealloc(FontObject *self) { if (self->face) { @@ -1483,30 +1528,75 @@ font_getattr_style(FontObject *self, void *closure) { } static PyObject * -font_getattr_ascent(FontObject *self, void *closure) { +font_getattr_ascent_impl(FontObject *self, void *closure) { return PyLong_FromLong(PIXEL(self->face->size->metrics.ascender)); } static PyObject * -font_getattr_descent(FontObject *self, void *closure) { +font_getattr_ascent(FontObject *self, void *closure) { + PyObject *result; + Py_BEGIN_CRITICAL_SECTION(self); + result = font_getattr_ascent_impl(self, closure); + Py_END_CRITICAL_SECTION(); + return result; +} + +static PyObject * +font_getattr_descent_impl(FontObject *self, void *closure) { return PyLong_FromLong(-PIXEL(self->face->size->metrics.descender)); } static PyObject * -font_getattr_height(FontObject *self, void *closure) { +font_getattr_descent(FontObject *self, void *closure) { + PyObject *result; + Py_BEGIN_CRITICAL_SECTION(self); + result = font_getattr_descent_impl(self, closure); + Py_END_CRITICAL_SECTION(); + return result; +} + +static PyObject * +font_getattr_height_impl(FontObject *self, void *closure) { return PyLong_FromLong(PIXEL(self->face->size->metrics.height)); } static PyObject * -font_getattr_x_ppem(FontObject *self, void *closure) { +font_getattr_height(FontObject *self, void *closure) { + PyObject *result; + Py_BEGIN_CRITICAL_SECTION(self); + result = font_getattr_height_impl(self, closure); + Py_END_CRITICAL_SECTION(); + return result; +} + +static PyObject * +font_getattr_x_ppem_impl(FontObject *self, void *closure) { return PyLong_FromLong(self->face->size->metrics.x_ppem); } static PyObject * -font_getattr_y_ppem(FontObject *self, void *closure) { +font_getattr_x_ppem(FontObject *self, void *closure) { + PyObject *result; + Py_BEGIN_CRITICAL_SECTION(self); + result = font_getattr_x_ppem_impl(self, closure); + Py_END_CRITICAL_SECTION(); + return result; +} + +static PyObject * +font_getattr_y_ppem_impl(FontObject *self, void *closure) { return PyLong_FromLong(self->face->size->metrics.y_ppem); } +static PyObject * +font_getattr_y_ppem(FontObject *self, void *closure) { + PyObject *result; + Py_BEGIN_CRITICAL_SECTION(self); + result = font_getattr_y_ppem_impl(self, closure); + Py_END_CRITICAL_SECTION(); + return result; +} + static PyObject * font_getattr_glyphs(FontObject *self, void *closure) { return PyLong_FromLong(self->face->num_glyphs); From b337b33564da0b21d244b46c2b3e954ae6afc099 Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade <1324225+hugovk@users.noreply.github.com> Date: Thu, 26 Mar 2026 17:45:59 +0200 Subject: [PATCH 016/190] PERF101 --- pyproject.toml | 31 ++++++++++++++++--------------- 1 file changed, 16 insertions(+), 15 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 6d9910ca1..4190f091a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -139,21 +139,22 @@ exclude = "wheels/multibuild" exclude = [ "wheels/multibuild" ] fix = true lint.select = [ - "C4", # flake8-comprehensions - "E", # pycodestyle errors - "EM", # flake8-errmsg - "F", # pyflakes errors - "I", # isort - "ISC", # flake8-implicit-str-concat - "LOG", # flake8-logging - "PGH", # pygrep-hooks - "PIE", # flake8-pie - "PT", # flake8-pytest-style - "PYI", # flake8-pyi - "RUF100", # unused noqa (yesqa) - "UP", # pyupgrade - "W", # pycodestyle warnings - "YTT", # flake8-2020 + "C4", # flake8-comprehensions + "E", # pycodestyle errors + "EM", # flake8-errmsg + "F", # pyflakes errors + "I", # isort + "ISC", # flake8-implicit-str-concat + "LOG", # flake8-logging + "PERF101", # perflint: unnecessary-list-cast + "PGH", # pygrep-hooks + "PIE", # flake8-pie + "PT", # flake8-pytest-style + "PYI", # flake8-pyi + "RUF100", # unused noqa (yesqa) + "UP", # pyupgrade + "W", # pycodestyle warnings + "YTT", # flake8-2020 ] lint.ignore = [ "E203", # Whitespace before ':' From 624fc87d2d91f9bd763f3cd721f999f58a6752bb Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade <1324225+hugovk@users.noreply.github.com> Date: Thu, 26 Mar 2026 17:46:16 +0200 Subject: [PATCH 017/190] PERF102 --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index 4190f091a..7178cb2de 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -147,6 +147,7 @@ lint.select = [ "ISC", # flake8-implicit-str-concat "LOG", # flake8-logging "PERF101", # perflint: unnecessary-list-cast + "PERF102", # perflint: incorrect-dict-iterator "PGH", # pygrep-hooks "PIE", # flake8-pie "PT", # flake8-pytest-style From b85b8534d7ca1132cfe4067ae2963c26f07b7811 Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade <1324225+hugovk@users.noreply.github.com> Date: Thu, 26 Mar 2026 18:11:12 +0200 Subject: [PATCH 018/190] PERF401 and fixes --- pyproject.toml | 1 + setup.py | 8 ++++---- src/PIL/IcnsImagePlugin.py | 3 +-- src/PIL/ImageDraw.py | 4 +--- src/PIL/ImageFile.py | 6 ++++-- winbuild/build_prepare.py | 6 +++--- 6 files changed, 14 insertions(+), 14 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 7178cb2de..ea43e9cb1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -148,6 +148,7 @@ lint.select = [ "LOG", # flake8-logging "PERF101", # perflint: unnecessary-list-cast "PERF102", # perflint: incorrect-dict-iterator + "PERF401", # perflint: manual-list-comprehension "PGH", # pygrep-hooks "PIE", # flake8-pie "PT", # flake8-pytest-style diff --git a/setup.py b/setup.py index 3d975950b..175aed25a 100644 --- a/setup.py +++ b/setup.py @@ -1078,10 +1078,10 @@ libraries: list[tuple[str, _BuildInfo]] = [ ] files: list[str | os.PathLike[str]] = ["src/_imaging.c"] -for src_file in _IMAGING: - files.append("src/" + src_file + ".c") -for src_file in _LIB_IMAGING: - files.append(os.path.join("src/libImaging", src_file + ".c")) +files.extend("src/" + src_file + ".c" for src_file in _IMAGING) +files.extend( + os.path.join("src/libImaging", src_file + ".c") for src_file in _LIB_IMAGING +) ext_modules = [ Extension("PIL._imaging", files), Extension("PIL._imagingft", ["src/_imagingft.c"]), diff --git a/src/PIL/IcnsImagePlugin.py b/src/PIL/IcnsImagePlugin.py index 023835fb7..cb7a74c2e 100644 --- a/src/PIL/IcnsImagePlugin.py +++ b/src/PIL/IcnsImagePlugin.py @@ -80,8 +80,7 @@ def read_32( if byte_int & 0x80: blocksize = byte_int - 125 byte = fobj.read(1) - for i in range(blocksize): - data.append(byte) + data.extend([byte] * blocksize) else: blocksize = byte_int + 1 data.append(fobj.read(blocksize)) diff --git a/src/PIL/ImageDraw.py b/src/PIL/ImageDraw.py index eb108ac41..506bb3b43 100644 --- a/src/PIL/ImageDraw.py +++ b/src/PIL/ImageDraw.py @@ -597,9 +597,7 @@ class ImageDraw: mode = self.fontmode if stroke_width == 0 and embedded_color: mode = "RGBA" - coord = [] - for i in range(2): - coord.append(int(xy[i])) + coord = [int(xy[i]) for i in range(2)] start = (math.modf(xy[0])[0], math.modf(xy[1])[0]) try: mask, offset = image_text.font.getmask2( # type: ignore[union-attr,misc] diff --git a/src/PIL/ImageFile.py b/src/PIL/ImageFile.py index 50e0075a2..df2a82b73 100644 --- a/src/PIL/ImageFile.py +++ b/src/PIL/ImageFile.py @@ -215,8 +215,10 @@ class ImageFile(Image.Image): if subifd_offsets: if not isinstance(subifd_offsets, tuple): subifd_offsets = (subifd_offsets,) - for subifd_offset in subifd_offsets: - ifds.append((exif._get_ifd_dict(subifd_offset), subifd_offset)) + ifds = [ + (exif._get_ifd_dict(subifd_offset), subifd_offset) + for subifd_offset in subifd_offsets + ] ifd1 = exif.get_ifd(ExifTags.IFD.IFD1) if ifd1 and ifd1.get(ExifTags.Base.JpegIFOffset): assert exif._info is not None diff --git a/winbuild/build_prepare.py b/winbuild/build_prepare.py index d958a4592..1438827ca 100644 --- a/winbuild/build_prepare.py +++ b/winbuild/build_prepare.py @@ -544,11 +544,11 @@ def write_script( def get_footer(dep: dict[str, Any]) -> list[str]: lines = [] for out in dep.get("headers", []): - lines.append(cmd_copy(out, "{inc_dir}")) + lines.append(cmd_copy(out, "{inc_dir}")) # noqa: PERF401 for out in dep.get("libs", []): - lines.append(cmd_copy(out, "{lib_dir}")) + lines.append(cmd_copy(out, "{lib_dir}")) # noqa: PERF401 for out in dep.get("bins", []): - lines.append(cmd_copy(out, "{bin_dir}")) + lines.append(cmd_copy(out, "{bin_dir}")) # noqa: PERF401 return lines From 9a358fa289e87b849d08ead61a6dccabf5961121 Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade <1324225+hugovk@users.noreply.github.com> Date: Thu, 26 Mar 2026 18:12:32 +0200 Subject: [PATCH 019/190] PERF402 and fixes --- Tests/test_file_container.py | 4 +--- pyproject.toml | 1 + 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/Tests/test_file_container.py b/Tests/test_file_container.py index 597ab5083..c73f2a40c 100644 --- a/Tests/test_file_container.py +++ b/Tests/test_file_container.py @@ -179,9 +179,7 @@ def test_iter(bytesmode: bool) -> None: container = ContainerIO.ContainerIO(fh, 0, 120) # Act - data = [] - for line in container: - data.append(line) + data = list(container) # Assert if bytesmode: diff --git a/pyproject.toml b/pyproject.toml index ea43e9cb1..149dbbac5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -149,6 +149,7 @@ lint.select = [ "PERF101", # perflint: unnecessary-list-cast "PERF102", # perflint: incorrect-dict-iterator "PERF401", # perflint: manual-list-comprehension + "PERF402", # perflint: manual-list-copy "PGH", # pygrep-hooks "PIE", # flake8-pie "PT", # flake8-pytest-style From 090ca9461b1ce0e0f91644f6cc7a1d1416e7b915 Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade <1324225+hugovk@users.noreply.github.com> Date: Thu, 26 Mar 2026 18:20:06 +0200 Subject: [PATCH 020/190] PERF403 and fixes --- pyproject.toml | 1 + src/PIL/IptcImagePlugin.py | 11 ++--------- 2 files changed, 3 insertions(+), 9 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 149dbbac5..bda99c3bf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -150,6 +150,7 @@ lint.select = [ "PERF102", # perflint: incorrect-dict-iterator "PERF401", # perflint: manual-list-comprehension "PERF402", # perflint: manual-list-copy + "PERF403", # perflint: manual-dict-comprehension "PGH", # pygrep-hooks "PIE", # flake8-pie "PT", # flake8-pytest-style diff --git a/src/PIL/IptcImagePlugin.py b/src/PIL/IptcImagePlugin.py index 6fc824e4c..9c8be8b4e 100644 --- a/src/PIL/IptcImagePlugin.py +++ b/src/PIL/IptcImagePlugin.py @@ -185,13 +185,9 @@ def getiptcinfo( data = None - info: dict[tuple[int, int], bytes | list[bytes]] = {} if isinstance(im, IptcImageFile): # return info dictionary right away - for k, v in im.info.items(): - if isinstance(k, tuple): - info[k] = v - return info + return {k: v for k, v in im.info.items() if isinstance(k, tuple)} elif isinstance(im, JpegImagePlugin.JpegImageFile): # extract the IPTC/NAA resource @@ -227,7 +223,4 @@ def getiptcinfo( except (IndexError, KeyError): pass # expected failure - for k, v in iptc_im.info.items(): - if isinstance(k, tuple): - info[k] = v - return info + return {k: v for k, v in iptc_im.info.items() if isinstance(k, tuple)} From 754c7ea3a0aa47429809a1675f249263de3eac7b Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade <1324225+hugovk@users.noreply.github.com> Date: Thu, 26 Mar 2026 18:28:11 +0200 Subject: [PATCH 021/190] PERF203 and fixes --- Tests/test_bmp_reference.py | 4 ++-- Tests/test_file_libtiff.py | 5 +---- pyproject.toml | 36 ++++++++++++++++-------------------- setup.py | 2 +- src/PIL/GifImagePlugin.py | 2 +- src/PIL/Image.py | 2 +- src/PIL/ImageFont.py | 2 +- src/PIL/ImagePalette.py | 6 ++---- src/PIL/JpegImagePlugin.py | 4 ++-- src/PIL/PcfFontFile.py | 2 +- src/PIL/PngImagePlugin.py | 2 +- 11 files changed, 29 insertions(+), 38 deletions(-) diff --git a/Tests/test_bmp_reference.py b/Tests/test_bmp_reference.py index 8fbd73748..ea0853100 100644 --- a/Tests/test_bmp_reference.py +++ b/Tests/test_bmp_reference.py @@ -56,7 +56,7 @@ def test_questionable() -> None: im.load() if os.path.basename(f) not in supported: print(f"Please add {f} to the partially supported bmp specs.") - except Exception: # as msg: + except Exception: # noqa: PERF203 if os.path.basename(f) in supported: raise @@ -106,7 +106,7 @@ def test_good() -> None: assert_image_similar(im_converted, compare_converted, 5) - except Exception as msg: + except Exception as msg: # noqa: PERF203 # there are three here that are unsupported: unsupported = ( os.path.join(base, "g", "rgb32bf.bmp"), diff --git a/Tests/test_file_libtiff.py b/Tests/test_file_libtiff.py index b453e3aa5..6f20900e4 100644 --- a/Tests/test_file_libtiff.py +++ b/Tests/test_file_libtiff.py @@ -224,10 +224,7 @@ class TestFileLibTiff(LibTiffTestCase): with Image.open("Tests/images/hopper_g4.tif") as im: assert isinstance(im, TiffImagePlugin.TiffImageFile) for tag in im.tag_v2: - try: - del core_items[tag] - except KeyError: - pass + core_items.pop(tag, None) del core_items[320] # colormap is special, tested below # Type codes: diff --git a/pyproject.toml b/pyproject.toml index bda99c3bf..7eb9a3fbd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -139,26 +139,22 @@ exclude = "wheels/multibuild" exclude = [ "wheels/multibuild" ] fix = true lint.select = [ - "C4", # flake8-comprehensions - "E", # pycodestyle errors - "EM", # flake8-errmsg - "F", # pyflakes errors - "I", # isort - "ISC", # flake8-implicit-str-concat - "LOG", # flake8-logging - "PERF101", # perflint: unnecessary-list-cast - "PERF102", # perflint: incorrect-dict-iterator - "PERF401", # perflint: manual-list-comprehension - "PERF402", # perflint: manual-list-copy - "PERF403", # perflint: manual-dict-comprehension - "PGH", # pygrep-hooks - "PIE", # flake8-pie - "PT", # flake8-pytest-style - "PYI", # flake8-pyi - "RUF100", # unused noqa (yesqa) - "UP", # pyupgrade - "W", # pycodestyle warnings - "YTT", # flake8-2020 + "C4", # flake8-comprehensions + "E", # pycodestyle errors + "EM", # flake8-errmsg + "F", # pyflakes errors + "I", # isort + "ISC", # flake8-implicit-str-concat + "LOG", # flake8-logging + "PERF", # perflint + "PGH", # pygrep-hooks + "PIE", # flake8-pie + "PT", # flake8-pytest-style + "PYI", # flake8-pyi + "RUF100", # unused noqa (yesqa) + "UP", # pyupgrade + "W", # pycodestyle warnings + "YTT", # flake8-2020 ] lint.ignore = [ "E203", # Whitespace before ':' diff --git a/setup.py b/setup.py index 175aed25a..496c8cb1f 100644 --- a/setup.py +++ b/setup.py @@ -302,7 +302,7 @@ def _pkg_config(name: str) -> tuple[list[str], list[str]] | None: subprocess.check_output(command_cflags).decode("utf8").strip(), )[::2][1:] return libs, cflags - except Exception: + except Exception: # noqa: PERF203 pass return None diff --git a/src/PIL/GifImagePlugin.py b/src/PIL/GifImagePlugin.py index 390b3b374..1ffb18b9c 100644 --- a/src/PIL/GifImagePlugin.py +++ b/src/PIL/GifImagePlugin.py @@ -167,7 +167,7 @@ class GifImageFile(ImageFile.ImageFile): for f in range(self.__frame + 1, frame + 1): try: self._seek(f) - except EOFError as e: + except EOFError as e: # noqa: PERF203 self.seek(last_frame) msg = "no more images in GIF file" raise EOFError(msg) from e diff --git a/src/PIL/Image.py b/src/PIL/Image.py index bde335504..6062857da 100644 --- a/src/PIL/Image.py +++ b/src/PIL/Image.py @@ -488,7 +488,7 @@ def init() -> bool: try: logger.debug("Importing %s", plugin) __import__(f"{__spec__.parent}.{plugin}", globals(), locals(), []) - except ImportError as e: + except ImportError as e: # noqa: PERF203 logger.debug("Image: failed to import %s: %s", plugin, e) if OPEN or SAVE: diff --git a/src/PIL/ImageFont.py b/src/PIL/ImageFont.py index ea7f4dc54..ec7c7cb08 100644 --- a/src/PIL/ImageFont.py +++ b/src/PIL/ImageFont.py @@ -930,7 +930,7 @@ def load_path(filename: str | bytes) -> ImageFont: for directory in sys.path: try: return load(os.path.join(directory, filename)) - except OSError: + except OSError: # noqa: PERF203 pass msg = f'cannot find font file "{filename}" in sys.path' if os.path.exists(filename): diff --git a/src/PIL/ImagePalette.py b/src/PIL/ImagePalette.py index 99ad2771b..2abbd46ea 100644 --- a/src/PIL/ImagePalette.py +++ b/src/PIL/ImagePalette.py @@ -198,13 +198,11 @@ class ImagePalette: try: fp.write("# Palette\n") fp.write(f"# Mode: {self.mode}\n") + palette_len = len(self.palette) for i in range(256): fp.write(f"{i}") for j in range(i * len(self.mode), (i + 1) * len(self.mode)): - try: - fp.write(f" {self.palette[j]}") - except IndexError: - fp.write(" 0") + fp.write(f" {self.palette[j] if j < palette_len else 0}") fp.write("\n") finally: if open_fp: diff --git a/src/PIL/JpegImagePlugin.py b/src/PIL/JpegImagePlugin.py index 2f11cbfe3..d5b67bba5 100644 --- a/src/PIL/JpegImagePlugin.py +++ b/src/PIL/JpegImagePlugin.py @@ -153,7 +153,7 @@ def APP(self: JpegImageFile, marker: int) -> None: photoshop[code] = data offset += size offset += offset & 1 # align - except struct.error: + except struct.error: # noqa: PERF203 break # insufficient data elif marker == 0xFFEE and s.startswith(b"Adobe"): @@ -744,7 +744,7 @@ def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: msg = "Invalid quantization table" raise TypeError(msg) table_array = array.array("H", table) - except TypeError as e: + except TypeError as e: # noqa: PERF203 msg = "Invalid quantization table" raise ValueError(msg) from e else: diff --git a/src/PIL/PcfFontFile.py b/src/PIL/PcfFontFile.py index a00e9b919..b923293b0 100644 --- a/src/PIL/PcfFontFile.py +++ b/src/PIL/PcfFontFile.py @@ -251,7 +251,7 @@ class PcfFontFile(FontFile.FontFile): ] if encoding_offset != 0xFFFF: encoding[i] = encoding_offset - except UnicodeDecodeError: + except UnicodeDecodeError: # noqa: PERF203 # character is not supported in selected encoding pass diff --git a/src/PIL/PngImagePlugin.py b/src/PIL/PngImagePlugin.py index 4e082a293..d58426c55 100644 --- a/src/PIL/PngImagePlugin.py +++ b/src/PIL/PngImagePlugin.py @@ -869,7 +869,7 @@ class PngImageFile(ImageFile.ImageFile): for f in range(self.__frame + 1, frame + 1): try: self._seek(f) - except EOFError as e: + except EOFError as e: # noqa: PERF203 self.seek(last_frame) msg = "no more images in APNG file" raise EOFError(msg) from e From 65c4f4ea8dc3ff6eaf663d85296ee9fdd71dbd0a Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Sat, 28 Mar 2026 13:19:27 +1100 Subject: [PATCH 022/190] Updated libjpeg-turbo to 3.1.4 --- .github/workflows/wheels-dependencies.sh | 2 +- winbuild/build_prepare.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/wheels-dependencies.sh b/.github/workflows/wheels-dependencies.sh index 107eeae9b..97011f4a0 100755 --- a/.github/workflows/wheels-dependencies.sh +++ b/.github/workflows/wheels-dependencies.sh @@ -93,7 +93,7 @@ ARCHIVE_SDIR=pillow-depends-main FREETYPE_VERSION=2.14.3 HARFBUZZ_VERSION=13.2.1 LIBPNG_VERSION=1.6.56 -JPEGTURBO_VERSION=3.1.3 +JPEGTURBO_VERSION=3.1.4.1 OPENJPEG_VERSION=2.5.4 XZ_VERSION=5.8.2 ZSTD_VERSION=1.5.7 diff --git a/winbuild/build_prepare.py b/winbuild/build_prepare.py index d958a4592..300cbf149 100644 --- a/winbuild/build_prepare.py +++ b/winbuild/build_prepare.py @@ -117,7 +117,7 @@ V = { "FREETYPE": "2.14.3", "FRIBIDI": "1.0.16", "HARFBUZZ": "13.2.1", - "JPEGTURBO": "3.1.3", + "JPEGTURBO": "3.1.4.1", "LCMS2": "2.18", "LIBAVIF": "1.4.1", "LIBIMAGEQUANT": "4.4.1", From 018801805f496245d3c3d85512809d8c0013b6fd Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Fri, 27 Mar 2026 23:03:51 +1100 Subject: [PATCH 023/190] Simplify setimage() --- src/PIL/ImageFile.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/src/PIL/ImageFile.py b/src/PIL/ImageFile.py index 50e0075a2..5275496e8 100644 --- a/src/PIL/ImageFile.py +++ b/src/PIL/ImageFile.py @@ -799,16 +799,13 @@ class PyCodec: if extents: x0, y0, x1, y1 = extents - else: - x0, y0, x1, y1 = (0, 0, 0, 0) - if x0 == 0 and x1 == 0: - self.state.xsize, self.state.ysize = self.im.size - else: self.state.xoff = x0 self.state.yoff = y0 self.state.xsize = x1 - x0 self.state.ysize = y1 - y0 + else: + self.state.xsize, self.state.ysize = self.im.size if self.state.xsize <= 0 or self.state.ysize <= 0: msg = "Size must be positive" From 9a7b91e5dbb6630ea4e3d5d6eccbf48f4463eda4 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Sat, 28 Mar 2026 15:11:04 +1100 Subject: [PATCH 024/190] PERF203 fixes --- src/PIL/GifImagePlugin.py | 12 ++++++------ src/PIL/JpegImagePlugin.py | 22 ++++++++++------------ src/PIL/PngImagePlugin.py | 12 ++++++------ 3 files changed, 22 insertions(+), 24 deletions(-) diff --git a/src/PIL/GifImagePlugin.py b/src/PIL/GifImagePlugin.py index 1ffb18b9c..b8db5d832 100644 --- a/src/PIL/GifImagePlugin.py +++ b/src/PIL/GifImagePlugin.py @@ -164,13 +164,13 @@ class GifImageFile(ImageFile.ImageFile): self._seek(0) last_frame = self.__frame - for f in range(self.__frame + 1, frame + 1): - try: + try: + for f in range(self.__frame + 1, frame + 1): self._seek(f) - except EOFError as e: # noqa: PERF203 - self.seek(last_frame) - msg = "no more images in GIF file" - raise EOFError(msg) from e + except EOFError as e: + self.seek(last_frame) + msg = "no more images in GIF file" + raise EOFError(msg) from e def _seek(self, frame: int, update_image: bool = True) -> None: if isinstance(self._fp, DeferredError): diff --git a/src/PIL/JpegImagePlugin.py b/src/PIL/JpegImagePlugin.py index d5b67bba5..46320eb3b 100644 --- a/src/PIL/JpegImagePlugin.py +++ b/src/PIL/JpegImagePlugin.py @@ -127,8 +127,8 @@ def APP(self: JpegImageFile, marker: int) -> None: # parse the image resource block offset = 14 photoshop = self.info.setdefault("photoshop", {}) - while s[offset : offset + 4] == b"8BIM": - try: + try: + while s[offset : offset + 4] == b"8BIM": offset += 4 # resource code code = i16(s, offset) @@ -153,8 +153,8 @@ def APP(self: JpegImageFile, marker: int) -> None: photoshop[code] = data offset += size offset += offset & 1 # align - except struct.error: # noqa: PERF203 - break # insufficient data + except struct.error: + pass # insufficient data elif marker == 0xFFEE and s.startswith(b"Adobe"): self.info["adobe"] = i16(s, 5) @@ -738,17 +738,15 @@ def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: if not (0 < len(qtables) < 5): msg = "None or too many quantization tables" raise ValueError(msg) - for idx, table in enumerate(qtables): - try: + try: + for idx, table in enumerate(qtables): if len(table) != 64: msg = "Invalid quantization table" raise TypeError(msg) - table_array = array.array("H", table) - except TypeError as e: # noqa: PERF203 - msg = "Invalid quantization table" - raise ValueError(msg) from e - else: - qtables[idx] = list(table_array) + qtables[idx] = list(array.array("H", table)) + except TypeError as e: + msg = "Invalid quantization table" + raise ValueError(msg) from e return qtables if qtables == "keep": diff --git a/src/PIL/PngImagePlugin.py b/src/PIL/PngImagePlugin.py index d58426c55..76a15bd0d 100644 --- a/src/PIL/PngImagePlugin.py +++ b/src/PIL/PngImagePlugin.py @@ -866,13 +866,13 @@ class PngImageFile(ImageFile.ImageFile): self._seek(0, True) last_frame = self.__frame - for f in range(self.__frame + 1, frame + 1): - try: + try: + for f in range(self.__frame + 1, frame + 1): self._seek(f) - except EOFError as e: # noqa: PERF203 - self.seek(last_frame) - msg = "no more images in APNG file" - raise EOFError(msg) from e + except EOFError as e: + self.seek(last_frame) + msg = "no more images in APNG file" + raise EOFError(msg) from e def _seek(self, frame: int, rewind: bool = False) -> None: assert self.png is not None From 701b49adc5d3f64dfb9eb2c42f0411b3d8490fef Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Sat, 28 Mar 2026 15:13:26 +1100 Subject: [PATCH 025/190] PERF401 fix --- winbuild/build_prepare.py | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/winbuild/build_prepare.py b/winbuild/build_prepare.py index 1438827ca..466cca176 100644 --- a/winbuild/build_prepare.py +++ b/winbuild/build_prepare.py @@ -542,14 +542,11 @@ def write_script( def get_footer(dep: dict[str, Any]) -> list[str]: - lines = [] - for out in dep.get("headers", []): - lines.append(cmd_copy(out, "{inc_dir}")) # noqa: PERF401 - for out in dep.get("libs", []): - lines.append(cmd_copy(out, "{lib_dir}")) # noqa: PERF401 - for out in dep.get("bins", []): - lines.append(cmd_copy(out, "{bin_dir}")) # noqa: PERF401 - return lines + return ( + [cmd_copy(out, "{inc_dir}") for out in dep.get("headers", [])] + + [cmd_copy(out, "{lib_dir}") for out in dep.get("libs", [])] + + [cmd_copy(out, "{bin_dir}") for out in dep.get("bins", [])] + ) def build_env(prefs: dict[str, str], verbose: bool) -> None: From 9f3f6de10982f46b3864adb6533326c87be6ab14 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Sat, 28 Mar 2026 18:31:49 +1100 Subject: [PATCH 026/190] Allow None extents in C setimage --- Tests/test_imagefile.py | 21 +++++++++++++++++++++ src/decode.c | 35 +++++++++++++++++++++++++++++++---- src/encode.c | 37 +++++++++++++++++++++++++++++++------ 3 files changed, 83 insertions(+), 10 deletions(-) diff --git a/Tests/test_imagefile.py b/Tests/test_imagefile.py index 6656ee506..6cb0d36a3 100644 --- a/Tests/test_imagefile.py +++ b/Tests/test_imagefile.py @@ -170,6 +170,27 @@ class TestImageFile: with pytest.raises(SystemError, match="tile cannot extend outside image"): ImageFile._save(im, fp, [ImageFile._Tile("raw", xy + (1, 1), 0, "1")]) + def test_extents_none(self) -> None: + with Image.open("Tests/images/hopper.jpg") as im: + im.tile = [im.tile[0]._replace(extents=None)] + im.load() + + for extents in ("invalid", (0,), ("0", "0", "0", "0")): + with Image.open("Tests/images/hopper.jpg") as im: + im.tile = [im.tile[0]._replace(extents=extents)] # type: ignore[arg-type] + with pytest.raises(ValueError, match="invalid extents"): + im.load() + + im2 = Image.new("L", (1, 1)) + fp = BytesIO() + tile = ImageFile._Tile("jpeg", None, 0, "L") + ImageFile._save(im2, fp, [tile]) + + for extents in ("invalid", (0,), ("0", "0", "0", "0")): + tile = tile._replace(extents=extents) # type: ignore[arg-type] + with pytest.raises(ValueError, match="invalid extents"): + ImageFile._save(im2, fp, [tile]) + def test_no_format(self) -> None: buf = BytesIO(b"\x00" * 255) diff --git a/src/decode.c b/src/decode.c index cda4ce702..71f8d73d2 100644 --- a/src/decode.c +++ b/src/decode.c @@ -155,21 +155,48 @@ PyImaging_AsImaging(PyObject *op); static PyObject * _setimage(ImagingDecoderObject *decoder, PyObject *args) { - PyObject *op; + PyObject *op, *extents; Imaging im; ImagingCodecState state; int x0, y0, x1, y1; - x0 = y0 = x1 = y1 = 0; - /* FIXME: should publish the ImagingType descriptor */ - if (!PyArg_ParseTuple(args, "O(iiii)", &op, &x0, &y0, &x1, &y1)) { + if (!PyArg_ParseTuple(args, "OO", &op, &extents)) { return NULL; } im = PyImaging_AsImaging(op); if (!im) { return NULL; } + if (extents == Py_None) { + x0 = 0; + y0 = 0; + x1 = im->xsize; + y1 = im->ysize; + } else { + if (!PyTuple_Check(extents) || PyTuple_GET_SIZE(extents) != 4) { + PyErr_SetString(PyExc_ValueError, "invalid extents"); + return NULL; + } + for (int i = 0; i < 4; i++) { + PyObject *extent = PyTuple_GetItem(extents, i); + if (!PyLong_Check(extent)) { + PyErr_SetString(PyExc_ValueError, "invalid extents"); + return NULL; + } + int e = (int)PyLong_AsLong(extent); + + if (i == 0) { + x0 = e; + } else if (i == 1) { + y0 = e; + } else if (i == 2) { + x1 = e; + } else { + y1 = e; + } + } + } decoder->im = im; diff --git a/src/encode.c b/src/encode.c index 1fc31404d..26b744935 100644 --- a/src/encode.c +++ b/src/encode.c @@ -222,23 +222,48 @@ PyImaging_AsImaging(PyObject *op); static PyObject * _setimage(ImagingEncoderObject *encoder, PyObject *args) { - PyObject *op; + PyObject *op, *extents; Imaging im; ImagingCodecState state; Py_ssize_t x0, y0, x1, y1; - /* Define where image data should be stored */ - - x0 = y0 = x1 = y1 = 0; - /* FIXME: should publish the ImagingType descriptor */ - if (!PyArg_ParseTuple(args, "O(nnnn)", &op, &x0, &y0, &x1, &y1)) { + if (!PyArg_ParseTuple(args, "OO", &op, &extents)) { return NULL; } im = PyImaging_AsImaging(op); if (!im) { return NULL; } + if (extents == Py_None) { + x0 = 0; + y0 = 0; + x1 = im->xsize; + y1 = im->ysize; + } else { + if (!PyTuple_Check(extents) || PyTuple_GET_SIZE(extents) != 4) { + PyErr_SetString(PyExc_ValueError, "invalid extents"); + return NULL; + } + for (int i = 0; i < 4; i++) { + PyObject *extent = PyTuple_GetItem(extents, i); + if (!PyLong_Check(extent)) { + PyErr_SetString(PyExc_ValueError, "invalid extents"); + return NULL; + } + Py_ssize_t e = (Py_ssize_t)PyLong_AsLong(extent); + + if (i == 0) { + x0 = e; + } else if (i == 1) { + y0 = e; + } else if (i == 2) { + x1 = e; + } else { + y1 = e; + } + } + } if (im->xsize == 0 || im->ysize == 0) { PyErr_SetString(PyExc_ValueError, "cannot write empty image"); return NULL; From 1ed39726c57cd2d094f9a9a90be08814678f6190 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Sat, 14 Mar 2026 13:23:30 +1100 Subject: [PATCH 027/190] Added release notes for #9419 --- docs/releasenotes/12.2.0.rst | 63 ++++++++++++++++++++++++++++++++++++ docs/releasenotes/index.rst | 1 + 2 files changed, 64 insertions(+) create mode 100644 docs/releasenotes/12.2.0.rst diff --git a/docs/releasenotes/12.2.0.rst b/docs/releasenotes/12.2.0.rst new file mode 100644 index 000000000..aa1206cd0 --- /dev/null +++ b/docs/releasenotes/12.2.0.rst @@ -0,0 +1,63 @@ +12.2.0 +------ + +Security +======== + +TODO +^^^^ + +TODO + +:cve:`YYYY-XXXXX`: TODO +^^^^^^^^^^^^^^^^^^^^^^^ + +TODO + +Backwards incompatible changes +============================== + +TODO +^^^^ + +TODO + +Deprecations +============ + +TODO +^^^^ + +TODO + +API changes +=========== + +TODO +^^^^ + +TODO + +API additions +============= + +FontFile.to_imagefont() +^^^^^^^^^^^^^^^^^^^^^^^ + +:py:class:`~PIL.FontFile.FontFile` instances can now be directly converted to +:py:class:`~PIL.ImageFont.ImageFont` instances:: + + >>> from PIL import PcfFontFile + >>> with open("Tests/fonts/10x20-ISO8859-1.pcf", "rb") as fp: + ... pcffont = PcfFontFile.PcfFontFile(fp) + ... pcffont.to_imagefont() + ... + + +Other changes +============= + +TODO +^^^^ + +TODO diff --git a/docs/releasenotes/index.rst b/docs/releasenotes/index.rst index 690be2072..076872979 100644 --- a/docs/releasenotes/index.rst +++ b/docs/releasenotes/index.rst @@ -15,6 +15,7 @@ expected to be backported to earlier versions. :maxdepth: 2 versioning + 12.2.0 12.1.1 12.1.0 12.0.0 From ccf9863ba8595b5920ee3883ffc9aba01e92ff7f Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Sat, 14 Mar 2026 13:23:44 +1100 Subject: [PATCH 028/190] Added release notes for #9394 --- docs/releasenotes/12.2.0.rst | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/docs/releasenotes/12.2.0.rst b/docs/releasenotes/12.2.0.rst index aa1206cd0..0bbb9b824 100644 --- a/docs/releasenotes/12.2.0.rst +++ b/docs/releasenotes/12.2.0.rst @@ -33,10 +33,14 @@ TODO API changes =========== -TODO -^^^^ +Error when encoding an empty image +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -TODO +Attempting to encode an image with zero width or height would previously raise +a :py:exc:`SystemError`. That has now been changed to a :py:exc:`ValueError`. + +This does not add any new errors. SGI, ICNS and ICO formats are still able to +save (0, 0) images. API additions ============= From 3121c77cad919703c2d5c77116a03422ba744d7f Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Sat, 28 Mar 2026 19:19:48 +1100 Subject: [PATCH 029/190] Added release notes for #9456 --- docs/releasenotes/12.2.0.rst | 23 ++++------------------- 1 file changed, 4 insertions(+), 19 deletions(-) diff --git a/docs/releasenotes/12.2.0.rst b/docs/releasenotes/12.2.0.rst index 0bbb9b824..66526592a 100644 --- a/docs/releasenotes/12.2.0.rst +++ b/docs/releasenotes/12.2.0.rst @@ -14,22 +14,6 @@ TODO TODO -Backwards incompatible changes -============================== - -TODO -^^^^ - -TODO - -Deprecations -============ - -TODO -^^^^ - -TODO - API changes =========== @@ -61,7 +45,8 @@ FontFile.to_imagefont() Other changes ============= -TODO -^^^^ +Support reading JPEG2000 images with CMYK palettes +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -TODO +JPEG2000 images with CMYK palettes can now be read. This is the first integration of +CMYK palettes into Pillow. From 7ef54f6bfd3b8c3666781ea2d27e20509201097f Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Sun, 29 Mar 2026 19:40:16 +1100 Subject: [PATCH 030/190] Image will never be None Co-authored-by: jorenham --- Tests/test_pyarrow.py | 2 -- src/PIL/ImageFont.py | 2 +- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/Tests/test_pyarrow.py b/Tests/test_pyarrow.py index 7a161f2ac..f282f2c00 100644 --- a/Tests/test_pyarrow.py +++ b/Tests/test_pyarrow.py @@ -112,8 +112,6 @@ def test_to_array(mode: str, dtype: pyarrow.DataType, mask: list[int] | None) -> reloaded = Image.fromarrow(arr, mode, img.size) - assert reloaded - assert_image_equal(img, reloaded) diff --git a/src/PIL/ImageFont.py b/src/PIL/ImageFont.py index ec7c7cb08..06ea0359c 100644 --- a/src/PIL/ImageFont.py +++ b/src/PIL/ImageFont.py @@ -110,7 +110,7 @@ class ImageFont: except Exception: pass else: - if image and image.mode in ("1", "L"): + if image.mode in ("1", "L"): break else: if image: From 7c121637c9b0062694069f8f6d173e17dba8c46f Mon Sep 17 00:00:00 2001 From: Jeffrey 'Alex' Clark Date: Sun, 29 Mar 2026 10:05:18 -0400 Subject: [PATCH 031/190] Jeffrey A. Clark -> Jeffrey 'Alex' Clark Follow up to 4197263dff19a79f13cd86f6cdc9a0ec6c06da92. People cannot figure out my preferred name, hence this final (I hope!) update to my name in Pillow. --- LICENSE | 2 +- README.md | 2 +- docs/COPYING | 2 +- docs/conf.py | 4 ++-- docs/index.rst | 2 +- pyproject.toml | 2 +- src/PIL/__init__.py | 2 +- 7 files changed, 8 insertions(+), 8 deletions(-) diff --git a/LICENSE b/LICENSE index 10dd42d9e..c011511a4 100644 --- a/LICENSE +++ b/LICENSE @@ -5,7 +5,7 @@ The Python Imaging Library (PIL) is Pillow is the friendly PIL fork. It is - Copyright © 2010 by Jeffrey A. Clark and contributors + Copyright © 2010 by Jeffrey 'Alex' Clark and contributors Like PIL, Pillow is licensed under the open source MIT-CMU License: diff --git a/README.md b/README.md index 8585ef6cb..04c9ae8ab 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ ## Python Imaging Library (Fork) -Pillow is the friendly PIL fork by [Jeffrey A. Clark and +Pillow is the friendly PIL fork by [Jeffrey 'Alex' Clark and contributors](https://github.com/python-pillow/Pillow/graphs/contributors). PIL is the Python Imaging Library by Fredrik Lundh and contributors. As of 2019, Pillow development is diff --git a/docs/COPYING b/docs/COPYING index 17fba5b87..1852f9e47 100644 --- a/docs/COPYING +++ b/docs/COPYING @@ -5,7 +5,7 @@ The Python Imaging Library (PIL) is Pillow is the friendly PIL fork. It is - Copyright © 2010 by Jeffrey A. Clark and contributors + Copyright © 2010 by Jeffrey 'Alex' Clark and contributors Like PIL, Pillow is licensed under the open source PIL Software License: diff --git a/docs/conf.py b/docs/conf.py index 040301433..189758944 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -55,9 +55,9 @@ master_doc = "index" project = "Pillow (PIL Fork)" copyright = ( "1995-2011 Fredrik Lundh and contributors, " - "2010 Jeffrey A. Clark and contributors." + "2010 Jeffrey 'Alex' Clark and contributors." ) -author = "Fredrik Lundh (PIL), Jeffrey A. Clark (Pillow)" +author = "Fredrik Lundh (PIL), Jeffrey 'Alex' Clark (Pillow)" # The version info for the project you're documenting, acts as replacement for # |version| and |release|, also used in various other places throughout the diff --git a/docs/index.rst b/docs/index.rst index ee51621ac..8612f77a5 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -1,7 +1,7 @@ Pillow ====== -Pillow is the friendly PIL fork by `Jeffrey A. Clark and contributors `_. PIL is the Python Imaging Library by Fredrik Lundh and contributors. +Pillow is the friendly PIL fork by `Jeffrey 'Alex' Clark and contributors `_. PIL is the Python Imaging Library by Fredrik Lundh and contributors. Pillow for enterprise is available via the Tidelift Subscription. `Learn more `_. diff --git a/pyproject.toml b/pyproject.toml index 7eb9a3fbd..65e3b7659 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,7 +18,7 @@ keywords = [ license = "MIT-CMU" license-files = [ "LICENSE" ] authors = [ - { name = "Jeffrey A. Clark", email = "aclark@aclark.net" }, + { name = "Jeffrey 'Alex' Clark", email = "aclark@aclark.net" }, ] requires-python = ">=3.10" classifiers = [ diff --git a/src/PIL/__init__.py b/src/PIL/__init__.py index 6e4c23f89..faf3e76e0 100644 --- a/src/PIL/__init__.py +++ b/src/PIL/__init__.py @@ -1,6 +1,6 @@ """Pillow (Fork of the Python Imaging Library) -Pillow is the friendly PIL fork by Jeffrey A. Clark and contributors. +Pillow is the friendly PIL fork by Jeffrey 'Alex' Clark and contributors. https://github.com/python-pillow/Pillow/ Pillow is forked from PIL 1.1.7. From 07c180b21e9de56b9cec064306c149581fe2ca50 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Mon, 30 Mar 2026 19:40:04 +1100 Subject: [PATCH 032/190] Simplify SAMPLEFORMAT when all values match for values other than 1 --- src/PIL/TiffImagePlugin.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/PIL/TiffImagePlugin.py b/src/PIL/TiffImagePlugin.py index 3eec94dca..f11b6ce97 100644 --- a/src/PIL/TiffImagePlugin.py +++ b/src/PIL/TiffImagePlugin.py @@ -1473,13 +1473,13 @@ class TiffImageFile(ImageFile.ImageFile): logger.debug("- size: %s", self.size) sample_format = self.tag_v2.get(SAMPLEFORMAT, (1,)) - if len(sample_format) > 1 and max(sample_format) == min(sample_format) == 1: + if len(sample_format) > 1 and max(sample_format) == min(sample_format): # SAMPLEFORMAT is properly per band, so an RGB image will # be (1,1,1). But, we don't support per band pixel types, # and anything more than one band is a uint8. So, just # take the first element. Revisit this if adding support # for more exotic images. - sample_format = (1,) + sample_format = (sample_format[0],) bps_tuple = self.tag_v2.get(BITSPERSAMPLE, (1,)) extra_tuple = self.tag_v2.get(EXTRASAMPLES, ()) From 84cb30d7a7388ff984dcf284e32e7c4fb2abb44b Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Mon, 30 Mar 2026 19:42:07 +1100 Subject: [PATCH 033/190] For separate planar configuration, ignore unspecified extra components --- Tests/images/separate_planar_extra_samples.tiff | Bin 0 -> 202 bytes Tests/test_file_libtiff.py | 4 ++++ src/PIL/TiffImagePlugin.py | 14 ++++++++++---- 3 files changed, 14 insertions(+), 4 deletions(-) create mode 100644 Tests/images/separate_planar_extra_samples.tiff diff --git a/Tests/images/separate_planar_extra_samples.tiff b/Tests/images/separate_planar_extra_samples.tiff new file mode 100644 index 0000000000000000000000000000000000000000..be51a7570ee92e579e41d178e6646c645eed56e1 GIT binary patch literal 202 zcmebD)MC(KU|^`2^Y-*YMg|5RCWZg?Rl5&lHTea_Ty9E~Xak~+Qv5qN-Hqm9U|?is z04icI0%AraHWQG|1Qg={LT0Eq2awMOWrOqxGO~cx90IaMq2eGtVo)~7OmQF^B&Gty XGDvEIplqNpLoieg6Idn4P6z-1gKZPS literal 0 HcmV?d00001 diff --git a/Tests/test_file_libtiff.py b/Tests/test_file_libtiff.py index 6f20900e4..ea0550a5f 100644 --- a/Tests/test_file_libtiff.py +++ b/Tests/test_file_libtiff.py @@ -1055,6 +1055,10 @@ class TestFileLibTiff(LibTiffTestCase): with Image.open("Tests/images/tiff_strip_planar_16bit_RGBa.tiff") as im: assert_image_equal_tofile(im, "Tests/images/tiff_16bit_RGBa_target.png") + def test_separate_planar_extra_samples(self) -> None: + with Image.open("Tests/images/separate_planar_extra_samples.tiff") as im: + assert im.mode == "L" + @pytest.mark.parametrize("compression", (None, "jpeg")) def test_block_tile_tags(self, compression: str | None, tmp_path: Path) -> None: im = hopper() diff --git a/src/PIL/TiffImagePlugin.py b/src/PIL/TiffImagePlugin.py index f11b6ce97..669dc8a3e 100644 --- a/src/PIL/TiffImagePlugin.py +++ b/src/PIL/TiffImagePlugin.py @@ -1483,18 +1483,24 @@ class TiffImageFile(ImageFile.ImageFile): bps_tuple = self.tag_v2.get(BITSPERSAMPLE, (1,)) extra_tuple = self.tag_v2.get(EXTRASAMPLES, ()) + samples_per_pixel = self.tag_v2.get( + SAMPLESPERPIXEL, + 3 if self._compression == "tiff_jpeg" and photo in (2, 6) else 1, + ) if photo in (2, 6, 8): # RGB, YCbCr, LAB bps_count = 3 elif photo == 5: # CMYK bps_count = 4 else: bps_count = 1 + if self._planar_configuration == 2 and extra_tuple and max(extra_tuple) == 0: + # If components are stored separately, + # then unspecified extra components at the end can be ignored + bps_tuple = bps_tuple[: -len(extra_tuple)] + samples_per_pixel -= len(extra_tuple) + extra_tuple = () bps_count += len(extra_tuple) bps_actual_count = len(bps_tuple) - samples_per_pixel = self.tag_v2.get( - SAMPLESPERPIXEL, - 3 if self._compression == "tiff_jpeg" and photo in (2, 6) else 1, - ) if samples_per_pixel > MAX_SAMPLESPERPIXEL: # DOS check, samples_per_pixel can be a Long, and we extend the tuple below From 007974d35b197030ee34ee8d959361263125ab6d Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Mon, 30 Mar 2026 20:04:39 +1100 Subject: [PATCH 034/190] Ignore EXTRASAMPLES tag from separate planes image when saving --- Tests/test_file_libtiff.py | 7 ++++++- src/PIL/TiffImagePlugin.py | 6 ++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/Tests/test_file_libtiff.py b/Tests/test_file_libtiff.py index ea0550a5f..ca3c055f9 100644 --- a/Tests/test_file_libtiff.py +++ b/Tests/test_file_libtiff.py @@ -1055,10 +1055,15 @@ class TestFileLibTiff(LibTiffTestCase): with Image.open("Tests/images/tiff_strip_planar_16bit_RGBa.tiff") as im: assert_image_equal_tofile(im, "Tests/images/tiff_16bit_RGBa_target.png") - def test_separate_planar_extra_samples(self) -> None: + def test_separate_planar_extra_samples(self, tmp_path: Path) -> None: + out = tmp_path / "temp.tif" with Image.open("Tests/images/separate_planar_extra_samples.tiff") as im: assert im.mode == "L" + im.save(out) + with Image.open(out) as reloaded: + assert reloaded.mode == "L" + @pytest.mark.parametrize("compression", (None, "jpeg")) def test_block_tile_tags(self, compression: str | None, tmp_path: Path) -> None: im = hopper() diff --git a/src/PIL/TiffImagePlugin.py b/src/PIL/TiffImagePlugin.py index 669dc8a3e..5094faa13 100644 --- a/src/PIL/TiffImagePlugin.py +++ b/src/PIL/TiffImagePlugin.py @@ -1768,6 +1768,12 @@ def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: legacy_ifd = im.tag.to_v2() supplied_tags = {**legacy_ifd, **getattr(im, "tag_v2", {})} + if supplied_tags.get(PLANAR_CONFIGURATION) == 2 and EXTRASAMPLES in supplied_tags: + # If the image used separate component planes, + # then EXTRASAMPLES should be ignored when saving contiguously + if SAMPLESPERPIXEL in supplied_tags: + supplied_tags[SAMPLESPERPIXEL] -= len(supplied_tags[EXTRASAMPLES]) + del supplied_tags[EXTRASAMPLES] for tag in ( # IFD offset that may not be correct in the saved image EXIFIFD, From a03b7b52f9858f438a7ca86b4e4307a72c7d95ac Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Mon, 30 Mar 2026 22:57:51 +1100 Subject: [PATCH 035/190] Updated Python versions --- docs/installation/platform-support.rst | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/installation/platform-support.rst b/docs/installation/platform-support.rst index 74c63fb06..7e6ad1e77 100644 --- a/docs/installation/platform-support.rst +++ b/docs/installation/platform-support.rst @@ -23,7 +23,7 @@ These platforms are built and tested for every change. +----------------------------------+----------------------------+---------------------+ | Amazon Linux 2023 | 3.11 | x86-64 | +----------------------------------+----------------------------+---------------------+ -| Arch | 3.13 | x86-64 | +| Arch | 3.14 | x86-64 | +----------------------------------+----------------------------+---------------------+ | CentOS Stream 9 | 3.10 | x86-64 | +----------------------------------+----------------------------+---------------------+ @@ -37,7 +37,7 @@ These platforms are built and tested for every change. +----------------------------------+----------------------------+---------------------+ | Fedora 43 | 3.14 | x86-64 | +----------------------------------+----------------------------+---------------------+ -| Gentoo | 3.12 | x86-64 | +| Gentoo | 3.13 | x86-64 | +----------------------------------+----------------------------+---------------------+ | macOS 15 Sequoia | 3.11, 3.12, 3.13, 3.14, | arm64 | | | 3.15, PyPy3 | | @@ -57,7 +57,7 @@ These platforms are built and tested for every change. | Windows Server 2025 | 3.11, 3.12, 3.13, 3.14, | x86-64 | | | 3.15, PyPy3 | | | +----------------------------+---------------------+ -| | 3.13 (MinGW) | x86-64 | +| | 3.14 (MinGW) | x86-64 | +----------------------------------+----------------------------+---------------------+ From f80de2152ccf84f430b2072362202ab0249b8c5f Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade <1324225+hugovk@users.noreply.github.com> Date: Fri, 27 Mar 2026 19:45:15 +0200 Subject: [PATCH 036/190] Run tests in parallel via tox --- tox.ini | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/tox.ini b/tox.ini index de18946ef..aede5fcdc 100644 --- a/tox.ini +++ b/tox.ini @@ -9,11 +9,16 @@ env_list = [testenv] deps = numpy + pytest-sugar extras = tests commands = {envpython} selftest.py - {envpython} -m pytest -W always {posargs} + {envpython} -m pytest \ + --dist worksteal \ + --numprocesses auto \ + -W always \ + {posargs} [testenv:lint] skip_install = true From 73e1ed91e3556a1dad7eb81b43fe14b66c73f009 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Sat, 28 Jun 2025 00:34:30 +1000 Subject: [PATCH 037/190] For DXT1, only check if 8 bytes are left --- src/libImaging/BcnEncode.c | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/libImaging/BcnEncode.c b/src/libImaging/BcnEncode.c index 973a7a2fa..c6989dc1c 100644 --- a/src/libImaging/BcnEncode.c +++ b/src/libImaging/BcnEncode.c @@ -257,9 +257,9 @@ ImagingBcnEncode(Imaging im, ImagingCodecState state, UINT8 *buf, int bytes) { UINT8 *dst = buf; + int will_write = (n == 2 || n == 3 || n == 5) ? 16 : 8; for (;;) { - // Loop writes a max of 16 bytes per iteration - if (dst + 16 >= bytes + buf) { + if (dst + will_write >= bytes + buf) { break; } if (n == 5) { From 228a85e56eacbd4b16184f2fbdaa162a23aeae42 Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade <1324225+hugovk@users.noreply.github.com> Date: Tue, 31 Mar 2026 11:22:11 +0300 Subject: [PATCH 038/190] Safer test_file_spider teardown under pytest-xdist --- Tests/test_file_spider.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Tests/test_file_spider.py b/Tests/test_file_spider.py index 3b1953aac..df385208f 100644 --- a/Tests/test_file_spider.py +++ b/Tests/test_file_spider.py @@ -15,7 +15,7 @@ TEST_FILE = "Tests/images/hopper.spider" def teardown_module() -> None: - del Image.EXTENSION[".spider"] + Image.EXTENSION.pop(".spider", None) def test_sanity() -> None: From 09c585dc2195e339566f488bc47000f038a6b5f1 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Tue, 31 Mar 2026 22:02:23 +1100 Subject: [PATCH 039/190] Cleanup .spider extension in the same test where it is added --- Tests/test_file_spider.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/Tests/test_file_spider.py b/Tests/test_file_spider.py index df385208f..71fb434cc 100644 --- a/Tests/test_file_spider.py +++ b/Tests/test_file_spider.py @@ -14,10 +14,6 @@ from .helper import assert_image_equal, hopper, is_pypy TEST_FILE = "Tests/images/hopper.spider" -def teardown_module() -> None: - Image.EXTENSION.pop(".spider", None) - - def test_sanity() -> None: with Image.open(TEST_FILE) as im: im.load() @@ -67,6 +63,8 @@ def test_save(tmp_path: Path) -> None: assert im2.size == (128, 128) assert im2.format == "SPIDER" + del Image.EXTENSION[".spider"] + @pytest.mark.parametrize("size", ((0, 1), (1, 0), (0, 0))) def test_save_zero(size: tuple[int, int]) -> None: From 4bada07dc6c24319edd1eb76f1dd28d968d58207 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Wed, 18 Feb 2026 22:24:03 +1100 Subject: [PATCH 040/190] Avoid overflow by not adding extents together --- Tests/images/psd-oob-write-overflow.psd | Bin 0 -> 496 bytes Tests/test_file_psd.py | 2 ++ src/decode.c | 13 ++++++------- src/encode.c | 12 +++++------- 4 files changed, 13 insertions(+), 14 deletions(-) create mode 100644 Tests/images/psd-oob-write-overflow.psd diff --git a/Tests/images/psd-oob-write-overflow.psd b/Tests/images/psd-oob-write-overflow.psd new file mode 100644 index 0000000000000000000000000000000000000000..c2bb10d614ed8a2130a28338f474b74f6e67d486 GIT binary patch literal 496 zcmcC;3J7LkWPkt=%>~9Ba4{g4F$IVd7?>c6z$8Q!L|>YPlc#T9eo^j!gaWWk1BA~7 bHETJhx&}G`21d@^a4>k8z_6l2U^D;#tMlDs literal 0 HcmV?d00001 diff --git a/Tests/test_file_psd.py b/Tests/test_file_psd.py index 3b145b139..9964a68e1 100644 --- a/Tests/test_file_psd.py +++ b/Tests/test_file_psd.py @@ -195,11 +195,13 @@ def test_layer_crashes(test_file: str) -> None: "Tests/images/psd-oob-write.psd", "Tests/images/psd-oob-write-x.psd", "Tests/images/psd-oob-write-y.psd", + "Tests/images/psd-oob-write-overflow.psd", ], ) def test_bounds_crash(test_file: str) -> None: with Image.open(test_file) as im: assert isinstance(im, PsdImagePlugin.PsdImageFile) + im.load() im.seek(im.n_frames) with pytest.raises(ValueError): diff --git a/src/decode.c b/src/decode.c index cda4ce702..2268b3533 100644 --- a/src/decode.c +++ b/src/decode.c @@ -171,6 +171,12 @@ _setimage(ImagingDecoderObject *decoder, PyObject *args) { return NULL; } + if (x0 < 0 || y0 < 0 || x1 <= x0 || y1 <= y0 || x1 > (int)im->xsize || + y1 > (int)im->ysize) { + PyErr_SetString(PyExc_ValueError, "tile cannot extend outside image"); + return NULL; + } + decoder->im = im; state = &decoder->state; @@ -181,13 +187,6 @@ _setimage(ImagingDecoderObject *decoder, PyObject *args) { state->xsize = x1 - x0; state->ysize = y1 - y0; - if (state->xoff < 0 || state->xsize <= 0 || - state->xsize + state->xoff > (int)im->xsize || state->yoff < 0 || - state->ysize <= 0 || state->ysize + state->yoff > (int)im->ysize) { - PyErr_SetString(PyExc_ValueError, "tile cannot extend outside image"); - return NULL; - } - /* Allocate memory buffer (if bits field is set) */ if (state->bits > 0) { if (!state->bytes) { diff --git a/src/encode.c b/src/encode.c index 1fc31404d..02356d564 100644 --- a/src/encode.c +++ b/src/encode.c @@ -244,6 +244,11 @@ _setimage(ImagingEncoderObject *encoder, PyObject *args) { return NULL; } + if (x0 < 0 || y0 < 0 || x1 <= x0 || y1 <= y0 || x1 > im->xsize || y1 > im->ysize) { + PyErr_SetString(PyExc_SystemError, "tile cannot extend outside image"); + return NULL; + } + encoder->im = im; state = &encoder->state; @@ -253,13 +258,6 @@ _setimage(ImagingEncoderObject *encoder, PyObject *args) { state->xsize = x1 - x0; state->ysize = y1 - y0; - if (state->xoff < 0 || state->xsize <= 0 || - state->xsize + state->xoff > im->xsize || state->yoff < 0 || - state->ysize <= 0 || state->ysize + state->yoff > im->ysize) { - PyErr_SetString(PyExc_SystemError, "tile cannot extend outside image"); - return NULL; - } - /* Allocate memory buffer (if bits field is set) */ if (state->bits > 0) { if (state->xsize > ((INT_MAX / state->bits) - 7)) { From 591ce38ca56fa7516df4f4ee0525730dee049144 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Wed, 18 Feb 2026 23:24:05 +1100 Subject: [PATCH 041/190] Skip OverflowError on Windows Python 3.10 --- Tests/test_file_psd.py | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/Tests/test_file_psd.py b/Tests/test_file_psd.py index 9964a68e1..a5223cace 100644 --- a/Tests/test_file_psd.py +++ b/Tests/test_file_psd.py @@ -1,12 +1,19 @@ from __future__ import annotations +import sys import warnings import pytest from PIL import Image, PsdImagePlugin -from .helper import assert_image_equal_tofile, assert_image_similar, hopper, is_pypy +from .helper import ( + assert_image_equal_tofile, + assert_image_similar, + hopper, + is_pypy, + is_win32, +) test_file = "Tests/images/hopper.psd" @@ -195,11 +202,23 @@ def test_layer_crashes(test_file: str) -> None: "Tests/images/psd-oob-write.psd", "Tests/images/psd-oob-write-x.psd", "Tests/images/psd-oob-write-y.psd", - "Tests/images/psd-oob-write-overflow.psd", ], ) def test_bounds_crash(test_file: str) -> None: with Image.open(test_file) as im: + assert isinstance(im, PsdImagePlugin.PsdImageFile) + im.seek(im.n_frames) + + with pytest.raises(ValueError): + im.load() + + +@pytest.mark.skipif( + is_win32() and sys.version_info < (3, 11), + reason="OverflowError on Windows Python 3.10", +) +def test_bounds_crash_overflow() -> None: + with Image.open("Tests/images/psd-oob-write-overflow.psd") as im: assert isinstance(im, PsdImagePlugin.PsdImageFile) im.load() im.seek(im.n_frames) From b2a16f0dbe80d4add20294b3dca0618cfe2c1660 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Sat, 28 Mar 2026 23:45:57 +1100 Subject: [PATCH 042/190] Copy offset check from C into Python --- Tests/test_imagefile.py | 49 +++++++++++++++++++++++++++++++++++++++-- src/PIL/ImageFile.py | 11 ++++----- 2 files changed, 51 insertions(+), 9 deletions(-) diff --git a/Tests/test_imagefile.py b/Tests/test_imagefile.py index 6656ee506..2dcebc4b1 100644 --- a/Tests/test_imagefile.py +++ b/Tests/test_imagefile.py @@ -308,7 +308,20 @@ class TestPyDecoder(CodecsTest): assert MockPyDecoder.last.state.xsize == 200 assert MockPyDecoder.last.state.ysize == 200 - def test_negsize(self) -> None: + def test_negative_offset(self) -> None: + buf = BytesIO(b"\x00" * 255) + + im = MockImageFile(buf) + im.tile = [ImageFile._Tile("MOCK", (-10, yoff, xsize, ysize), 32, None)] + + with pytest.raises(ValueError): + im.load() + + im.tile = [ImageFile._Tile("MOCK", (xoff, -10, xsize, ysize), 32, None)] + with pytest.raises(ValueError): + im.load() + + def test_negative_size(self) -> None: buf = BytesIO(b"\x00" * 255) im = MockImageFile(buf) @@ -386,7 +399,39 @@ class TestPyEncoder(CodecsTest): assert MockPyEncoder.last.state.xsize == 200 assert MockPyEncoder.last.state.ysize == 200 - def test_negsize(self) -> None: + def test_negative_offset(self) -> None: + buf = BytesIO(b"\x00" * 255) + + im = MockImageFile(buf) + + fp = BytesIO() + MockPyEncoder.last = None + with pytest.raises(ValueError): + ImageFile._save( + im, + fp, + [ + ImageFile._Tile( + "MOCK", (-10, yoff, xoff + xsize, yoff + ysize), 0, "RGB" + ) + ], + ) + last: MockPyEncoder | None = MockPyEncoder.last + assert last + assert last.cleanup_called + + with pytest.raises(ValueError): + ImageFile._save( + im, + fp, + [ + ImageFile._Tile( + "MOCK", (xoff, -10, xoff + xsize, yoff + ysize), 0, "RGB" + ) + ], + ) + + def test_negative_size(self) -> None: buf = BytesIO(b"\x00" * 255) im = MockImageFile(buf) diff --git a/src/PIL/ImageFile.py b/src/PIL/ImageFile.py index b79f23f0d..dd1116ab9 100644 --- a/src/PIL/ImageFile.py +++ b/src/PIL/ImageFile.py @@ -805,6 +805,10 @@ class PyCodec: if extents: x0, y0, x1, y1 = extents + + if x0 < 0 or y0 < 0 or x1 > self.im.size[0] or y1 > self.im.size[1]: + msg = "Tile cannot extend outside image" + raise ValueError(msg) else: x0, y0, x1, y1 = (0, 0, 0, 0) @@ -820,13 +824,6 @@ class PyCodec: msg = "Size must be positive" raise ValueError(msg) - if ( - self.state.xsize + self.state.xoff > self.im.size[0] - or self.state.ysize + self.state.yoff > self.im.size[1] - ): - msg = "Tile cannot extend outside image" - raise ValueError(msg) - class PyDecoder(PyCodec): """ From cc22efda7a296c3e6ca9b40a4a4eb4d1af1741ac Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Sat, 28 Mar 2026 23:54:09 +1100 Subject: [PATCH 043/190] Parametrize tests --- Tests/test_imagefile.py | 178 +++++++++++----------------------------- 1 file changed, 47 insertions(+), 131 deletions(-) diff --git a/Tests/test_imagefile.py b/Tests/test_imagefile.py index 2dcebc4b1..5f4ed2eb0 100644 --- a/Tests/test_imagefile.py +++ b/Tests/test_imagefile.py @@ -295,6 +295,26 @@ class TestPyDecoder(CodecsTest): with pytest.raises(ValueError): MockPyDecoder.last.set_as_raw(b"\x00") + @pytest.mark.parametrize( + "extents", + ( + (-10, yoff, xoff + xsize, yoff + ysize), + (xoff, -10, xoff + xsize, yoff + ysize), + (xoff, yoff, -10, yoff + ysize), + (xoff, yoff, xoff + xsize, -10), + (xoff, yoff, xoff + xsize + 100, yoff + ysize), + (xoff, yoff, xoff + xsize, yoff + ysize + 100), + ), + ) + def test_extents(self, extents: tuple[int, int, int, int]) -> None: + buf = BytesIO(b"\x00" * 255) + + im = MockImageFile(buf) + im.tile = [ImageFile._Tile("MOCK", extents, 32, None)] + + with pytest.raises(ValueError): + im.load() + def test_extents_none(self) -> None: buf = BytesIO(b"\x00" * 255) @@ -308,53 +328,6 @@ class TestPyDecoder(CodecsTest): assert MockPyDecoder.last.state.xsize == 200 assert MockPyDecoder.last.state.ysize == 200 - def test_negative_offset(self) -> None: - buf = BytesIO(b"\x00" * 255) - - im = MockImageFile(buf) - im.tile = [ImageFile._Tile("MOCK", (-10, yoff, xsize, ysize), 32, None)] - - with pytest.raises(ValueError): - im.load() - - im.tile = [ImageFile._Tile("MOCK", (xoff, -10, xsize, ysize), 32, None)] - with pytest.raises(ValueError): - im.load() - - def test_negative_size(self) -> None: - buf = BytesIO(b"\x00" * 255) - - im = MockImageFile(buf) - im.tile = [ImageFile._Tile("MOCK", (xoff, yoff, -10, yoff + ysize), 32, None)] - - with pytest.raises(ValueError): - im.load() - - im.tile = [ImageFile._Tile("MOCK", (xoff, yoff, xoff + xsize, -10), 32, None)] - with pytest.raises(ValueError): - im.load() - - def test_oversize(self) -> None: - buf = BytesIO(b"\x00" * 255) - - im = MockImageFile(buf) - im.tile = [ - ImageFile._Tile( - "MOCK", (xoff, yoff, xoff + xsize + 100, yoff + ysize), 32, None - ) - ] - - with pytest.raises(ValueError): - im.load() - - im.tile = [ - ImageFile._Tile( - "MOCK", (xoff, yoff, xoff + xsize, yoff + ysize + 100), 32, None - ) - ] - with pytest.raises(ValueError): - im.load() - def test_decode(self) -> None: decoder = ImageFile.PyDecoder("") with pytest.raises(NotImplementedError): @@ -384,6 +357,33 @@ class TestPyEncoder(CodecsTest): assert MockPyEncoder.last.state.xsize == xsize assert MockPyEncoder.last.state.ysize == ysize + @pytest.mark.parametrize( + "extents", + ( + (-10, yoff, xoff + xsize, yoff + ysize), + (xoff, -10, xoff + xsize, yoff + ysize), + (xoff, yoff, -10, yoff + ysize), + (xoff, yoff, xoff + xsize, -10), + (xoff, yoff, xoff + xsize + 100, yoff + ysize), + (xoff, yoff, xoff + xsize, yoff + ysize + 100), + ), + ) + def test_extents(self, extents: tuple[int, int, int, int]) -> None: + buf = BytesIO(b"\x00" * 255) + + im = MockImageFile(buf) + + fp = BytesIO() + MockPyEncoder.last = None + with pytest.raises(ValueError): + ImageFile._save(im, fp, [ImageFile._Tile("MOCK", extents, 0, "RGB")]) + last: MockPyEncoder | None = MockPyEncoder.last + assert last + assert last.cleanup_called + + with pytest.raises(ValueError): + ImageFile._save(im, fp, [ImageFile._Tile("MOCK", extents, 0, "RGB")]) + def test_extents_none(self) -> None: buf = BytesIO(b"\x00" * 255) @@ -399,90 +399,6 @@ class TestPyEncoder(CodecsTest): assert MockPyEncoder.last.state.xsize == 200 assert MockPyEncoder.last.state.ysize == 200 - def test_negative_offset(self) -> None: - buf = BytesIO(b"\x00" * 255) - - im = MockImageFile(buf) - - fp = BytesIO() - MockPyEncoder.last = None - with pytest.raises(ValueError): - ImageFile._save( - im, - fp, - [ - ImageFile._Tile( - "MOCK", (-10, yoff, xoff + xsize, yoff + ysize), 0, "RGB" - ) - ], - ) - last: MockPyEncoder | None = MockPyEncoder.last - assert last - assert last.cleanup_called - - with pytest.raises(ValueError): - ImageFile._save( - im, - fp, - [ - ImageFile._Tile( - "MOCK", (xoff, -10, xoff + xsize, yoff + ysize), 0, "RGB" - ) - ], - ) - - def test_negative_size(self) -> None: - buf = BytesIO(b"\x00" * 255) - - im = MockImageFile(buf) - - fp = BytesIO() - MockPyEncoder.last = None - with pytest.raises(ValueError): - ImageFile._save( - im, - fp, - [ImageFile._Tile("MOCK", (xoff, yoff, -10, yoff + ysize), 0, "RGB")], - ) - last: MockPyEncoder | None = MockPyEncoder.last - assert last - assert last.cleanup_called - - with pytest.raises(ValueError): - ImageFile._save( - im, - fp, - [ImageFile._Tile("MOCK", (xoff, yoff, xoff + xsize, -10), 0, "RGB")], - ) - - def test_oversize(self) -> None: - buf = BytesIO(b"\x00" * 255) - - im = MockImageFile(buf) - - fp = BytesIO() - with pytest.raises(ValueError): - ImageFile._save( - im, - fp, - [ - ImageFile._Tile( - "MOCK", (xoff, yoff, xoff + xsize + 100, yoff + ysize), 0, "RGB" - ) - ], - ) - - with pytest.raises(ValueError): - ImageFile._save( - im, - fp, - [ - ImageFile._Tile( - "MOCK", (xoff, yoff, xoff + xsize, yoff + ysize + 100), 0, "RGB" - ) - ], - ) - def test_encode(self) -> None: encoder = ImageFile.PyEncoder("") with pytest.raises(NotImplementedError): From 2696e962c2b145d7b354c12ac6ef1e4a95558fc9 Mon Sep 17 00:00:00 2001 From: Gareth Davidson Date: Tue, 31 Mar 2026 21:03:12 +0100 Subject: [PATCH 044/190] Add loader plugins: AMOS abk, Atari Degas, 40+ more obscure formats via Netpbm (#9482) Co-authored-by: Hugo van Kemenade <1324225+hugovk@users.noreply.github.com> --- docs/handbook/third-party-plugins.rst | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/handbook/third-party-plugins.rst b/docs/handbook/third-party-plugins.rst index 200866499..51181a596 100644 --- a/docs/handbook/third-party-plugins.rst +++ b/docs/handbook/third-party-plugins.rst @@ -8,12 +8,15 @@ itself. Here is a list of PyPI projects that offer additional plugins: * :pypi:`amigainfo`: Adds support for Amiga Workbench .info icon files. +* :pypi:`amos-abk`: AMOS BASIC sprite and image banks. * :pypi:`DjvuRleImagePlugin`: Plugin for the DjVu RLE image format as defined in the DjVuLibre docs. * :pypi:`heif-image-plugin`: Simple HEIF/HEIC images plugin, based on the pyheif library. * :pypi:`jxlpy`: Introduces reading and writing support for JPEG XL. +* :pypi:`pillow-degas`: Adds reading Atari ST Degas image files. * :pypi:`pillow-heif`: Python bindings to libheif for working with HEIF images. * :pypi:`pillow-jpls`: Plugin for the JPEG-LS codec, based on the Charls JPEG-LS implementation. Python bindings implemented using pybind11. * :pypi:`pillow-jxl-plugin`: Plugin for JPEG-XL, using Rust for bindings. * :pypi:`pillow-mbm`: Adds support for KSP's proprietary MBM texture format. +* :pypi:`pillow-netpbm`: Adds .pam support, and loads images using `Netpbm `__'s converter collection. * :pypi:`pillow-svg`: Implements basic SVG read support. Supports basic paths, shapes, and text. * :pypi:`raw-pillow-opener`: Simple camera raw opener, based on the rawpy library. From 3cb814f33899b4d50eb04c316e66a737751c70a8 Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade <1324225+hugovk@users.noreply.github.com> Date: Tue, 31 Mar 2026 23:15:06 +0300 Subject: [PATCH 045/190] Update 12.2.0 release notes --- docs/releasenotes/12.2.0.rst | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/docs/releasenotes/12.2.0.rst b/docs/releasenotes/12.2.0.rst index 209fa782f..d02d65414 100644 --- a/docs/releasenotes/12.2.0.rst +++ b/docs/releasenotes/12.2.0.rst @@ -67,6 +67,11 @@ or scaling, optionally with a font size limit:: text.wrap(58, 10, "grow") text.wrap(50, 50, ("grow", 12)) +EXIF tag FrameRate +^^^^^^^^^^^^^^^^^^ + +The EXIF tag ``FrameRate`` has been added. + Other changes ============= @@ -75,3 +80,16 @@ Support reading JPEG2000 images with CMYK palettes JPEG2000 images with CMYK palettes can now be read. This is the first integration of CMYK palettes into Pillow. + +Lazy plugin loading +^^^^^^^^^^^^^^^^^^^ + +When opening or saving an image, Pillow now lazily loads only the required plugin +based on the file extension, instead of importing all plugins upfront. This makes +``open`` 2.3-15.6x faster and ``save`` 2.2-9x faster for common formats. + +Thread safety for free-threaded Python +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Critical sections are now used to protect FreeType font objects, improving thread +safety when using fonts in the free-threaded build of Python. From 3cb854e8b2bab43f40e342e665f9340d861aa628 Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade <1324225+hugovk@users.noreply.github.com> Date: Wed, 1 Apr 2026 00:02:08 +0300 Subject: [PATCH 046/190] Only read as much data from gzip-decompressed data as necessary (#9521) --- src/PIL/FitsImagePlugin.py | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/src/PIL/FitsImagePlugin.py b/src/PIL/FitsImagePlugin.py index a3fdc0efe..e91840778 100644 --- a/src/PIL/FitsImagePlugin.py +++ b/src/PIL/FitsImagePlugin.py @@ -128,17 +128,18 @@ class FitsGzipDecoder(ImageFile.PyDecoder): def decode(self, buffer: bytes | Image.SupportsArrayInterface) -> tuple[int, int]: assert self.fd is not None - value = gzip.decompress(self.fd.read()) + with gzip.open(self.fd) as fp: + value = fp.read(self.state.xsize * self.state.ysize * 4) - rows = [] - offset = 0 - number_of_bits = min(self.args[0] // 8, 4) - for y in range(self.state.ysize): - row = bytearray() - for x in range(self.state.xsize): - row += value[offset + (4 - number_of_bits) : offset + 4] - offset += 4 - rows.append(row) + rows = [] + offset = 0 + number_of_bits = min(self.args[0] // 8, 4) + for y in range(self.state.ysize): + row = bytearray() + for x in range(self.state.xsize): + row += value[offset + (4 - number_of_bits) : offset + 4] + offset += 4 + rows.append(row) self.set_as_raw(bytes([pixel for row in rows[::-1] for pixel in row])) return -1, 0 From 3bf614e4b8615d0ce1d5039efaf6db447fe7c468 Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade <1324225+hugovk@users.noreply.github.com> Date: Wed, 1 Apr 2026 00:03:15 +0300 Subject: [PATCH 047/190] Raise an error if the trailer chain loops back on itself (#9519) --- Tests/images/trailer_loop.pdf | Bin 0 -> 1818 bytes Tests/test_pdfparser.py | 5 +++++ src/PIL/PdfParser.py | 10 ++++++++-- 3 files changed, 13 insertions(+), 2 deletions(-) create mode 100644 Tests/images/trailer_loop.pdf diff --git a/Tests/images/trailer_loop.pdf b/Tests/images/trailer_loop.pdf new file mode 100644 index 0000000000000000000000000000000000000000..7bf27ca370ec635039e274ddb65685fa147a2abc GIT binary patch literal 1818 zcmd5*OK;Oa5O!&W%HFuZg~MJVl|#E;zmlLThd2bHQZ=NJKp+l|y-l|`-m>2G!KuF- znDL_tE}&kJn#A$$?9A+Z-#6154~DyZ&m%1wYY`cd(AhN|%QRJ&6wYX(<%Q71qc&Bu zv;BR-rq}0!vM@4Hs^)}^qq)eb59bro>xnD@H-g*W+zT(lLbc2c<%Au`B&VOLgJJZ` zDv&n=KOW7_L~IB@%BBpu4s2n}gj7>=gXVRSVRu}TD7 zz{=G)(hIy7aU9THi0-FR{B@LbYV;DahALeyvK;eH)Fr-qJq+(llaGZC)#6-bqQn5c zN*|v`G0-s(7cv%abaYMFJCV(?G~R*W+yJc$a4t~7d~t2M{DcNYP|(M zkJs!^H@1qnZLmLEvp=uw=>KBP4qJIx!l`0~B-fxu8@2B^xZTliOvgPnj~qmji%+n{4r zT}7hjt~mUL$%{{Y(`pkhC@YH_DEgk<1s<$YPo+r(-TCp;Qr6Nkmh%#7#pahP8^8$A zoxv-|b^_bAeTO@3-}?j}hbsB&;ceevX>meq+9qY4_)i1hcLRDtYa91qn2M9^*5-Ag zzJ@LE@A}yuaNO{-JMBBRwg=4Cenv+!g&9VfCrTNL!#shBlHUcC%0}6VqR3C7X>KBI z3LG7;OnW zadsX8PUnv}E9_1e2KZjsyES%1S4xcVn%I$ None: pdf = PdfParser("Tests/images/duplicate_xref_entry.pdf") assert pdf.xref_table.existing_entries[6][0] == 1197 pdf.close() + + +def test_trailer_loop() -> None: + with pytest.raises(PdfFormatError, match="trailer loop found"): + PdfParser("Tests/images/trailer_loop.pdf") diff --git a/src/PIL/PdfParser.py b/src/PIL/PdfParser.py index 2c9031469..f7f3a4643 100644 --- a/src/PIL/PdfParser.py +++ b/src/PIL/PdfParser.py @@ -685,7 +685,9 @@ class PdfParser: if b"Prev" in self.trailer_dict: self.read_prev_trailer(self.trailer_dict[b"Prev"]) - def read_prev_trailer(self, xref_section_offset: int) -> None: + def read_prev_trailer( + self, xref_section_offset: int, processed_offsets: list[int] = [] + ) -> None: assert self.buf is not None trailer_offset = self.read_xref_table(xref_section_offset=xref_section_offset) m = self.re_trailer_prev.search( @@ -700,7 +702,11 @@ class PdfParser: ) trailer_dict = self.interpret_trailer(trailer_data) if b"Prev" in trailer_dict: - self.read_prev_trailer(trailer_dict[b"Prev"]) + processed_offsets.append(xref_section_offset) + check_format_condition( + trailer_dict[b"Prev"] not in processed_offsets, "trailer loop found" + ) + self.read_prev_trailer(trailer_dict[b"Prev"], processed_offsets) re_whitespace_optional = re.compile(whitespace_optional) re_name = re.compile( From cf4a8ee0b955ee8039624a654d9cab2b75ce179a Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Wed, 1 Apr 2026 08:26:13 +1100 Subject: [PATCH 048/190] Updated xz to 5.8.3 --- .github/workflows/wheels-dependencies.sh | 2 +- winbuild/build_prepare.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/wheels-dependencies.sh b/.github/workflows/wheels-dependencies.sh index 97011f4a0..331eae224 100755 --- a/.github/workflows/wheels-dependencies.sh +++ b/.github/workflows/wheels-dependencies.sh @@ -95,7 +95,7 @@ HARFBUZZ_VERSION=13.2.1 LIBPNG_VERSION=1.6.56 JPEGTURBO_VERSION=3.1.4.1 OPENJPEG_VERSION=2.5.4 -XZ_VERSION=5.8.2 +XZ_VERSION=5.8.3 ZSTD_VERSION=1.5.7 TIFF_VERSION=4.7.1 LCMS2_VERSION=2.18 diff --git a/winbuild/build_prepare.py b/winbuild/build_prepare.py index e2580f482..3b16da58a 100644 --- a/winbuild/build_prepare.py +++ b/winbuild/build_prepare.py @@ -125,7 +125,7 @@ V = { "LIBWEBP": "1.6.0", "OPENJPEG": "2.5.4", "TIFF": "4.7.1", - "XZ": "5.8.2", + "XZ": "5.8.3", "ZLIBNG": "2.3.3", } V["LIBPNG_XY"] = "".join(V["LIBPNG"].split(".")[:2]) From ec8272044d2adfc97a5f4b6e921c1a908318d9cb Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade <1324225+hugovk@users.noreply.github.com> Date: Wed, 1 Apr 2026 00:52:09 +0300 Subject: [PATCH 049/190] Use long for glyph position (#9518) Co-authored-by: Andrew Murray --- src/_imagingft.c | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/_imagingft.c b/src/_imagingft.c index 8e56c57ab..8330439f0 100644 --- a/src/_imagingft.c +++ b/src/_imagingft.c @@ -589,9 +589,9 @@ bounding_box_and_anchors( int *x_offset, int *y_offset ) { - int position; /* pen position along primary axis, in 26.6 precision */ - int advanced; /* pen position along primary axis, in pixels */ - int px, py; /* position of current glyph, in pixels */ + long position; /* pen position along primary axis, in 26.6 precision */ + long advanced; /* pen position along primary axis, in pixels */ + int px, py; /* position of current glyph, in pixels */ int x_min, x_max, y_min, y_max; /* text bounding box, in pixels */ int x_anchor, y_anchor; /* offset of point drawn at (0, 0), in pixels */ int error; From f5e893e46e869a9e275298207c70cf915173a072 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Wed, 1 Apr 2026 09:46:09 +1100 Subject: [PATCH 050/190] Seek raises OverFlowError on 32-bit --- Tests/test_file_psd.py | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/Tests/test_file_psd.py b/Tests/test_file_psd.py index a5223cace..538b1406b 100644 --- a/Tests/test_file_psd.py +++ b/Tests/test_file_psd.py @@ -12,7 +12,6 @@ from .helper import ( assert_image_similar, hopper, is_pypy, - is_win32, ) test_file = "Tests/images/hopper.psd" @@ -213,15 +212,15 @@ def test_bounds_crash(test_file: str) -> None: im.load() -@pytest.mark.skipif( - is_win32() and sys.version_info < (3, 11), - reason="OverflowError on Windows Python 3.10", -) def test_bounds_crash_overflow() -> None: with Image.open("Tests/images/psd-oob-write-overflow.psd") as im: assert isinstance(im, PsdImagePlugin.PsdImageFile) im.load() - im.seek(im.n_frames) + if sys.maxsize <= 2**32: + with pytest.raises(OverflowError): + im.seek(im.n_frames) + else: + im.seek(im.n_frames) - with pytest.raises(ValueError): - im.load() + with pytest.raises(ValueError): + im.load() From 4ef0ac611dd42602365c8b0506f796982c20fac2 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Wed, 1 Apr 2026 10:00:39 +1100 Subject: [PATCH 051/190] Resize tall images vertically first --- src/PIL/Image.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/PIL/Image.py b/src/PIL/Image.py index 6062857da..574980771 100644 --- a/src/PIL/Image.py +++ b/src/PIL/Image.py @@ -2428,7 +2428,14 @@ class Image: (box[3] - reduce_box[1]) / factor_y, ) - return self._new(self.im.resize(size, resample, box)) + if self.size[1] > self.size[0] * 100 and size[1] < self.size[1]: + im = self.im.resize( + (self.size[0], size[1]), resample, (0, box[1], self.size[0], box[3]) + ) + im = im.resize(size, resample, (box[0], 0, box[2], size[1])) + else: + im = self.im.resize(size, resample, box) + return self._new(im) def reduce( self, From 459bdf766fec2e6ed106c650e79d388284ead2eb Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Wed, 1 Apr 2026 10:38:22 +1100 Subject: [PATCH 052/190] Move variable declaration inside define --- src/libImaging/Paste.c | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/libImaging/Paste.c b/src/libImaging/Paste.c index f01bce933..f4b72c5d8 100644 --- a/src/libImaging/Paste.c +++ b/src/libImaging/Paste.c @@ -356,7 +356,7 @@ fill( ) { /* fill opaque region */ - int x, y, i; + int x, y; UINT8 ink8 = 0; INT32 ink32 = 0L; @@ -372,6 +372,7 @@ fill( } else { #if defined _WIN32 && !defined _WIN64 + int i; dx *= pixelsize; for (y = 0; y < ysize; y++) { UINT8 *out = (UINT8 *)imOut->image[y + dy] + dx; From c4f7aa5dfb4dbd1242978ac235e01b9934ec6d3c Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Wed, 1 Apr 2026 16:49:20 +1100 Subject: [PATCH 053/190] Added security release notes --- docs/releasenotes/12.2.0.rst | 31 +++++++++++++++++++++++++------ 1 file changed, 25 insertions(+), 6 deletions(-) diff --git a/docs/releasenotes/12.2.0.rst b/docs/releasenotes/12.2.0.rst index d02d65414..05d5dee25 100644 --- a/docs/releasenotes/12.2.0.rst +++ b/docs/releasenotes/12.2.0.rst @@ -4,15 +4,34 @@ Security ======== -TODO -^^^^ +Prevent FITS decompression bomb +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -TODO +When decompressing GZIP data from a FITS image, Pillow did not limit the amount of data +being read, meaning that it was vulnerable to GZIP decompression bombs. This was +introduced in Pillow 10.3.0. -:cve:`YYYY-XXXXX`: TODO -^^^^^^^^^^^^^^^^^^^^^^^ +The data being read is now limited to only the necessary amount. -TODO +Fix OOB write with invalid tile extents +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Pillow 12.1.1 added improved checks for tile extents to prevent an OOB write from +specially crafted PSD images in Pillow >= 10.3.0. However, these checks did not +consider integer overflow. This has been corrected. + +Prevent PDF parsing trailer infinite loop +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +When parsing a PDF, if a trailer refers to itself, or a more complex cyclic loop +exists, then an infinite loop occurs. Pillow now keeps a record of which trailers it +has already processed. PdfParser was added in Pillow 4.2.0. + +Integer overflow when processing fonts +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +If a font advances for each glyph by an exceeding large amount, when Pillow keeps track +of the current position, it may lead to an integer overflow. This has been fixed. API changes =========== From cf6de8ca9b23e714aa5310e1c791eda66fc0b670 Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade <1324225+hugovk@users.noreply.github.com> Date: Wed, 1 Apr 2026 14:50:45 +0300 Subject: [PATCH 054/190] Reject non-numeric elements inside list coords (#9526) --- Tests/test_imagepath.py | 29 +++++++++++++++++++++++++++++ docs/releasenotes/12.2.0.rst | 10 ++++++++++ src/path.c | 15 ++++++++++++++- 3 files changed, 53 insertions(+), 1 deletion(-) diff --git a/Tests/test_imagepath.py b/Tests/test_imagepath.py index ad8acde49..8d230eb56 100644 --- a/Tests/test_imagepath.py +++ b/Tests/test_imagepath.py @@ -51,6 +51,7 @@ def test_path() -> None: [0.0, 1.0], ((0, 1),), [(0, 1)], + [[0, 1]], ((0.0, 1.0),), [(0.0, 1.0)], array.array("f", [0, 1]), @@ -68,6 +69,34 @@ def test_path_constructors( assert list(p) == [(0.0, 1.0)] +@pytest.mark.parametrize( + "coords, expected", + ( + ([[0, 1], [2, 3]], [(0.0, 1.0), (2.0, 3.0)]), + ([[0.0, 1.0], [2.0, 3.0]], [(0.0, 1.0), (2.0, 3.0)]), + ), +) +def test_path_list_of_lists( + coords: list[list[float]], expected: list[tuple[float, float]] +) -> None: + p = ImagePath.Path(coords) + assert list(p) == expected + + +@pytest.mark.parametrize( + "coords, message", + ( + ([[1, 2, 3]], "coordinate list must contain exactly 2 coordinates"), + ([[1]], "coordinate list must contain exactly 2 coordinates"), + ([[[1, 2], [3, 4]]], "coordinate list must contain numbers"), + ([["a", "b"]], "coordinate list must contain numbers"), + ), +) +def test_invalid_list_coords(coords: list[list[object]], message: str) -> None: + with pytest.raises(ValueError, match=message): + ImagePath.Path(coords) + + def test_invalid_path_constructors() -> None: # Arrange / Act with pytest.raises(ValueError, match="incorrect coordinate type"): diff --git a/docs/releasenotes/12.2.0.rst b/docs/releasenotes/12.2.0.rst index 05d5dee25..b03afb665 100644 --- a/docs/releasenotes/12.2.0.rst +++ b/docs/releasenotes/12.2.0.rst @@ -33,6 +33,16 @@ Integer overflow when processing fonts If a font advances for each glyph by an exceeding large amount, when Pillow keeps track of the current position, it may lead to an integer overflow. This has been fixed. +Heap buffer overflow with nested list coordinates +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Passing nested lists as coordinates to APIs that accept coordinates such as +``ImagePath.Path``, :py:meth:`~PIL.ImageDraw.ImageDraw.polygon` +and :py:meth:`~PIL.ImageDraw.ImageDraw.line` could cause a heap buffer overflow, +as nested lists were recursively unpacked beyond the allocated buffer. +Coordinate lists are now validated to contain exactly two numeric coordinates. +This was introduced in Pillow 11.2.1. + API changes =========== diff --git a/src/path.c b/src/path.c index 38300547c..b88346d5f 100644 --- a/src/path.c +++ b/src/path.c @@ -118,14 +118,27 @@ assign_item_to_array(double *xy, Py_ssize_t j, PyObject *op) { } else if (PyNumber_Check(op)) { xy[j++] = PyFloat_AsDouble(op); } else if (PyList_Check(op)) { + if (PyList_GET_SIZE(op) != 2) { + PyErr_SetString( + PyExc_ValueError, "coordinate list must contain exactly 2 coordinates" + ); + return -1; + } for (int k = 0; k < 2; k++) { PyObject *op1 = PyList_GetItemRef(op, k); if (op1 == NULL) { return -1; } - j = assign_item_to_array(xy, j, op1); + if (PyFloat_Check(op1) || PyLong_Check(op1) || PyNumber_Check(op1)) { + j = assign_item_to_array(xy, j, op1); + } else { + j = -1; + } Py_DECREF(op1); if (j == -1) { + PyErr_SetString( + PyExc_ValueError, "coordinate list must contain numbers" + ); return -1; } } From 585b2f5a780722c8a5bfffb3a40f7f42e8a205be Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Sun, 29 Mar 2026 18:11:36 +1100 Subject: [PATCH 055/190] Check calloc return value --- src/_imaging.c | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/_imaging.c b/src/_imaging.c index 55d29d1bf..980f827ae 100644 --- a/src/_imaging.c +++ b/src/_imaging.c @@ -267,6 +267,9 @@ PyObject * ExportArrowSchemaPyCapsule(ImagingObject *self) { struct ArrowSchema *schema = (struct ArrowSchema *)calloc(1, sizeof(struct ArrowSchema)); + if (!schema) { + return ArrowError(IMAGING_CODEC_MEMORY); + } int err = export_imaging_schema(self->image, schema); if (err == 0) { return PyCapsule_New(schema, "arrow_schema", ReleaseArrowSchemaPyCapsule); From 3c41c095064200a02672d89cc5ff629eaf4b0d4f Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade <1324225+hugovk@users.noreply.github.com> Date: Wed, 1 Apr 2026 15:11:14 +0300 Subject: [PATCH 056/190] 12.2.0 version bump --- src/PIL/_version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/PIL/_version.py b/src/PIL/_version.py index 96363e9f1..72d11ae92 100644 --- a/src/PIL/_version.py +++ b/src/PIL/_version.py @@ -1,4 +1,4 @@ # Master version for Pillow from __future__ import annotations -__version__ = "12.2.0.dev0" +__version__ = "12.2.0" From 7d78ac519bac9062bebbd6ff257332c88f6c944d Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade <1324225+hugovk@users.noreply.github.com> Date: Wed, 1 Apr 2026 17:53:55 +0300 Subject: [PATCH 057/190] 12.3.0.dev0 version bump --- src/PIL/_version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/PIL/_version.py b/src/PIL/_version.py index 72d11ae92..8d005f33e 100644 --- a/src/PIL/_version.py +++ b/src/PIL/_version.py @@ -1,4 +1,4 @@ # Master version for Pillow from __future__ import annotations -__version__ = "12.2.0" +__version__ = "12.3.0.dev0" From 30b3dff0cb40336e406ff498ca821d254d498386 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Mon, 26 Jan 2026 19:19:27 +1100 Subject: [PATCH 058/190] Remove Amazon Linux 2 --- .github/workflows/test-docker.yml | 1 - docs/installation/platform-support.rst | 2 -- 2 files changed, 3 deletions(-) diff --git a/.github/workflows/test-docker.yml b/.github/workflows/test-docker.yml index 08226738e..becabb549 100644 --- a/.github/workflows/test-docker.yml +++ b/.github/workflows/test-docker.yml @@ -43,7 +43,6 @@ jobs: ubuntu-24.04-noble-s390x, # Then run the remainder alpine, - amazon-2-amd64, amazon-2023-amd64, arch, centos-stream-9-amd64, diff --git a/docs/installation/platform-support.rst b/docs/installation/platform-support.rst index 7e6ad1e77..cb7c18a55 100644 --- a/docs/installation/platform-support.rst +++ b/docs/installation/platform-support.rst @@ -19,8 +19,6 @@ These platforms are built and tested for every change. +==================================+============================+=====================+ | Alpine | 3.12 | x86-64 | +----------------------------------+----------------------------+---------------------+ -| Amazon Linux 2 | 3.10 | x86-64 | -+----------------------------------+----------------------------+---------------------+ | Amazon Linux 2023 | 3.11 | x86-64 | +----------------------------------+----------------------------+---------------------+ | Arch | 3.14 | x86-64 | From 4dc9398402bfe66f8b5a54a92c0840611488bd9d Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Mon, 26 Jan 2026 21:43:07 +1100 Subject: [PATCH 059/190] Remove manylinux2014 --- .github/workflows/wheels-dependencies.sh | 6 +----- .github/workflows/wheels.yml | 24 ++++++++++-------------- 2 files changed, 11 insertions(+), 19 deletions(-) diff --git a/.github/workflows/wheels-dependencies.sh b/.github/workflows/wheels-dependencies.sh index 331eae224..7750a2e07 100755 --- a/.github/workflows/wheels-dependencies.sh +++ b/.github/workflows/wheels-dependencies.sh @@ -178,7 +178,6 @@ function build_libavif { build_simple nasm 2.16.03 https://www.nasm.us/pub/nasm/releasebuilds/2.16.03 fi - local build_type=MinSizeRel local build_shared=ON local lto=ON @@ -195,9 +194,6 @@ function build_libavif { build_shared=OFF fi else - if [[ "$MB_ML_VER" == 2014 ]] && [[ "$PLAT" == "x86_64" ]]; then - build_type=Release - fi libavif_cmake_flags=(-DCMAKE_SHARED_LINKER_FLAGS_INIT="-Wl,--strip-all,-z,relro,-z,now") fi if [[ -n "$IOS_SDK" ]] && [[ "$PLAT" == "x86_64" ]]; then @@ -226,7 +222,7 @@ function build_libavif { -DCMAKE_INTERPROCEDURAL_OPTIMIZATION=$lto \ -DCMAKE_C_VISIBILITY_PRESET=hidden \ -DCMAKE_CXX_VISIBILITY_PRESET=hidden \ - -DCMAKE_BUILD_TYPE=$build_type \ + -DCMAKE_BUILD_TYPE=MinSizeRel \ "${libavif_cmake_flags[@]}" \ $HOST_CMAKE_FLAGS . ) diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index af2f9b3e8..6a5daf987 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -39,7 +39,7 @@ concurrency: cancel-in-progress: true env: - EXPECTED_DISTS: 91 + EXPECTED_DISTS: 75 FORCE_COLOR: 1 jobs: @@ -74,26 +74,26 @@ jobs: os: macos-latest cibw_arch: arm64 macosx_deployment_target: "11.0" - - name: "manylinux2014 and musllinux x86_64" - platform: linux - os: ubuntu-latest - cibw_arch: x86_64 - manylinux: "manylinux2014" - name: "manylinux_2_28 x86_64" platform: linux os: ubuntu-latest cibw_arch: x86_64 build: "*manylinux*" - - name: "manylinux2014 and musllinux aarch64" + - name: "musllinux x86_64" platform: linux - os: ubuntu-24.04-arm - cibw_arch: aarch64 - manylinux: "manylinux2014" + os: ubuntu-latest + cibw_arch: x86_64 + build: "*musllinux*" - name: "manylinux_2_28 aarch64" platform: linux os: ubuntu-24.04-arm cibw_arch: aarch64 build: "*manylinux*" + - name: "musllinux aarch64" + platform: linux + os: ubuntu-24.04-arm + cibw_arch: aarch64 + build: "*musllinux*" - name: "iOS arm64 device" platform: ios os: macos-latest @@ -128,10 +128,6 @@ jobs: CIBW_ARCHS: ${{ matrix.cibw_arch }} CIBW_BUILD: ${{ matrix.build }} CIBW_ENABLE: cpython-prerelease cpython-freethreading pypy - CIBW_MANYLINUX_AARCH64_IMAGE: ${{ matrix.manylinux }} - CIBW_MANYLINUX_PYPY_AARCH64_IMAGE: ${{ matrix.manylinux }} - CIBW_MANYLINUX_PYPY_X86_64_IMAGE: ${{ matrix.manylinux }} - CIBW_MANYLINUX_X86_64_IMAGE: ${{ matrix.manylinux }} MACOSX_DEPLOYMENT_TARGET: ${{ matrix.macosx_deployment_target }} - uses: actions/upload-artifact@v6 From abb1d2bf6ebaf459c274f4c957ed8001c92453bc Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade <1324225+hugovk@users.noreply.github.com> Date: Thu, 2 Apr 2026 10:11:35 +0300 Subject: [PATCH 060/190] Remove Debian 12 and Fedora 42 from CI (#9530) --- .github/workflows/test-docker.yml | 3 --- docs/installation/platform-support.rst | 4 ---- 2 files changed, 7 deletions(-) diff --git a/.github/workflows/test-docker.yml b/.github/workflows/test-docker.yml index becabb549..2e9966486 100644 --- a/.github/workflows/test-docker.yml +++ b/.github/workflows/test-docker.yml @@ -47,11 +47,8 @@ jobs: arch, centos-stream-9-amd64, centos-stream-10-amd64, - debian-12-bookworm-x86, - debian-12-bookworm-amd64, debian-13-trixie-x86, debian-13-trixie-amd64, - fedora-42-amd64, fedora-43-amd64, gentoo, ubuntu-22.04-jammy-amd64, diff --git a/docs/installation/platform-support.rst b/docs/installation/platform-support.rst index cb7c18a55..8ccd203dc 100644 --- a/docs/installation/platform-support.rst +++ b/docs/installation/platform-support.rst @@ -27,12 +27,8 @@ These platforms are built and tested for every change. +----------------------------------+----------------------------+---------------------+ | CentOS Stream 10 | 3.12 | x86-64 | +----------------------------------+----------------------------+---------------------+ -| Debian 12 Bookworm | 3.11 | x86, x86-64 | -+----------------------------------+----------------------------+---------------------+ | Debian 13 Trixie | 3.13 | x86, x86-64 | +----------------------------------+----------------------------+---------------------+ -| Fedora 42 | 3.13 | x86-64 | -+----------------------------------+----------------------------+---------------------+ | Fedora 43 | 3.14 | x86-64 | +----------------------------------+----------------------------+---------------------+ | Gentoo | 3.13 | x86-64 | From c39eda63487829ce566153aae7c7c8cdbdcfe3f9 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 3 Apr 2026 15:20:29 +1100 Subject: [PATCH 061/190] Update github-actions (#9533) --- .github/workflows/cifuzz.yml | 4 ++-- .github/workflows/release-drafter.yml | 2 +- .github/workflows/test-docker.yml | 4 ++-- .github/workflows/test-mingw.yml | 2 +- .github/workflows/test-windows.yml | 4 ++-- .github/workflows/test.yml | 4 ++-- .github/workflows/wheels.yml | 8 ++++---- 7 files changed, 14 insertions(+), 14 deletions(-) diff --git a/.github/workflows/cifuzz.yml b/.github/workflows/cifuzz.yml index 3f78c98b6..263700780 100644 --- a/.github/workflows/cifuzz.yml +++ b/.github/workflows/cifuzz.yml @@ -47,13 +47,13 @@ jobs: language: python dry-run: false - name: Upload New Crash - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@v7 if: failure() && steps.build.outcome == 'success' with: name: artifacts path: ./out/artifacts - name: Upload Legacy Crash - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@v7 if: steps.run.outcome == 'success' with: name: crash diff --git a/.github/workflows/release-drafter.yml b/.github/workflows/release-drafter.yml index 12633284f..aa8326b78 100644 --- a/.github/workflows/release-drafter.yml +++ b/.github/workflows/release-drafter.yml @@ -26,6 +26,6 @@ jobs: runs-on: ubuntu-latest steps: # Drafts your next release notes as pull requests are merged into "main" - - uses: release-drafter/release-drafter@v6 + - uses: release-drafter/release-drafter@v7 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/test-docker.yml b/.github/workflows/test-docker.yml index 2e9966486..427cc1fb5 100644 --- a/.github/workflows/test-docker.yml +++ b/.github/workflows/test-docker.yml @@ -76,7 +76,7 @@ jobs: - name: Set up QEMU if: "matrix.qemu-arch" - uses: docker/setup-qemu-action@v3 + uses: docker/setup-qemu-action@v4 with: platforms: ${{ matrix.qemu-arch }} @@ -104,7 +104,7 @@ jobs: .ci/after_success.sh - name: Upload coverage - uses: codecov/codecov-action@v5 + uses: codecov/codecov-action@v6 with: flags: GHA_Docker name: ${{ matrix.docker }} diff --git a/.github/workflows/test-mingw.yml b/.github/workflows/test-mingw.yml index 808373a65..311e08062 100644 --- a/.github/workflows/test-mingw.yml +++ b/.github/workflows/test-mingw.yml @@ -87,7 +87,7 @@ jobs: .ci/test.sh - name: Upload coverage - uses: codecov/codecov-action@v5 + uses: codecov/codecov-action@v6 with: files: ./coverage.xml flags: GHA_Windows diff --git a/.github/workflows/test-windows.yml b/.github/workflows/test-windows.yml index 45392a689..a15f5cd73 100644 --- a/.github/workflows/test-windows.yml +++ b/.github/workflows/test-windows.yml @@ -217,7 +217,7 @@ jobs: shell: bash - name: Upload errors - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@v7 if: failure() with: name: errors @@ -229,7 +229,7 @@ jobs: shell: pwsh - name: Upload coverage - uses: codecov/codecov-action@v5 + uses: codecov/codecov-action@v6 with: files: ./coverage.xml flags: GHA_Windows diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 80bbfb45f..8aa54d97e 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -162,7 +162,7 @@ jobs: mkdir -p Tests/errors - name: Upload errors - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@v7 if: failure() with: name: errors @@ -173,7 +173,7 @@ jobs: .ci/after_success.sh - name: Upload coverage - uses: codecov/codecov-action@v5 + uses: codecov/codecov-action@v6 with: flags: ${{ matrix.os == 'ubuntu-latest' && 'GHA_Ubuntu' || 'GHA_macOS' }} name: ${{ matrix.os }} Python ${{ matrix.python-version }} diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index 6a5daf987..b39f36f54 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -130,7 +130,7 @@ jobs: CIBW_ENABLE: cpython-prerelease cpython-freethreading pypy MACOSX_DEPLOYMENT_TARGET: ${{ matrix.macosx_deployment_target }} - - uses: actions/upload-artifact@v6 + - uses: actions/upload-artifact@v7 with: name: dist-${{ matrix.name }} path: ./wheelhouse/*.whl @@ -210,13 +210,13 @@ jobs: shell: bash - name: Upload wheels - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@v7 with: name: dist-windows-${{ matrix.cibw_arch }} path: ./wheelhouse/*.whl - name: Upload fribidi.dll - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@v7 with: name: fribidi-windows-${{ matrix.cibw_arch }} path: winbuild\build\bin\fribidi* @@ -236,7 +236,7 @@ jobs: - run: make sdist - - uses: actions/upload-artifact@v6 + - uses: actions/upload-artifact@v7 with: name: dist-sdist path: dist/*.tar.gz From 3f78ebb542a80b13076618825dedbf1b0b93dcfb Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 3 Apr 2026 15:38:40 +1100 Subject: [PATCH 062/190] Update dependency cibuildwheel to v3.4.0 (#9532) --- .ci/requirements-cibw.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.ci/requirements-cibw.txt b/.ci/requirements-cibw.txt index 6e869a5c2..fd4183aff 100644 --- a/.ci/requirements-cibw.txt +++ b/.ci/requirements-cibw.txt @@ -1 +1 @@ -cibuildwheel==3.3.1 +cibuildwheel==3.4.0 From 9d790af50c6b03f5a86b5e45c87cd05e063ee941 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Fri, 3 Apr 2026 15:41:02 +1100 Subject: [PATCH 063/190] Update macOS tested Python versions --- docs/installation/platform-support.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/installation/platform-support.rst b/docs/installation/platform-support.rst index 8ccd203dc..0d6bc2777 100644 --- a/docs/installation/platform-support.rst +++ b/docs/installation/platform-support.rst @@ -69,7 +69,7 @@ These platforms have been reported to work at the versions mentioned. | Operating system | | Tested Python | | Latest tested | | Tested | | | | versions | | Pillow version | | processors | +==================================+=============================+==================+==============+ -| macOS 26 Tahoe | 3.10, 3.11, 3.12, 3.13, 3.14| 12.1.1 |arm | +| macOS 26 Tahoe | 3.10, 3.11, 3.12, 3.13, 3.14| 12.2.0 |arm | | +-----------------------------+------------------+ | | | 3.9 | 11.3.0 | | +----------------------------------+-----------------------------+------------------+--------------+ From 20307667f94e933166f9d382f3af1b0fb7da3942 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Fri, 3 Apr 2026 15:51:01 +1100 Subject: [PATCH 064/190] Remove deprecated option to allow Python 3.13t wheels --- .github/workflows/wheels.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index b39f36f54..d16c80323 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -39,7 +39,7 @@ concurrency: cancel-in-progress: true env: - EXPECTED_DISTS: 75 + EXPECTED_DISTS: 66 FORCE_COLOR: 1 jobs: @@ -127,7 +127,7 @@ jobs: CIBW_PLATFORM: ${{ matrix.platform }} CIBW_ARCHS: ${{ matrix.cibw_arch }} CIBW_BUILD: ${{ matrix.build }} - CIBW_ENABLE: cpython-prerelease cpython-freethreading pypy + CIBW_ENABLE: cpython-prerelease pypy MACOSX_DEPLOYMENT_TARGET: ${{ matrix.macosx_deployment_target }} - uses: actions/upload-artifact@v7 @@ -198,7 +198,7 @@ jobs: CIBW_ARCHS: ${{ matrix.cibw_arch }} CIBW_BEFORE_ALL: "{package}\\winbuild\\build\\build_dep_all.cmd" CIBW_CACHE_PATH: "C:\\cibw" - CIBW_ENABLE: cpython-prerelease cpython-freethreading pypy + CIBW_ENABLE: cpython-prerelease pypy CIBW_TEST_SKIP: "*-win_arm64" CIBW_TEST_COMMAND: 'docker run --rm -v {project}:C:\pillow From c03ba8b3c0ea14f231e63e7b5c53e1e7d9187b84 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Fri, 3 Apr 2026 21:41:13 +1100 Subject: [PATCH 065/190] Added release notes --- docs/releasenotes/12.3.0.rst | 57 ++++++++++++++++++++++++++++++++++++ docs/releasenotes/index.rst | 1 + 2 files changed, 58 insertions(+) create mode 100644 docs/releasenotes/12.3.0.rst diff --git a/docs/releasenotes/12.3.0.rst b/docs/releasenotes/12.3.0.rst new file mode 100644 index 000000000..58c8836d2 --- /dev/null +++ b/docs/releasenotes/12.3.0.rst @@ -0,0 +1,57 @@ +12.3.0 +------ + +Security +======== + +TODO +^^^^ + +TODO + +:cve:`YYYY-XXXXX`: TODO +^^^^^^^^^^^^^^^^^^^^^^^ + +TODO + +Backwards incompatible changes +============================== + +TODO +^^^^ + +TODO + +Deprecations +============ + +TODO +^^^^ + +TODO + +API changes +=========== + +TODO +^^^^ + +TODO + +API additions +============= + +TODO +^^^^ + +TODO + +Other changes +============= + +Removed Python 3.13 free-threaded wheels +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Python 3.13 added an experimental free-threaded mode, and Pillow 11.0.0 added +corresponding wheels. Now that Python 3.14 includes official support for it, Pillow has +removed wheels for Python 3.13 free-threaded mode. diff --git a/docs/releasenotes/index.rst b/docs/releasenotes/index.rst index 076872979..7cae29d18 100644 --- a/docs/releasenotes/index.rst +++ b/docs/releasenotes/index.rst @@ -15,6 +15,7 @@ expected to be backported to earlier versions. :maxdepth: 2 versioning + 12.3.0 12.2.0 12.1.1 12.1.0 From 7f68decf2c34ad4bc2a700be8410df679180b8d9 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Fri, 3 Apr 2026 22:16:51 +1100 Subject: [PATCH 066/190] Clarified condition --- src/PIL/PngImagePlugin.py | 24 +++++++++++------------- 1 file changed, 11 insertions(+), 13 deletions(-) diff --git a/src/PIL/PngImagePlugin.py b/src/PIL/PngImagePlugin.py index 76a15bd0d..9bfeb1104 100644 --- a/src/PIL/PngImagePlugin.py +++ b/src/PIL/PngImagePlugin.py @@ -1443,9 +1443,9 @@ 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 @@ -1461,17 +1461,15 @@ def _save( 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]) + 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( From e58c67347a2f4eae454fa727008fa1ba4a71c923 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Fri, 3 Apr 2026 22:19:52 +1100 Subject: [PATCH 067/190] Raise error if transparency is incorrect type or length when saving --- Tests/test_file_png.py | 35 +++++++++++++++++++++++++++++++++-- src/PIL/PngImagePlugin.py | 24 +++++++++++++++++++----- 2 files changed, 52 insertions(+), 7 deletions(-) diff --git a/Tests/test_file_png.py b/Tests/test_file_png.py index 3f08d1ad3..0fad0b391 100644 --- a/Tests/test_file_png.py +++ b/Tests/test_file_png.py @@ -502,8 +502,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 @@ -518,6 +519,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" diff --git a/src/PIL/PngImagePlugin.py b/src/PIL/PngImagePlugin.py index 9bfeb1104..3f21fa48e 100644 --- a/src/PIL/PngImagePlugin.py +++ b/src/PIL/PngImagePlugin.py @@ -1451,16 +1451,30 @@ def _save( 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)) + 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. From 7f3751d4981af3973548f0dba3170c48accd9a46 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Sat, 4 Apr 2026 19:29:11 +1100 Subject: [PATCH 068/190] Remove type hint ignore --- Tests/test_file_jpeg2k.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Tests/test_file_jpeg2k.py b/Tests/test_file_jpeg2k.py index 0e60b59f5..2a69f1f9a 100644 --- a/Tests/test_file_jpeg2k.py +++ b/Tests/test_file_jpeg2k.py @@ -178,9 +178,9 @@ def test_default_num_resolutions( def test_reduce() -> None: with Image.open("Tests/images/test-card-lossless.jp2") as im: - assert callable(im.reduce) + assert isinstance(im, Jpeg2KImagePlugin.Jpeg2KImageFile) - im.reduce = 2 # type: ignore[assignment, method-assign] + im.reduce = 2 assert im.reduce == 2 im.load() From 1dd1c9a3e56d7735b1be441e33cce36555a1b5f5 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Sat, 4 Apr 2026 19:33:07 +1100 Subject: [PATCH 069/190] Replace custom class with TextIOWrapper --- Tests/test_file_png.py | 12 ++++-------- Tests/test_file_ppm.py | 12 ++++-------- 2 files changed, 8 insertions(+), 16 deletions(-) diff --git a/Tests/test_file_png.py b/Tests/test_file_png.py index 3f08d1ad3..8fe2a5eac 100644 --- a/Tests/test_file_png.py +++ b/Tests/test_file_png.py @@ -4,7 +4,7 @@ import re import sys import warnings import zlib -from io import BytesIO +from io import BytesIO, TextIOWrapper from pathlib import Path from types import ModuleType from typing import Any, cast @@ -821,19 +821,15 @@ class TestFilePng: @pytest.mark.parametrize("buffer", (True, False)) def test_save_stdout(self, buffer: bool, monkeypatch: pytest.MonkeyPatch) -> None: - class MyStdOut: - buffer = BytesIO() - - mystdout: MyStdOut | BytesIO = MyStdOut() if buffer else BytesIO() + fp = BytesIO() + mystdout = TextIOWrapper(fp) if buffer else fp monkeypatch.setattr(sys, "stdout", mystdout) with Image.open(TEST_PNG_FILE) as im: im.save(sys.stdout, "PNG") # type: ignore[arg-type] - if isinstance(mystdout, MyStdOut): - mystdout = mystdout.buffer - with Image.open(mystdout) as reloaded: + with Image.open(fp) as reloaded: assert_image_equal_tofile(reloaded, TEST_PNG_FILE) def test_truncated_end_chunk(self, monkeypatch: pytest.MonkeyPatch) -> None: diff --git a/Tests/test_file_ppm.py b/Tests/test_file_ppm.py index fbca46be5..d0b1cbf8e 100644 --- a/Tests/test_file_ppm.py +++ b/Tests/test_file_ppm.py @@ -1,7 +1,7 @@ from __future__ import annotations import sys -from io import BytesIO +from io import BytesIO, TextIOWrapper from pathlib import Path import pytest @@ -381,17 +381,13 @@ def test_mimetypes(tmp_path: Path) -> None: @pytest.mark.parametrize("buffer", (True, False)) def test_save_stdout(buffer: bool, monkeypatch: pytest.MonkeyPatch) -> None: - class MyStdOut: - buffer = BytesIO() - - mystdout: MyStdOut | BytesIO = MyStdOut() if buffer else BytesIO() + fp = BytesIO() + mystdout = TextIOWrapper(fp) if buffer else fp monkeypatch.setattr(sys, "stdout", mystdout) with Image.open(TEST_FILE) as im: im.save(sys.stdout, "PPM") # type: ignore[arg-type] - if isinstance(mystdout, MyStdOut): - mystdout = mystdout.buffer - with Image.open(mystdout) as reloaded: + with Image.open(fp) as reloaded: assert_image_equal_tofile(reloaded, TEST_FILE) From 64f6d4ebd878d00b581812faab3f9863576470e6 Mon Sep 17 00:00:00 2001 From: Andrew Murray <3112309+radarhere@users.noreply.github.com> Date: Sun, 5 Apr 2026 05:30:11 +1000 Subject: [PATCH 070/190] Close PdfParser if error occurs during init (#9539) Co-authored-by: Hugo van Kemenade <1324225+hugovk@users.noreply.github.com> --- src/PIL/PdfParser.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/PIL/PdfParser.py b/src/PIL/PdfParser.py index f7f3a4643..2a5ade773 100644 --- a/src/PIL/PdfParser.py +++ b/src/PIL/PdfParser.py @@ -402,7 +402,11 @@ class PdfParser: self.pages_ref: IndirectReference | None self.last_xref_section_offset: int | None if self.buf: - self.read_pdf_info() + try: + self.read_pdf_info() + except PdfFormatError: + self.close() + raise else: self.file_size_total = self.file_size_this = 0 self.root = PdfDict() From 17612be407cc6c28f9657ac34980b664535f2a73 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Sun, 5 Apr 2026 12:57:01 +1000 Subject: [PATCH 071/190] Skip test if FreeType is not available --- Tests/test_font_crash.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Tests/test_font_crash.py b/Tests/test_font_crash.py index 72a0f3534..d1e1b7f66 100644 --- a/Tests/test_font_crash.py +++ b/Tests/test_font_crash.py @@ -2,6 +2,8 @@ from __future__ import annotations from PIL import Image, ImageDraw, ImageFont +from .helper import skip_unless_feature + class TestFontCrash: def _fuzz_font(self, font: ImageFont.FreeTypeFont) -> None: @@ -14,6 +16,7 @@ class TestFontCrash: draw.multiline_textbbox((10, 10), "ABC\nAaaa", font, stroke_width=2) draw.text((10, 10), "Test Text", font=font, fill="#000") + @skip_unless_feature("freetype") def test_segfault(self) -> None: font = ImageFont.truetype("Tests/fonts/fuzz_font-5203009437302784") self._fuzz_font(font) From d7d2df8ab29ed6195918fb553bebf49b03d8ceb2 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Mon, 6 Apr 2026 09:39:23 +1000 Subject: [PATCH 072/190] Correct feature name --- Tests/test_font_crash.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Tests/test_font_crash.py b/Tests/test_font_crash.py index d1e1b7f66..9e92ea456 100644 --- a/Tests/test_font_crash.py +++ b/Tests/test_font_crash.py @@ -16,7 +16,7 @@ class TestFontCrash: draw.multiline_textbbox((10, 10), "ABC\nAaaa", font, stroke_width=2) draw.text((10, 10), "Test Text", font=font, fill="#000") - @skip_unless_feature("freetype") + @skip_unless_feature("freetype2") def test_segfault(self) -> None: font = ImageFont.truetype("Tests/fonts/fuzz_font-5203009437302784") self._fuzz_font(font) From b65bc406d8a0c2e98202a26a6a377dc5516f2463 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Mon, 6 Apr 2026 13:19:32 +1000 Subject: [PATCH 073/190] Fixed comparison warning --- src/libImaging/FliDecode.c | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/libImaging/FliDecode.c b/src/libImaging/FliDecode.c index 9b494dfa2..d3a1bb954 100644 --- a/src/libImaging/FliDecode.c +++ b/src/libImaging/FliDecode.c @@ -49,7 +49,7 @@ ImagingFliDecode(Imaging im, ImagingCodecState state, UINT8 *buf, Py_ssize_t byt framesize = I32(ptr); // there can be one pad byte in the framesize - if (bytes + (bytes % 2) < framesize) { + if ((unsigned)(bytes + (bytes % 2)) < framesize) { return 0; } @@ -259,7 +259,7 @@ ImagingFliDecode(Imaging im, ImagingCodecState state, UINT8 *buf, Py_ssize_t byt state->errcode = IMAGING_CODEC_BROKEN; return -1; } - if (advance < 0 || advance > bytes) { + if (advance < 0 || advance > (unsigned)bytes) { state->errcode = IMAGING_CODEC_OVERRUN; return -1; } From abb9b200ef14f0527cb1ecfd95aaf64d39801333 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Mon, 6 Apr 2026 14:21:21 +1000 Subject: [PATCH 074/190] Do not precompute horizontal coefficients if not horizontal resizing --- src/libImaging/Resample.c | 22 +++++++++------------- 1 file changed, 9 insertions(+), 13 deletions(-) diff --git a/src/libImaging/Resample.c b/src/libImaging/Resample.c index cbd18d0c1..fea00eea0 100644 --- a/src/libImaging/Resample.c +++ b/src/libImaging/Resample.c @@ -726,19 +726,10 @@ ImagingResampleInner( need_horizontal = xsize != imIn->xsize || box[0] || box[2] != xsize; need_vertical = ysize != imIn->ysize || box[1] || box[3] != ysize; - ksize_horiz = precompute_coeffs( - imIn->xsize, box[0], box[2], xsize, filterp, &bounds_horiz, &kk_horiz - ); - if (!ksize_horiz) { - return NULL; - } - ksize_vert = precompute_coeffs( imIn->ysize, box[1], box[3], ysize, filterp, &bounds_vert, &kk_vert ); if (!ksize_vert) { - free(bounds_horiz); - free(kk_horiz); return NULL; } @@ -749,6 +740,15 @@ ImagingResampleInner( /* two-pass resize, horizontal pass */ if (need_horizontal) { + ksize_horiz = precompute_coeffs( + imIn->xsize, box[0], box[2], xsize, filterp, &bounds_horiz, &kk_horiz + ); + if (!ksize_horiz) { + free(bounds_vert); + free(kk_vert); + return NULL; + } + // Shift bounds for vertical pass for (i = 0; i < ysize; i++) { bounds_vert[i * 2] -= ybox_first; @@ -768,10 +768,6 @@ ImagingResampleInner( return NULL; } imOut = imIn = imTemp; - } else { - // Free in any case - free(bounds_horiz); - free(kk_horiz); } /* vertical pass */ From ecc48f9b3e43649a3796696408b4bddeb9e38981 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 6 Apr 2026 17:24:37 +0000 Subject: [PATCH 075/190] [pre-commit.ci] pre-commit autoupdate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - [github.com/astral-sh/ruff-pre-commit: v0.15.4 → v0.15.9](https://github.com/astral-sh/ruff-pre-commit/compare/v0.15.4...v0.15.9) - [github.com/psf/black-pre-commit-mirror: 26.1.0 → 26.3.1](https://github.com/psf/black-pre-commit-mirror/compare/26.1.0...26.3.1) - [github.com/pre-commit/mirrors-clang-format: v22.1.0 → v22.1.2](https://github.com/pre-commit/mirrors-clang-format/compare/v22.1.0...v22.1.2) - [github.com/python-jsonschema/check-jsonschema: 0.37.0 → 0.37.1](https://github.com/python-jsonschema/check-jsonschema/compare/0.37.0...0.37.1) - [github.com/zizmorcore/zizmor-pre-commit: v1.22.0 → v1.23.1](https://github.com/zizmorcore/zizmor-pre-commit/compare/v1.22.0...v1.23.1) - [github.com/tox-dev/pyproject-fmt: v2.16.2 → v2.21.0](https://github.com/tox-dev/pyproject-fmt/compare/v2.16.2...v2.21.0) --- .pre-commit-config.yaml | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 53fd0a3ca..53e40e9cc 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,12 +1,12 @@ repos: - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.15.4 + rev: v0.15.9 hooks: - id: ruff-check args: [--exit-non-zero-on-fix] - repo: https://github.com/psf/black-pre-commit-mirror - rev: 26.1.0 + rev: 26.3.1 hooks: - id: black @@ -24,7 +24,7 @@ repos: exclude: (Makefile$|\.bat$|\.cmake$|\.eps$|\.fits$|\.gd$|\.opt$) - repo: https://github.com/pre-commit/mirrors-clang-format - rev: v22.1.0 + rev: v22.1.2 hooks: - id: clang-format types: [c] @@ -52,14 +52,14 @@ repos: exclude: ^\.github/.*TEMPLATE|^Tests/(fonts|images)/ - repo: https://github.com/python-jsonschema/check-jsonschema - rev: 0.37.0 + rev: 0.37.1 hooks: - id: check-github-workflows - id: check-readthedocs - id: check-renovate - repo: https://github.com/zizmorcore/zizmor-pre-commit - rev: v1.22.0 + rev: v1.23.1 hooks: - id: zizmor @@ -69,7 +69,7 @@ repos: - id: sphinx-lint - repo: https://github.com/tox-dev/pyproject-fmt - rev: v2.16.2 + rev: v2.21.0 hooks: - id: pyproject-fmt From b72f5730e12a823641065becfadce9b6b041be75 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 6 Apr 2026 17:25:17 +0000 Subject: [PATCH 076/190] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- pyproject.toml | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 65e3b7659..8861fe775 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -186,12 +186,6 @@ lint.isort.required-imports = [ [tool.pyproject-fmt] max_supported_python = "3.14" -[tool.pytest] -addopts = [ "-ra", "--color=auto" ] -testpaths = [ - "Tests", -] - [tool.mypy] python_version = "3.10" pretty = true @@ -203,3 +197,9 @@ follow_imports = "silent" warn_redundant_casts = true warn_unreachable = true warn_unused_ignores = true + +[tool.pytest] +addopts = [ "-ra", "--color=auto" ] +testpaths = [ + "Tests", +] From 43a3e5ca211bc9788131f45bf5974816d1b0efbf Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade <1324225+hugovk@users.noreply.github.com> Date: Mon, 6 Apr 2026 23:35:44 +0300 Subject: [PATCH 077/190] Remove Codecov token --- .github/workflows/test-docker.yml | 1 - .github/workflows/test-mingw.yml | 1 - .github/workflows/test-windows.yml | 1 - .github/workflows/test.yml | 1 - 4 files changed, 4 deletions(-) diff --git a/.github/workflows/test-docker.yml b/.github/workflows/test-docker.yml index 427cc1fb5..515d77d17 100644 --- a/.github/workflows/test-docker.yml +++ b/.github/workflows/test-docker.yml @@ -108,7 +108,6 @@ jobs: with: flags: GHA_Docker name: ${{ matrix.docker }} - token: ${{ secrets.CODECOV_ORG_TOKEN }} success: permissions: diff --git a/.github/workflows/test-mingw.yml b/.github/workflows/test-mingw.yml index 311e08062..0dc6e2a0c 100644 --- a/.github/workflows/test-mingw.yml +++ b/.github/workflows/test-mingw.yml @@ -92,4 +92,3 @@ jobs: files: ./coverage.xml flags: GHA_Windows name: "MSYS2 MinGW" - token: ${{ secrets.CODECOV_ORG_TOKEN }} diff --git a/.github/workflows/test-windows.yml b/.github/workflows/test-windows.yml index a15f5cd73..0b2aad283 100644 --- a/.github/workflows/test-windows.yml +++ b/.github/workflows/test-windows.yml @@ -234,7 +234,6 @@ jobs: files: ./coverage.xml flags: GHA_Windows name: ${{ runner.os }} Python ${{ matrix.python-version }} - token: ${{ secrets.CODECOV_ORG_TOKEN }} success: permissions: diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 8aa54d97e..d84504a8f 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -177,7 +177,6 @@ jobs: with: flags: ${{ matrix.os == 'ubuntu-latest' && 'GHA_Ubuntu' || 'GHA_macOS' }} name: ${{ matrix.os }} Python ${{ matrix.python-version }} - token: ${{ secrets.CODECOV_ORG_TOKEN }} success: permissions: From 117de2b181d94d2272d9b6f0a61083925e6fac6b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tr=E1=BA=A7n=20B=C3=A1ch?= <45133811+barttran2k@users.noreply.github.com> Date: Tue, 7 Apr 2026 09:41:12 +0700 Subject: [PATCH 078/190] fix(security)(_imagingtk.c): unsafe pointer dereference from unchecked python i MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In `_tkinit`, `PyLong_AsVoidPtr(arg)` converts an arbitrary Python object to a `void*` pointer which is then cast to `Tcl_Interp*` and passed to `TkImaging_Init`. If `PyLong_AsVoidPtr` fails (returns NULL and sets an error), or if the caller passes an arbitrary integer value, the code proceeds to dereference it without any validation, potentially leading to a crash or arbitrary memory access. Affected files: _imagingtk.c Signed-off-by: Trần Bách <45133811+barttran2k@users.noreply.github.com> --- src/_imagingtk.c | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/_imagingtk.c b/src/_imagingtk.c index 68d7bf4cd..7b9607cb5 100644 --- a/src/_imagingtk.c +++ b/src/_imagingtk.c @@ -33,8 +33,10 @@ _tkinit(PyObject *self, PyObject *args) { } interp = (Tcl_Interp *)PyLong_AsVoidPtr(arg); + if (interp == NULL && PyErr_Occurred()) { + return NULL; + } - /* This will bomb if interp is invalid... */ TkImaging_Init(interp); Py_RETURN_NONE; From 7cf4dac7aef468ad73293c64622872c1e48cd7ef Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade <1324225+hugovk@users.noreply.github.com> Date: Tue, 7 Apr 2026 12:09:30 +0300 Subject: [PATCH 079/190] Move Homebrew dependencies into Brewfile (#9546) --- .github/workflows/Brewfile | 13 +++++++++++++ .github/workflows/macos-install.sh | 15 +-------------- .pre-commit-config.yaml | 2 ++ 3 files changed, 16 insertions(+), 14 deletions(-) create mode 100644 .github/workflows/Brewfile diff --git a/.github/workflows/Brewfile b/.github/workflows/Brewfile new file mode 100644 index 000000000..414f04201 --- /dev/null +++ b/.github/workflows/Brewfile @@ -0,0 +1,13 @@ +brew "aom" +brew "dav1d" +brew "freetype" +brew "ghostscript" +brew "jpeg-turbo" +brew "libimagequant" +brew "libraqm" +brew "libtiff" +brew "little-cms2" +brew "openjpeg" +brew "rav1e" +brew "svt-av1" +brew "webp" diff --git a/.github/workflows/macos-install.sh b/.github/workflows/macos-install.sh index 7c768af48..603ef5a23 100755 --- a/.github/workflows/macos-install.sh +++ b/.github/workflows/macos-install.sh @@ -2,20 +2,7 @@ set -e -brew install \ - aom \ - dav1d \ - freetype \ - ghostscript \ - jpeg-turbo \ - libimagequant \ - libraqm \ - libtiff \ - little-cms2 \ - openjpeg \ - rav1e \ - svt-av1 \ - webp +brew bundle --file=.github/workflows/Brewfile export PKG_CONFIG_PATH="/usr/local/opt/openblas/lib/pkgconfig" python3 -m pip install coverage diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 53fd0a3ca..6d36d73cd 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -48,6 +48,8 @@ repos: args: [--allow-multiple-documents] - id: end-of-file-fixer exclude: ^Tests/images/ + - id: file-contents-sorter + files: .github/workflows/Brewfile - id: trailing-whitespace exclude: ^\.github/.*TEMPLATE|^Tests/(fonts|images)/ From ed89b939407507b8f0c55f75d2a4d1c2222e65d3 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Wed, 8 Apr 2026 21:51:43 +1000 Subject: [PATCH 080/190] Use github.event.repository.fork --- .github/workflows/stale.yml | 2 +- .github/workflows/wheels.yml | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index 9d1902838..e4ccd1aa3 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -17,7 +17,7 @@ env: jobs: stale: - if: github.repository_owner == 'python-pillow' + if: github.event.repository.fork == false permissions: issues: write diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index d16c80323..5524e293c 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -44,7 +44,7 @@ env: jobs: build-native-wheels: - if: github.event_name != 'schedule' || github.repository_owner == 'python-pillow' + if: github.event_name != 'schedule' || github.event.repository.fork == false name: ${{ matrix.name }} runs-on: ${{ matrix.os }} strategy: @@ -136,7 +136,7 @@ jobs: path: ./wheelhouse/*.whl windows: - if: github.event_name != 'schedule' || github.repository_owner == 'python-pillow' + if: github.event_name != 'schedule' || github.event.repository.fork == false name: Windows ${{ matrix.cibw_arch }} runs-on: ${{ matrix.os }} strategy: @@ -222,7 +222,7 @@ jobs: path: winbuild\build\bin\fribidi* sdist: - if: github.event_name != 'schedule' || github.repository_owner == 'python-pillow' + if: github.event_name != 'schedule' || github.event.repository.fork == false runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 @@ -260,7 +260,7 @@ jobs: [ "$files" -eq $EXPECTED_DISTS ] || exit 1 scientific-python-nightly-wheels-publish: - if: github.repository_owner == 'python-pillow' && (github.event_name == 'schedule' || github.event_name == 'workflow_dispatch') + if: github.event.repository.fork == false && (github.event_name == 'schedule' || github.event_name == 'workflow_dispatch') needs: count-dists runs-on: ubuntu-latest name: Upload wheels to scientific-python-nightly-wheels @@ -277,7 +277,7 @@ jobs: anaconda_nightly_upload_token: ${{ secrets.ANACONDA_ORG_UPLOAD_TOKEN }} pypi-publish: - if: github.repository_owner == 'python-pillow' && github.event_name == 'push' && startsWith(github.ref, 'refs/tags') + if: github.event.repository.fork == false && github.event_name == 'push' && startsWith(github.ref, 'refs/tags') needs: count-dists runs-on: ubuntu-latest name: Upload release to PyPI From ab02e810b083694b055cafcff13266ceabd144b8 Mon Sep 17 00:00:00 2001 From: Jeffrey 'Alex' Clark Date: Wed, 8 Apr 2026 13:16:37 -0400 Subject: [PATCH 081/190] Update security policy --- .github/SECURITY.md | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/.github/SECURITY.md b/.github/SECURITY.md index c6369fdef..46a28ef55 100644 --- a/.github/SECURITY.md +++ b/.github/SECURITY.md @@ -1,5 +1,9 @@ # Security policy -To report sensitive vulnerability information, please use the [Tidelift security contact](https://tidelift.com/security). Tidelift will coordinate the fix and disclosure. +To report sensitive vulnerability information, please use GitHub's [Private vulnerability reporting](https://docs.github.com/en/code-security/how-tos/report-and-fix-vulnerabilities/privately-reporting-a-security-vulnerability). -If your organisation/employer is a distributor of Pillow and would like advance notification of security-related bugs, please let us know your preferred contact method. +The Pillow team will respond by following the steps in [Managing privately reported security vulnerabilities](https://docs.github.com/en/code-security/how-tos/report-and-fix-vulnerabilities/fix-reported-vulnerabilities/managing-privately-reported-security-vulnerabilities). + +If you cannot use GitHub, please use the [Tidelift security contact](https://tidelift.com/security). Tidelift will coordinate the fix and disclosure. + +Please DO NOT report sensitive vulnerability information in public. From 05860779a1b9563390861d7c89793f4adb7b6efd Mon Sep 17 00:00:00 2001 From: Jeffrey 'Alex' Clark Date: Wed, 8 Apr 2026 14:52:19 -0400 Subject: [PATCH 082/190] Update .github/SECURITY.md Co-authored-by: Hugo van Kemenade <1324225+hugovk@users.noreply.github.com> --- .github/SECURITY.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/SECURITY.md b/.github/SECURITY.md index 46a28ef55..3a9d60e54 100644 --- a/.github/SECURITY.md +++ b/.github/SECURITY.md @@ -1,6 +1,6 @@ # Security policy -To report sensitive vulnerability information, please use GitHub's [Private vulnerability reporting](https://docs.github.com/en/code-security/how-tos/report-and-fix-vulnerabilities/privately-reporting-a-security-vulnerability). +To report sensitive vulnerability information, report it [privately on GitHub](https://github.com/python-pillow/Pillow/security). The Pillow team will respond by following the steps in [Managing privately reported security vulnerabilities](https://docs.github.com/en/code-security/how-tos/report-and-fix-vulnerabilities/fix-reported-vulnerabilities/managing-privately-reported-security-vulnerabilities). From 8edb7734b584b7e5e22142f348d95b5a357f8b28 Mon Sep 17 00:00:00 2001 From: Jeffrey 'Alex' Clark Date: Wed, 8 Apr 2026 14:52:36 -0400 Subject: [PATCH 083/190] Update .github/SECURITY.md Co-authored-by: Hugo van Kemenade <1324225+hugovk@users.noreply.github.com> --- .github/SECURITY.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/SECURITY.md b/.github/SECURITY.md index 3a9d60e54..19eb9bb32 100644 --- a/.github/SECURITY.md +++ b/.github/SECURITY.md @@ -4,6 +4,6 @@ To report sensitive vulnerability information, report it [privately on GitHub](h The Pillow team will respond by following the steps in [Managing privately reported security vulnerabilities](https://docs.github.com/en/code-security/how-tos/report-and-fix-vulnerabilities/fix-reported-vulnerabilities/managing-privately-reported-security-vulnerabilities). -If you cannot use GitHub, please use the [Tidelift security contact](https://tidelift.com/security). Tidelift will coordinate the fix and disclosure. +If you cannot use GitHub, use the [Tidelift security contact](https://tidelift.com/security). Tidelift will coordinate the fix and disclosure. -Please DO NOT report sensitive vulnerability information in public. +DO NOT report sensitive vulnerability information in public. From 8f625f19eff86fc7079267bc41a25c0f467410be Mon Sep 17 00:00:00 2001 From: Jeffrey 'Alex' Clark Date: Wed, 8 Apr 2026 16:17:52 -0400 Subject: [PATCH 084/190] Update .github/SECURITY.md Co-authored-by: Hugo van Kemenade <1324225+hugovk@users.noreply.github.com> --- .github/SECURITY.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/.github/SECURITY.md b/.github/SECURITY.md index 19eb9bb32..bc8bcaef6 100644 --- a/.github/SECURITY.md +++ b/.github/SECURITY.md @@ -2,8 +2,6 @@ To report sensitive vulnerability information, report it [privately on GitHub](https://github.com/python-pillow/Pillow/security). -The Pillow team will respond by following the steps in [Managing privately reported security vulnerabilities](https://docs.github.com/en/code-security/how-tos/report-and-fix-vulnerabilities/fix-reported-vulnerabilities/managing-privately-reported-security-vulnerabilities). - If you cannot use GitHub, use the [Tidelift security contact](https://tidelift.com/security). Tidelift will coordinate the fix and disclosure. DO NOT report sensitive vulnerability information in public. From b97034ae02c9d594d58a0981c661476411d7bb5e Mon Sep 17 00:00:00 2001 From: Jeffrey 'Alex' Clark Date: Wed, 8 Apr 2026 20:01:39 -0400 Subject: [PATCH 085/190] Link to New draft security advisory --- .github/SECURITY.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/SECURITY.md b/.github/SECURITY.md index bc8bcaef6..42ff1615b 100644 --- a/.github/SECURITY.md +++ b/.github/SECURITY.md @@ -1,6 +1,6 @@ # Security policy -To report sensitive vulnerability information, report it [privately on GitHub](https://github.com/python-pillow/Pillow/security). +To report sensitive vulnerability information, report it [privately on GitHub](https://github.com/python-pillow/Pillow/security/advisories/new). If you cannot use GitHub, use the [Tidelift security contact](https://tidelift.com/security). Tidelift will coordinate the fix and disclosure. From 6ede62874b58aaad66db77287a52ee0bfebca7ff Mon Sep 17 00:00:00 2001 From: Andrew Murray <3112309+radarhere@users.noreply.github.com> Date: Thu, 9 Apr 2026 19:01:17 +1000 Subject: [PATCH 086/190] Update README with revised security policy (#9553) --- README.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 04c9ae8ab..c6d09a821 100644 --- a/README.md +++ b/README.md @@ -106,4 +106,8 @@ The core image library is designed for fast access to data stored in a few basic ## Report a vulnerability -To report a security vulnerability, please follow the procedure described in the [Tidelift security policy](https://tidelift.com/docs/security). +To report sensitive vulnerability information, report it [privately on GitHub](https://github.com/python-pillow/Pillow/security/advisories/new). + +If you cannot use GitHub, use the [Tidelift security contact](https://tidelift.com/security). Tidelift will coordinate the fix and disclosure. + +DO NOT report sensitive vulnerability information in public. From cb5736ea3e04d22b529ef75707cbc266dc7ff8f1 Mon Sep 17 00:00:00 2001 From: Jeffrey 'Alex' Clark Date: Thu, 9 Apr 2026 12:26:29 -0400 Subject: [PATCH 087/190] Add INCIDENT_RESPONSE.md --- .github/INCIDENT_RESPONSE.md | 441 +++++++++++++++++++++++++++++++++++ 1 file changed, 441 insertions(+) create mode 100644 .github/INCIDENT_RESPONSE.md diff --git a/.github/INCIDENT_RESPONSE.md b/.github/INCIDENT_RESPONSE.md new file mode 100644 index 000000000..03371c4f3 --- /dev/null +++ b/.github/INCIDENT_RESPONSE.md @@ -0,0 +1,441 @@ +# Incident Response Plan — Pillow + +This document describes how the Pillow maintainers detect, triage, fix, communicate, and +learn from security incidents. It supplements the existing [Security Policy](SECURITY.md) +and [Release Checklist](../RELEASING.md). + +--- + +## 1. Preparation + +Maintaining readiness before an incident occurs reduces response time and errors under pressure. + +### 1.1 Version Support Matrix + +Only the following branches receive security fixes. Reporters should verify their affected +version before filing; maintainers should cherry-pick fixes only to supported branches. + +| Branch | Status | Notes | +|---|---|---| +| `main` | ✅ Active development | Always patched | +| Latest stable (e.g. `11.x`) | ✅ Security fixes | Current quarterly release series | +| Previous stable (e.g. `10.x`) | ⚠️ Critical only | One release series back; Critical CVEs only | +| Older branches | ❌ End of life | No security support; users must upgrade | + +> Update this table with each quarterly release. + +### 1.2 Team Readiness + +- Maintain a private list of current maintainer contact details (GitHub handles, email, + Mastodon) in a location accessible to all maintainers (e.g. a pinned private team + discussion or the Tidelift maintainer portal). +- Ensure at least two maintainers have admin access to: + - The GitHub repository (to manage Security Advisories) + - The [PyPI Pillow project](https://pypi.org/project/Pillow/) (to yank releases) + - The Tidelift maintainer portal +- Rotate and audit PyPI API tokens and GitHub Actions secrets at least once per year, + and immediately after any maintainer leaves the project. + +### 1.3 Annual Readiness Review + +Once per year (suggested: at the January quarterly release), maintainers should: + +1. Re-read this document and update any stale content (version table, contacts, tooling). +2. Verify the GitHub private security advisory flow still works (open and close a test advisory). +3. Confirm PyPI yank access is functional. +4. Review Dependabot and CodeQL alert settings are enabled on the repository. + +--- + +## 2. Scope + +This plan covers: + +| Incident type | Examples | +|---|---| +| Vulnerability in Pillow's own Python or C code | Buffer overflow in an image decoder, integer overflow in `ImagingNew` | +| Vulnerability in a bundled or wheel-shipped C library | libjpeg, libwebp, libtiff, libpng, openjpeg, libavif | +| Supply-chain compromise | Malicious commit, stolen maintainer credentials, tampered PyPI wheel | +| CI/CD or infrastructure compromise | GitHub Actions secret leak, Codecov breach, PyPI token exposure | +| Critical non-security regression | Data-loss bug shipped in a release, crash on all supported platforms | + +--- + +## 3. Roles + +| Role | Responsibility | +|---|---| +| **Incident Lead** | First maintainer to triage the report. Owns the incident until resolution. | +| **Patch Owner** | Writes and tests the fix (may be the same person as Incident Lead). | +| **Release Manager** | Cuts the point release following [RELEASING.md](../RELEASING.md). | +| **Communications Owner** | Drafts the GitHub Security Advisory, announces on Mastodon, notifies distros. | +| **Tidelift Contact** | For reports that arrive via Tidelift, coordinate through the Tidelift security portal. | + +For the typical small maintainer team, one person may fill multiple roles. Assign roles +explicitly at the start of each incident to avoid gaps. + +--- + +## 4. Severity Classification + +Use the [CVSS v3.1](https://www.first.org/cvss/v3.1/specification-document) base score as +a guide, mapped to the following levels: + +| Severity | CVSS | Definition | Target Response SLA | +|---|---|---|---| +| **Critical** | 9.0 – 10.0 | Remote code execution, arbitrary write, or complete integrity/confidentiality loss achievable by opening a crafted image | 48 hours to patch; embargoed release where possible | +| **High** | 7.0 – 8.9 | Heap/stack buffer overflow, use-after-free, or significant information disclosure | 7 days to patch | +| **Medium** | 4.0 – 6.9 | Denial of service via crafted image, out-of-bounds read, limited info disclosure | Next scheduled quarterly release, or earlier point release if needed | +| **Low** | 0.1 – 3.9 | Minor information disclosure, unlikely to be exploitable in practice | Next quarterly release | + +Supply-chain and CI/CD incidents are always treated as **Critical** regardless of CVSS. + +--- + +## 5. Detection Sources + +Vulnerabilities and incidents may be reported or discovered through: + +1. **GitHub private security advisory** — preferred channel; see [SECURITY.md](SECURITY.md) +2. **Tidelift security contact** — +3. **Direct maintainer contact** — DM on Mastodon or email +4. **External researcher / coordinated disclosure** — e.g. Google Project Zero, vendor PSIRT +5. **Automated scanning** — Dependabot, GitHub code-scanning (CodeQL), CI fuzzing +6. **Distro security teams** — Debian, Red Hat, Ubuntu, Alpine may report upstream +7. **User bug report** — public issue (reassess if it has security implications before it stays public) + +--- + +## 6. Response Process + +### 6.1 Triage (all severities) + +1. **Acknowledge receipt** to the reporter within **72 hours** using the template in + [Appendix A](#appendix-a-communication-templates). Ask the reporter: + - How they would like to be credited (name, handle, or anonymous) + - Whether they intend to publish their own advisory, and if so, their preferred timeline + - Thank them explicitly — reporters do the project a favour by disclosing privately. +2. Reproduce the issue. If the report is invalid, close it and notify the reporter. +3. Assign a severity level (Section 3) and an Incident Lead. +4. If the GitHub Security Advisory was not created by the reporter, create one now and keep + it **private** until the fix is released. Add the reporter as a collaborator if they wish + to be involved. +5. **Request a CVE** through the GitHub Security Advisory workflow (GitHub is a CVE + Numbering Authority — no separate MITRE form required). The CVE is reserved privately + and published automatically when the advisory goes public. +6. Notify Tidelift if the severity is High or Critical. +7. **Escalation** — Escalate beyond the core maintainer team if any of the following apply: + - The vulnerability is being actively exploited in the wild → notify [GitHub Security](mailto:security@github.com) and the [Python Security Response Team](https://www.python.org/news/security/) + - The fix requires changes to CPython or a dependency outside Pillow's control → contact the relevant upstream immediately + - A legal concern arises (e.g. GDPR-reportable data exposure) → contact the project's legal/fiscal sponsor + - The Incident Lead is unreachable for > 24 hours on a Critical issue → any other maintainer may assume the role + +### 6.2 Fix Development + +1. Develop the fix in a **private fork** or directly in the private security advisory + workspace on GitHub. Do **not** push to a public branch before the embargo lifts. +2. Write a regression test that fails before the fix and passes after. +3. Run the full test suite locally across all supported Python versions: + ```bash + make release-test + ``` +4. Review the patch with at least one other maintainer. + +### 6.3 Standard (Non-Embargoed) Release + +For Medium and Low severity, or when no distro pre-notification is needed: + +1. Merge the fix to `main`, then cherry-pick to all affected release branches + (see [RELEASING.md — Point release](../RELEASING.md)). +2. Amend commit messages to include the CVE identifier. +3. Tag and push; the GitHub Actions "Wheels" workflow will build and upload to PyPI. +4. Publish the GitHub Security Advisory (this simultaneously publishes the CVE). +5. Announce on [Mastodon](https://fosstodon.org/@pillow). + +### 6.4 Embargoed Release + +For Critical and High severity where distro pre-notification improves user safety: + +1. Prepare patches against all affected release branches and test locally. +2. Agree on an **embargo date** with the reporter (typically 7–14 days out, up to 90 days for + complex issues). +3. Privately send the patch to distros via the + [linux-distros](https://oss-security.openwall.org/wiki/mailing-lists/distros) mailing list + or directly to individual distro security teams. +4. On the embargo date: + - Amend commit messages with the CVE identifier. + - Tag and push all affected release branches (see [RELEASING.md — Embargoed release](../RELEASING.md)). + - Confirm the "Wheels" workflow has passed and wheels are live on PyPI. + - Publish the GitHub Security Advisory. + - Announce on [Mastodon](https://fosstodon.org/@pillow). + +### 6.5 Rollback Procedures + +If a security patch introduces a critical regression after release: + +1. **Yank the release immediately** via the PyPI web interface: + [https://pypi.org/manage/project/pillow/release/\/](https://pypi.org/manage/project/pillow/) + (navigate to the release, click **"Yank"**). + Yanked releases remain downloadable by pinned users but are excluded from `pip install` + resolution, giving time to fix without leaving users unpatched. +2. Post a public notice in the GitHub release and on Mastodon explaining the regression and + that the release has been yanked. +3. If the previous (vulnerable) version was also yanked, **un-yank it temporarily** so users + have a functional fallback while the corrected release is prepared. +4. Prepare a corrected point release (incrementing the patch version), repeating §6.2–§6.3. +5. Document the regression in the post-incident review (§9). + +### 6.6 Supply-Chain / Infrastructure Compromise + +1. **Immediately** revoke any potentially compromised credentials: + - PyPI API tokens (regenerate and update in GitHub secrets) + - GitHub personal access tokens and OAuth apps + - Codecov or other CI service tokens +2. Audit recent commits and releases for tampering: + - Verify release tags against known-good SHAs + - Re-inspect any wheel published since the potential compromise window +3. If a PyPI release is suspected to be tampered: yank it immediately via + [https://pypi.org/manage/project/pillow/](https://pypi.org/manage/project/pillow/); + file a report with the [PyPI security team](mailto:security@pypi.org). +4. Notify GitHub Security if repository access or Actions secrets are involved. +5. Issue a public advisory describing the scope and any user action required. + +--- + +## 7. Communication + +### Internal (during embargo) +- Use the **private GitHub Security Advisory** thread for all coordination. +- Do not discuss details in public issues, PRs, or Gitter/IRC channels. + +### External (at or after disclosure) + +| Audience | Channel | Timing | +|---|---|---| +| General users | [GitHub Security Advisory](https://github.com/python-pillow/Pillow/security/advisories) | At release | +| PyPI ecosystem | CVE published via advisory | At release | +| Downstream distros | Direct email or linux-distros list | Before embargo date (embargoed) | +| Tidelift subscribers | Tidelift security portal | At release (or coordinated) | +| Community | [Mastodon @pillow](https://fosstodon.org/@pillow) | At release | + +**Advisory content should include:** +- CVE identifier and CVSS score +- Affected Pillow versions +- Fixed version(s) +- Nature of the vulnerability (without full exploit details if still fresh) +- Credit to the reporter (with their consent) +- Upgrade instructions (`pip install --upgrade Pillow`) + +--- + +## 8. Post-Incident Review + +Within **2 weeks** of a Critical or High severity fix being released: + +1. Hold a brief retrospective (async is fine for a distributed team). +2. Document the following metrics for the incident record: + + | Metric | Target | Actual | + |---|---|---| + | Time to acknowledge reporter | ≤ 72 hours | | + | Time to reproduce & assess severity | ≤ 5 days | | + | Time to develop & review fix | Varies by severity | | + | Time from report to public release | Critical ≤ 14 days; High ≤ 30 days | | + +3. Record: + - What went well + - What could be improved + - Root cause: what allowed the vulnerability to exist + - Whether any distro/downstream was impacted before the fix was available +4. File follow-up issues for any process improvements identified. +5. Update this document if the response process needs revision. + +--- + +## 9. Dependency Map + +Understanding what Pillow depends on (upstream) and what depends on Pillow (downstream) +is essential for scoping impact and coordinating notifications during an incident. + +### 9.1 Upstream Dependencies + +#### Bundled C libraries (shipped in official wheels) + +These libraries are compiled into Pillow's binary wheels. A CVE in any of them may +require a Pillow point release even if Pillow's own code is unchanged. + +| Library | Purpose | Security advisory tracker | +|---|---|---| +| [libjpeg-turbo](https://libjpeg-turbo.org/) | JPEG encode/decode | [GitHub](https://github.com/libjpeg-turbo/libjpeg-turbo/security) | +| [libpng](http://www.libpng.org/pub/png/libpng.html) | PNG encode/decode | [SourceForge](https://sourceforge.net/p/libpng/bugs/) | +| [libtiff](https://libtiff.gitlab.io/libtiff/) | TIFF encode/decode | [GitLab](https://gitlab.com/libtiff/libtiff/-/issues) | +| [libwebp](https://chromium.googlesource.com/webm/libwebp) | WebP encode/decode | [Chromium tracker](https://bugs.chromium.org/p/webm/) | +| [libavif](https://github.com/AOMediaCodec/libavif) | AVIF encode/decode | [GitHub](https://github.com/AOMediaCodec/libavif/security) | +| [aom](https://aomedia.googlesource.com/aom/) | AV1 codec (AVIF) | [Chromium tracker](https://bugs.chromium.org/p/aomedia/) | +| [dav1d](https://code.videolan.org/videolan/dav1d) | AV1 decode (AVIF) | [VideoLAN](https://security.videolan.org/) | +| [openjpeg](https://www.openjpeg.org/) | JPEG 2000 encode/decode | [GitHub](https://github.com/uclouvain/openjpeg/security) | +| [freetype2](https://freetype.org/) | Font rendering | [GitLab](https://gitlab.freedesktop.org/freetype/freetype/-/issues) | +| [lcms2](https://www.littlecms.com/) | ICC color management | [GitHub](https://github.com/mm2/Little-CMS) | +| [harfbuzz](https://harfbuzz.github.io/) | Text shaping (via raqm) | [GitHub](https://github.com/harfbuzz/harfbuzz/security) | +| [raqm](https://github.com/HOST-Oman/libraqm) | Complex text layout | [GitHub](https://github.com/HOST-Oman/libraqm) | +| [fribidi](https://github.com/fribidi/fribidi) | Unicode bidi (via raqm) | [GitHub](https://github.com/fribidi/fribidi) | +| [zlib](https://zlib.net/) | Deflate compression | [zlib.net](https://zlib.net/) | +| [liblzma / xz-utils](https://tukaani.org/xz/) | XZ/LZMA compression | [GitHub](https://github.com/tukaani-project/xz) | +| [bzip2](https://sourceware.org/bzip2/) | BZ2 compression | [Sourceware](https://sourceware.org/bzip2/) | +| [zstd](https://github.com/facebook/zstd) | Zstandard compression | [GitHub](https://github.com/facebook/zstd/security) | +| [brotli](https://github.com/google/brotli) | Brotli compression | [GitHub](https://github.com/google/brotli) | +| [libyuv](https://chromium.googlesource.com/libyuv/libyuv/) | YUV conversion | [Chromium tracker](https://bugs.chromium.org/p/libyuv/) | + +#### Python-level dependencies + +| Package | Required? | Purpose | +|---|---|---| +| `pybind11` | Build-time only | C++ ↔ Python bindings | +| `olefile` | Optional (`fpx`, `mic` extras) | OLE2 container parsing (FPX, MIC formats) | +| `defusedxml` | Optional (`xmp` extra) | Safe XML parsing for XMP metadata | + +### 9.2 Downstream Dependencies + +A vulnerability in Pillow can have wide impact. Notify or consider the blast radius of +these downstream consumers when assessing severity and planning communications. + +#### Linux distribution packages + +| Distribution | Package name | Security contact | +|---|---|---| +| Debian / Ubuntu | `python3-pil` | [Debian Security](https://www.debian.org/security/) / [Ubuntu Security](https://ubuntu.com/security) | +| Fedora / RHEL / CentOS | `python3-pillow` | [Red Hat Security](https://access.redhat.com/security/) | +| Alpine Linux | `py3-pillow` | [Alpine security](https://security.alpinelinux.org/) | +| Arch Linux | `python-pillow` | [Arch security tracker](https://security.archlinux.org/) | +| Homebrew (macOS) | `pillow` | [Homebrew maintainers](https://github.com/Homebrew/homebrew-core) | +| conda-forge | `pillow` | [conda-forge](https://github.com/conda-forge/pillow-feedstock) | + +#### Major Python ecosystem consumers + +These are high-profile projects known to depend on Pillow; a critical vulnerability may +warrant proactive notification. + +| Project | Usage | +|---|---| +| [matplotlib](https://matplotlib.org/) | Image I/O for plots | +| [scikit-image](https://scikit-image.org/) | Image processing | +| [torchvision](https://github.com/pytorch/vision) (PyTorch) | Dataset loading, transforms | +| [Keras / TensorFlow](https://keras.io/) | Image preprocessing utilities | +| [Django](https://www.djangoproject.com/) | `ImageField` validation and thumbnail generation | +| [Wagtail](https://wagtail.org/) | CMS image renditions | +| [Plone](https://plone.org/) | CMS image handling | +| [Jupyter / IPython](https://jupyter.org/) | Inline image display | +| [ReportLab](https://www.reportlab.com/) | PDF image embedding | +| [Wand](https://docs.wand-py.org/) | Sometimes used alongside Pillow | +| [Tidelift subscribers](https://tidelift.com/) | Enterprise consumers (coordinated via Tidelift) | + +#### Pillow ecosystem plugins + +Third-party plugins extend Pillow and are distributed separately on PyPI. Their +maintainers should be notified for Critical/High issues that affect the plugin API +or the formats they decode. See the +[full plugin list](https://pillow.readthedocs.io/en/stable/handbook/third-party-plugins.html). + +### 9.3 Responding to an Upstream Vulnerability + +When a CVE is published for a bundled C library: + +1. Assess whether the vulnerable code path is reachable through Pillow's API. +2. If reachable, treat as a Pillow vulnerability and follow Section 5. +3. Update the bundled library version in the wheel build scripts and rebuild wheels. +4. Reference the upstream CVE in Pillow's release notes and GitHub Security Advisory. +5. If not reachable, document the rationale in a public issue so downstream distributors + can make informed decisions about patching their system packages. + +--- + +## 10. References + +- [Security Policy](SECURITY.md) +- [Release Checklist](../RELEASING.md) +- [Contributing Guide](CONTRIBUTING.md) +- [Tidelift Security Contact](https://tidelift.com/security) +- [GitHub: Privately reporting a security vulnerability](https://docs.github.com/en/code-security/security-advisories/guidance-on-reporting-and-writing/privately-reporting-a-security-vulnerability) +- [GitHub as a CVE Numbering Authority (CNA)](https://docs.github.com/en/code-security/security-advisories/working-with-repository-security-advisories/about-github-security-advisories-for-repositories#cve-identification-numbers) +- [FIRST CVSS v3.1 Calculator](https://www.first.org/cvss/calculator/3.1) +- [linux-distros mailing list](https://oss-security.openwall.org/wiki/mailing-lists/distros) +- [OpenSSF CVD Guide](https://github.com/ossf/oss-vulnerability-guide) *(basis for this plan)* + +--- + +## Appendix A: Communication Templates + +### A.1 Reporter Acknowledgment + +> Subject: Re: [Security] \ +> +> Hi \, +> +> Thank you for taking the time to report this — we genuinely appreciate it. +> +> We have received your report and will assess it within the next few days. We will keep +> you updated on our progress. +> +> A few quick questions so we can handle this well: +> - How would you like to be credited in the advisory? (name, handle, organisation, or anonymous) +> - Do you plan to publish your own write-up or advisory? If so, is there a disclosure date +> that works for you? +> +> We aim to treat all vulnerability reports in line with coordinated disclosure principles. +> If you have any questions or concerns at any point, please reply to this thread. +> +> Thanks again, +> The Pillow maintainers + +### A.2 Embargoed Distro Notification + +> Subject: [EMBARGOED] Pillow security issue — \ — disclosure \ +> +> This is an embargoed notification of a vulnerability in Pillow. Please keep this +> information confidential until the disclosure date listed below. +> +> **CVE:** \ +> **Affected versions:** \ +> **Fixed version:** \ +> **Severity:** \ (CVSS \: \) +> **Reporter:** \ +> **Public disclosure date:** \ +> +> **Summary:** +> \ +> +> **Proof of concept:** +> \ +> +> **Remediation:** +> Upgrade to Pillow \. No known workaround. +> +> Please do not share this information, issue public patches, or make user communications +> before the disclosure date. We will notify this list immediately if the date changes. +> +> — The Pillow maintainers + +### A.3 Public Disclosure Advisory + +*(Published as a GitHub Security Advisory; the CVE and date are included automatically.)* + +> **Summary:** \ +> +> **CVE:** \ +> **Affected versions:** Pillow \< \ +> **Fixed version:** \ +> **Severity:** \ (CVSS \) +> **Reporter:** \ +> +> **Details:** +> \ +> +> **Remediation:** +> ``` +> pip install --upgrade Pillow +> ``` +> +> **Timeline:** +> - Reported: \ +> - Fixed: \ +> - Disclosed: \ From 4d63d0b3a6c5deccce7f33ecf1e6c3d74ee9d8a5 Mon Sep 17 00:00:00 2001 From: Jeffrey 'Alex' Clark Date: Thu, 9 Apr 2026 12:47:50 -0400 Subject: [PATCH 088/190] Fix links --- .github/INCIDENT_RESPONSE.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/INCIDENT_RESPONSE.md b/.github/INCIDENT_RESPONSE.md index 03371c4f3..65b1f1b7d 100644 --- a/.github/INCIDENT_RESPONSE.md +++ b/.github/INCIDENT_RESPONSE.md @@ -174,7 +174,7 @@ For Critical and High severity where distro pre-notification improves user safet If a security patch introduces a critical regression after release: 1. **Yank the release immediately** via the PyPI web interface: - [https://pypi.org/manage/project/pillow/release/\/](https://pypi.org/manage/project/pillow/) + [https://pypi.org/manage/project/pillow/](https://pypi.org/manage/project/pillow/) (navigate to the release, click **"Yank"**). Yanked releases remain downloadable by pinned users but are excluded from `pip install` resolution, giving time to fix without leaving users unpatched. @@ -272,7 +272,7 @@ require a Pillow point release even if Pillow's own code is unchanged. | [libwebp](https://chromium.googlesource.com/webm/libwebp) | WebP encode/decode | [Chromium tracker](https://bugs.chromium.org/p/webm/) | | [libavif](https://github.com/AOMediaCodec/libavif) | AVIF encode/decode | [GitHub](https://github.com/AOMediaCodec/libavif/security) | | [aom](https://aomedia.googlesource.com/aom/) | AV1 codec (AVIF) | [Chromium tracker](https://bugs.chromium.org/p/aomedia/) | -| [dav1d](https://code.videolan.org/videolan/dav1d) | AV1 decode (AVIF) | [VideoLAN](https://security.videolan.org/) | +| [dav1d](https://code.videolan.org/videolan/dav1d) | AV1 decode (AVIF) | [VideoLAN Security](https://www.videolan.org/security/) | | [openjpeg](https://www.openjpeg.org/) | JPEG 2000 encode/decode | [GitHub](https://github.com/uclouvain/openjpeg/security) | | [freetype2](https://freetype.org/) | Font rendering | [GitLab](https://gitlab.freedesktop.org/freetype/freetype/-/issues) | | [lcms2](https://www.littlecms.com/) | ICC color management | [GitHub](https://github.com/mm2/Little-CMS) | @@ -281,7 +281,7 @@ require a Pillow point release even if Pillow's own code is unchanged. | [fribidi](https://github.com/fribidi/fribidi) | Unicode bidi (via raqm) | [GitHub](https://github.com/fribidi/fribidi) | | [zlib](https://zlib.net/) | Deflate compression | [zlib.net](https://zlib.net/) | | [liblzma / xz-utils](https://tukaani.org/xz/) | XZ/LZMA compression | [GitHub](https://github.com/tukaani-project/xz) | -| [bzip2](https://sourceware.org/bzip2/) | BZ2 compression | [Sourceware](https://sourceware.org/bzip2/) | +| [bzip2](https://gitlab.com/bzip2/bzip2) | BZ2 compression | [GitLab](https://gitlab.com/bzip2/bzip2/-/issues) | | [zstd](https://github.com/facebook/zstd) | Zstandard compression | [GitHub](https://github.com/facebook/zstd/security) | | [brotli](https://github.com/google/brotli) | Brotli compression | [GitHub](https://github.com/google/brotli) | | [libyuv](https://chromium.googlesource.com/libyuv/libyuv/) | YUV conversion | [Chromium tracker](https://bugs.chromium.org/p/libyuv/) | @@ -356,7 +356,7 @@ When a CVE is published for a bundled C library: - [Contributing Guide](CONTRIBUTING.md) - [Tidelift Security Contact](https://tidelift.com/security) - [GitHub: Privately reporting a security vulnerability](https://docs.github.com/en/code-security/security-advisories/guidance-on-reporting-and-writing/privately-reporting-a-security-vulnerability) -- [GitHub as a CVE Numbering Authority (CNA)](https://docs.github.com/en/code-security/security-advisories/working-with-repository-security-advisories/about-github-security-advisories-for-repositories#cve-identification-numbers) +- [GitHub as a CVE Numbering Authority (CNA)](https://docs.github.com/en/code-security/security-advisories/working-with-repository-security-advisories/about-repository-security-advisories) - [FIRST CVSS v3.1 Calculator](https://www.first.org/cvss/calculator/3.1) - [linux-distros mailing list](https://oss-security.openwall.org/wiki/mailing-lists/distros) - [OpenSSF CVD Guide](https://github.com/ossf/oss-vulnerability-guide) *(basis for this plan)* From cdaa1bf9ef0faca5ddf2f2e7d8742678552a90d1 Mon Sep 17 00:00:00 2001 From: Jeffrey 'Alex' Clark Date: Thu, 9 Apr 2026 12:57:16 -0400 Subject: [PATCH 089/190] Add sections from Bootstrap example At the risk of making this document larger, add in sections in Bootstrap IRP but not ours. - https://github.com/twbs/bootstrap/blob/main/.github/INCIDENT_RESPONSE.md --- .github/INCIDENT_RESPONSE.md | 75 +++++++++++++++++++++++++++--------- 1 file changed, 57 insertions(+), 18 deletions(-) diff --git a/.github/INCIDENT_RESPONSE.md b/.github/INCIDENT_RESPONSE.md index 65b1f1b7d..6fdbfff2e 100644 --- a/.github/INCIDENT_RESPONSE.md +++ b/.github/INCIDENT_RESPONSE.md @@ -61,7 +61,21 @@ This plan covers: --- -## 3. Roles +## 3. Definitions + +| Term | Meaning | +|---|---| +| **Incident** | Any event that compromises or threatens the confidentiality, integrity, or availability of Pillow's code, release artifacts, or infrastructure. | +| **Vulnerability** | A security flaw in Pillow or a bundled library that can be exploited by a crafted image or API call. | +| **Incident Lead** | The maintainer who owns coordination of the response from triage to closure. | +| **Embargo** | A period during which fix details are kept private to allow coordinated patching before public disclosure. | +| **Yank** | A PyPI action that keeps a release downloadable by pinned users but removes it from default `pip install` resolution. | +| **CVE** | Common Vulnerabilities and Exposures — a public identifier assigned to a specific vulnerability. | +| **CNA** | CVE Numbering Authority — GitHub is a CNA and can assign CVEs directly through the advisory workflow. | + +--- + +## 4. Roles | Role | Responsibility | |---|---| @@ -76,7 +90,7 @@ explicitly at the start of each incident to avoid gaps. --- -## 4. Severity Classification +## 5. Severity Classification Use the [CVSS v3.1](https://www.first.org/cvss/v3.1/specification-document) base score as a guide, mapped to the following levels: @@ -90,9 +104,11 @@ a guide, mapped to the following levels: Supply-chain and CI/CD incidents are always treated as **Critical** regardless of CVSS. +> **Note:** These are good-faith targets for a small volunteer maintainer team, not contractual SLAs. Public safety and transparency will always be prioritised, even when timing varies. + --- -## 5. Detection Sources +## 6. Detection Sources Vulnerabilities and incidents may be reported or discovered through: @@ -106,9 +122,9 @@ Vulnerabilities and incidents may be reported or discovered through: --- -## 6. Response Process +## 7. Response Process -### 6.1 Triage (all severities) +### 7.1 Triage (all severities) 1. **Acknowledge receipt** to the reporter within **72 hours** using the template in [Appendix A](#appendix-a-communication-templates). Ask the reporter: @@ -130,7 +146,7 @@ Vulnerabilities and incidents may be reported or discovered through: - A legal concern arises (e.g. GDPR-reportable data exposure) → contact the project's legal/fiscal sponsor - The Incident Lead is unreachable for > 24 hours on a Critical issue → any other maintainer may assume the role -### 6.2 Fix Development +### 7.2 Fix Development 1. Develop the fix in a **private fork** or directly in the private security advisory workspace on GitHub. Do **not** push to a public branch before the embargo lifts. @@ -141,7 +157,7 @@ Vulnerabilities and incidents may be reported or discovered through: ``` 4. Review the patch with at least one other maintainer. -### 6.3 Standard (Non-Embargoed) Release +### 7.3 Standard (Non-Embargoed) Release For Medium and Low severity, or when no distro pre-notification is needed: @@ -152,7 +168,7 @@ For Medium and Low severity, or when no distro pre-notification is needed: 4. Publish the GitHub Security Advisory (this simultaneously publishes the CVE). 5. Announce on [Mastodon](https://fosstodon.org/@pillow). -### 6.4 Embargoed Release +### 7.4 Embargoed Release For Critical and High severity where distro pre-notification improves user safety: @@ -169,7 +185,7 @@ For Critical and High severity where distro pre-notification improves user safet - Publish the GitHub Security Advisory. - Announce on [Mastodon](https://fosstodon.org/@pillow). -### 6.5 Rollback Procedures +### 7.5 Rollback Procedures If a security patch introduces a critical regression after release: @@ -182,10 +198,10 @@ If a security patch introduces a critical regression after release: that the release has been yanked. 3. If the previous (vulnerable) version was also yanked, **un-yank it temporarily** so users have a functional fallback while the corrected release is prepared. -4. Prepare a corrected point release (incrementing the patch version), repeating §6.2–§6.3. +4. Prepare a corrected point release (incrementing the patch version), repeating §7.2–§7.3. 5. Document the regression in the post-incident review (§9). -### 6.6 Supply-Chain / Infrastructure Compromise +### 7.6 Supply-Chain / Infrastructure Compromise 1. **Immediately** revoke any potentially compromised credentials: - PyPI API tokens (regenerate and update in GitHub secrets) @@ -200,9 +216,19 @@ If a security patch introduces a critical regression after release: 4. Notify GitHub Security if repository access or Actions secrets are involved. 5. Issue a public advisory describing the scope and any user action required. +### 7.7 Recovery + +After the fix is released and the advisory is public: + +1. Verify that the patched wheels are live on PyPI and passing CI across all supported platforms. +2. Confirm any yanked releases are handled correctly (re-yank if un-yanked as a fallback during rollback). +3. Resume normal development operations on `main`. +4. Monitor the GitHub issue tracker and Mastodon for user reports of residual problems for at least **72 hours** post-release. +5. Close the private GitHub Security Advisory once recovery is confirmed. + --- -## 7. Communication +## 8. Communication ### Internal (during embargo) - Use the **private GitHub Security Advisory** thread for all coordination. @@ -228,7 +254,7 @@ If a security patch introduces a critical regression after release: --- -## 8. Post-Incident Review +## 9. Post-Incident Review Within **2 weeks** of a Critical or High severity fix being released: @@ -252,12 +278,12 @@ Within **2 weeks** of a Critical or High severity fix being released: --- -## 9. Dependency Map +## 10. Dependency Map Understanding what Pillow depends on (upstream) and what depends on Pillow (downstream) is essential for scoping impact and coordinating notifications during an incident. -### 9.1 Upstream Dependencies +### 10.1 Upstream Dependencies #### Bundled C libraries (shipped in official wheels) @@ -294,7 +320,7 @@ require a Pillow point release even if Pillow's own code is unchanged. | `olefile` | Optional (`fpx`, `mic` extras) | OLE2 container parsing (FPX, MIC formats) | | `defusedxml` | Optional (`xmp` extra) | Safe XML parsing for XMP metadata | -### 9.2 Downstream Dependencies +### 10.2 Downstream Dependencies A vulnerability in Pillow can have wide impact. Notify or consider the blast radius of these downstream consumers when assessing severity and planning communications. @@ -336,7 +362,7 @@ maintainers should be notified for Critical/High issues that affect the plugin A or the formats they decode. See the [full plugin list](https://pillow.readthedocs.io/en/stable/handbook/third-party-plugins.html). -### 9.3 Responding to an Upstream Vulnerability +### 10.3 Responding to an Upstream Vulnerability When a CVE is published for a bundled C library: @@ -349,7 +375,20 @@ When a CVE is published for a bundled C library: --- -## 10. References +## 11. Plan Maintenance + +This document is a living record. It should be kept current so it is useful when an +incident actually occurs. + +- **Annual review** — revisit during the §1.3 readiness review each January. +- **Post-incident update** — if the response process revealed gaps or needed improvisation, + update this document before the post-incident review is closed (§9). +- **Ownership** — changes are approved by the Core Team and recorded in Git history. + Substantive changes should be noted in the PR description so they are easy to find later. + +--- + +## 12. References - [Security Policy](SECURITY.md) - [Release Checklist](../RELEASING.md) From 64ed4710b9282d4547ee62883e3f496f2de31fb0 Mon Sep 17 00:00:00 2001 From: Jeffrey 'Alex' Clark Date: Thu, 9 Apr 2026 16:59:41 -0400 Subject: [PATCH 090/190] Fix version support matrix to reflect main-only security policy Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/INCIDENT_RESPONSE.md | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/.github/INCIDENT_RESPONSE.md b/.github/INCIDENT_RESPONSE.md index 6fdbfff2e..921dfedd8 100644 --- a/.github/INCIDENT_RESPONSE.md +++ b/.github/INCIDENT_RESPONSE.md @@ -12,17 +12,18 @@ Maintaining readiness before an incident occurs reduces response time and errors ### 1.1 Version Support Matrix -Only the following branches receive security fixes. Reporters should verify their affected -version before filing; maintainers should cherry-pick fixes only to supported branches. +Security fixes are applied to the **latest stable release only**. Users on older versions +are expected to upgrade. This is consistent with Pillow's quarterly release cadence and +is not currently documented elsewhere — reporters should assume only the latest release +will receive a patch. -| Branch | Status | Notes | -|---|---|---| -| `main` | ✅ Active development | Always patched | -| Latest stable (e.g. `11.x`) | ✅ Security fixes | Current quarterly release series | -| Previous stable (e.g. `10.x`) | ⚠️ Critical only | One release series back; Critical CVEs only | -| Older branches | ❌ End of life | No security support; users must upgrade | +| Branch | Status | +|---|---| +| `main` / latest stable | ✅ Security fixes applied | +| All older releases | ❌ No security support — please upgrade | -> Update this table with each quarterly release. +> If backport support for older releases is ever added, update this table and document it +> in [SECURITY.md](SECURITY.md). ### 1.2 Team Readiness From 4a74a20b86b9a6c4836b54f336356accfdf53278 Mon Sep 17 00:00:00 2001 From: Jeffrey 'Alex' Clark Date: Thu, 9 Apr 2026 17:02:24 -0400 Subject: [PATCH 091/190] Update Readiness Review: quarterly cadence, trim checklist Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/INCIDENT_RESPONSE.md | 21 ++++++--------------- 1 file changed, 6 insertions(+), 15 deletions(-) diff --git a/.github/INCIDENT_RESPONSE.md b/.github/INCIDENT_RESPONSE.md index 921dfedd8..8e35754aa 100644 --- a/.github/INCIDENT_RESPONSE.md +++ b/.github/INCIDENT_RESPONSE.md @@ -27,24 +27,15 @@ will receive a patch. ### 1.2 Team Readiness -- Maintain a private list of current maintainer contact details (GitHub handles, email, - Mastodon) in a location accessible to all maintainers (e.g. a pinned private team - discussion or the Tidelift maintainer portal). -- Ensure at least two maintainers have admin access to: - - The GitHub repository (to manage Security Advisories) - - The [PyPI Pillow project](https://pypi.org/project/Pillow/) (to yank releases) - - The Tidelift maintainer portal -- Rotate and audit PyPI API tokens and GitHub Actions secrets at least once per year, - and immediately after any maintainer leaves the project. +The four members of the Pillow core team are in regular contact and share collective +responsibility for incident response. Any core team member may act as Incident Lead. +Contact details are known to all team members. -### 1.3 Annual Readiness Review +### 1.3 Readiness Review -Once per year (suggested: at the January quarterly release), maintainers should: +At each quarterly release, maintainers should: 1. Re-read this document and update any stale content (version table, contacts, tooling). -2. Verify the GitHub private security advisory flow still works (open and close a test advisory). -3. Confirm PyPI yank access is functional. -4. Review Dependabot and CodeQL alert settings are enabled on the repository. --- @@ -381,7 +372,7 @@ When a CVE is published for a bundled C library: This document is a living record. It should be kept current so it is useful when an incident actually occurs. -- **Annual review** — revisit during the §1.3 readiness review each January. +- **Quarterly review** — revisit during the §1.3 readiness review at each quarterly release. - **Post-incident update** — if the response process revealed gaps or needed improvisation, update this document before the post-incident review is closed (§9). - **Ownership** — changes are approved by the Core Team and recorded in Git history. From 3aa076129fa66eefa213c9708779ce36c832ed93 Mon Sep 17 00:00:00 2001 From: Jeffrey 'Alex' Clark Date: Thu, 9 Apr 2026 17:25:32 -0400 Subject: [PATCH 092/190] Remove backport comment from version support matrix Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/INCIDENT_RESPONSE.md | 3 --- 1 file changed, 3 deletions(-) diff --git a/.github/INCIDENT_RESPONSE.md b/.github/INCIDENT_RESPONSE.md index 8e35754aa..0662a3a31 100644 --- a/.github/INCIDENT_RESPONSE.md +++ b/.github/INCIDENT_RESPONSE.md @@ -22,9 +22,6 @@ will receive a patch. | `main` / latest stable | ✅ Security fixes applied | | All older releases | ❌ No security support — please upgrade | -> If backport support for older releases is ever added, update this table and document it -> in [SECURITY.md](SECURITY.md). - ### 1.2 Team Readiness The four members of the Pillow core team are in regular contact and share collective From c2ac2da31ccabc8d9c49f7bad1d36e9578b97e44 Mon Sep 17 00:00:00 2001 From: Jeffrey 'Alex' Clark Date: Thu, 9 Apr 2026 17:28:42 -0400 Subject: [PATCH 093/190] Inline Readiness Review procedure as prose Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/INCIDENT_RESPONSE.md | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/.github/INCIDENT_RESPONSE.md b/.github/INCIDENT_RESPONSE.md index 0662a3a31..186d054c6 100644 --- a/.github/INCIDENT_RESPONSE.md +++ b/.github/INCIDENT_RESPONSE.md @@ -30,9 +30,7 @@ Contact details are known to all team members. ### 1.3 Readiness Review -At each quarterly release, maintainers should: - -1. Re-read this document and update any stale content (version table, contacts, tooling). +At each quarterly release, maintainers should re-read this document and update any stale content. --- From ad582c1a8eac0c609ca84fea80d938a58dfe0597 Mon Sep 17 00:00:00 2001 From: Jeffrey 'Alex' Clark Date: Thu, 9 Apr 2026 17:38:34 -0400 Subject: [PATCH 094/190] Simplify Roles section note Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/INCIDENT_RESPONSE.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/INCIDENT_RESPONSE.md b/.github/INCIDENT_RESPONSE.md index 186d054c6..c6898969a 100644 --- a/.github/INCIDENT_RESPONSE.md +++ b/.github/INCIDENT_RESPONSE.md @@ -72,8 +72,7 @@ This plan covers: | **Communications Owner** | Drafts the GitHub Security Advisory, announces on Mastodon, notifies distros. | | **Tidelift Contact** | For reports that arrive via Tidelift, coordinate through the Tidelift security portal. | -For the typical small maintainer team, one person may fill multiple roles. Assign roles -explicitly at the start of each incident to avoid gaps. +One person may fill multiple roles. --- From e0f9e2b98ef9d4cf278117160f4aa86f7b4e44ba Mon Sep 17 00:00:00 2001 From: Jeffrey 'Alex' Clark Date: Thu, 9 Apr 2026 17:46:58 -0400 Subject: [PATCH 095/190] Fix severity classification cross-reference, remove incident lead assignment step Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/INCIDENT_RESPONSE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/INCIDENT_RESPONSE.md b/.github/INCIDENT_RESPONSE.md index c6898969a..9cb6ba9db 100644 --- a/.github/INCIDENT_RESPONSE.md +++ b/.github/INCIDENT_RESPONSE.md @@ -118,7 +118,7 @@ Vulnerabilities and incidents may be reported or discovered through: - Whether they intend to publish their own advisory, and if so, their preferred timeline - Thank them explicitly — reporters do the project a favour by disclosing privately. 2. Reproduce the issue. If the report is invalid, close it and notify the reporter. -3. Assign a severity level (Section 3) and an Incident Lead. +3. Assign a severity level ([§5 Severity Classification](#5-severity-classification)). 4. If the GitHub Security Advisory was not created by the reporter, create one now and keep it **private** until the fix is released. Add the reporter as a collaborator if they wish to be involved. From 68be7f30ff2872a474c4de9484f44061733eaf78 Mon Sep 17 00:00:00 2001 From: Jeffrey 'Alex' Clark Date: Thu, 9 Apr 2026 17:50:45 -0400 Subject: [PATCH 096/190] Remove Tidelift notification step from triage Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/INCIDENT_RESPONSE.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/INCIDENT_RESPONSE.md b/.github/INCIDENT_RESPONSE.md index 9cb6ba9db..af4217876 100644 --- a/.github/INCIDENT_RESPONSE.md +++ b/.github/INCIDENT_RESPONSE.md @@ -125,8 +125,7 @@ Vulnerabilities and incidents may be reported or discovered through: 5. **Request a CVE** through the GitHub Security Advisory workflow (GitHub is a CVE Numbering Authority — no separate MITRE form required). The CVE is reserved privately and published automatically when the advisory goes public. -6. Notify Tidelift if the severity is High or Critical. -7. **Escalation** — Escalate beyond the core maintainer team if any of the following apply: +6. **Escalation** — Escalate beyond the core maintainer team if any of the following apply: - The vulnerability is being actively exploited in the wild → notify [GitHub Security](mailto:security@github.com) and the [Python Security Response Team](https://www.python.org/news/security/) - The fix requires changes to CPython or a dependency outside Pillow's control → contact the relevant upstream immediately - A legal concern arises (e.g. GDPR-reportable data exposure) → contact the project's legal/fiscal sponsor From 3f90d5c4da6efc69641ecee639b1eba6dbb20bc7 Mon Sep 17 00:00:00 2001 From: Jeffrey 'Alex' Clark Date: Thu, 9 Apr 2026 17:53:04 -0400 Subject: [PATCH 097/190] =?UTF-8?q?Replace=20section=20sign=20(=C2=A7)=20w?= =?UTF-8?q?ith=20plain=20Section=20references?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/INCIDENT_RESPONSE.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/INCIDENT_RESPONSE.md b/.github/INCIDENT_RESPONSE.md index af4217876..5295a48db 100644 --- a/.github/INCIDENT_RESPONSE.md +++ b/.github/INCIDENT_RESPONSE.md @@ -118,7 +118,7 @@ Vulnerabilities and incidents may be reported or discovered through: - Whether they intend to publish their own advisory, and if so, their preferred timeline - Thank them explicitly — reporters do the project a favour by disclosing privately. 2. Reproduce the issue. If the report is invalid, close it and notify the reporter. -3. Assign a severity level ([§5 Severity Classification](#5-severity-classification)). +3. Assign a severity level ([Section 5: Severity Classification](#5-severity-classification)). 4. If the GitHub Security Advisory was not created by the reporter, create one now and keep it **private** until the fix is released. Add the reporter as a collaborator if they wish to be involved. @@ -183,8 +183,8 @@ If a security patch introduces a critical regression after release: that the release has been yanked. 3. If the previous (vulnerable) version was also yanked, **un-yank it temporarily** so users have a functional fallback while the corrected release is prepared. -4. Prepare a corrected point release (incrementing the patch version), repeating §7.2–§7.3. -5. Document the regression in the post-incident review (§9). +4. Prepare a corrected point release (incrementing the patch version), repeating sections 7.2–7.3. +5. Document the regression in the post-incident review (Section 9). ### 7.6 Supply-Chain / Infrastructure Compromise @@ -365,9 +365,9 @@ When a CVE is published for a bundled C library: This document is a living record. It should be kept current so it is useful when an incident actually occurs. -- **Quarterly review** — revisit during the §1.3 readiness review at each quarterly release. +- **Quarterly review** — revisit during the Section 1.3 readiness review at each quarterly release. - **Post-incident update** — if the response process revealed gaps or needed improvisation, - update this document before the post-incident review is closed (§9). + update this document before the post-incident review is closed (Section 9). - **Ownership** — changes are approved by the Core Team and recorded in Git history. Substantive changes should be noted in the PR description so they are easy to find later. From 20af4ec89c6dd568f256864c4f5407595238e3b3 Mon Sep 17 00:00:00 2001 From: Jeffrey 'Alex' Clark Date: Thu, 9 Apr 2026 17:55:11 -0400 Subject: [PATCH 098/190] Change Critical/High SLA targets to best effort Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/INCIDENT_RESPONSE.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/INCIDENT_RESPONSE.md b/.github/INCIDENT_RESPONSE.md index 5295a48db..f8cabe88d 100644 --- a/.github/INCIDENT_RESPONSE.md +++ b/.github/INCIDENT_RESPONSE.md @@ -83,8 +83,8 @@ a guide, mapped to the following levels: | Severity | CVSS | Definition | Target Response SLA | |---|---|---|---| -| **Critical** | 9.0 – 10.0 | Remote code execution, arbitrary write, or complete integrity/confidentiality loss achievable by opening a crafted image | 48 hours to patch; embargoed release where possible | -| **High** | 7.0 – 8.9 | Heap/stack buffer overflow, use-after-free, or significant information disclosure | 7 days to patch | +| **Critical** | 9.0 – 10.0 | Remote code execution, arbitrary write, or complete integrity/confidentiality loss achievable by opening a crafted image | Best effort; embargoed release where possible | +| **High** | 7.0 – 8.9 | Heap/stack buffer overflow, use-after-free, or significant information disclosure | Best effort | | **Medium** | 4.0 – 6.9 | Denial of service via crafted image, out-of-bounds read, limited info disclosure | Next scheduled quarterly release, or earlier point release if needed | | **Low** | 0.1 – 3.9 | Minor information disclosure, unlikely to be exploitable in practice | Next quarterly release | From e74a89f70e419a4035428bc558e6a674ba53dab6 Mon Sep 17 00:00:00 2001 From: Jeffrey 'Alex' Clark Date: Thu, 9 Apr 2026 17:59:29 -0400 Subject: [PATCH 099/190] Trim version support matrix prose Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/INCIDENT_RESPONSE.md | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/.github/INCIDENT_RESPONSE.md b/.github/INCIDENT_RESPONSE.md index f8cabe88d..4bcd22c69 100644 --- a/.github/INCIDENT_RESPONSE.md +++ b/.github/INCIDENT_RESPONSE.md @@ -13,9 +13,7 @@ Maintaining readiness before an incident occurs reduces response time and errors ### 1.1 Version Support Matrix Security fixes are applied to the **latest stable release only**. Users on older versions -are expected to upgrade. This is consistent with Pillow's quarterly release cadence and -is not currently documented elsewhere — reporters should assume only the latest release -will receive a patch. +are expected to upgrade. Reporters should assume only the latest release will receive a patch. | Branch | Status | |---|---| From 00ff8636a27f6d25d4abb8ea52040356df77c8a1 Mon Sep 17 00:00:00 2001 From: Jeffrey 'Alex' Clark Date: Thu, 9 Apr 2026 18:01:08 -0400 Subject: [PATCH 100/190] Remove section 7.5 Rollback Procedures Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/INCIDENT_RESPONSE.md | 22 +++------------------- 1 file changed, 3 insertions(+), 19 deletions(-) diff --git a/.github/INCIDENT_RESPONSE.md b/.github/INCIDENT_RESPONSE.md index 4bcd22c69..c879e4ed9 100644 --- a/.github/INCIDENT_RESPONSE.md +++ b/.github/INCIDENT_RESPONSE.md @@ -168,23 +168,7 @@ For Critical and High severity where distro pre-notification improves user safet - Publish the GitHub Security Advisory. - Announce on [Mastodon](https://fosstodon.org/@pillow). -### 7.5 Rollback Procedures - -If a security patch introduces a critical regression after release: - -1. **Yank the release immediately** via the PyPI web interface: - [https://pypi.org/manage/project/pillow/](https://pypi.org/manage/project/pillow/) - (navigate to the release, click **"Yank"**). - Yanked releases remain downloadable by pinned users but are excluded from `pip install` - resolution, giving time to fix without leaving users unpatched. -2. Post a public notice in the GitHub release and on Mastodon explaining the regression and - that the release has been yanked. -3. If the previous (vulnerable) version was also yanked, **un-yank it temporarily** so users - have a functional fallback while the corrected release is prepared. -4. Prepare a corrected point release (incrementing the patch version), repeating sections 7.2–7.3. -5. Document the regression in the post-incident review (Section 9). - -### 7.6 Supply-Chain / Infrastructure Compromise +### 7.5 Supply-Chain / Infrastructure Compromise 1. **Immediately** revoke any potentially compromised credentials: - PyPI API tokens (regenerate and update in GitHub secrets) @@ -199,12 +183,12 @@ If a security patch introduces a critical regression after release: 4. Notify GitHub Security if repository access or Actions secrets are involved. 5. Issue a public advisory describing the scope and any user action required. -### 7.7 Recovery +### 7.6 Recovery After the fix is released and the advisory is public: 1. Verify that the patched wheels are live on PyPI and passing CI across all supported platforms. -2. Confirm any yanked releases are handled correctly (re-yank if un-yanked as a fallback during rollback). +2. Confirm any yanked releases are handled correctly . 3. Resume normal development operations on `main`. 4. Monitor the GitHub issue tracker and Mastodon for user reports of residual problems for at least **72 hours** post-release. 5. Close the private GitHub Security Advisory once recovery is confirmed. From 0d440b7d09b490701f5edf3f0f076bbfdef23afc Mon Sep 17 00:00:00 2001 From: Jeffrey 'Alex' Clark Date: Thu, 9 Apr 2026 18:04:00 -0400 Subject: [PATCH 101/190] Trim Plan Maintenance section Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/INCIDENT_RESPONSE.md | 30 +----------------------------- 1 file changed, 1 insertion(+), 29 deletions(-) diff --git a/.github/INCIDENT_RESPONSE.md b/.github/INCIDENT_RESPONSE.md index c879e4ed9..029a69028 100644 --- a/.github/INCIDENT_RESPONSE.md +++ b/.github/INCIDENT_RESPONSE.md @@ -221,31 +221,7 @@ After the fix is released and the advisory is public: --- -## 9. Post-Incident Review - -Within **2 weeks** of a Critical or High severity fix being released: - -1. Hold a brief retrospective (async is fine for a distributed team). -2. Document the following metrics for the incident record: - - | Metric | Target | Actual | - |---|---|---| - | Time to acknowledge reporter | ≤ 72 hours | | - | Time to reproduce & assess severity | ≤ 5 days | | - | Time to develop & review fix | Varies by severity | | - | Time from report to public release | Critical ≤ 14 days; High ≤ 30 days | | - -3. Record: - - What went well - - What could be improved - - Root cause: what allowed the vulnerability to exist - - Whether any distro/downstream was impacted before the fix was available -4. File follow-up issues for any process improvements identified. -5. Update this document if the response process needs revision. - ---- - -## 10. Dependency Map +## 9. Dependency Map Understanding what Pillow depends on (upstream) and what depends on Pillow (downstream) is essential for scoping impact and coordinating notifications during an incident. @@ -348,10 +324,6 @@ This document is a living record. It should be kept current so it is useful when incident actually occurs. - **Quarterly review** — revisit during the Section 1.3 readiness review at each quarterly release. -- **Post-incident update** — if the response process revealed gaps or needed improvisation, - update this document before the post-incident review is closed (Section 9). -- **Ownership** — changes are approved by the Core Team and recorded in Git history. - Substantive changes should be noted in the PR description so they are easy to find later. --- From 80a91fdb4e90b609e1ad78a3df4cb70cd191be46 Mon Sep 17 00:00:00 2001 From: Jeffrey 'Alex' Clark Date: Thu, 9 Apr 2026 18:08:44 -0400 Subject: [PATCH 102/190] Add setuptools to Python-level dependencies Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/INCIDENT_RESPONSE.md | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/INCIDENT_RESPONSE.md b/.github/INCIDENT_RESPONSE.md index 029a69028..b82889503 100644 --- a/.github/INCIDENT_RESPONSE.md +++ b/.github/INCIDENT_RESPONSE.md @@ -259,6 +259,7 @@ require a Pillow point release even if Pillow's own code is unchanged. | Package | Required? | Purpose | |---|---|---| +| `setuptools` | Build-time only | Package build backend | | `pybind11` | Build-time only | C++ ↔ Python bindings | | `olefile` | Optional (`fpx`, `mic` extras) | OLE2 container parsing (FPX, MIC formats) | | `defusedxml` | Optional (`xmp` extra) | Safe XML parsing for XMP metadata | From 6f815c2d8d088b2d1f87621a7a0d347a5a9052f4 Mon Sep 17 00:00:00 2001 From: Jeffrey 'Alex' Clark Date: Thu, 9 Apr 2026 18:13:43 -0400 Subject: [PATCH 103/190] Clarify advisory thread purpose as reporter coordination Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/INCIDENT_RESPONSE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/INCIDENT_RESPONSE.md b/.github/INCIDENT_RESPONSE.md index b82889503..e4473ad33 100644 --- a/.github/INCIDENT_RESPONSE.md +++ b/.github/INCIDENT_RESPONSE.md @@ -198,7 +198,7 @@ After the fix is released and the advisory is public: ## 8. Communication ### Internal (during embargo) -- Use the **private GitHub Security Advisory** thread for all coordination. +- Use the **private GitHub Security Advisory** thread for coordination with the reporter. - Do not discuss details in public issues, PRs, or Gitter/IRC channels. ### External (at or after disclosure) From b579577aa0facc8bb03737a8741f81388200f6b6 Mon Sep 17 00:00:00 2001 From: Jeffrey 'Alex' Clark Date: Thu, 9 Apr 2026 18:15:25 -0400 Subject: [PATCH 104/190] Link to section 1.3 in Plan Maintenance Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/INCIDENT_RESPONSE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/INCIDENT_RESPONSE.md b/.github/INCIDENT_RESPONSE.md index e4473ad33..3eec4d4e4 100644 --- a/.github/INCIDENT_RESPONSE.md +++ b/.github/INCIDENT_RESPONSE.md @@ -324,7 +324,7 @@ When a CVE is published for a bundled C library: This document is a living record. It should be kept current so it is useful when an incident actually occurs. -- **Quarterly review** — revisit during the Section 1.3 readiness review at each quarterly release. +- **Quarterly review** — revisit during the [Section 1.3 readiness review](#13-readiness-review) at each quarterly release. --- From 55989595eaf4774347bec6f147d43a7cd5eb275f Mon Sep 17 00:00:00 2001 From: Jeffrey 'Alex' Clark Date: Thu, 9 Apr 2026 18:17:39 -0400 Subject: [PATCH 105/190] Add private channels note to internal communication guidance Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/INCIDENT_RESPONSE.md | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/INCIDENT_RESPONSE.md b/.github/INCIDENT_RESPONSE.md index 3eec4d4e4..7e556728a 100644 --- a/.github/INCIDENT_RESPONSE.md +++ b/.github/INCIDENT_RESPONSE.md @@ -199,6 +199,7 @@ After the fix is released and the advisory is public: ### Internal (during embargo) - Use the **private GitHub Security Advisory** thread for coordination with the reporter. +- Use private communication channels for all other coordination. - Do not discuss details in public issues, PRs, or Gitter/IRC channels. ### External (at or after disclosure) From 6fe81dd52e34df4cbfa1b355996e565cb6df9802 Mon Sep 17 00:00:00 2001 From: Jeffrey 'Alex' Clark Date: Thu, 9 Apr 2026 18:19:22 -0400 Subject: [PATCH 106/190] Remove Wand from downstream dependencies Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/INCIDENT_RESPONSE.md | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/INCIDENT_RESPONSE.md b/.github/INCIDENT_RESPONSE.md index 7e556728a..e02127cb6 100644 --- a/.github/INCIDENT_RESPONSE.md +++ b/.github/INCIDENT_RESPONSE.md @@ -297,7 +297,6 @@ warrant proactive notification. | [Plone](https://plone.org/) | CMS image handling | | [Jupyter / IPython](https://jupyter.org/) | Inline image display | | [ReportLab](https://www.reportlab.com/) | PDF image embedding | -| [Wand](https://docs.wand-py.org/) | Sometimes used alongside Pillow | | [Tidelift subscribers](https://tidelift.com/) | Enterprise consumers (coordinated via Tidelift) | #### Pillow ecosystem plugins From 6a0192a40afb9de0869331f3ea439190ea819436 Mon Sep 17 00:00:00 2001 From: Jeffrey 'Alex' Clark Date: Fri, 10 Apr 2026 07:40:27 -0400 Subject: [PATCH 107/190] Update .github/INCIDENT_RESPONSE.md Co-authored-by: Andrew Murray <3112309+radarhere@users.noreply.github.com> --- .github/INCIDENT_RESPONSE.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/INCIDENT_RESPONSE.md b/.github/INCIDENT_RESPONSE.md index e02127cb6..f2fd90aa7 100644 --- a/.github/INCIDENT_RESPONSE.md +++ b/.github/INCIDENT_RESPONSE.md @@ -218,7 +218,7 @@ After the fix is released and the advisory is public: - Fixed version(s) - Nature of the vulnerability (without full exploit details if still fresh) - Credit to the reporter (with their consent) -- Upgrade instructions (`pip install --upgrade Pillow`) +- Upgrade instructions (`python3 -m pip install --upgrade Pillow`) --- @@ -278,7 +278,7 @@ these downstream consumers when assessing severity and planning communications. | Fedora / RHEL / CentOS | `python3-pillow` | [Red Hat Security](https://access.redhat.com/security/) | | Alpine Linux | `py3-pillow` | [Alpine security](https://security.alpinelinux.org/) | | Arch Linux | `python-pillow` | [Arch security tracker](https://security.archlinux.org/) | -| Homebrew (macOS) | `pillow` | [Homebrew maintainers](https://github.com/Homebrew/homebrew-core) | +| Homebrew | `pillow` | [Homebrew maintainers](https://github.com/Homebrew/homebrew-core) | | conda-forge | `pillow` | [conda-forge](https://github.com/conda-forge/pillow-feedstock) | #### Major Python ecosystem consumers @@ -411,7 +411,7 @@ incident actually occurs. > > **Remediation:** > ``` -> pip install --upgrade Pillow +> python3 -m pip install --upgrade Pillow > ``` > > **Timeline:** From d016c90108ae58610f5f159bdcfc2a537cb8409b Mon Sep 17 00:00:00 2001 From: Jeffrey 'Alex' Clark Date: Fri, 10 Apr 2026 07:43:12 -0400 Subject: [PATCH 108/190] Remove active exploitation escalation bullet from incident response Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/INCIDENT_RESPONSE.md | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/INCIDENT_RESPONSE.md b/.github/INCIDENT_RESPONSE.md index f2fd90aa7..574647f3e 100644 --- a/.github/INCIDENT_RESPONSE.md +++ b/.github/INCIDENT_RESPONSE.md @@ -124,7 +124,6 @@ Vulnerabilities and incidents may be reported or discovered through: Numbering Authority — no separate MITRE form required). The CVE is reserved privately and published automatically when the advisory goes public. 6. **Escalation** — Escalate beyond the core maintainer team if any of the following apply: - - The vulnerability is being actively exploited in the wild → notify [GitHub Security](mailto:security@github.com) and the [Python Security Response Team](https://www.python.org/news/security/) - The fix requires changes to CPython or a dependency outside Pillow's control → contact the relevant upstream immediately - A legal concern arises (e.g. GDPR-reportable data exposure) → contact the project's legal/fiscal sponsor - The Incident Lead is unreachable for > 24 hours on a Critical issue → any other maintainer may assume the role From 24b12dc84f77a6976c97f8b0992ea2e9d369d0ae Mon Sep 17 00:00:00 2001 From: Jeffrey 'Alex' Clark Date: Fri, 10 Apr 2026 07:49:37 -0400 Subject: [PATCH 109/190] Combine plan maintenance into a single paragraph Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/INCIDENT_RESPONSE.md | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/.github/INCIDENT_RESPONSE.md b/.github/INCIDENT_RESPONSE.md index 574647f3e..636ce72e8 100644 --- a/.github/INCIDENT_RESPONSE.md +++ b/.github/INCIDENT_RESPONSE.md @@ -320,10 +320,7 @@ When a CVE is published for a bundled C library: ## 11. Plan Maintenance -This document is a living record. It should be kept current so it is useful when an -incident actually occurs. - -- **Quarterly review** — revisit during the [Section 1.3 readiness review](#13-readiness-review) at each quarterly release. +This document is a living record. It should be kept current so it is useful when an incident actually occurs. Revisit it during the [Section 1.3 readiness review](#13-readiness-review) at each quarterly release. --- From eda14b6c4a6dcc1aa8de8800c3f7daf793615b32 Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade <1324225+hugovk@users.noreply.github.com> Date: Fri, 10 Apr 2026 16:33:18 +0300 Subject: [PATCH 110/190] Restrict nightly Anaconda uploads to environment --- .github/workflows/wheels.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index d16c80323..6204e294c 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -264,6 +264,9 @@ jobs: needs: count-dists runs-on: ubuntu-latest name: Upload wheels to scientific-python-nightly-wheels + environment: + name: release-anaconda + url: https://anaconda.org/channels/anaconda/packages/pillow/overview steps: - uses: actions/download-artifact@v8 with: From 0cbdd2eff94f02e68e76dc9d5ec7d6060959dcea Mon Sep 17 00:00:00 2001 From: Jeffrey 'Alex' Clark Date: Fri, 10 Apr 2026 10:37:34 -0400 Subject: [PATCH 111/190] Update .github/INCIDENT_RESPONSE.md Co-authored-by: Hugo van Kemenade <1324225+hugovk@users.noreply.github.com> --- .github/INCIDENT_RESPONSE.md | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/.github/INCIDENT_RESPONSE.md b/.github/INCIDENT_RESPONSE.md index 636ce72e8..f3283ac3a 100644 --- a/.github/INCIDENT_RESPONSE.md +++ b/.github/INCIDENT_RESPONSE.md @@ -133,11 +133,7 @@ Vulnerabilities and incidents may be reported or discovered through: 1. Develop the fix in a **private fork** or directly in the private security advisory workspace on GitHub. Do **not** push to a public branch before the embargo lifts. 2. Write a regression test that fails before the fix and passes after. -3. Run the full test suite locally across all supported Python versions: - ```bash - make release-test - ``` -4. Review the patch with at least one other maintainer. +3. Review the patch with at least one other maintainer. ### 7.3 Standard (Non-Embargoed) Release From 6e1ccab749e6160cbecca0cccad068af3fead2da Mon Sep 17 00:00:00 2001 From: Jeffrey 'Alex' Clark Date: Fri, 10 Apr 2026 10:58:43 -0400 Subject: [PATCH 112/190] Address review feedback on INCIDENT_RESPONSE.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Update CVSS v3.1 to CVSS 4.0 throughout - Remove 'Direct maintainer contact' from detection sources - Fix 'before it stays public' wording for user bug reports - Simplify sections 7.3 and 7.4 to reference RELEASING.md instead of duplicating release process steps - Update RELEASING.md Point release section with security-specific steps (amend CVE in commits, publish GitHub Security Advisory) - Fix PyPI API tokens entry (remove GitHub secrets reference) - Fix 404 PyPI manage URL (use correct case and /releases/ path) - Replace security@pypi.org mailto with https://pypi.org/security/ - Remove unconfirmed 'Notify GitHub Security' bullet - Fix section numbering: 10.x → 9.x under Section 9. Dependency Map - Reorder: move 9.3 Responding to Upstream Vulnerability before 9.3 Downstream Dependencies (now 9.2 and 9.3 respectively) - Add anchor link for Section 5 reference in 9.2 - Add #plugin-list anchor to third-party plugins handbook link - Fix GitLab issue tracker URLs to use /-/work_items for libtiff, freetype2, and bzip2 - Add pyproject.toml reference for complete optional dependencies list Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/INCIDENT_RESPONSE.md | 71 ++++++++++++++++++------------------ RELEASING.md | 2 + 2 files changed, 38 insertions(+), 35 deletions(-) diff --git a/.github/INCIDENT_RESPONSE.md b/.github/INCIDENT_RESPONSE.md index f3283ac3a..b3c11a1df 100644 --- a/.github/INCIDENT_RESPONSE.md +++ b/.github/INCIDENT_RESPONSE.md @@ -76,7 +76,7 @@ One person may fill multiple roles. ## 5. Severity Classification -Use the [CVSS v3.1](https://www.first.org/cvss/v3.1/specification-document) base score as +Use the [CVSS 4.0](https://www.first.org/cvss/v4.0/specification-document) base score as a guide, mapped to the following levels: | Severity | CVSS | Definition | Target Response SLA | @@ -98,11 +98,10 @@ Vulnerabilities and incidents may be reported or discovered through: 1. **GitHub private security advisory** — preferred channel; see [SECURITY.md](SECURITY.md) 2. **Tidelift security contact** — -3. **Direct maintainer contact** — DM on Mastodon or email -4. **External researcher / coordinated disclosure** — e.g. Google Project Zero, vendor PSIRT -5. **Automated scanning** — Dependabot, GitHub code-scanning (CodeQL), CI fuzzing -6. **Distro security teams** — Debian, Red Hat, Ubuntu, Alpine may report upstream -7. **User bug report** — public issue (reassess if it has security implications before it stays public) +3. **External researcher / coordinated disclosure** — e.g. Google Project Zero, vendor PSIRT +4. **Automated scanning** — Dependabot, GitHub code-scanning (CodeQL), CI fuzzing +5. **Distro security teams** — Debian, Red Hat, Ubuntu, Alpine may report upstream +6. **User bug report** — public issue (reassess if it has security implications and convert to a private advisory if needed) --- @@ -142,9 +141,9 @@ For Medium and Low severity, or when no distro pre-notification is needed: 1. Merge the fix to `main`, then cherry-pick to all affected release branches (see [RELEASING.md — Point release](../RELEASING.md)). 2. Amend commit messages to include the CVE identifier. -3. Tag and push; the GitHub Actions "Wheels" workflow will build and upload to PyPI. +3. Follow the [Point release](../RELEASING.md#point-release) process in RELEASING.md to + tag, push, and confirm wheels are live on PyPI. 4. Publish the GitHub Security Advisory (this simultaneously publishes the CVE). -5. Announce on [Mastodon](https://fosstodon.org/@pillow). ### 7.4 Embargoed Release @@ -158,25 +157,24 @@ For Critical and High severity where distro pre-notification improves user safet or directly to individual distro security teams. 4. On the embargo date: - Amend commit messages with the CVE identifier. - - Tag and push all affected release branches (see [RELEASING.md — Embargoed release](../RELEASING.md)). - - Confirm the "Wheels" workflow has passed and wheels are live on PyPI. + - Follow the [Embargoed release](../RELEASING.md#embargoed-release) process in + RELEASING.md to tag, push, and confirm wheels are live on PyPI. - Publish the GitHub Security Advisory. - - Announce on [Mastodon](https://fosstodon.org/@pillow). ### 7.5 Supply-Chain / Infrastructure Compromise 1. **Immediately** revoke any potentially compromised credentials: - - PyPI API tokens (regenerate and update in GitHub secrets) + - PyPI API tokens - GitHub personal access tokens and OAuth apps - Codecov or other CI service tokens 2. Audit recent commits and releases for tampering: - Verify release tags against known-good SHAs - Re-inspect any wheel published since the potential compromise window -3. If a PyPI release is suspected to be tampered: yank it immediately via - [https://pypi.org/manage/project/pillow/](https://pypi.org/manage/project/pillow/); - file a report with the [PyPI security team](mailto:security@pypi.org). -4. Notify GitHub Security if repository access or Actions secrets are involved. -5. Issue a public advisory describing the scope and any user action required. +3. If a PyPI release is suspected to be tampered: yank it immediately via the + [PyPI release management page](https://pypi.org/manage/project/Pillow/releases/) + (login required); see [https://pypi.org/security/](https://pypi.org/security/) for + reporting to the PyPI security team. +4. Issue a public advisory describing the scope and any user action required. ### 7.6 Recovery @@ -222,7 +220,7 @@ After the fix is released and the advisory is public: Understanding what Pillow depends on (upstream) and what depends on Pillow (downstream) is essential for scoping impact and coordinating notifications during an incident. -### 10.1 Upstream Dependencies +### 9.1 Upstream Dependencies #### Bundled C libraries (shipped in official wheels) @@ -233,20 +231,20 @@ require a Pillow point release even if Pillow's own code is unchanged. |---|---|---| | [libjpeg-turbo](https://libjpeg-turbo.org/) | JPEG encode/decode | [GitHub](https://github.com/libjpeg-turbo/libjpeg-turbo/security) | | [libpng](http://www.libpng.org/pub/png/libpng.html) | PNG encode/decode | [SourceForge](https://sourceforge.net/p/libpng/bugs/) | -| [libtiff](https://libtiff.gitlab.io/libtiff/) | TIFF encode/decode | [GitLab](https://gitlab.com/libtiff/libtiff/-/issues) | +| [libtiff](https://libtiff.gitlab.io/libtiff/) | TIFF encode/decode | [GitLab](https://gitlab.com/libtiff/libtiff/-/work_items) | | [libwebp](https://chromium.googlesource.com/webm/libwebp) | WebP encode/decode | [Chromium tracker](https://bugs.chromium.org/p/webm/) | | [libavif](https://github.com/AOMediaCodec/libavif) | AVIF encode/decode | [GitHub](https://github.com/AOMediaCodec/libavif/security) | | [aom](https://aomedia.googlesource.com/aom/) | AV1 codec (AVIF) | [Chromium tracker](https://bugs.chromium.org/p/aomedia/) | | [dav1d](https://code.videolan.org/videolan/dav1d) | AV1 decode (AVIF) | [VideoLAN Security](https://www.videolan.org/security/) | | [openjpeg](https://www.openjpeg.org/) | JPEG 2000 encode/decode | [GitHub](https://github.com/uclouvain/openjpeg/security) | -| [freetype2](https://freetype.org/) | Font rendering | [GitLab](https://gitlab.freedesktop.org/freetype/freetype/-/issues) | +| [freetype2](https://freetype.org/) | Font rendering | [GitLab](https://gitlab.freedesktop.org/freetype/freetype/-/work_items) | | [lcms2](https://www.littlecms.com/) | ICC color management | [GitHub](https://github.com/mm2/Little-CMS) | | [harfbuzz](https://harfbuzz.github.io/) | Text shaping (via raqm) | [GitHub](https://github.com/harfbuzz/harfbuzz/security) | | [raqm](https://github.com/HOST-Oman/libraqm) | Complex text layout | [GitHub](https://github.com/HOST-Oman/libraqm) | | [fribidi](https://github.com/fribidi/fribidi) | Unicode bidi (via raqm) | [GitHub](https://github.com/fribidi/fribidi) | | [zlib](https://zlib.net/) | Deflate compression | [zlib.net](https://zlib.net/) | | [liblzma / xz-utils](https://tukaani.org/xz/) | XZ/LZMA compression | [GitHub](https://github.com/tukaani-project/xz) | -| [bzip2](https://gitlab.com/bzip2/bzip2) | BZ2 compression | [GitLab](https://gitlab.com/bzip2/bzip2/-/issues) | +| [bzip2](https://gitlab.com/bzip2/bzip2) | BZ2 compression | [GitLab](https://gitlab.com/bzip2/bzip2/-/work_items) | | [zstd](https://github.com/facebook/zstd) | Zstandard compression | [GitHub](https://github.com/facebook/zstd/security) | | [brotli](https://github.com/google/brotli) | Brotli compression | [GitHub](https://github.com/google/brotli) | | [libyuv](https://chromium.googlesource.com/libyuv/libyuv/) | YUV conversion | [Chromium tracker](https://bugs.chromium.org/p/libyuv/) | @@ -260,7 +258,21 @@ require a Pillow point release even if Pillow's own code is unchanged. | `olefile` | Optional (`fpx`, `mic` extras) | OLE2 container parsing (FPX, MIC formats) | | `defusedxml` | Optional (`xmp` extra) | Safe XML parsing for XMP metadata | -### 10.2 Downstream Dependencies +See [`pyproject.toml`](../pyproject.toml) for the complete and authoritative list of +optional dependencies. + +### 9.2 Responding to an Upstream Vulnerability + +When a CVE is published for a bundled C library: + +1. Assess whether the vulnerable code path is reachable through Pillow's API. +2. If reachable, treat as a Pillow vulnerability and follow [Section 5: Severity Classification](#5-severity-classification). +3. Update the bundled library version in the wheel build scripts and rebuild wheels. +4. Reference the upstream CVE in Pillow's release notes and GitHub Security Advisory. +5. If not reachable, document the rationale in a public issue so downstream distributors + can make informed decisions about patching their system packages. + +### 9.3 Downstream Dependencies A vulnerability in Pillow can have wide impact. Notify or consider the blast radius of these downstream consumers when assessing severity and planning communications. @@ -299,18 +311,7 @@ warrant proactive notification. Third-party plugins extend Pillow and are distributed separately on PyPI. Their maintainers should be notified for Critical/High issues that affect the plugin API or the formats they decode. See the -[full plugin list](https://pillow.readthedocs.io/en/stable/handbook/third-party-plugins.html). - -### 10.3 Responding to an Upstream Vulnerability - -When a CVE is published for a bundled C library: - -1. Assess whether the vulnerable code path is reachable through Pillow's API. -2. If reachable, treat as a Pillow vulnerability and follow Section 5. -3. Update the bundled library version in the wheel build scripts and rebuild wheels. -4. Reference the upstream CVE in Pillow's release notes and GitHub Security Advisory. -5. If not reachable, document the rationale in a public issue so downstream distributors - can make informed decisions about patching their system packages. +[full plugin list](https://pillow.readthedocs.io/en/stable/handbook/third-party-plugins.html#plugin-list). --- @@ -328,7 +329,7 @@ This document is a living record. It should be kept current so it is useful when - [Tidelift Security Contact](https://tidelift.com/security) - [GitHub: Privately reporting a security vulnerability](https://docs.github.com/en/code-security/security-advisories/guidance-on-reporting-and-writing/privately-reporting-a-security-vulnerability) - [GitHub as a CVE Numbering Authority (CNA)](https://docs.github.com/en/code-security/security-advisories/working-with-repository-security-advisories/about-repository-security-advisories) -- [FIRST CVSS v3.1 Calculator](https://www.first.org/cvss/calculator/3.1) +- [FIRST CVSS 4.0 Calculator](https://www.first.org/cvss/calculator/4.0) - [linux-distros mailing list](https://oss-security.openwall.org/wiki/mailing-lists/distros) - [OpenSSF CVD Guide](https://github.com/ossf/oss-vulnerability-guide) *(basis for this plan)* diff --git a/RELEASING.md b/RELEASING.md index 3c6188c82..469dca62a 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -19,6 +19,7 @@ Released as needed for security, installation or critical bug fixes. git checkout -t remotes/origin/5.2.x ``` * [ ] Cherry pick individual commits from `main` branch to release branch e.g. `5.2.x`, then `git push`. +* [ ] If this is a security fix: amend commits to include the CVE identifier in the commit message. * [ ] Check [GitHub Actions](https://github.com/python-pillow/Pillow/actions) to confirm passing tests in release branch e.g. `5.2.x`. * [ ] In compliance with [PEP 440](https://peps.python.org/pep-0440/), update version identifier in `src/PIL/_version.py` * [ ] Run pre-release check via `make release-test`. @@ -38,6 +39,7 @@ Released as needed for security, installation or critical bug fixes. ```bash git push ``` +* [ ] If this is a security fix: publish the [GitHub Security Advisory](https://github.com/python-pillow/Pillow/security/advisories). ## Embargoed release From fb1375d93b9399ad9bbed1d74a3dc46ff7809136 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Sat, 11 Apr 2026 08:34:08 +1000 Subject: [PATCH 113/190] Added CVEs --- docs/releasenotes/12.2.0.rst | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/releasenotes/12.2.0.rst b/docs/releasenotes/12.2.0.rst index b03afb665..0fee9fd82 100644 --- a/docs/releasenotes/12.2.0.rst +++ b/docs/releasenotes/12.2.0.rst @@ -4,8 +4,8 @@ Security ======== -Prevent FITS decompression bomb -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +:cve:`2026-40192`: Prevent FITS decompression bomb +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ When decompressing GZIP data from a FITS image, Pillow did not limit the amount of data being read, meaning that it was vulnerable to GZIP decompression bombs. This was @@ -16,9 +16,9 @@ The data being read is now limited to only the necessary amount. Fix OOB write with invalid tile extents ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -Pillow 12.1.1 added improved checks for tile extents to prevent an OOB write from -specially crafted PSD images in Pillow >= 10.3.0. However, these checks did not -consider integer overflow. This has been corrected. +Pillow 12.1.1 addressed :cve:`2026-25990` by improving checks for tile extents to +prevent an OOB write from specially crafted PSD images in Pillow >= 10.3.0. However, +these checks did not consider integer overflow. This has been corrected. Prevent PDF parsing trailer infinite loop ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ From 4b911c889ba8604ab2ef6422fee38e555530502a Mon Sep 17 00:00:00 2001 From: Andrew Murray <3112309+radarhere@users.noreply.github.com> Date: Sat, 11 Apr 2026 20:22:22 +1000 Subject: [PATCH 114/190] Correct environment URL (#9558) --- .github/workflows/wheels.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index b25b93512..a786f9939 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -266,7 +266,7 @@ jobs: name: Upload wheels to scientific-python-nightly-wheels environment: name: release-anaconda - url: https://anaconda.org/channels/anaconda/packages/pillow/overview + url: https://anaconda.org/channels/scientific-python-nightly-wheels/packages/pillow/overview steps: - uses: actions/download-artifact@v8 with: From 3a3dab8bb06840bbad405b8c2b846136ad2c51f6 Mon Sep 17 00:00:00 2001 From: Andrew Murray <3112309+radarhere@users.noreply.github.com> Date: Sun, 12 Apr 2026 15:13:32 +1000 Subject: [PATCH 115/190] Updated raqm to 0.10.5 (#9557) --- depends/install_raqm.sh | 2 +- src/thirdparty/raqm/NEWS | 18 ++++++++++++ src/thirdparty/raqm/raqm-version.h | 4 +-- src/thirdparty/raqm/raqm.c | 47 +++++++++++++++--------------- src/thirdparty/raqm/raqm.h | 4 +-- 5 files changed, 47 insertions(+), 28 deletions(-) diff --git a/depends/install_raqm.sh b/depends/install_raqm.sh index 33bb2d0a7..8730b7d83 100755 --- a/depends/install_raqm.sh +++ b/depends/install_raqm.sh @@ -2,7 +2,7 @@ # install raqm -archive=libraqm-0.10.3 +archive=libraqm-0.10.5 ./download-and-extract.sh $archive https://raw.githubusercontent.com/python-pillow/pillow-depends/main/$archive.tar.gz diff --git a/src/thirdparty/raqm/NEWS b/src/thirdparty/raqm/NEWS index fb432cffb..7d393eb14 100644 --- a/src/thirdparty/raqm/NEWS +++ b/src/thirdparty/raqm/NEWS @@ -1,3 +1,21 @@ +Overview of changes leading to 0.10.5 +Saturday, April 11, 2026 +==================================== + +Check for NULL return from malloc in couple of places. + +Overview of changes leading to 0.10.4 +Thursday, February 5, 2026 +==================================== + +Add build option to skip tests. + +Add dependency override for use as a subproject. + +Fix tests when b_ndebug=true. + +Build, CI and documentation updates. + Overview of changes leading to 0.10.3 Tuesday, August 5, 2025 ==================================== diff --git a/src/thirdparty/raqm/raqm-version.h b/src/thirdparty/raqm/raqm-version.h index f2dd61cf6..9908f06bd 100644 --- a/src/thirdparty/raqm/raqm-version.h +++ b/src/thirdparty/raqm/raqm-version.h @@ -33,9 +33,9 @@ #define RAQM_VERSION_MAJOR 0 #define RAQM_VERSION_MINOR 10 -#define RAQM_VERSION_MICRO 3 +#define RAQM_VERSION_MICRO 5 -#define RAQM_VERSION_STRING "0.10.3" +#define RAQM_VERSION_STRING "0.10.5" #define RAQM_VERSION_ATLEAST(major,minor,micro) \ ((major)*10000+(minor)*100+(micro) <= \ diff --git a/src/thirdparty/raqm/raqm.c b/src/thirdparty/raqm/raqm.c index 9ecc5cac8..88bbbfd5e 100644 --- a/src/thirdparty/raqm/raqm.c +++ b/src/thirdparty/raqm/raqm.c @@ -54,7 +54,7 @@ * @short_description: A library for complex text layout * @include: raqm.h * - * Raqm is a light weight text layout library with strong emphasis on + * Raqm is a lightweight text layout library with strong emphasis on * supporting languages and writing systems that require complex text layout. * * The main object in Raqm API is #raqm_t, it stores all the states of the @@ -338,6 +338,8 @@ _raqm_alloc_run (raqm_t *rq) else { run = malloc (sizeof (raqm_run_t)); + if (!run) + return NULL; run->font = NULL; run->buffer = NULL; } @@ -515,7 +517,7 @@ raqm_clear_contents (raqm_t *rq) * @len: the length of @text. * * Adds @text to @rq to be used for layout. It must be a valid UTF-32 text, any - * invalid character will be replaced with U+FFFD. The text should typically + * invalid characters will be replaced with U+FFFD. The text should typically * represent a full paragraph, since doing the layout of chunks of text * separately can give improper output. * @@ -765,13 +767,13 @@ raqm_set_par_direction (raqm_t *rq, * raqm_set_language: * @rq: a #raqm_t. * @lang: a BCP47 language code. - * @start: index of first character that should use @face. + * @start: index of the first character that should use @face. * @len: number of characters using @face. * * Sets a [BCP47 language * code](https://www.w3.org/International/articles/language-tags/) to be used - * for @len-number of characters staring at @start. The @start and @len are - * input string array indices (i.e. counting bytes in UTF-8 and scaler values + * for @len-number of characters starting at @start. The @start and @len are + * input string array indices (i.e. counting bytes in UTF-8 and scalar values * in UTF-32). * * This method can be used repeatedly to set different languages for different @@ -951,7 +953,7 @@ raqm_set_freetype_face (raqm_t *rq, * raqm_set_freetype_face_range: * @rq: a #raqm_t. * @face: an #FT_Face. - * @start: index of first character that should use @face from the input string. + * @start: index of the first character that should use @face from the input string. * @len: number of elements using @face. * * Sets an #FT_Face to be used for @len-number of characters staring at @start. @@ -962,7 +964,7 @@ raqm_set_freetype_face (raqm_t *rq, * * This method can be used repeatedly to set different faces for different * parts of the text. It is the responsibility of the client to make sure that - * face ranges cover the whole text, and is properly aligned. + * face ranges cover the whole text, and are properly aligned. * * See also raqm_set_freetype_face(). * @@ -1023,9 +1025,6 @@ _raqm_set_freetype_load_flags (raqm_t *rq, * Sets the load flags passed to FreeType when loading glyphs, should be the * same flags used by the client when rendering FreeType glyphs. * - * This requires version of HarfBuzz that has hb_ft_font_set_load_flags(), for - * older version the flags will be ignored. - * * Return value: * `true` if no errors happened, `false` otherwise. * @@ -1042,22 +1041,19 @@ raqm_set_freetype_load_flags (raqm_t *rq, * raqm_set_freetype_load_flags_range: * @rq: a #raqm_t. * @flags: FreeType load flags. - * @start: index of first character that should use @flags. + * @start: index of the first character that should use @flags. * @len: number of characters using @flags. * * Sets the load flags passed to FreeType when loading glyphs for @len-number * of characters staring at @start. Flags should be the same as used by the * client when rendering corresponding FreeType glyphs. The @start and @len - * are input string array indices (i.e. counting bytes in UTF-8 and scaler + * are input string array indices (i.e. counting bytes in UTF-8 and scalar * values in UTF-32). * * This method can be used repeatedly to set different flags for different * parts of the text. It is the responsibility of the client to make sure that * flag ranges cover the whole text. * - * This requires version of HarfBuzz that has hb_ft_font_set_load_flags(), for - * older version the flags will be ignored. - * * See also raqm_set_freetype_load_flags(). * * Return value: @@ -1143,7 +1139,7 @@ _raqm_set_spacing (raqm_t *rq, * raqm_set_letter_spacing_range: * @rq: a #raqm_t. * @spacing: amount of spacing in Freetype Font Units (26.6 format). - * @start: index of first character that should use @spacing. + * @start: index of the first character that should use @spacing. * @len: number of characters using @spacing. * * Set the letter spacing or tracking for a given range, the value @@ -1200,12 +1196,12 @@ raqm_set_letter_spacing_range(raqm_t *rq, * raqm_set_word_spacing_range: * @rq: a #raqm_t. * @spacing: amount of spacing in Freetype Font Units (26.6 format). - * @start: index of first character that should use @spacing. + * @start: index of the first character that should use @spacing. * @len: number of characters using @spacing. * * Set the word spacing for a given range. Word spacing will only be applied to * 'word separator' characters, such as 'space', 'no break space' and - * Ethiopic word separator'. + * 'Ethiopic word separator'. * The value will be added onto the advance and offset for RTL, and the advance * for other directions. * @@ -1239,7 +1235,7 @@ raqm_set_word_spacing_range(raqm_t *rq, * @rq: a #raqm_t. * @gid: glyph id to use for invisible glyphs. * - * Sets the glyph id to be used for invisible glyhphs. + * Sets the glyph id to be used for invisible glyphs. * * If @gid is negative, invisible glyphs will be suppressed from the output. * @@ -1629,6 +1625,11 @@ _raqm_reorder_runs (const FriBidiCharType *types, } runs = malloc (sizeof (_raqm_bidi_run) * count); + if (!runs) + { + *run_count = 0; + return NULL; + } while (run_start < len) { @@ -2747,10 +2748,10 @@ raqm_version_string (void) * @minor: Library minor version component. * @micro: Library micro version component. * - * Checks if library version is less than or equal the specified version. + * Checks if library version is less than or equal to the specified version. * * Return value: - * `true` if library version is less than or equal the specified version, + * `true` if library version is less than or equal to the specified version, * `false` otherwise. * * Since: 0.7 @@ -2769,10 +2770,10 @@ raqm_version_atleast (unsigned int major, * @minor: Library minor version component. * @micro: Library micro version component. * - * Checks if library version is less than or equal the specified version. + * Checks if library version is less than or equal to the specified version. * * Return value: - * `true` if library version is less than or equal the specified version, + * `true` if library version is less than or equal to the specified version, * `false` otherwise. * * Since: 0.7 diff --git a/src/thirdparty/raqm/raqm.h b/src/thirdparty/raqm/raqm.h index 6fd6089c7..4c75f9d46 100644 --- a/src/thirdparty/raqm/raqm.h +++ b/src/thirdparty/raqm/raqm.h @@ -48,7 +48,7 @@ extern "C" { /** * raqm_t: * - * This is the main object holding all state of the currently processed text as + * This is the main object holding all the states of the currently processed text as * well as its output. * * Since: 0.1 @@ -81,7 +81,7 @@ typedef enum * @y_advance: the glyph advance width in vertical text. * @x_offset: the horizontal movement of the glyph from the current point. * @y_offset: the vertical movement of the glyph from the current point. - * @cluster: the index of original character in input text. + * @cluster: the index of the original character in the input text. * @ftface: the @FT_Face of the glyph. * * The structure that holds information about output glyphs, returned from From a49c63208a1e6a7a8c13b9ee8337e9ef5449054e Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade <1324225+hugovk@users.noreply.github.com> Date: Sun, 25 Jan 2026 18:38:35 +0200 Subject: [PATCH 116/190] Move dependency versions to single JSON and enable Renovate --- .github/dependencies.json | 19 +++ .github/renovate.json | 163 ++++++++++++++++++++++- .github/workflows/cifuzz.yml | 2 + .github/workflows/wheels-dependencies.sh | 33 ++--- .github/workflows/wheels.yml | 2 + winbuild/build_prepare.py | 35 +++-- 6 files changed, 218 insertions(+), 36 deletions(-) create mode 100644 .github/dependencies.json diff --git a/.github/dependencies.json b/.github/dependencies.json new file mode 100644 index 000000000..0f61b7817 --- /dev/null +++ b/.github/dependencies.json @@ -0,0 +1,19 @@ +{ + "brotli": "1.2.0", + "bzip2": "1.0.8", + "freetype": "2.14.3", + "fribidi": "1.0.16", + "harfbuzz": "13.2.1", + "jpegturbo": "3.1.4.1", + "lcms2": "2.18", + "libavif": "1.4.1", + "libimagequant": "4.4.1", + "libpng": "1.6.56", + "libwebp": "1.6.0", + "libxcb": "1.17.0", + "openjpeg": "2.5.4", + "tiff": "4.7.1", + "xz": "5.8.3", + "zlib-ng": "2.3.3", + "zstd": "1.5.7" +} diff --git a/.github/renovate.json b/.github/renovate.json index 8187fc15b..212959be6 100644 --- a/.github/renovate.json +++ b/.github/renovate.json @@ -7,16 +7,167 @@ "Dependency" ], "minimumReleaseAge": "7 days", + "schedule": [ + "* * 3 * *" + ], + "customManagers": [ + { + "customType": "regex", + "managerFilePatterns": ["/^\\.github/dependencies\\.json$/"], + "matchStrings": ["\"brotli\":\\s*\"(?\\d+[^\"]*?)\""], + "depNameTemplate": "brotli", + "packageNameTemplate": "google/brotli", + "datasourceTemplate": "github-releases", + "extractVersionTemplate": "^v(?.+)$" + }, + { + "customType": "regex", + "managerFilePatterns": ["/^\\.github/dependencies\\.json$/"], + "matchStrings": ["\"fribidi\":\\s*\"(?\\d+[^\"]*?)\""], + "depNameTemplate": "fribidi", + "packageNameTemplate": "fribidi/fribidi", + "datasourceTemplate": "github-releases", + "extractVersionTemplate": "^v(?.+)$" + }, + { + "customType": "regex", + "managerFilePatterns": ["/^\\.github/dependencies\\.json$/"], + "matchStrings": ["\"harfbuzz\":\\s*\"(?\\d+[^\"]*?)\""], + "depNameTemplate": "harfbuzz", + "packageNameTemplate": "harfbuzz/harfbuzz", + "datasourceTemplate": "github-releases" + }, + { + "customType": "regex", + "managerFilePatterns": ["/^\\.github/dependencies\\.json$/"], + "matchStrings": ["\"jpegturbo\":\\s*\"(?\\d+[^\"]*?)\""], + "depNameTemplate": "jpegturbo", + "packageNameTemplate": "libjpeg-turbo/libjpeg-turbo", + "datasourceTemplate": "github-releases" + }, + { + "customType": "regex", + "managerFilePatterns": ["/^\\.github/dependencies\\.json$/"], + "matchStrings": ["\"lcms2\":\\s*\"(?\\d+[^\"]*?)\""], + "depNameTemplate": "lcms2", + "packageNameTemplate": "mm2/Little-CMS", + "datasourceTemplate": "github-releases", + "extractVersionTemplate": "^lcms(?.+)$" + }, + { + "customType": "regex", + "managerFilePatterns": ["/^\\.github/dependencies\\.json$/"], + "matchStrings": ["\"libavif\":\\s*\"(?\\d+[^\"]*?)\""], + "depNameTemplate": "libavif", + "packageNameTemplate": "AOMediaCodec/libavif", + "datasourceTemplate": "github-releases", + "extractVersionTemplate": "^v(?.+)$" + }, + { + "customType": "regex", + "managerFilePatterns": ["/^\\.github/dependencies\\.json$/"], + "matchStrings": ["\"libimagequant\":\\s*\"(?\\d+[^\"]*?)\""], + "depNameTemplate": "libimagequant", + "packageNameTemplate": "ImageOptim/libimagequant", + "datasourceTemplate": "github-tags" + }, + { + "customType": "regex", + "managerFilePatterns": ["/^\\.github/dependencies\\.json$/"], + "matchStrings": ["\"libwebp\":\\s*\"(?\\d+[^\"]*?)\""], + "depNameTemplate": "libwebp", + "packageNameTemplate": "webmproject/libwebp", + "datasourceTemplate": "github-tags", + "extractVersionTemplate": "^v(?.+)$" + }, + { + "customType": "regex", + "managerFilePatterns": ["/^\\.github/dependencies\\.json$/"], + "matchStrings": ["\"openjpeg\":\\s*\"(?\\d+[^\"]*?)\""], + "depNameTemplate": "openjpeg", + "packageNameTemplate": "uclouvain/openjpeg", + "datasourceTemplate": "github-releases", + "extractVersionTemplate": "^v(?.+)$" + }, + { + "customType": "regex", + "managerFilePatterns": ["/^\\.github/dependencies\\.json$/"], + "matchStrings": ["\"tiff\":\\s*\"(?\\d+[^\"]*?)\""], + "depNameTemplate": "tiff", + "packageNameTemplate": "libsdl-org/libtiff", + "datasourceTemplate": "github-tags", + "extractVersionTemplate": "^v(?.+)$" + }, + { + "customType": "regex", + "managerFilePatterns": ["/^\\.github/dependencies\\.json$/"], + "matchStrings": ["\"xz\":\\s*\"(?\\d+[^\"]*?)\""], + "depNameTemplate": "xz", + "packageNameTemplate": "tukaani-project/xz", + "datasourceTemplate": "github-releases", + "extractVersionTemplate": "^v(?.+)$" + }, + { + "customType": "regex", + "managerFilePatterns": ["/^\\.github/dependencies\\.json$/"], + "matchStrings": ["\"zlib-ng\":\\s*\"(?\\d+[^\"]*?)\""], + "depNameTemplate": "zlib-ng", + "packageNameTemplate": "zlib-ng/zlib-ng", + "datasourceTemplate": "github-releases" + }, + { + "customType": "regex", + "managerFilePatterns": ["/^\\.github/dependencies\\.json$/"], + "matchStrings": ["\"zstd\":\\s*\"(?\\d+[^\"]*?)\""], + "depNameTemplate": "zstd", + "packageNameTemplate": "facebook/zstd", + "datasourceTemplate": "github-releases", + "extractVersionTemplate": "^v(?.+)$" + }, + { + "customType": "regex", + "managerFilePatterns": ["/^\\.github/dependencies\\.json$/"], + "matchStrings": ["\"freetype\":\\s*\"(?\\d+[^\"]*?)\""], + "depNameTemplate": "freetype", + "packageNameTemplate": "freetype/freetype", + "datasourceTemplate": "github-tags", + "extractVersionTemplate": "^VER-(?[\\d-]+)$", + "versioningTemplate": "regex:^(?\\d+)[.-](?\\d+)[.-](?\\d+)$" + }, + { + "customType": "regex", + "managerFilePatterns": ["/^\\.github/dependencies\\.json$/"], + "matchStrings": ["\"libpng\":\\s*\"(?\\d+[^\"]*?)\""], + "depNameTemplate": "libpng", + "packageNameTemplate": "pnggroup/libpng", + "datasourceTemplate": "github-tags", + "extractVersionTemplate": "^v(?.+)$" + }, + { + "customType": "regex", + "managerFilePatterns": ["/^\\.github/dependencies\\.json$/"], + "matchStrings": ["\"libxcb\":\\s*\"(?\\d+[^\"]*?)\""], + "depNameTemplate": "libxcb", + "packageNameTemplate": "xorg/lib/libxcb", + "datasourceTemplate": "gitlab-tags", + "registryUrlTemplate": "https://gitlab.freedesktop.org", + "extractVersionTemplate": "^libxcb-(?.+)$" + }, + { + "customType": "regex", + "managerFilePatterns": ["/^\\.github/dependencies\\.json$/"], + "matchStrings": ["\"bzip2\":\\s*\"(?\\d+[^\"]*?)\""], + "depNameTemplate": "bzip2", + "packageNameTemplate": "bzip2/bzip2", + "datasourceTemplate": "gitlab-tags", + "extractVersionTemplate": "^bzip2-(?.+)$" + } + ], "packageRules": [ { "groupName": "github-actions", - "matchManagers": [ - "github-actions" - ], + "matchManagers": ["github-actions"], "separateMajorMinor": false } - ], - "schedule": [ - "* * 3 * *" ] } diff --git a/.github/workflows/cifuzz.yml b/.github/workflows/cifuzz.yml index 263700780..b92e88e06 100644 --- a/.github/workflows/cifuzz.yml +++ b/.github/workflows/cifuzz.yml @@ -5,12 +5,14 @@ on: branches: - "**" paths: + - ".github/dependencies.json" - ".github/workflows/cifuzz.yml" - ".github/workflows/wheels-dependencies.sh" - "**.c" - "**.h" pull_request: paths: + - ".github/dependencies.json" - ".github/workflows/cifuzz.yml" - ".github/workflows/wheels-dependencies.sh" - "**.c" diff --git a/.github/workflows/wheels-dependencies.sh b/.github/workflows/wheels-dependencies.sh index 7750a2e07..12593b3f5 100755 --- a/.github/workflows/wheels-dependencies.sh +++ b/.github/workflows/wheels-dependencies.sh @@ -89,22 +89,23 @@ fi ARCHIVE_SDIR=pillow-depends-main -# Package versions for fresh source builds. -FREETYPE_VERSION=2.14.3 -HARFBUZZ_VERSION=13.2.1 -LIBPNG_VERSION=1.6.56 -JPEGTURBO_VERSION=3.1.4.1 -OPENJPEG_VERSION=2.5.4 -XZ_VERSION=5.8.3 -ZSTD_VERSION=1.5.7 -TIFF_VERSION=4.7.1 -LCMS2_VERSION=2.18 -ZLIB_NG_VERSION=2.3.3 -LIBWEBP_VERSION=1.6.0 -BZIP2_VERSION=1.0.8 -LIBXCB_VERSION=1.17.0 -BROTLI_VERSION=1.2.0 -LIBAVIF_VERSION=1.4.1 +VERSIONS_FILE="$PROJECTDIR/.github/dependencies.json" +_get_ver() { python3 -c "import json; print(json.load(open('$VERSIONS_FILE'))['$1'])"; } +FREETYPE_VERSION=$(_get_ver freetype) +HARFBUZZ_VERSION=$(_get_ver harfbuzz) +LIBPNG_VERSION=$(_get_ver libpng) +JPEGTURBO_VERSION=$(_get_ver jpegturbo) +OPENJPEG_VERSION=$(_get_ver openjpeg) +XZ_VERSION=$(_get_ver xz) +ZSTD_VERSION=$(_get_ver zstd) +TIFF_VERSION=$(_get_ver tiff) +LCMS2_VERSION=$(_get_ver lcms2) +ZLIB_NG_VERSION=$(_get_ver zlib-ng) +LIBWEBP_VERSION=$(_get_ver libwebp) +BZIP2_VERSION=$(_get_ver bzip2) +LIBXCB_VERSION=$(_get_ver libxcb) +BROTLI_VERSION=$(_get_ver brotli) +LIBAVIF_VERSION=$(_get_ver libavif) function build_pkg_config { if [ -e pkg-config-stamp ]; then return; fi diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index a786f9939..415f7eb29 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -12,6 +12,7 @@ on: push: paths: - ".ci/requirements-cibw.txt" + - ".github/dependencies.json" - ".github/workflows/wheel*" - "pyproject.toml" - "setup.py" @@ -23,6 +24,7 @@ on: pull_request: paths: - ".ci/requirements-cibw.txt" + - ".github/dependencies.json" - ".github/workflows/wheel*" - "pyproject.toml" - "setup.py" diff --git a/winbuild/build_prepare.py b/winbuild/build_prepare.py index 3b16da58a..f659479e6 100644 --- a/winbuild/build_prepare.py +++ b/winbuild/build_prepare.py @@ -1,6 +1,7 @@ from __future__ import annotations import argparse +import json import os import platform import re @@ -8,6 +9,7 @@ import shutil import struct import subprocess import sys +from pathlib import Path from typing import Any @@ -112,21 +114,26 @@ ARCHITECTURES = { "ARM64": {"vcvars_arch": "x86_arm64", "msbuild_arch": "ARM64"}, } +_versions = json.loads( + (Path(__file__).parents[1] / ".github" / "dependencies.json").read_text() +) + + V = { - "BROTLI": "1.2.0", - "FREETYPE": "2.14.3", - "FRIBIDI": "1.0.16", - "HARFBUZZ": "13.2.1", - "JPEGTURBO": "3.1.4.1", - "LCMS2": "2.18", - "LIBAVIF": "1.4.1", - "LIBIMAGEQUANT": "4.4.1", - "LIBPNG": "1.6.56", - "LIBWEBP": "1.6.0", - "OPENJPEG": "2.5.4", - "TIFF": "4.7.1", - "XZ": "5.8.3", - "ZLIBNG": "2.3.3", + "BROTLI": _versions["brotli"], + "FREETYPE": _versions["freetype"], + "FRIBIDI": _versions["fribidi"], + "HARFBUZZ": _versions["harfbuzz"], + "JPEGTURBO": _versions["jpegturbo"], + "LCMS2": _versions["lcms2"], + "LIBAVIF": _versions["libavif"], + "LIBIMAGEQUANT": _versions["libimagequant"], + "LIBPNG": _versions["libpng"], + "LIBWEBP": _versions["libwebp"], + "OPENJPEG": _versions["openjpeg"], + "TIFF": _versions["tiff"], + "XZ": _versions["xz"], + "ZLIBNG": _versions["zlib-ng"], } V["LIBPNG_XY"] = "".join(V["LIBPNG"].split(".")[:2]) From 65767a0cf7502dbf92fd34085514508802d07373 Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade <1324225+hugovk@users.noreply.github.com> Date: Sun, 12 Apr 2026 12:08:07 +0300 Subject: [PATCH 117/190] Use GitLab as data source for libtiff Co-authored-by: Andrew Murray <3112309+radarhere@users.noreply.github.com> --- .github/renovate.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/renovate.json b/.github/renovate.json index 212959be6..80c352139 100644 --- a/.github/renovate.json +++ b/.github/renovate.json @@ -94,8 +94,8 @@ "managerFilePatterns": ["/^\\.github/dependencies\\.json$/"], "matchStrings": ["\"tiff\":\\s*\"(?\\d+[^\"]*?)\""], "depNameTemplate": "tiff", - "packageNameTemplate": "libsdl-org/libtiff", - "datasourceTemplate": "github-tags", + "packageNameTemplate": "libtiff/libtiff", + "datasourceTemplate": "gitlab-tags", "extractVersionTemplate": "^v(?.+)$" }, { From 6dd03edba80dd10a00d8e7b2dfc19799732c3858 Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade <1324225+hugovk@users.noreply.github.com> Date: Mon, 13 Apr 2026 15:39:38 +0300 Subject: [PATCH 118/190] Use GitLab as data source for FreeType Co-authored-by: Andrew Murray <3112309+radarhere@users.noreply.github.com> --- .github/renovate.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/renovate.json b/.github/renovate.json index 80c352139..6bd1c080e 100644 --- a/.github/renovate.json +++ b/.github/renovate.json @@ -130,7 +130,8 @@ "matchStrings": ["\"freetype\":\\s*\"(?\\d+[^\"]*?)\""], "depNameTemplate": "freetype", "packageNameTemplate": "freetype/freetype", - "datasourceTemplate": "github-tags", + "datasourceTemplate": "gitlab-tags", + "registryUrlTemplate": "https://gitlab.freedesktop.org", "extractVersionTemplate": "^VER-(?[\\d-]+)$", "versioningTemplate": "regex:^(?\\d+)[.-](?\\d+)[.-](?\\d+)$" }, From ee24a1107393d5763a14256fb665303b4634c9e8 Mon Sep 17 00:00:00 2001 From: Jeffrey 'Alex' Clark Date: Tue, 14 Apr 2026 11:15:08 -0400 Subject: [PATCH 119/190] Update .github/INCIDENT_RESPONSE.md Co-authored-by: Andrew Murray <3112309+radarhere@users.noreply.github.com> --- .github/INCIDENT_RESPONSE.md | 2 +- RELEASING.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/INCIDENT_RESPONSE.md b/.github/INCIDENT_RESPONSE.md index b3c11a1df..e05673a5d 100644 --- a/.github/INCIDENT_RESPONSE.md +++ b/.github/INCIDENT_RESPONSE.md @@ -254,7 +254,7 @@ require a Pillow point release even if Pillow's own code is unchanged. | Package | Required? | Purpose | |---|---|---| | `setuptools` | Build-time only | Package build backend | -| `pybind11` | Build-time only | C++ ↔ Python bindings | +| `pybind11` | Build-time only | Compile C files in parallel | | `olefile` | Optional (`fpx`, `mic` extras) | OLE2 container parsing (FPX, MIC formats) | | `defusedxml` | Optional (`xmp` extra) | Safe XML parsing for XMP metadata | diff --git a/RELEASING.md b/RELEASING.md index 469dca62a..fcf108943 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -39,7 +39,7 @@ Released as needed for security, installation or critical bug fixes. ```bash git push ``` -* [ ] If this is a security fix: publish the [GitHub Security Advisory](https://github.com/python-pillow/Pillow/security/advisories). +* [ ] If this is a security fix: publish the [GitHub Security Advisory or Advisories](https://github.com/python-pillow/Pillow/security/advisories). ## Embargoed release From a124ed208f84b3e12c9e9cb8f8006a740783baad Mon Sep 17 00:00:00 2001 From: Jeffrey 'Alex' Clark Date: Tue, 14 Apr 2026 11:36:33 -0400 Subject: [PATCH 120/190] Update template wording --- .github/INCIDENT_RESPONSE.md | 24 +++++++++++++----------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/.github/INCIDENT_RESPONSE.md b/.github/INCIDENT_RESPONSE.md index e05673a5d..0cefea6cb 100644 --- a/.github/INCIDENT_RESPONSE.md +++ b/.github/INCIDENT_RESPONSE.md @@ -343,21 +343,23 @@ This document is a living record. It should be kept current so it is useful when > > Hi \, > -> Thank you for taking the time to report this — we genuinely appreciate it. +> Thank you for taking the time to report this issue. We appreciate it. > -> We have received your report and will assess it within the next few days. We will keep -> you updated on our progress. +> We have received your report and will review it as soon as possible. We will +> keep you updated on our progress. > -> A few quick questions so we can handle this well: -> - How would you like to be credited in the advisory? (name, handle, organisation, or anonymous) -> - Do you plan to publish your own write-up or advisory? If so, is there a disclosure date -> that works for you? +> Questions: > -> We aim to treat all vulnerability reports in line with coordinated disclosure principles. -> If you have any questions or concerns at any point, please reply to this thread. +> - How would you like to be credited in the advisory? (name, handle, +> organisation, or anonymous) +> - Do you plan to publish your own write-up or advisory? If so, do you have a +> disclosure date in mind? > -> Thanks again, -> The Pillow maintainers +> We apply coordinated disclosure principles to all vulnerability reports. If +> you have any questions or concerns at any point, please reply to this thread. +> +> Thank you again, +> The Pillow team ### A.2 Embargoed Distro Notification From 9f24881521e64310a9f888c66a8310599d20cac5 Mon Sep 17 00:00:00 2001 From: Jeffrey 'Alex' Clark Date: Tue, 14 Apr 2026 12:13:45 -0400 Subject: [PATCH 121/190] Add STRIDE threat model to security docs - Update .github/SECURITY.md with threat model summary and link to handbook - Add docs/handbook/security.rst with full STRIDE analysis (14 threats across Spoofing, Tampering, Repudiation, Information Disclosure, Denial of Service, and Elevation of Privilege categories) - Add prioritised mitigation recommendations - Link security.rst into the handbook toctree Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/SECURITY.md | 16 ++- docs/handbook/index.rst | 1 + docs/handbook/security.rst | 261 +++++++++++++++++++++++++++++++++++++ 3 files changed, 277 insertions(+), 1 deletion(-) create mode 100644 docs/handbook/security.rst diff --git a/.github/SECURITY.md b/.github/SECURITY.md index 42ff1615b..2b668cc55 100644 --- a/.github/SECURITY.md +++ b/.github/SECURITY.md @@ -1,7 +1,21 @@ # Security policy +## Reporting a vulnerability + To report sensitive vulnerability information, report it [privately on GitHub](https://github.com/python-pillow/Pillow/security/advisories/new). If you cannot use GitHub, use the [Tidelift security contact](https://tidelift.com/security). Tidelift will coordinate the fix and disclosure. -DO NOT report sensitive vulnerability information in public. +**DO NOT report sensitive vulnerability information in public.** + +## Threat model + +Pillow's primary attack surface is parsing untrusted image data. A full STRIDE threat model covering spoofing, tampering, repudiation, information disclosure, denial of service, and elevation of privilege is maintained in the [Security handbook page](https://pillow.readthedocs.io/en/stable/handbook/security.html). + +Key risks to be aware of when using Pillow to process untrusted images: + +- **Decompression bombs** — do not set `Image.MAX_IMAGE_PIXELS = None` in production. +- **EPS files invoke Ghostscript** — block EPS input at the application layer unless strictly required. +- **`ImageMath.unsafe_eval()`** — never pass user-controlled strings to this function; use `lambda_eval` instead. +- **C extension memory safety** — keep Pillow and its bundled C libraries (libjpeg, libpng, libtiff, libwebp, etc.) up to date. +- **Sandboxing** — for high-risk deployments, run image processing in a sandboxed subprocess. diff --git a/docs/handbook/index.rst b/docs/handbook/index.rst index acdeff7db..23255eb8a 100644 --- a/docs/handbook/index.rst +++ b/docs/handbook/index.rst @@ -8,3 +8,4 @@ Handbook tutorial concepts appendices + security diff --git a/docs/handbook/security.rst b/docs/handbook/security.rst new file mode 100644 index 000000000..045b9410a --- /dev/null +++ b/docs/handbook/security.rst @@ -0,0 +1,261 @@ +Security +======== + +Pillow's primary attack surface is **parsing untrusted image data**. This page +documents the threat model for developers integrating Pillow into applications +that handle images from untrusted sources, along with recommended mitigations. + +To report a vulnerability see :ref:`security-reporting`. + +.. _security-threat-model: + +Threat model (STRIDE) +--------------------- + +The analysis below follows the `STRIDE +`_ framework and covers the +boundary between untrusted image input and the Pillow API. + +.. code-block:: text + + ┌──────────────────────────────────────────┐ + Untrusted zone │ Pillow API │ + ───────────── │ │ + Image files ────►│ Image.open() ──► Format plugins │ + Byte streams │ (40+ parsers) (Python + C FFI) │ + User metadata │ │ + │ ImageMath.unsafe_eval(expr) ───────────┼──► Python eval() + │ ImageShow.show(image) ─────────────────┼──► os.system / subprocess + │ EpsImagePlugin.open(eps) ──────────────┼──► Ghostscript (gs) + └──────────────┬───────────────────────────┘ + │ C extension (_imaging) + ▼ + ┌──────────────────────────────────────────┐ + │ C libraries (bundled or system) │ + │ libjpeg · libpng · libtiff · libwebp │ + │ openjpeg · freetype · littlecms │ + └──────────────────────────────────────────┘ + +Spoofing +^^^^^^^^ + +**S-1 — Format sniffing bypass** + +``Image.open()`` detects format by magic bytes, not file extension or MIME +type. An attacker can name a file ``safe.png`` while its content is TIFF, JPEG +2000, or EPS, causing a different — potentially more dangerous — parser to run. + +*Mitigations:* validate MIME type and magic bytes independently before calling +``Image.open()``; pass the ``format`` parameter explicitly; maintain an +allowlist of accepted formats. + +**S-2 — Plugin registry spoofing** + +Pillow's format registry is a global mutable dictionary. A malicious package +installed in the same environment could register a replacement parser for a +well-known format. + +*Mitigations:* use isolated virtual environments with pinned, hash-verified +dependencies; audit ``Image.registered_extensions()`` at startup. + +Tampering +^^^^^^^^^ + +**T-1 — Malicious metadata propagation** + +Pillow preserves EXIF, XMP, IPTC, ICC profiles, and comments when +round-tripping images. Applications that store or render metadata without +sanitisation are vulnerable to second-order injection (SQLi, XSS, command +injection). + +*Mitigations:* treat all values from ``image.info``, ``image._getexif()``, and +``image.text`` as untrusted; sanitise before storing or rendering; strip +metadata when it is not required. + +**T-2 — Covert data channel (steganography)** + +Pillow does not remove hidden data (JPEG comments, PNG text chunks, appended +bytes) when re-saving. An attacker can embed data that survives the +encode-decode cycle invisibly. + +*Mitigations:* to guarantee a clean output, load pixel data via +``image.tobytes()`` and rebuild the image from raw bytes before saving. + +**T-3 — Supply chain tampering** + +Pre-compiled wheels bundle libjpeg-turbo, libpng, libtiff, libwebp, openjpeg, +freetype, and littlecms. A compromised PyPI release or build pipeline could +ship malicious binaries. + +*Mitigations:* pin with hash verification (``pip install --require-hashes``); +monitor `Pillow security advisories +`_; use +Dependabot or OSV-Scanner for bundled C library CVEs. + +Repudiation +^^^^^^^^^^^ + +**R-1 — No structured audit trail** + +Pillow does not emit structured audit logs of files opened, formats detected, +or operations performed, making forensic investigation harder after an +incident. + +*Mitigations:* applications should log the filename/hash, detected format, and +dimensions of every image processed; log and alert on +``Image.DecompressionBombWarning`` and ``PIL.UnidentifiedImageError``. + +Information disclosure +^^^^^^^^^^^^^^^^^^^^^^ + +**I-1 — Metadata in saved images** + +GPS coordinates, author names, software version strings, and ICC profiles can +be inadvertently included in output images served publicly. + +*Mitigations:* explicitly strip EXIF and XMP on save (set ``exif=b""``, +``icc_profile=None``, omit ``pnginfo``); verify output with ``exiftool`` in CI. + +**I-2 — Sensitive exception messages** + +Parser errors can include byte offsets, dimension values, and tile descriptors. +Propagating these to API responses aids attacker reconnaissance. + +*Mitigations:* catch ``PIL.UnidentifiedImageError``, +``PIL.Image.DecompressionBombError``, and general exceptions at the +application boundary; return generic messages to clients. + +**I-3 — Temporary file exposure** + +Several code paths write pixel data to temporary files via +``tempfile.mkstemp()``. Exception paths can leave these files behind on shared +filesystems. + +*Mitigations:* files are created with mode ``0o600``; mount ``/tmp`` as a +per-container ``tmpfs``; ensure ``try/finally`` cleanup is in place. + +Denial of service +^^^^^^^^^^^^^^^^^ + +**D-1 — Decompression bomb** + +A small compressed image can expand to gigabytes in memory. +:py:data:`PIL.Image.MAX_IMAGE_PIXELS` (~89 MP by default) raises +``DecompressionBombError`` at 2× the limit and +``DecompressionBombWarning`` at 1×. PNG text chunks are +separately capped by ``PngImagePlugin.MAX_TEXT_CHUNK`` (1 MiB) and +``MAX_TEXT_MEMORY`` (64 MiB). + +*Mitigations:* **never** set ``Image.MAX_IMAGE_PIXELS = None`` in production; +treat ``DecompressionBombWarning`` as an error; set OS/container memory limits +per worker. + +**D-2 — CPU exhaustion** + +Large-but-legal images (within ``MAX_IMAGE_PIXELS``) can still saturate CPU +through high-quality resampling, convolution filters, or complex draw +operations. + +*Mitigations:* apply per-request CPU time limits; set a practical dimension +ceiling below ``MAX_IMAGE_PIXELS``; rate-limit processing requests. + +**D-3 — Algorithmic complexity in parsers** + +Formats such as TIFF (nested IFD chains), animated GIF/WebP (many frames), and +PNG (many text chunks) can exhaust CPU or memory before pixel data is decoded. + +*Mitigations:* restrict accepted formats to the minimum required; enforce a +file-size limit before passing data to Pillow; use per-request timeouts. + +Elevation of privilege +^^^^^^^^^^^^^^^^^^^^^^ + +**E-1 — C extension memory corruption (RCE)** + +Pillow's ~87 C source files and its bundled C libraries process +attacker-controlled bytes. Historical CVEs include buffer overflows, integer +overflows, and use-after-free vulnerabilities that allow arbitrary code +execution. + +*Mitigations:* keep Pillow and all C libraries up to date; compile with +hardening flags (ASLR, stack canaries, PIE, ``_FORTIFY_SOURCE=2``); run image +processing in a sandboxed subprocess (seccomp-bpf, AppArmor, or a restricted +container). + +**E-2 — Ghostscript exploitation via EPS (RCE)** + +Opening an EPS file invokes the system Ghostscript binary (``gs``) via +``subprocess``. Ghostscript has a long history of sandbox-escape CVEs +permitting arbitrary code execution from malicious PostScript. + +*Mitigations:* **block EPS files** at the application input layer; if EPS must +be supported, run Ghostscript in a fully isolated sandbox with no network and +no sensitive mounts; unregister the plugin if unused:: + + from PIL import Image, EpsImagePlugin + Image.OPEN.pop("EPS", None) + +**E-3 — ``ImageMath.unsafe_eval()`` code injection** + +:py:meth:`~PIL.ImageMath.unsafe_eval` calls Python's built-in ``eval()`` with +only a minimal ``__builtins__`` restriction, which can be bypassed via +introspection. Any user-controlled string passed to this function results in +arbitrary code execution. + +*Mitigations:* **never** pass user-controlled strings to +``ImageMath.unsafe_eval()``; use :py:meth:`~PIL.ImageMath.lambda_eval` instead, +which accepts a Python callable and never calls ``eval``. + +**E-4 — Font path traversal via ``ImageFont``** + +``ImageFont.truetype(font, size)`` passes the filename to the FreeType C +library. If font paths are constructed from user input without +canonicalisation, an attacker may supply a path like +``../../../../etc/passwd``. + +*Mitigations:* never construct font paths from user input; if font selection +must be user-driven, resolve names against an explicit allowlist of +pre-validated absolute paths. + +.. _security-recommendations: + +Recommendations +--------------- + +The following mitigations are listed in priority order. + +1. **Sandbox image processing** — run Pillow workers in a seccomp/AppArmor- + restricted subprocess, isolated from the main application process. +2. **Block or sandbox EPS** — reject EPS at the application boundary, or run + Ghostscript in an isolated container. +3. **Never use** ``ImageMath.unsafe_eval()`` **with user input** — migrate all + callers to :py:meth:`~PIL.ImageMath.lambda_eval`. +4. **Keep all dependencies current** — Pillow, libjpeg, libpng, libtiff, + libwebp, openjpeg, freetype, Ghostscript. Subscribe to `Pillow security + advisories `_. +5. **Enforce** ``MAX_IMAGE_PIXELS`` — never set it to ``None``; treat + ``DecompressionBombWarning`` as an error. +6. **Allowlist image formats** — unregister plugins your application does not + need. +7. **Strip metadata on output** — never pass through EXIF/XMP/ICC from user + uploads to publicly served images. +8. **Sanitise all metadata** returned by Pillow before using it downstream. +9. **Pin dependencies with hash verification** — use + ``pip install --require-hashes`` and lockfiles. +10. **Log and alert** on ``DecompressionBombWarning``, + ``DecompressionBombError``, ``PIL.UnidentifiedImageError``, + and all exceptions from ``Image.open()``. + +.. _security-reporting: + +Reporting a vulnerability +------------------------- + +To report sensitive vulnerability information, report it `privately on GitHub +`_. + +If you cannot use GitHub, use the `Tidelift security contact +`_. Tidelift will coordinate the fix and +disclosure. + +**Do not report sensitive vulnerability information in public.** From c07f7e56a1565aa8860ebd16a157ba4ee6b27476 Mon Sep 17 00:00:00 2001 From: Jeffrey 'Alex' Clark Date: Tue, 14 Apr 2026 19:54:25 -0400 Subject: [PATCH 122/190] Add python-pillow GitHub Sponsors to FUNDING.yml Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/FUNDING.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml index 8fc6bd0ad..2a44192bf 100644 --- a/.github/FUNDING.yml +++ b/.github/FUNDING.yml @@ -1 +1,2 @@ +github: python-pillow tidelift: "pypi/pillow" From b71b4b98d900d3a8450577f49a37818c03b4a7bb Mon Sep 17 00:00:00 2001 From: Jeffrey 'Alex' Clark Date: Tue, 14 Apr 2026 19:56:59 -0400 Subject: [PATCH 123/190] Lint --- docs/handbook/security.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/handbook/security.rst b/docs/handbook/security.rst index 045b9410a..ebff8199f 100644 --- a/docs/handbook/security.rst +++ b/docs/handbook/security.rst @@ -224,7 +224,7 @@ Recommendations The following mitigations are listed in priority order. -1. **Sandbox image processing** — run Pillow workers in a seccomp/AppArmor- +1. **Sandbox image processing** — run Pillow workers in a seccomp/AppArmor restricted subprocess, isolated from the main application process. 2. **Block or sandbox EPS** — reject EPS at the application boundary, or run Ghostscript in an isolated container. From 2d89dcc7ebcd570322a728ddb35eb6509754369c Mon Sep 17 00:00:00 2001 From: Jeffrey 'Alex' Clark Date: Tue, 14 Apr 2026 22:37:55 -0400 Subject: [PATCH 124/190] Update .github/FUNDING.yml Co-authored-by: Andrew Murray <3112309+radarhere@users.noreply.github.com> --- .github/FUNDING.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml index 2a44192bf..4378368a8 100644 --- a/.github/FUNDING.yml +++ b/.github/FUNDING.yml @@ -1,2 +1,2 @@ github: python-pillow -tidelift: "pypi/pillow" +tidelift: pypi/pillow From 658d9ce258701a23d2a1a1270e88fef42a4f377c Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Wed, 15 Apr 2026 19:52:16 +1000 Subject: [PATCH 125/190] Updated wheels path regex --- .github/workflows/wheels.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index 415f7eb29..b5edfc461 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -13,7 +13,7 @@ on: paths: - ".ci/requirements-cibw.txt" - ".github/dependencies.json" - - ".github/workflows/wheel*" + - ".github/workflows/wheels*" - "pyproject.toml" - "setup.py" - "wheels/*" @@ -25,7 +25,7 @@ on: paths: - ".ci/requirements-cibw.txt" - ".github/dependencies.json" - - ".github/workflows/wheel*" + - ".github/workflows/wheels*" - "pyproject.toml" - "setup.py" - "wheels/*" From ff00aaa6d3382dc6e15dbc2b9e45463fe010d788 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Wed, 15 Apr 2026 19:59:52 +1000 Subject: [PATCH 126/190] Use keys from dependencies JSON --- winbuild/build_prepare.py | 84 +++++++++++++++------------------------ 1 file changed, 33 insertions(+), 51 deletions(-) diff --git a/winbuild/build_prepare.py b/winbuild/build_prepare.py index f659479e6..f55c82112 100644 --- a/winbuild/build_prepare.py +++ b/winbuild/build_prepare.py @@ -114,35 +114,17 @@ ARCHITECTURES = { "ARM64": {"vcvars_arch": "x86_arm64", "msbuild_arch": "ARM64"}, } -_versions = json.loads( +V = json.loads( (Path(__file__).parents[1] / ".github" / "dependencies.json").read_text() ) - - -V = { - "BROTLI": _versions["brotli"], - "FREETYPE": _versions["freetype"], - "FRIBIDI": _versions["fribidi"], - "HARFBUZZ": _versions["harfbuzz"], - "JPEGTURBO": _versions["jpegturbo"], - "LCMS2": _versions["lcms2"], - "LIBAVIF": _versions["libavif"], - "LIBIMAGEQUANT": _versions["libimagequant"], - "LIBPNG": _versions["libpng"], - "LIBWEBP": _versions["libwebp"], - "OPENJPEG": _versions["openjpeg"], - "TIFF": _versions["tiff"], - "XZ": _versions["xz"], - "ZLIBNG": _versions["zlib-ng"], -} -V["LIBPNG_XY"] = "".join(V["LIBPNG"].split(".")[:2]) +V["libpng-xy"] = "".join(V["libpng"].split(".")[:2]) # dependencies, listed in order of compilation DEPS: dict[str, dict[str, Any]] = { "libjpeg": { - "url": f"https://github.com/libjpeg-turbo/libjpeg-turbo/releases/download/{V['JPEGTURBO']}/libjpeg-turbo-{V['JPEGTURBO']}.tar.gz", - "filename": f"libjpeg-turbo-{V['JPEGTURBO']}.tar.gz", + "url": f"https://github.com/libjpeg-turbo/libjpeg-turbo/releases/download/{V['jpegturbo']}/libjpeg-turbo-{V['jpegturbo']}.tar.gz", + "filename": f"libjpeg-turbo-{V['jpegturbo']}.tar.gz", "license": ["README.ijg", "LICENSE.md"], "license_pattern": ( "(LEGAL ISSUES\n============\n\n.+?)\n\nREFERENCES\n==========" @@ -169,8 +151,8 @@ DEPS: dict[str, dict[str, Any]] = { "bins": ["djpeg.exe"], }, "zlib": { - "url": f"https://github.com/zlib-ng/zlib-ng/archive/refs/tags/{V['ZLIBNG']}.tar.gz", - "filename": f"zlib-ng-{V['ZLIBNG']}.tar.gz", + "url": f"https://github.com/zlib-ng/zlib-ng/archive/refs/tags/{V['zlib-ng']}.tar.gz", + "filename": f"zlib-ng-{V['zlib-ng']}.tar.gz", "license": "LICENSE.md", "patch": { r"CMakeLists.txt": { @@ -186,8 +168,8 @@ DEPS: dict[str, dict[str, Any]] = { "libs": [r"zlib.lib"], }, "xz": { - "url": f"https://github.com/tukaani-project/xz/releases/download/v{V['XZ']}/FILENAME", - "filename": f"xz-{V['XZ']}.tar.gz", + "url": f"https://github.com/tukaani-project/xz/releases/download/v{V['xz']}/FILENAME", + "filename": f"xz-{V['xz']}.tar.gz", "license": "COPYING", "build": [ *cmds_cmake("liblzma", "-DBUILD_SHARED_LIBS:BOOL=OFF"), @@ -199,7 +181,7 @@ DEPS: dict[str, dict[str, Any]] = { }, "libwebp": { "url": "http://downloads.webmproject.org/releases/webp/FILENAME", - "filename": f"libwebp-{V['LIBWEBP']}.tar.gz", + "filename": f"libwebp-{V['libwebp']}.tar.gz", "license": "COPYING", "patch": { r"src\enc\picture_csp_enc.c": { @@ -220,7 +202,7 @@ DEPS: dict[str, dict[str, Any]] = { }, "libtiff": { "url": "https://download.osgeo.org/libtiff/FILENAME", - "filename": f"tiff-{V['TIFF']}.tar.gz", + "filename": f"tiff-{V['tiff']}.tar.gz", "license": "LICENSE.md", "patch": { r"libtiff\tif_lzma.c": { @@ -244,22 +226,22 @@ DEPS: dict[str, dict[str, Any]] = { "libs": [r"libtiff\*.lib"], }, "libpng": { - "url": f"{SF_PROJECTS}/libpng/files/libpng{V['LIBPNG_XY']}/{V['LIBPNG']}/" + "url": f"{SF_PROJECTS}/libpng/files/libpng{V['libpng-xy']}/{V['libpng']}/" f"FILENAME/download", - "filename": f"libpng-{V['LIBPNG']}.tar.gz", + "filename": f"libpng-{V['libpng']}.tar.gz", "license": "LICENSE", "build": [ *cmds_cmake("png_static", "-DPNG_SHARED:BOOL=OFF", "-DPNG_TESTS:BOOL=OFF"), cmd_copy( - f"libpng{V['LIBPNG_XY']}_static.lib", f"libpng{V['LIBPNG_XY']}.lib" + f"libpng{V['libpng-xy']}_static.lib", f"libpng{V['libpng-xy']}.lib" ), ], "headers": [r"png*.h"], - "libs": [f"libpng{V['LIBPNG_XY']}.lib"], + "libs": [f"libpng{V['libpng-xy']}.lib"], }, "brotli": { - "url": f"https://github.com/google/brotli/archive/refs/tags/v{V['BROTLI']}.tar.gz", - "filename": f"brotli-{V['BROTLI']}.tar.gz", + "url": f"https://github.com/google/brotli/archive/refs/tags/v{V['brotli']}.tar.gz", + "filename": f"brotli-{V['brotli']}.tar.gz", "license": "LICENSE", "build": [ *cmds_cmake(("brotlicommon", "brotlidec"), "-DBUILD_SHARED_LIBS:BOOL=OFF"), @@ -269,7 +251,7 @@ DEPS: dict[str, dict[str, Any]] = { }, "freetype": { "url": "https://download.savannah.gnu.org/releases/freetype/FILENAME", - "filename": f"freetype-{V['FREETYPE']}.tar.gz", + "filename": f"freetype-{V['freetype']}.tar.gz", "license": ["LICENSE.TXT", r"docs\FTL.TXT", r"docs\GPLv2.TXT"], "patch": { r"builds\windows\vc2010\freetype.vcxproj": { @@ -282,7 +264,7 @@ DEPS: dict[str, dict[str, Any]] = { "": "FT_CONFIG_OPTION_SYSTEM_ZLIB;FT_CONFIG_OPTION_USE_PNG;FT_CONFIG_OPTION_USE_HARFBUZZ;FT_CONFIG_OPTION_USE_BROTLI", # noqa: E501 "": r"{dir_harfbuzz}\src;{inc_dir}", # noqa: E501 "": "{lib_dir}", # noqa: E501 - "": f"zlib.lib;libpng{V['LIBPNG_XY']}.lib;brotlicommon.lib;brotlidec.lib", # noqa: E501 + "": f"zlib.lib;libpng{V['libpng-xy']}.lib;brotlicommon.lib;brotlidec.lib", # noqa: E501 }, r"src/autofit/afshaper.c": { # link against harfbuzz.lib @@ -302,8 +284,8 @@ DEPS: dict[str, dict[str, Any]] = { "libs": [r"objs\{msbuild_arch}\Release Static\freetype.lib"], }, "lcms2": { - "url": f"{SF_PROJECTS}/lcms/files/lcms/{V['LCMS2']}/FILENAME/download", - "filename": f"lcms2-{V['LCMS2']}.tar.gz", + "url": f"{SF_PROJECTS}/lcms/files/lcms/{V['lcms2']}/FILENAME/download", + "filename": f"lcms2-{V['lcms2']}.tar.gz", "license": "LICENSE", "patch": { r"Projects\VC2022\lcms2_static\lcms2_static.vcxproj": { @@ -327,21 +309,21 @@ DEPS: dict[str, dict[str, Any]] = { "libs": [r"Lib\MS\*.lib"], }, "openjpeg": { - "url": f"https://github.com/uclouvain/openjpeg/archive/v{V['OPENJPEG']}.tar.gz", - "filename": f"openjpeg-{V['OPENJPEG']}.tar.gz", + "url": f"https://github.com/uclouvain/openjpeg/archive/v{V['openjpeg']}.tar.gz", + "filename": f"openjpeg-{V['openjpeg']}.tar.gz", "license": "LICENSE", "build": [ *cmds_cmake( "openjp2", "-DBUILD_CODEC:BOOL=OFF", "-DBUILD_SHARED_LIBS:BOOL=OFF" ), - cmd_mkdir(rf"{{inc_dir}}\openjpeg-{V['OPENJPEG']}"), - cmd_copy(r"src\lib\openjp2\*.h", rf"{{inc_dir}}\openjpeg-{V['OPENJPEG']}"), + cmd_mkdir(rf"{{inc_dir}}\openjpeg-{V['openjpeg']}"), + cmd_copy(r"src\lib\openjp2\*.h", rf"{{inc_dir}}\openjpeg-{V['openjpeg']}"), ], "libs": [r"bin\*.lib"], }, "libimagequant": { - "url": "https://github.com/ImageOptim/libimagequant/archive/{V['LIBIMAGEQUANT']}.tar.gz", - "filename": f"libimagequant-{V['LIBIMAGEQUANT']}.tar.gz", + "url": "https://github.com/ImageOptim/libimagequant/archive/{V['libimagequant']}.tar.gz", + "filename": f"libimagequant-{V['libimagequant']}.tar.gz", "license": "COPYRIGHT", "build": [ cmd_cd("imagequant-sys"), @@ -351,8 +333,8 @@ DEPS: dict[str, dict[str, Any]] = { "libs": [r"..\target\release\imagequant_sys.lib"], }, "harfbuzz": { - "url": f"https://github.com/harfbuzz/harfbuzz/releases/download/{V['HARFBUZZ']}/FILENAME", - "filename": f"harfbuzz-{V['HARFBUZZ']}.tar.xz", + "url": f"https://github.com/harfbuzz/harfbuzz/releases/download/{V['harfbuzz']}/FILENAME", + "filename": f"harfbuzz-{V['harfbuzz']}.tar.xz", "license": "COPYING", "build": [ *cmds_cmake( @@ -365,11 +347,11 @@ DEPS: dict[str, dict[str, Any]] = { "libs": [r"*.lib"], }, "fribidi": { - "url": f"https://github.com/fribidi/fribidi/archive/v{V['FRIBIDI']}.zip", - "filename": f"fribidi-{V['FRIBIDI']}.zip", + "url": f"https://github.com/fribidi/fribidi/archive/v{V['fribidi']}.zip", + "filename": f"fribidi-{V['fribidi']}.zip", "license": "COPYING", "build": [ - cmd_copy(r"COPYING", rf"{{bin_dir}}\fribidi-{V['FRIBIDI']}-COPYING"), + cmd_copy(r"COPYING", rf"{{bin_dir}}\fribidi-{V['fribidi']}-COPYING"), cmd_copy(r"{winbuild_dir}\fribidi.cmake", r"CMakeLists.txt"), # generated tab.i files cannot be cross-compiled " ^&^& ".join( @@ -383,8 +365,8 @@ DEPS: dict[str, dict[str, Any]] = { "bins": [r"*.dll"], }, "libavif": { - "url": f"https://github.com/AOMediaCodec/libavif/archive/v{V['LIBAVIF']}.tar.gz", - "filename": f"libavif-{V['LIBAVIF']}.tar.gz", + "url": f"https://github.com/AOMediaCodec/libavif/archive/v{V['libavif']}.tar.gz", + "filename": f"libavif-{V['libavif']}.tar.gz", "license": "LICENSE", "build": [ "rustup update", From 237ab0763c614a907389b4606db331dfcf45a89b Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Wed, 15 Apr 2026 20:25:25 +1000 Subject: [PATCH 127/190] Remove unneeded ? from matchStrings regex --- .github/renovate.json | 34 +++++++++++++++++----------------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/.github/renovate.json b/.github/renovate.json index 6bd1c080e..da998d7de 100644 --- a/.github/renovate.json +++ b/.github/renovate.json @@ -14,7 +14,7 @@ { "customType": "regex", "managerFilePatterns": ["/^\\.github/dependencies\\.json$/"], - "matchStrings": ["\"brotli\":\\s*\"(?\\d+[^\"]*?)\""], + "matchStrings": ["\"brotli\":\\s*\"(?\\d+[^\"]*)\""], "depNameTemplate": "brotli", "packageNameTemplate": "google/brotli", "datasourceTemplate": "github-releases", @@ -23,7 +23,7 @@ { "customType": "regex", "managerFilePatterns": ["/^\\.github/dependencies\\.json$/"], - "matchStrings": ["\"fribidi\":\\s*\"(?\\d+[^\"]*?)\""], + "matchStrings": ["\"fribidi\":\\s*\"(?\\d+[^\"]*)\""], "depNameTemplate": "fribidi", "packageNameTemplate": "fribidi/fribidi", "datasourceTemplate": "github-releases", @@ -32,7 +32,7 @@ { "customType": "regex", "managerFilePatterns": ["/^\\.github/dependencies\\.json$/"], - "matchStrings": ["\"harfbuzz\":\\s*\"(?\\d+[^\"]*?)\""], + "matchStrings": ["\"harfbuzz\":\\s*\"(?\\d+[^\"]*)\""], "depNameTemplate": "harfbuzz", "packageNameTemplate": "harfbuzz/harfbuzz", "datasourceTemplate": "github-releases" @@ -40,7 +40,7 @@ { "customType": "regex", "managerFilePatterns": ["/^\\.github/dependencies\\.json$/"], - "matchStrings": ["\"jpegturbo\":\\s*\"(?\\d+[^\"]*?)\""], + "matchStrings": ["\"jpegturbo\":\\s*\"(?\\d+[^\"]*)\""], "depNameTemplate": "jpegturbo", "packageNameTemplate": "libjpeg-turbo/libjpeg-turbo", "datasourceTemplate": "github-releases" @@ -48,7 +48,7 @@ { "customType": "regex", "managerFilePatterns": ["/^\\.github/dependencies\\.json$/"], - "matchStrings": ["\"lcms2\":\\s*\"(?\\d+[^\"]*?)\""], + "matchStrings": ["\"lcms2\":\\s*\"(?\\d+[^\"]*)\""], "depNameTemplate": "lcms2", "packageNameTemplate": "mm2/Little-CMS", "datasourceTemplate": "github-releases", @@ -57,7 +57,7 @@ { "customType": "regex", "managerFilePatterns": ["/^\\.github/dependencies\\.json$/"], - "matchStrings": ["\"libavif\":\\s*\"(?\\d+[^\"]*?)\""], + "matchStrings": ["\"libavif\":\\s*\"(?\\d+[^\"]*)\""], "depNameTemplate": "libavif", "packageNameTemplate": "AOMediaCodec/libavif", "datasourceTemplate": "github-releases", @@ -66,7 +66,7 @@ { "customType": "regex", "managerFilePatterns": ["/^\\.github/dependencies\\.json$/"], - "matchStrings": ["\"libimagequant\":\\s*\"(?\\d+[^\"]*?)\""], + "matchStrings": ["\"libimagequant\":\\s*\"(?\\d+[^\"]*)\""], "depNameTemplate": "libimagequant", "packageNameTemplate": "ImageOptim/libimagequant", "datasourceTemplate": "github-tags" @@ -74,7 +74,7 @@ { "customType": "regex", "managerFilePatterns": ["/^\\.github/dependencies\\.json$/"], - "matchStrings": ["\"libwebp\":\\s*\"(?\\d+[^\"]*?)\""], + "matchStrings": ["\"libwebp\":\\s*\"(?\\d+[^\"]*)\""], "depNameTemplate": "libwebp", "packageNameTemplate": "webmproject/libwebp", "datasourceTemplate": "github-tags", @@ -83,7 +83,7 @@ { "customType": "regex", "managerFilePatterns": ["/^\\.github/dependencies\\.json$/"], - "matchStrings": ["\"openjpeg\":\\s*\"(?\\d+[^\"]*?)\""], + "matchStrings": ["\"openjpeg\":\\s*\"(?\\d+[^\"]*)\""], "depNameTemplate": "openjpeg", "packageNameTemplate": "uclouvain/openjpeg", "datasourceTemplate": "github-releases", @@ -92,7 +92,7 @@ { "customType": "regex", "managerFilePatterns": ["/^\\.github/dependencies\\.json$/"], - "matchStrings": ["\"tiff\":\\s*\"(?\\d+[^\"]*?)\""], + "matchStrings": ["\"tiff\":\\s*\"(?\\d+[^\"]*)\""], "depNameTemplate": "tiff", "packageNameTemplate": "libtiff/libtiff", "datasourceTemplate": "gitlab-tags", @@ -101,7 +101,7 @@ { "customType": "regex", "managerFilePatterns": ["/^\\.github/dependencies\\.json$/"], - "matchStrings": ["\"xz\":\\s*\"(?\\d+[^\"]*?)\""], + "matchStrings": ["\"xz\":\\s*\"(?\\d+[^\"]*)\""], "depNameTemplate": "xz", "packageNameTemplate": "tukaani-project/xz", "datasourceTemplate": "github-releases", @@ -110,7 +110,7 @@ { "customType": "regex", "managerFilePatterns": ["/^\\.github/dependencies\\.json$/"], - "matchStrings": ["\"zlib-ng\":\\s*\"(?\\d+[^\"]*?)\""], + "matchStrings": ["\"zlib-ng\":\\s*\"(?\\d+[^\"]*)\""], "depNameTemplate": "zlib-ng", "packageNameTemplate": "zlib-ng/zlib-ng", "datasourceTemplate": "github-releases" @@ -118,7 +118,7 @@ { "customType": "regex", "managerFilePatterns": ["/^\\.github/dependencies\\.json$/"], - "matchStrings": ["\"zstd\":\\s*\"(?\\d+[^\"]*?)\""], + "matchStrings": ["\"zstd\":\\s*\"(?\\d+[^\"]*)\""], "depNameTemplate": "zstd", "packageNameTemplate": "facebook/zstd", "datasourceTemplate": "github-releases", @@ -127,7 +127,7 @@ { "customType": "regex", "managerFilePatterns": ["/^\\.github/dependencies\\.json$/"], - "matchStrings": ["\"freetype\":\\s*\"(?\\d+[^\"]*?)\""], + "matchStrings": ["\"freetype\":\\s*\"(?\\d+[^\"]*)\""], "depNameTemplate": "freetype", "packageNameTemplate": "freetype/freetype", "datasourceTemplate": "gitlab-tags", @@ -138,7 +138,7 @@ { "customType": "regex", "managerFilePatterns": ["/^\\.github/dependencies\\.json$/"], - "matchStrings": ["\"libpng\":\\s*\"(?\\d+[^\"]*?)\""], + "matchStrings": ["\"libpng\":\\s*\"(?\\d+[^\"]*)\""], "depNameTemplate": "libpng", "packageNameTemplate": "pnggroup/libpng", "datasourceTemplate": "github-tags", @@ -147,7 +147,7 @@ { "customType": "regex", "managerFilePatterns": ["/^\\.github/dependencies\\.json$/"], - "matchStrings": ["\"libxcb\":\\s*\"(?\\d+[^\"]*?)\""], + "matchStrings": ["\"libxcb\":\\s*\"(?\\d+[^\"]*)\""], "depNameTemplate": "libxcb", "packageNameTemplate": "xorg/lib/libxcb", "datasourceTemplate": "gitlab-tags", @@ -157,7 +157,7 @@ { "customType": "regex", "managerFilePatterns": ["/^\\.github/dependencies\\.json$/"], - "matchStrings": ["\"bzip2\":\\s*\"(?\\d+[^\"]*?)\""], + "matchStrings": ["\"bzip2\":\\s*\"(?\\d+[^\"]*)\""], "depNameTemplate": "bzip2", "packageNameTemplate": "bzip2/bzip2", "datasourceTemplate": "gitlab-tags", From b27ae0b2fd3f198dd9a703828162585e1ce11c24 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Wed, 15 Apr 2026 20:57:03 +1000 Subject: [PATCH 128/190] Reorder to match dependencies order --- .github/renovate.json | 78 +++++++++++++++++++++---------------------- 1 file changed, 39 insertions(+), 39 deletions(-) diff --git a/.github/renovate.json b/.github/renovate.json index da998d7de..f5af3d05a 100644 --- a/.github/renovate.json +++ b/.github/renovate.json @@ -20,6 +20,26 @@ "datasourceTemplate": "github-releases", "extractVersionTemplate": "^v(?.+)$" }, + { + "customType": "regex", + "managerFilePatterns": ["/^\\.github/dependencies\\.json$/"], + "matchStrings": ["\"bzip2\":\\s*\"(?\\d+[^\"]*)\""], + "depNameTemplate": "bzip2", + "packageNameTemplate": "bzip2/bzip2", + "datasourceTemplate": "gitlab-tags", + "extractVersionTemplate": "^bzip2-(?.+)$" + }, + { + "customType": "regex", + "managerFilePatterns": ["/^\\.github/dependencies\\.json$/"], + "matchStrings": ["\"freetype\":\\s*\"(?\\d+[^\"]*)\""], + "depNameTemplate": "freetype", + "packageNameTemplate": "freetype/freetype", + "datasourceTemplate": "gitlab-tags", + "registryUrlTemplate": "https://gitlab.freedesktop.org", + "extractVersionTemplate": "^VER-(?[\\d-]+)$", + "versioningTemplate": "regex:^(?\\d+)[.-](?\\d+)[.-](?\\d+)$" + }, { "customType": "regex", "managerFilePatterns": ["/^\\.github/dependencies\\.json$/"], @@ -71,6 +91,15 @@ "packageNameTemplate": "ImageOptim/libimagequant", "datasourceTemplate": "github-tags" }, + { + "customType": "regex", + "managerFilePatterns": ["/^\\.github/dependencies\\.json$/"], + "matchStrings": ["\"libpng\":\\s*\"(?\\d+[^\"]*)\""], + "depNameTemplate": "libpng", + "packageNameTemplate": "pnggroup/libpng", + "datasourceTemplate": "github-tags", + "extractVersionTemplate": "^v(?.+)$" + }, { "customType": "regex", "managerFilePatterns": ["/^\\.github/dependencies\\.json$/"], @@ -80,6 +109,16 @@ "datasourceTemplate": "github-tags", "extractVersionTemplate": "^v(?.+)$" }, + { + "customType": "regex", + "managerFilePatterns": ["/^\\.github/dependencies\\.json$/"], + "matchStrings": ["\"libxcb\":\\s*\"(?\\d+[^\"]*)\""], + "depNameTemplate": "libxcb", + "packageNameTemplate": "xorg/lib/libxcb", + "datasourceTemplate": "gitlab-tags", + "registryUrlTemplate": "https://gitlab.freedesktop.org", + "extractVersionTemplate": "^libxcb-(?.+)$" + }, { "customType": "regex", "managerFilePatterns": ["/^\\.github/dependencies\\.json$/"], @@ -123,45 +162,6 @@ "packageNameTemplate": "facebook/zstd", "datasourceTemplate": "github-releases", "extractVersionTemplate": "^v(?.+)$" - }, - { - "customType": "regex", - "managerFilePatterns": ["/^\\.github/dependencies\\.json$/"], - "matchStrings": ["\"freetype\":\\s*\"(?\\d+[^\"]*)\""], - "depNameTemplate": "freetype", - "packageNameTemplate": "freetype/freetype", - "datasourceTemplate": "gitlab-tags", - "registryUrlTemplate": "https://gitlab.freedesktop.org", - "extractVersionTemplate": "^VER-(?[\\d-]+)$", - "versioningTemplate": "regex:^(?\\d+)[.-](?\\d+)[.-](?\\d+)$" - }, - { - "customType": "regex", - "managerFilePatterns": ["/^\\.github/dependencies\\.json$/"], - "matchStrings": ["\"libpng\":\\s*\"(?\\d+[^\"]*)\""], - "depNameTemplate": "libpng", - "packageNameTemplate": "pnggroup/libpng", - "datasourceTemplate": "github-tags", - "extractVersionTemplate": "^v(?.+)$" - }, - { - "customType": "regex", - "managerFilePatterns": ["/^\\.github/dependencies\\.json$/"], - "matchStrings": ["\"libxcb\":\\s*\"(?\\d+[^\"]*)\""], - "depNameTemplate": "libxcb", - "packageNameTemplate": "xorg/lib/libxcb", - "datasourceTemplate": "gitlab-tags", - "registryUrlTemplate": "https://gitlab.freedesktop.org", - "extractVersionTemplate": "^libxcb-(?.+)$" - }, - { - "customType": "regex", - "managerFilePatterns": ["/^\\.github/dependencies\\.json$/"], - "matchStrings": ["\"bzip2\":\\s*\"(?\\d+[^\"]*)\""], - "depNameTemplate": "bzip2", - "packageNameTemplate": "bzip2/bzip2", - "datasourceTemplate": "gitlab-tags", - "extractVersionTemplate": "^bzip2-(?.+)$" } ], "packageRules": [ From b300e788384e8fff6ac1fcc5f877599ebc24148a Mon Sep 17 00:00:00 2001 From: Jeffrey 'Alex' Clark Date: Tue, 14 Apr 2026 20:08:05 -0400 Subject: [PATCH 129/190] Update docs/handbook/security.rst Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- docs/handbook/security.rst | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/docs/handbook/security.rst b/docs/handbook/security.rst index ebff8199f..dc7e96c60 100644 --- a/docs/handbook/security.rst +++ b/docs/handbook/security.rst @@ -140,11 +140,12 @@ Denial of service **D-1 — Decompression bomb** A small compressed image can expand to gigabytes in memory. -:py:data:`PIL.Image.MAX_IMAGE_PIXELS` (~89 MP by default) raises +:py:data:`PIL.Image.MAX_IMAGE_PIXELS` raises ``DecompressionBombError`` at 2× the limit and ``DecompressionBombWarning`` at 1×. PNG text chunks are -separately capped by ``PngImagePlugin.MAX_TEXT_CHUNK`` (1 MiB) and -``MAX_TEXT_MEMORY`` (64 MiB). +separately capped by ``PngImagePlugin.MAX_TEXT_CHUNK`` and +``MAX_TEXT_MEMORY``. Check the values in your installed Pillow version at +runtime or in the reference/source for the current defaults. *Mitigations:* **never** set ``Image.MAX_IMAGE_PIXELS = None`` in production; treat ``DecompressionBombWarning`` as an error; set OS/container memory limits @@ -188,13 +189,12 @@ Opening an EPS file invokes the system Ghostscript binary (``gs``) via ``subprocess``. Ghostscript has a long history of sandbox-escape CVEs permitting arbitrary code execution from malicious PostScript. -*Mitigations:* **block EPS files** at the application input layer; if EPS must -be supported, run Ghostscript in a fully isolated sandbox with no network and -no sensitive mounts; unregister the plugin if unused:: - - from PIL import Image, EpsImagePlugin - Image.OPEN.pop("EPS", None) - +*Mitigations:* **block EPS files** at the application input layer before +passing files to Pillow; if EPS must be supported, run Ghostscript in a fully +isolated sandbox with no network and no sensitive mounts. Pillow does not +provide a stable public API for unregistering individual format plugins, so do +not rely on mutating internal registries such as ``Image.OPEN`` as a security +control. **E-3 — ``ImageMath.unsafe_eval()`` code injection** :py:meth:`~PIL.ImageMath.unsafe_eval` calls Python's built-in ``eval()`` with From 0c0bdf8d5adadb5e28d08246936e89ccfe77b020 Mon Sep 17 00:00:00 2001 From: Jeffrey 'Alex' Clark Date: Wed, 15 Apr 2026 13:03:19 -0400 Subject: [PATCH 130/190] Update security docs - docs/handbook/security.rst - .github/SECURITY.md Co-authored-by: Andrew Murray <3112309+radarhere@users.noreply.github.com> --- .github/SECURITY.md | 4 ++-- docs/handbook/security.rst | 14 +++++++------- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/.github/SECURITY.md b/.github/SECURITY.md index 2b668cc55..c9a396aa8 100644 --- a/.github/SECURITY.md +++ b/.github/SECURITY.md @@ -4,13 +4,13 @@ To report sensitive vulnerability information, report it [privately on GitHub](https://github.com/python-pillow/Pillow/security/advisories/new). -If you cannot use GitHub, use the [Tidelift security contact](https://tidelift.com/security). Tidelift will coordinate the fix and disclosure. +If you cannot use GitHub, use the [Tidelift security contact](https://tidelift.com/docs/security). Tidelift will coordinate the fix and disclosure. **DO NOT report sensitive vulnerability information in public.** ## Threat model -Pillow's primary attack surface is parsing untrusted image data. A full STRIDE threat model covering spoofing, tampering, repudiation, information disclosure, denial of service, and elevation of privilege is maintained in the [Security handbook page](https://pillow.readthedocs.io/en/stable/handbook/security.html). +Pillow's primary attack surface is parsing untrusted image data. A full STRIDE threat model covering spoofing, tampering, repudiation, information disclosure, denial of service, and elevation of privilege is maintained in the [Security handbook page](https://pillow.readthedocs.io/en/latest/handbook/security.html). Key risks to be aware of when using Pillow to process untrusted images: diff --git a/docs/handbook/security.rst b/docs/handbook/security.rst index dc7e96c60..e018f099a 100644 --- a/docs/handbook/security.rst +++ b/docs/handbook/security.rst @@ -141,14 +141,14 @@ Denial of service A small compressed image can expand to gigabytes in memory. :py:data:`PIL.Image.MAX_IMAGE_PIXELS` raises -``DecompressionBombError`` at 2× the limit and -``DecompressionBombWarning`` at 1×. PNG text chunks are +``Image.DecompressionBombError`` at 2× the limit and +``Image.DecompressionBombWarning`` at 1×. PNG text chunks are separately capped by ``PngImagePlugin.MAX_TEXT_CHUNK`` and ``MAX_TEXT_MEMORY``. Check the values in your installed Pillow version at runtime or in the reference/source for the current defaults. *Mitigations:* **never** set ``Image.MAX_IMAGE_PIXELS = None`` in production; -treat ``DecompressionBombWarning`` as an error; set OS/container memory limits +treat ``Image.DecompressionBombWarning`` as an error; set OS/container memory limits per worker. **D-2 — CPU exhaustion** @@ -234,7 +234,7 @@ The following mitigations are listed in priority order. libwebp, openjpeg, freetype, Ghostscript. Subscribe to `Pillow security advisories `_. 5. **Enforce** ``MAX_IMAGE_PIXELS`` — never set it to ``None``; treat - ``DecompressionBombWarning`` as an error. + ``Image.DecompressionBombWarning`` as an error. 6. **Allowlist image formats** — unregister plugins your application does not need. 7. **Strip metadata on output** — never pass through EXIF/XMP/ICC from user @@ -242,8 +242,8 @@ The following mitigations are listed in priority order. 8. **Sanitise all metadata** returned by Pillow before using it downstream. 9. **Pin dependencies with hash verification** — use ``pip install --require-hashes`` and lockfiles. -10. **Log and alert** on ``DecompressionBombWarning``, - ``DecompressionBombError``, ``PIL.UnidentifiedImageError``, +10. **Log and alert** on ``Image.DecompressionBombWarning``, + ``Image.DecompressionBombError``, ``PIL.UnidentifiedImageError``, and all exceptions from ``Image.open()``. .. _security-reporting: @@ -255,7 +255,7 @@ To report sensitive vulnerability information, report it `privately on GitHub `_. If you cannot use GitHub, use the `Tidelift security contact -`_. Tidelift will coordinate the fix and +`_. Tidelift will coordinate the fix and disclosure. **Do not report sensitive vulnerability information in public.** From 07b20b3b33a961c87e93559454b1cc1d9a131b62 Mon Sep 17 00:00:00 2001 From: Jeffrey 'Alex' Clark Date: Thu, 16 Apr 2026 06:45:55 -0400 Subject: [PATCH 131/190] Remove Sensitive exception messages --- docs/handbook/security.rst | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/docs/handbook/security.rst b/docs/handbook/security.rst index e018f099a..ec91ea82c 100644 --- a/docs/handbook/security.rst +++ b/docs/handbook/security.rst @@ -116,16 +116,7 @@ be inadvertently included in output images served publicly. *Mitigations:* explicitly strip EXIF and XMP on save (set ``exif=b""``, ``icc_profile=None``, omit ``pnginfo``); verify output with ``exiftool`` in CI. -**I-2 — Sensitive exception messages** - -Parser errors can include byte offsets, dimension values, and tile descriptors. -Propagating these to API responses aids attacker reconnaissance. - -*Mitigations:* catch ``PIL.UnidentifiedImageError``, -``PIL.Image.DecompressionBombError``, and general exceptions at the -application boundary; return generic messages to clients. - -**I-3 — Temporary file exposure** +**I-2 — Temporary file exposure** Several code paths write pixel data to temporary files via ``tempfile.mkstemp()``. Exception paths can leave these files behind on shared From 74e07b5b8adbc1fe5665ae364a40033ee546ccbb Mon Sep 17 00:00:00 2001 From: Jeffrey 'Alex' Clark Date: Thu, 16 Apr 2026 06:48:09 -0400 Subject: [PATCH 132/190] Lint --- docs/handbook/security.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/handbook/security.rst b/docs/handbook/security.rst index ec91ea82c..2984d8e2b 100644 --- a/docs/handbook/security.rst +++ b/docs/handbook/security.rst @@ -22,7 +22,7 @@ boundary between untrusted image input and the Pillow API. Untrusted zone │ Pillow API │ ───────────── │ │ Image files ────►│ Image.open() ──► Format plugins │ - Byte streams │ (40+ parsers) (Python + C FFI) │ + Byte streams │ (40+ parsers) (Python + C FFI) │ User metadata │ │ │ ImageMath.unsafe_eval(expr) ───────────┼──► Python eval() │ ImageShow.show(image) ─────────────────┼──► os.system / subprocess From 2593703e5122c9c60b441ac4c1fd868c6ddf8eeb Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade <1324225+hugovk@users.noreply.github.com> Date: Fri, 17 Apr 2026 12:07:21 +0300 Subject: [PATCH 133/190] Hash pin GitHub Actions --- .github/workflows/cifuzz.yml | 8 +++--- .github/workflows/docs.yml | 10 ++++---- .github/workflows/lint.yml | 6 ++--- .github/workflows/release-drafter.yml | 2 +- .github/workflows/stale.yml | 2 +- .github/workflows/test-docker.yml | 6 ++--- .github/workflows/test-mingw.yml | 4 +-- .github/workflows/test-valgrind-memory.yml | 2 +- .github/workflows/test-valgrind.yml | 2 +- .github/workflows/test-windows.yml | 14 +++++----- .github/workflows/test.yml | 14 +++++----- .github/workflows/wheels.yml | 30 +++++++++++----------- .github/zizmor.yml | 6 ----- 13 files changed, 50 insertions(+), 56 deletions(-) delete mode 100644 .github/zizmor.yml diff --git a/.github/workflows/cifuzz.yml b/.github/workflows/cifuzz.yml index b92e88e06..cc8b4606b 100644 --- a/.github/workflows/cifuzz.yml +++ b/.github/workflows/cifuzz.yml @@ -35,27 +35,27 @@ jobs: steps: - name: Build Fuzzers id: build - uses: google/oss-fuzz/infra/cifuzz/actions/build_fuzzers@master + uses: google/oss-fuzz/infra/cifuzz/actions/build_fuzzers@e41e2f295eb18d630932fdd33d072527ba74c87b # 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@master + uses: google/oss-fuzz/infra/cifuzz/actions/run_fuzzers@e41e2f295eb18d630932fdd33d072527ba74c87b # master with: oss-fuzz-project-name: 'pillow' fuzz-seconds: 600 language: python dry-run: false - name: Upload New Crash - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 if: failure() && steps.build.outcome == 'success' with: name: artifacts path: ./out/artifacts - name: Upload Legacy Crash - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 if: steps.run.outcome == 'success' with: name: crash diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 857881c01..8c29af7b7 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -32,12 +32,12 @@ jobs: name: Docs steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false - name: Set up Python - uses: actions/setup-python@v6 + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: python-version: "3.x" cache: pip @@ -49,21 +49,21 @@ jobs: run: python3 .github/workflows/system-info.py - name: Cache libavif - uses: actions/cache@v5 + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 id: cache-libavif with: path: ~/cache-libavif key: ${{ runner.os }}-libavif-${{ hashFiles('depends/install_libavif.sh', 'depends/libavif-svt4.patch') }} - name: Cache libimagequant - uses: actions/cache@v5 + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 id: cache-libimagequant with: path: ~/cache-libimagequant key: ${{ runner.os }}-libimagequant-${{ hashFiles('depends/install_imagequant.sh') }} - name: Cache libwebp - uses: actions/cache@v5 + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 id: cache-libwebp with: path: ~/cache-libwebp diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index e2f8bf47a..1aff5a0dd 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -18,14 +18,14 @@ jobs: runs-on: ubuntu-latest name: Lint steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false - - uses: actions/setup-python@v6 + - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: python-version: "3.x" - name: Install uv - uses: astral-sh/setup-uv@v7 + uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0 - name: Lint run: uvx --with tox-uv tox -e lint - name: Mypy diff --git a/.github/workflows/release-drafter.yml b/.github/workflows/release-drafter.yml index aa8326b78..d62d2c3c2 100644 --- a/.github/workflows/release-drafter.yml +++ b/.github/workflows/release-drafter.yml @@ -26,6 +26,6 @@ jobs: runs-on: ubuntu-latest steps: # Drafts your next release notes as pull requests are merged into "main" - - uses: release-drafter/release-drafter@v7 + - uses: release-drafter/release-drafter@5de93583980a40bd78603b6dfdcda5b4df377b32 # v7.2.0 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index e4ccd1aa3..b2dca6dd2 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -25,7 +25,7 @@ jobs: steps: - name: "Check issues" - uses: actions/stale@v10 + uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10.2.0 with: repo-token: ${{ secrets.GITHUB_TOKEN }} only-labels: "Awaiting OP Action" diff --git a/.github/workflows/test-docker.yml b/.github/workflows/test-docker.yml index 515d77d17..083cb9fc2 100644 --- a/.github/workflows/test-docker.yml +++ b/.github/workflows/test-docker.yml @@ -67,7 +67,7 @@ jobs: name: ${{ matrix.docker }} steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false @@ -76,7 +76,7 @@ jobs: - name: Set up QEMU if: "matrix.qemu-arch" - uses: docker/setup-qemu-action@v4 + uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0 with: platforms: ${{ matrix.qemu-arch }} @@ -104,7 +104,7 @@ jobs: .ci/after_success.sh - name: Upload coverage - uses: codecov/codecov-action@v6 + uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v6.0.0 with: flags: GHA_Docker name: ${{ matrix.docker }} diff --git a/.github/workflows/test-mingw.yml b/.github/workflows/test-mingw.yml index 0dc6e2a0c..a87928f0b 100644 --- a/.github/workflows/test-mingw.yml +++ b/.github/workflows/test-mingw.yml @@ -46,7 +46,7 @@ jobs: steps: - name: Checkout Pillow - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false @@ -87,7 +87,7 @@ jobs: .ci/test.sh - name: Upload coverage - uses: codecov/codecov-action@v6 + uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v6.0.0 with: files: ./coverage.xml flags: GHA_Windows diff --git a/.github/workflows/test-valgrind-memory.yml b/.github/workflows/test-valgrind-memory.yml index 87eace643..1cbcc40d3 100644 --- a/.github/workflows/test-valgrind-memory.yml +++ b/.github/workflows/test-valgrind-memory.yml @@ -44,7 +44,7 @@ jobs: name: ${{ matrix.docker }} steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false diff --git a/.github/workflows/test-valgrind.yml b/.github/workflows/test-valgrind.yml index f14dab616..f3ec8c10e 100644 --- a/.github/workflows/test-valgrind.yml +++ b/.github/workflows/test-valgrind.yml @@ -42,7 +42,7 @@ jobs: name: ${{ matrix.docker }} steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false diff --git a/.github/workflows/test-windows.yml b/.github/workflows/test-windows.yml index 0b2aad283..6a83338d8 100644 --- a/.github/workflows/test-windows.yml +++ b/.github/workflows/test-windows.yml @@ -49,19 +49,19 @@ jobs: steps: - name: Checkout Pillow - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false - name: Checkout cached dependencies - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false repository: python-pillow/pillow-depends path: winbuild\depends - name: Checkout extra test images - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false repository: python-pillow/test-images @@ -69,7 +69,7 @@ jobs: # sets env: pythonLocation - name: Set up Python - uses: actions/setup-python@v6 + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: python-version: ${{ matrix.python-version }} allow-prereleases: true @@ -113,7 +113,7 @@ jobs: - name: Cache build id: build-cache - uses: actions/cache@v5 + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: winbuild\build key: @@ -217,7 +217,7 @@ jobs: shell: bash - name: Upload errors - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 if: failure() with: name: errors @@ -229,7 +229,7 @@ jobs: shell: pwsh - name: Upload coverage - uses: codecov/codecov-action@v6 + uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v6.0.0 with: files: ./coverage.xml flags: GHA_Windows diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index d84504a8f..2654e2d04 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -69,12 +69,12 @@ jobs: name: ${{ matrix.os }} Python ${{ matrix.python-version }} steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v6 + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: python-version: ${{ matrix.python-version }} allow-prereleases: true @@ -93,7 +93,7 @@ jobs: - name: Cache libavif if: startsWith(matrix.os, 'ubuntu') - uses: actions/cache@v5 + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 id: cache-libavif with: path: ~/cache-libavif @@ -101,7 +101,7 @@ jobs: - name: Cache libimagequant if: startsWith(matrix.os, 'ubuntu') - uses: actions/cache@v5 + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 id: cache-libimagequant with: path: ~/cache-libimagequant @@ -109,7 +109,7 @@ jobs: - name: Cache libwebp if: startsWith(matrix.os, 'ubuntu') - uses: actions/cache@v5 + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 id: cache-libwebp with: path: ~/cache-libwebp @@ -162,7 +162,7 @@ jobs: mkdir -p Tests/errors - name: Upload errors - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 if: failure() with: name: errors @@ -173,7 +173,7 @@ jobs: .ci/after_success.sh - name: Upload coverage - uses: codecov/codecov-action@v6 + uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v6.0.0 with: flags: ${{ matrix.os == 'ubuntu-latest' && 'GHA_Ubuntu' || 'GHA_macOS' }} name: ${{ matrix.os }} Python ${{ matrix.python-version }} diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index b5edfc461..80080e2c8 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -109,12 +109,12 @@ jobs: os: macos-15-intel cibw_arch: x86_64_iphonesimulator steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false submodules: true - - uses: actions/setup-python@v6 + - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: python-version: "3.x" @@ -132,7 +132,7 @@ jobs: CIBW_ENABLE: cpython-prerelease pypy MACOSX_DEPLOYMENT_TARGET: ${{ matrix.macosx_deployment_target }} - - uses: actions/upload-artifact@v7 + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: dist-${{ matrix.name }} path: ./wheelhouse/*.whl @@ -152,18 +152,18 @@ jobs: - cibw_arch: ARM64 os: windows-11-arm steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false - name: Checkout extra test images - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false repository: python-pillow/test-images path: Tests\test-images - - uses: actions/setup-python@v6 + - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: python-version: "3.x" @@ -212,13 +212,13 @@ jobs: shell: bash - name: Upload wheels - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: dist-windows-${{ matrix.cibw_arch }} path: ./wheelhouse/*.whl - name: Upload fribidi.dll - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: fribidi-windows-${{ matrix.cibw_arch }} path: winbuild\build\bin\fribidi* @@ -227,18 +227,18 @@ jobs: if: github.event_name != 'schedule' || github.event.repository.fork == false runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false - name: Set up Python - uses: actions/setup-python@v6 + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: python-version: "3.x" - run: make sdist - - uses: actions/upload-artifact@v7 + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: dist-sdist path: dist/*.tar.gz @@ -248,7 +248,7 @@ jobs: runs-on: ubuntu-latest name: Count dists steps: - - uses: actions/download-artifact@v8 + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: pattern: dist-* path: dist @@ -270,7 +270,7 @@ jobs: name: release-anaconda url: https://anaconda.org/channels/scientific-python-nightly-wheels/packages/pillow/overview steps: - - uses: actions/download-artifact@v8 + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: pattern: dist-!(sdist)* path: dist @@ -292,12 +292,12 @@ jobs: permissions: id-token: write steps: - - uses: actions/download-artifact@v8 + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: pattern: dist-* path: dist merge-multiple: true - name: Publish to PyPI - uses: pypa/gh-action-pypi-publish@release/v1 + uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0 with: attestations: true diff --git a/.github/zizmor.yml b/.github/zizmor.yml deleted file mode 100644 index 100026562..000000000 --- a/.github/zizmor.yml +++ /dev/null @@ -1,6 +0,0 @@ -# https://docs.zizmor.sh/configuration/ -rules: - unpinned-uses: - config: - policies: - "*": ref-pin From 9867b51d894332dcd219f3ea7ac4a6d5b47242fc Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Tue, 21 Apr 2026 07:51:50 +1000 Subject: [PATCH 134/190] Catch subprocess.CalledProcessError in test_grab_x11 --- Tests/test_imagegrab.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Tests/test_imagegrab.py b/Tests/test_imagegrab.py index 07cb69719..180682c64 100644 --- a/Tests/test_imagegrab.py +++ b/Tests/test_imagegrab.py @@ -35,7 +35,7 @@ class TestImageGrab: ImageGrab.grab() ImageGrab.grab(xdisplay="") - except OSError as e: + except (OSError, subprocess.CalledProcessError) as e: pytest.skip(str(e)) @pytest.mark.skipif(Image.core.HAVE_XCB, reason="tests missing XCB") From 13433dc0a9c86abb338a01439af3f3795b2e3994 Mon Sep 17 00:00:00 2001 From: Jeffrey 'Alex' Clark Date: Tue, 21 Apr 2026 11:07:58 -0400 Subject: [PATCH 135/190] Update docs/handbook/security.rst Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- docs/handbook/security.rst | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/docs/handbook/security.rst b/docs/handbook/security.rst index 2984d8e2b..58d066a1d 100644 --- a/docs/handbook/security.rst +++ b/docs/handbook/security.rst @@ -46,8 +46,8 @@ type. An attacker can name a file ``safe.png`` while its content is TIFF, JPEG 2000, or EPS, causing a different — potentially more dangerous — parser to run. *Mitigations:* validate MIME type and magic bytes independently before calling -``Image.open()``; pass the ``format`` parameter explicitly; maintain an -allowlist of accepted formats. +``Image.open()``; pass the ``formats`` argument with an allowlist of accepted +formats. **S-2 — Plugin registry spoofing** @@ -226,8 +226,9 @@ The following mitigations are listed in priority order. advisories `_. 5. **Enforce** ``MAX_IMAGE_PIXELS`` — never set it to ``None``; treat ``Image.DecompressionBombWarning`` as an error. -6. **Allowlist image formats** — unregister plugins your application does not - need. +6. **Allowlist image formats** — restrict accepted formats when opening + images, for example with ``Image.open(..., formats=...)``, and isolate + installs/environments if you need to minimise supported formats. 7. **Strip metadata on output** — never pass through EXIF/XMP/ICC from user uploads to publicly served images. 8. **Sanitise all metadata** returned by Pillow before using it downstream. From 291142275383c4455f9bcdd1a9041cb2e60d48e1 Mon Sep 17 00:00:00 2001 From: Jeffrey 'Alex' Clark Date: Tue, 21 Apr 2026 11:11:00 -0400 Subject: [PATCH 136/190] s/littlecms/littlecms2/ --- docs/handbook/security.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/handbook/security.rst b/docs/handbook/security.rst index 58d066a1d..56333f70f 100644 --- a/docs/handbook/security.rst +++ b/docs/handbook/security.rst @@ -33,7 +33,7 @@ boundary between untrusted image input and the Pillow API. ┌──────────────────────────────────────────┐ │ C libraries (bundled or system) │ │ libjpeg · libpng · libtiff · libwebp │ - │ openjpeg · freetype · littlecms │ + │ openjpeg · freetype · littlecms2 │ └──────────────────────────────────────────┘ Spoofing @@ -84,7 +84,7 @@ encode-decode cycle invisibly. **T-3 — Supply chain tampering** Pre-compiled wheels bundle libjpeg-turbo, libpng, libtiff, libwebp, openjpeg, -freetype, and littlecms. A compromised PyPI release or build pipeline could +freetype, and littlecms2. A compromised PyPI release or build pipeline could ship malicious binaries. *Mitigations:* pin with hash verification (``pip install --require-hashes``); From 114e4d5695308a6c33791f011e4024dadc5b010e Mon Sep 17 00:00:00 2001 From: Jeffrey 'Alex' Clark Date: Tue, 21 Apr 2026 11:22:58 -0400 Subject: [PATCH 137/190] docs: list all 8 C extensions in security threat model diagram Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/handbook/security.rst | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/docs/handbook/security.rst b/docs/handbook/security.rst index 56333f70f..c3e692a9a 100644 --- a/docs/handbook/security.rst +++ b/docs/handbook/security.rst @@ -28,7 +28,10 @@ boundary between untrusted image input and the Pillow API. │ ImageShow.show(image) ─────────────────┼──► os.system / subprocess │ EpsImagePlugin.open(eps) ──────────────┼──► Ghostscript (gs) └──────────────┬───────────────────────────┘ - │ C extension (_imaging) + │ C extensions: + │ _imaging · _imagingft · _imagingcms + │ _webp · _avif · _imagingtk + │ _imagingmath · _imagingmorph ▼ ┌──────────────────────────────────────────┐ │ C libraries (bundled or system) │ From 1f026416f9911614f0cc938bfde1197383c0cec9 Mon Sep 17 00:00:00 2001 From: Jeffrey 'Alex' Clark Date: Tue, 21 Apr 2026 11:23:54 -0400 Subject: [PATCH 138/190] Update docs/handbook/security.rst Co-authored-by: Andrew Murray <3112309+radarhere@users.noreply.github.com> --- docs/handbook/security.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/handbook/security.rst b/docs/handbook/security.rst index c3e692a9a..dad1e4a29 100644 --- a/docs/handbook/security.rst +++ b/docs/handbook/security.rst @@ -81,8 +81,8 @@ Pillow does not remove hidden data (JPEG comments, PNG text chunks, appended bytes) when re-saving. An attacker can embed data that survives the encode-decode cycle invisibly. -*Mitigations:* to guarantee a clean output, load pixel data via -``image.tobytes()`` and rebuild the image from raw bytes before saving. +*Mitigations:* to guarantee a clean output when saving, create a new image instance via +``image.copy()`` and delete the ``image.info`` contents. **T-3 — Supply chain tampering** From 5af49b380e103e0045a0317437fe853af20f8fbe Mon Sep 17 00:00:00 2001 From: Jeffrey 'Alex' Clark Date: Tue, 21 Apr 2026 11:32:36 -0400 Subject: [PATCH 139/190] docs: address Andrew's review comments on security.rst - Add image.getexif() alongside image._getexif() in T-1 mitigations - Remove 'appended bytes' from T-2 (Pillow does not preserve them on resave) - Reframe R-1 threat as user-facing (not Pillow dev advice); add DecompressionBombError to the log/alert list - Add blank line before E-3 heading - Qualify dependency list in recommendation #4 as non-exhaustive Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/handbook/security.rst | 28 +++++++++++++++------------- 1 file changed, 15 insertions(+), 13 deletions(-) diff --git a/docs/handbook/security.rst b/docs/handbook/security.rst index dad1e4a29..34ce3e30f 100644 --- a/docs/handbook/security.rst +++ b/docs/handbook/security.rst @@ -71,14 +71,14 @@ round-tripping images. Applications that store or render metadata without sanitisation are vulnerable to second-order injection (SQLi, XSS, command injection). -*Mitigations:* treat all values from ``image.info``, ``image._getexif()``, and -``image.text`` as untrusted; sanitise before storing or rendering; strip -metadata when it is not required. +*Mitigations:* treat all values from ``image.info``, ``image._getexif()``, +``image.getexif()``, and ``image.text`` as untrusted; sanitise before storing +or rendering; strip metadata when it is not required. **T-2 — Covert data channel (steganography)** -Pillow does not remove hidden data (JPEG comments, PNG text chunks, appended -bytes) when re-saving. An attacker can embed data that survives the +Pillow does not remove hidden data (JPEG comments, PNG text chunks) when +re-saving. An attacker can embed data that survives the encode-decode cycle invisibly. *Mitigations:* to guarantee a clean output when saving, create a new image instance via @@ -100,13 +100,13 @@ Repudiation **R-1 — No structured audit trail** -Pillow does not emit structured audit logs of files opened, formats detected, -or operations performed, making forensic investigation harder after an -incident. +Without application-level logging there is no record of which images were +opened, what formats were detected, or what operations were performed, making +forensic investigation harder after an incident. -*Mitigations:* applications should log the filename/hash, detected format, and -dimensions of every image processed; log and alert on -``Image.DecompressionBombWarning`` and ``PIL.UnidentifiedImageError``. +*Mitigations:* log the filename/hash, detected format, and dimensions of every +image processed; log and alert on ``Image.DecompressionBombWarning``, +``Image.DecompressionBombError``, and ``PIL.UnidentifiedImageError``. Information disclosure ^^^^^^^^^^^^^^^^^^^^^^ @@ -189,6 +189,7 @@ isolated sandbox with no network and no sensitive mounts. Pillow does not provide a stable public API for unregistering individual format plugins, so do not rely on mutating internal registries such as ``Image.OPEN`` as a security control. + **E-3 — ``ImageMath.unsafe_eval()`` code injection** :py:meth:`~PIL.ImageMath.unsafe_eval` calls Python's built-in ``eval()`` with @@ -224,8 +225,9 @@ The following mitigations are listed in priority order. Ghostscript in an isolated container. 3. **Never use** ``ImageMath.unsafe_eval()`` **with user input** — migrate all callers to :py:meth:`~PIL.ImageMath.lambda_eval`. -4. **Keep all dependencies current** — Pillow, libjpeg, libpng, libtiff, - libwebp, openjpeg, freetype, Ghostscript. Subscribe to `Pillow security +4. **Keep all dependencies current** — Pillow and its C library dependencies + (including libjpeg, libpng, libtiff, libwebp, openjpeg, freetype, + littlecms2, Ghostscript, and others). Subscribe to `Pillow security advisories `_. 5. **Enforce** ``MAX_IMAGE_PIXELS`` — never set it to ``None``; treat ``Image.DecompressionBombWarning`` as an error. From d3b73ea4628368e369bf653ca80c36a61ce485aa Mon Sep 17 00:00:00 2001 From: Jeffrey 'Alex' Clark Date: Tue, 21 Apr 2026 11:33:48 -0400 Subject: [PATCH 140/190] Update docs/handbook/security.rst Co-authored-by: Andrew Murray <3112309+radarhere@users.noreply.github.com> --- docs/handbook/security.rst | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/handbook/security.rst b/docs/handbook/security.rst index 34ce3e30f..208afb287 100644 --- a/docs/handbook/security.rst +++ b/docs/handbook/security.rst @@ -190,6 +190,7 @@ provide a stable public API for unregistering individual format plugins, so do not rely on mutating internal registries such as ``Image.OPEN`` as a security control. + **E-3 — ``ImageMath.unsafe_eval()`` code injection** :py:meth:`~PIL.ImageMath.unsafe_eval` calls Python's built-in ``eval()`` with From da0664087309ece64410a44d3fb55851a2fafd8c Mon Sep 17 00:00:00 2001 From: Jeffrey 'Alex' Clark Date: Tue, 21 Apr 2026 11:58:06 -0400 Subject: [PATCH 141/190] docs: fix nested inline markup in E-3 and E-4 headings RST does not allow inline markup (backticks) nested inside bold markers. Remove backticks from the E-3 and E-4 heading text so they render correctly. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/handbook/security.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/handbook/security.rst b/docs/handbook/security.rst index 208afb287..6046466e8 100644 --- a/docs/handbook/security.rst +++ b/docs/handbook/security.rst @@ -191,7 +191,7 @@ not rely on mutating internal registries such as ``Image.OPEN`` as a security control. -**E-3 — ``ImageMath.unsafe_eval()`` code injection** +**E-3 — ImageMath.unsafe_eval() code injection** :py:meth:`~PIL.ImageMath.unsafe_eval` calls Python's built-in ``eval()`` with only a minimal ``__builtins__`` restriction, which can be bypassed via @@ -202,7 +202,7 @@ arbitrary code execution. ``ImageMath.unsafe_eval()``; use :py:meth:`~PIL.ImageMath.lambda_eval` instead, which accepts a Python callable and never calls ``eval``. -**E-4 — Font path traversal via ``ImageFont``** +**E-4 — Font path traversal via ImageFont** ``ImageFont.truetype(font, size)`` passes the filename to the FreeType C library. If font paths are constructed from user input without From 0cb00acc921fcb236b0569dbf45ea9e51bbcaa1a Mon Sep 17 00:00:00 2001 From: Jeffrey 'Alex' Clark Date: Wed, 22 Apr 2026 07:34:40 -0400 Subject: [PATCH 142/190] Update docs/handbook/security.rst Co-authored-by: Andrew Murray <3112309+radarhere@users.noreply.github.com> --- docs/handbook/security.rst | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/handbook/security.rst b/docs/handbook/security.rst index 6046466e8..c13389134 100644 --- a/docs/handbook/security.rst +++ b/docs/handbook/security.rst @@ -13,7 +13,7 @@ Threat model (STRIDE) --------------------- The analysis below follows the `STRIDE -`_ framework and covers the +`_ framework and covers the boundary between untrusted image input and the Pillow API. .. code-block:: text @@ -22,7 +22,7 @@ boundary between untrusted image input and the Pillow API. Untrusted zone │ Pillow API │ ───────────── │ │ Image files ────►│ Image.open() ──► Format plugins │ - Byte streams │ (40+ parsers) (Python + C FFI) │ + Byte streams │ (40+ parsers) (Python + C FFI) │ User metadata │ │ │ ImageMath.unsafe_eval(expr) ───────────┼──► Python eval() │ ImageShow.show(image) ─────────────────┼──► os.system / subprocess @@ -87,11 +87,11 @@ encode-decode cycle invisibly. **T-3 — Supply chain tampering** Pre-compiled wheels bundle libjpeg-turbo, libpng, libtiff, libwebp, openjpeg, -freetype, and littlecms2. A compromised PyPI release or build pipeline could -ship malicious binaries. +freetype, littlecms2, and other libraries. A compromised PyPI release or build pipeline +could ship malicious binaries. -*Mitigations:* pin with hash verification (``pip install --require-hashes``); -monitor `Pillow security advisories +*Mitigations:* pin with hash verification +(``python3 -m pip install --require-hashes``); monitor `Pillow security advisories `_; use Dependabot or OSV-Scanner for bundled C library CVEs. From c8c391b9c040ddb0db6b55f1a8da1d58d57156a8 Mon Sep 17 00:00:00 2001 From: Jeffrey 'Alex' Clark Date: Wed, 15 Apr 2026 13:15:50 -0400 Subject: [PATCH 143/190] Update .github/INCIDENT_RESPONSE.md Co-authored-by: Andrew Murray <3112309+radarhere@users.noreply.github.com> --- .github/INCIDENT_RESPONSE.md | 29 +++++++++++++++++++---------- 1 file changed, 19 insertions(+), 10 deletions(-) diff --git a/.github/INCIDENT_RESPONSE.md b/.github/INCIDENT_RESPONSE.md index 0cefea6cb..1c2e395dd 100644 --- a/.github/INCIDENT_RESPONSE.md +++ b/.github/INCIDENT_RESPONSE.md @@ -97,7 +97,7 @@ Supply-chain and CI/CD incidents are always treated as **Critical** regardless o Vulnerabilities and incidents may be reported or discovered through: 1. **GitHub private security advisory** — preferred channel; see [SECURITY.md](SECURITY.md) -2. **Tidelift security contact** — +2. **Tidelift security contact** — 3. **External researcher / coordinated disclosure** — e.g. Google Project Zero, vendor PSIRT 4. **Automated scanning** — Dependabot, GitHub code-scanning (CodeQL), CI fuzzing 5. **Distro security teams** — Debian, Red Hat, Ubuntu, Alpine may report upstream @@ -230,24 +230,24 @@ require a Pillow point release even if Pillow's own code is unchanged. | Library | Purpose | Security advisory tracker | |---|---|---| | [libjpeg-turbo](https://libjpeg-turbo.org/) | JPEG encode/decode | [GitHub](https://github.com/libjpeg-turbo/libjpeg-turbo/security) | -| [libpng](http://www.libpng.org/pub/png/libpng.html) | PNG encode/decode | [SourceForge](https://sourceforge.net/p/libpng/bugs/) | +| [libpng](http://www.libpng.org/pub/png/libpng.html) | PNG encode/decode within FreeType 2, OpenJPEG and WebP | [SourceForge](https://sourceforge.net/p/libpng/bugs/) | | [libtiff](https://libtiff.gitlab.io/libtiff/) | TIFF encode/decode | [GitLab](https://gitlab.com/libtiff/libtiff/-/work_items) | -| [libwebp](https://chromium.googlesource.com/webm/libwebp) | WebP encode/decode | [Chromium tracker](https://bugs.chromium.org/p/webm/) | +| [libwebp](https://chromium.googlesource.com/webm/libwebp) | WebP encode/decode | [Chromium tracker](https://issues.webmproject.org/issues) | | [libavif](https://github.com/AOMediaCodec/libavif) | AVIF encode/decode | [GitHub](https://github.com/AOMediaCodec/libavif/security) | -| [aom](https://aomedia.googlesource.com/aom/) | AV1 codec (AVIF) | [Chromium tracker](https://bugs.chromium.org/p/aomedia/) | +| [aom](https://aomedia.googlesource.com/aom/) | AV1 codec (AVIF) | [Chromium tracker](https://aomedia.issues.chromium.org/issues) | | [dav1d](https://code.videolan.org/videolan/dav1d) | AV1 decode (AVIF) | [VideoLAN Security](https://www.videolan.org/security/) | | [openjpeg](https://www.openjpeg.org/) | JPEG 2000 encode/decode | [GitHub](https://github.com/uclouvain/openjpeg/security) | | [freetype2](https://freetype.org/) | Font rendering | [GitLab](https://gitlab.freedesktop.org/freetype/freetype/-/work_items) | -| [lcms2](https://www.littlecms.com/) | ICC color management | [GitHub](https://github.com/mm2/Little-CMS) | +| [lcms2](https://www.littlecms.com/) | ICC color management | [GitHub](https://github.com/mm2/Little-CMS/security) | | [harfbuzz](https://harfbuzz.github.io/) | Text shaping (via raqm) | [GitHub](https://github.com/harfbuzz/harfbuzz/security) | | [raqm](https://github.com/HOST-Oman/libraqm) | Complex text layout | [GitHub](https://github.com/HOST-Oman/libraqm) | | [fribidi](https://github.com/fribidi/fribidi) | Unicode bidi (via raqm) | [GitHub](https://github.com/fribidi/fribidi) | | [zlib](https://zlib.net/) | Deflate compression | [zlib.net](https://zlib.net/) | -| [liblzma / xz-utils](https://tukaani.org/xz/) | XZ/LZMA compression | [GitHub](https://github.com/tukaani-project/xz) | +| [liblzma / xz-utils](https://tukaani.org/xz/) | XZ/LZMA compression | [GitHub](https://github.com/tukaani-project/xz/security) | | [bzip2](https://gitlab.com/bzip2/bzip2) | BZ2 compression | [GitLab](https://gitlab.com/bzip2/bzip2/-/work_items) | | [zstd](https://github.com/facebook/zstd) | Zstandard compression | [GitHub](https://github.com/facebook/zstd/security) | -| [brotli](https://github.com/google/brotli) | Brotli compression | [GitHub](https://github.com/google/brotli) | -| [libyuv](https://chromium.googlesource.com/libyuv/libyuv/) | YUV conversion | [Chromium tracker](https://bugs.chromium.org/p/libyuv/) | +| [brotli](https://github.com/google/brotli) | Brotli compression | [GitHub](https://github.com/google/brotli/security) | +| [libyuv](https://chromium.googlesource.com/libyuv/libyuv/) | YUV conversion | [Chromium tracker](https://libyuv.issues.chromium.org/issues) | #### Python-level dependencies @@ -285,7 +285,7 @@ these downstream consumers when assessing severity and planning communications. | Fedora / RHEL / CentOS | `python3-pillow` | [Red Hat Security](https://access.redhat.com/security/) | | Alpine Linux | `py3-pillow` | [Alpine security](https://security.alpinelinux.org/) | | Arch Linux | `python-pillow` | [Arch security tracker](https://security.archlinux.org/) | -| Homebrew | `pillow` | [Homebrew maintainers](https://github.com/Homebrew/homebrew-core) | +| Homebrew | `pillow` | [Homebrew maintainers](https://github.com/Homebrew/homebrew-core/security) | | conda-forge | `pillow` | [conda-forge](https://github.com/conda-forge/pillow-feedstock) | #### Major Python ecosystem consumers @@ -326,7 +326,7 @@ This document is a living record. It should be kept current so it is useful when - [Security Policy](SECURITY.md) - [Release Checklist](../RELEASING.md) - [Contributing Guide](CONTRIBUTING.md) -- [Tidelift Security Contact](https://tidelift.com/security) +- [Tidelift Security Contact](https://tidelift.com/docs/security) - [GitHub: Privately reporting a security vulnerability](https://docs.github.com/en/code-security/security-advisories/guidance-on-reporting-and-writing/privately-reporting-a-security-vulnerability) - [GitHub as a CVE Numbering Authority (CNA)](https://docs.github.com/en/code-security/security-advisories/working-with-repository-security-advisories/about-repository-security-advisories) - [FIRST CVSS 4.0 Calculator](https://www.first.org/cvss/calculator/4.0) @@ -369,10 +369,15 @@ This document is a living record. It should be kept current so it is useful when > information confidential until the disclosure date listed below. > > **CVE:** \ +> > **Affected versions:** \ +> > **Fixed version:** \ +> > **Severity:** \ (CVSS \: \) +> > **Reporter:** \ +> > **Public disclosure date:** \ > > **Summary:** @@ -396,9 +401,13 @@ This document is a living record. It should be kept current so it is useful when > **Summary:** \ > > **CVE:** \ +> > **Affected versions:** Pillow \< \ +> > **Fixed version:** \ +> > **Severity:** \ (CVSS \) +> > **Reporter:** \ > > **Details:** From 9605fccf00b19319a90caf35216145cd40084cf4 Mon Sep 17 00:00:00 2001 From: Jeffrey 'Alex' Clark Date: Wed, 22 Apr 2026 21:25:52 -0400 Subject: [PATCH 144/190] Revise development support information in README Updated development support section with new sponsors. --- README.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index c6d09a821..4fb31ca93 100644 --- a/README.md +++ b/README.md @@ -9,8 +9,10 @@ Pillow is the friendly PIL fork by [Jeffrey 'Alex' Clark and contributors](https://github.com/python-pillow/Pillow/graphs/contributors). PIL is the Python Imaging Library by Fredrik Lundh and contributors. -As of 2019, Pillow development is -[supported by Tidelift](https://tidelift.com/subscription/pkg/pypi-pillow?utm_source=pypi-pillow&utm_medium=readme&utm_campaign=enterprise). +Development is supported by: +- [Tidelift](https://tidelift.com/subscription/pkg/pypi-pillow?utm_source=pypi-pillow&utm_medium=readme&utm_campaign=enterprise) (since 2019) +- [Thanks.dev](https://thanks.dev) (since 2023) +- [GitHub Sponsors](https://github.com/sponsors/python-pillow) (since 2026) From 5f9112e86209c36a4b63d81e5ab6878d9a708790 Mon Sep 17 00:00:00 2001 From: Jeffrey 'Alex' Clark Date: Wed, 22 Apr 2026 22:22:33 -0400 Subject: [PATCH 145/190] Update README.md Co-authored-by: Andrew Murray <3112309+radarhere@users.noreply.github.com> --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 4fb31ca93..b4d83c2a9 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ Pillow is the friendly PIL fork by [Jeffrey 'Alex' Clark and contributors](https://github.com/python-pillow/Pillow/graphs/contributors). PIL is the Python Imaging Library by Fredrik Lundh and contributors. Development is supported by: -- [Tidelift](https://tidelift.com/subscription/pkg/pypi-pillow?utm_source=pypi-pillow&utm_medium=readme&utm_campaign=enterprise) (since 2019) +- [Tidelift](https://tidelift.com/subscription/pkg/pypi-pillow?utm_source=pypi-pillow&utm_medium=readme&utm_campaign=enterprise) (since 2018) - [Thanks.dev](https://thanks.dev) (since 2023) - [GitHub Sponsors](https://github.com/sponsors/python-pillow) (since 2026) From a908c624600e46391e9faf0108f50acf16b5bde3 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Sat, 25 Apr 2026 13:19:01 +1000 Subject: [PATCH 146/190] Skip test_1 for Ghostscript 10.06.0 --- Tests/test_file_eps.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/Tests/test_file_eps.py b/Tests/test_file_eps.py index d4e8db4f4..d41bab307 100644 --- a/Tests/test_file_eps.py +++ b/Tests/test_file_eps.py @@ -1,6 +1,7 @@ from __future__ import annotations import io +import subprocess from pathlib import Path import pytest @@ -281,6 +282,11 @@ def test_bytesio_object() -> None: ), ) def test_1(filename: str) -> None: + gs_binary = EpsImagePlugin.gs_binary + assert isinstance(gs_binary, str) + if subprocess.check_output([gs_binary, "--version"]) == b"10.06.0\n": + pytest.skip("Fails with Ghostscript 10.06.0") + with Image.open(filename) as im: assert_image_equal_tofile(im, "Tests/images/eps/1.bmp") From 855774a175d42019c33f319a2bf849489751be19 Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade <1324225+hugovk@users.noreply.github.com> Date: Fri, 24 Apr 2026 13:22:45 +0300 Subject: [PATCH 147/190] Test Ubuntu 26.04 Co-authored-by: Andrew Murray --- .github/workflows/test-docker.yml | 10 ++++++++++ docs/installation/building-from-source.rst | 2 +- docs/installation/platform-support.rst | 3 +++ 3 files changed, 14 insertions(+), 1 deletion(-) diff --git a/.github/workflows/test-docker.yml b/.github/workflows/test-docker.yml index 083cb9fc2..3210cca33 100644 --- a/.github/workflows/test-docker.yml +++ b/.github/workflows/test-docker.yml @@ -41,6 +41,8 @@ jobs: # Run slower jobs first to give them a headstart and reduce waiting time ubuntu-24.04-noble-ppc64le, ubuntu-24.04-noble-s390x, + ubuntu-26.04-resolute-ppc64le, + ubuntu-26.04-resolute-s390x, # Then run the remainder alpine, amazon-2023-amd64, @@ -53,6 +55,7 @@ jobs: gentoo, ubuntu-22.04-jammy-amd64, ubuntu-24.04-noble-amd64, + ubuntu-26.04-resolute-amd64, ] dockerTag: [main] include: @@ -63,6 +66,13 @@ jobs: - docker: "ubuntu-24.04-noble-arm64v8" os: "ubuntu-24.04-arm" dockerTag: main + - docker: "ubuntu-26.04-resolute-ppc64le" + qemu-arch: "ppc64le" + - docker: "ubuntu-26.04-resolute-s390x" + qemu-arch: "s390x" + - docker: "ubuntu-26.04-resolute-arm64v8" + os: "ubuntu-24.04-arm" + dockerTag: main name: ${{ matrix.docker }} diff --git a/docs/installation/building-from-source.rst b/docs/installation/building-from-source.rst index 1655b8f60..79d54145a 100644 --- a/docs/installation/building-from-source.rst +++ b/docs/installation/building-from-source.rst @@ -116,7 +116,7 @@ Many of Pillow's features require external libraries: .. Note:: ``redhat-rpm-config`` is required on Fedora 23, but not earlier versions. - Prerequisites for **Ubuntu 16.04 LTS - 24.04 LTS** are installed with:: + Prerequisites for **Ubuntu 16.04 LTS - 26.04 LTS** are installed with:: sudo apt-get install libtiff5-dev libjpeg8-dev libopenjp2-7-dev zlib1g-dev \ libfreetype6-dev liblcms2-dev libwebp-dev tcl8.6-dev tk8.6-dev python3-tk \ diff --git a/docs/installation/platform-support.rst b/docs/installation/platform-support.rst index 0d6bc2777..2d3735e7a 100644 --- a/docs/installation/platform-support.rst +++ b/docs/installation/platform-support.rst @@ -46,6 +46,9 @@ These platforms are built and tested for every change. | | 3.12 | arm64v8, ppc64le, | | | | s390x | +----------------------------------+----------------------------+---------------------+ +| Ubuntu Linux 26.04 LTS (Resolute)| 3.14 | x86-64, arm64v8, | +| | | ppc64le, s390x | ++----------------------------------+----------------------------+---------------------+ | Windows Server 2022 | 3.10 | x86 | +----------------------------------+----------------------------+---------------------+ | Windows Server 2025 | 3.11, 3.12, 3.13, 3.14, | x86-64 | From 8c522096e801de47159fc2eaa0e3f9f26d448011 Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade <1324225+hugovk@users.noreply.github.com> Date: Sat, 25 Apr 2026 14:08:01 +0300 Subject: [PATCH 148/190] Archive non-amd64 variants of 24.04 --- .github/workflows/test-docker.yml | 9 --------- docs/installation/platform-support.rst | 3 --- 2 files changed, 12 deletions(-) diff --git a/.github/workflows/test-docker.yml b/.github/workflows/test-docker.yml index 3210cca33..82b3c8a23 100644 --- a/.github/workflows/test-docker.yml +++ b/.github/workflows/test-docker.yml @@ -39,8 +39,6 @@ jobs: os: ["ubuntu-latest"] docker: [ # Run slower jobs first to give them a headstart and reduce waiting time - ubuntu-24.04-noble-ppc64le, - ubuntu-24.04-noble-s390x, ubuntu-26.04-resolute-ppc64le, ubuntu-26.04-resolute-s390x, # Then run the remainder @@ -59,13 +57,6 @@ jobs: ] dockerTag: [main] include: - - docker: "ubuntu-24.04-noble-ppc64le" - qemu-arch: "ppc64le" - - docker: "ubuntu-24.04-noble-s390x" - qemu-arch: "s390x" - - docker: "ubuntu-24.04-noble-arm64v8" - os: "ubuntu-24.04-arm" - dockerTag: main - docker: "ubuntu-26.04-resolute-ppc64le" qemu-arch: "ppc64le" - docker: "ubuntu-26.04-resolute-s390x" diff --git a/docs/installation/platform-support.rst b/docs/installation/platform-support.rst index 2d3735e7a..e90d989a2 100644 --- a/docs/installation/platform-support.rst +++ b/docs/installation/platform-support.rst @@ -42,9 +42,6 @@ These platforms are built and tested for every change. +----------------------------------+----------------------------+---------------------+ | Ubuntu Linux 24.04 LTS (Noble) | 3.10, 3.11, 3.12, 3.13, | x86-64 | | | 3.14, 3.15, PyPy3 | | -| +----------------------------+---------------------+ -| | 3.12 | arm64v8, ppc64le, | -| | | s390x | +----------------------------------+----------------------------+---------------------+ | Ubuntu Linux 26.04 LTS (Resolute)| 3.14 | x86-64, arm64v8, | | | | ppc64le, s390x | From d2b20102e4e695d088835a9f59ca95118c18a80b Mon Sep 17 00:00:00 2001 From: Jeffrey 'Alex' Clark Date: Sat, 25 Apr 2026 17:35:21 -0400 Subject: [PATCH 149/190] Generate CycloneDX SBOM at release time via CI (#9550) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Andrew Murray <3112309+radarhere@users.noreply.github.com> Co-authored-by: Andrew Murray Co-authored-by: Hugo van Kemenade <1324225+hugovk@users.noreply.github.com> Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: Jan Kowalleck --- .ci/requirements-sbom.txt | 1 + .github/generate-sbom.py | 547 +++++++++++++++++++++++++++++++++++ .github/workflows/wheels.yml | 51 ++++ 3 files changed, 599 insertions(+) create mode 100644 .ci/requirements-sbom.txt create mode 100755 .github/generate-sbom.py diff --git a/.ci/requirements-sbom.txt b/.ci/requirements-sbom.txt new file mode 100644 index 000000000..762812f80 --- /dev/null +++ b/.ci/requirements-sbom.txt @@ -0,0 +1 @@ +check-jsonschema==0.37.1 diff --git a/.github/generate-sbom.py b/.github/generate-sbom.py new file mode 100755 index 000000000..9e65121e6 --- /dev/null +++ b/.github/generate-sbom.py @@ -0,0 +1,547 @@ +#!/usr/bin/env python3 +"""Generate a CycloneDX 1.7 SBOM for Pillow's C extensions and their +vendored/optional native library dependencies. + +Usage: + python3 .github/generate-sbom.py [output-file] + +Output defaults to pillow-{version}.cdx.json in the current directory. +""" + +from __future__ import annotations + +import argparse +import base64 +import datetime as dt +import difflib +import hashlib +import json +import urllib.request +import uuid +from pathlib import Path + + +def get_version() -> str: + version_file = Path(__file__).parent.parent / "src" / "PIL" / "_version.py" + return version_file.read_text(encoding="utf-8").split('"')[1] + + +def sha256_file(path: Path) -> str: + return hashlib.sha256(path.read_bytes()).hexdigest() + + +def upstream_diff_b64( + upstream_url: str, + upstream_display: bytes, + local_path: Path, + local_display: bytes, +) -> str: + """ + Fetch an upstream file and return a base64-encoded unified diff vs the local copy. + """ + with urllib.request.urlopen(upstream_url) as resp: + upstream_text = resp.read() + local_text = local_path.read_bytes() + diff_lines = difflib.diff_bytes( + difflib.unified_diff, + upstream_text.splitlines(keepends=True), + local_text.splitlines(keepends=True), + fromfile=b"a/" + upstream_display, + tofile=b"b/" + local_display, + ) + return base64.b64encode(b"".join(diff_lines)).decode() + + +def generate(version: str) -> dict: + serial = str(uuid.uuid4()) + now = dt.datetime.now(dt.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + purl = f"pkg:pypi/pillow@{version}" + root = Path(__file__).parent.parent + thirdparty = root / "src" / "thirdparty" + + metadata_component = { + "bom-ref": purl, + "type": "library", + "name": "Pillow", + "version": version, + "description": "Python Imaging Library (fork)", + "licenses": [{"license": {"id": "MIT-CMU"}}], + "purl": purl, + "externalReferences": [ + {"type": "website", "url": "https://python-pillow.github.io"}, + {"type": "vcs", "url": "https://github.com/python-pillow/Pillow"}, + {"type": "documentation", "url": "https://pillow.readthedocs.io"}, + { + "type": "security-contact", + "url": "https://github.com/python-pillow/Pillow/security/policy", + }, + ], + } + + c_extensions = [ + ( + "PIL._imaging", + "Core image processing extension " + "(decode, encode, map, display, outline, path, libImaging)", + ), + ("PIL._imagingft", "FreeType font rendering extension"), + ("PIL._imagingcms", "LittleCMS2 colour management extension"), + ("PIL._webp", "WebP image format extension"), + ("PIL._avif", "AVIF image format extension"), + ("PIL._imagingtk", "Tk/Tcl display extension"), + ("PIL._imagingmath", "Image math operations extension"), + ("PIL._imagingmorph", "Image morphology extension"), + ] + + ext_components = [ + { + "bom-ref": f"{purl}#c-ext/{name}", + "type": "library", + "name": name, + "version": version, + "description": desc, + "licenses": [{"license": {"id": "MIT-CMU"}}], + "purl": f"{purl}#c-ext/{name}", + } + for name, desc in c_extensions + ] + + vendored_components = [ + { + "bom-ref": f"{purl}#thirdparty/raqm", + "type": "library", + "name": "raqm", + "version": "0.10.5", + "description": "Complex text layout library " + "(vendored in src/thirdparty/raqm/)", + "licenses": [{"license": {"id": "MIT"}}], + "hashes": [ + { + "alg": "SHA-256", + "content": sha256_file(thirdparty / "raqm" / "raqm.c"), + } + ], + "pedigree": { + "ancestors": [ + { + "bom-ref": "pkg:github/HOST-Oman/libraqm@0.10.5#upstream", + "type": "library", + "name": "raqm", + "version": "0.10.5", + "purl": "pkg:github/HOST-Oman/libraqm@0.10.5", + "externalReferences": [ + { + "type": "distribution", + "url": "https://github.com/HOST-Oman/libraqm/releases/tag/v0.10.5", + } + ], + } + ], + "patches": [ + { + "type": "unofficial", + "diff": { + "text": { + # raqm-version.h.in → raqm-version.h: + # template @RAQM_VERSION_*@ placeholders replaced + # with literal 0.10.5 values; filename changed to + # drop the .in suffix; minor indentation fix. + "content": upstream_diff_b64( + "https://raw.githubusercontent.com/HOST-Oman/libraqm/v0.10.5/src/raqm-version.h.in", + b"src/raqm-version.h.in", + thirdparty / "raqm" / "raqm-version.h", + b"src/raqm-version.h", + ), + "encoding": "base64", + } + }, + }, + { + "type": "unofficial", + "diff": { + "text": { + # raqm.c: wrap the include in an + # #ifdef HAVE_FRIBIDI_SYSTEM guard so that when + # building without a system FriBiDi Pillow's own + # fribidi-shim is used instead. + "content": upstream_diff_b64( + "https://raw.githubusercontent.com/HOST-Oman/libraqm/v0.10.5/src/raqm.c", + b"src/raqm.c", + thirdparty / "raqm" / "raqm.c", + b"src/raqm.c", + ), + "encoding": "base64", + } + }, + }, + ], + "notes": ( + "Vendored from upstream HOST-Oman/libraqm v0.10.5 with two " + "Pillow-specific modifications: (1) raqm-version.h.in was " + "pre-processed into raqm-version.h with version placeholders " + "replaced by literal values; (2) raqm.c wraps the " + "include in an #ifdef HAVE_FRIBIDI_SYSTEM guard so Pillow's " + "bundled fribidi-shim is used when a system FriBiDi is absent." + ), + }, + "externalReferences": [ + { + "type": "vcs", + "url": "https://github.com/python-pillow/Pillow/tree/main/src/thirdparty/raqm", + }, + ], + }, + { + "bom-ref": f"{purl}#thirdparty/fribidi-shim", + "type": "library", + "name": "fribidi-shim", + "version": "1.x", + "description": "FriBiDi runtime-loading shim " + "(vendored in src/thirdparty/fribidi-shim/); " + "loads libfribidi dynamically", + "licenses": [{"license": {"id": "LGPL-2.1-or-later"}}], + "hashes": [ + { + "alg": "SHA-256", + "content": sha256_file(thirdparty / "fribidi-shim" / "fribidi.c"), + } + ], + "pedigree": { + "notes": "Pillow-authored shim; not taken from an upstream project." + }, + "externalReferences": [ + {"type": "website", "url": "https://github.com/fribidi/fribidi"}, + ], + }, + { + "bom-ref": "pkg:github/python/pythoncapi-compat", + "type": "library", + "name": "pythoncapi_compat", + "description": "Backport header for new CPython C-API functions " + "(vendored in src/thirdparty/pythoncapi_compat.h)", + "licenses": [{"license": {"id": "0BSD"}}], + "hashes": [ + { + "alg": "SHA-256", + "content": sha256_file(thirdparty / "pythoncapi_compat.h"), + } + ], + "pedigree": { + "notes": "Vendored unmodified from upstream python/pythoncapi-compat." + }, + "externalReferences": [ + { + "type": "vcs", + "url": "https://github.com/python/pythoncapi-compat", + }, + ], + }, + ] + + native_deps = [ + { + "bom-ref": "pkg:generic/libjpeg", + "type": "library", + "name": "libjpeg / libjpeg-turbo", + "description": "JPEG codec (required by default; disable with " + "-C jpeg=disable). Tested with libjpeg 6b/8/9-9d " + "and libjpeg-turbo 2-3.", + "licenses": [ + {"license": {"id": "IJG"}}, + {"license": {"id": "BSD-3-Clause"}}, + ], + "externalReferences": [ + {"type": "website", "url": "https://ijg.org"}, + {"type": "website", "url": "https://libjpeg-turbo.org"}, + { + "type": "distribution", + "url": "https://github.com/libjpeg-turbo/libjpeg-turbo/releases", + }, + ], + }, + { + "bom-ref": "pkg:generic/zlib", + "type": "library", + "name": "zlib", + "description": "Deflate/PNG compression (required by default; " + "disable with -C zlib=disable).", + "licenses": [{"license": {"id": "Zlib"}}], + "externalReferences": [ + {"type": "website", "url": "https://zlib.net"}, + {"type": "distribution", "url": "https://zlib.net"}, + ], + }, + { + "bom-ref": "pkg:generic/libtiff", + "type": "library", + "name": "libtiff", + "scope": "optional", + "description": "TIFF codec (optional). Tested with libtiff 4.0-4.7.1.", + "licenses": [{"license": {"id": "libtiff"}}], + "externalReferences": [ + {"type": "website", "url": "https://libtiff.gitlab.io/libtiff/"}, + { + "type": "distribution", + "url": "https://download.osgeo.org/libtiff/", + }, + ], + }, + { + "bom-ref": "pkg:generic/freetype2", + "type": "library", + "name": "FreeType", + "scope": "optional", + "description": "Font rendering (optional, used by PIL._imagingft). " + "Required for text/font support.", + "licenses": [{"license": {"id": "FTL"}}], + "externalReferences": [ + {"type": "website", "url": "https://freetype.org"}, + { + "type": "distribution", + "url": "https://download.savannah.gnu.org/releases/freetype/", + }, + ], + }, + { + "bom-ref": "pkg:generic/littlecms2", + "type": "library", + "name": "Little CMS 2", + "scope": "optional", + "description": "Colour management (optional, used by PIL._imagingcms). " + "Tested with lcms2 2.7-2.18.", + "licenses": [{"license": {"id": "MIT"}}], + "externalReferences": [ + {"type": "website", "url": "https://www.littlecms.com"}, + { + "type": "distribution", + "url": "https://github.com/mm2/Little-CMS/releases", + }, + ], + }, + { + "bom-ref": "pkg:generic/libwebp", + "type": "library", + "name": "libwebp", + "scope": "optional", + "description": "WebP codec (optional, used by PIL._webp).", + "licenses": [{"license": {"id": "BSD-3-Clause"}}], + "externalReferences": [ + { + "type": "website", + "url": "https://chromium.googlesource.com/webm/libwebp", + }, + { + "type": "distribution", + "url": "https://chromium.googlesource.com/webm/libwebp", + }, + ], + }, + { + "bom-ref": "pkg:generic/openjpeg", + "type": "library", + "name": "OpenJPEG", + "scope": "optional", + "description": "JPEG 2000 codec (optional). " + "Tested with openjpeg 2.0.0-2.5.4.", + "licenses": [{"license": {"id": "BSD-2-Clause"}}], + "externalReferences": [ + {"type": "website", "url": "https://www.openjpeg.org"}, + { + "type": "distribution", + "url": "https://github.com/uclouvain/openjpeg/releases", + }, + ], + }, + { + "bom-ref": "pkg:generic/libavif", + "type": "library", + "name": "libavif", + "scope": "optional", + "description": "AVIF codec (optional, used by PIL._avif). " + "Requires libavif >= 1.0.0.", + "licenses": [{"license": {"id": "BSD-2-Clause"}}], + "externalReferences": [ + {"type": "website", "url": "https://github.com/AOMediaCodec/libavif"}, + { + "type": "distribution", + "url": "https://github.com/AOMediaCodec/libavif/releases", + }, + ], + }, + { + "bom-ref": "pkg:generic/harfbuzz", + "type": "library", + "name": "HarfBuzz", + "scope": "optional", + "description": "Text shaping (optional, required by libraqm " + "for complex text layout).", + "licenses": [{"license": {"id": "MIT"}}], + "externalReferences": [ + {"type": "website", "url": "https://harfbuzz.github.io"}, + { + "type": "distribution", + "url": "https://github.com/harfbuzz/harfbuzz/releases", + }, + ], + }, + { + "bom-ref": "pkg:generic/fribidi", + "type": "library", + "name": "FriBiDi", + "scope": "optional", + "description": "Unicode bidi algorithm library (optional, " + "loaded at runtime by fribidi-shim).", + "licenses": [{"license": {"id": "LGPL-2.1-or-later"}}], + "externalReferences": [ + {"type": "website", "url": "https://github.com/fribidi/fribidi"}, + { + "type": "distribution", + "url": "https://github.com/fribidi/fribidi/releases", + }, + ], + }, + { + "bom-ref": "pkg:generic/libimagequant", + "type": "library", + "name": "libimagequant", + "scope": "optional", + "description": "Improved colour quantization (optional). " + "Tested with 2.6-4.4.1.", + "licenses": [{"license": {"id": "GPL-3.0-or-later"}}], + "externalReferences": [ + {"type": "website", "url": "https://pngquant.org/lib/"}, + { + "type": "distribution", + "url": "https://github.com/ImageOptim/libimagequant/tags", + }, + ], + }, + { + "bom-ref": "pkg:generic/libxcb", + "type": "library", + "name": "libxcb", + "scope": "optional", + "description": "X11 screen-grab support (optional, " + "used by PIL._imaging on macOS and Linux).", + "licenses": [{"license": {"id": "X11"}}], + "externalReferences": [ + {"type": "website", "url": "https://xcb.freedesktop.org"}, + { + "type": "distribution", + "url": "https://xcb.freedesktop.org/dist/", + }, + ], + }, + { + "bom-ref": "pkg:pypi/pybind11", + "type": "library", + "name": "pybind11", + "scope": "excluded", + "description": "Parallel C compilation library (build-time dependency).", + "licenses": [{"license": {"id": "BSD-3-Clause"}}], + "externalReferences": [ + {"type": "website", "url": "https://pybind11.readthedocs.io"}, + { + "type": "distribution", + "url": "https://github.com/pybind/pybind11/releases", + }, + ], + }, + ] + + dependencies = [ + { + "ref": purl, + "dependsOn": [e["bom-ref"] for e in ext_components], + }, + { + "ref": f"{purl}#c-ext/PIL._imaging", + "dependsOn": [ + "pkg:generic/libjpeg", + "pkg:generic/zlib", + "pkg:generic/libtiff", + "pkg:generic/openjpeg", + "pkg:generic/libimagequant", + "pkg:generic/libxcb", + ], + }, + { + "ref": f"{purl}#c-ext/PIL._imagingft", + "dependsOn": [ + "pkg:generic/freetype2", + f"{purl}#thirdparty/raqm", + f"{purl}#thirdparty/fribidi-shim", + "pkg:generic/harfbuzz", + "pkg:generic/fribidi", + ], + }, + { + "ref": f"{purl}#c-ext/PIL._imagingcms", + "dependsOn": ["pkg:generic/littlecms2"], + }, + { + "ref": f"{purl}#c-ext/PIL._webp", + "dependsOn": ["pkg:generic/libwebp"], + }, + { + "ref": f"{purl}#c-ext/PIL._avif", + "dependsOn": ["pkg:generic/libavif"], + }, + { + "ref": f"{purl}#thirdparty/raqm", + "dependsOn": [ + f"{purl}#thirdparty/fribidi-shim", + "pkg:generic/harfbuzz", + ], + }, + ] + + return { + "$schema": "http://cyclonedx.org/schema/bom-1.7.schema.json", + "bomFormat": "CycloneDX", + "specVersion": "1.7", + "serialNumber": f"urn:uuid:{serial}", + "version": 1, + "metadata": { + "timestamp": now, + "lifecycles": [{"phase": "build"}], + "tools": { + "components": [ + { + "type": "application", + "name": "generate-sbom.py", + "group": "pillow", + } + ] + }, + "component": metadata_component, + }, + "components": ext_components + vendored_components + native_deps, + "dependencies": dependencies, + } + + +def main() -> None: + version = get_version() + + parser = argparse.ArgumentParser( + description=__doc__, formatter_class=argparse.ArgumentDefaultsHelpFormatter + ) + parser.add_argument( + "output", + nargs="?", + type=Path, + default=Path(f"pillow-{version}.cdx.json"), + help="output file", + ) + args = parser.parse_args() + + sbom = generate(version) + args.output.write_text(json.dumps(sbom, indent=2) + "\n", encoding="utf-8") + print( + f"Wrote {args.output} (Pillow {version}, {len(sbom['components'])} components)" + ) + + +if __name__ == "__main__": + main() diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index 80080e2c8..cc9e69428 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -12,7 +12,9 @@ on: push: paths: - ".ci/requirements-cibw.txt" + - ".ci/requirements-sbom.txt" - ".github/dependencies.json" + - ".github/generate-sbom.py" - ".github/workflows/wheels*" - "pyproject.toml" - "setup.py" @@ -24,7 +26,9 @@ on: pull_request: paths: - ".ci/requirements-cibw.txt" + - ".ci/requirements-sbom.txt" - ".github/dependencies.json" + - ".github/generate-sbom.py" - ".github/workflows/wheels*" - "pyproject.toml" - "setup.py" @@ -281,6 +285,53 @@ jobs: artifacts_path: dist anaconda_nightly_upload_token: ${{ secrets.ANACONDA_ORG_UPLOAD_TOKEN }} + sbom: + runs-on: ubuntu-latest + name: Generate SBOM + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + with: + python-version: "3.x" + + - name: Generate CycloneDX SBOM + run: python3 .github/generate-sbom.py + + - name: Upload SBOM as workflow artifact + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: sbom + path: "*.cdx.json" + + - name: Validate SBOM + run: | + python3 -m pip install -r .ci/requirements-sbom.txt + check-jsonschema --schemafile "https://raw.githubusercontent.com/CycloneDX/specification/1.7/schema/bom-1.7.schema.json" *.cdx.json + + sbom-publish: + if: | + github.event.repository.fork == false + && github.event_name == 'push' + && startsWith(github.ref, 'refs/tags') + needs: [count-dists, sbom] + runs-on: ubuntu-latest + name: Publish SBOM to GitHub release + permissions: + contents: write + steps: + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: sbom + path: . + + - name: Attach SBOM to GitHub release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: gh release upload "$GITHUB_REF_NAME" *.cdx.json + pypi-publish: if: github.event.repository.fork == false && github.event_name == 'push' && startsWith(github.ref, 'refs/tags') needs: count-dists From f0fe496315c1692f3d914ae908fec516e1c24521 Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade <1324225+hugovk@users.noreply.github.com> Date: Mon, 27 Apr 2026 13:44:52 +0300 Subject: [PATCH 150/190] Fix typo to trigger on self change --- .github/workflows/test-valgrind-memory.yml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/test-valgrind-memory.yml b/.github/workflows/test-valgrind-memory.yml index 1cbcc40d3..5ceb544ea 100644 --- a/.github/workflows/test-valgrind-memory.yml +++ b/.github/workflows/test-valgrind-memory.yml @@ -8,12 +8,13 @@ on: # branches: # - "**" # paths: - # - ".github/workflows/test-valgrind.yml" + # - ".github/workflows/test-valgrind-memory.yml" # - "**.c" # - "**.h" + # - "depends/docker-test-valgrind-memory.sh" pull_request: paths: - - ".github/workflows/test-valgrind.yml" + - ".github/workflows/test-valgrind-memory.yml" - "**.c" - "**.h" - "depends/docker-test-valgrind-memory.sh" From 755b73b274a9aeb2fb4c171762972a9bfc2beff3 Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade <1324225+hugovk@users.noreply.github.com> Date: Mon, 27 Apr 2026 14:14:13 +0300 Subject: [PATCH 151/190] Deduplicate path triggers in workflows --- .github/workflows/cifuzz.yml | 9 ++------- .github/workflows/docs.yml | 7 ++----- .github/workflows/test-docker.yml | 9 ++------- .github/workflows/test-mingw.yml | 9 ++------- .github/workflows/test-valgrind.yml | 7 ++----- .github/workflows/test-windows.yml | 9 ++------- .github/workflows/test.yml | 9 ++------- .github/workflows/wheels.yml | 14 ++------------ 8 files changed, 16 insertions(+), 57 deletions(-) diff --git a/.github/workflows/cifuzz.yml b/.github/workflows/cifuzz.yml index cc8b4606b..27b55cffc 100644 --- a/.github/workflows/cifuzz.yml +++ b/.github/workflows/cifuzz.yml @@ -4,19 +4,14 @@ on: push: branches: - "**" - paths: + paths: &paths - ".github/dependencies.json" - ".github/workflows/cifuzz.yml" - ".github/workflows/wheels-dependencies.sh" - "**.c" - "**.h" pull_request: - paths: - - ".github/dependencies.json" - - ".github/workflows/cifuzz.yml" - - ".github/workflows/wheels-dependencies.sh" - - "**.c" - - "**.h" + paths: *paths workflow_dispatch: permissions: diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 8c29af7b7..3734a3306 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -4,15 +4,12 @@ on: push: branches: - "**" - paths: + paths: &paths - ".github/workflows/docs.yml" - "docs/**" - "src/PIL/**" pull_request: - paths: - - ".github/workflows/docs.yml" - - "docs/**" - - "src/PIL/**" + paths: *paths workflow_dispatch: permissions: diff --git a/.github/workflows/test-docker.yml b/.github/workflows/test-docker.yml index 82b3c8a23..b035ac1de 100644 --- a/.github/workflows/test-docker.yml +++ b/.github/workflows/test-docker.yml @@ -4,19 +4,14 @@ on: push: branches: - "**" - paths-ignore: + paths-ignore: &paths-ignore - ".github/workflows/docs.yml" - ".github/workflows/wheels*" - ".gitmodules" - "docs/**" - "wheels/**" pull_request: - paths-ignore: - - ".github/workflows/docs.yml" - - ".github/workflows/wheels*" - - ".gitmodules" - - "docs/**" - - "wheels/**" + paths-ignore: *paths-ignore workflow_dispatch: permissions: diff --git a/.github/workflows/test-mingw.yml b/.github/workflows/test-mingw.yml index a87928f0b..1c36e06c0 100644 --- a/.github/workflows/test-mingw.yml +++ b/.github/workflows/test-mingw.yml @@ -4,19 +4,14 @@ on: push: branches: - "**" - paths-ignore: + paths-ignore: &paths-ignore - ".github/workflows/docs.yml" - ".github/workflows/wheels*" - ".gitmodules" - "docs/**" - "wheels/**" pull_request: - paths-ignore: - - ".github/workflows/docs.yml" - - ".github/workflows/wheels*" - - ".gitmodules" - - "docs/**" - - "wheels/**" + paths-ignore: *paths-ignore workflow_dispatch: permissions: diff --git a/.github/workflows/test-valgrind.yml b/.github/workflows/test-valgrind.yml index f3ec8c10e..c47a0d060 100644 --- a/.github/workflows/test-valgrind.yml +++ b/.github/workflows/test-valgrind.yml @@ -6,15 +6,12 @@ on: push: branches: - "**" - paths: + paths: &paths - ".github/workflows/test-valgrind.yml" - "**.c" - "**.h" pull_request: - paths: - - ".github/workflows/test-valgrind.yml" - - "**.c" - - "**.h" + paths: *paths workflow_dispatch: permissions: diff --git a/.github/workflows/test-windows.yml b/.github/workflows/test-windows.yml index 6a83338d8..fa1898df2 100644 --- a/.github/workflows/test-windows.yml +++ b/.github/workflows/test-windows.yml @@ -4,19 +4,14 @@ on: push: branches: - "**" - paths-ignore: + paths-ignore: &paths-ignore - ".github/workflows/docs.yml" - ".github/workflows/wheels*" - ".gitmodules" - "docs/**" - "wheels/**" pull_request: - paths-ignore: - - ".github/workflows/docs.yml" - - ".github/workflows/wheels*" - - ".gitmodules" - - "docs/**" - - "wheels/**" + paths-ignore: *paths-ignore workflow_dispatch: permissions: diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 2654e2d04..d90cc805a 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -4,19 +4,14 @@ on: push: branches: - "**" - paths-ignore: + paths-ignore: &paths-ignore - ".github/workflows/docs.yml" - ".github/workflows/wheels*" - ".gitmodules" - "docs/**" - "wheels/**" pull_request: - paths-ignore: - - ".github/workflows/docs.yml" - - ".github/workflows/wheels*" - - ".gitmodules" - - "docs/**" - - "wheels/**" + paths-ignore: *paths-ignore workflow_dispatch: permissions: diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index cc9e69428..98733b6c7 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -10,7 +10,7 @@ on: # │ │ │ │ │ - cron: "42 1 * * 0,3" push: - paths: + paths: &paths - ".ci/requirements-cibw.txt" - ".ci/requirements-sbom.txt" - ".github/dependencies.json" @@ -24,17 +24,7 @@ on: tags: - "*" pull_request: - paths: - - ".ci/requirements-cibw.txt" - - ".ci/requirements-sbom.txt" - - ".github/dependencies.json" - - ".github/generate-sbom.py" - - ".github/workflows/wheels*" - - "pyproject.toml" - - "setup.py" - - "wheels/*" - - "winbuild/build_prepare.py" - - "winbuild/fribidi.cmake" + paths: *paths workflow_dispatch: permissions: From fe054a1b3f29a5de2be3788cf91231915cf61c87 Mon Sep 17 00:00:00 2001 From: Andrew Murray <3112309+radarhere@users.noreply.github.com> Date: Tue, 28 Apr 2026 08:53:21 +1000 Subject: [PATCH 152/190] Added CVEs to 12.2.0 release notes (#9591) Co-authored-by: Andrew Murray --- docs/releasenotes/12.2.0.rst | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/docs/releasenotes/12.2.0.rst b/docs/releasenotes/12.2.0.rst index 0fee9fd82..da678a47b 100644 --- a/docs/releasenotes/12.2.0.rst +++ b/docs/releasenotes/12.2.0.rst @@ -13,28 +13,28 @@ introduced in Pillow 10.3.0. The data being read is now limited to only the necessary amount. -Fix OOB write with invalid tile extents -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +:cve:`2026-42311`: Fix OOB write with invalid tile extents +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Pillow 12.1.1 addressed :cve:`2026-25990` by improving checks for tile extents to prevent an OOB write from specially crafted PSD images in Pillow >= 10.3.0. However, these checks did not consider integer overflow. This has been corrected. -Prevent PDF parsing trailer infinite loop -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +:cve:`2026-42310`: Prevent PDF parsing trailer infinite loop +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ When parsing a PDF, if a trailer refers to itself, or a more complex cyclic loop exists, then an infinite loop occurs. Pillow now keeps a record of which trailers it has already processed. PdfParser was added in Pillow 4.2.0. -Integer overflow when processing fonts -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +:cve:`2026-42308`: Integer overflow when processing fonts +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ If a font advances for each glyph by an exceeding large amount, when Pillow keeps track of the current position, it may lead to an integer overflow. This has been fixed. -Heap buffer overflow with nested list coordinates -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +:cve:`2026-42309`: Heap buffer overflow with nested list coordinates +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Passing nested lists as coordinates to APIs that accept coordinates such as ``ImagePath.Path``, :py:meth:`~PIL.ImageDraw.ImageDraw.polygon` From 99869f031342ad718721b66a791bf0b6eba2fb5e Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade <1324225+hugovk@users.noreply.github.com> Date: Sun, 26 Apr 2026 12:14:58 +0300 Subject: [PATCH 153/190] Sort things alphabetically to make easier to find --- .github/generate-sbom.py | 386 +++++++++++++++++++-------------------- 1 file changed, 193 insertions(+), 193 deletions(-) diff --git a/.github/generate-sbom.py b/.github/generate-sbom.py index 9e65121e6..fb9b37f27 100755 --- a/.github/generate-sbom.py +++ b/.github/generate-sbom.py @@ -79,18 +79,18 @@ def generate(version: str) -> dict: } c_extensions = [ + ("PIL._avif", "AVIF image format extension"), ( "PIL._imaging", "Core image processing extension " "(decode, encode, map, display, outline, path, libImaging)", ), - ("PIL._imagingft", "FreeType font rendering extension"), ("PIL._imagingcms", "LittleCMS2 colour management extension"), - ("PIL._webp", "WebP image format extension"), - ("PIL._avif", "AVIF image format extension"), - ("PIL._imagingtk", "Tk/Tcl display extension"), + ("PIL._imagingft", "FreeType font rendering extension"), ("PIL._imagingmath", "Image math operations extension"), ("PIL._imagingmorph", "Image morphology extension"), + ("PIL._imagingtk", "Tk/Tcl display extension"), + ("PIL._webp", "WebP image format extension"), ] ext_components = [ @@ -107,6 +107,51 @@ def generate(version: str) -> dict: ] vendored_components = [ + { + "bom-ref": f"{purl}#thirdparty/fribidi-shim", + "type": "library", + "name": "fribidi-shim", + "version": "1.x", + "description": "FriBiDi runtime-loading shim " + "(vendored in src/thirdparty/fribidi-shim/); " + "loads libfribidi dynamically", + "licenses": [{"license": {"id": "LGPL-2.1-or-later"}}], + "hashes": [ + { + "alg": "SHA-256", + "content": sha256_file(thirdparty / "fribidi-shim" / "fribidi.c"), + } + ], + "pedigree": { + "notes": "Pillow-authored shim; not taken from an upstream project." + }, + "externalReferences": [ + {"type": "website", "url": "https://github.com/fribidi/fribidi"}, + ], + }, + { + "bom-ref": "pkg:github/python/pythoncapi-compat", + "type": "library", + "name": "pythoncapi_compat", + "description": "Backport header for new CPython C-API functions " + "(vendored in src/thirdparty/pythoncapi_compat.h)", + "licenses": [{"license": {"id": "0BSD"}}], + "hashes": [ + { + "alg": "SHA-256", + "content": sha256_file(thirdparty / "pythoncapi_compat.h"), + } + ], + "pedigree": { + "notes": "Vendored unmodified from upstream python/pythoncapi-compat." + }, + "externalReferences": [ + { + "type": "vcs", + "url": "https://github.com/python/pythoncapi-compat", + }, + ], + }, { "bom-ref": f"{purl}#thirdparty/raqm", "type": "library", @@ -191,54 +236,89 @@ def generate(version: str) -> dict: }, ], }, - { - "bom-ref": f"{purl}#thirdparty/fribidi-shim", - "type": "library", - "name": "fribidi-shim", - "version": "1.x", - "description": "FriBiDi runtime-loading shim " - "(vendored in src/thirdparty/fribidi-shim/); " - "loads libfribidi dynamically", - "licenses": [{"license": {"id": "LGPL-2.1-or-later"}}], - "hashes": [ - { - "alg": "SHA-256", - "content": sha256_file(thirdparty / "fribidi-shim" / "fribidi.c"), - } - ], - "pedigree": { - "notes": "Pillow-authored shim; not taken from an upstream project." - }, - "externalReferences": [ - {"type": "website", "url": "https://github.com/fribidi/fribidi"}, - ], - }, - { - "bom-ref": "pkg:github/python/pythoncapi-compat", - "type": "library", - "name": "pythoncapi_compat", - "description": "Backport header for new CPython C-API functions " - "(vendored in src/thirdparty/pythoncapi_compat.h)", - "licenses": [{"license": {"id": "0BSD"}}], - "hashes": [ - { - "alg": "SHA-256", - "content": sha256_file(thirdparty / "pythoncapi_compat.h"), - } - ], - "pedigree": { - "notes": "Vendored unmodified from upstream python/pythoncapi-compat." - }, - "externalReferences": [ - { - "type": "vcs", - "url": "https://github.com/python/pythoncapi-compat", - }, - ], - }, ] native_deps = [ + { + "bom-ref": "pkg:generic/freetype2", + "type": "library", + "name": "FreeType", + "scope": "optional", + "description": "Font rendering (optional, used by PIL._imagingft). " + "Required for text/font support.", + "licenses": [{"license": {"id": "FTL"}}], + "externalReferences": [ + {"type": "website", "url": "https://freetype.org"}, + { + "type": "distribution", + "url": "https://download.savannah.gnu.org/releases/freetype/", + }, + ], + }, + { + "bom-ref": "pkg:generic/fribidi", + "type": "library", + "name": "FriBiDi", + "scope": "optional", + "description": "Unicode bidi algorithm library (optional, " + "loaded at runtime by fribidi-shim).", + "licenses": [{"license": {"id": "LGPL-2.1-or-later"}}], + "externalReferences": [ + {"type": "website", "url": "https://github.com/fribidi/fribidi"}, + { + "type": "distribution", + "url": "https://github.com/fribidi/fribidi/releases", + }, + ], + }, + { + "bom-ref": "pkg:generic/harfbuzz", + "type": "library", + "name": "HarfBuzz", + "scope": "optional", + "description": "Text shaping (optional, required by libraqm " + "for complex text layout).", + "licenses": [{"license": {"id": "MIT"}}], + "externalReferences": [ + {"type": "website", "url": "https://harfbuzz.github.io"}, + { + "type": "distribution", + "url": "https://github.com/harfbuzz/harfbuzz/releases", + }, + ], + }, + { + "bom-ref": "pkg:generic/libavif", + "type": "library", + "name": "libavif", + "scope": "optional", + "description": "AVIF codec (optional, used by PIL._avif). " + "Requires libavif >= 1.0.0.", + "licenses": [{"license": {"id": "BSD-2-Clause"}}], + "externalReferences": [ + {"type": "website", "url": "https://github.com/AOMediaCodec/libavif"}, + { + "type": "distribution", + "url": "https://github.com/AOMediaCodec/libavif/releases", + }, + ], + }, + { + "bom-ref": "pkg:generic/libimagequant", + "type": "library", + "name": "libimagequant", + "scope": "optional", + "description": "Improved colour quantization (optional). " + "Tested with 2.6-4.4.1.", + "licenses": [{"license": {"id": "GPL-3.0-or-later"}}], + "externalReferences": [ + {"type": "website", "url": "https://pngquant.org/lib/"}, + { + "type": "distribution", + "url": "https://github.com/ImageOptim/libimagequant/tags", + }, + ], + }, { "bom-ref": "pkg:generic/libjpeg", "type": "library", @@ -259,18 +339,6 @@ def generate(version: str) -> dict: }, ], }, - { - "bom-ref": "pkg:generic/zlib", - "type": "library", - "name": "zlib", - "description": "Deflate/PNG compression (required by default; " - "disable with -C zlib=disable).", - "licenses": [{"license": {"id": "Zlib"}}], - "externalReferences": [ - {"type": "website", "url": "https://zlib.net"}, - {"type": "distribution", "url": "https://zlib.net"}, - ], - }, { "bom-ref": "pkg:generic/libtiff", "type": "library", @@ -286,38 +354,6 @@ def generate(version: str) -> dict: }, ], }, - { - "bom-ref": "pkg:generic/freetype2", - "type": "library", - "name": "FreeType", - "scope": "optional", - "description": "Font rendering (optional, used by PIL._imagingft). " - "Required for text/font support.", - "licenses": [{"license": {"id": "FTL"}}], - "externalReferences": [ - {"type": "website", "url": "https://freetype.org"}, - { - "type": "distribution", - "url": "https://download.savannah.gnu.org/releases/freetype/", - }, - ], - }, - { - "bom-ref": "pkg:generic/littlecms2", - "type": "library", - "name": "Little CMS 2", - "scope": "optional", - "description": "Colour management (optional, used by PIL._imagingcms). " - "Tested with lcms2 2.7-2.18.", - "licenses": [{"license": {"id": "MIT"}}], - "externalReferences": [ - {"type": "website", "url": "https://www.littlecms.com"}, - { - "type": "distribution", - "url": "https://github.com/mm2/Little-CMS/releases", - }, - ], - }, { "bom-ref": "pkg:generic/libwebp", "type": "library", @@ -336,86 +372,6 @@ def generate(version: str) -> dict: }, ], }, - { - "bom-ref": "pkg:generic/openjpeg", - "type": "library", - "name": "OpenJPEG", - "scope": "optional", - "description": "JPEG 2000 codec (optional). " - "Tested with openjpeg 2.0.0-2.5.4.", - "licenses": [{"license": {"id": "BSD-2-Clause"}}], - "externalReferences": [ - {"type": "website", "url": "https://www.openjpeg.org"}, - { - "type": "distribution", - "url": "https://github.com/uclouvain/openjpeg/releases", - }, - ], - }, - { - "bom-ref": "pkg:generic/libavif", - "type": "library", - "name": "libavif", - "scope": "optional", - "description": "AVIF codec (optional, used by PIL._avif). " - "Requires libavif >= 1.0.0.", - "licenses": [{"license": {"id": "BSD-2-Clause"}}], - "externalReferences": [ - {"type": "website", "url": "https://github.com/AOMediaCodec/libavif"}, - { - "type": "distribution", - "url": "https://github.com/AOMediaCodec/libavif/releases", - }, - ], - }, - { - "bom-ref": "pkg:generic/harfbuzz", - "type": "library", - "name": "HarfBuzz", - "scope": "optional", - "description": "Text shaping (optional, required by libraqm " - "for complex text layout).", - "licenses": [{"license": {"id": "MIT"}}], - "externalReferences": [ - {"type": "website", "url": "https://harfbuzz.github.io"}, - { - "type": "distribution", - "url": "https://github.com/harfbuzz/harfbuzz/releases", - }, - ], - }, - { - "bom-ref": "pkg:generic/fribidi", - "type": "library", - "name": "FriBiDi", - "scope": "optional", - "description": "Unicode bidi algorithm library (optional, " - "loaded at runtime by fribidi-shim).", - "licenses": [{"license": {"id": "LGPL-2.1-or-later"}}], - "externalReferences": [ - {"type": "website", "url": "https://github.com/fribidi/fribidi"}, - { - "type": "distribution", - "url": "https://github.com/fribidi/fribidi/releases", - }, - ], - }, - { - "bom-ref": "pkg:generic/libimagequant", - "type": "library", - "name": "libimagequant", - "scope": "optional", - "description": "Improved colour quantization (optional). " - "Tested with 2.6-4.4.1.", - "licenses": [{"license": {"id": "GPL-3.0-or-later"}}], - "externalReferences": [ - {"type": "website", "url": "https://pngquant.org/lib/"}, - { - "type": "distribution", - "url": "https://github.com/ImageOptim/libimagequant/tags", - }, - ], - }, { "bom-ref": "pkg:generic/libxcb", "type": "library", @@ -432,6 +388,38 @@ def generate(version: str) -> dict: }, ], }, + { + "bom-ref": "pkg:generic/littlecms2", + "type": "library", + "name": "Little CMS 2", + "scope": "optional", + "description": "Colour management (optional, used by PIL._imagingcms). " + "Tested with lcms2 2.7-2.18.", + "licenses": [{"license": {"id": "MIT"}}], + "externalReferences": [ + {"type": "website", "url": "https://www.littlecms.com"}, + { + "type": "distribution", + "url": "https://github.com/mm2/Little-CMS/releases", + }, + ], + }, + { + "bom-ref": "pkg:generic/openjpeg", + "type": "library", + "name": "OpenJPEG", + "scope": "optional", + "description": "JPEG 2000 codec (optional). " + "Tested with openjpeg 2.0.0-2.5.4.", + "licenses": [{"license": {"id": "BSD-2-Clause"}}], + "externalReferences": [ + {"type": "website", "url": "https://www.openjpeg.org"}, + { + "type": "distribution", + "url": "https://github.com/uclouvain/openjpeg/releases", + }, + ], + }, { "bom-ref": "pkg:pypi/pybind11", "type": "library", @@ -447,51 +435,63 @@ def generate(version: str) -> dict: }, ], }, + { + "bom-ref": "pkg:generic/zlib", + "type": "library", + "name": "zlib", + "description": "Deflate/PNG compression (required by default; " + "disable with -C zlib=disable).", + "licenses": [{"license": {"id": "Zlib"}}], + "externalReferences": [ + {"type": "website", "url": "https://zlib.net"}, + {"type": "distribution", "url": "https://zlib.net"}, + ], + }, ] dependencies = [ { "ref": purl, - "dependsOn": [e["bom-ref"] for e in ext_components], + "dependsOn": sorted(e["bom-ref"] for e in ext_components), + }, + { + "ref": f"{purl}#c-ext/PIL._avif", + "dependsOn": ["pkg:generic/libavif"], }, { "ref": f"{purl}#c-ext/PIL._imaging", "dependsOn": [ - "pkg:generic/libjpeg", - "pkg:generic/zlib", - "pkg:generic/libtiff", - "pkg:generic/openjpeg", "pkg:generic/libimagequant", + "pkg:generic/libjpeg", + "pkg:generic/libtiff", "pkg:generic/libxcb", - ], - }, - { - "ref": f"{purl}#c-ext/PIL._imagingft", - "dependsOn": [ - "pkg:generic/freetype2", - f"{purl}#thirdparty/raqm", - f"{purl}#thirdparty/fribidi-shim", - "pkg:generic/harfbuzz", - "pkg:generic/fribidi", + "pkg:generic/openjpeg", + "pkg:generic/zlib", ], }, { "ref": f"{purl}#c-ext/PIL._imagingcms", "dependsOn": ["pkg:generic/littlecms2"], }, + { + "ref": f"{purl}#c-ext/PIL._imagingft", + "dependsOn": [ + "pkg:generic/freetype2", + "pkg:generic/fribidi", + "pkg:generic/harfbuzz", + f"{purl}#thirdparty/fribidi-shim", + f"{purl}#thirdparty/raqm", + ], + }, { "ref": f"{purl}#c-ext/PIL._webp", "dependsOn": ["pkg:generic/libwebp"], }, - { - "ref": f"{purl}#c-ext/PIL._avif", - "dependsOn": ["pkg:generic/libavif"], - }, { "ref": f"{purl}#thirdparty/raqm", "dependsOn": [ - f"{purl}#thirdparty/fribidi-shim", "pkg:generic/harfbuzz", + f"{purl}#thirdparty/fribidi-shim", ], }, ] From f2ee74b2f8756f7ff162d65b0e501c635a169e24 Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade <1324225+hugovk@users.noreply.github.com> Date: Sun, 26 Apr 2026 12:19:01 +0300 Subject: [PATCH 154/190] Use versions from dependencies.json, remove historical 'tested on' --- .github/generate-sbom.py | 35 ++++++++++++++++++++++++----------- 1 file changed, 24 insertions(+), 11 deletions(-) diff --git a/.github/generate-sbom.py b/.github/generate-sbom.py index fb9b37f27..c041300f2 100755 --- a/.github/generate-sbom.py +++ b/.github/generate-sbom.py @@ -26,6 +26,11 @@ def get_version() -> str: return version_file.read_text(encoding="utf-8").split('"')[1] +def load_dep_versions() -> dict[str, str]: + deps_file = Path(__file__).parent / "dependencies.json" + return json.loads(deps_file.read_text(encoding="utf-8")) + + def sha256_file(path: Path) -> str: return hashlib.sha256(path.read_bytes()).hexdigest() @@ -58,6 +63,7 @@ def generate(version: str) -> dict: purl = f"pkg:pypi/pillow@{version}" root = Path(__file__).parent.parent thirdparty = root / "src" / "thirdparty" + versions = load_dep_versions() metadata_component = { "bom-ref": purl, @@ -243,6 +249,7 @@ def generate(version: str) -> dict: "bom-ref": "pkg:generic/freetype2", "type": "library", "name": "FreeType", + "version": versions["freetype"], "scope": "optional", "description": "Font rendering (optional, used by PIL._imagingft). " "Required for text/font support.", @@ -259,6 +266,7 @@ def generate(version: str) -> dict: "bom-ref": "pkg:generic/fribidi", "type": "library", "name": "FriBiDi", + "version": versions["fribidi"], "scope": "optional", "description": "Unicode bidi algorithm library (optional, " "loaded at runtime by fribidi-shim).", @@ -275,6 +283,7 @@ def generate(version: str) -> dict: "bom-ref": "pkg:generic/harfbuzz", "type": "library", "name": "HarfBuzz", + "version": versions["harfbuzz"], "scope": "optional", "description": "Text shaping (optional, required by libraqm " "for complex text layout).", @@ -291,9 +300,9 @@ def generate(version: str) -> dict: "bom-ref": "pkg:generic/libavif", "type": "library", "name": "libavif", + "version": versions["libavif"], "scope": "optional", - "description": "AVIF codec (optional, used by PIL._avif). " - "Requires libavif >= 1.0.0.", + "description": "AVIF codec (optional, used by PIL._avif).", "licenses": [{"license": {"id": "BSD-2-Clause"}}], "externalReferences": [ {"type": "website", "url": "https://github.com/AOMediaCodec/libavif"}, @@ -307,9 +316,9 @@ def generate(version: str) -> dict: "bom-ref": "pkg:generic/libimagequant", "type": "library", "name": "libimagequant", + "version": versions["libimagequant"], "scope": "optional", - "description": "Improved colour quantization (optional). " - "Tested with 2.6-4.4.1.", + "description": "Improved colour quantization (optional).", "licenses": [{"license": {"id": "GPL-3.0-or-later"}}], "externalReferences": [ {"type": "website", "url": "https://pngquant.org/lib/"}, @@ -323,9 +332,9 @@ def generate(version: str) -> dict: "bom-ref": "pkg:generic/libjpeg", "type": "library", "name": "libjpeg / libjpeg-turbo", + "version": versions["jpegturbo"], "description": "JPEG codec (required by default; disable with " - "-C jpeg=disable). Tested with libjpeg 6b/8/9-9d " - "and libjpeg-turbo 2-3.", + "-C jpeg=disable).", "licenses": [ {"license": {"id": "IJG"}}, {"license": {"id": "BSD-3-Clause"}}, @@ -343,8 +352,9 @@ def generate(version: str) -> dict: "bom-ref": "pkg:generic/libtiff", "type": "library", "name": "libtiff", + "version": versions["tiff"], "scope": "optional", - "description": "TIFF codec (optional). Tested with libtiff 4.0-4.7.1.", + "description": "TIFF codec (optional).", "licenses": [{"license": {"id": "libtiff"}}], "externalReferences": [ {"type": "website", "url": "https://libtiff.gitlab.io/libtiff/"}, @@ -358,6 +368,7 @@ def generate(version: str) -> dict: "bom-ref": "pkg:generic/libwebp", "type": "library", "name": "libwebp", + "version": versions["libwebp"], "scope": "optional", "description": "WebP codec (optional, used by PIL._webp).", "licenses": [{"license": {"id": "BSD-3-Clause"}}], @@ -376,6 +387,7 @@ def generate(version: str) -> dict: "bom-ref": "pkg:generic/libxcb", "type": "library", "name": "libxcb", + "version": versions["libxcb"], "scope": "optional", "description": "X11 screen-grab support (optional, " "used by PIL._imaging on macOS and Linux).", @@ -392,9 +404,9 @@ def generate(version: str) -> dict: "bom-ref": "pkg:generic/littlecms2", "type": "library", "name": "Little CMS 2", + "version": versions["lcms2"], "scope": "optional", - "description": "Colour management (optional, used by PIL._imagingcms). " - "Tested with lcms2 2.7-2.18.", + "description": "Colour management (optional, used by PIL._imagingcms).", "licenses": [{"license": {"id": "MIT"}}], "externalReferences": [ {"type": "website", "url": "https://www.littlecms.com"}, @@ -408,9 +420,9 @@ def generate(version: str) -> dict: "bom-ref": "pkg:generic/openjpeg", "type": "library", "name": "OpenJPEG", + "version": versions["openjpeg"], "scope": "optional", - "description": "JPEG 2000 codec (optional). " - "Tested with openjpeg 2.0.0-2.5.4.", + "description": "JPEG 2000 codec (optional).", "licenses": [{"license": {"id": "BSD-2-Clause"}}], "externalReferences": [ {"type": "website", "url": "https://www.openjpeg.org"}, @@ -439,6 +451,7 @@ def generate(version: str) -> dict: "bom-ref": "pkg:generic/zlib", "type": "library", "name": "zlib", + "version": versions["zlib-ng"], "description": "Deflate/PNG compression (required by default; " "disable with -C zlib=disable).", "licenses": [{"license": {"id": "Zlib"}}], From 3dda1d190f2136335eb10ff99987440d8743bf2e Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade <1324225+hugovk@users.noreply.github.com> Date: Sun, 26 Apr 2026 12:19:55 +0300 Subject: [PATCH 155/190] Git ignore generated SBOM --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index 3033c2ea7..4e9803957 100644 --- a/.gitignore +++ b/.gitignore @@ -97,3 +97,6 @@ pillow-test-images.zip # pyinstaller *.spec + +# Generated SBOM +pillow-*.cdx.json From 0ef81c33af3d4416feb96a0b1cd8c2d3b3d06ab7 Mon Sep 17 00:00:00 2001 From: Andrew Murray <3112309+radarhere@users.noreply.github.com> Date: Wed, 29 Apr 2026 10:30:17 +1000 Subject: [PATCH 156/190] Add Fedora 44 (#9594) --- .github/workflows/test-docker.yml | 1 + docs/installation/platform-support.rst | 2 ++ 2 files changed, 3 insertions(+) diff --git a/.github/workflows/test-docker.yml b/.github/workflows/test-docker.yml index b035ac1de..e868b53a8 100644 --- a/.github/workflows/test-docker.yml +++ b/.github/workflows/test-docker.yml @@ -45,6 +45,7 @@ jobs: debian-13-trixie-x86, debian-13-trixie-amd64, fedora-43-amd64, + fedora-44-amd64, gentoo, ubuntu-22.04-jammy-amd64, ubuntu-24.04-noble-amd64, diff --git a/docs/installation/platform-support.rst b/docs/installation/platform-support.rst index e90d989a2..90321d054 100644 --- a/docs/installation/platform-support.rst +++ b/docs/installation/platform-support.rst @@ -31,6 +31,8 @@ These platforms are built and tested for every change. +----------------------------------+----------------------------+---------------------+ | Fedora 43 | 3.14 | x86-64 | +----------------------------------+----------------------------+---------------------+ +| Fedora 44 | 3.14 | x86-64 | ++----------------------------------+----------------------------+---------------------+ | Gentoo | 3.13 | x86-64 | +----------------------------------+----------------------------+---------------------+ | macOS 15 Sequoia | 3.11, 3.12, 3.13, 3.14, | arm64 | From 1f3b8a831d9cd7e140081289259fd0cb5d90934f Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Thu, 30 Apr 2026 00:13:37 +1000 Subject: [PATCH 157/190] If PdfParser buffer is memoryview, release it when closing --- src/PIL/PdfParser.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/PIL/PdfParser.py b/src/PIL/PdfParser.py index 2a5ade773..b0b32b1c9 100644 --- a/src/PIL/PdfParser.py +++ b/src/PIL/PdfParser.py @@ -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 From 4af29fb7324cd05cd8b6f6bbf1ff2dddf0a6c573 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Thu, 30 Apr 2026 18:41:41 +1000 Subject: [PATCH 158/190] Restrict SBOM upload to Pillow JSON --- .github/workflows/wheels.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index 98733b6c7..d5af65c98 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -294,12 +294,12 @@ jobs: uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: sbom - path: "*.cdx.json" + path: "pillow-*.cdx.json" - name: Validate SBOM run: | python3 -m pip install -r .ci/requirements-sbom.txt - check-jsonschema --schemafile "https://raw.githubusercontent.com/CycloneDX/specification/1.7/schema/bom-1.7.schema.json" *.cdx.json + check-jsonschema --schemafile "https://raw.githubusercontent.com/CycloneDX/specification/1.7/schema/bom-1.7.schema.json" pillow-*.cdx.json sbom-publish: if: | @@ -320,7 +320,7 @@ jobs: - name: Attach SBOM to GitHub release env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: gh release upload "$GITHUB_REF_NAME" *.cdx.json + run: gh release upload "$GITHUB_REF_NAME" pillow-*.cdx.json pypi-publish: if: github.event.repository.fork == false && github.event_name == 'push' && startsWith(github.ref, 'refs/tags') From fc47d0760381fd904e4db45295912ef01d7137ae Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade <1324225+hugovk@users.noreply.github.com> Date: Thu, 30 Apr 2026 16:17:39 +0300 Subject: [PATCH 159/190] No need to sort a sorted list --- .github/generate-sbom.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/generate-sbom.py b/.github/generate-sbom.py index c041300f2..3b15a7d91 100755 --- a/.github/generate-sbom.py +++ b/.github/generate-sbom.py @@ -465,7 +465,7 @@ def generate(version: str) -> dict: dependencies = [ { "ref": purl, - "dependsOn": sorted(e["bom-ref"] for e in ext_components), + "dependsOn": [e["bom-ref"] for e in ext_components], }, { "ref": f"{purl}#c-ext/PIL._avif", From 7e4ca8b3abf1476b0c956bbc3642d0b2d5717e7c Mon Sep 17 00:00:00 2001 From: Hayato Ikoma Date: Fri, 1 May 2026 21:36:20 -0700 Subject: [PATCH 160/190] Correct integer overflow in 16-bit resampling (#9480) Co-authored-by: Andrew Murray --- Tests/test_image_resample.py | 34 ++++++++++++++++++++++++++++++++++ src/libImaging/Convert.c | 2 -- src/libImaging/ImagingUtils.h | 2 ++ src/libImaging/Resample.c | 12 ++++++------ 4 files changed, 42 insertions(+), 8 deletions(-) diff --git a/Tests/test_image_resample.py b/Tests/test_image_resample.py index 73b25ed51..f188be81e 100644 --- a/Tests/test_image_resample.py +++ b/Tests/test_image_resample.py @@ -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}" diff --git a/src/libImaging/Convert.c b/src/libImaging/Convert.c index 002497c32..f156810ff 100644 --- a/src/libImaging/Convert.c +++ b/src/libImaging/Convert.c @@ -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) diff --git a/src/libImaging/ImagingUtils.h b/src/libImaging/ImagingUtils.h index 714458ad0..a362780d0 100644 --- a/src/libImaging/ImagingUtils.h +++ b/src/libImaging/ImagingUtils.h @@ -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 */ diff --git a/src/libImaging/Resample.c b/src/libImaging/Resample.c index fea00eea0..1647dca14 100644 --- a/src/libImaging/Resample.c +++ b/src/libImaging/Resample.c @@ -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); From 2d02654c54c1584980fd239b58fefa7f1f8f4626 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Sun, 3 May 2026 14:11:33 +1000 Subject: [PATCH 161/190] Update dependency cibuildwheel to v3.4.1 (#9607) --- .ci/requirements-cibw.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.ci/requirements-cibw.txt b/.ci/requirements-cibw.txt index fd4183aff..c824c10bc 100644 --- a/.ci/requirements-cibw.txt +++ b/.ci/requirements-cibw.txt @@ -1 +1 @@ -cibuildwheel==3.4.0 +cibuildwheel==3.4.1 From d92b826c4a4fff179b1f8c1ec421fefd78cdb26f Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Sun, 3 May 2026 06:03:07 +0000 Subject: [PATCH 162/190] Update github-actions --- .github/workflows/cifuzz.yml | 4 ++-- .github/workflows/lint.yml | 2 +- .github/workflows/wheels.yml | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/cifuzz.yml b/.github/workflows/cifuzz.yml index 27b55cffc..a2e1112dc 100644 --- a/.github/workflows/cifuzz.yml +++ b/.github/workflows/cifuzz.yml @@ -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 diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 1aff5a0dd..dacf40cc1 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -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 diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index d5af65c98..e0edb3ac0 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -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 }} From 3bbb7a2a04c748b70ff1061572c67099a787ecc9 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Sun, 3 May 2026 10:25:22 +0000 Subject: [PATCH 163/190] Update dependency libpng to v1.6.58 --- .github/dependencies.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/dependencies.json b/.github/dependencies.json index 0f61b7817..a5e9b01f7 100644 --- a/.github/dependencies.json +++ b/.github/dependencies.json @@ -8,7 +8,7 @@ "lcms2": "2.18", "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", From 956d434c68ee83f971057d4ce1321d12c12390be Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Sun, 3 May 2026 10:25:27 +0000 Subject: [PATCH 164/190] Update dependency lcms2 to v2.19 --- .github/dependencies.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/dependencies.json b/.github/dependencies.json index 0f61b7817..cf4990c67 100644 --- a/.github/dependencies.json +++ b/.github/dependencies.json @@ -5,7 +5,7 @@ "fribidi": "1.0.16", "harfbuzz": "13.2.1", "jpegturbo": "3.1.4.1", - "lcms2": "2.18", + "lcms2": "2.19", "libavif": "1.4.1", "libimagequant": "4.4.1", "libpng": "1.6.56", From 32b6c5f0eee19ccb5e255e1edfdf7fd8833edfa7 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Sun, 3 May 2026 10:25:32 +0000 Subject: [PATCH 165/190] Update dependency harfbuzz to v14 --- .github/dependencies.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/dependencies.json b/.github/dependencies.json index 0f61b7817..c21c60e9e 100644 --- a/.github/dependencies.json +++ b/.github/dependencies.json @@ -3,7 +3,7 @@ "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", "libavif": "1.4.1", From 575b33d811b8f50577fb8465d0f59e7bca4e5d95 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 30 Apr 2026 22:29:50 +0000 Subject: [PATCH 166/190] Update dependency mypy to v1.20.2 --- .ci/requirements-mypy.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.ci/requirements-mypy.txt b/.ci/requirements-mypy.txt index c64343a73..ad78bcb67 100644 --- a/.ci/requirements-mypy.txt +++ b/.ci/requirements-mypy.txt @@ -1,4 +1,4 @@ -mypy==1.19.1 +mypy==1.20.2 arro3-compute arro3-core IceSpringPySideStubs-PyQt6 From c234720acad29ff0ccd10b1564c0cdaae1d0fade Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Fri, 1 May 2026 11:40:49 +1000 Subject: [PATCH 167/190] Convert Exif to dictionary before checking --- Tests/test_image.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Tests/test_image.py b/Tests/test_image.py index 32c799195..81bd47299 100644 --- a/Tests/test_image.py +++ b/Tests/test_image.py @@ -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 From 21790fc0da70f8dc9594248a1e30654c3cc05e65 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Fri, 1 May 2026 11:42:27 +1000 Subject: [PATCH 168/190] Check if sys.stdout is a TextIOWrapper instance --- src/PIL/Image.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/src/PIL/Image.py b/src/PIL/Image.py index 574980771..81add2f7a 100644 --- a/src/PIL/Image.py +++ b/src/PIL/Image.py @@ -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) From 4bba24632f13d2427ad7f52bf3402e35fe220b5f Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Sun, 3 May 2026 22:13:11 +1000 Subject: [PATCH 169/190] Update docs --- docs/installation/building-from-source.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/installation/building-from-source.rst b/docs/installation/building-from-source.rst index 79d54145a..ecb892e1f 100644 --- a/docs/installation/building-from-source.rst +++ b/docs/installation/building-from-source.rst @@ -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. From 2128d6465c7fb054149b58be29fa44d892bce2de Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Mon, 27 Apr 2026 19:51:05 +1000 Subject: [PATCH 170/190] Do not draw line or arc if width is zero --- src/PIL/ImageDraw.py | 6 +++--- src/_imaging.c | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/PIL/ImageDraw.py b/src/PIL/ImageDraw.py index 9b0864d1a..66511697a 100644 --- a/src/PIL/ImageDraw.py +++ b/src/PIL/ImageDraw.py @@ -175,7 +175,7 @@ class ImageDraw: ) -> None: """Draw an arc.""" ink, fill = self._getink(fill) - if ink is not None: + if ink is not None and width != 0: self.draw.draw_arc(xy, start, end, ink, width) def bitmap( @@ -235,12 +235,12 @@ class ImageDraw: self, xy: Coords, fill: _Ink | None = None, - width: int = 0, + width: int = 1, joint: str | None = None, ) -> None: """Draw a line, or a connected sequence of line segments.""" ink = self._getink(fill)[0] - if ink is not None: + if ink is not None and width != 0: self.draw.draw_lines(xy, ink, width) if joint == "curve" and width > 4: points: Sequence[Sequence[float]] diff --git a/src/_imaging.c b/src/_imaging.c index 980f827ae..808112d57 100644 --- a/src/_imaging.c +++ b/src/_imaging.c @@ -3182,7 +3182,7 @@ _draw_lines(ImagingDrawObject *self, PyObject *args) { return NULL; } - if (width <= 1) { + if (width == 1) { double *p = NULL; for (i = 0; i < n - 1; i++) { p = &xy[i + i]; From ab25042353a72e7d75260bbacad71a3f942a7a4a Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Mon, 4 May 2026 19:42:55 +1000 Subject: [PATCH 171/190] Set prCreation to not-pending --- .github/renovate.json | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/renovate.json b/.github/renovate.json index f5af3d05a..387630dd6 100644 --- a/.github/renovate.json +++ b/.github/renovate.json @@ -7,6 +7,7 @@ "Dependency" ], "minimumReleaseAge": "7 days", + "prCreation": "not-pending", "schedule": [ "* * 3 * *" ], From 689a7f37fd346608b30beb4d0e12e1b39fa19560 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 4 May 2026 21:45:55 +1000 Subject: [PATCH 172/190] Update google/oss-fuzz digest to d872252 (#9614) --- .github/workflows/cifuzz.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/cifuzz.yml b/.github/workflows/cifuzz.yml index a2e1112dc..99d04205f 100644 --- a/.github/workflows/cifuzz.yml +++ b/.github/workflows/cifuzz.yml @@ -30,14 +30,14 @@ jobs: steps: - name: Build Fuzzers id: build - uses: google/oss-fuzz/infra/cifuzz/actions/build_fuzzers@c11174f47deee98f260dede5d661614bda78ae39 # master + uses: google/oss-fuzz/infra/cifuzz/actions/build_fuzzers@d87225267726cf7ce1a3e17cf103c5ac943c4f05 # 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@c11174f47deee98f260dede5d661614bda78ae39 # master + uses: google/oss-fuzz/infra/cifuzz/actions/run_fuzzers@d87225267726cf7ce1a3e17cf103c5ac943c4f05 # master with: oss-fuzz-project-name: 'pillow' fuzz-seconds: 600 From 903065f5e9f496d4db6854315173a1dc171843d2 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 4 May 2026 17:17:50 +0000 Subject: [PATCH 173/190] [pre-commit.ci] pre-commit autoupdate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - [github.com/astral-sh/ruff-pre-commit: v0.15.9 → v0.15.12](https://github.com/astral-sh/ruff-pre-commit/compare/v0.15.9...v0.15.12) - [github.com/pre-commit/mirrors-clang-format: v22.1.2 → v22.1.4](https://github.com/pre-commit/mirrors-clang-format/compare/v22.1.2...v22.1.4) - [github.com/python-jsonschema/check-jsonschema: 0.37.1 → 0.37.2](https://github.com/python-jsonschema/check-jsonschema/compare/0.37.1...0.37.2) - [github.com/zizmorcore/zizmor-pre-commit: v1.23.1 → v1.24.1](https://github.com/zizmorcore/zizmor-pre-commit/compare/v1.23.1...v1.24.1) - [github.com/tox-dev/pyproject-fmt: v2.21.0 → v2.21.1](https://github.com/tox-dev/pyproject-fmt/compare/v2.21.0...v2.21.1) --- .pre-commit-config.yaml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index e61e50087..5ee040297 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.15.9 + rev: v0.15.12 hooks: - id: ruff-check args: [--exit-non-zero-on-fix] @@ -24,7 +24,7 @@ repos: exclude: (Makefile$|\.bat$|\.cmake$|\.eps$|\.fits$|\.gd$|\.opt$) - repo: https://github.com/pre-commit/mirrors-clang-format - rev: v22.1.2 + rev: v22.1.4 hooks: - id: clang-format types: [c] @@ -54,14 +54,14 @@ repos: exclude: ^\.github/.*TEMPLATE|^Tests/(fonts|images)/ - repo: https://github.com/python-jsonschema/check-jsonschema - rev: 0.37.1 + rev: 0.37.2 hooks: - id: check-github-workflows - id: check-readthedocs - id: check-renovate - repo: https://github.com/zizmorcore/zizmor-pre-commit - rev: v1.23.1 + rev: v1.24.1 hooks: - id: zizmor @@ -71,7 +71,7 @@ repos: - id: sphinx-lint - repo: https://github.com/tox-dev/pyproject-fmt - rev: v2.21.0 + rev: v2.21.1 hooks: - id: pyproject-fmt From f693a3a0e5355376438988eb2f35dcd82a936571 Mon Sep 17 00:00:00 2001 From: Andrew Murray <3112309+radarhere@users.noreply.github.com> Date: Wed, 6 May 2026 23:51:16 +1000 Subject: [PATCH 174/190] Use plugin method directly when saving PDFs (#9547) --- src/PIL/PdfImagePlugin.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/PIL/PdfImagePlugin.py b/src/PIL/PdfImagePlugin.py index 5594c7e0f..cb26786b0 100644 --- a/src/PIL/PdfImagePlugin.py +++ b/src/PIL/PdfImagePlugin.py @@ -148,10 +148,14 @@ def _write_image( strip_size=math.ceil(width / 8) * height, ) elif decode_filter == "DCTDecode": - Image.SAVE["JPEG"](im, op, filename) + from . import JpegImagePlugin + + JpegImagePlugin._save(im, op, filename) elif decode_filter == "JPXDecode": + from . import Jpeg2KImagePlugin + del dict_obj["BitsPerComponent"] - Image.SAVE["JPEG2000"](im, op, filename) + Jpeg2KImagePlugin._save(im, op, filename) else: msg = f"unsupported PDF filter ({decode_filter})" raise ValueError(msg) From 894c5d533579d12b63264920f8fc968901204665 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Thu, 7 May 2026 19:48:08 +1000 Subject: [PATCH 175/190] Width is always provided --- src/_imaging.c | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/_imaging.c b/src/_imaging.c index 808112d57..7fee41114 100644 --- a/src/_imaging.c +++ b/src/_imaging.c @@ -3172,8 +3172,8 @@ _draw_lines(ImagingDrawObject *self, PyObject *args) { PyObject *data; int ink; - int width = 0; - if (!PyArg_ParseTuple(args, "Oi|i", &data, &ink, &width)) { + int width; + if (!PyArg_ParseTuple(args, "Oii", &data, &ink, &width)) { return NULL; } From 70713d69b05ebdcf8aa1cd1464d86e143c195020 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Thu, 7 May 2026 23:53:24 +1000 Subject: [PATCH 176/190] Do not generate SBOM in scheduled run on fork --- .github/workflows/wheels.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index e0edb3ac0..fa3271de0 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -276,6 +276,7 @@ jobs: anaconda_nightly_upload_token: ${{ secrets.ANACONDA_ORG_UPLOAD_TOKEN }} sbom: + if: github.event_name != 'schedule' || github.event.repository.fork == false runs-on: ubuntu-latest name: Generate SBOM steps: From 24696af8898f86007252c5774da9f14f2be93879 Mon Sep 17 00:00:00 2001 From: Andrew Murray <3112309+radarhere@users.noreply.github.com> Date: Fri, 8 May 2026 19:50:29 +1000 Subject: [PATCH 177/190] Increase AVIF test epsilon for riscv64 (#9606) --- Tests/test_file_avif.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Tests/test_file_avif.py b/Tests/test_file_avif.py index a25f77177..3ad38fd7e 100644 --- a/Tests/test_file_avif.py +++ b/Tests/test_file_avif.py @@ -145,14 +145,14 @@ class TestFileAvif: # avifdec hopper.avif avif/hopper_avif_write.png assert_image_similar_tofile( - reloaded, "Tests/images/avif/hopper_avif_write.png", 6.88 + reloaded, "Tests/images/avif/hopper_avif_write.png", 6.93 ) # This test asserts that the images are similar. If the average pixel # difference between the two images is less than the epsilon value, # then we're going to accept that it's a reasonable lossy version of # the image. - assert_image_similar(reloaded, im, 9.28) + assert_image_similar(reloaded, im, 9.39) def test_AvifEncoder_with_invalid_args(self) -> None: """ From ea5901535d94897adb4821fcf7ac4152e66d8a3c Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade <1324225+hugovk@users.noreply.github.com> Date: Tue, 12 May 2026 00:31:03 +0300 Subject: [PATCH 178/190] Compare dist sizes vs latest PyPI release (#9621) Co-authored-by: Andrew Murray --- .github/compare-dist-sizes.py | 271 ++++++++++++++++++++++++++++++++++ .github/workflows/wheels.yml | 23 +++ 2 files changed, 294 insertions(+) create mode 100644 .github/compare-dist-sizes.py diff --git a/.github/compare-dist-sizes.py b/.github/compare-dist-sizes.py new file mode 100644 index 000000000..ed7b9be0e --- /dev/null +++ b/.github/compare-dist-sizes.py @@ -0,0 +1,271 @@ +"""Compare sizes of newly-built dists against the latest release on PyPI. + +Fetches file sizes for the latest Pillow release from the PyPI JSON API +(no download required) and compares them to a directory of freshly-built +wheels and sdist. Outputs a table to stdout (and to +`$GITHUB_STEP_SUMMARY` if set). + +Usage: + `uv run .github/compare-dist-sizes.py ` +""" + +# /// script +# requires-python = ">=3.10" +# dependencies = [ +# "humanize", +# "prettytable", +# "termcolor", +# ] +# /// + +from __future__ import annotations + +import argparse +import json +import os +import re +import sys +import urllib.request +from pathlib import Path + +import humanize +from prettytable import PrettyTable, TableStyle +from termcolor import colored + +PYPI_JSON_URL = "https://pypi.org/pypi/pillow/json" + +# Wheel filename: {distribution}-{version}(-{build})?-{python}-{abi}-{platform}.whl +# sdist filename: {distribution}-{version}.tar.gz +WHEEL_RE = re.compile( + r"^[^-]+-[^-]+(?:-(?P\d[^-]*))?" + r"-(?P[^-]+)-(?P[^-]+)-(?P[^-]+)\.whl$", + re.IGNORECASE, +) +SDIST_RE = re.compile( + r"^(?P[^-]+)-(?P.+)\.tar\.gz$", + re.IGNORECASE, +) + + +def key_for(filename: str) -> str: + """Return a version-independent identifier for a dist file.""" + if m := WHEEL_RE.match(filename): + build = f"{m['build']}-" if m["build"] else "" + return f"wheel:{build}{m['python']}-{m['abi']}-{m['platform']}" + if SDIST_RE.match(filename): + return "sdist" + msg = f"Unexpected dist name: {filename}" + raise ValueError(msg) + + +def display_for(filename: str) -> str: + """Strip the `pillow-{version}-` prefix for compact table display.""" + if m := WHEEL_RE.match(filename): + build = f"{m['build']}-" if m["build"] else "" + return f"{build}{m['python']}-{m['abi']}-{m['platform']}.whl" + if SDIST_RE.match(filename): + return "sdist (.tar.gz)" + return filename + + +def fetch_pypi_sizes() -> tuple[str, dict[str, tuple[str, int]]]: + """Return (version, {key: (filename, size)}) for the latest PyPI release.""" + with urllib.request.urlopen(PYPI_JSON_URL) as response: + data = json.load(response) + version = data["info"]["version"] + sizes: dict[str, tuple[str, int]] = {} + for entry in data.get("urls", []): + filename = entry["filename"] + key = key_for(filename) + sizes[key] = (filename, entry["size"]) + return version, sizes + + +def collect_local_sizes(dist_dir: Path) -> dict[str, tuple[str, int]]: + sizes: dict[str, tuple[str, int]] = {} + for path in sorted(dist_dir.iterdir()): + if not path.is_file(): + continue + key = key_for(path.name) + sizes[key] = (path.name, path.stat().st_size) + return sizes + + +def human(n: int | None) -> str: + if n is None: + return "n/a" + return humanize.naturalsize(n) + + +def pct_change(before: int | None, after: int | None) -> str: + if before is None or after is None: + return "n/a" + delta = 0 if before == 0 else (after - before) / before * 100 + return f"{delta:+.2f}%" + + +def pct_severity(text: str) -> dict[str, str] | None: + """Return status indicators based on the change percent.""" + if text == "n/a": + return None + pct = float(text.rstrip("%")) + if pct >= 5: + return {"color": "red", "emoji": "🔴"} + if pct > 0: + return {"color": "yellow", "emoji": "🟡"} + else: + return {"color": "green", "emoji": "🟢"} + + +def render_table( + baseline_label: str, + baseline_sizes: dict[str, tuple[str, int]], + local_sizes: dict[str, tuple[str, int]], + *, + markdown: bool, +) -> str: + table = PrettyTable() + table.set_style(TableStyle.MARKDOWN if markdown else TableStyle.SINGLE_BORDER) + table.field_names = ["File", "Size before", "Size now", "Change"] + table.align = "r" + table.align["File"] = "l" + + def style(cells: list[str], role: str) -> list[str]: + severity = pct_severity(cells[3]) + if markdown: + if severity: + cells[3] = f"{severity['emoji']} {cells[3]}" + if role == "orphan": + return [f"*{c}*" for c in cells] + if role == "summary": + return [f"**{c}**" for c in cells] + return cells + + if role == "orphan": + return [colored(c, "dark_grey") for c in cells] + + bold_attrs = ["bold"] if role == "summary" else [] + if bold_attrs: + cells[:3] = [colored(c, attrs=bold_attrs) for c in cells[:3]] + if severity: + cells[3] = colored(cells[3], severity["color"], attrs=bold_attrs) + elif bold_attrs: + cells[3] = colored(cells[3], attrs=bold_attrs) + return cells + + keys = list(set(baseline_sizes) | set(local_sizes)) + # Put sdist first for readability + keys.sort(key=lambda k: (k != "sdist", k)) + + wheel_before = [] + wheel_after = [] + total_before = [] + total_after = [] + for key in keys: + baseline_entry = baseline_sizes.get(key) + local_entry = local_sizes.get(key) + display_name = display_for((local_entry or baseline_entry)[0]) + before = baseline_entry[1] if baseline_entry else None + after = local_entry[1] if local_entry else None + if after is None: + # Removed since baseline: ignore in totals + role = "orphan" + else: + # Present locally (in both, or newly added): count in totals + total_after.append(after) + if before is not None: + total_before.append(before) + if key != "sdist": + wheel_after.append(after) + if before is not None: + wheel_before.append(before) + role = "data" + cells = [ + display_name, + human(before), + human(after), + pct_change(before, after), + ] + table.add_row(style(cells, role)) + + if not markdown: + table.add_divider() + + if wheel_after: + avg_before = sum(wheel_before) // len(wheel_before) if wheel_before else None + table.add_row( + style( + [ + f"wheel average ({len(wheel_after)} wheels)", + human(avg_before), + human(sum(wheel_after) // len(wheel_after)), + pct_change(avg_before, sum(wheel_after) // len(wheel_after)), + ], + "summary", + ) + ) + table.add_row( + style( + [ + f"wheel total ({len(wheel_after)} wheels)", + human(sum(wheel_before)), + human(sum(wheel_after)), + pct_change(sum(wheel_before), sum(wheel_after)), + ], + "summary", + ), + divider=not markdown, + ) + + if total_after: + table.add_row( + style( + [ + f"artifacts total ({len(total_after)} artifacts)", + human(sum(total_before)), + human(sum(total_after)), + pct_change(sum(total_before), sum(total_after)), + ], + "summary", + ) + ) + + title = f"## Dist size comparison vs {baseline_label}" + if not markdown: + title = colored(title, attrs=["bold"]) + return f"{title}\n\n{table.get_string()}\n" + + +def main() -> int: + parser = argparse.ArgumentParser( + description=__doc__, formatter_class=argparse.ArgumentDefaultsHelpFormatter + ) + parser.add_argument( + "dist_dir", + type=Path, + help="Directory containing newly-built wheels and sdist", + ) + args = parser.parse_args() + + if not args.dist_dir.is_dir(): + print(f"error: {args.dist_dir} is not a directory", file=sys.stderr) + return 1 + + baseline_version, baseline_sizes = fetch_pypi_sizes() + baseline_label = f"Pillow {baseline_version} on PyPI" + + local_sizes = collect_local_sizes(args.dist_dir) + + print(render_table(baseline_label, baseline_sizes, local_sizes, markdown=False)) + + if summary_path := os.environ.get("GITHUB_STEP_SUMMARY"): + with open(summary_path, "a", encoding="utf-8") as f: + f.write( + render_table(baseline_label, baseline_sizes, local_sizes, markdown=True) + ) + + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index fa3271de0..e2008ac6c 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -13,6 +13,7 @@ on: paths: &paths - ".ci/requirements-cibw.txt" - ".ci/requirements-sbom.txt" + - ".github/compare-dist-sizes.py" - ".github/dependencies.json" - ".github/generate-sbom.py" - ".github/workflows/wheels*" @@ -255,6 +256,28 @@ jobs: echo $files [ "$files" -eq $EXPECTED_DISTS ] || exit 1 + compare-dist-sizes: + needs: [build-native-wheels, windows, sdist] + runs-on: ubuntu-latest + name: Compare dist sizes vs PyPI + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 + with: + enable-cache: false + + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + pattern: dist-* + path: dist + merge-multiple: true + + - name: Compare dist sizes vs latest PyPI release + run: uv run .github/compare-dist-sizes.py dist + scientific-python-nightly-wheels-publish: if: github.event.repository.fork == false && (github.event_name == 'schedule' || github.event_name == 'workflow_dispatch') needs: count-dists From 3ce681240fa6ad2f19a73b2c5531baada38dee96 Mon Sep 17 00:00:00 2001 From: Andrew Murray <3112309+radarhere@users.noreply.github.com> Date: Tue, 12 May 2026 12:11:38 +1000 Subject: [PATCH 179/190] Use _accept check in WebP _open (#9605) --- Tests/test_file_webp.py | 6 ++++++ src/PIL/WebPImagePlugin.py | 9 +++++++-- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/Tests/test_file_webp.py b/Tests/test_file_webp.py index f996cce67..e419c29f0 100644 --- a/Tests/test_file_webp.py +++ b/Tests/test_file_webp.py @@ -49,6 +49,12 @@ class TestFileWebp: assert version is not None assert re.search(r"\d+\.\d+\.\d+$", version) + def test_invalid_file(self) -> None: + invalid_file = "Tests/images/flower.jpg" + + with pytest.raises(SyntaxError): + WebPImagePlugin.WebPImageFile(invalid_file) + def test_read_rgb(self) -> None: """ Can we read a RGB mode WebP file without error? diff --git a/src/PIL/WebPImagePlugin.py b/src/PIL/WebPImagePlugin.py index e20e40d91..63a481691 100644 --- a/src/PIL/WebPImagePlugin.py +++ b/src/PIL/WebPImagePlugin.py @@ -43,10 +43,15 @@ class WebPImageFile(ImageFile.ImageFile): __logical_frame = 0 def _open(self) -> None: + assert self.fp is not None + s = self.fp.read() + if not _accept(s): + msg = "not a WEBP file" + raise SyntaxError(msg) + # Use the newer AnimDecoder API to parse the (possibly) animated file, # and access muxed chunks like ICC/EXIF/XMP. - assert self.fp is not None - self._decoder = _webp.WebPAnimDecoder(self.fp.read()) + self._decoder = _webp.WebPAnimDecoder(s) # Get info from decoder self._size, self.info["loop"], bgcolor, self.n_frames, self.rawmode = ( From 22e47e38bba354f85a55c875688bc447fd618bcc Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade <1324225+hugovk@users.noreply.github.com> Date: Sun, 10 May 2026 14:36:29 +0300 Subject: [PATCH 180/190] Simplify setting PYTHON_GIL --- .github/workflows/test.yml | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index d90cc805a..154455e1a 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -51,10 +51,6 @@ jobs: include: - { python-version: "3.12", PYTHONOPTIMIZE: 1, REVERSE: "--reverse" } - { python-version: "3.11", PYTHONOPTIMIZE: 2 } - # Free-threaded - - { python-version: "3.15t", disable-gil: true } - - { python-version: "3.14t", disable-gil: true } - - { python-version: "3.13t", disable-gil: true } # Intel - { os: "macos-26-intel", python-version: "3.10" } exclude: @@ -79,7 +75,7 @@ jobs: "pyproject.toml" - name: Set PYTHON_GIL - if: "${{ matrix.disable-gil }}" + if: endsWith(matrix.python-version, 't') run: | echo "PYTHON_GIL=0" >> $GITHUB_ENV From 0582f43bad6e4b3e2f51dddd6ef187108aaa8f07 Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade <1324225+hugovk@users.noreply.github.com> Date: Sun, 10 May 2026 14:36:44 +0300 Subject: [PATCH 181/190] No longer test experimental 3.13t --- .github/workflows/test.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 154455e1a..dc9c33743 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -42,7 +42,6 @@ jobs: "3.15", "3.14t", "3.14", - "3.13t", "3.13", "3.12", "3.11", From 4dc442fb012f821678b9b64dfa6ecc56e9bc57bd Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade <1324225+hugovk@users.noreply.github.com> Date: Tue, 12 May 2026 20:48:26 +0300 Subject: [PATCH 182/190] Don't force PYTHON_GIL=0, instead fail if anything re-enables --- .ci/install.sh | 4 ++-- .github/workflows/test.yml | 5 ----- Tests/conftest.py | 29 +++++++++++++++++++++++++++++ 3 files changed, 31 insertions(+), 7 deletions(-) diff --git a/.ci/install.sh b/.ci/install.sh index 9553eb8f4..cc104c45f 100755 --- a/.ci/install.sh +++ b/.ci/install.sh @@ -39,8 +39,8 @@ python3 -m pip install --only-binary=:all: pyarrow || true # PyQt6 doesn't support PyPy3 if [[ $GHA_PYTHON_VERSION == 3.* ]]; then sudo apt-get -qq install libegl1 libxcb-cursor0 libxcb-icccm4 libxcb-image0 libxcb-keysyms1 libxcb-randr0 libxcb-render-util0 libxcb-shape0 libxkbcommon-x11-0 - # TODO Update condition when pyqt6 supports free-threading - if ! [[ "$PYTHON_GIL" == "0" ]]; then python3 -m pip install pyqt6 ; fi + # pyqt6 doesn't yet support free-threading; only install if a wheel is available + python3 -m pip install --only-binary=:all: pyqt6 || true fi # webp diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index dc9c33743..362412e94 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -73,11 +73,6 @@ jobs: ".ci/*.sh" "pyproject.toml" - - name: Set PYTHON_GIL - if: endsWith(matrix.python-version, 't') - run: | - echo "PYTHON_GIL=0" >> $GITHUB_ENV - - name: Build system information run: python3 .github/workflows/system-info.py diff --git a/Tests/conftest.py b/Tests/conftest.py index e00d1f019..8d12a0e14 100644 --- a/Tests/conftest.py +++ b/Tests/conftest.py @@ -1,9 +1,17 @@ from __future__ import annotations import io +import sys +import sysconfig import pytest +FREE_THREADED_BUILD = bool(sysconfig.get_config_var("Py_GIL_DISABLED")) + +gil_enabled_at_start = True +if FREE_THREADED_BUILD: + gil_enabled_at_start = sys._is_gil_enabled() # type: ignore[attr-defined] + def pytest_report_header(config: pytest.Config) -> str: try: @@ -16,6 +24,27 @@ def pytest_report_header(config: pytest.Config) -> str: return f"pytest_report_header failed: {e}" +def pytest_terminal_summary( + terminalreporter: pytest.TerminalReporter, exitstatus: int, config: pytest.Config +) -> None: + if ( + FREE_THREADED_BUILD + and not gil_enabled_at_start + and sys._is_gil_enabled() # type: ignore[attr-defined] + ): + tr = terminalreporter + tr.ensure_newline() + tr.section("GIL re-enabled", sep="=", red=True, bold=True) + tr.line("The GIL was re-enabled at runtime during the tests.") + tr.line("This can happen with no test failures if the RuntimeWarning") + tr.line("raised by Python when this happens is filtered by a test.") + tr.line("") + tr.line("Please ensure all new C modules declare support for running") + tr.line("without the GIL. Any new tests that intentionally imports") + tr.line("code that re-enables the GIL should do so in a subprocess.") + pytest.exit("GIL re-enabled during tests", returncode=1) + + def pytest_configure(config: pytest.Config) -> None: config.addinivalue_line( "markers", From 9289863c2cd2ec65f0048b37a68a3fe303d925c2 Mon Sep 17 00:00:00 2001 From: Andrew Murray <3112309+radarhere@users.noreply.github.com> Date: Wed, 13 May 2026 07:54:40 +1000 Subject: [PATCH 183/190] Add support for Python 3.15 (#9624) --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 496c8cb1f..6a6bffab6 100644 --- a/setup.py +++ b/setup.py @@ -57,7 +57,7 @@ WEBP_ROOT = None ZLIB_ROOT = None FUZZING_BUILD = "LIB_FUZZING_ENGINE" in os.environ -if sys.platform == "win32" and sys.version_info >= (3, 15): +if sys.platform == "win32" and sys.version_info >= (3, 16): import atexit atexit.register( From 381e264e18dd119dbec5094ef3390635a5881efe Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Wed, 13 May 2026 19:13:21 +1000 Subject: [PATCH 184/190] Consistently use "coordinates" instead of "co-ordinates" Co-authored-by: mokashang --- Tests/test_imagefont.py | 4 ++-- docs/releasenotes/8.2.0.rst | 2 +- src/libImaging/Jpeg2KDecode.c | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Tests/test_imagefont.py b/Tests/test_imagefont.py index d0b458d6b..409474707 100644 --- a/Tests/test_imagefont.py +++ b/Tests/test_imagefont.py @@ -365,7 +365,7 @@ def test_rotated_transposed_font( bbox_b[2] - bbox_b[0], ) - # Check top left co-ordinates are correct + # Check top left coordinates are correct assert bbox_b[:2] == (20, 20) # text length is undefined for vertical text @@ -410,7 +410,7 @@ def test_unrotated_transposed_font( bbox_b[3] - bbox_b[1], ) - # Check top left co-ordinates are correct + # Check top left coordinates are correct assert bbox_b[:2] == (20, 20) assert length_a == length_b diff --git a/docs/releasenotes/8.2.0.rst b/docs/releasenotes/8.2.0.rst index a59560695..abd3e0e04 100644 --- a/docs/releasenotes/8.2.0.rst +++ b/docs/releasenotes/8.2.0.rst @@ -81,7 +81,7 @@ Image.alpha_composite: dest ^^^^^^^^^^^^^^^^^^^^^^^^^^^ When calling :py:meth:`~PIL.Image.Image.alpha_composite`, the ``dest`` argument now -accepts negative co-ordinates, like the upper left corner of the ``box`` argument of +accepts negative coordinates, like the upper left corner of the ``box`` argument of :py:meth:`~PIL.Image.Image.paste` can be negative. Naturally, this has effect of cropping the overlaid image. diff --git a/src/libImaging/Jpeg2KDecode.c b/src/libImaging/Jpeg2KDecode.c index 1123d7bc9..ccb6a199c 100644 --- a/src/libImaging/Jpeg2KDecode.c +++ b/src/libImaging/Jpeg2KDecode.c @@ -812,7 +812,7 @@ j2k_decode_entry(Imaging im, ImagingCodecState state) { break; } - /* Adjust the tile co-ordinates based on the reduction (OpenJPEG + /* Adjust the tile coordinates based on the reduction (OpenJPEG doesn't do this for us) */ tile_info.x0 = (tile_info.x0 + correction) >> context->reduce; tile_info.y0 = (tile_info.y0 + correction) >> context->reduce; From 78ee80a6fdd4b787f71880f10e66d6b989c6e58d Mon Sep 17 00:00:00 2001 From: Daniel Garcia Moreno Date: Wed, 13 May 2026 11:14:41 +0200 Subject: [PATCH 185/190] PdfParser: Don't use list as def in read_prev_trailer It's not recommended to use the empty list as default value in functions or methods because Python interpreter evaluates during parsing, so it will be the same list for different calls. https://pylint.pycqa.org/en/latest/user_guide/messages/warning/dangerous-default-value.html --- src/PIL/PdfParser.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/PIL/PdfParser.py b/src/PIL/PdfParser.py index b0b32b1c9..40b7d2cc1 100644 --- a/src/PIL/PdfParser.py +++ b/src/PIL/PdfParser.py @@ -692,7 +692,7 @@ class PdfParser: self.read_prev_trailer(self.trailer_dict[b"Prev"]) def read_prev_trailer( - self, xref_section_offset: int, processed_offsets: list[int] = [] + self, xref_section_offset: int, processed_offsets: list[int] = None ) -> None: assert self.buf is not None trailer_offset = self.read_xref_table(xref_section_offset=xref_section_offset) @@ -708,6 +708,8 @@ class PdfParser: ) trailer_dict = self.interpret_trailer(trailer_data) if b"Prev" in trailer_dict: + if processed_offsets is None: + processed_offsets = [] processed_offsets.append(xref_section_offset) check_format_condition( trailer_dict[b"Prev"] not in processed_offsets, "trailer loop found" From f0f67f8cf86e6fc049dec23996c41653a5fdd63f Mon Sep 17 00:00:00 2001 From: danigm Date: Wed, 13 May 2026 11:37:39 +0200 Subject: [PATCH 186/190] PdfParser: Fix typing in read_prev_trailer Co-authored-by: Andrew Murray <3112309+radarhere@users.noreply.github.com> --- src/PIL/PdfParser.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/PIL/PdfParser.py b/src/PIL/PdfParser.py index 40b7d2cc1..99ff26999 100644 --- a/src/PIL/PdfParser.py +++ b/src/PIL/PdfParser.py @@ -692,7 +692,7 @@ class PdfParser: self.read_prev_trailer(self.trailer_dict[b"Prev"]) def read_prev_trailer( - self, xref_section_offset: int, processed_offsets: list[int] = None + self, xref_section_offset: int, processed_offsets: list[int] | None = None ) -> None: assert self.buf is not None trailer_offset = self.read_xref_table(xref_section_offset=xref_section_offset) From e9855d1705c5a1185256597eac9030af98e904a5 Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade <1324225+hugovk@users.noreply.github.com> Date: Wed, 13 May 2026 13:30:01 +0300 Subject: [PATCH 187/190] Remove unused params Co-authored-by: Andrew Murray <3112309+radarhere@users.noreply.github.com> --- Tests/conftest.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/Tests/conftest.py b/Tests/conftest.py index 8d12a0e14..16d4c04d4 100644 --- a/Tests/conftest.py +++ b/Tests/conftest.py @@ -24,9 +24,7 @@ def pytest_report_header(config: pytest.Config) -> str: return f"pytest_report_header failed: {e}" -def pytest_terminal_summary( - terminalreporter: pytest.TerminalReporter, exitstatus: int, config: pytest.Config -) -> None: +def pytest_terminal_summary(terminalreporter: pytest.TerminalReporter) -> None: if ( FREE_THREADED_BUILD and not gil_enabled_at_start From 3f74d08263af36fb131ddda070c9ce19e8cac8b7 Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade <1324225+hugovk@users.noreply.github.com> Date: Thu, 14 May 2026 14:05:55 +0300 Subject: [PATCH 188/190] Remove default sep="=" Co-authored-by: Andrew Murray <3112309+radarhere@users.noreply.github.com> --- Tests/conftest.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Tests/conftest.py b/Tests/conftest.py index 16d4c04d4..1f32bbedf 100644 --- a/Tests/conftest.py +++ b/Tests/conftest.py @@ -32,7 +32,7 @@ def pytest_terminal_summary(terminalreporter: pytest.TerminalReporter) -> None: ): tr = terminalreporter tr.ensure_newline() - tr.section("GIL re-enabled", sep="=", red=True, bold=True) + tr.section("GIL re-enabled", red=True, bold=True) tr.line("The GIL was re-enabled at runtime during the tests.") tr.line("This can happen with no test failures if the RuntimeWarning") tr.line("raised by Python when this happens is filtered by a test.") From 764e31592309b6d517a1e54d76b6db2e8b318e16 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Fri, 15 May 2026 15:02:42 +1000 Subject: [PATCH 189/190] Revert "Switch iOS back to macos-15-intel" This reverts commit 27de86483d8c23d9375b071993c04c2ff8388ca3. --- .github/workflows/wheels.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index e2008ac6c..0180d1c1c 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -101,7 +101,7 @@ jobs: cibw_arch: arm64_iphonesimulator - name: "iOS x86_64 simulator" platform: ios - os: macos-15-intel + os: macos-26-intel cibw_arch: x86_64_iphonesimulator steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 From dcd6d41e77e3e15415ec51ca2e36afae830afd3b Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Fri, 15 May 2026 18:41:18 +1000 Subject: [PATCH 190/190] Fixed typo --- docs/releasenotes/12.2.0.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/releasenotes/12.2.0.rst b/docs/releasenotes/12.2.0.rst index da678a47b..c03a28482 100644 --- a/docs/releasenotes/12.2.0.rst +++ b/docs/releasenotes/12.2.0.rst @@ -30,8 +30,8 @@ has already processed. PdfParser was added in Pillow 4.2.0. :cve:`2026-42308`: Integer overflow when processing fonts ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -If a font advances for each glyph by an exceeding large amount, when Pillow keeps track -of the current position, it may lead to an integer overflow. This has been fixed. +If a font advances for each glyph by an exceedingly large amount, when Pillow keeps +track of the current position, it may lead to an integer overflow. This has been fixed. :cve:`2026-42309`: Heap buffer overflow with nested list coordinates ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^