diff --git a/HEIF_IMPLEMENTATION_PLAN.md b/HEIF_IMPLEMENTATION_PLAN.md index aa8c30dcc..1cf805497 100644 --- a/HEIF_IMPLEMENTATION_PLAN.md +++ b/HEIF_IMPLEMENTATION_PLAN.md @@ -93,6 +93,8 @@ Checkboxes may be marked complete only when the implementation and the verificat - [x] Require unity movie and track matrices so image presentation remains on the optimized `clap`/`irot`/`imir` path without a movie compositor. - [x] Apply the shared `DecoderOptions` contract consistently to still items, nested payload codecs, grids, metadata properties, and sequence samples. - `Strict` rejects recoverable ancillary and image-data errors, `IgnoreAncillary` suppresses only ancillary failures, and `IgnoreImageData` additionally permits failed image properties or samples to be omitted. `SkipMetadata` avoids optional property and item-payload validation, while cancellation and the caller configuration flow into nested JPEG and AV1 decoders. Target scaling and ICC conversion remain presentation-level operations after item or grid composition. The focused Release matrix passes all 19 new still-image policy cases, all 3 new sequence-sample cases, and the complete 37-test sequence-parser suite; the Release test-project build completes with zero errors. + - [x] Apply the same recovery boundary to still-image item relationships, coded payloads, primary-item thumbnail fallback, and optional alpha composition. + - Unknown item-reference types are skipped within their validated child boundaries. `cdsc` failures follow ancillary policy and are not parsed when metadata is skipped. `dimg`, `auxl`, `prem`, and `thmb` failures follow image-data policy. `IgnoreImageData` can omit an unreadable alpha plane or recover from a failed primary payload through a valid registered thumbnail, but decoding still fails when no color presentation remains. Real AVIF fixtures cover corrupt alpha payloads, corrupt alpha relationships, malformed Exif relationships, and strict, ancillary-only, image-data, and metadata-skipping behavior. - [ ] Complete reference-dependent AV1 and HEVC sample reconstruction and independent sequence vectors. - [ ] Write the same bounded movie, track, sample-description, location, dependency, timing, repetition, alpha, and metadata syntax from ImageSharp frames. - [ ] Decode frame dependencies, durations, repetition, frame-local auxiliary images, and frame-local metadata into the existing ImageSharp multi-frame model. @@ -393,9 +395,11 @@ Tasks: - [ ] Define distinct HEIC and AVIF public format types over the shared internal HEIF container and register the correct brands, MIME types, and extensions. - [ ] Add generated `SaveAsHeic` and `SaveAsAvif` APIs and format metadata integration through the same mechanisms as established codecs. Define generic `SaveAsHeif` only if its options require an explicit supported payload codec. - [ ] Define decoder options using existing `DecoderOptions` behavior, including target pixel type, metadata handling, cancellation, and image-size limits. + - [x] Generic decode selects the caller's pixel type and the non-generic entry point defaults to `Rgba32`. `TargetSize` and `Sampler` are applied once after HEIF presentation composition. `MaxFrames` bounds retained sequence samples. `SkipMetadata`, `SegmentIntegrityHandling`, `ColorProfileHandling`, `Configuration`, and cancellation flow through the container, item, sequence, and nested-codec boundaries. + - [ ] Complete adversarial dimension and allocation-limit coverage for still items, grids, auxiliary images, and sequence tracks before closing this contract item. Large payload and image buffers already use the configured allocator, but the complete cross-product has not been verified. - [ ] Define codec-specific encoder options with observable semantics for quality, speed/effort, lossless mode, chroma subsampling, bit depth, alpha quality, and metadata handling. Avoid exposing internal HEVC or AV1 tuning knobs without a stable user-facing meaning. -- [ ] Make `Rgba32` the default 8-bit decode output so alpha is not silently lost. -- [ ] Remove unsupported JPEG 2000, JPEG-XR, JPEG-XS, and AVC capability claims unless those payload codecs are added to the completion matrix. Retain legacy JPEG as an explicit supported HEIF image-item codec. +- [x] Make `Rgba32` the default 8-bit decode output so alpha is not silently lost. +- [x] Remove unsupported JPEG 2000, JPEG-XR, JPEG-XS, and AVC capability claims unless those payload codecs are added to the completion matrix. Retain legacy JPEG as an explicit supported HEIF image-item codec. Exit gate: diff --git a/src/ImageSharp/Formats/Heif/HeifDecoderCore.cs b/src/ImageSharp/Formats/Heif/HeifDecoderCore.cs index c3902c1d3..3ba01425f 100644 --- a/src/ImageSharp/Formats/Heif/HeifDecoderCore.cs +++ b/src/ImageSharp/Formats/Heif/HeifDecoderCore.cs @@ -1002,34 +1002,69 @@ internal sealed class HeifDecoderCore : ImageDecoderCore int referenceEnd = checked(bytesRead + referenceHeaderLength + (int)referenceLength); Span referenceBuffer = boxBuffer[..referenceEnd]; bytesRead += referenceHeaderLength; - uint sourceId = ReadUInt16Or32(referenceBuffer, largeIds, ref bytesRead); - if (this.FindItemById(sourceId) is null) + + if (linkType is not Heif4CharCode.Dimg + and not Heif4CharCode.Auxl + and not Heif4CharCode.Prem + and not Heif4CharCode.Thmb + and not Heif4CharCode.Cdsc) { - throw new InvalidImageContentException($"The item reference box references unknown source item ID {sourceId}."); + // Unknown reference types do not participate in the bounded image model. Their child-box boundary + // was validated above, so skip the payload without imposing semantics from a general ISOBMFF reader. + bytesRead = referenceEnd; + continue; } - HeifItemLink link = new(linkType, sourceId); + if (this.Options.SkipMetadata && linkType == Heif4CharCode.Cdsc) + { + // Descriptive metadata links have no effect when their payloads are not requested. Avoid validating + // their optional item graph while preserving the surrounding image relationships. + bytesRead = referenceEnd; + continue; + } - EnsureBufferRemaining(referenceBuffer, bytesRead, 2, "item reference"); - int count = BinaryPrimitives.ReadUInt16BigEndian(referenceBuffer[bytesRead..]); - bytesRead += 2; - for (uint i = 0; i < count; i++) + try { - uint destId = ReadUInt16Or32(referenceBuffer, largeIds, ref bytesRead); - if (this.FindItemById(destId) is null) + uint sourceId = ReadUInt16Or32(referenceBuffer, largeIds, ref bytesRead); + if (this.FindItemById(sourceId) is null) { - throw new InvalidImageContentException($"The item reference box references unknown destination item ID {destId}."); + throw new InvalidImageContentException($"The item reference box references unknown source item ID {sourceId}."); } - link.DestinationIds.Add(destId); - } + HeifItemLink link = new(linkType, sourceId); + + EnsureBufferRemaining(referenceBuffer, bytesRead, 2, "item reference"); + int count = BinaryPrimitives.ReadUInt16BigEndian(referenceBuffer[bytesRead..]); + bytesRead += 2; + for (uint i = 0; i < count; i++) + { + uint destId = ReadUInt16Or32(referenceBuffer, largeIds, ref bytesRead); + if (this.FindItemById(destId) is null) + { + throw new InvalidImageContentException($"The item reference box references unknown destination item ID {destId}."); + } + + link.DestinationIds.Add(destId); + } - if (bytesRead != referenceEnd) + if (bytesRead != referenceEnd) + { + throw new InvalidImageContentException($"The '{linkType}' item reference length does not match its entry count."); + } + + this.itemLinks.Add(link); + } + catch (Exception ex) when (linkType == Heif4CharCode.Cdsc && ImageDecoderCore.ShouldIgnoreAncillarySegmentError(this.Options, ex)) { - throw new InvalidImageContentException($"The '{linkType}' item reference length does not match its entry count."); + // A malformed descriptive link cannot change reconstructed pixels, so non-strict modes omit it. + bytesRead = referenceEnd; + } + catch (Exception ex) when (linkType != Heif4CharCode.Cdsc && ImageDecoderCore.ShouldIgnoreImageDataSegmentError(this.Options, ex)) + { + // IgnoreImageData permits a malformed optional image relationship to be omitted while retaining + // independently reconstructable items and thumbnail fallbacks. + bytesRead = referenceEnd; } - - this.itemLinks.Add(link); } } @@ -2034,6 +2069,12 @@ internal sealed class HeifDecoderCore : ImageDecoderCore // A failed optional metadata extent is discarded without weakening image-item extent validation. extentMemory?.Dispose(); } + catch (Exception ex) when (!isMetadataItem && ImageDecoderCore.ShouldIgnoreImageDataSegmentError(this.Options, ex)) + { + // Keep the item declaration but omit its unreadable payload. The presentation can still use a valid + // thumbnail, omit an auxiliary plane, or reject the file later when no decodable color item remains. + extentMemory?.Dispose(); + } catch { // The dictionary takes ownership only after every declared extent has been assembled successfully. @@ -2048,32 +2089,57 @@ internal sealed class HeifDecoderCore : ImageDecoderCore throw new ImageFormatException("No primary HEIF item defined."); } + Image? image = null; + HeifItem itemToDecode = rootItem; IHeifItemDecoder? itemDecoder = this.GetItemDecoder(rootItem, buffers); + bool supportedItemFound = itemDecoder is not null; + if (itemDecoder is not null) + { + this.ExecuteImageDataSegmentAction( + () => image = this.DecodeImageItem(rootItem, itemDecoder, buffers, cancellationToken)); + } - HeifItem itemToDecode = rootItem; - if (itemDecoder is null) + if (image is null) { - // Unable to decode the primary image, decode the thumbnail instead. + // An unsupported primary item always permits its registered thumbnail fallback. IgnoreImageData also + // reaches this branch after a recoverable primary payload failure, matching other multi-image decoders. HeifItem? thumbnailItem = this.FindDecodableThumbnail(rootItem); if (thumbnailItem is not null) { itemDecoder = HeifCompressionFactory.GetDecoder(thumbnailItem.Type); - itemToDecode = thumbnailItem; + supportedItemFound |= itemDecoder is not null; + if (itemDecoder is not null) + { + itemToDecode = thumbnailItem; + this.ExecuteImageDataSegmentAction( + () => image = this.DecodeImageItem(thumbnailItem, itemDecoder, buffers, cancellationToken)); + } } } - if (itemDecoder is null) + if (image is null || itemDecoder is null) { - throw new ImageFormatException("No decodable item found inside this HEIF container."); + if (!supportedItemFound) + { + throw new ImageFormatException("No supported image item was found inside this HEIF container."); + } + + throw new InvalidImageContentException("The HEIF container does not contain a decodable image item."); } - Image image = this.DecodeImageItem(itemToDecode, itemDecoder, buffers, cancellationToken); try { - using Image? alphaImage = this.DecodeAlphaPlane(itemToDecode, buffers, cancellationToken, out bool alphaPremultiplied); - if (alphaImage is not null) + Image? alphaImage = null; + bool alphaPremultiplied = false; + this.ExecuteImageDataSegmentAction( + () => alphaImage = this.DecodeAlphaPlane(itemToDecode, buffers, cancellationToken, out alphaPremultiplied)); + + using (alphaImage) { - this.ApplyAlpha(image, alphaImage, alphaPremultiplied); + if (alphaImage is not null) + { + this.ApplyAlpha(image, alphaImage, alphaPremultiplied); + } } if (!this.Options.SkipMetadata) diff --git a/tests/ImageSharp.Tests/Formats/Heif/HeifDecoderTests.cs b/tests/ImageSharp.Tests/Formats/Heif/HeifDecoderTests.cs index c04908719..4796edce1 100644 --- a/tests/ImageSharp.Tests/Formats/Heif/HeifDecoderTests.cs +++ b/tests/ImageSharp.Tests/Formats/Heif/HeifDecoderTests.cs @@ -363,6 +363,137 @@ public class HeifDecoderTests Assert.Same(configuration, image.Configuration); } + /// + /// Verifies that invalid optional alpha payloads remain fatal when image-data errors cannot be ignored. + /// + [Theory] + [InlineData(SegmentIntegrityHandling.Strict)] + [InlineData(SegmentIntegrityHandling.IgnoreAncillary)] + public void DecodeRejectsInvalidAlphaPayloadUnlessImageDataErrorsAreIgnored(SegmentIntegrityHandling handling) + { + byte[] data = [.. TestFile.Create(TestImages.Heif.DuckyRommIccAlphaAvif).Bytes]; + uint alphaItemId = FindFirstItemReferenceSourceId(data, Heif4CharCode.Auxl); + ClearItemPayload(data, alphaItemId); + DecoderOptions options = new() { SegmentIntegrityHandling = handling }; + + Assert.ThrowsAny(() => + { + using Image image = Image.Load(options, data); + }); + } + + /// + /// Verifies that omits a corrupt optional alpha item while + /// retaining the independently decodable color item. + /// + [Fact] + public void DecodeOmitsInvalidAlphaPayloadWhenImageDataErrorsAreIgnored() + { + byte[] source = TestFile.Create(TestImages.Heif.DuckyRommIccAlphaAvif).Bytes; + byte[] data = [.. source]; + uint alphaItemId = FindFirstItemReferenceSourceId(data, Heif4CharCode.Auxl); + ClearItemPayload(data, alphaItemId); + DecoderOptions options = new() { SegmentIntegrityHandling = SegmentIntegrityHandling.IgnoreImageData }; + + using Image expected = Image.Load(source); + using Image actual = Image.Load(options, data); + + AssertOpaqueRgbMatches(expected, actual); + } + + /// + /// Verifies that a malformed alpha relationship remains fatal when image-data errors cannot be ignored. + /// + [Theory] + [InlineData(SegmentIntegrityHandling.Strict)] + [InlineData(SegmentIntegrityHandling.IgnoreAncillary)] + public void DecodeRejectsMalformedAlphaReferenceUnlessImageDataErrorsAreIgnored(SegmentIntegrityHandling handling) + { + byte[] data = [.. TestFile.Create(TestImages.Heif.DuckyRommIccAlphaAvif).Bytes]; + InvalidateFirstItemReferenceSource(data, Heif4CharCode.Auxl); + DecoderOptions options = new() { SegmentIntegrityHandling = handling }; + + Assert.Throws(() => + { + using Image image = Image.Load(options, data); + }); + } + + /// + /// Verifies that omits a malformed optional alpha + /// relationship while retaining the independently decodable color item. + /// + [Fact] + public void DecodeOmitsMalformedAlphaReferenceWhenImageDataErrorsAreIgnored() + { + byte[] source = TestFile.Create(TestImages.Heif.DuckyRommIccAlphaAvif).Bytes; + byte[] data = [.. source]; + InvalidateFirstItemReferenceSource(data, Heif4CharCode.Auxl); + DecoderOptions options = new() { SegmentIntegrityHandling = SegmentIntegrityHandling.IgnoreImageData }; + + using Image expected = Image.Load(source); + using Image actual = Image.Load(options, data); + + AssertOpaqueRgbMatches(expected, actual); + } + + /// + /// Verifies that strict validation rejects a malformed descriptive metadata relationship. + /// + [Fact] + public void DecodeRejectsMalformedMetadataReferenceInStrictMode() + { + byte[] data = [.. TestFile.Create(TestImages.Heif.ParisIccExifXmpAvif).Bytes]; + InvalidateFirstItemReferenceSource(data, Heif4CharCode.Cdsc); + DecoderOptions options = new() { SegmentIntegrityHandling = SegmentIntegrityHandling.Strict }; + + Assert.Throws(() => + { + using Image image = Image.Load(options, data); + }); + } + + /// + /// Verifies that non-strict validation omits a malformed descriptive relationship without weakening image-data + /// validation. + /// + [Theory] + [InlineData(SegmentIntegrityHandling.IgnoreAncillary)] + [InlineData(SegmentIntegrityHandling.IgnoreImageData)] + public void DecodeOmitsMalformedMetadataReferenceWhenAncillaryErrorsAreIgnored(SegmentIntegrityHandling handling) + { + byte[] data = [.. TestFile.Create(TestImages.Heif.ParisIccExifXmpAvif).Bytes]; + InvalidateFirstItemReferenceSource(data, Heif4CharCode.Cdsc); + DecoderOptions options = new() { SegmentIntegrityHandling = handling }; + + using Image image = Image.Load(options, data); + + Assert.Null(image.Metadata.ExifProfile); + Assert.NotNull(image.Metadata.XmpProfile); + Assert.NotNull(image.Metadata.IccProfile); + } + + /// + /// Verifies that skipped metadata is neither retained nor validated through its optional descriptive links. + /// + [Fact] + public void DecodeDoesNotValidateSkippedMetadataReference() + { + byte[] data = [.. TestFile.Create(TestImages.Heif.ParisIccExifXmpAvif).Bytes]; + InvalidateFirstItemReferenceSource(data, Heif4CharCode.Cdsc); + DecoderOptions options = new() + { + SkipMetadata = true, + SegmentIntegrityHandling = SegmentIntegrityHandling.Strict + }; + + using Image image = Image.Load(options, data); + + Assert.Null(image.Metadata.ExifProfile); + Assert.Null(image.Metadata.XmpProfile); + Assert.Null(image.Metadata.IccProfile); + } + [Fact] public void IdentifyIgnoresUnknownMetadataBox() { @@ -883,6 +1014,177 @@ public class HeifDecoderTests return -1; } + /// + /// Reads the source item identifier from the first registered relationship of the requested type. + /// + /// The complete HEIF container. + /// The item-reference child type. + /// The source item identifier. + private static uint FindFirstItemReferenceSourceId(ReadOnlySpan data, Heif4CharCode referenceType) + { + int metaOffset = FindBoxOffset(data, Heif4CharCode.Meta, 0, data.Length); + Assert.True(metaOffset >= 0); + + int metaSize = (int)BinaryPrimitives.ReadUInt32BigEndian(data[metaOffset..]); + int itemReferenceOffset = FindBoxOffset(data, Heif4CharCode.Iref, metaOffset + 12, metaSize - 12); + Assert.True(itemReferenceOffset >= 0); + + int itemReferenceSize = (int)BinaryPrimitives.ReadUInt32BigEndian(data[itemReferenceOffset..]); + int relationshipOffset = FindBoxOffset(data, referenceType, itemReferenceOffset + 12, itemReferenceSize - 12); + Assert.True(relationshipOffset >= 0); + + byte version = data[itemReferenceOffset + 8]; + + Assert.InRange(version, (byte)0, (byte)1); + + return version == 0 + ? BinaryPrimitives.ReadUInt16BigEndian(data[(relationshipOffset + 8)..]) + : BinaryPrimitives.ReadUInt32BigEndian(data[(relationshipOffset + 8)..]); + } + + /// + /// Replaces the source item identifier of the first requested relationship with an undeclared value. + /// + /// The complete mutable HEIF container. + /// The item-reference child type. + private static void InvalidateFirstItemReferenceSource(Span data, Heif4CharCode referenceType) + { + int metaOffset = FindBoxOffset(data, Heif4CharCode.Meta, 0, data.Length); + Assert.True(metaOffset >= 0); + + int metaSize = (int)BinaryPrimitives.ReadUInt32BigEndian(data[metaOffset..]); + int itemReferenceOffset = FindBoxOffset(data, Heif4CharCode.Iref, metaOffset + 12, metaSize - 12); + Assert.True(itemReferenceOffset >= 0); + + int itemReferenceSize = (int)BinaryPrimitives.ReadUInt32BigEndian(data[itemReferenceOffset..]); + int relationshipOffset = FindBoxOffset(data, referenceType, itemReferenceOffset + 12, itemReferenceSize - 12); + Assert.True(relationshipOffset >= 0); + + byte version = data[itemReferenceOffset + 8]; + + Assert.InRange(version, (byte)0, (byte)1); + + if (version == 0) + { + BinaryPrimitives.WriteUInt16BigEndian(data[(relationshipOffset + 8)..], ushort.MaxValue); + } + else + { + BinaryPrimitives.WriteUInt32BigEndian(data[(relationshipOffset + 8)..], uint.MaxValue); + } + } + + /// + /// Clears every file-relative extent belonging to the requested item while retaining the container structure. + /// + /// The complete mutable HEIF container. + /// The item whose coded payload is cleared. + private static void ClearItemPayload(Span data, uint itemId) + { + int metaOffset = FindBoxOffset(data, Heif4CharCode.Meta, 0, data.Length); + Assert.True(metaOffset >= 0); + + int metaSize = (int)BinaryPrimitives.ReadUInt32BigEndian(data[metaOffset..]); + int itemLocationOffset = FindBoxOffset(data, Heif4CharCode.Iloc, metaOffset + 12, metaSize - 12); + Assert.True(itemLocationOffset >= 0); + + int offset = itemLocationOffset + 8; + byte version = data[offset]; + offset += 4; + + int extentOffsetSize = data[offset] >> 4; + int extentLengthSize = data[offset] & 0x0F; + offset++; + int baseOffsetSize = data[offset] >> 4; + int extentIndexSize = version is 1 or 2 ? data[offset] & 0x0F : 0; + offset++; + + uint itemCount = version == 2 + ? BinaryPrimitives.ReadUInt32BigEndian(data[offset..]) + : BinaryPrimitives.ReadUInt16BigEndian(data[offset..]); + + offset += version == 2 ? 4 : 2; + bool found = false; + for (uint itemIndex = 0; itemIndex < itemCount; itemIndex++) + { + uint currentItemId = version == 2 + ? BinaryPrimitives.ReadUInt32BigEndian(data[offset..]) + : BinaryPrimitives.ReadUInt16BigEndian(data[offset..]); + + offset += version == 2 ? 4 : 2; + if (version is 1 or 2) + { + ushort constructionMethod = BinaryPrimitives.ReadUInt16BigEndian(data[offset..]); + Assert.Equal(0, constructionMethod & 0x0F); + offset += 2; + } + + // The data-reference index is zero for the self-contained image items used by the fixture. + Assert.Equal(0, BinaryPrimitives.ReadUInt16BigEndian(data[offset..])); + offset += 2; + ulong baseOffset = ReadVariableUnsigned(data, baseOffsetSize, ref offset); + int extentCount = BinaryPrimitives.ReadUInt16BigEndian(data[offset..]); + offset += 2; + for (int extentIndex = 0; extentIndex < extentCount; extentIndex++) + { + _ = ReadVariableUnsigned(data, extentIndexSize, ref offset); + ulong extentOffset = ReadVariableUnsigned(data, extentOffsetSize, ref offset); + ulong extentLength = ReadVariableUnsigned(data, extentLengthSize, ref offset); + if (currentItemId == itemId) + { + data.Slice(checked((int)(baseOffset + extentOffset)), checked((int)extentLength)).Clear(); + found = true; + } + } + } + + Assert.True(found); + } + + /// + /// Reads one zero-width, 32-bit, or 64-bit unsigned item-location field. + /// + /// The complete HEIF container. + /// The field width in bytes. + /// The current read offset, advanced past the field. + /// The decoded field value. + private static ulong ReadVariableUnsigned(ReadOnlySpan data, int size, ref int offset) + { + ulong value = size switch + { + 0 => 0, + 4 => BinaryPrimitives.ReadUInt32BigEndian(data[offset..]), + 8 => BinaryPrimitives.ReadUInt64BigEndian(data[offset..]), + _ => throw new InvalidOperationException($"Unexpected item-location field width {size} in the test fixture.") + }; + + offset += size; + return value; + } + + /// + /// Verifies that omitting an invalid alpha item preserves color channels and produces opaque output. + /// + /// The image decoded with its valid alpha item. + /// The image decoded after the alpha item or relationship was invalidated. + private static void AssertOpaqueRgbMatches(Image expected, Image actual) + { + Assert.False(actual.Metadata.GetHeifMetadata().HasAlpha); + Assert.Equal(expected.Size, actual.Size); + for (int y = 0; y < actual.Height; y++) + { + Span expectedRow = expected.Frames.RootFrame.PixelBuffer.DangerousGetRowSpan(y); + Span actualRow = actual.Frames.RootFrame.PixelBuffer.DangerousGetRowSpan(y); + for (int x = 0; x < actualRow.Length; x++) + { + Assert.Equal(expectedRow[x].R, actualRow[x].R); + Assert.Equal(expectedRow[x].G, actualRow[x].G); + Assert.Equal(expectedRow[x].B, actualRow[x].B); + Assert.Equal(byte.MaxValue, actualRow[x].A); + } + } + } + private static byte[] InsertBytes(byte[] data, int offset, ReadOnlySpan inserted) { byte[] result = new byte[data.Length + inserted.Length];