diff --git a/src/ImageSharp/Metadata/Profiles/IPTC/IptcProfile.cs b/src/ImageSharp/Metadata/Profiles/IPTC/IptcProfile.cs index 306924e62c..b21e4db7c8 100644 --- a/src/ImageSharp/Metadata/Profiles/IPTC/IptcProfile.cs +++ b/src/ImageSharp/Metadata/Profiles/IPTC/IptcProfile.cs @@ -227,6 +227,7 @@ public void SetDateTimeValue(IptcTag tag, DateTimeOffset dateTimeOffset) /// /// Updates the data of the profile. + /// Adds the UTF-8 character set declaration when any value uses UTF-8. /// public void UpdateData() { @@ -330,14 +331,15 @@ private void Initialize() } /// - /// Gets if any value has UTF-8 encoding. + /// Determines whether any value uses UTF-8. /// - /// true if any value has UTF-8 encoding. + /// True if any value uses UTF-8. private bool HasValuesInUtf8() { foreach (IptcValue value in this.values) { - if (value.Encoding == Encoding.UTF8) + // Identify UTF-8 by code page. Cloning an encoding changes its identity, not its code page. + if (value.Encoding.CodePage == Encoding.UTF8.CodePage) { return true; } diff --git a/tests/ImageSharp.Tests/Metadata/Profiles/IPTC/IptcProfileTests.cs b/tests/ImageSharp.Tests/Metadata/Profiles/IPTC/IptcProfileTests.cs index 37f99517c7..9009308763 100644 --- a/tests/ImageSharp.Tests/Metadata/Profiles/IPTC/IptcProfileTests.cs +++ b/tests/ImageSharp.Tests/Metadata/Profiles/IPTC/IptcProfileTests.cs @@ -1,6 +1,7 @@ // Copyright (c) Six Labors. // Licensed under the Six Labors Split License. +using System.Text; using SixLabors.ImageSharp.Formats.Jpeg; using SixLabors.ImageSharp.Formats.Tiff; using SixLabors.ImageSharp.Metadata.Profiles.Iptc; @@ -34,6 +35,88 @@ public void IptcProfile_WithUtf8Data_WritesEnvelopeRecord_Works() Assert.True(profileBytes.AsSpan(0, 8).SequenceEqual(expectedEnvelopeData)); } + /// + /// Preserves the UTF-8 declaration when cloning creates a separate encoding instance. + /// + [Fact] + public void IptcProfile_DeepClone_WithUtf8Data_WritesEnvelopeRecord_Works() + { + // arrange + IptcProfile profile = new(); + profile.SetValue(IptcTag.City, "ESPAÑA"); + IptcProfile clone = profile.DeepClone(); + byte[] expectedEnvelopeData = [28, 1, 90, 0, 3, 27, 37, 71]; + + // act + clone.UpdateData(); + + // assert + Assert.True(clone.Data.AsSpan(0, 8).SequenceEqual(expectedEnvelopeData)); + } + + /// + /// Declares UTF-8 for a caller-supplied encoding instance. + /// + [Fact] + public void IptcProfile_WithUtf8EncodingInstance_WritesEnvelopeRecord_Works() + { + // arrange + IptcProfile profile = new(); + profile.SetValue(IptcTag.City, new UTF8Encoding(false), "ESPAÑA"); + byte[] expectedEnvelopeData = [28, 1, 90, 0, 3, 27, 37, 71]; + + // act + profile.UpdateData(); + + // assert + Assert.True(profile.Data.AsSpan(0, 8).SequenceEqual(expectedEnvelopeData)); + } + + /// + /// Keeps a cloned Latin-1 value in its original encoding without a UTF-8 declaration. + /// + [Fact] + public void IptcProfile_DeepClone_WithLatin1Data_DoesNotWriteUtf8EnvelopeRecord() + { + IptcProfile profile = new(); + profile.SetValue(IptcTag.City, Encoding.Latin1, "ESPAÑA"); + IptcProfile clone = profile.DeepClone(); + + clone.UpdateData(); + + // Only the 2:90 City dataset is present. Latin-1 stores Ñ as D1, without a UTF-8 envelope. + byte[] expectedData = [28, 2, 90, 0, 6, 69, 83, 80, 65, 209, 65]; + Assert.Equal(expectedData, clone.Data); + } + + /// + /// Writes one UTF-8 declaration after repeated JPEG saves and image clones. + /// + [Fact] + public void WritingClonedImage_WithUtf8Data_WritesEnvelopeRecordOnce() + { + // arrange + using Image image = Image.Load(TestFile.Create(TestImages.Png.TestPattern31x31).Bytes); + image.Metadata.IptcProfile = new IptcProfile(); + image.Metadata.IptcProfile.SetValue(IptcTag.Caption, "ESPAÑA"); + + // 1:90 declares UTF-8. The 2:120 Caption dataset contains the seven UTF-8 bytes for ESPAÑA. + byte[] expectedData = [28, 1, 90, 0, 3, 27, 37, 71, 28, 2, 120, 0, 7, 69, 83, 80, 65, 195, 145, 65]; + + // act + using Image firstClone = image.Clone(); + using Image once = WriteAndReadJpeg(firstClone); + + using Image secondClone = once.Clone(); + using Image twice = WriteAndReadJpeg(secondClone); + + // assert + IptcProfile actual = twice.Metadata.IptcProfile; + Assert.Equal(expectedData, actual.Data); + IptcValue value = Assert.Single(actual.Values); + Assert.Equal("ESPAÑA", value.Value); + } + [Theory] [MemberData(nameof(AllIptcTags))] public void IptcProfile_SetValue_WithStrictEnabled_Works(IptcTag tag) @@ -227,7 +310,7 @@ public void IptcValue_CloneIsDeep() public void WritingImage_PreservesIptcProfile() { // arrange - using Image image = new(1, 1); + using Image image = Image.Load(TestFile.Create(TestImages.Png.TestPattern31x31).Bytes); image.Metadata.IptcProfile = new IptcProfile(); const string expectedCaptionWriter = "unittest"; const string expectedCaption = "test"; @@ -383,12 +466,16 @@ private static void ContainsIptcValue(List values, IptcTag tag, strin Assert.True(values.Contains(new IptcValue(tag, System.Text.Encoding.UTF8.GetBytes(value), false)), $"expected iptc value '{value}' was not found for tag '{tag}'"); } + /// + /// Saves a JPEG and decodes it without disposing the caller's source image. + /// + /// The source image. The caller retains ownership. + /// The decoded image. The caller must dispose it. private static Image WriteAndReadJpeg(Image image) { using (MemoryStream memStream = new()) { image.SaveAsJpeg(memStream); - image.Dispose(); memStream.Position = 0; return Image.Load(memStream);