From 4629f81ff998e46a25b2e58fde9d7194e5476057 Mon Sep 17 00:00:00 2001 From: Nicolas Lemoine Date: Fri, 10 Jul 2026 09:11:09 +0200 Subject: [PATCH] Add JPEG XL (JXL) encoder Encodes JXL through libvips' jxlsave, mirroring the AVIF and HEIC encoders. libvips has a native lossless flag, but JXL has no separate lossless setting beyond maximum quality, so it is enabled at Q 100 like the sibling encoders. LoaderDetector now maps the jxl loader to Format::JXL so the driver reports JXL support when libvips is built with libjxl. The Dockerfile and CI workflow install libjxl-dev so the source-built libvips includes the JXL loader and saver. JXL support requires intervention/image 4.2.0, so the constraint is bumped to ^4.2. --- .github/workflows/run-tests.yml | 1 + Dockerfile | 1 + composer.json | 2 +- src/Encoders/JxlEncoder.php | 71 ++++++++++++++++++++++++++ src/LoaderDetector.php | 4 ++ tests/Unit/DriverTest.php | 8 +++ tests/Unit/Encoders/JxlEncoderTest.php | 24 +++++++++ 7 files changed, 110 insertions(+), 1 deletion(-) create mode 100644 src/Encoders/JxlEncoder.php create mode 100644 tests/Unit/Encoders/JxlEncoderTest.php diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index 736301a..d9df674 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -41,6 +41,7 @@ jobs: libheif-dev \ libheif-plugin-aomenc \ libheif-plugin-x265 \ + libjxl-dev \ libcgif-dev \ libimagequant-dev \ libopenjp2-7-dev \ diff --git a/Dockerfile b/Dockerfile index e0ae5e1..7779a45 100644 --- a/Dockerfile +++ b/Dockerfile @@ -21,6 +21,7 @@ RUN apt update \ libheif-dev \ libheif-plugin-aomenc \ libheif-plugin-x265 \ + libjxl-dev \ libcgif-dev \ libimagequant-dev \ libopenjp2-7-dev \ diff --git a/composer.json b/composer.json index ab3ac65..2bf175c 100644 --- a/composer.json +++ b/composer.json @@ -21,7 +21,7 @@ ], "require": { "php": "^8.3", - "intervention/image": "^4.0.2", + "intervention/image": "^4.2", "jcupitt/vips": "^2.6" }, "require-dev": { diff --git a/src/Encoders/JxlEncoder.php b/src/Encoders/JxlEncoder.php new file mode 100644 index 0000000..18678c7 --- /dev/null +++ b/src/Encoders/JxlEncoder.php @@ -0,0 +1,71 @@ +core()->native()->writeToBuffer('.jxl', $this->options()); + } catch (VipsException $e) { + throw new EncoderException('Failed to encode JXL image format', previous: $e); + } + + return new EncodedImage($result, MediaType::IMAGE_JXL->value); + } + + /** + * libvips' jxlsave has a native lossless flag. JXL has no separate lossless + * setting beyond maximum quality, so it is enabled at Q 100, matching the + * AVIF and HEIC encoders of this driver. + * + * @throws StateException + * @return array{lossless: bool, Q: int, keep?: int, strip?: bool} + */ + private function options(): array + { + $options = [ + 'lossless' => $this->quality === 100, + 'Q' => $this->quality, + ]; + + $strip = $this->strip || $this->driver()->config()->strip; + + if (VipsConfig::atLeast(8, 15)) { + $keepAll = VipsConfig::atLeast(8, 18) + ? ForeignKeep::ALL + : ForeignKeep::ALL & ~ForeignKeep::GAINMAP; + $options['keep'] = $strip ? ForeignKeep::ICC : $keepAll; + } else { + $options['strip'] = $strip; + } + + return $options; + } +} diff --git a/src/LoaderDetector.php b/src/LoaderDetector.php index c361640..b32df0b 100644 --- a/src/LoaderDetector.php +++ b/src/LoaderDetector.php @@ -117,6 +117,10 @@ public function formats(): array case 'jp2k': $formats[] = Format::JP2; break; + + case 'jxl': + $formats[] = Format::JXL; + break; } } diff --git a/tests/Unit/DriverTest.php b/tests/Unit/DriverTest.php index 10b7acd..3d79b08 100644 --- a/tests/Unit/DriverTest.php +++ b/tests/Unit/DriverTest.php @@ -168,6 +168,14 @@ public static function supportsDataProvider(): Generator yield [true, 'image/heic']; yield [true, 'image/heif']; + yield [true, Format::JXL]; + yield [true, MediaType::IMAGE_JXL]; + yield [true, MediaType::IMAGE_X_JXL]; + yield [true, FileExtension::JXL]; + yield [true, 'jxl']; + yield [true, 'image/jxl']; + yield [true, 'image/x-jxl']; + yield [false, 'tga']; yield [false, 'image/tga']; yield [false, 'image/x-targa']; diff --git a/tests/Unit/Encoders/JxlEncoderTest.php b/tests/Unit/Encoders/JxlEncoderTest.php new file mode 100644 index 0000000..790653d --- /dev/null +++ b/tests/Unit/Encoders/JxlEncoderTest.php @@ -0,0 +1,24 @@ +createImage(3, 2); + $encoder = new JxlEncoder(75); + $encoder->setDriver(new Driver()); + $result = $encoder->encode($image); + $this->assertMediaType(['image/jxl', 'image/x-jxl'], $result); + $this->assertEquals('image/jxl', $result->mimetype()); + } +}