From 75f7fe77c776c1d9e30922d43401f096171fb8db Mon Sep 17 00:00:00 2001 From: Nicolas Lemoine Date: Wed, 8 Jul 2026 16:11:59 +0200 Subject: [PATCH 1/2] Add JPEG XL (JXL) encoder JXL is wired through the Format, MediaType and FileExtension enums and encoded with Imagick, mirroring the HEIC encoder. GD has no JXL support, so the GD driver reports it as unsupported and throws NotSupportedException. The encoder exposes quality, strip and a lossless flag. Lossless maps to a compression quality of 100, which is how ImageMagick's coder switches libjxl to lossless (distance 0). Adding image/jxl (and image/x-jxl, which libmagic reports for JXL blobs on some builds) to the media type enum also lets the Imagick binary decoder read and round-trip .jxl input. CI runs on ubuntu-24.04 because libjxl-dev is not packaged for 22.04. That also needs the libheif encoder plugins (x265, aomenc), since 24.04's libheif moved its encoders out of libheif-dev and AVIF/HEIC would otherwise fail to encode. ImageMagick 6.9.13 does not build the JXL coder even with libjxl, so the JXL tests skip when Imagick::queryFormats('JXL') is empty and run on the 7.1.2 cells. --- .github/workflows/run-tests.yml | 8 +-- src/Drivers/Imagick/Encoders/JxlEncoder.php | 55 +++++++++++++++++++ src/Encoders/JxlEncoder.php | 28 ++++++++++ src/FileExtension.php | 2 + src/Format.php | 3 + src/MediaType.php | 4 ++ tests/Unit/Drivers/Gd/DriverTest.php | 8 +++ tests/Unit/Drivers/Imagick/DriverTest.php | 16 ++++++ .../Imagick/Encoders/JxlEncoderTest.php | 44 +++++++++++++++ tests/Unit/FileExtensionTest.php | 7 +++ tests/Unit/FormatTest.php | 27 +++++++++ tests/Unit/MediaTypeTest.php | 11 ++++ 12 files changed, 209 insertions(+), 4 deletions(-) create mode 100644 src/Drivers/Imagick/Encoders/JxlEncoder.php create mode 100644 src/Encoders/JxlEncoder.php create mode 100644 tests/Unit/Drivers/Imagick/Encoders/JxlEncoderTest.php diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index 69f1be05f..9173050b8 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -7,7 +7,7 @@ permissions: jobs: run: - runs-on: ubuntu-22.04 + runs-on: ubuntu-24.04 strategy: fail-fast: false matrix: @@ -35,15 +35,15 @@ jobs: sudo apt remove -y imagemagick imagemagick-6-common libmagic-dev sudo apt update --allow-releaseinfo-change sudo apt update - sudo apt install -y libjpeg-dev libgif-dev libtiff-dev libpng-dev libwebp-dev libavif-dev libheif-dev libraqm-dev libmagickwand-dev + sudo apt install -y libjpeg-dev libgif-dev libtiff-dev libpng-dev libwebp-dev libavif-dev libheif-dev libheif-plugin-x265 libheif-plugin-aomenc libheif-plugin-aomdec libheif-plugin-libde265 libjxl-dev libraqm-dev libmagickwand-dev - name: Cache ImageMagick uses: actions/cache@v6 id: cache-imagemagick with: path: /home/runner/im/imagemagick-${{ matrix.imagemagick }} - key: ${{ runner.os }}-ImageMagick-${{ matrix.imagemagick }}-${{ hashFiles('**/composer.json') }} - restore-keys: ${{ runner.os }}-ImageMagick-${{ matrix.imagemagick }}- + key: ${{ runner.os }}-ImageMagick-jxl1-${{ matrix.imagemagick }}-${{ hashFiles('**/composer.json') }} + restore-keys: ${{ runner.os }}-ImageMagick-jxl1-${{ matrix.imagemagick }}- - name: Check ImageMagick cache exists uses: andstor/file-existence-action@v3 diff --git a/src/Drivers/Imagick/Encoders/JxlEncoder.php b/src/Drivers/Imagick/Encoders/JxlEncoder.php new file mode 100644 index 000000000..42eece176 --- /dev/null +++ b/src/Drivers/Imagick/Encoders/JxlEncoder.php @@ -0,0 +1,55 @@ +strip || (is_null($this->strip) && $this->driver()->config()->strip)) { + $image->modify(new StripMetaModifier()); + } + + // In ImageMagick, JXL lossless encoding is triggered by a compression quality of 100. + $quality = $this->lossless ? 100 : $this->quality; + + try { + $imagick = clone $image->core()->native(); + $imagick->setFormat($format); + $imagick->setImageFormat($format); + $imagick->setCompressionQuality($quality); + $imagick->setImageCompressionQuality($quality); + + $result = new EncodedImage($imagick->getImagesBlob(), 'image/jxl'); + $imagick->clear(); + + return $result; + } catch (ImagickException | ImageException $e) { + throw new EncoderException('Failed to encode jxl format', previous: $e); + } + } +} diff --git a/src/Encoders/JxlEncoder.php b/src/Encoders/JxlEncoder.php new file mode 100644 index 000000000..fbcbeb0f3 --- /dev/null +++ b/src/Encoders/JxlEncoder.php @@ -0,0 +1,28 @@ + 100) { + throw new InvalidArgumentException('Quality must be in range 0 to 100'); + } + } +} diff --git a/src/FileExtension.php b/src/FileExtension.php index 622b263a4..a41c7d4fb 100644 --- a/src/FileExtension.php +++ b/src/FileExtension.php @@ -32,6 +32,7 @@ enum FileExtension: string case JPX = 'jpx'; case HEIC = 'heic'; case HEIF = 'heif'; + case JXL = 'jxl'; case ICO = 'ico'; /** @@ -120,6 +121,7 @@ public function format(): Format self::JPX => Format::JP2, self::HEIC, self::HEIF => Format::HEIC, + self::JXL => Format::JXL, self::ICO => Format::ICO, }; } diff --git a/src/Format.php b/src/Format.php index ba8a90907..8ff1d1aa6 100644 --- a/src/Format.php +++ b/src/Format.php @@ -12,6 +12,7 @@ use Intervention\Image\Encoders\IcoEncoder; use Intervention\Image\Encoders\Jpeg2000Encoder; use Intervention\Image\Encoders\JpegEncoder; +use Intervention\Image\Encoders\JxlEncoder; use Intervention\Image\Encoders\PngEncoder; use Intervention\Image\Encoders\TiffEncoder; use Intervention\Image\Encoders\WebpEncoder; @@ -30,6 +31,7 @@ enum Format case ICO; case JP2; case JPEG; + case JXL; case PNG; case TIFF; case WEBP; @@ -157,6 +159,7 @@ public function encoder(mixed ...$options): EncoderInterface self::ICO => IcoEncoder::class, self::JP2 => Jpeg2000Encoder::class, self::JPEG => JpegEncoder::class, + self::JXL => JxlEncoder::class, self::PNG => PngEncoder::class, self::TIFF => TiffEncoder::class, self::WEBP => WebpEncoder::class, diff --git a/src/MediaType.php b/src/MediaType.php index 56a090fa8..e4c56d184 100644 --- a/src/MediaType.php +++ b/src/MediaType.php @@ -38,6 +38,8 @@ enum MediaType: string case IMAGE_HEIC = 'image/heic'; case IMAGE_X_HEIC = 'image/x-heic'; case IMAGE_HEIF = 'image/heif'; + case IMAGE_JXL = 'image/jxl'; + case IMAGE_X_JXL = 'image/x-jxl'; case IMAGE_X_ICON = 'image/x-icon'; case IMAGE_VND_MICROSOFT_ICON = 'image/vnd.microsoft.icon'; @@ -133,6 +135,8 @@ public function format(): Format self::IMAGE_HEIF, self::IMAGE_HEIC, self::IMAGE_X_HEIC => Format::HEIC, + self::IMAGE_JXL, + self::IMAGE_X_JXL => Format::JXL, self::IMAGE_X_ICON, self::IMAGE_VND_MICROSOFT_ICON => Format::ICO, }; diff --git a/tests/Unit/Drivers/Gd/DriverTest.php b/tests/Unit/Drivers/Gd/DriverTest.php index f4d806684..74fcc91a0 100644 --- a/tests/Unit/Drivers/Gd/DriverTest.php +++ b/tests/Unit/Drivers/Gd/DriverTest.php @@ -210,6 +210,14 @@ public static function supportsDataProvider(): Generator yield [false, 'image/heic']; yield [false, 'image/heif']; + yield [false, Format::JXL]; + yield [false, MediaType::IMAGE_JXL]; + yield [false, MediaType::IMAGE_X_JXL]; + yield [false, FileExtension::JXL]; + yield [false, 'jxl']; + yield [false, 'image/jxl']; + yield [false, 'image/x-jxl']; + yield [false, 'tga']; yield [false, 'image/tga']; yield [false, 'image/x-targa']; diff --git a/tests/Unit/Drivers/Imagick/DriverTest.php b/tests/Unit/Drivers/Imagick/DriverTest.php index 60450ef41..082037c68 100644 --- a/tests/Unit/Drivers/Imagick/DriverTest.php +++ b/tests/Unit/Drivers/Imagick/DriverTest.php @@ -5,6 +5,7 @@ namespace Intervention\Image\Tests\Unit\Drivers\Imagick; use Generator; +use Imagick; use Intervention\Image\Analyzers\WidthAnalyzer as GenericWidthAnalyzer; use Intervention\Image\Decoders\FilePathImageDecoder as GenericFilePathImageDecoder; use Intervention\Image\Encoders\PngEncoder as GenericPngEncoder; @@ -106,6 +107,21 @@ public function testSupports(bool $result, mixed $identifier): void $this->assertEquals($result, $this->driver->supports($identifier)); } + public function testSupportsJxl(): void + { + if (Imagick::queryFormats('JXL') === []) { + $this->markTestSkipped('ImageMagick was built without JXL (libjxl) support'); + } + + $this->assertTrue($this->driver->supports(Format::JXL)); + $this->assertTrue($this->driver->supports(MediaType::IMAGE_JXL)); + $this->assertTrue($this->driver->supports(MediaType::IMAGE_X_JXL)); + $this->assertTrue($this->driver->supports(FileExtension::JXL)); + $this->assertTrue($this->driver->supports('jxl')); + $this->assertTrue($this->driver->supports('image/jxl')); + $this->assertTrue($this->driver->supports('image/x-jxl')); + } + public static function supportsDataProvider(): Generator { yield [true, Format::JPEG]; diff --git a/tests/Unit/Drivers/Imagick/Encoders/JxlEncoderTest.php b/tests/Unit/Drivers/Imagick/Encoders/JxlEncoderTest.php new file mode 100644 index 000000000..7037f296b --- /dev/null +++ b/tests/Unit/Drivers/Imagick/Encoders/JxlEncoderTest.php @@ -0,0 +1,44 @@ +markTestSkipped('ImageMagick was built without JXL (libjxl) support'); + } + } + + public function testEncode(): void + { + $image = $this->createTestImage(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()); + } + + public function testEncodeLossless(): void + { + $image = $this->createTestImage(3, 2); + $encoder = new JxlEncoder(lossless: true); + $encoder->setDriver(new Driver()); + $result = $encoder->encode($image); + $this->assertMediaType(['image/jxl', 'image/x-jxl'], $result); + $this->assertEquals('image/jxl', $result->mimetype()); + } +} diff --git a/tests/Unit/FileExtensionTest.php b/tests/Unit/FileExtensionTest.php index 742c28fff..a8ffe6c90 100644 --- a/tests/Unit/FileExtensionTest.php +++ b/tests/Unit/FileExtensionTest.php @@ -133,6 +133,12 @@ public function testFormatHeic(): void $this->assertEquals(Format::HEIC, $ext->format()); } + public function testFormatJxl(): void + { + $ext = FileExtension::JXL; + $this->assertEquals(Format::JXL, $ext->format()); + } + public function testFormatIco(): void { $ext = FileExtension::ICO; @@ -168,6 +174,7 @@ public static function mediaTypesDataProvider(): Generator yield [FileExtension::JP2, 4, MediaType::IMAGE_JP2]; yield [FileExtension::HEIC, 3, MediaType::IMAGE_HEIC]; yield [FileExtension::HEIF, 3, MediaType::IMAGE_HEIC]; + yield [FileExtension::JXL, 2, MediaType::IMAGE_JXL]; yield [FileExtension::JPF, 4, MediaType::IMAGE_JP2]; yield [FileExtension::JPX, 4, MediaType::IMAGE_JP2]; yield [FileExtension::JPC, 4, MediaType::IMAGE_JP2]; diff --git a/tests/Unit/FormatTest.php b/tests/Unit/FormatTest.php index c0fe32330..0cfe9161f 100644 --- a/tests/Unit/FormatTest.php +++ b/tests/Unit/FormatTest.php @@ -11,6 +11,7 @@ use Intervention\Image\Encoders\IcoEncoder; use Intervention\Image\Encoders\Jpeg2000Encoder; use Intervention\Image\Encoders\JpegEncoder; +use Intervention\Image\Encoders\JxlEncoder; use Intervention\Image\Encoders\PngEncoder; use Intervention\Image\Encoders\TiffEncoder; use Intervention\Image\Encoders\WebpEncoder; @@ -147,6 +148,16 @@ public function testMediaTypesHeic(): void $this->assertEquals(MediaType::IMAGE_HEIC, $format->mediaType()); } + public function testMediaTypesJxl(): void + { + $format = Format::JXL; + $mediaTypes = $format->mediaTypes(); + $this->assertIsArray($mediaTypes); + $this->assertCount(2, $mediaTypes); + + $this->assertEquals(MediaType::IMAGE_JXL, $format->mediaType()); + } + public function testEncoderJpeg(): void { $format = Format::JPEG; @@ -201,6 +212,12 @@ public function testEncoderHeic(): void $this->assertInstanceOf(HeicEncoder::class, $format->encoder()); } + public function testEncoderJxl(): void + { + $format = Format::JXL; + $this->assertInstanceOf(JxlEncoder::class, $format->encoder()); + } + public function testFileExtensionsJpeg(): void { $format = Format::JPEG; @@ -291,6 +308,16 @@ public function testFileExtensionsHeic(): void $this->assertEquals(FileExtension::HEIC, $format->fileExtension()); } + public function testFileExtensionsJxl(): void + { + $format = Format::JXL; + $extensions = $format->fileExtensions(); + $this->assertIsArray($extensions); + $this->assertCount(1, $extensions); + + $this->assertEquals(FileExtension::JXL, $format->fileExtension()); + } + public function testMediaTypesIco(): void { $format = Format::ICO; diff --git a/tests/Unit/MediaTypeTest.php b/tests/Unit/MediaTypeTest.php index 1e041a026..308ff7229 100644 --- a/tests/Unit/MediaTypeTest.php +++ b/tests/Unit/MediaTypeTest.php @@ -176,6 +176,15 @@ public function testFormatHeic(): void $this->assertEquals(Format::HEIC, $mime->format()); } + public function testFormatJxl(): void + { + $mime = MediaType::IMAGE_JXL; + $this->assertEquals(Format::JXL, $mime->format()); + + $mime = MediaType::IMAGE_X_JXL; + $this->assertEquals(Format::JXL, $mime->format()); + } + #[DataProvider('fileExtensionsDataProvider')] public function testFileExtensions( MediaType $mediaType, @@ -215,6 +224,8 @@ public static function fileExtensionsDataProvider(): Generator yield [MediaType::IMAGE_HEIC, 2, FileExtension::HEIC]; yield [MediaType::IMAGE_X_HEIC, 2, FileExtension::HEIC]; yield [MediaType::IMAGE_HEIF, 2, FileExtension::HEIC]; + yield [MediaType::IMAGE_JXL, 1, FileExtension::JXL]; + yield [MediaType::IMAGE_X_JXL, 1, FileExtension::JXL]; yield [MediaType::IMAGE_X_JP2_CODESTREAM, 9, FileExtension::JP2]; yield [MediaType::IMAGE_X_ICON, 1, FileExtension::ICO]; yield [MediaType::IMAGE_VND_MICROSOFT_ICON, 1, FileExtension::ICO]; From ef2a63bd5da2f195bd3145b7b09025fe5af2d27e Mon Sep 17 00:00:00 2001 From: Nicolas Lemoine Date: Wed, 8 Jul 2026 17:31:43 +0200 Subject: [PATCH 2/2] Remove lossless option from JXL encoder JXL has no independent lossless setting in ImageMagick. Lossless is just a compression quality of 100 (see coders/jxl.c), so a separate lossless flag was redundant and wrongly implied quality and lossless could be set independently. Dropping it also makes JxlEncoder match AvifEncoder and HeicEncoder. --- src/Drivers/Imagick/Encoders/JxlEncoder.php | 7 ++----- src/Encoders/JxlEncoder.php | 2 -- tests/Unit/Drivers/Imagick/Encoders/JxlEncoderTest.php | 10 ---------- 3 files changed, 2 insertions(+), 17 deletions(-) diff --git a/src/Drivers/Imagick/Encoders/JxlEncoder.php b/src/Drivers/Imagick/Encoders/JxlEncoder.php index 42eece176..5bbeb588f 100644 --- a/src/Drivers/Imagick/Encoders/JxlEncoder.php +++ b/src/Drivers/Imagick/Encoders/JxlEncoder.php @@ -34,15 +34,12 @@ public function encode(ImageInterface $image): EncodedImageInterface $image->modify(new StripMetaModifier()); } - // In ImageMagick, JXL lossless encoding is triggered by a compression quality of 100. - $quality = $this->lossless ? 100 : $this->quality; - try { $imagick = clone $image->core()->native(); $imagick->setFormat($format); $imagick->setImageFormat($format); - $imagick->setCompressionQuality($quality); - $imagick->setImageCompressionQuality($quality); + $imagick->setCompressionQuality($this->quality); + $imagick->setImageCompressionQuality($this->quality); $result = new EncodedImage($imagick->getImagesBlob(), 'image/jxl'); $imagick->clear(); diff --git a/src/Encoders/JxlEncoder.php b/src/Encoders/JxlEncoder.php index fbcbeb0f3..8b34937c4 100644 --- a/src/Encoders/JxlEncoder.php +++ b/src/Encoders/JxlEncoder.php @@ -13,13 +13,11 @@ class JxlEncoder extends SpecializableEncoder * Create new encoder object. * * @param null|bool $strip Strip EXIF metadata - * @param bool $lossless Encode losslessly, ignoring the quality value * @throws InvalidArgumentException */ public function __construct( public int $quality = self::DEFAULT_QUALITY, public ?bool $strip = null, - public bool $lossless = false, ) { if ($quality < 0 || $quality > 100) { throw new InvalidArgumentException('Quality must be in range 0 to 100'); diff --git a/tests/Unit/Drivers/Imagick/Encoders/JxlEncoderTest.php b/tests/Unit/Drivers/Imagick/Encoders/JxlEncoderTest.php index 7037f296b..82280a6ee 100644 --- a/tests/Unit/Drivers/Imagick/Encoders/JxlEncoderTest.php +++ b/tests/Unit/Drivers/Imagick/Encoders/JxlEncoderTest.php @@ -31,14 +31,4 @@ public function testEncode(): void $this->assertMediaType(['image/jxl', 'image/x-jxl'], $result); $this->assertEquals('image/jxl', $result->mimetype()); } - - public function testEncodeLossless(): void - { - $image = $this->createTestImage(3, 2); - $encoder = new JxlEncoder(lossless: true); - $encoder->setDriver(new Driver()); - $result = $encoder->encode($image); - $this->assertMediaType(['image/jxl', 'image/x-jxl'], $result); - $this->assertEquals('image/jxl', $result->mimetype()); - } }