From 494d2dd97b7b639495f46111a3cdf4ec91cbef4f Mon Sep 17 00:00:00 2001 From: James Jackson-South Date: Thu, 23 Jul 2026 20:17:31 +1000 Subject: [PATCH 1/2] 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); From e2b43407cc2ce5fe4bab23200f1e08aed1ff0113 Mon Sep 17 00:00:00 2001 From: James Jackson-South Date: Thu, 23 Jul 2026 20:37:58 +1000 Subject: [PATCH 2/2] Handle ulong values for EXIF Long writes Fixes EXIF serialization when `ExifLong8Array` downgrades to `ExifDataType.Long` but still stores values as `ulong`. `ExifWriter.WriteValue` now handles `ulong` inputs in the `Long` branch by writing 32-bit values, and a BigTIFF metadata test verifies mixed in-range values are written correctly as two little-endian `uint`s. --- .../Metadata/Profiles/Exif/ExifWriter.cs | 7 +++++++ .../Formats/Tiff/BigTiffMetadataTests.cs | 15 +++++++++++++++ 2 files changed, 22 insertions(+) diff --git a/src/ImageSharp/Metadata/Profiles/Exif/ExifWriter.cs b/src/ImageSharp/Metadata/Profiles/Exif/ExifWriter.cs index 732e3eab2..659df01a6 100644 --- a/src/ImageSharp/Metadata/Profiles/Exif/ExifWriter.cs +++ b/src/ImageSharp/Metadata/Profiles/Exif/ExifWriter.cs @@ -403,6 +403,13 @@ internal sealed class ExifWriter return WriteUInt32((uint)longNumber, destination, offset); } + // ExifLong8Array retains ulong storage but reports Long when every value fits + // in 32 bits, allowing BigTIFF offsets to be serialized by classic EXIF writers. + if (value is ulong long8Value) + { + return WriteUInt32((uint)long8Value, destination, offset); + } + return WriteUInt32((uint)value, destination, offset); case ExifDataType.Long8: return WriteUInt64((ulong)value, destination, offset); diff --git a/tests/ImageSharp.Tests/Formats/Tiff/BigTiffMetadataTests.cs b/tests/ImageSharp.Tests/Formats/Tiff/BigTiffMetadataTests.cs index d19f27807..6aebf7b9c 100644 --- a/tests/ImageSharp.Tests/Formats/Tiff/BigTiffMetadataTests.cs +++ b/tests/ImageSharp.Tests/Formats/Tiff/BigTiffMetadataTests.cs @@ -88,6 +88,21 @@ public class BigTiffMetadataTests Assert.Equal(ExifDataType.Long8, long8.DataType); } + [Fact] + public void ExifLong8Array_CanWriteValuesAsLong() + { + ExifLong8Array long8 = new(ExifTagValue.StripOffsets); + Assert.True(long8.TrySetValue(new long[] { 1, uint.MaxValue })); + Assert.Equal(ExifDataType.Long, long8.DataType); + + byte[] buffer = new byte[8]; + int written = ExifWriter.WriteValue(long8, buffer, 0); + + Assert.Equal(buffer.Length, written); + Assert.Equal(1U, BinaryPrimitives.ReadUInt32LittleEndian(buffer)); + Assert.Equal(uint.MaxValue, BinaryPrimitives.ReadUInt32LittleEndian(buffer.AsSpan(4))); + } + [Fact] public void ExifSignedLong8Array() {