Skip to content

Commit 11af26c

Browse files
Merge pull request #3224 from jafin/jf/gif-lzw-and-frame-geometry
Drop GIF frame rows outside the logical screen and stop on LZW codes past the code tables
2 parents 93a186c + c8d3c42 commit 11af26c

21 files changed

Lines changed: 265 additions & 6 deletions

‎src/ImageSharp/Formats/Gif/GifDecoderCore.cs‎

Lines changed: 50 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,12 @@ protected override Image<TPixel> Decode<TPixel>(BufferedReadStream stream, Cance
175175
this.gifMetadata.ColorTableMode = FrameColorTableMode.Local;
176176
}
177177
}
178+
catch
179+
{
180+
// Ownership transfers to the caller only after decoding succeeds. Dispose a partially decoded image on failure.
181+
image?.Dispose();
182+
throw;
183+
}
178184
finally
179185
{
180186
this.globalColorTable?.Dispose();
@@ -589,7 +595,15 @@ private bool ReadFrame<TPixel>(
589595
}
590596
}
591597

592-
this.ReadFrameColors(stream, ref image, ref previousFrame, ref previousDisposalMode, colorTable, backgroundColor.ToPixel<TPixel>());
598+
try
599+
{
600+
this.ReadFrameColors(stream, ref image, ref previousFrame, ref previousDisposalMode, colorTable, backgroundColor.ToPixel<TPixel>());
601+
}
602+
catch (InvalidImageContentException) when (this.Options.SegmentIntegrityHandling is SegmentIntegrityHandling.IgnoreImageData)
603+
{
604+
// Keep completed rows. SkipBlock below consumes the rest of this frame so the next descriptor remains readable.
605+
// Header and palette parsing stay outside this recovery boundary; structural errors remain fatal.
606+
}
593607

594608
// Update from newly decoded frame.
595609
FrameDisposalMode disposalMethod = this.graphicsControlExtension.DisposalMethod;
@@ -723,12 +737,14 @@ private void ReadFrameColors<TPixel>(
723737
using IMemoryOwner<byte> indicesRowOwner = this.memoryAllocator.Allocate<byte>(descriptor.Width);
724738
Span<byte> indicesRow = indicesRowOwner.Memory.Span;
725739

740+
// A frame that starts at or beyond the right or bottom edge of the logical screen has nothing to draw.
741+
// Its image data is then left to the caller, which skips the remaining data sub-blocks.
726742
int minCodeSize = stream.ReadByte();
727-
if (LzwDecoder.IsValidMinCodeSize(minCodeSize))
743+
if (descriptorTop < imageHeight && descriptorLeft < imageWidth && LzwDecoder.IsValidMinCodeSize(minCodeSize))
728744
{
729745
using LzwDecoder lzwDecoder = new(this.configuration.MemoryAllocator, stream, minCodeSize);
730746

731-
for (int y = descriptorTop; y < descriptorBottom && y < imageHeight; y++)
747+
for (int y = descriptorTop; y < descriptorBottom; y++)
732748
{
733749
// Check if this image is interlaced.
734750
int writeY; // the target y offset to write to
@@ -737,7 +753,10 @@ private void ReadFrameColors<TPixel>(
737753
// If so then we read lines at predetermined offsets.
738754
// When an entire image height worth of offset lines has been read we consider this a pass.
739755
// With each pass the number of offset lines changes and the starting line changes.
740-
if (interlaceY >= descriptor.Height)
756+
// A frame shorter than 5 lines has passes without any lines, so keep advancing until a
757+
// pass has a line left. The four passes visit every frame line exactly once and the loop
758+
// runs once per frame line, so a pass with a line left always exists.
759+
while (interlaceY >= descriptor.Height)
741760
{
742761
interlacePass++;
743762
switch (interlacePass)
@@ -756,14 +775,31 @@ private void ReadFrameColors<TPixel>(
756775
}
757776
}
758777

759-
writeY = Math.Min(interlaceY + descriptor.Top, image.Height);
778+
writeY = interlaceY + descriptorTop;
760779
interlaceY += interlaceIncrement;
761780
}
762781
else
763782
{
764783
writeY = y;
765784
}
766785

786+
// For a properly encoded gif the frame never extends below the logical screen. When it does
787+
// the lines below the screen are dropped. Lines only move down within a pass, so once a line
788+
// falls below the screen the rest of the pass is hidden too. A sequential frame is a single
789+
// pass, so decoding can stop there. Before the last interlace pass, later passes still start
790+
// at frame line 1, 2 or 4 and can reach the screen again unless it shows only frame line 0.
791+
// The hidden lines in between must still be decoded and discarded to reach those lines.
792+
if (writeY >= imageHeight)
793+
{
794+
if (!descriptor.InterlaceFlag || interlacePass == 3 || imageHeight - descriptorTop < 2)
795+
{
796+
break;
797+
}
798+
799+
lzwDecoder.SkipIndices(descriptor.Width);
800+
continue;
801+
}
802+
767803
lzwDecoder.DecodePixelRow(indicesRow);
768804

769805
// #403 The left + width value can be larger than the image width
@@ -840,7 +876,15 @@ private bool ReadFrameMetadata(BufferedReadStream stream, List<ImageFrameMetadat
840876
if (LzwDecoder.IsValidMinCodeSize(minCodeSize))
841877
{
842878
using LzwDecoder lzwDecoder = new(this.configuration.MemoryAllocator, stream, minCodeSize);
843-
lzwDecoder.SkipIndices(this.imageDescriptor.Width * this.imageDescriptor.Height);
879+
880+
try
881+
{
882+
lzwDecoder.SkipIndices(this.imageDescriptor.Width * this.imageDescriptor.Height);
883+
}
884+
catch (InvalidImageContentException) when (this.Options.SegmentIntegrityHandling is SegmentIntegrityHandling.IgnoreImageData)
885+
{
886+
// Identify still reads LZW codes. Use the same recovery policy and sub-block skipping as full decoding.
887+
}
844888
}
845889

846890
ImageFrameMetadata currentFrame = new();

‎src/ImageSharp/Formats/Gif/LzwDecoder.cs‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,7 @@ public static bool IsValidMinCodeSize(int minCodeSize)
131131
/// Decodes and decompresses all pixel indices for a single row from the stream, assigning the pixel values to the buffer.
132132
/// </summary>
133133
/// <param name="indices">The pixel indices array to decode to.</param>
134+
/// <exception cref="InvalidImageContentException">The first code addresses an entry outside the LZW code tables.</exception>
134135
public void DecodePixelRow(Span<byte> indices)
135136
{
136137
indices.Clear();
@@ -209,6 +210,14 @@ public void DecodePixelRow(Span<byte> indices)
209210

210211
if (oldCode == NullCode)
211212
{
213+
// This first code has no previous string to extend. With a tolerated minimum size of 12,
214+
// the next available code is 4098, beyond the 4096-entry tables. Report the corrupt data
215+
// before indexing either table; the GIF decoder applies the image data integrity policy.
216+
if (code >= MaxStackSize)
217+
{
218+
GifThrowHelper.ThrowInvalidImageContentException("The GIF LZW code exceeds the code table bounds.");
219+
}
220+
212221
pixelStack[top++] = suffix[code];
213222
oldCode = code;
214223
first = code;
@@ -274,6 +283,7 @@ public void DecodePixelRow(Span<byte> indices)
274283
/// Decodes and decompresses all pixel indices from the stream allowing skipping of the data.
275284
/// </summary>
276285
/// <param name="length">The resulting index table length.</param>
286+
/// <exception cref="InvalidImageContentException">The first code addresses an entry outside the LZW code tables.</exception>
277287
public void SkipIndices(int length)
278288
{
279289
// Get span values from the owners.
@@ -350,6 +360,14 @@ public void SkipIndices(int length)
350360

351361
if (oldCode == NullCode)
352362
{
363+
// This first code has no previous string to extend. With a tolerated minimum size of 12,
364+
// the next available code is 4098, beyond the 4096-entry tables. Report the corrupt data
365+
// before indexing either table; the GIF decoder applies the image data integrity policy.
366+
if (code >= MaxStackSize)
367+
{
368+
GifThrowHelper.ThrowInvalidImageContentException("The GIF LZW code exceeds the code table bounds.");
369+
}
370+
353371
pixelStack[top++] = suffix[code];
354372
oldCode = code;
355373
first = code;

‎tests/ImageSharp.Tests/Formats/Gif/GifDecoderTests.cs‎

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,14 @@ public class GifDecoderTests
2424
TestImages.Gif.Giphy, TestImages.Gif.Kumin
2525
];
2626

27+
/// <summary>
28+
/// The four palette colors in the stored frame geometry fixtures.
29+
/// </summary>
30+
private static readonly Rgba32[] GeometryTestColorTable =
31+
[
32+
new(255, 0, 0), new(0, 255, 0), new(0, 0, 255), new(255, 255, 255)
33+
];
34+
2735
[Theory]
2836
[WithFileCollection(nameof(MultiFrameTestFiles), PixelTypes.Rgba32)]
2937
public void Decode_VerifyAllFrames<TPixel>(TestImageProvider<TPixel> provider)
@@ -442,6 +450,127 @@ public void Decode_MalformedApplicationExtension_ThrowsWithStrict()
442450
});
443451
}
444452

453+
/// <summary>
454+
/// Invalid LZW table codes fail unless image data errors are explicitly ignored.
455+
/// Recovery retains completed rows and continues to the following animation frame.
456+
/// </summary>
457+
/// <param name="path">The stored GIF with an invalid first code or an invalid code after a clear.</param>
458+
/// <param name="completedRows">The number of valid rows before the invalid code.</param>
459+
/// <param name="frameCount">The number of stored animation frames.</param>
460+
[Theory]
461+
[InlineData(TestImages.Gif.LzwCodeOutsideTable, 0, 1)]
462+
[InlineData(TestImages.Gif.LzwCodeOutsideTableHiddenRow, 1, 2)]
463+
public void Decode_LzwCodePastCodeTables_RespectsIntegrityHandling(string path, int completedRows, int frameCount)
464+
{
465+
byte[] data = TestFile.Create(path).Bytes;
466+
467+
foreach (SegmentIntegrityHandling integrity in Enum.GetValues<SegmentIntegrityHandling>())
468+
{
469+
DecoderOptions options = new() { SegmentIntegrityHandling = integrity };
470+
if (integrity is not SegmentIntegrityHandling.IgnoreImageData)
471+
{
472+
Assert.Throws<InvalidImageContentException>(() =>
473+
{
474+
using Image<Rgba32> image = Image.Load<Rgba32>(options, data);
475+
});
476+
477+
continue;
478+
}
479+
480+
using Image<Rgba32> actual = Image.Load<Rgba32>(options, data);
481+
Assert.Equal(new Size(4, frameCount == 1 ? 4 : 2), actual.Size);
482+
Assert.Equal(frameCount, actual.Frames.Count);
483+
484+
for (int y = 0; y < actual.Height; y++)
485+
{
486+
for (int x = 0; x < actual.Width; x++)
487+
{
488+
// A completed row is red. An undecoded row keeps the frame's transparent background.
489+
Assert.Equal(y < completedRows ? new Rgba32(255, 0, 0) : default, actual[x, y]);
490+
491+
if (frameCount == 2)
492+
{
493+
// The complete green frame after the corrupt frame proves that sub-block recovery resumed correctly.
494+
Assert.Equal(new Rgba32(0, 255, 0), actual.Frames[1][x, y]);
495+
}
496+
}
497+
}
498+
}
499+
}
500+
501+
/// <summary>
502+
/// Identify uses LZW index skipping and must apply the same image data policy as full decoding.
503+
/// </summary>
504+
/// <param name="path">The stored GIF with an invalid table code.</param>
505+
/// <param name="frameCount">The number of stored animation frames.</param>
506+
[Theory]
507+
[InlineData(TestImages.Gif.LzwCodeOutsideTable, 1)]
508+
[InlineData(TestImages.Gif.LzwCodeOutsideTableHiddenRow, 2)]
509+
public void Identify_LzwCodePastCodeTables_RespectsIntegrityHandling(string path, int frameCount)
510+
{
511+
byte[] data = TestFile.Create(path).Bytes;
512+
513+
foreach (SegmentIntegrityHandling integrity in Enum.GetValues<SegmentIntegrityHandling>())
514+
{
515+
DecoderOptions options = new() { SegmentIntegrityHandling = integrity };
516+
if (integrity is not SegmentIntegrityHandling.IgnoreImageData)
517+
{
518+
Assert.Throws<InvalidImageContentException>(() => Image.Identify(options, data));
519+
continue;
520+
}
521+
522+
ImageInfo info = Image.Identify(options, data);
523+
Assert.Equal(new Size(4, frameCount == 1 ? 4 : 2), info.Size);
524+
Assert.Equal(frameCount, info.FrameMetadataCollection.Count);
525+
}
526+
}
527+
528+
/// <summary>
529+
/// Clips stored frame rows to the logical screen without changing interlace order or later frame parsing.
530+
/// </summary>
531+
/// <param name="path">The stored GIF with a four-color row pattern and a second frame containing one white pixel.</param>
532+
/// <param name="width">The first frame's stored width.</param>
533+
/// <param name="height">The first frame's stored height.</param>
534+
/// <param name="left">The first frame's left offset.</param>
535+
/// <param name="top">The first frame's top offset.</param>
536+
[Theory]
537+
[InlineData(TestImages.Gif.Frame20x20At0x0Interlaced, 20, 20, 0, 0)]
538+
[InlineData(TestImages.Gif.Frame20x20At0x0Sequential, 20, 20, 0, 0)]
539+
[InlineData(TestImages.Gif.Frame10x10At0x5Interlaced, 10, 10, 0, 5)]
540+
[InlineData(TestImages.Gif.Frame10x10At0x5Sequential, 10, 10, 0, 5)]
541+
[InlineData(TestImages.Gif.Frame10x10At5x3Interlaced, 10, 10, 5, 3)]
542+
[InlineData(TestImages.Gif.Frame10x10At0x10Interlaced, 10, 10, 0, 10)]
543+
[InlineData(TestImages.Gif.Frame10x10At0x10Sequential, 10, 10, 0, 10)]
544+
[InlineData(TestImages.Gif.Frame10x20At0x8Interlaced, 10, 20, 0, 8)]
545+
[InlineData(TestImages.Gif.Frame10x20At0x9Interlaced, 10, 20, 0, 9)]
546+
[InlineData(TestImages.Gif.Frame10x1At0x0Interlaced, 10, 1, 0, 0)]
547+
[InlineData(TestImages.Gif.Frame10x2At0x0Interlaced, 10, 2, 0, 0)]
548+
[InlineData(TestImages.Gif.Frame10x3At0x7Interlaced, 10, 3, 0, 7)]
549+
[InlineData(TestImages.Gif.Frame10x4At0x0Interlaced, 10, 4, 0, 0)]
550+
[InlineData(TestImages.Gif.Frame4x4At12x0Interlaced, 4, 4, 12, 0)]
551+
[InlineData(TestImages.Gif.Frame4x4At12x0Sequential, 4, 4, 12, 0)]
552+
public void Decode_FrameExceedingLogicalScreen_WritesOnlyRowsInsideCanvas(string path, int width, int height, int left, int top)
553+
{
554+
using Image<Rgba32> image = Image.Load<Rgba32>(TestFile.Create(path).Bytes);
555+
556+
Assert.Equal(new Size(10, 10), image.Size);
557+
Assert.Equal(2, image.Frames.Count);
558+
559+
for (int y = 0; y < image.Height; y++)
560+
{
561+
for (int x = 0; x < image.Width; x++)
562+
{
563+
// Frame row n contains palette index n modulo four. Pixels outside its rectangle stay transparent.
564+
bool inFrame = x >= left && x < left + width && y >= top && y < top + height;
565+
Rgba32 expected = inFrame ? GeometryTestColorTable[(y - top) % 4] : default;
566+
Assert.Equal(expected, image[x, y]);
567+
}
568+
}
569+
570+
// Even when the first frame is clipped or fully hidden, its data must not consume the next image descriptor.
571+
Assert.Equal(new Rgba32(255, 255, 255), image.Frames[1][0, 0]);
572+
}
573+
445574
private static byte[] CreateGifWithMalformedApplicationExtension()
446575
{
447576
// The application extension declares a 5-byte application block (0x05), but GIF application

‎tests/ImageSharp.Tests/TestImages.cs‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -588,6 +588,23 @@ public static class Bmp
588588
public static class Gif
589589
{
590590
public const string Rings = "Gif/rings.gif";
591+
public const string Frame20x20At0x0Interlaced = "Gif/frames/20x20-left0-top0-interlaced.gif";
592+
public const string Frame20x20At0x0Sequential = "Gif/frames/20x20-left0-top0-sequential.gif";
593+
public const string Frame10x10At0x5Interlaced = "Gif/frames/10x10-left0-top5-interlaced.gif";
594+
public const string Frame10x10At0x5Sequential = "Gif/frames/10x10-left0-top5-sequential.gif";
595+
public const string Frame10x10At5x3Interlaced = "Gif/frames/10x10-left5-top3-interlaced.gif";
596+
public const string Frame10x10At0x10Interlaced = "Gif/frames/10x10-left0-top10-interlaced.gif";
597+
public const string Frame10x10At0x10Sequential = "Gif/frames/10x10-left0-top10-sequential.gif";
598+
public const string Frame10x20At0x8Interlaced = "Gif/frames/10x20-left0-top8-interlaced.gif";
599+
public const string Frame10x20At0x9Interlaced = "Gif/frames/10x20-left0-top9-interlaced.gif";
600+
public const string Frame10x1At0x0Interlaced = "Gif/frames/10x1-left0-top0-interlaced.gif";
601+
public const string Frame10x2At0x0Interlaced = "Gif/frames/10x2-left0-top0-interlaced.gif";
602+
public const string Frame10x3At0x7Interlaced = "Gif/frames/10x3-left0-top7-interlaced.gif";
603+
public const string Frame10x4At0x0Interlaced = "Gif/frames/10x4-left0-top0-interlaced.gif";
604+
public const string Frame4x4At12x0Interlaced = "Gif/frames/4x4-left12-top0-interlaced.gif";
605+
public const string Frame4x4At12x0Sequential = "Gif/frames/4x4-left12-top0-sequential.gif";
606+
public const string LzwCodeOutsideTable = "Gif/frames/lzw-code-outside-table.gif";
607+
public const string LzwCodeOutsideTableHiddenRow = "Gif/frames/lzw-code-outside-table-hidden-row.gif";
591608
public const string Giphy = "Gif/giphy.gif";
592609
public const string Cheers = "Gif/cheers.gif";
593610
public const string Receipt = "Gif/receipt.gif";
Lines changed: 3 additions & 0 deletions
Loading
Lines changed: 3 additions & 0 deletions
Loading
Lines changed: 3 additions & 0 deletions
Loading
Lines changed: 3 additions & 0 deletions
Loading
Lines changed: 3 additions & 0 deletions
Loading
Lines changed: 3 additions & 0 deletions
Loading

0 commit comments

Comments
 (0)