From b335b8a5f87b78abe30130e9e32dde2c87ef20a1 Mon Sep 17 00:00:00 2001 From: James Jackson-South Date: Sat, 5 Sep 2026 09:46:25 +1000 Subject: [PATCH] Apply EXIF part selection at serialization boundary --- .../Formats/Webp/BitWriter/BitWriterBase.cs | 3 +- .../Metadata/Profiles/Exif/ExifProfile.cs | 11 ++- .../Formats/WebP/WebpEncoderTests.cs | 83 ++++++++++++++----- .../Profiles/Exif/ExifProfileTests.cs | 41 +++++++++ 4 files changed, 112 insertions(+), 26 deletions(-) diff --git a/src/ImageSharp/Formats/Webp/BitWriter/BitWriterBase.cs b/src/ImageSharp/Formats/Webp/BitWriter/BitWriterBase.cs index e367697166..cdf1470ddb 100644 --- a/src/ImageSharp/Formats/Webp/BitWriter/BitWriterBase.cs +++ b/src/ImageSharp/Formats/Webp/BitWriter/BitWriterBase.cs @@ -143,8 +143,7 @@ internal abstract class BitWriterBase { if (exifProfile != null) { - // Materialize lazy values so Parts is applied during serialization. - _ = exifProfile.Values; + // Serialization applies Parts even when the current profile has not been initialized. RiffHelper.WriteChunk(stream, (uint)WebpChunkType.Exif, exifProfile.ToByteArray()); } diff --git a/src/ImageSharp/Metadata/Profiles/Exif/ExifProfile.cs b/src/ImageSharp/Metadata/Profiles/Exif/ExifProfile.cs index aa2eb29e79..6cd16aa9c2 100644 --- a/src/ImageSharp/Metadata/Profiles/Exif/ExifProfile.cs +++ b/src/ImageSharp/Metadata/Profiles/Exif/ExifProfile.cs @@ -221,14 +221,21 @@ public sealed class ExifProfile : IDeepCloneable => this.SetValueInternal(tag, value); /// - /// Converts this instance to a byte array. + /// Converts the sections selected by to a byte array. /// /// The public byte[]? ToByteArray() { if (this.values is null) { - return this.data; + // The original bytes include every section. They can only be reused when no filtering + // is requested; otherwise lazy profiles must go through the same writer as initialized ones. + if (this.Parts == ExifParts.All) + { + return this.data; + } + + this.InitializeValues(); } if (this.values.Count == 0) diff --git a/tests/ImageSharp.Tests/Formats/WebP/WebpEncoderTests.cs b/tests/ImageSharp.Tests/Formats/WebP/WebpEncoderTests.cs index 3db4a0909e..73bb1457da 100644 --- a/tests/ImageSharp.Tests/Formats/WebP/WebpEncoderTests.cs +++ b/tests/ImageSharp.Tests/Formats/WebP/WebpEncoderTests.cs @@ -24,29 +24,68 @@ public class WebpEncoderTests { private static string TestImageLossyFullPath => Path.Combine(TestEnvironment.InputImagesDirectoryFullPath, Lossy.NoFilter06); - [Fact] - public void Encode_LazyExifProfile_AppliesSelectedParts() + /// + /// Selected EXIF parts are respected whether the lazy profile is installed before or after synchronization. + /// + /// Whether the stream installs the profile after metadata synchronization. + [Theory] + [InlineData(false)] + [InlineData(true)] + public void Encode_LazyExifProfile_AppliesSelectedParts(bool reentrant) { - using Image input = new(8, 8); - ExifProfile exif = new(); - exif.SetValue(ExifTag.Make, "ImageSharp"); - exif.SetValue(ExifTag.GPSLatitudeRef, "N"); - input.Metadata.ExifProfile = exif; - - using MemoryStream jpegStream = new(); - input.Save(jpegStream, new JpegEncoder()); - jpegStream.Position = 0; - - using Image source = Image.Load(jpegStream); - source.Metadata.ExifProfile!.Parts = ExifParts.IfdTags | ExifParts.ExifTags; - - using MemoryStream webpStream = new(); - source.Save(webpStream, new WebpEncoder()); - webpStream.Position = 0; - - using Image result = Image.Load(webpStream); - Assert.True(result.Metadata.ExifProfile!.TryGetValue(ExifTag.Make, out _)); - Assert.False(result.Metadata.ExifProfile.TryGetValue(ExifTag.GPSLatitudeRef, out _)); + ExifProfile source = new(); + source.SetValue(ExifTag.Make, "POC"); + source.SetValue(ExifTag.GPSLatitudeRef, "N"); + ExifProfile filteredLazy = new(source.ToByteArray()) + { + Parts = ExifParts.IfdTags | ExifParts.ExifTags + }; + + using Image image = new(1, 1); + using MemoryStream output = reentrant + ? new SwapOnCanSeekStream(() => image.Metadata.ExifProfile = filteredLazy) + : new MemoryStream(); + + if (!reentrant) + { + image.Metadata.ExifProfile = filteredLazy; + } + + image.SaveAsWebp(output); + output.Position = 0; + using Image decoded = Image.Load(output); + + Assert.NotNull(decoded.Metadata.ExifProfile); + Assert.True(decoded.Metadata.ExifProfile.TryGetValue(ExifTag.Make, out IExifValue make)); + Assert.Equal("POC", make.Value); + Assert.False(decoded.Metadata.ExifProfile.TryGetValue(ExifTag.GPSLatitudeRef, out _)); + } + + /// + /// Replaces metadata at the stream capability check, after encoder synchronization has completed. + /// + private sealed class SwapOnCanSeekStream : MemoryStream + { + private Action callback; + + /// + /// Initializes a stream that invokes the callback on its first capability check. + /// + /// The metadata replacement callback. + public SwapOnCanSeekStream(Action callback) => this.callback = callback; + + /// + public override bool CanSeek + { + get + { + Action action = this.callback; + this.callback = null; + action?.Invoke(); + + return base.CanSeek; + } + } } [Theory] diff --git a/tests/ImageSharp.Tests/Metadata/Profiles/Exif/ExifProfileTests.cs b/tests/ImageSharp.Tests/Metadata/Profiles/Exif/ExifProfileTests.cs index c098ace09a..2b47d09843 100644 --- a/tests/ImageSharp.Tests/Metadata/Profiles/Exif/ExifProfileTests.cs +++ b/tests/ImageSharp.Tests/Metadata/Profiles/Exif/ExifProfileTests.cs @@ -490,6 +490,47 @@ public class ExifProfileTests } } + /// + /// Lazy profile serialization filters selected sections without changing the all-parts passthrough. + /// + /// The sections to serialize. + /// Whether the IFD tag should remain. + /// Whether the GPS tag should remain. + [Theory] + [InlineData(ExifParts.All, true, true)] + [InlineData(ExifParts.IfdTags | ExifParts.ExifTags, true, false)] + [InlineData(ExifParts.GpsTags, false, true)] + [InlineData(ExifParts.None, false, false)] + public void ProfileToByteArray_AppliesPartsToLazyValues(ExifParts parts, bool keepMake, bool keepGps) + { + ExifProfile source = new(); + source.SetValue(ExifTag.Make, "POC"); + source.SetValue(ExifTag.GPSLatitudeRef, "N"); + byte[] originalData = source.ToByteArray(); + ExifProfile lazy = new(originalData) { Parts = parts }; + + byte[] filteredData = lazy.ToByteArray(); + ExifProfile result = new(filteredData); + + Assert.Equal(keepMake, result.TryGetValue(ExifTag.Make, out IExifValue make)); + Assert.Equal(keepGps, result.TryGetValue(ExifTag.GPSLatitudeRef, out IExifValue gps)); + + if (keepMake) + { + Assert.Equal("POC", make.Value); + } + + if (keepGps) + { + Assert.Equal("N", gps.Value); + } + + if (parts is ExifParts.All) + { + Assert.Same(originalData, filteredData); + } + } + private static ExifProfile CreateExifProfile() { ExifProfile profile = new();