diff --git a/src/ImageSharp/IO/ChunkedMemoryStream.cs b/src/ImageSharp/IO/ChunkedMemoryStream.cs index c164045df5..8be3fbb75e 100644 --- a/src/ImageSharp/IO/ChunkedMemoryStream.cs +++ b/src/ImageSharp/IO/ChunkedMemoryStream.cs @@ -193,10 +193,21 @@ public override void Write(ReadOnlySpan buffer) long remaining = this.memoryChunkBuffer.Length - this.position; // Ensure we have enough capacity to write the data. - while (remaining < count) + if (remaining < count) { - this.memoryChunkBuffer.Expand(); - remaining = this.memoryChunkBuffer.Length - this.position; + // A position beyond the allocated chunks, reached by seeking past the end, has no chunk + // until the buffer grows, so its chunk location is resolved again after expanding. + bool resolvePosition = remaining < 0; + while (remaining < count) + { + this.memoryChunkBuffer.Expand(); + remaining = this.memoryChunkBuffer.Length - this.position; + } + + if (resolvePosition) + { + this.SetPosition(this.position); + } } int bytesToWrite = count; @@ -231,7 +242,11 @@ public override void Write(ReadOnlySpan buffer) } this.position += bytesWritten; - this.length += bytesWritten; + + // Encoders such as TIFF and BMP seek back to patch offsets that were written earlier. + // Overwriting existing bytes must not extend the stream, so the length only grows + // when the write ends beyond the current end of the data. + this.length = Math.Max(this.length, this.position); } /// @@ -344,11 +359,13 @@ private void SetPosition(long value) int currentChunkIndex = 0; long offset = newPosition; - // If the new position is greater than the length of the stream, set the position to the end of the stream - if (offset > 0 && offset >= this.memoryChunkBuffer.Length) + // A position at or beyond the end of the allocated chunks starts the chunk that the next write allocates. + // This is exact at the end of the chunks, which is also where a write that fills the last chunk leaves + // the position. Write resolves a position further beyond the end once the chunks reach it. + if (offset >= this.memoryChunkBuffer.Length) { - this.bufferIndex = this.memoryChunkBuffer.ChunkCount - 1; - this.chunkIndex = this.memoryChunkBuffer[this.bufferIndex].Length - 1; + this.bufferIndex = this.memoryChunkBuffer.ChunkCount; + this.chunkIndex = 0; return; } diff --git a/tests/ImageSharp.Tests/Formats/Tiff/TiffEncoderTests.cs b/tests/ImageSharp.Tests/Formats/Tiff/TiffEncoderTests.cs index e4a8eeebbb..2f1c36f8df 100644 --- a/tests/ImageSharp.Tests/Formats/Tiff/TiffEncoderTests.cs +++ b/tests/ImageSharp.Tests/Formats/Tiff/TiffEncoderTests.cs @@ -813,6 +813,29 @@ public void TiffEncoder_WritesIfdOffsetAtWordBoundary() Assert.Equal(imageBytes[5], expectedIfdOffsetBytes[1]); } + [Fact] + public async Task TiffEncoder_ToNonSeekableStream_MatchesSeekableStream() + { + // arrange + // Each frame adds an IFD offset that is patched after the frame data is written. + using Image image = new(32, 24, Color.Red.ToPixel()); + image.Frames.AddFrame(new ImageFrame(image.Configuration, 32, 24, Color.Blue.ToPixel())); + TiffEncoder encoder = new(); + using MemoryStream seekable = new(); + image.Save(seekable, encoder); + + using MemoryStream syncTarget = new(); + using MemoryStream asyncTarget = new(); + + // act + image.Save(new NonSeekableStream(syncTarget), encoder); + await image.SaveAsync(new NonSeekableStream(asyncTarget), encoder, TestContext.Current.CancellationToken); + + // assert + Assert.Equal(seekable.ToArray(), syncTarget.ToArray()); + Assert.Equal(seekable.ToArray(), asyncTarget.ToArray()); + } + [Theory] [WithFile(RgbUncompressed, PixelTypes.Rgba32, TiffCompression.CcittGroup3Fax, TiffCompression.CcittGroup3Fax)] [WithFile(RgbUncompressed, PixelTypes.Rgba32, TiffCompression.CcittGroup4Fax, TiffCompression.CcittGroup4Fax)] diff --git a/tests/ImageSharp.Tests/IO/ChunkedMemoryStreamTests.cs b/tests/ImageSharp.Tests/IO/ChunkedMemoryStreamTests.cs index 63c1f7fa31..e358b37f56 100644 --- a/tests/ImageSharp.Tests/IO/ChunkedMemoryStreamTests.cs +++ b/tests/ImageSharp.Tests/IO/ChunkedMemoryStreamTests.cs @@ -224,6 +224,53 @@ public void MemoryStream_WriteToSpanTests(int length) } } + [Theory] + [InlineData(16)] + [InlineData(DefaultSmallChunkSize + 16)] + public void MemoryStream_OverwriteAfterSeek_DoesNotChangeLength(int length) + { + using ChunkedMemoryStream ms = new(this.allocator); + byte[] expected = this.CreateTestBuffer(length); + ms.Write(expected, 0, expected.Length); + + // Patch bytes inside the written data, as encoders do for offsets that are only known later. + byte[] patch = [1, 2, 3, 4]; + ms.Position = length - 10; + ms.Write(patch, 0, patch.Length); + patch.CopyTo(expected, length - 10); + + Assert.Equal(length - 6, ms.Position); + Assert.Equal(length, ms.Length); + Assert.Equal(expected, ms.ToArray()); + } + + [Theory] + [InlineData(0, 16)] + [InlineData(16, 16)] + [InlineData(DefaultSmallChunkSize, 0)] + [InlineData(DefaultSmallChunkSize - 8, 16)] + [InlineData(16, DefaultSmallChunkSize * 2)] + public void MemoryStream_WriteAfterSeekPastEnd_WritesAtPosition(int length, int gap) + { + using ChunkedMemoryStream ms = new(this.allocator); + byte[] head = this.CreateTestBuffer(length); + ms.Write(head, 0, head.Length); + + // Leave a gap after the data, as encoders do for a header that is written last. + byte[] tail = [1, 2, 3, 4]; + ms.Seek(gap, SeekOrigin.Current); + ms.Write(tail, 0, tail.Length); + Assert.Equal(length + gap + tail.Length, ms.Length); + + byte[] header = this.CreateTestBuffer(gap); + ms.Position = length; + ms.Write(header, 0, header.Length); + + byte[] expected = [.. head, .. header, .. tail]; + Assert.Equal(expected.Length, ms.Length); + Assert.Equal(expected, ms.ToArray()); + } + [Fact] public void MemoryStream_WriteByteTests() { diff --git a/tests/ImageSharp.Tests/Image/ImageTests.Save.cs b/tests/ImageSharp.Tests/Image/ImageTests.Save.cs index 867930c4cf..f1df925bb1 100644 --- a/tests/ImageSharp.Tests/Image/ImageTests.Save.cs +++ b/tests/ImageSharp.Tests/Image/ImageTests.Save.cs @@ -2,6 +2,7 @@ // Licensed under the Six Labors Split License. using Moq; +using SixLabors.ImageSharp.Advanced; using SixLabors.ImageSharp.Formats; using SixLabors.ImageSharp.Formats.Png; using SixLabors.ImageSharp.PixelFormats; @@ -57,6 +58,51 @@ public void SetEncoding() Assert.True(format is PngFormat); } + [Theory] + [InlineData("test.ani")] + [InlineData("test.bmp")] + [InlineData("test.cur")] + [InlineData("test.exr")] + [InlineData("test.gif")] + [InlineData("test.heif")] + [InlineData("test.ico")] + [InlineData("test.jpg")] + [InlineData("test.pbm")] + [InlineData("test.png")] + [InlineData("test.qoi")] + [InlineData("test.tga")] + [InlineData("test.tiff")] + [InlineData("test.webp")] + public async Task ToNonSeekableStream_SyncAndAsync_MatchSeekableStream(string filename) + { + using Image image = new(32, 24); + image.ProcessPixelRows(accessor => + { + for (int y = 0; y < accessor.Height; y++) + { + Span row = accessor.GetRowSpan(y); + for (int x = 0; x < row.Length; x++) + { + row[x] = new Rgba32((byte)(x * 8), (byte)(y * 10), (byte)((x + y) * 4)); + } + } + }); + + IImageEncoder encoder = image.DetectEncoder(filename); + + using MemoryStream seekable = new(); + image.Save(seekable, encoder); + + using MemoryStream syncTarget = new(); + image.Save(new NonSeekableStream(syncTarget), encoder); + + using MemoryStream asyncTarget = new(); + await image.SaveAsync(new NonSeekableStream(asyncTarget), encoder, TestContext.Current.CancellationToken); + + Assert.Equal(seekable.ToArray(), syncTarget.ToArray()); + Assert.Equal(seekable.ToArray(), asyncTarget.ToArray()); + } + [Fact] public void ThrowsWhenDisposed() {