Commit a5f9eb3fe for imagemagick.org
commit a5f9eb3fea94251b9f91b94b0871c520223562b6
Author: njardim <32397188+njardim@users.noreply.github.com>
Date: Fri Oct 2 00:17:16 2026 +0100
JXL: fix decoding of boxes/animations, metadata, ICC, CMYK, FLOAT16, tps and define errors (#8992)
* JXL: fix box/animation decoding, FLOAT16 encoding and silent define errors
- Reader: a container box with size <= 8 (empty, or unbounded last box)
ended the decode loop early, leaving a 0x0 image. Keep decoding.
- Reader: frames after the first lost the color encoding (colorspace,
gamma, chromaticity, rendering intent, ICC). Animated gray and
gray+alpha files failed to decode (output buffer sized for the first
frame's channel count) and linear frames were tagged sRGB.
- Writer: FLOAT16 pixels are exported as 32-bit floats but were declared
to libjxl as half floats, so such writes always failed. Use a float
buffer.
- Writer: warn (InvalidSetting) when libjxl rejects jxl:effort or
jxl:decoding-speed instead of silently using the default.
Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011Ni58cN4hNBMuQHb5ggJRd
* JXL: read compressed metadata boxes, keep ICC in lossy, fix CMYK and tps
- Reader: decompress Brotli (brob) boxes so Exif/XMP written by cjxl (its
default) are read. The profile grows as needed and is trimmed to the bytes
written; metadata boxes larger than 16 MiB (or the max profile size
policy) are skipped to bound decompression.
- Writer: keep the image ICC profile in lossy mode (it was only used for
lossless, so lossy files were tagged sRGB). The profile must match the
channel count (RGB or GRAY); if libjxl rejects it, fall back to the
built-in encoding with a warning. Linear images keep the built-in
encoding in lossy mode, as before.
- Writer: convert CMYK to sRGB; it was exported as RGB with wrong colors.
- Animation: keep tps_numerator as ticks_per_second and scale the frame
duration by tps_denominator, instead of truncating the ratio (24000/1001
gave 23, 1/2 gave 0). Clamp the written duration to 32 bits.
Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011Ni58cN4hNBMuQHb5ggJRd
* JXL: address review findings
- Writer: drop the unreachable FLOAT16 branches; half floats are promoted to
32-bit floats before the basic info and the buffer size are computed.
- Writer: also require the ICC "acsp" signature before handing a profile to
libjxl.
- Reader: release the box buffer before the profiles are attached to a
frame, so a box in progress can never point at a profile owned by an image.
Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011Ni58cN4hNBMuQHb5ggJRd
---------
Co-authored-by: Claude <noreply@anthropic.com>
diff --git a/coders/jxl.c b/coders/jxl.c
index 70e2e2522..066f994f6 100644
--- a/coders/jxl.c
+++ b/coders/jxl.c
@@ -297,12 +297,70 @@ static inline void JXLInitImage(Image *image,JxlBasicInfo *basic_info)
{
if ((basic_info->animation.tps_numerator > 0) &&
(basic_info->animation.tps_denominator > 0))
- image->ticks_per_second=basic_info->animation.tps_numerator /
- basic_info->animation.tps_denominator;
+ image->ticks_per_second=(ssize_t) basic_info->animation.tps_numerator;
image->iterations=basic_info->animation.num_loops;
}
}
+static inline size_t JXLGetDelay(const JxlBasicInfo *basic_info,
+ const uint32_t duration)
+{
+ uint64_t
+ delay;
+
+ /*
+ The image ticks per second is tps_numerator, the duration is in units of
+ tps_denominator ticks.
+ */
+ delay=(uint64_t) duration;
+ if ((basic_info->have_animation == JXL_TRUE) &&
+ (basic_info->animation.tps_numerator > 0) &&
+ (basic_info->animation.tps_denominator > 0))
+ delay*=(uint64_t) basic_info->animation.tps_denominator;
+ return((size_t) MagickMin(delay,(uint64_t) MAGICK_SSIZE_MAX));
+}
+
+static inline size_t JXLGetMaxBoxSize(void)
+{
+ return(MagickMin(GetMaxProfileSize(),(size_t) 16*1024*1024));
+}
+
+static inline void JXLReleaseBoxBuffer(JxlDecoder *jxl_info,
+ StringInfo *profile)
+{
+ size_t
+ length,
+ remaining;
+
+ /*
+ Release the box buffer and trim the profile to the bytes written.
+ */
+ remaining=JxlDecoderReleaseBoxBuffer(jxl_info);
+ if (profile != (StringInfo *) NULL)
+ {
+ length=GetStringInfoLength(profile);
+ if (remaining <= length)
+ SetStringInfoLength(profile,length-remaining);
+ (void) memset(GetStringInfoDatum(profile)+GetStringInfoLength(profile),0,
+ MagickPathExtent);
+ }
+}
+
+static inline void JXLCopyColorInfo(Image *image,const Image *source,
+ ExceptionInfo *exception)
+{
+ const StringInfo
+ *profile;
+
+ image->colorspace=source->colorspace;
+ image->gamma=source->gamma;
+ image->chromaticity=source->chromaticity;
+ image->rendering_intent=source->rendering_intent;
+ profile=GetImageProfile(source,"icc");
+ if (profile != (const StringInfo *) NULL)
+ (void) SetImageProfile(image,"icc",profile,exception);
+}
+
static inline MagickBooleanType JXLPatchExifProfile(StringInfo *exif_profile)
{
size_t
@@ -401,6 +459,7 @@ static Image *ReadJXLImage(const ImageInfo *image_info,
input_size;
StringInfo
+ *box_profile = (StringInfo *) NULL,
*exif_profile = (StringInfo *) NULL,
*xmp_profile = (StringInfo *) NULL;
@@ -439,6 +498,7 @@ static Image *ReadJXLImage(const ImageInfo *image_info,
ThrowReaderException(CoderError,"MemoryAllocationFailed");
(void) JxlDecoderSetKeepOrientation(jxl_info,JXL_TRUE);
(void) JxlDecoderSetUnpremultiplyAlpha(jxl_info,JXL_TRUE);
+ (void) JxlDecoderSetDecompressBoxes(jxl_info,JXL_TRUE);
events_wanted=(JxlDecoderStatus) (JXL_DEC_BASIC_INFO | JXL_DEC_BOX |
JXL_DEC_FRAME);
if (image_info->ping == MagickFalse)
@@ -664,6 +724,8 @@ static Image *ReadJXLImage(const ImageInfo *image_info,
if (image_count++ != 0)
{
+ JXLReleaseBoxBuffer(jxl_info,box_profile);
+ box_profile=(StringInfo *) NULL;
JXLAddProfilesToImage(image,&exif_profile,&xmp_profile,exception);
/*
Allocate next image structure.
@@ -673,6 +735,7 @@ static Image *ReadJXLImage(const ImageInfo *image_info,
break;
image=SyncNextImageInList(image);
JXLInitImage(image,&basic_info);
+ JXLCopyColorInfo(image,image->previous,exception);
status=SetImageExtent(image,image->columns,image->rows,exception);
if (status == MagickFalse)
{
@@ -682,7 +745,7 @@ static Image *ReadJXLImage(const ImageInfo *image_info,
}
(void) memset(&frame_header,0,sizeof(frame_header));
if (JxlDecoderGetFrameHeader(jxl_info,&frame_header) == JXL_DEC_SUCCESS)
- image->delay=(size_t) frame_header.duration;
+ image->delay=JXLGetDelay(&basic_info,frame_header.duration);
if ((basic_info.have_animation == JXL_TRUE) &&
(basic_info.alpha_bits != 0))
image->dispose=BackgroundDispose;
@@ -762,13 +825,22 @@ static Image *ReadJXLImage(const ImageInfo *image_info,
uint64_t
size;
- (void) JxlDecoderReleaseBoxBuffer(jxl_info);
- jxl_status=JxlDecoderGetBoxType(jxl_info,type,JXL_FALSE);
+ JXLReleaseBoxBuffer(jxl_info,box_profile);
+ box_profile=(StringInfo *) NULL;
+ jxl_status=JxlDecoderGetBoxType(jxl_info,type,JXL_TRUE);
if (jxl_status != JXL_DEC_SUCCESS)
break;
jxl_status=JxlDecoderGetBoxSizeRaw(jxl_info,&size);
- if ((jxl_status != JXL_DEC_SUCCESS) || (size <= 8))
+ if (jxl_status != JXL_DEC_SUCCESS)
break;
+ if ((size <= 8) || ((size-8) > (uint64_t) JXLGetMaxBoxSize()))
+ {
+ /*
+ Box without payload, unbounded or too large, keep decoding.
+ */
+ jxl_status=JXL_DEC_BOX;
+ break;
+ }
size-=8;
if (LocaleNCompare(type,"Exif",sizeof(type)) == 0)
{
@@ -780,8 +852,11 @@ static Image *ReadJXLImage(const ImageInfo *image_info,
exif_profile=AcquireProfileStringInfo("exif",(size_t) size,
exception);
if (exif_profile != (StringInfo *) NULL)
- jxl_status=JxlDecoderSetBoxBuffer(jxl_info,
- GetStringInfoDatum(exif_profile),(size_t) size);
+ {
+ box_profile=exif_profile;
+ jxl_status=JxlDecoderSetBoxBuffer(jxl_info,
+ GetStringInfoDatum(exif_profile),(size_t) size);
+ }
}
}
if (LocaleNCompare(type,"xml ",sizeof(type)) == 0)
@@ -794,14 +869,57 @@ static Image *ReadJXLImage(const ImageInfo *image_info,
xmp_profile=AcquireProfileStringInfo("xmp",(size_t) size,
exception);
if (xmp_profile != (StringInfo *) NULL)
- jxl_status=JxlDecoderSetBoxBuffer(jxl_info,
- GetStringInfoDatum(xmp_profile),(size_t) size);
+ {
+ box_profile=xmp_profile;
+ jxl_status=JxlDecoderSetBoxBuffer(jxl_info,
+ GetStringInfoDatum(xmp_profile),(size_t) size);
+ }
}
}
if (jxl_status == JXL_DEC_SUCCESS)
jxl_status=JXL_DEC_BOX;
break;
}
+ case JXL_DEC_BOX_NEED_MORE_OUTPUT:
+ {
+ size_t
+ length,
+ remaining;
+
+ /*
+ The decompressed box is larger than the raw box, grow the profile.
+ */
+ if (box_profile == (StringInfo *) NULL)
+ {
+ jxl_status=JXL_DEC_ERROR;
+ break;
+ }
+ length=GetStringInfoLength(box_profile);
+ remaining=JxlDecoderReleaseBoxBuffer(jxl_info);
+ if (remaining > length)
+ {
+ jxl_status=JXL_DEC_ERROR;
+ break;
+ }
+ if (length > (JXLGetMaxBoxSize()/2))
+ {
+ /*
+ Too large, skip the remainder of the box.
+ */
+ if (box_profile == exif_profile)
+ exif_profile=DestroyStringInfo(exif_profile);
+ else
+ xmp_profile=DestroyStringInfo(xmp_profile);
+ box_profile=(StringInfo *) NULL;
+ break;
+ }
+ SetStringInfoLength(box_profile,length*2);
+ jxl_status=JxlDecoderSetBoxBuffer(jxl_info,GetStringInfoDatum(
+ box_profile)+(length-remaining),length*2-(length-remaining));
+ if (jxl_status == JXL_DEC_SUCCESS)
+ jxl_status=JXL_DEC_BOX_NEED_MORE_OUTPUT;
+ break;
+ }
case JXL_DEC_SUCCESS:
case JXL_DEC_ERROR:
break;
@@ -814,7 +932,7 @@ static Image *ReadJXLImage(const ImageInfo *image_info,
}
}
}
- (void) JxlDecoderReleaseBoxBuffer(jxl_info);
+ JXLReleaseBoxBuffer(jxl_info,box_profile);
JXLAddProfilesToImage(image,&exif_profile,&xmp_profile,exception);
output_buffer=(unsigned char *) RelinquishMagickMemory(output_buffer);
pixels=(unsigned char *) RelinquishMagickMemory(pixels);
@@ -972,8 +1090,27 @@ static inline MagickBooleanType JXLSameFrameType(const Image *image,
return(MagickTrue);
}
+static inline MagickBooleanType JXLMatchICCProfile(const Image *image,
+ const StringInfo *icc_profile)
+{
+ const unsigned char
+ *datum;
+
+ /*
+ The data color space of the profile must match the color channels.
+ */
+ if (GetStringInfoLength(icc_profile) < 128)
+ return(MagickFalse);
+ datum=GetStringInfoDatum(icc_profile);
+ if (memcmp(datum+36,"acsp",4) != 0)
+ return(MagickFalse);
+ if (IsGrayColorspace(image->colorspace) != MagickFalse)
+ return(memcmp(datum+16,"GRAY",4) == 0 ? MagickTrue : MagickFalse);
+ return(memcmp(datum+16,"RGB ",4) == 0 ? MagickTrue : MagickFalse);
+}
+
static JxlEncoderStatus JXLWriteMetadata(const Image *image,
- JxlEncoder *jxl_info, const StringInfo *icc_profile)
+ JxlEncoder *jxl_info, const StringInfo *icc_profile,ExceptionInfo *exception)
{
JxlColorEncoding
color_encoding;
@@ -981,11 +1118,15 @@ static JxlEncoderStatus JXLWriteMetadata(const Image *image,
JxlEncoderStatus
jxl_status;
- if (icc_profile != (StringInfo *) NULL)
+ if ((icc_profile != (StringInfo *) NULL) &&
+ (JXLMatchICCProfile(image,icc_profile) != MagickFalse))
{
jxl_status=JxlEncoderSetICCProfile(jxl_info,(const uint8_t *)
GetStringInfoDatum(icc_profile),GetStringInfoLength(icc_profile));
- return(jxl_status);
+ if (jxl_status == JXL_ENC_SUCCESS)
+ return(jxl_status);
+ (void) ThrowMagickException(exception,GetMagickModule(),CoderWarning,
+ "UnableToCopyProfile","`%s'",image->filename);
}
(void) memset(&color_encoding,0,sizeof(color_encoding));
color_encoding.color_space=JXL_COLOR_SPACE_RGB;
@@ -1067,8 +1208,7 @@ static MagickBooleanType WriteJXLImage(const ImageInfo *image_info,Image *image,
status=OpenBlob(image_info,image,WriteBinaryBlobMode,exception);
if (status == MagickFalse)
return(status);
- if ((IssRGBCompatibleColorspace(image->colorspace) == MagickFalse) &&
- (IsCMYKColorspace(image->colorspace) == MagickFalse))
+ if (IssRGBCompatibleColorspace(image->colorspace) == MagickFalse)
(void) TransformImageColorspace(image,sRGBColorspace,exception);
if ((image_info->adjoin != MagickFalse) &&
(GetNextImageInList(image) != (Image *) NULL))
@@ -1129,6 +1269,13 @@ static MagickBooleanType WriteJXLImage(const ImageInfo *image_info,Image *image,
return(MagickFalse);
}
JXLSetFormat(image,&pixel_format,exception);
+ if (pixel_format.data_type == JXL_TYPE_FLOAT16)
+ {
+ /*
+ The pixels are exported as 32-bit floats, not as half floats.
+ */
+ pixel_format.data_type=JXL_TYPE_FLOAT;
+ }
option=GetImageOption(image_info,"jxl:distance");
if (option != (const char *) NULL)
{
@@ -1171,12 +1318,6 @@ static MagickBooleanType WriteJXLImage(const ImageInfo *image_info,Image *image,
basic_info.bits_per_sample=32;
basic_info.exponent_bits_per_sample=8;
}
- else
- if (pixel_format.data_type == JXL_TYPE_FLOAT16)
- {
- basic_info.bits_per_sample=16;
- basic_info.exponent_bits_per_sample=8;
- }
if (IsGrayColorspace(image->colorspace) != MagickFalse)
basic_info.num_color_channels=1;
if ((image->alpha_trait & BlendPixelTrait) != 0)
@@ -1186,10 +1327,9 @@ static MagickBooleanType WriteJXLImage(const ImageInfo *image_info,Image *image,
basic_info.num_extra_channels=1;
}
if (distance == 0.0)
- {
- basic_info.uses_original_profile=JXL_TRUE;
- icc_profile=GetImageProfile(image,"icc");
- }
+ basic_info.uses_original_profile=JXL_TRUE;
+ if ((distance == 0.0) || (IsRGBColorspace(image->colorspace) == MagickFalse))
+ icc_profile=GetImageProfile(image,"icc");
if ((image_info->adjoin != MagickFalse) &&
(GetNextImageInList(image) != (Image *) NULL))
{
@@ -1225,12 +1365,22 @@ static MagickBooleanType WriteJXLImage(const ImageInfo *image_info,Image *image,
(void) JxlEncoderSetFrameDistance(frame_settings,(float) distance);
option=GetImageOption(image_info,"jxl:effort");
if (option != (const char *) NULL)
- (void) JxlEncoderFrameSettingsSetOption(frame_settings,
- JXL_ENC_FRAME_SETTING_EFFORT,StringToInteger(option));
+ {
+ if (JxlEncoderFrameSettingsSetOption(frame_settings,
+ JXL_ENC_FRAME_SETTING_EFFORT,StringToInteger(option)) !=
+ JXL_ENC_SUCCESS)
+ (void) ThrowMagickException(exception,GetMagickModule(),OptionWarning,
+ "InvalidSetting","`%s'",option);
+ }
option=GetImageOption(image_info,"jxl:decoding-speed");
if (option != (const char *) NULL)
- (void) JxlEncoderFrameSettingsSetOption(frame_settings,
- JXL_ENC_FRAME_SETTING_DECODING_SPEED,StringToInteger(option));
+ {
+ if (JxlEncoderFrameSettingsSetOption(frame_settings,
+ JXL_ENC_FRAME_SETTING_DECODING_SPEED,StringToInteger(option)) !=
+ JXL_ENC_SUCCESS)
+ (void) ThrowMagickException(exception,GetMagickModule(),OptionWarning,
+ "InvalidSetting","`%s'",option);
+ }
exif_profile=GetImageProfile(image,"exif");
xmp_profile=GetImageProfile(image,"xmp");
if ((exif_profile != (StringInfo *) NULL) ||
@@ -1265,7 +1415,7 @@ static MagickBooleanType WriteJXLImage(const ImageInfo *image_info,Image *image,
}
(void) JxlEncoderCloseBoxes(jxl_info);
}
- jxl_status=JXLWriteMetadata(image,jxl_info,icc_profile);
+ jxl_status=JXLWriteMetadata(image,jxl_info,icc_profile,exception);
if (jxl_status != JXL_ENC_SUCCESS)
{
JxlThreadParallelRunnerDestroy(runner);
@@ -1276,8 +1426,7 @@ static MagickBooleanType WriteJXLImage(const ImageInfo *image_info,Image *image,
Write image as a JXL stream.
*/
sample_size=sizeof(char);
- if ((pixel_format.data_type == JXL_TYPE_FLOAT) ||
- (pixel_format.data_type == JXL_TYPE_FLOAT16))
+ if (pixel_format.data_type == JXL_TYPE_FLOAT)
sample_size=sizeof(float);
else
if (pixel_format.data_type == JXL_TYPE_UINT16)
@@ -1317,7 +1466,8 @@ static MagickBooleanType WriteJXLImage(const ImageInfo *image_info,Image *image,
JxlBlendInfo
alpha_blend_info;
- frame_header.duration=(uint32_t) image->delay;
+ frame_header.duration=(uint32_t) MagickMin(image->delay,
+ (size_t) UINT32_MAX);
if ((image->previous == (Image *) NULL) ||
(image->previous->dispose == BackgroundDispose) ||
(image->previous->dispose == PreviousDispose))