diff --git a/.ci/install.sh b/.ci/install.sh
index aeb5e65145d..0131c47bcf3 100755
--- a/.ci/install.sh
+++ b/.ci/install.sh
@@ -55,5 +55,8 @@ pushd depends && sudo ./install_raqm.sh && popd
# libavif
pushd depends && sudo ./install_libavif.sh && popd
+# libjxl
+pushd depends && sudo ./install_libjxl.sh && popd
+
# extra test images
pushd depends && ./install_extra_test_images.sh && popd
diff --git a/.github/workflows/test-mingw.yml b/.github/workflows/test-mingw.yml
index e247414c8fc..860e1496528 100644
--- a/.github/workflows/test-mingw.yml
+++ b/.github/workflows/test-mingw.yml
@@ -63,6 +63,7 @@ jobs:
mingw-w64-x86_64-libavif \
mingw-w64-x86_64-libimagequant \
mingw-w64-x86_64-libjpeg-turbo \
+ mingw-w64-x86_64-libjxl \
mingw-w64-x86_64-libraqm \
mingw-w64-x86_64-libtiff \
mingw-w64-x86_64-libwebp \
diff --git a/.github/workflows/test-windows.yml b/.github/workflows/test-windows.yml
index e864763da21..57002301323 100644
--- a/.github/workflows/test-windows.yml
+++ b/.github/workflows/test-windows.yml
@@ -175,6 +175,14 @@ jobs:
if: steps.build-cache.outputs.cache-hit != 'true'
run: "& winbuild\\build\\build_dep_libimagequant.cmd"
+ - name: Build dependencies / highway
+ if: steps.build-cache.outputs.cache-hit != 'true'
+ run: "& winbuild\\build\\build_dep_highway.cmd"
+
+ - name: Build dependencies / libjxl
+ if: steps.build-cache.outputs.cache-hit != 'true'
+ run: "& winbuild\\build\\build_dep_libjxl.cmd"
+
# Raqm dependencies
- name: Build dependencies / HarfBuzz
if: steps.build-cache.outputs.cache-hit != 'true'
diff --git a/.github/workflows/wheels-dependencies.sh b/.github/workflows/wheels-dependencies.sh
index e1586b7c5a3..fb1f9b2f92c 100755
--- a/.github/workflows/wheels-dependencies.sh
+++ b/.github/workflows/wheels-dependencies.sh
@@ -99,6 +99,7 @@ HARFBUZZ_VERSION=12.3.0
LIBPNG_VERSION=1.6.53
JPEGTURBO_VERSION=3.1.3
OPENJPEG_VERSION=2.5.4
+JPEGXL_VERSION=0.11.1
XZ_VERSION=5.8.2
ZSTD_VERSION=1.5.7
TIFF_VERSION=4.7.1
@@ -161,6 +162,21 @@ function build_brotli {
touch brotli-stamp
}
+function build_jpegxl {
+ if [ -e jpegxl-stamp ]; then return; fi
+
+ local out_dir=$(fetch_unpack https://github.com/google/highway/archive/1.3.0.tar.gz)
+ (cd $out_dir \
+ && cmake -DCMAKE_INSTALL_PREFIX=$BUILD_PREFIX -DCMAKE_INSTALL_LIBDIR=$BUILD_PREFIX/lib -DCMAKE_INSTALL_NAME_DIR=$BUILD_PREFIX/lib $HOST_CMAKE_FLAGS . \
+ && make -j4 install)
+
+ local out_dir=$(fetch_unpack https://github.com/libjxl/libjxl/archive/v$JPEGXL_VERSION.tar.gz)
+ (cd $out_dir \
+ && cmake -DCMAKE_INSTALL_PREFIX=$BUILD_PREFIX -DCMAKE_INSTALL_LIBDIR=$BUILD_PREFIX/lib -DCMAKE_INSTALL_NAME_DIR=$BUILD_PREFIX/lib -DJPEGXL_ENABLE_SJPEG=OFF -DJPEGXL_ENABLE_SKCMS=OFF -DBUILD_TESTING=OFF $HOST_CMAKE_FLAGS . \
+ && make -j4 install)
+ touch jpegxl-stamp
+}
+
function build_harfbuzz {
if [ -e harfbuzz-stamp ]; then return; fi
python3 -m pip install meson ninja
@@ -293,19 +309,6 @@ function build {
build_libpng
build_lcms2
build_openjpeg
-
- webp_cflags="-O3 -DNDEBUG"
- if [[ -n "$IS_MACOS" ]]; then
- webp_cflags="$webp_cflags -Wl,-headerpad_max_install_names"
- fi
- webp_ldflags=""
- if [[ -n "$IOS_SDK" ]]; then
- webp_ldflags="$webp_ldflags -llzma -lz"
- fi
- CFLAGS="$CFLAGS $webp_cflags" LDFLAGS="$LDFLAGS $webp_ldflags" build_simple libwebp $LIBWEBP_VERSION \
- https://storage.googleapis.com/downloads.webmproject.org/releases/webp tar.gz \
- --enable-libwebpmux --enable-libwebpdemux
-
build_brotli
if [[ -n "$IS_MACOS" ]]; then
@@ -323,7 +326,23 @@ function build {
# On iOS, there's no vendor-provided raqm, and we can't ship it due to
# licensing, so there's no point building harfbuzz.
build_harfbuzz
+
+ if [[ "$MB_ML_VER" != 2014 ]]; then
+ build_jpegxl
+ fi
fi
+
+ webp_cflags="-O3 -DNDEBUG"
+ if [[ -n "$IS_MACOS" ]]; then
+ webp_cflags="$webp_cflags -Wl,-headerpad_max_install_names"
+ fi
+ webp_ldflags=""
+ if [[ -n "$IOS_SDK" ]]; then
+ webp_ldflags="$webp_ldflags -llzma -lz"
+ fi
+ CFLAGS="$CFLAGS $webp_cflags" LDFLAGS="$LDFLAGS $webp_ldflags" build_simple libwebp $LIBWEBP_VERSION \
+ https://storage.googleapis.com/downloads.webmproject.org/releases/webp tar.gz \
+ --enable-libwebpmux --enable-libwebpdemux
}
function create_meson_cross_config {
diff --git a/Tests/images/jxl/16bit_subcutaneous.cropped.jxl b/Tests/images/jxl/16bit_subcutaneous.cropped.jxl
new file mode 100644
index 00000000000..eae30759603
Binary files /dev/null and b/Tests/images/jxl/16bit_subcutaneous.cropped.jxl differ
diff --git a/Tests/images/jxl/16bit_subcutaneous.cropped.png b/Tests/images/jxl/16bit_subcutaneous.cropped.png
new file mode 100644
index 00000000000..b337f7bddbe
Binary files /dev/null and b/Tests/images/jxl/16bit_subcutaneous.cropped.png differ
diff --git a/Tests/images/jxl/flower.jxl b/Tests/images/jxl/flower.jxl
new file mode 100644
index 00000000000..aeb8ae79165
Binary files /dev/null and b/Tests/images/jxl/flower.jxl differ
diff --git a/Tests/images/jxl/flower2.jxl b/Tests/images/jxl/flower2.jxl
new file mode 100644
index 00000000000..30d45a13d16
Binary files /dev/null and b/Tests/images/jxl/flower2.jxl differ
diff --git a/Tests/images/jxl/hopper.jxl b/Tests/images/jxl/hopper.jxl
new file mode 100644
index 00000000000..3be85de648c
Binary files /dev/null and b/Tests/images/jxl/hopper.jxl differ
diff --git a/Tests/images/jxl/hopper_bw_500.jxl b/Tests/images/jxl/hopper_bw_500.jxl
new file mode 100644
index 00000000000..79ad8883f0d
Binary files /dev/null and b/Tests/images/jxl/hopper_bw_500.jxl differ
diff --git a/Tests/images/jxl/hopper_gray.jxl b/Tests/images/jxl/hopper_gray.jxl
new file mode 100644
index 00000000000..2f50436dafa
Binary files /dev/null and b/Tests/images/jxl/hopper_gray.jxl differ
diff --git a/Tests/images/jxl/iss634.jxl b/Tests/images/jxl/iss634.jxl
new file mode 100644
index 00000000000..99c2cf03633
Binary files /dev/null and b/Tests/images/jxl/iss634.jxl differ
diff --git a/Tests/images/jxl/traffic_light.gif b/Tests/images/jxl/traffic_light.gif
new file mode 100644
index 00000000000..4f7ecfdbcd7
Binary files /dev/null and b/Tests/images/jxl/traffic_light.gif differ
diff --git a/Tests/images/jxl/traffic_light.jxl b/Tests/images/jxl/traffic_light.jxl
new file mode 100644
index 00000000000..c777e3bd618
Binary files /dev/null and b/Tests/images/jxl/traffic_light.jxl differ
diff --git a/Tests/images/jxl/transparent.jxl b/Tests/images/jxl/transparent.jxl
new file mode 100644
index 00000000000..cea19bb6cfa
Binary files /dev/null and b/Tests/images/jxl/transparent.jxl differ
diff --git a/Tests/images/jxl/unknown_mode.jxl b/Tests/images/jxl/unknown_mode.jxl
new file mode 100644
index 00000000000..8e8f8ede35e
Binary files /dev/null and b/Tests/images/jxl/unknown_mode.jxl differ
diff --git a/Tests/test_file_jxl.py b/Tests/test_file_jxl.py
new file mode 100644
index 00000000000..86e718a05c5
--- /dev/null
+++ b/Tests/test_file_jxl.py
@@ -0,0 +1,64 @@
+from __future__ import annotations
+
+import os
+import re
+
+import pytest
+
+from PIL import Image, JpegXlImagePlugin, UnidentifiedImageError, features
+
+from .helper import assert_image_similar_tofile, skip_unless_feature
+
+try:
+ from PIL import _jpegxl
+except ImportError:
+ pass
+
+
+class TestUnsupportedJpegXl:
+ def test_unsupported(self, monkeypatch: pytest.MonkeyPatch) -> None:
+ monkeypatch.setattr(JpegXlImagePlugin, "SUPPORTED", False)
+
+ with pytest.raises(OSError):
+ with pytest.warns(UserWarning, match="JXL support not installed"):
+ Image.open("Tests/images/jxl/hopper.jxl")
+
+
+@skip_unless_feature("jpegxl")
+class TestFileJpegXl:
+ def test_version(self) -> None:
+ version = features.version_module("jpegxl")
+ assert version is not None
+ assert re.search(r"\d+\.\d+\.\d+$", version)
+
+ @pytest.mark.parametrize(
+ "mode, test_file",
+ (
+ ("1", "hopper_bw_500.png"),
+ ("L", "hopper_gray.jpg"),
+ ("I;16", "jxl/16bit_subcutaneous.cropped.png"),
+ ("RGB", "hopper.jpg"),
+ ("RGBA", "transparent.png"),
+ ),
+ )
+ def test_read(self, mode: str, test_file: str) -> None:
+ with Image.open(
+ "Tests/images/jxl/"
+ + os.path.splitext(os.path.basename(test_file))[0]
+ + ".jxl"
+ ) as im:
+ assert im.format == "JPEG XL"
+ assert im.mode == mode
+
+ assert_image_similar_tofile(im, "Tests/images/" + test_file, 1.9)
+
+ def test_unknown_mode(self) -> None:
+ with pytest.raises(UnidentifiedImageError):
+ Image.open("Tests/images/jxl/unknown_mode.jxl")
+
+ def test_JpegXlDecode_with_invalid_args(self) -> None:
+ """
+ Calling decoder functions with no arguments should result in an error.
+ """
+ with pytest.raises(TypeError):
+ _jpegxl.JpegXlDecoder()
diff --git a/Tests/test_file_jxl_animated.py b/Tests/test_file_jxl_animated.py
new file mode 100644
index 00000000000..f4283a71ea6
--- /dev/null
+++ b/Tests/test_file_jxl_animated.py
@@ -0,0 +1,76 @@
+from __future__ import annotations
+
+import pytest
+
+from PIL import Image
+
+from .helper import assert_image_equal, skip_unless_feature
+
+pytestmark = skip_unless_feature("jpegxl")
+
+
+def test_n_frames() -> None:
+ """Ensure that jxl format sets n_frames and is_animated attributes correctly."""
+
+ with Image.open("Tests/images/jxl/hopper.jxl") as im:
+ assert im.n_frames == 1
+ assert not im.is_animated
+
+ with Image.open("Tests/images/jxl/iss634.jxl") as im:
+ assert im.n_frames == 41
+ assert im.is_animated
+
+
+def test_duration() -> None:
+ with Image.open("Tests/images/jxl/iss634.jxl") as im:
+ assert im.info["duration"] == 70
+ assert im.info["timestamp"] == 0
+
+ im.seek(2)
+ assert im.info["duration"] == 60
+ assert im.info["timestamp"] == 140
+
+
+def test_seek() -> None:
+ """
+ Open an animated jxl file, and then try seeking through frames in reverse-order,
+ verifying the durations are correct.
+ """
+
+ with Image.open("Tests/images/jxl/traffic_light.jxl") as im1:
+ with Image.open("Tests/images/jxl/traffic_light.gif") as im2:
+ assert im1.n_frames == im2.n_frames
+ assert im1.is_animated
+
+ # Traverse frames in reverse, checking timestamps and durations
+ total_duration = 0
+ for frame in reversed(range(im1.n_frames)):
+ im1.seek(frame)
+ im2.seek(frame)
+
+ assert_image_equal(im1.convert("RGB"), im2.convert("RGB"))
+
+ total_duration += im1.info["duration"]
+ assert im1.info["duration"] == im2.info["duration"]
+ assert im1.info["timestamp"] == im1.info["timestamp"]
+ assert total_duration == 8000
+
+ assert im1.tell() == 0
+ assert im2.tell() == 0
+
+ im1.seek(0)
+ im1.load()
+ im2.seek(0)
+ im2.load()
+
+
+def test_seek_errors() -> None:
+ with Image.open("Tests/images/jxl/iss634.jxl") as im:
+ with pytest.raises(EOFError, match="attempt to seek outside sequence"):
+ im.seek(-1)
+
+ im.seek(1)
+ with pytest.raises(EOFError, match="no more images in JPEG XL file"):
+ im.seek(47)
+
+ assert im.tell() == 1
diff --git a/Tests/test_file_jxl_metadata.py b/Tests/test_file_jxl_metadata.py
new file mode 100644
index 00000000000..e9f25c3eedb
--- /dev/null
+++ b/Tests/test_file_jxl_metadata.py
@@ -0,0 +1,114 @@
+from __future__ import annotations
+
+from types import ModuleType
+
+import pytest
+
+from PIL import Image, JpegXlImagePlugin
+
+from .helper import skip_unless_feature
+
+pytestmark = skip_unless_feature("jpegxl")
+
+ElementTree: ModuleType | None
+try:
+ from defusedxml import ElementTree
+except ImportError:
+ ElementTree = None
+
+
+# cjxl flower.jpg flower.jxl --lossless_jpeg=0 -q 75 -e 8
+
+# >>> from PIL import Image
+# >>> with Image.open('Tests/images/flower2.webp') as im:
+# >>> with open('/tmp/xmp.xml', 'wb') as f:
+# >>> f.write(im.info['xmp'])
+# cjxl flower2.jpg flower2.jxl --lossless_jpeg=0 -q 75 -e 8 -x xmp=/tmp/xmp.xml
+
+
+def test_read_exif_metadata() -> None:
+ with Image.open("Tests/images/jxl/flower.jxl") as im:
+ assert im.format == "JPEG XL"
+ exif_data = im.info["exif"]
+
+ exif = im.getexif()
+
+ # Camera make
+ assert exif[271] == "Canon"
+
+ with Image.open("Tests/images/flower.jpg") as im_jpeg:
+ expected_exif = im_jpeg.info["exif"]
+
+ # JPEG XL always returns exif without "Exif\x00\x00" prefix
+ assert exif_data == expected_exif[6:]
+
+
+def test_read_exif_metadata_without_prefix() -> None:
+ with Image.open("Tests/images/jxl/flower2.jxl") as im:
+ # Assert prefix is not present
+ assert im.info["exif"][:6] != b"Exif\x00\x00"
+
+ exif = im.getexif()
+ assert exif[305] == "Adobe Photoshop CS6 (Macintosh)"
+
+
+def test_read_icc_profile() -> None:
+ with Image.open("Tests/images/jxl/flower.jxl") as im:
+ assert "icc_profile" in im.info
+
+
+def test_getxmp() -> None:
+ with Image.open("Tests/images/jxl/flower.jxl") as im:
+ assert "xmp" not in im.info
+ if ElementTree is None:
+ with pytest.warns(
+ UserWarning,
+ match="XMP data cannot be read without defusedxml dependency",
+ ):
+ xmp = im.getxmp()
+ else:
+ xmp = im.getxmp()
+ assert xmp == {}
+
+ with Image.open("Tests/images/jxl/flower2.jxl") as im:
+ if ElementTree is None:
+ with pytest.warns(
+ UserWarning,
+ match="XMP data cannot be read without defusedxml dependency",
+ ):
+ assert im.getxmp() == {}
+ else:
+ assert "xmp" in im.info
+ assert (
+ im.getxmp()["xmpmeta"]["xmptk"]
+ == "Adobe XMP Core 5.3-c011 66.145661, 2012/02/06-14:56:27 "
+ )
+
+
+def test_4_byte_exif(monkeypatch: pytest.MonkeyPatch) -> None:
+ class _mock_jpegxl:
+ class JpegXlDecoder:
+ def __init__(self, b: bytes) -> None:
+ pass
+
+ def get_info(self) -> tuple[tuple[int, int], str, int, int, int, int, int]:
+ return ((1, 1), "L", 0, 0, 0, 0, 0)
+
+ def get_icc(self) -> None:
+ pass
+
+ def get_exif(self) -> bytes:
+ return b"\0\0\0\0"
+
+ def get_xmp(self) -> None:
+ pass
+
+ monkeypatch.setattr(JpegXlImagePlugin, "_jpegxl", _mock_jpegxl)
+
+ with Image.open("Tests/images/jxl/hopper.jxl") as im:
+ assert "exif" not in im.info
+
+
+def test_read_exif_metadata_empty() -> None:
+ with Image.open("Tests/images/jxl/hopper.jxl") as im:
+ assert im.getexif() == {}
diff --git a/checks/check_wheel.py b/checks/check_wheel.py
index f716c8498bb..ef39811493f 100644
--- a/checks/check_wheel.py
+++ b/checks/check_wheel.py
@@ -1,5 +1,6 @@
from __future__ import annotations
+import os
import platform
import sys
@@ -7,7 +8,15 @@
def test_wheel_modules() -> None:
- expected_modules = {"pil", "tkinter", "freetype2", "littlecms2", "webp", "avif"}
+ expected_modules = {
+ "pil",
+ "tkinter",
+ "freetype2",
+ "littlecms2",
+ "webp",
+ "avif",
+ "jpegxl",
+ }
if sys.platform == "win32":
# tkinter is not available in cibuildwheel installed CPython on Windows
@@ -18,13 +27,17 @@ def test_wheel_modules() -> None:
except ImportError:
expected_modules.remove("tkinter")
+ expected_modules.remove("jpegxl")
+
# libavif is not available on Windows for ARM64 architectures
if platform.machine() == "ARM64":
expected_modules.remove("avif")
elif sys.platform == "ios":
# tkinter is not available on iOS
- expected_modules.remove("tkinter")
+ expected_modules -= {"tkinter", "jpegxl"}
+ elif os.environ.get("AUDITWHEEL_POLICY") == "manylinux2014":
+ expected_modules.remove("jpegxl")
assert set(features.get_supported_modules()) == expected_modules
diff --git a/depends/install_libjxl.sh b/depends/install_libjxl.sh
new file mode 100755
index 00000000000..0b222a7e3a5
--- /dev/null
+++ b/depends/install_libjxl.sh
@@ -0,0 +1,17 @@
+#!/bin/bash
+
+version=0.11.1
+
+./download-and-extract.sh highway-1.3.0 https://github.com/google/highway/archive/1.3.0.tar.gz
+
+pushd highway-1.3.0
+cmake .
+make -j4 install
+popd
+
+./download-and-extract.sh libjxl-$version https://github.com/libjxl/libjxl/archive/v$version.tar.gz
+
+pushd libjxl-$version
+cmake -DCMAKE_INSTALL_PREFIX=/usr -DJPEGXL_ENABLE_SJPEG=OFF -DJPEGXL_ENABLE_SKCMS=OFF -DBUILD_TESTING=OFF .
+make -j4 install
+popd
diff --git a/docs/handbook/image-file-formats.rst b/docs/handbook/image-file-formats.rst
index 03ee96c0f00..0ac3978a209 100644
--- a/docs/handbook/image-file-formats.rst
+++ b/docs/handbook/image-file-formats.rst
@@ -1569,6 +1569,31 @@ IPTC/NAA
Pillow provides limited read support for IPTC/NAA newsphoto files.
+JPEG XL
+^^^^^^^
+
+Pillow identifies and reads JPEG XL files. Requires libjxl version **0.9.0** or
+greater.
+
+The :py:meth:`~PIL.Image.open` method sets the following
+:py:attr:`~PIL.Image.Image.info` properties:
+
+**duration**
+ The delay (in milliseconds) between each frame.
+
+**exif**
+ Raw EXIF data from the image.
+
+**icc_profile**
+ The ICC color profile for the image.
+
+**timestamp**
+ The time of the current frame. This is the sum of the duration of all previous
+ frames.
+
+**xmp**
+ Raw XMP data from the image.
+
MCIDAS
^^^^^^
diff --git a/docs/installation/building-from-source.rst b/docs/installation/building-from-source.rst
index c86ebe896a7..8c7cda96b00 100644
--- a/docs/installation/building-from-source.rst
+++ b/docs/installation/building-from-source.rst
@@ -97,6 +97,10 @@ Many of Pillow's features require external libraries:
and decoder, such as rav1e and dav1d, or libaom, which both encodes and
decodes AVIF images.
+* **libjxl** provides support for the JPEG XL format.
+
+ * Pillow requires libjxl version **0.9.0** or greater.
+
.. tab:: Linux
If you didn't build Python from source, make sure you have Python's
@@ -125,6 +129,8 @@ Many of Pillow's features require external libraries:
To install libraqm, ``sudo apt-get install meson`` and then see
``depends/install_raqm.sh``.
+ To install libjxl, see ``depends/install_libjxl.sh``.
+
Build prerequisites for libavif on Ubuntu are installed with::
sudo apt-get install cmake ninja-build nasm
@@ -162,7 +168,7 @@ Many of Pillow's features require external libraries:
The easiest way to install external libraries is via `Homebrew
`_. After you install Homebrew, run::
- brew install libavif libjpeg libraqm libtiff little-cms2 openjpeg webp
+ brew install jpeg-xl libavif libjpeg libraqm libtiff little-cms2 openjpeg webp
If you would like to use libavif with more codecs than just aom, then
instead of installing libavif through Homebrew directly, you can use
@@ -202,6 +208,7 @@ Many of Pillow's features require external libraries:
pacman -S \
mingw-w64-x86_64-libjpeg-turbo \
+ mingw-w64-x86_64-libjxl \
mingw-w64-x86_64-zlib \
mingw-w64-x86_64-libtiff \
mingw-w64-x86_64-freetype \
@@ -284,7 +291,7 @@ Build options
``-C tiff=disable``, ``-C freetype=disable``, ``-C raqm=disable``,
``-C lcms=disable``, ``-C webp=disable``,
``-C jpeg2000=disable``, ``-C imagequant=disable``, ``-C xcb=disable``,
- ``-C avif=disable``.
+ ``-C avif=disable``, ``-C jpegxl=disable``.
Disable building the corresponding feature even if the development
libraries are present on the building machine.
@@ -292,7 +299,7 @@ Build options
``-C tiff=enable``, ``-C freetype=enable``, ``-C raqm=enable``,
``-C lcms=enable``, ``-C webp=enable``,
``-C jpeg2000=enable``, ``-C imagequant=enable``, ``-C xcb=enable``,
- ``-C avif=enable``.
+ ``-C avif=enable``, ``-C jpegxl=enable``.
Require that the corresponding feature is built. The build will raise
an exception if the libraries are not found. Tcl and Tk must be used
together.
diff --git a/docs/reference/features.rst b/docs/reference/features.rst
index 45067ba3587..01ae4582c02 100644
--- a/docs/reference/features.rst
+++ b/docs/reference/features.rst
@@ -22,6 +22,7 @@ Support for the following modules can be checked:
* ``littlecms2``: LittleCMS 2 support via :py:mod:`PIL.ImageCms`.
* ``webp``: WebP image support.
* ``avif``: AVIF image support.
+* ``jpegxl``: JPEG XL image support.
.. autofunction:: PIL.features.check_module
.. autofunction:: PIL.features.version_module
diff --git a/docs/reference/plugins.rst b/docs/reference/plugins.rst
index 243d4f353f5..b5e7b634622 100644
--- a/docs/reference/plugins.rst
+++ b/docs/reference/plugins.rst
@@ -169,6 +169,14 @@ Plugin reference
:undoc-members:
:show-inheritance:
+:mod:`~PIL.JpegXlImagePlugin` module
+------------------------------------
+
+.. automodule:: PIL.JpegXlImagePlugin
+ :members:
+ :undoc-members:
+ :show-inheritance:
+
:mod:`~PIL.McIdasImagePlugin` module
------------------------------------
diff --git a/setup.py b/setup.py
index 032c1c6d263..fbb6df17c0c 100644
--- a/setup.py
+++ b/setup.py
@@ -48,8 +48,9 @@ def get_version() -> str:
HARFBUZZ_ROOT = None
FRIBIDI_ROOT = None
IMAGEQUANT_ROOT = None
-JPEG2K_ROOT = None
JPEG_ROOT = None
+JPEG2K_ROOT = None
+JPEGXL_ROOT = None
LCMS_ROOT = None
RAQM_ROOT = None
TIFF_ROOT = None
@@ -312,6 +313,7 @@ class ext_feature:
features = [
"zlib",
"jpeg",
+ "jpegxl",
"tiff",
"freetype",
"raqm",
@@ -443,6 +445,7 @@ def _update_extension(
libraries: list[str] | list[str | bool | None],
define_macros: list[tuple[str, str | None]] | None = None,
sources: list[str] | None = None,
+ args: list[str] | None = None,
) -> None:
for extension in self.extensions:
if extension.name == name:
@@ -451,6 +454,8 @@ def _update_extension(
extension.define_macros += define_macros
if sources is not None:
extension.sources += sources
+ if args is not None:
+ extension.extra_compile_args += args
if FUZZING_BUILD:
extension.language = "c++"
extension.extra_link_args = ["--stdlib=libc++"]
@@ -510,6 +515,7 @@ def build_extensions(self) -> None:
"AVIF_ROOT": "avif",
"JPEG_ROOT": "libjpeg",
"JPEG2K_ROOT": "libopenjp2",
+ "JPEGXL_ROOT": "jxl",
"TIFF_ROOT": ("libtiff-5", "libtiff-4"),
"ZLIB_ROOT": "zlib",
"FREETYPE_ROOT": "freetype2",
@@ -777,6 +783,16 @@ def build_extensions(self) -> None:
feature.set("jpeg2000", "openjp2")
feature.set("openjpeg_version", ".".join(str(x) for x in best_version))
+ if feature.want("jpegxl"):
+ _dbg("Looking for jpegxl")
+ if _find_include_file(self, "jxl/decode.h") and _find_include_file(
+ self, "jxl/thread_parallel_runner.h"
+ ):
+ if _find_library_file(self, "jxl") and _find_library_file(
+ self, "jxl_threads"
+ ):
+ feature.set("jpegxl", "jxl")
+
if feature.want("imagequant"):
_dbg("Looking for imagequant")
if _find_include_file(self, "libimagequant.h"):
@@ -1001,6 +1017,17 @@ def build_extensions(self) -> None:
else:
self._remove_extension("PIL._avif")
+ jpegxl = feature.get("jpegxl")
+ if isinstance(jpegxl, str):
+ libs = [jpegxl, jpegxl + "_threads"]
+ args: list[str] | None = None
+ if sys.platform == "win32":
+ libs.extend(["brotlicommon", "brotlidec", "brotlienc", "hwy"])
+ args = ["-DJXL_STATIC_DEFINE"]
+ self._update_extension("PIL._jpegxl", libs, args=args)
+ else:
+ self._remove_extension("PIL._jpegxl")
+
tk_libs = ["psapi"] if sys.platform in ("win32", "cygwin") else []
self._update_extension("PIL._imagingtk", tk_libs)
@@ -1041,6 +1068,7 @@ def summary_report(self, feature: ext_feature) -> None:
(feature.get("freetype"), "FREETYPE2"),
(feature.get("raqm"), "RAQM (Text shaping)", raqm_extra_info),
(feature.get("lcms"), "LITTLECMS2"),
+ (feature.get("jpegxl"), "JPEG XL"),
(feature.get("webp"), "WEBP"),
(feature.get("xcb"), "XCB (X protocol)"),
(feature.get("avif"), "LIBAVIF"),
@@ -1089,6 +1117,7 @@ def debug_build() -> bool:
Extension("PIL._imaging", files),
Extension("PIL._imagingft", ["src/_imagingft.c"]),
Extension("PIL._imagingcms", ["src/_imagingcms.c"]),
+ Extension("PIL._jpegxl", ["src/_jpegxl.c"]),
Extension("PIL._webp", ["src/_webp.c"]),
Extension("PIL._avif", ["src/_avif.c"]),
Extension("PIL._imagingtk", ["src/_imagingtk.c", "src/Tk/tkImaging.c"]),
diff --git a/src/PIL/JpegXlImagePlugin.py b/src/PIL/JpegXlImagePlugin.py
new file mode 100644
index 00000000000..f4dd6da1e06
--- /dev/null
+++ b/src/PIL/JpegXlImagePlugin.py
@@ -0,0 +1,122 @@
+from __future__ import annotations
+
+import struct
+from io import BytesIO
+
+from . import Image, ImageFile
+
+try:
+ from . import _jpegxl
+
+ SUPPORTED = True
+except ImportError:
+ SUPPORTED = False
+
+
+def _accept(prefix: bytes) -> bool | str:
+ is_jxl = prefix.startswith(
+ (b"\xff\x0a", b"\x00\x00\x00\x0c\x4a\x58\x4c\x20\x0d\x0a\x87\x0a")
+ )
+ if is_jxl and not SUPPORTED:
+ return "image file could not be identified because JXL support not installed"
+ return is_jxl
+
+
+class JpegXlImageFile(ImageFile.ImageFile):
+ format = "JPEG XL"
+ format_description = "JPEG XL image"
+ __frame = 0
+
+ def _open(self) -> None:
+ assert self.fp is not None
+ self._decoder = _jpegxl.JpegXlDecoder(self.fp.read())
+
+ (
+ self._size,
+ self._mode,
+ self.is_animated,
+ tps_num,
+ tps_denom,
+ self.info["loop"],
+ tps_duration,
+ ) = self._decoder.get_info()
+
+ self._n_frames = None if self.is_animated else 1
+ self._tps_dur_secs = tps_num / tps_denom if tps_denom != 0 else 1
+ self.info["duration"] = 1000 * tps_duration * (1 / self._tps_dur_secs)
+ self.info["timestamp"] = 0
+
+ if icc := self._decoder.get_icc():
+ self.info["icc_profile"] = icc
+ if exif := self._decoder.get_exif():
+ # JPEG XL does some weird shenanigans when storing exif
+ # it omits first 6 bytes of tiff header but adds 4 byte offset instead
+ if len(exif) > 4:
+ exif_start_offset = struct.unpack(">I", exif[:4])[0]
+ self.info["exif"] = exif[exif_start_offset + 4 :]
+ if xmp := self._decoder.get_xmp():
+ self.info["xmp"] = xmp
+ rawmode = "L" if self.mode == "1" else self.mode
+ self.tile = [ImageFile._Tile("raw", (0, 0) + self.size, 0, rawmode)]
+
+ @property
+ def n_frames(self) -> int:
+ if self._n_frames is None:
+ current = self.tell()
+ self._n_frames = current + self._decoder.get_frames_left()
+ self.seek(current)
+
+ return self._n_frames
+
+ def _get_next(self) -> bytes:
+ data, tps_duration, is_last = self._decoder.get_next()
+
+ if is_last and self._n_frames is None:
+ self._n_frames = self.__frame
+
+ # duration in milliseconds
+ self.info["timestamp"] += self.info["duration"]
+ self.info["duration"] = 1000 * tps_duration * (1 / self._tps_dur_secs)
+
+ return data
+
+ def seek(self, frame: int) -> None:
+ if not self._seek_check(frame):
+ return
+
+ if frame < self.__frame:
+ self.__frame = 0
+ self._decoder.rewind()
+ self.info["timestamp"] = 0
+
+ last_frame = self.__frame
+ while self.__frame < frame:
+ self._get_next()
+ self.__frame += 1
+ if self._n_frames is not None and self._n_frames < frame:
+ self.seek(last_frame)
+ msg = "no more images in JPEG XL file"
+ raise EOFError(msg)
+
+ self.tile = [ImageFile._Tile("raw", (0, 0) + self.size, 0, self.mode)]
+
+ def load(self) -> Image.core.PixelAccess | None:
+ if self.tile:
+ data = self._get_next()
+
+ if self.fp and self._exclusive_fp:
+ self.fp.close()
+ self.fp = BytesIO(data)
+
+ return super().load()
+
+ def load_seek(self, pos: int) -> None:
+ pass
+
+ def tell(self) -> int:
+ return self.__frame
+
+
+Image.register_open(JpegXlImageFile.format, JpegXlImageFile, _accept)
+Image.register_extension(JpegXlImageFile.format, ".jxl")
+Image.register_mime(JpegXlImageFile.format, "image/jxl")
diff --git a/src/PIL/__init__.py b/src/PIL/__init__.py
index 6e4c23f897f..de299a1a13b 100644
--- a/src/PIL/__init__.py
+++ b/src/PIL/__init__.py
@@ -48,6 +48,7 @@
"IptcImagePlugin",
"JpegImagePlugin",
"Jpeg2KImagePlugin",
+ "JpegXlImagePlugin",
"McIdasImagePlugin",
"MicImagePlugin",
"MpegImagePlugin",
diff --git a/src/PIL/_jpegxl.pyi b/src/PIL/_jpegxl.pyi
new file mode 100644
index 00000000000..e27843e5338
--- /dev/null
+++ b/src/PIL/_jpegxl.pyi
@@ -0,0 +1,3 @@
+from typing import Any
+
+def __getattr__(name: str) -> Any: ...
diff --git a/src/PIL/features.py b/src/PIL/features.py
index ff32c251045..1bfa40020c5 100644
--- a/src/PIL/features.py
+++ b/src/PIL/features.py
@@ -17,6 +17,7 @@
"littlecms2": ("PIL._imagingcms", "littlecms_version"),
"webp": ("PIL._webp", "webpdecoder_version"),
"avif": ("PIL._avif", "libavif_version"),
+ "jpegxl": ("PIL._jpegxl", "libjxl_version"),
}
@@ -272,6 +273,7 @@ def pilinfo(out: IO[str] | None = None, supported_formats: bool = True) -> None:
("littlecms2", "LITTLECMS2"),
("webp", "WEBP"),
("avif", "AVIF"),
+ ("jpegxl", "JPEG XL"),
("jpg", "JPEG"),
("jpg_2000", "OPENJPEG (JPEG2000)"),
("zlib", "ZLIB (PNG/ZIP)"),
diff --git a/src/_jpegxl.c b/src/_jpegxl.c
new file mode 100644
index 00000000000..a91a8c34e01
--- /dev/null
+++ b/src/_jpegxl.c
@@ -0,0 +1,563 @@
+#define PY_SSIZE_T_CLEAN
+#include
+#include "libImaging/Imaging.h"
+
+#include
+#include
+
+#define _JXL_CHECK(call_name) \
+ if (decp->status != JXL_DEC_SUCCESS) { \
+ jxl_call_name = call_name; \
+ goto end; \
+ }
+
+void
+_jxl_get_pixel_format(JxlPixelFormat *pf, const JxlBasicInfo *bi) {
+ pf->num_channels = bi->num_color_channels + bi->num_extra_channels;
+ pf->data_type = bi->bits_per_sample > 8 ? JXL_TYPE_UINT16 : JXL_TYPE_UINT8;
+ pf->align = 0;
+}
+
+char *
+_jxl_get_mode(const JxlBasicInfo *bi) {
+ if (bi->num_color_channels == 1 && !bi->alpha_bits) {
+ if (bi->bits_per_sample == 1) {
+ return "1";
+ }
+ if (bi->bits_per_sample == 16) {
+ return "I;16";
+ }
+ }
+
+ if (bi->bits_per_sample == 8) {
+ if (bi->alpha_bits) {
+ // image has transparency
+ if (bi->num_color_channels == 3) {
+ return bi->alpha_premultiplied ? "RGBa" : "RGBA";
+ }
+ } else {
+ // image has no transparency
+ if (bi->num_color_channels == 3) {
+ return "RGB";
+ }
+ if (bi->num_color_channels == 1) {
+ return "L";
+ }
+ }
+ }
+
+ // could not recognize mode
+ return NULL;
+}
+
+// Decoder type
+typedef struct {
+ PyObject_HEAD JxlDecoder *decoder;
+ void *runner;
+
+ uint8_t *jxl_data; // input jxl bitstream
+ Py_ssize_t jxl_data_len; // length of input jxl bitstream
+
+ uint8_t *output_buffer;
+ size_t output_buffer_len;
+
+ uint8_t *jxl_icc;
+ size_t jxl_icc_len;
+ uint8_t *jxl_exif;
+ Py_ssize_t jxl_exif_len;
+ uint8_t *jxl_xmp;
+ Py_ssize_t jxl_xmp_len;
+
+ JxlDecoderStatus status;
+ JxlBasicInfo basic_info;
+ JxlPixelFormat pixel_format;
+
+ char *mode;
+} JpegXlDecoderObject;
+
+static PyTypeObject JpegXlDecoder_Type;
+
+void
+_jxl_decoder_dealloc(PyObject *self) {
+ JpegXlDecoderObject *decp = (JpegXlDecoderObject *)self;
+
+ if (decp->jxl_data) {
+ free(decp->jxl_data);
+ decp->jxl_data = NULL;
+ decp->jxl_data_len = 0;
+ }
+ if (decp->output_buffer) {
+ free(decp->output_buffer);
+ decp->output_buffer = NULL;
+ decp->output_buffer_len = 0;
+ }
+ if (decp->jxl_icc) {
+ free(decp->jxl_icc);
+ decp->jxl_icc = NULL;
+ decp->jxl_icc_len = 0;
+ }
+ if (decp->jxl_exif) {
+ free(decp->jxl_exif);
+ decp->jxl_exif = NULL;
+ decp->jxl_exif_len = 0;
+ }
+ if (decp->jxl_xmp) {
+ free(decp->jxl_xmp);
+ decp->jxl_xmp = NULL;
+ decp->jxl_xmp_len = 0;
+ }
+
+ if (decp->decoder) {
+ JxlDecoderDestroy(decp->decoder);
+ decp->decoder = NULL;
+ }
+
+ if (decp->runner) {
+ JxlThreadParallelRunnerDestroy(decp->runner);
+ decp->runner = NULL;
+ }
+}
+
+// sets input jxl bitstream loaded into jxl_data
+void
+_jxl_decoder_set_input(PyObject *self) {
+ JpegXlDecoderObject *decp = (JpegXlDecoderObject *)self;
+
+ decp->status =
+ JxlDecoderSetInput(decp->decoder, decp->jxl_data, decp->jxl_data_len);
+
+ // the input contains the whole jxl bitstream so it can be closed
+ JxlDecoderCloseInput(decp->decoder);
+}
+
+PyObject *
+_jxl_decoder_rewind(PyObject *self) {
+ JpegXlDecoderObject *decp = (JpegXlDecoderObject *)self;
+ JxlDecoderRewind(decp->decoder);
+ Py_RETURN_NONE;
+}
+
+PyObject *
+_jxl_decoder_get_frames_left(PyObject *self) {
+ int frames_left = 0;
+
+ // count all JXL_DEC_NEED_IMAGE_OUT_BUFFER events
+ JpegXlDecoderObject *decp = (JpegXlDecoderObject *)self;
+ while (decp->status != JXL_DEC_SUCCESS) {
+ decp->status = JxlDecoderProcessInput(decp->decoder);
+
+ if (decp->status == JXL_DEC_NEED_IMAGE_OUT_BUFFER) {
+ if (JxlDecoderSkipCurrentFrame(decp->decoder) != JXL_DEC_SUCCESS) {
+ PyErr_SetString(PyExc_OSError, "Error when counting frames");
+ break;
+ }
+ frames_left++;
+ }
+ }
+ JxlDecoderRewind(decp->decoder);
+
+ return Py_BuildValue("i", frames_left);
+}
+
+PyObject *
+_jxl_decoder_new(PyObject *self, PyObject *args) {
+ PyBytesObject *jxl_string;
+
+ // parse one argument which is a string with jxl data
+ if (!PyArg_ParseTuple(args, "O", &jxl_string)) {
+ return NULL;
+ }
+
+ JpegXlDecoderObject *decp = NULL;
+ decp = PyObject_New(JpegXlDecoderObject, &JpegXlDecoder_Type);
+ decp->jxl_data = NULL;
+ decp->jxl_data_len = 0;
+ decp->output_buffer = NULL;
+ decp->output_buffer_len = 0;
+ decp->jxl_icc = NULL;
+ decp->jxl_icc_len = 0;
+ decp->jxl_exif = NULL;
+ decp->jxl_exif_len = 0;
+ decp->jxl_xmp = NULL;
+ decp->jxl_xmp_len = 0;
+ decp->mode = NULL;
+
+ // used for printing more detailed error messages
+ char *jxl_call_name;
+
+ // this data needs to be copied to JpegXlDecoderObject
+ // so that input bitstream is preserved across calls
+ const uint8_t *_tmp_jxl_data;
+ Py_ssize_t _tmp_jxl_data_len;
+
+ // convert jxl data string to C uint8_t pointer
+ PyBytes_AsStringAndSize(
+ (PyObject *)jxl_string, (char **)&_tmp_jxl_data, &_tmp_jxl_data_len
+ );
+
+ decp->jxl_data = malloc(_tmp_jxl_data_len);
+ memcpy(decp->jxl_data, _tmp_jxl_data, _tmp_jxl_data_len);
+ decp->jxl_data_len = _tmp_jxl_data_len;
+
+ size_t suggested_num_threads = JxlThreadParallelRunnerDefaultNumWorkerThreads();
+ decp->runner = JxlThreadParallelRunnerCreate(NULL, suggested_num_threads);
+ decp->decoder = JxlDecoderCreate(NULL);
+
+ decp->status = JxlDecoderSetParallelRunner(
+ decp->decoder, JxlThreadParallelRunner, decp->runner
+ );
+ _JXL_CHECK("JxlDecoderSetParallelRunner")
+
+ decp->status = JxlDecoderSubscribeEvents(
+ decp->decoder,
+ JXL_DEC_BASIC_INFO | JXL_DEC_COLOR_ENCODING | JXL_DEC_FRAME | JXL_DEC_BOX |
+ JXL_DEC_FULL_IMAGE
+ );
+ _JXL_CHECK("JxlDecoderSubscribeEvents")
+
+ // tell libjxl to decompress boxes (for example Exif is usually compressed)
+ decp->status = JxlDecoderSetDecompressBoxes(decp->decoder, JXL_TRUE);
+ _JXL_CHECK("JxlDecoderSetDecompressBoxes")
+
+ _jxl_decoder_set_input((PyObject *)decp);
+ _JXL_CHECK("JxlDecoderSetInput")
+
+ // decode everything up to the first frame
+ do {
+ decp->status = JxlDecoderProcessInput(decp->decoder);
+
+decoder_loop_skip_process:
+
+ if (decp->status == JXL_DEC_ERROR) {
+ jxl_call_name = "JxlDecoderProcessInput";
+ goto end;
+ }
+
+ if (decp->status == JXL_DEC_BASIC_INFO) {
+ decp->status = JxlDecoderGetBasicInfo(decp->decoder, &decp->basic_info);
+ _JXL_CHECK("JxlDecoderGetBasicInfo");
+
+ _jxl_get_pixel_format(&decp->pixel_format, &decp->basic_info);
+ decp->mode = _jxl_get_mode(&decp->basic_info);
+ } else if (decp->status == JXL_DEC_COLOR_ENCODING) {
+ decp->status = JxlDecoderGetICCProfileSize(
+ decp->decoder, JXL_COLOR_PROFILE_TARGET_DATA, &decp->jxl_icc_len
+ );
+ _JXL_CHECK("JxlDecoderGetICCProfileSize");
+
+ decp->jxl_icc = malloc(decp->jxl_icc_len);
+ if (!decp->jxl_icc) {
+ PyErr_SetString(PyExc_OSError, "jxl_icc malloc failed");
+ goto end_with_custom_error;
+ }
+
+ decp->status = JxlDecoderGetColorAsICCProfile(
+ decp->decoder,
+ JXL_COLOR_PROFILE_TARGET_DATA,
+ decp->jxl_icc,
+ decp->jxl_icc_len
+ );
+ _JXL_CHECK("JxlDecoderGetColorAsICCProfile");
+ } else if (decp->status == JXL_DEC_BOX) {
+ char box_type[4];
+ decp->status = JxlDecoderGetBoxType(decp->decoder, box_type, JXL_TRUE);
+ _JXL_CHECK("JxlDecoderGetBoxType");
+
+ int is_box_exif = !memcmp(box_type, "Exif", 4);
+ int is_box_xmp = is_box_exif ? 0 : !memcmp(box_type, "xml ", 4);
+ if (!is_box_exif && !is_box_xmp) {
+ // not exif/xmp box so continue
+ continue;
+ }
+
+ uint64_t compressed_box_size;
+ decp->status = JxlDecoderGetBoxSizeRaw(decp->decoder, &compressed_box_size);
+ _JXL_CHECK("JxlDecoderGetBoxSizeRaw");
+
+ uint8_t *final_jxl_buf = NULL;
+ Py_ssize_t final_jxl_buf_len = 0;
+
+ do {
+ uint8_t *_new_jxl_buf =
+ realloc(final_jxl_buf, final_jxl_buf_len + compressed_box_size);
+ if (!_new_jxl_buf) {
+ PyErr_SetString(PyExc_OSError, "failed to allocate final_jxl_buf");
+ goto end_with_custom_error;
+ }
+ final_jxl_buf = _new_jxl_buf;
+
+ decp->status = JxlDecoderSetBoxBuffer(
+ decp->decoder,
+ final_jxl_buf + final_jxl_buf_len,
+ compressed_box_size
+ );
+ _JXL_CHECK("JxlDecoderSetBoxBuffer");
+
+ decp->status = JxlDecoderProcessInput(decp->decoder);
+
+ size_t remaining = JxlDecoderReleaseBoxBuffer(decp->decoder);
+ final_jxl_buf_len += compressed_box_size - remaining;
+ } while (decp->status == JXL_DEC_BOX_NEED_MORE_OUTPUT);
+
+ if (is_box_exif) {
+ decp->jxl_exif = final_jxl_buf;
+ decp->jxl_exif_len = final_jxl_buf_len;
+ } else {
+ decp->jxl_xmp = final_jxl_buf;
+ decp->jxl_xmp_len = final_jxl_buf_len;
+ }
+
+ // dirty hack: skip first step of decoding loop since
+ // we already did it in do...while above
+ goto decoder_loop_skip_process;
+ }
+
+ } while (decp->status != JXL_DEC_FRAME);
+
+ return (PyObject *)decp;
+
+ // on success we should never reach here
+
+ // set error message
+ char err_msg[128];
+
+end:
+ snprintf(
+ err_msg,
+ 128,
+ "could not create decoder object. libjxl call: %s returned: %d",
+ jxl_call_name,
+ decp->status
+ );
+ PyErr_SetString(PyExc_OSError, err_msg);
+
+end_with_custom_error:
+
+ // deallocate
+ _jxl_decoder_dealloc((PyObject *)decp);
+ PyObject_Del(decp);
+
+ return NULL;
+}
+
+PyObject *
+_jxl_decoder_get_info(PyObject *self) {
+ JpegXlDecoderObject *decp = (JpegXlDecoderObject *)self;
+ JxlFrameHeader fhdr = {};
+ if (JxlDecoderGetFrameHeader(decp->decoder, &fhdr) != JXL_DEC_SUCCESS) {
+ PyErr_SetString(PyExc_OSError, "Error determining duration");
+ return NULL;
+ }
+ return Py_BuildValue(
+ "(II)sOIIII",
+ decp->basic_info.xsize,
+ decp->basic_info.ysize,
+ decp->mode,
+ decp->basic_info.have_animation ? Py_True : Py_False,
+ decp->basic_info.animation.tps_numerator,
+ decp->basic_info.animation.tps_denominator,
+ decp->basic_info.animation.num_loops,
+ fhdr.duration
+ );
+}
+
+PyObject *
+_jxl_decoder_get_next(PyObject *self) {
+ JpegXlDecoderObject *decp = (JpegXlDecoderObject *)self;
+ PyObject *bytes;
+ PyObject *ret;
+ JxlFrameHeader fhdr = {};
+
+ char *jxl_call_name;
+
+ // process events until next frame output is ready
+ if (decp->status == JXL_DEC_FRAME) {
+ decp->status = JxlDecoderGetFrameHeader(decp->decoder, &fhdr);
+ _JXL_CHECK("JxlDecoderGetFrameHeader");
+ }
+ while (decp->status != JXL_DEC_NEED_IMAGE_OUT_BUFFER) {
+ decp->status = JxlDecoderProcessInput(decp->decoder);
+
+ if (decp->status == JXL_DEC_NEED_MORE_INPUT) {
+ // this should only occur after rewind
+ _jxl_decoder_set_input((PyObject *)decp);
+ _JXL_CHECK("JxlDecoderSetInput")
+ } else if (decp->status == JXL_DEC_FRAME) {
+ // decode frame header
+ decp->status = JxlDecoderGetFrameHeader(decp->decoder, &fhdr);
+ _JXL_CHECK("JxlDecoderGetFrameHeader");
+ }
+ }
+
+ size_t new_output_buffer_len;
+ decp->status = JxlDecoderImageOutBufferSize(
+ decp->decoder, &decp->pixel_format, &new_output_buffer_len
+ );
+ _JXL_CHECK("JxlDecoderImageOutBufferSize");
+
+ // only allocate memory when current buffer is too small
+ if (decp->output_buffer_len < new_output_buffer_len) {
+ decp->output_buffer_len = new_output_buffer_len;
+ uint8_t *new_output_buffer =
+ realloc(decp->output_buffer, decp->output_buffer_len);
+ if (!new_output_buffer) {
+ PyErr_SetString(PyExc_OSError, "failed to allocate buffer");
+ return NULL;
+ }
+ decp->output_buffer = new_output_buffer;
+ }
+
+ decp->status = JxlDecoderSetImageOutBuffer(
+ decp->decoder, &decp->pixel_format, decp->output_buffer, decp->output_buffer_len
+ );
+ _JXL_CHECK("JxlDecoderSetImageOutBuffer");
+
+ // decode image into output buffer
+ decp->status = JxlDecoderProcessInput(decp->decoder);
+
+ if (decp->status != JXL_DEC_FULL_IMAGE) {
+ PyErr_SetString(PyExc_OSError, "failed to read next frame");
+ return NULL;
+ }
+
+ bytes = PyBytes_FromStringAndSize(
+ (char *)(decp->output_buffer), decp->output_buffer_len
+ );
+
+ ret = Py_BuildValue("SIi", bytes, fhdr.duration, fhdr.is_last);
+
+ Py_DECREF(bytes);
+ return ret;
+
+ // we also shouldn't reach here if frame read was ok
+
+ // set error message
+ char err_msg[128];
+
+end:
+ snprintf(
+ err_msg,
+ 128,
+ "could not read frame. libjxl call: %s returned: %d",
+ jxl_call_name,
+ decp->status
+ );
+ PyErr_SetString(PyExc_OSError, err_msg);
+ return NULL;
+}
+
+PyObject *
+_jxl_decoder_get_icc(PyObject *self) {
+ JpegXlDecoderObject *decp = (JpegXlDecoderObject *)self;
+
+ if (!decp->jxl_icc) {
+ Py_RETURN_NONE;
+ }
+
+ return PyBytes_FromStringAndSize((const char *)decp->jxl_icc, decp->jxl_icc_len);
+}
+
+PyObject *
+_jxl_decoder_get_exif(PyObject *self) {
+ JpegXlDecoderObject *decp = (JpegXlDecoderObject *)self;
+
+ if (!decp->jxl_exif) {
+ Py_RETURN_NONE;
+ }
+
+ return PyBytes_FromStringAndSize((const char *)decp->jxl_exif, decp->jxl_exif_len);
+}
+
+PyObject *
+_jxl_decoder_get_xmp(PyObject *self) {
+ JpegXlDecoderObject *decp = (JpegXlDecoderObject *)self;
+
+ if (!decp->jxl_xmp) {
+ Py_RETURN_NONE;
+ }
+
+ return PyBytes_FromStringAndSize((const char *)decp->jxl_xmp, decp->jxl_xmp_len);
+}
+
+// Version as string
+const char *
+JpegXlDecoderVersion_str(void) {
+ static char version[20];
+ sprintf(
+ version,
+ "%d.%d.%d",
+ JPEGXL_MAJOR_VERSION,
+ JPEGXL_MINOR_VERSION,
+ JPEGXL_PATCH_VERSION
+ );
+ return version;
+}
+
+/* -------------------------------------------------------------------- */
+/* Type Definitions */
+/* -------------------------------------------------------------------- */
+
+// JpegXlDecoder methods
+static struct PyMethodDef _jpegxl_decoder_methods[] = {
+ {"get_info", (PyCFunction)_jxl_decoder_get_info, METH_NOARGS, "get_info"},
+ {"get_next", (PyCFunction)_jxl_decoder_get_next, METH_NOARGS, "get_next"},
+ {"get_icc", (PyCFunction)_jxl_decoder_get_icc, METH_NOARGS, "get_icc"},
+ {"get_exif", (PyCFunction)_jxl_decoder_get_exif, METH_NOARGS, "get_exif"},
+ {"get_xmp", (PyCFunction)_jxl_decoder_get_xmp, METH_NOARGS, "get_xmp"},
+ {"get_frames_left",
+ (PyCFunction)_jxl_decoder_get_frames_left,
+ METH_NOARGS,
+ "get_frames_left"},
+ {"rewind", (PyCFunction)_jxl_decoder_rewind, METH_NOARGS, "rewind"},
+ {NULL, NULL} /* sentinel */
+};
+
+// JpegXlDecoder type definition
+static PyTypeObject JpegXlDecoder_Type = {
+ PyVarObject_HEAD_INIT(NULL, 0).tp_name = "JpegXlDecoder",
+ .tp_basicsize = sizeof(JpegXlDecoderObject),
+ .tp_dealloc = (destructor)_jxl_decoder_dealloc,
+ .tp_methods = _jpegxl_decoder_methods,
+};
+
+/* -------------------------------------------------------------------- */
+/* Module Setup */
+/* -------------------------------------------------------------------- */
+
+static PyMethodDef jpegxlMethods[] = {
+ {"JpegXlDecoder", _jxl_decoder_new, METH_VARARGS, "JpegXlDecoder"}, {NULL, NULL}
+};
+
+static int
+setup_module(PyObject *m) {
+ if (PyType_Ready(&JpegXlDecoder_Type) < 0) {
+ return -1;
+ }
+
+ PyObject *d = PyModule_GetDict(m);
+ PyObject *v = PyUnicode_FromString(JpegXlDecoderVersion_str());
+ PyDict_SetItemString(d, "libjxl_version", v ? v : Py_None);
+ Py_XDECREF(v);
+
+ return 0;
+}
+
+static PyModuleDef_Slot slots[] = {
+ {Py_mod_exec, setup_module},
+#ifdef Py_GIL_DISABLED
+ {Py_mod_gil, Py_MOD_GIL_NOT_USED},
+#endif
+ {0, NULL}
+};
+
+PyMODINIT_FUNC
+PyInit__jpegxl(void) {
+ static PyModuleDef module_def = {
+ PyModuleDef_HEAD_INIT,
+ .m_name = "_jpegxl",
+ .m_methods = jpegxlMethods,
+ .m_slots = slots
+ };
+
+ return PyModuleDef_Init(&module_def);
+}
diff --git a/src/libImaging/Unpack.c b/src/libImaging/Unpack.c
index 203bcac2ca9..7456168b3e9 100644
--- a/src/libImaging/Unpack.c
+++ b/src/libImaging/Unpack.c
@@ -256,6 +256,14 @@ unpack18(UINT8 *out, const UINT8 *in, int pixels) {
}
}
+static void
+unpack1L(UINT8 *out, const UINT8 *in, int pixels) {
+ int i;
+ for (i = 0; i < pixels; i++) {
+ out[i] = in[i] > 128 ? 255 : 0;
+ }
+}
+
/* Unpack to "L" image */
static void
@@ -1564,6 +1572,7 @@ static struct {
{IMAGING_MODE_1, IMAGING_RAWMODE_1_R, 1, unpack1R},
{IMAGING_MODE_1, IMAGING_RAWMODE_1_IR, 1, unpack1IR},
{IMAGING_MODE_1, IMAGING_RAWMODE_1_8, 8, unpack18},
+ {IMAGING_MODE_1, IMAGING_RAWMODE_L, 8, unpack1L},
/* grayscale */
{IMAGING_MODE_L, IMAGING_RAWMODE_L_2, 2, unpackL2},
diff --git a/wheels/dependency_licenses/LIBJXL.txt b/wheels/dependency_licenses/LIBJXL.txt
new file mode 100644
index 00000000000..6f4e574e89b
--- /dev/null
+++ b/wheels/dependency_licenses/LIBJXL.txt
@@ -0,0 +1,50 @@
+Copyright (c) the JPEG XL Project Authors.
+All rights reserved.
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are met:
+
+1. Redistributions of source code must retain the above copyright notice, this
+ list of conditions and the following disclaimer.
+
+2. Redistributions in binary form must reproduce the above copyright notice,
+ this list of conditions and the following disclaimer in the documentation
+ and/or other materials provided with the distribution.
+
+3. Neither the name of the copyright holder nor the names of its
+ contributors may be used to endorse or promote products derived from
+ this software without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
+FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+Additional IP Rights Grant (Patents)
+
+"This implementation" means the copyrightable works distributed by
+Google as part of the JPEG XL project.
+
+Google hereby grants to You a perpetual, worldwide, non-exclusive,
+no-charge, royalty-free, irrevocable (except as stated in this section)
+patent license to make, have made, use, offer to sell, sell, import,
+transfer and otherwise run, modify and propagate the contents of this
+implementation of JPEG XL, where such license applies only to those patent
+claims, both currently owned or controlled by Google and acquired in
+the future, licensable by Google that are necessarily infringed by this
+implementation of JPEG XL. This grant does not include claims that would be
+infringed only as a consequence of further modification of this
+implementation. If you or your agent or exclusive licensee institute or
+order or agree to the institution of patent litigation against any
+entity (including a cross-claim or counterclaim in a lawsuit) alleging
+that this implementation of JPEG XL or any code incorporated within this
+implementation of JPEG XL constitutes direct or contributory patent
+infringement, or inducement of patent infringement, then any patent
+rights granted to you under this License for this implementation of JPEG XL
+shall terminate as of the date such litigation is filed.
diff --git a/winbuild/build_prepare.py b/winbuild/build_prepare.py
index 3377d952c0d..e167934d045 100644
--- a/winbuild/build_prepare.py
+++ b/winbuild/build_prepare.py
@@ -117,7 +117,9 @@ def cmd_msbuild(
"FREETYPE": "2.14.1",
"FRIBIDI": "1.0.16",
"HARFBUZZ": "12.3.0",
+ "HIGHWAY": "1.3.0",
"JPEGTURBO": "3.1.3",
+ "JPEGXL": "0.11.1",
"LCMS2": "2.17",
"LIBAVIF": "1.3.0",
"LIBIMAGEQUANT": "4.4.1",
@@ -255,7 +257,10 @@ def cmd_msbuild(
"filename": f"brotli-{V['BROTLI']}.tar.gz",
"license": "LICENSE",
"build": [
- *cmds_cmake(("brotlicommon", "brotlidec"), "-DBUILD_SHARED_LIBS:BOOL=OFF"),
+ *cmds_cmake(
+ ("brotlicommon", "brotlidec", "brotlienc"),
+ "-DBUILD_SHARED_LIBS:BOOL=OFF",
+ ),
cmd_xcopy(r"c\include", "{inc_dir}"),
],
"libs": ["*.lib"],
@@ -332,6 +337,35 @@ def cmd_msbuild(
],
"libs": [r"bin\*.lib"],
},
+ "highway": {
+ "url": f"https://github.com/google/highway/archive/{V['HIGHWAY']}.tar.gz",
+ "filename": f"highway-{V['HIGHWAY']}.tar.gz",
+ "license": "LICENSE",
+ "build": [*cmds_cmake("hwy")],
+ "libs": ["hwy.lib"],
+ },
+ "libjxl": {
+ "url": f"https://github.com/libjxl/libjxl/archive/v{V['JPEGXL']}.tar.gz",
+ "filename": f"libjxl-{V['JPEGXL']}.tar.gz",
+ "license": "LICENSE",
+ "build": [
+ *cmds_cmake(
+ "jxl",
+ rf"-DHWY_INCLUDE_DIR=..\highway-{V['HIGHWAY']}",
+ r"-DLCMS2_LIBRARY=..\..\lib\lcms2_static",
+ r"-DLCMS2_INCLUDE_DIR=..\..\inc",
+ "-DJPEGXL_ENABLE_SJPEG:BOOL=OFF",
+ "-DJPEGXL_ENABLE_SKCMS:BOOL=OFF",
+ "-DBUILD_TESTING:BOOL=OFF",
+ "-DBUILD_SHARED_LIBS:BOOL=OFF",
+ ),
+ cmd_copy(r"lib\jxl.lib", "{lib_dir}"),
+ *cmds_cmake("jxl_threads"),
+ cmd_copy(r"lib\jxl_threads.lib", "{lib_dir}"),
+ cmd_mkdir(r"{inc_dir}\jxl"),
+ cmd_copy(r"lib\include\jxl\*.h", r"{inc_dir}\jxl"),
+ ],
+ },
"libimagequant": {
"url": "https://github.com/ImageOptim/libimagequant/archive/{V['LIBIMAGEQUANT']}.tar.gz",
"filename": f"libimagequant-{V['LIBIMAGEQUANT']}.tar.gz",
@@ -630,6 +664,8 @@ def build_dep_all(disabled: list[str], prefs: dict[str, str], verbose: bool) ->
print(f"Skipping disabled dependency {dep_name}")
continue
script = build_dep(dep_name, prefs, verbose)
+ if dep_name in ("highway", "libjxl"):
+ continue
if gha_groups:
lines.append(f"@echo ::group::Running {script}")
lines.append(rf'cmd.exe /c "{{build_dir}}\{script}"')