mirror of https://github.com/SixLabors/ImageSharp
committed by
GitHub
80 changed files with 4340 additions and 570 deletions
@ -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 |
||||
|
} |
||||
@ -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()); |
||||
|
} |
||||
|
} |
||||
@ -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; |
||||
|
} |
||||
@ -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); |
||||
|
} |
||||
|
} |
||||
@ -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; |
||||
|
} |
||||
@ -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); |
||||
|
} |
||||
|
} |
||||
@ -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)); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -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(); |
||||
|
} |
||||
@ -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 |
||||
|
} |
||||
@ -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 |
||||
|
}; |
||||
|
} |
||||
@ -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(); |
||||
|
} |
||||
@ -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); |
||||
|
} |
||||
|
} |
||||
@ -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 |
||||
|
} |
||||
@ -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); |
||||
|
} |
||||
@ -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); |
||||
|
} |
||||
@ -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..]) |
||||
|
}; |
||||
|
} |
||||
@ -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); |
||||
|
} |
||||
|
} |
||||
@ -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); |
||||
|
} |
||||
|
} |
||||
@ -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(); |
||||
|
} |
||||
@ -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; |
|
||||
} |
|
||||
} |
|
||||
@ -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)); |
||||
|
} |
||||
|
} |
||||
@ -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); |
||||
|
} |
||||
|
} |
||||
@ -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); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,3 @@ |
|||||
|
version https://git-lfs.github.com/spec/v1 |
||||
|
oid sha256:c49cbb1ca0a3f268695a80df93b1ce2b2cba335a80e8244dd3a702863159bd99 |
||||
|
size 12998 |
||||
@ -0,0 +1,3 @@ |
|||||
|
version https://git-lfs.github.com/spec/v1 |
||||
|
oid sha256:740353739d3763addddd383614d125918781b8879f7c1ad3c770162a3e143a33 |
||||
|
size 1150338 |
||||
@ -0,0 +1,3 @@ |
|||||
|
version https://git-lfs.github.com/spec/v1 |
||||
|
oid sha256:ff38afb523490e1a9f157c0447bc616b19c22df88bdb45c163243d834e9745f8 |
||||
|
size 556304 |
||||
Loading…
Reference in new issue