diff --git a/HEIF_IMPLEMENTATION_PLAN.md b/HEIF_IMPLEMENTATION_PLAN.md index a6e1fc2bf5..1d3d70923e 100644 --- a/HEIF_IMPLEMENTATION_PLAN.md +++ b/HEIF_IMPLEMENTATION_PLAN.md @@ -34,7 +34,7 @@ Reconciled with the worktree on 2026-09-03. reference/mode deltas. - [~] Loop filtering, CDEF, super-resolution, restoration, film grain, layered presentation, alpha composition, and color conversion exist locally. Shared-source cleanup changed the current tree, so final production-path verification is open. - [~] AV1 writer primitives, forward transforms, symbol encoding, and tile-writing source are connected to the public encoder for bounded still-image AVIF color and optional auxiliary alpha output. -- [~] The public AV1 encoder supports explicit single-image requests. Lossless output, bounded sequences, grids, metadata preservation, orientation handling, and default format registration remain open. +- [~] The public AV1 encoder supports explicit single-image requests. Lossless output, bounded sequences, grids, orientation handling, and default format registration remain open. - [x] Patented codec production code, registrations, tests, benchmarks, fixtures, reference outputs, and notices were manually deleted and committed by `78a74d448`. - [x] Remaining task-created HM, HEVC, libheif, GPAC, Nokia, FFmpeg, Pillow HEIF, libavif-build, and libjpeg-build directories were traced to their creation commands in the recovered Codex session history and deleted on 2026-08-31. The user-provided repositories and all libaom-only source, build, and reference data were left untouched. - [x] The PNG metadata-suppression fix and three HEIF/AV1 diagnostic-save call-site corrections passed the exact 34 net11.0 ARM CI cases and were committed with the single-reference checkpoint as `54bb6cbe59bd113058854a3ee31448cf61f462ca`. They are infrastructure evidence, not decoder or encoder completion evidence. @@ -874,9 +874,11 @@ Encoder verification contract: - [~] AV1 image properties now write `ispe`, `pixi`, `av1C`, `colr`, and `auxC` in current AVIF item order. Only `av1C` is essential; color and alpha items retain independent property sets and the registered alpha auxiliary type. The property container reacquires its span after nested expansion before patching `ipco`, removing the prior stale-buffer write, and selects compact or 15-bit `ipma` indices from the property count rather than the unrelated item count. A forced-growth color-plus-alpha case validates every property payload and association byte; a separate 43-item, 129-property case proves indices 127 through 129 and the extended essential bit. Both pass direct foreground net11 Release VSTest. Complete AVIF assembly remains open. - [~] Explicit public AV1 encoding now writes a still-image AVIF with `avif` major brand, compatible `avif`, `mif1`, and `miaf` brands, one primary color item, an optional alpha auxiliary item, `auxl` from alpha to color, independent item properties, absolute version-one `iloc` extents, and a shared `mdat`. Quality uses current libaom's quantizer-to-qindex mapping with public quality 100 deliberately clamped from lossless qindex 0 to qindex 4. Effort controls the implemented search stages, and the resolved value is required explicitly by every internal frame, tile, and mode-decision operation rather than repeated as optional defaults. Encoder options take precedence over source metadata for 8-, 10-, and 12-bit monochrome, 4:2:0, 4:2:2, and 4:4:4 output. Alpha derives from the source pixel type without scanning pixels, and incompatible identity-matrix metadata is normalized without mutating the source image. - [~] The production path writes color and alpha payloads sequentially through allocator-backed chunked storage, supports non-seekable and prefixed destinations, and does not materialize a complete file or payload copy. Uniform encoder-side `pixi` depth is written directly without allocating per-item channel-depth arrays; decoder-side non-uniform channel depths remain supported. The Release test project builds with zero errors, all 39 encoder cases pass, the complete non-HEVC HEIF namespace passes 9,277 of 9,277, and current official libaom accepts all 47 generated payloads. +- [x] Still-image AVIF metadata preservation now writes an unrestricted ICC `colr/prof` property before the independent `colr/nclx` property, Exif and XMP as separate `mdat` items, and one `cdsc` relationship from each metadata item to the primary color item. Exif stores the exact big-endian TIFF-header offset required by the HEIF item syntax; XMP uses the `mime` item type and `application/rdf+xml` content type. Existing ICC and XMP storage is read synchronously and copied once into final encoder storage rather than cloned into an intermediate array. `SkipMetadata` suppresses all three profile types while retaining the CICP values required to describe the encoded planes. The same option now reaches legacy JPEG payloads, whose encoder no longer writes application profiles or comments when metadata is disabled. +- [x] Exact container tests verify every emitted item declaration, name, MIME content type, `cdsc` relationship, Exif offset and payload, XMP payload, ICC/CICP property order, compact association byte, propertyless metadata exclusion, decoded profile value, and both `SkipMetadata` branches. The final HEIF encoder set passes 44 of 44 and the complete JPEG encoder set passes 257 of 257 through direct foreground net11 Release VSTest. The complete non-HEVC HEIF namespace passes 9,282 of 9,282 with no failure, crash, or detached test host, and current official libaom accepts all 47 current generated AV1 payloads. - [~] Write the correct AVIF file type, item information, locations, references, properties, AV1 configuration, dimensions, color, alpha, metadata, and media data. - [~] Support single images, alpha auxiliary images, grids, multiple extents, and bounded image sequences in the final public scope. -- [ ] Preserve ICC, Exif, and XMP according to encoder options. +- [x] Preserve ICC, Exif, and XMP according to encoder options. - [~] Write CICP, range, chroma position, bit depth, and subsampling values that match the encoded planes. - [ ] Apply orientation and clean-aperture behavior consistently with ImageSharp encoder conventions. - [~] Stream output through allocator-backed chunked storage without file-sized copies or ToArray materialization. diff --git a/src/ImageSharp/Formats/Heif/HeifEncoderCore.cs b/src/ImageSharp/Formats/Heif/HeifEncoderCore.cs index 7577d258b8..1f21a76fa8 100644 --- a/src/ImageSharp/Formats/Heif/HeifEncoderCore.cs +++ b/src/ImageSharp/Formats/Heif/HeifEncoderCore.cs @@ -10,6 +10,7 @@ using SixLabors.ImageSharp.Formats.Jpeg; using SixLabors.ImageSharp.IO; using SixLabors.ImageSharp.Memory; using SixLabors.ImageSharp.Metadata.Profiles.Cicp; +using SixLabors.ImageSharp.Metadata.Profiles.Icc; using SixLabors.ImageSharp.PixelFormats; namespace SixLabors.ImageSharp.Formats.Heif; @@ -259,7 +260,21 @@ internal sealed class HeifEncoderCore /// The complete item-information-box length. private static int WriteItemInfoBox(AutoExpandingMemory memory, int memoryOffset, List items) { - Span buffer = memory.GetSpan(memoryOffset, 14 + (items.Count * 21)); + int capacity = 14; + foreach (HeifItem item in items) + { + capacity += 21 + Encoding.UTF8.GetByteCount(item.Name ?? string.Empty); + if (item.Type == Heif4CharCode.Mime) + { + capacity += 1 + Encoding.UTF8.GetByteCount(item.ContentType ?? string.Empty); + if (item.ContentEncoding is not null) + { + capacity += 1 + Encoding.UTF8.GetByteCount(item.ContentEncoding); + } + } + } + + Span buffer = memory.GetSpan(memoryOffset, capacity); int bytesWritten = WriteBoxHeader(buffer, Heif4CharCode.Iinf, 0, 0); BinaryPrimitives.WriteUInt16BigEndian(buffer[bytesWritten..], (ushort)items.Count); bytesWritten += 2; @@ -273,7 +288,18 @@ internal sealed class HeifEncoderCore bytesWritten += 2; BinaryPrimitives.WriteUInt32BigEndian(buffer[bytesWritten..], (uint)item.Type); bytesWritten += 4; + bytesWritten += Encoding.UTF8.GetBytes(item.Name ?? string.Empty, buffer[bytesWritten..]); buffer[bytesWritten++] = 0; + if (item.Type == Heif4CharCode.Mime) + { + bytesWritten += Encoding.UTF8.GetBytes(item.ContentType ?? string.Empty, buffer[bytesWritten..]); + buffer[bytesWritten++] = 0; + if (item.ContentEncoding is not null) + { + bytesWritten += Encoding.UTF8.GetBytes(item.ContentEncoding, buffer[bytesWritten..]); + buffer[bytesWritten++] = 0; + } + } BinaryPrimitives.WriteUInt32BigEndian(buffer[itemLengthOffset..], (uint)(bytesWritten - itemLengthOffset)); } @@ -332,7 +358,10 @@ internal sealed class HeifEncoderCore bytesWritten += WriteBoxHeader(buffer[bytesWritten..], Heif4CharCode.Ipco); foreach (HeifItem item in items) { - bytesWritten += WriteSpatialExtentPropertyBox(memory, memoryOffset + bytesWritten, item); + if (item.Extent != default) + { + bytesWritten += WriteSpatialExtentPropertyBox(memory, memoryOffset + bytesWritten, item); + } byte[]? channelBitDepths = item.ChannelBitDepths; if (channelBitDepths is not null) @@ -364,6 +393,12 @@ internal sealed class HeifEncoderCore bytesWritten += WriteAuxiliaryTypePropertyBox(memory, memoryOffset + bytesWritten, auxiliaryType); } + IccProfile? iccProfile = item.IccProfile; + if (iccProfile is not null) + { + bytesWritten += WriteIccColorInformationPropertyBox(memory, memoryOffset + bytesWritten, iccProfile); + } + CicpProfile? cicpProfile = item.CicpProfile; if (cicpProfile is not null) { @@ -374,11 +409,18 @@ internal sealed class HeifEncoderCore buffer = memory.GetSpan(memoryOffset, bytesWritten); BinaryPrimitives.WriteUInt32BigEndian(buffer[ipcoLengthOffset..], (uint)(bytesWritten - ipcoLengthOffset)); int propertyCount = 0; + int associationItemCount = 0; int associationBoxCapacity = 16; foreach (HeifItem item in items) { int itemPropertyCount = GetPropertyCount(item); + if (itemPropertyCount == 0) + { + continue; + } + propertyCount += itemPropertyCount; + associationItemCount++; associationBoxCapacity += 3 + itemPropertyCount; } @@ -393,17 +435,25 @@ internal sealed class HeifEncoderCore // ipma uses a 15-bit index only when the property table cannot fit in the compact seven-bit form. int ipmaLengthOffset = bytesWritten; bytesWritten += WriteBoxHeader(buffer[bytesWritten..], Heif4CharCode.Ipma, 0, largePropertyIndex ? 1U : 0U); - BinaryPrimitives.WriteUInt32BigEndian(buffer[bytesWritten..], (uint)items.Count); + BinaryPrimitives.WriteUInt32BigEndian(buffer[bytesWritten..], (uint)associationItemCount); bytesWritten += 4; ushort propertyIndex = 1; foreach (HeifItem item in items) { + int itemPropertyCount = GetPropertyCount(item); + if (itemPropertyCount == 0) + { + continue; + } + BinaryPrimitives.WriteUInt16BigEndian(buffer[bytesWritten..], (ushort)item.Id); bytesWritten += 2; - int itemPropertyCount = GetPropertyCount(item); buffer[bytesWritten++] = (byte)itemPropertyCount; - WritePropertyAssociation(buffer, ref bytesWritten, propertyIndex++, largePropertyIndex, false); + if (item.Extent != default) + { + WritePropertyAssociation(buffer, ref bytesWritten, propertyIndex++, largePropertyIndex, false); + } if (item.ChannelBitDepths is not null || item.UniformChannelBitDepth is not null) { @@ -420,6 +470,11 @@ internal sealed class HeifEncoderCore WritePropertyAssociation(buffer, ref bytesWritten, propertyIndex++, largePropertyIndex, false); } + if (item.IccProfile is not null) + { + WritePropertyAssociation(buffer, ref bytesWritten, propertyIndex++, largePropertyIndex, false); + } + if (item.CicpProfile is not null) { WritePropertyAssociation(buffer, ref bytesWritten, propertyIndex++, largePropertyIndex, false); @@ -440,10 +495,11 @@ internal sealed class HeifEncoderCore /// The number of emitted properties. private static int GetPropertyCount(HeifItem item) { - int count = 1; + int count = item.Extent != default ? 1 : 0; count += item.ChannelBitDepths is not null || item.UniformChannelBitDepth is not null ? 1 : 0; count += item.Av1CodecConfiguration is not null ? 1 : 0; count += item.AuxiliaryType is not null ? 1 : 0; + count += item.IccProfile is not null ? 1 : 0; count += item.CicpProfile is not null ? 1 : 0; return count; } @@ -564,6 +620,30 @@ internal sealed class HeifEncoderCore return bytesWritten; } + /// + /// Writes an unrestricted ICC color profile for a color image item. + /// + /// The expanding metadata buffer. + /// The destination offset within the property container. + /// The ICC profile to write. + /// The complete color-information-box length. + private static int WriteIccColorInformationPropertyBox( + AutoExpandingMemory memory, + int memoryOffset, + IccProfile profile) + { + ReadOnlyMemory profileData = profile.GetDataForWriting(); + Span buffer = memory.GetSpan(memoryOffset, 12 + profileData.Length); + int bytesWritten = WriteBoxHeader(buffer, Heif4CharCode.Colr); + BinaryPrimitives.WriteUInt32BigEndian(buffer[bytesWritten..], (uint)Heif4CharCode.Prof); + bytesWritten += 4; + profileData.Span.CopyTo(buffer[bytesWritten..]); + bytesWritten += profileData.Length; + + BinaryPrimitives.WriteUInt32BigEndian(buffer, (uint)bytesWritten); + return bytesWritten; + } + /// /// Writes an H.273 color description for a color image item. /// @@ -809,6 +889,7 @@ internal sealed class HeifEncoderCore UniformChannelBitDepth = channelBitDepth, BitsPerPixel = channelBitDepth * (isMonochrome ? 1 : 3), Av1CodecConfiguration = new Av1CodecConfiguration(colorHeader), + IccProfile = this.encoder.SkipMetadata ? null : image.Metadata.IccProfile, CicpProfile = colorProfile }; @@ -817,48 +898,130 @@ internal sealed class HeifEncoderCore items.Add(colorItem); bool hasAlpha = TPixel.GetPixelTypeInfo().AlphaRepresentation != PixelAlphaRepresentation.None; - if (!hasAlpha) + if (hasAlpha) + { + ObuColorConfig alphaConfig = new() + { + IsMonochrome = true, + ColorRange = true, + SubSamplingX = true, + SubSamplingY = true, + BitDepth = av1BitDepth + }; + + int alphaQuality = this.encoder.AlphaQuality ?? quality; + int alphaQIndex = GetAv1QuantizerIndex(alphaQuality); + cancellationToken.ThrowIfCancellationRequested(); + long alphaOffset = stream.Length; + ObuSequenceHeader alphaHeader = Av1FrameEncoder.EncodeAlpha( + this.configuration, + image.Frames.RootFrame, + stream, + alphaConfig, + alphaQIndex, + this.encoder.Effort); + + long alphaLength = stream.Length - alphaOffset; + HeifItem alphaItem = new(Heif4CharCode.Av01, 2) + { + ChannelCount = 1, + UniformChannelBitDepth = channelBitDepth, + BitsPerPixel = channelBitDepth, + Av1CodecConfiguration = new Av1CodecConfiguration(alphaHeader), + AuxiliaryType = HeifConstants.AlphaAuxiliaryType + }; + + alphaItem.DataLocations.Add(new HeifLocation(HeifLocationOffsetOrigin.FileOffset, 0L, alphaOffset, alphaLength)); + alphaItem.SetExtent(image.Size); + items.Add(alphaItem); + HeifItemLink alphaLink = new(Heif4CharCode.Auxl, alphaItem.Id); + alphaLink.DestinationIds.Add(colorItem.Id); + links.Add(alphaLink); + } + + if (this.encoder.SkipMetadata) { return; } - ObuColorConfig alphaConfig = new() + byte[]? exifData = image.Metadata.ExifProfile?.ToByteArray(); + if (exifData is not null && exifData.Length > 0) { - IsMonochrome = true, - ColorRange = true, - SubSamplingX = true, - SubSamplingY = true, - BitDepth = av1BitDepth - }; + int tiffHeaderOffset = -1; - int alphaQuality = this.encoder.AlphaQuality ?? quality; - int alphaQIndex = GetAv1QuantizerIndex(alphaQuality); - cancellationToken.ThrowIfCancellationRequested(); - long alphaOffset = stream.Length; - ObuSequenceHeader alphaHeader = Av1FrameEncoder.EncodeAlpha( - this.configuration, - image.Frames.RootFrame, - stream, - alphaConfig, - alphaQIndex, - this.encoder.Effort); + // The HEIF Exif prefix identifies the first TIFF byte-order marker, which can follow an optional Exif + // identifier in profiles supplied directly by callers. + for (int i = 0; i <= exifData.Length - 4; i++) + { + bool isBigEndianTiff = exifData[i] == (byte)'M' + && exifData[i + 1] == (byte)'M' + && exifData[i + 2] == 0 + && exifData[i + 3] == 42; - long alphaLength = stream.Length - alphaOffset; - HeifItem alphaItem = new(Heif4CharCode.Av01, 2) - { - ChannelCount = 1, - UniformChannelBitDepth = channelBitDepth, - BitsPerPixel = channelBitDepth, - Av1CodecConfiguration = new Av1CodecConfiguration(alphaHeader), - AuxiliaryType = HeifConstants.AlphaAuxiliaryType - }; + bool isLittleEndianTiff = exifData[i] == (byte)'I' + && exifData[i + 1] == (byte)'I' + && exifData[i + 2] == 42 + && exifData[i + 3] == 0; - alphaItem.DataLocations.Add(new HeifLocation(HeifLocationOffsetOrigin.FileOffset, 0L, alphaOffset, alphaLength)); - alphaItem.SetExtent(image.Size); - items.Add(alphaItem); - HeifItemLink alphaLink = new(Heif4CharCode.Auxl, alphaItem.Id); - alphaLink.DestinationIds.Add(colorItem.Id); - links.Add(alphaLink); + if (isBigEndianTiff || isLittleEndianTiff) + { + tiffHeaderOffset = i; + break; + } + } + + if (tiffHeaderOffset < 0) + { + throw new ImageFormatException("The Exif profile does not contain a TIFF header."); + } + + long exifOffset = stream.Length; + Span offsetBuffer = stackalloc byte[4]; + BinaryPrimitives.WriteUInt32BigEndian(offsetBuffer, (uint)tiffHeaderOffset); + stream.Write(offsetBuffer); + stream.Write(exifData); + + HeifItem exifItem = new(Heif4CharCode.Exif, (uint)items.Count + 1) + { + Name = "Exif" + }; + + exifItem.DataLocations.Add( + new HeifLocation( + HeifLocationOffsetOrigin.FileOffset, + 0L, + exifOffset, + 4L + exifData.Length)); + + items.Add(exifItem); + HeifItemLink exifLink = new(Heif4CharCode.Cdsc, exifItem.Id); + exifLink.DestinationIds.Add(colorItem.Id); + links.Add(exifLink); + } + + byte[]? xmpData = image.Metadata.XmpProfile?.Data; + if (xmpData is not null && xmpData.Length > 0) + { + long xmpOffset = stream.Length; + stream.Write(xmpData); + HeifItem xmpItem = new(Heif4CharCode.Mime, (uint)items.Count + 1) + { + Name = "XMP", + ContentType = "application/rdf+xml" + }; + + xmpItem.DataLocations.Add( + new HeifLocation( + HeifLocationOffsetOrigin.FileOffset, + 0L, + xmpOffset, + xmpData.Length)); + + items.Add(xmpItem); + HeifItemLink xmpLink = new(Heif4CharCode.Cdsc, xmpItem.Id); + xmpLink.DestinationIds.Add(colorItem.Id); + links.Add(xmpLink); + } } /// @@ -898,7 +1061,8 @@ internal sealed class HeifEncoderCore // The HEIF quality scale includes zero while the JPEG payload encoder starts at one. // Map the lowest HEIF setting to the lowest representable JPEG setting. Quality = this.encoder.Quality == 0 ? 1 : this.encoder.Quality, - ColorType = colorType + ColorType = colorType, + SkipMetadata = this.encoder.SkipMetadata }; // ImageEncoder is a synchronous contract. Wait for the cancellable JPEG operation so HEIF encoding diff --git a/src/ImageSharp/Formats/Jpeg/JpegEncoderCore.cs b/src/ImageSharp/Formats/Jpeg/JpegEncoderCore.cs index c9b684009e..47c8d45a63 100644 --- a/src/ImageSharp/Formats/Jpeg/JpegEncoderCore.cs +++ b/src/ImageSharp/Formats/Jpeg/JpegEncoderCore.cs @@ -90,11 +90,13 @@ internal sealed unsafe partial class JpegEncoderCore this.WriteApp14Marker(frameConfig.AdobeColorTransformMarkerFlag.Value, buffer); } - // Write Exif, XMP, ICC and IPTC profiles - this.WriteProfiles(metadata, buffer); - - // Write comments - this.WriteComments(image.Configuration, jpegMetadata); + if (!this.encoder.SkipMetadata) + { + // Profiles and comments are optional application metadata and must not leak into a containing format + // when the containing encoder has requested a metadata-free embedded JPEG payload. + this.WriteProfiles(metadata, buffer); + this.WriteComments(image.Configuration, jpegMetadata); + } // Write the image dimensions. this.WriteStartOfFrame(image.Width, image.Height, frameConfig, buffer); diff --git a/src/ImageSharp/Metadata/Profiles/ICC/IccProfile.cs b/src/ImageSharp/Metadata/Profiles/ICC/IccProfile.cs index eaba0a045c..d337b77e14 100644 --- a/src/ImageSharp/Metadata/Profiles/ICC/IccProfile.cs +++ b/src/ImageSharp/Metadata/Profiles/ICC/IccProfile.cs @@ -177,6 +177,22 @@ public sealed partial class IccProfile : IDeepCloneable return IccWriter.Write(this); } + /// + /// Gets the serialized profile storage for an encoder that consumes it before the operation returns. + /// + /// The existing raw profile storage, or a newly serialized profile when this instance was built from entries. + internal ReadOnlyMemory GetDataForWriting() + { + if (this.data is not null) + { + // Internal encoders only read this memory during the current operation, so the immutable view avoids + // cloning an already-owned profile before it is copied into format-specific output. + return this.data; + } + + return IccWriter.Write(this); + } + private void InitializeHeader() { if (this.header != null) diff --git a/tests/ImageSharp.Tests/Formats/Heif/HeifEncoderTests.cs b/tests/ImageSharp.Tests/Formats/Heif/HeifEncoderTests.cs index 5bbcdb31db..8773b36789 100644 --- a/tests/ImageSharp.Tests/Formats/Heif/HeifEncoderTests.cs +++ b/tests/ImageSharp.Tests/Formats/Heif/HeifEncoderTests.cs @@ -3,12 +3,19 @@ using System.Buffers.Binary; using System.Text; +using SixLabors.ImageSharp.ColorProfiles; +using SixLabors.ImageSharp.Formats; using SixLabors.ImageSharp.Formats.Heif; using SixLabors.ImageSharp.Formats.Heif.Av1; using SixLabors.ImageSharp.Formats.Heif.Av1.OpenBitstreamUnit; using SixLabors.ImageSharp.Memory; +using SixLabors.ImageSharp.Metadata; using SixLabors.ImageSharp.Metadata.Profiles.Cicp; +using SixLabors.ImageSharp.Metadata.Profiles.Exif; +using SixLabors.ImageSharp.Metadata.Profiles.Icc; +using SixLabors.ImageSharp.Metadata.Profiles.Xmp; using SixLabors.ImageSharp.PixelFormats; +using SixLabors.ImageSharp.Tests.TestDataIcc; using SixLabors.ImageSharp.Tests.TestUtilities.ImageComparison; using SixLabors.ImageSharp.Tests.TestUtilities.ReferenceCodecs; @@ -125,6 +132,30 @@ public class HeifEncoderTests Assert.Equal(image.Size, decoded.Size); } + [Theory] + [InlineData(false, true)] + [InlineData(true, false)] + public void LegacyJpegHonorsSkipMetadataForEmbeddedProfiles(bool skipMetadata, bool expectedIccProfile) + { + using Image image = new(8, 8); + image.Metadata.IccProfile = new IccProfile(IccTestDataProfiles.ProfileRandomArray); + using MemoryStream stream = new(); + HeifEncoder encoder = new() { SkipMetadata = skipMetadata }; + + image.Save(stream, encoder); + + DecoderOptions preserveOptions = new() { ColorProfileHandling = ColorProfileHandling.Preserve }; + stream.Position = 0; + using Image decoded = Image.Load(preserveOptions, stream); + Assert.Equal(expectedIccProfile, decoded.Metadata.IccProfile is not null); + if (expectedIccProfile) + { + Assert.Equal( + IccTestDataProfiles.ProfileRandomArray, + Assert.IsType(decoded.Metadata.IccProfile).ToByteArray()); + } + } + [Fact] public void LegacyJpegRejectsLosslessEncoding() { @@ -427,6 +458,140 @@ public class HeifEncoderTests Assert.False(decodedProfile.FullRange); } + [Fact] + public void Av1PreservesIccExifAndXmpMetadata() + { + using Image image = new(8, 8); + image.Metadata.IccProfile = new IccProfile(IccTestDataProfiles.ProfileRandomArray); + + ExifProfile generatedExif = new(); + generatedExif.SetValue(ExifTag.Software, "ImageSharp HEIF"); + byte[] exifData = generatedExif.ToByteArray(); + image.Metadata.ExifProfile = generatedExif; + + byte[] xmpData = Encoding.UTF8.GetBytes("ImageSharp HEIF"); + image.Metadata.XmpProfile = new XmpProfile(xmpData); + + using MemoryStream stream = new(); + HeifEncoder encoder = new() + { + CompressionMethod = HeifCompressionMethod.Av1, + Effort = 0 + }; + + image.Save(stream, encoder); + byte[] file = stream.ToArray(); + + Span itemInfo = GetMetadataChild(file, Heif4CharCode.Iinf); + Assert.Equal(3, BinaryPrimitives.ReadUInt16BigEndian(itemInfo[12..])); + int entryOffset = 14; + + int colorEntryLength = BinaryPrimitives.ReadInt32BigEndian(itemInfo[entryOffset..]); + Assert.Equal(1, BinaryPrimitives.ReadUInt16BigEndian(itemInfo[(entryOffset + 12)..])); + Assert.Equal(Heif4CharCode.Av01, (Heif4CharCode)BinaryPrimitives.ReadUInt32BigEndian(itemInfo[(entryOffset + 16)..])); + Assert.Equal([0], itemInfo.Slice(entryOffset + 20, colorEntryLength - 20).ToArray()); + entryOffset += colorEntryLength; + + int exifEntryLength = BinaryPrimitives.ReadInt32BigEndian(itemInfo[entryOffset..]); + Assert.Equal(2, BinaryPrimitives.ReadUInt16BigEndian(itemInfo[(entryOffset + 12)..])); + Assert.Equal(Heif4CharCode.Exif, (Heif4CharCode)BinaryPrimitives.ReadUInt32BigEndian(itemInfo[(entryOffset + 16)..])); + Assert.Equal("Exif\0", Encoding.UTF8.GetString(itemInfo.Slice(entryOffset + 20, exifEntryLength - 20))); + entryOffset += exifEntryLength; + + int xmpEntryLength = BinaryPrimitives.ReadInt32BigEndian(itemInfo[entryOffset..]); + Assert.Equal(3, BinaryPrimitives.ReadUInt16BigEndian(itemInfo[(entryOffset + 12)..])); + Assert.Equal(Heif4CharCode.Mime, (Heif4CharCode)BinaryPrimitives.ReadUInt32BigEndian(itemInfo[(entryOffset + 16)..])); + Assert.Equal( + "XMP\0application/rdf+xml\0", + Encoding.UTF8.GetString(itemInfo.Slice(entryOffset + 20, xmpEntryLength - 20))); + + entryOffset += xmpEntryLength; + Assert.Equal(itemInfo.Length, entryOffset); + + Span itemReferences = GetMetadataChild(file, Heif4CharCode.Iref); + int referenceOffset = 12; + for (ushort sourceId = 2; sourceId <= 3; sourceId++) + { + int referenceLength = BinaryPrimitives.ReadInt32BigEndian(itemReferences[referenceOffset..]); + Assert.Equal(14, referenceLength); + Assert.Equal( + Heif4CharCode.Cdsc, + (Heif4CharCode)BinaryPrimitives.ReadUInt32BigEndian(itemReferences[(referenceOffset + 4)..])); + + Assert.Equal(sourceId, BinaryPrimitives.ReadUInt16BigEndian(itemReferences[(referenceOffset + 8)..])); + Assert.Equal(1, BinaryPrimitives.ReadUInt16BigEndian(itemReferences[(referenceOffset + 10)..])); + Assert.Equal(1, BinaryPrimitives.ReadUInt16BigEndian(itemReferences[(referenceOffset + 12)..])); + referenceOffset += referenceLength; + } + + Assert.Equal(itemReferences.Length, referenceOffset); + + Span encodedExif = GetItemPayload(file, 2); + Assert.Equal(0U, BinaryPrimitives.ReadUInt32BigEndian(encodedExif)); + Assert.Equal(exifData, encodedExif[4..].ToArray()); + Assert.Equal(xmpData, GetItemPayload(file, 3).ToArray()); + + DecoderOptions preserveOptions = new() { ColorProfileHandling = ColorProfileHandling.Preserve }; + using Image decoded = Image.Load(preserveOptions, file); + Assert.Equal( + IccTestDataProfiles.ProfileRandomArray, + Assert.IsType(decoded.Metadata.IccProfile).ToByteArray()); + + ExifProfile decodedExif = Assert.IsType(decoded.Metadata.ExifProfile); + Assert.True(decodedExif.TryGetValue(ExifTag.Software, out IExifValue software)); + Assert.Equal("ImageSharp HEIF", software.Value); + Assert.Equal(xmpData, Assert.IsType(decoded.Metadata.XmpProfile).ToByteArray()); + } + + [Fact] + public void Av1SkipMetadataSuppressesIccExifAndXmp() + { + using Image image = new(8, 8); + image.Metadata.IccProfile = new IccProfile(IccTestDataProfiles.ProfileRandomArray); + image.Metadata.ExifProfile = new ExifProfile(); + image.Metadata.ExifProfile.SetValue(ExifTag.Software, "ImageSharp HEIF"); + image.Metadata.XmpProfile = new XmpProfile(Encoding.UTF8.GetBytes("ImageSharp HEIF")); + + using MemoryStream stream = new(); + HeifEncoder encoder = new() + { + CompressionMethod = HeifCompressionMethod.Av1, + Effort = 0, + SkipMetadata = true + }; + + image.Save(stream, encoder); + byte[] file = stream.ToArray(); + Span itemInfo = GetMetadataChild(file, Heif4CharCode.Iinf); + Assert.Equal(1, BinaryPrimitives.ReadUInt16BigEndian(itemInfo[12..])); + + Span itemProperties = GetMetadataChild(file, Heif4CharCode.Iprp); + const int IpcoOffset = 8; + int ipcoEnd = IpcoOffset + BinaryPrimitives.ReadInt32BigEndian(itemProperties[IpcoOffset..]); + int propertyOffset = IpcoOffset + 8; + while (propertyOffset < ipcoEnd) + { + int propertyLength = BinaryPrimitives.ReadInt32BigEndian(itemProperties[propertyOffset..]); + Heif4CharCode propertyType = (Heif4CharCode)BinaryPrimitives.ReadUInt32BigEndian(itemProperties[(propertyOffset + 4)..]); + if (propertyType == Heif4CharCode.Colr) + { + Assert.Equal( + Heif4CharCode.Nclx, + (Heif4CharCode)BinaryPrimitives.ReadUInt32BigEndian(itemProperties[(propertyOffset + 8)..])); + } + + propertyOffset += propertyLength; + } + + Assert.Equal(ipcoEnd, propertyOffset); + + DecoderOptions preserveOptions = new() { ColorProfileHandling = ColorProfileHandling.Preserve }; + using Image decoded = Image.Load(preserveOptions, file); + Assert.Null(decoded.Metadata.IccProfile); + Assert.Null(decoded.Metadata.ExifProfile); + Assert.Null(decoded.Metadata.XmpProfile); + } + [Fact] public void Av1WritesNonSeekableStream() { @@ -595,6 +760,57 @@ public class HeifEncoderTests ipmaPayload[8..].ToArray()); } + [Fact] + public void Av1ItemPropertiesWriteIccBeforeCicpAndExcludeMetadataItemsFromAssociations() + { + IccProfile iccProfile = new(IccTestDataProfiles.ProfileRandomArray); + HeifItem colorItem = new(Heif4CharCode.Av01, 1) + { + IccProfile = iccProfile, + CicpProfile = new CicpProfile(1, 13, 6, true) + }; + + colorItem.SetExtent(new Size(64, 48)); + List items = + [ + colorItem, + new HeifItem(Heif4CharCode.Exif, 2), + new HeifItem(Heif4CharCode.Mime, 3) + ]; + + using AutoExpandingMemory memory = new(Configuration.Default, 16); + int length = HeifEncoderCore.WriteItemPropertiesBox(memory, 0, items); + ReadOnlySpan propertyBox = memory.GetSpan(length); + const int IpcoOffset = 8; + int ipcoEnd = IpcoOffset + BinaryPrimitives.ReadInt32BigEndian(propertyBox[IpcoOffset..]); + int propertyOffset = IpcoOffset + 8; + + Assert.Equal(Heif4CharCode.Ispe, (Heif4CharCode)BinaryPrimitives.ReadUInt32BigEndian(propertyBox[(propertyOffset + 4)..])); + propertyOffset += BinaryPrimitives.ReadInt32BigEndian(propertyBox[propertyOffset..]); + + int iccPropertyLength = BinaryPrimitives.ReadInt32BigEndian(propertyBox[propertyOffset..]); + Assert.Equal(Heif4CharCode.Colr, (Heif4CharCode)BinaryPrimitives.ReadUInt32BigEndian(propertyBox[(propertyOffset + 4)..])); + Assert.Equal(Heif4CharCode.Prof, (Heif4CharCode)BinaryPrimitives.ReadUInt32BigEndian(propertyBox[(propertyOffset + 8)..])); + Assert.Equal( + IccTestDataProfiles.ProfileRandomArray, + propertyBox.Slice(propertyOffset + 12, iccPropertyLength - 12).ToArray()); + + propertyOffset += iccPropertyLength; + + int cicpPropertyLength = BinaryPrimitives.ReadInt32BigEndian(propertyBox[propertyOffset..]); + Assert.Equal(Heif4CharCode.Colr, (Heif4CharCode)BinaryPrimitives.ReadUInt32BigEndian(propertyBox[(propertyOffset + 4)..])); + Assert.Equal(Heif4CharCode.Nclx, (Heif4CharCode)BinaryPrimitives.ReadUInt32BigEndian(propertyBox[(propertyOffset + 8)..])); + propertyOffset += cicpPropertyLength; + Assert.Equal(ipcoEnd, propertyOffset); + + int ipmaOffset = ipcoEnd; + int ipmaLength = BinaryPrimitives.ReadInt32BigEndian(propertyBox[ipmaOffset..]); + ReadOnlySpan ipmaPayload = propertyBox.Slice(ipmaOffset + 8, ipmaLength - 8); + Assert.Equal(0, BinaryPrimitives.ReadInt32BigEndian(ipmaPayload)); + Assert.Equal(1, BinaryPrimitives.ReadInt32BigEndian(ipmaPayload[4..])); + Assert.Equal([0, 1, 3, 1, 2, 3], ipmaPayload[8..].ToArray()); + } + [Fact] public void ItemPropertiesUseLargeAssociationsWhenPropertyCountExceedsCompactRange() { @@ -732,4 +948,36 @@ public class HeifEncoderTests throw new InvalidImageContentException($"The encoded file has no payload for item {itemId}."); } + + private static Span GetMetadataChild(Span file, Heif4CharCode childType) + { + int offset = 0; + while (offset < file.Length) + { + int boxSize = BinaryPrimitives.ReadInt32BigEndian(file[offset..]); + Heif4CharCode boxType = (Heif4CharCode)BinaryPrimitives.ReadUInt32BigEndian(file[(offset + 4)..]); + if (boxType == Heif4CharCode.Meta) + { + int childOffset = offset + 12; + int boxEnd = offset + boxSize; + while (childOffset < boxEnd) + { + int childSize = BinaryPrimitives.ReadInt32BigEndian(file[childOffset..]); + Heif4CharCode currentChildType = + (Heif4CharCode)BinaryPrimitives.ReadUInt32BigEndian(file[(childOffset + 4)..]); + + if (currentChildType == childType) + { + return file.Slice(childOffset, childSize); + } + + childOffset += childSize; + } + } + + offset += boxSize; + } + + throw new InvalidImageContentException($"The encoded file has no {childType} metadata child."); + } }