Browse Source

Merge pull request #2899 from zxbmmmmmmmmm/feat/ani

Add ANI decoder support
pull/3024/merge
James Jackson-South 3 weeks ago
committed by GitHub
parent
commit
8db96638cb
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 52
      src/ImageSharp/Common/InlineArray.cs
  2. 2
      src/ImageSharp/Common/InlineArray.tt
  3. 15
      src/ImageSharp/Compression/Zlib/ChunkedReadStream.cs
  4. 11
      src/ImageSharp/Compression/Zlib/ZlibInflateReader.cs
  5. 5
      src/ImageSharp/Configuration.cs
  6. 73
      src/ImageSharp/Formats/Ani/AniChunkType.cs
  7. 25
      src/ImageSharp/Formats/Ani/AniConfigurationModule.cs
  8. 54
      src/ImageSharp/Formats/Ani/AniConstants.cs
  9. 52
      src/ImageSharp/Formats/Ani/AniDecoder.cs
  10. 862
      src/ImageSharp/Formats/Ani/AniDecoderCore.cs
  11. 24
      src/ImageSharp/Formats/Ani/AniEncoder.cs
  12. 514
      src/ImageSharp/Formats/Ani/AniEncoderCore.cs
  13. 40
      src/ImageSharp/Formats/Ani/AniFormat.cs
  14. 25
      src/ImageSharp/Formats/Ani/AniFrameFormat.cs
  15. 248
      src/ImageSharp/Formats/Ani/AniFrameMetadata.cs
  16. 114
      src/ImageSharp/Formats/Ani/AniFrameStream.cs
  17. 98
      src/ImageSharp/Formats/Ani/AniHeader.cs
  18. 21
      src/ImageSharp/Formats/Ani/AniHeaderFlags.cs
  19. 39
      src/ImageSharp/Formats/Ani/AniImageFormatDetector.cs
  20. 134
      src/ImageSharp/Formats/Ani/AniMetadata.cs
  21. 34
      src/ImageSharp/Formats/Ani/AniRiffChunkHeader.cs
  22. 87
      src/ImageSharp/Formats/Bmp/BmpEncoderCore.cs
  23. 6
      src/ImageSharp/Formats/Cur/CurConfigurationModule.cs
  24. 10
      src/ImageSharp/Formats/Cur/CurConstants.cs
  25. 7
      src/ImageSharp/Formats/Cur/CurDecoder.cs
  26. 10
      src/ImageSharp/Formats/Cur/CurDecoderCore.cs
  27. 5
      src/ImageSharp/Formats/Cur/CurEncoder.cs
  28. 33
      src/ImageSharp/Formats/Cur/CurEncoderCore.cs
  29. 9
      src/ImageSharp/Formats/Cur/CurFormat.cs
  30. 52
      src/ImageSharp/Formats/Cur/CurFrameMetadata.cs
  31. 49
      src/ImageSharp/Formats/Cur/CurImageFormatDetector.cs
  32. 6
      src/ImageSharp/Formats/Cur/CurMetadata.cs
  33. 32
      src/ImageSharp/Formats/Exr/Compression/Decompressors/B44ExrCompression.cs
  34. 2
      src/ImageSharp/Formats/Exr/ExrDecoderCore.cs
  35. 32
      src/ImageSharp/Formats/Exr/ExrEncoderCore.cs
  36. 30
      src/ImageSharp/Formats/Gif/GifDecoderCore.cs
  37. 6
      src/ImageSharp/Formats/Ico/IcoConfigurationModule.cs
  38. 10
      src/ImageSharp/Formats/Ico/IcoConstants.cs
  39. 7
      src/ImageSharp/Formats/Ico/IcoDecoder.cs
  40. 18
      src/ImageSharp/Formats/Ico/IcoDecoderCore.cs
  41. 5
      src/ImageSharp/Formats/Ico/IcoEncoder.cs
  42. 33
      src/ImageSharp/Formats/Ico/IcoEncoderCore.cs
  43. 5
      src/ImageSharp/Formats/Ico/IcoFormat.cs
  44. 49
      src/ImageSharp/Formats/Ico/IcoFrameMetadata.cs
  45. 49
      src/ImageSharp/Formats/Ico/IcoImageFormatDetector.cs
  46. 6
      src/ImageSharp/Formats/Ico/IcoMetadata.cs
  47. 424
      src/ImageSharp/Formats/Icon/IconDecoderCore.cs
  48. 38
      src/ImageSharp/Formats/Icon/IconDir.cs
  49. 20
      src/ImageSharp/Formats/Icon/IconDirEntry.cs
  50. 281
      src/ImageSharp/Formats/Icon/IconEncoderCore.cs
  51. 8
      src/ImageSharp/Formats/Icon/IconFileType.cs
  52. 6
      src/ImageSharp/Formats/Icon/IconFrameCompression.cs
  53. 114
      src/ImageSharp/Formats/Icon/IconFrameStream.cs
  54. 66
      src/ImageSharp/Formats/Icon/IconImageFormatDetector.cs
  55. 7
      src/ImageSharp/Formats/Jpeg/Components/Decoder/ArithmeticScanDecoder.cs
  56. 12
      src/ImageSharp/Formats/Jpeg/Components/Decoder/HuffmanTable.cs
  57. 42
      src/ImageSharp/Formats/Png/PngEncoderCore.cs
  58. 17
      src/ImageSharp/Formats/Webp/Lossless/Vp8LEncoder.cs
  59. 10
      src/ImageSharp/Formats/Webp/Lossy/QuantEnc.cs
  60. 12
      src/ImageSharp/Formats/Webp/Lossy/Vp8Decoder.cs
  61. 12
      src/ImageSharp/Formats/Webp/Lossy/Vp8Matrix.cs
  62. 2
      src/ImageSharp/Formats/Webp/Lossy/WebpLossyDecoder.cs
  63. 8
      src/ImageSharp/Formats/Webp/Lossy/YuvConversion.cs
  64. 103
      src/ImageSharp/Formats/_Generated/ImageExtensions.Save.cs
  65. 41
      src/ImageSharp/Formats/_Generated/ImageMetadataExtensions.cs
  66. 2
      src/ImageSharp/Formats/_Generated/_Formats.ttinclude
  67. 6
      tests/Directory.Build.targets
  68. 2
      tests/ImageSharp.Tests/ConfigurationTests.cs
  69. 210
      tests/ImageSharp.Tests/Formats/Ani/AniDecoderTests.cs
  70. 242
      tests/ImageSharp.Tests/Formats/Ani/AniEncoderTests.cs
  71. 31
      tests/ImageSharp.Tests/Formats/Ani/AniMetadataTests.cs
  72. 40
      tests/ImageSharp.Tests/Formats/Icon/Cur/CurDecoderTests.cs
  73. 36
      tests/ImageSharp.Tests/Formats/Icon/Cur/CurEncoderTests.cs
  74. 48
      tests/ImageSharp.Tests/Formats/Icon/Ico/IcoDecoderTests.cs
  75. 51
      tests/ImageSharp.Tests/Formats/Icon/Ico/IcoEncoderTests.cs
  76. 3
      tests/ImageSharp.Tests/Formats/ImageFormatManagerTests.cs
  77. 7
      tests/ImageSharp.Tests/TestImages.cs
  78. 3
      tests/Images/Input/Ani/Help.ani
  79. 3
      tests/Images/Input/Ani/Work.ani
  80. 3
      tests/Images/Input/Ani/aero_busy.ani

52
src/ImageSharp/Common/InlineArray.cs

@ -26,6 +26,15 @@ internal struct InlineArray8<T>
private T t;
}
/// <summary>
/// Represents a safe, fixed sized buffer of 14 elements.
/// </summary>
[InlineArray(14)]
internal struct InlineArray14<T>
{
private T t;
}
/// <summary>
/// Represents a safe, fixed sized buffer of 16 elements.
/// </summary>
@ -35,4 +44,47 @@ internal struct InlineArray16<T>
private T t;
}
/// <summary>
/// Represents a safe, fixed sized buffer of 18 elements.
/// </summary>
[InlineArray(18)]
internal struct InlineArray18<T>
{
private T t;
}
/// <summary>
/// Represents a safe, fixed sized buffer of 19 elements.
/// </summary>
[InlineArray(19)]
internal struct InlineArray19<T>
{
private T t;
}
/// <summary>
/// Represents a safe, fixed sized buffer of 26 elements.
/// </summary>
[InlineArray(26)]
internal struct InlineArray26<T>
{
private T t;
}
/// <summary>
/// Represents a safe, fixed sized buffer of 36 elements.
/// </summary>
[InlineArray(36)]
internal struct InlineArray36<T>
{
private T t;
}
/// <summary>
/// Represents a safe, fixed sized buffer of 256 elements.
/// </summary>
[InlineArray(256)]
internal struct InlineArray256<T>
{
private T t;
}

2
src/ImageSharp/Common/InlineArray.tt

@ -16,7 +16,7 @@ namespace SixLabors.ImageSharp;
<#GenerateInlineArrays();#>
<#+
private static int[] Lengths = [4, 8, 16 ];
private static int[] Lengths = [4, 8, 14, 16, 18, 19, 26, 36, 256];
void GenerateInlineArrays()
{

15
src/ImageSharp/Compression/Zlib/ChunkedReadStream.cs

@ -77,13 +77,14 @@ internal sealed class ChunkedReadStream : Stream
}
/// <inheritdoc/>
public override int Read(byte[] buffer, int offset, int count)
public override int Read(byte[] buffer, int offset, int count) => this.Read(buffer.AsSpan(offset, count));
/// <inheritdoc/>
public override int Read(Span<byte> buffer)
{
// Decrement currentDataRemaining only by bytes actually returned by
// innerStream.Read; a short read otherwise underflows the segment
// counter and triggers getData() before the segment is truly drained.
// Decrement currentDataRemaining only by bytes actually returned by innerStream.Read; a short read otherwise advances segments too early.
int totalBytesRead = 0;
while (totalBytesRead < count)
while (totalBytesRead < buffer.Length)
{
if (this.currentDataRemaining is 0)
{
@ -94,8 +95,8 @@ internal sealed class ChunkedReadStream : Stream
}
}
int bytesToRead = Math.Min(count - totalBytesRead, this.currentDataRemaining);
int bytesRead = this.innerStream.Read(buffer, offset + totalBytesRead, bytesToRead);
int bytesToRead = Math.Min(buffer.Length - totalBytesRead, this.currentDataRemaining);
int bytesRead = this.innerStream.Read(buffer.Slice(totalBytesRead, bytesToRead));
if (bytesRead is 0)
{
break;

11
src/ImageSharp/Compression/Zlib/ZlibInflateReader.cs

@ -14,13 +14,6 @@ namespace SixLabors.ImageSharp.Compression.Zlib;
/// </summary>
internal sealed class ZlibInflateReader : IDisposable
{
/// <summary>
/// Used to read the Adler-32 and Crc-32 checksums.
/// We don't actually use this for anything so it doesn't
/// have to be threadsafe.
/// </summary>
private static readonly byte[] ChecksumBuffer = new byte[4];
private readonly ChunkedReadStream segmentStream;
public ZlibInflateReader(BufferedReadStream innerStream)
@ -112,7 +105,9 @@ internal sealed class ZlibInflateReader : IDisposable
{
// We don't need this for inflate so simply skip by the next four bytes.
// https://tools.ietf.org/html/rfc1950#page-6
if (this.segmentStream.Read(ChecksumBuffer, 0, 4) != 4)
InlineArray4<byte> checksumBuffer = default;
if (this.segmentStream.Read(checksumBuffer) != 4)
{
return false;
}

5
src/ImageSharp/Configuration.cs

@ -5,6 +5,7 @@ using System.Collections.Concurrent;
using System.Diagnostics.CodeAnalysis;
using SixLabors.ImageSharp.Advanced;
using SixLabors.ImageSharp.Formats;
using SixLabors.ImageSharp.Formats.Ani;
using SixLabors.ImageSharp.Formats.Bmp;
using SixLabors.ImageSharp.Formats.Cur;
using SixLabors.ImageSharp.Formats.Exr;
@ -224,6 +225,7 @@ public sealed class Configuration
/// <see cref="WebpConfigurationModule"/>.
/// <see cref="ExrConfigurationModule"/>.
/// <see cref="QoiConfigurationModule"/>.
/// <see cref="AniConfigurationModule"/>.
/// </summary>
/// <returns>The default configuration of <see cref="Configuration"/>.</returns>
internal static Configuration CreateDefaultInstance() => new(
@ -238,5 +240,6 @@ public sealed class Configuration
new ExrConfigurationModule(),
new QoiConfigurationModule(),
new IcoConfigurationModule(),
new CurConfigurationModule());
new CurConfigurationModule(),
new AniConfigurationModule());
}

73
src/ImageSharp/Formats/Ani/AniChunkType.cs

@ -0,0 +1,73 @@
// Copyright (c) Six Labors.
// Licensed under the Six Labors Split License.
namespace SixLabors.ImageSharp.Formats.Ani;
/// <summary>
/// Identifies top-level ANI RIFF chunks.
/// </summary>
internal enum AniChunkType : uint
{
/// <summary>
/// The animation header chunk, "anih".
/// </summary>
Header = 0x68_69_6E_61,
/// <summary>
/// The frame sequence chunk, "seq ".
/// </summary>
Sequence = 0x20_71_65_73,
/// <summary>
/// The per-step display-rate chunk, "rate".
/// </summary>
Rate = 0x65_74_61_72,
/// <summary>
/// A RIFF list chunk, "LIST".
/// </summary>
List = 0x54_53_49_4C
}
/// <summary>
/// Identifies ANI RIFF list types.
/// </summary>
internal enum AniListType : uint
{
/// <summary>
/// The information list, "INFO".
/// </summary>
Info = 0x4F_46_4E_49,
/// <summary>
/// The embedded frame-resource list, "fram".
/// </summary>
Frames = 0x6D_61_72_66
}
/// <summary>
/// Identifies chunks stored in an ANI information list.
/// </summary>
internal enum AniInfoChunkType : uint
{
/// <summary>
/// The animation name, "INAM".
/// </summary>
Name = 0x4D_41_4E_49,
/// <summary>
/// The animation artist, "IART".
/// </summary>
Artist = 0x54_52_41_49
}
/// <summary>
/// Identifies chunks stored in an ANI frame list.
/// </summary>
internal enum AniFrameChunkType : uint
{
/// <summary>
/// An embedded frame resource, "icon".
/// </summary>
Icon = 0x6E_6F_63_69
}

25
src/ImageSharp/Formats/Ani/AniConfigurationModule.cs

@ -0,0 +1,25 @@
// Copyright (c) Six Labors.
// Licensed under the Six Labors Split License.
namespace SixLabors.ImageSharp.Formats.Ani;
/// <summary>
/// Registers the image encoder, decoder, and format detector for the ANI format.
/// </summary>
public sealed class AniConfigurationModule : IImageFormatConfigurationModule
{
/// <summary>
/// Initializes a new instance of the <see cref="AniConfigurationModule"/> class.
/// </summary>
public AniConfigurationModule()
{
}
/// <inheritdoc/>
public void Configure(Configuration configuration)
{
configuration.ImageFormatsManager.SetEncoder(AniFormat.Instance, new AniEncoder());
configuration.ImageFormatsManager.SetDecoder(AniFormat.Instance, AniDecoder.Instance);
configuration.ImageFormatsManager.AddImageFormatDetector(new AniImageFormatDetector());
}
}

54
src/ImageSharp/Formats/Ani/AniConstants.cs

@ -0,0 +1,54 @@
// Copyright (c) Six Labors.
// Licensed under the Six Labors Split License.
namespace SixLabors.ImageSharp.Formats.Ani;
/// <summary>
/// Defines constants used by the ANI format.
/// </summary>
internal static class AniConstants
{
/// <summary>
/// The number of bytes in the RIFF identifier, size, and form type.
/// </summary>
public const int RiffHeaderSize = 12;
/// <summary>
/// The number of bytes in a RIFF chunk identifier and size.
/// </summary>
public const int ChunkHeaderSize = 8;
/// <summary>
/// The number of bytes required to identify an embedded ICO or CUR resource.
/// </summary>
public const int IconDirHeaderSize = 6;
/// <summary>
/// The maximum number of bytes retained from an ancillary chunk.
/// </summary>
/// <remarks>
/// Control arrays and information strings come from untrusted input. Bounding them independently of the allocator
/// prevents a physically large RIFF chunk from consuming an unreasonable amount of memory.
/// </remarks>
public const int MaxAncillaryChunkSize = 8 * 1024 * 1024;
/// <summary>
/// The list of MIME types that identify ANI data.
/// </summary>
public static readonly IEnumerable<string> MimeTypes = ["application/x-navi-animation"];
/// <summary>
/// The list of file extensions that identify ANI data.
/// </summary>
public static readonly IEnumerable<string> FileExtensions = ["ani"];
/// <summary>
/// Gets the RIFF container identifier.
/// </summary>
public static ReadOnlySpan<byte> RiffFourCc => "RIFF"u8;
/// <summary>
/// Gets the ANI RIFF form type.
/// </summary>
public static ReadOnlySpan<byte> AniFormTypeFourCc => "ACON"u8;
}

52
src/ImageSharp/Formats/Ani/AniDecoder.cs

@ -0,0 +1,52 @@
// Copyright (c) Six Labors.
// Licensed under the Six Labors Split License.
using SixLabors.ImageSharp.PixelFormats;
namespace SixLabors.ImageSharp.Formats.Ani;
/// <summary>
/// Decodes Windows animated cursor images.
/// </summary>
public sealed class AniDecoder : ImageDecoder
{
/// <summary>
/// Prevents a default instance of the <see cref="AniDecoder"/> class from being created.
/// </summary>
private AniDecoder()
{
}
/// <summary>
/// Gets the shared instance.
/// </summary>
public static AniDecoder Instance { get; } = new();
/// <inheritdoc/>
protected override Image<TPixel> Decode<TPixel>(DecoderOptions options, Stream stream, CancellationToken cancellationToken)
{
Guard.NotNull(options, nameof(options));
Guard.NotNull(stream, nameof(stream));
using AniDecoderCore decoder = new(options);
Image<TPixel> image = decoder.Decode<TPixel>(options.Configuration, stream, cancellationToken);
ScaleToTargetSize(options, image);
return image;
}
/// <inheritdoc/>
protected override Image Decode(DecoderOptions options, Stream stream, CancellationToken cancellationToken)
=> this.Decode<Rgba32>(options, stream, cancellationToken);
/// <inheritdoc/>
protected override ImageInfo Identify(DecoderOptions options, Stream stream, CancellationToken cancellationToken)
{
Guard.NotNull(options, nameof(options));
Guard.NotNull(stream, nameof(stream));
using AniDecoderCore decoder = new(options);
return decoder.Identify(options.Configuration, stream, cancellationToken);
}
}

862
src/ImageSharp/Formats/Ani/AniDecoderCore.cs

@ -0,0 +1,862 @@
// Copyright (c) Six Labors.
// Licensed under the Six Labors Split License.
using System.Buffers;
using System.Buffers.Binary;
using System.Runtime.InteropServices;
using System.Text;
using SixLabors.ImageSharp.Formats.Bmp;
using SixLabors.ImageSharp.Formats.Cur;
using SixLabors.ImageSharp.Formats.Ico;
using SixLabors.ImageSharp.Formats.Icon;
using SixLabors.ImageSharp.IO;
using SixLabors.ImageSharp.Memory;
using SixLabors.ImageSharp.Metadata;
using SixLabors.ImageSharp.PixelFormats;
namespace SixLabors.ImageSharp.Formats.Ani;
/// <summary>
/// Performs ANI decoding and identification.
/// </summary>
internal sealed class AniDecoderCore : ImageDecoderCore, IDisposable
{
private readonly List<(long Start, long End)> frameLists = new(1);
private readonly ImageMetadata imageMetadata;
private readonly AniMetadata aniMetadata;
private AniHeader header;
private IMemoryOwner<uint>? sequence;
private IMemoryOwner<uint>? rates;
/// <summary>
/// Reusable storage for the fixed ANI header and smaller RIFF values.
/// </summary>
private InlineArray36<byte> buffer;
/// <summary>
/// Initializes a new instance of the <see cref="AniDecoderCore"/> class.
/// </summary>
/// <param name="options">The general decoder options.</param>
public AniDecoderCore(DecoderOptions options)
: base(options)
{
// The decoded ANI metadata must belong to the same ImageMetadata instance transferred to Image or ImageInfo.
this.imageMetadata = new ImageMetadata();
this.aniMetadata = this.imageMetadata.GetAniMetadata();
}
/// <inheritdoc/>
protected override Image<TPixel> Decode<TPixel>(BufferedReadStream stream, CancellationToken cancellationToken)
{
this.ParseContainer(stream);
DecoderOptions frameOptions = this.CreateFrameDecoderOptions();
List<(AniFrameFormat Format, Image<TPixel> Image)?> resources = [];
List<ImageFrame<TPixel>> outputFrames = [];
// Until Image accepts the frame collection, this method remains responsible for disposing every constructed output frame.
bool outputFramesOwned = false;
try
{
// Container parsing runs first because seq/rate chunks can occur after the frame list and affect how resources are projected.
resources.EnsureCapacity((int)Math.Min(this.header.FrameCount, this.Options.MaxFrames));
this.ProcessFrameChunks(stream, resources, (format, frameStream) =>
{
cancellationToken.ThrowIfCancellationRequested();
Image<TPixel> resource = DecodeFrame<TPixel>(format, frameOptions, frameStream, cancellationToken);
this.Dimensions = new(Math.Max(this.Dimensions.Width, resource.Width), Math.Max(this.Dimensions.Height, resource.Height));
return resource;
});
if (resources.Count is 0)
{
throw new InvalidImageContentException("The ANI file does not contain any frame resources.");
}
// Keep the owners alive and resolve their spans once; sequence and rate lookup occurs for every animation step.
IMemoryOwner<uint>? sequenceOwner = this.sequence;
bool hasSequence = sequenceOwner is not null;
ReadOnlySpan<uint> sequence = sequenceOwner is null ? [] : sequenceOwner.GetSpan();
ReadOnlySpan<uint> rates = this.rates is null ? [] : this.rates.GetSpan();
int stepCount = hasSequence ? sequence.Length : resources.Count;
int maxFrames = (int)this.Options.MaxFrames;
outputFrames.EnsureCapacity(Math.Min(maxFrames, resources.Count));
for (int step = 0; step < stepCount && outputFrames.Count < maxFrames; step++)
{
cancellationToken.ThrowIfCancellationRequested();
uint resourceIndex = hasSequence ? sequence[step] : (uint)step;
if (resourceIndex >= resources.Count || resources[(int)resourceIndex] is not { } resource)
{
// A bad ordering entry is recoverable ancillary data: the remaining valid steps can still be decoded.
this.ExecuteAncillarySegmentAction(() => throw new InvalidImageContentException("The ANI sequence references a missing frame resource."));
continue;
}
(AniFrameFormat format, Image<TPixel> resourceImage) = resource;
uint frameDelay = step < rates.Length ? rates[step] : this.aniMetadata.DisplayRate;
for (int i = 0; i < resourceImage.Frames.Count && outputFrames.Count < maxFrames; i++)
{
ImageFrame<TPixel> source = resourceImage.Frames[i];
ImageFrame<TPixel> target = new(this.Options.Configuration, this.Dimensions);
// ANI flattens differently sized ICO/CUR variants into one ImageSharp frame collection.
// The common canvas preserves that invariant, while encoding dimensions retain the source size.
for (int y = 0; y < source.Height; y++)
{
source.PixelBuffer.DangerousGetRowSpan(y).CopyTo(target.PixelBuffer.DangerousGetRowSpan(y));
}
AniFrameMetadata metadata = CreateFrameMetadata(source.Metadata, format, step + 1, frameDelay, source.Size);
target.Metadata.SetFormatMetadata(AniFormat.Instance, metadata);
outputFrames.Add(target);
}
}
if (outputFrames.Count is 0)
{
throw new InvalidImageContentException("The ANI file does not contain any decodable animation steps.");
}
// Image takes ownership of the supplied frames; only the temporary decoded resources remain locally owned.
Image<TPixel> image = new(this.Options.Configuration, this.imageMetadata, outputFrames);
outputFramesOwned = true;
return image;
}
finally
{
// Embedded images are temporary resource containers; their pixels have already been copied to the flattened output frames.
foreach ((AniFrameFormat Format, Image<TPixel> Image)? resource in resources)
{
if (resource is { } value)
{
value.Image.Dispose();
}
}
// Construction failures occur before Image can own the frames, so the partial collection must be released here.
if (!outputFramesOwned)
{
foreach (ImageFrame<TPixel> frame in outputFrames)
{
frame.Dispose();
}
}
}
}
/// <inheritdoc/>
protected override ImageInfo Identify(BufferedReadStream stream, CancellationToken cancellationToken)
{
this.ParseContainer(stream);
DecoderOptions frameOptions = this.CreateFrameDecoderOptions();
List<(AniFrameFormat Format, ImageInfo Info)?> resources = [];
resources.EnsureCapacity((int)Math.Min(this.header.FrameCount, this.Options.MaxFrames));
this.ProcessFrameChunks(stream, resources, (format, frameStream) =>
{
cancellationToken.ThrowIfCancellationRequested();
ImageInfo info = IdentifyFrame(format, frameOptions, frameStream, cancellationToken);
this.Dimensions = new(Math.Max(this.Dimensions.Width, info.Width), Math.Max(this.Dimensions.Height, info.Height));
return info;
});
if (resources.Count is 0)
{
throw new InvalidImageContentException("The ANI file does not contain any frame resources.");
}
// Identification mirrors decode without allocating pixels, while preserving the same step-to-resource projection.
List<ImageFrameMetadata> outputFrames = [];
IMemoryOwner<uint>? sequenceOwner = this.sequence;
bool hasSequence = sequenceOwner is not null;
ReadOnlySpan<uint> sequence = sequenceOwner is null ? [] : sequenceOwner.GetSpan();
ReadOnlySpan<uint> rates = this.rates is null ? [] : this.rates.GetSpan();
int stepCount = hasSequence ? sequence.Length : resources.Count;
int maxFrames = (int)this.Options.MaxFrames;
_ = outputFrames.EnsureCapacity(Math.Min(maxFrames, resources.Count));
for (int step = 0; step < stepCount && outputFrames.Count < maxFrames; step++)
{
cancellationToken.ThrowIfCancellationRequested();
uint resourceIndex = hasSequence ? sequence[step] : (uint)step;
if (resourceIndex >= resources.Count || resources[(int)resourceIndex] is not { } resource)
{
// Sequence errors are ancillary during identification for the same reason as decoding: other steps remain usable.
this.ExecuteAncillarySegmentAction(() => throw new InvalidImageContentException("The ANI sequence references a missing frame resource."));
continue;
}
(AniFrameFormat format, ImageInfo info) = resource;
uint frameDelay = step < rates.Length ? rates[step] : this.aniMetadata.DisplayRate;
if (info.FrameMetadataCollection.Count is 0)
{
// Some embedded decoders expose only resource-level dimensions, so synthesize the one required ANI frame entry.
ImageFrameMetadata target = new();
target.SetFormatMetadata(AniFormat.Instance, CreateFrameMetadata(null, format, step + 1, frameDelay, info.Size));
outputFrames.Add(target);
continue;
}
for (int i = 0; i < info.FrameMetadataCollection.Count && outputFrames.Count < maxFrames; i++)
{
ImageFrameMetadata source = info.FrameMetadataCollection[i];
ImageFrameMetadata target = new();
target.SetFormatMetadata(AniFormat.Instance, CreateFrameMetadata(source, format, step + 1, frameDelay, info.Size));
outputFrames.Add(target);
}
}
if (outputFrames.Count is 0)
{
throw new InvalidImageContentException("The ANI file does not contain any identifiable animation steps.");
}
return new ImageInfo(this.Dimensions, this.imageMetadata, outputFrames);
}
/// <summary>
/// Parses the RIFF container and records frame-list boundaries for subsequent embedded decoding.
/// </summary>
/// <param name="stream">The ANI stream.</param>
private void ParseContainer(BufferedReadStream stream)
{
// Parser-owned chunk state is replaced by the container currently being scanned.
this.frameLists.Clear();
this.sequence?.Dispose();
this.rates?.Dispose();
this.sequence = null;
this.rates = null;
long containerStart = stream.Position;
Span<byte> riffHeader = this.buffer[..AniConstants.RiffHeaderSize];
ReadExactly(stream, riffHeader, "RIFF header");
if (!riffHeader[..4].SequenceEqual(AniConstants.RiffFourCc)
|| !riffHeader.Slice(8, 4).SequenceEqual(AniConstants.AniFormTypeFourCc))
{
throw new InvalidImageContentException("The stream does not contain an ANI RIFF container.");
}
uint declaredSize = BinaryPrimitives.ReadUInt32LittleEndian(riffHeader[4..]);
if (declaredSize < sizeof(uint))
{
throw new InvalidImageContentException("The ANI RIFF container size is invalid.");
}
// RIFF size excludes the initial identifier and size field. Some real-world ANI files incorrectly
// include those eight bytes, so the physical stream length remains the hard read boundary.
long declaredEnd = checked(containerStart + 8 + declaredSize);
long containerEnd = Math.Min(declaredEnd, stream.Length);
bool headerFound = false;
while (stream.Position + AniConstants.ChunkHeaderSize <= containerEnd)
{
AniRiffChunkHeader chunk = this.ReadChunkHeader(stream);
long dataEnd = GetChunkDataEnd(stream, chunk.Size, containerEnd);
switch ((AniChunkType)chunk.FourCc)
{
case AniChunkType.Header:
this.ReadAniHeader(stream, chunk.Size);
headerFound = true;
break;
case AniChunkType.Sequence:
// Ordering and timing affect presentation, not pixel decoding, so malformed chunks follow ancillary handling.
this.ExecuteAncillarySegmentAction(() => this.ReadUInt32Values(stream, chunk.Size, "sequence", ref this.sequence));
break;
case AniChunkType.Rate:
this.ExecuteAncillarySegmentAction(() => this.ReadUInt32Values(stream, chunk.Size, "rate", ref this.rates));
break;
case AniChunkType.List:
this.ReadList(stream, dataEnd);
break;
}
stream.Position = GetPaddedEnd(dataEnd, chunk.Size, containerEnd);
}
if (!headerFound)
{
throw new InvalidImageContentException("The ANI file does not contain an animation header.");
}
}
/// <summary>
/// Parses the mandatory 36-byte ANI header and copies its observable values to image metadata.
/// </summary>
/// <param name="stream">The ANI stream.</param>
/// <param name="chunkSize">The ANI header chunk size.</param>
private void ReadAniHeader(BufferedReadStream stream, uint chunkSize)
{
if (chunkSize < AniHeader.Size)
{
throw new InvalidImageContentException("The ANI animation header is truncated.");
}
Span<byte> data = this.buffer;
ReadExactly(stream, data, "ANI header");
this.header = AniHeader.Parse(data);
if (this.header.BytesInHeader < AniHeader.Size || this.header.BytesInHeader > chunkSize)
{
throw new InvalidImageContentException("The ANI animation header declares an invalid size.");
}
this.aniMetadata.Width = this.header.Width;
this.aniMetadata.Height = this.header.Height;
this.aniMetadata.BitCount = this.header.BitCount;
this.aniMetadata.Planes = this.header.Planes;
this.aniMetadata.DisplayRate = this.header.DisplayRate;
this.aniMetadata.Flags = this.header.Flags;
}
/// <summary>
/// Reads a RIFF list type and records or parses its contents.
/// </summary>
/// <param name="stream">The ANI stream.</param>
/// <param name="listEnd">The exclusive end of the list payload.</param>
private void ReadList(BufferedReadStream stream, long listEnd)
{
if (listEnd - stream.Position < sizeof(uint))
{
throw new InvalidImageContentException("The ANI file contains a truncated RIFF list.");
}
Span<byte> typeData = this.buffer[..sizeof(uint)];
ReadExactly(stream, typeData, "RIFF list type");
AniListType type = (AniListType)BinaryPrimitives.ReadUInt32LittleEndian(typeData);
switch (type)
{
case AniListType.Frames:
// Defer nested decoding until the complete container has supplied any later seq/rate chunks.
this.frameLists.Add((stream.Position, listEnd));
break;
case AniListType.Info when !this.Options.SkipMetadata:
this.ExecuteAncillarySegmentAction(() => this.ReadInfoList(stream, listEnd));
break;
}
}
/// <summary>
/// Parses the optional ANI name and artist information.
/// </summary>
/// <param name="stream">The ANI stream.</param>
/// <param name="listEnd">The exclusive end of the information list.</param>
private void ReadInfoList(BufferedReadStream stream, long listEnd)
{
// INAM and IART are consumed sequentially, so one grow-only buffer covers every text chunk in the list.
IMemoryOwner<byte>? textOwner = null;
try
{
while (stream.Position + AniConstants.ChunkHeaderSize <= listEnd)
{
AniRiffChunkHeader chunk = this.ReadChunkHeader(stream);
long dataEnd = GetChunkDataEnd(stream, chunk.Size, listEnd);
switch ((AniInfoChunkType)chunk.FourCc)
{
case AniInfoChunkType.Name:
if (this.TryReadText(stream, chunk.Size, ref textOwner, out string? name))
{
this.aniMetadata.Name = name;
}
break;
case AniInfoChunkType.Artist:
if (this.TryReadText(stream, chunk.Size, ref textOwner, out string? artist))
{
this.aniMetadata.Artist = artist;
}
break;
}
stream.Position = GetPaddedEnd(dataEnd, chunk.Size, listEnd);
}
}
finally
{
textOwner?.Dispose();
}
}
/// <summary>
/// Reads a sequence or rate chunk into reusable allocator-owned memory.
/// </summary>
/// <param name="stream">The ANI stream.</param>
/// <param name="chunkSize">The chunk payload size.</param>
/// <param name="description">The chunk description used in error messages.</param>
/// <param name="owner">The buffer to reuse or replace.</param>
private void ReadUInt32Values(BufferedReadStream stream, uint chunkSize, string description, ref IMemoryOwner<uint>? owner)
{
// seq and rate payloads are DWORD arrays; trailing bytes cannot form a valid entry.
if (chunkSize % sizeof(uint) is not 0)
{
this.ThrowOrIgnoreNonStrictSegmentError($"The ANI {description} chunk has an invalid size.");
return;
}
// MaxFrames controls retained animation steps, but its default is intentionally unbounded. Apply a separate
// byte limit before allocation so an oversized control chunk follows ancillary integrity handling.
if (chunkSize > AniConstants.MaxAncillaryChunkSize)
{
this.ThrowOrIgnoreNonStrictSegmentError($"The ANI {description} chunk is too large.");
return;
}
int count = (int)Math.Min(chunkSize / sizeof(uint), this.Options.MaxFrames);
if (count is 0)
{
this.ThrowOrIgnoreNonStrictSegmentError($"The ANI {description} chunk does not contain any values.");
return;
}
IMemoryOwner<uint> valuesOwner;
bool replaceOwner;
// Duplicate chunks can overwrite an equal-sized allocation. A different size uses a replacement so a failed read
// leaves the last valid chunk available to non-strict decoding.
if (owner is not null && owner.GetSpan().Length == count)
{
valuesOwner = owner;
replaceOwner = false;
}
else
{
valuesOwner = this.Options.Configuration.MemoryAllocator.Allocate<uint>(count);
replaceOwner = true;
}
bool success = false;
// A newly allocated replacement is not published until the entire payload has been read and normalized.
try
{
Span<uint> values = valuesOwner.GetSpan()[..count];
Span<byte> data = MemoryMarshal.AsBytes(values);
if (stream.Read(data) != data.Length)
{
this.ThrowOrIgnoreNonStrictSegmentError($"Not enough bytes to read the ANI {description} chunk.");
return;
}
if (!BitConverter.IsLittleEndian)
{
// RIFF integers are always little-endian; normalize once here so hot step loops use native uint indexing.
for (int i = 0; i < values.Length; i++)
{
values[i] = BinaryPrimitives.ReverseEndianness(values[i]);
}
}
success = true;
}
finally
{
if (!success && replaceOwner)
{
valuesOwner.Dispose();
}
}
if (replaceOwner)
{
owner?.Dispose();
owner = valuesOwner;
}
}
/// <inheritdoc/>
public void Dispose()
{
this.sequence?.Dispose();
this.rates?.Dispose();
this.sequence = null;
this.rates = null;
}
/// <summary>
/// Tries to read a null-terminated ANI information string.
/// </summary>
/// <param name="stream">The ANI stream.</param>
/// <param name="chunkSize">The text chunk payload size.</param>
/// <param name="owner">The reusable text buffer.</param>
/// <param name="value">The decoded ASCII text when successful.</param>
/// <returns><see langword="true"/> when the text was read successfully; otherwise, <see langword="false"/>.</returns>
private bool TryReadText(BufferedReadStream stream, uint chunkSize, ref IMemoryOwner<byte>? owner, out string? value)
{
value = null;
// INFO text is optional metadata. Reject or skip oversized values before renting their backing buffer.
if (chunkSize > AniConstants.MaxAncillaryChunkSize)
{
this.ThrowOrIgnoreNonStrictSegmentError("The ANI information text chunk is too large.");
return false;
}
int length = (int)chunkSize;
// Retain the largest text buffer encountered because INFO values are decoded one at a time.
if (owner is null || owner.GetSpan().Length < length)
{
owner?.Dispose();
owner = this.Options.Configuration.MemoryAllocator.Allocate<byte>(length);
}
Span<byte> data = owner.GetSpan()[..length];
if (stream.Read(data) != data.Length)
{
this.ThrowOrIgnoreNonStrictSegmentError("Not enough bytes to read the ANI information text.");
return false;
}
// RIFF text is null-terminated, but the declared chunk may include bytes after the first terminator.
int terminator = data.IndexOf((byte)0);
value = Encoding.ASCII.GetString(terminator < 0 ? data : data[..terminator]);
return true;
}
/// <summary>
/// Processes each embedded frame-resource chunk without allowing its decoder to read adjacent RIFF data.
/// </summary>
/// <typeparam name="T">The parsed resource type.</typeparam>
/// <param name="stream">The ANI stream.</param>
/// <param name="resources">The destination resource slots.</param>
/// <param name="action">The operation to perform for each resource format and bounded stream.</param>
private void ProcessFrameChunks<T>(BufferedReadStream stream, List<(AniFrameFormat Format, T Resource)?> resources, Func<AniFrameFormat, Stream, T> action)
where T : class
{
// Child decoding is synchronous, so one bounded stream object can be repositioned for every physical resource.
AniFrameStream frameStream = new(stream);
ReadOnlySpan<uint> sequence = this.sequence is null ? [] : this.sequence.GetSpan();
bool hasSequence = this.sequence is not null;
int decodedResourceCount = 0;
int maxDecodedResources = (int)this.Options.MaxFrames;
IMemoryOwner<uint>? sortedSequenceOwner = null;
try
{
ReadOnlySpan<uint> requiredResources = sequence;
if (hasSequence)
{
bool isSorted = true;
for (int i = 1; i < sequence.Length; i++)
{
if (sequence[i] < sequence[i - 1])
{
isSorted = false;
break;
}
}
if (!isSorted)
{
// Playback order can reference resources arbitrarily. A sorted allocator-owned copy turns the physical
// resource scan into a linear merge instead of searching the complete sequence for every icon chunk.
sortedSequenceOwner = this.Options.Configuration.MemoryAllocator.Allocate<uint>(sequence.Length);
Span<uint> sortedSequence = sortedSequenceOwner.GetSpan();
sequence.CopyTo(sortedSequence);
sortedSequence.Sort();
requiredResources = sortedSequence;
}
}
int requiredResourceIndex = 0;
uint lastRequiredResource = hasSequence ? requiredResources[^1] : 0;
foreach ((long start, long end) in this.frameLists)
{
stream.Position = start;
while (stream.Position + AniConstants.ChunkHeaderSize <= end)
{
AniRiffChunkHeader chunk = this.ReadChunkHeader(stream);
long dataStart = stream.Position;
long dataEnd = GetChunkDataEnd(stream, chunk.Size, end);
if ((AniFrameChunkType)chunk.FourCc is AniFrameChunkType.Icon)
{
int resourceIndex = resources.Count;
// Sequence entries index the physical resource table, so ignored corrupt resources retain an empty slot.
resources.Add(null);
if (hasSequence)
{
while (requiredResourceIndex < requiredResources.Length && requiredResources[requiredResourceIndex] < (uint)resourceIndex)
{
requiredResourceIndex++;
}
}
// Unsequenced resources are consumed in physical order; sequenced files need only the referenced indices.
bool shouldDecode = !hasSequence
|| (requiredResourceIndex < requiredResources.Length && requiredResources[requiredResourceIndex] == (uint)resourceIndex);
if (shouldDecode)
{
this.ExecuteImageDataSegmentAction(() =>
{
// Child decoders may seek according to embedded offsets; the bounded view prevents crossing the icon chunk.
frameStream.Reset(dataStart, chunk.Size);
AniFrameFormat format = this.GetFrameFormat(frameStream);
// Format probing consumes the directory prefix, while the selected child decoder requires the complete resource.
frameStream.Position = 0;
resources[resourceIndex] = (format, action(format, frameStream));
});
if (resources[resourceIndex] is not null)
{
decodedResourceCount++;
}
}
// Every decoded resource contributes at least one output frame, while a sequence cannot reference later indices.
if ((!hasSequence && decodedResourceCount == maxDecodedResources)
|| (hasSequence && (uint)resourceIndex == lastRequiredResource))
{
return;
}
}
stream.Position = GetPaddedEnd(dataEnd, chunk.Size, end);
}
}
}
finally
{
sortedSequenceOwner?.Dispose();
}
}
/// <summary>
/// Determines the embedded resource format from the ANI header and ICO/CUR directory prefix.
/// </summary>
/// <param name="stream">The bounded frame-resource stream.</param>
/// <returns>The embedded resource format.</returns>
private AniFrameFormat GetFrameFormat(Stream stream)
{
// Without AF_ICON, the icon chunk payload is a raw DIB and has no ICO/CUR directory prefix to inspect.
if (!this.header.Flags.HasFlag(AniHeaderFlags.IsIcon))
{
return AniFrameFormat.Bmp;
}
Span<byte> iconHeader = this.buffer[..AniConstants.IconDirHeaderSize];
if (stream.Read(iconHeader) != iconHeader.Length)
{
throw new InvalidImageContentException("The ANI file contains a truncated ICO or CUR resource.");
}
IconFileType type = (IconFileType)BinaryPrimitives.ReadUInt16LittleEndian(iconHeader[2..]);
return type switch
{
IconFileType.ICO => AniFrameFormat.Ico,
IconFileType.CUR => AniFrameFormat.Cur,
_ => throw new InvalidImageContentException("The ANI file contains an unsupported icon resource.")
};
}
/// <summary>
/// Decodes one embedded ANI frame resource.
/// </summary>
/// <typeparam name="TPixel">The destination pixel type.</typeparam>
/// <param name="format">The embedded resource format.</param>
/// <param name="options">The nested decoder options.</param>
/// <param name="stream">The bounded resource stream.</param>
/// <param name="cancellationToken">The token to monitor for cancellation requests.</param>
/// <returns>The decoded resource.</returns>
private static Image<TPixel> DecodeFrame<TPixel>(AniFrameFormat format, DecoderOptions options, Stream stream, CancellationToken cancellationToken)
where TPixel : unmanaged, IPixel<TPixel>
=> format switch
{
AniFrameFormat.Ico => new IcoDecoderCore(options).Decode<TPixel>(options.Configuration, stream, cancellationToken),
AniFrameFormat.Cur => new CurDecoderCore(options).Decode<TPixel>(options.Configuration, stream, cancellationToken),
AniFrameFormat.Bmp => new BmpDecoderCore(new BmpDecoderOptions
{
GeneralOptions = options,
SkipFileHeader = true
}).Decode<TPixel>(options.Configuration, stream, cancellationToken),
_ => throw new InvalidImageContentException("The ANI file contains an unsupported frame format.")
};
/// <summary>
/// Identifies one embedded ANI frame resource.
/// </summary>
/// <param name="format">The embedded resource format.</param>
/// <param name="options">The nested decoder options.</param>
/// <param name="stream">The bounded resource stream.</param>
/// <param name="cancellationToken">The token to monitor for cancellation requests.</param>
/// <returns>The identified resource.</returns>
private static ImageInfo IdentifyFrame(AniFrameFormat format, DecoderOptions options, Stream stream, CancellationToken cancellationToken)
=> format switch
{
AniFrameFormat.Ico => new IcoDecoderCore(options).Identify(options.Configuration, stream, cancellationToken),
AniFrameFormat.Cur => new CurDecoderCore(options).Identify(options.Configuration, stream, cancellationToken),
AniFrameFormat.Bmp => new BmpDecoderCore(new BmpDecoderOptions
{
GeneralOptions = options,
SkipFileHeader = true
}).Identify(options.Configuration, stream, cancellationToken),
_ => throw new InvalidImageContentException("The ANI file contains an unsupported frame format.")
};
/// <summary>
/// Creates ANI metadata for one flattened output frame.
/// </summary>
/// <param name="source">The embedded frame metadata, when available.</param>
/// <param name="format">The embedded resource format.</param>
/// <param name="sequenceNumber">The animation sequence number.</param>
/// <param name="frameDelay">The display rate in sixtieths of a second.</param>
/// <param name="size">The embedded frame size.</param>
/// <returns>The ANI frame metadata.</returns>
private static AniFrameMetadata CreateFrameMetadata(ImageFrameMetadata? source, AniFrameFormat format, int sequenceNumber, uint frameDelay, Size size)
{
AniFrameMetadata metadata = new()
{
FrameDelay = frameDelay,
SequenceNumber = sequenceNumber,
FrameFormat = format
};
if (source is null)
{
metadata.EncodingWidth = NarrowDimension(size.Width);
metadata.EncodingHeight = NarrowDimension(size.Height);
return metadata;
}
// ColorTable is managed read-only memory and remains valid after the temporary child image is disposed,
// so the flattened metadata can retain the same view without cloning its backing array.
switch (format)
{
case AniFrameFormat.Ico:
IcoFrameMetadata icoMetadata = source.GetIcoMetadata();
metadata.EncodingWidth = icoMetadata.EncodingWidth;
metadata.EncodingHeight = icoMetadata.EncodingHeight;
metadata.Compression = icoMetadata.Compression;
metadata.BmpBitsPerPixel = icoMetadata.BmpBitsPerPixel;
metadata.ColorTable = icoMetadata.ColorTable;
break;
case AniFrameFormat.Cur:
CurFrameMetadata curMetadata = source.GetCurMetadata();
metadata.EncodingWidth = curMetadata.EncodingWidth;
metadata.EncodingHeight = curMetadata.EncodingHeight;
metadata.Compression = curMetadata.Compression;
metadata.BmpBitsPerPixel = curMetadata.BmpBitsPerPixel;
metadata.HotspotX = curMetadata.HotspotX;
metadata.HotspotY = curMetadata.HotspotY;
metadata.ColorTable = curMetadata.ColorTable;
break;
case AniFrameFormat.Bmp:
metadata.EncodingWidth = NarrowDimension(size.Width);
metadata.EncodingHeight = NarrowDimension(size.Height);
break;
}
return metadata;
}
/// <summary>
/// Creates decoder options for embedded resources without applying the outer ANI resize twice.
/// </summary>
/// <returns>The embedded frame decoder options.</returns>
private DecoderOptions CreateFrameDecoderOptions()
=> new()
{
Configuration = this.Options.Configuration,
MaxFrames = this.Options.MaxFrames,
SkipMetadata = this.Options.SkipMetadata,
SegmentIntegrityHandling = this.Options.SegmentIntegrityHandling,
ColorProfileHandling = this.Options.ColorProfileHandling
};
/// <summary>
/// Reads one fixed-size RIFF chunk header.
/// </summary>
/// <param name="stream">The ANI stream.</param>
/// <returns>The parsed chunk header.</returns>
private AniRiffChunkHeader ReadChunkHeader(BufferedReadStream stream)
{
Span<byte> data = this.buffer[..AniConstants.ChunkHeaderSize];
ReadExactly(stream, data, "RIFF chunk header");
return AniRiffChunkHeader.Parse(data);
}
/// <summary>
/// Calculates and validates the exclusive end of a RIFF chunk payload.
/// </summary>
/// <param name="stream">The ANI stream.</param>
/// <param name="size">The declared payload size.</param>
/// <param name="containerEnd">The exclusive parent-container boundary.</param>
/// <returns>The exclusive payload boundary.</returns>
private static long GetChunkDataEnd(BufferedReadStream stream, uint size, long containerEnd)
{
long end = checked(stream.Position + size);
if (end > containerEnd)
{
throw new InvalidImageContentException("An ANI RIFF chunk extends beyond its containing list.");
}
return end;
}
/// <summary>
/// Calculates and validates the word-aligned end of a RIFF chunk.
/// </summary>
/// <param name="dataEnd">The exclusive payload boundary.</param>
/// <param name="size">The declared payload size.</param>
/// <param name="containerEnd">The exclusive parent-container boundary.</param>
/// <returns>The exclusive padded chunk boundary.</returns>
private static long GetPaddedEnd(long dataEnd, uint size, long containerEnd)
{
// RIFF aligns each chunk to a 16-bit boundary without including the optional pad byte in the declared size.
long paddedEnd = dataEnd + (size & 1);
if (paddedEnd > containerEnd)
{
throw new InvalidImageContentException("An ANI RIFF chunk is missing its alignment padding.");
}
return paddedEnd;
}
/// <summary>
/// Reads an exact number of bytes or reports a truncated ANI file.
/// </summary>
/// <param name="stream">The ANI stream.</param>
/// <param name="destination">The destination buffer.</param>
/// <param name="description">The data description used in the error message.</param>
private static void ReadExactly(BufferedReadStream stream, Span<byte> destination, string description)
{
if (stream.Read(destination) != destination.Length)
{
throw new InvalidImageContentException($"Not enough bytes to read the {description}.");
}
}
/// <summary>
/// Converts a pixel dimension to the one-byte ICO/CUR representation.
/// </summary>
/// <param name="value">The pixel dimension.</param>
/// <returns>The encoded dimension, where zero represents 256 pixels or greater.</returns>
private static byte NarrowDimension(int value) => value > byte.MaxValue ? (byte)0 : (byte)value;
}

24
src/ImageSharp/Formats/Ani/AniEncoder.cs

@ -0,0 +1,24 @@
// Copyright (c) Six Labors.
// Licensed under the Six Labors Split License.
namespace SixLabors.ImageSharp.Formats.Ani;
/// <summary>
/// Encodes images as Windows animated cursors.
/// </summary>
public sealed class AniEncoder : QuantizingImageEncoder
{
/// <summary>
/// Initializes a new instance of the <see cref="AniEncoder"/> class.
/// </summary>
public AniEncoder()
{
}
/// <inheritdoc/>
protected override void Encode<TPixel>(Image<TPixel> image, Stream stream, CancellationToken cancellationToken)
{
AniEncoderCore encoder = new(this);
encoder.Encode(image, stream, cancellationToken);
}
}

514
src/ImageSharp/Formats/Ani/AniEncoderCore.cs

@ -0,0 +1,514 @@
// Copyright (c) Six Labors.
// Licensed under the Six Labors Split License.
using System.Buffers;
using System.Buffers.Binary;
using System.Text;
using SixLabors.ImageSharp.Formats.Bmp;
using SixLabors.ImageSharp.Formats.Cur;
using SixLabors.ImageSharp.Formats.Ico;
using SixLabors.ImageSharp.Formats.Icon;
using SixLabors.ImageSharp.Memory;
using SixLabors.ImageSharp.PixelFormats;
namespace SixLabors.ImageSharp.Formats.Ani;
/// <summary>
/// Performs ANI encoding.
/// </summary>
internal sealed class AniEncoderCore
{
private readonly AniEncoder encoder;
// Each nested encoder is configured once and reused for every resource of that type in this ANI operation.
private IcoEncoderCore? icoEncoder;
private CurEncoderCore? curEncoder;
private BmpEncoderCore? bmpEncoder;
/// <summary>
/// Reusable storage for the fixed ANI header and smaller RIFF values.
/// </summary>
private InlineArray36<byte> buffer;
/// <summary>
/// Initializes a new instance of the <see cref="AniEncoderCore"/> class.
/// </summary>
/// <param name="encoder">The encoder options.</param>
public AniEncoderCore(AniEncoder encoder)
=> this.encoder = encoder;
/// <summary>
/// Encodes an image as ANI data.
/// </summary>
/// <typeparam name="TPixel">The source pixel type.</typeparam>
/// <param name="image">The source image.</param>
/// <param name="stream">The destination stream.</param>
/// <param name="cancellationToken">The token to monitor for cancellation requests.</param>
public void Encode<TPixel>(Image<TPixel> image, Stream stream, CancellationToken cancellationToken)
where TPixel : unmanaged, IPixel<TPixel>
{
Guard.NotNull(image, nameof(image));
Guard.NotNull(stream, nameof(stream));
AniMetadata imageMetadata = image.Metadata.GetAniMetadata();
AniFrameMetadata firstMetadata = image.Frames.RootFrame.Metadata.GetAniMetadata();
AniFrameFormat firstFormat = firstMetadata.FrameFormat;
bool bitmapResources = firstFormat is AniFrameFormat.Bmp;
bool writeSequence = imageMetadata.Flags.HasFlag(AniHeaderFlags.ContainsSequence);
uint displayRate = firstMetadata.FrameDelay is 0 ? imageMetadata.DisplayRate : firstMetadata.FrameDelay;
bool hasVariableRates = false;
int groupCount = 0;
int maxGroupSize = 1;
if (bitmapResources && imageMetadata.BitCount is not (0 or 1 or 2 or 4 or 8 or 16 or 24 or 32))
{
throw new ImageFormatException("ANI bitmap resources require a supported bit depth.");
}
if (bitmapResources && imageMetadata.Planes is not (0 or 1))
{
throw new ImageFormatException("ANI bitmap resources require exactly one color plane.");
}
// This validation pass derives the fixed ANI header and largest icon directory without allocating a grouping graph.
// Encoding repeats the linear grouping scan below, trading a cheap pass for zero per-group collections.
for (int frameIndex = 0; frameIndex < image.Frames.Count;)
{
AniFrameMetadata metadata = image.Frames[frameIndex].Metadata.GetAniMetadata();
int groupSize = 1;
if (metadata.FrameFormat is not (AniFrameFormat.Ico or AniFrameFormat.Cur or AniFrameFormat.Bmp))
{
// FrameFormat is public metadata and therefore must be validated before any container bytes are written.
throw new ImageFormatException("ANI contains an unsupported embedded frame format.");
}
// Positive sequence numbers group adjacent resolution variants; non-positive values form independent steps.
if (metadata.SequenceNumber > 0)
{
while (frameIndex + groupSize < image.Frames.Count && image.Frames[frameIndex + groupSize].Metadata.GetAniMetadata().SequenceNumber == metadata.SequenceNumber)
{
groupSize++;
}
}
if (bitmapResources != (metadata.FrameFormat is AniFrameFormat.Bmp))
{
// AF_ICON applies to the complete file, so raw DIB resources cannot coexist with ICO/CUR resources.
throw new ImageFormatException("ANI cannot mix bitmap resources with ICO or CUR resources.");
}
if (bitmapResources && groupSize > 1)
{
// Only ICO/CUR directories can contain multiple resolution variants in one physical resource.
throw new ImageFormatException("ANI bitmap resources cannot contain resolution variants.");
}
// All variants share one animation step, which requires one child format and one rate value.
for (int i = 1; i < groupSize; i++)
{
AniFrameMetadata current = image.Frames[frameIndex + i].Metadata.GetAniMetadata();
if (current.FrameFormat != metadata.FrameFormat)
{
throw new ImageFormatException("ANI resolution variants must use the same embedded format.");
}
if (current.FrameDelay != metadata.FrameDelay)
{
throw new ImageFormatException("ANI resolution variants must use the same frame delay.");
}
}
uint frameDelay = metadata.FrameDelay is 0 ? displayRate : metadata.FrameDelay;
hasVariableRates |= frameDelay != displayRate;
maxGroupSize = Math.Max(maxGroupSize, groupSize);
groupCount++;
frameIndex += groupSize;
}
// Icon-based ANI files leave global geometry and pixel layout at zero because each ICO/CUR entry owns those values.
AniHeader header = new()
{
BytesInHeader = AniHeader.Size,
FrameCount = (uint)groupCount,
StepCount = (uint)groupCount,
Width = bitmapResources ? imageMetadata.Width is 0 ? (uint)image.Width : imageMetadata.Width : 0,
Height = bitmapResources ? imageMetadata.Height is 0 ? (uint)image.Height : imageMetadata.Height : 0,
BitCount = bitmapResources ? imageMetadata.BitCount is 0 ? 32U : imageMetadata.BitCount : 0,
Planes = bitmapResources ? 1U : 0,
DisplayRate = displayRate,
Flags = (bitmapResources ? 0 : AniHeaderFlags.IsIcon) | (writeSequence ? AniHeaderFlags.ContainsSequence : 0)
};
// One allocator-owned directory buffer is sliced and reused for every icon resource; its capacity is the largest group.
using IMemoryOwner<IconEncoderCore.EncodingFrameMetadata>? iconEntriesOwner = bitmapResources ? null : image.Configuration.MemoryAllocator.Allocate<IconEncoderCore.EncodingFrameMetadata>(maxGroupSize);
Span<IconEncoderCore.EncodingFrameMetadata> iconEntries = iconEntriesOwner is null ? [] : iconEntriesOwner.GetSpan();
// ImageEncoder guarantees a seekable destination, allowing direct nested encoding and RIFF size backpatching.
long riffSizePosition = this.BeginContainer(stream, AniConstants.RiffFourCc, AniConstants.AniFormTypeFourCc);
this.WriteHeader(stream, header);
if (writeSequence)
{
this.WriteSequence(stream, groupCount);
}
if (hasVariableRates)
{
this.WriteRates(stream, image, displayRate);
}
if (!this.encoder.SkipMetadata && (imageMetadata.Name is not null || imageMetadata.Artist is not null))
{
this.WriteInfoList(stream, imageMetadata, image.Configuration.MemoryAllocator);
}
long frameListSizePosition = this.BeginContainer(stream, "LIST"u8, "fram"u8);
// Repeat the allocation-free adjacent grouping scan used by the validation pass.
for (int frameIndex = 0; frameIndex < image.Frames.Count;)
{
cancellationToken.ThrowIfCancellationRequested();
AniFrameMetadata metadata = image.Frames[frameIndex].Metadata.GetAniMetadata();
int groupSize = 1;
if (metadata.SequenceNumber > 0)
{
while (frameIndex + groupSize < image.Frames.Count && image.Frames[frameIndex + groupSize].Metadata.GetAniMetadata().SequenceNumber == metadata.SequenceNumber)
{
groupSize++;
}
}
long frameSizePosition = this.BeginChunk(stream, "icon"u8);
this.WriteFrameResource(image, stream, frameIndex, groupSize, metadata.FrameFormat, header.BitCount, iconEntries, cancellationToken);
this.EndChunk(stream, frameSizePosition);
frameIndex += groupSize;
}
this.EndChunk(stream, frameListSizePosition);
this.EndChunk(stream, riffSizePosition);
}
/// <summary>
/// Writes the fixed-size ANI animation header chunk.
/// </summary>
/// <param name="stream">The destination stream.</param>
/// <param name="header">The animation header.</param>
private void WriteHeader(Stream stream, AniHeader header)
{
long sizePosition = this.BeginChunk(stream, "anih"u8);
Span<byte> data = this.buffer;
header.WriteTo(data);
stream.Write(data);
this.EndChunk(stream, sizePosition);
}
/// <summary>
/// Writes an identity sequence table when the source metadata declares an explicit sequence.
/// </summary>
/// <param name="stream">The destination stream.</param>
/// <param name="stepCount">The number of animation steps.</param>
private void WriteSequence(Stream stream, int stepCount)
{
long sizePosition = this.BeginChunk(stream, "seq "u8);
Span<byte> value = this.buffer[..sizeof(uint)];
// Decoding expands source resource references into presentation order. Encoding writes those expanded steps as
// distinct resources, so an identity table preserves the explicit-sequence flag without changing playback.
for (uint i = 0; i < stepCount; i++)
{
BinaryPrimitives.WriteUInt32LittleEndian(value, i);
stream.Write(value);
}
this.EndChunk(stream, sizePosition);
}
/// <summary>
/// Writes per-step rates when they cannot be represented by one header value.
/// </summary>
/// <param name="stream">The destination stream.</param>
/// <param name="image">The source image.</param>
/// <param name="displayRate">The default header display rate.</param>
private void WriteRates(Stream stream, Image image, uint displayRate)
{
long sizePosition = this.BeginChunk(stream, "rate"u8);
Span<byte> value = this.buffer[..sizeof(uint)];
// The rate table contains one DWORD per animation step, not one value per resolution variant.
for (int frameIndex = 0; frameIndex < image.Frames.Count;)
{
AniFrameMetadata metadata = image.Frames[frameIndex].Metadata.GetAniMetadata();
uint frameDelay = metadata.FrameDelay;
BinaryPrimitives.WriteUInt32LittleEndian(value, frameDelay is 0 ? displayRate : frameDelay);
stream.Write(value);
frameIndex++;
if (metadata.SequenceNumber > 0)
{
while (frameIndex < image.Frames.Count && image.Frames[frameIndex].Metadata.GetAniMetadata().SequenceNumber == metadata.SequenceNumber)
{
frameIndex++;
}
}
}
this.EndChunk(stream, sizePosition);
}
/// <summary>
/// Writes the optional ANI name and artist list.
/// </summary>
/// <param name="stream">The destination stream.</param>
/// <param name="metadata">The ANI image metadata.</param>
/// <param name="memoryAllocator">The allocator used for the text buffer.</param>
private void WriteInfoList(Stream stream, AniMetadata metadata, MemoryAllocator memoryAllocator)
{
long sizePosition = this.BeginContainer(stream, "LIST"u8, "INFO"u8);
int nameLength = metadata.Name is null ? 0 : Encoding.ASCII.GetByteCount(metadata.Name);
int artistLength = metadata.Artist is null ? 0 : Encoding.ASCII.GetByteCount(metadata.Artist);
// Name and artist are emitted sequentially, so a single buffer sized for the larger value avoids a second allocation.
using IMemoryOwner<byte> owner = memoryAllocator.Allocate<byte>(Math.Max(nameLength, artistLength));
Span<byte> buffer = owner.GetSpan();
if (metadata.Name is not null)
{
this.WriteTextChunk(stream, "INAM"u8, metadata.Name, buffer);
}
if (metadata.Artist is not null)
{
this.WriteTextChunk(stream, "IART"u8, metadata.Artist, buffer);
}
this.EndChunk(stream, sizePosition);
}
/// <summary>
/// Writes a null-terminated ASCII RIFF information chunk.
/// </summary>
/// <param name="stream">The destination stream.</param>
/// <param name="fourCc">The chunk identifier.</param>
/// <param name="value">The text value.</param>
/// <param name="buffer">The reusable text buffer.</param>
private void WriteTextChunk(Stream stream, ReadOnlySpan<byte> fourCc, string value, Span<byte> buffer)
{
long sizePosition = this.BeginChunk(stream, fourCc);
int written = Encoding.ASCII.GetBytes(value, buffer);
stream.Write(buffer[..written]);
// The terminating zero belongs to the RIFF text payload and is therefore included in the backpatched chunk size.
stream.WriteByte(0);
this.EndChunk(stream, sizePosition);
}
/// <summary>
/// Encodes one ANI frame resource using the existing ICO, CUR, or BMP encoder.
/// </summary>
/// <typeparam name="TPixel">The source pixel type.</typeparam>
/// <param name="image">The source image.</param>
/// <param name="stream">The destination stream.</param>
/// <param name="frameIndex">The first source-frame index.</param>
/// <param name="frameCount">The number of source frames in this resource.</param>
/// <param name="format">The embedded resource format.</param>
/// <param name="bitCount">The bitmap bit depth declared by the ANI header.</param>
/// <param name="iconEntries">The reusable icon directory metadata buffer.</param>
/// <param name="cancellationToken">The token to monitor for cancellation requests.</param>
private void WriteFrameResource<TPixel>(Image<TPixel> image, Stream stream, int frameIndex, int frameCount, AniFrameFormat format, uint bitCount, Span<IconEncoderCore.EncodingFrameMetadata> iconEntries, CancellationToken cancellationToken)
where TPixel : unmanaged, IPixel<TPixel>
{
switch (format)
{
case AniFrameFormat.Ico:
case AniFrameFormat.Cur:
// Only the active prefix is exposed to the child encoder; the same backing allocation serves later resources.
Span<IconEncoderCore.EncodingFrameMetadata> entries = iconEntries[..frameCount];
AniIconFrameMetadataProvider provider = new(format);
if (format is AniFrameFormat.Ico)
{
this.icoEncoder ??= new IcoEncoderCore(new IcoEncoder
{
PixelSamplingStrategy = this.encoder.PixelSamplingStrategy,
Quantizer = this.encoder.Quantizer,
SkipMetadata = this.encoder.SkipMetadata,
TransparentColorMode = this.encoder.TransparentColorMode
});
this.icoEncoder.Encode(image, stream, frameIndex, entries, provider, cancellationToken);
}
else
{
this.curEncoder ??= new CurEncoderCore(new CurEncoder
{
PixelSamplingStrategy = this.encoder.PixelSamplingStrategy,
Quantizer = this.encoder.Quantizer,
SkipMetadata = this.encoder.SkipMetadata,
TransparentColorMode = this.encoder.TransparentColorMode
});
this.curEncoder.Encode(image, stream, frameIndex, entries, provider, cancellationToken);
}
break;
case AniFrameFormat.Bmp:
if (this.bmpEncoder is null)
{
BmpEncoder bmpEncoder = new()
{
BitsPerPixel = GetBmpBitsPerPixel(bitCount),
PixelSamplingStrategy = this.encoder.PixelSamplingStrategy,
Quantizer = this.encoder.Quantizer,
SkipFileHeader = true,
SkipMetadata = this.encoder.SkipMetadata,
SupportTransparency = bitCount is 32,
TransparentColorMode = this.encoder.TransparentColorMode
};
this.bmpEncoder = new BmpEncoderCore(bmpEncoder, image.Configuration.MemoryAllocator);
}
// The frame overload writes the raw DIB directly and avoids constructing a temporary single-frame Image.
this.bmpEncoder.Encode(image.Frames[frameIndex], image.Metadata, stream, cancellationToken);
break;
}
}
/// <summary>
/// Creates an icon directory entry from ANI-owned frame metadata.
/// </summary>
/// <param name="metadata">The ANI frame metadata.</param>
/// <param name="format">The embedded icon format.</param>
/// <param name="size">The source frame size.</param>
/// <returns>The icon directory entry.</returns>
private static IconDirEntry CreateIconDirEntry(AniFrameMetadata metadata, AniFrameFormat format, Size size)
{
// PNG and direct-color bitmap entries do not declare a palette; indexed bitmap entries advertise their color count.
byte colorCount = metadata.Compression is IconFrameCompression.Png || metadata.BmpBitsPerPixel > BmpBitsPerPixel.Bit8
? (byte)0
: (byte)ColorNumerics.GetColorCountForBitDepth((int)metadata.BmpBitsPerPixel);
// ICO stores planes/BPP in these fields, while CUR reuses the same two words for the hotspot coordinates.
return new IconDirEntry
{
Width = metadata.EncodingWidth ?? NarrowDimension(size.Width),
Height = metadata.EncodingHeight ?? NarrowDimension(size.Height),
ColorCount = colorCount,
Planes = format is AniFrameFormat.Ico ? (ushort)1 : metadata.HotspotX,
BitCount = format is AniFrameFormat.Ico
? metadata.Compression is IconFrameCompression.Bmp ? (ushort)metadata.BmpBitsPerPixel : (ushort)32
: metadata.HotspotY
};
}
/// <summary>
/// Converts an ANI bitmap bit depth to a supported BMP encoder value.
/// </summary>
/// <param name="bitCount">The ANI bit depth.</param>
/// <returns>The BMP encoder bit depth.</returns>
private static BmpBitsPerPixel GetBmpBitsPerPixel(uint bitCount)
=> bitCount switch
{
1 => BmpBitsPerPixel.Bit1,
2 => BmpBitsPerPixel.Bit2,
4 => BmpBitsPerPixel.Bit4,
8 => BmpBitsPerPixel.Bit8,
16 => BmpBitsPerPixel.Bit16,
24 => BmpBitsPerPixel.Bit24,
_ => BmpBitsPerPixel.Bit32
};
/// <summary>
/// Converts a pixel dimension to the one-byte ICO/CUR representation.
/// </summary>
/// <param name="value">The pixel dimension.</param>
/// <returns>The encoded dimension, where zero represents 256 pixels or greater.</returns>
private static byte NarrowDimension(int value) => value > byte.MaxValue ? (byte)0 : (byte)value;
/// <summary>
/// Begins a RIFF chunk whose size will be backpatched after its payload is written.
/// </summary>
/// <param name="stream">The destination stream.</param>
/// <param name="fourCc">The chunk identifier.</param>
/// <returns>The stream position of the chunk-size field.</returns>
private long BeginChunk(Stream stream, ReadOnlySpan<byte> fourCc)
{
stream.Write(fourCc);
long sizePosition = stream.Position;
// Payload length is unknown until nested encoding completes, so reserve the DWORD and remember its absolute position.
Span<byte> size = this.buffer[..sizeof(uint)];
size.Clear();
stream.Write(size);
return sizePosition;
}
/// <summary>
/// Begins a RIFF container chunk and writes its form or list type.
/// </summary>
/// <param name="stream">The destination stream.</param>
/// <param name="fourCc">The container identifier.</param>
/// <param name="type">The container form or list type.</param>
/// <returns>The stream position of the container-size field.</returns>
private long BeginContainer(Stream stream, ReadOnlySpan<byte> fourCc, ReadOnlySpan<byte> type)
{
long sizePosition = this.BeginChunk(stream, fourCc);
stream.Write(type);
return sizePosition;
}
/// <summary>
/// Word-aligns a RIFF chunk and writes its payload size into the reserved field.
/// </summary>
/// <param name="stream">The destination stream.</param>
/// <param name="sizePosition">The stream position of the reserved size field.</param>
private void EndChunk(Stream stream, long sizePosition)
{
long endPosition = stream.Position;
// sizePosition addresses the size DWORD itself; subtracting its four bytes yields payload length.
uint dataSize = checked((uint)(endPosition - sizePosition - sizeof(uint)));
// RIFF chunk sizes exclude the optional padding byte used to align the next chunk to a WORD boundary.
if ((dataSize & 1) is 1)
{
stream.WriteByte(0);
endPosition++;
}
Span<byte> size = this.buffer[..sizeof(uint)];
BinaryPrimitives.WriteUInt32LittleEndian(size, dataSize);
// Backpatch only the reserved DWORD, then restore the append position after any alignment byte.
stream.Position = sizePosition;
stream.Write(size);
stream.Position = endPosition;
}
/// <summary>
/// Projects ANI-owned metadata into the icon encoder without allocating intermediary metadata objects.
/// </summary>
private readonly struct AniIconFrameMetadataProvider : IconEncoderCore.IEncodingFrameMetadataProvider
{
private readonly AniFrameFormat format;
/// <summary>
/// Initializes a new instance of the <see cref="AniIconFrameMetadataProvider"/> struct.
/// </summary>
/// <param name="format">The embedded icon format.</param>
public AniIconFrameMetadataProvider(AniFrameFormat format)
=> this.format = format;
/// <inheritdoc/>
public IconEncoderCore.EncodingFrameMetadata GetEncodingFrameMetadata(ImageFrame frame, out ReadOnlyMemory<Color>? colorTable)
{
AniFrameMetadata metadata = frame.Metadata.GetAniMetadata();
colorTable = metadata.ColorTable;
return new IconEncoderCore.EncodingFrameMetadata(metadata.Compression, metadata.BmpBitsPerPixel, CreateIconDirEntry(metadata, this.format, frame.Size));
}
}
}

40
src/ImageSharp/Formats/Ani/AniFormat.cs

@ -0,0 +1,40 @@
// Copyright (c) Six Labors.
// Licensed under the Six Labors Split License.
namespace SixLabors.ImageSharp.Formats.Ani;
/// <summary>
/// Describes the ANI image format.
/// </summary>
public sealed class AniFormat : IImageFormat<AniMetadata, AniFrameMetadata>
{
/// <summary>
/// Prevents a default instance of the <see cref="AniFormat"/> class from being created.
/// </summary>
private AniFormat()
{
}
/// <summary>
/// Gets the shared instance.
/// </summary>
public static AniFormat Instance { get; } = new();
/// <inheritdoc/>
public string Name => "ANI";
/// <inheritdoc/>
public string DefaultMimeType => "application/x-navi-animation";
/// <inheritdoc/>
public IEnumerable<string> MimeTypes => AniConstants.MimeTypes;
/// <inheritdoc/>
public IEnumerable<string> FileExtensions => AniConstants.FileExtensions;
/// <inheritdoc/>
public AniMetadata CreateDefaultFormatMetadata() => new();
/// <inheritdoc/>
public AniFrameMetadata CreateDefaultFormatFrameMetadata() => new();
}

25
src/ImageSharp/Formats/Ani/AniFrameFormat.cs

@ -0,0 +1,25 @@
// Copyright (c) Six Labors.
// Licensed under the Six Labors Split License.
namespace SixLabors.ImageSharp.Formats.Ani;
/// <summary>
/// Specifies the format of the frame data.
/// </summary>
public enum AniFrameFormat : byte
{
/// <summary>
/// The frame resource is encoded as a Windows cursor.
/// </summary>
Cur,
/// <summary>
/// The frame resource is encoded as a Windows icon.
/// </summary>
Ico,
/// <summary>
/// The frame resource is encoded as a Windows bitmap.
/// </summary>
Bmp
}

248
src/ImageSharp/Formats/Ani/AniFrameMetadata.cs

@ -0,0 +1,248 @@
// Copyright (c) Six Labors.
// Licensed under the Six Labors Split License.
using System.Numerics;
using SixLabors.ImageSharp.Formats.Bmp;
using SixLabors.ImageSharp.Formats.Icon;
using SixLabors.ImageSharp.PixelFormats;
namespace SixLabors.ImageSharp.Formats.Ani;
/// <summary>
/// Provides ANI-specific metadata for an image frame.
/// </summary>
public class AniFrameMetadata : IFormatFrameMetadata<AniFrameMetadata>
{
/// <summary>
/// Initializes a new instance of the <see cref="AniFrameMetadata"/> class.
/// </summary>
public AniFrameMetadata()
{
}
/// <summary>
/// Initializes a new instance of the <see cref="AniFrameMetadata"/> class by copying another instance.
/// </summary>
/// <param name="other">The metadata to copy.</param>
private AniFrameMetadata(AniFrameMetadata other)
{
this.FrameDelay = other.FrameDelay;
this.SequenceNumber = other.SequenceNumber;
this.EncodingWidth = other.EncodingWidth;
this.EncodingHeight = other.EncodingHeight;
this.FrameFormat = other.FrameFormat;
this.Compression = other.Compression;
this.BmpBitsPerPixel = other.BmpBitsPerPixel;
this.HotspotX = other.HotspotX;
this.HotspotY = other.HotspotY;
if (other.ColorTable?.Length > 0)
{
this.ColorTable = other.ColorTable.Value.ToArray();
}
}
/// <summary>
/// Gets or sets the frame display time in sixtieths of a second.
/// </summary>
public uint FrameDelay { get; set; }
/// <summary>
/// Gets or sets the animation sequence number.
/// Adjacent frames with the same positive value are grouped as resolution variants in one ANI frame resource.
/// A non-positive value encodes the frame as its own animation step.
/// </summary>
public int SequenceNumber { get; set; }
/// <summary>
/// Gets or sets the encoded frame width.
/// A value of zero represents 256 pixels or greater in ICO and CUR resources.
/// </summary>
public byte? EncodingWidth { get; set; }
/// <summary>
/// Gets or sets the encoded frame height.
/// A value of zero represents 256 pixels or greater in ICO and CUR resources.
/// </summary>
public byte? EncodingHeight { get; set; }
/// <summary>
/// Gets or sets the format used for this frame resource.
/// </summary>
public AniFrameFormat FrameFormat { get; set; }
/// <summary>
/// Gets or sets the embedded ICO or CUR compression format.
/// </summary>
public IconFrameCompression Compression { get; set; } = IconFrameCompression.Png;
/// <summary>
/// Gets or sets the embedded bitmap bits per pixel.
/// </summary>
public BmpBitsPerPixel BmpBitsPerPixel { get; set; } = BmpBitsPerPixel.Bit32;
/// <summary>
/// Gets or sets the embedded bitmap color table.
/// The underlying pixel format is represented by <see cref="Bgr24"/>.
/// </summary>
public ReadOnlyMemory<Color>? ColorTable { get; set; }
/// <summary>
/// Gets or sets the horizontal cursor hotspot in pixels from the left.
/// </summary>
public ushort HotspotX { get; set; }
/// <summary>
/// Gets or sets the vertical cursor hotspot in pixels from the top.
/// </summary>
public ushort HotspotY { get; set; }
/// <inheritdoc/>
public static AniFrameMetadata FromFormatConnectingFrameMetadata(FormatConnectingFrameMetadata metadata)
{
int bitsPerPixel = metadata.PixelTypeInfo?.BitsPerPixel ?? 32;
BmpBitsPerPixel bmpBitsPerPixel = bitsPerPixel switch
{
1 => BmpBitsPerPixel.Bit1,
2 => BmpBitsPerPixel.Bit2,
<= 4 => BmpBitsPerPixel.Bit4,
<= 8 => BmpBitsPerPixel.Bit8,
<= 16 => BmpBitsPerPixel.Bit16,
<= 24 => BmpBitsPerPixel.Bit24,
_ => BmpBitsPerPixel.Bit32
};
return new AniFrameMetadata
{
FrameDelay = (uint)Math.Round(metadata.Duration.TotalSeconds * 60),
EncodingWidth = ClampEncodingDimension(metadata.EncodingWidth),
EncodingHeight = ClampEncodingDimension(metadata.EncodingHeight),
Compression = bmpBitsPerPixel is BmpBitsPerPixel.Bit32 ? IconFrameCompression.Png : IconFrameCompression.Bmp,
BmpBitsPerPixel = bmpBitsPerPixel
};
}
/// <inheritdoc/>
public FormatConnectingFrameMetadata ToFormatConnectingFrameMetadata()
=> new()
{
Duration = TimeSpan.FromSeconds(this.FrameDelay / 60D),
EncodingWidth = this.EncodingWidth,
EncodingHeight = this.EncodingHeight,
PixelTypeInfo = this.GetPixelTypeInfo()
};
/// <inheritdoc/>
public void AfterFrameApply<TPixel>(ImageFrame<TPixel> source, ImageFrame<TPixel> destination, Matrix4x4 matrix)
where TPixel : unmanaged, IPixel<TPixel>
{
float ratioX = destination.Width / (float)source.Width;
float ratioY = destination.Height / (float)source.Height;
this.EncodingWidth = ScaleEncodingDimension(this.EncodingWidth, destination.Width, ratioX);
this.EncodingHeight = ScaleEncodingDimension(this.EncodingHeight, destination.Height, ratioY);
this.ColorTable = null;
}
/// <inheritdoc/>
IDeepCloneable IDeepCloneable.DeepClone() => this.DeepClone();
/// <inheritdoc/>
public AniFrameMetadata DeepClone() => new(this);
/// <summary>
/// Gets the pixel layout represented by the embedded resource metadata.
/// </summary>
/// <returns>The represented pixel layout.</returns>
private PixelTypeInfo GetPixelTypeInfo()
{
int bitsPerPixel = (int)this.BmpBitsPerPixel;
PixelComponentInfo componentInfo;
PixelColorType colorType;
PixelAlphaRepresentation alphaRepresentation = PixelAlphaRepresentation.None;
if (this.Compression is IconFrameCompression.Png)
{
bitsPerPixel = 32;
componentInfo = PixelComponentInfo.Create(4, bitsPerPixel, 8, 8, 8, 8);
colorType = PixelColorType.RGB | PixelColorType.Alpha;
alphaRepresentation = PixelAlphaRepresentation.Unassociated;
}
else
{
switch (this.BmpBitsPerPixel)
{
case BmpBitsPerPixel.Bit1:
componentInfo = PixelComponentInfo.Create(1, bitsPerPixel, 1);
colorType = PixelColorType.Binary;
break;
case BmpBitsPerPixel.Bit2:
componentInfo = PixelComponentInfo.Create(1, bitsPerPixel, 2);
colorType = PixelColorType.Indexed;
break;
case BmpBitsPerPixel.Bit4:
componentInfo = PixelComponentInfo.Create(1, bitsPerPixel, 4);
colorType = PixelColorType.Indexed;
break;
case BmpBitsPerPixel.Bit8:
componentInfo = PixelComponentInfo.Create(1, bitsPerPixel, 8);
colorType = PixelColorType.Indexed;
break;
// Windows bitmaps commonly use a 5-6-5 layout for 16-bit color.
case BmpBitsPerPixel.Bit16:
componentInfo = PixelComponentInfo.Create(3, bitsPerPixel, 5, 6, 5);
colorType = PixelColorType.RGB;
break;
case BmpBitsPerPixel.Bit24:
componentInfo = PixelComponentInfo.Create(3, bitsPerPixel, 8, 8, 8);
colorType = PixelColorType.RGB;
break;
case BmpBitsPerPixel.Bit32 or _:
componentInfo = PixelComponentInfo.Create(4, bitsPerPixel, 8, 8, 8, 8);
colorType = PixelColorType.RGB | PixelColorType.Alpha;
alphaRepresentation = PixelAlphaRepresentation.Unassociated;
break;
}
}
return new PixelTypeInfo(bitsPerPixel)
{
AlphaRepresentation = alphaRepresentation,
ComponentInfo = componentInfo,
ColorType = colorType
};
}
/// <summary>
/// Scales an encoded dimension after an image transform.
/// </summary>
/// <param name="value">The encoded source dimension.</param>
/// <param name="destination">The full destination dimension.</param>
/// <param name="ratio">The destination-to-source scale ratio.</param>
/// <returns>The encoded destination dimension.</returns>
private static byte ScaleEncodingDimension(byte? value, int destination, float ratio)
{
if (value is null)
{
return ClampEncodingDimension(destination);
}
// ICO and CUR encode dimensions in one byte, where zero represents 256 pixels or greater.
int source = value.Value is 0 ? 256 : value.Value;
return ClampEncodingDimension(MathF.Ceiling(source * ratio));
}
/// <summary>
/// Converts a pixel dimension to the one-byte ICO/CUR representation.
/// </summary>
/// <param name="dimension">The pixel dimension.</param>
/// <returns>The encoded dimension.</returns>
private static byte ClampEncodingDimension(float? dimension)
=> dimension switch
{
> 255 => 0,
>= 1 => (byte)dimension,
_ => 0
};
}

114
src/ImageSharp/Formats/Ani/AniFrameStream.cs

@ -0,0 +1,114 @@
// Copyright (c) Six Labors.
// Licensed under the Six Labors Split License.
namespace SixLabors.ImageSharp.Formats.Ani;
/// <summary>
/// Exposes one ANI frame-resource chunk as an isolated seekable stream.
/// </summary>
/// <remarks>
/// Embedded decoders accept arbitrary seek offsets from their own headers. Bounding those seeks to the
/// current RIFF chunk prevents malformed ICO, CUR, or BMP offsets from reading neighboring ANI chunks.
/// </remarks>
internal sealed class AniFrameStream : Stream
{
private readonly Stream stream;
// start is absolute in the containing stream; position is always relative to this bounded resource.
private long start;
private long length;
private long position;
/// <summary>
/// Initializes a new instance of the <see cref="AniFrameStream"/> class.
/// </summary>
/// <param name="stream">The containing ANI stream.</param>
public AniFrameStream(Stream stream)
=> this.stream = stream;
/// <inheritdoc/>
public override bool CanRead => true;
/// <inheritdoc/>
public override bool CanSeek => true;
/// <inheritdoc/>
public override bool CanWrite => false;
/// <inheritdoc/>
public override long Length => this.length;
/// <inheritdoc/>
public override long Position
{
get => this.position;
set => this.Seek(value, SeekOrigin.Begin);
}
/// <summary>
/// Repositions this stream over another frame-resource payload in the same containing stream.
/// </summary>
/// <param name="start">The absolute start of the frame-resource payload.</param>
/// <param name="length">The frame-resource payload length.</param>
public void Reset(long start, long length)
{
this.start = start;
this.length = length;
this.position = 0;
}
/// <inheritdoc/>
public override void Flush()
{
}
/// <inheritdoc/>
public override int Read(byte[] buffer, int offset, int count)
=> this.Read(buffer.AsSpan(offset, count));
/// <inheritdoc/>
public override int Read(Span<byte> buffer)
{
// Clamp every read to the resource boundary so a child decoder cannot consume the next RIFF chunk.
int count = (int)Math.Min(buffer.Length, this.length - this.position);
if (count is 0)
{
return 0;
}
// The containing stream is shared by all resources, so synchronize its absolute position immediately before reading.
this.stream.Position = this.start + this.position;
int read = this.stream.Read(buffer[..count]);
this.position += read;
return read;
}
/// <inheritdoc/>
public override long Seek(long offset, SeekOrigin origin)
{
long target = origin switch
{
SeekOrigin.Begin => offset,
SeekOrigin.Current => this.position + offset,
SeekOrigin.End => this.length + offset,
_ => throw new ArgumentOutOfRangeException(nameof(origin))
};
// Casting rejects both negative offsets and offsets beyond Length with one bounds check.
if ((ulong)target > (ulong)this.length)
{
throw new InvalidImageContentException("The embedded ANI frame resource contains an invalid seek offset.");
}
// Delay moving the containing stream until Read; this keeps logical seeks isolated from sibling resource processing.
this.position = target;
return target;
}
/// <inheritdoc/>
public override void SetLength(long value) => throw new NotSupportedException();
/// <inheritdoc/>
public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException();
}

98
src/ImageSharp/Formats/Ani/AniHeader.cs

@ -0,0 +1,98 @@
// Copyright (c) Six Labors.
// Licensed under the Six Labors Split License.
using System.Buffers.Binary;
namespace SixLabors.ImageSharp.Formats.Ani;
/// <summary>
/// Represents the data stored in an ANI "anih" chunk.
/// </summary>
internal struct AniHeader
{
/// <summary>
/// The number of bytes in the ANI header.
/// </summary>
public const int Size = 9 * sizeof(uint);
/// <summary>
/// Gets or sets the declared ANI header size.
/// </summary>
public uint BytesInHeader { get; set; }
/// <summary>
/// Gets or sets the number of embedded frame resources.
/// </summary>
public uint FrameCount { get; set; }
/// <summary>
/// Gets or sets the number of animation steps.
/// </summary>
public uint StepCount { get; set; }
/// <summary>
/// Gets or sets the frame width used by bitmap-based animations.
/// </summary>
public uint Width { get; set; }
/// <summary>
/// Gets or sets the frame height used by bitmap-based animations.
/// </summary>
public uint Height { get; set; }
/// <summary>
/// Gets or sets the encoded bits per pixel.
/// </summary>
public uint BitCount { get; set; }
/// <summary>
/// Gets or sets the number of color planes.
/// </summary>
public uint Planes { get; set; }
/// <summary>
/// Gets or sets the default display rate in sixtieths of a second.
/// </summary>
public uint DisplayRate { get; set; }
/// <summary>
/// Gets or sets the ANI header flags.
/// </summary>
public AniHeaderFlags Flags { get; set; }
/// <summary>
/// Parses an ANI header from its little-endian byte representation.
/// </summary>
/// <param name="data">The ANI header data.</param>
/// <returns>The parsed ANI header.</returns>
public static AniHeader Parse(ReadOnlySpan<byte> data)
=> new()
{
BytesInHeader = BinaryPrimitives.ReadUInt32LittleEndian(data),
FrameCount = BinaryPrimitives.ReadUInt32LittleEndian(data[4..]),
StepCount = BinaryPrimitives.ReadUInt32LittleEndian(data[8..]),
Width = BinaryPrimitives.ReadUInt32LittleEndian(data[12..]),
Height = BinaryPrimitives.ReadUInt32LittleEndian(data[16..]),
BitCount = BinaryPrimitives.ReadUInt32LittleEndian(data[20..]),
Planes = BinaryPrimitives.ReadUInt32LittleEndian(data[24..]),
DisplayRate = BinaryPrimitives.ReadUInt32LittleEndian(data[28..]),
Flags = (AniHeaderFlags)BinaryPrimitives.ReadUInt32LittleEndian(data[32..])
};
/// <summary>
/// Writes the ANI header to its little-endian byte representation.
/// </summary>
/// <param name="destination">The destination buffer.</param>
public readonly void WriteTo(Span<byte> destination)
{
BinaryPrimitives.WriteUInt32LittleEndian(destination, this.BytesInHeader);
BinaryPrimitives.WriteUInt32LittleEndian(destination[4..], this.FrameCount);
BinaryPrimitives.WriteUInt32LittleEndian(destination[8..], this.StepCount);
BinaryPrimitives.WriteUInt32LittleEndian(destination[12..], this.Width);
BinaryPrimitives.WriteUInt32LittleEndian(destination[16..], this.Height);
BinaryPrimitives.WriteUInt32LittleEndian(destination[20..], this.BitCount);
BinaryPrimitives.WriteUInt32LittleEndian(destination[24..], this.Planes);
BinaryPrimitives.WriteUInt32LittleEndian(destination[28..], this.DisplayRate);
BinaryPrimitives.WriteUInt32LittleEndian(destination[32..], (uint)this.Flags);
}
}

21
src/ImageSharp/Formats/Ani/AniHeaderFlags.cs

@ -0,0 +1,21 @@
// Copyright (c) Six Labors.
// Licensed under the Six Labors Split License.
namespace SixLabors.ImageSharp.Formats.Ani;
/// <summary>
/// Flags for the ANI header.
/// </summary>
[Flags]
public enum AniHeaderFlags : uint
{
/// <summary>
/// The "icon" chunks contain ICO or CUR resources. Without this flag, they contain BMP resources.
/// </summary>
IsIcon = 1,
/// <summary>
/// The ANI file contains a "seq " chunk that maps animation steps to frame resources.
/// </summary>
ContainsSequence = 2
}

39
src/ImageSharp/Formats/Ani/AniImageFormatDetector.cs

@ -0,0 +1,39 @@
// Copyright (c) Six Labors.
// Licensed under the Six Labors Split License.
using System.Diagnostics.CodeAnalysis;
namespace SixLabors.ImageSharp.Formats.Ani;
/// <summary>
/// Detects ANI file headers.
/// </summary>
public sealed class AniImageFormatDetector : IImageFormatDetector
{
/// <summary>
/// Initializes a new instance of the <see cref="AniImageFormatDetector"/> class.
/// </summary>
public AniImageFormatDetector()
{
}
/// <inheritdoc/>
public int HeaderSize => AniConstants.RiffHeaderSize;
/// <inheritdoc/>
public bool TryDetectFormat(ReadOnlySpan<byte> header, [NotNullWhen(true)] out IImageFormat? format)
{
format = this.IsSupportedFileFormat(header) ? AniFormat.Instance : null;
return format is not null;
}
/// <summary>
/// Determines whether the supplied header is a RIFF container with the ANI "ACON" form type.
/// </summary>
/// <param name="header">The candidate file header.</param>
/// <returns><see langword="true"/> when the header identifies ANI data.</returns>
private bool IsSupportedFileFormat(ReadOnlySpan<byte> header)
=> header.Length >= this.HeaderSize
&& header[..4].SequenceEqual(AniConstants.RiffFourCc)
&& header.Slice(8, 4).SequenceEqual(AniConstants.AniFormTypeFourCc);
}

134
src/ImageSharp/Formats/Ani/AniMetadata.cs

@ -0,0 +1,134 @@
// Copyright (c) Six Labors.
// Licensed under the Six Labors Split License.
using System.Numerics;
using SixLabors.ImageSharp.PixelFormats;
namespace SixLabors.ImageSharp.Formats.Ani;
/// <summary>
/// Provides ANI-specific metadata for an image.
/// </summary>
public class AniMetadata : IFormatMetadata<AniMetadata>
{
/// <summary>
/// Initializes a new instance of the <see cref="AniMetadata"/> class.
/// </summary>
public AniMetadata()
{
}
/// <summary>
/// Initializes a new instance of the <see cref="AniMetadata"/> class by copying another instance.
/// </summary>
/// <param name="other">The metadata to copy.</param>
private AniMetadata(AniMetadata other)
{
this.Width = other.Width;
this.Height = other.Height;
this.BitCount = other.BitCount;
this.Planes = other.Planes;
this.DisplayRate = other.DisplayRate;
this.Flags = other.Flags;
this.Name = other.Name;
this.Artist = other.Artist;
}
/// <summary>
/// Gets or sets the frame width declared by the ANI header.
/// </summary>
/// <remarks>
/// Icon-based ANI files commonly store zero because each embedded resource declares its own dimensions.
/// </remarks>
public uint Width { get; set; }
/// <summary>
/// Gets or sets the frame height declared by the ANI header.
/// </summary>
/// <remarks>
/// Icon-based ANI files commonly store zero because each embedded resource declares its own dimensions.
/// </remarks>
public uint Height { get; set; }
/// <summary>
/// Gets or sets the bits per pixel declared by the ANI header.
/// </summary>
/// <remarks>
/// Bitmap-based ANI files use this value to describe their raw frame data. Icon-based files commonly store zero
/// because each embedded ICO or CUR entry declares its own pixel layout.
/// </remarks>
public uint BitCount { get; set; }
/// <summary>
/// Gets or sets the number of independently addressable color planes declared by the ANI header.
/// </summary>
/// <remarks>
/// Bitmap-based ANI files use the Windows DIB plane value, which must be one. Icon-based ANI files use zero because
/// each embedded ICO or CUR entry describes its own pixel layout. No other values are defined by the format.
/// </remarks>
public uint Planes { get; set; }
/// <summary>
/// Gets or sets the default frame display rate in sixtieths of a second.
/// </summary>
public uint DisplayRate { get; set; }
/// <summary>
/// Gets or sets the ANI header flags.
/// </summary>
public AniHeaderFlags Flags { get; set; } = AniHeaderFlags.IsIcon;
/// <summary>
/// Gets or sets the animation name.
/// </summary>
public string? Name { get; set; }
/// <summary>
/// Gets or sets the animation artist.
/// </summary>
public string? Artist { get; set; }
/// <inheritdoc/>
public static AniMetadata FromFormatConnectingMetadata(FormatConnectingMetadata metadata)
=> new()
{
BitCount = (uint)metadata.PixelTypeInfo.BitsPerPixel,
Planes = 1,
Flags = AniHeaderFlags.IsIcon
};
/// <inheritdoc/>
public PixelTypeInfo GetPixelTypeInfo()
{
// Icon-based files are allowed to leave the global bit depth unspecified. Their embedded
// ICO/CUR metadata carries the exact value, while 32-bit is the least lossy conversion default.
int bitsPerPixel = this.BitCount is > 0 and <= 32 ? (int)this.BitCount : 32;
return new PixelTypeInfo(bitsPerPixel);
}
/// <inheritdoc/>
public FormatConnectingMetadata ToFormatConnectingMetadata()
=> new()
{
AnimateRootFrame = true,
EncodingType = EncodingType.Lossless,
PixelTypeInfo = this.GetPixelTypeInfo()
};
/// <inheritdoc/>
public void AfterImageApply<TPixel>(Image<TPixel> destination, Matrix4x4 matrix)
where TPixel : unmanaged, IPixel<TPixel>
{
if (!this.Flags.HasFlag(AniHeaderFlags.IsIcon))
{
this.Width = (uint)destination.Width;
this.Height = (uint)destination.Height;
}
}
/// <inheritdoc/>
IDeepCloneable IDeepCloneable.DeepClone() => this.DeepClone();
/// <inheritdoc/>
public AniMetadata DeepClone() => new(this);
}

34
src/ImageSharp/Formats/Ani/AniRiffChunkHeader.cs

@ -0,0 +1,34 @@
// Copyright (c) Six Labors.
// Licensed under the Six Labors Split License.
using System.Buffers.Binary;
namespace SixLabors.ImageSharp.Formats.Ani;
/// <summary>
/// Represents a RIFF chunk identifier and payload size.
/// </summary>
internal struct AniRiffChunkHeader
{
/// <summary>
/// Gets or sets the chunk identifier.
/// </summary>
public uint FourCc { get; set; }
/// <summary>
/// Gets or sets the chunk payload size in bytes, excluding alignment padding.
/// </summary>
public uint Size { get; set; }
/// <summary>
/// Parses a RIFF chunk header from its little-endian byte representation.
/// </summary>
/// <param name="data">The RIFF chunk header data.</param>
/// <returns>The parsed RIFF chunk header.</returns>
public static AniRiffChunkHeader Parse(ReadOnlySpan<byte> data)
=> new()
{
FourCc = BinaryPrimitives.ReadUInt32LittleEndian(data),
Size = BinaryPrimitives.ReadUInt32LittleEndian(data[4..])
};
}

87
src/ImageSharp/Formats/Bmp/BmpEncoderCore.cs

@ -102,6 +102,11 @@ internal sealed class BmpEncoderCore
/// <inheritdoc cref="BmpDecoderOptions.SkipFileHeader"/>
private readonly bool skipFileHeader;
/// <summary>
/// Whether optional image metadata should be omitted.
/// </summary>
private readonly bool skipMetadata;
/// <inheritdoc cref="BmpDecoderOptions.UseDoubleHeight"/>
private readonly bool isDoubleHeight;
@ -122,6 +127,7 @@ internal sealed class BmpEncoderCore
this.infoHeaderType = encoder.SupportTransparency ? BmpInfoHeaderType.WinVersion4 : BmpInfoHeaderType.WinVersion3;
this.processedAlphaMask = encoder.ProcessedAlphaMask;
this.skipFileHeader = encoder.SkipFileHeader;
this.skipMetadata = encoder.SkipMetadata;
this.isDoubleHeight = encoder.UseDoubleHeight;
}
@ -138,17 +144,30 @@ internal sealed class BmpEncoderCore
Guard.NotNull(image, nameof(image));
Guard.NotNull(stream, nameof(stream));
// Stream may not at 0.
this.Encode(image.Frames.RootFrame, image.Metadata, stream, cancellationToken);
}
/// <summary>
/// Encodes a source frame using the supplied image metadata.
/// </summary>
/// <typeparam name="TPixel">The pixel format.</typeparam>
/// <param name="frame">The source frame.</param>
/// <param name="metadata">The source image metadata.</param>
/// <param name="stream">The destination stream.</param>
/// <param name="cancellationToken">The token to request cancellation.</param>
internal void Encode<TPixel>(ImageFrame<TPixel> frame, ImageMetadata metadata, Stream stream, CancellationToken cancellationToken)
where TPixel : unmanaged, IPixel<TPixel>
{
// Nested ANI/ICO/CUR encoding starts inside a parent stream, so all later profile offsets use this local base.
long basePosition = stream.Position;
Configuration configuration = image.Configuration;
ImageMetadata metadata = image.Metadata;
Configuration configuration = frame.Configuration;
BmpMetadata bmpMetadata = metadata.GetBmpMetadata();
this.bitsPerPixel ??= bmpMetadata.BitsPerPixel;
ushort bpp = (ushort)this.bitsPerPixel;
int bytesPerLine = (int)(4 * ((((uint)image.Width * bpp) + 31) / 32));
this.padding = bytesPerLine - (int)(image.Width * (bpp / 8F));
int bytesPerLine = (int)(4 * ((((uint)frame.Width * bpp) + 31) / 32));
this.padding = bytesPerLine - (int)(frame.Width * (bpp / 8F));
int colorPaletteSize = this.bitsPerPixel switch
{
@ -161,7 +180,7 @@ internal sealed class BmpEncoderCore
byte[]? iccProfileData = null;
int iccProfileSize = 0;
if (metadata.IccProfile != null)
if (!this.skipMetadata && metadata.IccProfile != null)
{
this.infoHeaderType = BmpInfoHeaderType.WinVersion5;
iccProfileData = metadata.IccProfile.ToByteArray();
@ -176,25 +195,25 @@ internal sealed class BmpEncoderCore
_ => BmpInfoHeader.SizeV3
};
// for ico/cur encoder.
int height = image.Height;
// ICO/CUR DIB headers include the XOR bitmap and following AND mask in one doubled height.
int height = frame.Height;
if (this.isDoubleHeight)
{
height <<= 1;
}
BmpInfoHeader infoHeader = this.CreateBmpInfoHeader(image.Width, height, infoHeaderSize, bpp, bytesPerLine, metadata, iccProfileData);
BmpInfoHeader infoHeader = this.CreateBmpInfoHeader(frame.Width, height, infoHeaderSize, bpp, bytesPerLine, metadata, iccProfileData);
Span<byte> buffer = stackalloc byte[infoHeaderSize];
// For ico/cur encoder.
// ICO/CUR resources contain a DIB directly; standalone BMP files additionally require BITMAPFILEHEADER.
if (!this.skipFileHeader)
{
WriteBitmapFileHeader(stream, infoHeaderSize, colorPaletteSize, iccProfileSize, infoHeader, buffer);
}
this.WriteBitmapInfoHeader(stream, infoHeader, buffer, infoHeaderSize);
this.WriteImage(configuration, stream, image, cancellationToken);
this.WriteImage(configuration, stream, frame, cancellationToken);
WriteColorProfile(stream, iccProfileData, buffer, basePosition);
stream.Flush();
@ -216,7 +235,8 @@ internal sealed class BmpEncoderCore
int hResolution = 0;
int vResolution = 0;
if (metadata.ResolutionUnits != PixelResolutionUnit.AspectRatio
if (!this.skipMetadata
&& metadata.ResolutionUnits != PixelResolutionUnit.AspectRatio
&& metadata.HorizontalResolution > 0
&& metadata.VerticalResolution > 0)
{
@ -348,15 +368,11 @@ internal sealed class BmpEncoderCore
/// <typeparam name="TPixel">The pixel format.</typeparam>
/// <param name="configuration">The global configuration.</param>
/// <param name="stream">The <see cref="Stream"/> to write to.</param>
/// <param name="image">
/// <param name="frame">
/// The <see cref="ImageFrame{TPixel}"/> containing pixel data.
/// </param>
/// <param name="cancellationToken">The token to monitor for cancellation requests.</param>
private void WriteImage<TPixel>(
Configuration configuration,
Stream stream,
Image<TPixel> image,
CancellationToken cancellationToken)
private void WriteImage<TPixel>(Configuration configuration, Stream stream, ImageFrame<TPixel> frame, CancellationToken cancellationToken)
where TPixel : unmanaged, IPixel<TPixel>
{
ImageFrame<TPixel>? clonedFrame = null;
@ -367,11 +383,11 @@ internal sealed class BmpEncoderCore
int bpp = this.bitsPerPixel != null ? (int)this.bitsPerPixel : 32;
if (bpp > 8 && EncodingUtilities.ShouldReplaceTransparentPixels<TPixel>(this.transparentColorMode))
{
clonedFrame = image.Frames.RootFrame.Clone();
clonedFrame = frame.Clone();
EncodingUtilities.ReplaceTransparentPixels(clonedFrame);
}
ImageFrame<TPixel> encodingFrame = clonedFrame ?? image.Frames.RootFrame;
ImageFrame<TPixel> encodingFrame = clonedFrame ?? frame;
Buffer2D<TPixel> pixels = encodingFrame.PixelBuffer;
switch (this.bitsPerPixel)
@ -864,10 +880,17 @@ internal sealed class BmpEncoderCore
stream.WriteByte(indices);
}
/// <summary>
/// Writes the bottom-up 1-bit transparency mask required by an ICO/CUR bitmap resource.
/// </summary>
/// <typeparam name="TPixel">The source pixel type.</typeparam>
/// <param name="stream">The destination stream.</param>
/// <param name="encodingFrame">The source frame.</param>
private static void ProcessedAlphaMask<TPixel>(Stream stream, ImageFrame<TPixel> encodingFrame)
where TPixel : unmanaged, IPixel<TPixel>
where TPixel : unmanaged, IPixel<TPixel>
{
int arrayWidth = encodingFrame.Width / 8;
// Each byte represents eight pixels and every scanline is padded to a 4-byte DIB boundary.
int arrayWidth = (encodingFrame.Width + 7) / 8;
int padding = arrayWidth % 4;
if (padding is not 0)
{
@ -875,6 +898,9 @@ internal sealed class BmpEncoderCore
}
Span<byte> mask = stackalloc byte[arrayWidth];
Span<byte> paddingBytes = stackalloc byte[3];
paddingBytes.Clear();
for (int y = encodingFrame.Height - 1; y >= 0; y--)
{
mask.Clear();
@ -884,19 +910,30 @@ internal sealed class BmpEncoderCore
{
int x = i * 8;
for (int j = 0; j < 8; j++)
// The final byte can represent fewer than eight pixels when the image width is not byte-aligned.
int pixelCount = Math.Min(8, encodingFrame.Width - x);
for (int j = 0; j < pixelCount; j++)
{
WriteAlphaMask(row[x + j], ref mask[i], j);
}
}
stream.Write(mask);
stream.Skip(padding);
// Alpha-mask rows are DWORD-aligned, and the final row padding must extend the stream.
stream.Write(paddingBytes[..padding]);
}
}
/// <summary>
/// Sets one most-significant-bit-first transparency flag in an ICO/CUR AND-mask byte.
/// </summary>
/// <typeparam name="TPixel">The source pixel type.</typeparam>
/// <param name="pixel">The source pixel.</param>
/// <param name="mask">The destination mask byte.</param>
/// <param name="index">The pixel index within the byte.</param>
private static void WriteAlphaMask<TPixel>(in TPixel pixel, ref byte mask, in int index)
where TPixel : unmanaged, IPixel<TPixel>
where TPixel : unmanaged, IPixel<TPixel>
{
Rgba32 rgba = pixel.ToRgba32();
if (rgba.A is 0)

6
src/ImageSharp/Formats/Cur/CurConfigurationModule.cs

@ -1,12 +1,10 @@
// Copyright (c) Six Labors.
// Licensed under the Six Labors Split License.
using SixLabors.ImageSharp.Formats.Icon;
namespace SixLabors.ImageSharp.Formats.Cur;
/// <summary>
/// Registers the image encoders, decoders and mime type detectors for the Ico format.
/// Registers the image encoder, decoder, and format detector for the CUR format.
/// </summary>
public sealed class CurConfigurationModule : IImageFormatConfigurationModule
{
@ -15,6 +13,6 @@ public sealed class CurConfigurationModule : IImageFormatConfigurationModule
{
configuration.ImageFormatsManager.SetEncoder(CurFormat.Instance, new CurEncoder());
configuration.ImageFormatsManager.SetDecoder(CurFormat.Instance, CurDecoder.Instance);
configuration.ImageFormatsManager.AddImageFormatDetector(new IconImageFormatDetector());
configuration.ImageFormatsManager.AddImageFormatDetector(new CurImageFormatDetector());
}
}

10
src/ImageSharp/Formats/Cur/CurConstants.cs

@ -4,12 +4,12 @@
namespace SixLabors.ImageSharp.Formats.Cur;
/// <summary>
/// Defines constants relating to ICOs
/// Defines constants used by the CUR format.
/// </summary>
internal static class CurConstants
{
/// <summary>
/// The list of mime types that equate to a cur.
/// The MIME types that identify CUR data.
/// </summary>
/// <remarks>
/// See <see href="https://en.wikipedia.org/wiki/ICO_(file_format)#MIME_type"/>
@ -27,13 +27,11 @@ internal static class CurConstants
"image/ico",
"image/icon",
"text/ico",
"application/ico",
"application/ico"
];
/// <summary>
/// The list of file extensions that equate to a cur.
/// The file extensions that identify CUR data.
/// </summary>
public static readonly IEnumerable<string> FileExtensions = ["cur"];
public const uint FileHeader = 0x00_02_00_00;
}

7
src/ImageSharp/Formats/Cur/CurDecoder.cs

@ -6,10 +6,13 @@ using SixLabors.ImageSharp.PixelFormats;
namespace SixLabors.ImageSharp.Formats.Cur;
/// <summary>
/// Decoder for generating an image out of a ico encoded stream.
/// Decoder for generating an image from a CUR encoded stream.
/// </summary>
public sealed class CurDecoder : ImageDecoder
{
/// <summary>
/// Initializes a new instance of the <see cref="CurDecoder"/> class.
/// </summary>
private CurDecoder()
{
}
@ -34,7 +37,7 @@ public sealed class CurDecoder : ImageDecoder
/// <inheritdoc/>
protected override Image Decode(DecoderOptions options, Stream stream, CancellationToken cancellationToken)
=> this.Decode<Rgba32>(options, stream, cancellationToken);
=> this.Decode<Rgba32>(options, stream, cancellationToken);
/// <inheritdoc/>
protected override ImageInfo Identify(DecoderOptions options, Stream stream, CancellationToken cancellationToken)

10
src/ImageSharp/Formats/Cur/CurDecoderCore.cs

@ -7,13 +7,21 @@ using SixLabors.ImageSharp.Metadata;
namespace SixLabors.ImageSharp.Formats.Cur;
/// <summary>
/// Decodes CUR containers and maps directory metadata to CUR metadata.
/// </summary>
internal sealed class CurDecoderCore : IconDecoderCore
{
/// <summary>
/// Initializes a new instance of the <see cref="CurDecoderCore"/> class.
/// </summary>
/// <param name="options">The decoder options.</param>
public CurDecoderCore(DecoderOptions options)
: base(options)
: base(options, IconFileType.CUR)
{
}
/// <inheritdoc/>
protected override void SetFrameMetadata(
ImageMetadata imageMetadata,
ImageFrameMetadata frameMetadata,

5
src/ImageSharp/Formats/Cur/CurEncoder.cs

@ -10,8 +10,5 @@ public sealed class CurEncoder : QuantizingImageEncoder
{
/// <inheritdoc/>
protected override void Encode<TPixel>(Image<TPixel> image, Stream stream, CancellationToken cancellationToken)
{
CurEncoderCore encoderCore = new(this);
encoderCore.Encode(image, stream, cancellationToken);
}
=> new CurEncoderCore(this).Encode(image, stream, cancellationToken);
}

33
src/ImageSharp/Formats/Cur/CurEncoderCore.cs

@ -2,13 +2,46 @@
// Licensed under the Six Labors Split License.
using SixLabors.ImageSharp.Formats.Icon;
using SixLabors.ImageSharp.PixelFormats;
namespace SixLabors.ImageSharp.Formats.Cur;
/// <summary>
/// Encodes CUR containers using CUR frame metadata.
/// </summary>
internal sealed class CurEncoderCore : IconEncoderCore
{
/// <summary>
/// Initializes a new instance of the <see cref="CurEncoderCore"/> class.
/// </summary>
/// <param name="encoder">The encoder options.</param>
public CurEncoderCore(QuantizingImageEncoder encoder)
: base(encoder, IconFileType.CUR)
{
}
/// <summary>
/// Encodes all source frames as a CUR resource.
/// </summary>
/// <typeparam name="TPixel">The source pixel type.</typeparam>
/// <param name="image">The source image.</param>
/// <param name="stream">The destination stream.</param>
/// <param name="cancellationToken">The token to monitor for cancellation requests.</param>
public void Encode<TPixel>(Image<TPixel> image, Stream stream, CancellationToken cancellationToken)
where TPixel : unmanaged, IPixel<TPixel>
=> this.Encode(image, stream, default(CurFrameMetadataProvider), cancellationToken);
/// <summary>
/// Supplies CUR frame metadata to the shared container encoder.
/// </summary>
private readonly struct CurFrameMetadataProvider : IEncodingFrameMetadataProvider
{
/// <inheritdoc/>
public EncodingFrameMetadata GetEncodingFrameMetadata(ImageFrame frame, out ReadOnlyMemory<Color>? colorTable)
{
CurFrameMetadata metadata = frame.Metadata.GetCurMetadata();
colorTable = metadata.ColorTable;
return new EncodingFrameMetadata(metadata.Compression, metadata.BmpBitsPerPixel, metadata.ToIconDirEntry(frame.Size));
}
}
}

9
src/ImageSharp/Formats/Cur/CurFormat.cs

@ -4,10 +4,13 @@
namespace SixLabors.ImageSharp.Formats.Cur;
/// <summary>
/// Registers the image encoders, decoders and mime type detectors for the ICO format.
/// Describes the CUR image format.
/// </summary>
public sealed class CurFormat : IImageFormat<CurMetadata, CurFrameMetadata>
{
/// <summary>
/// Prevents a default instance of the <see cref="CurFormat"/> class from being created.
/// </summary>
private CurFormat()
{
}
@ -18,10 +21,10 @@ public sealed class CurFormat : IImageFormat<CurMetadata, CurFrameMetadata>
public static CurFormat Instance { get; } = new();
/// <inheritdoc/>
public string Name => "ICO";
public string Name => "CUR";
/// <inheritdoc/>
public string DefaultMimeType => CurConstants.MimeTypes.First();
public string DefaultMimeType => "image/vnd.microsoft.icon";
/// <inheritdoc/>
public IEnumerable<string> MimeTypes => CurConstants.MimeTypes;

52
src/ImageSharp/Formats/Cur/CurFrameMetadata.cs

@ -9,7 +9,7 @@ using SixLabors.ImageSharp.PixelFormats;
namespace SixLabors.ImageSharp.Formats.Cur;
/// <summary>
/// IcoFrameMetadata.
/// Provides CUR-specific metadata for an image frame.
/// </summary>
public class CurFrameMetadata : IFormatFrameMetadata<CurFrameMetadata>
{
@ -20,6 +20,10 @@ public class CurFrameMetadata : IFormatFrameMetadata<CurFrameMetadata>
{
}
/// <summary>
/// Initializes a new instance of the <see cref="CurFrameMetadata"/> class by copying another instance.
/// </summary>
/// <param name="other">The metadata to copy.</param>
private CurFrameMetadata(CurFrameMetadata other)
{
this.Compression = other.Compression;
@ -28,10 +32,15 @@ public class CurFrameMetadata : IFormatFrameMetadata<CurFrameMetadata>
this.EncodingWidth = other.EncodingWidth;
this.EncodingHeight = other.EncodingHeight;
this.BmpBitsPerPixel = other.BmpBitsPerPixel;
if (other.ColorTable?.Length > 0)
{
this.ColorTable = other.ColorTable.Value.ToArray();
}
}
/// <summary>
/// Gets or sets the frame compressions format.
/// Gets or sets the frame compression format.
/// </summary>
public IconFrameCompression Compression { get; set; }
@ -46,14 +55,14 @@ public class CurFrameMetadata : IFormatFrameMetadata<CurFrameMetadata>
public ushort HotspotY { get; set; }
/// <summary>
/// Gets or sets the encoding width. <br />
/// Can be any number between 0 and 255. Value 0 means a frame height of 256 pixels or greater.
/// Gets or sets the encoded width.
/// A value of zero represents 256 pixels or greater.
/// </summary>
public byte? EncodingWidth { get; set; }
/// <summary>
/// Gets or sets the encoding height. <br />
/// Can be any number between 0 and 255. Value 0 means a frame height of 256 pixels or greater.
/// Gets or sets the encoded height.
/// A value of zero represents 256 pixels or greater.
/// </summary>
public byte? EncodingHeight { get; set; }
@ -104,7 +113,7 @@ public class CurFrameMetadata : IFormatFrameMetadata<CurFrameMetadata>
BmpBitsPerPixel = bbpp,
Compression = compression,
EncodingWidth = ClampEncodingDimension(metadata.EncodingWidth),
EncodingHeight = ClampEncodingDimension(metadata.EncodingHeight),
EncodingHeight = ClampEncodingDimension(metadata.EncodingHeight)
};
}
@ -134,6 +143,10 @@ public class CurFrameMetadata : IFormatFrameMetadata<CurFrameMetadata>
/// <inheritdoc/>
public CurFrameMetadata DeepClone() => new(this);
/// <summary>
/// Copies the observable CUR directory values from an entry.
/// </summary>
/// <param name="entry">The source directory entry.</param>
internal void FromIconDirEntry(IconDirEntry entry)
{
this.EncodingWidth = entry.Width;
@ -142,6 +155,11 @@ public class CurFrameMetadata : IFormatFrameMetadata<CurFrameMetadata>
this.HotspotY = entry.BitCount;
}
/// <summary>
/// Creates a CUR directory entry from this metadata.
/// </summary>
/// <param name="size">The source frame size.</param>
/// <returns>The CUR directory entry.</returns>
internal IconDirEntry ToIconDirEntry(Size size)
{
byte colorCount = this.Compression == IconFrameCompression.Png || this.BmpBitsPerPixel > BmpBitsPerPixel.Bit8
@ -158,6 +176,10 @@ public class CurFrameMetadata : IFormatFrameMetadata<CurFrameMetadata>
};
}
/// <summary>
/// Gets the pixel layout represented by this metadata.
/// </summary>
/// <returns>The represented pixel layout.</returns>
private PixelTypeInfo GetPixelTypeInfo()
{
int bpp = (int)this.BmpBitsPerPixel;
@ -219,6 +241,13 @@ public class CurFrameMetadata : IFormatFrameMetadata<CurFrameMetadata>
};
}
/// <summary>
/// Scales an encoded dimension after an image transform.
/// </summary>
/// <param name="value">The encoded source dimension.</param>
/// <param name="destination">The full destination dimension.</param>
/// <param name="ratio">The destination-to-source scale ratio.</param>
/// <returns>The encoded destination dimension.</returns>
private static byte ScaleEncodingDimension(byte? value, int destination, float ratio)
{
if (value is null)
@ -226,9 +255,16 @@ public class CurFrameMetadata : IFormatFrameMetadata<CurFrameMetadata>
return ClampEncodingDimension(destination);
}
return ClampEncodingDimension(MathF.Ceiling(value.Value * ratio));
// A stored zero represents 256 pixels, so scaling must expand it before applying the transform ratio.
int source = value.Value is 0 ? 256 : value.Value;
return ClampEncodingDimension(MathF.Ceiling(source * ratio));
}
/// <summary>
/// Converts a pixel dimension to the one-byte CUR representation.
/// </summary>
/// <param name="dimension">The pixel dimension.</param>
/// <returns>The encoded dimension.</returns>
private static byte ClampEncodingDimension(float? dimension)
=> dimension switch
{

49
src/ImageSharp/Formats/Cur/CurImageFormatDetector.cs

@ -0,0 +1,49 @@
// Copyright (c) Six Labors.
// Licensed under the Six Labors Split License.
using System.Diagnostics.CodeAnalysis;
using SixLabors.ImageSharp.Formats.Icon;
namespace SixLabors.ImageSharp.Formats.Cur;
/// <summary>
/// Detects CUR file headers.
/// </summary>
public sealed class CurImageFormatDetector : IImageFormatDetector
{
/// <inheritdoc/>
public int HeaderSize => IconDir.Size + IconDirEntry.Size;
/// <inheritdoc/>
public bool TryDetectFormat(ReadOnlySpan<byte> header, [NotNullWhen(true)] out IImageFormat? format)
{
format = this.IsSupportedFileFormat(header) ? CurFormat.Instance : null;
return format is not null;
}
/// <summary>
/// Determines whether the supplied header contains a valid CUR directory and first entry.
/// </summary>
/// <param name="header">The candidate file header.</param>
/// <returns><see langword="true"/> when the header identifies CUR data.</returns>
private bool IsSupportedFileFormat(ReadOnlySpan<byte> header)
{
if (header.Length < this.HeaderSize)
{
return false;
}
IconDir dir = IconDir.Parse(header);
if (dir is not { Reserved: 0, Type: IconFileType.CUR } || dir.Count is 0)
{
return false;
}
IconDirEntry entry = IconDirEntry.Parse(header[IconDir.Size..]);
// The first payload must begin after the complete directory, even when the caller supplied only the detection prefix.
return entry.Reserved is 0
&& entry.BytesInRes is not 0
&& entry.ImageOffset >= IconDir.Size + (dir.Count * IconDirEntry.Size);
}
}

6
src/ImageSharp/Formats/Cur/CurMetadata.cs

@ -9,7 +9,7 @@ using SixLabors.ImageSharp.PixelFormats;
namespace SixLabors.ImageSharp.Formats.Cur;
/// <summary>
/// Provides Cur specific metadata information for the image.
/// Provides CUR-specific metadata for an image.
/// </summary>
public class CurMetadata : IFormatMetadata<CurMetadata>
{
@ -32,7 +32,7 @@ public class CurMetadata : IFormatMetadata<CurMetadata>
}
/// <summary>
/// Gets or sets the frame compressions format. Derived from the root frame.
/// Gets or sets the root frame compression format.
/// </summary>
public IconFrameCompression Compression { get; set; }
@ -43,7 +43,7 @@ public class CurMetadata : IFormatMetadata<CurMetadata>
public BmpBitsPerPixel BmpBitsPerPixel { get; set; } = BmpBitsPerPixel.Bit32;
/// <summary>
/// Gets or sets the color table, if any. Derived from the root frame.<br/>
/// Gets or sets the root frame color table, if any.<br/>
/// The underlying pixel format is represented by <see cref="Bgr24"/>.
/// </summary>
public ReadOnlyMemory<Color>? ColorTable { get; set; }

32
src/ImageSharp/Formats/Exr/Compression/Decompressors/B44ExrCompression.cs

@ -15,9 +15,10 @@ internal class B44ExrCompression : ExrBaseDecompressor
{
private readonly int channelCount;
private readonly byte[] scratch = new byte[14];
// B44 encodes each 4x4 block in either 3 or 14 bytes, so both representations share this inline storage.
private InlineArray14<byte> scratch;
private readonly ushort[] s = new ushort[16];
private InlineArray16<ushort> s;
private readonly IMemoryOwner<ushort> tmpBuffer;
@ -42,8 +43,11 @@ internal class B44ExrCompression : ExrBaseDecompressor
{
Span<ushort> outputBuffer = MemoryMarshal.Cast<byte, ushort>(buffer);
Span<ushort> decompressed = this.tmpBuffer.GetSpan();
Span<byte> scratch = this.scratch;
Span<ushort> samples = this.s;
int outputOffset = 0;
int bytesLeft = (int)compressedBytes;
for (int i = 0; i < this.channelCount && bytesLeft > 0; i++)
{
for (int y = 0; y < this.RowsPerBlock; y += 4)
@ -60,49 +64,49 @@ internal class B44ExrCompression : ExrBaseDecompressor
int rowOffset = 0;
for (int x = 0; x < this.Width && bytesLeft > 0; x += 4)
{
int bytesRead = stream.Read(this.scratch, 0, 3);
int bytesRead = stream.Read(scratch[..3]);
if (bytesRead == 0)
{
ExrThrowHelper.ThrowInvalidImageContentException("Could not read enough data from the stream!");
}
// Check if 3-byte encoded flat field.
if (this.scratch[2] >= 13 << 2)
if (scratch[2] >= 13 << 2)
{
Unpack3(this.scratch, this.s);
Unpack3(scratch, samples);
bytesLeft -= 3;
}
else
{
bytesRead = stream.Read(this.scratch, 3, 11);
bytesRead = stream.Read(scratch.Slice(3, 11));
if (bytesRead == 0)
{
ExrThrowHelper.ThrowInvalidImageContentException("Could not read enough data from the stream!");
}
Unpack14(this.scratch, this.s);
Unpack14(scratch, samples);
bytesLeft -= 14;
}
int n = x + 3 < this.Width ? 4 : this.Width - x;
if (y + 3 < this.RowsPerBlock)
{
this.s.AsSpan(0, n).CopyTo(row0[rowOffset..]);
this.s.AsSpan(4, n).CopyTo(row1[rowOffset..]);
this.s.AsSpan(8, n).CopyTo(row2[rowOffset..]);
this.s.AsSpan(12, n).CopyTo(row3[rowOffset..]);
samples[..n].CopyTo(row0[rowOffset..]);
samples.Slice(4, n).CopyTo(row1[rowOffset..]);
samples.Slice(8, n).CopyTo(row2[rowOffset..]);
samples.Slice(12, n).CopyTo(row3[rowOffset..]);
}
else
{
this.s.AsSpan(0, n).CopyTo(row0[rowOffset..]);
samples[..n].CopyTo(row0[rowOffset..]);
if (y + 1 < this.RowsPerBlock)
{
this.s.AsSpan(4, n).CopyTo(row1[rowOffset..]);
samples.Slice(4, n).CopyTo(row1[rowOffset..]);
}
if (y + 2 < this.RowsPerBlock)
{
this.s.AsSpan(8, n).CopyTo(row2[rowOffset..]);
samples.Slice(8, n).CopyTo(row2[rowOffset..]);
}
}

2
src/ImageSharp/Formats/Exr/ExrDecoderCore.cs

@ -24,7 +24,7 @@ internal sealed class ExrDecoderCore : ImageDecoderCore
/// <summary>
/// Reusable buffer.
/// </summary>
private readonly byte[] buffer = new byte[8];
private InlineArray8<byte> buffer;
/// <summary>
/// Used for allocating memory during processing operations.

32
src/ImageSharp/Formats/Exr/ExrEncoderCore.cs

@ -21,7 +21,7 @@ internal sealed class ExrEncoderCore
/// <summary>
/// Reusable buffer.
/// </summary>
private readonly byte[] buffer = new byte[8];
private InlineArray8<byte> buffer;
/// <summary>
/// Used for allocating memory during processing operations.
@ -108,7 +108,7 @@ internal sealed class ExrEncoderCore
// Write magick bytes.
BinaryPrimitives.WriteInt32LittleEndian(this.buffer, ExrConstants.MagickBytes);
stream.Write(this.buffer.AsSpan(0, 4));
stream.Write(this.buffer[..4]);
// Version number.
this.buffer[0] = 2;
@ -117,7 +117,7 @@ internal sealed class ExrEncoderCore
this.buffer[1] = 0;
this.buffer[2] = 0;
this.buffer[3] = 0;
stream.Write(this.buffer.AsSpan(0, 4));
stream.Write(this.buffer[..4]);
// Write EXR header.
this.WriteHeader(stream, header);
@ -194,7 +194,7 @@ internal sealed class ExrEncoderCore
// Write row index.
BinaryPrimitives.WriteUInt32LittleEndian(this.buffer, y);
stream.Write(this.buffer.AsSpan(0, 4));
stream.Write(this.buffer[..4]);
// At this point, it is not yet known how much bytes the compressed data will take up, keep stream position.
long pixelDataSizePos = stream.Position;
@ -237,7 +237,7 @@ internal sealed class ExrEncoderCore
// Write pixel row data size.
BinaryPrimitives.WriteUInt32LittleEndian(this.buffer, compressedBytes);
stream.Position = pixelDataSizePos;
stream.Write(this.buffer.AsSpan(0, 4));
stream.Write(this.buffer[..4]);
stream.Position = positionAfterPixelData;
cancellationToken.ThrowIfCancellationRequested();
@ -293,7 +293,7 @@ internal sealed class ExrEncoderCore
// Write row index.
BinaryPrimitives.WriteUInt32LittleEndian(this.buffer, y);
stream.Write(this.buffer.AsSpan(0, 4));
stream.Write(this.buffer[..4]);
// At this point, it is not yet known how much bytes the compressed data will take up, keep stream position.
long pixelDataSizePos = stream.Position;
@ -328,7 +328,7 @@ internal sealed class ExrEncoderCore
// Write pixel row data size.
BinaryPrimitives.WriteUInt32LittleEndian(this.buffer, compressedBytes);
stream.Position = pixelDataSizePos;
stream.Write(this.buffer.AsSpan(0, 4));
stream.Write(this.buffer[..4]);
stream.Position = positionAfterPixelData;
cancellationToken.ThrowIfCancellationRequested();
@ -518,7 +518,7 @@ internal sealed class ExrEncoderCore
WriteString(stream, channelInfo.ChannelName);
BinaryPrimitives.WriteInt32LittleEndian(this.buffer, (int)channelInfo.PixelType);
stream.Write(this.buffer.AsSpan(0, 4));
stream.Write(this.buffer[..4]);
stream.WriteByte(channelInfo.Linear);
@ -528,10 +528,10 @@ internal sealed class ExrEncoderCore
stream.WriteByte(0);
BinaryPrimitives.WriteInt32LittleEndian(this.buffer, channelInfo.XSampling);
stream.Write(this.buffer.AsSpan(0, 4));
stream.Write(this.buffer[..4]);
BinaryPrimitives.WriteInt32LittleEndian(this.buffer, channelInfo.YSampling);
stream.Write(this.buffer.AsSpan(0, 4));
stream.Write(this.buffer[..4]);
}
/// <summary>
@ -629,7 +629,7 @@ internal sealed class ExrEncoderCore
// Write attribute size.
BinaryPrimitives.WriteUInt32LittleEndian(this.buffer, (uint)size);
stream.Write(this.buffer.AsSpan(0, 4));
stream.Write(this.buffer[..4]);
}
/// <summary>
@ -656,16 +656,16 @@ internal sealed class ExrEncoderCore
private void WriteBoxInteger(Stream stream, ExrBox2i box)
{
BinaryPrimitives.WriteInt32LittleEndian(this.buffer, box.XMin);
stream.Write(this.buffer.AsSpan(0, 4));
stream.Write(this.buffer[..4]);
BinaryPrimitives.WriteInt32LittleEndian(this.buffer, box.YMin);
stream.Write(this.buffer.AsSpan(0, 4));
stream.Write(this.buffer[..4]);
BinaryPrimitives.WriteInt32LittleEndian(this.buffer, box.XMax);
stream.Write(this.buffer.AsSpan(0, 4));
stream.Write(this.buffer[..4]);
BinaryPrimitives.WriteInt32LittleEndian(this.buffer, box.YMax);
stream.Write(this.buffer.AsSpan(0, 4));
stream.Write(this.buffer[..4]);
}
/// <summary>
@ -677,7 +677,7 @@ internal sealed class ExrEncoderCore
private unsafe void WriteSingle(Stream stream, float value)
{
BinaryPrimitives.WriteInt32LittleEndian(this.buffer, *(int*)&value);
stream.Write(this.buffer.AsSpan(0, 4));
stream.Write(this.buffer[..4]);
}
/// <summary>

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

@ -22,7 +22,7 @@ internal sealed class GifDecoderCore : ImageDecoderCore
/// <summary>
/// The temp buffer used to reduce allocations.
/// </summary>
private ScratchBuffer buffer; // mutable struct, don't make readonly
private InlineArray16<byte> buffer; // mutable struct, don't make readonly
/// <summary>
/// The global color table.
@ -285,13 +285,13 @@ internal sealed class GifDecoderCore : ImageDecoderCore
/// <param name="stream">The <see cref="BufferedReadStream"/> containing image data.</param>
private void ReadGraphicalControlExtension(BufferedReadStream stream)
{
int bytesRead = stream.Read(this.buffer.Span, 0, 6);
int bytesRead = stream.Read(this.buffer, 0, 6);
if (bytesRead != 6)
{
GifThrowHelper.ThrowInvalidImageContentException("Not enough data to read the graphic control extension");
}
this.graphicsControlExtension = GifGraphicControlExtension.Parse(this.buffer.Span);
this.graphicsControlExtension = GifGraphicControlExtension.Parse(this.buffer);
}
/// <summary>
@ -300,13 +300,13 @@ internal sealed class GifDecoderCore : ImageDecoderCore
/// <param name="stream">The <see cref="BufferedReadStream"/> containing image data.</param>
private void ReadImageDescriptor(BufferedReadStream stream)
{
int bytesRead = stream.Read(this.buffer.Span, 0, 9);
int bytesRead = stream.Read(this.buffer, 0, 9);
if (bytesRead != 9)
{
GifThrowHelper.ThrowInvalidImageContentException("Not enough data to read the image descriptor");
}
this.imageDescriptor = GifImageDescriptor.Parse(this.buffer.Span);
this.imageDescriptor = GifImageDescriptor.Parse(this.buffer);
if (this.imageDescriptor.Height == 0 || this.imageDescriptor.Width == 0)
{
GifThrowHelper.ThrowInvalidImageContentException("Width or height should not be 0");
@ -321,13 +321,13 @@ internal sealed class GifDecoderCore : ImageDecoderCore
/// <param name="stream">The <see cref="BufferedReadStream"/> containing image data.</param>
private void ReadLogicalScreenDescriptor(BufferedReadStream stream)
{
int bytesRead = stream.Read(this.buffer.Span, 0, 7);
int bytesRead = stream.Read(this.buffer, 0, 7);
if (bytesRead != 7)
{
GifThrowHelper.ThrowInvalidImageContentException("Not enough data to read the logical screen descriptor");
}
this.logicalScreenDescriptor = GifLogicalScreenDescriptor.Parse(this.buffer.Span);
this.logicalScreenDescriptor = GifLogicalScreenDescriptor.Parse(this.buffer);
}
/// <summary>
@ -353,13 +353,13 @@ internal sealed class GifDecoderCore : ImageDecoderCore
// If the length is 11 then it's a valid extension and most likely
// a NETSCAPE, XMP or ANIMEXTS extension. We want the loop count from this.
long position = stream.Position;
int bytesRead = stream.Read(this.buffer.Span, 0, GifConstants.ApplicationBlockSize);
int bytesRead = stream.Read(this.buffer, 0, GifConstants.ApplicationBlockSize);
if (bytesRead != GifConstants.ApplicationBlockSize)
{
GifThrowHelper.ThrowInvalidImageContentException("Unexpected end of stream while reading gif application extension");
}
bool isXmp = this.buffer.Span.StartsWith(GifConstants.XmpApplicationIdentificationBytes);
bool isXmp = ((ReadOnlySpan<byte>)this.buffer).StartsWith(GifConstants.XmpApplicationIdentificationBytes);
if (isXmp)
{
this.ReadXmpApplicationExtension(stream, position, appLength);
@ -447,13 +447,13 @@ internal sealed class GifDecoderCore : ImageDecoderCore
/// <param name="stream">The <see cref="BufferedReadStream"/> containing image data.</param>
private void ReadNetscapeApplicationExtensionData(BufferedReadStream stream)
{
int bytesRead = stream.Read(this.buffer.Span, 0, GifConstants.NetscapeLoopingSubBlockSize);
int bytesRead = stream.Read(this.buffer, 0, GifConstants.NetscapeLoopingSubBlockSize);
if (bytesRead != GifConstants.NetscapeLoopingSubBlockSize)
{
throw new InvalidImageContentException("Unexpected end of stream while reading gif application extension");
}
this.gifMetadata!.RepeatCount = GifNetscapeLoopingApplicationExtension.Parse(this.buffer.Span[1..]).RepeatCount;
this.gifMetadata!.RepeatCount = GifNetscapeLoopingApplicationExtension.Parse(this.buffer[1..]).RepeatCount;
int terminator = stream.ReadByte();
if (terminator == -1)
@ -996,12 +996,4 @@ internal sealed class GifDecoderCore : ImageDecoderCore
this.gifMetadata.BackgroundColor = globalColorTable.Value.Span[index];
}
}
private unsafe struct ScratchBuffer
{
private const int Size = 16;
private fixed byte scratch[Size];
public Span<byte> Span => MemoryMarshal.CreateSpan(ref this.scratch[0], Size);
}
}

6
src/ImageSharp/Formats/Ico/IcoConfigurationModule.cs

@ -1,12 +1,10 @@
// Copyright (c) Six Labors.
// Licensed under the Six Labors Split License.
using SixLabors.ImageSharp.Formats.Icon;
namespace SixLabors.ImageSharp.Formats.Ico;
/// <summary>
/// Registers the image encoders, decoders and mime type detectors for the Ico format.
/// Registers the image encoder, decoder, and format detector for the ICO format.
/// </summary>
public sealed class IcoConfigurationModule : IImageFormatConfigurationModule
{
@ -15,6 +13,6 @@ public sealed class IcoConfigurationModule : IImageFormatConfigurationModule
{
configuration.ImageFormatsManager.SetEncoder(IcoFormat.Instance, new IcoEncoder());
configuration.ImageFormatsManager.SetDecoder(IcoFormat.Instance, IcoDecoder.Instance);
configuration.ImageFormatsManager.AddImageFormatDetector(new IconImageFormatDetector());
configuration.ImageFormatsManager.AddImageFormatDetector(new IcoImageFormatDetector());
}
}

10
src/ImageSharp/Formats/Ico/IcoConstants.cs

@ -4,12 +4,12 @@
namespace SixLabors.ImageSharp.Formats.Ico;
/// <summary>
/// Defines constants relating to ICOs
/// Defines constants used by the ICO format.
/// </summary>
internal static class IcoConstants
{
/// <summary>
/// The list of mime types that equate to a ico.
/// The MIME types that identify ICO data.
/// </summary>
/// <remarks>
/// See <see href="https://en.wikipedia.org/wiki/ICO_(file_format)#MIME_type"/>
@ -27,13 +27,11 @@ internal static class IcoConstants
"image/ico",
"image/icon",
"text/ico",
"application/ico",
"application/ico"
];
/// <summary>
/// The list of file extensions that equate to a ico.
/// The file extensions that identify ICO data.
/// </summary>
public static readonly IEnumerable<string> FileExtensions = ["ico"];
public const uint FileHeader = 0x00_01_00_00;
}

7
src/ImageSharp/Formats/Ico/IcoDecoder.cs

@ -6,10 +6,13 @@ using SixLabors.ImageSharp.PixelFormats;
namespace SixLabors.ImageSharp.Formats.Ico;
/// <summary>
/// Decoder for generating an image out of a ico encoded stream.
/// Decoder for generating an image from an ICO encoded stream.
/// </summary>
public sealed class IcoDecoder : ImageDecoder
{
/// <summary>
/// Initializes a new instance of the <see cref="IcoDecoder"/> class.
/// </summary>
private IcoDecoder()
{
}
@ -34,7 +37,7 @@ public sealed class IcoDecoder : ImageDecoder
/// <inheritdoc/>
protected override Image Decode(DecoderOptions options, Stream stream, CancellationToken cancellationToken)
=> this.Decode<Rgba32>(options, stream, cancellationToken);
=> this.Decode<Rgba32>(options, stream, cancellationToken);
/// <inheritdoc/>
protected override ImageInfo Identify(DecoderOptions options, Stream stream, CancellationToken cancellationToken)

18
src/ImageSharp/Formats/Ico/IcoDecoderCore.cs

@ -7,13 +7,21 @@ using SixLabors.ImageSharp.Metadata;
namespace SixLabors.ImageSharp.Formats.Ico;
/// <summary>
/// Decodes ICO containers and maps directory metadata to ICO metadata.
/// </summary>
internal sealed class IcoDecoderCore : IconDecoderCore
{
/// <summary>
/// Initializes a new instance of the <see cref="IcoDecoderCore"/> class.
/// </summary>
/// <param name="options">The decoder options.</param>
public IcoDecoderCore(DecoderOptions options)
: base(options)
: base(options, IconFileType.ICO)
{
}
/// <inheritdoc/>
protected override void SetFrameMetadata(
ImageMetadata imageMetadata,
ImageFrameMetadata frameMetadata,
@ -31,10 +39,10 @@ internal sealed class IcoDecoderCore : IconDecoderCore
if (index == 0)
{
IcoMetadata curMetadata = imageMetadata.GetIcoMetadata();
curMetadata.Compression = compression;
curMetadata.BmpBitsPerPixel = bitsPerPixel;
curMetadata.ColorTable = colorTable;
IcoMetadata icoMetadata = imageMetadata.GetIcoMetadata();
icoMetadata.Compression = compression;
icoMetadata.BmpBitsPerPixel = bitsPerPixel;
icoMetadata.ColorTable = colorTable;
}
}
}

5
src/ImageSharp/Formats/Ico/IcoEncoder.cs

@ -10,8 +10,5 @@ public sealed class IcoEncoder : QuantizingImageEncoder
{
/// <inheritdoc/>
protected override void Encode<TPixel>(Image<TPixel> image, Stream stream, CancellationToken cancellationToken)
{
IcoEncoderCore encoderCore = new(this);
encoderCore.Encode(image, stream, cancellationToken);
}
=> new IcoEncoderCore(this).Encode(image, stream, cancellationToken);
}

33
src/ImageSharp/Formats/Ico/IcoEncoderCore.cs

@ -2,13 +2,46 @@
// Licensed under the Six Labors Split License.
using SixLabors.ImageSharp.Formats.Icon;
using SixLabors.ImageSharp.PixelFormats;
namespace SixLabors.ImageSharp.Formats.Ico;
/// <summary>
/// Encodes ICO containers using ICO frame metadata.
/// </summary>
internal sealed class IcoEncoderCore : IconEncoderCore
{
/// <summary>
/// Initializes a new instance of the <see cref="IcoEncoderCore"/> class.
/// </summary>
/// <param name="encoder">The encoder options.</param>
public IcoEncoderCore(QuantizingImageEncoder encoder)
: base(encoder, IconFileType.ICO)
{
}
/// <summary>
/// Encodes all source frames as an ICO resource.
/// </summary>
/// <typeparam name="TPixel">The source pixel type.</typeparam>
/// <param name="image">The source image.</param>
/// <param name="stream">The destination stream.</param>
/// <param name="cancellationToken">The token to monitor for cancellation requests.</param>
public void Encode<TPixel>(Image<TPixel> image, Stream stream, CancellationToken cancellationToken)
where TPixel : unmanaged, IPixel<TPixel>
=> this.Encode(image, stream, default(IcoFrameMetadataProvider), cancellationToken);
/// <summary>
/// Supplies ICO frame metadata to the shared container encoder.
/// </summary>
private readonly struct IcoFrameMetadataProvider : IEncodingFrameMetadataProvider
{
/// <inheritdoc/>
public EncodingFrameMetadata GetEncodingFrameMetadata(ImageFrame frame, out ReadOnlyMemory<Color>? colorTable)
{
IcoFrameMetadata metadata = frame.Metadata.GetIcoMetadata();
colorTable = metadata.ColorTable;
return new EncodingFrameMetadata(metadata.Compression, metadata.BmpBitsPerPixel, metadata.ToIconDirEntry(frame.Size));
}
}
}

5
src/ImageSharp/Formats/Ico/IcoFormat.cs

@ -8,6 +8,9 @@ namespace SixLabors.ImageSharp.Formats.Ico;
/// </summary>
public sealed class IcoFormat : IImageFormat<IcoMetadata, IcoFrameMetadata>
{
/// <summary>
/// Prevents a default instance of the <see cref="IcoFormat"/> class from being created.
/// </summary>
private IcoFormat()
{
}
@ -21,7 +24,7 @@ public sealed class IcoFormat : IImageFormat<IcoMetadata, IcoFrameMetadata>
public string Name => "ICO";
/// <inheritdoc/>
public string DefaultMimeType => IcoConstants.MimeTypes.First();
public string DefaultMimeType => "image/vnd.microsoft.icon";
/// <inheritdoc/>
public IEnumerable<string> MimeTypes => IcoConstants.MimeTypes;

49
src/ImageSharp/Formats/Ico/IcoFrameMetadata.cs

@ -9,7 +9,7 @@ using SixLabors.ImageSharp.PixelFormats;
namespace SixLabors.ImageSharp.Formats.Ico;
/// <summary>
/// Provides Ico specific metadata information for the image frame.
/// Provides ICO-specific metadata for an image frame.
/// </summary>
public class IcoFrameMetadata : IFormatFrameMetadata<IcoFrameMetadata>
{
@ -20,6 +20,10 @@ public class IcoFrameMetadata : IFormatFrameMetadata<IcoFrameMetadata>
{
}
/// <summary>
/// Initializes a new instance of the <see cref="IcoFrameMetadata"/> class by copying another instance.
/// </summary>
/// <param name="other">The metadata to copy.</param>
private IcoFrameMetadata(IcoFrameMetadata other)
{
this.Compression = other.Compression;
@ -34,19 +38,19 @@ public class IcoFrameMetadata : IFormatFrameMetadata<IcoFrameMetadata>
}
/// <summary>
/// Gets or sets the frame compressions format.
/// Gets or sets the frame compression format.
/// </summary>
public IconFrameCompression Compression { get; set; }
/// <summary>
/// Gets or sets the encoding width. <br />
/// Can be any number between 0 and 255. Value 0 means a frame height of 256 pixels or greater.
/// Gets or sets the encoded width.
/// A value of zero represents 256 pixels or greater.
/// </summary>
public byte? EncodingWidth { get; set; }
/// <summary>
/// Gets or sets the encoding height. <br />
/// Can be any number between 0 and 255. Value 0 means a frame height of 256 pixels or greater.
/// Gets or sets the encoded height.
/// A value of zero represents 256 pixels or greater.
/// </summary>
public byte? EncodingHeight { get; set; }
@ -127,12 +131,21 @@ public class IcoFrameMetadata : IFormatFrameMetadata<IcoFrameMetadata>
/// <inheritdoc/>
public IcoFrameMetadata DeepClone() => new(this);
/// <summary>
/// Copies the observable ICO directory values from an entry.
/// </summary>
/// <param name="entry">The source directory entry.</param>
internal void FromIconDirEntry(IconDirEntry entry)
{
this.EncodingWidth = entry.Width;
this.EncodingHeight = entry.Height;
}
/// <summary>
/// Creates an ICO directory entry from this metadata.
/// </summary>
/// <param name="size">The source frame size.</param>
/// <returns>The ICO directory entry.</returns>
internal IconDirEntry ToIconDirEntry(Size size)
{
byte colorCount = this.Compression == IconFrameCompression.Png || this.BmpBitsPerPixel > BmpBitsPerPixel.Bit8
@ -148,11 +161,15 @@ public class IcoFrameMetadata : IFormatFrameMetadata<IcoFrameMetadata>
BitCount = this.Compression switch
{
IconFrameCompression.Bmp => (ushort)this.BmpBitsPerPixel,
IconFrameCompression.Png or _ => 32,
},
IconFrameCompression.Png or _ => 32
}
};
}
/// <summary>
/// Gets the pixel layout represented by this metadata.
/// </summary>
/// <returns>The represented pixel layout.</returns>
private PixelTypeInfo GetPixelTypeInfo()
{
int bpp = (int)this.BmpBitsPerPixel;
@ -214,6 +231,13 @@ public class IcoFrameMetadata : IFormatFrameMetadata<IcoFrameMetadata>
};
}
/// <summary>
/// Scales an encoded dimension after an image transform.
/// </summary>
/// <param name="value">The encoded source dimension.</param>
/// <param name="destination">The full destination dimension.</param>
/// <param name="ratio">The destination-to-source scale ratio.</param>
/// <returns>The encoded destination dimension.</returns>
private static byte ScaleEncodingDimension(byte? value, int destination, float ratio)
{
if (value is null)
@ -221,9 +245,16 @@ public class IcoFrameMetadata : IFormatFrameMetadata<IcoFrameMetadata>
return ClampEncodingDimension(destination);
}
return ClampEncodingDimension(MathF.Ceiling(value.Value * ratio));
// A stored zero represents 256 pixels, so scaling must expand it before applying the transform ratio.
int source = value.Value is 0 ? 256 : value.Value;
return ClampEncodingDimension(MathF.Ceiling(source * ratio));
}
/// <summary>
/// Converts a pixel dimension to the one-byte ICO representation.
/// </summary>
/// <param name="dimension">The pixel dimension.</param>
/// <returns>The encoded dimension.</returns>
private static byte ClampEncodingDimension(float? dimension)
=> dimension switch
{

49
src/ImageSharp/Formats/Ico/IcoImageFormatDetector.cs

@ -0,0 +1,49 @@
// Copyright (c) Six Labors.
// Licensed under the Six Labors Split License.
using System.Diagnostics.CodeAnalysis;
using SixLabors.ImageSharp.Formats.Icon;
namespace SixLabors.ImageSharp.Formats.Ico;
/// <summary>
/// Detects ICO file headers.
/// </summary>
public sealed class IcoImageFormatDetector : IImageFormatDetector
{
/// <inheritdoc/>
public int HeaderSize => IconDir.Size + IconDirEntry.Size;
/// <inheritdoc/>
public bool TryDetectFormat(ReadOnlySpan<byte> header, [NotNullWhen(true)] out IImageFormat? format)
{
format = this.IsSupportedFileFormat(header) ? IcoFormat.Instance : null;
return format is not null;
}
/// <summary>
/// Determines whether the supplied header contains a valid ICO directory and first entry.
/// </summary>
/// <param name="header">The candidate file header.</param>
/// <returns><see langword="true"/> when the header identifies ICO data.</returns>
private bool IsSupportedFileFormat(ReadOnlySpan<byte> header)
{
if (header.Length < this.HeaderSize)
{
return false;
}
IconDir dir = IconDir.Parse(header);
if (dir is not { Reserved: 0, Type: IconFileType.ICO } || dir.Count is 0)
{
return false;
}
IconDirEntry entry = IconDirEntry.Parse(header[IconDir.Size..]);
// The first payload must begin after the complete directory, even when the caller supplied only the detection prefix.
return entry is { Reserved: 0, Planes: 0 or 1, BitCount: 1 or 4 or 8 or 16 or 24 or 32 }
&& entry.BytesInRes is not 0
&& entry.ImageOffset >= IconDir.Size + (dir.Count * IconDirEntry.Size);
}
}

6
src/ImageSharp/Formats/Ico/IcoMetadata.cs

@ -9,7 +9,7 @@ using SixLabors.ImageSharp.PixelFormats;
namespace SixLabors.ImageSharp.Formats.Ico;
/// <summary>
/// Provides Ico specific metadata information for the image.
/// Provides ICO-specific metadata for an image.
/// </summary>
public class IcoMetadata : IFormatMetadata<IcoMetadata>
{
@ -32,7 +32,7 @@ public class IcoMetadata : IFormatMetadata<IcoMetadata>
}
/// <summary>
/// Gets or sets the frame compressions format. Derived from the root frame.
/// Gets or sets the root frame compression format.
/// </summary>
public IconFrameCompression Compression { get; set; }
@ -43,7 +43,7 @@ public class IcoMetadata : IFormatMetadata<IcoMetadata>
public BmpBitsPerPixel BmpBitsPerPixel { get; set; } = BmpBitsPerPixel.Bit32;
/// <summary>
/// Gets or sets the color table, if any. Derived from the root frame.<br/>
/// Gets or sets the root frame color table, if any.<br/>
/// The underlying pixel format is represented by <see cref="Bgr24"/>.
/// </summary>
public ReadOnlyMemory<Color>? ColorTable { get; set; }

424
src/ImageSharp/Formats/Icon/IconDecoderCore.cs

@ -9,15 +9,28 @@ using SixLabors.ImageSharp.Metadata;
namespace SixLabors.ImageSharp.Formats.Icon;
/// <summary>
/// Decodes the shared ICO/CUR directory and embedded BMP or PNG frame payloads.
/// </summary>
internal abstract class IconDecoderCore : ImageDecoderCore
{
private readonly IconFileType iconFileType;
private IconDir fileHeader;
private IconDirEntry[]? entries;
protected IconDecoderCore(DecoderOptions options)
/// <summary>
/// Reusable storage for an icon directory entry and smaller fixed values.
/// </summary>
private InlineArray16<byte> buffer;
/// <summary>
/// Initializes a new instance of the <see cref="IconDecoderCore"/> class.
/// </summary>
/// <param name="options">The decoder options.</param>
/// <param name="iconFileType">The expected icon container type.</param>
protected IconDecoderCore(DecoderOptions options, IconFileType iconFileType)
: base(options)
{
}
=> this.iconFileType = iconFileType;
/// <inheritdoc />
protected override Image<TPixel> Decode<TPixel>(BufferedReadStream stream, CancellationToken cancellationToken)
@ -26,108 +39,125 @@ internal abstract class IconDecoderCore : ImageDecoderCore
long basePosition = stream.Position;
this.ReadHeader(stream);
Span<byte> flag = stackalloc byte[PngConstants.HeaderBytes.Length];
List<(Image<TPixel> Image, IconFrameCompression Compression, int Index)> decodedEntries
= new((int)Math.Min(this.entries.Length, this.Options.MaxFrames));
int entryCount = this.entries.Length;
(int EntryIndex, Image<TPixel> Image, IconFrameCompression Compression)[] decodedEntries = new (int, Image<TPixel>, IconFrameCompression)[entryCount];
int decodedCount = 0;
IconFrameStream frameStream = new(stream);
this.Dimensions = default;
for (int i = 0; i < this.entries.Length; i++)
try
{
if (i == this.Options.MaxFrames)
for (int i = 0; i < entryCount; i++)
{
break;
}
ref IconDirEntry entry = ref this.entries[i];
int entryIndex = i;
// If we hit the end of the stream we should break.
if (stream.Seek(basePosition + entry.ImageOffset, SeekOrigin.Begin) >= stream.Length)
{
break;
}
// There should always be enough bytes for this regardless of the entry type.
if (stream.Read(flag) != PngConstants.HeaderBytes.Length)
{
break;
}
this.ExecuteImageDataSegmentAction(() =>
{
cancellationToken.ThrowIfCancellationRequested();
// Reset the stream position.
_ = stream.Seek(-PngConstants.HeaderBytes.Length, SeekOrigin.Current);
ref IconDirEntry entry = ref this.entries[entryIndex];
this.SetFrameStreamBounds(frameStream, stream, basePosition, entry);
Span<byte> flag = this.buffer[..PngConstants.HeaderBytes.Length];
CheckEndOfStream(frameStream.Read(flag), flag.Length);
frameStream.Position = 0;
bool isPng = flag.SequenceEqual(PngConstants.HeaderBytes);
bool isPng = flag.SequenceEqual(PngConstants.HeaderBytes);
IconFrameCompression compression = isPng ? IconFrameCompression.Png : IconFrameCompression.Bmp;
// Decode the frame into a temp image buffer. This is disposed after the frame is copied to the result.
Image<TPixel> temp = this.GetDecoder(isPng).Decode<TPixel>(this.Options.Configuration, stream, cancellationToken);
decodedEntries.Add((temp, isPng ? IconFrameCompression.Png : IconFrameCompression.Bmp, i));
// Frames remain alive until the largest decoded dimensions are known and the common canvas can be allocated.
Image<TPixel> decoded = this.GetDecoder(isPng).Decode<TPixel>(this.Options.Configuration, frameStream, cancellationToken);
decodedEntries[decodedCount++] = (entryIndex, decoded, compression);
// Since Windows Vista, the size of an image is determined from the BITMAPINFOHEADER structure or PNG image data
// which technically allows storing icons with larger than 256 pixels, but such larger sizes are not recommended by Microsoft.
this.Dimensions = new Size(Math.Max(this.Dimensions.Width, temp.Size.Width), Math.Max(this.Dimensions.Height, temp.Size.Height));
}
// The embedded header is authoritative because a zero directory dimension can represent 256 pixels or a larger Vista-era PNG.
this.Dimensions = new(Math.Max(this.Dimensions.Width, decoded.Width), Math.Max(this.Dimensions.Height, decoded.Height));
});
}
ImageMetadata metadata = new();
BmpMetadata? bmpMetadata = null;
PngMetadata? pngMetadata = null;
Image<TPixel> result = new(this.Options.Configuration, metadata, decodedEntries.Select(x =>
{
BmpBitsPerPixel bitsPerPixel = BmpBitsPerPixel.Bit32;
ReadOnlyMemory<Color>? colorTable = null;
ImageFrame<TPixel> target = new(this.Options.Configuration, this.Dimensions);
ImageFrame<TPixel> source = x.Image.Frames.RootFrameUnsafe;
for (int y = 0; y < source.Height; y++)
if (decodedCount is 0)
{
source.PixelBuffer.DangerousGetRowSpan(y).CopyTo(target.PixelBuffer.DangerousGetRowSpan(y));
throw new InvalidImageContentException("The icon file does not contain any decodable image entries.");
}
// Copy the format specific frame metadata to the image.
if (x.Compression is IconFrameCompression.Png)
// General profiles belong to the icon result even though the first successfully decoded child image is temporary.
ImageMetadata metadata = decodedEntries[0].Image.Metadata.DeepClone();
BmpMetadata? bmpMetadata = null;
PngMetadata? pngMetadata = null;
ImageFrame<TPixel>[] frames = new ImageFrame<TPixel>[decodedCount];
int initializedFrameCount = 0;
try
{
if (x.Index == 0)
for (int i = 0; i < decodedCount; i++)
{
pngMetadata = x.Image.Metadata.GetPngMetadata();
BmpBitsPerPixel bitsPerPixel = BmpBitsPerPixel.Bit32;
ReadOnlyMemory<Color>? colorTable = null;
Image<TPixel> decoded = decodedEntries[i].Image;
ref IconDirEntry entry = ref this.entries[decodedEntries[i].EntryIndex];
ImageFrame<TPixel> source = decoded.Frames.RootFrameUnsafe;
ImageFrame<TPixel> target = new(this.Options.Configuration, this.Dimensions, source.Metadata.DeepClone());
frames[i] = target;
initializedFrameCount++;
for (int y = 0; y < source.Height; y++)
{
source.PixelBuffer.DangerousGetRowSpan(y).CopyTo(target.PixelBuffer.DangerousGetRowSpan(y));
}
// Preserve both the embedded format metadata and the ICO/CUR directory metadata on the output frame.
if (decodedEntries[i].Compression is IconFrameCompression.Png)
{
if (i == 0)
{
pngMetadata = decoded.Metadata.GetPngMetadata();
}
}
else
{
BmpMetadata currentBmpMetadata = decoded.Metadata.GetBmpMetadata();
bitsPerPixel = currentBmpMetadata.BitsPerPixel;
colorTable = currentBmpMetadata.ColorTable;
if (i == 0)
{
bmpMetadata = currentBmpMetadata;
}
}
this.SetFrameMetadata(metadata, target.Metadata, i, entry, decodedEntries[i].Compression, bitsPerPixel, colorTable);
}
target.Metadata.SetFormatMetadata(PngFormat.Instance, target.Metadata.GetPngMetadata());
}
else
{
BmpMetadata meta = x.Image.Metadata.GetBmpMetadata();
bitsPerPixel = meta.BitsPerPixel;
colorTable = meta.ColorTable;
if (x.Index == 0)
// Embedded metadata belongs to the container even though the temporary decoded images are disposed below.
if (bmpMetadata is not null)
{
bmpMetadata = meta;
metadata.SetFormatMetadata(BmpFormat.Instance, bmpMetadata);
}
}
this.SetFrameMetadata(
metadata,
target.Metadata,
x.Index,
this.entries[x.Index],
x.Compression,
bitsPerPixel,
colorTable);
x.Image.Dispose();
if (pngMetadata is not null)
{
metadata.SetFormatMetadata(PngFormat.Instance, pngMetadata);
}
return target;
}).ToArray());
Image<TPixel> result = new(this.Options.Configuration, metadata, frames);
// Copy the format specific metadata to the image.
if (bmpMetadata != null)
{
result.Metadata.SetFormatMetadata(BmpFormat.Instance, bmpMetadata);
// Ownership of every output frame transfers to the result only after construction succeeds.
initializedFrameCount = 0;
return result;
}
finally
{
for (int i = 0; i < initializedFrameCount; i++)
{
frames[i].Dispose();
}
}
}
if (pngMetadata != null)
finally
{
result.Metadata.SetFormatMetadata(PngFormat.Instance, pngMetadata);
for (int i = 0; i < decodedCount; i++)
{
decodedEntries[i].Image.Dispose();
}
}
return result;
}
/// <inheritdoc />
@ -137,87 +167,94 @@ internal abstract class IconDecoderCore : ImageDecoderCore
long basePosition = stream.Position;
this.ReadHeader(stream);
Span<byte> flag = stackalloc byte[PngConstants.HeaderBytes.Length];
ImageMetadata metadata = new();
BmpMetadata? bmpMetadata = null;
PngMetadata? pngMetadata = null;
ImageFrameMetadata[] frames = new ImageFrameMetadata[Math.Min(this.fileHeader.Count, this.Options.MaxFrames)];
int bpp = 0;
ImageFrameMetadata[] frames = new ImageFrameMetadata[this.entries.Length];
int frameCount = 0;
IconFrameStream frameStream = new(stream);
this.Dimensions = default;
for (int i = 0; i < frames.Length; i++)
{
BmpBitsPerPixel bitsPerPixel = BmpBitsPerPixel.Bit32;
ReadOnlyMemory<Color>? colorTable = null;
ref IconDirEntry entry = ref this.entries[i];
int entryIndex = i;
// If we hit the end of the stream we should break.
if (stream.Seek(basePosition + entry.ImageOffset, SeekOrigin.Begin) >= stream.Length)
this.ExecuteImageDataSegmentAction(() =>
{
break;
}
// There should always be enough bytes for this regardless of the entry type.
if (stream.Read(flag) != PngConstants.HeaderBytes.Length)
{
break;
}
cancellationToken.ThrowIfCancellationRequested();
// Reset the stream position.
_ = stream.Seek(-PngConstants.HeaderBytes.Length, SeekOrigin.Current);
BmpBitsPerPixel bitsPerPixel = BmpBitsPerPixel.Bit32;
ReadOnlyMemory<Color>? colorTable = null;
ref IconDirEntry entry = ref this.entries[entryIndex];
this.SetFrameStreamBounds(frameStream, stream, basePosition, entry);
Span<byte> flag = this.buffer[..PngConstants.HeaderBytes.Length];
CheckEndOfStream(frameStream.Read(flag), flag.Length);
frameStream.Position = 0;
bool isPng = flag.SequenceEqual(PngConstants.HeaderBytes);
bool isPng = flag.SequenceEqual(PngConstants.HeaderBytes);
ImageInfo frameInfo = this.GetDecoder(isPng).Identify(this.Options.Configuration, frameStream, cancellationToken);
ImageFrameMetadata frameMetadata = frameInfo.FrameMetadataCollection.Count is 0 ? new ImageFrameMetadata() : frameInfo.FrameMetadataCollection[0].DeepClone();
// Decode the frame into a temp image buffer. This is disposed after the frame is copied to the result.
ImageInfo frameInfo = this.GetDecoder(isPng).Identify(this.Options.Configuration, stream, cancellationToken);
ImageFrameMetadata frameMetadata = new();
if (isPng)
{
if (i == 0)
if (frameCount is 0)
{
pngMetadata = frameInfo.Metadata.GetPngMetadata();
// The container has one image-level metadata object, so the first valid entry supplies general profiles and resolution.
ImageMetadata sourceMetadata = frameInfo.Metadata;
metadata.HorizontalResolution = sourceMetadata.HorizontalResolution;
metadata.VerticalResolution = sourceMetadata.VerticalResolution;
metadata.ResolutionUnits = sourceMetadata.ResolutionUnits;
metadata.ExifProfile = sourceMetadata.ExifProfile?.DeepClone();
metadata.IccProfile = sourceMetadata.IccProfile?.DeepClone();
metadata.IptcProfile = sourceMetadata.IptcProfile?.DeepClone();
metadata.XmpProfile = sourceMetadata.XmpProfile?.DeepClone();
metadata.CicpProfile = sourceMetadata.CicpProfile?.DeepClone();
}
frameMetadata.SetFormatMetadata(PngFormat.Instance, frameInfo.FrameMetadataCollection[0].GetPngMetadata());
}
else
{
BmpMetadata meta = frameInfo.Metadata.GetBmpMetadata();
bitsPerPixel = meta.BitsPerPixel;
colorTable = meta.ColorTable;
if (i == 0)
if (isPng)
{
bmpMetadata = meta;
if (frameCount is 0)
{
pngMetadata = frameInfo.Metadata.GetPngMetadata();
}
}
else
{
BmpMetadata currentBmpMetadata = frameInfo.Metadata.GetBmpMetadata();
bitsPerPixel = currentBmpMetadata.BitsPerPixel;
colorTable = currentBmpMetadata.ColorTable;
if (frameCount is 0)
{
bmpMetadata = currentBmpMetadata;
}
}
}
bpp = Math.Max(bpp, (int)bitsPerPixel);
IconFrameCompression compression = isPng ? IconFrameCompression.Png : IconFrameCompression.Bmp;
this.SetFrameMetadata(metadata, frameMetadata, frameCount, entry, compression, bitsPerPixel, colorTable);
frames[frameCount++] = frameMetadata;
frames[i] = frameMetadata;
// Identification uses the same embedded-header dimensions as decoding, without allocating pixel buffers.
this.Dimensions = new(Math.Max(this.Dimensions.Width, frameInfo.Width), Math.Max(this.Dimensions.Height, frameInfo.Height));
});
}
this.SetFrameMetadata(
metadata,
frames[i],
i,
this.entries[i],
isPng ? IconFrameCompression.Png : IconFrameCompression.Bmp,
bitsPerPixel,
colorTable);
if (frameCount is 0)
{
throw new InvalidImageContentException("The icon file does not contain any identifiable image entries.");
}
// Since Windows Vista, the size of an image is determined from the BITMAPINFOHEADER structure or PNG image data
// which technically allows storing icons with larger than 256 pixels, but such larger sizes are not recommended by Microsoft.
this.Dimensions = new Size(Math.Max(this.Dimensions.Width, frameInfo.Size.Width), Math.Max(this.Dimensions.Height, frameInfo.Size.Height));
if (frameCount != frames.Length)
{
// Preserve successfully identified frames when truncated image data ends the scan before the declared directory count.
Array.Resize(ref frames, frameCount);
}
// Copy the format specific metadata to the image.
if (bmpMetadata != null)
if (bmpMetadata is not null)
{
metadata.SetFormatMetadata(BmpFormat.Instance, bmpMetadata);
}
if (pngMetadata != null)
if (pngMetadata is not null)
{
metadata.SetFormatMetadata(PngFormat.Instance, pngMetadata);
}
@ -225,6 +262,16 @@ internal abstract class IconDecoderCore : ImageDecoderCore
return new ImageInfo(this.Dimensions, metadata, frames);
}
/// <summary>
/// Copies format-specific directory and embedded-frame metadata to an output frame.
/// </summary>
/// <param name="imageMetadata">The output image metadata.</param>
/// <param name="frameMetadata">The output frame metadata.</param>
/// <param name="index">The directory entry index.</param>
/// <param name="entry">The directory entry.</param>
/// <param name="compression">The embedded frame compression.</param>
/// <param name="bitsPerPixel">The embedded bitmap bit depth.</param>
/// <param name="colorTable">The embedded bitmap color table.</param>
protected abstract void SetFrameMetadata(
ImageMetadata imageMetadata,
ImageFrameMetadata frameMetadata,
@ -234,58 +281,45 @@ internal abstract class IconDecoderCore : ImageDecoderCore
BmpBitsPerPixel bitsPerPixel,
ReadOnlyMemory<Color>? colorTable);
/// <summary>
/// Reads the icon directory entries needed by the configured frame limit.
/// </summary>
/// <param name="stream">The source stream.</param>
[MemberNotNull(nameof(entries))]
protected void ReadHeader(Stream stream)
private void ReadHeader(Stream stream)
{
Span<byte> buffer = stackalloc byte[IconDirEntry.Size];
Span<byte> buffer = this.buffer;
// ICONDIR
_ = CheckEndOfStream(stream.Read(buffer[..IconDir.Size]), IconDir.Size);
CheckEndOfStream(stream.Read(buffer[..IconDir.Size]), IconDir.Size);
this.fileHeader = IconDir.Parse(buffer);
if (this.fileHeader.Reserved != 0 || this.fileHeader.Type != this.iconFileType || this.fileHeader.Count == 0)
{
throw new InvalidImageContentException("The icon directory header is invalid.");
}
// ICONDIRENTRY
this.entries = new IconDirEntry[this.fileHeader.Count];
int entryCount = (int)Math.Min(this.fileHeader.Count, this.Options.MaxFrames);
this.entries = new IconDirEntry[entryCount];
for (int i = 0; i < this.entries.Length; i++)
{
_ = CheckEndOfStream(stream.Read(buffer[..IconDirEntry.Size]), IconDirEntry.Size);
CheckEndOfStream(stream.Read(buffer[..IconDirEntry.Size]), IconDirEntry.Size);
this.entries[i] = IconDirEntry.Parse(buffer);
}
int width = 0;
int height = 0;
foreach (IconDirEntry entry in this.entries)
{
// Since Windows 95 size of an image in the ICONDIRENTRY structure might
// be set to zero, which means 256 pixels.
if (entry.Width == 0)
{
width = 256;
}
if (entry.Height == 0)
{
height = 256;
}
if (width == 256 && height == 256)
{
break;
}
width = Math.Max(width, entry.Width);
height = Math.Max(height, entry.Height);
}
this.Dimensions = new Size(width, height);
}
/// <summary>
/// Creates the decoder configured for an embedded PNG or headerless, double-height bitmap frame.
/// </summary>
/// <param name="isPng">Whether the embedded frame has a PNG signature.</param>
/// <returns>The configured frame decoder.</returns>
private ImageDecoderCore GetDecoder(bool isPng)
{
if (isPng)
{
return new PngDecoderCore(new PngDecoderOptions
{
GeneralOptions = this.Options,
GeneralOptions = this.Options
});
}
@ -294,17 +328,59 @@ internal abstract class IconDecoderCore : ImageDecoderCore
GeneralOptions = this.Options,
ProcessedAlphaMask = true,
SkipFileHeader = true,
UseDoubleHeight = true,
UseDoubleHeight = true
});
}
private static int CheckEndOfStream(int v, int length)
/// <summary>
/// Creates a seekable view bounded to one directory entry's declared payload.
/// </summary>
/// <param name="frameStream">The reusable bounded payload stream.</param>
/// <param name="stream">The containing icon stream.</param>
/// <param name="basePosition">The absolute start of the icon resource.</param>
/// <param name="entry">The directory entry describing the payload.</param>
private void SetFrameStreamBounds(IconFrameStream frameStream, BufferedReadStream stream, long basePosition, in IconDirEntry entry)
{
if (v != length)
long available = stream.Length - basePosition;
uint directorySize = (uint)(IconDir.Size + (this.fileHeader.Count * IconDirEntry.Size));
// Offsets are relative to the icon resource and must not point into its directory or beyond its containing stream.
if (entry.Reserved is not 0
|| entry.BytesInRes is 0
|| entry.ImageOffset < directorySize
|| entry.ImageOffset > available)
{
throw new InvalidImageContentException("Not enough bytes to read icon header.");
throw new InvalidImageContentException("The icon directory contains an invalid image resource range.");
}
return v;
long remaining = available - entry.ImageOffset;
long length = entry.BytesInRes;
if (length > remaining)
{
if (this.fileHeader.Count is not 1)
{
// Clamping a multi-entry resource could expose the next image payload to the current child decoder.
throw new InvalidImageContentException("The icon directory contains an invalid image resource range.");
}
// Some established single-image ICO files overstate BytesInRes but contain a complete payload.
// The containing stream is still a safe hard boundary because no sibling image can follow it.
length = remaining;
}
frameStream.Reset(basePosition + entry.ImageOffset, length);
}
/// <summary>
/// Ensures that a complete fixed-size directory structure was read.
/// </summary>
/// <param name="bytesRead">The number of bytes read.</param>
/// <param name="expectedLength">The required structure length.</param>
private static void CheckEndOfStream(int bytesRead, int expectedLength)
{
if (bytesRead != expectedLength)
{
throw new InvalidImageContentException("Not enough bytes to read icon header.");
}
}
}

38
src/ImageSharp/Formats/Icon/IconDir.cs

@ -5,39 +5,65 @@ using System.Runtime.InteropServices;
namespace SixLabors.ImageSharp.Formats.Icon;
/// <summary>
/// Represents an ICO or CUR file directory header.
/// </summary>
[StructLayout(LayoutKind.Sequential, Pack = 1, Size = Size)]
internal struct IconDir(ushort reserved, IconFileType type, ushort count)
internal struct IconDir
{
/// <summary>
/// The serialized directory-header size in bytes.
/// </summary>
public const int Size = 3 * sizeof(ushort);
/// <summary>
/// Reserved. Must always be 0.
/// </summary>
public ushort Reserved = reserved;
public ushort Reserved;
/// <summary>
/// Specifies image type: 1 for icon (.ICO) image, 2 for cursor (.CUR) image. Other values are invalid.
/// </summary>
public IconFileType Type = type;
public IconFileType Type;
/// <summary>
/// Specifies number of images in the file.
/// </summary>
public ushort Count = count;
public ushort Count;
/// <summary>
/// Initializes a new instance of the <see cref="IconDir"/> struct.
/// </summary>
/// <param name="type">The icon file type.</param>
public IconDir(IconFileType type)
: this(type, 0)
{
}
/// <summary>
/// Initializes a new instance of the <see cref="IconDir"/> struct.
/// </summary>
/// <param name="type">The icon file type.</param>
/// <param name="count">The number of directory entries.</param>
public IconDir(IconFileType type, ushort count)
: this(0, type, count)
{
this.Reserved = 0;
this.Type = type;
this.Count = count;
}
/// <summary>
/// Parses an icon directory header from its byte representation.
/// </summary>
/// <param name="data">The icon directory header data.</param>
/// <returns>The parsed icon directory header.</returns>
public static IconDir Parse(ReadOnlySpan<byte> data)
=> MemoryMarshal.Cast<byte, IconDir>(data)[0];
public readonly unsafe void WriteTo(Stream stream)
/// <summary>
/// Writes the icon directory header to the destination stream.
/// </summary>
/// <param name="stream">The destination stream.</param>
public readonly void WriteTo(Stream stream)
=> stream.Write(MemoryMarshal.Cast<IconDir, byte>([this]));
}

20
src/ImageSharp/Formats/Icon/IconDirEntry.cs

@ -8,6 +8,9 @@ namespace SixLabors.ImageSharp.Formats.Icon;
[StructLayout(LayoutKind.Sequential, Pack = 1, Size = Size)]
internal struct IconDirEntry
{
/// <summary>
/// The serialized directory-entry size in bytes.
/// </summary>
public const int Size = (4 * sizeof(byte)) + (2 * sizeof(ushort)) + (2 * sizeof(uint));
/// <summary>
@ -16,7 +19,7 @@ internal struct IconDirEntry
public byte Width;
/// <summary>
/// Specifies image height in pixels. Can be any number between 0 and 255. Value 0 means image height is 256 pixels.[
/// Specifies image height in pixels. Can be any number between 0 and 255. Value 0 means image height is 256 pixels.
/// </summary>
public byte Height;
@ -43,7 +46,7 @@ internal struct IconDirEntry
public ushort BitCount;
/// <summary>
/// Specifies the size of the image's data in bytes
/// Specifies the size of the image's data in bytes.
/// </summary>
public uint BytesInRes;
@ -52,9 +55,18 @@ internal struct IconDirEntry
/// </summary>
public uint ImageOffset;
public static IconDirEntry Parse(in ReadOnlySpan<byte> data)
/// <summary>
/// Parses an icon directory entry from its byte representation.
/// </summary>
/// <param name="data">The icon directory entry data.</param>
/// <returns>The parsed icon directory entry.</returns>
public static IconDirEntry Parse(ReadOnlySpan<byte> data)
=> MemoryMarshal.Cast<byte, IconDirEntry>(data)[0];
public readonly unsafe void WriteTo(in Stream stream)
/// <summary>
/// Writes the icon directory entry to the destination stream.
/// </summary>
/// <param name="stream">The destination stream.</param>
public readonly void WriteTo(Stream stream)
=> stream.Write(MemoryMarshal.Cast<IconDirEntry, byte>([this]));
}

281
src/ImageSharp/Formats/Icon/IconEncoderCore.cs

@ -1,111 +1,216 @@
// Copyright (c) Six Labors.
// Licensed under the Six Labors Split License.
using System.Diagnostics.CodeAnalysis;
using System.Buffers;
using SixLabors.ImageSharp.Formats.Bmp;
using SixLabors.ImageSharp.Formats.Cur;
using SixLabors.ImageSharp.Formats.Ico;
using SixLabors.ImageSharp.Formats.Png;
using SixLabors.ImageSharp.Memory;
using SixLabors.ImageSharp.Metadata;
using SixLabors.ImageSharp.PixelFormats;
using SixLabors.ImageSharp.Processing.Processors.Quantization;
namespace SixLabors.ImageSharp.Formats.Icon;
/// <summary>
/// Encodes ICO and CUR containers.
/// </summary>
internal abstract class IconEncoderCore
{
private readonly QuantizingImageEncoder encoder;
private readonly IconFileType iconFileType;
private IconDir fileHeader;
private EncodingFrameMetadata[]? entries;
/// <summary>
/// Initializes a new instance of the <see cref="IconEncoderCore"/> class.
/// </summary>
/// <param name="encoder">The encoder options.</param>
/// <param name="iconFileType">The icon container type.</param>
protected IconEncoderCore(QuantizingImageEncoder encoder, IconFileType iconFileType)
{
this.encoder = encoder;
this.iconFileType = iconFileType;
}
public void Encode<TPixel>(
Image<TPixel> image,
Stream stream,
CancellationToken cancellationToken)
/// <summary>
/// Supplies icon directory and color-table metadata without allocating intermediary metadata objects.
/// </summary>
internal interface IEncodingFrameMetadataProvider
{
/// <summary>
/// Gets the encoding metadata for a source frame.
/// </summary>
/// <param name="frame">The source frame.</param>
/// <param name="colorTable">The optional bitmap color table.</param>
/// <returns>The encoding metadata.</returns>
public EncodingFrameMetadata GetEncodingFrameMetadata(ImageFrame frame, out ReadOnlyMemory<Color>? colorTable);
}
/// <summary>
/// Encodes all source frames using the metadata provider owned by the concrete icon format.
/// </summary>
/// <typeparam name="TPixel">The source pixel type.</typeparam>
/// <typeparam name="TProvider">The metadata provider type.</typeparam>
/// <param name="image">The source image.</param>
/// <param name="stream">The destination stream.</param>
/// <param name="provider">The frame metadata provider.</param>
/// <param name="cancellationToken">The token to monitor for cancellation requests.</param>
protected void Encode<TPixel, TProvider>(Image<TPixel> image, Stream stream, TProvider provider, CancellationToken cancellationToken)
where TPixel : unmanaged, IPixel<TPixel>
where TProvider : struct, IEncodingFrameMetadataProvider
{
Guard.NotNull(image, nameof(image));
Guard.NotNull(stream, nameof(stream));
// Stream may not at 0.
// Directory metadata is unmanaged and short-lived, so allocator-owned storage avoids an array plus one object per frame.
using IMemoryOwner<EncodingFrameMetadata> owner = image.Configuration.MemoryAllocator.Allocate<EncodingFrameMetadata>(image.Frames.Count);
Span<EncodingFrameMetadata> entries = owner.GetSpan()[..image.Frames.Count];
this.Encode(image, stream, 0, entries, provider, cancellationToken);
}
/// <summary>
/// Encodes a contiguous source-frame range using a stack-only metadata provider.
/// </summary>
/// <typeparam name="TPixel">The source pixel type.</typeparam>
/// <typeparam name="TProvider">The metadata provider type.</typeparam>
/// <param name="image">The source image.</param>
/// <param name="stream">The destination stream.</param>
/// <param name="frameIndex">The first source-frame index.</param>
/// <param name="entries">The directory metadata for the source frames.</param>
/// <param name="provider">The frame metadata provider.</param>
/// <param name="cancellationToken">The token to monitor for cancellation requests.</param>
internal void Encode<TPixel, TProvider>(Image<TPixel> image, Stream stream, int frameIndex, Span<EncodingFrameMetadata> entries, TProvider provider, CancellationToken cancellationToken)
where TPixel : unmanaged, IPixel<TPixel>
where TProvider : struct, IEncodingFrameMetadataProvider
{
if ((uint)entries.Length > ushort.MaxValue)
{
throw new ImageFormatException("ICO and CUR resources cannot contain more than 65535 directory entries.");
}
// Offsets stored in ICO/CUR entries are relative to the start of this child resource, not the containing ANI stream.
long basePosition = stream.Position;
this.InitHeader(image);
IconDir fileHeader = new(this.iconFileType, (ushort)entries.Length);
// We don't write the header and entries yet as we need to write the image data first.
int dataOffset = IconDir.Size + (IconDirEntry.Size * this.entries.Length);
// Reserve the directory first because BytesInRes and ImageOffset are known only after each payload is encoded.
int dataOffset = IconDir.Size + (IconDirEntry.Size * entries.Length);
_ = stream.Seek(dataOffset, SeekOrigin.Current);
for (int i = 0; i < image.Frames.Count; i++)
for (int i = 0; i < entries.Length; i++)
{
cancellationToken.ThrowIfCancellationRequested();
// Since Windows Vista, the size of an image is determined from the BITMAPINFOHEADER structure or PNG image data
// which technically allows storing icons with larger than 256 pixels, but such larger sizes are not recommended by Microsoft.
ImageFrame<TPixel> frame = image.Frames[i];
int width = this.entries[i].Entry.Width;
ImageFrame<TPixel> frame = image.Frames[frameIndex + i];
// The struct provider is statically dispatched, avoiding boxing and intermediary ICO/CUR metadata allocations.
// Only unmanaged directory data survives until backpatching; the managed color table is consumed for this frame.
entries[i] = provider.GetEncodingFrameMetadata(frame, out ReadOnlyMemory<Color>? colorTable);
int width = entries[i].Entry.Width;
if (width is 0)
{
width = frame.Width;
}
int height = this.entries[i].Entry.Height;
int height = entries[i].Entry.Height;
if (height is 0)
{
height = frame.Height;
}
this.entries[i].Entry.ImageOffset = (uint)stream.Position;
// We crop the frame to the size specified in the metadata.
using Image<TPixel> encodingFrame = new(width, height);
for (int y = 0; y < height; y++)
if (width > frame.Width || height > frame.Height)
{
frame.PixelBuffer.DangerousGetRowSpan(y)[..width]
.CopyTo(encodingFrame.GetRootFramePixelBuffer().DangerousGetRowSpan(y));
// EncodingWidth and EncodingHeight are public metadata, so reject a crop that exceeds the source frame here.
throw new ImageFormatException("The icon encoding dimensions exceed the source frame dimensions.");
}
ref EncodingFrameMetadata encodingMetadata = ref this.entries[i];
long imageStart = stream.Position;
entries[i].Entry.ImageOffset = checked((uint)(imageStart - basePosition));
ref EncodingFrameMetadata encodingMetadata = ref entries[i];
Image<TPixel>? encodingImage = null;
QuantizingImageEncoder encoder = encodingMetadata.Compression switch
try
{
IconFrameCompression.Bmp => new BmpEncoder()
bool requiresCrop = width != frame.Width || height != frame.Height;
bool requiresIsolatedImage = encodingMetadata.Compression is IconFrameCompression.Png && image.Frames.Count > 1;
if (requiresCrop || requiresIsolatedImage)
{
Quantizer = this.GetQuantizer(encodingMetadata),
ProcessedAlphaMask = true,
UseDoubleHeight = true,
SkipFileHeader = true,
SupportTransparency = false,
TransparentColorMode = this.encoder.TransparentColorMode,
PixelSamplingStrategy = this.encoder.PixelSamplingStrategy,
BitsPerPixel = encodingMetadata.BmpBitsPerPixel
},
IconFrameCompression.Png => new PngEncoder()
// PNG accepts Image rather than ImageFrame, and ANI variants may occupy only part of their common canvas.
// Allocate only for those cases; full-sized BMP frames can be encoded directly from their existing storage.
ImageMetadata? metadata = this.encoder.SkipMetadata || encodingMetadata.Compression is not IconFrameCompression.Png ? null : image.Metadata.DeepClone();
encodingImage = new Image<TPixel>(image.Configuration, width, height, metadata);
for (int y = 0; y < height; y++)
{
frame.PixelBuffer.DangerousGetRowSpan(y)[..width].CopyTo(encodingImage.GetRootFramePixelBuffer().DangerousGetRowSpan(y));
}
if (!this.encoder.SkipMetadata && encodingMetadata.Compression is IconFrameCompression.Png)
{
encodingImage.Frames.RootFrame.Metadata.SetFormatMetadata(PngFormat.Instance, frame.Metadata.GetPngMetadata().DeepClone());
}
}
ImageFrame<TPixel> sourceFrame = encodingImage?.Frames.RootFrame ?? frame;
// Compression and bitmap depth are per-entry, so the concrete encoder configuration must be selected per frame.
switch (encodingMetadata.Compression)
{
// Only 32bit Png supported.
// https://devblogs.microsoft.com/oldnewthing/20101022-00/?p=12473
BitDepth = PngBitDepth.Bit8,
ColorType = PngColorType.RgbWithAlpha,
TransparentColorMode = this.encoder.TransparentColorMode,
CompressionLevel = PngCompressionLevel.BestCompression
},
_ => throw new NotSupportedException(),
};
encoder.Encode(encodingFrame, stream);
encodingMetadata.Entry.BytesInRes = (uint)stream.Position - encodingMetadata.Entry.ImageOffset;
case IconFrameCompression.Bmp:
{
BmpEncoder bmpEncoder = new()
{
Quantizer = this.GetQuantizer(encodingMetadata, colorTable),
ProcessedAlphaMask = true,
UseDoubleHeight = true,
SkipFileHeader = true,
SupportTransparency = false,
TransparentColorMode = this.encoder.TransparentColorMode,
PixelSamplingStrategy = this.encoder.PixelSamplingStrategy,
BitsPerPixel = encodingMetadata.BmpBitsPerPixel,
SkipMetadata = this.encoder.SkipMetadata
};
BmpEncoderCore bmpEncoderCore = new(bmpEncoder, image.Configuration.MemoryAllocator);
bmpEncoderCore.Encode(sourceFrame, image.Metadata, stream, cancellationToken);
break;
}
case IconFrameCompression.Png:
{
PngEncoder pngEncoder = new()
{
// Only 32bit Png supported.
// https://devblogs.microsoft.com/oldnewthing/20101022-00/?p=12473
BitDepth = PngBitDepth.Bit8,
ColorType = PngColorType.RgbWithAlpha,
TransparentColorMode = this.encoder.TransparentColorMode,
CompressionLevel = PngCompressionLevel.BestCompression,
SkipMetadata = this.encoder.SkipMetadata
};
using PngEncoderCore pngEncoderCore = new(image.Configuration, pngEncoder);
pngEncoderCore.Encode(encodingImage ?? image, stream, cancellationToken);
break;
}
default:
throw new NotSupportedException();
}
}
finally
{
encodingImage?.Dispose();
}
encodingMetadata.Entry.BytesInRes = checked((uint)(stream.Position - imageStart));
}
// We now need to rewind the stream and write the header and the entries.
// Backpatch the reserved directory after every relative offset and payload length has been measured.
long endPosition = stream.Position;
_ = stream.Seek(basePosition, SeekOrigin.Begin);
this.fileHeader.WriteTo(stream);
foreach (EncodingFrameMetadata frame in this.entries)
fileHeader.WriteTo(stream);
foreach (EncodingFrameMetadata frame in entries)
{
frame.Entry.WriteTo(stream);
}
@ -113,31 +218,16 @@ internal abstract class IconEncoderCore
_ = stream.Seek(endPosition, SeekOrigin.Begin);
}
[MemberNotNull(nameof(entries))]
private void InitHeader(Image image)
/// <summary>
/// Gets the quantizer for an embedded bitmap frame.
/// </summary>
/// <param name="metadata">The frame encoding metadata.</param>
/// <param name="colorTable">The optional bitmap color table.</param>
/// <returns>The configured quantizer, or <see langword="null"/> when quantization is not required.</returns>
private IQuantizer? GetQuantizer(EncodingFrameMetadata metadata, ReadOnlyMemory<Color>? colorTable)
{
this.fileHeader = new IconDir(this.iconFileType, (ushort)image.Frames.Count);
this.entries = this.iconFileType switch
{
IconFileType.ICO =>
[.. image.Frames.Select(i =>
{
IcoFrameMetadata metadata = i.Metadata.GetIcoMetadata();
return new EncodingFrameMetadata(metadata.Compression, metadata.BmpBitsPerPixel, metadata.ColorTable, metadata.ToIconDirEntry(i.Size));
})],
IconFileType.CUR =>
[.. image.Frames.Select(i =>
{
CurFrameMetadata metadata = i.Metadata.GetCurMetadata();
return new EncodingFrameMetadata(metadata.Compression, metadata.BmpBitsPerPixel, metadata.ColorTable, metadata.ToIconDirEntry(i.Size));
})],
_ => throw new NotSupportedException(),
};
}
private IQuantizer? GetQuantizer(EncodingFrameMetadata metadata)
{
if (metadata.Entry.BitCount > 8)
// CUR stores its vertical hotspot in Entry.BitCount, so quantization must use the independent bitmap depth.
if (metadata.BmpBitsPerPixel > BmpBitsPerPixel.Bit8)
{
return null;
}
@ -147,7 +237,7 @@ internal abstract class IconEncoderCore
return this.encoder.Quantizer;
}
if (metadata.ColorTable is null)
if (colorTable is null)
{
int count = metadata.Entry.ColorCount;
if (count == 0)
@ -162,33 +252,42 @@ internal abstract class IconEncoderCore
}
// Don't dither if we have a palette. We want to preserve as much information as possible.
return new PaletteQuantizer(metadata.ColorTable.Value, new QuantizerOptions { Dither = null });
return new PaletteQuantizer(colorTable.Value, new QuantizerOptions { Dither = null });
}
internal sealed class EncodingFrameMetadata
/// <summary>
/// Stores the unmanaged per-frame state required while an icon directory is backpatched.
/// </summary>
internal struct EncodingFrameMetadata
{
private IconDirEntry iconDirEntry;
/// <summary>
/// The icon directory entry.
/// </summary>
public IconDirEntry Entry;
public EncodingFrameMetadata(
IconFrameCompression compression,
BmpBitsPerPixel bmpBitsPerPixel,
ReadOnlyMemory<Color>? colorTable,
IconDirEntry iconDirEntry)
/// <summary>
/// Initializes a new instance of the <see cref="EncodingFrameMetadata"/> struct.
/// </summary>
/// <param name="compression">The embedded image compression.</param>
/// <param name="bmpBitsPerPixel">The bitmap bit depth.</param>
/// <param name="iconDirEntry">The icon directory entry.</param>
public EncodingFrameMetadata(IconFrameCompression compression, BmpBitsPerPixel bmpBitsPerPixel, IconDirEntry iconDirEntry)
{
this.Compression = compression;
this.BmpBitsPerPixel = compression == IconFrameCompression.Png
? BmpBitsPerPixel.Bit32
: bmpBitsPerPixel;
this.ColorTable = colorTable;
this.iconDirEntry = iconDirEntry;
this.Entry = iconDirEntry;
}
/// <summary>
/// Gets the embedded image compression.
/// </summary>
public IconFrameCompression Compression { get; }
/// <summary>
/// Gets the bitmap bit depth.
/// </summary>
public BmpBitsPerPixel BmpBitsPerPixel { get; }
public ReadOnlyMemory<Color>? ColorTable { get; set; }
public ref IconDirEntry Entry => ref this.iconDirEntry;
}
}

8
src/ImageSharp/Formats/Icon/IconFileType.cs

@ -4,17 +4,17 @@
namespace SixLabors.ImageSharp.Formats.Icon;
/// <summary>
/// Ico file type
/// Identifies the type stored in an ICO or CUR directory header.
/// </summary>
internal enum IconFileType : ushort
{
/// <summary>
/// ICO file
/// A Windows icon file.
/// </summary>
ICO = 1,
/// <summary>
/// CUR file
/// A Windows cursor file.
/// </summary>
CUR = 2,
CUR = 2
}

6
src/ImageSharp/Formats/Icon/IconFrameCompression.cs

@ -4,17 +4,17 @@
namespace SixLabors.ImageSharp.Formats.Icon;
/// <summary>
/// IconFrameCompression
/// Specifies the encoding used for an image embedded in an ICO or CUR resource.
/// </summary>
public enum IconFrameCompression
{
/// <summary>
/// Bmp
/// The image is encoded as a headerless Windows bitmap with an AND transparency mask.
/// </summary>
Bmp,
/// <summary>
/// Png
/// The image is encoded as PNG data.
/// </summary>
Png
}

114
src/ImageSharp/Formats/Icon/IconFrameStream.cs

@ -0,0 +1,114 @@
// Copyright (c) Six Labors.
// Licensed under the Six Labors Split License.
namespace SixLabors.ImageSharp.Formats.Icon;
/// <summary>
/// Exposes one ICO or CUR directory entry as an isolated seekable stream.
/// </summary>
/// <remarks>
/// Embedded decoders accept seek offsets from their own headers. Bounding those seeks to <c>BytesInRes</c>
/// prevents a malformed BMP or PNG payload from consuming an adjacent icon resource.
/// </remarks>
internal sealed class IconFrameStream : Stream
{
private readonly Stream stream;
// start is absolute in the containing stream; position is always relative to this bounded resource.
private long start;
private long length;
private long position;
/// <summary>
/// Initializes a new instance of the <see cref="IconFrameStream"/> class.
/// </summary>
/// <param name="stream">The containing icon stream.</param>
public IconFrameStream(Stream stream)
=> this.stream = stream;
/// <inheritdoc/>
public override bool CanRead => true;
/// <inheritdoc/>
public override bool CanSeek => true;
/// <inheritdoc/>
public override bool CanWrite => false;
/// <inheritdoc/>
public override long Length => this.length;
/// <inheritdoc/>
public override long Position
{
get => this.position;
set => this.Seek(value, SeekOrigin.Begin);
}
/// <summary>
/// Repositions this stream over another image payload in the same containing stream.
/// </summary>
/// <param name="start">The absolute start of the image payload.</param>
/// <param name="length">The image payload length.</param>
public void Reset(long start, long length)
{
this.start = start;
this.length = length;
this.position = 0;
}
/// <inheritdoc/>
public override void Flush()
{
}
/// <inheritdoc/>
public override int Read(byte[] buffer, int offset, int count)
=> this.Read(buffer.AsSpan(offset, count));
/// <inheritdoc/>
public override int Read(Span<byte> buffer)
{
// Clamp every read to the entry boundary so a child decoder cannot consume the next resource.
int count = (int)Math.Min(buffer.Length, this.length - this.position);
if (count is 0)
{
return 0;
}
// The containing stream is shared by all entries, so synchronize its absolute position immediately before reading.
this.stream.Position = this.start + this.position;
int read = this.stream.Read(buffer[..count]);
this.position += read;
return read;
}
/// <inheritdoc/>
public override long Seek(long offset, SeekOrigin origin)
{
long target = origin switch
{
SeekOrigin.Begin => offset,
SeekOrigin.Current => this.position + offset,
SeekOrigin.End => this.length + offset,
_ => throw new ArgumentOutOfRangeException(nameof(origin))
};
// Casting rejects both negative offsets and offsets beyond Length with one bounds check.
if ((ulong)target > (ulong)this.length)
{
throw new InvalidImageContentException("The embedded icon resource contains an invalid seek offset.");
}
// Delay moving the containing stream until Read; this keeps logical seeks isolated from sibling resources.
this.position = target;
return target;
}
/// <inheritdoc/>
public override void SetLength(long value) => throw new NotSupportedException();
/// <inheritdoc/>
public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException();
}

66
src/ImageSharp/Formats/Icon/IconImageFormatDetector.cs

@ -1,66 +0,0 @@
// Copyright (c) Six Labors.
// Licensed under the Six Labors Split License.
using System.Diagnostics.CodeAnalysis;
namespace SixLabors.ImageSharp.Formats.Icon;
/// <summary>
/// Detects ico file headers.
/// </summary>
public class IconImageFormatDetector : IImageFormatDetector
{
/// <inheritdoc/>
public int HeaderSize { get; } = IconDir.Size + IconDirEntry.Size;
/// <inheritdoc/>
public bool TryDetectFormat(ReadOnlySpan<byte> header, [NotNullWhen(true)] out IImageFormat? format)
{
format = this.IsSupportedFileFormat(header) switch
{
true => Ico.IcoFormat.Instance,
false => Cur.CurFormat.Instance,
null => default
};
return format is not null;
}
private bool? IsSupportedFileFormat(ReadOnlySpan<byte> header)
{
// There are no magic bytes in the first few bytes of a tga file,
// so we try to figure out if its a valid tga by checking for valid tga header bytes.
if (header.Length < this.HeaderSize)
{
return null;
}
IconDir dir = IconDir.Parse(header);
if (dir is not { Reserved: 0 } // Should be 0.
or not { Type: IconFileType.ICO or IconFileType.CUR } // Unknown Type.
or { Count: 0 })
{
return null;
}
IconDirEntry entry = IconDirEntry.Parse(header[IconDir.Size..]);
if (entry is not { Reserved: 0 } // Should be 0.
or { BytesInRes: 0 } // Should not be 0.
|| entry.ImageOffset < IconDir.Size + (dir.Count * IconDirEntry.Size))
{
return null;
}
if (dir.Type is IconFileType.ICO)
{
if (entry is not { BitCount: 1 or 4 or 8 or 16 or 24 or 32 } or not { Planes: 0 or 1 })
{
return null;
}
return true;
}
return false;
}
}

7
src/ImageSharp/Formats/Jpeg/Components/Decoder/ArithmeticScanDecoder.cs

@ -55,7 +55,7 @@ internal class ArithmeticScanDecoder : IJpegScanDecoder
private ArithmeticDecodingTable[] acDecodingTables;
// Don't make this a ReadOnlySpan<byte>, as the values need to get updated.
private readonly byte[] fixedBin = [113, 0, 0, 0];
private InlineArray4<byte> fixedBin;
private readonly CancellationToken cancellationToken;
@ -194,6 +194,9 @@ internal class ArithmeticScanDecoder : IJpegScanDecoder
this.spectralConverter = converter;
this.cancellationToken = cancellationToken;
// Inline storage is zero-initialized with the decoder; only the arithmetic probability state starts nonzero.
this.fixedBin[0] = 113;
this.c = 0;
this.a = 0;
this.ct = -16; // Force reading 2 initial bytes to fill C.
@ -233,7 +236,7 @@ internal class ArithmeticScanDecoder : IJpegScanDecoder
}
}
private ref byte GetFixedBinReference() => ref MemoryMarshal.GetArrayDataReference(this.fixedBin);
private ref byte GetFixedBinReference() => ref this.fixedBin[0];
/// <inheritdoc/>
public void ParseEntropyCodedData(int scanComponentCount, IccProfile iccProfile)

12
src/ImageSharp/Formats/Jpeg/Components/Decoder/HuffmanTable.cs

@ -10,7 +10,7 @@ namespace SixLabors.ImageSharp.Formats.Jpeg.Components.Decoder;
/// Represents a Huffman coding table containing basic coding data plus tables for accelerated computation.
/// </summary>
[StructLayout(LayoutKind.Sequential)]
internal unsafe struct HuffmanTable
internal struct HuffmanTable
{
/// <summary>
/// Memory workspace buffer size used in <see cref="HuffmanTable"/> ctor.
@ -20,25 +20,25 @@ internal unsafe struct HuffmanTable
/// <summary>
/// Derived from the DHT marker. Contains the symbols, in order of incremental code length.
/// </summary>
public fixed byte Values[256];
public InlineArray256<byte> Values;
/// <summary>
/// Contains the largest code of length k (0 if none). MaxCode[17] is a sentinel to
/// ensure <see cref="JpegBitReader.DecodeHuffman"/> terminates.
/// </summary>
public fixed ulong MaxCode[18];
public InlineArray18<ulong> MaxCode;
/// <summary>
/// Values[] offset for codes of length k ValOffset[k] = Values[] index of 1st symbol of code length
/// k, less the smallest code of length k; so given a code of length k, the corresponding symbol is
/// Values[code + ValOffset[k]].
/// </summary>
public fixed int ValOffset[19];
public InlineArray19<int> ValOffset;
/// <summary>
/// Contains the length of bits for the given k value.
/// </summary>
public fixed byte LookaheadSize[JpegConstants.Huffman.LookupSize];
public InlineArray256<byte> LookaheadSize;
/// <summary>
/// Lookahead table: indexed by the next <see cref="JpegConstants.Huffman.LookupBits"/> bits of
@ -50,7 +50,7 @@ internal unsafe struct HuffmanTable
/// bits in the corresponding Huffman code, or <see cref="JpegConstants.Huffman.LookupBits"/> + 1
/// if too long. The next 8 bits of each entry contain the symbol.
/// </summary>
public fixed byte LookaheadValue[JpegConstants.Huffman.LookupSize];
public InlineArray256<byte> LookaheadValue;
/// <summary>
/// Initializes a new instance of the <see cref="HuffmanTable"/> struct.

42
src/ImageSharp/Formats/Png/PngEncoderCore.cs

@ -43,7 +43,7 @@ internal sealed class PngEncoderCore : IDisposable
/// <summary>
/// Reusable buffer for writing chunk data.
/// </summary>
private ScratchBuffer chunkDataBuffer; // mutable struct, don't make readonly
private InlineArray26<byte> chunkDataBuffer; // mutable struct, don't make readonly
/// <summary>
/// The encoder with options
@ -734,9 +734,9 @@ internal sealed class PngEncoderCore : IDisposable
filterMethod: 0,
interlaceMethod: this.interlaceMode);
header.WriteTo(this.chunkDataBuffer.Span);
header.WriteTo(this.chunkDataBuffer);
this.WriteChunk(stream, PngChunkType.Header, this.chunkDataBuffer.Span, 0, PngHeader.Size);
this.WriteChunk(stream, PngChunkType.Header, this.chunkDataBuffer, 0, PngHeader.Size);
}
/// <summary>
@ -749,9 +749,9 @@ internal sealed class PngEncoderCore : IDisposable
{
AnimationControl acTL = new(framesCount, playsCount);
acTL.WriteTo(this.chunkDataBuffer.Span);
acTL.WriteTo(this.chunkDataBuffer);
this.WriteChunk(stream, PngChunkType.AnimationControl, this.chunkDataBuffer.Span, 0, AnimationControl.Size);
this.WriteChunk(stream, PngChunkType.AnimationControl, this.chunkDataBuffer, 0, AnimationControl.Size);
}
/// <summary>
@ -820,9 +820,9 @@ internal sealed class PngEncoderCore : IDisposable
return;
}
PngPhysical.FromMetadata(meta).WriteTo(this.chunkDataBuffer.Span);
PngPhysical.FromMetadata(meta).WriteTo(this.chunkDataBuffer);
this.WriteChunk(stream, PngChunkType.Physical, this.chunkDataBuffer.Span, 0, PngPhysical.Size);
this.WriteChunk(stream, PngChunkType.Physical, this.chunkDataBuffer, 0, PngPhysical.Size);
}
/// <summary>
@ -1067,7 +1067,7 @@ internal sealed class PngEncoderCore : IDisposable
throw new NotSupportedException("CICP matrix coefficients other than Identity are not supported in PNG");
}
Span<byte> outputBytes = this.chunkDataBuffer.Span[..4];
Span<byte> outputBytes = this.chunkDataBuffer[..4];
outputBytes[0] = (byte)metaData.CicpProfile.ColorPrimaries;
outputBytes[1] = (byte)metaData.CicpProfile.TransferCharacteristics;
outputBytes[2] = (byte)metaData.CicpProfile.MatrixCoefficients;
@ -1222,9 +1222,9 @@ internal sealed class PngEncoderCore : IDisposable
// 4-byte unsigned integer of gamma * 100,000.
uint gammaValue = (uint)(this.gamma * 100_000F);
BinaryPrimitives.WriteUInt32BigEndian(this.chunkDataBuffer.Span[..4], gammaValue);
BinaryPrimitives.WriteUInt32BigEndian(this.chunkDataBuffer[..4], gammaValue);
this.WriteChunk(stream, PngChunkType.Gamma, this.chunkDataBuffer.Span, 0, 4);
this.WriteChunk(stream, PngChunkType.Gamma, this.chunkDataBuffer, 0, 4);
}
}
@ -1241,7 +1241,7 @@ internal sealed class PngEncoderCore : IDisposable
return;
}
Span<byte> alpha = this.chunkDataBuffer.Span;
Span<byte> alpha = this.chunkDataBuffer;
if (pngMetadata.ColorType == PngColorType.Rgb)
{
if (this.use16Bit)
@ -1251,7 +1251,7 @@ internal sealed class PngEncoderCore : IDisposable
BinaryPrimitives.WriteUInt16LittleEndian(alpha.Slice(2, 2), rgb.G);
BinaryPrimitives.WriteUInt16LittleEndian(alpha.Slice(4, 2), rgb.B);
this.WriteChunk(stream, PngChunkType.Transparency, this.chunkDataBuffer.Span, 0, 6);
this.WriteChunk(stream, PngChunkType.Transparency, this.chunkDataBuffer, 0, 6);
}
else
{
@ -1260,7 +1260,7 @@ internal sealed class PngEncoderCore : IDisposable
alpha[1] = rgb.R;
alpha[3] = rgb.G;
alpha[5] = rgb.B;
this.WriteChunk(stream, PngChunkType.Transparency, this.chunkDataBuffer.Span, 0, 6);
this.WriteChunk(stream, PngChunkType.Transparency, this.chunkDataBuffer, 0, 6);
}
}
else if (pngMetadata.ColorType == PngColorType.Grayscale)
@ -1269,14 +1269,14 @@ internal sealed class PngEncoderCore : IDisposable
{
L16 l16 = pngMetadata.TransparentColor.Value.ToPixel<L16>();
BinaryPrimitives.WriteUInt16LittleEndian(alpha, l16.PackedValue);
this.WriteChunk(stream, PngChunkType.Transparency, this.chunkDataBuffer.Span, 0, 2);
this.WriteChunk(stream, PngChunkType.Transparency, this.chunkDataBuffer, 0, 2);
}
else
{
L8 l8 = pngMetadata.TransparentColor.Value.ToPixel<L8>();
alpha.Clear();
alpha[1] = l8.PackedValue;
this.WriteChunk(stream, PngChunkType.Transparency, this.chunkDataBuffer.Span, 0, 2);
this.WriteChunk(stream, PngChunkType.Transparency, this.chunkDataBuffer, 0, 2);
}
}
}
@ -1301,9 +1301,9 @@ internal sealed class PngEncoderCore : IDisposable
disposalMode: frameMetadata.DisposalMode,
blendMode: frameMetadata.BlendMode);
fcTL.WriteTo(this.chunkDataBuffer.Span);
fcTL.WriteTo(this.chunkDataBuffer);
this.WriteChunk(stream, PngChunkType.FrameControl, this.chunkDataBuffer.Span, 0, FrameControl.Size);
this.WriteChunk(stream, PngChunkType.FrameControl, this.chunkDataBuffer, 0, FrameControl.Size);
return fcTL;
}
@ -1834,12 +1834,4 @@ internal sealed class PngEncoderCore : IDisposable
// PngColorType.RgbWithAlpha
_ => use16Bit ? 8 : 4,
};
private unsafe struct ScratchBuffer
{
private const int Size = 26;
private fixed byte scratch[Size];
public Span<byte> Span => MemoryMarshal.CreateSpan(ref this.scratch[0], Size);
}
}

17
src/ImageSharp/Formats/Webp/Lossless/Vp8LEncoder.cs

@ -24,7 +24,7 @@ internal class Vp8LEncoder : IDisposable
/// <summary>
/// Scratch buffer to reduce allocations.
/// </summary>
private ScratchBuffer scratch; // mutable struct, don't make readonly
private InlineArray256<int> scratch; // mutable struct, don't make readonly
private readonly int[][] histoArgb = [new int[256], new int[256], new int[256], new int[256]];
@ -803,7 +803,7 @@ internal class Vp8LEncoder : IDisposable
int transformWidth = LosslessUtils.SubSampleSize(width, colorTransformBits);
int transformHeight = LosslessUtils.SubSampleSize(height, colorTransformBits);
PredictorEncoder.ColorSpaceTransform(width, height, colorTransformBits, this.quality, this.EncodedData.GetSpan(), this.TransformData.GetSpan(), this.scratch.Span);
PredictorEncoder.ColorSpaceTransform(width, height, colorTransformBits, this.quality, this.EncodedData.GetSpan(), this.TransformData.GetSpan(), this.scratch);
this.bitWriter.PutBits(WebpConstants.TransformPresent, 1);
this.bitWriter.PutBits((uint)Vp8LTransformType.CrossColorTransform, 2);
@ -876,7 +876,7 @@ internal class Vp8LEncoder : IDisposable
private void StoreHuffmanCode(Span<HuffmanTree> huffTree, HuffmanTreeToken[] tokens, HuffmanTreeCode huffmanCode)
{
int count = 0;
Span<int> symbols = this.scratch.Span[..2];
Span<int> symbols = this.scratch[..2];
symbols.Clear();
const int maxBits = 8;
const int maxSymbol = 1 << maxBits;
@ -1915,15 +1915,4 @@ internal class Vp8LEncoder : IDisposable
this.HashChain.Dispose();
}
/// <summary>
/// Scratch buffer to reduce allocations.
/// </summary>
private unsafe struct ScratchBuffer
{
private const int Size = 256;
private fixed int scratch[Size];
public Span<int> Span => MemoryMarshal.CreateSpan(ref this.scratch[0], Size);
}
}

10
src/ImageSharp/Formats/Webp/Lossy/QuantEnc.cs

@ -35,7 +35,7 @@ internal static unsafe class QuantEnc
int lambda = dqm.LambdaI16;
int tlambda = dqm.TLambda;
Span<byte> src = it.YuvIn.AsSpan(Vp8EncIterator.YOffEnc);
Span<int> scratch = it.Scratch3;
Span<int> scratch = it.Scratch3.AsSpan();
Vp8ModeScore rdTmp = new();
Vp8Residual res = new();
Vp8ModeScore rdCur = rdTmp;
@ -105,7 +105,7 @@ internal static unsafe class QuantEnc
int tlambda = dqm.TLambda;
Span<byte> src0 = it.YuvIn.AsSpan(Vp8EncIterator.YOffEnc);
Span<byte> bestBlocks = it.YuvOut2.AsSpan(Vp8EncIterator.YOffEnc);
Span<int> scratch = it.Scratch3;
Span<int> scratch = it.Scratch3.AsSpan();
int totalHeaderBits = 0;
Vp8ModeScore rdBest = new();
@ -280,7 +280,7 @@ internal static unsafe class QuantEnc
int nz = 0;
int n;
Span<short> shortScratchSpan = it.Scratch2.AsSpan();
Span<int> scratch = it.Scratch3.AsSpan(0, 16);
Span<int> scratch = it.Scratch3.AsSpan();
shortScratchSpan.Clear();
scratch.Clear();
Span<short> dcTmp = shortScratchSpan[..16];
@ -321,7 +321,7 @@ internal static unsafe class QuantEnc
{
Span<byte> reference = it.YuvP.AsSpan(Vp8Encoding.Vp8I4ModeOffsets[mode]);
Span<short> tmp = it.Scratch2.AsSpan(0, 16);
Span<int> scratch = it.Scratch3.AsSpan(0, 16);
Span<int> scratch = it.Scratch3.AsSpan();
Vp8Encoding.FTransform(src, reference, tmp, scratch);
int nz = QuantizeBlock(tmp, levels, ref dqm.Y1);
Vp8Encoding.ITransformOne(reference, tmp, yuvOut, scratch);
@ -336,7 +336,7 @@ internal static unsafe class QuantEnc
int nz = 0;
int n;
Span<short> tmp = it.Scratch2.AsSpan(0, 8 * 16);
Span<int> scratch = it.Scratch3.AsSpan(0, 16);
Span<int> scratch = it.Scratch3.AsSpan();
for (n = 0; n < 8; n += 2)
{

12
src/ImageSharp/Formats/Webp/Lossy/Vp8Decoder.cs

@ -13,8 +13,13 @@ namespace SixLabors.ImageSharp.Formats.Webp.Lossy;
/// </summary>
internal class Vp8Decoder : IDisposable
{
private const int UpsamplingBufferSize = (14 * 32) + 15;
private Vp8MacroBlock leftMacroBlock;
// Vector upsampling needs 15 bytes of left padding, 128 interleaved UV bytes, two 128-byte BGR rows, and two 32-byte luma rows.
private readonly IMemoryOwner<byte> upsamplingBuffer;
/// <summary>
/// Initializes a new instance of the <see cref="Vp8Decoder"/> class.
/// </summary>
@ -74,6 +79,7 @@ internal class Vp8Decoder : IDisposable
this.TmpYBuffer = memoryAllocator.Allocate<byte>((int)width);
this.TmpUBuffer = memoryAllocator.Allocate<byte>((int)width);
this.TmpVBuffer = memoryAllocator.Allocate<byte>((int)width);
this.upsamplingBuffer = memoryAllocator.Allocate<byte>(UpsamplingBufferSize);
this.Pixels = memoryAllocator.Allocate<byte>((int)(width * height * 4), AllocationOptions.Clean);
#if DEBUG
@ -238,6 +244,11 @@ internal class Vp8Decoder : IDisposable
/// </summary>
public IMemoryOwner<byte> Pixels { get; }
/// <summary>
/// Gets the reusable workspace used while upsampling YUV rows.
/// </summary>
public Span<byte> UpsamplingBuffer => this.upsamplingBuffer.Memory.Span[..UpsamplingBufferSize];
/// <summary>
/// Gets or sets filter info.
/// </summary>
@ -339,6 +350,7 @@ internal class Vp8Decoder : IDisposable
this.TmpYBuffer.Dispose();
this.TmpUBuffer.Dispose();
this.TmpVBuffer.Dispose();
this.upsamplingBuffer.Dispose();
this.Pixels.Dispose();
}
}

12
src/ImageSharp/Formats/Webp/Lossy/Vp8Matrix.cs

@ -3,7 +3,7 @@
namespace SixLabors.ImageSharp.Formats.Webp.Lossy;
internal unsafe struct Vp8Matrix
internal struct Vp8Matrix
{
// [luma-ac,luma-dc,chroma][dc,ac]
private static readonly int[][] BiasMatrices =
@ -21,27 +21,27 @@ internal unsafe struct Vp8Matrix
/// <summary>
/// The quantizer steps.
/// </summary>
public fixed ushort Q[16];
public InlineArray16<ushort> Q;
/// <summary>
/// The reciprocals, fixed point.
/// </summary>
public fixed ushort IQ[16];
public InlineArray16<ushort> IQ;
/// <summary>
/// The rounding bias.
/// </summary>
public fixed uint Bias[16];
public InlineArray16<uint> Bias;
/// <summary>
/// The value below which a coefficient is zeroed.
/// </summary>
public fixed uint ZThresh[16];
public InlineArray16<uint> ZThresh;
/// <summary>
/// The frequency boosters for slight sharpening.
/// </summary>
public fixed short Sharpen[16];
public InlineArray16<short> Sharpen;
// Sharpening by (slightly) raising the hi-frequency coeffs.
// Hack-ish but helpful for mid-bitrate range. Use with care.

2
src/ImageSharp/Formats/Webp/Lossy/WebpLossyDecoder.cs

@ -733,7 +733,7 @@ internal sealed class WebpLossyDecoder
int mbw = io.MbW;
int uvw = (mbw + 1) >> 1; // >> 1 is bit-hack for / 2
int y = io.MbY;
byte[] uvBuffer = new byte[(14 * 32) + 15];
Span<byte> uvBuffer = dec.UpsamplingBuffer;
if (y == 0)
{

8
src/ImageSharp/Formats/Webp/Lossy/YuvConversion.cs

@ -27,7 +27,7 @@ internal static class YuvConversion
// we interpolate u/v as:
// ([9*a + 3*b + 3*c + d 3*a + 9*b + 3*c + d] + [8 8]) / 16
// ([3*a + b + 9*c + 3*d a + 3*b + 3*c + 9*d] [8 8]) / 16
public static void UpSample(Span<byte> topY, Span<byte> bottomY, Span<byte> topU, Span<byte> topV, Span<byte> curU, Span<byte> curV, Span<byte> topDst, Span<byte> bottomDst, int len, byte[] uvBuffer)
public static void UpSample(Span<byte> topY, Span<byte> bottomY, Span<byte> topU, Span<byte> topV, Span<byte> curU, Span<byte> curV, Span<byte> topDst, Span<byte> bottomDst, int len, Span<byte> uvBuffer)
{
if (Vector128.IsHardwareAccelerated)
{
@ -107,11 +107,11 @@ internal static class YuvConversion
//
// Then m can be written as
// m = (k + t + 1) / 2 - (((b^c) & (s^t)) | (k^t)) & 1
private static void UpSampleVector128(Span<byte> topY, Span<byte> bottomY, Span<byte> topU, Span<byte> topV, Span<byte> curU, Span<byte> curV, Span<byte> topDst, Span<byte> bottomDst, int len, byte[] uvBuffer)
private static void UpSampleVector128(Span<byte> topY, Span<byte> bottomY, Span<byte> topU, Span<byte> topV, Span<byte> curU, Span<byte> curV, Span<byte> topDst, Span<byte> bottomDst, int len, Span<byte> uvBuffer)
{
const int xStep = 3;
Array.Clear(uvBuffer);
Span<byte> ru = uvBuffer.AsSpan(15);
uvBuffer.Clear();
Span<byte> ru = uvBuffer[15..];
Span<byte> rv = ru[32..];
// Treat the first pixel in regular way.

103
src/ImageSharp/Formats/_Generated/ImageExtensions.Save.cs

@ -2,6 +2,7 @@
// Licensed under the Six Labors Split License.
// <auto-generated />
using SixLabors.ImageSharp.Formats.Ani;
using SixLabors.ImageSharp.Formats.Bmp;
using SixLabors.ImageSharp.Formats.Cur;
using SixLabors.ImageSharp.Formats.Gif;
@ -22,6 +23,108 @@ namespace SixLabors.ImageSharp;
/// </summary>
public static partial class ImageExtensions
{
/// <summary>
/// Saves the image to the given stream with the Ani format.
/// </summary>
/// <param name="source">The image this method extends.</param>
/// <param name="path">The file path to save the image to.</param>
/// <exception cref="System.ArgumentNullException">Thrown if the path is null.</exception>
public static void SaveAsAni(this Image source, string path) => SaveAsAni(source, path, default);
/// <summary>
/// Saves the image to the given stream with the Ani format.
/// </summary>
/// <param name="source">The image this method extends.</param>
/// <param name="path">The file path to save the image to.</param>
/// <exception cref="System.ArgumentNullException">Thrown if the path is null.</exception>
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
public static Task SaveAsAniAsync(this Image source, string path) => SaveAsAniAsync(source, path, default);
/// <summary>
/// Saves the image to the given stream with the Ani format.
/// </summary>
/// <param name="source">The image this method extends.</param>
/// <param name="path">The file path to save the image to.</param>
/// <param name="cancellationToken">The token to monitor for cancellation requests.</param>
/// <exception cref="System.ArgumentNullException">Thrown if the path is null.</exception>
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
public static Task SaveAsAniAsync(this Image source, string path, CancellationToken cancellationToken)
=> SaveAsAniAsync(source, path, default, cancellationToken);
/// <summary>
/// Saves the image to the given stream with the Ani format.
/// </summary>
/// <param name="source">The image this method extends.</param>
/// <param name="path">The file path to save the image to.</param>
/// <param name="encoder">The encoder to save the image with.</param>
/// <exception cref="System.ArgumentNullException">Thrown if the path is null.</exception>
public static void SaveAsAni(this Image source, string path, AniEncoder encoder) =>
source.Save(
path,
encoder ?? source.Configuration.ImageFormatsManager.GetEncoder(AniFormat.Instance));
/// <summary>
/// Saves the image to the given stream with the Ani format.
/// </summary>
/// <param name="source">The image this method extends.</param>
/// <param name="path">The file path to save the image to.</param>
/// <param name="encoder">The encoder to save the image with.</param>
/// <param name="cancellationToken">The token to monitor for cancellation requests.</param>
/// <exception cref="System.ArgumentNullException">Thrown if the path is null.</exception>
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
public static Task SaveAsAniAsync(this Image source, string path, AniEncoder encoder, CancellationToken cancellationToken = default)
=> source.SaveAsync(
path,
encoder ?? source.Configuration.ImageFormatsManager.GetEncoder(AniFormat.Instance),
cancellationToken);
/// <summary>
/// Saves the image to the given stream with the Ani format.
/// </summary>
/// <param name="source">The image this method extends.</param>
/// <param name="stream">The stream to save the image to.</param>
/// <exception cref="System.ArgumentNullException">Thrown if the stream is null.</exception>
public static void SaveAsAni(this Image source, Stream stream)
=> SaveAsAni(source, stream, default);
/// <summary>
/// Saves the image to the given stream with the Ani format.
/// </summary>
/// <param name="source">The image this method extends.</param>
/// <param name="stream">The stream to save the image to.</param>
/// <param name="cancellationToken">The token to monitor for cancellation requests.</param>
/// <exception cref="System.ArgumentNullException">Thrown if the stream is null.</exception>
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
public static Task SaveAsAniAsync(this Image source, Stream stream, CancellationToken cancellationToken = default)
=> SaveAsAniAsync(source, stream, default, cancellationToken);
/// <summary>
/// Saves the image to the given stream with the Ani format.
/// </summary>
/// <param name="source">The image this method extends.</param>
/// <param name="stream">The stream to save the image to.</param>
/// <param name="encoder">The encoder to save the image with.</param>
/// <exception cref="System.ArgumentNullException">Thrown if the stream is null.</exception>
public static void SaveAsAni(this Image source, Stream stream, AniEncoder encoder)
=> source.Save(
stream,
encoder ?? source.Configuration.ImageFormatsManager.GetEncoder(AniFormat.Instance));
/// <summary>
/// Saves the image to the given stream with the Ani format.
/// </summary>
/// <param name="source">The image this method extends.</param>
/// <param name="stream">The stream to save the image to.</param>
/// <param name="encoder">The encoder to save the image with.</param>
/// <param name="cancellationToken">The token to monitor for cancellation requests.</param>
/// <exception cref="System.ArgumentNullException">Thrown if the stream is null.</exception>
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
public static Task SaveAsAniAsync(this Image source, Stream stream, AniEncoder encoder, CancellationToken cancellationToken = default)
=> source.SaveAsync(
stream,
encoder ?? source.Configuration.ImageFormatsManager.GetEncoder(AniFormat.Instance),
cancellationToken);
/// <summary>
/// Saves the image to the given stream with the Bmp format.
/// </summary>

41
src/ImageSharp/Formats/_Generated/ImageMetadataExtensions.cs

@ -3,6 +3,7 @@
// <auto-generated />
using SixLabors.ImageSharp.Metadata;
using SixLabors.ImageSharp.Formats.Ani;
using SixLabors.ImageSharp.Formats.Bmp;
using SixLabors.ImageSharp.Formats.Cur;
using SixLabors.ImageSharp.Formats.Gif;
@ -23,6 +24,26 @@ namespace SixLabors.ImageSharp;
/// </summary>
public static class ImageMetadataExtensions
{
/// <summary>
/// Gets the <see cref="AniMetadata"/> from <paramref name="source"/>.<br/>
/// If none is found, an instance is created either by conversion from the decoded image format metadata
/// or the requested format default constructor.
/// This instance will be added to the metadata for future requests.
/// </summary>
/// <param name="source">The image metadata.</param>
/// <returns>
/// The <see cref="AniMetadata"/>
/// </returns>
public static AniMetadata GetAniMetadata(this ImageMetadata source) => source.GetFormatMetadata(AniFormat.Instance);
/// <summary>
/// Creates a new cloned instance of <see cref="AniMetadata"/> from the <paramref name="source"/>.
/// The instance is created via <see cref="GetAniMetadata(ImageMetadata)"/>
/// </summary>
/// <param name="source">The image metadata.</param>
/// <returns>The new <see cref="AniMetadata"/></returns>
public static AniMetadata CloneAniMetadata(this ImageMetadata source) => source.CloneFormatMetadata(AniFormat.Instance);
/// <summary>
/// Gets the <see cref="BmpMetadata"/> from <paramref name="source"/>.<br/>
/// If none is found, an instance is created either by conversion from the decoded image format metadata
@ -264,6 +285,26 @@ public static class ImageMetadataExtensions
public static ExrMetadata CloneExrMetadata(this ImageMetadata source) => source.CloneFormatMetadata(ExrFormat.Instance);
/// <summary>
/// Gets the <see cref="AniFrameMetadata"/> from <paramref name="source"/>.<br/>
/// If none is found, an instance is created either by conversion from the decoded image format metadata
/// or the requested format default constructor.
/// This instance will be added to the metadata for future requests.
/// </summary>
/// <param name="source">The image frame metadata.</param>
/// <returns>
/// The <see cref="AniFrameMetadata"/>
/// </returns>
public static AniFrameMetadata GetAniMetadata(this ImageFrameMetadata source) => source.GetFormatMetadata(AniFormat.Instance);
/// <summary>
/// Creates a new cloned instance of <see cref="AniMetadata"/> from the <paramref name="source"/>.
/// The instance is created via <see cref="GetAniMetadata(ImageFrameMetadata)"/>
/// </summary>
/// <param name="source">The image frame metadata.</param>
/// <returns>The new <see cref="AniFrameMetadata"/></returns>
public static AniFrameMetadata CloneAniMetadata(this ImageFrameMetadata source) => source.CloneFormatMetadata(AniFormat.Instance);
/// <summary>
/// Gets the <see cref="CurFrameMetadata"/> from <paramref name="source"/>.<br/>
/// If none is found, an instance is created either by conversion from the decoded image format metadata

2
src/ImageSharp/Formats/_Generated/_Formats.ttinclude

@ -4,6 +4,7 @@
// Licensed under the Six Labors Split License.
<#+
private static readonly string[] formats = [
"Ani",
"Bmp",
"Cur",
"Gif",
@ -19,6 +20,7 @@
];
private static readonly string[] frameFormats = [
"Ani",
"Cur",
"Ico",
"Gif",

6
tests/Directory.Build.targets

@ -19,7 +19,11 @@
<ItemGroup>
<!-- Test Dependencies -->
<PackageReference Update="Colourful" Version="3.2.0" />
<PackageReference Update="Magick.NET-Q16-AnyCPU" Version="14.11.1" />
<!--
Magick.NET 14.15.0 disables WebP-compressed TIFF support on Windows.
https://github.com/ImageMagick/tiff/commit/978181b6c999ee013f1b6df0a010d8c550d378cf
-->
<PackageReference Update="Magick.NET-Q16-AnyCPU" Version="14.14.0" />
<PackageReference Update="Microsoft.DotNet.RemoteExecutor" Version="10.0.0-beta.25563.105" />
<PackageReference Update="Microsoft.DotNet.XUnitExtensions" Version="8.0.0-beta.23580.1" />
<PackageReference Update="Moq" Version="4.20.72" />

2
tests/ImageSharp.Tests/ConfigurationTests.cs

@ -20,7 +20,7 @@ public class ConfigurationTests
public Configuration DefaultConfiguration { get; }
private readonly int expectedDefaultConfigurationCount = 12;
private readonly int expectedDefaultConfigurationCount = 13;
public ConfigurationTests()
{

210
tests/ImageSharp.Tests/Formats/Ani/AniDecoderTests.cs

@ -0,0 +1,210 @@
// Copyright (c) Six Labors.
// Licensed under the Six Labors Split License.
using System.Buffers.Binary;
using SixLabors.ImageSharp.Formats;
using SixLabors.ImageSharp.Formats.Ani;
using SixLabors.ImageSharp.PixelFormats;
using static SixLabors.ImageSharp.Tests.TestImages.Ani;
namespace SixLabors.ImageSharp.Tests.Formats.Ani;
[Trait("Format", "Ani")]
[ValidateDisposedMemoryAllocations]
public class AniDecoderTests
{
/// <summary>
/// Verifies that ANI animation steps and embedded CUR resolution variants are flattened with their ANI metadata.
/// </summary>
[Theory]
[WithFile(Work, PixelTypes.Rgba32, 17, 1, 6U, 6U)]
[WithFile(MultiFramesInEveryIconChunk, PixelTypes.Rgba32, 54, 3, 3U, 3U)]
[WithFile(Help, PixelTypes.Rgba32, 4, 1, 10U, 12U)]
public void AniDecoder_Decode(
TestImageProvider<Rgba32> provider,
int expectedFrameCount,
int variantsPerStep,
uint expectedDisplayRate,
uint expectedFrameDelay)
{
using Image<Rgba32> image = provider.GetImage(AniDecoder.Instance);
Assert.Equal(expectedFrameCount, image.Frames.Count);
Assert.Equal(expectedDisplayRate, image.Metadata.GetAniMetadata().DisplayRate);
for (int i = 0; i < image.Frames.Count; i++)
{
AniFrameMetadata metadata = image.Frames[i].Metadata.GetAniMetadata();
Assert.Equal((i / variantsPerStep) + 1, metadata.SequenceNumber);
Assert.Equal(expectedFrameDelay, metadata.FrameDelay);
Assert.Equal(AniFrameFormat.Cur, metadata.FrameFormat);
Assert.NotEqual(0, (int)metadata.BmpBitsPerPixel);
}
}
/// <summary>
/// Verifies that identification exposes the same flattened ANI frame structure without decoding pixels.
/// </summary>
[Theory]
[InlineData(Work, 17)]
[InlineData(MultiFramesInEveryIconChunk, 54)]
[InlineData(Help, 4)]
public void AniDecoder_Identify(string path, int expectedFrameCount)
{
TestFile file = TestFile.Create(path);
using MemoryStream stream = new(file.Bytes, false);
ImageInfo info = AniDecoder.Instance.Identify(DecoderOptions.Default, stream);
Assert.Equal(expectedFrameCount, info.FrameMetadataCollection.Count);
for (int i = 0; i < info.FrameMetadataCollection.Count; i++)
{
Assert.True(info.FrameMetadataCollection[i].GetAniMetadata().SequenceNumber > 0);
}
}
/// <summary>
/// Verifies that invalid sequence metadata follows the ancillary-segment integrity policy.
/// </summary>
[Fact]
public void AniDecoder_InvalidSequenceReference_FollowsIntegrityHandling()
{
byte[] data = TestFile.Create(Help).Bytes.ToArray();
int sequenceOffset = data.AsSpan().IndexOf("seq "u8);
Assert.True(sequenceOffset >= 0);
BinaryPrimitives.WriteUInt32LittleEndian(data.AsSpan(sequenceOffset + AniConstants.ChunkHeaderSize), uint.MaxValue);
using MemoryStream strictStream = new(data, false);
DecoderOptions strict = new() { SegmentIntegrityHandling = SegmentIntegrityHandling.Strict };
Assert.Throws<InvalidImageContentException>(() => AniDecoder.Instance.Decode<Rgba32>(strict, strictStream));
using MemoryStream ignoreStream = new(data, false);
DecoderOptions ignore = new() { SegmentIntegrityHandling = SegmentIntegrityHandling.IgnoreAncillary };
using Image<Rgba32> image = AniDecoder.Instance.Decode<Rgba32>(ignore, ignoreStream);
Assert.Equal(3, image.Frames.Count);
}
/// <summary>
/// Verifies that ignored corrupt resources retain their sequence-table slot.
/// </summary>
[Fact]
public void AniDecoder_UnsupportedResource_FollowsIntegrityHandling()
{
byte[] data = TestFile.Create(Work).Bytes.ToArray();
int resourceOffset = data.AsSpan().IndexOf("icon"u8);
Assert.True(resourceOffset >= 0);
int iconTypeOffset = resourceOffset + AniConstants.ChunkHeaderSize + sizeof(ushort);
BinaryPrimitives.WriteUInt16LittleEndian(data.AsSpan(iconTypeOffset), ushort.MaxValue);
using MemoryStream strictStream = new(data, false);
DecoderOptions strict = new() { SegmentIntegrityHandling = SegmentIntegrityHandling.Strict };
Assert.Throws<InvalidImageContentException>(() => AniDecoder.Instance.Decode<Rgba32>(strict, strictStream));
using MemoryStream ignoreStream = new(data, false);
DecoderOptions ignore = new() { SegmentIntegrityHandling = SegmentIntegrityHandling.IgnoreImageData };
using Image<Rgba32> image = AniDecoder.Instance.Decode<Rgba32>(ignore, ignoreStream);
Assert.Equal(16, image.Frames.Count);
Assert.Equal(2, image.Frames.RootFrame.Metadata.GetAniMetadata().SequenceNumber);
}
/// <summary>
/// Verifies that a malformed rate chunk follows the ancillary-segment integrity policy.
/// </summary>
[Fact]
public void AniDecoder_InvalidRateChunk_FollowsIntegrityHandling()
{
byte[] source = TestFile.Create(Help).Bytes.ToArray();
int rateOffset = source.AsSpan().IndexOf("rate"u8);
Assert.True(rateOffset >= 0);
int rateSizeOffset = rateOffset + sizeof(uint);
int rateSize = (int)BinaryPrimitives.ReadUInt32LittleEndian(source.AsSpan(rateSizeOffset));
int rateEnd = rateOffset + AniConstants.ChunkHeaderSize + rateSize;
byte[] data = new byte[source.Length + 2];
source.AsSpan(0, rateEnd).CopyTo(data);
source.AsSpan(rateEnd).CopyTo(data.AsSpan(rateEnd + 2));
BinaryPrimitives.WriteUInt32LittleEndian(data.AsSpan(rateSizeOffset), (uint)rateSize + 2);
BinaryPrimitives.WriteUInt32LittleEndian(data.AsSpan(sizeof(uint)), (uint)data.Length - 8);
using MemoryStream defaultStream = new(data, false);
using Image<Rgba32> image = AniDecoder.Instance.Decode<Rgba32>(DecoderOptions.Default, defaultStream);
Assert.Equal(4, image.Frames.Count);
foreach (ImageFrame<Rgba32> frame in image.Frames)
{
Assert.Equal(10U, frame.Metadata.GetAniMetadata().FrameDelay);
}
using MemoryStream strictStream = new(data, false);
DecoderOptions strict = new() { SegmentIntegrityHandling = SegmentIntegrityHandling.Strict };
Assert.Throws<InvalidImageContentException>(() => AniDecoder.Instance.Decode<Rgba32>(strict, strictStream));
}
/// <summary>
/// Verifies that oversized control arrays are rejected before allocation and follow ancillary integrity handling.
/// </summary>
/// <param name="sequence"><see langword="true"/> to append a sequence chunk; otherwise, a rate chunk.</param>
[Theory]
[InlineData(false)]
[InlineData(true)]
public void AniDecoder_OversizedControlChunk_FollowsIntegrityHandling(bool sequence)
{
byte[] source = TestFile.Create(Help).Bytes.ToArray();
int chunkOffset = (source.Length + 1) & ~1;
int payloadSize = AniConstants.MaxAncillaryChunkSize + sizeof(uint);
byte[] data = new byte[chunkOffset + AniConstants.ChunkHeaderSize + payloadSize];
source.CopyTo(data, 0);
ReadOnlySpan<byte> identifier = sequence ? "seq "u8 : "rate"u8;
identifier.CopyTo(data.AsSpan(chunkOffset));
BinaryPrimitives.WriteUInt32LittleEndian(data.AsSpan(chunkOffset + sizeof(uint)), (uint)payloadSize);
BinaryPrimitives.WriteUInt32LittleEndian(data.AsSpan(sizeof(uint)), (uint)data.Length - AniConstants.ChunkHeaderSize);
using MemoryStream defaultStream = new(data, false);
using Image<Rgba32> image = AniDecoder.Instance.Decode<Rgba32>(DecoderOptions.Default, defaultStream);
Assert.Equal(4, image.Frames.Count);
using MemoryStream strictStream = new(data, false);
DecoderOptions strict = new() { SegmentIntegrityHandling = SegmentIntegrityHandling.Strict };
Assert.Throws<InvalidImageContentException>(() => AniDecoder.Instance.Decode<Rgba32>(strict, strictStream));
}
/// <summary>
/// Verifies that oversized information text is rejected before allocation and follows ancillary integrity handling.
/// </summary>
[Fact]
public void AniDecoder_OversizedInformationText_FollowsIntegrityHandling()
{
byte[] source = TestFile.Create(Help).Bytes.ToArray();
int listOffset = (source.Length + 1) & ~1;
int textSize = AniConstants.MaxAncillaryChunkSize + 1;
int paddedTextSize = textSize + (textSize & 1);
int listSize = sizeof(uint) + AniConstants.ChunkHeaderSize + paddedTextSize;
byte[] data = new byte[listOffset + AniConstants.ChunkHeaderSize + listSize];
source.CopyTo(data, 0);
"LIST"u8.CopyTo(data.AsSpan(listOffset));
BinaryPrimitives.WriteUInt32LittleEndian(data.AsSpan(listOffset + sizeof(uint)), (uint)listSize);
"INFO"u8.CopyTo(data.AsSpan(listOffset + AniConstants.ChunkHeaderSize));
int textOffset = listOffset + AniConstants.ChunkHeaderSize + sizeof(uint);
"INAM"u8.CopyTo(data.AsSpan(textOffset));
BinaryPrimitives.WriteUInt32LittleEndian(data.AsSpan(textOffset + sizeof(uint)), (uint)textSize);
BinaryPrimitives.WriteUInt32LittleEndian(data.AsSpan(sizeof(uint)), (uint)data.Length - AniConstants.ChunkHeaderSize);
using MemoryStream defaultStream = new(data, false);
using Image<Rgba32> image = AniDecoder.Instance.Decode<Rgba32>(DecoderOptions.Default, defaultStream);
Assert.Equal(4, image.Frames.Count);
using MemoryStream strictStream = new(data, false);
DecoderOptions strict = new() { SegmentIntegrityHandling = SegmentIntegrityHandling.Strict };
Assert.Throws<InvalidImageContentException>(() => AniDecoder.Instance.Decode<Rgba32>(strict, strictStream));
}
}

242
tests/ImageSharp.Tests/Formats/Ani/AniEncoderTests.cs

@ -0,0 +1,242 @@
// Copyright (c) Six Labors.
// Licensed under the Six Labors Split License.
using System.Buffers.Binary;
using SixLabors.ImageSharp.Formats.Ani;
using SixLabors.ImageSharp.Formats.Bmp;
using SixLabors.ImageSharp.Formats.Icon;
using SixLabors.ImageSharp.Metadata.Profiles.Icc;
using SixLabors.ImageSharp.PixelFormats;
using SixLabors.ImageSharp.Tests.TestDataIcc;
using SixLabors.ImageSharp.Tests.TestUtilities.ImageComparison;
using static SixLabors.ImageSharp.Tests.TestImages.Ani;
namespace SixLabors.ImageSharp.Tests.Formats.Ani;
[Trait("Format", "Ani")]
[ValidateDisposedMemoryAllocations]
public class AniEncoderTests
{
/// <summary>
/// Verifies that ANI resources, including multi-resolution CUR resources, survive an encode/decode round trip.
/// </summary>
[Theory]
[WithFile(Work, PixelTypes.Rgba32)]
[WithFile(MultiFramesInEveryIconChunk, PixelTypes.Rgba32)]
[WithFile(Help, PixelTypes.Rgba32)]
public void AniEncoder_RoundTrips(TestImageProvider<Rgba32> provider)
{
using Image<Rgba32> image = provider.GetImage(AniDecoder.Instance);
using MemoryStream stream = new();
image.Save(stream, new AniEncoder());
// The RIFF size covers everything after its identifier and size field.
Assert.Equal(stream.Length - 8, BinaryPrimitives.ReadUInt32LittleEndian(stream.GetBuffer().AsSpan(4, sizeof(uint))));
stream.Position = 0;
using Image<Rgba32> decoded = Image.Load<Rgba32>(stream);
ImageComparer.Exact.VerifySimilarity(image, decoded);
Assert.Equal(image.Frames.Count, decoded.Frames.Count);
for (int i = 0; i < image.Frames.Count; i++)
{
AniFrameMetadata expected = image.Frames[i].Metadata.GetAniMetadata();
AniFrameMetadata actual = decoded.Frames[i].Metadata.GetAniMetadata();
Assert.Equal(expected.SequenceNumber, actual.SequenceNumber);
Assert.Equal(expected.FrameDelay, actual.FrameDelay);
Assert.Equal(expected.FrameFormat, actual.FrameFormat);
Assert.Equal(expected.EncodingWidth, actual.EncodingWidth);
Assert.Equal(expected.EncodingHeight, actual.EncodingHeight);
Assert.Equal(expected.Compression, actual.Compression);
Assert.Equal(expected.BmpBitsPerPixel, actual.BmpBitsPerPixel);
Assert.Equal(expected.HotspotX, actual.HotspotX);
Assert.Equal(expected.HotspotY, actual.HotspotY);
Assert.Equal(expected.ColorTable?.ToArray(), actual.ColorTable?.ToArray());
}
}
/// <summary>
/// Verifies that per-step rates and RIFF information metadata are emitted and decoded.
/// </summary>
[Theory]
[WithFile(Work, PixelTypes.Rgba32)]
public void AniEncoder_WritesVariableRatesAndInformation(TestImageProvider<Rgba32> provider)
{
using Image<Rgba32> image = provider.GetImage(AniDecoder.Instance);
AniMetadata imageMetadata = image.Metadata.GetAniMetadata();
imageMetadata.Name = "ImageSharp ANI";
imageMetadata.Artist = "Six Labors";
for (int i = 0; i < image.Frames.Count; i++)
{
image.Frames[i].Metadata.GetAniMetadata().FrameDelay = (uint)(i + 1);
}
using MemoryStream stream = new();
image.Save(stream, new AniEncoder());
Assert.Equal(stream.Length - 8, BinaryPrimitives.ReadUInt32LittleEndian(stream.GetBuffer().AsSpan(4, sizeof(uint))));
stream.Position = 0;
using Image<Rgba32> decoded = Image.Load<Rgba32>(stream);
AniMetadata decodedMetadata = decoded.Metadata.GetAniMetadata();
Assert.Equal(imageMetadata.Name, decodedMetadata.Name);
Assert.Equal(imageMetadata.Artist, decodedMetadata.Artist);
for (int i = 0; i < decoded.Frames.Count; i++)
{
Assert.Equal((uint)(i + 1), decoded.Frames[i].Metadata.GetAniMetadata().FrameDelay);
}
}
/// <summary>
/// Verifies the encoder and decoder paths for embedded ICO and BMP resources.
/// </summary>
[Theory]
[InlineData(AniFrameFormat.Ico)]
[InlineData(AniFrameFormat.Bmp)]
public void AniEncoder_RoundTripsOtherFrameFormats(AniFrameFormat frameFormat)
{
using Image<Rgba32> image = new(16, 16, Color.Red.ToPixel<Rgba32>());
AniMetadata imageMetadata = image.Metadata.GetAniMetadata();
imageMetadata.DisplayRate = 6;
imageMetadata.BitCount = 32;
imageMetadata.Planes = 1;
AniFrameMetadata frameMetadata = image.Frames.RootFrame.Metadata.GetAniMetadata();
frameMetadata.FrameDelay = 6;
frameMetadata.SequenceNumber = 1;
frameMetadata.FrameFormat = frameFormat;
frameMetadata.Compression = IconFrameCompression.Bmp;
using MemoryStream stream = new();
image.Save(stream, new AniEncoder());
Assert.Equal(stream.Length - 8, BinaryPrimitives.ReadUInt32LittleEndian(stream.GetBuffer().AsSpan(4, sizeof(uint))));
if (frameFormat is AniFrameFormat.Bmp)
{
ReadOnlySpan<byte> encoded = stream.GetBuffer().AsSpan(0, (int)stream.Length);
int frameChunkOffset = encoded.IndexOf("icon"u8);
Assert.True(frameChunkOffset >= 0);
// AF_ICON-clear resources contain a headerless BMP DIB, not a standalone file beginning with BITMAPFILEHEADER.
ReadOnlySpan<byte> frameData = encoded[(frameChunkOffset + AniConstants.ChunkHeaderSize)..];
Assert.False(frameData.StartsWith("BM"u8));
}
stream.Position = 0;
using Image<Rgba32> decoded = Image.Load<Rgba32>(stream);
ImageComparer.Exact.VerifySimilarity(image, decoded);
Assert.Equal(frameFormat, decoded.Frames.RootFrame.Metadata.GetAniMetadata().FrameFormat);
}
/// <summary>
/// Verifies that metadata suppression is propagated to every embedded resource encoder.
/// </summary>
[Theory]
[InlineData(AniFrameFormat.Ico)]
[InlineData(AniFrameFormat.Cur)]
[InlineData(AniFrameFormat.Bmp)]
public void AniEncoder_SkipMetadataPropagatesToEmbeddedEncoder(AniFrameFormat frameFormat)
{
using Image<Rgba32> image = new(16, 16, Color.Red.ToPixel<Rgba32>());
image.Metadata.IccProfile = new IccProfile(IccTestDataProfiles.ProfileRandomArray);
AniMetadata imageMetadata = image.Metadata.GetAniMetadata();
imageMetadata.BitCount = 32;
imageMetadata.Planes = 1;
AniFrameMetadata frameMetadata = image.Frames.RootFrame.Metadata.GetAniMetadata();
frameMetadata.FrameFormat = frameFormat;
frameMetadata.Compression = IconFrameCompression.Bmp;
frameMetadata.BmpBitsPerPixel = BmpBitsPerPixel.Bit32;
using MemoryStream stream = new();
image.Save(stream, new AniEncoder { SkipMetadata = true });
ReadOnlySpan<byte> encoded = stream.GetBuffer().AsSpan(0, (int)stream.Length);
int frameChunkOffset = encoded.IndexOf("icon"u8);
Assert.True(frameChunkOffset >= 0);
ReadOnlySpan<byte> resource = encoded[(frameChunkOffset + AniConstants.ChunkHeaderSize)..];
int dibOffset = 0;
if (frameFormat is not AniFrameFormat.Bmp)
{
dibOffset = checked((int)BinaryPrimitives.ReadUInt32LittleEndian(resource[(IconDir.Size + IconDirEntry.Size - sizeof(uint))..]));
}
// Metadata-free ICO/CUR bitmaps use BITMAPINFOHEADER, while raw transparent ANI bitmaps require BITMAPV4HEADER.
int expectedHeaderSize = frameFormat is AniFrameFormat.Bmp ? BmpInfoHeader.SizeV4 : BmpInfoHeader.SizeV3;
Assert.Equal(expectedHeaderSize, BinaryPrimitives.ReadInt32LittleEndian(resource[dibOffset..]));
}
/// <summary>
/// Verifies that an independent frame cannot collide with an explicit sequence group.
/// </summary>
[Fact]
public void AniEncoder_NonPositiveSequenceDoesNotCollideWithExplicitGroup()
{
using Image<Rgba32> image = new(16, 16, Color.Red.ToPixel<Rgba32>());
image.Frames.AddFrame(image.Frames.RootFrame);
image.Frames[1].Metadata.GetAniMetadata().SequenceNumber = 1;
using MemoryStream stream = new();
image.Save(stream, new AniEncoder());
stream.Position = 0;
using Image<Rgba32> decoded = Image.Load<Rgba32>(stream);
Assert.Equal(2, decoded.Frames.Count);
Assert.Equal(1, decoded.Frames[0].Metadata.GetAniMetadata().SequenceNumber);
Assert.Equal(2, decoded.Frames[1].Metadata.GetAniMetadata().SequenceNumber);
}
/// <summary>
/// Verifies that an explicit source sequence is preserved as an identity table after playback-order expansion.
/// </summary>
[Fact]
public void AniEncoder_PreservesExplicitSequence()
{
using Image<Rgba32> image = new(16, 16, Color.Red.ToPixel<Rgba32>());
image.Frames.AddFrame(image.Frames.RootFrame);
image.Metadata.GetAniMetadata().Flags = AniHeaderFlags.IsIcon | AniHeaderFlags.ContainsSequence;
using MemoryStream stream = new();
image.Save(stream, new AniEncoder());
ReadOnlySpan<byte> data = stream.GetBuffer().AsSpan(0, (int)stream.Length);
int sequenceOffset = data.IndexOf("seq "u8);
Assert.True(sequenceOffset >= 0);
Assert.Equal((uint)(2 * sizeof(uint)), BinaryPrimitives.ReadUInt32LittleEndian(data[(sequenceOffset + sizeof(uint))..]));
Assert.Equal(0U, BinaryPrimitives.ReadUInt32LittleEndian(data[(sequenceOffset + AniConstants.ChunkHeaderSize)..]));
Assert.Equal(1U, BinaryPrimitives.ReadUInt32LittleEndian(data[(sequenceOffset + AniConstants.ChunkHeaderSize + sizeof(uint))..]));
stream.Position = 0;
using Image<Rgba32> decoded = Image.Load<Rgba32>(stream);
Assert.True(decoded.Metadata.GetAniMetadata().Flags.HasFlag(AniHeaderFlags.ContainsSequence));
}
/// <summary>
/// Verifies that unsupported public frame metadata is rejected before any container data is written.
/// </summary>
[Fact]
public void AniEncoder_UnsupportedFrameFormatThrowsBeforeWriting()
{
using Image<Rgba32> image = new(16, 16);
image.Frames.RootFrame.Metadata.GetAniMetadata().FrameFormat = (AniFrameFormat)byte.MaxValue;
using MemoryStream stream = new();
Assert.Throws<ImageFormatException>(() => image.Save(stream, new AniEncoder()));
Assert.Equal(0, stream.Length);
}
}

31
tests/ImageSharp.Tests/Formats/Ani/AniMetadataTests.cs

@ -0,0 +1,31 @@
// Copyright (c) Six Labors.
// Licensed under the Six Labors Split License.
using SixLabors.ImageSharp.Formats.Ani;
using SixLabors.ImageSharp.PixelFormats;
using SixLabors.ImageSharp.Processing;
namespace SixLabors.ImageSharp.Tests.Formats.Ani;
[Trait("Format", "Ani")]
public class AniMetadataTests
{
/// <summary>
/// Verifies that resizing scales the ANI-owned encoding dimensions exactly once.
/// </summary>
[Fact]
public void AfterFrameApply_ScalesEncodingDimensionsOnce()
{
using Image<Rgba32> image = new(32, 32);
AniFrameMetadata metadata = image.Frames.RootFrame.Metadata.GetAniMetadata();
metadata.EncodingWidth = 32;
metadata.EncodingHeight = 32;
metadata.FrameFormat = AniFrameFormat.Cur;
image.Mutate(context => context.Resize(64, 64));
AniFrameMetadata resized = image.Frames.RootFrame.Metadata.GetAniMetadata();
Assert.Equal((byte)64, resized.EncodingWidth);
Assert.Equal((byte)64, resized.EncodingHeight);
}
}

40
tests/ImageSharp.Tests/Formats/Icon/Cur/CurDecoderTests.cs

@ -1,6 +1,7 @@
// Copyright (c) Six Labors.
// Licensed under the Six Labors Split License.
using System.Numerics;
using SixLabors.ImageSharp.Formats.Bmp;
using SixLabors.ImageSharp.Formats.Cur;
using SixLabors.ImageSharp.Formats.Icon;
@ -13,6 +14,19 @@ namespace SixLabors.ImageSharp.Tests.Formats.Icon.Cur;
[ValidateDisposedMemoryAllocations]
public class CurDecoderTests
{
[Fact]
public void CurFormat_HasCorrectName()
=> Assert.Equal("CUR", CurFormat.Instance.Name);
[Fact]
public void CurDetector_RejectsIco()
{
TestFile file = TestFile.Create(TestImages.Ico.Flutter);
CurImageFormatDetector detector = new();
Assert.False(detector.TryDetectFormat(file.Bytes, out _));
}
[Theory]
[WithFile(WindowsMouse, PixelTypes.Rgba32)]
public void CurDecoder_Decode(TestImageProvider<Rgba32> provider)
@ -38,4 +52,30 @@ public class CurDecoderTests
Assert.Equal(IconFrameCompression.Bmp, meta.Compression);
Assert.Equal(BmpBitsPerPixel.Bit32, meta.BmpBitsPerPixel);
}
[Fact]
public void CurFrameMetadata_DeepClonePreservesColorTable()
{
Color[] colors = [Color.Red, Color.Green];
CurFrameMetadata metadata = new() { ColorTable = colors };
CurFrameMetadata clone = metadata.DeepClone();
colors[0] = Color.Blue;
Assert.Equal(Color.Red, clone.ColorTable.Value.Span[0]);
Assert.Equal(Color.Green, clone.ColorTable.Value.Span[1]);
}
[Fact]
public void CurFrameMetadata_ScalesZeroEncodingDimensionsFrom256()
{
using Image<Rgba32> source = new(256, 256);
using Image<Rgba32> destination = new(128, 128);
CurFrameMetadata metadata = new() { EncodingWidth = 0, EncodingHeight = 0 };
metadata.AfterFrameApply(source.Frames.RootFrame, destination.Frames.RootFrame, Matrix4x4.Identity);
Assert.Equal((byte)128, metadata.EncodingWidth);
Assert.Equal((byte)128, metadata.EncodingHeight);
}
}

36
tests/ImageSharp.Tests/Formats/Icon/Cur/CurEncoderTests.cs

@ -2,9 +2,12 @@
// Licensed under the Six Labors Split License.
using SixLabors.ImageSharp.Formats;
using SixLabors.ImageSharp.Formats.Bmp;
using SixLabors.ImageSharp.Formats.Cur;
using SixLabors.ImageSharp.Formats.Ico;
using SixLabors.ImageSharp.Formats.Icon;
using SixLabors.ImageSharp.PixelFormats;
using SixLabors.ImageSharp.Processing.Processors.Quantization;
using SixLabors.ImageSharp.Tests.TestUtilities.ImageComparison;
using static SixLabors.ImageSharp.Tests.TestImages.Cur;
using static SixLabors.ImageSharp.Tests.TestImages.Ico;
@ -124,4 +127,37 @@ public class CurEncoderTests
}
});
}
[Fact]
public void Encode_UsesBitmapDepthInsteadOfHotspotForQuantizerSelection()
{
using Image<Rgba32> image = new(32, 1);
for (int x = 0; x < image.Width; x++)
{
image[x, 0] = new Rgba32((byte)(x * 8), (byte)(255 - (x * 8)), (byte)(x * 4));
}
CurFrameMetadata metadata = image.Frames.RootFrame.Metadata.GetCurMetadata();
metadata.Compression = IconFrameCompression.Bmp;
metadata.BmpBitsPerPixel = BmpBitsPerPixel.Bit8;
metadata.HotspotY = 32;
CurEncoder encoder = new()
{
Quantizer = new WuQuantizer(new QuantizerOptions { MaxColors = 2 })
};
using MemoryStream stream = new();
image.Save(stream, encoder);
stream.Position = 0;
using Image<Rgba32> decoded = Image.Load<Rgba32>(stream);
HashSet<Rgba32> colors = [];
for (int x = 0; x < decoded.Width; x++)
{
colors.Add(decoded[x, 0]);
}
Assert.InRange(colors.Count, 1, 2);
}
}

48
tests/ImageSharp.Tests/Formats/Icon/Ico/IcoDecoderTests.cs

@ -1,6 +1,8 @@
// Copyright (c) Six Labors.
// Licensed under the Six Labors Split License.
using System.Buffers.Binary;
using System.Numerics;
using SixLabors.ImageSharp.Formats;
using SixLabors.ImageSharp.Formats.Bmp;
using SixLabors.ImageSharp.Formats.Ico;
@ -14,6 +16,15 @@ namespace SixLabors.ImageSharp.Tests.Formats.Icon.Ico;
[ValidateDisposedMemoryAllocations]
public class IcoDecoderTests
{
[Fact]
public void IcoDetector_RejectsCur()
{
TestFile file = TestFile.Create(TestImages.Cur.WindowsMouse);
IcoImageFormatDetector detector = new();
Assert.False(detector.TryDetectFormat(file.Bytes, out _));
}
[Theory]
[WithFile(Flutter, PixelTypes.Rgba32)]
public void IcoDecoder_Decode(TestImageProvider<Rgba32> provider)
@ -306,6 +317,43 @@ public class IcoDecoderTests
Assert.Single(imageInfo.FrameMetadataCollection);
}
[Fact]
public void TruncatedEntry_FollowsImageDataIntegrityHandling()
{
byte[] data = TestFile.Create(Flutter).Bytes.ToArray();
ushort entryCount = BinaryPrimitives.ReadUInt16LittleEndian(data.AsSpan(4));
Assert.True(entryCount > 1);
// Limit the first resource below the PNG signature length. A bounded child decoder must not read into following data.
BinaryPrimitives.WriteUInt32LittleEndian(data.AsSpan(IconDir.Size + 8), 4);
using MemoryStream defaultStream = new(data, false);
Assert.Throws<InvalidImageContentException>(() => IcoDecoder.Instance.Decode<Rgba32>(DecoderOptions.Default, defaultStream));
DecoderOptions ignore = new() { SegmentIntegrityHandling = SegmentIntegrityHandling.IgnoreImageData };
using MemoryStream decodeStream = new(data, false);
using Image<Rgba32> image = IcoDecoder.Instance.Decode<Rgba32>(ignore, decodeStream);
Assert.Equal(entryCount - 1, image.Frames.Count);
using MemoryStream identifyStream = new(data, false);
ImageInfo info = IcoDecoder.Instance.Identify(ignore, identifyStream);
Assert.Equal(entryCount - 1, info.FrameMetadataCollection.Count);
}
[Fact]
public void IcoFrameMetadata_ScalesZeroEncodingDimensionsFrom256()
{
using Image<Rgba32> source = new(256, 256);
using Image<Rgba32> destination = new(128, 128);
IcoFrameMetadata metadata = new() { EncodingWidth = 0, EncodingHeight = 0 };
metadata.AfterFrameApply(source.Frames.RootFrame, destination.Frames.RootFrame, Matrix4x4.Identity);
Assert.Equal((byte)128, metadata.EncodingWidth);
Assert.Equal((byte)128, metadata.EncodingHeight);
}
[Theory]
[WithFile(MultiSizeMultiBitsA, PixelTypes.Rgba32)]
[WithFile(MultiSizeMultiBitsB, PixelTypes.Rgba32)]

51
tests/ImageSharp.Tests/Formats/Icon/Ico/IcoEncoderTests.cs

@ -2,8 +2,11 @@
// Licensed under the Six Labors Split License.
using SixLabors.ImageSharp.Formats;
using SixLabors.ImageSharp.Formats.Bmp;
using SixLabors.ImageSharp.Formats.Cur;
using SixLabors.ImageSharp.Formats.Ico;
using SixLabors.ImageSharp.Formats.Icon;
using SixLabors.ImageSharp.Metadata.Profiles.Exif;
using SixLabors.ImageSharp.PixelFormats;
using SixLabors.ImageSharp.Tests.TestUtilities.ImageComparison;
using static SixLabors.ImageSharp.Tests.TestImages.Cur;
@ -121,4 +124,52 @@ public class IcoEncoderTests
}
});
}
[Fact]
public void PngEntry_PreservesExifProfile()
{
using Image<Rgba32> image = new(16, 16);
image.Metadata.ExifProfile = new ExifProfile();
image.Metadata.ExifProfile.SetValue(ExifTag.Software, "ImageSharp");
image.Frames.RootFrame.Metadata.GetIcoMetadata().Compression = IconFrameCompression.Png;
using MemoryStream stream = new();
image.Save(stream, Encoder);
stream.Position = 0;
using Image<Rgba32> decoded = Image.Load<Rgba32>(stream);
Assert.NotNull(decoded.Metadata.ExifProfile);
Assert.Equal(image.Metadata.ExifProfile.Values, decoded.Metadata.ExifProfile.Values);
}
/// <summary>
/// Verifies that the final partial AND-mask byte contains every pixel when the bitmap width is not byte-aligned.
/// </summary>
/// <param name="width">The bitmap width to encode.</param>
[Theory]
[InlineData(1)]
[InlineData(7)]
[InlineData(9)]
[InlineData(15)]
public void BmpEntry_WritesPartialAlphaMaskByte(int width)
{
using Image<Rgba32> image = new(width, 1, Color.Red.ToPixel<Rgba32>());
image[width - 1, 0] = Color.Transparent.ToPixel<Rgba32>();
IcoFrameMetadata metadata = image.Frames.RootFrame.Metadata.GetIcoMetadata();
metadata.Compression = IconFrameCompression.Bmp;
metadata.BmpBitsPerPixel = BmpBitsPerPixel.Bit32;
using MemoryStream stream = new();
image.Save(stream, Encoder);
// These widths produce one DWORD-aligned mask row at the end of the bitmap resource.
ReadOnlySpan<byte> mask = stream.GetBuffer().AsSpan(checked((int)stream.Length) - sizeof(uint), sizeof(uint));
int pixelIndex = width - 1;
int byteIndex = pixelIndex / 8;
int bitIndex = pixelIndex % 8;
Assert.Equal((byte)(0b10000000 >> bitIndex), mask[byteIndex]);
}
}

3
tests/ImageSharp.Tests/Formats/ImageFormatManagerTests.cs

@ -3,6 +3,7 @@
using Moq;
using SixLabors.ImageSharp.Formats;
using SixLabors.ImageSharp.Formats.Ani;
using SixLabors.ImageSharp.Formats.Bmp;
using SixLabors.ImageSharp.Formats.Gif;
using SixLabors.ImageSharp.Formats.Jpeg;
@ -30,6 +31,7 @@ public class ImageFormatManagerTests
[Fact]
public void IfAutoLoadWellKnownFormatsIsTrueAllFormatsAreLoaded()
{
Assert.Equal(1, this.DefaultFormatsManager.ImageEncoders.Select(item => item.Value).OfType<AniEncoder>().Count());
Assert.Equal(1, this.DefaultFormatsManager.ImageEncoders.Select(item => item.Value).OfType<PbmEncoder>().Count());
Assert.Equal(1, this.DefaultFormatsManager.ImageEncoders.Select(item => item.Value).OfType<PngEncoder>().Count());
Assert.Equal(1, this.DefaultFormatsManager.ImageEncoders.Select(item => item.Value).OfType<BmpEncoder>().Count());
@ -39,6 +41,7 @@ public class ImageFormatManagerTests
Assert.Equal(1, this.DefaultFormatsManager.ImageEncoders.Select(item => item.Value).OfType<TiffEncoder>().Count());
Assert.Equal(1, this.DefaultFormatsManager.ImageEncoders.Select(item => item.Value).OfType<WebpEncoder>().Count());
Assert.Equal(1, this.DefaultFormatsManager.ImageDecoders.Select(item => item.Value).OfType<AniDecoder>().Count());
Assert.Equal(1, this.DefaultFormatsManager.ImageDecoders.Select(item => item.Value).OfType<PbmDecoder>().Count());
Assert.Equal(1, this.DefaultFormatsManager.ImageDecoders.Select(item => item.Value).OfType<PngDecoder>().Count());
Assert.Equal(1, this.DefaultFormatsManager.ImageDecoders.Select(item => item.Value).OfType<BmpDecoder>().Count());

7
tests/ImageSharp.Tests/TestImages.cs

@ -1400,6 +1400,13 @@ public static class TestImages
public const string CurFake = "Icon/cur_fake.ico";
}
public static class Ani
{
public const string Work = "Ani/Work.ani";
public const string MultiFramesInEveryIconChunk = "Ani/aero_busy.ani";
public const string Help = "Ani/Help.ani";
}
public static class Exr
{
public const string Benchmark = "Exr/Calliphora_benchmark.exr";

3
tests/Images/Input/Ani/Help.ani

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:c49cbb1ca0a3f268695a80df93b1ce2b2cba335a80e8244dd3a702863159bd99
size 12998

3
tests/Images/Input/Ani/Work.ani

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:740353739d3763addddd383614d125918781b8879f7c1ad3c770162a3e143a33
size 1150338

3
tests/Images/Input/Ani/aero_busy.ani

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:ff38afb523490e1a9f157c0447bc616b19c22df88bdb45c163243d834e9745f8
size 556304
Loading…
Cancel
Save