From c129686de777cdd273512add43b6e8780b3862a9 Mon Sep 17 00:00:00 2001 From: Jason Finch Date: Sun, 18 Oct 2026 09:21:50 +1000 Subject: [PATCH 1/2] Declare UTF-8 in IPTC data written from cloned profiles UpdateData only wrote the 1:90 Coded Character Set dataset when a value's encoding was the Encoding.UTF8 instance. Cloning an IPTC value clones its encoding, so a profile from a cloned image, or a value set with a caller's own UTF8Encoding, was written without the declaration and other readers fell back to Latin-1 for non-ASCII text. The encoding is now matched by code page. --- .../Metadata/Profiles/IPTC/IptcProfile.cs | 8 ++- .../Profiles/IPTC/IptcProfileTests.cs | 51 +++++++++++++++++++ 2 files changed, 58 insertions(+), 1 deletion(-) diff --git a/src/ImageSharp/Metadata/Profiles/IPTC/IptcProfile.cs b/src/ImageSharp/Metadata/Profiles/IPTC/IptcProfile.cs index 306924e62c..f123e62d5f 100644 --- a/src/ImageSharp/Metadata/Profiles/IPTC/IptcProfile.cs +++ b/src/ImageSharp/Metadata/Profiles/IPTC/IptcProfile.cs @@ -228,6 +228,10 @@ public void SetDateTimeValue(IptcTag tag, DateTimeOffset dateTimeOffset) /// /// Updates the data of the profile. /// + /// + /// When any value uses UTF-8 encoding, which is the default encoding of a value, the data starts with the + /// 1:90 Coded Character Set dataset declaring UTF-8, also when the text is ASCII only. + /// public void UpdateData() { int length = 0; @@ -337,7 +341,9 @@ private bool HasValuesInUtf8() { foreach (IptcValue value in this.values) { - if (value.Encoding == Encoding.UTF8) + // Compare the code page rather than the instance. Cloned values hold a copy of their encoding, + // and callers can pass their own UTF8Encoding, but both still write UTF-8 bytes. + 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..4ae475c653 100644 --- a/tests/ImageSharp.Tests/Metadata/Profiles/IPTC/IptcProfileTests.cs +++ b/tests/ImageSharp.Tests/Metadata/Profiles/IPTC/IptcProfileTests.cs @@ -34,6 +34,57 @@ public void IptcProfile_WithUtf8Data_WritesEnvelopeRecord_Works() Assert.True(profileBytes.AsSpan(0, 8).SequenceEqual(expectedEnvelopeData)); } + [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)); + } + + [Fact] + public void IptcProfile_WithUtf8EncodingInstance_WritesEnvelopeRecord_Works() + { + // arrange + IptcProfile profile = new(); + profile.SetValue(IptcTag.City, new System.Text.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)); + } + + [Fact] + public void WritingClonedImage_WithUtf8Data_WritesEnvelopeRecordOnce() + { + // arrange + using Image image = new(1, 1); + image.Metadata.IptcProfile = new IptcProfile(); + image.Metadata.IptcProfile.SetValue(IptcTag.Caption, "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 once = WriteAndReadJpeg(image.Clone()); + using Image twice = WriteAndReadJpeg(once.Clone()); + + // 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) From de6739fed59f5b4577a7292f988b8d59cc3822b6 Mon Sep 17 00:00:00 2001 From: James Jackson-South Date: Wed, 28 Oct 2026 09:23:42 +1000 Subject: [PATCH 2/2] Verify IPTC encoding declarations with stored inputs and explicit image ownership --- .../Metadata/Profiles/IPTC/IptcProfile.cs | 12 ++--- .../Profiles/IPTC/IptcProfileTests.cs | 48 ++++++++++++++++--- 2 files changed, 46 insertions(+), 14 deletions(-) diff --git a/src/ImageSharp/Metadata/Profiles/IPTC/IptcProfile.cs b/src/ImageSharp/Metadata/Profiles/IPTC/IptcProfile.cs index f123e62d5f..b21e4db7c8 100644 --- a/src/ImageSharp/Metadata/Profiles/IPTC/IptcProfile.cs +++ b/src/ImageSharp/Metadata/Profiles/IPTC/IptcProfile.cs @@ -227,11 +227,8 @@ 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. /// - /// - /// When any value uses UTF-8 encoding, which is the default encoding of a value, the data starts with the - /// 1:90 Coded Character Set dataset declaring UTF-8, also when the text is ASCII only. - /// public void UpdateData() { int length = 0; @@ -334,15 +331,14 @@ 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) { - // Compare the code page rather than the instance. Cloned values hold a copy of their encoding, - // and callers can pass their own UTF8Encoding, but both still write UTF-8 bytes. + // 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 4ae475c653..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,9 @@ 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() { @@ -50,12 +54,15 @@ public void IptcProfile_DeepClone_WithUtf8Data_WritesEnvelopeRecord_Works() 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 System.Text.UTF8Encoding(false), "ESPAÑA"); + profile.SetValue(IptcTag.City, new UTF8Encoding(false), "ESPAÑA"); byte[] expectedEnvelopeData = [28, 1, 90, 0, 3, 27, 37, 71]; // act @@ -65,18 +72,43 @@ public void IptcProfile_WithUtf8EncodingInstance_WritesEnvelopeRecord_Works() 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 = new(1, 1); + 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 once = WriteAndReadJpeg(image.Clone()); - using Image twice = WriteAndReadJpeg(once.Clone()); + 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; @@ -278,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"; @@ -434,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);