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}"')