diff --git a/src/ImageSharp/Common/InlineArray.cs b/src/ImageSharp/Common/InlineArray.cs index 778981fd96..700551a8f3 100644 --- a/src/ImageSharp/Common/InlineArray.cs +++ b/src/ImageSharp/Common/InlineArray.cs @@ -26,6 +26,15 @@ internal struct InlineArray8 private T t; } +/// +/// Represents a safe, fixed sized buffer of 14 elements. +/// +[InlineArray(14)] +internal struct InlineArray14 +{ + private T t; +} + /// /// Represents a safe, fixed sized buffer of 16 elements. /// @@ -35,4 +44,47 @@ internal struct InlineArray16 private T t; } +/// +/// Represents a safe, fixed sized buffer of 18 elements. +/// +[InlineArray(18)] +internal struct InlineArray18 +{ + private T t; +} + +/// +/// Represents a safe, fixed sized buffer of 19 elements. +/// +[InlineArray(19)] +internal struct InlineArray19 +{ + private T t; +} + +/// +/// Represents a safe, fixed sized buffer of 26 elements. +/// +[InlineArray(26)] +internal struct InlineArray26 +{ + private T t; +} +/// +/// Represents a safe, fixed sized buffer of 36 elements. +/// +[InlineArray(36)] +internal struct InlineArray36 +{ + private T t; +} + +/// +/// Represents a safe, fixed sized buffer of 256 elements. +/// +[InlineArray(256)] +internal struct InlineArray256 +{ + private T t; +} diff --git a/src/ImageSharp/Common/InlineArray.tt b/src/ImageSharp/Common/InlineArray.tt index 6dc17b9a9c..d689b0469a 100644 --- a/src/ImageSharp/Common/InlineArray.tt +++ b/src/ImageSharp/Common/InlineArray.tt @@ -16,7 +16,7 @@ namespace SixLabors.ImageSharp; <#GenerateInlineArrays();#> <#+ -private static int[] Lengths = [4, 8, 16 ]; +private static int[] Lengths = [4, 8, 14, 16, 18, 19, 26, 36, 256]; void GenerateInlineArrays() { diff --git a/src/ImageSharp/Compression/Zlib/ChunkedReadStream.cs b/src/ImageSharp/Compression/Zlib/ChunkedReadStream.cs index b697327fff..9d1bd9a734 100644 --- a/src/ImageSharp/Compression/Zlib/ChunkedReadStream.cs +++ b/src/ImageSharp/Compression/Zlib/ChunkedReadStream.cs @@ -77,13 +77,14 @@ public override int ReadByte() } /// - public override int Read(byte[] buffer, int offset, int count) + public override int Read(byte[] buffer, int offset, int count) => this.Read(buffer.AsSpan(offset, count)); + + /// + public override int Read(Span buffer) { - // Decrement currentDataRemaining only by bytes actually returned by - // innerStream.Read; a short read otherwise underflows the segment - // counter and triggers getData() before the segment is truly drained. + // Decrement currentDataRemaining only by bytes actually returned by innerStream.Read; a short read otherwise advances segments too early. int totalBytesRead = 0; - while (totalBytesRead < count) + while (totalBytesRead < buffer.Length) { if (this.currentDataRemaining is 0) { @@ -94,8 +95,8 @@ public override int Read(byte[] buffer, int offset, int count) } } - int bytesToRead = Math.Min(count - totalBytesRead, this.currentDataRemaining); - int bytesRead = this.innerStream.Read(buffer, offset + totalBytesRead, bytesToRead); + int bytesToRead = Math.Min(buffer.Length - totalBytesRead, this.currentDataRemaining); + int bytesRead = this.innerStream.Read(buffer.Slice(totalBytesRead, bytesToRead)); if (bytesRead is 0) { break; diff --git a/src/ImageSharp/Compression/Zlib/ZlibInflateReader.cs b/src/ImageSharp/Compression/Zlib/ZlibInflateReader.cs index de69717da4..493ec27380 100644 --- a/src/ImageSharp/Compression/Zlib/ZlibInflateReader.cs +++ b/src/ImageSharp/Compression/Zlib/ZlibInflateReader.cs @@ -14,13 +14,6 @@ namespace SixLabors.ImageSharp.Compression.Zlib; /// internal sealed class ZlibInflateReader : IDisposable { - /// - /// Used to read the Adler-32 and Crc-32 checksums. - /// We don't actually use this for anything so it doesn't - /// have to be threadsafe. - /// - private static readonly byte[] ChecksumBuffer = new byte[4]; - private readonly ChunkedReadStream segmentStream; public ZlibInflateReader(BufferedReadStream innerStream) @@ -112,7 +105,9 @@ private bool InitializeInflateStream(bool isCriticalChunk) { // We don't need this for inflate so simply skip by the next four bytes. // https://tools.ietf.org/html/rfc1950#page-6 - if (this.segmentStream.Read(ChecksumBuffer, 0, 4) != 4) + InlineArray4 checksumBuffer = default; + + if (this.segmentStream.Read(checksumBuffer) != 4) { return false; } diff --git a/src/ImageSharp/Configuration.cs b/src/ImageSharp/Configuration.cs index 1e5433b9e2..94073dc099 100644 --- a/src/ImageSharp/Configuration.cs +++ b/src/ImageSharp/Configuration.cs @@ -5,6 +5,7 @@ using System.Diagnostics.CodeAnalysis; using SixLabors.ImageSharp.Advanced; using SixLabors.ImageSharp.Formats; +using SixLabors.ImageSharp.Formats.Ani; using SixLabors.ImageSharp.Formats.Bmp; using SixLabors.ImageSharp.Formats.Cur; using SixLabors.ImageSharp.Formats.Exr; @@ -224,6 +225,7 @@ public void Configure(IImageFormatConfigurationModule configuration) /// . /// . /// . + /// . /// /// The default configuration of . internal static Configuration CreateDefaultInstance() => new( @@ -238,5 +240,6 @@ public void Configure(IImageFormatConfigurationModule configuration) new ExrConfigurationModule(), new QoiConfigurationModule(), new IcoConfigurationModule(), - new CurConfigurationModule()); + new CurConfigurationModule(), + new AniConfigurationModule()); } diff --git a/src/ImageSharp/Formats/Ani/AniChunkType.cs b/src/ImageSharp/Formats/Ani/AniChunkType.cs new file mode 100644 index 0000000000..b50b1c3684 --- /dev/null +++ b/src/ImageSharp/Formats/Ani/AniChunkType.cs @@ -0,0 +1,73 @@ +// Copyright (c) Six Labors. +// Licensed under the Six Labors Split License. + +namespace SixLabors.ImageSharp.Formats.Ani; + +/// +/// Identifies top-level ANI RIFF chunks. +/// +internal enum AniChunkType : uint +{ + /// + /// The animation header chunk, "anih". + /// + Header = 0x68_69_6E_61, + + /// + /// The frame sequence chunk, "seq ". + /// + Sequence = 0x20_71_65_73, + + /// + /// The per-step display-rate chunk, "rate". + /// + Rate = 0x65_74_61_72, + + /// + /// A RIFF list chunk, "LIST". + /// + List = 0x54_53_49_4C +} + +/// +/// Identifies ANI RIFF list types. +/// +internal enum AniListType : uint +{ + /// + /// The information list, "INFO". + /// + Info = 0x4F_46_4E_49, + + /// + /// The embedded frame-resource list, "fram". + /// + Frames = 0x6D_61_72_66 +} + +/// +/// Identifies chunks stored in an ANI information list. +/// +internal enum AniInfoChunkType : uint +{ + /// + /// The animation name, "INAM". + /// + Name = 0x4D_41_4E_49, + + /// + /// The animation artist, "IART". + /// + Artist = 0x54_52_41_49 +} + +/// +/// Identifies chunks stored in an ANI frame list. +/// +internal enum AniFrameChunkType : uint +{ + /// + /// An embedded frame resource, "icon". + /// + Icon = 0x6E_6F_63_69 +} diff --git a/src/ImageSharp/Formats/Ani/AniConfigurationModule.cs b/src/ImageSharp/Formats/Ani/AniConfigurationModule.cs new file mode 100644 index 0000000000..27068c7192 --- /dev/null +++ b/src/ImageSharp/Formats/Ani/AniConfigurationModule.cs @@ -0,0 +1,25 @@ +// Copyright (c) Six Labors. +// Licensed under the Six Labors Split License. + +namespace SixLabors.ImageSharp.Formats.Ani; + +/// +/// Registers the image encoder, decoder, and format detector for the ANI format. +/// +public sealed class AniConfigurationModule : IImageFormatConfigurationModule +{ + /// + /// Initializes a new instance of the class. + /// + public AniConfigurationModule() + { + } + + /// + public void Configure(Configuration configuration) + { + configuration.ImageFormatsManager.SetEncoder(AniFormat.Instance, new AniEncoder()); + configuration.ImageFormatsManager.SetDecoder(AniFormat.Instance, AniDecoder.Instance); + configuration.ImageFormatsManager.AddImageFormatDetector(new AniImageFormatDetector()); + } +} diff --git a/src/ImageSharp/Formats/Ani/AniConstants.cs b/src/ImageSharp/Formats/Ani/AniConstants.cs new file mode 100644 index 0000000000..773fe7c267 --- /dev/null +++ b/src/ImageSharp/Formats/Ani/AniConstants.cs @@ -0,0 +1,54 @@ +// Copyright (c) Six Labors. +// Licensed under the Six Labors Split License. + +namespace SixLabors.ImageSharp.Formats.Ani; + +/// +/// Defines constants used by the ANI format. +/// +internal static class AniConstants +{ + /// + /// The number of bytes in the RIFF identifier, size, and form type. + /// + public const int RiffHeaderSize = 12; + + /// + /// The number of bytes in a RIFF chunk identifier and size. + /// + public const int ChunkHeaderSize = 8; + + /// + /// The number of bytes required to identify an embedded ICO or CUR resource. + /// + public const int IconDirHeaderSize = 6; + + /// + /// The maximum number of bytes retained from an ancillary chunk. + /// + /// + /// 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. + /// + public const int MaxAncillaryChunkSize = 8 * 1024 * 1024; + + /// + /// The list of MIME types that identify ANI data. + /// + public static readonly IEnumerable MimeTypes = ["application/x-navi-animation"]; + + /// + /// The list of file extensions that identify ANI data. + /// + public static readonly IEnumerable FileExtensions = ["ani"]; + + /// + /// Gets the RIFF container identifier. + /// + public static ReadOnlySpan RiffFourCc => "RIFF"u8; + + /// + /// Gets the ANI RIFF form type. + /// + public static ReadOnlySpan AniFormTypeFourCc => "ACON"u8; +} diff --git a/src/ImageSharp/Formats/Ani/AniDecoder.cs b/src/ImageSharp/Formats/Ani/AniDecoder.cs new file mode 100644 index 0000000000..756b281c9a --- /dev/null +++ b/src/ImageSharp/Formats/Ani/AniDecoder.cs @@ -0,0 +1,52 @@ +// Copyright (c) Six Labors. +// Licensed under the Six Labors Split License. + +using SixLabors.ImageSharp.PixelFormats; + +namespace SixLabors.ImageSharp.Formats.Ani; + +/// +/// Decodes Windows animated cursor images. +/// +public sealed class AniDecoder : ImageDecoder +{ + /// + /// Prevents a default instance of the class from being created. + /// + private AniDecoder() + { + } + + /// + /// Gets the shared instance. + /// + public static AniDecoder Instance { get; } = new(); + + /// + protected override Image Decode(DecoderOptions options, Stream stream, CancellationToken cancellationToken) + { + Guard.NotNull(options, nameof(options)); + Guard.NotNull(stream, nameof(stream)); + + using AniDecoderCore decoder = new(options); + Image image = decoder.Decode(options.Configuration, stream, cancellationToken); + + ScaleToTargetSize(options, image); + + return image; + } + + /// + protected override Image Decode(DecoderOptions options, Stream stream, CancellationToken cancellationToken) + => this.Decode(options, stream, cancellationToken); + + /// + 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); + } +} diff --git a/src/ImageSharp/Formats/Ani/AniDecoderCore.cs b/src/ImageSharp/Formats/Ani/AniDecoderCore.cs new file mode 100644 index 0000000000..cdfcf30164 --- /dev/null +++ b/src/ImageSharp/Formats/Ani/AniDecoderCore.cs @@ -0,0 +1,862 @@ +// Copyright (c) Six Labors. +// Licensed under the Six Labors Split License. + +using System.Buffers; +using System.Buffers.Binary; +using System.Runtime.InteropServices; +using System.Text; +using SixLabors.ImageSharp.Formats.Bmp; +using SixLabors.ImageSharp.Formats.Cur; +using SixLabors.ImageSharp.Formats.Ico; +using SixLabors.ImageSharp.Formats.Icon; +using SixLabors.ImageSharp.IO; +using SixLabors.ImageSharp.Memory; +using SixLabors.ImageSharp.Metadata; +using SixLabors.ImageSharp.PixelFormats; + +namespace SixLabors.ImageSharp.Formats.Ani; + +/// +/// Performs ANI decoding and identification. +/// +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? sequence; + private IMemoryOwner? rates; + + /// + /// Reusable storage for the fixed ANI header and smaller RIFF values. + /// + private InlineArray36 buffer; + + /// + /// Initializes a new instance of the class. + /// + /// The general decoder options. + 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(); + } + + /// + protected override Image Decode(BufferedReadStream stream, CancellationToken cancellationToken) + { + this.ParseContainer(stream); + + DecoderOptions frameOptions = this.CreateFrameDecoderOptions(); + List<(AniFrameFormat Format, Image Image)?> resources = []; + List> 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 resource = DecodeFrame(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? sequenceOwner = this.sequence; + bool hasSequence = sequenceOwner is not null; + ReadOnlySpan sequence = sequenceOwner is null ? [] : sequenceOwner.GetSpan(); + ReadOnlySpan 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 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 source = resourceImage.Frames[i]; + ImageFrame 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 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 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 frame in outputFrames) + { + frame.Dispose(); + } + } + } + } + + /// + 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 outputFrames = []; + IMemoryOwner? sequenceOwner = this.sequence; + bool hasSequence = sequenceOwner is not null; + ReadOnlySpan sequence = sequenceOwner is null ? [] : sequenceOwner.GetSpan(); + ReadOnlySpan 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); + } + + /// + /// Parses the RIFF container and records frame-list boundaries for subsequent embedded decoding. + /// + /// The ANI stream. + 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 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."); + } + } + + /// + /// Parses the mandatory 36-byte ANI header and copies its observable values to image metadata. + /// + /// The ANI stream. + /// The ANI header chunk size. + private void ReadAniHeader(BufferedReadStream stream, uint chunkSize) + { + if (chunkSize < AniHeader.Size) + { + throw new InvalidImageContentException("The ANI animation header is truncated."); + } + + Span 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; + } + + /// + /// Reads a RIFF list type and records or parses its contents. + /// + /// The ANI stream. + /// The exclusive end of the list payload. + 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 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; + } + } + + /// + /// Parses the optional ANI name and artist information. + /// + /// The ANI stream. + /// The exclusive end of the information list. + 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? 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(); + } + } + + /// + /// Reads a sequence or rate chunk into reusable allocator-owned memory. + /// + /// The ANI stream. + /// The chunk payload size. + /// The chunk description used in error messages. + /// The buffer to reuse or replace. + private void ReadUInt32Values(BufferedReadStream stream, uint chunkSize, string description, ref IMemoryOwner? 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 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(count); + replaceOwner = true; + } + + bool success = false; + + // A newly allocated replacement is not published until the entire payload has been read and normalized. + try + { + Span values = valuesOwner.GetSpan()[..count]; + Span 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; + } + } + + /// + public void Dispose() + { + this.sequence?.Dispose(); + this.rates?.Dispose(); + this.sequence = null; + this.rates = null; + } + + /// + /// Tries to read a null-terminated ANI information string. + /// + /// The ANI stream. + /// The text chunk payload size. + /// The reusable text buffer. + /// The decoded ASCII text when successful. + /// when the text was read successfully; otherwise, . + private bool TryReadText(BufferedReadStream stream, uint chunkSize, ref IMemoryOwner? 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(length); + } + + Span 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; + } + + /// + /// Processes each embedded frame-resource chunk without allowing its decoder to read adjacent RIFF data. + /// + /// The parsed resource type. + /// The ANI stream. + /// The destination resource slots. + /// The operation to perform for each resource format and bounded stream. + private void ProcessFrameChunks(BufferedReadStream stream, List<(AniFrameFormat Format, T Resource)?> resources, Func 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 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? sortedSequenceOwner = null; + + try + { + ReadOnlySpan 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(sequence.Length); + Span 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(); + } + } + + /// + /// Determines the embedded resource format from the ANI header and ICO/CUR directory prefix. + /// + /// The bounded frame-resource stream. + /// The embedded resource format. + 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 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.") + }; + } + + /// + /// Decodes one embedded ANI frame resource. + /// + /// The destination pixel type. + /// The embedded resource format. + /// The nested decoder options. + /// The bounded resource stream. + /// The token to monitor for cancellation requests. + /// The decoded resource. + private static Image DecodeFrame(AniFrameFormat format, DecoderOptions options, Stream stream, CancellationToken cancellationToken) + where TPixel : unmanaged, IPixel + => format switch + { + AniFrameFormat.Ico => new IcoDecoderCore(options).Decode(options.Configuration, stream, cancellationToken), + AniFrameFormat.Cur => new CurDecoderCore(options).Decode(options.Configuration, stream, cancellationToken), + AniFrameFormat.Bmp => new BmpDecoderCore(new BmpDecoderOptions + { + GeneralOptions = options, + SkipFileHeader = true + }).Decode(options.Configuration, stream, cancellationToken), + _ => throw new InvalidImageContentException("The ANI file contains an unsupported frame format.") + }; + + /// + /// Identifies one embedded ANI frame resource. + /// + /// The embedded resource format. + /// The nested decoder options. + /// The bounded resource stream. + /// The token to monitor for cancellation requests. + /// The identified resource. + 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.") + }; + + /// + /// Creates ANI metadata for one flattened output frame. + /// + /// The embedded frame metadata, when available. + /// The embedded resource format. + /// The animation sequence number. + /// The display rate in sixtieths of a second. + /// The embedded frame size. + /// The ANI frame metadata. + 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; + } + + /// + /// Creates decoder options for embedded resources without applying the outer ANI resize twice. + /// + /// The embedded frame decoder options. + private DecoderOptions CreateFrameDecoderOptions() + => new() + { + Configuration = this.Options.Configuration, + MaxFrames = this.Options.MaxFrames, + SkipMetadata = this.Options.SkipMetadata, + SegmentIntegrityHandling = this.Options.SegmentIntegrityHandling, + ColorProfileHandling = this.Options.ColorProfileHandling + }; + + /// + /// Reads one fixed-size RIFF chunk header. + /// + /// The ANI stream. + /// The parsed chunk header. + private AniRiffChunkHeader ReadChunkHeader(BufferedReadStream stream) + { + Span data = this.buffer[..AniConstants.ChunkHeaderSize]; + ReadExactly(stream, data, "RIFF chunk header"); + return AniRiffChunkHeader.Parse(data); + } + + /// + /// Calculates and validates the exclusive end of a RIFF chunk payload. + /// + /// The ANI stream. + /// The declared payload size. + /// The exclusive parent-container boundary. + /// The exclusive payload boundary. + 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; + } + + /// + /// Calculates and validates the word-aligned end of a RIFF chunk. + /// + /// The exclusive payload boundary. + /// The declared payload size. + /// The exclusive parent-container boundary. + /// The exclusive padded chunk boundary. + 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; + } + + /// + /// Reads an exact number of bytes or reports a truncated ANI file. + /// + /// The ANI stream. + /// The destination buffer. + /// The data description used in the error message. + private static void ReadExactly(BufferedReadStream stream, Span destination, string description) + { + if (stream.Read(destination) != destination.Length) + { + throw new InvalidImageContentException($"Not enough bytes to read the {description}."); + } + } + + /// + /// Converts a pixel dimension to the one-byte ICO/CUR representation. + /// + /// The pixel dimension. + /// The encoded dimension, where zero represents 256 pixels or greater. + private static byte NarrowDimension(int value) => value > byte.MaxValue ? (byte)0 : (byte)value; +} diff --git a/src/ImageSharp/Formats/Ani/AniEncoder.cs b/src/ImageSharp/Formats/Ani/AniEncoder.cs new file mode 100644 index 0000000000..ccc93c3716 --- /dev/null +++ b/src/ImageSharp/Formats/Ani/AniEncoder.cs @@ -0,0 +1,24 @@ +// Copyright (c) Six Labors. +// Licensed under the Six Labors Split License. + +namespace SixLabors.ImageSharp.Formats.Ani; + +/// +/// Encodes images as Windows animated cursors. +/// +public sealed class AniEncoder : QuantizingImageEncoder +{ + /// + /// Initializes a new instance of the class. + /// + public AniEncoder() + { + } + + /// + protected override void Encode(Image image, Stream stream, CancellationToken cancellationToken) + { + AniEncoderCore encoder = new(this); + encoder.Encode(image, stream, cancellationToken); + } +} diff --git a/src/ImageSharp/Formats/Ani/AniEncoderCore.cs b/src/ImageSharp/Formats/Ani/AniEncoderCore.cs new file mode 100644 index 0000000000..96b5fb5751 --- /dev/null +++ b/src/ImageSharp/Formats/Ani/AniEncoderCore.cs @@ -0,0 +1,514 @@ +// Copyright (c) Six Labors. +// Licensed under the Six Labors Split License. + +using System.Buffers; +using System.Buffers.Binary; +using System.Text; +using SixLabors.ImageSharp.Formats.Bmp; +using SixLabors.ImageSharp.Formats.Cur; +using SixLabors.ImageSharp.Formats.Ico; +using SixLabors.ImageSharp.Formats.Icon; +using SixLabors.ImageSharp.Memory; +using SixLabors.ImageSharp.PixelFormats; + +namespace SixLabors.ImageSharp.Formats.Ani; + +/// +/// Performs ANI encoding. +/// +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; + + /// + /// Reusable storage for the fixed ANI header and smaller RIFF values. + /// + private InlineArray36 buffer; + + /// + /// Initializes a new instance of the class. + /// + /// The encoder options. + public AniEncoderCore(AniEncoder encoder) + => this.encoder = encoder; + + /// + /// Encodes an image as ANI data. + /// + /// The source pixel type. + /// The source image. + /// The destination stream. + /// The token to monitor for cancellation requests. + public void Encode(Image image, Stream stream, CancellationToken cancellationToken) + where TPixel : unmanaged, IPixel + { + 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? iconEntriesOwner = bitmapResources ? null : image.Configuration.MemoryAllocator.Allocate(maxGroupSize); + Span 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); + } + + /// + /// Writes the fixed-size ANI animation header chunk. + /// + /// The destination stream. + /// The animation header. + private void WriteHeader(Stream stream, AniHeader header) + { + long sizePosition = this.BeginChunk(stream, "anih"u8); + Span data = this.buffer; + header.WriteTo(data); + stream.Write(data); + this.EndChunk(stream, sizePosition); + } + + /// + /// Writes an identity sequence table when the source metadata declares an explicit sequence. + /// + /// The destination stream. + /// The number of animation steps. + private void WriteSequence(Stream stream, int stepCount) + { + long sizePosition = this.BeginChunk(stream, "seq "u8); + Span 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); + } + + /// + /// Writes per-step rates when they cannot be represented by one header value. + /// + /// The destination stream. + /// The source image. + /// The default header display rate. + private void WriteRates(Stream stream, Image image, uint displayRate) + { + long sizePosition = this.BeginChunk(stream, "rate"u8); + Span 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); + } + + /// + /// Writes the optional ANI name and artist list. + /// + /// The destination stream. + /// The ANI image metadata. + /// The allocator used for the text buffer. + 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 owner = memoryAllocator.Allocate(Math.Max(nameLength, artistLength)); + Span 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); + } + + /// + /// Writes a null-terminated ASCII RIFF information chunk. + /// + /// The destination stream. + /// The chunk identifier. + /// The text value. + /// The reusable text buffer. + private void WriteTextChunk(Stream stream, ReadOnlySpan fourCc, string value, Span 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); + } + + /// + /// Encodes one ANI frame resource using the existing ICO, CUR, or BMP encoder. + /// + /// The source pixel type. + /// The source image. + /// The destination stream. + /// The first source-frame index. + /// The number of source frames in this resource. + /// The embedded resource format. + /// The bitmap bit depth declared by the ANI header. + /// The reusable icon directory metadata buffer. + /// The token to monitor for cancellation requests. + private void WriteFrameResource(Image image, Stream stream, int frameIndex, int frameCount, AniFrameFormat format, uint bitCount, Span iconEntries, CancellationToken cancellationToken) + where TPixel : unmanaged, IPixel + { + 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 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; + } + } + + /// + /// Creates an icon directory entry from ANI-owned frame metadata. + /// + /// The ANI frame metadata. + /// The embedded icon format. + /// The source frame size. + /// The icon directory entry. + 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 + }; + } + + /// + /// Converts an ANI bitmap bit depth to a supported BMP encoder value. + /// + /// The ANI bit depth. + /// The BMP encoder bit depth. + 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 + }; + + /// + /// Converts a pixel dimension to the one-byte ICO/CUR representation. + /// + /// The pixel dimension. + /// The encoded dimension, where zero represents 256 pixels or greater. + private static byte NarrowDimension(int value) => value > byte.MaxValue ? (byte)0 : (byte)value; + + /// + /// Begins a RIFF chunk whose size will be backpatched after its payload is written. + /// + /// The destination stream. + /// The chunk identifier. + /// The stream position of the chunk-size field. + private long BeginChunk(Stream stream, ReadOnlySpan 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 size = this.buffer[..sizeof(uint)]; + size.Clear(); + stream.Write(size); + + return sizePosition; + } + + /// + /// Begins a RIFF container chunk and writes its form or list type. + /// + /// The destination stream. + /// The container identifier. + /// The container form or list type. + /// The stream position of the container-size field. + private long BeginContainer(Stream stream, ReadOnlySpan fourCc, ReadOnlySpan type) + { + long sizePosition = this.BeginChunk(stream, fourCc); + stream.Write(type); + return sizePosition; + } + + /// + /// Word-aligns a RIFF chunk and writes its payload size into the reserved field. + /// + /// The destination stream. + /// The stream position of the reserved size field. + 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 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; + } + + /// + /// Projects ANI-owned metadata into the icon encoder without allocating intermediary metadata objects. + /// + private readonly struct AniIconFrameMetadataProvider : IconEncoderCore.IEncodingFrameMetadataProvider + { + private readonly AniFrameFormat format; + + /// + /// Initializes a new instance of the struct. + /// + /// The embedded icon format. + public AniIconFrameMetadataProvider(AniFrameFormat format) + => this.format = format; + + /// + public IconEncoderCore.EncodingFrameMetadata GetEncodingFrameMetadata(ImageFrame frame, out ReadOnlyMemory? colorTable) + { + AniFrameMetadata metadata = frame.Metadata.GetAniMetadata(); + colorTable = metadata.ColorTable; + return new IconEncoderCore.EncodingFrameMetadata(metadata.Compression, metadata.BmpBitsPerPixel, CreateIconDirEntry(metadata, this.format, frame.Size)); + } + } +} diff --git a/src/ImageSharp/Formats/Ani/AniFormat.cs b/src/ImageSharp/Formats/Ani/AniFormat.cs new file mode 100644 index 0000000000..04781974ec --- /dev/null +++ b/src/ImageSharp/Formats/Ani/AniFormat.cs @@ -0,0 +1,40 @@ +// Copyright (c) Six Labors. +// Licensed under the Six Labors Split License. + +namespace SixLabors.ImageSharp.Formats.Ani; + +/// +/// Describes the ANI image format. +/// +public sealed class AniFormat : IImageFormat +{ + /// + /// Prevents a default instance of the class from being created. + /// + private AniFormat() + { + } + + /// + /// Gets the shared instance. + /// + public static AniFormat Instance { get; } = new(); + + /// + public string Name => "ANI"; + + /// + public string DefaultMimeType => "application/x-navi-animation"; + + /// + public IEnumerable MimeTypes => AniConstants.MimeTypes; + + /// + public IEnumerable FileExtensions => AniConstants.FileExtensions; + + /// + public AniMetadata CreateDefaultFormatMetadata() => new(); + + /// + public AniFrameMetadata CreateDefaultFormatFrameMetadata() => new(); +} diff --git a/src/ImageSharp/Formats/Ani/AniFrameFormat.cs b/src/ImageSharp/Formats/Ani/AniFrameFormat.cs new file mode 100644 index 0000000000..c7902ee1c5 --- /dev/null +++ b/src/ImageSharp/Formats/Ani/AniFrameFormat.cs @@ -0,0 +1,25 @@ +// Copyright (c) Six Labors. +// Licensed under the Six Labors Split License. + +namespace SixLabors.ImageSharp.Formats.Ani; + +/// +/// Specifies the format of the frame data. +/// +public enum AniFrameFormat : byte +{ + /// + /// The frame resource is encoded as a Windows cursor. + /// + Cur, + + /// + /// The frame resource is encoded as a Windows icon. + /// + Ico, + + /// + /// The frame resource is encoded as a Windows bitmap. + /// + Bmp +} diff --git a/src/ImageSharp/Formats/Ani/AniFrameMetadata.cs b/src/ImageSharp/Formats/Ani/AniFrameMetadata.cs new file mode 100644 index 0000000000..4fe31788c8 --- /dev/null +++ b/src/ImageSharp/Formats/Ani/AniFrameMetadata.cs @@ -0,0 +1,248 @@ +// Copyright (c) Six Labors. +// Licensed under the Six Labors Split License. + +using System.Numerics; +using SixLabors.ImageSharp.Formats.Bmp; +using SixLabors.ImageSharp.Formats.Icon; +using SixLabors.ImageSharp.PixelFormats; + +namespace SixLabors.ImageSharp.Formats.Ani; + +/// +/// Provides ANI-specific metadata for an image frame. +/// +public class AniFrameMetadata : IFormatFrameMetadata +{ + /// + /// Initializes a new instance of the class. + /// + public AniFrameMetadata() + { + } + + /// + /// Initializes a new instance of the class by copying another instance. + /// + /// The metadata to copy. + 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(); + } + } + + /// + /// Gets or sets the frame display time in sixtieths of a second. + /// + public uint FrameDelay { get; set; } + + /// + /// 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. + /// + public int SequenceNumber { get; set; } + + /// + /// Gets or sets the encoded frame width. + /// A value of zero represents 256 pixels or greater in ICO and CUR resources. + /// + public byte? EncodingWidth { get; set; } + + /// + /// Gets or sets the encoded frame height. + /// A value of zero represents 256 pixels or greater in ICO and CUR resources. + /// + public byte? EncodingHeight { get; set; } + + /// + /// Gets or sets the format used for this frame resource. + /// + public AniFrameFormat FrameFormat { get; set; } + + /// + /// Gets or sets the embedded ICO or CUR compression format. + /// + public IconFrameCompression Compression { get; set; } = IconFrameCompression.Png; + + /// + /// Gets or sets the embedded bitmap bits per pixel. + /// + public BmpBitsPerPixel BmpBitsPerPixel { get; set; } = BmpBitsPerPixel.Bit32; + + /// + /// Gets or sets the embedded bitmap color table. + /// The underlying pixel format is represented by . + /// + public ReadOnlyMemory? ColorTable { get; set; } + + /// + /// Gets or sets the horizontal cursor hotspot in pixels from the left. + /// + public ushort HotspotX { get; set; } + + /// + /// Gets or sets the vertical cursor hotspot in pixels from the top. + /// + public ushort HotspotY { get; set; } + + /// + 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 + }; + } + + /// + public FormatConnectingFrameMetadata ToFormatConnectingFrameMetadata() + => new() + { + Duration = TimeSpan.FromSeconds(this.FrameDelay / 60D), + EncodingWidth = this.EncodingWidth, + EncodingHeight = this.EncodingHeight, + PixelTypeInfo = this.GetPixelTypeInfo() + }; + + /// + public void AfterFrameApply(ImageFrame source, ImageFrame destination, Matrix4x4 matrix) + where TPixel : unmanaged, IPixel + { + 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; + } + + /// + IDeepCloneable IDeepCloneable.DeepClone() => this.DeepClone(); + + /// + public AniFrameMetadata DeepClone() => new(this); + + /// + /// Gets the pixel layout represented by the embedded resource metadata. + /// + /// The represented pixel layout. + 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 + }; + } + + /// + /// Scales an encoded dimension after an image transform. + /// + /// The encoded source dimension. + /// The full destination dimension. + /// The destination-to-source scale ratio. + /// The encoded destination dimension. + 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)); + } + + /// + /// Converts a pixel dimension to the one-byte ICO/CUR representation. + /// + /// The pixel dimension. + /// The encoded dimension. + private static byte ClampEncodingDimension(float? dimension) + => dimension switch + { + > 255 => 0, + >= 1 => (byte)dimension, + _ => 0 + }; +} diff --git a/src/ImageSharp/Formats/Ani/AniFrameStream.cs b/src/ImageSharp/Formats/Ani/AniFrameStream.cs new file mode 100644 index 0000000000..7a0b9ca3c8 --- /dev/null +++ b/src/ImageSharp/Formats/Ani/AniFrameStream.cs @@ -0,0 +1,114 @@ +// Copyright (c) Six Labors. +// Licensed under the Six Labors Split License. + +namespace SixLabors.ImageSharp.Formats.Ani; + +/// +/// Exposes one ANI frame-resource chunk as an isolated seekable stream. +/// +/// +/// 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. +/// +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; + + /// + /// Initializes a new instance of the class. + /// + /// The containing ANI stream. + public AniFrameStream(Stream stream) + => this.stream = stream; + + /// + public override bool CanRead => true; + + /// + public override bool CanSeek => true; + + /// + public override bool CanWrite => false; + + /// + public override long Length => this.length; + + /// + public override long Position + { + get => this.position; + set => this.Seek(value, SeekOrigin.Begin); + } + + /// + /// Repositions this stream over another frame-resource payload in the same containing stream. + /// + /// The absolute start of the frame-resource payload. + /// The frame-resource payload length. + public void Reset(long start, long length) + { + this.start = start; + this.length = length; + this.position = 0; + } + + /// + public override void Flush() + { + } + + /// + public override int Read(byte[] buffer, int offset, int count) + => this.Read(buffer.AsSpan(offset, count)); + + /// + public override int Read(Span 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; + } + + /// + 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; + } + + /// + public override void SetLength(long value) => throw new NotSupportedException(); + + /// + public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException(); +} diff --git a/src/ImageSharp/Formats/Ani/AniHeader.cs b/src/ImageSharp/Formats/Ani/AniHeader.cs new file mode 100644 index 0000000000..e886df6b6c --- /dev/null +++ b/src/ImageSharp/Formats/Ani/AniHeader.cs @@ -0,0 +1,98 @@ +// Copyright (c) Six Labors. +// Licensed under the Six Labors Split License. + +using System.Buffers.Binary; + +namespace SixLabors.ImageSharp.Formats.Ani; + +/// +/// Represents the data stored in an ANI "anih" chunk. +/// +internal struct AniHeader +{ + /// + /// The number of bytes in the ANI header. + /// + public const int Size = 9 * sizeof(uint); + + /// + /// Gets or sets the declared ANI header size. + /// + public uint BytesInHeader { get; set; } + + /// + /// Gets or sets the number of embedded frame resources. + /// + public uint FrameCount { get; set; } + + /// + /// Gets or sets the number of animation steps. + /// + public uint StepCount { get; set; } + + /// + /// Gets or sets the frame width used by bitmap-based animations. + /// + public uint Width { get; set; } + + /// + /// Gets or sets the frame height used by bitmap-based animations. + /// + public uint Height { get; set; } + + /// + /// Gets or sets the encoded bits per pixel. + /// + public uint BitCount { get; set; } + + /// + /// Gets or sets the number of color planes. + /// + public uint Planes { get; set; } + + /// + /// Gets or sets the default display rate in sixtieths of a second. + /// + public uint DisplayRate { get; set; } + + /// + /// Gets or sets the ANI header flags. + /// + public AniHeaderFlags Flags { get; set; } + + /// + /// Parses an ANI header from its little-endian byte representation. + /// + /// The ANI header data. + /// The parsed ANI header. + public static AniHeader Parse(ReadOnlySpan 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..]) + }; + + /// + /// Writes the ANI header to its little-endian byte representation. + /// + /// The destination buffer. + public readonly void WriteTo(Span 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); + } +} diff --git a/src/ImageSharp/Formats/Ani/AniHeaderFlags.cs b/src/ImageSharp/Formats/Ani/AniHeaderFlags.cs new file mode 100644 index 0000000000..528a8908fc --- /dev/null +++ b/src/ImageSharp/Formats/Ani/AniHeaderFlags.cs @@ -0,0 +1,21 @@ +// Copyright (c) Six Labors. +// Licensed under the Six Labors Split License. + +namespace SixLabors.ImageSharp.Formats.Ani; + +/// +/// Flags for the ANI header. +/// +[Flags] +public enum AniHeaderFlags : uint +{ + /// + /// The "icon" chunks contain ICO or CUR resources. Without this flag, they contain BMP resources. + /// + IsIcon = 1, + + /// + /// The ANI file contains a "seq " chunk that maps animation steps to frame resources. + /// + ContainsSequence = 2 +} diff --git a/src/ImageSharp/Formats/Ani/AniImageFormatDetector.cs b/src/ImageSharp/Formats/Ani/AniImageFormatDetector.cs new file mode 100644 index 0000000000..5b63a28183 --- /dev/null +++ b/src/ImageSharp/Formats/Ani/AniImageFormatDetector.cs @@ -0,0 +1,39 @@ +// Copyright (c) Six Labors. +// Licensed under the Six Labors Split License. + +using System.Diagnostics.CodeAnalysis; + +namespace SixLabors.ImageSharp.Formats.Ani; + +/// +/// Detects ANI file headers. +/// +public sealed class AniImageFormatDetector : IImageFormatDetector +{ + /// + /// Initializes a new instance of the class. + /// + public AniImageFormatDetector() + { + } + + /// + public int HeaderSize => AniConstants.RiffHeaderSize; + + /// + public bool TryDetectFormat(ReadOnlySpan header, [NotNullWhen(true)] out IImageFormat? format) + { + format = this.IsSupportedFileFormat(header) ? AniFormat.Instance : null; + return format is not null; + } + + /// + /// Determines whether the supplied header is a RIFF container with the ANI "ACON" form type. + /// + /// The candidate file header. + /// when the header identifies ANI data. + private bool IsSupportedFileFormat(ReadOnlySpan header) + => header.Length >= this.HeaderSize + && header[..4].SequenceEqual(AniConstants.RiffFourCc) + && header.Slice(8, 4).SequenceEqual(AniConstants.AniFormTypeFourCc); +} diff --git a/src/ImageSharp/Formats/Ani/AniMetadata.cs b/src/ImageSharp/Formats/Ani/AniMetadata.cs new file mode 100644 index 0000000000..b72d7c1e3f --- /dev/null +++ b/src/ImageSharp/Formats/Ani/AniMetadata.cs @@ -0,0 +1,134 @@ +// Copyright (c) Six Labors. +// Licensed under the Six Labors Split License. + +using System.Numerics; +using SixLabors.ImageSharp.PixelFormats; + +namespace SixLabors.ImageSharp.Formats.Ani; + +/// +/// Provides ANI-specific metadata for an image. +/// +public class AniMetadata : IFormatMetadata +{ + /// + /// Initializes a new instance of the class. + /// + public AniMetadata() + { + } + + /// + /// Initializes a new instance of the class by copying another instance. + /// + /// The metadata to copy. + 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; + } + + /// + /// Gets or sets the frame width declared by the ANI header. + /// + /// + /// Icon-based ANI files commonly store zero because each embedded resource declares its own dimensions. + /// + public uint Width { get; set; } + + /// + /// Gets or sets the frame height declared by the ANI header. + /// + /// + /// Icon-based ANI files commonly store zero because each embedded resource declares its own dimensions. + /// + public uint Height { get; set; } + + /// + /// Gets or sets the bits per pixel declared by the ANI header. + /// + /// + /// 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. + /// + public uint BitCount { get; set; } + + /// + /// Gets or sets the number of independently addressable color planes declared by the ANI header. + /// + /// + /// 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. + /// + public uint Planes { get; set; } + + /// + /// Gets or sets the default frame display rate in sixtieths of a second. + /// + public uint DisplayRate { get; set; } + + /// + /// Gets or sets the ANI header flags. + /// + public AniHeaderFlags Flags { get; set; } = AniHeaderFlags.IsIcon; + + /// + /// Gets or sets the animation name. + /// + public string? Name { get; set; } + + /// + /// Gets or sets the animation artist. + /// + public string? Artist { get; set; } + + /// + public static AniMetadata FromFormatConnectingMetadata(FormatConnectingMetadata metadata) + => new() + { + BitCount = (uint)metadata.PixelTypeInfo.BitsPerPixel, + Planes = 1, + Flags = AniHeaderFlags.IsIcon + }; + + /// + 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); + } + + /// + public FormatConnectingMetadata ToFormatConnectingMetadata() + => new() + { + AnimateRootFrame = true, + EncodingType = EncodingType.Lossless, + PixelTypeInfo = this.GetPixelTypeInfo() + }; + + /// + public void AfterImageApply(Image destination, Matrix4x4 matrix) + where TPixel : unmanaged, IPixel + { + if (!this.Flags.HasFlag(AniHeaderFlags.IsIcon)) + { + this.Width = (uint)destination.Width; + this.Height = (uint)destination.Height; + } + } + + /// + IDeepCloneable IDeepCloneable.DeepClone() => this.DeepClone(); + + /// + public AniMetadata DeepClone() => new(this); +} diff --git a/src/ImageSharp/Formats/Ani/AniRiffChunkHeader.cs b/src/ImageSharp/Formats/Ani/AniRiffChunkHeader.cs new file mode 100644 index 0000000000..0968741793 --- /dev/null +++ b/src/ImageSharp/Formats/Ani/AniRiffChunkHeader.cs @@ -0,0 +1,34 @@ +// Copyright (c) Six Labors. +// Licensed under the Six Labors Split License. + +using System.Buffers.Binary; + +namespace SixLabors.ImageSharp.Formats.Ani; + +/// +/// Represents a RIFF chunk identifier and payload size. +/// +internal struct AniRiffChunkHeader +{ + /// + /// Gets or sets the chunk identifier. + /// + public uint FourCc { get; set; } + + /// + /// Gets or sets the chunk payload size in bytes, excluding alignment padding. + /// + public uint Size { get; set; } + + /// + /// Parses a RIFF chunk header from its little-endian byte representation. + /// + /// The RIFF chunk header data. + /// The parsed RIFF chunk header. + public static AniRiffChunkHeader Parse(ReadOnlySpan data) + => new() + { + FourCc = BinaryPrimitives.ReadUInt32LittleEndian(data), + Size = BinaryPrimitives.ReadUInt32LittleEndian(data[4..]) + }; +} diff --git a/src/ImageSharp/Formats/Bmp/BmpEncoderCore.cs b/src/ImageSharp/Formats/Bmp/BmpEncoderCore.cs index 0bf57c5612..af6582420e 100644 --- a/src/ImageSharp/Formats/Bmp/BmpEncoderCore.cs +++ b/src/ImageSharp/Formats/Bmp/BmpEncoderCore.cs @@ -102,6 +102,11 @@ internal sealed class BmpEncoderCore /// private readonly bool skipFileHeader; + /// + /// Whether optional image metadata should be omitted. + /// + private readonly bool skipMetadata; + /// private readonly bool isDoubleHeight; @@ -122,6 +127,7 @@ public BmpEncoderCore(BmpEncoder encoder, MemoryAllocator memoryAllocator) this.infoHeaderType = encoder.SupportTransparency ? BmpInfoHeaderType.WinVersion4 : BmpInfoHeaderType.WinVersion3; this.processedAlphaMask = encoder.ProcessedAlphaMask; this.skipFileHeader = encoder.SkipFileHeader; + this.skipMetadata = encoder.SkipMetadata; this.isDoubleHeight = encoder.UseDoubleHeight; } @@ -138,17 +144,30 @@ public void Encode(Image image, Stream stream, CancellationToken Guard.NotNull(image, nameof(image)); Guard.NotNull(stream, nameof(stream)); - // Stream may not at 0. + this.Encode(image.Frames.RootFrame, image.Metadata, stream, cancellationToken); + } + + /// + /// Encodes a source frame using the supplied image metadata. + /// + /// The pixel format. + /// The source frame. + /// The source image metadata. + /// The destination stream. + /// The token to request cancellation. + internal void Encode(ImageFrame frame, ImageMetadata metadata, Stream stream, CancellationToken cancellationToken) + where TPixel : unmanaged, IPixel + { + // Nested ANI/ICO/CUR encoding starts inside a parent stream, so all later profile offsets use this local base. long basePosition = stream.Position; - Configuration configuration = image.Configuration; - ImageMetadata metadata = image.Metadata; + Configuration configuration = frame.Configuration; BmpMetadata bmpMetadata = metadata.GetBmpMetadata(); this.bitsPerPixel ??= bmpMetadata.BitsPerPixel; ushort bpp = (ushort)this.bitsPerPixel; - int bytesPerLine = (int)(4 * ((((uint)image.Width * bpp) + 31) / 32)); - this.padding = bytesPerLine - (int)(image.Width * (bpp / 8F)); + int bytesPerLine = (int)(4 * ((((uint)frame.Width * bpp) + 31) / 32)); + this.padding = bytesPerLine - (int)(frame.Width * (bpp / 8F)); int colorPaletteSize = this.bitsPerPixel switch { @@ -161,7 +180,7 @@ public void Encode(Image image, Stream stream, CancellationToken byte[]? iccProfileData = null; int iccProfileSize = 0; - if (metadata.IccProfile != null) + if (!this.skipMetadata && metadata.IccProfile != null) { this.infoHeaderType = BmpInfoHeaderType.WinVersion5; iccProfileData = metadata.IccProfile.ToByteArray(); @@ -176,25 +195,25 @@ public void Encode(Image image, Stream stream, CancellationToken _ => BmpInfoHeader.SizeV3 }; - // for ico/cur encoder. - int height = image.Height; + // ICO/CUR DIB headers include the XOR bitmap and following AND mask in one doubled height. + int height = frame.Height; if (this.isDoubleHeight) { height <<= 1; } - BmpInfoHeader infoHeader = this.CreateBmpInfoHeader(image.Width, height, infoHeaderSize, bpp, bytesPerLine, metadata, iccProfileData); + BmpInfoHeader infoHeader = this.CreateBmpInfoHeader(frame.Width, height, infoHeaderSize, bpp, bytesPerLine, metadata, iccProfileData); Span buffer = stackalloc byte[infoHeaderSize]; - // For ico/cur encoder. + // ICO/CUR resources contain a DIB directly; standalone BMP files additionally require BITMAPFILEHEADER. if (!this.skipFileHeader) { WriteBitmapFileHeader(stream, infoHeaderSize, colorPaletteSize, iccProfileSize, infoHeader, buffer); } this.WriteBitmapInfoHeader(stream, infoHeader, buffer, infoHeaderSize); - this.WriteImage(configuration, stream, image, cancellationToken); + this.WriteImage(configuration, stream, frame, cancellationToken); WriteColorProfile(stream, iccProfileData, buffer, basePosition); stream.Flush(); @@ -216,7 +235,8 @@ private BmpInfoHeader CreateBmpInfoHeader(int width, int height, int infoHeaderS int hResolution = 0; int vResolution = 0; - if (metadata.ResolutionUnits != PixelResolutionUnit.AspectRatio + if (!this.skipMetadata + && metadata.ResolutionUnits != PixelResolutionUnit.AspectRatio && metadata.HorizontalResolution > 0 && metadata.VerticalResolution > 0) { @@ -348,15 +368,11 @@ private void WriteBitmapInfoHeader(Stream stream, BmpInfoHeader infoHeader, Span /// The pixel format. /// The global configuration. /// The to write to. - /// + /// /// The containing pixel data. /// /// The token to monitor for cancellation requests. - private void WriteImage( - Configuration configuration, - Stream stream, - Image image, - CancellationToken cancellationToken) + private void WriteImage(Configuration configuration, Stream stream, ImageFrame frame, CancellationToken cancellationToken) where TPixel : unmanaged, IPixel { ImageFrame? clonedFrame = null; @@ -367,11 +383,11 @@ private void WriteImage( int bpp = this.bitsPerPixel != null ? (int)this.bitsPerPixel : 32; if (bpp > 8 && EncodingUtilities.ShouldReplaceTransparentPixels(this.transparentColorMode)) { - clonedFrame = image.Frames.RootFrame.Clone(); + clonedFrame = frame.Clone(); EncodingUtilities.ReplaceTransparentPixels(clonedFrame); } - ImageFrame encodingFrame = clonedFrame ?? image.Frames.RootFrame; + ImageFrame encodingFrame = clonedFrame ?? frame; Buffer2D pixels = encodingFrame.PixelBuffer; switch (this.bitsPerPixel) @@ -864,10 +880,17 @@ private static void Write1BitPalette(Stream stream, int startIdx, int endIdx, Re stream.WriteByte(indices); } + /// + /// Writes the bottom-up 1-bit transparency mask required by an ICO/CUR bitmap resource. + /// + /// The source pixel type. + /// The destination stream. + /// The source frame. private static void ProcessedAlphaMask(Stream stream, ImageFrame encodingFrame) - where TPixel : unmanaged, IPixel + where TPixel : unmanaged, IPixel { - int arrayWidth = encodingFrame.Width / 8; + // Each byte represents eight pixels and every scanline is padded to a 4-byte DIB boundary. + int arrayWidth = (encodingFrame.Width + 7) / 8; int padding = arrayWidth % 4; if (padding is not 0) { @@ -875,6 +898,9 @@ private static void ProcessedAlphaMask(Stream stream, ImageFrame } Span mask = stackalloc byte[arrayWidth]; + Span paddingBytes = stackalloc byte[3]; + paddingBytes.Clear(); + for (int y = encodingFrame.Height - 1; y >= 0; y--) { mask.Clear(); @@ -884,19 +910,30 @@ private static void ProcessedAlphaMask(Stream stream, ImageFrame { int x = i * 8; - for (int j = 0; j < 8; j++) + // The final byte can represent fewer than eight pixels when the image width is not byte-aligned. + int pixelCount = Math.Min(8, encodingFrame.Width - x); + for (int j = 0; j < pixelCount; j++) { WriteAlphaMask(row[x + j], ref mask[i], j); } } stream.Write(mask); - stream.Skip(padding); + + // Alpha-mask rows are DWORD-aligned, and the final row padding must extend the stream. + stream.Write(paddingBytes[..padding]); } } + /// + /// Sets one most-significant-bit-first transparency flag in an ICO/CUR AND-mask byte. + /// + /// The source pixel type. + /// The source pixel. + /// The destination mask byte. + /// The pixel index within the byte. private static void WriteAlphaMask(in TPixel pixel, ref byte mask, in int index) - where TPixel : unmanaged, IPixel + where TPixel : unmanaged, IPixel { Rgba32 rgba = pixel.ToRgba32(); if (rgba.A is 0) diff --git a/src/ImageSharp/Formats/Cur/CurConfigurationModule.cs b/src/ImageSharp/Formats/Cur/CurConfigurationModule.cs index 879b3f1123..ba092ebc7f 100644 --- a/src/ImageSharp/Formats/Cur/CurConfigurationModule.cs +++ b/src/ImageSharp/Formats/Cur/CurConfigurationModule.cs @@ -1,12 +1,10 @@ // Copyright (c) Six Labors. // Licensed under the Six Labors Split License. -using SixLabors.ImageSharp.Formats.Icon; - namespace SixLabors.ImageSharp.Formats.Cur; /// -/// Registers the image encoders, decoders and mime type detectors for the Ico format. +/// Registers the image encoder, decoder, and format detector for the CUR format. /// public sealed class CurConfigurationModule : IImageFormatConfigurationModule { @@ -15,6 +13,6 @@ public void Configure(Configuration configuration) { configuration.ImageFormatsManager.SetEncoder(CurFormat.Instance, new CurEncoder()); configuration.ImageFormatsManager.SetDecoder(CurFormat.Instance, CurDecoder.Instance); - configuration.ImageFormatsManager.AddImageFormatDetector(new IconImageFormatDetector()); + configuration.ImageFormatsManager.AddImageFormatDetector(new CurImageFormatDetector()); } } diff --git a/src/ImageSharp/Formats/Cur/CurConstants.cs b/src/ImageSharp/Formats/Cur/CurConstants.cs index 7abf4c812e..3edd3137ac 100644 --- a/src/ImageSharp/Formats/Cur/CurConstants.cs +++ b/src/ImageSharp/Formats/Cur/CurConstants.cs @@ -4,12 +4,12 @@ namespace SixLabors.ImageSharp.Formats.Cur; /// -/// Defines constants relating to ICOs +/// Defines constants used by the CUR format. /// internal static class CurConstants { /// - /// The list of mime types that equate to a cur. + /// The MIME types that identify CUR data. /// /// /// See @@ -27,13 +27,11 @@ internal static class CurConstants "image/ico", "image/icon", "text/ico", - "application/ico", + "application/ico" ]; /// - /// The list of file extensions that equate to a cur. + /// The file extensions that identify CUR data. /// public static readonly IEnumerable FileExtensions = ["cur"]; - - public const uint FileHeader = 0x00_02_00_00; } diff --git a/src/ImageSharp/Formats/Cur/CurDecoder.cs b/src/ImageSharp/Formats/Cur/CurDecoder.cs index cbe646c47b..522e91be56 100644 --- a/src/ImageSharp/Formats/Cur/CurDecoder.cs +++ b/src/ImageSharp/Formats/Cur/CurDecoder.cs @@ -6,10 +6,13 @@ namespace SixLabors.ImageSharp.Formats.Cur; /// -/// Decoder for generating an image out of a ico encoded stream. +/// Decoder for generating an image from a CUR encoded stream. /// public sealed class CurDecoder : ImageDecoder { + /// + /// Initializes a new instance of the class. + /// private CurDecoder() { } @@ -34,7 +37,7 @@ protected override Image Decode(DecoderOptions options, Stream s /// protected override Image Decode(DecoderOptions options, Stream stream, CancellationToken cancellationToken) - => this.Decode(options, stream, cancellationToken); + => this.Decode(options, stream, cancellationToken); /// protected override ImageInfo Identify(DecoderOptions options, Stream stream, CancellationToken cancellationToken) diff --git a/src/ImageSharp/Formats/Cur/CurDecoderCore.cs b/src/ImageSharp/Formats/Cur/CurDecoderCore.cs index 6fc8905279..9471b79580 100644 --- a/src/ImageSharp/Formats/Cur/CurDecoderCore.cs +++ b/src/ImageSharp/Formats/Cur/CurDecoderCore.cs @@ -7,13 +7,21 @@ namespace SixLabors.ImageSharp.Formats.Cur; +/// +/// Decodes CUR containers and maps directory metadata to CUR metadata. +/// internal sealed class CurDecoderCore : IconDecoderCore { + /// + /// Initializes a new instance of the class. + /// + /// The decoder options. public CurDecoderCore(DecoderOptions options) - : base(options) + : base(options, IconFileType.CUR) { } + /// protected override void SetFrameMetadata( ImageMetadata imageMetadata, ImageFrameMetadata frameMetadata, diff --git a/src/ImageSharp/Formats/Cur/CurEncoder.cs b/src/ImageSharp/Formats/Cur/CurEncoder.cs index e19a73990c..f4a0369b3b 100644 --- a/src/ImageSharp/Formats/Cur/CurEncoder.cs +++ b/src/ImageSharp/Formats/Cur/CurEncoder.cs @@ -10,8 +10,5 @@ public sealed class CurEncoder : QuantizingImageEncoder { /// protected override void Encode(Image image, Stream stream, CancellationToken cancellationToken) - { - CurEncoderCore encoderCore = new(this); - encoderCore.Encode(image, stream, cancellationToken); - } + => new CurEncoderCore(this).Encode(image, stream, cancellationToken); } diff --git a/src/ImageSharp/Formats/Cur/CurEncoderCore.cs b/src/ImageSharp/Formats/Cur/CurEncoderCore.cs index 6435587e2f..b38e89214e 100644 --- a/src/ImageSharp/Formats/Cur/CurEncoderCore.cs +++ b/src/ImageSharp/Formats/Cur/CurEncoderCore.cs @@ -2,13 +2,46 @@ // Licensed under the Six Labors Split License. using SixLabors.ImageSharp.Formats.Icon; +using SixLabors.ImageSharp.PixelFormats; namespace SixLabors.ImageSharp.Formats.Cur; +/// +/// Encodes CUR containers using CUR frame metadata. +/// internal sealed class CurEncoderCore : IconEncoderCore { + /// + /// Initializes a new instance of the class. + /// + /// The encoder options. public CurEncoderCore(QuantizingImageEncoder encoder) : base(encoder, IconFileType.CUR) { } + + /// + /// Encodes all source frames as a CUR resource. + /// + /// The source pixel type. + /// The source image. + /// The destination stream. + /// The token to monitor for cancellation requests. + public void Encode(Image image, Stream stream, CancellationToken cancellationToken) + where TPixel : unmanaged, IPixel + => this.Encode(image, stream, default(CurFrameMetadataProvider), cancellationToken); + + /// + /// Supplies CUR frame metadata to the shared container encoder. + /// + private readonly struct CurFrameMetadataProvider : IEncodingFrameMetadataProvider + { + /// + public EncodingFrameMetadata GetEncodingFrameMetadata(ImageFrame frame, out ReadOnlyMemory? colorTable) + { + CurFrameMetadata metadata = frame.Metadata.GetCurMetadata(); + colorTable = metadata.ColorTable; + return new EncodingFrameMetadata(metadata.Compression, metadata.BmpBitsPerPixel, metadata.ToIconDirEntry(frame.Size)); + } + } } diff --git a/src/ImageSharp/Formats/Cur/CurFormat.cs b/src/ImageSharp/Formats/Cur/CurFormat.cs index af93382ec0..a4bd959898 100644 --- a/src/ImageSharp/Formats/Cur/CurFormat.cs +++ b/src/ImageSharp/Formats/Cur/CurFormat.cs @@ -4,10 +4,13 @@ namespace SixLabors.ImageSharp.Formats.Cur; /// -/// Registers the image encoders, decoders and mime type detectors for the ICO format. +/// Describes the CUR image format. /// public sealed class CurFormat : IImageFormat { + /// + /// Prevents a default instance of the class from being created. + /// private CurFormat() { } @@ -18,10 +21,10 @@ private CurFormat() public static CurFormat Instance { get; } = new(); /// - public string Name => "ICO"; + public string Name => "CUR"; /// - public string DefaultMimeType => CurConstants.MimeTypes.First(); + public string DefaultMimeType => "image/vnd.microsoft.icon"; /// public IEnumerable MimeTypes => CurConstants.MimeTypes; diff --git a/src/ImageSharp/Formats/Cur/CurFrameMetadata.cs b/src/ImageSharp/Formats/Cur/CurFrameMetadata.cs index dbe8183b6d..806ebf039a 100644 --- a/src/ImageSharp/Formats/Cur/CurFrameMetadata.cs +++ b/src/ImageSharp/Formats/Cur/CurFrameMetadata.cs @@ -9,7 +9,7 @@ namespace SixLabors.ImageSharp.Formats.Cur; /// -/// IcoFrameMetadata. +/// Provides CUR-specific metadata for an image frame. /// public class CurFrameMetadata : IFormatFrameMetadata { @@ -20,6 +20,10 @@ public CurFrameMetadata() { } + /// + /// Initializes a new instance of the class by copying another instance. + /// + /// The metadata to copy. private CurFrameMetadata(CurFrameMetadata other) { this.Compression = other.Compression; @@ -28,10 +32,15 @@ private CurFrameMetadata(CurFrameMetadata other) this.EncodingWidth = other.EncodingWidth; this.EncodingHeight = other.EncodingHeight; this.BmpBitsPerPixel = other.BmpBitsPerPixel; + + if (other.ColorTable?.Length > 0) + { + this.ColorTable = other.ColorTable.Value.ToArray(); + } } /// - /// Gets or sets the frame compressions format. + /// Gets or sets the frame compression format. /// public IconFrameCompression Compression { get; set; } @@ -46,14 +55,14 @@ private CurFrameMetadata(CurFrameMetadata other) public ushort HotspotY { get; set; } /// - /// Gets or sets the encoding width.
- /// Can be any number between 0 and 255. Value 0 means a frame height of 256 pixels or greater. + /// Gets or sets the encoded width. + /// A value of zero represents 256 pixels or greater. ///
public byte? EncodingWidth { get; set; } /// - /// Gets or sets the encoding height.
- /// Can be any number between 0 and 255. Value 0 means a frame height of 256 pixels or greater. + /// Gets or sets the encoded height. + /// A value of zero represents 256 pixels or greater. ///
public byte? EncodingHeight { get; set; } @@ -104,7 +113,7 @@ public static CurFrameMetadata FromFormatConnectingFrameMetadata(FormatConnectin BmpBitsPerPixel = bbpp, Compression = compression, EncodingWidth = ClampEncodingDimension(metadata.EncodingWidth), - EncodingHeight = ClampEncodingDimension(metadata.EncodingHeight), + EncodingHeight = ClampEncodingDimension(metadata.EncodingHeight) }; } @@ -134,6 +143,10 @@ public void AfterFrameApply(ImageFrame source, ImageFrame public CurFrameMetadata DeepClone() => new(this); + /// + /// Copies the observable CUR directory values from an entry. + /// + /// The source directory entry. internal void FromIconDirEntry(IconDirEntry entry) { this.EncodingWidth = entry.Width; @@ -142,6 +155,11 @@ internal void FromIconDirEntry(IconDirEntry entry) this.HotspotY = entry.BitCount; } + /// + /// Creates a CUR directory entry from this metadata. + /// + /// The source frame size. + /// The CUR directory entry. internal IconDirEntry ToIconDirEntry(Size size) { byte colorCount = this.Compression == IconFrameCompression.Png || this.BmpBitsPerPixel > BmpBitsPerPixel.Bit8 @@ -158,6 +176,10 @@ internal IconDirEntry ToIconDirEntry(Size size) }; } + /// + /// Gets the pixel layout represented by this metadata. + /// + /// The represented pixel layout. private PixelTypeInfo GetPixelTypeInfo() { int bpp = (int)this.BmpBitsPerPixel; @@ -219,6 +241,13 @@ private PixelTypeInfo GetPixelTypeInfo() }; } + /// + /// Scales an encoded dimension after an image transform. + /// + /// The encoded source dimension. + /// The full destination dimension. + /// The destination-to-source scale ratio. + /// The encoded destination dimension. private static byte ScaleEncodingDimension(byte? value, int destination, float ratio) { if (value is null) @@ -226,9 +255,16 @@ private static byte ScaleEncodingDimension(byte? value, int destination, float r return ClampEncodingDimension(destination); } - return ClampEncodingDimension(MathF.Ceiling(value.Value * ratio)); + // A stored zero represents 256 pixels, so scaling must expand it before applying the transform ratio. + int source = value.Value is 0 ? 256 : value.Value; + return ClampEncodingDimension(MathF.Ceiling(source * ratio)); } + /// + /// Converts a pixel dimension to the one-byte CUR representation. + /// + /// The pixel dimension. + /// The encoded dimension. private static byte ClampEncodingDimension(float? dimension) => dimension switch { diff --git a/src/ImageSharp/Formats/Cur/CurImageFormatDetector.cs b/src/ImageSharp/Formats/Cur/CurImageFormatDetector.cs new file mode 100644 index 0000000000..3fd951902b --- /dev/null +++ b/src/ImageSharp/Formats/Cur/CurImageFormatDetector.cs @@ -0,0 +1,49 @@ +// Copyright (c) Six Labors. +// Licensed under the Six Labors Split License. + +using System.Diagnostics.CodeAnalysis; +using SixLabors.ImageSharp.Formats.Icon; + +namespace SixLabors.ImageSharp.Formats.Cur; + +/// +/// Detects CUR file headers. +/// +public sealed class CurImageFormatDetector : IImageFormatDetector +{ + /// + public int HeaderSize => IconDir.Size + IconDirEntry.Size; + + /// + public bool TryDetectFormat(ReadOnlySpan header, [NotNullWhen(true)] out IImageFormat? format) + { + format = this.IsSupportedFileFormat(header) ? CurFormat.Instance : null; + return format is not null; + } + + /// + /// Determines whether the supplied header contains a valid CUR directory and first entry. + /// + /// The candidate file header. + /// when the header identifies CUR data. + private bool IsSupportedFileFormat(ReadOnlySpan 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); + } +} diff --git a/src/ImageSharp/Formats/Cur/CurMetadata.cs b/src/ImageSharp/Formats/Cur/CurMetadata.cs index 4c4d83dd88..81090d168f 100644 --- a/src/ImageSharp/Formats/Cur/CurMetadata.cs +++ b/src/ImageSharp/Formats/Cur/CurMetadata.cs @@ -9,7 +9,7 @@ namespace SixLabors.ImageSharp.Formats.Cur; /// -/// Provides Cur specific metadata information for the image. +/// Provides CUR-specific metadata for an image. /// public class CurMetadata : IFormatMetadata { @@ -32,7 +32,7 @@ private CurMetadata(CurMetadata other) } /// - /// Gets or sets the frame compressions format. Derived from the root frame. + /// Gets or sets the root frame compression format. /// public IconFrameCompression Compression { get; set; } @@ -43,7 +43,7 @@ private CurMetadata(CurMetadata other) public BmpBitsPerPixel BmpBitsPerPixel { get; set; } = BmpBitsPerPixel.Bit32; /// - /// Gets or sets the color table, if any. Derived from the root frame.
+ /// Gets or sets the root frame color table, if any.
/// The underlying pixel format is represented by . ///
public ReadOnlyMemory? ColorTable { get; set; } diff --git a/src/ImageSharp/Formats/Exr/Compression/Decompressors/B44ExrCompression.cs b/src/ImageSharp/Formats/Exr/Compression/Decompressors/B44ExrCompression.cs index 06df7b6a25..2b5b740569 100644 --- a/src/ImageSharp/Formats/Exr/Compression/Decompressors/B44ExrCompression.cs +++ b/src/ImageSharp/Formats/Exr/Compression/Decompressors/B44ExrCompression.cs @@ -15,9 +15,10 @@ internal class B44ExrCompression : ExrBaseDecompressor { private readonly int channelCount; - private readonly byte[] scratch = new byte[14]; + // B44 encodes each 4x4 block in either 3 or 14 bytes, so both representations share this inline storage. + private InlineArray14 scratch; - private readonly ushort[] s = new ushort[16]; + private InlineArray16 s; private readonly IMemoryOwner tmpBuffer; @@ -42,8 +43,11 @@ public override void Decompress(BufferedReadStream stream, uint compressedBytes, { Span outputBuffer = MemoryMarshal.Cast(buffer); Span decompressed = this.tmpBuffer.GetSpan(); + Span scratch = this.scratch; + Span samples = this.s; int outputOffset = 0; int bytesLeft = (int)compressedBytes; + for (int i = 0; i < this.channelCount && bytesLeft > 0; i++) { for (int y = 0; y < this.RowsPerBlock; y += 4) @@ -60,49 +64,49 @@ public override void Decompress(BufferedReadStream stream, uint compressedBytes, int rowOffset = 0; for (int x = 0; x < this.Width && bytesLeft > 0; x += 4) { - int bytesRead = stream.Read(this.scratch, 0, 3); + int bytesRead = stream.Read(scratch[..3]); if (bytesRead == 0) { ExrThrowHelper.ThrowInvalidImageContentException("Could not read enough data from the stream!"); } // Check if 3-byte encoded flat field. - if (this.scratch[2] >= 13 << 2) + if (scratch[2] >= 13 << 2) { - Unpack3(this.scratch, this.s); + Unpack3(scratch, samples); bytesLeft -= 3; } else { - bytesRead = stream.Read(this.scratch, 3, 11); + bytesRead = stream.Read(scratch.Slice(3, 11)); if (bytesRead == 0) { ExrThrowHelper.ThrowInvalidImageContentException("Could not read enough data from the stream!"); } - Unpack14(this.scratch, this.s); + Unpack14(scratch, samples); bytesLeft -= 14; } int n = x + 3 < this.Width ? 4 : this.Width - x; if (y + 3 < this.RowsPerBlock) { - this.s.AsSpan(0, n).CopyTo(row0[rowOffset..]); - this.s.AsSpan(4, n).CopyTo(row1[rowOffset..]); - this.s.AsSpan(8, n).CopyTo(row2[rowOffset..]); - this.s.AsSpan(12, n).CopyTo(row3[rowOffset..]); + samples[..n].CopyTo(row0[rowOffset..]); + samples.Slice(4, n).CopyTo(row1[rowOffset..]); + samples.Slice(8, n).CopyTo(row2[rowOffset..]); + samples.Slice(12, n).CopyTo(row3[rowOffset..]); } else { - this.s.AsSpan(0, n).CopyTo(row0[rowOffset..]); + samples[..n].CopyTo(row0[rowOffset..]); if (y + 1 < this.RowsPerBlock) { - this.s.AsSpan(4, n).CopyTo(row1[rowOffset..]); + samples.Slice(4, n).CopyTo(row1[rowOffset..]); } if (y + 2 < this.RowsPerBlock) { - this.s.AsSpan(8, n).CopyTo(row2[rowOffset..]); + samples.Slice(8, n).CopyTo(row2[rowOffset..]); } } diff --git a/src/ImageSharp/Formats/Exr/ExrDecoderCore.cs b/src/ImageSharp/Formats/Exr/ExrDecoderCore.cs index abe3ae439e..680e8d333d 100644 --- a/src/ImageSharp/Formats/Exr/ExrDecoderCore.cs +++ b/src/ImageSharp/Formats/Exr/ExrDecoderCore.cs @@ -24,7 +24,7 @@ internal sealed class ExrDecoderCore : ImageDecoderCore /// /// Reusable buffer. /// - private readonly byte[] buffer = new byte[8]; + private InlineArray8 buffer; /// /// Used for allocating memory during processing operations. diff --git a/src/ImageSharp/Formats/Exr/ExrEncoderCore.cs b/src/ImageSharp/Formats/Exr/ExrEncoderCore.cs index bd74a46b77..5f91d1bd39 100644 --- a/src/ImageSharp/Formats/Exr/ExrEncoderCore.cs +++ b/src/ImageSharp/Formats/Exr/ExrEncoderCore.cs @@ -21,7 +21,7 @@ internal sealed class ExrEncoderCore /// /// Reusable buffer. /// - private readonly byte[] buffer = new byte[8]; + private InlineArray8 buffer; /// /// Used for allocating memory during processing operations. @@ -108,7 +108,7 @@ public void Encode(Image image, Stream stream, CancellationToken // Write magick bytes. BinaryPrimitives.WriteInt32LittleEndian(this.buffer, ExrConstants.MagickBytes); - stream.Write(this.buffer.AsSpan(0, 4)); + stream.Write(this.buffer[..4]); // Version number. this.buffer[0] = 2; @@ -117,7 +117,7 @@ public void Encode(Image image, Stream stream, CancellationToken this.buffer[1] = 0; this.buffer[2] = 0; this.buffer[3] = 0; - stream.Write(this.buffer.AsSpan(0, 4)); + stream.Write(this.buffer[..4]); // Write EXR header. this.WriteHeader(stream, header); @@ -194,7 +194,7 @@ private ulong[] EncodeFloatingPointPixelData( // Write row index. BinaryPrimitives.WriteUInt32LittleEndian(this.buffer, y); - stream.Write(this.buffer.AsSpan(0, 4)); + stream.Write(this.buffer[..4]); // At this point, it is not yet known how much bytes the compressed data will take up, keep stream position. long pixelDataSizePos = stream.Position; @@ -237,7 +237,7 @@ private ulong[] EncodeFloatingPointPixelData( // Write pixel row data size. BinaryPrimitives.WriteUInt32LittleEndian(this.buffer, compressedBytes); stream.Position = pixelDataSizePos; - stream.Write(this.buffer.AsSpan(0, 4)); + stream.Write(this.buffer[..4]); stream.Position = positionAfterPixelData; cancellationToken.ThrowIfCancellationRequested(); @@ -293,7 +293,7 @@ private ulong[] EncodeUnsignedIntPixelData( // Write row index. BinaryPrimitives.WriteUInt32LittleEndian(this.buffer, y); - stream.Write(this.buffer.AsSpan(0, 4)); + stream.Write(this.buffer[..4]); // At this point, it is not yet known how much bytes the compressed data will take up, keep stream position. long pixelDataSizePos = stream.Position; @@ -328,7 +328,7 @@ private ulong[] EncodeUnsignedIntPixelData( // Write pixel row data size. BinaryPrimitives.WriteUInt32LittleEndian(this.buffer, compressedBytes); stream.Position = pixelDataSizePos; - stream.Write(this.buffer.AsSpan(0, 4)); + stream.Write(this.buffer[..4]); stream.Position = positionAfterPixelData; cancellationToken.ThrowIfCancellationRequested(); @@ -518,7 +518,7 @@ private void WriteChannelInfo(Stream stream, ExrChannelInfo channelInfo) WriteString(stream, channelInfo.ChannelName); BinaryPrimitives.WriteInt32LittleEndian(this.buffer, (int)channelInfo.PixelType); - stream.Write(this.buffer.AsSpan(0, 4)); + stream.Write(this.buffer[..4]); stream.WriteByte(channelInfo.Linear); @@ -528,10 +528,10 @@ private void WriteChannelInfo(Stream stream, ExrChannelInfo channelInfo) stream.WriteByte(0); BinaryPrimitives.WriteInt32LittleEndian(this.buffer, channelInfo.XSampling); - stream.Write(this.buffer.AsSpan(0, 4)); + stream.Write(this.buffer[..4]); BinaryPrimitives.WriteInt32LittleEndian(this.buffer, channelInfo.YSampling); - stream.Write(this.buffer.AsSpan(0, 4)); + stream.Write(this.buffer[..4]); } /// @@ -629,7 +629,7 @@ private void WriteAttributeInformation(Stream stream, string name, string type, // Write attribute size. BinaryPrimitives.WriteUInt32LittleEndian(this.buffer, (uint)size); - stream.Write(this.buffer.AsSpan(0, 4)); + stream.Write(this.buffer[..4]); } /// @@ -656,16 +656,16 @@ private static void WriteString(Stream stream, string str) private void WriteBoxInteger(Stream stream, ExrBox2i box) { BinaryPrimitives.WriteInt32LittleEndian(this.buffer, box.XMin); - stream.Write(this.buffer.AsSpan(0, 4)); + stream.Write(this.buffer[..4]); BinaryPrimitives.WriteInt32LittleEndian(this.buffer, box.YMin); - stream.Write(this.buffer.AsSpan(0, 4)); + stream.Write(this.buffer[..4]); BinaryPrimitives.WriteInt32LittleEndian(this.buffer, box.XMax); - stream.Write(this.buffer.AsSpan(0, 4)); + stream.Write(this.buffer[..4]); BinaryPrimitives.WriteInt32LittleEndian(this.buffer, box.YMax); - stream.Write(this.buffer.AsSpan(0, 4)); + stream.Write(this.buffer[..4]); } /// @@ -677,7 +677,7 @@ private void WriteBoxInteger(Stream stream, ExrBox2i box) private unsafe void WriteSingle(Stream stream, float value) { BinaryPrimitives.WriteInt32LittleEndian(this.buffer, *(int*)&value); - stream.Write(this.buffer.AsSpan(0, 4)); + stream.Write(this.buffer[..4]); } /// diff --git a/src/ImageSharp/Formats/Gif/GifDecoderCore.cs b/src/ImageSharp/Formats/Gif/GifDecoderCore.cs index dc6ce1846b..e4ffd42823 100644 --- a/src/ImageSharp/Formats/Gif/GifDecoderCore.cs +++ b/src/ImageSharp/Formats/Gif/GifDecoderCore.cs @@ -22,7 +22,7 @@ internal sealed class GifDecoderCore : ImageDecoderCore /// /// The temp buffer used to reduce allocations. /// - private ScratchBuffer buffer; // mutable struct, don't make readonly + private InlineArray16 buffer; // mutable struct, don't make readonly /// /// The global color table. @@ -285,13 +285,13 @@ protected override ImageInfo Identify(BufferedReadStream stream, CancellationTok /// The containing image data. private void ReadGraphicalControlExtension(BufferedReadStream stream) { - int bytesRead = stream.Read(this.buffer.Span, 0, 6); + int bytesRead = stream.Read(this.buffer, 0, 6); if (bytesRead != 6) { GifThrowHelper.ThrowInvalidImageContentException("Not enough data to read the graphic control extension"); } - this.graphicsControlExtension = GifGraphicControlExtension.Parse(this.buffer.Span); + this.graphicsControlExtension = GifGraphicControlExtension.Parse(this.buffer); } /// @@ -300,13 +300,13 @@ private void ReadGraphicalControlExtension(BufferedReadStream stream) /// The containing image data. private void ReadImageDescriptor(BufferedReadStream stream) { - int bytesRead = stream.Read(this.buffer.Span, 0, 9); + int bytesRead = stream.Read(this.buffer, 0, 9); if (bytesRead != 9) { GifThrowHelper.ThrowInvalidImageContentException("Not enough data to read the image descriptor"); } - this.imageDescriptor = GifImageDescriptor.Parse(this.buffer.Span); + this.imageDescriptor = GifImageDescriptor.Parse(this.buffer); if (this.imageDescriptor.Height == 0 || this.imageDescriptor.Width == 0) { GifThrowHelper.ThrowInvalidImageContentException("Width or height should not be 0"); @@ -321,13 +321,13 @@ private void ReadImageDescriptor(BufferedReadStream stream) /// The containing image data. private void ReadLogicalScreenDescriptor(BufferedReadStream stream) { - int bytesRead = stream.Read(this.buffer.Span, 0, 7); + int bytesRead = stream.Read(this.buffer, 0, 7); if (bytesRead != 7) { GifThrowHelper.ThrowInvalidImageContentException("Not enough data to read the logical screen descriptor"); } - this.logicalScreenDescriptor = GifLogicalScreenDescriptor.Parse(this.buffer.Span); + this.logicalScreenDescriptor = GifLogicalScreenDescriptor.Parse(this.buffer); } /// @@ -353,13 +353,13 @@ private void ReadApplicationExtension(BufferedReadStream stream) // If the length is 11 then it's a valid extension and most likely // a NETSCAPE, XMP or ANIMEXTS extension. We want the loop count from this. long position = stream.Position; - int bytesRead = stream.Read(this.buffer.Span, 0, GifConstants.ApplicationBlockSize); + int bytesRead = stream.Read(this.buffer, 0, GifConstants.ApplicationBlockSize); if (bytesRead != GifConstants.ApplicationBlockSize) { GifThrowHelper.ThrowInvalidImageContentException("Unexpected end of stream while reading gif application extension"); } - bool isXmp = this.buffer.Span.StartsWith(GifConstants.XmpApplicationIdentificationBytes); + bool isXmp = ((ReadOnlySpan)this.buffer).StartsWith(GifConstants.XmpApplicationIdentificationBytes); if (isXmp) { this.ReadXmpApplicationExtension(stream, position, appLength); @@ -447,13 +447,13 @@ private void ReadNetscapeApplicationExtension(BufferedReadStream stream) => /// The containing image data. private void ReadNetscapeApplicationExtensionData(BufferedReadStream stream) { - int bytesRead = stream.Read(this.buffer.Span, 0, GifConstants.NetscapeLoopingSubBlockSize); + int bytesRead = stream.Read(this.buffer, 0, GifConstants.NetscapeLoopingSubBlockSize); if (bytesRead != GifConstants.NetscapeLoopingSubBlockSize) { throw new InvalidImageContentException("Unexpected end of stream while reading gif application extension"); } - this.gifMetadata!.RepeatCount = GifNetscapeLoopingApplicationExtension.Parse(this.buffer.Span[1..]).RepeatCount; + this.gifMetadata!.RepeatCount = GifNetscapeLoopingApplicationExtension.Parse(this.buffer[1..]).RepeatCount; int terminator = stream.ReadByte(); if (terminator == -1) @@ -996,12 +996,4 @@ private void ReadLogicalScreenDescriptorAndGlobalColorTable(BufferedReadStream s this.gifMetadata.BackgroundColor = globalColorTable.Value.Span[index]; } } - - private unsafe struct ScratchBuffer - { - private const int Size = 16; - private fixed byte scratch[Size]; - - public Span Span => MemoryMarshal.CreateSpan(ref this.scratch[0], Size); - } } diff --git a/src/ImageSharp/Formats/Ico/IcoConfigurationModule.cs b/src/ImageSharp/Formats/Ico/IcoConfigurationModule.cs index 224aaa31ec..adbafc713a 100644 --- a/src/ImageSharp/Formats/Ico/IcoConfigurationModule.cs +++ b/src/ImageSharp/Formats/Ico/IcoConfigurationModule.cs @@ -1,12 +1,10 @@ // Copyright (c) Six Labors. // Licensed under the Six Labors Split License. -using SixLabors.ImageSharp.Formats.Icon; - namespace SixLabors.ImageSharp.Formats.Ico; /// -/// Registers the image encoders, decoders and mime type detectors for the Ico format. +/// Registers the image encoder, decoder, and format detector for the ICO format. /// public sealed class IcoConfigurationModule : IImageFormatConfigurationModule { @@ -15,6 +13,6 @@ public void Configure(Configuration configuration) { configuration.ImageFormatsManager.SetEncoder(IcoFormat.Instance, new IcoEncoder()); configuration.ImageFormatsManager.SetDecoder(IcoFormat.Instance, IcoDecoder.Instance); - configuration.ImageFormatsManager.AddImageFormatDetector(new IconImageFormatDetector()); + configuration.ImageFormatsManager.AddImageFormatDetector(new IcoImageFormatDetector()); } } diff --git a/src/ImageSharp/Formats/Ico/IcoConstants.cs b/src/ImageSharp/Formats/Ico/IcoConstants.cs index 1165793688..1c05cc7178 100644 --- a/src/ImageSharp/Formats/Ico/IcoConstants.cs +++ b/src/ImageSharp/Formats/Ico/IcoConstants.cs @@ -4,12 +4,12 @@ namespace SixLabors.ImageSharp.Formats.Ico; /// -/// Defines constants relating to ICOs +/// Defines constants used by the ICO format. /// internal static class IcoConstants { /// - /// The list of mime types that equate to a ico. + /// The MIME types that identify ICO data. /// /// /// See @@ -27,13 +27,11 @@ internal static class IcoConstants "image/ico", "image/icon", "text/ico", - "application/ico", + "application/ico" ]; /// - /// The list of file extensions that equate to a ico. + /// The file extensions that identify ICO data. /// public static readonly IEnumerable FileExtensions = ["ico"]; - - public const uint FileHeader = 0x00_01_00_00; } diff --git a/src/ImageSharp/Formats/Ico/IcoDecoder.cs b/src/ImageSharp/Formats/Ico/IcoDecoder.cs index a0c8a30752..3be834f6c0 100644 --- a/src/ImageSharp/Formats/Ico/IcoDecoder.cs +++ b/src/ImageSharp/Formats/Ico/IcoDecoder.cs @@ -6,10 +6,13 @@ namespace SixLabors.ImageSharp.Formats.Ico; /// -/// Decoder for generating an image out of a ico encoded stream. +/// Decoder for generating an image from an ICO encoded stream. /// public sealed class IcoDecoder : ImageDecoder { + /// + /// Initializes a new instance of the class. + /// private IcoDecoder() { } @@ -34,7 +37,7 @@ protected override Image Decode(DecoderOptions options, Stream s /// protected override Image Decode(DecoderOptions options, Stream stream, CancellationToken cancellationToken) - => this.Decode(options, stream, cancellationToken); + => this.Decode(options, stream, cancellationToken); /// protected override ImageInfo Identify(DecoderOptions options, Stream stream, CancellationToken cancellationToken) diff --git a/src/ImageSharp/Formats/Ico/IcoDecoderCore.cs b/src/ImageSharp/Formats/Ico/IcoDecoderCore.cs index b8a1dded15..463e620514 100644 --- a/src/ImageSharp/Formats/Ico/IcoDecoderCore.cs +++ b/src/ImageSharp/Formats/Ico/IcoDecoderCore.cs @@ -7,13 +7,21 @@ namespace SixLabors.ImageSharp.Formats.Ico; +/// +/// Decodes ICO containers and maps directory metadata to ICO metadata. +/// internal sealed class IcoDecoderCore : IconDecoderCore { + /// + /// Initializes a new instance of the class. + /// + /// The decoder options. public IcoDecoderCore(DecoderOptions options) - : base(options) + : base(options, IconFileType.ICO) { } + /// protected override void SetFrameMetadata( ImageMetadata imageMetadata, ImageFrameMetadata frameMetadata, @@ -31,10 +39,10 @@ protected override void SetFrameMetadata( if (index == 0) { - IcoMetadata curMetadata = imageMetadata.GetIcoMetadata(); - curMetadata.Compression = compression; - curMetadata.BmpBitsPerPixel = bitsPerPixel; - curMetadata.ColorTable = colorTable; + IcoMetadata icoMetadata = imageMetadata.GetIcoMetadata(); + icoMetadata.Compression = compression; + icoMetadata.BmpBitsPerPixel = bitsPerPixel; + icoMetadata.ColorTable = colorTable; } } } diff --git a/src/ImageSharp/Formats/Ico/IcoEncoder.cs b/src/ImageSharp/Formats/Ico/IcoEncoder.cs index e729251567..36c661419d 100644 --- a/src/ImageSharp/Formats/Ico/IcoEncoder.cs +++ b/src/ImageSharp/Formats/Ico/IcoEncoder.cs @@ -10,8 +10,5 @@ public sealed class IcoEncoder : QuantizingImageEncoder { /// protected override void Encode(Image image, Stream stream, CancellationToken cancellationToken) - { - IcoEncoderCore encoderCore = new(this); - encoderCore.Encode(image, stream, cancellationToken); - } + => new IcoEncoderCore(this).Encode(image, stream, cancellationToken); } diff --git a/src/ImageSharp/Formats/Ico/IcoEncoderCore.cs b/src/ImageSharp/Formats/Ico/IcoEncoderCore.cs index f3cacb1b96..0364ce6ab8 100644 --- a/src/ImageSharp/Formats/Ico/IcoEncoderCore.cs +++ b/src/ImageSharp/Formats/Ico/IcoEncoderCore.cs @@ -2,13 +2,46 @@ // Licensed under the Six Labors Split License. using SixLabors.ImageSharp.Formats.Icon; +using SixLabors.ImageSharp.PixelFormats; namespace SixLabors.ImageSharp.Formats.Ico; +/// +/// Encodes ICO containers using ICO frame metadata. +/// internal sealed class IcoEncoderCore : IconEncoderCore { + /// + /// Initializes a new instance of the class. + /// + /// The encoder options. public IcoEncoderCore(QuantizingImageEncoder encoder) : base(encoder, IconFileType.ICO) { } + + /// + /// Encodes all source frames as an ICO resource. + /// + /// The source pixel type. + /// The source image. + /// The destination stream. + /// The token to monitor for cancellation requests. + public void Encode(Image image, Stream stream, CancellationToken cancellationToken) + where TPixel : unmanaged, IPixel + => this.Encode(image, stream, default(IcoFrameMetadataProvider), cancellationToken); + + /// + /// Supplies ICO frame metadata to the shared container encoder. + /// + private readonly struct IcoFrameMetadataProvider : IEncodingFrameMetadataProvider + { + /// + public EncodingFrameMetadata GetEncodingFrameMetadata(ImageFrame frame, out ReadOnlyMemory? colorTable) + { + IcoFrameMetadata metadata = frame.Metadata.GetIcoMetadata(); + colorTable = metadata.ColorTable; + return new EncodingFrameMetadata(metadata.Compression, metadata.BmpBitsPerPixel, metadata.ToIconDirEntry(frame.Size)); + } + } } diff --git a/src/ImageSharp/Formats/Ico/IcoFormat.cs b/src/ImageSharp/Formats/Ico/IcoFormat.cs index 5f89ab7f28..43621a496e 100644 --- a/src/ImageSharp/Formats/Ico/IcoFormat.cs +++ b/src/ImageSharp/Formats/Ico/IcoFormat.cs @@ -8,6 +8,9 @@ namespace SixLabors.ImageSharp.Formats.Ico; /// public sealed class IcoFormat : IImageFormat { + /// + /// Prevents a default instance of the class from being created. + /// private IcoFormat() { } @@ -21,7 +24,7 @@ private IcoFormat() public string Name => "ICO"; /// - public string DefaultMimeType => IcoConstants.MimeTypes.First(); + public string DefaultMimeType => "image/vnd.microsoft.icon"; /// public IEnumerable MimeTypes => IcoConstants.MimeTypes; diff --git a/src/ImageSharp/Formats/Ico/IcoFrameMetadata.cs b/src/ImageSharp/Formats/Ico/IcoFrameMetadata.cs index fb42d60437..829fc6b999 100644 --- a/src/ImageSharp/Formats/Ico/IcoFrameMetadata.cs +++ b/src/ImageSharp/Formats/Ico/IcoFrameMetadata.cs @@ -9,7 +9,7 @@ namespace SixLabors.ImageSharp.Formats.Ico; /// -/// Provides Ico specific metadata information for the image frame. +/// Provides ICO-specific metadata for an image frame. /// public class IcoFrameMetadata : IFormatFrameMetadata { @@ -20,6 +20,10 @@ public IcoFrameMetadata() { } + /// + /// Initializes a new instance of the class by copying another instance. + /// + /// The metadata to copy. private IcoFrameMetadata(IcoFrameMetadata other) { this.Compression = other.Compression; @@ -34,19 +38,19 @@ private IcoFrameMetadata(IcoFrameMetadata other) } /// - /// Gets or sets the frame compressions format. + /// Gets or sets the frame compression format. /// public IconFrameCompression Compression { get; set; } /// - /// Gets or sets the encoding width.
- /// Can be any number between 0 and 255. Value 0 means a frame height of 256 pixels or greater. + /// Gets or sets the encoded width. + /// A value of zero represents 256 pixels or greater. ///
public byte? EncodingWidth { get; set; } /// - /// Gets or sets the encoding height.
- /// Can be any number between 0 and 255. Value 0 means a frame height of 256 pixels or greater. + /// Gets or sets the encoded height. + /// A value of zero represents 256 pixels or greater. ///
public byte? EncodingHeight { get; set; } @@ -127,12 +131,21 @@ public void AfterFrameApply(ImageFrame source, ImageFrame public IcoFrameMetadata DeepClone() => new(this); + /// + /// Copies the observable ICO directory values from an entry. + /// + /// The source directory entry. internal void FromIconDirEntry(IconDirEntry entry) { this.EncodingWidth = entry.Width; this.EncodingHeight = entry.Height; } + /// + /// Creates an ICO directory entry from this metadata. + /// + /// The source frame size. + /// The ICO directory entry. internal IconDirEntry ToIconDirEntry(Size size) { byte colorCount = this.Compression == IconFrameCompression.Png || this.BmpBitsPerPixel > BmpBitsPerPixel.Bit8 @@ -148,11 +161,15 @@ internal IconDirEntry ToIconDirEntry(Size size) BitCount = this.Compression switch { IconFrameCompression.Bmp => (ushort)this.BmpBitsPerPixel, - IconFrameCompression.Png or _ => 32, - }, + IconFrameCompression.Png or _ => 32 + } }; } + /// + /// Gets the pixel layout represented by this metadata. + /// + /// The represented pixel layout. private PixelTypeInfo GetPixelTypeInfo() { int bpp = (int)this.BmpBitsPerPixel; @@ -214,6 +231,13 @@ private PixelTypeInfo GetPixelTypeInfo() }; } + /// + /// Scales an encoded dimension after an image transform. + /// + /// The encoded source dimension. + /// The full destination dimension. + /// The destination-to-source scale ratio. + /// The encoded destination dimension. private static byte ScaleEncodingDimension(byte? value, int destination, float ratio) { if (value is null) @@ -221,9 +245,16 @@ private static byte ScaleEncodingDimension(byte? value, int destination, float r return ClampEncodingDimension(destination); } - return ClampEncodingDimension(MathF.Ceiling(value.Value * ratio)); + // A stored zero represents 256 pixels, so scaling must expand it before applying the transform ratio. + int source = value.Value is 0 ? 256 : value.Value; + return ClampEncodingDimension(MathF.Ceiling(source * ratio)); } + /// + /// Converts a pixel dimension to the one-byte ICO representation. + /// + /// The pixel dimension. + /// The encoded dimension. private static byte ClampEncodingDimension(float? dimension) => dimension switch { diff --git a/src/ImageSharp/Formats/Ico/IcoImageFormatDetector.cs b/src/ImageSharp/Formats/Ico/IcoImageFormatDetector.cs new file mode 100644 index 0000000000..78e9634dd0 --- /dev/null +++ b/src/ImageSharp/Formats/Ico/IcoImageFormatDetector.cs @@ -0,0 +1,49 @@ +// Copyright (c) Six Labors. +// Licensed under the Six Labors Split License. + +using System.Diagnostics.CodeAnalysis; +using SixLabors.ImageSharp.Formats.Icon; + +namespace SixLabors.ImageSharp.Formats.Ico; + +/// +/// Detects ICO file headers. +/// +public sealed class IcoImageFormatDetector : IImageFormatDetector +{ + /// + public int HeaderSize => IconDir.Size + IconDirEntry.Size; + + /// + public bool TryDetectFormat(ReadOnlySpan header, [NotNullWhen(true)] out IImageFormat? format) + { + format = this.IsSupportedFileFormat(header) ? IcoFormat.Instance : null; + return format is not null; + } + + /// + /// Determines whether the supplied header contains a valid ICO directory and first entry. + /// + /// The candidate file header. + /// when the header identifies ICO data. + private bool IsSupportedFileFormat(ReadOnlySpan 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); + } +} diff --git a/src/ImageSharp/Formats/Ico/IcoMetadata.cs b/src/ImageSharp/Formats/Ico/IcoMetadata.cs index ae768d3111..943b6338cc 100644 --- a/src/ImageSharp/Formats/Ico/IcoMetadata.cs +++ b/src/ImageSharp/Formats/Ico/IcoMetadata.cs @@ -9,7 +9,7 @@ namespace SixLabors.ImageSharp.Formats.Ico; /// -/// Provides Ico specific metadata information for the image. +/// Provides ICO-specific metadata for an image. /// public class IcoMetadata : IFormatMetadata { @@ -32,7 +32,7 @@ private IcoMetadata(IcoMetadata other) } /// - /// Gets or sets the frame compressions format. Derived from the root frame. + /// Gets or sets the root frame compression format. /// public IconFrameCompression Compression { get; set; } @@ -43,7 +43,7 @@ private IcoMetadata(IcoMetadata other) public BmpBitsPerPixel BmpBitsPerPixel { get; set; } = BmpBitsPerPixel.Bit32; /// - /// Gets or sets the color table, if any. Derived from the root frame.
+ /// Gets or sets the root frame color table, if any.
/// The underlying pixel format is represented by . ///
public ReadOnlyMemory? ColorTable { get; set; } diff --git a/src/ImageSharp/Formats/Icon/IconDecoderCore.cs b/src/ImageSharp/Formats/Icon/IconDecoderCore.cs index bc3258e6b2..79d7e37869 100644 --- a/src/ImageSharp/Formats/Icon/IconDecoderCore.cs +++ b/src/ImageSharp/Formats/Icon/IconDecoderCore.cs @@ -9,15 +9,28 @@ namespace SixLabors.ImageSharp.Formats.Icon; +/// +/// Decodes the shared ICO/CUR directory and embedded BMP or PNG frame payloads. +/// internal abstract class IconDecoderCore : ImageDecoderCore { + private readonly IconFileType iconFileType; private IconDir fileHeader; private IconDirEntry[]? entries; - protected IconDecoderCore(DecoderOptions options) + /// + /// Reusable storage for an icon directory entry and smaller fixed values. + /// + private InlineArray16 buffer; + + /// + /// Initializes a new instance of the class. + /// + /// The decoder options. + /// The expected icon container type. + protected IconDecoderCore(DecoderOptions options, IconFileType iconFileType) : base(options) - { - } + => this.iconFileType = iconFileType; /// protected override Image Decode(BufferedReadStream stream, CancellationToken cancellationToken) @@ -26,108 +39,125 @@ protected override Image Decode(BufferedReadStream stream, Cance long basePosition = stream.Position; this.ReadHeader(stream); - Span flag = stackalloc byte[PngConstants.HeaderBytes.Length]; - - List<(Image Image, IconFrameCompression Compression, int Index)> decodedEntries - = new((int)Math.Min(this.entries.Length, this.Options.MaxFrames)); + int entryCount = this.entries.Length; + (int EntryIndex, Image Image, IconFrameCompression Compression)[] decodedEntries = new (int, Image, IconFrameCompression)[entryCount]; + int decodedCount = 0; + IconFrameStream frameStream = new(stream); + this.Dimensions = default; - for (int i = 0; i < this.entries.Length; i++) + try { - if (i == this.Options.MaxFrames) + for (int i = 0; i < entryCount; i++) { - break; - } - - ref IconDirEntry entry = ref this.entries[i]; + int entryIndex = i; - // If we hit the end of the stream we should break. - if (stream.Seek(basePosition + entry.ImageOffset, SeekOrigin.Begin) >= stream.Length) - { - break; - } - - // There should always be enough bytes for this regardless of the entry type. - if (stream.Read(flag) != PngConstants.HeaderBytes.Length) - { - break; - } + this.ExecuteImageDataSegmentAction(() => + { + cancellationToken.ThrowIfCancellationRequested(); - // Reset the stream position. - _ = stream.Seek(-PngConstants.HeaderBytes.Length, SeekOrigin.Current); + ref IconDirEntry entry = ref this.entries[entryIndex]; + this.SetFrameStreamBounds(frameStream, stream, basePosition, entry); + Span flag = this.buffer[..PngConstants.HeaderBytes.Length]; + CheckEndOfStream(frameStream.Read(flag), flag.Length); + frameStream.Position = 0; - bool isPng = flag.SequenceEqual(PngConstants.HeaderBytes); + bool isPng = flag.SequenceEqual(PngConstants.HeaderBytes); + IconFrameCompression compression = isPng ? IconFrameCompression.Png : IconFrameCompression.Bmp; - // Decode the frame into a temp image buffer. This is disposed after the frame is copied to the result. - Image temp = this.GetDecoder(isPng).Decode(this.Options.Configuration, stream, cancellationToken); - decodedEntries.Add((temp, isPng ? IconFrameCompression.Png : IconFrameCompression.Bmp, i)); + // Frames remain alive until the largest decoded dimensions are known and the common canvas can be allocated. + Image decoded = this.GetDecoder(isPng).Decode(this.Options.Configuration, frameStream, cancellationToken); + decodedEntries[decodedCount++] = (entryIndex, decoded, compression); - // Since Windows Vista, the size of an image is determined from the BITMAPINFOHEADER structure or PNG image data - // which technically allows storing icons with larger than 256 pixels, but such larger sizes are not recommended by Microsoft. - this.Dimensions = new Size(Math.Max(this.Dimensions.Width, temp.Size.Width), Math.Max(this.Dimensions.Height, temp.Size.Height)); - } + // The embedded header is authoritative because a zero directory dimension can represent 256 pixels or a larger Vista-era PNG. + this.Dimensions = new(Math.Max(this.Dimensions.Width, decoded.Width), Math.Max(this.Dimensions.Height, decoded.Height)); + }); + } - ImageMetadata metadata = new(); - BmpMetadata? bmpMetadata = null; - PngMetadata? pngMetadata = null; - Image result = new(this.Options.Configuration, metadata, decodedEntries.Select(x => - { - BmpBitsPerPixel bitsPerPixel = BmpBitsPerPixel.Bit32; - ReadOnlyMemory? colorTable = null; - ImageFrame target = new(this.Options.Configuration, this.Dimensions); - ImageFrame source = x.Image.Frames.RootFrameUnsafe; - for (int y = 0; y < source.Height; y++) + if (decodedCount is 0) { - source.PixelBuffer.DangerousGetRowSpan(y).CopyTo(target.PixelBuffer.DangerousGetRowSpan(y)); + throw new InvalidImageContentException("The icon file does not contain any decodable image entries."); } - // Copy the format specific frame metadata to the image. - if (x.Compression is IconFrameCompression.Png) + // General profiles belong to the icon result even though the first successfully decoded child image is temporary. + ImageMetadata metadata = decodedEntries[0].Image.Metadata.DeepClone(); + BmpMetadata? bmpMetadata = null; + PngMetadata? pngMetadata = null; + ImageFrame[] frames = new ImageFrame[decodedCount]; + int initializedFrameCount = 0; + + try { - if (x.Index == 0) + for (int i = 0; i < decodedCount; i++) { - pngMetadata = x.Image.Metadata.GetPngMetadata(); + BmpBitsPerPixel bitsPerPixel = BmpBitsPerPixel.Bit32; + ReadOnlyMemory? colorTable = null; + Image decoded = decodedEntries[i].Image; + ref IconDirEntry entry = ref this.entries[decodedEntries[i].EntryIndex]; + ImageFrame source = decoded.Frames.RootFrameUnsafe; + ImageFrame target = new(this.Options.Configuration, this.Dimensions, source.Metadata.DeepClone()); + frames[i] = target; + initializedFrameCount++; + + for (int y = 0; y < source.Height; y++) + { + source.PixelBuffer.DangerousGetRowSpan(y).CopyTo(target.PixelBuffer.DangerousGetRowSpan(y)); + } + + // Preserve both the embedded format metadata and the ICO/CUR directory metadata on the output frame. + if (decodedEntries[i].Compression is IconFrameCompression.Png) + { + if (i == 0) + { + pngMetadata = decoded.Metadata.GetPngMetadata(); + } + } + else + { + BmpMetadata currentBmpMetadata = decoded.Metadata.GetBmpMetadata(); + bitsPerPixel = currentBmpMetadata.BitsPerPixel; + colorTable = currentBmpMetadata.ColorTable; + + if (i == 0) + { + bmpMetadata = currentBmpMetadata; + } + } + + this.SetFrameMetadata(metadata, target.Metadata, i, entry, decodedEntries[i].Compression, bitsPerPixel, colorTable); } - target.Metadata.SetFormatMetadata(PngFormat.Instance, target.Metadata.GetPngMetadata()); - } - else - { - BmpMetadata meta = x.Image.Metadata.GetBmpMetadata(); - bitsPerPixel = meta.BitsPerPixel; - colorTable = meta.ColorTable; - - if (x.Index == 0) + // Embedded metadata belongs to the container even though the temporary decoded images are disposed below. + if (bmpMetadata is not null) { - bmpMetadata = meta; + metadata.SetFormatMetadata(BmpFormat.Instance, bmpMetadata); } - } - - this.SetFrameMetadata( - metadata, - target.Metadata, - x.Index, - this.entries[x.Index], - x.Compression, - bitsPerPixel, - colorTable); - x.Image.Dispose(); + if (pngMetadata is not null) + { + metadata.SetFormatMetadata(PngFormat.Instance, pngMetadata); + } - return target; - }).ToArray()); + Image result = new(this.Options.Configuration, metadata, frames); - // Copy the format specific metadata to the image. - if (bmpMetadata != null) - { - result.Metadata.SetFormatMetadata(BmpFormat.Instance, bmpMetadata); + // Ownership of every output frame transfers to the result only after construction succeeds. + initializedFrameCount = 0; + return result; + } + finally + { + for (int i = 0; i < initializedFrameCount; i++) + { + frames[i].Dispose(); + } + } } - - if (pngMetadata != null) + finally { - result.Metadata.SetFormatMetadata(PngFormat.Instance, pngMetadata); + for (int i = 0; i < decodedCount; i++) + { + decodedEntries[i].Image.Dispose(); + } } - - return result; } /// @@ -137,87 +167,94 @@ protected override ImageInfo Identify(BufferedReadStream stream, CancellationTok long basePosition = stream.Position; this.ReadHeader(stream); - Span flag = stackalloc byte[PngConstants.HeaderBytes.Length]; - ImageMetadata metadata = new(); BmpMetadata? bmpMetadata = null; PngMetadata? pngMetadata = null; - ImageFrameMetadata[] frames = new ImageFrameMetadata[Math.Min(this.fileHeader.Count, this.Options.MaxFrames)]; - int bpp = 0; + ImageFrameMetadata[] frames = new ImageFrameMetadata[this.entries.Length]; + int frameCount = 0; + IconFrameStream frameStream = new(stream); + this.Dimensions = default; + for (int i = 0; i < frames.Length; i++) { - BmpBitsPerPixel bitsPerPixel = BmpBitsPerPixel.Bit32; - ReadOnlyMemory? colorTable = null; - ref IconDirEntry entry = ref this.entries[i]; + int entryIndex = i; - // If we hit the end of the stream we should break. - if (stream.Seek(basePosition + entry.ImageOffset, SeekOrigin.Begin) >= stream.Length) + this.ExecuteImageDataSegmentAction(() => { - break; - } - - // There should always be enough bytes for this regardless of the entry type. - if (stream.Read(flag) != PngConstants.HeaderBytes.Length) - { - break; - } + cancellationToken.ThrowIfCancellationRequested(); - // Reset the stream position. - _ = stream.Seek(-PngConstants.HeaderBytes.Length, SeekOrigin.Current); + BmpBitsPerPixel bitsPerPixel = BmpBitsPerPixel.Bit32; + ReadOnlyMemory? colorTable = null; + ref IconDirEntry entry = ref this.entries[entryIndex]; + this.SetFrameStreamBounds(frameStream, stream, basePosition, entry); + Span flag = this.buffer[..PngConstants.HeaderBytes.Length]; + CheckEndOfStream(frameStream.Read(flag), flag.Length); + frameStream.Position = 0; - bool isPng = flag.SequenceEqual(PngConstants.HeaderBytes); + bool isPng = flag.SequenceEqual(PngConstants.HeaderBytes); + ImageInfo frameInfo = this.GetDecoder(isPng).Identify(this.Options.Configuration, frameStream, cancellationToken); + ImageFrameMetadata frameMetadata = frameInfo.FrameMetadataCollection.Count is 0 ? new ImageFrameMetadata() : frameInfo.FrameMetadataCollection[0].DeepClone(); - // Decode the frame into a temp image buffer. This is disposed after the frame is copied to the result. - ImageInfo frameInfo = this.GetDecoder(isPng).Identify(this.Options.Configuration, stream, cancellationToken); - - ImageFrameMetadata frameMetadata = new(); - - if (isPng) - { - if (i == 0) + if (frameCount is 0) { - pngMetadata = frameInfo.Metadata.GetPngMetadata(); + // The container has one image-level metadata object, so the first valid entry supplies general profiles and resolution. + ImageMetadata sourceMetadata = frameInfo.Metadata; + metadata.HorizontalResolution = sourceMetadata.HorizontalResolution; + metadata.VerticalResolution = sourceMetadata.VerticalResolution; + metadata.ResolutionUnits = sourceMetadata.ResolutionUnits; + metadata.ExifProfile = sourceMetadata.ExifProfile?.DeepClone(); + metadata.IccProfile = sourceMetadata.IccProfile?.DeepClone(); + metadata.IptcProfile = sourceMetadata.IptcProfile?.DeepClone(); + metadata.XmpProfile = sourceMetadata.XmpProfile?.DeepClone(); + metadata.CicpProfile = sourceMetadata.CicpProfile?.DeepClone(); } - frameMetadata.SetFormatMetadata(PngFormat.Instance, frameInfo.FrameMetadataCollection[0].GetPngMetadata()); - } - else - { - BmpMetadata meta = frameInfo.Metadata.GetBmpMetadata(); - bitsPerPixel = meta.BitsPerPixel; - colorTable = meta.ColorTable; - - if (i == 0) + if (isPng) { - bmpMetadata = meta; + if (frameCount is 0) + { + pngMetadata = frameInfo.Metadata.GetPngMetadata(); + } + } + else + { + BmpMetadata currentBmpMetadata = frameInfo.Metadata.GetBmpMetadata(); + bitsPerPixel = currentBmpMetadata.BitsPerPixel; + colorTable = currentBmpMetadata.ColorTable; + + if (frameCount is 0) + { + bmpMetadata = currentBmpMetadata; + } } - } - bpp = Math.Max(bpp, (int)bitsPerPixel); + IconFrameCompression compression = isPng ? IconFrameCompression.Png : IconFrameCompression.Bmp; + this.SetFrameMetadata(metadata, frameMetadata, frameCount, entry, compression, bitsPerPixel, colorTable); + frames[frameCount++] = frameMetadata; - frames[i] = frameMetadata; + // Identification uses the same embedded-header dimensions as decoding, without allocating pixel buffers. + this.Dimensions = new(Math.Max(this.Dimensions.Width, frameInfo.Width), Math.Max(this.Dimensions.Height, frameInfo.Height)); + }); + } - this.SetFrameMetadata( - metadata, - frames[i], - i, - this.entries[i], - isPng ? IconFrameCompression.Png : IconFrameCompression.Bmp, - bitsPerPixel, - colorTable); + if (frameCount is 0) + { + throw new InvalidImageContentException("The icon file does not contain any identifiable image entries."); + } - // Since Windows Vista, the size of an image is determined from the BITMAPINFOHEADER structure or PNG image data - // which technically allows storing icons with larger than 256 pixels, but such larger sizes are not recommended by Microsoft. - this.Dimensions = new Size(Math.Max(this.Dimensions.Width, frameInfo.Size.Width), Math.Max(this.Dimensions.Height, frameInfo.Size.Height)); + if (frameCount != frames.Length) + { + // Preserve successfully identified frames when truncated image data ends the scan before the declared directory count. + Array.Resize(ref frames, frameCount); } // Copy the format specific metadata to the image. - if (bmpMetadata != null) + if (bmpMetadata is not null) { metadata.SetFormatMetadata(BmpFormat.Instance, bmpMetadata); } - if (pngMetadata != null) + if (pngMetadata is not null) { metadata.SetFormatMetadata(PngFormat.Instance, pngMetadata); } @@ -225,6 +262,16 @@ protected override ImageInfo Identify(BufferedReadStream stream, CancellationTok return new ImageInfo(this.Dimensions, metadata, frames); } + /// + /// Copies format-specific directory and embedded-frame metadata to an output frame. + /// + /// The output image metadata. + /// The output frame metadata. + /// The directory entry index. + /// The directory entry. + /// The embedded frame compression. + /// The embedded bitmap bit depth. + /// The embedded bitmap color table. protected abstract void SetFrameMetadata( ImageMetadata imageMetadata, ImageFrameMetadata frameMetadata, @@ -234,58 +281,45 @@ protected abstract void SetFrameMetadata( BmpBitsPerPixel bitsPerPixel, ReadOnlyMemory? colorTable); + /// + /// Reads the icon directory entries needed by the configured frame limit. + /// + /// The source stream. [MemberNotNull(nameof(entries))] - protected void ReadHeader(Stream stream) + private void ReadHeader(Stream stream) { - Span buffer = stackalloc byte[IconDirEntry.Size]; + Span buffer = this.buffer; // ICONDIR - _ = CheckEndOfStream(stream.Read(buffer[..IconDir.Size]), IconDir.Size); + CheckEndOfStream(stream.Read(buffer[..IconDir.Size]), IconDir.Size); this.fileHeader = IconDir.Parse(buffer); + if (this.fileHeader.Reserved != 0 || this.fileHeader.Type != this.iconFileType || this.fileHeader.Count == 0) + { + throw new InvalidImageContentException("The icon directory header is invalid."); + } // ICONDIRENTRY - this.entries = new IconDirEntry[this.fileHeader.Count]; + int entryCount = (int)Math.Min(this.fileHeader.Count, this.Options.MaxFrames); + this.entries = new IconDirEntry[entryCount]; for (int i = 0; i < this.entries.Length; i++) { - _ = CheckEndOfStream(stream.Read(buffer[..IconDirEntry.Size]), IconDirEntry.Size); + CheckEndOfStream(stream.Read(buffer[..IconDirEntry.Size]), IconDirEntry.Size); this.entries[i] = IconDirEntry.Parse(buffer); } - - int width = 0; - int height = 0; - foreach (IconDirEntry entry in this.entries) - { - // Since Windows 95 size of an image in the ICONDIRENTRY structure might - // be set to zero, which means 256 pixels. - if (entry.Width == 0) - { - width = 256; - } - - if (entry.Height == 0) - { - height = 256; - } - - if (width == 256 && height == 256) - { - break; - } - - width = Math.Max(width, entry.Width); - height = Math.Max(height, entry.Height); - } - - this.Dimensions = new Size(width, height); } + /// + /// Creates the decoder configured for an embedded PNG or headerless, double-height bitmap frame. + /// + /// Whether the embedded frame has a PNG signature. + /// The configured frame decoder. private ImageDecoderCore GetDecoder(bool isPng) { if (isPng) { return new PngDecoderCore(new PngDecoderOptions { - GeneralOptions = this.Options, + GeneralOptions = this.Options }); } @@ -294,17 +328,59 @@ private ImageDecoderCore GetDecoder(bool isPng) GeneralOptions = this.Options, ProcessedAlphaMask = true, SkipFileHeader = true, - UseDoubleHeight = true, + UseDoubleHeight = true }); } - private static int CheckEndOfStream(int v, int length) + /// + /// Creates a seekable view bounded to one directory entry's declared payload. + /// + /// The reusable bounded payload stream. + /// The containing icon stream. + /// The absolute start of the icon resource. + /// The directory entry describing the payload. + private void SetFrameStreamBounds(IconFrameStream frameStream, BufferedReadStream stream, long basePosition, in IconDirEntry entry) { - if (v != length) + long available = stream.Length - basePosition; + uint directorySize = (uint)(IconDir.Size + (this.fileHeader.Count * IconDirEntry.Size)); + + // Offsets are relative to the icon resource and must not point into its directory or beyond its containing stream. + if (entry.Reserved is not 0 + || entry.BytesInRes is 0 + || entry.ImageOffset < directorySize + || entry.ImageOffset > available) { - throw new InvalidImageContentException("Not enough bytes to read icon header."); + throw new InvalidImageContentException("The icon directory contains an invalid image resource range."); } - return v; + long remaining = available - entry.ImageOffset; + long length = entry.BytesInRes; + if (length > remaining) + { + if (this.fileHeader.Count is not 1) + { + // Clamping a multi-entry resource could expose the next image payload to the current child decoder. + throw new InvalidImageContentException("The icon directory contains an invalid image resource range."); + } + + // Some established single-image ICO files overstate BytesInRes but contain a complete payload. + // The containing stream is still a safe hard boundary because no sibling image can follow it. + length = remaining; + } + + frameStream.Reset(basePosition + entry.ImageOffset, length); + } + + /// + /// Ensures that a complete fixed-size directory structure was read. + /// + /// The number of bytes read. + /// The required structure length. + private static void CheckEndOfStream(int bytesRead, int expectedLength) + { + if (bytesRead != expectedLength) + { + throw new InvalidImageContentException("Not enough bytes to read icon header."); + } } } diff --git a/src/ImageSharp/Formats/Icon/IconDir.cs b/src/ImageSharp/Formats/Icon/IconDir.cs index 3e02538c84..06644fc6c2 100644 --- a/src/ImageSharp/Formats/Icon/IconDir.cs +++ b/src/ImageSharp/Formats/Icon/IconDir.cs @@ -5,39 +5,65 @@ namespace SixLabors.ImageSharp.Formats.Icon; +/// +/// Represents an ICO or CUR file directory header. +/// [StructLayout(LayoutKind.Sequential, Pack = 1, Size = Size)] -internal struct IconDir(ushort reserved, IconFileType type, ushort count) +internal struct IconDir { + /// + /// The serialized directory-header size in bytes. + /// public const int Size = 3 * sizeof(ushort); /// /// Reserved. Must always be 0. /// - public ushort Reserved = reserved; + public ushort Reserved; /// /// Specifies image type: 1 for icon (.ICO) image, 2 for cursor (.CUR) image. Other values are invalid. /// - public IconFileType Type = type; + public IconFileType Type; /// /// Specifies number of images in the file. /// - public ushort Count = count; + public ushort Count; + /// + /// Initializes a new instance of the struct. + /// + /// The icon file type. public IconDir(IconFileType type) : this(type, 0) { } + /// + /// Initializes a new instance of the struct. + /// + /// The icon file type. + /// The number of directory entries. public IconDir(IconFileType type, ushort count) - : this(0, type, count) { + this.Reserved = 0; + this.Type = type; + this.Count = count; } + /// + /// Parses an icon directory header from its byte representation. + /// + /// The icon directory header data. + /// The parsed icon directory header. public static IconDir Parse(ReadOnlySpan data) => MemoryMarshal.Cast(data)[0]; - public readonly unsafe void WriteTo(Stream stream) + /// + /// Writes the icon directory header to the destination stream. + /// + /// The destination stream. + public readonly void WriteTo(Stream stream) => stream.Write(MemoryMarshal.Cast([this])); } diff --git a/src/ImageSharp/Formats/Icon/IconDirEntry.cs b/src/ImageSharp/Formats/Icon/IconDirEntry.cs index eab15dd872..9f74c5379e 100644 --- a/src/ImageSharp/Formats/Icon/IconDirEntry.cs +++ b/src/ImageSharp/Formats/Icon/IconDirEntry.cs @@ -8,6 +8,9 @@ namespace SixLabors.ImageSharp.Formats.Icon; [StructLayout(LayoutKind.Sequential, Pack = 1, Size = Size)] internal struct IconDirEntry { + /// + /// The serialized directory-entry size in bytes. + /// public const int Size = (4 * sizeof(byte)) + (2 * sizeof(ushort)) + (2 * sizeof(uint)); /// @@ -16,7 +19,7 @@ internal struct IconDirEntry public byte Width; /// - /// Specifies image height in pixels. Can be any number between 0 and 255. Value 0 means image height is 256 pixels.[ + /// Specifies image height in pixels. Can be any number between 0 and 255. Value 0 means image height is 256 pixels. /// public byte Height; @@ -43,7 +46,7 @@ internal struct IconDirEntry public ushort BitCount; /// - /// Specifies the size of the image's data in bytes + /// Specifies the size of the image's data in bytes. /// public uint BytesInRes; @@ -52,9 +55,18 @@ internal struct IconDirEntry /// public uint ImageOffset; - public static IconDirEntry Parse(in ReadOnlySpan data) + /// + /// Parses an icon directory entry from its byte representation. + /// + /// The icon directory entry data. + /// The parsed icon directory entry. + public static IconDirEntry Parse(ReadOnlySpan data) => MemoryMarshal.Cast(data)[0]; - public readonly unsafe void WriteTo(in Stream stream) + /// + /// Writes the icon directory entry to the destination stream. + /// + /// The destination stream. + public readonly void WriteTo(Stream stream) => stream.Write(MemoryMarshal.Cast([this])); } diff --git a/src/ImageSharp/Formats/Icon/IconEncoderCore.cs b/src/ImageSharp/Formats/Icon/IconEncoderCore.cs index 479cf9f1b0..8d043d7279 100644 --- a/src/ImageSharp/Formats/Icon/IconEncoderCore.cs +++ b/src/ImageSharp/Formats/Icon/IconEncoderCore.cs @@ -1,111 +1,216 @@ // Copyright (c) Six Labors. // Licensed under the Six Labors Split License. -using System.Diagnostics.CodeAnalysis; +using System.Buffers; using SixLabors.ImageSharp.Formats.Bmp; -using SixLabors.ImageSharp.Formats.Cur; -using SixLabors.ImageSharp.Formats.Ico; using SixLabors.ImageSharp.Formats.Png; +using SixLabors.ImageSharp.Memory; +using SixLabors.ImageSharp.Metadata; using SixLabors.ImageSharp.PixelFormats; using SixLabors.ImageSharp.Processing.Processors.Quantization; namespace SixLabors.ImageSharp.Formats.Icon; +/// +/// Encodes ICO and CUR containers. +/// internal abstract class IconEncoderCore { private readonly QuantizingImageEncoder encoder; private readonly IconFileType iconFileType; - private IconDir fileHeader; - private EncodingFrameMetadata[]? entries; + /// + /// Initializes a new instance of the class. + /// + /// The encoder options. + /// The icon container type. protected IconEncoderCore(QuantizingImageEncoder encoder, IconFileType iconFileType) { this.encoder = encoder; this.iconFileType = iconFileType; } - public void Encode( - Image image, - Stream stream, - CancellationToken cancellationToken) + /// + /// Supplies icon directory and color-table metadata without allocating intermediary metadata objects. + /// + internal interface IEncodingFrameMetadataProvider + { + /// + /// Gets the encoding metadata for a source frame. + /// + /// The source frame. + /// The optional bitmap color table. + /// The encoding metadata. + public EncodingFrameMetadata GetEncodingFrameMetadata(ImageFrame frame, out ReadOnlyMemory? colorTable); + } + + /// + /// Encodes all source frames using the metadata provider owned by the concrete icon format. + /// + /// The source pixel type. + /// The metadata provider type. + /// The source image. + /// The destination stream. + /// The frame metadata provider. + /// The token to monitor for cancellation requests. + protected void Encode(Image image, Stream stream, TProvider provider, CancellationToken cancellationToken) where TPixel : unmanaged, IPixel + where TProvider : struct, IEncodingFrameMetadataProvider { Guard.NotNull(image, nameof(image)); Guard.NotNull(stream, nameof(stream)); - // Stream may not at 0. + // Directory metadata is unmanaged and short-lived, so allocator-owned storage avoids an array plus one object per frame. + using IMemoryOwner owner = image.Configuration.MemoryAllocator.Allocate(image.Frames.Count); + Span entries = owner.GetSpan()[..image.Frames.Count]; + this.Encode(image, stream, 0, entries, provider, cancellationToken); + } + + /// + /// Encodes a contiguous source-frame range using a stack-only metadata provider. + /// + /// The source pixel type. + /// The metadata provider type. + /// The source image. + /// The destination stream. + /// The first source-frame index. + /// The directory metadata for the source frames. + /// The frame metadata provider. + /// The token to monitor for cancellation requests. + internal void Encode(Image image, Stream stream, int frameIndex, Span entries, TProvider provider, CancellationToken cancellationToken) + where TPixel : unmanaged, IPixel + where TProvider : struct, IEncodingFrameMetadataProvider + { + if ((uint)entries.Length > ushort.MaxValue) + { + throw new ImageFormatException("ICO and CUR resources cannot contain more than 65535 directory entries."); + } + + // Offsets stored in ICO/CUR entries are relative to the start of this child resource, not the containing ANI stream. long basePosition = stream.Position; - this.InitHeader(image); + IconDir fileHeader = new(this.iconFileType, (ushort)entries.Length); - // We don't write the header and entries yet as we need to write the image data first. - int dataOffset = IconDir.Size + (IconDirEntry.Size * this.entries.Length); + // Reserve the directory first because BytesInRes and ImageOffset are known only after each payload is encoded. + int dataOffset = IconDir.Size + (IconDirEntry.Size * entries.Length); _ = stream.Seek(dataOffset, SeekOrigin.Current); - for (int i = 0; i < image.Frames.Count; i++) + for (int i = 0; i < entries.Length; i++) { cancellationToken.ThrowIfCancellationRequested(); // Since Windows Vista, the size of an image is determined from the BITMAPINFOHEADER structure or PNG image data // which technically allows storing icons with larger than 256 pixels, but such larger sizes are not recommended by Microsoft. - ImageFrame frame = image.Frames[i]; - int width = this.entries[i].Entry.Width; + ImageFrame frame = image.Frames[frameIndex + i]; + + // The struct provider is statically dispatched, avoiding boxing and intermediary ICO/CUR metadata allocations. + // Only unmanaged directory data survives until backpatching; the managed color table is consumed for this frame. + entries[i] = provider.GetEncodingFrameMetadata(frame, out ReadOnlyMemory? colorTable); + int width = entries[i].Entry.Width; if (width is 0) { width = frame.Width; } - int height = this.entries[i].Entry.Height; + int height = entries[i].Entry.Height; if (height is 0) { height = frame.Height; } - this.entries[i].Entry.ImageOffset = (uint)stream.Position; - - // We crop the frame to the size specified in the metadata. - using Image encodingFrame = new(width, height); - for (int y = 0; y < height; y++) + if (width > frame.Width || height > frame.Height) { - frame.PixelBuffer.DangerousGetRowSpan(y)[..width] - .CopyTo(encodingFrame.GetRootFramePixelBuffer().DangerousGetRowSpan(y)); + // EncodingWidth and EncodingHeight are public metadata, so reject a crop that exceeds the source frame here. + throw new ImageFormatException("The icon encoding dimensions exceed the source frame dimensions."); } - ref EncodingFrameMetadata encodingMetadata = ref this.entries[i]; + long imageStart = stream.Position; + entries[i].Entry.ImageOffset = checked((uint)(imageStart - basePosition)); + ref EncodingFrameMetadata encodingMetadata = ref entries[i]; + Image? encodingImage = null; - QuantizingImageEncoder encoder = encodingMetadata.Compression switch + try { - IconFrameCompression.Bmp => new BmpEncoder() + bool requiresCrop = width != frame.Width || height != frame.Height; + bool requiresIsolatedImage = encodingMetadata.Compression is IconFrameCompression.Png && image.Frames.Count > 1; + + if (requiresCrop || requiresIsolatedImage) { - Quantizer = this.GetQuantizer(encodingMetadata), - ProcessedAlphaMask = true, - UseDoubleHeight = true, - SkipFileHeader = true, - SupportTransparency = false, - TransparentColorMode = this.encoder.TransparentColorMode, - PixelSamplingStrategy = this.encoder.PixelSamplingStrategy, - BitsPerPixel = encodingMetadata.BmpBitsPerPixel - }, - IconFrameCompression.Png => new PngEncoder() + // PNG accepts Image rather than ImageFrame, and ANI variants may occupy only part of their common canvas. + // Allocate only for those cases; full-sized BMP frames can be encoded directly from their existing storage. + ImageMetadata? metadata = this.encoder.SkipMetadata || encodingMetadata.Compression is not IconFrameCompression.Png ? null : image.Metadata.DeepClone(); + encodingImage = new Image(image.Configuration, width, height, metadata); + + for (int y = 0; y < height; y++) + { + frame.PixelBuffer.DangerousGetRowSpan(y)[..width].CopyTo(encodingImage.GetRootFramePixelBuffer().DangerousGetRowSpan(y)); + } + + if (!this.encoder.SkipMetadata && encodingMetadata.Compression is IconFrameCompression.Png) + { + encodingImage.Frames.RootFrame.Metadata.SetFormatMetadata(PngFormat.Instance, frame.Metadata.GetPngMetadata().DeepClone()); + } + } + + ImageFrame sourceFrame = encodingImage?.Frames.RootFrame ?? frame; + + // Compression and bitmap depth are per-entry, so the concrete encoder configuration must be selected per frame. + switch (encodingMetadata.Compression) { - // Only 32bit Png supported. - // https://devblogs.microsoft.com/oldnewthing/20101022-00/?p=12473 - BitDepth = PngBitDepth.Bit8, - ColorType = PngColorType.RgbWithAlpha, - TransparentColorMode = this.encoder.TransparentColorMode, - CompressionLevel = PngCompressionLevel.BestCompression - }, - _ => throw new NotSupportedException(), - }; - - encoder.Encode(encodingFrame, stream); - encodingMetadata.Entry.BytesInRes = (uint)stream.Position - encodingMetadata.Entry.ImageOffset; + case IconFrameCompression.Bmp: + { + BmpEncoder bmpEncoder = new() + { + Quantizer = this.GetQuantizer(encodingMetadata, colorTable), + ProcessedAlphaMask = true, + UseDoubleHeight = true, + SkipFileHeader = true, + SupportTransparency = false, + TransparentColorMode = this.encoder.TransparentColorMode, + PixelSamplingStrategy = this.encoder.PixelSamplingStrategy, + BitsPerPixel = encodingMetadata.BmpBitsPerPixel, + SkipMetadata = this.encoder.SkipMetadata + }; + + BmpEncoderCore bmpEncoderCore = new(bmpEncoder, image.Configuration.MemoryAllocator); + bmpEncoderCore.Encode(sourceFrame, image.Metadata, stream, cancellationToken); + break; + } + + case IconFrameCompression.Png: + { + PngEncoder pngEncoder = new() + { + // Only 32bit Png supported. + // https://devblogs.microsoft.com/oldnewthing/20101022-00/?p=12473 + BitDepth = PngBitDepth.Bit8, + ColorType = PngColorType.RgbWithAlpha, + TransparentColorMode = this.encoder.TransparentColorMode, + CompressionLevel = PngCompressionLevel.BestCompression, + SkipMetadata = this.encoder.SkipMetadata + }; + + using PngEncoderCore pngEncoderCore = new(image.Configuration, pngEncoder); + pngEncoderCore.Encode(encodingImage ?? image, stream, cancellationToken); + break; + } + + default: + throw new NotSupportedException(); + } + } + finally + { + encodingImage?.Dispose(); + } + + encodingMetadata.Entry.BytesInRes = checked((uint)(stream.Position - imageStart)); } - // We now need to rewind the stream and write the header and the entries. + // Backpatch the reserved directory after every relative offset and payload length has been measured. long endPosition = stream.Position; _ = stream.Seek(basePosition, SeekOrigin.Begin); - this.fileHeader.WriteTo(stream); - foreach (EncodingFrameMetadata frame in this.entries) + fileHeader.WriteTo(stream); + foreach (EncodingFrameMetadata frame in entries) { frame.Entry.WriteTo(stream); } @@ -113,31 +218,16 @@ public void Encode( _ = stream.Seek(endPosition, SeekOrigin.Begin); } - [MemberNotNull(nameof(entries))] - private void InitHeader(Image image) + /// + /// Gets the quantizer for an embedded bitmap frame. + /// + /// The frame encoding metadata. + /// The optional bitmap color table. + /// The configured quantizer, or when quantization is not required. + private IQuantizer? GetQuantizer(EncodingFrameMetadata metadata, ReadOnlyMemory? colorTable) { - this.fileHeader = new IconDir(this.iconFileType, (ushort)image.Frames.Count); - this.entries = this.iconFileType switch - { - IconFileType.ICO => - [.. image.Frames.Select(i => - { - IcoFrameMetadata metadata = i.Metadata.GetIcoMetadata(); - return new EncodingFrameMetadata(metadata.Compression, metadata.BmpBitsPerPixel, metadata.ColorTable, metadata.ToIconDirEntry(i.Size)); - })], - IconFileType.CUR => - [.. image.Frames.Select(i => - { - CurFrameMetadata metadata = i.Metadata.GetCurMetadata(); - return new EncodingFrameMetadata(metadata.Compression, metadata.BmpBitsPerPixel, metadata.ColorTable, metadata.ToIconDirEntry(i.Size)); - })], - _ => throw new NotSupportedException(), - }; - } - - private IQuantizer? GetQuantizer(EncodingFrameMetadata metadata) - { - if (metadata.Entry.BitCount > 8) + // CUR stores its vertical hotspot in Entry.BitCount, so quantization must use the independent bitmap depth. + if (metadata.BmpBitsPerPixel > BmpBitsPerPixel.Bit8) { return null; } @@ -147,7 +237,7 @@ [.. image.Frames.Select(i => return this.encoder.Quantizer; } - if (metadata.ColorTable is null) + if (colorTable is null) { int count = metadata.Entry.ColorCount; if (count == 0) @@ -162,33 +252,42 @@ [.. image.Frames.Select(i => } // Don't dither if we have a palette. We want to preserve as much information as possible. - return new PaletteQuantizer(metadata.ColorTable.Value, new QuantizerOptions { Dither = null }); + return new PaletteQuantizer(colorTable.Value, new QuantizerOptions { Dither = null }); } - internal sealed class EncodingFrameMetadata + /// + /// Stores the unmanaged per-frame state required while an icon directory is backpatched. + /// + internal struct EncodingFrameMetadata { - private IconDirEntry iconDirEntry; + /// + /// The icon directory entry. + /// + public IconDirEntry Entry; - public EncodingFrameMetadata( - IconFrameCompression compression, - BmpBitsPerPixel bmpBitsPerPixel, - ReadOnlyMemory? colorTable, - IconDirEntry iconDirEntry) + /// + /// Initializes a new instance of the struct. + /// + /// The embedded image compression. + /// The bitmap bit depth. + /// The icon directory entry. + public EncodingFrameMetadata(IconFrameCompression compression, BmpBitsPerPixel bmpBitsPerPixel, IconDirEntry iconDirEntry) { this.Compression = compression; this.BmpBitsPerPixel = compression == IconFrameCompression.Png ? BmpBitsPerPixel.Bit32 : bmpBitsPerPixel; - this.ColorTable = colorTable; - this.iconDirEntry = iconDirEntry; + this.Entry = iconDirEntry; } + /// + /// Gets the embedded image compression. + /// public IconFrameCompression Compression { get; } + /// + /// Gets the bitmap bit depth. + /// public BmpBitsPerPixel BmpBitsPerPixel { get; } - - public ReadOnlyMemory? ColorTable { get; set; } - - public ref IconDirEntry Entry => ref this.iconDirEntry; } } diff --git a/src/ImageSharp/Formats/Icon/IconFileType.cs b/src/ImageSharp/Formats/Icon/IconFileType.cs index 3450698f11..0b94dc71f9 100644 --- a/src/ImageSharp/Formats/Icon/IconFileType.cs +++ b/src/ImageSharp/Formats/Icon/IconFileType.cs @@ -4,17 +4,17 @@ namespace SixLabors.ImageSharp.Formats.Icon; /// -/// Ico file type +/// Identifies the type stored in an ICO or CUR directory header. /// internal enum IconFileType : ushort { /// - /// ICO file + /// A Windows icon file. /// ICO = 1, /// - /// CUR file + /// A Windows cursor file. /// - CUR = 2, + CUR = 2 } diff --git a/src/ImageSharp/Formats/Icon/IconFrameCompression.cs b/src/ImageSharp/Formats/Icon/IconFrameCompression.cs index 5c772c3fe3..f0dbe62f29 100644 --- a/src/ImageSharp/Formats/Icon/IconFrameCompression.cs +++ b/src/ImageSharp/Formats/Icon/IconFrameCompression.cs @@ -4,17 +4,17 @@ namespace SixLabors.ImageSharp.Formats.Icon; /// -/// IconFrameCompression +/// Specifies the encoding used for an image embedded in an ICO or CUR resource. /// public enum IconFrameCompression { /// - /// Bmp + /// The image is encoded as a headerless Windows bitmap with an AND transparency mask. /// Bmp, /// - /// Png + /// The image is encoded as PNG data. /// Png } diff --git a/src/ImageSharp/Formats/Icon/IconFrameStream.cs b/src/ImageSharp/Formats/Icon/IconFrameStream.cs new file mode 100644 index 0000000000..06b00b4b05 --- /dev/null +++ b/src/ImageSharp/Formats/Icon/IconFrameStream.cs @@ -0,0 +1,114 @@ +// Copyright (c) Six Labors. +// Licensed under the Six Labors Split License. + +namespace SixLabors.ImageSharp.Formats.Icon; + +/// +/// Exposes one ICO or CUR directory entry as an isolated seekable stream. +/// +/// +/// Embedded decoders accept seek offsets from their own headers. Bounding those seeks to BytesInRes +/// prevents a malformed BMP or PNG payload from consuming an adjacent icon resource. +/// +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; + + /// + /// Initializes a new instance of the class. + /// + /// The containing icon stream. + public IconFrameStream(Stream stream) + => this.stream = stream; + + /// + public override bool CanRead => true; + + /// + public override bool CanSeek => true; + + /// + public override bool CanWrite => false; + + /// + public override long Length => this.length; + + /// + public override long Position + { + get => this.position; + set => this.Seek(value, SeekOrigin.Begin); + } + + /// + /// Repositions this stream over another image payload in the same containing stream. + /// + /// The absolute start of the image payload. + /// The image payload length. + public void Reset(long start, long length) + { + this.start = start; + this.length = length; + this.position = 0; + } + + /// + public override void Flush() + { + } + + /// + public override int Read(byte[] buffer, int offset, int count) + => this.Read(buffer.AsSpan(offset, count)); + + /// + public override int Read(Span 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; + } + + /// + 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; + } + + /// + public override void SetLength(long value) => throw new NotSupportedException(); + + /// + public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException(); +} diff --git a/src/ImageSharp/Formats/Icon/IconImageFormatDetector.cs b/src/ImageSharp/Formats/Icon/IconImageFormatDetector.cs deleted file mode 100644 index 9e7d22de22..0000000000 --- a/src/ImageSharp/Formats/Icon/IconImageFormatDetector.cs +++ /dev/null @@ -1,66 +0,0 @@ -// Copyright (c) Six Labors. -// Licensed under the Six Labors Split License. - -using System.Diagnostics.CodeAnalysis; - -namespace SixLabors.ImageSharp.Formats.Icon; - -/// -/// Detects ico file headers. -/// -public class IconImageFormatDetector : IImageFormatDetector -{ - /// - public int HeaderSize { get; } = IconDir.Size + IconDirEntry.Size; - - /// - public bool TryDetectFormat(ReadOnlySpan 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 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; - } -} diff --git a/src/ImageSharp/Formats/Jpeg/Components/Decoder/ArithmeticScanDecoder.cs b/src/ImageSharp/Formats/Jpeg/Components/Decoder/ArithmeticScanDecoder.cs index 6e83f5b2b4..75f2c63713 100644 --- a/src/ImageSharp/Formats/Jpeg/Components/Decoder/ArithmeticScanDecoder.cs +++ b/src/ImageSharp/Formats/Jpeg/Components/Decoder/ArithmeticScanDecoder.cs @@ -55,7 +55,7 @@ internal class ArithmeticScanDecoder : IJpegScanDecoder private ArithmeticDecodingTable[] acDecodingTables; // Don't make this a ReadOnlySpan, as the values need to get updated. - private readonly byte[] fixedBin = [113, 0, 0, 0]; + private InlineArray4 fixedBin; private readonly CancellationToken cancellationToken; @@ -194,6 +194,9 @@ public ArithmeticScanDecoder(BufferedReadStream stream, SpectralConverter conver this.spectralConverter = converter; this.cancellationToken = cancellationToken; + // Inline storage is zero-initialized with the decoder; only the arithmetic probability state starts nonzero. + this.fixedBin[0] = 113; + this.c = 0; this.a = 0; this.ct = -16; // Force reading 2 initial bytes to fill C. @@ -233,7 +236,7 @@ public void InitDecodingTables(List arithmeticDecodingT } } - private ref byte GetFixedBinReference() => ref MemoryMarshal.GetArrayDataReference(this.fixedBin); + private ref byte GetFixedBinReference() => ref this.fixedBin[0]; /// public void ParseEntropyCodedData(int scanComponentCount, IccProfile iccProfile) diff --git a/src/ImageSharp/Formats/Jpeg/Components/Decoder/HuffmanTable.cs b/src/ImageSharp/Formats/Jpeg/Components/Decoder/HuffmanTable.cs index 7cf90bc235..96c69b1143 100644 --- a/src/ImageSharp/Formats/Jpeg/Components/Decoder/HuffmanTable.cs +++ b/src/ImageSharp/Formats/Jpeg/Components/Decoder/HuffmanTable.cs @@ -10,7 +10,7 @@ namespace SixLabors.ImageSharp.Formats.Jpeg.Components.Decoder; /// Represents a Huffman coding table containing basic coding data plus tables for accelerated computation. ///
[StructLayout(LayoutKind.Sequential)] -internal unsafe struct HuffmanTable +internal struct HuffmanTable { /// /// Memory workspace buffer size used in ctor. @@ -20,25 +20,25 @@ internal unsafe struct HuffmanTable /// /// Derived from the DHT marker. Contains the symbols, in order of incremental code length. /// - public fixed byte Values[256]; + public InlineArray256 Values; /// /// Contains the largest code of length k (0 if none). MaxCode[17] is a sentinel to /// ensure terminates. /// - public fixed ulong MaxCode[18]; + public InlineArray18 MaxCode; /// /// Values[] offset for codes of length k ValOffset[k] = Values[] index of 1st symbol of code length /// k, less the smallest code of length k; so given a code of length k, the corresponding symbol is /// Values[code + ValOffset[k]]. /// - public fixed int ValOffset[19]; + public InlineArray19 ValOffset; /// /// Contains the length of bits for the given k value. /// - public fixed byte LookaheadSize[JpegConstants.Huffman.LookupSize]; + public InlineArray256 LookaheadSize; /// /// Lookahead table: indexed by the next bits of @@ -50,7 +50,7 @@ internal unsafe struct HuffmanTable /// bits in the corresponding Huffman code, or + 1 /// if too long. The next 8 bits of each entry contain the symbol. /// - public fixed byte LookaheadValue[JpegConstants.Huffman.LookupSize]; + public InlineArray256 LookaheadValue; /// /// Initializes a new instance of the struct. diff --git a/src/ImageSharp/Formats/Png/PngEncoderCore.cs b/src/ImageSharp/Formats/Png/PngEncoderCore.cs index 2bb97221cc..1730d59dcb 100644 --- a/src/ImageSharp/Formats/Png/PngEncoderCore.cs +++ b/src/ImageSharp/Formats/Png/PngEncoderCore.cs @@ -43,7 +43,7 @@ internal sealed class PngEncoderCore : IDisposable /// /// Reusable buffer for writing chunk data. /// - private ScratchBuffer chunkDataBuffer; // mutable struct, don't make readonly + private InlineArray26 chunkDataBuffer; // mutable struct, don't make readonly /// /// The encoder with options @@ -734,9 +734,9 @@ private void WriteHeaderChunk(Stream stream) filterMethod: 0, interlaceMethod: this.interlaceMode); - header.WriteTo(this.chunkDataBuffer.Span); + header.WriteTo(this.chunkDataBuffer); - this.WriteChunk(stream, PngChunkType.Header, this.chunkDataBuffer.Span, 0, PngHeader.Size); + this.WriteChunk(stream, PngChunkType.Header, this.chunkDataBuffer, 0, PngHeader.Size); } /// @@ -749,9 +749,9 @@ private void WriteAnimationControlChunk(Stream stream, uint framesCount, uint pl { AnimationControl acTL = new(framesCount, playsCount); - acTL.WriteTo(this.chunkDataBuffer.Span); + acTL.WriteTo(this.chunkDataBuffer); - this.WriteChunk(stream, PngChunkType.AnimationControl, this.chunkDataBuffer.Span, 0, AnimationControl.Size); + this.WriteChunk(stream, PngChunkType.AnimationControl, this.chunkDataBuffer, 0, AnimationControl.Size); } /// @@ -820,9 +820,9 @@ private void WritePhysicalChunk(Stream stream, ImageMetadata meta) return; } - PngPhysical.FromMetadata(meta).WriteTo(this.chunkDataBuffer.Span); + PngPhysical.FromMetadata(meta).WriteTo(this.chunkDataBuffer); - this.WriteChunk(stream, PngChunkType.Physical, this.chunkDataBuffer.Span, 0, PngPhysical.Size); + this.WriteChunk(stream, PngChunkType.Physical, this.chunkDataBuffer, 0, PngPhysical.Size); } /// @@ -1067,7 +1067,7 @@ private void WriteCicpChunk(Stream stream, ImageMetadata metaData) throw new NotSupportedException("CICP matrix coefficients other than Identity are not supported in PNG"); } - Span outputBytes = this.chunkDataBuffer.Span[..4]; + Span outputBytes = this.chunkDataBuffer[..4]; outputBytes[0] = (byte)metaData.CicpProfile.ColorPrimaries; outputBytes[1] = (byte)metaData.CicpProfile.TransferCharacteristics; outputBytes[2] = (byte)metaData.CicpProfile.MatrixCoefficients; @@ -1222,9 +1222,9 @@ private void WriteGammaChunk(Stream stream) // 4-byte unsigned integer of gamma * 100,000. uint gammaValue = (uint)(this.gamma * 100_000F); - BinaryPrimitives.WriteUInt32BigEndian(this.chunkDataBuffer.Span[..4], gammaValue); + BinaryPrimitives.WriteUInt32BigEndian(this.chunkDataBuffer[..4], gammaValue); - this.WriteChunk(stream, PngChunkType.Gamma, this.chunkDataBuffer.Span, 0, 4); + this.WriteChunk(stream, PngChunkType.Gamma, this.chunkDataBuffer, 0, 4); } } @@ -1241,7 +1241,7 @@ private void WriteTransparencyChunk(Stream stream, PngMetadata pngMetadata) return; } - Span alpha = this.chunkDataBuffer.Span; + Span alpha = this.chunkDataBuffer; if (pngMetadata.ColorType == PngColorType.Rgb) { if (this.use16Bit) @@ -1251,7 +1251,7 @@ private void WriteTransparencyChunk(Stream stream, PngMetadata pngMetadata) BinaryPrimitives.WriteUInt16LittleEndian(alpha.Slice(2, 2), rgb.G); BinaryPrimitives.WriteUInt16LittleEndian(alpha.Slice(4, 2), rgb.B); - this.WriteChunk(stream, PngChunkType.Transparency, this.chunkDataBuffer.Span, 0, 6); + this.WriteChunk(stream, PngChunkType.Transparency, this.chunkDataBuffer, 0, 6); } else { @@ -1260,7 +1260,7 @@ private void WriteTransparencyChunk(Stream stream, PngMetadata pngMetadata) alpha[1] = rgb.R; alpha[3] = rgb.G; alpha[5] = rgb.B; - this.WriteChunk(stream, PngChunkType.Transparency, this.chunkDataBuffer.Span, 0, 6); + this.WriteChunk(stream, PngChunkType.Transparency, this.chunkDataBuffer, 0, 6); } } else if (pngMetadata.ColorType == PngColorType.Grayscale) @@ -1269,14 +1269,14 @@ private void WriteTransparencyChunk(Stream stream, PngMetadata pngMetadata) { L16 l16 = pngMetadata.TransparentColor.Value.ToPixel(); BinaryPrimitives.WriteUInt16LittleEndian(alpha, l16.PackedValue); - this.WriteChunk(stream, PngChunkType.Transparency, this.chunkDataBuffer.Span, 0, 2); + this.WriteChunk(stream, PngChunkType.Transparency, this.chunkDataBuffer, 0, 2); } else { L8 l8 = pngMetadata.TransparentColor.Value.ToPixel(); alpha.Clear(); alpha[1] = l8.PackedValue; - this.WriteChunk(stream, PngChunkType.Transparency, this.chunkDataBuffer.Span, 0, 2); + this.WriteChunk(stream, PngChunkType.Transparency, this.chunkDataBuffer, 0, 2); } } } @@ -1301,9 +1301,9 @@ private FrameControl WriteFrameControlChunk(Stream stream, PngFrameMetadata fram disposalMode: frameMetadata.DisposalMode, blendMode: frameMetadata.BlendMode); - fcTL.WriteTo(this.chunkDataBuffer.Span); + fcTL.WriteTo(this.chunkDataBuffer); - this.WriteChunk(stream, PngChunkType.FrameControl, this.chunkDataBuffer.Span, 0, FrameControl.Size); + this.WriteChunk(stream, PngChunkType.FrameControl, this.chunkDataBuffer, 0, FrameControl.Size); return fcTL; } @@ -1834,12 +1834,4 @@ private static int CalculateBytesPerPixel(PngColorType? pngColorType, bool use16 // PngColorType.RgbWithAlpha _ => use16Bit ? 8 : 4, }; - - private unsafe struct ScratchBuffer - { - private const int Size = 26; - private fixed byte scratch[Size]; - - public Span Span => MemoryMarshal.CreateSpan(ref this.scratch[0], Size); - } } diff --git a/src/ImageSharp/Formats/Webp/Lossless/Vp8LEncoder.cs b/src/ImageSharp/Formats/Webp/Lossless/Vp8LEncoder.cs index b398554eb1..a051cf0ce8 100644 --- a/src/ImageSharp/Formats/Webp/Lossless/Vp8LEncoder.cs +++ b/src/ImageSharp/Formats/Webp/Lossless/Vp8LEncoder.cs @@ -24,7 +24,7 @@ internal class Vp8LEncoder : IDisposable /// /// Scratch buffer to reduce allocations. /// - private ScratchBuffer scratch; // mutable struct, don't make readonly + private InlineArray256 scratch; // mutable struct, don't make readonly private readonly int[][] histoArgb = [new int[256], new int[256], new int[256], new int[256]]; @@ -803,7 +803,7 @@ private void ApplyCrossColorFilter(int width, int height, bool lowEffort) int transformWidth = LosslessUtils.SubSampleSize(width, colorTransformBits); int transformHeight = LosslessUtils.SubSampleSize(height, colorTransformBits); - PredictorEncoder.ColorSpaceTransform(width, height, colorTransformBits, this.quality, this.EncodedData.GetSpan(), this.TransformData.GetSpan(), this.scratch.Span); + PredictorEncoder.ColorSpaceTransform(width, height, colorTransformBits, this.quality, this.EncodedData.GetSpan(), this.TransformData.GetSpan(), this.scratch); this.bitWriter.PutBits(WebpConstants.TransformPresent, 1); this.bitWriter.PutBits((uint)Vp8LTransformType.CrossColorTransform, 2); @@ -876,7 +876,7 @@ private void EncodeImageNoHuffman(Span bgra, Vp8LHashChain hashChain, Vp8L private void StoreHuffmanCode(Span huffTree, HuffmanTreeToken[] tokens, HuffmanTreeCode huffmanCode) { int count = 0; - Span symbols = this.scratch.Span[..2]; + Span symbols = this.scratch[..2]; symbols.Clear(); const int maxBits = 8; const int maxSymbol = 1 << maxBits; @@ -1915,15 +1915,4 @@ public void Dispose() this.HashChain.Dispose(); } - - /// - /// Scratch buffer to reduce allocations. - /// - private unsafe struct ScratchBuffer - { - private const int Size = 256; - private fixed int scratch[Size]; - - public Span Span => MemoryMarshal.CreateSpan(ref this.scratch[0], Size); - } } diff --git a/src/ImageSharp/Formats/Webp/Lossy/QuantEnc.cs b/src/ImageSharp/Formats/Webp/Lossy/QuantEnc.cs index ab83e1641c..7548d5d7ea 100644 --- a/src/ImageSharp/Formats/Webp/Lossy/QuantEnc.cs +++ b/src/ImageSharp/Formats/Webp/Lossy/QuantEnc.cs @@ -35,7 +35,7 @@ public static void PickBestIntra16(Vp8EncIterator it, ref Vp8ModeScore rd, Vp8Se int lambda = dqm.LambdaI16; int tlambda = dqm.TLambda; Span src = it.YuvIn.AsSpan(Vp8EncIterator.YOffEnc); - Span scratch = it.Scratch3; + Span scratch = it.Scratch3.AsSpan(); Vp8ModeScore rdTmp = new(); Vp8Residual res = new(); Vp8ModeScore rdCur = rdTmp; @@ -105,7 +105,7 @@ public static bool PickBestIntra4(Vp8EncIterator it, ref Vp8ModeScore rd, Vp8Seg int tlambda = dqm.TLambda; Span src0 = it.YuvIn.AsSpan(Vp8EncIterator.YOffEnc); Span bestBlocks = it.YuvOut2.AsSpan(Vp8EncIterator.YOffEnc); - Span scratch = it.Scratch3; + Span scratch = it.Scratch3.AsSpan(); int totalHeaderBits = 0; Vp8ModeScore rdBest = new(); @@ -280,7 +280,7 @@ public static int ReconstructIntra16(Vp8EncIterator it, Vp8SegmentInfo dqm, Vp8M int nz = 0; int n; Span shortScratchSpan = it.Scratch2.AsSpan(); - Span scratch = it.Scratch3.AsSpan(0, 16); + Span scratch = it.Scratch3.AsSpan(); shortScratchSpan.Clear(); scratch.Clear(); Span dcTmp = shortScratchSpan[..16]; @@ -321,7 +321,7 @@ public static int ReconstructIntra4(Vp8EncIterator it, Vp8SegmentInfo dqm, Span< { Span reference = it.YuvP.AsSpan(Vp8Encoding.Vp8I4ModeOffsets[mode]); Span tmp = it.Scratch2.AsSpan(0, 16); - Span scratch = it.Scratch3.AsSpan(0, 16); + Span scratch = it.Scratch3.AsSpan(); Vp8Encoding.FTransform(src, reference, tmp, scratch); int nz = QuantizeBlock(tmp, levels, ref dqm.Y1); Vp8Encoding.ITransformOne(reference, tmp, yuvOut, scratch); @@ -336,7 +336,7 @@ public static int ReconstructUv(Vp8EncIterator it, Vp8SegmentInfo dqm, Vp8ModeSc int nz = 0; int n; Span tmp = it.Scratch2.AsSpan(0, 8 * 16); - Span scratch = it.Scratch3.AsSpan(0, 16); + Span scratch = it.Scratch3.AsSpan(); for (n = 0; n < 8; n += 2) { diff --git a/src/ImageSharp/Formats/Webp/Lossy/Vp8Decoder.cs b/src/ImageSharp/Formats/Webp/Lossy/Vp8Decoder.cs index 3c8bafa1b2..efb1ce0c05 100644 --- a/src/ImageSharp/Formats/Webp/Lossy/Vp8Decoder.cs +++ b/src/ImageSharp/Formats/Webp/Lossy/Vp8Decoder.cs @@ -13,8 +13,13 @@ namespace SixLabors.ImageSharp.Formats.Webp.Lossy; /// internal class Vp8Decoder : IDisposable { + private const int UpsamplingBufferSize = (14 * 32) + 15; + private Vp8MacroBlock leftMacroBlock; + // Vector upsampling needs 15 bytes of left padding, 128 interleaved UV bytes, two 128-byte BGR rows, and two 32-byte luma rows. + private readonly IMemoryOwner upsamplingBuffer; + /// /// Initializes a new instance of the class. /// @@ -74,6 +79,7 @@ public Vp8Decoder(Vp8FrameHeader frameHeader, Vp8PictureHeader pictureHeader, Vp this.TmpYBuffer = memoryAllocator.Allocate((int)width); this.TmpUBuffer = memoryAllocator.Allocate((int)width); this.TmpVBuffer = memoryAllocator.Allocate((int)width); + this.upsamplingBuffer = memoryAllocator.Allocate(UpsamplingBufferSize); this.Pixels = memoryAllocator.Allocate((int)(width * height * 4), AllocationOptions.Clean); #if DEBUG @@ -238,6 +244,11 @@ public Vp8Decoder(Vp8FrameHeader frameHeader, Vp8PictureHeader pictureHeader, Vp /// public IMemoryOwner Pixels { get; } + /// + /// Gets the reusable workspace used while upsampling YUV rows. + /// + public Span UpsamplingBuffer => this.upsamplingBuffer.Memory.Span[..UpsamplingBufferSize]; + /// /// Gets or sets filter info. /// @@ -339,6 +350,7 @@ public void Dispose() this.TmpYBuffer.Dispose(); this.TmpUBuffer.Dispose(); this.TmpVBuffer.Dispose(); + this.upsamplingBuffer.Dispose(); this.Pixels.Dispose(); } } diff --git a/src/ImageSharp/Formats/Webp/Lossy/Vp8Matrix.cs b/src/ImageSharp/Formats/Webp/Lossy/Vp8Matrix.cs index 5cd8812c95..e602c43551 100644 --- a/src/ImageSharp/Formats/Webp/Lossy/Vp8Matrix.cs +++ b/src/ImageSharp/Formats/Webp/Lossy/Vp8Matrix.cs @@ -3,7 +3,7 @@ namespace SixLabors.ImageSharp.Formats.Webp.Lossy; -internal unsafe struct Vp8Matrix +internal struct Vp8Matrix { // [luma-ac,luma-dc,chroma][dc,ac] private static readonly int[][] BiasMatrices = @@ -21,27 +21,27 @@ internal unsafe struct Vp8Matrix /// /// The quantizer steps. /// - public fixed ushort Q[16]; + public InlineArray16 Q; /// /// The reciprocals, fixed point. /// - public fixed ushort IQ[16]; + public InlineArray16 IQ; /// /// The rounding bias. /// - public fixed uint Bias[16]; + public InlineArray16 Bias; /// /// The value below which a coefficient is zeroed. /// - public fixed uint ZThresh[16]; + public InlineArray16 ZThresh; /// /// The frequency boosters for slight sharpening. /// - public fixed short Sharpen[16]; + public InlineArray16 Sharpen; // Sharpening by (slightly) raising the hi-frequency coeffs. // Hack-ish but helpful for mid-bitrate range. Use with care. diff --git a/src/ImageSharp/Formats/Webp/Lossy/WebpLossyDecoder.cs b/src/ImageSharp/Formats/Webp/Lossy/WebpLossyDecoder.cs index f14df853cd..bff13d4020 100644 --- a/src/ImageSharp/Formats/Webp/Lossy/WebpLossyDecoder.cs +++ b/src/ImageSharp/Formats/Webp/Lossy/WebpLossyDecoder.cs @@ -733,7 +733,7 @@ private static int EmitRgb(Vp8Decoder dec, Vp8Io io) int mbw = io.MbW; int uvw = (mbw + 1) >> 1; // >> 1 is bit-hack for / 2 int y = io.MbY; - byte[] uvBuffer = new byte[(14 * 32) + 15]; + Span uvBuffer = dec.UpsamplingBuffer; if (y == 0) { diff --git a/src/ImageSharp/Formats/Webp/Lossy/YuvConversion.cs b/src/ImageSharp/Formats/Webp/Lossy/YuvConversion.cs index d5f91b7c88..a1fa520725 100644 --- a/src/ImageSharp/Formats/Webp/Lossy/YuvConversion.cs +++ b/src/ImageSharp/Formats/Webp/Lossy/YuvConversion.cs @@ -27,7 +27,7 @@ internal static class YuvConversion // we interpolate u/v as: // ([9*a + 3*b + 3*c + d 3*a + 9*b + 3*c + d] + [8 8]) / 16 // ([3*a + b + 9*c + 3*d a + 3*b + 3*c + 9*d] [8 8]) / 16 - public static void UpSample(Span topY, Span bottomY, Span topU, Span topV, Span curU, Span curV, Span topDst, Span bottomDst, int len, byte[] uvBuffer) + public static void UpSample(Span topY, Span bottomY, Span topU, Span topV, Span curU, Span curV, Span topDst, Span bottomDst, int len, Span uvBuffer) { if (Vector128.IsHardwareAccelerated) { @@ -107,11 +107,11 @@ private static void UpSampleScalar(Span topY, Span bottomY, Span topY, Span bottomY, Span topU, Span topV, Span curU, Span curV, Span topDst, Span bottomDst, int len, byte[] uvBuffer) + private static void UpSampleVector128(Span topY, Span bottomY, Span topU, Span topV, Span curU, Span curV, Span topDst, Span bottomDst, int len, Span uvBuffer) { const int xStep = 3; - Array.Clear(uvBuffer); - Span ru = uvBuffer.AsSpan(15); + uvBuffer.Clear(); + Span ru = uvBuffer[15..]; Span rv = ru[32..]; // Treat the first pixel in regular way. diff --git a/src/ImageSharp/Formats/_Generated/ImageExtensions.Save.cs b/src/ImageSharp/Formats/_Generated/ImageExtensions.Save.cs index fa6eaa722d..3c459eca3f 100644 --- a/src/ImageSharp/Formats/_Generated/ImageExtensions.Save.cs +++ b/src/ImageSharp/Formats/_Generated/ImageExtensions.Save.cs @@ -2,6 +2,7 @@ // Licensed under the Six Labors Split License. // +using SixLabors.ImageSharp.Formats.Ani; using SixLabors.ImageSharp.Formats.Bmp; using SixLabors.ImageSharp.Formats.Cur; using SixLabors.ImageSharp.Formats.Gif; @@ -22,6 +23,108 @@ namespace SixLabors.ImageSharp; /// public static partial class ImageExtensions { + /// + /// Saves the image to the given stream with the Ani format. + /// + /// The image this method extends. + /// The file path to save the image to. + /// Thrown if the path is null. + public static void SaveAsAni(this Image source, string path) => SaveAsAni(source, path, default); + + /// + /// Saves the image to the given stream with the Ani format. + /// + /// The image this method extends. + /// The file path to save the image to. + /// Thrown if the path is null. + /// A representing the asynchronous operation. + public static Task SaveAsAniAsync(this Image source, string path) => SaveAsAniAsync(source, path, default); + + /// + /// Saves the image to the given stream with the Ani format. + /// + /// The image this method extends. + /// The file path to save the image to. + /// The token to monitor for cancellation requests. + /// Thrown if the path is null. + /// A representing the asynchronous operation. + public static Task SaveAsAniAsync(this Image source, string path, CancellationToken cancellationToken) + => SaveAsAniAsync(source, path, default, cancellationToken); + + /// + /// Saves the image to the given stream with the Ani format. + /// + /// The image this method extends. + /// The file path to save the image to. + /// The encoder to save the image with. + /// Thrown if the path is null. + public static void SaveAsAni(this Image source, string path, AniEncoder encoder) => + source.Save( + path, + encoder ?? source.Configuration.ImageFormatsManager.GetEncoder(AniFormat.Instance)); + + /// + /// Saves the image to the given stream with the Ani format. + /// + /// The image this method extends. + /// The file path to save the image to. + /// The encoder to save the image with. + /// The token to monitor for cancellation requests. + /// Thrown if the path is null. + /// A representing the asynchronous operation. + public static Task SaveAsAniAsync(this Image source, string path, AniEncoder encoder, CancellationToken cancellationToken = default) + => source.SaveAsync( + path, + encoder ?? source.Configuration.ImageFormatsManager.GetEncoder(AniFormat.Instance), + cancellationToken); + + /// + /// Saves the image to the given stream with the Ani format. + /// + /// The image this method extends. + /// The stream to save the image to. + /// Thrown if the stream is null. + public static void SaveAsAni(this Image source, Stream stream) + => SaveAsAni(source, stream, default); + + /// + /// Saves the image to the given stream with the Ani format. + /// + /// The image this method extends. + /// The stream to save the image to. + /// The token to monitor for cancellation requests. + /// Thrown if the stream is null. + /// A representing the asynchronous operation. + public static Task SaveAsAniAsync(this Image source, Stream stream, CancellationToken cancellationToken = default) + => SaveAsAniAsync(source, stream, default, cancellationToken); + + /// + /// Saves the image to the given stream with the Ani format. + /// + /// The image this method extends. + /// The stream to save the image to. + /// The encoder to save the image with. + /// Thrown if the stream is null. + public static void SaveAsAni(this Image source, Stream stream, AniEncoder encoder) + => source.Save( + stream, + encoder ?? source.Configuration.ImageFormatsManager.GetEncoder(AniFormat.Instance)); + + /// + /// Saves the image to the given stream with the Ani format. + /// + /// The image this method extends. + /// The stream to save the image to. + /// The encoder to save the image with. + /// The token to monitor for cancellation requests. + /// Thrown if the stream is null. + /// A representing the asynchronous operation. + public static Task SaveAsAniAsync(this Image source, Stream stream, AniEncoder encoder, CancellationToken cancellationToken = default) + => source.SaveAsync( + stream, + encoder ?? source.Configuration.ImageFormatsManager.GetEncoder(AniFormat.Instance), + cancellationToken); + /// /// Saves the image to the given stream with the Bmp format. /// diff --git a/src/ImageSharp/Formats/_Generated/ImageMetadataExtensions.cs b/src/ImageSharp/Formats/_Generated/ImageMetadataExtensions.cs index 0dcbaed808..4a038b5fce 100644 --- a/src/ImageSharp/Formats/_Generated/ImageMetadataExtensions.cs +++ b/src/ImageSharp/Formats/_Generated/ImageMetadataExtensions.cs @@ -3,6 +3,7 @@ // using SixLabors.ImageSharp.Metadata; +using SixLabors.ImageSharp.Formats.Ani; using SixLabors.ImageSharp.Formats.Bmp; using SixLabors.ImageSharp.Formats.Cur; using SixLabors.ImageSharp.Formats.Gif; @@ -23,6 +24,26 @@ namespace SixLabors.ImageSharp; /// public static class ImageMetadataExtensions { + /// + /// Gets the from .
+ /// If none is found, an instance is created either by conversion from the decoded image format metadata + /// or the requested format default constructor. + /// This instance will be added to the metadata for future requests. + ///
+ /// The image metadata. + /// + /// The + /// + public static AniMetadata GetAniMetadata(this ImageMetadata source) => source.GetFormatMetadata(AniFormat.Instance); + + /// + /// Creates a new cloned instance of from the . + /// The instance is created via + /// + /// The image metadata. + /// The new + public static AniMetadata CloneAniMetadata(this ImageMetadata source) => source.CloneFormatMetadata(AniFormat.Instance); + /// /// Gets the from .
/// If none is found, an instance is created either by conversion from the decoded image format metadata @@ -264,6 +285,26 @@ public static class ImageMetadataExtensions public static ExrMetadata CloneExrMetadata(this ImageMetadata source) => source.CloneFormatMetadata(ExrFormat.Instance); + /// + /// Gets the from .
+ /// If none is found, an instance is created either by conversion from the decoded image format metadata + /// or the requested format default constructor. + /// This instance will be added to the metadata for future requests. + ///
+ /// The image frame metadata. + /// + /// The + /// + public static AniFrameMetadata GetAniMetadata(this ImageFrameMetadata source) => source.GetFormatMetadata(AniFormat.Instance); + + /// + /// Creates a new cloned instance of from the . + /// The instance is created via + /// + /// The image frame metadata. + /// The new + public static AniFrameMetadata CloneAniMetadata(this ImageFrameMetadata source) => source.CloneFormatMetadata(AniFormat.Instance); + /// /// Gets the from .
/// If none is found, an instance is created either by conversion from the decoded image format metadata diff --git a/src/ImageSharp/Formats/_Generated/_Formats.ttinclude b/src/ImageSharp/Formats/_Generated/_Formats.ttinclude index c1c69c5b5b..e8077c2939 100644 --- a/src/ImageSharp/Formats/_Generated/_Formats.ttinclude +++ b/src/ImageSharp/Formats/_Generated/_Formats.ttinclude @@ -4,6 +4,7 @@ // Licensed under the Six Labors Split License. <#+ private static readonly string[] formats = [ + "Ani", "Bmp", "Cur", "Gif", @@ -19,6 +20,7 @@ ]; private static readonly string[] frameFormats = [ + "Ani", "Cur", "Ico", "Gif", diff --git a/tests/Directory.Build.targets b/tests/Directory.Build.targets index 71f2bc333c..da57541faa 100644 --- a/tests/Directory.Build.targets +++ b/tests/Directory.Build.targets @@ -19,7 +19,11 @@ - + + diff --git a/tests/ImageSharp.Tests/ConfigurationTests.cs b/tests/ImageSharp.Tests/ConfigurationTests.cs index bd65200844..ac1a18cf8e 100644 --- a/tests/ImageSharp.Tests/ConfigurationTests.cs +++ b/tests/ImageSharp.Tests/ConfigurationTests.cs @@ -20,7 +20,7 @@ public class ConfigurationTests public Configuration DefaultConfiguration { get; } - private readonly int expectedDefaultConfigurationCount = 12; + private readonly int expectedDefaultConfigurationCount = 13; public ConfigurationTests() { diff --git a/tests/ImageSharp.Tests/Formats/Ani/AniDecoderTests.cs b/tests/ImageSharp.Tests/Formats/Ani/AniDecoderTests.cs new file mode 100644 index 0000000000..93de4d2d9f --- /dev/null +++ b/tests/ImageSharp.Tests/Formats/Ani/AniDecoderTests.cs @@ -0,0 +1,210 @@ +// Copyright (c) Six Labors. +// Licensed under the Six Labors Split License. + +using System.Buffers.Binary; +using SixLabors.ImageSharp.Formats; +using SixLabors.ImageSharp.Formats.Ani; +using SixLabors.ImageSharp.PixelFormats; +using static SixLabors.ImageSharp.Tests.TestImages.Ani; + +namespace SixLabors.ImageSharp.Tests.Formats.Ani; + +[Trait("Format", "Ani")] +[ValidateDisposedMemoryAllocations] +public class AniDecoderTests +{ + /// + /// Verifies that ANI animation steps and embedded CUR resolution variants are flattened with their ANI metadata. + /// + [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 provider, + int expectedFrameCount, + int variantsPerStep, + uint expectedDisplayRate, + uint expectedFrameDelay) + { + using Image 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); + } + } + + /// + /// Verifies that identification exposes the same flattened ANI frame structure without decoding pixels. + /// + [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); + } + } + + /// + /// Verifies that invalid sequence metadata follows the ancillary-segment integrity policy. + /// + [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(() => AniDecoder.Instance.Decode(strict, strictStream)); + + using MemoryStream ignoreStream = new(data, false); + DecoderOptions ignore = new() { SegmentIntegrityHandling = SegmentIntegrityHandling.IgnoreAncillary }; + using Image image = AniDecoder.Instance.Decode(ignore, ignoreStream); + + Assert.Equal(3, image.Frames.Count); + } + + /// + /// Verifies that ignored corrupt resources retain their sequence-table slot. + /// + [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(() => AniDecoder.Instance.Decode(strict, strictStream)); + + using MemoryStream ignoreStream = new(data, false); + DecoderOptions ignore = new() { SegmentIntegrityHandling = SegmentIntegrityHandling.IgnoreImageData }; + using Image image = AniDecoder.Instance.Decode(ignore, ignoreStream); + + Assert.Equal(16, image.Frames.Count); + Assert.Equal(2, image.Frames.RootFrame.Metadata.GetAniMetadata().SequenceNumber); + } + + /// + /// Verifies that a malformed rate chunk follows the ancillary-segment integrity policy. + /// + [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 image = AniDecoder.Instance.Decode(DecoderOptions.Default, defaultStream); + + Assert.Equal(4, image.Frames.Count); + foreach (ImageFrame 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(() => AniDecoder.Instance.Decode(strict, strictStream)); + } + + /// + /// Verifies that oversized control arrays are rejected before allocation and follow ancillary integrity handling. + /// + /// to append a sequence chunk; otherwise, a rate chunk. + [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 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 image = AniDecoder.Instance.Decode(DecoderOptions.Default, defaultStream); + + Assert.Equal(4, image.Frames.Count); + + using MemoryStream strictStream = new(data, false); + DecoderOptions strict = new() { SegmentIntegrityHandling = SegmentIntegrityHandling.Strict }; + Assert.Throws(() => AniDecoder.Instance.Decode(strict, strictStream)); + } + + /// + /// Verifies that oversized information text is rejected before allocation and follows ancillary integrity handling. + /// + [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 image = AniDecoder.Instance.Decode(DecoderOptions.Default, defaultStream); + + Assert.Equal(4, image.Frames.Count); + + using MemoryStream strictStream = new(data, false); + DecoderOptions strict = new() { SegmentIntegrityHandling = SegmentIntegrityHandling.Strict }; + Assert.Throws(() => AniDecoder.Instance.Decode(strict, strictStream)); + } +} diff --git a/tests/ImageSharp.Tests/Formats/Ani/AniEncoderTests.cs b/tests/ImageSharp.Tests/Formats/Ani/AniEncoderTests.cs new file mode 100644 index 0000000000..8b0246c504 --- /dev/null +++ b/tests/ImageSharp.Tests/Formats/Ani/AniEncoderTests.cs @@ -0,0 +1,242 @@ +// Copyright (c) Six Labors. +// Licensed under the Six Labors Split License. + +using System.Buffers.Binary; +using SixLabors.ImageSharp.Formats.Ani; +using SixLabors.ImageSharp.Formats.Bmp; +using SixLabors.ImageSharp.Formats.Icon; +using SixLabors.ImageSharp.Metadata.Profiles.Icc; +using SixLabors.ImageSharp.PixelFormats; +using SixLabors.ImageSharp.Tests.TestDataIcc; +using SixLabors.ImageSharp.Tests.TestUtilities.ImageComparison; +using static SixLabors.ImageSharp.Tests.TestImages.Ani; + +namespace SixLabors.ImageSharp.Tests.Formats.Ani; + +[Trait("Format", "Ani")] +[ValidateDisposedMemoryAllocations] +public class AniEncoderTests +{ + /// + /// Verifies that ANI resources, including multi-resolution CUR resources, survive an encode/decode round trip. + /// + [Theory] + [WithFile(Work, PixelTypes.Rgba32)] + [WithFile(MultiFramesInEveryIconChunk, PixelTypes.Rgba32)] + [WithFile(Help, PixelTypes.Rgba32)] + public void AniEncoder_RoundTrips(TestImageProvider provider) + { + using Image 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 decoded = Image.Load(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()); + } + } + + /// + /// Verifies that per-step rates and RIFF information metadata are emitted and decoded. + /// + [Theory] + [WithFile(Work, PixelTypes.Rgba32)] + public void AniEncoder_WritesVariableRatesAndInformation(TestImageProvider provider) + { + using Image 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 decoded = Image.Load(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); + } + } + + /// + /// Verifies the encoder and decoder paths for embedded ICO and BMP resources. + /// + [Theory] + [InlineData(AniFrameFormat.Ico)] + [InlineData(AniFrameFormat.Bmp)] + public void AniEncoder_RoundTripsOtherFrameFormats(AniFrameFormat frameFormat) + { + using Image image = new(16, 16, Color.Red.ToPixel()); + 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 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 frameData = encoded[(frameChunkOffset + AniConstants.ChunkHeaderSize)..]; + Assert.False(frameData.StartsWith("BM"u8)); + } + + stream.Position = 0; + using Image decoded = Image.Load(stream); + + ImageComparer.Exact.VerifySimilarity(image, decoded); + Assert.Equal(frameFormat, decoded.Frames.RootFrame.Metadata.GetAniMetadata().FrameFormat); + } + + /// + /// Verifies that metadata suppression is propagated to every embedded resource encoder. + /// + [Theory] + [InlineData(AniFrameFormat.Ico)] + [InlineData(AniFrameFormat.Cur)] + [InlineData(AniFrameFormat.Bmp)] + public void AniEncoder_SkipMetadataPropagatesToEmbeddedEncoder(AniFrameFormat frameFormat) + { + using Image image = new(16, 16, Color.Red.ToPixel()); + 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 encoded = stream.GetBuffer().AsSpan(0, (int)stream.Length); + int frameChunkOffset = encoded.IndexOf("icon"u8); + Assert.True(frameChunkOffset >= 0); + + ReadOnlySpan 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..])); + } + + /// + /// Verifies that an independent frame cannot collide with an explicit sequence group. + /// + [Fact] + public void AniEncoder_NonPositiveSequenceDoesNotCollideWithExplicitGroup() + { + using Image image = new(16, 16, Color.Red.ToPixel()); + 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 decoded = Image.Load(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); + } + + /// + /// Verifies that an explicit source sequence is preserved as an identity table after playback-order expansion. + /// + [Fact] + public void AniEncoder_PreservesExplicitSequence() + { + using Image image = new(16, 16, Color.Red.ToPixel()); + image.Frames.AddFrame(image.Frames.RootFrame); + image.Metadata.GetAniMetadata().Flags = AniHeaderFlags.IsIcon | AniHeaderFlags.ContainsSequence; + + using MemoryStream stream = new(); + image.Save(stream, new AniEncoder()); + + ReadOnlySpan 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 decoded = Image.Load(stream); + + Assert.True(decoded.Metadata.GetAniMetadata().Flags.HasFlag(AniHeaderFlags.ContainsSequence)); + } + + /// + /// Verifies that unsupported public frame metadata is rejected before any container data is written. + /// + [Fact] + public void AniEncoder_UnsupportedFrameFormatThrowsBeforeWriting() + { + using Image image = new(16, 16); + image.Frames.RootFrame.Metadata.GetAniMetadata().FrameFormat = (AniFrameFormat)byte.MaxValue; + + using MemoryStream stream = new(); + + Assert.Throws(() => image.Save(stream, new AniEncoder())); + Assert.Equal(0, stream.Length); + } +} diff --git a/tests/ImageSharp.Tests/Formats/Ani/AniMetadataTests.cs b/tests/ImageSharp.Tests/Formats/Ani/AniMetadataTests.cs new file mode 100644 index 0000000000..676556827a --- /dev/null +++ b/tests/ImageSharp.Tests/Formats/Ani/AniMetadataTests.cs @@ -0,0 +1,31 @@ +// Copyright (c) Six Labors. +// Licensed under the Six Labors Split License. + +using SixLabors.ImageSharp.Formats.Ani; +using SixLabors.ImageSharp.PixelFormats; +using SixLabors.ImageSharp.Processing; + +namespace SixLabors.ImageSharp.Tests.Formats.Ani; + +[Trait("Format", "Ani")] +public class AniMetadataTests +{ + /// + /// Verifies that resizing scales the ANI-owned encoding dimensions exactly once. + /// + [Fact] + public void AfterFrameApply_ScalesEncodingDimensionsOnce() + { + using Image 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); + } +} diff --git a/tests/ImageSharp.Tests/Formats/Icon/Cur/CurDecoderTests.cs b/tests/ImageSharp.Tests/Formats/Icon/Cur/CurDecoderTests.cs index bac52fc728..80d721ba9f 100644 --- a/tests/ImageSharp.Tests/Formats/Icon/Cur/CurDecoderTests.cs +++ b/tests/ImageSharp.Tests/Formats/Icon/Cur/CurDecoderTests.cs @@ -1,6 +1,7 @@ // Copyright (c) Six Labors. // Licensed under the Six Labors Split License. +using System.Numerics; using SixLabors.ImageSharp.Formats.Bmp; using SixLabors.ImageSharp.Formats.Cur; using SixLabors.ImageSharp.Formats.Icon; @@ -13,6 +14,19 @@ namespace SixLabors.ImageSharp.Tests.Formats.Icon.Cur; [ValidateDisposedMemoryAllocations] public class CurDecoderTests { + [Fact] + public void CurFormat_HasCorrectName() + => Assert.Equal("CUR", CurFormat.Instance.Name); + + [Fact] + public void CurDetector_RejectsIco() + { + TestFile file = TestFile.Create(TestImages.Ico.Flutter); + CurImageFormatDetector detector = new(); + + Assert.False(detector.TryDetectFormat(file.Bytes, out _)); + } + [Theory] [WithFile(WindowsMouse, PixelTypes.Rgba32)] public void CurDecoder_Decode(TestImageProvider provider) @@ -38,4 +52,30 @@ public void CurDecoder_Decode2(TestImageProvider provider) Assert.Equal(IconFrameCompression.Bmp, meta.Compression); Assert.Equal(BmpBitsPerPixel.Bit32, meta.BmpBitsPerPixel); } + + [Fact] + public void CurFrameMetadata_DeepClonePreservesColorTable() + { + Color[] colors = [Color.Red, Color.Green]; + CurFrameMetadata metadata = new() { ColorTable = colors }; + + CurFrameMetadata clone = metadata.DeepClone(); + colors[0] = Color.Blue; + + Assert.Equal(Color.Red, clone.ColorTable.Value.Span[0]); + Assert.Equal(Color.Green, clone.ColorTable.Value.Span[1]); + } + + [Fact] + public void CurFrameMetadata_ScalesZeroEncodingDimensionsFrom256() + { + using Image source = new(256, 256); + using Image destination = new(128, 128); + CurFrameMetadata metadata = new() { EncodingWidth = 0, EncodingHeight = 0 }; + + metadata.AfterFrameApply(source.Frames.RootFrame, destination.Frames.RootFrame, Matrix4x4.Identity); + + Assert.Equal((byte)128, metadata.EncodingWidth); + Assert.Equal((byte)128, metadata.EncodingHeight); + } } diff --git a/tests/ImageSharp.Tests/Formats/Icon/Cur/CurEncoderTests.cs b/tests/ImageSharp.Tests/Formats/Icon/Cur/CurEncoderTests.cs index c41f455cd1..305adb9895 100644 --- a/tests/ImageSharp.Tests/Formats/Icon/Cur/CurEncoderTests.cs +++ b/tests/ImageSharp.Tests/Formats/Icon/Cur/CurEncoderTests.cs @@ -2,9 +2,12 @@ // Licensed under the Six Labors Split License. using SixLabors.ImageSharp.Formats; +using SixLabors.ImageSharp.Formats.Bmp; using SixLabors.ImageSharp.Formats.Cur; using SixLabors.ImageSharp.Formats.Ico; +using SixLabors.ImageSharp.Formats.Icon; using SixLabors.ImageSharp.PixelFormats; +using SixLabors.ImageSharp.Processing.Processors.Quantization; using SixLabors.ImageSharp.Tests.TestUtilities.ImageComparison; using static SixLabors.ImageSharp.Tests.TestImages.Cur; using static SixLabors.ImageSharp.Tests.TestImages.Ico; @@ -124,4 +127,37 @@ public void Encode_WithTransparentColorBehaviorClear_Works() } }); } + + [Fact] + public void Encode_UsesBitmapDepthInsteadOfHotspotForQuantizerSelection() + { + using Image image = new(32, 1); + for (int x = 0; x < image.Width; x++) + { + image[x, 0] = new Rgba32((byte)(x * 8), (byte)(255 - (x * 8)), (byte)(x * 4)); + } + + CurFrameMetadata metadata = image.Frames.RootFrame.Metadata.GetCurMetadata(); + metadata.Compression = IconFrameCompression.Bmp; + metadata.BmpBitsPerPixel = BmpBitsPerPixel.Bit8; + metadata.HotspotY = 32; + + CurEncoder encoder = new() + { + Quantizer = new WuQuantizer(new QuantizerOptions { MaxColors = 2 }) + }; + + using MemoryStream stream = new(); + image.Save(stream, encoder); + stream.Position = 0; + + using Image decoded = Image.Load(stream); + HashSet colors = []; + for (int x = 0; x < decoded.Width; x++) + { + colors.Add(decoded[x, 0]); + } + + Assert.InRange(colors.Count, 1, 2); + } } diff --git a/tests/ImageSharp.Tests/Formats/Icon/Ico/IcoDecoderTests.cs b/tests/ImageSharp.Tests/Formats/Icon/Ico/IcoDecoderTests.cs index 539826799e..0329f0e4dd 100644 --- a/tests/ImageSharp.Tests/Formats/Icon/Ico/IcoDecoderTests.cs +++ b/tests/ImageSharp.Tests/Formats/Icon/Ico/IcoDecoderTests.cs @@ -1,6 +1,8 @@ // Copyright (c) Six Labors. // Licensed under the Six Labors Split License. +using System.Buffers.Binary; +using System.Numerics; using SixLabors.ImageSharp.Formats; using SixLabors.ImageSharp.Formats.Bmp; using SixLabors.ImageSharp.Formats.Ico; @@ -14,6 +16,15 @@ namespace SixLabors.ImageSharp.Tests.Formats.Icon.Ico; [ValidateDisposedMemoryAllocations] public class IcoDecoderTests { + [Fact] + public void IcoDetector_RejectsCur() + { + TestFile file = TestFile.Create(TestImages.Cur.WindowsMouse); + IcoImageFormatDetector detector = new(); + + Assert.False(detector.TryDetectFormat(file.Bytes, out _)); + } + [Theory] [WithFile(Flutter, PixelTypes.Rgba32)] public void IcoDecoder_Decode(TestImageProvider provider) @@ -306,6 +317,43 @@ public void MultiSize_CanIdentifySingleFrame(string imagePath) Assert.Single(imageInfo.FrameMetadataCollection); } + [Fact] + public void TruncatedEntry_FollowsImageDataIntegrityHandling() + { + byte[] data = TestFile.Create(Flutter).Bytes.ToArray(); + ushort entryCount = BinaryPrimitives.ReadUInt16LittleEndian(data.AsSpan(4)); + Assert.True(entryCount > 1); + + // Limit the first resource below the PNG signature length. A bounded child decoder must not read into following data. + BinaryPrimitives.WriteUInt32LittleEndian(data.AsSpan(IconDir.Size + 8), 4); + + using MemoryStream defaultStream = new(data, false); + Assert.Throws(() => IcoDecoder.Instance.Decode(DecoderOptions.Default, defaultStream)); + + DecoderOptions ignore = new() { SegmentIntegrityHandling = SegmentIntegrityHandling.IgnoreImageData }; + + using MemoryStream decodeStream = new(data, false); + using Image image = IcoDecoder.Instance.Decode(ignore, decodeStream); + Assert.Equal(entryCount - 1, image.Frames.Count); + + using MemoryStream identifyStream = new(data, false); + ImageInfo info = IcoDecoder.Instance.Identify(ignore, identifyStream); + Assert.Equal(entryCount - 1, info.FrameMetadataCollection.Count); + } + + [Fact] + public void IcoFrameMetadata_ScalesZeroEncodingDimensionsFrom256() + { + using Image source = new(256, 256); + using Image destination = new(128, 128); + IcoFrameMetadata metadata = new() { EncodingWidth = 0, EncodingHeight = 0 }; + + metadata.AfterFrameApply(source.Frames.RootFrame, destination.Frames.RootFrame, Matrix4x4.Identity); + + Assert.Equal((byte)128, metadata.EncodingWidth); + Assert.Equal((byte)128, metadata.EncodingHeight); + } + [Theory] [WithFile(MultiSizeMultiBitsA, PixelTypes.Rgba32)] [WithFile(MultiSizeMultiBitsB, PixelTypes.Rgba32)] diff --git a/tests/ImageSharp.Tests/Formats/Icon/Ico/IcoEncoderTests.cs b/tests/ImageSharp.Tests/Formats/Icon/Ico/IcoEncoderTests.cs index 4c7438d568..08bb537ad7 100644 --- a/tests/ImageSharp.Tests/Formats/Icon/Ico/IcoEncoderTests.cs +++ b/tests/ImageSharp.Tests/Formats/Icon/Ico/IcoEncoderTests.cs @@ -2,8 +2,11 @@ // Licensed under the Six Labors Split License. using SixLabors.ImageSharp.Formats; +using SixLabors.ImageSharp.Formats.Bmp; using SixLabors.ImageSharp.Formats.Cur; using SixLabors.ImageSharp.Formats.Ico; +using SixLabors.ImageSharp.Formats.Icon; +using SixLabors.ImageSharp.Metadata.Profiles.Exif; using SixLabors.ImageSharp.PixelFormats; using SixLabors.ImageSharp.Tests.TestUtilities.ImageComparison; using static SixLabors.ImageSharp.Tests.TestImages.Cur; @@ -121,4 +124,52 @@ public void Encode_WithTransparentColorBehaviorClear_Works() } }); } + + [Fact] + public void PngEntry_PreservesExifProfile() + { + using Image image = new(16, 16); + image.Metadata.ExifProfile = new ExifProfile(); + image.Metadata.ExifProfile.SetValue(ExifTag.Software, "ImageSharp"); + image.Frames.RootFrame.Metadata.GetIcoMetadata().Compression = IconFrameCompression.Png; + + using MemoryStream stream = new(); + image.Save(stream, Encoder); + + stream.Position = 0; + using Image decoded = Image.Load(stream); + + Assert.NotNull(decoded.Metadata.ExifProfile); + Assert.Equal(image.Metadata.ExifProfile.Values, decoded.Metadata.ExifProfile.Values); + } + + /// + /// Verifies that the final partial AND-mask byte contains every pixel when the bitmap width is not byte-aligned. + /// + /// The bitmap width to encode. + [Theory] + [InlineData(1)] + [InlineData(7)] + [InlineData(9)] + [InlineData(15)] + public void BmpEntry_WritesPartialAlphaMaskByte(int width) + { + using Image image = new(width, 1, Color.Red.ToPixel()); + image[width - 1, 0] = Color.Transparent.ToPixel(); + + IcoFrameMetadata metadata = image.Frames.RootFrame.Metadata.GetIcoMetadata(); + metadata.Compression = IconFrameCompression.Bmp; + metadata.BmpBitsPerPixel = BmpBitsPerPixel.Bit32; + + using MemoryStream stream = new(); + image.Save(stream, Encoder); + + // These widths produce one DWORD-aligned mask row at the end of the bitmap resource. + ReadOnlySpan mask = stream.GetBuffer().AsSpan(checked((int)stream.Length) - sizeof(uint), sizeof(uint)); + int pixelIndex = width - 1; + int byteIndex = pixelIndex / 8; + int bitIndex = pixelIndex % 8; + + Assert.Equal((byte)(0b10000000 >> bitIndex), mask[byteIndex]); + } } diff --git a/tests/ImageSharp.Tests/Formats/ImageFormatManagerTests.cs b/tests/ImageSharp.Tests/Formats/ImageFormatManagerTests.cs index 9c8f45f784..1cd03e0d5d 100644 --- a/tests/ImageSharp.Tests/Formats/ImageFormatManagerTests.cs +++ b/tests/ImageSharp.Tests/Formats/ImageFormatManagerTests.cs @@ -3,6 +3,7 @@ using Moq; using SixLabors.ImageSharp.Formats; +using SixLabors.ImageSharp.Formats.Ani; using SixLabors.ImageSharp.Formats.Bmp; using SixLabors.ImageSharp.Formats.Gif; using SixLabors.ImageSharp.Formats.Jpeg; @@ -30,6 +31,7 @@ public ImageFormatManagerTests() [Fact] public void IfAutoLoadWellKnownFormatsIsTrueAllFormatsAreLoaded() { + Assert.Equal(1, this.DefaultFormatsManager.ImageEncoders.Select(item => item.Value).OfType().Count()); Assert.Equal(1, this.DefaultFormatsManager.ImageEncoders.Select(item => item.Value).OfType().Count()); Assert.Equal(1, this.DefaultFormatsManager.ImageEncoders.Select(item => item.Value).OfType().Count()); Assert.Equal(1, this.DefaultFormatsManager.ImageEncoders.Select(item => item.Value).OfType().Count()); @@ -39,6 +41,7 @@ public void IfAutoLoadWellKnownFormatsIsTrueAllFormatsAreLoaded() Assert.Equal(1, this.DefaultFormatsManager.ImageEncoders.Select(item => item.Value).OfType().Count()); Assert.Equal(1, this.DefaultFormatsManager.ImageEncoders.Select(item => item.Value).OfType().Count()); + Assert.Equal(1, this.DefaultFormatsManager.ImageDecoders.Select(item => item.Value).OfType().Count()); Assert.Equal(1, this.DefaultFormatsManager.ImageDecoders.Select(item => item.Value).OfType().Count()); Assert.Equal(1, this.DefaultFormatsManager.ImageDecoders.Select(item => item.Value).OfType().Count()); Assert.Equal(1, this.DefaultFormatsManager.ImageDecoders.Select(item => item.Value).OfType().Count()); diff --git a/tests/ImageSharp.Tests/TestImages.cs b/tests/ImageSharp.Tests/TestImages.cs index f0477b383d..2622a0fb9b 100644 --- a/tests/ImageSharp.Tests/TestImages.cs +++ b/tests/ImageSharp.Tests/TestImages.cs @@ -1400,6 +1400,13 @@ public static class Cur public const string CurFake = "Icon/cur_fake.ico"; } + public static class Ani + { + public const string Work = "Ani/Work.ani"; + public const string MultiFramesInEveryIconChunk = "Ani/aero_busy.ani"; + public const string Help = "Ani/Help.ani"; + } + public static class Exr { public const string Benchmark = "Exr/Calliphora_benchmark.exr"; diff --git a/tests/Images/Input/Ani/Help.ani b/tests/Images/Input/Ani/Help.ani new file mode 100644 index 0000000000..d623503cac --- /dev/null +++ b/tests/Images/Input/Ani/Help.ani @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c49cbb1ca0a3f268695a80df93b1ce2b2cba335a80e8244dd3a702863159bd99 +size 12998 diff --git a/tests/Images/Input/Ani/Work.ani b/tests/Images/Input/Ani/Work.ani new file mode 100644 index 0000000000..d576244bbd --- /dev/null +++ b/tests/Images/Input/Ani/Work.ani @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:740353739d3763addddd383614d125918781b8879f7c1ad3c770162a3e143a33 +size 1150338 diff --git a/tests/Images/Input/Ani/aero_busy.ani b/tests/Images/Input/Ani/aero_busy.ani new file mode 100644 index 0000000000..e2fa9d31a5 --- /dev/null +++ b/tests/Images/Input/Ani/aero_busy.ani @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ff38afb523490e1a9f157c0447bc616b19c22df88bdb45c163243d834e9745f8 +size 556304