From acb75a7880873f235c90d2de175a1b3ab01ec5ed Mon Sep 17 00:00:00 2001 From: Pavith <35229068+Ram-Pavith@users.noreply.github.com> Date: Thu, 15 Oct 2026 22:13:16 +0530 Subject: [PATCH 1/3] Add JPEG XL plugin via optional imagecodecs (libjxl) --- Tests/test_file_jxl.py | 23 ++++++++++ src/PIL/Image.py | 1 + src/PIL/JxlImagePlugin.py | 93 +++++++++++++++++++++++++++++++++++++++ src/PIL/__init__.py | 1 + 4 files changed, 118 insertions(+) create mode 100644 Tests/test_file_jxl.py create mode 100644 src/PIL/JxlImagePlugin.py diff --git a/Tests/test_file_jxl.py b/Tests/test_file_jxl.py new file mode 100644 index 00000000000..daa1691b6c7 --- /dev/null +++ b/Tests/test_file_jxl.py @@ -0,0 +1,23 @@ +import io + +import pytest + +from PIL import Image + +imagecodecs = pytest.importorskip("imagecodecs", reason="JPEG XL requires imagecodecs") + + +def test_jxl_roundtrip() -> None: + im = Image.new("RGB", (24, 24), (30, 120, 200)) + im.info["lossless"] = True + buf = io.BytesIO() + im.save(buf, format="JXL") + buf.seek(0) + assert buf.read(2) == b"\xff\x0a" # JPEG XL codestream signature + + buf.seek(0) + with Image.open(buf) as reloaded: + assert reloaded.format == "JXL" + assert reloaded.size == (24, 24) + assert reloaded.mode == "RGB" + assert list(reloaded.getdata()) == list(im.getdata()) diff --git a/src/PIL/Image.py b/src/PIL/Image.py index 6dec284d086..ae2b44f2bfe 100644 --- a/src/PIL/Image.py +++ b/src/PIL/Image.py @@ -345,6 +345,7 @@ def getmodebands(mode: str) -> int: ".apng": "PngImagePlugin", # Less common formats (init) ".avif": "AvifImagePlugin", + ".jxl": "JxlImagePlugin", ".avifs": "AvifImagePlugin", ".blp": "BlpImagePlugin", ".bufr": "BufrStubImagePlugin", diff --git a/src/PIL/JxlImagePlugin.py b/src/PIL/JxlImagePlugin.py new file mode 100644 index 00000000000..beee0d21add --- /dev/null +++ b/src/PIL/JxlImagePlugin.py @@ -0,0 +1,93 @@ +# +# The Python Imaging Library. +# +# JPEG XL support for PIL, via the optional ``imagecodecs`` package +# (which bundles libjxl). Without it, opening or saving .jxl files +# raises an error, mirroring how the external pillow-jxl-plugin works. +# +# See the README file for information on usage and redistribution. +# +from __future__ import annotations + +import io +from typing import IO + +from . import Image, ImageFile + +try: + import imagecodecs + + SUPPORTED = True +except ImportError: + SUPPORTED = False + +_CODESTREAM_MAGIC = b"\xff\x0a" +_CONTAINER_MAGIC = b"\x00\x00\x00\x0cJXL \x0d\x0a\x87\x0a" + + +def _accept(prefix: bytes) -> bool: + return prefix.startswith(_CODESTREAM_MAGIC) or prefix.startswith( + _CONTAINER_MAGIC + ) + + +class JxlImageFile(ImageFile.ImageFile): + format = "JXL" + format_description = "JPEG XL" + + def _open(self) -> None: + if not SUPPORTED: + msg = "image file could not be opened because JPEG XL support not installed" + raise SyntaxError(msg) + + assert self.fp is not None + data = self.fp.read() + try: + import numpy as np + + arr = np.asarray(imagecodecs.jpegxl_decode(data)) + except Exception as e: + msg = "could not decode JPEG XL image" + raise SyntaxError(msg) from e + + if arr.ndim == 2: + self._mode = "L" + elif arr.shape[2] == 3: + self._mode = "RGB" + elif arr.shape[2] == 4: + self._mode = "RGBA" + else: + msg = f"unsupported JPEG XL channel count: {arr.shape}" + raise SyntaxError(msg) + + self._size = arr.shape[1], arr.shape[0] + # Hand the decoded pixels to Pillow's raw decoder. + self.fp = io.BytesIO(arr.tobytes()) + self.tile = [ImageFile._Tile("raw", (0, 0, *self._size), 0, (self._mode, 0, 1))] + self._fp = self.fp + + +def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: + if not SUPPORTED: + msg = "JPEG XL support not installed" + raise ValueError(msg) + + import numpy as np + + if im.mode not in ("L", "RGB", "RGBA"): + im = im.convert("RGBA" if "A" in im.getbands() else "RGB") + arr = np.asarray(im) + lossless = bool(im.info.get("lossless", False)) + encoded = imagecodecs.jpegxl_encode( + arr, + lossless=lossless, + distance=None if lossless else float(im.info.get("distance", 1.0)), + effort=int(im.info.get("effort", 7)), + ) + fp.write(bytes(encoded)) + + +Image.register_open(JxlImageFile.format, JxlImageFile, _accept) +Image.register_save(JxlImageFile.format, _save) +Image.register_extension(JxlImageFile.format, ".jxl") +Image.register_mime(JxlImageFile.format, "image/jxl") diff --git a/src/PIL/__init__.py b/src/PIL/__init__.py index faf3e76e0ae..7daf09e55f6 100644 --- a/src/PIL/__init__.py +++ b/src/PIL/__init__.py @@ -26,6 +26,7 @@ _plugins = [ "AvifImagePlugin", + "JxlImagePlugin", "BlpImagePlugin", "BmpImagePlugin", "BufrStubImagePlugin", From 92d2ace56f28c8beb5b05e4b75505e447718279f Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Thu, 15 Oct 2026 16:44:09 +0000 Subject: [PATCH 2/3] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- Tests/test_file_jxl.py | 2 ++ src/PIL/JxlImagePlugin.py | 4 +--- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Tests/test_file_jxl.py b/Tests/test_file_jxl.py index daa1691b6c7..35a9460c958 100644 --- a/Tests/test_file_jxl.py +++ b/Tests/test_file_jxl.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import io import pytest diff --git a/src/PIL/JxlImagePlugin.py b/src/PIL/JxlImagePlugin.py index beee0d21add..bb4732e0eaf 100644 --- a/src/PIL/JxlImagePlugin.py +++ b/src/PIL/JxlImagePlugin.py @@ -26,9 +26,7 @@ def _accept(prefix: bytes) -> bool: - return prefix.startswith(_CODESTREAM_MAGIC) or prefix.startswith( - _CONTAINER_MAGIC - ) + return prefix.startswith(_CODESTREAM_MAGIC) or prefix.startswith(_CONTAINER_MAGIC) class JxlImageFile(ImageFile.ImageFile): From c8ea5a3d543578091d420a5ba4a4c4c9cbbf9405 Mon Sep 17 00:00:00 2001 From: Andrew Murray <3112309+radarhere@users.noreply.github.com> Date: Fri, 16 Oct 2026 07:42:01 +1100 Subject: [PATCH 3/3] Lint fix --- src/PIL/JxlImagePlugin.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/PIL/JxlImagePlugin.py b/src/PIL/JxlImagePlugin.py index bb4732e0eaf..52d2bdef9b1 100644 --- a/src/PIL/JxlImagePlugin.py +++ b/src/PIL/JxlImagePlugin.py @@ -26,7 +26,7 @@ def _accept(prefix: bytes) -> bool: - return prefix.startswith(_CODESTREAM_MAGIC) or prefix.startswith(_CONTAINER_MAGIC) + return prefix.startswith((_CODESTREAM_MAGIC, _CONTAINER_MAGIC)) class JxlImageFile(ImageFile.ImageFile):