From 494d2dd97b7b639495f46111a3cdf4ec91cbef4f Mon Sep 17 00:00:00 2001 From: James Jackson-South Date: Thu, 23 Jul 2026 20:17:31 +1000 Subject: [PATCH] Clone TIFF profiles into image metadata Update TIFF metadata creation to build ImageMetadata after IFD tag processing and copy root-frame EXIF/ICC/IPTC/XMP profiles to image scope via deep clones. This keeps image-level metadata in sync for downstream single-frame encoders while preserving independent mutability between frame and image metadata. Tests now cover preserved ICC handling and verify image-level profile presence, equality, and non-shared instances for EXIF/XMP/IPTC, including ignore-metadata behavior. --- .../Formats/Tiff/TiffDecoderMetadataCreator.cs | 18 ++++++++++++++---- .../Formats/Tiff/TiffDecoderTests.cs | 16 ++++++++++++++++ .../Formats/Tiff/TiffMetadataTests.cs | 13 +++++++++++++ 3 files changed, 43 insertions(+), 4 deletions(-) diff --git a/src/ImageSharp/Formats/Tiff/TiffDecoderMetadataCreator.cs b/src/ImageSharp/Formats/Tiff/TiffDecoderMetadataCreator.cs index ebf407f9b..f7cdd1bfc 100644 --- a/src/ImageSharp/Formats/Tiff/TiffDecoderMetadataCreator.cs +++ b/src/ImageSharp/Formats/Tiff/TiffDecoderMetadataCreator.cs @@ -22,8 +22,6 @@ internal static class TiffDecoderMetadataCreator TiffThrowHelper.ThrowImageFormatException("Expected at least one frame."); } - ImageMetadata imageMetaData = Create(byteOrder, isBigTiff, frames[0]); - if (!ignoreMetadata) { for (int i = 0; i < frames.Count; i++) @@ -43,12 +41,24 @@ internal static class TiffDecoderMetadataCreator } } - return imageMetaData; + // IPTC and XMP are materialized from IFD tags above, so image-level metadata + // must be created after all frame profiles are available. + return Create(byteOrder, isBigTiff, frames[0]); } private static ImageMetadata Create(ByteOrder byteOrder, bool isBigTiff, ImageFrameMetadata rootFrameMetadata) { - ImageMetadata imageMetaData = new(); + // TIFF stores metadata per IFD, while ImageMetadata is the source used by single-frame encoders. + // Mirror the root IFD profiles at image scope so cross-format encoding preserves them. Each profile + // is cloned because image-level and frame-level metadata are independently mutable public APIs. + ImageMetadata imageMetaData = new() + { + ExifProfile = rootFrameMetadata.ExifProfile?.DeepClone(), + IccProfile = rootFrameMetadata.IccProfile?.DeepClone(), + IptcProfile = rootFrameMetadata.IptcProfile?.DeepClone(), + XmpProfile = rootFrameMetadata.XmpProfile?.DeepClone() + }; + SetResolution(imageMetaData, rootFrameMetadata.ExifProfile); TiffMetadata tiffMetadata = imageMetaData.GetTiffMetadata(); diff --git a/tests/ImageSharp.Tests/Formats/Tiff/TiffDecoderTests.cs b/tests/ImageSharp.Tests/Formats/Tiff/TiffDecoderTests.cs index de893aca6..ec6113be3 100644 --- a/tests/ImageSharp.Tests/Formats/Tiff/TiffDecoderTests.cs +++ b/tests/ImageSharp.Tests/Formats/Tiff/TiffDecoderTests.cs @@ -370,6 +370,22 @@ public class TiffDecoderTests : TiffDecoderBaseTester Assert.Null(image.Metadata.IccProfile); } + [Theory] + [WithFile(Icc.PerceptualRgb8, PixelTypes.Rgba32)] + [WithFile(Icc.PerceptualRgb16, PixelTypes.Rgba32)] + public void Decode_WhenColorProfileHandlingIsPreserve_PreservesIccProfile(TestImageProvider provider) + where TPixel : unmanaged, IPixel + { + DecoderOptions options = new() { ColorProfileHandling = ColorProfileHandling.Preserve }; + using Image image = provider.GetImage(TiffDecoder.Instance, options); + + Assert.NotNull(image.Metadata.IccProfile); + Assert.NotSame(image.Frames.RootFrame.Metadata.IccProfile, image.Metadata.IccProfile); + Assert.Equal( + image.Frames.RootFrame.Metadata.IccProfile.ToByteArray(), + image.Metadata.IccProfile.ToByteArray()); + } + [Theory] [WithFile(Issues2454_A, PixelTypes.Rgba32)] [WithFile(Issues2454_B, PixelTypes.Rgba32)] diff --git a/tests/ImageSharp.Tests/Formats/Tiff/TiffMetadataTests.cs b/tests/ImageSharp.Tests/Formats/Tiff/TiffMetadataTests.cs index 94ed54326..9f0f0e991 100644 --- a/tests/ImageSharp.Tests/Formats/Tiff/TiffMetadataTests.cs +++ b/tests/ImageSharp.Tests/Formats/Tiff/TiffMetadataTests.cs @@ -150,13 +150,22 @@ public class TiffMetadataTests Assert.NotNull(meta); if (ignoreMetadata) { + Assert.Null(image.Metadata.XmpProfile); + Assert.Null(image.Metadata.ExifProfile); Assert.Null(rootFrameMetaData.XmpProfile); Assert.Null(rootFrameMetaData.ExifProfile); } else { + Assert.NotNull(image.Metadata.XmpProfile); + Assert.NotNull(image.Metadata.ExifProfile); Assert.NotNull(rootFrameMetaData.XmpProfile); Assert.NotNull(rootFrameMetaData.ExifProfile); + + Assert.NotSame(rootFrameMetaData.XmpProfile, image.Metadata.XmpProfile); + Assert.NotSame(rootFrameMetaData.ExifProfile, image.Metadata.ExifProfile); + Assert.Equal(rootFrameMetaData.XmpProfile.Data, image.Metadata.XmpProfile.Data); + Assert.Equal(rootFrameMetaData.ExifProfile.ToByteArray(), image.Metadata.ExifProfile.ToByteArray()); Assert.Equal(2596, rootFrameMetaData.XmpProfile.Data.Length); // padding bytes are trimmed Assert.Equal(25, rootFrameMetaData.ExifProfile.Values.Count); } @@ -171,6 +180,10 @@ public class TiffMetadataTests IptcProfile iptcProfile = image.Frames.RootFrame.Metadata.IptcProfile; Assert.NotNull(iptcProfile); + Assert.NotNull(image.Metadata.IptcProfile); + Assert.NotSame(iptcProfile, image.Metadata.IptcProfile); + Assert.Equal(iptcProfile.Data, image.Metadata.IptcProfile.Data); + IptcValue byline = iptcProfile.Values.FirstOrDefault(data => data.Tag == IptcTag.Byline); Assert.NotNull(byline); Assert.Equal("Studio Mantyniemi", byline.Value);