From 3233d0bd22e2adb86cf4ec33a0d969c8a96041ec Mon Sep 17 00:00:00 2001 From: Jason Finch Date: Sun, 18 Oct 2026 09:15:30 +1000 Subject: [PATCH] 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; + } + } +}