Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 25 additions & 8 deletions src/ImageSharp/IO/ChunkedMemoryStream.cs
Original file line number Diff line number Diff line change
Expand Up @@ -193,10 +193,21 @@ public override void Write(ReadOnlySpan<byte> 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;
Expand Down Expand Up @@ -231,7 +242,11 @@ public override void Write(ReadOnlySpan<byte> 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);
}

/// <summary>
Expand Down Expand Up @@ -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;
}

Expand Down
23 changes: 23 additions & 0 deletions tests/ImageSharp.Tests/Formats/Tiff/TiffEncoderTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Rgba32> image = new(32, 24, Color.Red.ToPixel<Rgba32>());
image.Frames.AddFrame(new ImageFrame<Rgba32>(image.Configuration, 32, 24, Color.Blue.ToPixel<Rgba32>()));
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)]
Expand Down
47 changes: 47 additions & 0 deletions tests/ImageSharp.Tests/IO/ChunkedMemoryStreamTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
{
Expand Down
46 changes: 46 additions & 0 deletions tests/ImageSharp.Tests/Image/ImageTests.Save.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<Rgba32> image = new(32, 24);
image.ProcessPixelRows(accessor =>
{
for (int y = 0; y < accessor.Height; y++)
{
Span<Rgba32> 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()
{
Expand Down