From 3233d0bd22e2adb86cf4ec33a0d969c8a96041ec Mon Sep 17 00:00:00 2001 From: Jason Finch Date: Sun, 18 Oct 2026 09:15:30 +1000 Subject: [PATCH 1/2] Decode TIFF files with large strips and backwards directory chains A missing RowsPerStrip, or one above the image height, is now limited to the height, so planar files without the entry decode and the strip buffer only holds rows that exist. Uncompressed strips, chunky or planar, that are larger than the single buffer allocation limit are read one row at a time. The image dimensions are recorded before the root frame is allocated, so an allocation failure reports the real size, and a frame that fails to decode is released. Directory chains that go backwards in the file are accepted; only a revisited directory is reported as circular. --- .../Formats/Tiff/Ifd/DirectoryReader.cs | 14 +- .../Formats/Tiff/TiffDecoderCore.cs | 125 +++-- .../Tiff/TiffDecoderStripLayoutTests.cs | 454 ++++++++++++++++++ 3 files changed, 554 insertions(+), 39 deletions(-) create mode 100644 tests/ImageSharp.Tests/Formats/Tiff/TiffDecoderStripLayoutTests.cs diff --git a/src/ImageSharp/Formats/Tiff/Ifd/DirectoryReader.cs b/src/ImageSharp/Formats/Tiff/Ifd/DirectoryReader.cs index 18c0f2e0d3..f13cb91b17 100644 --- a/src/ImageSharp/Formats/Tiff/Ifd/DirectoryReader.cs +++ b/src/ImageSharp/Formats/Tiff/Ifd/DirectoryReader.cs @@ -74,8 +74,17 @@ private static ByteOrder ReadByteOrder(Stream stream) private List ReadIfds(bool isBigTiff) { List readers = []; + + // TIFF allows directories at any offset, so a chain that goes backwards in the file is valid. + // Only a directory offset that has already been read means the chain loops. + HashSet visitedOffsets = []; while (this.nextIfdOffset != 0 && this.nextIfdOffset < (ulong)this.stream.Length) { + if (!visitedOffsets.Add(this.nextIfdOffset)) + { + TiffThrowHelper.ThrowImageFormatException("TIFF image contains circular directory offsets"); + } + EntryReader reader = new(this.stream, this.ByteOrder, this.allocator); reader.ReadTags(isBigTiff, this.nextIfdOffset); @@ -90,11 +99,6 @@ private List ReadIfds(bool isBigTiff) } } - if (this.nextIfdOffset >= reader.NextIfdOffset && reader.NextIfdOffset != 0) - { - TiffThrowHelper.ThrowImageFormatException("TIFF image contains circular directory offsets"); - } - this.nextIfdOffset = reader.NextIfdOffset; readers.Add(reader); diff --git a/src/ImageSharp/Formats/Tiff/TiffDecoderCore.cs b/src/ImageSharp/Formats/Tiff/TiffDecoderCore.cs index 413e970836..ce8789628e 100644 --- a/src/ImageSharp/Formats/Tiff/TiffDecoderCore.cs +++ b/src/ImageSharp/Formats/Tiff/TiffDecoderCore.cs @@ -184,7 +184,6 @@ protected override Image Decode(BufferedReadStream stream, Cance } } - this.Dimensions = frames[0].Size; ImageMetadata metadata = TiffDecoderMetadataCreator.Create(framesMetadata, this.skipMetadata, reader.ByteOrder, reader.IsBigTiff); return new Image(this.configuration, metadata, frames); } @@ -253,18 +252,30 @@ private ImageFrame DecodeFrame(ExifProfile tags, Size? size, Can } else { + // Record the image size before the root frame is allocated, so that a failure to allocate + // any buffer of the image reports its real dimensions. size = new Size(width, height); + this.Dimensions = size.Value; } ImageFrame frame = new(this.configuration, size.Value.Width, size.Value.Height, metadata.FrameMetadata); - if (isTiled) + try { - this.DecodeImageWithTiles(tags, frame, width, height, cancellationToken); + if (isTiled) + { + this.DecodeImageWithTiles(tags, frame, width, height, cancellationToken); + } + else + { + this.DecodeImageWithStrips(tags, frame, width, height, cancellationToken); + } } - else + catch { - this.DecodeImageWithStrips(tags, frame, width, height, cancellationToken); + // The frame is not owned by an image yet, so it must be released here. + frame.Dispose(); + throw; } // Only RGB-compatible color types can be converted here because the TPixel-based ICC profile conversion @@ -323,14 +334,13 @@ private ImageFrame DecodeFrame(ExifProfile tags, Size? size, Can private void DecodeImageWithStrips(ExifProfile tags, ImageFrame frame, int width, int height, CancellationToken cancellationToken) where TPixel : unmanaged, IPixel { - int rowsPerStrip; + // A missing RowsPerStrip defaults to 2^32 - 1, and any value of at least the image height means that + // the entire image is one strip. Limiting the value to the height keeps the strip buffer sizes to the + // rows that actually exist, for both the chunky and the planar layout. + int rowsPerStrip = height; if (tags.TryGetValue(ExifTag.RowsPerStrip, out IExifValue value)) { - rowsPerStrip = (int)value.Value; - } - else - { - rowsPerStrip = TiffConstants.RowsPerStripInfinity; + rowsPerStrip = Math.Min((int)value.Value, height); } Array stripOffsetsArray = (Array)tags.GetValueInternal(ExifTag.StripOffsets).GetValue(); @@ -451,13 +461,32 @@ private void DecodeStripsPlanar( Buffer2D pixels = frame.PixelBuffer; + using TiffBaseDecompressor decompressor = this.CreateDecompressor(width, bitsPerPixel, frame.Metadata); + + // As for chunky data, an uncompressed strip of a plane that is larger than the allocator allows for a single + // buffer is read and decoded one row at a time. Every row of a plane holds the same number of bytes, so the + // position of each row in the strip is known without decoding the rows before it. + bool readRows = false; + if (decompressor is NoneTiffCompression && this.CanReadStripRows()) + { + for (int planeIndex = 0; planeIndex < stripsPerPixel; planeIndex++) + { + if (this.CalculateStripBufferSize(width, rowsPerStrip, planeIndex) > (ulong)this.memoryAllocator.SingleBufferAllocationLimitBytes) + { + readRows = true; + break; + } + } + } + + int rowsPerRead = readRows ? 1 : rowsPerStrip; IMemoryOwner[] stripBuffers = new IMemoryOwner[stripsPerPixel]; try { for (int stripIndex = 0; stripIndex < stripBuffers.Length; stripIndex++) { - ulong uncompressedStripSize = this.CalculateStripBufferSize(width, rowsPerStrip, stripIndex); + ulong uncompressedStripSize = this.CalculateStripBufferSize(width, rowsPerRead, stripIndex); if (uncompressedStripSize > int.MaxValue) { @@ -467,7 +496,6 @@ private void DecodeStripsPlanar( stripBuffers[stripIndex] = this.memoryAllocator.Allocate((int)uncompressedStripSize); } - using TiffBaseDecompressor decompressor = this.CreateDecompressor(width, bitsPerPixel, frame.Metadata); TiffBasePlanarColorDecoder colorDecoder = this.CreatePlanarColorDecoder(frame.Metadata); for (int i = 0; i < stripsPerPlane; i++) @@ -476,21 +504,39 @@ private void DecodeStripsPlanar( int stripHeight = i < stripsPerPlane - 1 || height % rowsPerStrip == 0 ? rowsPerStrip : height % rowsPerStrip; - int stripIndex = i; - for (int planeIndex = 0; planeIndex < stripsPerPixel; planeIndex++) + for (int row = 0; row < stripHeight; row += rowsPerRead) { - decompressor.Decompress( - this.inputStream, - stripOffsets[stripIndex], - stripByteCounts[stripIndex], - stripHeight, - stripBuffers[planeIndex].GetSpan(), - cancellationToken); + cancellationToken.ThrowIfCancellationRequested(); - stripIndex += stripsPerPlane; - } + int readHeight = Math.Min(rowsPerRead, stripHeight - row); + int stripIndex = i; + for (int planeIndex = 0; planeIndex < stripsPerPixel; planeIndex++) + { + ulong offset = stripOffsets[stripIndex]; + ulong byteCount = stripByteCounts[stripIndex]; + if (readRows) + { + // Read the one row at its position in the strip of this plane. A strip that is shorter + // than its rows is read only up to its end, as when the strip is read whole. + ulong bytesPerRow = this.CalculateStripBufferSize(width, 1, planeIndex); + ulong rowStart = (ulong)row * bytesPerRow; + offset += rowStart; + byteCount = byteCount > rowStart ? Math.Min(byteCount - rowStart, bytesPerRow) : 0; + } + + decompressor.Decompress( + this.inputStream, + offset, + byteCount, + readHeight, + stripBuffers[planeIndex].GetSpan(), + cancellationToken); + + stripIndex += stripsPerPlane; + } - colorDecoder.Decode(stripBuffers, pixels, 0, rowsPerStrip * i, width, stripHeight); + colorDecoder.Decode(stripBuffers, pixels, 0, (rowsPerStrip * i) + row, width, readHeight); + } } } finally @@ -523,12 +569,6 @@ private void DecodeStripsChunky( CancellationToken cancellationToken) where TPixel : unmanaged, IPixel { - // If the rowsPerStrip has the default value, which is effectively infinity. That is, the entire image is one strip. - if (rowsPerStrip == TiffConstants.RowsPerStripInfinity) - { - rowsPerStrip = height; - } - ulong uncompressedStripSize = this.CalculateStripBufferSize(width, rowsPerStrip); int bitsPerPixel = this.BitsPerPixel; @@ -536,13 +576,16 @@ private void DecodeStripsChunky( TiffBaseColorDecoder colorDecoder = this.CreateChunkyColorDecoder(frame.Metadata); Buffer2D pixels = frame.PixelBuffer; - // There exists in this world TIFF files with uncompressed strips larger than Int32.MaxValue. - // We can read them, but we cannot allocate a buffer that large to hold the uncompressed data. + // There exists in this world TIFF files with uncompressed strips larger than the allocator allows + // for a single buffer, up to and beyond Int32.MaxValue bytes. We can read them, but we cannot allocate + // a buffer that large to hold the uncompressed data, even when the decoded image fits in memory. // In this scenario we fall back to reading and decoding one row at a time. // // The NoneTiffCompression decompressor can be used to read individual rows since we have // a guarantee that each row required the same number of bytes. - if (decompressor is NoneTiffCompression none && uncompressedStripSize > int.MaxValue) + if (decompressor is NoneTiffCompression none + && uncompressedStripSize > (ulong)this.memoryAllocator.SingleBufferAllocationLimitBytes + && this.CanReadStripRows()) { ulong bytesPerRowU = this.CalculateStripBufferSize(width, 1); @@ -966,6 +1009,20 @@ private ulong CalculateStripBufferSize(int width, int height, int plane = -1) return bytesPerRow * (ulong)height; } + /// + /// Gets a value indicating whether every row of uncompressed strip data can be decoded on its own, + /// which allows a strip to be read one row at a time. + /// + /// + /// YCbCr data with chroma subsampling stores the samples of a block of rows together, so its rows cannot be + /// located or decoded separately and the strip must be decoded whole. + /// + /// if the rows of a strip can be decoded one at a time. + private bool CanReadStripRows() + => this.PhotometricInterpretation != TiffPhotometricInterpretation.YCbCr + || this.YcbcrSubSampling is null + || (this.YcbcrSubSampling[0] == 1 && this.YcbcrSubSampling[1] == 1); + [MethodImpl(MethodImplOptions.AggressiveInlining)] private static int RoundUpToMultipleOfEight(int value) => (int)(((uint)value + 7) / 8); } diff --git a/tests/ImageSharp.Tests/Formats/Tiff/TiffDecoderStripLayoutTests.cs b/tests/ImageSharp.Tests/Formats/Tiff/TiffDecoderStripLayoutTests.cs new file mode 100644 index 0000000000..8974b82e02 --- /dev/null +++ b/tests/ImageSharp.Tests/Formats/Tiff/TiffDecoderStripLayoutTests.cs @@ -0,0 +1,454 @@ +// Copyright (c) Six Labors. +// Licensed under the Six Labors Split License. + +using System.Buffers.Binary; +using SixLabors.ImageSharp.Formats; +using SixLabors.ImageSharp.Formats.Tiff.Constants; +using SixLabors.ImageSharp.Memory; +using SixLabors.ImageSharp.Metadata.Profiles.Exif; +using SixLabors.ImageSharp.PixelFormats; + +namespace SixLabors.ImageSharp.Tests.Formats.Tiff; + +/// +/// Decodes hand-built TIFF files with unusual but valid strip and directory layouts, very large strips, and malformed data. +/// +[Collection("RunSerial")] +[Trait("Format", "Tiff")] +[ValidateDisposedMemoryAllocations] +public class TiffDecoderStripLayoutTests +{ + /// + /// A missing RowsPerStrip, or one larger than the image height, means the whole image is one strip, + /// for both chunky and planar data. + /// + /// Whether the samples are stored planar. + /// The image width. + /// The RowsPerStrip value, or to omit the entry. + [Theory] + [InlineData(false, 4000, null)] + [InlineData(false, 4000, 100000u)] + [InlineData(false, 4000, uint.MaxValue)] + [InlineData(true, 4, null)] + [InlineData(true, 4, 2u)] + [InlineData(true, 4000, null)] + [InlineData(true, 4000, 100000u)] + [InlineData(true, 4000, uint.MaxValue)] + public void Decode_RowsPerStripMissingOrAboveHeight_DecodesAllRows(bool planar, int width, uint? rowsPerStrip) + { + const int height = 2; + TiffPage page = TiffPage.Create(width, height, [8, 8, 8], TiffPhotometricInterpretation.Rgb, planar, rowsPerStrip, CreateRgbStrips(width, height, 1, planar, height)); + + using Image image = Decode(BuildTiff([page])); + + AssertRgb24Pixels(image); + } + + /// + /// An uncompressed strip larger than the single buffer allocation limit is read in rows, so the image + /// decodes whenever its pixel buffer fits in memory, with the same pixels as without the limit. + /// + /// Whether the samples are stored planar. + /// The number of rows per strip. + [Theory] + [InlineData(false, 700)] + [InlineData(false, 600)] + [InlineData(true, 700)] + [InlineData(true, 600)] + public void Decode_UncompressedStripAboveBufferLimit_DecodesAllRows(bool planar, int rowsPerStrip) + { + // With 3 x 16-bit samples, a strip of 600 rows needs 3.7 MB (1.2 MB per plane), more than the 1 MB + // allocation limit, while the decoded L8 image needs only 0.7 MB. + const int width = 1024; + const int height = 700; + TiffPage page = TiffPage.Create(width, height, [16, 16, 16], TiffPhotometricInterpretation.Rgb, planar, (uint)rowsPerStrip, CreateRgbStrips(width, height, 2, planar, rowsPerStrip)); + byte[] tiff = BuildTiff([page]); + + using Image expected = Decode(tiff); + using Image image = Decode(tiff, CreateConfiguration(1)); + + for (int y = 0; y < height; y++) + { + for (int x = 0; x < width; x++) + { + if (expected[x, y] != image[x, y]) + { + Assert.Fail($"Pixel ({x}, {y}) is {image[x, y]}, expected {expected[x, y]}."); + } + } + } + } + + /// + /// A frame that does not fit in the allocation limit reports its real dimensions. + /// + [Fact] + public void Decode_FrameAboveAllocationLimit_ThrowsWithRealDimensions() + { + TiffPage page = TiffPage.Create(20000, 15000, [16, 16, 16, 16], TiffPhotometricInterpretation.Rgb, false, null, [new byte[4096]]); + + InvalidImageContentException ex = Assert.Throws(() => Decode(BuildTiff([page]), CreateConfiguration(100)).Dispose()); + + Assert.Contains("20000x15000", ex.Message, StringComparison.Ordinal); + } + + /// + /// TIFF allows directories at any file offset, so a chain that goes backwards in the file is valid. + /// + [Fact] + public void Decode_DirectoriesStoredInReverseFileOrder_DecodesAllFrames() + { + TiffPage first = TiffPage.Create(2, 2, [8], TiffPhotometricInterpretation.BlackIsZero, false, 2, [[10, 11, 12, 13]]); + TiffPage second = TiffPage.Create(2, 2, [8], TiffPhotometricInterpretation.BlackIsZero, false, 2, [[20, 21, 22, 23]]); + + using Image image = Decode(BuildTiff([first, second], reverseIfdOrder: true)); + + Assert.Equal(2, image.Frames.Count); + Assert.Equal(10, image.Frames[0][0, 0].PackedValue); + Assert.Equal(13, image.Frames[0][1, 1].PackedValue); + Assert.Equal(20, image.Frames[1][0, 0].PackedValue); + Assert.Equal(23, image.Frames[1][1, 1].PackedValue); + } + + /// + /// A directory chain that returns to a directory it has already read is a loop and fails fast. + /// + /// The number of directories in the loop. + /// Whether the directories are stored in reverse file order. + [Theory] + [InlineData(1, false)] + [InlineData(2, false)] + [InlineData(2, true)] + [InlineData(3, true)] + public void Decode_DirectoryChainRevisitsDirectory_ThrowsImageFormatException(int pageCount, bool reverseIfdOrder) + { + TiffPage[] pages = new TiffPage[pageCount]; + for (int i = 0; i < pageCount; i++) + { + pages[i] = TiffPage.Create(2, 2, [8], TiffPhotometricInterpretation.BlackIsZero, false, 2, [new byte[4]]); + } + + byte[] tiff = BuildTiff(pages, reverseIfdOrder, loopToFirstIfd: true); + + ImageFormatException ex = Assert.Throws(() => Decode(tiff).Dispose()); + Assert.Contains("circular", ex.Message, StringComparison.Ordinal); + } + + /// + /// Gets the expected value of a sample of the synthesised images. + /// + /// The x coordinate. + /// The y coordinate. + /// The channel index. + /// The sample value. + private static byte Sample(int x, int y, int channel) => (byte)((x * 7) + (y * 101) + (channel * 53)); + + /// + /// Asserts that every pixel of the image matches . + /// + /// The decoded image. + private static void AssertRgb24Pixels(Image image) + { + for (int y = 0; y < image.Height; y++) + { + for (int x = 0; x < image.Width; x++) + { + Rgb24 expected = new(Sample(x, y, 0), Sample(x, y, 1), Sample(x, y, 2)); + if (!expected.Equals(image[x, y])) + { + Assert.Fail($"Pixel ({x}, {y}) is {image[x, y]}, expected {expected}."); + } + } + } + } + + /// + /// Creates the strip data of an RGB image whose samples are , stored as + /// 8-bit samples, or as 16-bit samples holding the same value in both bytes. Planar strips are ordered by plane. + /// + /// The image width. + /// The image height. + /// The bytes per sample, 1 or 2. + /// Whether the samples are stored planar. + /// The number of rows per strip. + /// The strips. + private static byte[][] CreateRgbStrips(int width, int height, int bytesPerSample, bool planar, int rowsPerStrip) + { + int stripsPerPlane = (height + rowsPerStrip - 1) / rowsPerStrip; + int planes = planar ? 3 : 1; + int samplesPerPixel = planar ? 1 : 3; + byte[][] strips = new byte[planes * stripsPerPlane][]; + for (int plane = 0; plane < planes; plane++) + { + for (int strip = 0; strip < stripsPerPlane; strip++) + { + int top = strip * rowsPerStrip; + int rows = Math.Min(rowsPerStrip, height - top); + byte[] data = new byte[width * rows * samplesPerPixel * bytesPerSample]; + for (int y = 0; y < rows; y++) + { + for (int x = 0; x < width; x++) + { + for (int sample = 0; sample < samplesPerPixel; sample++) + { + byte value = Sample(x, top + y, plane + sample); + int index = (((y * width) + x) * samplesPerPixel) + sample; + data.AsSpan(index * bytesPerSample, bytesPerSample).Fill(value); + } + } + } + + strips[(plane * stripsPerPlane) + strip] = data; + } + } + + return strips; + } + + /// + /// Decodes a TIFF file from memory. + /// + /// The pixel format. + /// The encoded TIFF. + /// The configuration, or for the default. + /// The decoded image. + private static Image Decode(byte[] tiff, Configuration configuration = null) + where TPixel : unmanaged, IPixel + { + using MemoryStream stream = new(tiff, false); + return Image.Load(new DecoderOptions { Configuration = configuration ?? Configuration.Default }, stream); + } + + /// + /// Creates a configuration whose memory allocator has the given allocation limit. + /// + /// The allocation limit in megabytes. + /// The configuration. + private static Configuration CreateConfiguration(int allocationLimitMegabytes) + { + Configuration configuration = Configuration.Default.Clone(); + configuration.MemoryAllocator = MemoryAllocator.Create(new MemoryAllocatorOptions { AllocationLimitMegabytes = allocationLimitMegabytes }); + return configuration; + } + + /// + /// Writes a little-endian TIFF. The strip data of every page follows the header, and the directories follow + /// the strip data in page order, or in reverse page order when is set. + /// + /// The pages. + /// Whether to store the directories in reverse page order. + /// Whether the last directory links back to the first one. + /// The encoded TIFF. + private static byte[] BuildTiff(TiffPage[] pages, bool reverseIfdOrder = false, bool loopToFirstIfd = false) + { + List bytes = [0x49, 0x49, 42, 0, 0, 0, 0, 0]; + + uint[][] stripOffsets = new uint[pages.Length][]; + for (int i = 0; i < pages.Length; i++) + { + stripOffsets[i] = new uint[pages[i].Strips.Length]; + for (int s = 0; s < pages[i].Strips.Length; s++) + { + stripOffsets[i][s] = (uint)bytes.Count; + bytes.AddRange(pages[i].Strips[s]); + } + } + + uint[] ifdOffsets = new uint[pages.Length]; + int[] nextIfdPositions = new int[pages.Length]; + for (int n = 0; n < pages.Length; n++) + { + int i = reverseIfdOrder ? pages.Length - 1 - n : n; + + // Directories must start on a word boundary. + if ((bytes.Count & 1) != 0) + { + bytes.Add(0); + } + + ifdOffsets[i] = (uint)bytes.Count; + List<(ExifTagValue Tag, ExifDataType Type, uint[] Values)> entries = pages[i].GetEntries(stripOffsets[i]); + + // Values that do not fit in an entry are stored straight after the directory. + int valuesOffset = bytes.Count + 2 + (entries.Count * 12) + 4; + List values = []; + AddUInt16(bytes, (ushort)entries.Count); + foreach ((ExifTagValue tag, ExifDataType type, uint[] entryValues) in entries) + { + AddUInt16(bytes, (ushort)tag); + AddUInt16(bytes, (ushort)type); + AddUInt32(bytes, (uint)entryValues.Length); + + List target = bytes; + if (entryValues.Length * (type == ExifDataType.Short ? 2 : 4) > 4) + { + AddUInt32(bytes, (uint)(valuesOffset + values.Count)); + target = values; + } + + int start = target.Count; + foreach (uint value in entryValues) + { + if (type == ExifDataType.Short) + { + AddUInt16(target, (ushort)value); + } + else + { + AddUInt32(target, value); + } + } + + while (target == bytes && bytes.Count - start < 4) + { + bytes.Add(0); + } + } + + nextIfdPositions[i] = bytes.Count; + AddUInt32(bytes, 0); + bytes.AddRange(values); + } + + byte[] tiff = [.. bytes]; + BinaryPrimitives.WriteUInt32LittleEndian(tiff.AsSpan(4), ifdOffsets[0]); + for (int i = 0; i < pages.Length; i++) + { + uint next = i + 1 < pages.Length ? ifdOffsets[i + 1] : loopToFirstIfd ? ifdOffsets[0] : 0; + BinaryPrimitives.WriteUInt32LittleEndian(tiff.AsSpan(nextIfdPositions[i]), next); + } + + return tiff; + } + + /// + /// Appends a little-endian 16-bit value. + /// + /// The target. + /// The value. + private static void AddUInt16(List bytes, ushort value) + { + bytes.Add((byte)value); + bytes.Add((byte)(value >> 8)); + } + + /// + /// Appends a little-endian 32-bit value. + /// + /// The target. + /// The value. + private static void AddUInt32(List bytes, uint value) + { + AddUInt16(bytes, (ushort)value); + AddUInt16(bytes, (ushort)(value >> 16)); + } + + /// + /// Describes one uncompressed page of a synthesised TIFF. + /// + private sealed class TiffPage + { + /// + /// Gets the image width. + /// + public int Width { get; private init; } + + /// + /// Gets the image height. + /// + public int Height { get; private init; } + + /// + /// Gets the bits of each sample; the length is the number of samples per pixel. + /// + public ushort[] BitsPerSample { get; private init; } + + /// + /// Gets the photometric interpretation. + /// + public TiffPhotometricInterpretation Photometric { get; private init; } + + /// + /// Gets a value indicating whether the samples are stored planar. + /// + public bool Planar { get; private init; } + + /// + /// Gets the RowsPerStrip value, or to omit the entry. + /// + public uint? RowsPerStrip { get; private init; } + + /// + /// Gets the strip data. + /// + public byte[][] Strips { get; private init; } + + /// + /// Creates a page. + /// + /// The image width. + /// The image height. + /// The bits of each sample. + /// The photometric interpretation. + /// Whether the samples are stored planar. + /// The RowsPerStrip value, or to omit the entry. + /// The strip data. + /// The page. + public static TiffPage Create(int width, int height, ushort[] bitsPerSample, TiffPhotometricInterpretation photometric, bool planar, uint? rowsPerStrip, byte[][] strips) + => new() + { + Width = width, + Height = height, + BitsPerSample = bitsPerSample, + Photometric = photometric, + Planar = planar, + RowsPerStrip = rowsPerStrip, + Strips = strips, + }; + + /// + /// Gets the directory entries of the page, sorted by tag. + /// + /// The file offset of each strip. + /// The entries. + public List<(ExifTagValue Tag, ExifDataType Type, uint[] Values)> GetEntries(uint[] stripOffsets) + { + uint[] bitsPerSample = new uint[this.BitsPerSample.Length]; + for (int i = 0; i < bitsPerSample.Length; i++) + { + bitsPerSample[i] = this.BitsPerSample[i]; + } + + uint[] stripByteCounts = new uint[this.Strips.Length]; + for (int i = 0; i < stripByteCounts.Length; i++) + { + stripByteCounts[i] = (uint)this.Strips[i].Length; + } + + List<(ExifTagValue Tag, ExifDataType Type, uint[] Values)> entries = + [ + (ExifTagValue.ImageWidth, ExifDataType.Long, [(uint)this.Width]), + (ExifTagValue.ImageLength, ExifDataType.Long, [(uint)this.Height]), + (ExifTagValue.BitsPerSample, ExifDataType.Short, bitsPerSample), + (ExifTagValue.Compression, ExifDataType.Short, [(uint)TiffCompression.None]), + (ExifTagValue.PhotometricInterpretation, ExifDataType.Short, [(uint)this.Photometric]), + (ExifTagValue.StripOffsets, ExifDataType.Long, stripOffsets), + (ExifTagValue.SamplesPerPixel, ExifDataType.Short, [(uint)this.BitsPerSample.Length]), + (ExifTagValue.StripByteCounts, ExifDataType.Long, stripByteCounts), + ]; + + if (this.RowsPerStrip.HasValue) + { + entries.Add((ExifTagValue.RowsPerStrip, ExifDataType.Long, [this.RowsPerStrip.Value])); + } + + if (this.Planar) + { + entries.Add((ExifTagValue.PlanarConfiguration, ExifDataType.Short, [(uint)TiffPlanarConfiguration.Planar])); + } + + entries.Sort((a, b) => a.Tag.CompareTo(b.Tag)); + return entries; + } + } +} From 1bcc8bb7a297e386b3f2e7f8ced6d3b8732cb53e Mon Sep 17 00:00:00 2001 From: James Jackson-South Date: Tue, 27 Oct 2026 15:16:40 +1000 Subject: [PATCH 2/2] Verify TIFF strip layouts with stored inputs and enforce image data integrity --- .../Decompressors/NoneTiffCompression.cs | 21 +- .../Compression/TiffDecompressorsFactory.cs | 2 +- .../Formats/Tiff/Ifd/DirectoryReader.cs | 2 +- .../Formats/Tiff/TiffDecoderCore.cs | 149 +++-- .../Compression/NoneTiffCompressionTests.cs | 2 +- .../Tiff/TiffDecoderStripLayoutTests.cs | 517 +++++------------- tests/ImageSharp.Tests/TestImages.cs | 28 + .../Tiff/strips/chunky-rgb16-600-rows.tiff | 3 + .../Tiff/strips/chunky-rgb16-700-rows.tiff | 3 + .../Tiff/strips/chunky-rgb16-short-strip.tiff | 3 + .../strips/chunky-rowsperstrip-large.tiff | 3 + .../Tiff/strips/chunky-rowsperstrip-max.tiff | 3 + .../strips/chunky-rowsperstrip-missing.tiff | 3 + .../Tiff/strips/chunky-rowsperstrip-zero.tiff | 3 + .../strips/frame-above-allocation-limit.tiff | 3 + .../Input/Tiff/strips/loop-1-forward.bigtiff | Bin 0 -> 220 bytes .../Input/Tiff/strips/loop-1-forward.tiff | 3 + .../Input/Tiff/strips/loop-2-forward.bigtiff | Bin 0 -> 420 bytes .../Input/Tiff/strips/loop-2-forward.tiff | 3 + .../Input/Tiff/strips/loop-2-reverse.bigtiff | Bin 0 -> 420 bytes .../Input/Tiff/strips/loop-2-reverse.tiff | 3 + .../Input/Tiff/strips/loop-3-reverse.bigtiff | Bin 0 -> 628 bytes .../Input/Tiff/strips/loop-3-reverse.tiff | 3 + .../Tiff/strips/planar-rgb16-600-rows.tiff | 3 + .../Tiff/strips/planar-rgb16-700-rows.tiff | 3 + .../Tiff/strips/planar-rgb16-short-strip.tiff | 3 + .../strips/planar-rowsperstrip-large.tiff | 3 + .../Tiff/strips/planar-rowsperstrip-max.tiff | 3 + .../strips/planar-rowsperstrip-missing.tiff | 3 + .../Tiff/strips/planar-rowsperstrip-zero.tiff | 3 + .../Tiff/strips/planar-ycbcr-subsampled.tiff | 3 + .../Tiff/strips/reverse-directories.bigtiff | Bin 0 -> 420 bytes .../Tiff/strips/reverse-directories.tiff | 3 + tests/Images/Input/Tiff/strips/rgb-4000x2.png | 3 + 34 files changed, 366 insertions(+), 421 deletions(-) create mode 100644 tests/Images/Input/Tiff/strips/chunky-rgb16-600-rows.tiff create mode 100644 tests/Images/Input/Tiff/strips/chunky-rgb16-700-rows.tiff create mode 100644 tests/Images/Input/Tiff/strips/chunky-rgb16-short-strip.tiff create mode 100644 tests/Images/Input/Tiff/strips/chunky-rowsperstrip-large.tiff create mode 100644 tests/Images/Input/Tiff/strips/chunky-rowsperstrip-max.tiff create mode 100644 tests/Images/Input/Tiff/strips/chunky-rowsperstrip-missing.tiff create mode 100644 tests/Images/Input/Tiff/strips/chunky-rowsperstrip-zero.tiff create mode 100644 tests/Images/Input/Tiff/strips/frame-above-allocation-limit.tiff create mode 100644 tests/Images/Input/Tiff/strips/loop-1-forward.bigtiff create mode 100644 tests/Images/Input/Tiff/strips/loop-1-forward.tiff create mode 100644 tests/Images/Input/Tiff/strips/loop-2-forward.bigtiff create mode 100644 tests/Images/Input/Tiff/strips/loop-2-forward.tiff create mode 100644 tests/Images/Input/Tiff/strips/loop-2-reverse.bigtiff create mode 100644 tests/Images/Input/Tiff/strips/loop-2-reverse.tiff create mode 100644 tests/Images/Input/Tiff/strips/loop-3-reverse.bigtiff create mode 100644 tests/Images/Input/Tiff/strips/loop-3-reverse.tiff create mode 100644 tests/Images/Input/Tiff/strips/planar-rgb16-600-rows.tiff create mode 100644 tests/Images/Input/Tiff/strips/planar-rgb16-700-rows.tiff create mode 100644 tests/Images/Input/Tiff/strips/planar-rgb16-short-strip.tiff create mode 100644 tests/Images/Input/Tiff/strips/planar-rowsperstrip-large.tiff create mode 100644 tests/Images/Input/Tiff/strips/planar-rowsperstrip-max.tiff create mode 100644 tests/Images/Input/Tiff/strips/planar-rowsperstrip-missing.tiff create mode 100644 tests/Images/Input/Tiff/strips/planar-rowsperstrip-zero.tiff create mode 100644 tests/Images/Input/Tiff/strips/planar-ycbcr-subsampled.tiff create mode 100644 tests/Images/Input/Tiff/strips/reverse-directories.bigtiff create mode 100644 tests/Images/Input/Tiff/strips/reverse-directories.tiff create mode 100644 tests/Images/Input/Tiff/strips/rgb-4000x2.png diff --git a/src/ImageSharp/Formats/Tiff/Compression/Decompressors/NoneTiffCompression.cs b/src/ImageSharp/Formats/Tiff/Compression/Decompressors/NoneTiffCompression.cs index 0c601e9ddb..335c0ae635 100644 --- a/src/ImageSharp/Formats/Tiff/Compression/Decompressors/NoneTiffCompression.cs +++ b/src/ImageSharp/Formats/Tiff/Compression/Decompressors/NoneTiffCompression.cs @@ -11,20 +11,37 @@ namespace SixLabors.ImageSharp.Formats.Tiff.Compression.Decompressors; /// internal sealed class NoneTiffCompression : TiffBaseDecompressor { + private readonly SegmentIntegrityHandling integrityHandling; + /// /// Initializes a new instance of the class. /// /// The memory allocator. /// The width of the image. /// The bits per pixel. - public NoneTiffCompression(MemoryAllocator memoryAllocator, int width, int bitsPerPixel) + /// The policy for incomplete image data. + public NoneTiffCompression(MemoryAllocator memoryAllocator, int width, int bitsPerPixel, SegmentIntegrityHandling integrityHandling) : base(memoryAllocator, width, bitsPerPixel) { + this.integrityHandling = integrityHandling; } /// protected override void Decompress(BufferedReadStream stream, int byteCount, int stripHeight, Span buffer, CancellationToken cancellationToken) - => _ = stream.Read(buffer, 0, Math.Min(buffer.Length, byteCount)); + { + // The caller supplies only the rows needed for this read. Respect both the declared byte count and end of file. + int bytesRead = stream.Read(buffer, 0, Math.Min(buffer.Length, byteCount)); + if (bytesRead < buffer.Length) + { + if (this.integrityHandling != SegmentIntegrityHandling.IgnoreImageData) + { + throw new InvalidImageContentException("TIFF strip or tile contains incomplete image data."); + } + + // The buffer is reused. Missing samples must not retain bytes from a previous strip, row, or pooled allocation. + buffer[bytesRead..].Clear(); + } + } /// protected override void Dispose(bool disposing) diff --git a/src/ImageSharp/Formats/Tiff/Compression/TiffDecompressorsFactory.cs b/src/ImageSharp/Formats/Tiff/Compression/TiffDecompressorsFactory.cs index 1eaa64c43b..bb3d7e18b6 100644 --- a/src/ImageSharp/Formats/Tiff/Compression/TiffDecompressorsFactory.cs +++ b/src/ImageSharp/Formats/Tiff/Compression/TiffDecompressorsFactory.cs @@ -40,7 +40,7 @@ public static TiffBaseDecompressor Create( case TiffDecoderCompressionType.None: DebugGuard.IsTrue(predictor == TiffPredictor.None, "Predictor should only be used with lzw or deflate compression"); DebugGuard.IsTrue(faxOptions == FaxCompressionOptions.None, "No fax compression options are expected"); - return new NoneTiffCompression(allocator, width, bitsPerPixel); + return new NoneTiffCompression(allocator, width, bitsPerPixel, options.SegmentIntegrityHandling); case TiffDecoderCompressionType.PackBits: DebugGuard.IsTrue(predictor == TiffPredictor.None, "Predictor should only be used with lzw or deflate compression"); diff --git a/src/ImageSharp/Formats/Tiff/Ifd/DirectoryReader.cs b/src/ImageSharp/Formats/Tiff/Ifd/DirectoryReader.cs index f13cb91b17..fb9d6d5d7b 100644 --- a/src/ImageSharp/Formats/Tiff/Ifd/DirectoryReader.cs +++ b/src/ImageSharp/Formats/Tiff/Ifd/DirectoryReader.cs @@ -77,7 +77,7 @@ private List ReadIfds(bool isBigTiff) // TIFF allows directories at any offset, so a chain that goes backwards in the file is valid. // Only a directory offset that has already been read means the chain loops. - HashSet visitedOffsets = []; + HashSet visitedOffsets = new(4); while (this.nextIfdOffset != 0 && this.nextIfdOffset < (ulong)this.stream.Length) { if (!visitedOffsets.Add(this.nextIfdOffset)) diff --git a/src/ImageSharp/Formats/Tiff/TiffDecoderCore.cs b/src/ImageSharp/Formats/Tiff/TiffDecoderCore.cs index ce8789628e..dbd844faec 100644 --- a/src/ImageSharp/Formats/Tiff/TiffDecoderCore.cs +++ b/src/ImageSharp/Formats/Tiff/TiffDecoderCore.cs @@ -252,8 +252,7 @@ private ImageFrame DecodeFrame(ExifProfile tags, Size? size, Can } else { - // Record the image size before the root frame is allocated, so that a failure to allocate - // any buffer of the image reports its real dimensions. + // Record the root size before allocation so allocation errors report the image dimensions. size = new Size(width, height); this.Dimensions = size.Value; } @@ -270,24 +269,21 @@ private ImageFrame DecodeFrame(ExifProfile tags, Size? size, Can { this.DecodeImageWithStrips(tags, frame, width, height, cancellationToken); } + + // This shared conversion accepts RGB-compatible pixels. Other TIFF color models convert while decoding their samples. + if (this.ColorType is >= TiffColorType.PaletteColor and <= TiffColorType.Rgba32323232Planar) + { + _ = this.TryConvertIccProfile(frame); + } + + return frame; } catch { - // The frame is not owned by an image yet, so it must be released here. + // The caller takes ownership only after this method returns, including any ICC conversion. frame.Dispose(); throw; } - - // Only RGB-compatible color types can be converted here because the TPixel-based ICC profile conversion - // expects RGB-like pixel data; other photometric interpretations (YCbCr, CMYK, Lab, etc.) would require - // dedicated transforms. We do this once at the frame level to avoid duplicating conversion logic - // across all color decoders and to keep their decode paths focused on raw pixel unpacking. - if (this.ColorType is >= TiffColorType.PaletteColor and <= TiffColorType.Rgba32323232Planar) - { - _ = this.TryConvertIccProfile(frame); - } - - return frame; } private (ImageFrameMetadata FrameMetadata, TiffFrameMetadata TiffMetadata) CreateFrameMetadata(ExifProfile tags) @@ -334,15 +330,20 @@ private ImageFrame DecodeFrame(ExifProfile tags, Size? size, Can private void DecodeImageWithStrips(ExifProfile tags, ImageFrame frame, int width, int height, CancellationToken cancellationToken) where TPixel : unmanaged, IPixel { - // A missing RowsPerStrip defaults to 2^32 - 1, and any value of at least the image height means that - // the entire image is one strip. Limiting the value to the height keeps the strip buffer sizes to the - // rows that actually exist, for both the chunky and the planar layout. + // TIFF 6.0 defaults RowsPerStrip to 2^32 - 1 (page 39). A value at least as large as height describes one strip. + // Both layouts allocate storage for the rows that exist, not the larger declared strip height. int rowsPerStrip = height; if (tags.TryGetValue(ExifTag.RowsPerStrip, out IExifValue value)) { rowsPerStrip = Math.Min((int)value.Value, height); } + // TIFF 6.0 requires at least one row per strip. Zero cannot define row offsets or a strip count. + if (rowsPerStrip <= 0) + { + TiffThrowHelper.ThrowImageFormatException("RowsPerStrip must be greater than zero."); + } + Array stripOffsetsArray = (Array)tags.GetValueInternal(ExifTag.StripOffsets).GetValue(); Array stripByteCountsArray = (Array)tags.GetValueInternal(ExifTag.StripByteCounts).GetValue(); @@ -463,9 +464,8 @@ private void DecodeStripsPlanar( using TiffBaseDecompressor decompressor = this.CreateDecompressor(width, bitsPerPixel, frame.Metadata); - // As for chunky data, an uncompressed strip of a plane that is larger than the allocator allows for a single - // buffer is read and decoded one row at a time. Every row of a plane holds the same number of bytes, so the - // position of each row in the strip is known without decoding the rows before it. + // Read an uncompressed plane in rows when its strip exceeds the single-buffer limit. + // Each row has a fixed byte count, so its file offset does not depend on decoding earlier rows. bool readRows = false; if (decompressor is NoneTiffCompression && this.CanReadStripRows()) { @@ -524,12 +524,19 @@ private void DecodeStripsPlanar( byteCount = byteCount > rowStart ? Math.Min(byteCount - rowStart, bytesPerRow) : 0; } + Span stripBuffer = stripBuffers[planeIndex].GetSpan(); + if (decompressor is NoneTiffCompression) + { + // The last strip can be shorter than the reusable buffer. Padding is not required image data. + stripBuffer = stripBuffer[..(int)this.CalculateUncompressedDataSize(width, readHeight, planeIndex)]; + } + decompressor.Decompress( this.inputStream, offset, byteCount, readHeight, - stripBuffers[planeIndex].GetSpan(), + stripBuffer, cancellationToken); stripIndex += stripsPerPlane; @@ -576,13 +583,8 @@ private void DecodeStripsChunky( TiffBaseColorDecoder colorDecoder = this.CreateChunkyColorDecoder(frame.Metadata); Buffer2D pixels = frame.PixelBuffer; - // There exists in this world TIFF files with uncompressed strips larger than the allocator allows - // for a single buffer, up to and beyond Int32.MaxValue bytes. We can read them, but we cannot allocate - // a buffer that large to hold the uncompressed data, even when the decoded image fits in memory. - // In this scenario we fall back to reading and decoding one row at a time. - // - // The NoneTiffCompression decompressor can be used to read individual rows since we have - // a guarantee that each row required the same number of bytes. + // An uncompressed strip can exceed the single-buffer limit even when the decoded image fits in memory. + // Read it in rows. Each row has a fixed byte count and uses the same reusable buffer. if (decompressor is NoneTiffCompression none && uncompressedStripSize > (ulong)this.memoryAllocator.SingleBufferAllocationLimitBytes && this.CanReadStripRows()) @@ -614,23 +616,19 @@ private void DecodeStripsChunky( ulong baseOffset = stripOffsets[stripIndex]; ulong available = stripByteCounts[stripIndex]; - ulong required = (ulong)bytesPerRow * (ulong)stripHeight; - if (available < required) - { - break; - } - for (int r = 0; r < stripHeight; r++) { cancellationToken.ThrowIfCancellationRequested(); - ulong rowOffset = baseOffset + ((ulong)r * (ulong)bytesPerRow); + ulong rowStart = (ulong)r * (ulong)bytesPerRow; + ulong rowOffset = baseOffset + rowStart; + ulong rowByteCount = available > rowStart ? Math.Min(available - rowStart, (ulong)bytesPerRow) : 0; - // Use the NoneTiffCompression decompressor to read exactly one row. + // Keep reads within this strip. The decompressor applies the integrity policy to missing row samples. none.Decompress( this.inputStream, rowOffset, - (ulong)bytesPerRow, + rowByteCount, 1, rowBuffer, cancellationToken); @@ -674,12 +672,19 @@ private void DecodeStripsChunky( break; } + Span stripData = stripBufferSpan; + if (decompressor is NoneTiffCompression) + { + // Only the final strip's actual rows must be present. The buffer can also hold a full strip. + stripData = stripData[..(int)this.CalculateUncompressedDataSize(width, stripHeight, -1)]; + } + decompressor.Decompress( this.inputStream, stripOffsets[stripIndex], stripByteCounts[stripIndex], stripHeight, - stripBufferSpan, + stripData, cancellationToken); colorDecoder.Decode(stripBufferSpan, pixels, 0, top, width, stripHeight); @@ -729,11 +734,16 @@ private void DecodeTilesPlanar( try { - int bytesPerTileRow = RoundUpToMultipleOfEight(tileWidth * bitsPerPixel); - int uncompressedTilesSize = bytesPerTileRow * tileLength; for (int i = 0; i < tilesBuffers.Length; i++) { - tilesBuffers[i] = this.memoryAllocator.Allocate(uncompressedTilesSize, AllocationOptions.Clean); + // Each buffer holds one plane, not every component of a pixel. Tile padding still uses the full tile dimensions. + ulong uncompressedTileSize = this.CalculateStripBufferSize(tileWidth, tileLength, i); + if (uncompressedTileSize > int.MaxValue) + { + TiffThrowHelper.ThrowNotSupported("Tiles larger than Int32.MaxValue bytes are not supported."); + } + + tilesBuffers[i] = this.memoryAllocator.Allocate((int)uncompressedTileSize, AllocationOptions.Clean); } using TiffBaseDecompressor decompressor = this.CreateDecompressor(frame.Width, bitsPerPixel, frame.Metadata, true, tileWidth, tileLength); @@ -755,12 +765,18 @@ private void DecodeTilesPlanar( { cancellationToken.ThrowIfCancellationRequested(); + Span tileBuffer = tilesBuffers[i].GetSpan(); + if (decompressor is NoneTiffCompression) + { + tileBuffer = tileBuffer[..(int)this.CalculateUncompressedDataSize(tileWidth, tileLength, i)]; + } + decompressor.Decompress( this.inputStream, tileOffsets[tileIndexForChannel], tileByteCounts[tileIndexForChannel], tileLength, - tilesBuffers[i].GetSpan(), + tileBuffer, cancellationToken); tileIndexForChannel += tilesPerChannel; @@ -981,6 +997,12 @@ private ulong CalculateStripBufferSize(int width, int height, int plane = -1) if (this.PlanarConfiguration == TiffPlanarConfiguration.Chunky) { DebugGuard.IsTrue(plane == -1, "Expected Chunky planar."); + + if (this.CompressionType == TiffDecoderCompressionType.None && !this.CanReadStripRows()) + { + return this.CalculateUncompressedDataSize(width, height, plane); + } + bitsPerPixel = this.BitsPerPixel; } else @@ -1010,18 +1032,43 @@ private ulong CalculateStripBufferSize(int width, int height, int plane = -1) } /// - /// Gets a value indicating whether every row of uncompressed strip data can be decoded on its own, - /// which allows a strip to be read one row at a time. + /// Calculates the encoded byte count for an uncompressed strip, tile, or individual row. + /// + /// The stored row width, including tile padding when applicable. + /// The number of stored rows. + /// The planar component index, or -1 for chunky data. + /// The number of encoded bytes required for these samples. + private ulong CalculateUncompressedDataSize(int width, int height, int plane) + { + if (this.CanReadStripRows()) + { + return this.CalculateStripBufferSize(width, height, plane); + } + + ulong horizontal = this.YcbcrSubSampling[0]; + ulong vertical = this.YcbcrSubSampling[1]; + ulong groupsAcross = ((ulong)width + horizontal - 1) / horizontal; + ulong groupsDown = ((ulong)height + vertical - 1) / vertical; + + // Chunky YCbCr stores horizontal * vertical luminance samples, then one Cb and one Cr sample per group. + // Planar YCbCr stores full-resolution luminance and one sample per group in each separate chroma plane. + // Chroma expansion needs larger plane buffers, but those extra bytes are not required in the file. + return this.PlanarConfiguration == TiffPlanarConfiguration.Chunky + ? groupsAcross * groupsDown * ((horizontal * vertical) + 2) + : plane == 0 ? (ulong)width * (ulong)height : groupsAcross * groupsDown; + } + + /// + /// Determines whether each uncompressed row can be read and decoded separately. /// - /// - /// YCbCr data with chroma subsampling stores the samples of a block of rows together, so its rows cannot be - /// located or decoded separately and the strip must be decoded whole. - /// /// if the rows of a strip can be decoded one at a time. private bool CanReadStripRows() - => this.PhotometricInterpretation != TiffPhotometricInterpretation.YCbCr - || this.YcbcrSubSampling is null - || (this.YcbcrSubSampling[0] == 1 && this.YcbcrSubSampling[1] == 1); + { + // Subsampled YCbCr groups can span rows. Read the complete strip before expanding its chroma samples. + return this.PhotometricInterpretation != TiffPhotometricInterpretation.YCbCr + || this.YcbcrSubSampling is null + || (this.YcbcrSubSampling[0] == 1 && this.YcbcrSubSampling[1] == 1); + } [MethodImpl(MethodImplOptions.AggressiveInlining)] private static int RoundUpToMultipleOfEight(int value) => (int)(((uint)value + 7) / 8); diff --git a/tests/ImageSharp.Tests/Formats/Tiff/Compression/NoneTiffCompressionTests.cs b/tests/ImageSharp.Tests/Formats/Tiff/Compression/NoneTiffCompressionTests.cs index b911d1b177..e059ff3c5d 100644 --- a/tests/ImageSharp.Tests/Formats/Tiff/Compression/NoneTiffCompressionTests.cs +++ b/tests/ImageSharp.Tests/Formats/Tiff/Compression/NoneTiffCompressionTests.cs @@ -18,7 +18,7 @@ public void Decompress_ReadsData(byte[] inputData, uint byteCount, byte[] expect using BufferedReadStream stream = new(Configuration.Default, memoryStream); byte[] buffer = new byte[expectedResult.Length]; - using NoneTiffCompression decompressor = new(default, default, default); + using NoneTiffCompression decompressor = new(default, default, default, default); decompressor.Decompress(stream, 0, byteCount, 1, buffer, default); Assert.Equal(expectedResult, buffer); diff --git a/tests/ImageSharp.Tests/Formats/Tiff/TiffDecoderStripLayoutTests.cs b/tests/ImageSharp.Tests/Formats/Tiff/TiffDecoderStripLayoutTests.cs index 8974b82e02..9a5bb40a8d 100644 --- a/tests/ImageSharp.Tests/Formats/Tiff/TiffDecoderStripLayoutTests.cs +++ b/tests/ImageSharp.Tests/Formats/Tiff/TiffDecoderStripLayoutTests.cs @@ -1,454 +1,241 @@ // Copyright (c) Six Labors. // Licensed under the Six Labors Split License. -using System.Buffers.Binary; using SixLabors.ImageSharp.Formats; -using SixLabors.ImageSharp.Formats.Tiff.Constants; using SixLabors.ImageSharp.Memory; -using SixLabors.ImageSharp.Metadata.Profiles.Exif; using SixLabors.ImageSharp.PixelFormats; +using SixLabors.ImageSharp.Tests.TestUtilities.ImageComparison; +using static SixLabors.ImageSharp.Tests.TestImages.Tiff; namespace SixLabors.ImageSharp.Tests.Formats.Tiff; /// -/// Decodes hand-built TIFF files with unusual but valid strip and directory layouts, very large strips, and malformed data. +/// Tests strip allocation and directory traversal with stored TIFF inputs. /// -[Collection("RunSerial")] [Trait("Format", "Tiff")] [ValidateDisposedMemoryAllocations] public class TiffDecoderStripLayoutTests { /// - /// A missing RowsPerStrip, or one larger than the image height, means the whole image is one strip, - /// for both chunky and planar data. + /// A missing RowsPerStrip or a value greater than the image height describes one strip per plane. /// - /// Whether the samples are stored planar. - /// The image width. - /// The RowsPerStrip value, or to omit the entry. + /// The stored TIFF input. [Theory] - [InlineData(false, 4000, null)] - [InlineData(false, 4000, 100000u)] - [InlineData(false, 4000, uint.MaxValue)] - [InlineData(true, 4, null)] - [InlineData(true, 4, 2u)] - [InlineData(true, 4000, null)] - [InlineData(true, 4000, 100000u)] - [InlineData(true, 4000, uint.MaxValue)] - public void Decode_RowsPerStripMissingOrAboveHeight_DecodesAllRows(bool planar, int width, uint? rowsPerStrip) + [InlineData(ChunkyRowsPerStripMissing)] + [InlineData(ChunkyRowsPerStripLarge)] + [InlineData(ChunkyRowsPerStripMax)] + [InlineData(PlanarRowsPerStripMissing)] + [InlineData(PlanarRowsPerStripLarge)] + [InlineData(PlanarRowsPerStripMax)] + public void Decode_RowsPerStripMissingOrAboveHeight_DecodesAllRows(string path) { - const int height = 2; - TiffPage page = TiffPage.Create(width, height, [8, 8, 8], TiffPhotometricInterpretation.Rgb, planar, rowsPerStrip, CreateRgbStrips(width, height, 1, planar, height)); + using Image expected = Image.Load(TestFile.Create(StripLayoutRgbReference).Bytes); + byte[] data = TestFile.Create(path).Bytes; - using Image image = Decode(BuildTiff([page])); + foreach (SegmentIntegrityHandling integrity in Enum.GetValues()) + { + DecoderOptions options = new() { SegmentIntegrityHandling = integrity }; + using Image actual = Image.Load(options, data); - AssertRgb24Pixels(image); + ImageComparer.Exact.VerifySimilarity(expected, actual); + } } /// - /// An uncompressed strip larger than the single buffer allocation limit is read in rows, so the image - /// decodes whenever its pixel buffer fits in memory, with the same pixels as without the limit. + /// Reading an uncompressed strip in rows preserves every 16-bit component. /// - /// Whether the samples are stored planar. - /// The number of rows per strip. + /// The stored single-strip or multiple-strip input. [Theory] - [InlineData(false, 700)] - [InlineData(false, 600)] - [InlineData(true, 700)] - [InlineData(true, 600)] - public void Decode_UncompressedStripAboveBufferLimit_DecodesAllRows(bool planar, int rowsPerStrip) + [InlineData(ChunkyRgb16SingleStrip)] + [InlineData(ChunkyRgb16MultipleStrips)] + [InlineData(PlanarRgb16SingleStrip)] + [InlineData(PlanarRgb16MultipleStrips)] + public void Decode_UncompressedStripAboveBufferLimit_DecodesAllRows(string path) { - // With 3 x 16-bit samples, a strip of 600 rows needs 3.7 MB (1.2 MB per plane), more than the 1 MB - // allocation limit, while the decoded L8 image needs only 0.7 MB. - const int width = 1024; - const int height = 700; - TiffPage page = TiffPage.Create(width, height, [16, 16, 16], TiffPhotometricInterpretation.Rgb, planar, (uint)rowsPerStrip, CreateRgbStrips(width, height, 2, planar, rowsPerStrip)); - byte[] tiff = BuildTiff([page]); - - using Image expected = Decode(tiff); - using Image image = Decode(tiff, CreateConfiguration(1)); + byte[] data = TestFile.Create(path).Bytes; + using Image expected = Image.Load(data); + Configuration configuration = Configuration.Default.Clone(); + configuration.MemoryAllocator = MemoryAllocator.Create(new MemoryAllocatorOptions { SingleBufferAllocationLimitMegabytes = 1 }); - for (int y = 0; y < height; y++) + // Each full strip exceeds 1 MiB, including each separate plane. A row fits within the limit. + // The final strip in the multiple-strip inputs contains only the remaining 100 rows. + foreach (SegmentIntegrityHandling integrity in Enum.GetValues()) { - for (int x = 0; x < width; x++) - { - if (expected[x, y] != image[x, y]) - { - Assert.Fail($"Pixel ({x}, {y}) is {image[x, y]}, expected {expected[x, y]}."); - } - } - } - } + DecoderOptions options = new() { Configuration = configuration, SegmentIntegrityHandling = integrity }; + using Image actual = Image.Load(options, data); - /// - /// A frame that does not fit in the allocation limit reports its real dimensions. - /// - [Fact] - public void Decode_FrameAboveAllocationLimit_ThrowsWithRealDimensions() - { - TiffPage page = TiffPage.Create(20000, 15000, [16, 16, 16, 16], TiffPhotometricInterpretation.Rgb, false, null, [new byte[4096]]); - - InvalidImageContentException ex = Assert.Throws(() => Decode(BuildTiff([page]), CreateConfiguration(100)).Dispose()); - - Assert.Contains("20000x15000", ex.Message, StringComparison.Ordinal); + ImageComparer.Exact.VerifySimilarity(expected, actual); + } } /// - /// TIFF allows directories at any file offset, so a chain that goes backwards in the file is valid. + /// Subsampled chroma planes require fewer stored samples than their expanded destination buffers. /// [Fact] - public void Decode_DirectoriesStoredInReverseFileOrder_DecodesAllFrames() + public void Decode_SubsampledPlanarYCbCr_DoesNotRequireExpandedChromaInFile() { - TiffPage first = TiffPage.Create(2, 2, [8], TiffPhotometricInterpretation.BlackIsZero, false, 2, [[10, 11, 12, 13]]); - TiffPage second = TiffPage.Create(2, 2, [8], TiffPhotometricInterpretation.BlackIsZero, false, 2, [[20, 21, 22, 23]]); - - using Image image = Decode(BuildTiff([first, second], reverseIfdOrder: true)); + byte[] data = TestFile.Create(PlanarYCbCrSubsampled).Bytes; - Assert.Equal(2, image.Frames.Count); - Assert.Equal(10, image.Frames[0][0, 0].PackedValue); - Assert.Equal(13, image.Frames[0][1, 1].PackedValue); - Assert.Equal(20, image.Frames[1][0, 0].PackedValue); - Assert.Equal(23, image.Frames[1][1, 1].PackedValue); - } - - /// - /// A directory chain that returns to a directory it has already read is a loop and fails fast. - /// - /// The number of directories in the loop. - /// Whether the directories are stored in reverse file order. - [Theory] - [InlineData(1, false)] - [InlineData(2, false)] - [InlineData(2, true)] - [InlineData(3, true)] - public void Decode_DirectoryChainRevisitsDirectory_ThrowsImageFormatException(int pageCount, bool reverseIfdOrder) - { - TiffPage[] pages = new TiffPage[pageCount]; - for (int i = 0; i < pageCount; i++) + foreach (SegmentIntegrityHandling integrity in Enum.GetValues()) { - pages[i] = TiffPage.Create(2, 2, [8], TiffPhotometricInterpretation.BlackIsZero, false, 2, [new byte[4]]); - } + DecoderOptions options = new() { SegmentIntegrityHandling = integrity }; + using Image image = Image.Load(options, data); - byte[] tiff = BuildTiff(pages, reverseIfdOrder, loopToFirstIfd: true); - - ImageFormatException ex = Assert.Throws(() => Decode(tiff).Dispose()); - Assert.Contains("circular", ex.Message, StringComparison.Ordinal); - } - - /// - /// Gets the expected value of a sample of the synthesised images. - /// - /// The x coordinate. - /// The y coordinate. - /// The channel index. - /// The sample value. - private static byte Sample(int x, int y, int channel) => (byte)((x * 7) + (y * 101) + (channel * 53)); - - /// - /// Asserts that every pixel of the image matches . - /// - /// The decoded image. - private static void AssertRgb24Pixels(Image image) - { - for (int y = 0; y < image.Height; y++) - { - for (int x = 0; x < image.Width; x++) + // Y = 64 with neutral Cb and Cr = 128 represents gray 64 in every pixel. + Assert.Equal(new Size(4, 2), image.Size); + for (int y = 0; y < image.Height; y++) { - Rgb24 expected = new(Sample(x, y, 0), Sample(x, y, 1), Sample(x, y, 2)); - if (!expected.Equals(image[x, y])) + for (int x = 0; x < image.Width; x++) { - Assert.Fail($"Pixel ({x}, {y}) is {image[x, y]}, expected {expected}."); + Assert.Equal(new Rgb24(64, 64, 64), image[x, y]); } } } } /// - /// Creates the strip data of an RGB image whose samples are , stored as - /// 8-bit samples, or as 16-bit samples holding the same value in both bytes. Planar strips are ordered by plane. + /// A zero strip height is invalid in every integrity mode. /// - /// The image width. - /// The image height. - /// The bytes per sample, 1 or 2. - /// Whether the samples are stored planar. - /// The number of rows per strip. - /// The strips. - private static byte[][] CreateRgbStrips(int width, int height, int bytesPerSample, bool planar, int rowsPerStrip) + /// The stored TIFF with RowsPerStrip set to zero. + [Theory] + [InlineData(ChunkyRowsPerStripZero)] + [InlineData(PlanarRowsPerStripZero)] + public void Decode_ZeroRowsPerStrip_ThrowsImageFormatException(string path) { - int stripsPerPlane = (height + rowsPerStrip - 1) / rowsPerStrip; - int planes = planar ? 3 : 1; - int samplesPerPixel = planar ? 1 : 3; - byte[][] strips = new byte[planes * stripsPerPlane][]; - for (int plane = 0; plane < planes; plane++) + byte[] data = TestFile.Create(path).Bytes; + + foreach (SegmentIntegrityHandling integrity in Enum.GetValues()) { - for (int strip = 0; strip < stripsPerPlane; strip++) + DecoderOptions options = new() { SegmentIntegrityHandling = integrity }; + Assert.Throws(() => { - int top = strip * rowsPerStrip; - int rows = Math.Min(rowsPerStrip, height - top); - byte[] data = new byte[width * rows * samplesPerPixel * bytesPerSample]; - for (int y = 0; y < rows; y++) - { - for (int x = 0; x < width; x++) - { - for (int sample = 0; sample < samplesPerPixel; sample++) - { - byte value = Sample(x, top + y, plane + sample); - int index = (((y * width) + x) * samplesPerPixel) + sample; - data.AsSpan(index * bytesPerSample, bytesPerSample).Fill(value); - } - } - } - - strips[(plane * stripsPerPlane) + strip] = data; - } + using Image image = Image.Load(options, data); + }); } - - return strips; } /// - /// Decodes a TIFF file from memory. + /// Short image data follows the integrity policy without reading past the declared strip or reusing old samples. /// - /// The pixel format. - /// The encoded TIFF. - /// The configuration, or for the default. - /// The decoded image. - private static Image Decode(byte[] tiff, Configuration configuration = null) - where TPixel : unmanaged, IPixel - { - using MemoryStream stream = new(tiff, false); - return Image.Load(new DecoderOptions { Configuration = configuration ?? Configuration.Default }, stream); - } - - /// - /// Creates a configuration whose memory allocator has the given allocation limit. - /// - /// The allocation limit in megabytes. - /// The configuration. - private static Configuration CreateConfiguration(int allocationLimitMegabytes) + /// The stored TIFF whose strip ends halfway through its second row. + /// Whether to force row-by-row reads. + [Theory] + [InlineData(ChunkyRgb16ShortStrip, false)] + [InlineData(ChunkyRgb16ShortStrip, true)] + [InlineData(PlanarRgb16ShortStrip, false)] + [InlineData(PlanarRgb16ShortStrip, true)] + public void Decode_ShortUncompressedStrip_UsesImageDataIntegrityPolicy(string path, bool limitBuffer) { + byte[] data = TestFile.Create(path).Bytes; Configuration configuration = Configuration.Default.Clone(); - configuration.MemoryAllocator = MemoryAllocator.Create(new MemoryAllocatorOptions { AllocationLimitMegabytes = allocationLimitMegabytes }); - return configuration; - } - - /// - /// Writes a little-endian TIFF. The strip data of every page follows the header, and the directories follow - /// the strip data in page order, or in reverse page order when is set. - /// - /// The pages. - /// Whether to store the directories in reverse page order. - /// Whether the last directory links back to the first one. - /// The encoded TIFF. - private static byte[] BuildTiff(TiffPage[] pages, bool reverseIfdOrder = false, bool loopToFirstIfd = false) - { - List bytes = [0x49, 0x49, 42, 0, 0, 0, 0, 0]; - - uint[][] stripOffsets = new uint[pages.Length][]; - for (int i = 0; i < pages.Length; i++) + if (limitBuffer) { - stripOffsets[i] = new uint[pages[i].Strips.Length]; - for (int s = 0; s < pages[i].Strips.Length; s++) - { - stripOffsets[i][s] = (uint)bytes.Count; - bytes.AddRange(pages[i].Strips[s]); - } + configuration.MemoryAllocator = MemoryAllocator.Create(new MemoryAllocatorOptions { SingleBufferAllocationLimitMegabytes = 1 }); } - uint[] ifdOffsets = new uint[pages.Length]; - int[] nextIfdPositions = new int[pages.Length]; - for (int n = 0; n < pages.Length; n++) + foreach (SegmentIntegrityHandling integrity in Enum.GetValues()) { - int i = reverseIfdOrder ? pages.Length - 1 - n : n; - - // Directories must start on a word boundary. - if ((bytes.Count & 1) != 0) + DecoderOptions options = new() { Configuration = configuration, SegmentIntegrityHandling = integrity }; + if (integrity != SegmentIntegrityHandling.IgnoreImageData) { - bytes.Add(0); + Assert.Throws(() => + { + using Image image = Image.Load(options, data); + }); } - - ifdOffsets[i] = (uint)bytes.Count; - List<(ExifTagValue Tag, ExifDataType Type, uint[] Values)> entries = pages[i].GetEntries(stripOffsets[i]); - - // Values that do not fit in an entry are stored straight after the directory. - int valuesOffset = bytes.Count + 2 + (entries.Count * 12) + 4; - List values = []; - AddUInt16(bytes, (ushort)entries.Count); - foreach ((ExifTagValue tag, ExifDataType type, uint[] entryValues) in entries) + else { - AddUInt16(bytes, (ushort)tag); - AddUInt16(bytes, (ushort)type); - AddUInt32(bytes, (uint)entryValues.Length); - - List target = bytes; - if (entryValues.Length * (type == ExifDataType.Short ? 2 : 4) > 4) - { - AddUInt32(bytes, (uint)(valuesOffset + values.Count)); - target = values; - } - - int start = target.Count; - foreach (uint value in entryValues) - { - if (type == ExifDataType.Short) - { - AddUInt16(target, (ushort)value); - } - else - { - AddUInt32(target, value); - } - } - - while (target == bytes && bytes.Count - start < 4) - { - bytes.Add(0); - } + using Image image = Image.Load(options, data); + + // The fixtures contain more bytes after StripByteCounts. Those bytes must not become image samples. + // Samples before the declared end survive. Missing samples are zero in both read paths. + Assert.Equal(new Rgb48(0, 13621, 27242), image[0, 0]); + Assert.Equal(new Rgb48(25957, 39578, 53199), image[0, 1]); + Assert.Equal(default, image[1023, 1]); + Assert.Equal(default, image[0, 2]); + Assert.Equal(default, image[1023, 699]); } - - nextIfdPositions[i] = bytes.Count; - AddUInt32(bytes, 0); - bytes.AddRange(values); - } - - byte[] tiff = [.. bytes]; - BinaryPrimitives.WriteUInt32LittleEndian(tiff.AsSpan(4), ifdOffsets[0]); - for (int i = 0; i < pages.Length; i++) - { - uint next = i + 1 < pages.Length ? ifdOffsets[i + 1] : loopToFirstIfd ? ifdOffsets[0] : 0; - BinaryPrimitives.WriteUInt32LittleEndian(tiff.AsSpan(nextIfdPositions[i]), next); } - - return tiff; } /// - /// Appends a little-endian 16-bit value. + /// A failed image allocation reports the declared dimensions. /// - /// The target. - /// The value. - private static void AddUInt16(List bytes, ushort value) + [Fact] + public void Decode_FrameAboveAllocationLimit_ThrowsWithRealDimensions() { - bytes.Add((byte)value); - bytes.Add((byte)(value >> 8)); + byte[] data = TestFile.Create(FrameAboveAllocationLimit).Bytes; + Configuration configuration = Configuration.Default.Clone(); + configuration.MemoryAllocator = MemoryAllocator.Create(new MemoryAllocatorOptions { AllocationLimitMegabytes = 100 }); + DecoderOptions options = new() { Configuration = configuration }; + + InvalidImageContentException ex = Assert.Throws(() => + { + using Image image = Image.Load(options, data); + }); + + Assert.Contains("20000x15000", ex.Message, StringComparison.Ordinal); } /// - /// Appends a little-endian 32-bit value. + /// Directory links determine frame order even when their file offsets decrease. /// - /// The target. - /// The value. - private static void AddUInt32(List bytes, uint value) + /// The stored TIFF or BigTIFF with reversed directory offsets. + [Theory] + [InlineData(ReverseDirectories)] + [InlineData(BigTiffReverseDirectories)] + public void Decode_DirectoriesStoredInReverseFileOrder_DecodesAllFrames(string path) { - AddUInt16(bytes, (ushort)value); - AddUInt16(bytes, (ushort)(value >> 16)); + byte[] data = TestFile.Create(path).Bytes; + + foreach (SegmentIntegrityHandling integrity in Enum.GetValues()) + { + DecoderOptions options = new() { SegmentIntegrityHandling = integrity }; + using Image image = Image.Load(options, data); + ImageInfo info = Image.Identify(options, data); + + Assert.Equal(2, image.Frames.Count); + Assert.Equal(2, info.FrameMetadataCollection.Count); + Assert.Equal(new Size(2, 2), image.Size); + Assert.Equal(new L8(10), image.Frames[0][0, 0]); + Assert.Equal(new L8(11), image.Frames[0][1, 0]); + Assert.Equal(new L8(12), image.Frames[0][0, 1]); + Assert.Equal(new L8(13), image.Frames[0][1, 1]); + Assert.Equal(new L8(20), image.Frames[1][0, 0]); + Assert.Equal(new L8(21), image.Frames[1][1, 0]); + Assert.Equal(new L8(22), image.Frames[1][0, 1]); + Assert.Equal(new L8(23), image.Frames[1][1, 1]); + } } /// - /// Describes one uncompressed page of a synthesised TIFF. + /// A repeated directory offset is a structural error in every integrity mode. /// - private sealed class TiffPage + /// The stored TIFF or BigTIFF with a circular directory chain. + [Theory] + [InlineData(DirectoryLoopOne)] + [InlineData(DirectoryLoopTwo)] + [InlineData(DirectoryLoopTwoReverse)] + [InlineData(DirectoryLoopThreeReverse)] + [InlineData(BigTiffDirectoryLoopOne)] + [InlineData(BigTiffDirectoryLoopTwo)] + [InlineData(BigTiffDirectoryLoopTwoReverse)] + [InlineData(BigTiffDirectoryLoopThreeReverse)] + public void Decode_DirectoryChainRevisitsDirectory_ThrowsImageFormatException(string path) { - /// - /// Gets the image width. - /// - public int Width { get; private init; } - - /// - /// Gets the image height. - /// - public int Height { get; private init; } - - /// - /// Gets the bits of each sample; the length is the number of samples per pixel. - /// - public ushort[] BitsPerSample { get; private init; } - - /// - /// Gets the photometric interpretation. - /// - public TiffPhotometricInterpretation Photometric { get; private init; } - - /// - /// Gets a value indicating whether the samples are stored planar. - /// - public bool Planar { get; private init; } - - /// - /// Gets the RowsPerStrip value, or to omit the entry. - /// - public uint? RowsPerStrip { get; private init; } - - /// - /// Gets the strip data. - /// - public byte[][] Strips { get; private init; } + byte[] data = TestFile.Create(path).Bytes; - /// - /// Creates a page. - /// - /// The image width. - /// The image height. - /// The bits of each sample. - /// The photometric interpretation. - /// Whether the samples are stored planar. - /// The RowsPerStrip value, or to omit the entry. - /// The strip data. - /// The page. - public static TiffPage Create(int width, int height, ushort[] bitsPerSample, TiffPhotometricInterpretation photometric, bool planar, uint? rowsPerStrip, byte[][] strips) - => new() - { - Width = width, - Height = height, - BitsPerSample = bitsPerSample, - Photometric = photometric, - Planar = planar, - RowsPerStrip = rowsPerStrip, - Strips = strips, - }; - - /// - /// Gets the directory entries of the page, sorted by tag. - /// - /// The file offset of each strip. - /// The entries. - public List<(ExifTagValue Tag, ExifDataType Type, uint[] Values)> GetEntries(uint[] stripOffsets) + foreach (SegmentIntegrityHandling integrity in Enum.GetValues()) { - uint[] bitsPerSample = new uint[this.BitsPerSample.Length]; - for (int i = 0; i < bitsPerSample.Length; i++) + DecoderOptions options = new() { SegmentIntegrityHandling = integrity }; + ImageFormatException identifyError = Assert.Throws(() => Image.Identify(options, data)); + ImageFormatException decodeError = Assert.Throws(() => { - bitsPerSample[i] = this.BitsPerSample[i]; - } - - uint[] stripByteCounts = new uint[this.Strips.Length]; - for (int i = 0; i < stripByteCounts.Length; i++) - { - stripByteCounts[i] = (uint)this.Strips[i].Length; - } - - List<(ExifTagValue Tag, ExifDataType Type, uint[] Values)> entries = - [ - (ExifTagValue.ImageWidth, ExifDataType.Long, [(uint)this.Width]), - (ExifTagValue.ImageLength, ExifDataType.Long, [(uint)this.Height]), - (ExifTagValue.BitsPerSample, ExifDataType.Short, bitsPerSample), - (ExifTagValue.Compression, ExifDataType.Short, [(uint)TiffCompression.None]), - (ExifTagValue.PhotometricInterpretation, ExifDataType.Short, [(uint)this.Photometric]), - (ExifTagValue.StripOffsets, ExifDataType.Long, stripOffsets), - (ExifTagValue.SamplesPerPixel, ExifDataType.Short, [(uint)this.BitsPerSample.Length]), - (ExifTagValue.StripByteCounts, ExifDataType.Long, stripByteCounts), - ]; - - if (this.RowsPerStrip.HasValue) - { - entries.Add((ExifTagValue.RowsPerStrip, ExifDataType.Long, [this.RowsPerStrip.Value])); - } - - if (this.Planar) - { - entries.Add((ExifTagValue.PlanarConfiguration, ExifDataType.Short, [(uint)TiffPlanarConfiguration.Planar])); - } + using Image image = Image.Load(options, data); + }); - entries.Sort((a, b) => a.Tag.CompareTo(b.Tag)); - return entries; + Assert.Contains("circular", identifyError.Message, StringComparison.Ordinal); + Assert.Contains("circular", decodeError.Message, StringComparison.Ordinal); } } } diff --git a/tests/ImageSharp.Tests/TestImages.cs b/tests/ImageSharp.Tests/TestImages.cs index e4c3fffff6..b30226ee87 100644 --- a/tests/ImageSharp.Tests/TestImages.cs +++ b/tests/ImageSharp.Tests/TestImages.cs @@ -1002,6 +1002,34 @@ public static class Tiff public const string Benchmark_RgbPackbits = "medium_rgb_packbits.tiff"; public const string Benchmark_RgbUncompressed = "medium_rgb_uncompressed.tiff"; + public const string StripLayoutRgbReference = "Tiff/strips/rgb-4000x2.png"; + public const string PlanarYCbCrSubsampled = "Tiff/strips/planar-ycbcr-subsampled.tiff"; + public const string ChunkyRowsPerStripMissing = "Tiff/strips/chunky-rowsperstrip-missing.tiff"; + public const string ChunkyRowsPerStripLarge = "Tiff/strips/chunky-rowsperstrip-large.tiff"; + public const string ChunkyRowsPerStripMax = "Tiff/strips/chunky-rowsperstrip-max.tiff"; + public const string ChunkyRowsPerStripZero = "Tiff/strips/chunky-rowsperstrip-zero.tiff"; + public const string PlanarRowsPerStripMissing = "Tiff/strips/planar-rowsperstrip-missing.tiff"; + public const string PlanarRowsPerStripLarge = "Tiff/strips/planar-rowsperstrip-large.tiff"; + public const string PlanarRowsPerStripMax = "Tiff/strips/planar-rowsperstrip-max.tiff"; + public const string PlanarRowsPerStripZero = "Tiff/strips/planar-rowsperstrip-zero.tiff"; + public const string ChunkyRgb16SingleStrip = "Tiff/strips/chunky-rgb16-700-rows.tiff"; + public const string ChunkyRgb16MultipleStrips = "Tiff/strips/chunky-rgb16-600-rows.tiff"; + public const string PlanarRgb16SingleStrip = "Tiff/strips/planar-rgb16-700-rows.tiff"; + public const string PlanarRgb16MultipleStrips = "Tiff/strips/planar-rgb16-600-rows.tiff"; + public const string ChunkyRgb16ShortStrip = "Tiff/strips/chunky-rgb16-short-strip.tiff"; + public const string PlanarRgb16ShortStrip = "Tiff/strips/planar-rgb16-short-strip.tiff"; + public const string FrameAboveAllocationLimit = "Tiff/strips/frame-above-allocation-limit.tiff"; + public const string ReverseDirectories = "Tiff/strips/reverse-directories.tiff"; + public const string BigTiffReverseDirectories = "Tiff/strips/reverse-directories.bigtiff"; + public const string DirectoryLoopOne = "Tiff/strips/loop-1-forward.tiff"; + public const string DirectoryLoopTwo = "Tiff/strips/loop-2-forward.tiff"; + public const string DirectoryLoopTwoReverse = "Tiff/strips/loop-2-reverse.tiff"; + public const string DirectoryLoopThreeReverse = "Tiff/strips/loop-3-reverse.tiff"; + public const string BigTiffDirectoryLoopOne = "Tiff/strips/loop-1-forward.bigtiff"; + public const string BigTiffDirectoryLoopTwo = "Tiff/strips/loop-2-forward.bigtiff"; + public const string BigTiffDirectoryLoopTwoReverse = "Tiff/strips/loop-2-reverse.bigtiff"; + public const string BigTiffDirectoryLoopThreeReverse = "Tiff/strips/loop-3-reverse.bigtiff"; + public const string BilevelWithoutBitsPerSampleUncompressed = "Tiff/defaults/bilevel-no-bitspersample-none.tiff"; public const string BilevelWithoutBitsPerSampleFax4 = "Tiff/defaults/bilevel-no-bitspersample-g4.tiff"; public const string BilevelWithoutSampleTags = "Tiff/defaults/bilevel-no-bitspersample-or-samplesperpixel.tiff"; diff --git a/tests/Images/Input/Tiff/strips/chunky-rgb16-600-rows.tiff b/tests/Images/Input/Tiff/strips/chunky-rgb16-600-rows.tiff new file mode 100644 index 0000000000..355a61ed5e --- /dev/null +++ b/tests/Images/Input/Tiff/strips/chunky-rgb16-600-rows.tiff @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b47a52f17b1f1b56984f46264c5daa4b023ff85195ef5ceedf61bfaeb86d18c8 +size 4300944 diff --git a/tests/Images/Input/Tiff/strips/chunky-rgb16-700-rows.tiff b/tests/Images/Input/Tiff/strips/chunky-rgb16-700-rows.tiff new file mode 100644 index 0000000000..83e730d48c --- /dev/null +++ b/tests/Images/Input/Tiff/strips/chunky-rgb16-700-rows.tiff @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:386e773612f8673784d243e251c99657bd5e92204bd8b3fed0c7ec1b3ef16b16 +size 4300928 diff --git a/tests/Images/Input/Tiff/strips/chunky-rgb16-short-strip.tiff b/tests/Images/Input/Tiff/strips/chunky-rgb16-short-strip.tiff new file mode 100644 index 0000000000..c25ed053c3 --- /dev/null +++ b/tests/Images/Input/Tiff/strips/chunky-rgb16-short-strip.tiff @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:240011d8f11a91720343ef6878d9fe1b89018507edcfc48954cd25b89b4bd6b8 +size 4300928 diff --git a/tests/Images/Input/Tiff/strips/chunky-rowsperstrip-large.tiff b/tests/Images/Input/Tiff/strips/chunky-rowsperstrip-large.tiff new file mode 100644 index 0000000000..725563b927 --- /dev/null +++ b/tests/Images/Input/Tiff/strips/chunky-rowsperstrip-large.tiff @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f826e3e26cc5a6b47b7ba186dd05dbe8f3b30faaae055722986426902d8f23db +size 24128 diff --git a/tests/Images/Input/Tiff/strips/chunky-rowsperstrip-max.tiff b/tests/Images/Input/Tiff/strips/chunky-rowsperstrip-max.tiff new file mode 100644 index 0000000000..4d9b73ad13 --- /dev/null +++ b/tests/Images/Input/Tiff/strips/chunky-rowsperstrip-max.tiff @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:164916f345450cc883b4ed3017ed21f0027543a4ca7d20476fe4e2e27b2e35f6 +size 24128 diff --git a/tests/Images/Input/Tiff/strips/chunky-rowsperstrip-missing.tiff b/tests/Images/Input/Tiff/strips/chunky-rowsperstrip-missing.tiff new file mode 100644 index 0000000000..1b2e814aa3 --- /dev/null +++ b/tests/Images/Input/Tiff/strips/chunky-rowsperstrip-missing.tiff @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:398b7f818dcd20576d7027797ee83871d50b532f31ff3c45339b0e13255a89be +size 24116 diff --git a/tests/Images/Input/Tiff/strips/chunky-rowsperstrip-zero.tiff b/tests/Images/Input/Tiff/strips/chunky-rowsperstrip-zero.tiff new file mode 100644 index 0000000000..65e0390f5d --- /dev/null +++ b/tests/Images/Input/Tiff/strips/chunky-rowsperstrip-zero.tiff @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:48267753ca7aa7e4ce87a6c117c2c6fe201a1db7993334bd7e671a10cb116fc2 +size 24128 diff --git a/tests/Images/Input/Tiff/strips/frame-above-allocation-limit.tiff b/tests/Images/Input/Tiff/strips/frame-above-allocation-limit.tiff new file mode 100644 index 0000000000..97f825e5c2 --- /dev/null +++ b/tests/Images/Input/Tiff/strips/frame-above-allocation-limit.tiff @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:129a0ebbb2c88516825de8cd6528f1c1c95a9798eef8f4068c398638ef757bd9 +size 8310 diff --git a/tests/Images/Input/Tiff/strips/loop-1-forward.bigtiff b/tests/Images/Input/Tiff/strips/loop-1-forward.bigtiff new file mode 100644 index 0000000000000000000000000000000000000000..c342c77dc99d180e157a3f146815a55c1100ad51 GIT binary patch literal 220 zcmebD)MnsdU|^5{Vr0Mx<1n%?Fhbc(P?`~o7!xBiTph?f5MV|UgPFmGMNAOMJOPMS W22m{P#E{g%+#-%7#sbv~^9um}m;to_ literal 0 HcmV?d00001 diff --git a/tests/Images/Input/Tiff/strips/loop-1-forward.tiff b/tests/Images/Input/Tiff/strips/loop-1-forward.tiff new file mode 100644 index 0000000000..7ca8ad93fe --- /dev/null +++ b/tests/Images/Input/Tiff/strips/loop-1-forward.tiff @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:274d9263569569b70b329d73f705c5e3ad160ca30331cd7acf90be3f38ed8af7 +size 126 diff --git a/tests/Images/Input/Tiff/strips/loop-2-forward.bigtiff b/tests/Images/Input/Tiff/strips/loop-2-forward.bigtiff new file mode 100644 index 0000000000000000000000000000000000000000..0b64a98a0497e09aa155c8474db122142973d767 GIT binary patch literal 420 zcmebD)MnsdU|^5{Vi;fq0!}!Gg@F;uW`fd;jF@6fjLdL#AoDOR literal 0 HcmV?d00001 diff --git a/tests/Images/Input/Tiff/strips/loop-2-forward.tiff b/tests/Images/Input/Tiff/strips/loop-2-forward.tiff new file mode 100644 index 0000000000..32593a425e --- /dev/null +++ b/tests/Images/Input/Tiff/strips/loop-2-forward.tiff @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f3ec05f949c46b7394e7a404b9d0c475af63110ce1e8a410dc3dbc3333af1d6c +size 244 diff --git a/tests/Images/Input/Tiff/strips/loop-2-reverse.bigtiff b/tests/Images/Input/Tiff/strips/loop-2-reverse.bigtiff new file mode 100644 index 0000000000000000000000000000000000000000..8fb7ebc027303fba42ba5ca95cc41e448373ee93 GIT binary patch literal 420 zcmebD)MnsdU|@Iv#4x}J1e|aV3j-sR%><j5aU?MosNM%q8bl40KLns|pu`^%P%{98bp#{; literal 0 HcmV?d00001 diff --git a/tests/Images/Input/Tiff/strips/loop-2-reverse.tiff b/tests/Images/Input/Tiff/strips/loop-2-reverse.tiff new file mode 100644 index 0000000000..b265f3d112 --- /dev/null +++ b/tests/Images/Input/Tiff/strips/loop-2-reverse.tiff @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2d380758cdc90a0c6c007c3e1de356e517b2ffb3848b238062a5426a8359829f +size 244 diff --git a/tests/Images/Input/Tiff/strips/loop-3-reverse.bigtiff b/tests/Images/Input/Tiff/strips/loop-3-reverse.bigtiff new file mode 100644 index 0000000000000000000000000000000000000000..74c8a86e2b51c5b1483e9b9be298f013ef9d9498 GIT binary patch literal 628 zcmebD)MnsdU|`t52qd6@5eS%o0L0~l3$if4lz=6G;*5-#VoZ$8aCIPaL4X-a3}yx! z7BN92^CTcz8AP$D6GKu5bBj2V7z