From 7781fbd879e29b2c7a79438eb4937ac646ea91b7 Mon Sep 17 00:00:00 2001 From: James Jackson-South Date: Sat, 5 Sep 2026 01:04:43 +1000 Subject: [PATCH 01/27] Validate decoded image dimensions --- src/ImageSharp/Formats/Bmp/BmpDecoderCore.cs | 5 +++++ src/ImageSharp/Formats/Gif/GifDecoderCore.cs | 9 ++++----- src/ImageSharp/Formats/Tga/TgaDecoderCore.cs | 4 ++-- .../Formats/InvalidImageDimensionsTests.cs | 18 ++++++++++++++++++ 4 files changed, 29 insertions(+), 7 deletions(-) create mode 100644 tests/ImageSharp.Tests/Formats/InvalidImageDimensionsTests.cs diff --git a/src/ImageSharp/Formats/Bmp/BmpDecoderCore.cs b/src/ImageSharp/Formats/Bmp/BmpDecoderCore.cs index aba1243d77..db7719019b 100644 --- a/src/ImageSharp/Formats/Bmp/BmpDecoderCore.cs +++ b/src/ImageSharp/Formats/Bmp/BmpDecoderCore.cs @@ -1560,6 +1560,11 @@ private int ReadImageHeaders(BufferedReadStream stream, out bool inverted, out b this.infoHeader.Height = -this.infoHeader.Height; } + if (this.infoHeader.Width <= 0 || this.infoHeader.Height <= 0) + { + BmpThrowHelper.ThrowInvalidImageContentException("Width and height must be greater than 0."); + } + int bytesPerColorMapEntry = 4; int colorMapSizeBytes = -1; if (this.infoHeader.ClrUsed == 0) diff --git a/src/ImageSharp/Formats/Gif/GifDecoderCore.cs b/src/ImageSharp/Formats/Gif/GifDecoderCore.cs index e4ffd42823..0a06f4915b 100644 --- a/src/ImageSharp/Formats/Gif/GifDecoderCore.cs +++ b/src/ImageSharp/Formats/Gif/GifDecoderCore.cs @@ -261,11 +261,6 @@ protected override ImageInfo Identify(BufferedReadStream stream, CancellationTok this.currentLocalColorTable?.Dispose(); } - if (this.logicalScreenDescriptor.Width == 0 && this.logicalScreenDescriptor.Height == 0) - { - GifThrowHelper.ThrowNoHeader(); - } - // Ignoring a malformed ancillary extension must not let identify succeed for a file // that never contained any readable image frame data. if (previousFrame is null) @@ -328,6 +323,10 @@ private void ReadLogicalScreenDescriptor(BufferedReadStream stream) } this.logicalScreenDescriptor = GifLogicalScreenDescriptor.Parse(this.buffer); + if (this.logicalScreenDescriptor.Width == 0 || this.logicalScreenDescriptor.Height == 0) + { + GifThrowHelper.ThrowInvalidImageContentException("Width and height must be greater than 0."); + } } /// diff --git a/src/ImageSharp/Formats/Tga/TgaDecoderCore.cs b/src/ImageSharp/Formats/Tga/TgaDecoderCore.cs index ead157986a..86f80530fb 100644 --- a/src/ImageSharp/Formats/Tga/TgaDecoderCore.cs +++ b/src/ImageSharp/Formats/Tga/TgaDecoderCore.cs @@ -72,9 +72,9 @@ protected override Image Decode(BufferedReadStream stream, Cance TgaThrowHelper.ThrowNotSupportedException($"Unknown tga colormap type {this.fileHeader.ColorMapType} found"); } - if (this.fileHeader.Width == 0 || this.fileHeader.Height == 0) + if (this.fileHeader.Width <= 0 || this.fileHeader.Height <= 0) { - throw new UnknownImageFormatException("Width or height cannot be 0"); + TgaThrowHelper.ThrowInvalidImageContentException("Width and height must be greater than 0."); } Image image = new(this.configuration, this.fileHeader.Width, this.fileHeader.Height, this.metadata); diff --git a/tests/ImageSharp.Tests/Formats/InvalidImageDimensionsTests.cs b/tests/ImageSharp.Tests/Formats/InvalidImageDimensionsTests.cs new file mode 100644 index 0000000000..bcf7df5984 --- /dev/null +++ b/tests/ImageSharp.Tests/Formats/InvalidImageDimensionsTests.cs @@ -0,0 +1,18 @@ +// Copyright (c) Six Labors. +// Licensed under the Six Labors Split License. + +namespace SixLabors.ImageSharp.Tests.Formats; + +public class InvalidImageDimensionsTests +{ + [Theory] + [InlineData("Qk1GAAAAAAAAADYAAAAoAAAAAgACAAAAAAABABgAAAAAABAAAAATCwAAEwsAAAAAAAAAAAAAAAD/AP8AAAAAAP8A/wAAAA==")] + [InlineData("R0lGODdhAgIAAIEAAAD/AP8AAAAA/wAAACwAAAQAAgACAAAIBwADABAQICAAOw==")] + [InlineData("AAACAAAAAAAAAAAAAgDCsRgAAgAAAP8A/wD/AAAA//8=")] + public void Load_WithNonPositiveDimensions_ThrowsInvalidImageContentException(string encodedData) + { + byte[] data = Convert.FromBase64String(encodedData); + + Assert.Throws(() => Image.Load(data)); + } +} From 23ed0b0cdad67d4e0e421f9586b7914e11dd5e10 Mon Sep 17 00:00:00 2001 From: James Jackson-South Date: Sat, 5 Sep 2026 01:14:19 +1000 Subject: [PATCH 02/27] Reject duplicate PNG headers --- src/ImageSharp/Formats/Png/PngDecoderCore.cs | 10 +++++----- .../Formats/Png/PngDecoderTests.Chunks.cs | 13 +++++++++++++ 2 files changed, 18 insertions(+), 5 deletions(-) diff --git a/src/ImageSharp/Formats/Png/PngDecoderCore.cs b/src/ImageSharp/Formats/Png/PngDecoderCore.cs index 28f9a989b9..4fb769fbec 100644 --- a/src/ImageSharp/Formats/Png/PngDecoderCore.cs +++ b/src/ImageSharp/Formats/Png/PngDecoderCore.cs @@ -195,11 +195,6 @@ protected override Image Decode(BufferedReadStream stream, Cance switch (chunk.Type) { case PngChunkType.Header: - if (!Equals(this.header, default(PngHeader))) - { - PngThrowHelper.ThrowInvalidHeader(); - } - this.ReadHeaderChunk(pngMetadata, chunk.Data.GetSpan()); break; case PngChunkType.AnimationControl: @@ -1439,6 +1434,11 @@ private FrameControl ReadFrameControlChunk(ReadOnlySpan data) /// The containing data. private void ReadHeaderChunk(PngMetadata pngMetadata, ReadOnlySpan data) { + if (!Equals(this.header, default(PngHeader))) + { + PngThrowHelper.ThrowInvalidHeader(); + } + this.header = PngHeader.Parse(data); this.header.Validate(); diff --git a/tests/ImageSharp.Tests/Formats/Png/PngDecoderTests.Chunks.cs b/tests/ImageSharp.Tests/Formats/Png/PngDecoderTests.Chunks.cs index ed33f71636..54a60f98e3 100644 --- a/tests/ImageSharp.Tests/Formats/Png/PngDecoderTests.Chunks.cs +++ b/tests/ImageSharp.Tests/Formats/Png/PngDecoderTests.Chunks.cs @@ -108,6 +108,19 @@ public void Decode_TruncatedFrameControlChunk_ExceptionIsThrown() Assert.Equal("The frame control chunk does not contain enough data!", exception.Message); } + [Fact] + public void DecodeAndIdentify_WithDuplicateHeader_ThrowInvalidImageContentException() + { + using MemoryStream payloadStream = new(); + payloadStream.Write(Raw1X1PngIhdrAndpHYs); + payloadStream.Write(Raw1X1PngIhdrAndpHYs.AsSpan(8, 25)); + payloadStream.Write(Raw1X1PngIdatAndIend); + byte[] payload = payloadStream.ToArray(); + + Assert.Throws(() => Image.Load(payload)); + Assert.Throws(() => Image.Identify(payload)); + } + // https://github.com/SixLabors/ImageSharp/issues/3079 [Fact] public void Decode_CompressedTxtChunk_WithTruncatedData_DoesNotThrow() From ea0475ebd848fbff38fca7b8a4f18a06a26e15ad Mon Sep 17 00:00:00 2001 From: James Jackson-South Date: Sat, 5 Sep 2026 01:15:48 +1000 Subject: [PATCH 03/27] Use wide arithmetic for PNG scanline lengths --- src/ImageSharp/Formats/Png/PngDecoderCore.cs | 24 ++++++++++++------- .../Formats/Png/PngDecoderCoreTests.cs | 18 ++++++++++++++ 2 files changed, 34 insertions(+), 8 deletions(-) create mode 100644 tests/ImageSharp.Tests/Formats/Png/PngDecoderCoreTests.cs diff --git a/src/ImageSharp/Formats/Png/PngDecoderCore.cs b/src/ImageSharp/Formats/Png/PngDecoderCore.cs index 4fb769fbec..d4b3828453 100644 --- a/src/ImageSharp/Formats/Png/PngDecoderCore.cs +++ b/src/ImageSharp/Formats/Png/PngDecoderCore.cs @@ -651,7 +651,7 @@ private void InitializeImage(ImageMetadata metadata, FrameControl frameC frameMetadata.FromChunk(in frameControl); this.bytesPerPixel = this.CalculateBytesPerPixel(); - this.bytesPerScanline = this.CalculateScanlineLength(this.header.Width) + 1; + this.bytesPerScanline = CalculateScanlineLength(this.header.Width, this.header.BitDepth, this.bytesPerPixel) + 1; this.bytesPerSample = 1; if (this.header.BitDepth >= 8) { @@ -736,21 +736,29 @@ private int CalculateBytesPerPixel() /// Calculates the scanline length. /// /// The width of the row. + /// The number of bits per sample. + /// The number of bytes per pixel. /// /// The representing the length. /// - private int CalculateScanlineLength(int width) + internal static int CalculateScanlineLength(int width, int bitDepth, int bytesPerPixel) { - int mod = this.header.BitDepth == 16 ? 16 : 8; - int scanlineLength = width * this.header.BitDepth * this.bytesPerPixel; + int mod = bitDepth == 16 ? 16 : 8; + long scanlineLength = (long)width * bitDepth * bytesPerPixel; - int amount = scanlineLength % mod; + long amount = scanlineLength % mod; if (amount != 0) { scanlineLength += mod - amount; } - return scanlineLength / mod; + scanlineLength /= mod; + if (scanlineLength >= int.MaxValue) + { + PngThrowHelper.ThrowInvalidImageContentException("PNG scanline length exceeds the supported maximum."); + } + + return (int)scanlineLength; } /// @@ -870,7 +878,7 @@ private void DecodePixelDataCore( while (currentRow < height) { cancellationToken.ThrowIfCancellationRequested(); - int bytesPerFrameScanline = this.CalculateScanlineLength((int)frameControl.Width) + 1; + int bytesPerFrameScanline = CalculateScanlineLength((int)frameControl.Width, this.header.BitDepth, this.bytesPerPixel) + 1; Span scanSpan = this.scanline.GetSpan()[..bytesPerFrameScanline]; Span prevSpan = this.previousScanline.GetSpan()[..bytesPerFrameScanline]; @@ -1001,7 +1009,7 @@ private void DecodeInterlacedPixelDataCore( continue; } - int bytesPerInterlaceScanline = this.CalculateScanlineLength(numColumns) + 1; + int bytesPerInterlaceScanline = CalculateScanlineLength(numColumns, this.header.BitDepth, this.bytesPerPixel) + 1; while (currentRow < endRow) { diff --git a/tests/ImageSharp.Tests/Formats/Png/PngDecoderCoreTests.cs b/tests/ImageSharp.Tests/Formats/Png/PngDecoderCoreTests.cs new file mode 100644 index 0000000000..0b442f8834 --- /dev/null +++ b/tests/ImageSharp.Tests/Formats/Png/PngDecoderCoreTests.cs @@ -0,0 +1,18 @@ +// Copyright (c) Six Labors. +// Licensed under the Six Labors Split License. + +using SixLabors.ImageSharp.Formats.Png; + +namespace SixLabors.ImageSharp.Tests.Formats.Png; + +[Trait("Format", "Png")] +public class PngDecoderCoreTests +{ + [Fact] + public void CalculateScanlineLength_WithLargeGrayscaleWidth_ReturnsExpectedLength() + { + int length = PngDecoderCore.CalculateScanlineLength(536_870_913, 8, 1); + + Assert.Equal(536_870_913, length); + } +} From 0446ba0d69a2a3a343b2798ca60b2623571735e5 Mon Sep 17 00:00:00 2001 From: James Jackson-South Date: Sat, 5 Sep 2026 01:16:21 +1000 Subject: [PATCH 04/27] Cover large PNG average-filter scanlines --- tests/ImageSharp.Tests/Formats/Png/PngDecoderCoreTests.cs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/tests/ImageSharp.Tests/Formats/Png/PngDecoderCoreTests.cs b/tests/ImageSharp.Tests/Formats/Png/PngDecoderCoreTests.cs index 0b442f8834..95c452c0f8 100644 --- a/tests/ImageSharp.Tests/Formats/Png/PngDecoderCoreTests.cs +++ b/tests/ImageSharp.Tests/Formats/Png/PngDecoderCoreTests.cs @@ -15,4 +15,12 @@ public void CalculateScanlineLength_WithLargeGrayscaleWidth_ReturnsExpectedLengt Assert.Equal(536_870_913, length); } + + [Fact] + public void CalculateScanlineLength_WithLargeRgbaWidth_ReturnsExpectedLength() + { + int length = PngDecoderCore.CalculateScanlineLength(33_554_432, 16, 8); + + Assert.Equal(268_435_456, length); + } } From 2b52b55be6ca50a9f01d4689ebd9205eb49922de Mon Sep 17 00:00:00 2001 From: James Jackson-South Date: Sat, 5 Sep 2026 01:17:31 +1000 Subject: [PATCH 05/27] Validate WebP metadata chunk lengths --- .../Formats/Webp/WebpChunkParsingUtils.cs | 28 +++++++++++++------ .../Formats/WebP/WebpMetaDataTests.cs | 10 +++++++ 2 files changed, 29 insertions(+), 9 deletions(-) diff --git a/src/ImageSharp/Formats/Webp/WebpChunkParsingUtils.cs b/src/ImageSharp/Formats/Webp/WebpChunkParsingUtils.cs index 119d53fe96..1e334057e7 100644 --- a/src/ImageSharp/Formats/Webp/WebpChunkParsingUtils.cs +++ b/src/ImageSharp/Formats/Webp/WebpChunkParsingUtils.cs @@ -355,15 +355,15 @@ public static void ReadIccProfile( bool ignoreMetadata) { Span buffer = stackalloc byte[4]; - uint iccpChunkSize = ReadChunkSize(stream, buffer); + int iccpChunkSize = ValidateMetadataChunkSize(stream, ReadChunkSize(stream, buffer), "ICCP"); if (ignoreMetadata || metadata.IccProfile != null) { - stream.Skip((int)iccpChunkSize); + stream.Skip(iccpChunkSize); } else { byte[] iccpData = new byte[iccpChunkSize]; - int bytesRead = stream.Read(iccpData, 0, (int)iccpChunkSize); + int bytesRead = stream.Read(iccpData, 0, iccpChunkSize); if (bytesRead != iccpChunkSize) { WebpThrowHelper.ThrowInvalidImageContentException("Not enough data to read the iccp chunk"); @@ -391,15 +391,15 @@ public static void ReadExifProfile( bool ignoreMetadata) { Span buffer = stackalloc byte[4]; - uint exifChunkSize = ReadChunkSize(stream, buffer); + int exifChunkSize = ValidateMetadataChunkSize(stream, ReadChunkSize(stream, buffer), "EXIF"); if (ignoreMetadata || metadata.ExifProfile != null) { - stream.Skip((int)exifChunkSize); + stream.Skip(exifChunkSize); } else { byte[] exifData = new byte[exifChunkSize]; - int bytesRead = stream.Read(exifData, 0, (int)exifChunkSize); + int bytesRead = stream.Read(exifData, 0, exifChunkSize); if (bytesRead != exifChunkSize) { WebpThrowHelper.ThrowInvalidImageContentException("Could not read enough data for the EXIF profile"); @@ -434,15 +434,15 @@ public static void ReadXmpProfile( bool ignoreMetadata) { Span buffer = stackalloc byte[4]; - uint xmpChunkSize = ReadChunkSize(stream, buffer); + int xmpChunkSize = ValidateMetadataChunkSize(stream, ReadChunkSize(stream, buffer), "XMP"); if (ignoreMetadata || metadata.XmpProfile != null) { - stream.Skip((int)xmpChunkSize); + stream.Skip(xmpChunkSize); } else { byte[] xmpData = new byte[xmpChunkSize]; - int bytesRead = stream.Read(xmpData, 0, (int)xmpChunkSize); + int bytesRead = stream.Read(xmpData, 0, xmpChunkSize); if (bytesRead != xmpChunkSize) { WebpThrowHelper.ThrowInvalidImageContentException("Could not read enough data for the XMP profile"); @@ -452,6 +452,16 @@ public static void ReadXmpProfile( } } + private static int ValidateMetadataChunkSize(BufferedReadStream stream, uint chunkSize, string chunkName) + { + if (chunkSize > int.MaxValue || chunkSize > stream.Length - stream.Position) + { + WebpThrowHelper.ThrowInvalidImageContentException($"Not enough data to read the {chunkName} chunk"); + } + + return (int)chunkSize; + } + private static double GetExifResolutionValue(ExifProfile exifProfile, ExifTag tag) { if (exifProfile.TryGetValue(tag, out IExifValue? resolution)) diff --git a/tests/ImageSharp.Tests/Formats/WebP/WebpMetaDataTests.cs b/tests/ImageSharp.Tests/Formats/WebP/WebpMetaDataTests.cs index 394479f89d..4092abfe69 100644 --- a/tests/ImageSharp.Tests/Formats/WebP/WebpMetaDataTests.cs +++ b/tests/ImageSharp.Tests/Formats/WebP/WebpMetaDataTests.cs @@ -228,4 +228,14 @@ public void Decode_InvalidExifChunk_ThrowsWithStrict() using Image image = Image.Load(options, stream); }); } + + [Fact] + public void Decode_WithOversizedIccChunk_ThrowsInvalidImageContentException() + { + byte[] payload = Convert.FromHexString( + "524946462200000057454250565038580A0000002000000000000000000049434350FEFFFFFF01020304"); + + Assert.Throws(() => Image.Load(payload)); + Assert.Throws(() => Image.Identify(payload)); + } } From 14ddc15e26628c4d73c568f02d93548a9ca8eb00 Mon Sep 17 00:00:00 2001 From: James Jackson-South Date: Sat, 5 Sep 2026 01:18:39 +1000 Subject: [PATCH 06/27] Validate embedded profile extents --- src/ImageSharp/Formats/Bmp/BmpDecoderCore.cs | 11 ++++++++++- .../Formats/Bmp/BmpDecoderTests.cs | 14 ++++++++++++++ .../Formats/WebP/WebpMetaDataTests.cs | 10 ++++++++++ 3 files changed, 34 insertions(+), 1 deletion(-) diff --git a/src/ImageSharp/Formats/Bmp/BmpDecoderCore.cs b/src/ImageSharp/Formats/Bmp/BmpDecoderCore.cs index db7719019b..b5ca4c26e9 100644 --- a/src/ImageSharp/Formats/Bmp/BmpDecoderCore.cs +++ b/src/ImageSharp/Formats/Bmp/BmpDecoderCore.cs @@ -1474,8 +1474,17 @@ private void ReadInfoHeader(BufferedReadStream stream) /// The stream position where the info header begins. private void ReadIccProfile(BufferedReadStream stream, ImageMetadata imageMetadata, long infoHeaderStart) { + long profileStart = infoHeaderStart + this.infoHeader.ProfileData; + if (this.infoHeader.ProfileData < 0 || + this.infoHeader.ProfileSize <= 0 || + profileStart > stream.Length || + this.infoHeader.ProfileSize > stream.Length - profileStart) + { + BmpThrowHelper.ThrowInvalidImageContentException("Not enough data to read BMP ICC profile."); + } + byte[] iccProfileData = new byte[this.infoHeader.ProfileSize]; - stream.Position = infoHeaderStart + this.infoHeader.ProfileData; + stream.Position = profileStart; if (stream.Read(iccProfileData) != iccProfileData.Length) { diff --git a/tests/ImageSharp.Tests/Formats/Bmp/BmpDecoderTests.cs b/tests/ImageSharp.Tests/Formats/Bmp/BmpDecoderTests.cs index e85c6bcdf7..ce15c91f0b 100644 --- a/tests/ImageSharp.Tests/Formats/Bmp/BmpDecoderTests.cs +++ b/tests/ImageSharp.Tests/Formats/Bmp/BmpDecoderTests.cs @@ -34,6 +34,20 @@ public class BmpDecoderTests { RLE8, 2835, 2835, PixelResolutionUnit.PixelsPerMeter } }; + [Fact] + public void Decode_WithProfileLargerThanRemainingData_ThrowsInStrictMode() + { + byte[] payload = Convert.FromHexString( + "424D8E000000000000008A0000007C0000000100000001000000010018000000" + + "0000000000000000000000000000000000000000000000000000000000000000" + + "0000000000000000000000000000000000000000000000000000000000000000" + + "000000000000000000000000000000000000000000000000000000000000C800" + + "00000000004000000000000000"); + DecoderOptions options = new() { SegmentIntegrityHandling = SegmentIntegrityHandling.Strict }; + + Assert.Throws(() => Image.Load(options, payload)); + } + [Theory] [WithFileCollection(nameof(MiscBmpFiles), PixelTypes.Rgba32)] public void BmpDecoder_CanDecode_MiscellaneousBitmaps(TestImageProvider provider) diff --git a/tests/ImageSharp.Tests/Formats/WebP/WebpMetaDataTests.cs b/tests/ImageSharp.Tests/Formats/WebP/WebpMetaDataTests.cs index 4092abfe69..10678273bb 100644 --- a/tests/ImageSharp.Tests/Formats/WebP/WebpMetaDataTests.cs +++ b/tests/ImageSharp.Tests/Formats/WebP/WebpMetaDataTests.cs @@ -238,4 +238,14 @@ public void Decode_WithOversizedIccChunk_ThrowsInvalidImageContentException() Assert.Throws(() => Image.Load(payload)); Assert.Throws(() => Image.Identify(payload)); } + + [Fact] + public void Decode_WithIccChunkLargerThanRemainingData_ThrowsInvalidImageContentException() + { + byte[] payload = Convert.FromHexString( + "524946460000000057454250565038580A00000020000000010000010000494343500000004000000000"); + + Assert.Throws(() => Image.Load(payload)); + Assert.Throws(() => Image.Identify(payload)); + } } From 84613d4839b2eaf3643aeb6b089a9c7fa9bd218a Mon Sep 17 00:00:00 2001 From: James Jackson-South Date: Sat, 5 Sep 2026 01:19:45 +1000 Subject: [PATCH 07/27] Apply EXIF part selection when encoding WebP --- .../Formats/Webp/BitWriter/BitWriterBase.cs | 2 ++ .../Formats/WebP/WebpEncoderTests.cs | 27 +++++++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/src/ImageSharp/Formats/Webp/BitWriter/BitWriterBase.cs b/src/ImageSharp/Formats/Webp/BitWriter/BitWriterBase.cs index 39c4beb618..e367697166 100644 --- a/src/ImageSharp/Formats/Webp/BitWriter/BitWriterBase.cs +++ b/src/ImageSharp/Formats/Webp/BitWriter/BitWriterBase.cs @@ -143,6 +143,8 @@ public static void WriteTrunksAfterData( { if (exifProfile != null) { + // Materialize lazy values so Parts is applied during serialization. + _ = exifProfile.Values; RiffHelper.WriteChunk(stream, (uint)WebpChunkType.Exif, exifProfile.ToByteArray()); } diff --git a/tests/ImageSharp.Tests/Formats/WebP/WebpEncoderTests.cs b/tests/ImageSharp.Tests/Formats/WebP/WebpEncoderTests.cs index ee82687167..3db4a0909e 100644 --- a/tests/ImageSharp.Tests/Formats/WebP/WebpEncoderTests.cs +++ b/tests/ImageSharp.Tests/Formats/WebP/WebpEncoderTests.cs @@ -4,9 +4,11 @@ using System.Runtime.InteropServices; using SixLabors.ImageSharp.Formats; using SixLabors.ImageSharp.Formats.Gif; +using SixLabors.ImageSharp.Formats.Jpeg; using SixLabors.ImageSharp.Formats.Png; using SixLabors.ImageSharp.Formats.Webp; using SixLabors.ImageSharp.Metadata; +using SixLabors.ImageSharp.Metadata.Profiles.Exif; using SixLabors.ImageSharp.PixelFormats; using SixLabors.ImageSharp.Processing; using SixLabors.ImageSharp.Processing.Processors.Quantization; @@ -22,6 +24,31 @@ public class WebpEncoderTests { private static string TestImageLossyFullPath => Path.Combine(TestEnvironment.InputImagesDirectoryFullPath, Lossy.NoFilter06); + [Fact] + public void Encode_LazyExifProfile_AppliesSelectedParts() + { + using Image input = new(8, 8); + ExifProfile exif = new(); + exif.SetValue(ExifTag.Make, "ImageSharp"); + exif.SetValue(ExifTag.GPSLatitudeRef, "N"); + input.Metadata.ExifProfile = exif; + + using MemoryStream jpegStream = new(); + input.Save(jpegStream, new JpegEncoder()); + jpegStream.Position = 0; + + using Image source = Image.Load(jpegStream); + source.Metadata.ExifProfile!.Parts = ExifParts.IfdTags | ExifParts.ExifTags; + + using MemoryStream webpStream = new(); + source.Save(webpStream, new WebpEncoder()); + webpStream.Position = 0; + + using Image result = Image.Load(webpStream); + Assert.True(result.Metadata.ExifProfile!.TryGetValue(ExifTag.Make, out _)); + Assert.False(result.Metadata.ExifProfile.TryGetValue(ExifTag.GPSLatitudeRef, out _)); + } + [Theory] [WithFile(Lossless.Animated, PixelTypes.Rgba32)] public void Encode_AnimatedLossless(TestImageProvider provider) From 8de892a7623aa8a09ba2333b624c8d2eb98325df Mon Sep 17 00:00:00 2001 From: James Jackson-South Date: Sat, 5 Sep 2026 01:20:44 +1000 Subject: [PATCH 08/27] Validate ICC CLUT storage before allocation --- .../ICC/DataReader/IccDataReader.Lut.cs | 48 +++++++++++-------- .../ICC/DataReader/IccDataReaderLutTests.cs | 10 ++++ 2 files changed, 37 insertions(+), 21 deletions(-) diff --git a/src/ImageSharp/Metadata/Profiles/ICC/DataReader/IccDataReader.Lut.cs b/src/ImageSharp/Metadata/Profiles/ICC/DataReader/IccDataReader.Lut.cs index 700e43f972..da2f37efd3 100644 --- a/src/ImageSharp/Metadata/Profiles/ICC/DataReader/IccDataReader.Lut.cs +++ b/src/ImageSharp/Metadata/Profiles/ICC/DataReader/IccDataReader.Lut.cs @@ -72,13 +72,7 @@ public IccClut ReadClut(int inChannelCount, int outChannelCount, bool isFloat) /// The read CLUT8. public IccClut ReadClut8(int inChannelCount, int outChannelCount, byte[] gridPointCount) { - int length = 0; - for (int i = 0; i < inChannelCount; i++) - { - length += (int)Math.Pow(gridPointCount[i], inChannelCount); - } - - length /= inChannelCount; + int length = this.GetClutLength(inChannelCount, outChannelCount, gridPointCount, 1); const float Max = byte.MaxValue; @@ -105,13 +99,7 @@ public IccClut ReadClut8(int inChannelCount, int outChannelCount, byte[] gridPoi public IccClut ReadClut16(int inChannelCount, int outChannelCount, byte[] gridPointCount) { int start = this.currentIndex; - int length = 0; - for (int i = 0; i < inChannelCount; i++) - { - length += (int)Math.Pow(gridPointCount[i], inChannelCount); - } - - length /= inChannelCount; + int length = this.GetClutLength(inChannelCount, outChannelCount, gridPointCount, 2); const float Max = ushort.MaxValue; @@ -139,13 +127,7 @@ public IccClut ReadClut16(int inChannelCount, int outChannelCount, byte[] gridPo public IccClut ReadClutF32(int inChCount, int outChCount, byte[] gridPointCount) { int start = this.currentIndex; - int length = 0; - for (int i = 0; i < inChCount; i++) - { - length += (int)Math.Pow(gridPointCount[i], inChCount); - } - - length /= inChCount; + int length = this.GetClutLength(inChCount, outChCount, gridPointCount, 4); float[] values = new float[length * outChCount]; int offset = 0; @@ -160,4 +142,28 @@ public IccClut ReadClutF32(int inChCount, int outChCount, byte[] gridPointCount) this.currentIndex = start + (length * outChCount * 4); return new IccClut(values, gridPointCount, IccClutDataType.Float, outChCount); } + + private int GetClutLength(int inputChannelCount, int outputChannelCount, byte[] gridPointCount, int bytesPerValue) + { + int length = 1; + for (int i = 0; i < inputChannelCount; i++) + { + int gridPoints = gridPointCount[i]; + if (gridPoints == 0 || length > int.MaxValue / gridPoints) + { + throw new InvalidIccProfileException("Invalid CLUT dimensions."); + } + + length *= gridPoints; + } + + long valueCount = (long)length * outputChannelCount; + long byteCount = valueCount * bytesPerValue; + if (valueCount > int.MaxValue || byteCount > this.data.Length - this.currentIndex) + { + throw new InvalidIccProfileException("The CLUT data is shorter than its declared dimensions."); + } + + return length; + } } diff --git a/tests/ImageSharp.Tests/Metadata/Profiles/ICC/DataReader/IccDataReaderLutTests.cs b/tests/ImageSharp.Tests/Metadata/Profiles/ICC/DataReader/IccDataReaderLutTests.cs index a686d44872..055756e96b 100644 --- a/tests/ImageSharp.Tests/Metadata/Profiles/ICC/DataReader/IccDataReaderLutTests.cs +++ b/tests/ImageSharp.Tests/Metadata/Profiles/ICC/DataReader/IccDataReaderLutTests.cs @@ -53,6 +53,16 @@ internal void ReadClutF32(byte[] data, IccClut expected, int inChannelCount, int Assert.Equal(expected, output); } + [Fact] + public void ReadClut_WithOversizedDimensions_ThrowsInvalidIccProfileException() + { + byte[] gridPointCount = Enumerable.Repeat((byte)3, 15).ToArray(); + + Assert.Throws(() => CreateReader(new byte[8]).ReadClut8(15, 15, gridPointCount)); + Assert.Throws(() => CreateReader(new byte[8]).ReadClut16(15, 15, gridPointCount)); + Assert.Throws(() => CreateReader(new byte[8]).ReadClutF32(15, 15, gridPointCount)); + } + [Theory] [MemberData(nameof(IccTestDataLut.Lut8TestData), MemberType = typeof(IccTestDataLut))] internal void ReadLut8(byte[] data, IccLut expected) From 73d8e02077fddca4beec20ff75a04da91412a67f Mon Sep 17 00:00:00 2001 From: James Jackson-South Date: Sat, 5 Sep 2026 01:23:14 +1000 Subject: [PATCH 09/27] Reject undersized ICC tag entries --- .../Metadata/Profiles/ICC/IccReader.cs | 3 ++- .../Metadata/Profiles/ICC/IccReaderTests.cs | 23 +++++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/src/ImageSharp/Metadata/Profiles/ICC/IccReader.cs b/src/ImageSharp/Metadata/Profiles/ICC/IccReader.cs index 084ec388d6..d4ee3bded1 100644 --- a/src/ImageSharp/Metadata/Profiles/ICC/IccReader.cs +++ b/src/ImageSharp/Metadata/Profiles/ICC/IccReader.cs @@ -119,6 +119,7 @@ private static IccTagTableEntry[] ReadTagTable(IccDataReader reader) } List table = new((int)tagCount); + uint dataLength = (uint)reader.DataLength; for (int i = 0; i < tagCount; i++) { uint tagSignature = reader.ReadUInt32(); @@ -126,7 +127,7 @@ private static IccTagTableEntry[] ReadTagTable(IccDataReader reader) uint tagSize = reader.ReadUInt32(); // Exclude entries that have nonsense values and could cause exceptions further on - if (tagOffset < reader.DataLength && tagSize < reader.DataLength - 128) + if (tagSize >= 8 && tagOffset <= dataLength && tagSize <= dataLength - tagOffset) { table.Add(new IccTagTableEntry((IccProfileTag)tagSignature, tagOffset, tagSize)); } diff --git a/tests/ImageSharp.Tests/Metadata/Profiles/ICC/IccReaderTests.cs b/tests/ImageSharp.Tests/Metadata/Profiles/ICC/IccReaderTests.cs index 2a80ae9e9c..610c831e8f 100644 --- a/tests/ImageSharp.Tests/Metadata/Profiles/ICC/IccReaderTests.cs +++ b/tests/ImageSharp.Tests/Metadata/Profiles/ICC/IccReaderTests.cs @@ -1,6 +1,7 @@ // Copyright (c) Six Labors. // Licensed under the Six Labors Split License. +using System.Buffers.Binary; using SixLabors.ImageSharp.Metadata.Profiles.Icc; using SixLabors.ImageSharp.PixelFormats; using SixLabors.ImageSharp.Tests.TestDataIcc; @@ -59,4 +60,26 @@ public void ReadProfile_NoEntries() Assert.Equal(header.Size, expected.Size); Assert.Equal(header.Version, expected.Version); } + + [Fact] + public void ReadProfile_WithUndersizedArrayTags_IgnoresTags() + { + const int tagCount = 100; + const int dataOffset = 132 + (tagCount * 12); + byte[] data = new byte[dataOffset + 16]; + BinaryPrimitives.WriteUInt32BigEndian(data.AsSpan(128), tagCount); + + for (int i = 0; i < tagCount; i++) + { + Span entry = data.AsSpan(132 + (i * 12), 12); + BinaryPrimitives.WriteUInt32BigEndian(entry, 0x73663332); + BinaryPrimitives.WriteUInt32BigEndian(entry[4..], dataOffset); + BinaryPrimitives.WriteUInt32BigEndian(entry[8..], 0); + } + + BinaryPrimitives.WriteUInt32BigEndian(data.AsSpan(dataOffset), 0x73663332); + IccProfile profile = new(data); + + Assert.Empty(profile.Entries); + } } From 92b12d72550ebb6be5b1e22e559c361fd863a6ec Mon Sep 17 00:00:00 2001 From: James Jackson-South Date: Sat, 5 Sep 2026 01:25:07 +1000 Subject: [PATCH 10/27] Bound BigTIFF directory entry counts --- src/ImageSharp/Metadata/Profiles/Exif/ExifReader.cs | 7 +++++++ .../Formats/Tiff/BigTiffDecoderTests.cs | 13 +++++++++++++ 2 files changed, 20 insertions(+) diff --git a/src/ImageSharp/Metadata/Profiles/Exif/ExifReader.cs b/src/ImageSharp/Metadata/Profiles/Exif/ExifReader.cs index 4cd4b4aac9..4e2315cd88 100644 --- a/src/ImageSharp/Metadata/Profiles/Exif/ExifReader.cs +++ b/src/ImageSharp/Metadata/Profiles/Exif/ExifReader.cs @@ -224,6 +224,13 @@ protected void ReadValues64(List values, ulong offset) this.Seek(offset); ulong count = this.ReadUInt64(); + // Each entry occupies 20 bytes and the directory ends with an 8-byte next-IFD offset. + long remainingDirectoryBytes = this.data.Length - this.data.Position; + if (remainingDirectoryBytes < 8 || count > (ulong)((remainingDirectoryBytes - 8) / 20)) + { + throw new InvalidImageContentException("The BigTIFF directory entry count exceeds the available data."); + } + Span offsetBuffer = stackalloc byte[8]; for (ulong i = 0; i < count; i++) { diff --git a/tests/ImageSharp.Tests/Formats/Tiff/BigTiffDecoderTests.cs b/tests/ImageSharp.Tests/Formats/Tiff/BigTiffDecoderTests.cs index 72f53cab78..769ee00a82 100644 --- a/tests/ImageSharp.Tests/Formats/Tiff/BigTiffDecoderTests.cs +++ b/tests/ImageSharp.Tests/Formats/Tiff/BigTiffDecoderTests.cs @@ -115,4 +115,17 @@ public void TiffDecoder_SubIfd8(TestImageProvider provider) Assert.Equal(1, meta.Values.Count(v => (ushort)v.Tag == (ushort)ExifTagValue.StripOffsets)); Assert.Equal(1, meta.Values.Count(v => (ushort)v.Tag == (ushort)ExifTagValue.StripByteCounts)); } + + [Fact] + public void TiffDecoder_DirectoryEntryCountExceedsAvailableData_Throws() + { + byte[] data = + [ + 0x49, 0x49, 0x2B, 0x00, 0x08, 0x00, 0x00, 0x00, + 0x10, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0xF2, 0x05, 0x2A, 0x01, 0x00, 0x00, 0x00, + ]; + + Assert.Throws(() => Image.Load(data)); + } } From c4c4bf292298c026df470db1a17ff826515fd95d Mon Sep 17 00:00:00 2001 From: James Jackson-South Date: Sat, 5 Sep 2026 01:26:41 +1000 Subject: [PATCH 11/27] Clamp histogram luminance indices --- .../Common/Helpers/ColorNumerics.cs | 12 +++- .../HistogramEqualizationTests.cs | 55 +++++++++++++++++++ 2 files changed, 66 insertions(+), 1 deletion(-) diff --git a/src/ImageSharp/Common/Helpers/ColorNumerics.cs b/src/ImageSharp/Common/Helpers/ColorNumerics.cs index 0b88aa5b07..d13eead453 100644 --- a/src/ImageSharp/Common/Helpers/ColorNumerics.cs +++ b/src/ImageSharp/Common/Helpers/ColorNumerics.cs @@ -27,7 +27,17 @@ internal static class ColorNumerics /// [MethodImpl(MethodImplOptions.AggressiveInlining)] public static int GetBT709Luminance(Vector4 vector, int luminanceLevels) - => (int)MathF.Round(Vector4.Dot(vector, Bt709) * (luminanceLevels - 1)); + { + float luminance = Vector4.Dot(vector, Bt709); + if (float.IsNaN(luminance)) + { + return 0; + } + + // Floating-point pixel formats can contain values outside their normalized range. + luminance = Math.Clamp(luminance, 0F, 1F); + return (int)MathF.Round(luminance * (luminanceLevels - 1)); + } /// /// Gets the luminance from the rgb components using the formula diff --git a/tests/ImageSharp.Tests/Processing/Normalization/HistogramEqualizationTests.cs b/tests/ImageSharp.Tests/Processing/Normalization/HistogramEqualizationTests.cs index c39648d014..917d68d025 100644 --- a/tests/ImageSharp.Tests/Processing/Normalization/HistogramEqualizationTests.cs +++ b/tests/ImageSharp.Tests/Processing/Normalization/HistogramEqualizationTests.cs @@ -14,6 +14,18 @@ public class HistogramEqualizationTests { private static readonly ImageComparer ValidatorComparer = ImageComparer.TolerantPercentage(0.0456F); + [Fact] + public void GlobalHistogramEqualization_ClampsDecodedFloatingPointSamples() + { + byte[] data = BuildFloatTiff(8, 2F); + + using Image image = Image.Load(data); + image.Mutate(x => x.HistogramEqualization()); + + Assert.Equal(8, image.Width); + Assert.Equal(1, image.Height); + } + [Theory] [InlineData(256)] [InlineData(65536)] @@ -238,4 +250,47 @@ public unsafe void Issue1640(TestImageProvider provider) ValidatorComparer.VerifySimilarity(referenceResult, processed); } + + private static byte[] BuildFloatTiff(int width, float sample) + { + const int headerLength = 8; + const int entryCount = 10; + const int ifdLength = 2 + (entryCount * 12) + 4; + uint pixelOffset = headerLength + ifdLength; + + using MemoryStream stream = new(); + using BinaryWriter writer = new(stream); + + writer.Write((byte)0x49); + writer.Write((byte)0x49); + writer.Write((ushort)42); + writer.Write((uint)headerLength); + writer.Write((ushort)entryCount); + WriteEntry(writer, 256, 4, 1, (uint)width); + WriteEntry(writer, 257, 4, 1, 1); + WriteEntry(writer, 258, 3, 1, 32); + WriteEntry(writer, 259, 3, 1, 1); + WriteEntry(writer, 262, 3, 1, 1); + WriteEntry(writer, 273, 4, 1, pixelOffset); + WriteEntry(writer, 277, 3, 1, 1); + WriteEntry(writer, 278, 4, 1, 1); + WriteEntry(writer, 279, 4, 1, (uint)(width * sizeof(float))); + WriteEntry(writer, 339, 3, 1, 3); + writer.Write(0U); + + for (int i = 0; i < width; i++) + { + writer.Write(sample); + } + + return stream.ToArray(); + + static void WriteEntry(BinaryWriter writer, ushort tag, ushort type, uint count, uint value) + { + writer.Write(tag); + writer.Write(type); + writer.Write(count); + writer.Write(value); + } + } } From a9498c6db3642ef92c712ebc81e706914ac16a95 Mon Sep 17 00:00:00 2001 From: James Jackson-South Date: Sat, 5 Sep 2026 01:29:00 +1000 Subject: [PATCH 12/27] Size CCITT encoder output buffers --- .../Compressors/T4BitCompressor.cs | 8 +++++++ .../Compressors/T6BitCompressor.cs | 8 +++++++ .../Compressors/TiffCcittCompressor.cs | 24 ++++++++++++++++--- .../Formats/Tiff/TiffEncoderTests.cs | 21 ++++++++++++++++ 4 files changed, 58 insertions(+), 3 deletions(-) diff --git a/src/ImageSharp/Formats/Tiff/Compression/Compressors/T4BitCompressor.cs b/src/ImageSharp/Formats/Tiff/Compression/Compressors/T4BitCompressor.cs index dc9e1e7296..74914e036f 100644 --- a/src/ImageSharp/Formats/Tiff/Compression/Compressors/T4BitCompressor.cs +++ b/src/ImageSharp/Formats/Tiff/Compression/Compressors/T4BitCompressor.cs @@ -126,6 +126,14 @@ protected override void CompressStrip(Span pixelsAsGray, int height, Span< } } + /// + protected override long GetMaximumEncodedBits(int rowsPerStrip) + { + // A pixel can require a 13-bit terminating code. Each row can also require + // an 8-bit zero-length white run and a 12-bit EOL, plus the initial EOL. + return 12L + ((((long)this.Width * 13) + 20) * rowsPerStrip); + } + private void WriteEndOfLine(Span compressedData) { if (this.useModifiedHuffman) diff --git a/src/ImageSharp/Formats/Tiff/Compression/Compressors/T6BitCompressor.cs b/src/ImageSharp/Formats/Tiff/Compression/Compressors/T6BitCompressor.cs index cffc96fcdf..6dba8b7d25 100644 --- a/src/ImageSharp/Formats/Tiff/Compression/Compressors/T6BitCompressor.cs +++ b/src/ImageSharp/Formats/Tiff/Compression/Compressors/T6BitCompressor.cs @@ -131,6 +131,14 @@ protected override void CompressStrip(Span pixelsAsGray, int height, Span< this.WriteCode(12, 1, compressedData); } + /// + protected override long GetMaximumEncodedBits(int rowsPerStrip) + { + // Alternating pixels use at most 29 bits per two-pixel horizontal mode. + // Allow 16 bits per pixel, row transition overhead, and the final 24-bit EOFB. + return ((((long)this.Width * 16) + 24) * rowsPerStrip) + 24; + } + /// protected override void Dispose(bool disposing) { diff --git a/src/ImageSharp/Formats/Tiff/Compression/Compressors/TiffCcittCompressor.cs b/src/ImageSharp/Formats/Tiff/Compression/Compressors/TiffCcittCompressor.cs index 8e2227cba5..06cf84437a 100644 --- a/src/ImageSharp/Formats/Tiff/Compression/Compressors/TiffCcittCompressor.cs +++ b/src/ImageSharp/Formats/Tiff/Compression/Compressors/TiffCcittCompressor.cs @@ -465,6 +465,12 @@ protected void PadByte() /// The destination buffer to write the code to. protected void WriteCode(uint codeLength, uint code, Span compressedData) { + long availableBits = (((long)compressedData.Length - this.bytePosition) * 8) - this.bitPosition; + if (codeLength > availableBits) + { + throw new InvalidMemoryOperationException("The CCITT output buffer is too small for the encoded data."); + } + while (codeLength > 0) { int bitNumber = (int)codeLength; @@ -526,8 +532,20 @@ public override void CompressStrip(Span rows, int height) /// public override void Initialize(int rowsPerStrip) { - // This is too much memory allocated, but just 1 bit per pixel will not do, if the compression rate is not good. - int maxNeededBytes = this.Width * rowsPerStrip; - this.compressedDataBuffer = this.Allocator.Allocate(maxNeededBytes); + long maxNeededBits = this.GetMaximumEncodedBits(rowsPerStrip); + ulong maxNeededBytes = (ulong)((maxNeededBits + 7) / 8); + if (maxNeededBytes > int.MaxValue) + { + InvalidMemoryOperationException.ThrowAllocationOverLimitException(maxNeededBytes, int.MaxValue); + } + + this.compressedDataBuffer = this.Allocator.Allocate((int)maxNeededBytes); } + + /// + /// Gets an upper bound for the encoded strip length in bits. + /// + /// The number of rows in the strip. + /// The maximum encoded length. + protected abstract long GetMaximumEncodedBits(int rowsPerStrip); } diff --git a/tests/ImageSharp.Tests/Formats/Tiff/TiffEncoderTests.cs b/tests/ImageSharp.Tests/Formats/Tiff/TiffEncoderTests.cs index 4317c2714d..5e663b8f3c 100644 --- a/tests/ImageSharp.Tests/Formats/Tiff/TiffEncoderTests.cs +++ b/tests/ImageSharp.Tests/Formats/Tiff/TiffEncoderTests.cs @@ -554,6 +554,27 @@ public void TiffEncoder_EncodeBiColor_WithCcittGroup3FaxCompression_WhiteIsZero_ public void TiffEncoder_EncodeBiColor_WithCcittGroup3FaxCompression_BlackIsZero_Works(TestImageProvider provider) where TPixel : unmanaged, IPixel => TestTiffEncoderCore(provider, TiffBitsPerPixel.Bit1, TiffPhotometricInterpretation.BlackIsZero, TiffCompression.CcittGroup3Fax); + [Fact] + public void TiffEncoder_EncodeNarrowCcittGroup3Fax_Works() + { + using Image image = new(1, 2000); + for (int y = 0; y < image.Height; y++) + { + image[0, y] = new L8((byte)((y & 1) == 0 ? 255 : 0)); + } + + TiffFrameMetadata metadata = image.Frames.RootFrame.Metadata.GetTiffMetadata(); + metadata.BitsPerPixel = TiffBitsPerPixel.Bit1; + metadata.Compression = TiffCompression.CcittGroup3Fax; + + using MemoryStream output = new(); + image.Save(output, new TiffEncoder()); + + output.Position = 0; + using Image decoded = Image.Load(output); + Assert.Equal(image.Size, decoded.Size); + } + [Theory] [WithFile(Issues2255, PixelTypes.Rgba32)] public void TiffEncoder_EncodeBiColor_WithCcittGroup3FaxCompression_WithoutSpecifyingBitPerPixel_Works(TestImageProvider provider) From cdc79d0c0363c47ac73a5cabe17c180f429d0e6d Mon Sep 17 00:00:00 2001 From: James Jackson-South Date: Sat, 5 Sep 2026 01:29:53 +1000 Subject: [PATCH 13/27] Cover narrow CCITT Group 4 re-encoding --- .../Formats/Tiff/TiffEncoderTests.cs | 71 +++++++++++++++++++ 1 file changed, 71 insertions(+) diff --git a/tests/ImageSharp.Tests/Formats/Tiff/TiffEncoderTests.cs b/tests/ImageSharp.Tests/Formats/Tiff/TiffEncoderTests.cs index 5e663b8f3c..0ac94acb49 100644 --- a/tests/ImageSharp.Tests/Formats/Tiff/TiffEncoderTests.cs +++ b/tests/ImageSharp.Tests/Formats/Tiff/TiffEncoderTests.cs @@ -590,6 +590,20 @@ public void TiffEncoder_EncodeBiColor_WithCcittGroup4FaxCompression_WhiteIsZero_ public void TiffEncoder_EncodeBiColor_WithCcittGroup4FaxCompression_BlackIsZero_Works(TestImageProvider provider) where TPixel : unmanaged, IPixel => TestTiffEncoderCore(provider, TiffBitsPerPixel.Bit1, TiffPhotometricInterpretation.BlackIsZero, TiffCompression.CcittGroup4Fax); + [Fact] + public void TiffEncoder_ReencodeNarrowCcittGroup4Fax_Works() + { + byte[] data = BuildCcittGroup4Tiff([0x80, 0x08, 0x00, 0x80]); + + using Image image = Image.Load(data); + using MemoryStream output = new(); + image.Save(output, new TiffEncoder()); + + output.Position = 0; + using Image decoded = Image.Load(output); + Assert.Equal(new Size(1, 1), decoded.Size); + } + [Theory] [WithFile(Calliphora_BiColorUncompressed, PixelTypes.Rgba32)] public void TiffEncoder_EncodeBiColor_WithModifiedHuffmanCompression_WhiteIsZero_Works(TestImageProvider provider) @@ -647,4 +661,61 @@ public void TiffEncode_WorksWithDiscontiguousBuffers(TestImageProvider Date: Sat, 5 Sep 2026 01:32:03 +1000 Subject: [PATCH 14/27] Reject unsupported ICC conversion channels --- .../Icc/Calculators/ClutCalculator.cs | 4 + .../Icc/Calculators/LutEntryCalculator.cs | 5 + .../Icc/Calculators/TrcCalculator.cs | 4 + .../Formats/Png/PngDecoderTests.Icc.cs | 124 ++++++++++++++++++ 4 files changed, 137 insertions(+) create mode 100644 tests/ImageSharp.Tests/Formats/Png/PngDecoderTests.Icc.cs diff --git a/src/ImageSharp/ColorProfiles/Icc/Calculators/ClutCalculator.cs b/src/ImageSharp/ColorProfiles/Icc/Calculators/ClutCalculator.cs index 82d475e578..b33d7dd449 100644 --- a/src/ImageSharp/ColorProfiles/Icc/Calculators/ClutCalculator.cs +++ b/src/ImageSharp/ColorProfiles/Icc/Calculators/ClutCalculator.cs @@ -42,6 +42,10 @@ public ClutCalculator(IccClut clut) Guard.NotNull(clut, nameof(clut)); Guard.MustBeGreaterThan(clut.InputChannelCount, 0, nameof(clut.InputChannelCount)); Guard.MustBeGreaterThan(clut.OutputChannelCount, 0, nameof(clut.OutputChannelCount)); + if (clut.InputChannelCount > 4 || clut.OutputChannelCount > 4) + { + throw new InvalidIccProfileException("ICC conversion supports at most four input and output channels."); + } this.inputCount = clut.InputChannelCount; this.outputCount = clut.OutputChannelCount; diff --git a/src/ImageSharp/ColorProfiles/Icc/Calculators/LutEntryCalculator.cs b/src/ImageSharp/ColorProfiles/Icc/Calculators/LutEntryCalculator.cs index c97578ee3f..08f957bbe5 100644 --- a/src/ImageSharp/ColorProfiles/Icc/Calculators/LutEntryCalculator.cs +++ b/src/ImageSharp/ColorProfiles/Icc/Calculators/LutEntryCalculator.cs @@ -59,6 +59,11 @@ private static Vector4 CalculateLut(LutCalculator[] lut, Vector4 value) private void Init(IccLut[] inputCurve, IccLut[] outputCurve, IccClut clut, Matrix4x4 matrix) { + if (inputCurve.Length > 4 || outputCurve.Length > 4) + { + throw new InvalidIccProfileException("ICC conversion supports at most four input and output channels."); + } + this.inputCurve = InitLut(inputCurve); this.outputCurve = InitLut(outputCurve); this.clutCalculator = new ClutCalculator(clut); diff --git a/src/ImageSharp/ColorProfiles/Icc/Calculators/TrcCalculator.cs b/src/ImageSharp/ColorProfiles/Icc/Calculators/TrcCalculator.cs index d2fc5d9b55..029be68c51 100644 --- a/src/ImageSharp/ColorProfiles/Icc/Calculators/TrcCalculator.cs +++ b/src/ImageSharp/ColorProfiles/Icc/Calculators/TrcCalculator.cs @@ -15,6 +15,10 @@ internal class TrcCalculator : IVector4Calculator public TrcCalculator(IccTagDataEntry[] entries, bool inverted) { Guard.NotNull(entries, nameof(entries)); + if (entries.Length > 4) + { + throw new InvalidIccProfileException("ICC conversion supports at most four tone response curves."); + } this.calculators = new ISingleCalculator[entries.Length]; for (int i = 0; i < entries.Length; i++) diff --git a/tests/ImageSharp.Tests/Formats/Png/PngDecoderTests.Icc.cs b/tests/ImageSharp.Tests/Formats/Png/PngDecoderTests.Icc.cs new file mode 100644 index 0000000000..25632212c6 --- /dev/null +++ b/tests/ImageSharp.Tests/Formats/Png/PngDecoderTests.Icc.cs @@ -0,0 +1,124 @@ +// Copyright (c) Six Labors. +// Licensed under the Six Labors Split License. + +using System.Buffers.Binary; +using System.Text; +using SixLabors.ImageSharp.Formats; +using SixLabors.ImageSharp.Formats.Png; +using SixLabors.ImageSharp.Metadata.Profiles.Icc; +using SixLabors.ImageSharp.PixelFormats; + +namespace SixLabors.ImageSharp.Tests.Formats.Png; + +public partial class PngDecoderTests +{ + [Fact] + public void Decode_IccLutExceedsVectorChannelCount_Throws() + { + byte[] profileData = BuildLut16Profile(3, 15, 2, 2, 2); + byte[] pngData = BuildPng(profileData); + DecoderOptions options = new() { ColorProfileHandling = ColorProfileHandling.Convert }; + + Assert.Throws(() => Image.Load(options, pngData)); + } + + private static byte[] BuildLut16Profile(int inputChannels, int outputChannels, int clutPoints, int inputTableLength, int outputTableLength) + { + using MemoryStream stream = new(); + + byte[] header = new byte[128]; + BinaryPrimitives.WriteUInt32BigEndian(header.AsSpan(8), 0x04300000U); + Encoding.ASCII.GetBytes("mntr").CopyTo(header, 12); + Encoding.ASCII.GetBytes("RGB ").CopyTo(header, 16); + Encoding.ASCII.GetBytes("XYZ ").CopyTo(header, 20); + stream.Write(header); + + WriteUInt32(1); + stream.Write(Encoding.ASCII.GetBytes("A2B0")); + long offsetPosition = stream.Position; + WriteUInt32(0); + long sizePosition = stream.Position; + WriteUInt32(0); + + long tagStart = stream.Position; + stream.Write(Encoding.ASCII.GetBytes("mft2")); + WriteUInt32(0); + stream.WriteByte((byte)inputChannels); + stream.WriteByte((byte)outputChannels); + stream.WriteByte((byte)clutPoints); + stream.WriteByte(0); + + for (int y = 0; y < 3; y++) + { + for (int x = 0; x < 3; x++) + { + WriteFix16(x == y ? 1D : 0D); + } + } + + WriteUInt16((ushort)inputTableLength); + WriteUInt16((ushort)outputTableLength); + + for (int channel = 0; channel < inputChannels; channel++) + { + for (int i = 0; i < inputTableLength; i++) + { + WriteUInt16((ushort)(i == 0 ? 0 : ushort.MaxValue)); + } + } + + int clutLength = (int)Math.Pow(clutPoints, inputChannels); + for (int i = 0; i < clutLength; i++) + { + for (int channel = 0; channel < outputChannels; channel++) + { + WriteUInt16(0x8000); + } + } + + for (int channel = 0; channel < outputChannels; channel++) + { + for (int i = 0; i < outputTableLength; i++) + { + WriteUInt16((ushort)(i == 0 ? 0 : ushort.MaxValue)); + } + } + + long tagEnd = stream.Position; + byte[] result = stream.ToArray(); + BinaryPrimitives.WriteUInt32BigEndian(result.AsSpan((int)offsetPosition), (uint)tagStart); + BinaryPrimitives.WriteUInt32BigEndian(result.AsSpan((int)sizePosition), (uint)(tagEnd - tagStart)); + BinaryPrimitives.WriteUInt32BigEndian(result, (uint)result.Length); + return result; + + void WriteUInt32(uint value) + { + Span buffer = stackalloc byte[4]; + BinaryPrimitives.WriteUInt32BigEndian(buffer, value); + stream.Write(buffer); + } + + void WriteUInt16(ushort value) + { + Span buffer = stackalloc byte[2]; + BinaryPrimitives.WriteUInt16BigEndian(buffer, value); + stream.Write(buffer); + } + + void WriteFix16(double value) + { + int rawValue = (int)Math.Round(value * 65536D); + WriteUInt32(unchecked((uint)rawValue)); + } + } + + private static byte[] BuildPng(byte[] profileData) + { + using Image image = new(16, 16); + image.Metadata.IccProfile = new IccProfile(profileData); + + using MemoryStream stream = new(); + image.SaveAsPng(stream); + return stream.ToArray(); + } +} From 3c43cf583fdd98eff0f451397affaa31c6a2e6b1 Mon Sep 17 00:00:00 2001 From: James Jackson-South Date: Sat, 5 Sep 2026 01:33:42 +1000 Subject: [PATCH 15/27] Reject short EXR ZIP output --- .../Exr/Compression/ExrBaseDecompressor.cs | 2 +- .../Formats/Exr/ExrZipDecoderTests.cs | 106 ++++++++++++++++++ 2 files changed, 107 insertions(+), 1 deletion(-) create mode 100644 tests/ImageSharp.Tests/Formats/Exr/ExrZipDecoderTests.cs diff --git a/src/ImageSharp/Formats/Exr/Compression/ExrBaseDecompressor.cs b/src/ImageSharp/Formats/Exr/Compression/ExrBaseDecompressor.cs index f598955f43..2f1320e587 100644 --- a/src/ImageSharp/Formats/Exr/Compression/ExrBaseDecompressor.cs +++ b/src/ImageSharp/Formats/Exr/Compression/ExrBaseDecompressor.cs @@ -67,7 +67,7 @@ protected static int UndoZipCompression(BufferedReadStream stream, uint compress totalRead += bytesRead; } - if (totalRead == 0) + if (totalRead != uncompressedBytes) { ExrThrowHelper.ThrowInvalidImageContentException("Could not read enough data for zip compressed EXR image data!"); } diff --git a/tests/ImageSharp.Tests/Formats/Exr/ExrZipDecoderTests.cs b/tests/ImageSharp.Tests/Formats/Exr/ExrZipDecoderTests.cs new file mode 100644 index 0000000000..afce3257fc --- /dev/null +++ b/tests/ImageSharp.Tests/Formats/Exr/ExrZipDecoderTests.cs @@ -0,0 +1,106 @@ +// Copyright (c) Six Labors. +// Licensed under the Six Labors Split License. + +using System.Buffers.Binary; +using System.IO.Compression; +using System.Text; +using SixLabors.ImageSharp.PixelFormats; + +namespace SixLabors.ImageSharp.Tests.Formats.Exr; + +[Trait("Format", "Exr")] +[ValidateDisposedMemoryAllocations] +public class ExrZipDecoderTests +{ + [Fact] + public void Decode_ShortInflatedBlock_Throws() + { + byte[] data = BuildExr(ZlibCompress(new byte[8])); + + Assert.Throws(() => Image.Load(data)); + } + + private static byte[] ZlibCompress(byte[] data) + { + using MemoryStream output = new(); + using (ZLibStream zlib = new(output, CompressionLevel.Optimal, leaveOpen: true)) + { + zlib.Write(data); + } + + return output.ToArray(); + } + + private static byte[] BuildExr(byte[] compressed) + { + const int width = 64; + const int height = 1; + + using MemoryStream output = new(); + using BinaryWriter writer = new(output); + + writer.Write(new byte[] { 0x76, 0x2F, 0x31, 0x01 }); + writer.Write((byte)2); + writer.Write(new byte[] { 0, 0, 0 }); + + using (MemoryStream channelStream = new()) + using (BinaryWriter channelWriter = new(channelStream)) + { + WriteString(channelWriter, "R"); + channelWriter.Write(2); + channelWriter.Write((byte)0); + channelWriter.Write(new byte[] { 0, 0, 0 }); + channelWriter.Write(1); + channelWriter.Write(1); + channelWriter.Write((byte)0); + + WriteAttribute(writer, "channels", "chlist", channelStream.ToArray()); + } + + WriteAttribute(writer, "compression", "compression", [2]); + + using (MemoryStream boxStream = new()) + using (BinaryWriter boxWriter = new(boxStream)) + { + boxWriter.Write(0); + boxWriter.Write(0); + boxWriter.Write(width - 1); + boxWriter.Write(height - 1); + + byte[] box = boxStream.ToArray(); + WriteAttribute(writer, "dataWindow", "box2i", box); + WriteAttribute(writer, "displayWindow", "box2i", box); + } + + WriteAttribute(writer, "lineOrder", "lineOrder", [0]); + + byte[] one = new byte[4]; + BinaryPrimitives.WriteSingleLittleEndian(one, 1F); + WriteAttribute(writer, "pixelAspectRatio", "float", one); + WriteAttribute(writer, "screenWindowCenter", "v2f", new byte[8]); + WriteAttribute(writer, "screenWindowWidth", "float", one); + writer.Write((byte)0); + + long chunkStart = output.Position + sizeof(ulong); + writer.Write((ulong)chunkStart); + writer.Write(0U); + writer.Write((uint)compressed.Length); + writer.Write(compressed); + + return output.ToArray(); + } + + private static void WriteString(BinaryWriter writer, string value) + { + writer.Write(Encoding.ASCII.GetBytes(value)); + writer.Write((byte)0); + } + + private static void WriteAttribute(BinaryWriter writer, string name, string type, byte[] value) + { + WriteString(writer, name); + WriteString(writer, type); + writer.Write(value.Length); + writer.Write(value); + } +} From 6ed2a275217d39301e76df42acec2a9533b39d2b Mon Sep 17 00:00:00 2001 From: James Jackson-South Date: Sat, 5 Sep 2026 08:49:50 +1000 Subject: [PATCH 16/27] Handle partial EXR scanline blocks --- .../Decompressors/B44ExrCompression.cs | 2 +- .../Decompressors/NoneExrCompression.cs | 6 +++--- .../Decompressors/Pxr24Compression.cs | 11 ++++++----- .../Decompressors/RunLengthExrCompression.cs | 2 +- .../Decompressors/ZipExrCompression.cs | 9 ++++----- .../Exr/Compression/ExrBaseDecompressor.cs | 7 ++++--- src/ImageSharp/Formats/Exr/ExrDecoderCore.cs | 18 ++++++++++++++++-- .../Formats/Exr/ExrZipDecoderTests.cs | 2 +- 8 files changed, 36 insertions(+), 21 deletions(-) diff --git a/src/ImageSharp/Formats/Exr/Compression/Decompressors/B44ExrCompression.cs b/src/ImageSharp/Formats/Exr/Compression/Decompressors/B44ExrCompression.cs index 2b5b740569..e58eca4e98 100644 --- a/src/ImageSharp/Formats/Exr/Compression/Decompressors/B44ExrCompression.cs +++ b/src/ImageSharp/Formats/Exr/Compression/Decompressors/B44ExrCompression.cs @@ -39,7 +39,7 @@ public B44ExrCompression(MemoryAllocator allocator, uint bytesPerBlock, uint byt } /// - public override void Decompress(BufferedReadStream stream, uint compressedBytes, Span buffer) + public override void Decompress(BufferedReadStream stream, uint compressedBytes, uint uncompressedBytes, Span buffer) { Span outputBuffer = MemoryMarshal.Cast(buffer); Span decompressed = this.tmpBuffer.GetSpan(); diff --git a/src/ImageSharp/Formats/Exr/Compression/Decompressors/NoneExrCompression.cs b/src/ImageSharp/Formats/Exr/Compression/Decompressors/NoneExrCompression.cs index 19edb31afe..b1a2094dab 100644 --- a/src/ImageSharp/Formats/Exr/Compression/Decompressors/NoneExrCompression.cs +++ b/src/ImageSharp/Formats/Exr/Compression/Decompressors/NoneExrCompression.cs @@ -25,10 +25,10 @@ public NoneExrCompression(MemoryAllocator allocator, uint bytesPerBlock, uint by } /// - public override void Decompress(BufferedReadStream stream, uint compressedBytes, Span buffer) + public override void Decompress(BufferedReadStream stream, uint compressedBytes, uint uncompressedBytes, Span buffer) { - int bytesRead = stream.Read(buffer, 0, Math.Min(buffer.Length, (int)this.BytesPerBlock)); - if (bytesRead != (int)this.BytesPerBlock) + int bytesRead = stream.Read(buffer[..(int)uncompressedBytes]); + if (bytesRead != uncompressedBytes) { ExrThrowHelper.ThrowInvalidImageContentException("Could not read enough pixel data from the stream!"); } diff --git a/src/ImageSharp/Formats/Exr/Compression/Decompressors/Pxr24Compression.cs b/src/ImageSharp/Formats/Exr/Compression/Decompressors/Pxr24Compression.cs index f45b660e7d..d621c893e5 100644 --- a/src/ImageSharp/Formats/Exr/Compression/Decompressors/Pxr24Compression.cs +++ b/src/ImageSharp/Formats/Exr/Compression/Decompressors/Pxr24Compression.cs @@ -39,19 +39,20 @@ public Pxr24Compression(MemoryAllocator allocator, uint bytesPerBlock, uint byte } /// - public override void Decompress(BufferedReadStream stream, uint compressedBytes, Span buffer) + public override void Decompress(BufferedReadStream stream, uint compressedBytes, uint uncompressedBytes, Span buffer) { - Span uncompressed = this.tmpBuffer.GetSpan(); + uint rowCount = uncompressedBytes / this.BytesPerRow; + uint packedBytes = this.pixelType == ExrPixelType.Float ? (uncompressedBytes / 4) * 3 : uncompressedBytes; + Span uncompressed = this.tmpBuffer.GetSpan()[..(int)packedBytes]; Span outputBufferHalf = MemoryMarshal.Cast(buffer); Span outputBufferFloat = MemoryMarshal.Cast(buffer); Span outputBufferUint = MemoryMarshal.Cast(buffer); - uint uncompressedBytes = this.BytesPerBlock; - UndoZipCompression(stream, compressedBytes, uncompressed, uncompressedBytes); + UndoZipCompression(stream, compressedBytes, uncompressed, packedBytes); int lastIn = 0; int outputOffset = 0; - for (int y = 0; y < this.RowsPerBlock; y++) + for (uint y = 0; y < rowCount; y++) { for (int c = 0; c < this.channelCount; c++) { diff --git a/src/ImageSharp/Formats/Exr/Compression/Decompressors/RunLengthExrCompression.cs b/src/ImageSharp/Formats/Exr/Compression/Decompressors/RunLengthExrCompression.cs index f548a81810..5ffe83af3c 100644 --- a/src/ImageSharp/Formats/Exr/Compression/Decompressors/RunLengthExrCompression.cs +++ b/src/ImageSharp/Formats/Exr/Compression/Decompressors/RunLengthExrCompression.cs @@ -26,7 +26,7 @@ public RunLengthExrCompression(MemoryAllocator allocator, uint bytesPerBlock, ui : base(allocator, bytesPerBlock, bytesPerRow, rowsPerBlock, width) => this.tmpBuffer = allocator.Allocate((int)bytesPerBlock); /// - public override void Decompress(BufferedReadStream stream, uint compressedBytes, Span buffer) + public override void Decompress(BufferedReadStream stream, uint compressedBytes, uint uncompressedBytes, Span buffer) { Span uncompressed = this.tmpBuffer.GetSpan(); int maxLength = (int)this.BytesPerBlock; diff --git a/src/ImageSharp/Formats/Exr/Compression/Decompressors/ZipExrCompression.cs b/src/ImageSharp/Formats/Exr/Compression/Decompressors/ZipExrCompression.cs index 8bab76f402..41fda54366 100644 --- a/src/ImageSharp/Formats/Exr/Compression/Decompressors/ZipExrCompression.cs +++ b/src/ImageSharp/Formats/Exr/Compression/Decompressors/ZipExrCompression.cs @@ -26,15 +26,14 @@ public ZipExrCompression(MemoryAllocator allocator, uint bytesPerBlock, uint byt : base(allocator, bytesPerBlock, bytesPerRow, rowsPerBlock, width) => this.tmpBuffer = allocator.Allocate((int)bytesPerBlock); /// - public override void Decompress(BufferedReadStream stream, uint compressedBytes, Span buffer) + public override void Decompress(BufferedReadStream stream, uint compressedBytes, uint uncompressedBytes, Span buffer) { - Span uncompressed = this.tmpBuffer.GetSpan(); + Span uncompressed = this.tmpBuffer.GetSpan()[..(int)uncompressedBytes]; - uint uncompressedBytes = (uint)buffer.Length; int totalRead = UndoZipCompression(stream, compressedBytes, uncompressed, uncompressedBytes); - Reconstruct(uncompressed, (uint)totalRead); - Interleave(uncompressed, (uint)totalRead, buffer); + Reconstruct(uncompressed, uncompressedBytes); + Interleave(uncompressed, uncompressedBytes, buffer); } /// diff --git a/src/ImageSharp/Formats/Exr/Compression/ExrBaseDecompressor.cs b/src/ImageSharp/Formats/Exr/Compression/ExrBaseDecompressor.cs index 2f1320e587..52a8164c7a 100644 --- a/src/ImageSharp/Formats/Exr/Compression/ExrBaseDecompressor.cs +++ b/src/ImageSharp/Formats/Exr/Compression/ExrBaseDecompressor.cs @@ -31,8 +31,9 @@ protected ExrBaseDecompressor(MemoryAllocator allocator, uint bytesPerBlock, uin /// /// The buffered stream to decompress. /// The compressed bytes. + /// The expected byte count for the current block. /// The buffer to write the decompressed data to. - public abstract void Decompress(BufferedReadStream stream, uint compressedBytes, Span buffer); + public abstract void Decompress(BufferedReadStream stream, uint compressedBytes, uint uncompressedBytes, Span buffer); /// /// Decompresses zip compressed data. @@ -67,9 +68,9 @@ protected static int UndoZipCompression(BufferedReadStream stream, uint compress totalRead += bytesRead; } - if (totalRead != uncompressedBytes) + if (totalRead != uncompressedBytes || dataStream.ReadByte() != -1) { - ExrThrowHelper.ThrowInvalidImageContentException("Could not read enough data for zip compressed EXR image data!"); + ExrThrowHelper.ThrowInvalidImageContentException("ZIP compressed EXR block has an invalid decompressed length."); } return totalRead; diff --git a/src/ImageSharp/Formats/Exr/ExrDecoderCore.cs b/src/ImageSharp/Formats/Exr/ExrDecoderCore.cs index 680e8d333d..9dd333d92f 100644 --- a/src/ImageSharp/Formats/Exr/ExrDecoderCore.cs +++ b/src/ImageSharp/Formats/Exr/ExrDecoderCore.cs @@ -193,9 +193,16 @@ private void DecodeFloatingPointPixelData(BufferedReadStream stream, Buf this.ValidateChunkOffset(rowOffset, stream); stream.Position = (long)rowOffset; uint rowStartIndex = this.ReadUnsignedInteger(stream); + if (rowStartIndex >= height) + { + ExrThrowHelper.ThrowInvalidImageContentException("EXR chunk row index is outside the data window."); + } uint compressedBytesCount = this.ReadUnsignedInteger(stream); - decompressor.Decompress(stream, compressedBytesCount, decompressedPixelData); + uint rowsInBlock = Math.Min(rowsPerBlock, (uint)height - rowStartIndex); + uint uncompressedBytesCount = (uint)(bytesPerRow * rowsInBlock); + + decompressor.Decompress(stream, compressedBytesCount, uncompressedBytesCount, decompressedPixelData); int offset = 0; for (uint rowIndex = rowStartIndex; rowIndex < rowStartIndex + rowsPerBlock && rowIndex < height; rowIndex++) @@ -274,9 +281,16 @@ private void DecodeUnsignedIntPixelData(BufferedReadStream stream, Buffe this.ValidateChunkOffset(rowOffset, stream); stream.Position = (long)rowOffset; uint rowStartIndex = this.ReadUnsignedInteger(stream); + if (rowStartIndex >= height) + { + ExrThrowHelper.ThrowInvalidImageContentException("EXR chunk row index is outside the data window."); + } uint compressedBytesCount = this.ReadUnsignedInteger(stream); - decompressor.Decompress(stream, compressedBytesCount, decompressedPixelData); + uint rowsInBlock = Math.Min(rowsPerBlock, (uint)height - rowStartIndex); + uint uncompressedBytesCount = (uint)(bytesPerRow * rowsInBlock); + + decompressor.Decompress(stream, compressedBytesCount, uncompressedBytesCount, decompressedPixelData); int offset = 0; for (uint rowIndex = rowStartIndex; rowIndex < rowStartIndex + rowsPerBlock && rowIndex < height; rowIndex++) diff --git a/tests/ImageSharp.Tests/Formats/Exr/ExrZipDecoderTests.cs b/tests/ImageSharp.Tests/Formats/Exr/ExrZipDecoderTests.cs index afce3257fc..23d9ccedd4 100644 --- a/tests/ImageSharp.Tests/Formats/Exr/ExrZipDecoderTests.cs +++ b/tests/ImageSharp.Tests/Formats/Exr/ExrZipDecoderTests.cs @@ -33,7 +33,7 @@ private static byte[] ZlibCompress(byte[] data) private static byte[] BuildExr(byte[] compressed) { - const int width = 64; + const int width = 256; const int height = 1; using MemoryStream output = new(); From 18532d7a746cca7a0f5e42e545963426f4e8caa8 Mon Sep 17 00:00:00 2001 From: James Jackson-South Date: Sat, 5 Sep 2026 09:31:59 +1000 Subject: [PATCH 17/27] Normalize metadata extents and preserve decoder recovery policies --- .../BufferedReadStreamExtensions.cs | 72 +++++++ .../Common/Extensions/StreamExtensions.cs | 51 ----- src/ImageSharp/Formats/Bmp/BmpDecoderCore.cs | 5 +- .../Exr/Compression/ExrBaseDecompressor.cs | 2 +- .../Sections/GifXmpApplicationExtension.cs | 4 +- .../Formats/Jpeg/JpegDecoderCore.cs | 18 +- src/ImageSharp/Formats/Png/PngDecoderCore.cs | 8 +- .../Decompressors/DeflateTiffCompression.cs | 2 +- .../Formats/Webp/BitReader/BitReaderBase.cs | 2 +- .../Formats/Webp/Chunks/WebpFrameData.cs | 4 +- .../Formats/Webp/WebpAnimationDecoder.cs | 5 +- .../Formats/Webp/WebpChunkParsingUtils.cs | 133 +++++++------ .../Formats/Webp/WebpDecoderCore.cs | 18 +- .../BufferedReadStreamExtensionsTests.cs | 60 ++++++ .../Common/StreamExtensionsTests.cs | 111 ----------- .../Formats/Bmp/BmpDecoderTests.cs | 29 ++- .../Formats/WebP/WebpMetaDataTests.cs | 176 +++++++++++++++++- .../IO/ChunkedMemoryStreamTests.cs | 4 +- 18 files changed, 440 insertions(+), 264 deletions(-) create mode 100644 src/ImageSharp/Common/Extensions/BufferedReadStreamExtensions.cs create mode 100644 tests/ImageSharp.Tests/Common/BufferedReadStreamExtensionsTests.cs delete mode 100644 tests/ImageSharp.Tests/Common/StreamExtensionsTests.cs diff --git a/src/ImageSharp/Common/Extensions/BufferedReadStreamExtensions.cs b/src/ImageSharp/Common/Extensions/BufferedReadStreamExtensions.cs new file mode 100644 index 0000000000..e90aba7138 --- /dev/null +++ b/src/ImageSharp/Common/Extensions/BufferedReadStreamExtensions.cs @@ -0,0 +1,72 @@ +// Copyright (c) Six Labors. +// Licensed under the Six Labors Split License. + +using SixLabors.ImageSharp.IO; + +namespace SixLabors.ImageSharp; + +/// +/// Extension methods for the type. +/// +internal static class BufferedReadStreamExtensions +{ + /// + /// Determines whether the complete read range is contained in the stream. + /// + /// The stream containing the data. + /// The absolute start of the range. + /// The number of bytes in the range. + /// Whether the range is contained in the stream. + public static bool IsReadRangeValid(this BufferedReadStream stream, long offset, ulong length) + { + // Compare the offset first so subtraction cannot underflow, and avoid + // adding an untrusted length to the offset where it could wrap around. + ulong streamLength = (ulong)stream.Length; + return (ulong)offset <= streamLength && length <= streamLength - (ulong)offset; + } + + /// + /// Gets a buffer length when the complete read fits in both the stream and an integer-sized buffer. + /// + /// The stream containing the data. + /// The declared length in bytes. + /// The validated length, or zero when the range is invalid. + /// Whether the complete read is valid. + public static bool TryGetReadLength(this BufferedReadStream stream, ulong length, out int bufferLength) + { + if (length > int.MaxValue || !stream.IsReadRangeValid(stream.Position, length)) + { + bufferLength = 0; + return false; + } + + bufferLength = (int)length; + return true; + } + + /// + /// Reads data from the stream into a slice of the provided buffer. + /// + /// The stream. + /// The buffer. + /// The offset within the buffer where bytes are read into. + /// The number of bytes, if available, to read. + /// The actual number of bytes read. + public static int Read(this BufferedReadStream stream, Span buffer, int offset, int count) + => stream.Read(buffer.Slice(offset, count)); + + /// + /// Advances the stream by the specified number of bytes. Nonpositive counts are ignored. + /// + /// The stream. + /// The number of bytes to skip. + public static void Skip(this BufferedReadStream stream, int count) + { + if (count > 0) + { + // BufferedReadStream is always seekable; its position setter preserves + // buffered data when the destination is inside the current buffer. + stream.Position += count; + } + } +} diff --git a/src/ImageSharp/Common/Extensions/StreamExtensions.cs b/src/ImageSharp/Common/Extensions/StreamExtensions.cs index 7ed3348240..13761d4d2a 100644 --- a/src/ImageSharp/Common/Extensions/StreamExtensions.cs +++ b/src/ImageSharp/Common/Extensions/StreamExtensions.cs @@ -1,8 +1,6 @@ // Copyright (c) Six Labors. // Licensed under the Six Labors Split License. -using System.Buffers; - namespace SixLabors.ImageSharp; /// @@ -19,53 +17,4 @@ internal static class StreamExtensions /// The number of bytes to write to the stream. public static void Write(this Stream stream, Span buffer, int offset, int count) => stream.Write(buffer.Slice(offset, count)); - - /// - /// Reads data from a stream into the provided buffer. - /// - /// The stream. - /// The buffer. - /// The offset within the buffer where the bytes are read into. - /// The number of bytes, if available, to read. - /// The actual number of bytes read. - public static int Read(this Stream stream, Span buffer, int offset, int count) - => stream.Read(buffer.Slice(offset, count)); - - /// - /// Skips the number of bytes in the given stream. - /// - /// The stream. - /// A byte offset relative to the origin parameter. - public static void Skip(this Stream stream, int count) - { - if (count < 1) - { - return; - } - - if (stream.CanSeek) - { - stream.Seek(count, SeekOrigin.Current); - return; - } - - byte[] buffer = ArrayPool.Shared.Rent(count); - try - { - while (count > 0) - { - int bytesRead = stream.Read(buffer, 0, count); - if (bytesRead == 0) - { - break; - } - - count -= bytesRead; - } - } - finally - { - ArrayPool.Shared.Return(buffer); - } - } } diff --git a/src/ImageSharp/Formats/Bmp/BmpDecoderCore.cs b/src/ImageSharp/Formats/Bmp/BmpDecoderCore.cs index b5ca4c26e9..95f02503e9 100644 --- a/src/ImageSharp/Formats/Bmp/BmpDecoderCore.cs +++ b/src/ImageSharp/Formats/Bmp/BmpDecoderCore.cs @@ -1429,7 +1429,7 @@ private void ReadInfoHeader(BufferedReadStream stream) // > 108 bytes infoHeaderType = BmpInfoHeaderType.WinVersion5; this.infoHeader = BmpInfoHeader.ParseV5(buffer); - if (this.infoHeader.ProfileData != 0 && this.infoHeader.ProfileSize != 0) + if (!this.Options.SkipMetadata && this.infoHeader.ProfileData != 0 && this.infoHeader.ProfileSize != 0) { long streamPosition = stream.Position; this.ExecuteAncillarySegmentAction(() => this.ReadIccProfile(stream, this.metadata, infoHeaderStart)); @@ -1477,8 +1477,7 @@ private void ReadIccProfile(BufferedReadStream stream, ImageMetadata imageMetada long profileStart = infoHeaderStart + this.infoHeader.ProfileData; if (this.infoHeader.ProfileData < 0 || this.infoHeader.ProfileSize <= 0 || - profileStart > stream.Length || - this.infoHeader.ProfileSize > stream.Length - profileStart) + !stream.IsReadRangeValid(profileStart, (uint)this.infoHeader.ProfileSize)) { BmpThrowHelper.ThrowInvalidImageContentException("Not enough data to read BMP ICC profile."); } diff --git a/src/ImageSharp/Formats/Exr/Compression/ExrBaseDecompressor.cs b/src/ImageSharp/Formats/Exr/Compression/ExrBaseDecompressor.cs index 52a8164c7a..afd32ed663 100644 --- a/src/ImageSharp/Formats/Exr/Compression/ExrBaseDecompressor.cs +++ b/src/ImageSharp/Formats/Exr/Compression/ExrBaseDecompressor.cs @@ -59,7 +59,7 @@ protected static int UndoZipCompression(BufferedReadStream stream, uint compress int totalRead = 0; while (totalRead < uncompressedBytes) { - int bytesRead = dataStream.Read(uncompressed, totalRead, (int)uncompressedBytes - totalRead); + int bytesRead = dataStream.Read(uncompressed.Slice(totalRead, (int)uncompressedBytes - totalRead)); if (bytesRead <= 0) { break; diff --git a/src/ImageSharp/Formats/Gif/Sections/GifXmpApplicationExtension.cs b/src/ImageSharp/Formats/Gif/Sections/GifXmpApplicationExtension.cs index 85c4959a98..aa3df4a4fd 100644 --- a/src/ImageSharp/Formats/Gif/Sections/GifXmpApplicationExtension.cs +++ b/src/ImageSharp/Formats/Gif/Sections/GifXmpApplicationExtension.cs @@ -28,7 +28,7 @@ namespace SixLabors.ImageSharp.Formats.Gif; /// The stream to read from. /// The memory allocator. /// The XMP metadata - public static GifXmpApplicationExtension Read(Stream stream, MemoryAllocator allocator) + public static GifXmpApplicationExtension Read(BufferedReadStream stream, MemoryAllocator allocator) { byte[] xmpBytes = ReadXmpData(stream, allocator, out bool terminated); if (!terminated) @@ -75,7 +75,7 @@ public int WriteTo(Span buffer) return this.ContentLength; } - private static byte[] ReadXmpData(Stream stream, MemoryAllocator allocator, out bool terminated) + private static byte[] ReadXmpData(BufferedReadStream stream, MemoryAllocator allocator, out bool terminated) { using ChunkedMemoryStream bytes = new(allocator); diff --git a/src/ImageSharp/Formats/Jpeg/JpegDecoderCore.cs b/src/ImageSharp/Formats/Jpeg/JpegDecoderCore.cs index ae26914e8e..05cdd9cc77 100644 --- a/src/ImageSharp/Formats/Jpeg/JpegDecoderCore.cs +++ b/src/ImageSharp/Formats/Jpeg/JpegDecoderCore.cs @@ -274,10 +274,9 @@ public void LoadTables(byte[] tableBytes, IJpegScanDecoder scanDecoder) // Get the marker length. int markerContentByteSize = ReadUint16(stream, markerBuffer) - 2; - // Check whether the stream actually has enough bytes to read - // markerContentByteSize is always positive so we cast - // to uint to avoid sign extension - if (stream.RemainingBytes < (uint)markerContentByteSize) + // Validate the entire segment before parsing it. Casting directly + // to ulong also rejects lengths smaller than the two-byte length field. + if (!stream.IsReadRangeValid(stream.Position, (ulong)markerContentByteSize)) { JpegThrowHelper.ThrowNotEnoughBytesForMarker(fileMarker.Marker); } @@ -351,10 +350,9 @@ internal void ParseStream(BufferedReadStream stream, SpectralConverter spectralC // Get the marker length. int markerContentByteSize = ReadUint16(stream, markerBuffer) - 2; - // Check whether stream actually has enough bytes to read - // markerContentByteSize is always positive so we cast - // to uint to avoid sign extension. - if (stream.RemainingBytes < (uint)markerContentByteSize) + // Validate the entire segment before parsing it. Casting directly + // to ulong also rejects lengths smaller than the two-byte length field. + if (!stream.IsReadRangeValid(stream.Position, (ulong)markerContentByteSize)) { if (metadataOnly && this.Metadata != null && this.Frame != null) { @@ -841,7 +839,7 @@ private void ProcessApplicationHeaderMarker(BufferedReadStream stream, int remai // TODO: thumbnail if (remaining > 0) { - if (stream.Position + remaining >= stream.Length) + if (!stream.IsReadRangeValid(stream.Position, (ulong)remaining + 1)) { this.ThrowOrIgnoreNonStrictSegmentError("Bad App0 Marker length."); stream.Skip(remaining); @@ -877,7 +875,7 @@ private void ProcessApp1Marker(BufferedReadStream stream, int remaining) return; } - if (stream.Position + remaining >= stream.Length) + if (!stream.IsReadRangeValid(stream.Position, (ulong)remaining + 1)) { this.ThrowOrIgnoreNonStrictSegmentError("Bad App1 Marker length."); stream.Skip(remaining); diff --git a/src/ImageSharp/Formats/Png/PngDecoderCore.cs b/src/ImageSharp/Formats/Png/PngDecoderCore.cs index d4b3828453..7693c78d45 100644 --- a/src/ImageSharp/Formats/Png/PngDecoderCore.cs +++ b/src/ImageSharp/Formats/Png/PngDecoderCore.cs @@ -884,7 +884,7 @@ private void DecodePixelDataCore( while (currentRowBytesRead < bytesPerFrameScanline) { - int bytesRead = compressedStream.Read(scanSpan, currentRowBytesRead, bytesPerFrameScanline - currentRowBytesRead); + int bytesRead = compressedStream.Read(scanSpan.Slice(currentRowBytesRead, bytesPerFrameScanline - currentRowBytesRead)); if (bytesRead <= 0) { goto EXIT; @@ -1016,7 +1016,7 @@ private void DecodeInterlacedPixelDataCore( cancellationToken.ThrowIfCancellationRequested(); while (currentRowBytesRead < bytesPerInterlaceScanline) { - int bytesRead = compressedStream.Read(this.scanline.GetSpan(), currentRowBytesRead, bytesPerInterlaceScanline - currentRowBytesRead); + int bytesRead = compressedStream.Read(this.scanline.GetSpan().Slice(currentRowBytesRead, bytesPerInterlaceScanline - currentRowBytesRead)); if (bytesRead <= 0) { goto EXIT; @@ -1984,7 +1984,7 @@ private unsafe bool TryDecompressZlibData(ReadOnlySpan compressedData, int return false; } - int bytesRead = inflateStream.CompressedStream.Read(destUncompressedData, 0, destUncompressedData.Length); + int bytesRead = inflateStream.CompressedStream.Read(destUncompressedData); while (bytesRead != 0) { if (memoryStreamOutput.Length > maxLength) @@ -1994,7 +1994,7 @@ private unsafe bool TryDecompressZlibData(ReadOnlySpan compressedData, int } memoryStreamOutput.Write(destUncompressedData[..bytesRead]); - bytesRead = inflateStream.CompressedStream.Read(destUncompressedData, 0, destUncompressedData.Length); + bytesRead = inflateStream.CompressedStream.Read(destUncompressedData); } uncompressedBytesArray = memoryStreamOutput.ToArray(); diff --git a/src/ImageSharp/Formats/Tiff/Compression/Decompressors/DeflateTiffCompression.cs b/src/ImageSharp/Formats/Tiff/Compression/Decompressors/DeflateTiffCompression.cs index d3b65c537c..a09a39b52b 100644 --- a/src/ImageSharp/Formats/Tiff/Compression/Decompressors/DeflateTiffCompression.cs +++ b/src/ImageSharp/Formats/Tiff/Compression/Decompressors/DeflateTiffCompression.cs @@ -69,7 +69,7 @@ protected override void Decompress(BufferedReadStream stream, int byteCount, int int totalRead = 0; while (totalRead < buffer.Length) { - int bytesRead = dataStream.Read(buffer, totalRead, buffer.Length - totalRead); + int bytesRead = dataStream.Read(buffer[totalRead..]); if (bytesRead <= 0) { break; diff --git a/src/ImageSharp/Formats/Webp/BitReader/BitReaderBase.cs b/src/ImageSharp/Formats/Webp/BitReader/BitReaderBase.cs index 2b843cc8f6..4276199ee0 100644 --- a/src/ImageSharp/Formats/Webp/BitReader/BitReaderBase.cs +++ b/src/ImageSharp/Formats/Webp/BitReader/BitReaderBase.cs @@ -34,7 +34,7 @@ protected static IMemoryOwner ReadImageDataFromStream(Stream input, int by { IMemoryOwner data = memoryAllocator.Allocate(bytesToRead, AllocationOptions.Clean); Span dataSpan = data.Memory.Span; - input.Read(dataSpan[..bytesToRead], 0, bytesToRead); + input.Read(dataSpan[..bytesToRead]); return data; } diff --git a/src/ImageSharp/Formats/Webp/Chunks/WebpFrameData.cs b/src/ImageSharp/Formats/Webp/Chunks/WebpFrameData.cs index 7d22f7f2b3..e7b135dc96 100644 --- a/src/ImageSharp/Formats/Webp/Chunks/WebpFrameData.cs +++ b/src/ImageSharp/Formats/Webp/Chunks/WebpFrameData.cs @@ -1,6 +1,8 @@ // Copyright (c) Six Labors. // Licensed under the Six Labors Split License. +using SixLabors.ImageSharp.IO; + namespace SixLabors.ImageSharp.Formats.Webp.Chunks; internal readonly struct WebpFrameData @@ -120,7 +122,7 @@ public long WriteHeaderTo(Stream stream) /// /// The stream to read from. /// Animation frame data. - public static WebpFrameData Parse(Stream stream) + public static WebpFrameData Parse(BufferedReadStream stream) { Span buffer = stackalloc byte[4]; diff --git a/src/ImageSharp/Formats/Webp/WebpAnimationDecoder.cs b/src/ImageSharp/Formats/Webp/WebpAnimationDecoder.cs index 8a8ad823dc..9ea86d0c9f 100644 --- a/src/ImageSharp/Formats/Webp/WebpAnimationDecoder.cs +++ b/src/ImageSharp/Formats/Webp/WebpAnimationDecoder.cs @@ -381,10 +381,7 @@ private void ReadOptionalChunk( switch (chunkType) { case WebpChunkType.Iccp: - - // While ICC profiles are optional, an invalid ICC profile cannot be ignored because it must - // precede the frame data, and we cannot safely skip it without successfully reading its size. - WebpChunkParsingUtils.ReadIccProfile(stream, imageMetadata, ignoreMetadata); + WebpChunkParsingUtils.ReadIccProfile(stream, imageMetadata, ignoreMetadata, this.executeAncillarySegmentAction); break; case WebpChunkType.Exif: this.executeAncillarySegmentAction(() => WebpChunkParsingUtils.ReadExifProfile(stream, imageMetadata, ignoreMetadata)); diff --git a/src/ImageSharp/Formats/Webp/WebpChunkParsingUtils.cs b/src/ImageSharp/Formats/Webp/WebpChunkParsingUtils.cs index 1e334057e7..958f4df80b 100644 --- a/src/ImageSharp/Formats/Webp/WebpChunkParsingUtils.cs +++ b/src/ImageSharp/Formats/Webp/WebpChunkParsingUtils.cs @@ -262,7 +262,7 @@ public static WebpImageInfo ReadVp8XHeader(BufferedReadStream stream, Span /// /// Thrown if the input stream is not valid. /// - public static uint ReadUInt24LittleEndian(Stream stream, Span buffer) + public static uint ReadUInt24LittleEndian(BufferedReadStream stream, Span buffer) { if (stream.Read(buffer, 0, 3) == 3) { @@ -306,12 +306,33 @@ public static unsafe void WriteUInt24LittleEndian(Stream stream, uint data) /// If true, the chunk size is required to be read, otherwise it can be skipped. /// The chunk size in bytes. /// Thrown if the input stream is not valid. - public static uint ReadChunkSize(Stream stream, Span buffer, bool required = true) + public static uint ReadChunkSize(BufferedReadStream stream, Span buffer, bool required = true) + { + ulong chunkSize = ReadPaddedChunkSize(stream, buffer, required); + + // Structural chunk sizes must remain representable by their uint-sized consumers. + // Metadata readers retain the wider extent so their recovery can skip it safely. + if (chunkSize > uint.MaxValue) + { + WebpThrowHelper.ThrowInvalidImageContentException("WebP chunk size exceeds the supported maximum."); + } + + return (uint)chunkSize; + } + + /// + /// Reads a chunk's complete padded extent without wrapping a uint-sized payload length. + /// + /// The input stream. + /// The four-byte size buffer. + /// Whether an incomplete size field is an error. + /// The padded extent, or remaining bytes when an optional size field is incomplete. + private static ulong ReadPaddedChunkSize(BufferedReadStream stream, Span buffer, bool required) { if (stream.Read(buffer) is 4) { uint chunkSize = BinaryPrimitives.ReadUInt32LittleEndian(buffer); - return chunkSize % 2 is 0 ? chunkSize : chunkSize + 1; + return (ulong)chunkSize + (chunkSize & 1); } if (required) @@ -320,7 +341,7 @@ public static uint ReadChunkSize(Stream stream, Span buffer, bool required } // Return the size of the remaining data in the stream. - return (uint)(stream.Length - stream.Position); + return (ulong)stream.RemainingBytes; } /// @@ -349,34 +370,36 @@ public static WebpChunkType ReadChunkType(BufferedReadStream stream, Span /// The stream to decode from. /// The image metadata. /// If true, metadata will be ignored. + /// Executes profile parsing under the decoder's integrity policy. public static void ReadIccProfile( BufferedReadStream stream, ImageMetadata metadata, - bool ignoreMetadata) + bool ignoreMetadata, + Action executeAncillarySegmentAction) { - Span buffer = stackalloc byte[4]; - int iccpChunkSize = ValidateMetadataChunkSize(stream, ReadChunkSize(stream, buffer), "ICCP"); - if (ignoreMetadata || metadata.IccProfile != null) + ulong chunkSize = ReadPaddedChunkSize(stream, stackalloc byte[4], true); + + // ICCP precedes image/frame data. Its framing must be readable even when + // metadata is skipped; otherwise there is no safe location to resume decoding. + if (!stream.IsReadRangeValid(stream.Position, chunkSize)) { - stream.Skip(iccpChunkSize); + WebpThrowHelper.ThrowInvalidImageContentException("Not enough data to read the ICCP chunk."); } - else + + executeAncillarySegmentAction(() => { - byte[] iccpData = new byte[iccpChunkSize]; - int bytesRead = stream.Read(iccpData, 0, iccpChunkSize); - if (bytesRead != iccpChunkSize) + byte[]? iccpData = ReadMetadataChunk(stream, chunkSize, ignoreMetadata || metadata.IccProfile != null); + if (iccpData is not null) { - WebpThrowHelper.ThrowInvalidImageContentException("Not enough data to read the iccp chunk"); - } + IccProfile profile = new(iccpData); + if (!profile.CheckIsValid()) + { + throw new InvalidIccProfileException("Invalid ICC profile."); + } - IccProfile profile = new(iccpData); - if (!profile.CheckIsValid()) - { - throw new InvalidIccProfileException("Invalid ICC profile."); + metadata.IccProfile = profile; } - - metadata.IccProfile = profile; - } + }); } /// @@ -390,21 +413,10 @@ public static void ReadExifProfile( ImageMetadata metadata, bool ignoreMetadata) { - Span buffer = stackalloc byte[4]; - int exifChunkSize = ValidateMetadataChunkSize(stream, ReadChunkSize(stream, buffer), "EXIF"); - if (ignoreMetadata || metadata.ExifProfile != null) - { - stream.Skip(exifChunkSize); - } - else + ulong chunkSize = ReadPaddedChunkSize(stream, stackalloc byte[4], !ignoreMetadata); + byte[]? exifData = ReadMetadataChunk(stream, chunkSize, ignoreMetadata || metadata.ExifProfile != null); + if (exifData is not null) { - byte[] exifData = new byte[exifChunkSize]; - int bytesRead = stream.Read(exifData, 0, exifChunkSize); - if (bytesRead != exifChunkSize) - { - WebpThrowHelper.ThrowInvalidImageContentException("Could not read enough data for the EXIF profile"); - } - ExifProfile exifProfile = new(exifData); // Set the resolution from the metadata. @@ -433,33 +445,46 @@ public static void ReadXmpProfile( ImageMetadata metadata, bool ignoreMetadata) { - Span buffer = stackalloc byte[4]; - int xmpChunkSize = ValidateMetadataChunkSize(stream, ReadChunkSize(stream, buffer), "XMP"); - if (ignoreMetadata || metadata.XmpProfile != null) + ulong chunkSize = ReadPaddedChunkSize(stream, stackalloc byte[4], !ignoreMetadata); + byte[]? xmpData = ReadMetadataChunk(stream, chunkSize, ignoreMetadata || metadata.XmpProfile != null); + if (xmpData is not null) { - stream.Skip(xmpChunkSize); - } - else - { - byte[] xmpData = new byte[xmpChunkSize]; - int bytesRead = stream.Read(xmpData, 0, xmpChunkSize); - if (bytesRead != xmpChunkSize) - { - WebpThrowHelper.ThrowInvalidImageContentException("Could not read enough data for the XMP profile"); - } - metadata.XmpProfile = new XmpProfile(xmpData); } } - private static int ValidateMetadataChunkSize(BufferedReadStream stream, uint chunkSize, string chunkName) + /// + /// Reads a metadata payload, leaving the stream at the next chunk or EOF on a recoverable error. + /// Callers execute metadata parsing under the decoder's ancillary integrity policy. + /// + /// The input stream positioned at the chunk payload. + /// The declared extent including its padding byte. + /// Whether to skip the payload without parsing it. + /// The payload, or null when metadata is skipped. + private static byte[]? ReadMetadataChunk(BufferedReadStream stream, ulong paddedLength, bool ignoreMetadata) { - if (chunkSize > int.MaxValue || chunkSize > stream.Length - stream.Position) + long chunkEnd = stream.Position + (long)Math.Min(paddedLength, (ulong)stream.RemainingBytes); + if (ignoreMetadata) + { + stream.Position = chunkEnd; + return null; + } + + if (!stream.TryGetReadLength(paddedLength, out int bufferLength)) + { + // Ignoring an ancillary error must not make the next parser interpret + // this payload as another chunk header. A truncated chunk consumes EOF. + stream.Position = chunkEnd; + WebpThrowHelper.ThrowInvalidImageContentException("Not enough data to read the metadata chunk."); + } + + byte[] data = new byte[bufferLength]; + if (stream.Read(data) != bufferLength) { - WebpThrowHelper.ThrowInvalidImageContentException($"Not enough data to read the {chunkName} chunk"); + WebpThrowHelper.ThrowInvalidImageContentException("Not enough data to read the metadata chunk."); } - return (int)chunkSize; + return data; } private static double GetExifResolutionValue(ExifProfile exifProfile, ExifTag tag) diff --git a/src/ImageSharp/Formats/Webp/WebpDecoderCore.cs b/src/ImageSharp/Formats/Webp/WebpDecoderCore.cs index 55bdca69cb..cbff54e669 100644 --- a/src/ImageSharp/Formats/Webp/WebpDecoderCore.cs +++ b/src/ImageSharp/Formats/Webp/WebpDecoderCore.cs @@ -92,6 +92,13 @@ protected override Image Decode(BufferedReadStream stream, Cance return animationDecoder.Decode(stream, this.webImageInfo.Features, this.webImageInfo.Width, this.webImageInfo.Height, fileSize); } + // A VP8X header alone describes a canvas, not decodable image data. + // Ignoring a truncated optional chunk must not bypass this requirement. + if (this.webImageInfo.Vp8BitReader is null && this.webImageInfo.Vp8LBitReader is null) + { + WebpThrowHelper.ThrowInvalidImageContentException("Missing WebP image data."); + } + image = new Image(this.configuration, (int)this.webImageInfo.Width, (int)this.webImageInfo.Height, metadata); Buffer2D pixels = image.GetRootFramePixelBuffer(); if (this.webImageInfo.IsLossless) @@ -278,10 +285,7 @@ private bool ParseOptionalExtendedChunks( switch (chunkType) { case WebpChunkType.Iccp: - - // While ICC profiles are optional, an invalid ICC profile cannot be ignored because it must - // precede the image data, and we cannot safely skip it without successfully reading its size. - WebpChunkParsingUtils.ReadIccProfile(stream, metadata, ignoreMetadata); + WebpChunkParsingUtils.ReadIccProfile(stream, metadata, ignoreMetadata, this.ExecuteAncillarySegmentAction); break; case WebpChunkType.Exif: @@ -330,17 +334,17 @@ private void ParseOptionalChunks(BufferedReadStream stream, ImageMetadata metada { // Read chunk header. WebpChunkType chunkType = WebpChunkParsingUtils.ReadChunkType(stream, buffer); - if (chunkType == WebpChunkType.Exif && metadata.ExifProfile == null) + if (chunkType == WebpChunkType.Exif) { this.ExecuteAncillarySegmentAction(() => WebpChunkParsingUtils.ReadExifProfile(stream, metadata, ignoreMetadata)); } - else if (chunkType == WebpChunkType.Xmp && metadata.XmpProfile == null) + else if (chunkType == WebpChunkType.Xmp) { this.ExecuteAncillarySegmentAction(() => WebpChunkParsingUtils.ReadXmpProfile(stream, metadata, ignoreMetadata)); } else { - // Skip duplicate XMP or EXIF chunk. + // Skip unknown chunks. uint chunkLength = WebpChunkParsingUtils.ReadChunkSize(stream, buffer, false); stream.Skip((int)chunkLength); } diff --git a/tests/ImageSharp.Tests/Common/BufferedReadStreamExtensionsTests.cs b/tests/ImageSharp.Tests/Common/BufferedReadStreamExtensionsTests.cs new file mode 100644 index 0000000000..6bef711931 --- /dev/null +++ b/tests/ImageSharp.Tests/Common/BufferedReadStreamExtensionsTests.cs @@ -0,0 +1,60 @@ +// Copyright (c) Six Labors. +// Licensed under the Six Labors Split License. + +using SixLabors.ImageSharp.IO; + +namespace SixLabors.ImageSharp.Tests.Common; + +public class BufferedReadStreamExtensionsTests +{ + [Theory] + [InlineData(0L, 8UL, true)] + [InlineData(8L, 0UL, true)] + [InlineData(7L, 2UL, false)] + [InlineData(9L, 0UL, false)] + [InlineData(-1L, 1UL, false)] + [InlineData(long.MaxValue, ulong.MaxValue, false)] + [InlineData(0L, ulong.MaxValue, false)] + public void IsReadRangeValid_ChecksCompleteExtent(long offset, ulong length, bool expected) + { + using MemoryStream input = new(new byte[8]); + using BufferedReadStream stream = new(Configuration.Default, input); + + Assert.Equal(expected, stream.IsReadRangeValid(offset, length)); + Assert.Equal(0, stream.Position); + } + + [Theory] + [InlineData(0UL, true, 0)] + [InlineData(6UL, true, 6)] + [InlineData(7UL, false, 0)] + [InlineData(1073741824UL, false, 0)] + [InlineData(4294967294UL, false, 0)] + [InlineData(4294967296UL, false, 0)] + [InlineData(ulong.MaxValue, false, 0)] + public void TryGetReadLength_ReturnsResultWithoutMovingStream(ulong length, bool expected, int expectedLength) + { + using MemoryStream input = new(new byte[8]); + using BufferedReadStream stream = new(Configuration.Default, input); + stream.Position = 2; + + Assert.Equal(expected, stream.TryGetReadLength(length, out int bufferLength)); + Assert.Equal(expectedLength, bufferLength); + Assert.Equal(2, stream.Position); + } + + [Theory] + [InlineData(0)] + [InlineData(-1)] + public void Skip_CountZeroOrLower_PositionNotChanged(int count) + { + using MemoryStream input = new(new byte[8]); + using BufferedReadStream stream = new(Configuration.Default, input); + stream.Position = 4; + + stream.Skip(count); + + Assert.Equal(4, stream.Position); + Assert.Equal(0, stream.ReadByte()); + } +} diff --git a/tests/ImageSharp.Tests/Common/StreamExtensionsTests.cs b/tests/ImageSharp.Tests/Common/StreamExtensionsTests.cs deleted file mode 100644 index 5ea7afaf80..0000000000 --- a/tests/ImageSharp.Tests/Common/StreamExtensionsTests.cs +++ /dev/null @@ -1,111 +0,0 @@ -// Copyright (c) Six Labors. -// Licensed under the Six Labors Split License. - -namespace SixLabors.ImageSharp.Tests.Common; - -public class StreamExtensionsTests -{ - [Theory] - [InlineData(0)] - [InlineData(-1)] - public void Skip_CountZeroOrLower_PositionNotChanged(int count) - { - using (MemoryStream memStream = new(5)) - { - memStream.Position = 4; - memStream.Skip(count); - - Assert.Equal(4, memStream.Position); - } - } - - [Fact] - public void Skip_SeekableStream_SeekIsCalled() - { - using (SeekableStream seekableStream = new(4)) - { - seekableStream.Skip(4); - - Assert.Equal(4, seekableStream.Offset); - Assert.Equal(SeekOrigin.Current, seekableStream.Loc); - } - } - - [Fact] - public void Skip_NonSeekableStream_BytesAreRead() - { - using (NonSeekableStream nonSeekableStream = new()) - { - nonSeekableStream.Skip(5); - - Assert.Equal(3, nonSeekableStream.Counts.Count); - - Assert.Equal(5, nonSeekableStream.Counts[0]); - Assert.Equal(3, nonSeekableStream.Counts[1]); - Assert.Equal(1, nonSeekableStream.Counts[2]); - } - } - - [Fact] - public void Skip_EofStream_NoExceptionIsThrown() - { - using (EofStream eofStream = new(7)) - { - eofStream.Skip(7); - - Assert.Equal(0, eofStream.Position); - } - } - - private class SeekableStream : MemoryStream - { - public long Offset; - public SeekOrigin Loc; - - public SeekableStream(int capacity) - : base(capacity) - { - } - - public override long Seek(long offset, SeekOrigin loc) - { - this.Offset = offset; - this.Loc = loc; - return base.Seek(offset, loc); - } - } - - private class NonSeekableStream : MemoryStream - { - public override bool CanSeek => false; - - public List Counts = new(); - - public NonSeekableStream() - : base(4) - { - } - - public override int Read(byte[] buffer, int offset, int count) - { - this.Counts.Add(count); - - return Math.Min(2, count); - } - } - - private class EofStream : MemoryStream - { - public override bool CanSeek => false; - - public EofStream(int capacity) - : base(capacity) - { - } - - public override int Read(byte[] buffer, int offset, int count) - { - return 0; - } - } -} diff --git a/tests/ImageSharp.Tests/Formats/Bmp/BmpDecoderTests.cs b/tests/ImageSharp.Tests/Formats/Bmp/BmpDecoderTests.cs index ce15c91f0b..1702657892 100644 --- a/tests/ImageSharp.Tests/Formats/Bmp/BmpDecoderTests.cs +++ b/tests/ImageSharp.Tests/Formats/Bmp/BmpDecoderTests.cs @@ -34,8 +34,14 @@ public class BmpDecoderTests { RLE8, 2835, 2835, PixelResolutionUnit.PixelsPerMeter } }; - [Fact] - public void Decode_WithProfileLargerThanRemainingData_ThrowsInStrictMode() + [Theory] + [InlineData(SegmentIntegrityHandling.Strict, false)] + [InlineData(SegmentIntegrityHandling.IgnoreAncillary, false)] + [InlineData(SegmentIntegrityHandling.IgnoreImageData, false)] + [InlineData(SegmentIntegrityHandling.Strict, true)] + [InlineData(SegmentIntegrityHandling.IgnoreAncillary, true)] + [InlineData(SegmentIntegrityHandling.IgnoreImageData, true)] + public void Decode_WithProfileLargerThanRemainingData_RespectsOptions(SegmentIntegrityHandling integrityHandling, bool skipMetadata) { byte[] payload = Convert.FromHexString( "424D8E000000000000008A0000007C0000000100000001000000010018000000" + @@ -43,9 +49,24 @@ public void Decode_WithProfileLargerThanRemainingData_ThrowsInStrictMode() "0000000000000000000000000000000000000000000000000000000000000000" + "000000000000000000000000000000000000000000000000000000000000C800" + "00000000004000000000000000"); - DecoderOptions options = new() { SegmentIntegrityHandling = SegmentIntegrityHandling.Strict }; + DecoderOptions options = new() { SegmentIntegrityHandling = integrityHandling, SkipMetadata = skipMetadata }; - Assert.Throws(() => Image.Load(options, payload)); + if (integrityHandling is SegmentIntegrityHandling.Strict && !skipMetadata) + { + Assert.Throws(() => Image.Load(options, payload)); + Assert.Throws(() => Image.Identify(options, payload)); + } + else + { + using Image image = Image.Load(options, payload); + Assert.Equal(new Size(1, 1), image.Size); + Assert.Equal(new Rgba32(0, 0, 0), image[0, 0]); + Assert.Null(image.Metadata.IccProfile); + + ImageInfo info = Image.Identify(options, payload); + Assert.Equal(image.Size, info.Size); + Assert.Null(info.Metadata.IccProfile); + } } [Theory] diff --git a/tests/ImageSharp.Tests/Formats/WebP/WebpMetaDataTests.cs b/tests/ImageSharp.Tests/Formats/WebP/WebpMetaDataTests.cs index 10678273bb..324070e4a5 100644 --- a/tests/ImageSharp.Tests/Formats/WebP/WebpMetaDataTests.cs +++ b/tests/ImageSharp.Tests/Formats/WebP/WebpMetaDataTests.cs @@ -1,9 +1,12 @@ // Copyright (c) Six Labors. // Licensed under the Six Labors Split License. +using System.Buffers.Binary; +using System.Text; using SixLabors.ImageSharp.Formats; using SixLabors.ImageSharp.Formats.Webp; using SixLabors.ImageSharp.Metadata.Profiles.Exif; +using SixLabors.ImageSharp.Metadata.Profiles.Icc; using SixLabors.ImageSharp.PixelFormats; using SixLabors.ImageSharp.Tests.TestUtilities; @@ -13,6 +16,35 @@ namespace SixLabors.ImageSharp.Tests.Formats.Webp; [Trait("Format", "Webp")] public class WebpMetaDataTests { + public static IEnumerable IccMetadataOptions() + { + foreach (SegmentIntegrityHandling integrity in new[] { SegmentIntegrityHandling.Strict, SegmentIntegrityHandling.IgnoreAncillary, SegmentIntegrityHandling.IgnoreImageData }) + { + foreach (bool skipMetadata in new[] { false, true }) + { + foreach (bool animated in new[] { false, true }) + { + yield return new object[] { integrity, skipMetadata, animated }; + } + } + } + } + + public static IEnumerable TruncatedMetadataOptions() + { + foreach (string chunkType in new[] { "EXIF", "XMP " }) + { + foreach (uint length in new[] { 0x40000000U, 0xFFFFFFFEU, uint.MaxValue }) + { + foreach (SegmentIntegrityHandling integrity in new[] { SegmentIntegrityHandling.Strict, SegmentIntegrityHandling.IgnoreAncillary, SegmentIntegrityHandling.IgnoreImageData }) + { + yield return new object[] { chunkType, length, integrity, false }; + yield return new object[] { chunkType, length, integrity, true }; + } + } + } + } + [Theory] [WithFile(TestImages.Webp.Lossy.BikeWithExif, PixelTypes.Rgba32, false)] [WithFile(TestImages.Webp.Lossy.BikeWithExif, PixelTypes.Rgba32, true)] @@ -229,23 +261,151 @@ public void Decode_InvalidExifChunk_ThrowsWithStrict() }); } - [Fact] - public void Decode_WithOversizedIccChunk_ThrowsInvalidImageContentException() + [Theory] + [InlineData("ICCP", 0xFFFFFFFEU)] + [InlineData("EXIF", 0xFFFFFFFEU)] + [InlineData("XMP ", 0xFFFFFFFEU)] + [InlineData("ICCP", uint.MaxValue)] + [InlineData("EXIF", uint.MaxValue)] + [InlineData("XMP ", uint.MaxValue)] + public void Decode_WithOversizedMetadataChunk_ThrowsInvalidImageContentException(string chunkType, uint length) { byte[] payload = Convert.FromHexString( "524946462200000057454250565038580A0000002000000000000000000049434350FEFFFFFF01020304"); + Encoding.ASCII.GetBytes(chunkType, payload.AsSpan(30, 4)); + BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(34), length); + DecoderOptions options = new() { SegmentIntegrityHandling = SegmentIntegrityHandling.Strict }; - Assert.Throws(() => Image.Load(payload)); - Assert.Throws(() => Image.Identify(payload)); + Assert.Throws(() => Image.Load(options, payload)); + Assert.Throws(() => Image.Identify(options, payload)); } - [Fact] - public void Decode_WithIccChunkLargerThanRemainingData_ThrowsInvalidImageContentException() + [Theory] + [InlineData("ICCP")] + [InlineData("EXIF")] + [InlineData("XMP ")] + public void Decode_WithMetadataChunkLargerThanRemainingData_ThrowsInStrictMode(string chunkType) { byte[] payload = Convert.FromHexString( "524946460000000057454250565038580A00000020000000010000010000494343500000004000000000"); + Encoding.ASCII.GetBytes(chunkType, payload.AsSpan(30, 4)); + DecoderOptions options = new() { SegmentIntegrityHandling = SegmentIntegrityHandling.Strict }; + + Assert.Throws(() => Image.Load(options, payload)); + Assert.Throws(() => Image.Identify(options, payload)); + } + + [Theory] + [MemberData(nameof(TruncatedMetadataOptions))] + public void Decode_TruncatedTrailingMetadata_RespectsOptions(string chunkType, uint length, SegmentIntegrityHandling integrity, bool skipMetadata) + { + byte[] payload = CreateWebpWithMetadata(chunkType, length, false, false); + DecoderOptions options = new() { SegmentIntegrityHandling = integrity, SkipMetadata = skipMetadata }; + + if (integrity is SegmentIntegrityHandling.Strict && !skipMetadata) + { + Assert.Throws(() => Image.Load(options, payload)); + Assert.Throws(() => Image.Identify(options, payload)); + } + else + { + using Image image = Image.Load(options, payload); + Assert.Equal(new Size(2, 2), image.Size); + for (int y = 0; y < image.Height; y++) + { + for (int x = 0; x < image.Width; x++) + { + Assert.Equal(new Rgba32(17, 34, 51), image[x, y]); + } + } + + Assert.Null(image.Metadata.ExifProfile); + Assert.Null(image.Metadata.XmpProfile); + + ImageInfo info = Image.Identify(options, payload); + Assert.Equal(image.Size, info.Size); + Assert.Null(info.Metadata.ExifProfile); + Assert.Null(info.Metadata.XmpProfile); + } + } + + [Theory] + [MemberData(nameof(IccMetadataOptions))] + public void Decode_InvalidIccPayload_RespectsOptionsAndReadsFollowingImage(SegmentIntegrityHandling integrity, bool skipMetadata, bool animated) + { + byte[] payload = CreateWebpWithMetadata("ICCP", 4, true, animated); + DecoderOptions options = new() { SegmentIntegrityHandling = integrity, SkipMetadata = skipMetadata }; + + if (integrity is SegmentIntegrityHandling.Strict && !skipMetadata) + { + Assert.Throws(() => Image.Load(options, payload)); + Assert.Throws(() => Image.Identify(options, payload)); + } + else + { + using Image image = Image.Load(options, payload); + Assert.Equal(new Size(2, 2), image.Size); + Assert.Equal(animated ? 2 : 1, image.Frames.Count); + Assert.Equal(new Rgba32(17, 34, 51), image[0, 0]); + Assert.Null(image.Metadata.IccProfile); + + ImageInfo info = Image.Identify(options, payload); + Assert.Equal(image.Size, info.Size); + Assert.Null(info.Metadata.IccProfile); + } + } + + [Theory] + [MemberData(nameof(IccMetadataOptions))] + public void Decode_TruncatedIccFraming_RemainsFatal(SegmentIntegrityHandling integrity, bool skipMetadata, bool animated) + { + byte[] payload = CreateWebpWithMetadata("ICCP", 0x40000000, true, animated); + DecoderOptions options = new() { SegmentIntegrityHandling = integrity, SkipMetadata = skipMetadata }; + + Assert.Throws(() => Image.Load(options, payload)); + Assert.Throws(() => Image.Identify(options, payload)); + } + + /// + /// Places a metadata declaration around a complete lossless image to test recovery independently of pixel truncation. + /// + private static byte[] CreateWebpWithMetadata(string chunkType, uint length, bool beforeImage, bool animated) + { + byte[] header = Convert.FromHexString( + "524946460000000057454250565038580A00000020000000010000010000494343500000004000000000"); + header[20] = chunkType switch { "ICCP" => 0x20, "EXIF" => 0x08, _ => 0x04 }; + Encoding.ASCII.GetBytes(chunkType, header.AsSpan(30, 4)); + BinaryPrimitives.WriteUInt32LittleEndian(header.AsSpan(34), length); + + using Image source = new(2, 2, new Rgba32(17, 34, 51)); + if (animated) + { + header[20] |= 0x02; + using Image secondFrame = new(2, 2, new Rgba32(51, 34, 17)); + source.Frames.AddFrame(secondFrame.Frames.RootFrame); + } + + using MemoryStream encoded = new(); + source.Save(encoded, new WebpEncoder { FileFormat = WebpFileFormatType.Lossless }); + byte[] imageData = encoded.ToArray(); + using MemoryStream combined = new(); + combined.Write(header.AsSpan(0, 30)); + if (beforeImage) + { + combined.Write(header.AsSpan(30)); + } + + // Replace the encoder's extended header when present, keeping its complete + // image or animation chunks and the deliberately chosen metadata declaration. + int imageChunkOffset = imageData.AsSpan(12, 4).SequenceEqual("VP8X"u8) ? 30 : 12; + combined.Write(imageData.AsSpan(imageChunkOffset)); + if (!beforeImage) + { + combined.Write(header.AsSpan(30)); + } - Assert.Throws(() => Image.Load(payload)); - Assert.Throws(() => Image.Identify(payload)); + byte[] payload = combined.ToArray(); + BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(4), (uint)payload.Length - 8); + return payload; } } diff --git a/tests/ImageSharp.Tests/IO/ChunkedMemoryStreamTests.cs b/tests/ImageSharp.Tests/IO/ChunkedMemoryStreamTests.cs index 89256507ed..63c1f7fa31 100644 --- a/tests/ImageSharp.Tests/IO/ChunkedMemoryStreamTests.cs +++ b/tests/ImageSharp.Tests/IO/ChunkedMemoryStreamTests.cs @@ -197,7 +197,7 @@ public void MemoryStream_WriteToSpanTests(int length) readonlyStream.Position = 0; bytArrRet = new byte[(int)readonlyStream.Length]; - readonlyStream.Read(bytArrRet, 0, (int)readonlyStream.Length); + readonlyStream.Read(bytArrRet); for (int i = 0; i < bytArr.Length; i++) { Assert.Equal(bytArr[i], bytArrRet[i]); @@ -216,7 +216,7 @@ public void MemoryStream_WriteToSpanTests(int length) ms2.WriteTo(ms3); ms3.Position = 0; bytArrRet = new byte[(int)ms3.Length]; - ms3.Read(bytArrRet, 0, (int)ms3.Length); + ms3.Read(bytArrRet); for (int i = 0; i < bytArr.Length; i++) { Assert.Equal(bytArr[i], bytArrRet[i]); From b335b8a5f87b78abe30130e9e32dde2c87ef20a1 Mon Sep 17 00:00:00 2001 From: James Jackson-South Date: Sat, 5 Sep 2026 09:46:25 +1000 Subject: [PATCH 18/27] Apply EXIF part selection at serialization boundary --- .../Formats/Webp/BitWriter/BitWriterBase.cs | 3 +- .../Metadata/Profiles/Exif/ExifProfile.cs | 11 ++- .../Formats/WebP/WebpEncoderTests.cs | 83 ++++++++++++++----- .../Profiles/Exif/ExifProfileTests.cs | 41 +++++++++ 4 files changed, 112 insertions(+), 26 deletions(-) diff --git a/src/ImageSharp/Formats/Webp/BitWriter/BitWriterBase.cs b/src/ImageSharp/Formats/Webp/BitWriter/BitWriterBase.cs index e367697166..cdf1470ddb 100644 --- a/src/ImageSharp/Formats/Webp/BitWriter/BitWriterBase.cs +++ b/src/ImageSharp/Formats/Webp/BitWriter/BitWriterBase.cs @@ -143,8 +143,7 @@ public static void WriteTrunksAfterData( { if (exifProfile != null) { - // Materialize lazy values so Parts is applied during serialization. - _ = exifProfile.Values; + // Serialization applies Parts even when the current profile has not been initialized. RiffHelper.WriteChunk(stream, (uint)WebpChunkType.Exif, exifProfile.ToByteArray()); } diff --git a/src/ImageSharp/Metadata/Profiles/Exif/ExifProfile.cs b/src/ImageSharp/Metadata/Profiles/Exif/ExifProfile.cs index aa2eb29e79..6cd16aa9c2 100644 --- a/src/ImageSharp/Metadata/Profiles/Exif/ExifProfile.cs +++ b/src/ImageSharp/Metadata/Profiles/Exif/ExifProfile.cs @@ -221,14 +221,21 @@ public void SetValue(ExifTag tag, TValueType value) => this.SetValueInternal(tag, value); /// - /// Converts this instance to a byte array. + /// Converts the sections selected by to a byte array. /// /// The public byte[]? ToByteArray() { if (this.values is null) { - return this.data; + // The original bytes include every section. They can only be reused when no filtering + // is requested; otherwise lazy profiles must go through the same writer as initialized ones. + if (this.Parts == ExifParts.All) + { + return this.data; + } + + this.InitializeValues(); } if (this.values.Count == 0) diff --git a/tests/ImageSharp.Tests/Formats/WebP/WebpEncoderTests.cs b/tests/ImageSharp.Tests/Formats/WebP/WebpEncoderTests.cs index 3db4a0909e..73bb1457da 100644 --- a/tests/ImageSharp.Tests/Formats/WebP/WebpEncoderTests.cs +++ b/tests/ImageSharp.Tests/Formats/WebP/WebpEncoderTests.cs @@ -24,29 +24,68 @@ public class WebpEncoderTests { private static string TestImageLossyFullPath => Path.Combine(TestEnvironment.InputImagesDirectoryFullPath, Lossy.NoFilter06); - [Fact] - public void Encode_LazyExifProfile_AppliesSelectedParts() + /// + /// Selected EXIF parts are respected whether the lazy profile is installed before or after synchronization. + /// + /// Whether the stream installs the profile after metadata synchronization. + [Theory] + [InlineData(false)] + [InlineData(true)] + public void Encode_LazyExifProfile_AppliesSelectedParts(bool reentrant) { - using Image input = new(8, 8); - ExifProfile exif = new(); - exif.SetValue(ExifTag.Make, "ImageSharp"); - exif.SetValue(ExifTag.GPSLatitudeRef, "N"); - input.Metadata.ExifProfile = exif; - - using MemoryStream jpegStream = new(); - input.Save(jpegStream, new JpegEncoder()); - jpegStream.Position = 0; - - using Image source = Image.Load(jpegStream); - source.Metadata.ExifProfile!.Parts = ExifParts.IfdTags | ExifParts.ExifTags; - - using MemoryStream webpStream = new(); - source.Save(webpStream, new WebpEncoder()); - webpStream.Position = 0; - - using Image result = Image.Load(webpStream); - Assert.True(result.Metadata.ExifProfile!.TryGetValue(ExifTag.Make, out _)); - Assert.False(result.Metadata.ExifProfile.TryGetValue(ExifTag.GPSLatitudeRef, out _)); + ExifProfile source = new(); + source.SetValue(ExifTag.Make, "POC"); + source.SetValue(ExifTag.GPSLatitudeRef, "N"); + ExifProfile filteredLazy = new(source.ToByteArray()) + { + Parts = ExifParts.IfdTags | ExifParts.ExifTags + }; + + using Image image = new(1, 1); + using MemoryStream output = reentrant + ? new SwapOnCanSeekStream(() => image.Metadata.ExifProfile = filteredLazy) + : new MemoryStream(); + + if (!reentrant) + { + image.Metadata.ExifProfile = filteredLazy; + } + + image.SaveAsWebp(output); + output.Position = 0; + using Image decoded = Image.Load(output); + + Assert.NotNull(decoded.Metadata.ExifProfile); + Assert.True(decoded.Metadata.ExifProfile.TryGetValue(ExifTag.Make, out IExifValue make)); + Assert.Equal("POC", make.Value); + Assert.False(decoded.Metadata.ExifProfile.TryGetValue(ExifTag.GPSLatitudeRef, out _)); + } + + /// + /// Replaces metadata at the stream capability check, after encoder synchronization has completed. + /// + private sealed class SwapOnCanSeekStream : MemoryStream + { + private Action callback; + + /// + /// Initializes a stream that invokes the callback on its first capability check. + /// + /// The metadata replacement callback. + public SwapOnCanSeekStream(Action callback) => this.callback = callback; + + /// + public override bool CanSeek + { + get + { + Action action = this.callback; + this.callback = null; + action?.Invoke(); + + return base.CanSeek; + } + } } [Theory] diff --git a/tests/ImageSharp.Tests/Metadata/Profiles/Exif/ExifProfileTests.cs b/tests/ImageSharp.Tests/Metadata/Profiles/Exif/ExifProfileTests.cs index c098ace09a..2b47d09843 100644 --- a/tests/ImageSharp.Tests/Metadata/Profiles/Exif/ExifProfileTests.cs +++ b/tests/ImageSharp.Tests/Metadata/Profiles/Exif/ExifProfileTests.cs @@ -490,6 +490,47 @@ public void ProfileToByteArray() } } + /// + /// Lazy profile serialization filters selected sections without changing the all-parts passthrough. + /// + /// The sections to serialize. + /// Whether the IFD tag should remain. + /// Whether the GPS tag should remain. + [Theory] + [InlineData(ExifParts.All, true, true)] + [InlineData(ExifParts.IfdTags | ExifParts.ExifTags, true, false)] + [InlineData(ExifParts.GpsTags, false, true)] + [InlineData(ExifParts.None, false, false)] + public void ProfileToByteArray_AppliesPartsToLazyValues(ExifParts parts, bool keepMake, bool keepGps) + { + ExifProfile source = new(); + source.SetValue(ExifTag.Make, "POC"); + source.SetValue(ExifTag.GPSLatitudeRef, "N"); + byte[] originalData = source.ToByteArray(); + ExifProfile lazy = new(originalData) { Parts = parts }; + + byte[] filteredData = lazy.ToByteArray(); + ExifProfile result = new(filteredData); + + Assert.Equal(keepMake, result.TryGetValue(ExifTag.Make, out IExifValue make)); + Assert.Equal(keepGps, result.TryGetValue(ExifTag.GPSLatitudeRef, out IExifValue gps)); + + if (keepMake) + { + Assert.Equal("POC", make.Value); + } + + if (keepGps) + { + Assert.Equal("N", gps.Value); + } + + if (parts is ExifParts.All) + { + Assert.Same(originalData, filteredData); + } + } + private static ExifProfile CreateExifProfile() { ExifProfile profile = new(); From 584b662ffa7455a3c5ae55182c32c8674b56d01f Mon Sep 17 00:00:00 2001 From: James Jackson-South Date: Sat, 5 Sep 2026 09:55:06 +1000 Subject: [PATCH 19/27] Restore WebP ICC framing comments --- src/ImageSharp/Formats/Webp/WebpAnimationDecoder.cs | 5 +++++ src/ImageSharp/Formats/Webp/WebpDecoderCore.cs | 5 +++++ 2 files changed, 10 insertions(+) diff --git a/src/ImageSharp/Formats/Webp/WebpAnimationDecoder.cs b/src/ImageSharp/Formats/Webp/WebpAnimationDecoder.cs index 9ea86d0c9f..323ee30b90 100644 --- a/src/ImageSharp/Formats/Webp/WebpAnimationDecoder.cs +++ b/src/ImageSharp/Formats/Webp/WebpAnimationDecoder.cs @@ -381,6 +381,11 @@ private void ReadOptionalChunk( switch (chunkType) { case WebpChunkType.Iccp: + + // While ICC profiles are optional, an invalid ICC profile cannot be ignored because it must + // precede the frame data, and we cannot safely skip it without successfully reading its size. + // ReadIccProfile therefore validates the complete chunk extent before invoking the ancillary + // handler. Only errors in the contents of a complete chunk follow that recovery policy. WebpChunkParsingUtils.ReadIccProfile(stream, imageMetadata, ignoreMetadata, this.executeAncillarySegmentAction); break; case WebpChunkType.Exif: diff --git a/src/ImageSharp/Formats/Webp/WebpDecoderCore.cs b/src/ImageSharp/Formats/Webp/WebpDecoderCore.cs index cbff54e669..c2a4e44653 100644 --- a/src/ImageSharp/Formats/Webp/WebpDecoderCore.cs +++ b/src/ImageSharp/Formats/Webp/WebpDecoderCore.cs @@ -285,6 +285,11 @@ private bool ParseOptionalExtendedChunks( switch (chunkType) { case WebpChunkType.Iccp: + + // While ICC profiles are optional, an invalid ICC profile cannot be ignored because it must + // precede the image data, and we cannot safely skip it without successfully reading its size. + // ReadIccProfile therefore validates the complete chunk extent before invoking the ancillary + // handler. Only errors in the contents of a complete chunk follow that recovery policy. WebpChunkParsingUtils.ReadIccProfile(stream, metadata, ignoreMetadata, this.ExecuteAncillarySegmentAction); break; From 8d48d343521a45315aa8f6c549f08ba82c7faff1 Mon Sep 17 00:00:00 2001 From: James Jackson-South Date: Sat, 5 Sep 2026 11:17:26 +1000 Subject: [PATCH 20/27] Clamp nonfinite floating-point pixel conversions --- .../Common/Helpers/ColorNumerics.cs | 13 +- src/ImageSharp/Common/Helpers/Numerics.cs | 178 ++++++++++++---- src/ImageSharp/PixelFormats/HalfTypeHelper.cs | 201 ++++++++++++++++-- .../PixelImplementations/HalfVector4P.cs | 2 +- .../HalfVector4.PixelOperations.cs | 20 +- .../HalfVector4P.PixelOperations.cs | 63 +++--- .../RgbaHalfP.PixelOperations.cs | 94 +++----- .../Vector4Converters.AffineOperators.cs | 7 +- .../Formats/Tiff/TiffDecoderTests.cs | 33 +++ .../ImageSharp.Tests/Helpers/NumericsTests.cs | 109 ++++++++++ .../FloatingPointPixelNormalizationTests.cs | 183 ++++++++++++++++ .../HistogramEqualizationTests.cs | 55 ----- 12 files changed, 721 insertions(+), 237 deletions(-) create mode 100644 tests/ImageSharp.Tests/PixelFormats/FloatingPointPixelNormalizationTests.cs diff --git a/src/ImageSharp/Common/Helpers/ColorNumerics.cs b/src/ImageSharp/Common/Helpers/ColorNumerics.cs index d13eead453..8e99728314 100644 --- a/src/ImageSharp/Common/Helpers/ColorNumerics.cs +++ b/src/ImageSharp/Common/Helpers/ColorNumerics.cs @@ -26,18 +26,7 @@ internal static class ColorNumerics /// The number of luminance levels (256 for 8 bit, 65536 for 16 bit grayscale images). /// [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static int GetBT709Luminance(Vector4 vector, int luminanceLevels) - { - float luminance = Vector4.Dot(vector, Bt709); - if (float.IsNaN(luminance)) - { - return 0; - } - - // Floating-point pixel formats can contain values outside their normalized range. - luminance = Math.Clamp(luminance, 0F, 1F); - return (int)MathF.Round(luminance * (luminanceLevels - 1)); - } + public static int GetBT709Luminance(Vector4 vector, int luminanceLevels) => (int)MathF.Round(Vector4.Dot(vector, Bt709) * (luminanceLevels - 1)); /// /// Gets the luminance from the rgb components using the formula diff --git a/src/ImageSharp/Common/Helpers/Numerics.cs b/src/ImageSharp/Common/Helpers/Numerics.cs index 8980d2b53e..4154cbf1f7 100644 --- a/src/ImageSharp/Common/Helpers/Numerics.cs +++ b/src/ImageSharp/Common/Helpers/Numerics.cs @@ -263,63 +263,95 @@ public static int Clamp(int value, int min, int max) } /// - /// Returns the value clamped to the inclusive range of min and max. + /// Returns the value clamped to the inclusive range of min and max, mapping NaN to min. /// /// The value to clamp. /// The minimum inclusive value. /// The maximum inclusive value. /// The clamped . [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static float Clamp(float value, float min, float max) - { - if (value > max) - { - return max; - } - - if (value < min) - { - return min; - } - - return value; - } + public static float Clamp(float value, float min, float max) => Clamp(value, min, max); /// - /// Returns the value clamped to the inclusive range of min and max. + /// Returns the value clamped to the inclusive range of min and max, mapping NaN to min. /// /// The value to clamp. /// The minimum inclusive value. /// The maximum inclusive value. /// The clamped . [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static double Clamp(double value, double min, double max) + public static double Clamp(double value, double min, double max) => Clamp(value, min, max); + + /// + /// Clamps components to the inclusive range of min and max, mapping NaN to min. + /// + /// The components to clamp. + /// The inclusive lower bounds. + /// The inclusive upper bounds. + /// The clamped components. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static Vector2 Clamp(Vector2 value, Vector2 min, Vector2 max) => Clamp(value.AsVector128(), min.AsVector128(), max.AsVector128()).AsVector2(); + + /// + /// Clamps components to the inclusive range of min and max, mapping NaN to min. + /// + /// The component type. + /// The components to clamp. + /// The inclusive lower bounds. + /// The inclusive upper bounds. + /// The clamped components. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static Vector128 Clamp(Vector128 value, Vector128 min, Vector128 max) + where T : struct, INumber { - if (value > max) - { - return max; - } + // Ordered comparisons map NaN to min and preserve in-range signed zero on every runtime. + Vector128 lowerClamped = Vector128.ConditionalSelect(Vector128.GreaterThanOrEqual(value, min), value, min); + return Vector128.ConditionalSelect(Vector128.GreaterThan(value, max), max, lowerClamped); + } - if (value < min) - { - return min; - } + /// + /// Clamps components to the inclusive range of min and max, mapping NaN to min. + /// + /// The component type. + /// The components to clamp. + /// The inclusive lower bounds. + /// The inclusive upper bounds. + /// The clamped components. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static Vector256 Clamp(Vector256 value, Vector256 min, Vector256 max) + where T : struct, INumber + { + // Ordered comparisons map NaN to min and preserve in-range signed zero on every runtime. + Vector256 lowerClamped = Vector256.ConditionalSelect(Vector256.GreaterThanOrEqual(value, min), value, min); + return Vector256.ConditionalSelect(Vector256.GreaterThan(value, max), max, lowerClamped); + } - return value; + /// + /// Clamps components to the inclusive range of min and max, mapping NaN to min. + /// + /// The component type. + /// The components to clamp. + /// The inclusive lower bounds. + /// The inclusive upper bounds. + /// The clamped components. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static Vector512 Clamp(Vector512 value, Vector512 min, Vector512 max) + where T : struct, INumber + { + // Ordered comparisons map NaN to min and preserve in-range signed zero on every runtime. + Vector512 lowerClamped = Vector512.ConditionalSelect(Vector512.GreaterThanOrEqual(value, min), value, min); + return Vector512.ConditionalSelect(Vector512.GreaterThan(value, max), max, lowerClamped); } /// - /// Returns the value clamped to the inclusive range of min and max. - /// 5x Faster than - /// on platforms < NET 5. + /// Clamps components to the inclusive range of min and max, mapping NaN to min. /// /// The value to clamp. /// The minimum inclusive value. /// The maximum inclusive value. /// The clamped . [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static Vector4 Clamp(Vector4 value, Vector4 min, Vector4 max) - => Vector4.Min(Vector4.Max(value, min), max); + public static Vector4 Clamp(Vector4 value, Vector4 min, Vector4 max) => Clamp(value.AsVector128(), min.AsVector128(), max.AsVector128()).AsVector4(); /// /// Clamps the span values to the inclusive range of min and max. @@ -352,24 +384,24 @@ public static void Clamp(Span span, int min, int max) => TensorPrimitives_.Clamp(span, min, max, span); /// - /// Clamps the span values to the inclusive range of min and max. + /// Clamps the span values to the inclusive range of min and max, mapping NaN to min. /// /// The span containing the values to clamp. /// The minimum inclusive value. /// The maximum inclusive value. [MethodImpl(MethodImplOptions.AggressiveInlining)] public static void Clamp(Span span, float min, float max) - => TensorPrimitives_.Clamp(span, min, max, span); + => Clamp(span, min, max); /// - /// Clamps the span values to the inclusive range of min and max. + /// Clamps the span values to the inclusive range of min and max, mapping NaN to min. /// /// The span containing the values to clamp. /// The minimum inclusive value. /// The maximum inclusive value. [MethodImpl(MethodImplOptions.AggressiveInlining)] public static void Clamp(Span span, double min, double max) - => TensorPrimitives_.Clamp(span, min, max, span); + => Clamp(span, min, max); /// /// Pre-multiplies the "x", "y", "z" components of a vector by its "w" component leaving the "w" component intact. @@ -392,7 +424,7 @@ public static void Premultiply(ref Vector4 source) public static void ClampRgbToAlpha(ref Vector4 source) { Vector4 alpha = PermuteW(source); - source = WithW(Vector4.Min(Vector4.Max(source, Vector4.Zero), alpha), alpha); + source = WithW(Clamp(source, Vector4.Zero, alpha), alpha); } /// @@ -1071,4 +1103,78 @@ public static nuint Vector512Count(int length) [MethodImpl(MethodImplOptions.AggressiveInlining)] public static void Normalize(Span span, float sum) => TensorPrimitives_.Divide(span, sum, span); + + /// + /// Clamps a floating-point component while mapping NaN to the lower bound. + /// + /// The component type. + /// The component to clamp. + /// The inclusive lower bound. + /// The inclusive upper bound. + /// The clamped component. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static T Clamp(T value, T min, T max) + where T : struct, INumber + { + // Ordered comparisons map NaN to min; in-range values retain their original bits, including signed zero. + return value > max ? max : value >= min ? value : min; + } + + /// + /// Applies the scalar clamp contract to floating-point spans in place. + /// + /// The component type. + /// The components to clamp. + /// The inclusive lower bound. + /// The inclusive upper bound. + private static void Clamp(Span span, T min, T max) + where T : struct, INumber + { + ref T start = ref MemoryMarshal.GetReference(span); + int i = 0; + + // Each register uses the same Clamp overload as individual vector callers. Descending widths consume + // the remainder without overlapping stores, and the final components use the scalar overload. + if (Vector512.IsHardwareAccelerated) + { + Vector512 lower = Vector512.Create(min); + Vector512 upper = Vector512.Create(max); + + for (; i <= span.Length - Vector512.Count; i += Vector512.Count) + { + Vector512 value = Vector512.LoadUnsafe(ref start, (nuint)i); + Clamp(value, lower, upper).StoreUnsafe(ref start, (nuint)i); + } + } + + if (Vector256.IsHardwareAccelerated) + { + Vector256 lower = Vector256.Create(min); + Vector256 upper = Vector256.Create(max); + + for (; i <= span.Length - Vector256.Count; i += Vector256.Count) + { + Vector256 value = Vector256.LoadUnsafe(ref start, (nuint)i); + Clamp(value, lower, upper).StoreUnsafe(ref start, (nuint)i); + } + } + + if (Vector128.IsHardwareAccelerated) + { + Vector128 lower = Vector128.Create(min); + Vector128 upper = Vector128.Create(max); + + for (; i <= span.Length - Vector128.Count; i += Vector128.Count) + { + Vector128 value = Vector128.LoadUnsafe(ref start, (nuint)i); + Clamp(value, lower, upper).StoreUnsafe(ref start, (nuint)i); + } + } + + for (; i < span.Length; i++) + { + ref T value = ref Unsafe.Add(ref start, (uint)i); + value = Clamp(value, min, max); + } + } } diff --git a/src/ImageSharp/PixelFormats/HalfTypeHelper.cs b/src/ImageSharp/PixelFormats/HalfTypeHelper.cs index 53f22eb09a..6de4b30b9e 100644 --- a/src/ImageSharp/PixelFormats/HalfTypeHelper.cs +++ b/src/ImageSharp/PixelFormats/HalfTypeHelper.cs @@ -3,6 +3,7 @@ using System.Numerics; using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; using System.Runtime.Intrinsics; namespace SixLabors.ImageSharp.PixelFormats; @@ -50,52 +51,228 @@ internal static class HalfTypeHelper internal static float Unpack(ushort value) => (float)BitConverter.UInt16BitsToHalf(value); /// - /// Normalizes a finite binary16 value to the scaled pixel range. + /// Normalizes a binary16 value to [0, 1], saturating infinities and mapping NaN to zero. /// /// The native binary16 value represented as a . /// The normalized value. [MethodImpl(MethodImplOptions.AggressiveInlining)] - internal static float ToScaled(float value) => (value * InverseFiniteRange) + ScaledMidpoint; + public static float ToScaled(float value) + { + // Clamp after mapping so native infinities reach the scaled endpoints and NaN becomes zero. + return Numerics.Clamp((value * InverseFiniteRange) + ScaledMidpoint, 0F, 1F); + } /// - /// Normalizes finite binary16 values to the scaled pixel range. + /// Normalizes binary16 values to [0, 1], saturating infinities and mapping NaN to zero. /// /// The native binary16 values. /// The normalized values. [MethodImpl(MethodImplOptions.AggressiveInlining)] - internal static Vector2 ToScaled(Vector2 value) => (value * InverseFiniteRange) + new Vector2(ScaledMidpoint); + public static Vector2 ToScaled(Vector2 value) => ToScaled(value.AsVector128()).AsVector2(); /// - /// Normalizes finite binary16 values to the scaled pixel range. + /// Normalizes binary16 values to [0, 1], saturating infinities and mapping NaN to zero. /// /// The native binary16 values. /// The normalized values. [MethodImpl(MethodImplOptions.AggressiveInlining)] - internal static Vector4 ToScaled(Vector4 value) => (value * InverseFiniteRange) + new Vector4(ScaledMidpoint); + public static Vector4 ToScaled(Vector4 value) => ToScaled(value.AsVector128()).AsVector4(); + + /// + /// Normalizes binary16 values to [0, 1], saturating infinities and mapping NaN to zero. + /// + /// The component values. + /// The converted values. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static Vector128 ToScaled(Vector128 value) + { + Vector128 scaled = (value * Vector128.Create(InverseFiniteRange)) + Vector128.Create(ScaledMidpoint); + + return Numerics.Clamp(scaled, Vector128.Zero, Vector128.One); + } + + /// + /// Normalizes binary16 values to [0, 1], saturating infinities and mapping NaN to zero. + /// + /// The component values. + /// The converted values. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static Vector256 ToScaled(Vector256 value) + { + Vector256 scaled = (value * Vector256.Create(InverseFiniteRange)) + Vector256.Create(ScaledMidpoint); + + return Numerics.Clamp(scaled, Vector256.Zero, Vector256.One); + } + + /// + /// Normalizes binary16 values to [0, 1], saturating infinities and mapping NaN to zero. + /// + /// The component values. + /// The converted values. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static Vector512 ToScaled(Vector512 value) + { + Vector512 scaled = (value * Vector512.Create(InverseFiniteRange)) + Vector512.Create(ScaledMidpoint); + + return Numerics.Clamp(scaled, Vector512.Zero, Vector512.One); + } + + /// + /// Normalizes binary16 values to [0, 1], saturating infinities and mapping NaN to zero. + /// + /// The component values to convert in place. + public static void ToScaled(Span values) + { + ref Vector4 source = ref MemoryMarshal.GetReference(values); + int i = 0; + + // Each register contains whole RGBA pixels. Convert wide groups first, then narrower + // remainders without revisiting any pixel: mapping the same pixel twice would change its value. + if (Vector512.IsHardwareAccelerated) + { + int pixelsPerRegister = Vector512.Count / Vector128.Count; + + for (; i <= values.Length - pixelsPerRegister; i += pixelsPerRegister) + { + ref Vector512 vector = ref Unsafe.As>(ref Unsafe.Add(ref source, (uint)i)); + + vector = ToScaled(vector); + } + } + + if (Vector256.IsHardwareAccelerated) + { + int pixelsPerRegister = Vector256.Count / Vector128.Count; + + for (; i <= values.Length - pixelsPerRegister; i += pixelsPerRegister) + { + ref Vector256 vector = ref Unsafe.As>(ref Unsafe.Add(ref source, (uint)i)); + + vector = ToScaled(vector); + } + } + + // One Vector4 uses the same 128-bit conversion as an individual pixel, including the + // runtime's software fallback when SIMD is unavailable. No separate scalar mapping is needed. + for (; i < values.Length; i++) + { + ref Vector4 vector = ref Unsafe.Add(ref source, (uint)i); + + vector = ToScaled(vector); + } + } /// - /// Expands a normalized value to the finite binary16 range. + /// Normalizes a scaled value, mapping NaN to zero, and expands it to the finite binary16 range. /// /// The normalized value. /// The native binary16 value represented as a . [MethodImpl(MethodImplOptions.AggressiveInlining)] - internal static float FromScaled(float value) => (value * FiniteRange) + FiniteMinimum; + public static float FromScaled(float value) + { + // Clamp before expanding so nonfinite scaled input cannot become nonfinite half storage. + return (Numerics.Clamp(value, 0F, 1F) * FiniteRange) + FiniteMinimum; + } /// - /// Expands normalized values to the finite binary16 range. + /// Normalizes scaled values, mapping NaN to zero, and expands them to the finite binary16 range. /// /// The normalized values. /// The native binary16 values. [MethodImpl(MethodImplOptions.AggressiveInlining)] - internal static Vector2 FromScaled(Vector2 value) => (value * FiniteRange) + new Vector2(FiniteMinimum); + public static Vector2 FromScaled(Vector2 value) => FromScaled(value.AsVector128()).AsVector2(); /// - /// Expands normalized values to the finite binary16 range. + /// Normalizes scaled values, mapping NaN to zero, and expands them to the finite binary16 range. /// /// The normalized values. /// The native binary16 values. [MethodImpl(MethodImplOptions.AggressiveInlining)] - internal static Vector4 FromScaled(Vector4 value) => (value * FiniteRange) + new Vector4(FiniteMinimum); + public static Vector4 FromScaled(Vector4 value) => FromScaled(value.AsVector128()).AsVector4(); + + /// + /// Normalizes scaled values, mapping NaN to zero, and expands them to the finite binary16 range. + /// + /// The component values. + /// The converted values. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static Vector128 FromScaled(Vector128 value) + { + Vector128 scaled = Numerics.Clamp(value, Vector128.Zero, Vector128.One); + + return (scaled * Vector128.Create(FiniteRange)) + Vector128.Create(FiniteMinimum); + } + + /// + /// Normalizes scaled values, mapping NaN to zero, and expands them to the finite binary16 range. + /// + /// The component values. + /// The converted values. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static Vector256 FromScaled(Vector256 value) + { + Vector256 scaled = Numerics.Clamp(value, Vector256.Zero, Vector256.One); + + return (scaled * Vector256.Create(FiniteRange)) + Vector256.Create(FiniteMinimum); + } + + /// + /// Normalizes scaled values, mapping NaN to zero, and expands them to the finite binary16 range. + /// + /// The component values. + /// The converted values. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static Vector512 FromScaled(Vector512 value) + { + Vector512 scaled = Numerics.Clamp(value, Vector512.Zero, Vector512.One); + + return (scaled * Vector512.Create(FiniteRange)) + Vector512.Create(FiniteMinimum); + } + + /// + /// Normalizes scaled values, mapping NaN to zero, and expands them to the finite binary16 range. + /// + /// The component values to convert in place. + public static void FromScaled(Span values) + { + ref Vector4 source = ref MemoryMarshal.GetReference(values); + int i = 0; + + // Each register contains whole RGBA pixels. Clamping and expansion happen together in + // the conversion overload, so each pixel is loaded and stored once without a clamp-only pass. + if (Vector512.IsHardwareAccelerated) + { + int pixelsPerRegister = Vector512.Count / Vector128.Count; + + for (; i <= values.Length - pixelsPerRegister; i += pixelsPerRegister) + { + ref Vector512 vector = ref Unsafe.As>(ref Unsafe.Add(ref source, (uint)i)); + + vector = FromScaled(vector); + } + } + + if (Vector256.IsHardwareAccelerated) + { + int pixelsPerRegister = Vector256.Count / Vector128.Count; + + for (; i <= values.Length - pixelsPerRegister; i += pixelsPerRegister) + { + ref Vector256 vector = ref Unsafe.As>(ref Unsafe.Add(ref source, (uint)i)); + + vector = FromScaled(vector); + } + } + + // The remaining whole pixels use the same 128-bit conversion as individual pixels, + // or its software fallback. Narrowing the remainder never reprocesses a converted pixel. + for (; i < values.Length; i++) + { + ref Vector4 vector = ref Unsafe.Add(ref source, (uint)i); + + vector = FromScaled(vector); + } + } /// /// Unpacks eight binary16 values into two vectors of single-precision values. diff --git a/src/ImageSharp/PixelFormats/PixelImplementations/HalfVector4P.cs b/src/ImageSharp/PixelFormats/PixelImplementations/HalfVector4P.cs index 50fc272553..ca90afcf75 100644 --- a/src/ImageSharp/PixelFormats/PixelImplementations/HalfVector4P.cs +++ b/src/ImageSharp/PixelFormats/PixelImplementations/HalfVector4P.cs @@ -250,7 +250,7 @@ private static HalfVector4P PackAssociatedScaledVector4(Vector4 source) [MethodImpl(MethodImplOptions.AggressiveInlining)] private static float QuantizeScaledAlpha(float alpha) { - float nativeAlpha = HalfTypeHelper.FromScaled(Numerics.Clamp(alpha, 0F, 1F)); + float nativeAlpha = HalfTypeHelper.FromScaled(alpha); return HalfTypeHelper.ToScaled(HalfTypeHelper.Unpack(HalfTypeHelper.Pack(nativeAlpha))); } diff --git a/src/ImageSharp/PixelFormats/PixelImplementations/PixelOperations/HalfVector4.PixelOperations.cs b/src/ImageSharp/PixelFormats/PixelImplementations/PixelOperations/HalfVector4.PixelOperations.cs index 9eae7d5cbc..a921f99bae 100644 --- a/src/ImageSharp/PixelFormats/PixelImplementations/PixelOperations/HalfVector4.PixelOperations.cs +++ b/src/ImageSharp/PixelFormats/PixelImplementations/PixelOperations/HalfVector4.PixelOperations.cs @@ -3,7 +3,6 @@ using System.Numerics; using System.Runtime.InteropServices; -using SixLabors.ImageSharp.PixelFormats.Utils; namespace SixLabors.ImageSharp.PixelFormats; @@ -17,11 +16,6 @@ public partial struct HalfVector4 /// internal class PixelOperations : PixelOperations { - private static readonly Vector4 NativeToScaledMultiplier = new(HalfTypeHelper.InverseFiniteRange); - private static readonly Vector4 NativeToScaledOffset = new(HalfTypeHelper.ScaledMidpoint); - private static readonly Vector4 ScaledToNativeMultiplier = new(HalfTypeHelper.FiniteRange); - private static readonly Vector4 ScaledToNativeOffset = new(HalfTypeHelper.FiniteMinimum); - /// protected override void ToUnassociatedVector4(Configuration configuration, ReadOnlySpan source, Span destination) { @@ -40,9 +34,9 @@ protected override void ToAssociatedVector4(Configuration configuration, ReadOnl // Association uses normalized opacity, not the native binary16 alpha value. RgbaHalfP.PixelOperations.Unpack(MemoryMarshal.Cast(source), destination); - Vector4Converters.MultiplyThenAdd(destination, NativeToScaledMultiplier, NativeToScaledOffset); + HalfTypeHelper.ToScaled(destination); Numerics.Premultiply(destination); - Vector4Converters.MultiplyThenAdd(destination, ScaledToNativeMultiplier, ScaledToNativeOffset); + HalfTypeHelper.FromScaled(destination); } /// @@ -52,7 +46,7 @@ protected override void ToUnassociatedScaledVector4(Configuration configuration, destination = destination[..source.Length]; RgbaHalfP.PixelOperations.Unpack(MemoryMarshal.Cast(source), destination); - Vector4Converters.MultiplyThenAdd(destination, NativeToScaledMultiplier, NativeToScaledOffset); + HalfTypeHelper.ToScaled(destination); } /// @@ -77,9 +71,9 @@ protected override void FromAssociatedVector4Destructive(Configuration configura Guard.DestinationShouldNotBeTooShort(source, destination, nameof(destination)); // Restore normalized opacity before unassociating, then return the result to the native binary16 range. - Vector4Converters.MultiplyThenAdd(source, NativeToScaledMultiplier, NativeToScaledOffset); + HalfTypeHelper.ToScaled(source); Numerics.UnPremultiply(source); - Vector4Converters.MultiplyThenAdd(source, ScaledToNativeMultiplier, ScaledToNativeOffset); + HalfTypeHelper.FromScaled(source); RgbaHalfP.PixelOperations.PackUnclamped(source, MemoryMarshal.Cast(destination[..source.Length])); } @@ -88,7 +82,7 @@ protected override void FromUnassociatedScaledVector4Destructive(Configuration c { Guard.DestinationShouldNotBeTooShort(source, destination, nameof(destination)); - Vector4Converters.MultiplyThenAdd(source, ScaledToNativeMultiplier, ScaledToNativeOffset); + HalfTypeHelper.FromScaled(source); RgbaHalfP.PixelOperations.PackUnclamped(source, MemoryMarshal.Cast(destination[..source.Length])); } @@ -98,7 +92,7 @@ protected override void FromAssociatedScaledVector4Destructive(Configuration con Guard.DestinationShouldNotBeTooShort(source, destination, nameof(destination)); Numerics.UnPremultiply(source); - Vector4Converters.MultiplyThenAdd(source, ScaledToNativeMultiplier, ScaledToNativeOffset); + HalfTypeHelper.FromScaled(source); RgbaHalfP.PixelOperations.PackUnclamped(source, MemoryMarshal.Cast(destination[..source.Length])); } } diff --git a/src/ImageSharp/PixelFormats/PixelImplementations/PixelOperations/HalfVector4P.PixelOperations.cs b/src/ImageSharp/PixelFormats/PixelImplementations/PixelOperations/HalfVector4P.PixelOperations.cs index 52b2dbf376..172159a49c 100644 --- a/src/ImageSharp/PixelFormats/PixelImplementations/PixelOperations/HalfVector4P.PixelOperations.cs +++ b/src/ImageSharp/PixelFormats/PixelImplementations/PixelOperations/HalfVector4P.PixelOperations.cs @@ -20,16 +20,11 @@ public partial struct HalfVector4P /// internal class PixelOperations : AssociatedAlphaPixelOperations { - private static readonly Vector4 NativeToScaledMultiplier = new(HalfTypeHelper.InverseFiniteRange); - private static readonly Vector4 NativeToScaledOffset = new(HalfTypeHelper.ScaledMidpoint); - private static readonly Vector4 ScaledToNativeMultiplier = new(HalfTypeHelper.FiniteRange); - private static readonly Vector4 ScaledToNativeOffset = new(HalfTypeHelper.FiniteMinimum); - /// protected override void ToUnassociatedVector4(Configuration configuration, ReadOnlySpan source, Span destination) { this.ToUnassociatedScaledVector4(configuration, source, destination); - Vector4Converters.MultiplyThenAdd(destination[..source.Length], ScaledToNativeMultiplier, ScaledToNativeOffset); + HalfTypeHelper.FromScaled(destination[..source.Length]); } /// @@ -54,7 +49,7 @@ protected override void ToAssociatedScaledVector4(Configuration configuration, R destination = destination[..source.Length]; RgbaHalfP.PixelOperations.Unpack(MemoryMarshal.Cast(source), destination); - Vector4Converters.MultiplyThenAdd(destination, NativeToScaledMultiplier, NativeToScaledOffset); + HalfTypeHelper.ToScaled(destination); } /// @@ -62,7 +57,7 @@ protected override void FromUnassociatedVector4Destructive(Configuration configu { Guard.DestinationShouldNotBeTooShort(source, destination, nameof(destination)); - Vector4Converters.MultiplyThenAdd(source, NativeToScaledMultiplier, NativeToScaledOffset); + HalfTypeHelper.ToScaled(source); Associate(source); PackAssociatedScaled(source, destination[..source.Length]); } @@ -72,7 +67,7 @@ protected override void FromAssociatedVector4Destructive(Configuration configura { Guard.DestinationShouldNotBeTooShort(source, destination, nameof(destination)); - Vector4Converters.MultiplyThenAdd(source, NativeToScaledMultiplier, NativeToScaledOffset); + HalfTypeHelper.ToScaled(source); Reassociate(source); PackAssociatedScaled(source, destination[..source.Length]); } @@ -250,7 +245,9 @@ private static Vector128 Reassociate(Vector128 source) Vector128 storedAlpha = QuantizeScaledAlpha(alpha); Vector128 result = source * (storedAlpha / alpha); result = Vector128.ConditionalSelect(Vector128.Create(0, 0, 0, -1).AsSingle(), storedAlpha, result); - result = Vector128.Min(Vector128.Max(result, zero), storedAlpha); + + // Clamp after the alpha ratio, matching the scalar conversion for nonfinite RGB. + result = Numerics.Clamp(result, zero, storedAlpha); return Vector128.ConditionalSelect(Vector128.LessThanOrEqual(alpha, zero), zero, result); } @@ -267,7 +264,9 @@ private static Vector256 Reassociate(Vector256 source) Vector256 storedAlpha = QuantizeScaledAlpha(alpha); Vector256 result = source * (storedAlpha / alpha); result = Vector256.ConditionalSelect(Vector256.Create(0, 0, 0, -1, 0, 0, 0, -1).AsSingle(), storedAlpha, result); - result = Vector256.Min(Vector256.Max(result, zero), storedAlpha); + + // Clamp after the alpha ratio, matching the scalar conversion for nonfinite RGB. + result = Numerics.Clamp(result, zero, storedAlpha); return Vector256.ConditionalSelect(Vector256.LessThanOrEqual(alpha, zero), zero, result); } @@ -285,7 +284,9 @@ private static Vector512 Reassociate(Vector512 source) Vector512 result = source * (storedAlpha / alpha); Vector512 alphaMask = Vector512.Create(0, 0, 0, -1, 0, 0, 0, -1, 0, 0, 0, -1, 0, 0, 0, -1).AsSingle(); result = Vector512.ConditionalSelect(alphaMask, storedAlpha, result); - result = Vector512.Min(Vector512.Max(result, zero), storedAlpha); + + // Clamp after the alpha ratio, matching the scalar conversion for nonfinite RGB. + result = Numerics.Clamp(result, zero, storedAlpha); return Vector512.ConditionalSelect(Vector512.LessThanOrEqual(alpha, zero), zero, result); } @@ -297,8 +298,8 @@ private static Vector512 Reassociate(Vector512 source) [MethodImpl(MethodImplOptions.AggressiveInlining)] private static Vector128 QuantizeScaledAlpha(Vector128 alpha) { - Vector128 native = (ClampUnit(alpha) * Vector128.Create(HalfTypeHelper.FiniteRange)) + Vector128.Create(HalfTypeHelper.FiniteMinimum); - return (HalfTypeHelper.RoundToHalf(native) * Vector128.Create(HalfTypeHelper.InverseFiniteRange)) + Vector128.Create(HalfTypeHelper.ScaledMidpoint); + Vector128 native = HalfTypeHelper.FromScaled(alpha); + return HalfTypeHelper.ToScaled(HalfTypeHelper.RoundToHalf(native)); } /// @@ -309,8 +310,8 @@ private static Vector128 QuantizeScaledAlpha(Vector128 alpha) [MethodImpl(MethodImplOptions.AggressiveInlining)] private static Vector256 QuantizeScaledAlpha(Vector256 alpha) { - Vector256 native = (ClampUnit(alpha) * Vector256.Create(HalfTypeHelper.FiniteRange)) + Vector256.Create(HalfTypeHelper.FiniteMinimum); - return (HalfTypeHelper.RoundToHalf(native) * Vector256.Create(HalfTypeHelper.InverseFiniteRange)) + Vector256.Create(HalfTypeHelper.ScaledMidpoint); + Vector256 native = HalfTypeHelper.FromScaled(alpha); + return HalfTypeHelper.ToScaled(HalfTypeHelper.RoundToHalf(native)); } /// @@ -321,45 +322,33 @@ private static Vector256 QuantizeScaledAlpha(Vector256 alpha) [MethodImpl(MethodImplOptions.AggressiveInlining)] private static Vector512 QuantizeScaledAlpha(Vector512 alpha) { - Vector512 native = (ClampUnit(alpha) * Vector512.Create(HalfTypeHelper.FiniteRange)) + Vector512.Create(HalfTypeHelper.FiniteMinimum); - return (HalfTypeHelper.RoundToHalf(native) * Vector512.Create(HalfTypeHelper.InverseFiniteRange)) + Vector512.Create(HalfTypeHelper.ScaledMidpoint); + Vector512 native = HalfTypeHelper.FromScaled(alpha); + return HalfTypeHelper.ToScaled(HalfTypeHelper.RoundToHalf(native)); } /// - /// Clamps vectors to the scaled color range while preserving NaN lanes. + /// Clamps vectors to the scaled color range, mapping NaN lanes to zero. /// /// The vectors to clamp. /// The clamped vectors. [MethodImpl(MethodImplOptions.AggressiveInlining)] - private static Vector128 ClampUnit(Vector128 source) - { - Vector128 clamped = Vector128.Min(Vector128.Max(source, Vector128.Zero), Vector128.One); - return Vector128.ConditionalSelect(Vector128.Equals(source, source), clamped, source); - } + private static Vector128 ClampUnit(Vector128 source) => Numerics.Clamp(source, Vector128.Zero, Vector128.One); /// - /// Clamps vectors to the scaled color range while preserving NaN lanes. + /// Clamps vectors to the scaled color range, mapping NaN lanes to zero. /// /// The vectors to clamp. /// The clamped vectors. [MethodImpl(MethodImplOptions.AggressiveInlining)] - private static Vector256 ClampUnit(Vector256 source) - { - Vector256 clamped = Vector256.Min(Vector256.Max(source, Vector256.Zero), Vector256.One); - return Vector256.ConditionalSelect(Vector256.Equals(source, source), clamped, source); - } + private static Vector256 ClampUnit(Vector256 source) => Numerics.Clamp(source, Vector256.Zero, Vector256.One); /// - /// Clamps vectors to the scaled color range while preserving NaN lanes. + /// Clamps vectors to the scaled color range, mapping NaN lanes to zero. /// /// The vectors to clamp. /// The clamped vectors. [MethodImpl(MethodImplOptions.AggressiveInlining)] - private static Vector512 ClampUnit(Vector512 source) - { - Vector512 clamped = Vector512.Min(Vector512.Max(source, Vector512.Zero), Vector512.One); - return Vector512.ConditionalSelect(Vector512.Equals(source, source), clamped, source); - } + private static Vector512 ClampUnit(Vector512 source) => Numerics.Clamp(source, Vector512.Zero, Vector512.One); /// /// Maps associated scaled vectors to native components and packs them as binary16 values. @@ -368,7 +357,7 @@ private static Vector512 ClampUnit(Vector512 source) /// The destination pixels. private static void PackAssociatedScaled(Span source, Span destination) { - Vector4Converters.MultiplyThenAdd(source, ScaledToNativeMultiplier, ScaledToNativeOffset); + HalfTypeHelper.FromScaled(source); RgbaHalfP.PixelOperations.PackUnclamped(source, MemoryMarshal.Cast(destination)); } } diff --git a/src/ImageSharp/PixelFormats/PixelImplementations/PixelOperations/RgbaHalfP.PixelOperations.cs b/src/ImageSharp/PixelFormats/PixelImplementations/PixelOperations/RgbaHalfP.PixelOperations.cs index 825ebeec92..a9f3081f1a 100644 --- a/src/ImageSharp/PixelFormats/PixelImplementations/PixelOperations/RgbaHalfP.PixelOperations.cs +++ b/src/ImageSharp/PixelFormats/PixelImplementations/PixelOperations/RgbaHalfP.PixelOperations.cs @@ -277,8 +277,8 @@ internal static void Pack(Span source, Span destination) { for (; i <= componentCount - Vector512.Count; i += Vector512.Count) { - Vector512 lower = ClampUnit(Vector512.LoadUnsafe(ref sourceBase, (nuint)i)); - Vector512 upper = ClampUnit(Vector512.LoadUnsafe(ref sourceBase, (nuint)(i + Vector512.Count))); + Vector512 lower = Numerics.Clamp(Vector512.LoadUnsafe(ref sourceBase, (nuint)i), Vector512.Zero, Vector512.One); + Vector512 upper = Numerics.Clamp(Vector512.LoadUnsafe(ref sourceBase, (nuint)(i + Vector512.Count)), Vector512.Zero, Vector512.One); Vector512.StoreUnsafe(HalfTypeHelper.Pack(lower, upper), ref destinationBase, (nuint)i); } } @@ -287,8 +287,8 @@ internal static void Pack(Span source, Span destination) { for (; i <= componentCount - Vector256.Count; i += Vector256.Count) { - Vector256 lower = ClampUnit(Vector256.LoadUnsafe(ref sourceBase, (nuint)i)); - Vector256 upper = ClampUnit(Vector256.LoadUnsafe(ref sourceBase, (nuint)(i + Vector256.Count))); + Vector256 lower = Numerics.Clamp(Vector256.LoadUnsafe(ref sourceBase, (nuint)i), Vector256.Zero, Vector256.One); + Vector256 upper = Numerics.Clamp(Vector256.LoadUnsafe(ref sourceBase, (nuint)(i + Vector256.Count)), Vector256.Zero, Vector256.One); Vector256.StoreUnsafe(HalfTypeHelper.Pack(lower, upper), ref destinationBase, (nuint)i); } } @@ -297,15 +297,15 @@ internal static void Pack(Span source, Span destination) { for (; i <= componentCount - Vector128.Count; i += Vector128.Count) { - Vector128 lower = ClampUnit(Vector128.LoadUnsafe(ref sourceBase, (nuint)i)); - Vector128 upper = ClampUnit(Vector128.LoadUnsafe(ref sourceBase, (nuint)(i + Vector128.Count))); + Vector128 lower = Numerics.Clamp(Vector128.LoadUnsafe(ref sourceBase, (nuint)i), Vector128.Zero, Vector128.One); + Vector128 upper = Numerics.Clamp(Vector128.LoadUnsafe(ref sourceBase, (nuint)(i + Vector128.Count)), Vector128.Zero, Vector128.One); Vector128.StoreUnsafe(HalfTypeHelper.Pack(lower, upper), ref destinationBase, (nuint)i); } if (i < componentCount) { // Duplicate the final vector to use the two-input narrowing primitive, then store only one complete pixel. - Vector128 vector = ClampUnit(Vector128.LoadUnsafe(ref sourceBase, (nuint)i)); + Vector128 vector = Numerics.Clamp(Vector128.LoadUnsafe(ref sourceBase, (nuint)i), Vector128.Zero, Vector128.One); Vector128 packed = HalfTypeHelper.Pack(vector, vector); Unsafe.WriteUnaligned(ref Unsafe.As(ref Unsafe.Add(ref destinationBase, (uint)i)), packed.AsUInt64().GetElement(0)); } @@ -401,8 +401,8 @@ internal static void PackFromAssociated(Span source, Span de { for (; i <= componentCount - Vector512.Count; i += Vector512.Count) { - Vector512 lower = ClampUnit(Unassociate(Vector512.LoadUnsafe(ref sourceBase, (nuint)i))); - Vector512 upper = ClampUnit(Unassociate(Vector512.LoadUnsafe(ref sourceBase, (nuint)(i + Vector512.Count)))); + Vector512 lower = Numerics.Clamp(Unassociate(Vector512.LoadUnsafe(ref sourceBase, (nuint)i)), Vector512.Zero, Vector512.One); + Vector512 upper = Numerics.Clamp(Unassociate(Vector512.LoadUnsafe(ref sourceBase, (nuint)(i + Vector512.Count))), Vector512.Zero, Vector512.One); Vector512.StoreUnsafe(HalfTypeHelper.Pack(lower, upper), ref destinationBase, (nuint)i); } } @@ -411,8 +411,8 @@ internal static void PackFromAssociated(Span source, Span de { for (; i <= componentCount - Vector256.Count; i += Vector256.Count) { - Vector256 lower = ClampUnit(Unassociate(Vector256.LoadUnsafe(ref sourceBase, (nuint)i))); - Vector256 upper = ClampUnit(Unassociate(Vector256.LoadUnsafe(ref sourceBase, (nuint)(i + Vector256.Count)))); + Vector256 lower = Numerics.Clamp(Unassociate(Vector256.LoadUnsafe(ref sourceBase, (nuint)i)), Vector256.Zero, Vector256.One); + Vector256 upper = Numerics.Clamp(Unassociate(Vector256.LoadUnsafe(ref sourceBase, (nuint)(i + Vector256.Count))), Vector256.Zero, Vector256.One); Vector256.StoreUnsafe(HalfTypeHelper.Pack(lower, upper), ref destinationBase, (nuint)i); } } @@ -421,15 +421,15 @@ internal static void PackFromAssociated(Span source, Span de { for (; i <= componentCount - Vector128.Count; i += Vector128.Count) { - Vector128 lower = ClampUnit(Unassociate(Vector128.LoadUnsafe(ref sourceBase, (nuint)i))); - Vector128 upper = ClampUnit(Unassociate(Vector128.LoadUnsafe(ref sourceBase, (nuint)(i + Vector128.Count)))); + Vector128 lower = Numerics.Clamp(Unassociate(Vector128.LoadUnsafe(ref sourceBase, (nuint)i)), Vector128.Zero, Vector128.One); + Vector128 upper = Numerics.Clamp(Unassociate(Vector128.LoadUnsafe(ref sourceBase, (nuint)(i + Vector128.Count))), Vector128.Zero, Vector128.One); Vector128.StoreUnsafe(HalfTypeHelper.Pack(lower, upper), ref destinationBase, (nuint)i); } if (i < componentCount) { // Duplicate the final vector to use the two-input narrowing primitive, then store only one complete pixel. - Vector128 vector = ClampUnit(Unassociate(Vector128.LoadUnsafe(ref sourceBase, (nuint)i))); + Vector128 vector = Numerics.Clamp(Unassociate(Vector128.LoadUnsafe(ref sourceBase, (nuint)i)), Vector128.Zero, Vector128.One); Vector128 packed = HalfTypeHelper.Pack(vector, vector); Unsafe.WriteUnaligned(ref Unsafe.As(ref Unsafe.Add(ref destinationBase, (uint)i)), packed.AsUInt64().GetElement(0)); } @@ -649,48 +649,6 @@ private static Vector512 Unassociate(Vector512 source) return Numerics.UnPremultiply(source, alpha); } - /// - /// Clamps vectors to the unit range represented by the pixel format. - /// - /// The vectors to clamp. - /// The clamped vectors. - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private static Vector128 ClampUnit(Vector128 source) - { - Vector128 clamped = Vector128.Min(Vector128.Max(source, Vector128.Zero), Vector128.One); - - // Ordered comparison is false for NaN, restoring the source lane to match the scalar clamp contract. - return Vector128.ConditionalSelect(Vector128.Equals(source, source), clamped, source); - } - - /// - /// Clamps vectors to the unit range represented by the pixel format. - /// - /// The vectors to clamp. - /// The clamped vectors. - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private static Vector256 ClampUnit(Vector256 source) - { - Vector256 clamped = Vector256.Min(Vector256.Max(source, Vector256.Zero), Vector256.One); - - // Ordered comparison is false for NaN, restoring the source lane to match the scalar clamp contract. - return Vector256.ConditionalSelect(Vector256.Equals(source, source), clamped, source); - } - - /// - /// Clamps vectors to the unit range represented by the pixel format. - /// - /// The vectors to clamp. - /// The clamped vectors. - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private static Vector512 ClampUnit(Vector512 source) - { - Vector512 clamped = Vector512.Min(Vector512.Max(source, Vector512.Zero), Vector512.One); - - // Ordered comparison is false for NaN, restoring the source lane to match the scalar clamp contract. - return Vector512.ConditionalSelect(Vector512.Equals(source, source), clamped, source); - } - /// /// Associates unassociated vectors with the alpha value binary16 storage can reproduce. /// @@ -699,7 +657,7 @@ private static Vector512 ClampUnit(Vector512 source) [MethodImpl(MethodImplOptions.AggressiveInlining)] private static Vector128 AssociateForStorage(Vector128 source) { - source = ClampUnit(source); + source = Numerics.Clamp(source, Vector128.Zero, Vector128.One); Vector128 alpha = Vector128_.ShuffleNative(source, 0b_11_11_11_11); Vector128 storedAlpha = HalfTypeHelper.RoundToHalf(alpha); Vector128 result = source * storedAlpha; @@ -714,7 +672,7 @@ private static Vector128 AssociateForStorage(Vector128 source) [MethodImpl(MethodImplOptions.AggressiveInlining)] private static Vector256 AssociateForStorage(Vector256 source) { - source = ClampUnit(source); + source = Numerics.Clamp(source, Vector256.Zero, Vector256.One); Vector256 alpha = Vector256_.ShuffleNative(source, 0b_11_11_11_11); Vector256 storedAlpha = HalfTypeHelper.RoundToHalf(alpha); Vector256 result = source * storedAlpha; @@ -729,7 +687,7 @@ private static Vector256 AssociateForStorage(Vector256 source) [MethodImpl(MethodImplOptions.AggressiveInlining)] private static Vector512 AssociateForStorage(Vector512 source) { - source = ClampUnit(source); + source = Numerics.Clamp(source, Vector512.Zero, Vector512.One); Vector512 alpha = Vector512_.ShuffleNative(source, 0b_11_11_11_11); Vector512 storedAlpha = HalfTypeHelper.RoundToHalf(alpha); Vector512 result = source * storedAlpha; @@ -747,11 +705,13 @@ private static Vector128 ReassociateForStorage(Vector128 source) { Vector128 zero = Vector128.Zero; Vector128 alpha = Vector128_.ShuffleNative(source, 0b_11_11_11_11); - Vector128 clampedAlpha = ClampUnit(alpha); + Vector128 clampedAlpha = Numerics.Clamp(alpha, Vector128.Zero, Vector128.One); Vector128 storedAlpha = HalfTypeHelper.RoundToHalf(clampedAlpha); Vector128 result = source * (storedAlpha / alpha); result = Vector128.ConditionalSelect(Vector128.Create(0, 0, 0, -1).AsSingle(), storedAlpha, result); - result = Vector128.Min(Vector128.Max(result, zero), storedAlpha); + + // Clamp after the alpha ratio, matching the scalar conversion for nonfinite RGB. + result = Numerics.Clamp(result, zero, storedAlpha); return Vector128.ConditionalSelect(Vector128.LessThanOrEqual(alpha, zero), zero, result); } @@ -765,11 +725,13 @@ private static Vector256 ReassociateForStorage(Vector256 source) { Vector256 zero = Vector256.Zero; Vector256 alpha = Vector256_.ShuffleNative(source, 0b_11_11_11_11); - Vector256 clampedAlpha = ClampUnit(alpha); + Vector256 clampedAlpha = Numerics.Clamp(alpha, Vector256.Zero, Vector256.One); Vector256 storedAlpha = HalfTypeHelper.RoundToHalf(clampedAlpha); Vector256 result = source * (storedAlpha / alpha); result = Vector256.ConditionalSelect(Vector256.Create(0, 0, 0, -1, 0, 0, 0, -1).AsSingle(), storedAlpha, result); - result = Vector256.Min(Vector256.Max(result, zero), storedAlpha); + + // Clamp after the alpha ratio, matching the scalar conversion for nonfinite RGB. + result = Numerics.Clamp(result, zero, storedAlpha); return Vector256.ConditionalSelect(Vector256.LessThanOrEqual(alpha, zero), zero, result); } @@ -783,12 +745,14 @@ private static Vector512 ReassociateForStorage(Vector512 source) { Vector512 zero = Vector512.Zero; Vector512 alpha = Vector512_.ShuffleNative(source, 0b_11_11_11_11); - Vector512 clampedAlpha = ClampUnit(alpha); + Vector512 clampedAlpha = Numerics.Clamp(alpha, Vector512.Zero, Vector512.One); Vector512 storedAlpha = HalfTypeHelper.RoundToHalf(clampedAlpha); Vector512 result = source * (storedAlpha / alpha); Vector512 alphaMask = Vector512.Create(0, 0, 0, -1, 0, 0, 0, -1, 0, 0, 0, -1, 0, 0, 0, -1).AsSingle(); result = Vector512.ConditionalSelect(alphaMask, storedAlpha, result); - result = Vector512.Min(Vector512.Max(result, zero), storedAlpha); + + // Clamp after the alpha ratio, matching the scalar conversion for nonfinite RGB. + result = Numerics.Clamp(result, zero, storedAlpha); return Vector512.ConditionalSelect(Vector512.LessThanOrEqual(alpha, zero), zero, result); } } diff --git a/src/ImageSharp/PixelFormats/Utils/Vector4Converters.AffineOperators.cs b/src/ImageSharp/PixelFormats/Utils/Vector4Converters.AffineOperators.cs index 96d81617b2..32d6521dff 100644 --- a/src/ImageSharp/PixelFormats/Utils/Vector4Converters.AffineOperators.cs +++ b/src/ImageSharp/PixelFormats/Utils/Vector4Converters.AffineOperators.cs @@ -71,12 +71,7 @@ public MultiplyThenAddOperator(Vector4 multiplier, Vector4 offset) /// [MethodImpl(MethodImplOptions.AggressiveInlining)] - public Vector4 Invoke(Vector4 source) - { - Vector128 result = (source.AsVector128() * this.multiplier.GetLower().GetLower()) + this.offset.GetLower().GetLower(); - - return result.AsVector4(); - } + public Vector4 Invoke(Vector4 source) => this.Invoke(source.AsVector128()).AsVector4(); /// [MethodImpl(MethodImplOptions.AggressiveInlining)] diff --git a/tests/ImageSharp.Tests/Formats/Tiff/TiffDecoderTests.cs b/tests/ImageSharp.Tests/Formats/Tiff/TiffDecoderTests.cs index ec6113be30..d23eedcfee 100644 --- a/tests/ImageSharp.Tests/Formats/Tiff/TiffDecoderTests.cs +++ b/tests/ImageSharp.Tests/Formats/Tiff/TiffDecoderTests.cs @@ -2,6 +2,7 @@ // Licensed under the Six Labors Split License. // ReSharper disable InconsistentNaming +using System.Numerics; using System.Runtime.Intrinsics.X86; using SixLabors.ImageSharp.Formats; using SixLabors.ImageSharp.Formats.Png; @@ -25,6 +26,38 @@ public class TiffDecoderTests : TiffDecoderBaseTester { public static readonly string[] MultiframeTestImages = Multiframes; + /// + /// Decoded floating-point components are normalized before they enter half-vector storage. + /// + /// The encoded floating-point TIFF. + /// The normalized intensity. + [Theory] + [InlineData("49492A00080000000A0000010400010000000800000001010400010000000100000002010300010000002000000003010300010000000100" + + "0000060103000100000001000000110104000100000086000000150103000100000001000000160104000100000001000000170104000100" + + "000020000000530103000100000003000000000000000000807F0000807F0000807F0000807F0000807F0000807F0000807F0000807F", 1F)] + [InlineData("49492A00080000000A0000010400010000000800000001010400010000000100000002010300010000002000000003010300010000000100" + + "0000060103000100000001000000110104000100000086000000150103000100000001000000160104000100000001000000170104000100" + + "000020000000530103000100000003000000000000000000C07F0000C07F0000C07F0000C07F0000C07F0000C07F0000C07F0000C07F", 0F)] + [InlineData("49492A00080000000A0000010400010000000800000001010400010000000100000002010300010000002000000003010300010000000100" + + "0000060103000100000001000000110104000100000086000000150103000100000001000000160104000100000001000000170104000100" + + "000020000000530103000100000003000000000000000000004000000040000000400000004000000040000000400000004000000040", 1F)] + [InlineData("49492A00080000000A0000010400010000000800000001010400010000000100000002010300010000002000000003010300010000000100" + + "0000060103000100000001000000110104000100000086000000150103000100000001000000160104000100000001000000170104000100" + + "000020000000530103000100000003000000000000000000003F0000003F0000003F0000003F0000003F0000003F0000003F0000003F", .5F)] + public void Decode_FloatingPointSamples_NormalizesHalfVector4(string hex, float intensity) + { + byte[] data = Convert.FromHexString(hex); + using Image image = Image.Load(data); + Assert.Equal(new Size(8, 1), image.Size); + + Vector4 expected = new(intensity, intensity, intensity, 1F); + + for (int x = 0; x < image.Width; x++) + { + Assert.Equal(expected, image[x, 0].ToScaledVector4()); + } + } + [Theory] [WithFile(MultiframeDifferentVariants, PixelTypes.Rgba32)] [WithFile(Cmyk64BitDeflate, PixelTypes.Rgba32)] diff --git a/tests/ImageSharp.Tests/Helpers/NumericsTests.cs b/tests/ImageSharp.Tests/Helpers/NumericsTests.cs index 35109d352f..77ecc447ee 100644 --- a/tests/ImageSharp.Tests/Helpers/NumericsTests.cs +++ b/tests/ImageSharp.Tests/Helpers/NumericsTests.cs @@ -2,6 +2,7 @@ // Licensed under the Six Labors Split License. using System.Numerics; +using System.Runtime.Intrinsics; namespace SixLabors.ImageSharp.Tests.Helpers; @@ -305,6 +306,114 @@ public void ClampDouble(int length, double min, double max) (v, m1, m2) => Numerics.Clamp(v, m1, m2)); } + /// + /// Scalar, SIMD, and span clamps map nonfinite values to the requested bounds. + /// + /// The lower bound. + /// The upper bound. + [Theory] + [InlineData(0F, 1F)] + [InlineData(-2F, 3F)] + [InlineData(.25F, .75F)] + public void ClampSingle_NormalizesNonfiniteValues(float min, float max) + { + float midpoint = (min + max) / 2; + float[] inputs = [float.NaN, float.PositiveInfinity, float.NegativeInfinity, midpoint]; + float[] normalized = [min, max, min, midpoint]; + float[] values = new float[65]; + float[] expected = new float[values.Length]; + + // The length includes complete registers and remainders for every supported SIMD width. + for (int i = 0; i < values.Length; i++) + { + values[i] = inputs[i % inputs.Length]; + expected[i] = normalized[i % normalized.Length]; + Assert.Equal(expected[i], Numerics.Clamp(values[i], min, max)); + } + + Vector4 input = new(inputs[0], inputs[1], inputs[2], inputs[3]); + Vector4 result = new(normalized[0], normalized[1], normalized[2], normalized[3]); + Assert.Equal(result, Numerics.Clamp(input, new Vector4(min), new Vector4(max))); + Assert.Equal(new Vector2(min, max), Numerics.Clamp(new Vector2(float.NaN, float.PositiveInfinity), new Vector2(min), new Vector2(max))); + + Vector128 vector128 = Vector128.LoadUnsafe(ref values[0]); + Assert.Equal(Vector128.LoadUnsafe(ref expected[0]), Numerics.Clamp(vector128, Vector128.Create(min), Vector128.Create(max))); + + Vector256 vector256 = Vector256.LoadUnsafe(ref values[0]); + Assert.Equal(Vector256.LoadUnsafe(ref expected[0]), Numerics.Clamp(vector256, Vector256.Create(min), Vector256.Create(max))); + + Vector512 vector512 = Vector512.LoadUnsafe(ref values[0]); + Assert.Equal(Vector512.LoadUnsafe(ref expected[0]), Numerics.Clamp(vector512, Vector512.Create(min), Vector512.Create(max))); + + Numerics.Clamp(values, min, max); + Assert.Equal(expected, values); + } + + /// + /// Scalar, SIMD, and span clamps map nonfinite values to the requested bounds. + /// + /// The lower bound. + /// The upper bound. + [Theory] + [InlineData(0D, 1D)] + [InlineData(-2D, 3D)] + [InlineData(.25D, .75D)] + public void ClampDouble_NormalizesNonfiniteValues(double min, double max) + { + double midpoint = (min + max) / 2; + double[] inputs = [double.NaN, double.PositiveInfinity, double.NegativeInfinity, midpoint]; + double[] normalized = [min, max, min, midpoint]; + double[] values = new double[65]; + double[] expected = new double[values.Length]; + + // The length includes complete registers and remainders for every supported SIMD width. + for (int i = 0; i < values.Length; i++) + { + values[i] = inputs[i % inputs.Length]; + expected[i] = normalized[i % normalized.Length]; + Assert.Equal(expected[i], Numerics.Clamp(values[i], min, max)); + } + + Vector128 vector128 = Vector128.LoadUnsafe(ref values[0]); + Assert.Equal(Vector128.LoadUnsafe(ref expected[0]), Numerics.Clamp(vector128, Vector128.Create(min), Vector128.Create(max))); + + Vector256 vector256 = Vector256.LoadUnsafe(ref values[0]); + Assert.Equal(Vector256.LoadUnsafe(ref expected[0]), Numerics.Clamp(vector256, Vector256.Create(min), Vector256.Create(max))); + + Vector512 vector512 = Vector512.LoadUnsafe(ref values[0]); + Assert.Equal(Vector512.LoadUnsafe(ref expected[0]), Numerics.Clamp(vector512, Vector512.Create(min), Vector512.Create(max))); + + Numerics.Clamp(values, min, max); + Assert.Equal(expected, values); + } + + /// + /// Clamping an in-range zero preserves its sign in scalar and bulk conversions. + /// + [Fact] + public void Clamp_PreservesInRangeSignedZero() + { + float[] singles = new float[65]; + double[] doubles = new double[65]; + + for (int i = 0; i < singles.Length; i++) + { + singles[i] = i % 2 == 0 ? -0F : 0F; + doubles[i] = i % 2 == 0 ? -0D : 0D; + Assert.Equal(BitConverter.SingleToInt32Bits(singles[i]), BitConverter.SingleToInt32Bits(Numerics.Clamp(singles[i], 0F, 1F))); + Assert.Equal(BitConverter.DoubleToInt64Bits(doubles[i]), BitConverter.DoubleToInt64Bits(Numerics.Clamp(doubles[i], 0D, 1D))); + } + + Numerics.Clamp(singles, 0F, 1F); + Numerics.Clamp(doubles, 0D, 1D); + + for (int i = 0; i < singles.Length; i++) + { + Assert.Equal(BitConverter.SingleToInt32Bits(i % 2 == 0 ? -0F : 0F), BitConverter.SingleToInt32Bits(singles[i])); + Assert.Equal(BitConverter.DoubleToInt64Bits(i % 2 == 0 ? -0D : 0D), BitConverter.DoubleToInt64Bits(doubles[i])); + } + } + private static void TestClampSpan( int length, T min, diff --git a/tests/ImageSharp.Tests/PixelFormats/FloatingPointPixelNormalizationTests.cs b/tests/ImageSharp.Tests/PixelFormats/FloatingPointPixelNormalizationTests.cs new file mode 100644 index 0000000000..5718da5b03 --- /dev/null +++ b/tests/ImageSharp.Tests/PixelFormats/FloatingPointPixelNormalizationTests.cs @@ -0,0 +1,183 @@ +// Copyright (c) Six Labors. +// Licensed under the Six Labors Split License. + +using System.Numerics; +using SixLabors.ImageSharp.PixelFormats; + +namespace SixLabors.ImageSharp.Tests.PixelFormats; + +[Trait("Category", "PixelFormats")] +public class FloatingPointPixelNormalizationTests +{ + /// + /// HalfSingle normalizes scaled input identically in scalar and bulk conversions. + /// + [Fact] + public void HalfSingle_ScaledInputIsNormalized() => AssertScaledInputIsNormalized(); + + /// + /// HalfVector2 normalizes scaled input identically in scalar and bulk conversions. + /// + [Fact] + public void HalfVector2_ScaledInputIsNormalized() => AssertScaledInputIsNormalized(); + + /// + /// HalfVector4 normalizes scaled input identically in scalar and bulk conversions. + /// + [Fact] + public void HalfVector4_ScaledInputIsNormalized() => AssertScaledInputIsNormalized(); + + /// + /// HalfVector4P normalizes scaled input identically in scalar and bulk conversions. + /// + [Fact] + public void HalfVector4P_ScaledInputIsNormalized() => AssertScaledInputIsNormalized(); + + /// + /// RgbaVector normalizes scaled input identically in scalar and bulk conversions. + /// + [Fact] + public void RgbaVector_ScaledInputIsNormalized() => AssertScaledInputIsNormalized(); + + /// + /// RgbaHalf normalizes scaled input identically in scalar and bulk conversions. + /// + [Fact] + public void RgbaHalf_ScaledInputIsNormalized() => AssertScaledInputIsNormalized(); + + /// + /// RgbaHalfP normalizes scaled input identically in scalar and bulk conversions. + /// + [Fact] + public void RgbaHalfP_ScaledInputIsNormalized() => AssertScaledInputIsNormalized(); + + /// + /// Raw half storage preserves IEEE special values while its scaled representation remains finite. + /// + [Fact] + public void HalfVector4_NativeSpecialValuesHaveNormalizedScaledOutput() => AssertNativeSpecialValuesHaveNormalizedScaledOutput(); + + /// + /// Associated half-vector conversion uses the stored alpha ratio before normalizing RGB. + /// + [Fact] + public void HalfVector4P_AssociatedScaledInputIsNormalized() => AssertAssociatedScaledInputIsNormalized(); + + /// + /// Associated half-RGBA conversion uses the stored alpha ratio before normalizing RGB. + /// + [Fact] + public void RgbaHalfP_AssociatedScaledInputIsNormalized() => AssertAssociatedScaledInputIsNormalized(); + + /// + /// Checks saturation and NaN handling without deriving expectations from the invalid-input path. + /// + /// The destination pixel format. + private static void AssertScaledInputIsNormalized() + where TPixel : unmanaged, IPixel + { + Vector4[] inputs = + [ + new(float.PositiveInfinity, float.NegativeInfinity, float.NaN, 1F), + new(2F, -2F, .5F, 1F), + new(.25F, .5F, .75F, .5F), + new(.25F, .5F, .75F, float.NaN), + new(.25F, .5F, .75F, float.PositiveInfinity) + ]; + + Vector4[] normalized = + [ + new(1F, 0F, 0F, 1F), + new(1F, 0F, .5F, 1F), + new(.25F, .5F, .75F, .5F), + new(.25F, .5F, .75F, 0F), + new(.25F, .5F, .75F, 1F) + ]; + + // Seventeen pixels exercise wide registers and the narrower remainder paths. + Vector4[] source = new Vector4[17]; + TPixel[] expected = new TPixel[source.Length]; + TPixel[] actual = new TPixel[source.Length]; + + for (int i = 0; i < source.Length; i++) + { + int sample = i % inputs.Length; + source[i] = inputs[sample]; + expected[i] = TPixel.FromUnassociatedScaledVector4(normalized[sample]); + Assert.Equal(expected[i], TPixel.FromUnassociatedScaledVector4(source[i])); + } + + // Associated formats otherwise interpret the vectors using their native alpha representation. + PixelOperations.Instance.FromVector4Destructive(Configuration.Default, source, actual, PixelConversionModifiers.Scale | PixelConversionModifiers.UnPremultiply); + Assert.Equal(expected, actual); + } + + /// + /// Checks associated input against finite control values with the same represented color. + /// + /// The associated destination pixel format. + private static void AssertAssociatedScaledInputIsNormalized() + where TPixel : unmanaged, IPixel + { + Vector4[] inputs = + [ + new(float.PositiveInfinity, float.NegativeInfinity, float.NaN, 1F), + new(1F, .5F, 1.5F, 2F), + new(.125F, .25F, .375F, .5F), + new(.25F, .5F, .75F, float.NaN), + new(.25F, .5F, .75F, float.PositiveInfinity) + ]; + + Vector4[] normalized = + [ + new(1F, 0F, 0F, 1F), + new(.5F, .25F, .75F, 1F), + new(.125F, .25F, .375F, .5F), + Vector4.Zero, + new(0F, 0F, 0F, 1F) + ]; + + Vector4[] source = new Vector4[17]; + TPixel[] expected = new TPixel[source.Length]; + TPixel[] actual = new TPixel[source.Length]; + + for (int i = 0; i < source.Length; i++) + { + int sample = i % inputs.Length; + source[i] = inputs[sample]; + expected[i] = TPixel.FromAssociatedScaledVector4(normalized[sample]); + Assert.Equal(expected[i], TPixel.FromAssociatedScaledVector4(source[i])); + } + + PixelOperations.Instance.FromVector4Destructive(Configuration.Default, source, actual, PixelConversionModifiers.Scale | PixelConversionModifiers.Premultiply); + Assert.Equal(expected, actual); + } + + /// + /// Checks native storage and every scaled output lane independently of integer conversion semantics. + /// + private static void AssertNativeSpecialValuesHaveNormalizedScaledOutput() + { + Vector4 native = new(float.PositiveInfinity, float.NegativeInfinity, float.NaN, 65504F); + HalfVector4 pixel = HalfVector4.FromVector4(native); + Assert.True(float.IsPositiveInfinity(pixel.ToVector4().X)); + Assert.True(float.IsNegativeInfinity(pixel.ToVector4().Y)); + Assert.True(float.IsNaN(pixel.ToVector4().Z)); + + Vector4 expected = new(1F, 0F, 0F, 1F); + Assert.Equal(expected, pixel.ToScaledVector4()); + Assert.Equal(1F, new HalfSingle(float.PositiveInfinity).ToScaledVector4().X); + Assert.Equal(0F, new HalfSingle(float.NaN).ToScaledVector4().X); + Assert.Equal(new Vector4(1F, 0F, 0F, 1F), new HalfVector2(new Vector2(float.PositiveInfinity, float.NaN)).ToScaledVector4()); + + HalfVector4[] source = new HalfVector4[17]; + Vector4[] nativeSource = new Vector4[source.Length]; + Array.Fill(nativeSource, native); + PixelOperations.Instance.FromVector4Destructive(Configuration.Default, nativeSource, source, PixelConversionModifiers.None); + Assert.All(source, value => Assert.Equal(pixel.PackedValue, value.PackedValue)); + + Vector4[] actual = new Vector4[source.Length]; + PixelOperations.Instance.ToVector4(Configuration.Default, source, actual, PixelConversionModifiers.Scale); + Assert.All(actual, value => Assert.Equal(expected, value)); + } +} diff --git a/tests/ImageSharp.Tests/Processing/Normalization/HistogramEqualizationTests.cs b/tests/ImageSharp.Tests/Processing/Normalization/HistogramEqualizationTests.cs index 917d68d025..c39648d014 100644 --- a/tests/ImageSharp.Tests/Processing/Normalization/HistogramEqualizationTests.cs +++ b/tests/ImageSharp.Tests/Processing/Normalization/HistogramEqualizationTests.cs @@ -14,18 +14,6 @@ public class HistogramEqualizationTests { private static readonly ImageComparer ValidatorComparer = ImageComparer.TolerantPercentage(0.0456F); - [Fact] - public void GlobalHistogramEqualization_ClampsDecodedFloatingPointSamples() - { - byte[] data = BuildFloatTiff(8, 2F); - - using Image image = Image.Load(data); - image.Mutate(x => x.HistogramEqualization()); - - Assert.Equal(8, image.Width); - Assert.Equal(1, image.Height); - } - [Theory] [InlineData(256)] [InlineData(65536)] @@ -250,47 +238,4 @@ public unsafe void Issue1640(TestImageProvider provider) ValidatorComparer.VerifySimilarity(referenceResult, processed); } - - private static byte[] BuildFloatTiff(int width, float sample) - { - const int headerLength = 8; - const int entryCount = 10; - const int ifdLength = 2 + (entryCount * 12) + 4; - uint pixelOffset = headerLength + ifdLength; - - using MemoryStream stream = new(); - using BinaryWriter writer = new(stream); - - writer.Write((byte)0x49); - writer.Write((byte)0x49); - writer.Write((ushort)42); - writer.Write((uint)headerLength); - writer.Write((ushort)entryCount); - WriteEntry(writer, 256, 4, 1, (uint)width); - WriteEntry(writer, 257, 4, 1, 1); - WriteEntry(writer, 258, 3, 1, 32); - WriteEntry(writer, 259, 3, 1, 1); - WriteEntry(writer, 262, 3, 1, 1); - WriteEntry(writer, 273, 4, 1, pixelOffset); - WriteEntry(writer, 277, 3, 1, 1); - WriteEntry(writer, 278, 4, 1, 1); - WriteEntry(writer, 279, 4, 1, (uint)(width * sizeof(float))); - WriteEntry(writer, 339, 3, 1, 3); - writer.Write(0U); - - for (int i = 0; i < width; i++) - { - writer.Write(sample); - } - - return stream.ToArray(); - - static void WriteEntry(BinaryWriter writer, ushort tag, ushort type, uint count, uint value) - { - writer.Write(tag); - writer.Write(type); - writer.Write(count); - writer.Write(value); - } - } } From 6f9d16910de2b1ba53fdbe786898565e02dde1d3 Mon Sep 17 00:00:00 2001 From: James Jackson-South Date: Sat, 5 Sep 2026 11:26:28 +1000 Subject: [PATCH 21/27] Initialize omitted EXR color channels --- src/ImageSharp/Formats/Exr/ExrDecoderCore.cs | 6 +- .../Formats/Exr/ExrZipDecoderTests.cs | 56 +++++++++++++++++-- 2 files changed, 56 insertions(+), 6 deletions(-) diff --git a/src/ImageSharp/Formats/Exr/ExrDecoderCore.cs b/src/ImageSharp/Formats/Exr/ExrDecoderCore.cs index 9dd333d92f..ac810fff78 100644 --- a/src/ImageSharp/Formats/Exr/ExrDecoderCore.cs +++ b/src/ImageSharp/Formats/Exr/ExrDecoderCore.cs @@ -166,7 +166,8 @@ private void DecodeFloatingPointPixelData(BufferedReadStream stream, Buf int height = this.Height; int channelCount = this.Channels.Count; - using IMemoryOwner rowBuffer = this.memoryAllocator.Allocate(width * 4); + // EXR can omit color channels. Initialize their planes once so absent channels remain black on every row. + using IMemoryOwner rowBuffer = this.memoryAllocator.Allocate(width * 4, AllocationOptions.Clean); using IMemoryOwner decompressedPixelDataBuffer = this.memoryAllocator.Allocate((int)bytesPerBlock); Span decompressedPixelData = decompressedPixelDataBuffer.GetSpan(); Span redPixelData = rowBuffer.GetSpan()[..width]; @@ -254,7 +255,8 @@ private void DecodeUnsignedIntPixelData(BufferedReadStream stream, Buffe int height = this.Height; int channelCount = this.Channels.Count; - using IMemoryOwner rowBuffer = this.memoryAllocator.Allocate(width * 4); + // EXR can omit color channels. Initialize their planes once so absent channels remain black on every row. + using IMemoryOwner rowBuffer = this.memoryAllocator.Allocate(width * 4, AllocationOptions.Clean); using IMemoryOwner decompressedPixelDataBuffer = this.memoryAllocator.Allocate((int)bytesPerBlock); Span decompressedPixelData = decompressedPixelDataBuffer.GetSpan(); Span redPixelData = rowBuffer.GetSpan()[..width]; diff --git a/tests/ImageSharp.Tests/Formats/Exr/ExrZipDecoderTests.cs b/tests/ImageSharp.Tests/Formats/Exr/ExrZipDecoderTests.cs index 23d9ccedd4..e102ab8076 100644 --- a/tests/ImageSharp.Tests/Formats/Exr/ExrZipDecoderTests.cs +++ b/tests/ImageSharp.Tests/Formats/Exr/ExrZipDecoderTests.cs @@ -3,8 +3,12 @@ using System.Buffers.Binary; using System.IO.Compression; +using System.Numerics; using System.Text; +using SixLabors.ImageSharp.Formats; +using SixLabors.ImageSharp.Formats.Exr.Constants; using SixLabors.ImageSharp.PixelFormats; +using SixLabors.ImageSharp.Tests.Memory; namespace SixLabors.ImageSharp.Tests.Formats.Exr; @@ -15,11 +19,48 @@ public class ExrZipDecoderTests [Fact] public void Decode_ShortInflatedBlock_Throws() { - byte[] data = BuildExr(ZlibCompress(new byte[8])); + byte[] data = BuildExr(ZlibCompress(new byte[8]), ExrPixelType.Float, 2); Assert.Throws(() => Image.Load(data)); } + /// + /// Missing color channels must not inherit the allocator's previous contents. + /// + /// The stored sample type. + /// The ZIP compression code. + [Theory] + [InlineData(ExrPixelType.Half, 2)] + [InlineData(ExrPixelType.Float, 2)] + [InlineData(ExrPixelType.UnsignedInt, 2)] + [InlineData(ExrPixelType.Half, 3)] + [InlineData(ExrPixelType.Float, 3)] + [InlineData(ExrPixelType.UnsignedInt, 3)] + public void Decode_SingleRedChannel_InitializesMissingColorChannels(ExrPixelType pixelType, byte compression) + { + byte[] predicted = new byte[256 * (pixelType == ExrPixelType.Half ? 2 : 4)]; + + // A zero first byte followed by 128-valued differences reconstructs an all-zero sample plane. + predicted.AsSpan(1).Fill(128); + byte[] data = BuildExr(ZlibCompress(predicted), pixelType, compression); + Configuration configuration = Configuration.Default.Clone(); + configuration.MemoryAllocator = new TestMemoryAllocator(0x3F); + DecoderOptions options = new() { Configuration = configuration }; + + using Image image = Image.Load(options, data); + Assert.Equal(new Size(256, 1), image.Size); + + for (int x = 0; x < image.Width; x++) + { + Assert.Equal(new Vector4(0, 0, 0, 1), image[x, 0].ToVector4()); + } + } + + /// + /// Compresses the predictor bytes for a scanline block. + /// + /// The predictor bytes. + /// The zlib stream. private static byte[] ZlibCompress(byte[] data) { using MemoryStream output = new(); @@ -31,7 +72,14 @@ private static byte[] ZlibCompress(byte[] data) return output.ToArray(); } - private static byte[] BuildExr(byte[] compressed) + /// + /// Builds a single-row EXR containing only the red channel. + /// + /// The compressed scanline bytes. + /// The stored sample type. + /// The ZIP compression code. + /// The encoded image. + private static byte[] BuildExr(byte[] compressed, ExrPixelType pixelType, byte compression) { const int width = 256; const int height = 1; @@ -47,7 +95,7 @@ private static byte[] BuildExr(byte[] compressed) using (BinaryWriter channelWriter = new(channelStream)) { WriteString(channelWriter, "R"); - channelWriter.Write(2); + channelWriter.Write((int)pixelType); channelWriter.Write((byte)0); channelWriter.Write(new byte[] { 0, 0, 0 }); channelWriter.Write(1); @@ -57,7 +105,7 @@ private static byte[] BuildExr(byte[] compressed) WriteAttribute(writer, "channels", "chlist", channelStream.ToArray()); } - WriteAttribute(writer, "compression", "compression", [2]); + WriteAttribute(writer, "compression", "compression", [compression]); using (MemoryStream boxStream = new()) using (BinaryWriter boxWriter = new(boxStream)) From f113fa836fea021524328a5f193c455af7f3d9ef Mon Sep 17 00:00:00 2001 From: James Jackson-South Date: Sat, 5 Sep 2026 11:28:31 +1000 Subject: [PATCH 22/27] Honor EXR image data recovery without exposing incomplete blocks --- src/ImageSharp/Formats/Exr/ExrDecoderCore.cs | 26 +++++++++- .../Formats/Exr/ExrZipDecoderTests.cs | 51 +++++++++++++++++-- 2 files changed, 71 insertions(+), 6 deletions(-) diff --git a/src/ImageSharp/Formats/Exr/ExrDecoderCore.cs b/src/ImageSharp/Formats/Exr/ExrDecoderCore.cs index ac810fff78..8ac96f9483 100644 --- a/src/ImageSharp/Formats/Exr/ExrDecoderCore.cs +++ b/src/ImageSharp/Formats/Exr/ExrDecoderCore.cs @@ -203,7 +203,7 @@ private void DecodeFloatingPointPixelData(BufferedReadStream stream, Buf uint rowsInBlock = Math.Min(rowsPerBlock, (uint)height - rowStartIndex); uint uncompressedBytesCount = (uint)(bytesPerRow * rowsInBlock); - decompressor.Decompress(stream, compressedBytesCount, uncompressedBytesCount, decompressedPixelData); + this.DecompressBlock(decompressor, stream, compressedBytesCount, uncompressedBytesCount, decompressedPixelData); int offset = 0; for (uint rowIndex = rowStartIndex; rowIndex < rowStartIndex + rowsPerBlock && rowIndex < height; rowIndex++) @@ -292,7 +292,7 @@ private void DecodeUnsignedIntPixelData(BufferedReadStream stream, Buffe uint rowsInBlock = Math.Min(rowsPerBlock, (uint)height - rowStartIndex); uint uncompressedBytesCount = (uint)(bytesPerRow * rowsInBlock); - decompressor.Decompress(stream, compressedBytesCount, uncompressedBytesCount, decompressedPixelData); + this.DecompressBlock(decompressor, stream, compressedBytesCount, uncompressedBytesCount, decompressedPixelData); int offset = 0; for (uint rowIndex = rowStartIndex; rowIndex < rowStartIndex + rowsPerBlock && rowIndex < height; rowIndex++) @@ -321,6 +321,28 @@ private void DecodeUnsignedIntPixelData(BufferedReadStream stream, Buffe } } + /// + /// Decompresses a block according to the configured image-data integrity policy. + /// + /// The decompressor for the stored compression type. + /// The encoded block stream. + /// The declared compressed byte count. + /// The expected byte count for the rows in this block. + /// The reusable decompressed pixel buffer. + private void DecompressBlock(ExrBaseDecompressor decompressor, BufferedReadStream stream, uint compressedBytes, uint uncompressedBytes, Span buffer) + { + try + { + decompressor.Decompress(stream, compressedBytes, uncompressedBytes, buffer); + } + catch (Exception ex) when (this.Options.SegmentIntegrityHandling == SegmentIntegrityHandling.IgnoreImageData && ex is InvalidImageContentException or InvalidDataException) + { + // The offset table locates the next block independently of this damaged payload. + // Discard the entire failed block so partial output or pooled bytes cannot become pixels. + buffer[..(int)uncompressedBytes].Clear(); + } + } + /// /// Reads float image channel data. /// diff --git a/tests/ImageSharp.Tests/Formats/Exr/ExrZipDecoderTests.cs b/tests/ImageSharp.Tests/Formats/Exr/ExrZipDecoderTests.cs index e102ab8076..4435410316 100644 --- a/tests/ImageSharp.Tests/Formats/Exr/ExrZipDecoderTests.cs +++ b/tests/ImageSharp.Tests/Formats/Exr/ExrZipDecoderTests.cs @@ -16,12 +16,55 @@ namespace SixLabors.ImageSharp.Tests.Formats.Exr; [ValidateDisposedMemoryAllocations] public class ExrZipDecoderTests { - [Fact] - public void Decode_ShortInflatedBlock_Throws() + /// + /// Incomplete and oversized blocks are rejected unless image-data recovery is enabled. + /// + /// The inflated payload length. + /// The image-data integrity policy. + [Theory] + [InlineData(0, SegmentIntegrityHandling.Strict)] + [InlineData(8, SegmentIntegrityHandling.Strict)] + [InlineData(1025, SegmentIntegrityHandling.Strict)] + [InlineData(0, SegmentIntegrityHandling.IgnoreAncillary)] + [InlineData(8, SegmentIntegrityHandling.IgnoreAncillary)] + [InlineData(1025, SegmentIntegrityHandling.IgnoreAncillary)] + public void Decode_InvalidInflatedBlock_Throws(int length, SegmentIntegrityHandling integrity) + { + byte[] data = BuildExr(ZlibCompress(new byte[length]), ExrPixelType.Float, 2); + DecoderOptions options = new() { SegmentIntegrityHandling = integrity }; + + Assert.Throws(() => Image.Load(options, data)); + } + + /// + /// Recovering an invalid image-data block must not expose partially decoded or pooled bytes. + /// + /// The stored sample type. + /// The inflated payload length. + [Theory] + [InlineData(ExrPixelType.Half, 0)] + [InlineData(ExrPixelType.Half, 8)] + [InlineData(ExrPixelType.Half, 1025)] + [InlineData(ExrPixelType.Float, 0)] + [InlineData(ExrPixelType.Float, 8)] + [InlineData(ExrPixelType.Float, 1025)] + [InlineData(ExrPixelType.UnsignedInt, 0)] + [InlineData(ExrPixelType.UnsignedInt, 8)] + [InlineData(ExrPixelType.UnsignedInt, 1025)] + public void Decode_InvalidInflatedBlock_IgnoreImageData_ClearsPixels(ExrPixelType pixelType, int length) { - byte[] data = BuildExr(ZlibCompress(new byte[8]), ExrPixelType.Float, 2); + byte[] data = BuildExr(ZlibCompress(new byte[length]), pixelType, 2); + Configuration configuration = Configuration.Default.Clone(); + configuration.MemoryAllocator = new TestMemoryAllocator(0x3F); + DecoderOptions options = new() { Configuration = configuration, SegmentIntegrityHandling = SegmentIntegrityHandling.IgnoreImageData }; + + using Image image = Image.Load(options, data); + Assert.Equal(new Size(256, 1), image.Size); - Assert.Throws(() => Image.Load(data)); + for (int x = 0; x < image.Width; x++) + { + Assert.Equal(new Vector4(0, 0, 0, 1), image[x, 0].ToVector4()); + } } /// From ffd137222b9e81772759ac640c7dbe9bf15eeb1f Mon Sep 17 00:00:00 2001 From: James Jackson-South Date: Sat, 5 Sep 2026 11:30:24 +1000 Subject: [PATCH 23/27] Validate EXR block rows relative to the data window --- src/ImageSharp/Formats/Exr/ExrDecoderCore.cs | 8 +++-- .../Formats/Exr/ExrZipDecoderTests.cs | 36 +++++++++++-------- 2 files changed, 28 insertions(+), 16 deletions(-) diff --git a/src/ImageSharp/Formats/Exr/ExrDecoderCore.cs b/src/ImageSharp/Formats/Exr/ExrDecoderCore.cs index 8ac96f9483..4c9924ec3b 100644 --- a/src/ImageSharp/Formats/Exr/ExrDecoderCore.cs +++ b/src/ImageSharp/Formats/Exr/ExrDecoderCore.cs @@ -193,7 +193,9 @@ private void DecodeFloatingPointPixelData(BufferedReadStream stream, Buf this.ValidateChunkOffset(rowOffset, stream); stream.Position = (long)rowOffset; - uint rowStartIndex = this.ReadUnsignedInteger(stream); + + // Chunk coordinates are signed and absolute; pixel rows are relative to the data window. + uint rowStartIndex = (uint)((long)this.ReadSignedInteger(stream) - this.HeaderAttributes.DataWindow.YMin); if (rowStartIndex >= height) { ExrThrowHelper.ThrowInvalidImageContentException("EXR chunk row index is outside the data window."); @@ -282,7 +284,9 @@ private void DecodeUnsignedIntPixelData(BufferedReadStream stream, Buffe this.ValidateChunkOffset(rowOffset, stream); stream.Position = (long)rowOffset; - uint rowStartIndex = this.ReadUnsignedInteger(stream); + + // Chunk coordinates are signed and absolute; pixel rows are relative to the data window. + uint rowStartIndex = (uint)((long)this.ReadSignedInteger(stream) - this.HeaderAttributes.DataWindow.YMin); if (rowStartIndex >= height) { ExrThrowHelper.ThrowInvalidImageContentException("EXR chunk row index is outside the data window."); diff --git a/tests/ImageSharp.Tests/Formats/Exr/ExrZipDecoderTests.cs b/tests/ImageSharp.Tests/Formats/Exr/ExrZipDecoderTests.cs index 4435410316..d40564d4ad 100644 --- a/tests/ImageSharp.Tests/Formats/Exr/ExrZipDecoderTests.cs +++ b/tests/ImageSharp.Tests/Formats/Exr/ExrZipDecoderTests.cs @@ -30,7 +30,7 @@ public class ExrZipDecoderTests [InlineData(1025, SegmentIntegrityHandling.IgnoreAncillary)] public void Decode_InvalidInflatedBlock_Throws(int length, SegmentIntegrityHandling integrity) { - byte[] data = BuildExr(ZlibCompress(new byte[length]), ExrPixelType.Float, 2); + byte[] data = BuildExr(ZlibCompress(new byte[length]), ExrPixelType.Float, 2, 0); DecoderOptions options = new() { SegmentIntegrityHandling = integrity }; Assert.Throws(() => Image.Load(options, data)); @@ -53,7 +53,7 @@ public void Decode_InvalidInflatedBlock_Throws(int length, SegmentIntegrityHandl [InlineData(ExrPixelType.UnsignedInt, 1025)] public void Decode_InvalidInflatedBlock_IgnoreImageData_ClearsPixels(ExrPixelType pixelType, int length) { - byte[] data = BuildExr(ZlibCompress(new byte[length]), pixelType, 2); + byte[] data = BuildExr(ZlibCompress(new byte[length]), pixelType, 2, 0); Configuration configuration = Configuration.Default.Clone(); configuration.MemoryAllocator = new TestMemoryAllocator(0x3F); DecoderOptions options = new() { Configuration = configuration, SegmentIntegrityHandling = SegmentIntegrityHandling.IgnoreImageData }; @@ -72,20 +72,27 @@ public void Decode_InvalidInflatedBlock_IgnoreImageData_ClearsPixels(ExrPixelTyp /// /// The stored sample type. /// The ZIP compression code. + /// The data window's first row coordinate. [Theory] - [InlineData(ExrPixelType.Half, 2)] - [InlineData(ExrPixelType.Float, 2)] - [InlineData(ExrPixelType.UnsignedInt, 2)] - [InlineData(ExrPixelType.Half, 3)] - [InlineData(ExrPixelType.Float, 3)] - [InlineData(ExrPixelType.UnsignedInt, 3)] - public void Decode_SingleRedChannel_InitializesMissingColorChannels(ExrPixelType pixelType, byte compression) + [InlineData(ExrPixelType.Half, 2, 0)] + [InlineData(ExrPixelType.Float, 2, 0)] + [InlineData(ExrPixelType.UnsignedInt, 2, 0)] + [InlineData(ExrPixelType.Half, 3, 0)] + [InlineData(ExrPixelType.Float, 3, 0)] + [InlineData(ExrPixelType.UnsignedInt, 3, 0)] + [InlineData(ExrPixelType.Half, 3, -10)] + [InlineData(ExrPixelType.Float, 3, -10)] + [InlineData(ExrPixelType.UnsignedInt, 3, -10)] + [InlineData(ExrPixelType.Half, 3, 10)] + [InlineData(ExrPixelType.Float, 3, 10)] + [InlineData(ExrPixelType.UnsignedInt, 3, 10)] + public void Decode_SingleRedChannel_InitializesMissingColorChannels(ExrPixelType pixelType, byte compression, int yMin) { byte[] predicted = new byte[256 * (pixelType == ExrPixelType.Half ? 2 : 4)]; // A zero first byte followed by 128-valued differences reconstructs an all-zero sample plane. predicted.AsSpan(1).Fill(128); - byte[] data = BuildExr(ZlibCompress(predicted), pixelType, compression); + byte[] data = BuildExr(ZlibCompress(predicted), pixelType, compression, yMin); Configuration configuration = Configuration.Default.Clone(); configuration.MemoryAllocator = new TestMemoryAllocator(0x3F); DecoderOptions options = new() { Configuration = configuration }; @@ -121,8 +128,9 @@ private static byte[] ZlibCompress(byte[] data) /// The compressed scanline bytes. /// The stored sample type. /// The ZIP compression code. + /// The data window's first row coordinate. /// The encoded image. - private static byte[] BuildExr(byte[] compressed, ExrPixelType pixelType, byte compression) + private static byte[] BuildExr(byte[] compressed, ExrPixelType pixelType, byte compression, int yMin) { const int width = 256; const int height = 1; @@ -154,9 +162,9 @@ private static byte[] BuildExr(byte[] compressed, ExrPixelType pixelType, byte c using (BinaryWriter boxWriter = new(boxStream)) { boxWriter.Write(0); - boxWriter.Write(0); + boxWriter.Write(yMin); boxWriter.Write(width - 1); - boxWriter.Write(height - 1); + boxWriter.Write(yMin + height - 1); byte[] box = boxStream.ToArray(); WriteAttribute(writer, "dataWindow", "box2i", box); @@ -174,7 +182,7 @@ private static byte[] BuildExr(byte[] compressed, ExrPixelType pixelType, byte c long chunkStart = output.Position + sizeof(ulong); writer.Write((ulong)chunkStart); - writer.Write(0U); + writer.Write(yMin); writer.Write((uint)compressed.Length); writer.Write(compressed); From 4b73eaad10906b4706b100d724ce31cb7f8d8472 Mon Sep 17 00:00:00 2001 From: James Jackson-South Date: Sat, 5 Sep 2026 11:32:47 +1000 Subject: [PATCH 24/27] Cover complete ICC CLUT payload and conversion controls --- .../Formats/Png/PngDecoderTests.Icc.cs | 34 +++++++++++++++++++ .../ICC/DataReader/IccDataReaderLutTests.cs | 20 +++++++++++ 2 files changed, 54 insertions(+) diff --git a/tests/ImageSharp.Tests/Formats/Png/PngDecoderTests.Icc.cs b/tests/ImageSharp.Tests/Formats/Png/PngDecoderTests.Icc.cs index 25632212c6..a69e0b21cd 100644 --- a/tests/ImageSharp.Tests/Formats/Png/PngDecoderTests.Icc.cs +++ b/tests/ImageSharp.Tests/Formats/Png/PngDecoderTests.Icc.cs @@ -22,6 +22,40 @@ public void Decode_IccLutExceedsVectorChannelCount_Throws() Assert.Throws(() => Image.Load(options, pngData)); } + /// + /// Three-channel LUT conversion remains supported. + /// + [Fact] + public void Decode_IccLutWithSupportedChannelCount_ConvertsPixels() + { + byte[] pngData = BuildPng(BuildLut16Profile(3, 3, 2, 2, 2)); + DecoderOptions options = new() { ColorProfileHandling = ColorProfileHandling.Convert }; + + using Image image = Image.Load(options, pngData); + Assert.Equal(new Size(16, 16), image.Size); + + // Every CLUT node contains a nonzero XYZ value, even though the encoded pixels are black. + Assert.NotEqual(default(Rgb24), image[0, 0]); + } + + /// + /// Preserving a profile does not impose the converter's four-component storage limit on the parser. + /// + /// The number of output channels in the LUT. + [Theory] + [InlineData(3)] + [InlineData(15)] + public void Decode_IccLut_Preserve_RetainsChannels(int outputChannels) + { + byte[] pngData = BuildPng(BuildLut16Profile(3, outputChannels, 2, 2, 2)); + DecoderOptions options = new() { ColorProfileHandling = ColorProfileHandling.Preserve }; + + using Image image = Image.Load(options, pngData); + IccLut16TagDataEntry entry = Assert.IsType(Assert.Single(image.Metadata.IccProfile.Entries)); + Assert.Equal(outputChannels, entry.OutputValues.Length); + Assert.Equal(default(Rgb24), image[0, 0]); + } + private static byte[] BuildLut16Profile(int inputChannels, int outputChannels, int clutPoints, int inputTableLength, int outputTableLength) { using MemoryStream stream = new(); diff --git a/tests/ImageSharp.Tests/Metadata/Profiles/ICC/DataReader/IccDataReaderLutTests.cs b/tests/ImageSharp.Tests/Metadata/Profiles/ICC/DataReader/IccDataReaderLutTests.cs index 055756e96b..530da5594b 100644 --- a/tests/ImageSharp.Tests/Metadata/Profiles/ICC/DataReader/IccDataReaderLutTests.cs +++ b/tests/ImageSharp.Tests/Metadata/Profiles/ICC/DataReader/IccDataReaderLutTests.cs @@ -63,6 +63,26 @@ public void ReadClut_WithOversizedDimensions_ThrowsInvalidIccProfileException() Assert.Throws(() => CreateReader(new byte[8]).ReadClutF32(15, 15, gridPointCount)); } + /// + /// A complete profile header and element table do not make absent CLUT values readable. + /// + [Fact] + public void ReadTagDataEntry_WithTruncatedClut_RejectsMissingValues() + { + // A2B0 starts at byte 144; its element at byte 168 declares a 15-channel, three-point grid. + byte[] data = Convert.FromHexString( + "000000C874657374040000006D6E74725247422058595A200000000000000000000000006163737000000000000000000000" + + "0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000" + + "00000000000000000000000000000000000000000000000000000000000000014132423000000090000000386D7065740000" + + "000000000000000000010000001800000020636C7574000F000F030303030303030303030303030303000000000000000000"); + + IccDataReader reader = new(data); + IccTagTableEntry tag = new(IccProfileTag.AToB0, 144, 56); + + Assert.Throws(() => reader.ReadTagDataEntry(tag)); + Assert.Empty(new IccProfile(data).Entries); + } + [Theory] [MemberData(nameof(IccTestDataLut.Lut8TestData), MemberType = typeof(IccTestDataLut))] internal void ReadLut8(byte[] data, IccLut expected) From 906f037954cd11120f57ae18d937f92089204830 Mon Sep 17 00:00:00 2001 From: James Jackson-South Date: Sat, 5 Sep 2026 11:34:30 +1000 Subject: [PATCH 25/27] Verify CCITT pixels with supplied narrow-image fixtures --- .../Formats/Tiff/TiffEncoderTests.cs | 95 ++++++------------- 1 file changed, 31 insertions(+), 64 deletions(-) diff --git a/tests/ImageSharp.Tests/Formats/Tiff/TiffEncoderTests.cs b/tests/ImageSharp.Tests/Formats/Tiff/TiffEncoderTests.cs index 0ac94acb49..cc1b78c076 100644 --- a/tests/ImageSharp.Tests/Formats/Tiff/TiffEncoderTests.cs +++ b/tests/ImageSharp.Tests/Formats/Tiff/TiffEncoderTests.cs @@ -554,13 +554,23 @@ public void TiffEncoder_EncodeBiColor_WithCcittGroup3FaxCompression_WhiteIsZero_ public void TiffEncoder_EncodeBiColor_WithCcittGroup3FaxCompression_BlackIsZero_Works(TestImageProvider provider) where TPixel : unmanaged, IPixel => TestTiffEncoderCore(provider, TiffBitsPerPixel.Bit1, TiffPhotometricInterpretation.BlackIsZero, TiffCompression.CcittGroup3Fax); - [Fact] - public void TiffEncoder_EncodeNarrowCcittGroup3Fax_Works() + /// + /// CCITT row framing must fit even when each row contains only one pixel. + /// + /// The image width. + [Theory] + [InlineData(1)] + [InlineData(64)] + public void TiffEncoder_EncodeNarrowCcittGroup3Fax_Works(int width) { - using Image image = new(1, 2000); + using Image image = new(width, 2000); + for (int y = 0; y < image.Height; y++) { - image[0, y] = new L8((byte)((y & 1) == 0 ? 255 : 0)); + for (int x = 0; x < image.Width; x++) + { + image[x, y] = new L8((byte)(((x + y) & 1) == 0 ? 255 : 0)); + } } TiffFrameMetadata metadata = image.Frames.RootFrame.Metadata.GetTiffMetadata(); @@ -573,6 +583,14 @@ public void TiffEncoder_EncodeNarrowCcittGroup3Fax_Works() output.Position = 0; using Image decoded = Image.Load(output); Assert.Equal(image.Size, decoded.Size); + + for (int y = 0; y < image.Height; y++) + { + for (int x = 0; x < image.Width; x++) + { + Assert.Equal(image[x, y], decoded[x, y]); + } + } } [Theory] @@ -590,18 +608,24 @@ public void TiffEncoder_EncodeBiColor_WithCcittGroup4FaxCompression_WhiteIsZero_ public void TiffEncoder_EncodeBiColor_WithCcittGroup4FaxCompression_BlackIsZero_Works(TestImageProvider provider) where TPixel : unmanaged, IPixel => TestTiffEncoderCore(provider, TiffBitsPerPixel.Bit1, TiffPhotometricInterpretation.BlackIsZero, TiffCompression.CcittGroup4Fax); + /// + /// Re-encoding a one-pixel Group 4 image must retain its pixel and fit the end-of-block code. + /// [Fact] public void TiffEncoder_ReencodeNarrowCcittGroup4Fax_Works() { - byte[] data = BuildCcittGroup4Tiff([0x80, 0x08, 0x00, 0x80]); + byte[] data = Convert.FromBase64String( + "SUkqAAgAAAAJAAABAwABAAAAAQAAAAEBAwABAAAAAQAAAAIBAwABAAAAAQAAAAMBAwABAAAABAAAAAYBAwABAAAAAAAAABEBBAABAAAA" + + "egAAABUBAwABAAAAAQAAABYBBAABAAAAAQAAABcBBAABAAAABAAAAAAAAACACACA"); - using Image image = Image.Load(data); + using Image image = Image.Load(data); using MemoryStream output = new(); image.Save(output, new TiffEncoder()); output.Position = 0; - using Image decoded = Image.Load(output); + using Image decoded = Image.Load(output); Assert.Equal(new Size(1, 1), decoded.Size); + Assert.Equal(image[0, 0], decoded[0, 0]); } [Theory] @@ -661,61 +685,4 @@ public void TiffEncode_WorksWithDiscontiguousBuffers(TestImageProvider Date: Sat, 5 Sep 2026 11:39:37 +1000 Subject: [PATCH 26/27] Honor PNG ancillary policy for invalid compressed metadata --- src/ImageSharp/Formats/Png/PngDecoderCore.cs | 33 +++++--- .../Formats/Png/PngDecoderTests.Chunks.cs | 78 +++++++++++++++++++ tests/ImageSharp.Tests/TestImages.cs | 1 + .../Png/duplicate-header-chunk-resync.png | 3 + 4 files changed, 106 insertions(+), 9 deletions(-) create mode 100644 tests/Images/Input/Png/duplicate-header-chunk-resync.png diff --git a/src/ImageSharp/Formats/Png/PngDecoderCore.cs b/src/ImageSharp/Formats/Png/PngDecoderCore.cs index 7693c78d45..5e5225cdd0 100644 --- a/src/ImageSharp/Formats/Png/PngDecoderCore.cs +++ b/src/ImageSharp/Formats/Png/PngDecoderCore.cs @@ -1984,21 +1984,36 @@ private unsafe bool TryDecompressZlibData(ReadOnlySpan compressedData, int return false; } - int bytesRead = inflateStream.CompressedStream.Read(destUncompressedData); - while (bytesRead != 0) + try { - if (memoryStreamOutput.Length > maxLength) + int bytesRead = inflateStream.CompressedStream.Read(destUncompressedData); + while (bytesRead != 0) { - uncompressedBytesArray = []; - return false; + if (memoryStreamOutput.Length > maxLength) + { + uncompressedBytesArray = []; + return false; + } + + memoryStreamOutput.Write(destUncompressedData[..bytesRead]); + bytesRead = inflateStream.CompressedStream.Read(destUncompressedData); } - memoryStreamOutput.Write(destUncompressedData[..bytesRead]); - bytesRead = inflateStream.CompressedStream.Read(destUncompressedData); + uncompressedBytesArray = memoryStreamOutput.ToArray(); + return true; } + catch (InvalidDataException ex) + { + // ICC and text chunks are already bounded in memory, so rejecting their compressed contents + // does not lose the next chunk boundary. Apply the ancillary policy without keeping partial output. + if (this.Options.SegmentIntegrityHandling == SegmentIntegrityHandling.Strict) + { + throw new InvalidImageContentException("Invalid compressed PNG metadata.", ex); + } - uncompressedBytesArray = memoryStreamOutput.ToArray(); - return true; + uncompressedBytesArray = []; + return false; + } } } diff --git a/tests/ImageSharp.Tests/Formats/Png/PngDecoderTests.Chunks.cs b/tests/ImageSharp.Tests/Formats/Png/PngDecoderTests.Chunks.cs index 54a60f98e3..252ea49603 100644 --- a/tests/ImageSharp.Tests/Formats/Png/PngDecoderTests.Chunks.cs +++ b/tests/ImageSharp.Tests/Formats/Png/PngDecoderTests.Chunks.cs @@ -2,6 +2,7 @@ // Licensed under the Six Labors Split License. using System.Buffers.Binary; +using System.IO.Hashing; using System.Text; using SixLabors.ImageSharp.Formats; using SixLabors.ImageSharp.Formats.Png; @@ -121,6 +122,83 @@ public void DecodeAndIdentify_WithDuplicateHeader_ThrowInvalidImageContentExcept Assert.Throws(() => Image.Identify(payload)); } + /// + /// Chunk recovery must not replace the header after scanline storage has been sized. + /// + /// The segment integrity policy. + [Theory] + [InlineData(SegmentIntegrityHandling.Strict)] + [InlineData(SegmentIntegrityHandling.IgnoreAncillary)] + [InlineData(SegmentIntegrityHandling.IgnoreImageData)] + public void DecodeAndIdentify_WithChunkRecovery_FollowIntegrityPolicy(SegmentIntegrityHandling integrity) + { + byte[] data = TestFile.Create(TestImages.Png.DuplicateHeaderChunkResync).Bytes; + DecoderOptions options = new() { SegmentIntegrityHandling = integrity }; + + Assert.Throws(() => Image.Load(options, data)); + + if (integrity == SegmentIntegrityHandling.Strict) + { + Assert.Throws(() => Image.Identify(options, data)); + } + else + { + // Identify skips the image-data payload rather than decoding and resynchronizing within it. + Assert.Equal(new Size(1, 1), Image.Identify(options, data).Size); + } + } + + /// + /// Corrupt compressed metadata follows the ancillary policy without preventing valid pixel decoding. + /// + /// The compressed metadata chunk type. + /// The segment integrity policy. + [Theory] + [InlineData("iCCP", SegmentIntegrityHandling.Strict)] + [InlineData("iCCP", SegmentIntegrityHandling.IgnoreAncillary)] + [InlineData("iCCP", SegmentIntegrityHandling.IgnoreImageData)] + [InlineData("zTXt", SegmentIntegrityHandling.Strict)] + [InlineData("zTXt", SegmentIntegrityHandling.IgnoreAncillary)] + [InlineData("zTXt", SegmentIntegrityHandling.IgnoreImageData)] + [InlineData("iTXt", SegmentIntegrityHandling.Strict)] + [InlineData("iTXt", SegmentIntegrityHandling.IgnoreAncillary)] + [InlineData("iTXt", SegmentIntegrityHandling.IgnoreImageData)] + public void Decode_InvalidCompressedMetadata_FollowsIntegrityPolicy(string chunkType, SegmentIntegrityHandling integrity) + { + // iTXt adds a compression flag and empty language/translated-keyword fields before the zlib stream. + byte[] fields = chunkType == "iTXt" ? [(byte)'p', 0, 1, 0, 0, 0] : [(byte)'p', 0, 0]; + + // The zlib header is valid, but the first deflate block uses reserved block type 3. + byte[] chunk = [.. Encoding.ASCII.GetBytes(chunkType), .. fields, 0x78, 0x9C, 0x07, 0, 0, 0, 0]; + using MemoryStream stream = new(); + stream.Write(Raw1X1PngIhdrAndpHYs); + Span buffer = stackalloc byte[4]; + BinaryPrimitives.WriteInt32BigEndian(buffer, chunk.Length - 4); + stream.Write(buffer); + stream.Write(chunk); + Crc32 crc = new(); + crc.Append(chunk); + BinaryPrimitives.WriteUInt32BigEndian(buffer, crc.GetCurrentHashAsUInt32()); + stream.Write(buffer); + stream.Write(Raw1X1PngIdatAndIend); + byte[] data = stream.ToArray(); + DecoderOptions options = new() { SegmentIntegrityHandling = integrity }; + + if (integrity == SegmentIntegrityHandling.Strict) + { + InvalidImageContentException exception = Assert.Throws(() => Image.Load(options, data)); + Assert.IsType(exception.InnerException); + } + else + { + using Image image = Image.Load(options, data); + Assert.Equal(new Size(1, 1), image.Size); + Assert.Equal(default(Rgb24), image[0, 0]); + Assert.Null(image.Metadata.IccProfile); + Assert.Empty(image.Metadata.GetPngMetadata().TextData); + } + } + // https://github.com/SixLabors/ImageSharp/issues/3079 [Fact] public void Decode_CompressedTxtChunk_WithTruncatedData_DoesNotThrow() diff --git a/tests/ImageSharp.Tests/TestImages.cs b/tests/ImageSharp.Tests/TestImages.cs index c0071e9062..d1e0d64ae6 100644 --- a/tests/ImageSharp.Tests/TestImages.cs +++ b/tests/ImageSharp.Tests/TestImages.cs @@ -57,6 +57,7 @@ public static class Png public const string LowColorVariance = "Png/low-variance.png"; public const string PngWithMetadata = "Png/PngWithMetaData.png"; public const string InvalidTextData = "Png/InvalidTextData.png"; + public const string DuplicateHeaderChunkResync = "Png/duplicate-header-chunk-resync.png"; public const string David = "Png/david.png"; public const string TestPattern31x31 = "Png/testpattern31x31.png"; public const string TestPattern31x31HalfTransparent = "Png/testpattern31x31-halftransparent.png"; diff --git a/tests/Images/Input/Png/duplicate-header-chunk-resync.png b/tests/Images/Input/Png/duplicate-header-chunk-resync.png new file mode 100644 index 0000000000..50652a1067 --- /dev/null +++ b/tests/Images/Input/Png/duplicate-header-chunk-resync.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1183a3462f92784a0608fef2da95bef92d7f13f6275d4c628cc2310c772085cb +size 38937 From b11f2eb6472a2732f6336ac26b8d5003732f1640 Mon Sep 17 00:00:00 2001 From: James Jackson-South Date: Sat, 5 Sep 2026 12:51:25 +1000 Subject: [PATCH 27/27] Handle incomplete EXR zlib headers --- .../Exr/Compression/ExrBaseDecompressor.cs | 10 ++++- .../Formats/Exr/ExrZipDecoderTests.cs | 43 +++++++++++++++++++ 2 files changed, 51 insertions(+), 2 deletions(-) diff --git a/src/ImageSharp/Formats/Exr/Compression/ExrBaseDecompressor.cs b/src/ImageSharp/Formats/Exr/Compression/ExrBaseDecompressor.cs index afd32ed663..b85c37546e 100644 --- a/src/ImageSharp/Formats/Exr/Compression/ExrBaseDecompressor.cs +++ b/src/ImageSharp/Formats/Exr/Compression/ExrBaseDecompressor.cs @@ -53,8 +53,14 @@ protected static int UndoZipCompression(BufferedReadStream stream, uint compress int left = (int)(compressedBytes - (stream.Position - pos)); return left > 0 ? left : 0; }); - inflateStream.AllocateNewBytes((int)compressedBytes, true); - using DeflateStream dataStream = inflateStream.CompressedStream!; + + // Incomplete headers return false even for critical chunks, leaving no stream to read. + if (!inflateStream.AllocateNewBytes((int)compressedBytes, true)) + { + ExrThrowHelper.ThrowInvalidImageContentException("ZIP compressed EXR block has an incomplete zlib header."); + } + + using DeflateStream dataStream = inflateStream.CompressedStream; int totalRead = 0; while (totalRead < uncompressedBytes) diff --git a/tests/ImageSharp.Tests/Formats/Exr/ExrZipDecoderTests.cs b/tests/ImageSharp.Tests/Formats/Exr/ExrZipDecoderTests.cs index d40564d4ad..1a7632d5e3 100644 --- a/tests/ImageSharp.Tests/Formats/Exr/ExrZipDecoderTests.cs +++ b/tests/ImageSharp.Tests/Formats/Exr/ExrZipDecoderTests.cs @@ -67,6 +67,42 @@ public void Decode_InvalidInflatedBlock_IgnoreImageData_ClearsPixels(ExrPixelTyp } } + /// + /// Missing or truncated zlib headers obey the image-data integrity policy. + /// + /// The number of available zlib header bytes. + /// The image-data integrity policy. + [Theory] + [InlineData(0, SegmentIntegrityHandling.Strict)] + [InlineData(1, SegmentIntegrityHandling.Strict)] + [InlineData(0, SegmentIntegrityHandling.IgnoreAncillary)] + [InlineData(1, SegmentIntegrityHandling.IgnoreAncillary)] + [InlineData(0, SegmentIntegrityHandling.IgnoreImageData)] + [InlineData(1, SegmentIntegrityHandling.IgnoreImageData)] + public void Decode_IncompleteZlibHeader_RespectsIntegrityHandling(int length, SegmentIntegrityHandling integrity) + { + byte[] header = [0x78, 0x9C]; + byte[] data = BuildExr(header[..length], ExrPixelType.Float, 2, 0); + Configuration configuration = Configuration.Default.Clone(); + configuration.MemoryAllocator = new TestMemoryAllocator(0x3F); + DecoderOptions options = new() { Configuration = configuration, SegmentIntegrityHandling = integrity }; + + if (integrity == SegmentIntegrityHandling.IgnoreImageData) + { + using Image image = Image.Load(options, data); + Assert.Equal(new Size(256, 1), image.Size); + + for (int x = 0; x < image.Width; x++) + { + Assert.Equal(new Vector4(0, 0, 0, 1), image[x, 0].ToVector4()); + } + } + else + { + Assert.Throws(() => Image.Load(options, data)); + } + } + /// /// Missing color channels must not inherit the allocator's previous contents. /// @@ -113,6 +149,13 @@ public void Decode_SingleRedChannel_InitializesMissingColorChannels(ExrPixelType /// The zlib stream. private static byte[] ZlibCompress(byte[] data) { + if (data.Length == 0) + { + // An empty write produces no output on some runtimes. Use a complete zlib stream + // containing an empty final DEFLATE block and Adler-32 checksum instead. + return [0x78, 0x9C, 0x03, 0x00, 0x00, 0x00, 0x00, 0x01]; + } + using MemoryStream output = new(); using (ZLibStream zlib = new(output, CompressionLevel.Optimal, leaveOpen: true)) {