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))