diff --git a/ImageSharp.sln b/ImageSharp.sln index 2967acb8f..82eeefcde 100644 --- a/ImageSharp.sln +++ b/ImageSharp.sln @@ -237,6 +237,7 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "issues", "issues", "{5C9B68 tests\Images\Input\Jpg\issues\issue750-exif-tranform.jpg = tests\Images\Input\Jpg\issues\issue750-exif-tranform.jpg tests\Images\Input\Jpg\issues\Issue845-Incorrect-Quality99.jpg = tests\Images\Input\Jpg\issues\Issue845-Incorrect-Quality99.jpg tests\Images\Input\Jpg\issues\issue855-incorrect-colorspace.jpg = tests\Images\Input\Jpg\issues\issue855-incorrect-colorspace.jpg + tests\Images\Input\Jpg\issues\issue-2067-comment.jpg = tests\Images\Input\Jpg\issues\issue-2067-comment.jpg EndProjectSection EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "fuzz", "fuzz", "{516A3532-6AC2-417B-AD79-9BD5D0D378A0}" diff --git a/src/ImageSharp/Formats/Jpeg/JpegDecoderCore.cs b/src/ImageSharp/Formats/Jpeg/JpegDecoderCore.cs index ccace190f..e11923ac8 100644 --- a/src/ImageSharp/Formats/Jpeg/JpegDecoderCore.cs +++ b/src/ImageSharp/Formats/Jpeg/JpegDecoderCore.cs @@ -6,6 +6,7 @@ using System.Buffers; using System.Buffers.Binary; using System.Runtime.CompilerServices; using System.Runtime.InteropServices; +using System.Text; using SixLabors.ImageSharp.Common.Helpers; using SixLabors.ImageSharp.Formats.Jpeg.Components; using SixLabors.ImageSharp.Formats.Jpeg.Components.Decoder; @@ -481,7 +482,7 @@ internal sealed class JpegDecoderCore : IRawJpegData, IImageDecoderInternals case JpegConstants.Markers.APP15: case JpegConstants.Markers.COM: - stream.Skip(markerContentByteSize); + this.ProcessComMarker(stream, markerContentByteSize); break; case JpegConstants.Markers.DAC: @@ -515,6 +516,23 @@ internal sealed class JpegDecoderCore : IRawJpegData, IImageDecoderInternals this.scanDecoder = null; } + /// + /// Assigns COM marker bytes to comment property + /// + /// The input stream. + /// The remaining bytes in the segment block. + private void ProcessComMarker(BufferedReadStream stream, int markerContentByteSize) + { + Span temp = stackalloc byte[markerContentByteSize]; + char[] chars = new char[markerContentByteSize]; + JpegMetadata metadata = this.Metadata.GetFormatMetadata(JpegFormat.Instance); + + stream.Read(temp); + Encoding.ASCII.GetChars(temp, chars); + + metadata.Comments.Add(chars); + } + /// /// Returns encoded colorspace based on the adobe APP14 marker. /// diff --git a/src/ImageSharp/Formats/Jpeg/JpegEncoderCore.cs b/src/ImageSharp/Formats/Jpeg/JpegEncoderCore.cs index 7fc2a1f45..cc6042b6e 100644 --- a/src/ImageSharp/Formats/Jpeg/JpegEncoderCore.cs +++ b/src/ImageSharp/Formats/Jpeg/JpegEncoderCore.cs @@ -3,6 +3,7 @@ #nullable disable using System.Buffers.Binary; +using System.Text; using SixLabors.ImageSharp.Common.Helpers; using SixLabors.ImageSharp.Formats.Jpeg.Components; using SixLabors.ImageSharp.Formats.Jpeg.Components.Encoder; @@ -89,6 +90,9 @@ internal sealed unsafe partial class JpegEncoderCore : IImageEncoderInternals // Write Exif, XMP, ICC and IPTC profiles this.WriteProfiles(metadata, buffer); + // Write comments + this.WriteComment(jpegMetadata); + // Write the image dimensions. this.WriteStartOfFrame(image.Width, image.Height, frameConfig, buffer); @@ -167,6 +171,47 @@ internal sealed unsafe partial class JpegEncoderCore : IImageEncoderInternals this.outputStream.Write(buffer, 0, 18); } + /// + /// Writes comment + /// + /// The image metadata. + private void WriteComment(JpegMetadata metadata) + { + if (metadata.Comments is { Count: 0 }) + { + return; + } + + // Length (comment strings lengths) + (comments markers with payload sizes) + int commentsBytes = metadata.Comments.Sum(x => x.Length) + (metadata.Comments.Count * 4); + int commentStart = 0; + Span commentBuffer = stackalloc byte[commentsBytes]; + + foreach (Memory comment in metadata.Comments) + { + int totalComLength = comment.Length + 4; + + Span commentData = commentBuffer.Slice(commentStart, totalComLength); + Span markers = commentData.Slice(0, 2); + Span payloadSize = commentData.Slice(2, 2); + Span payload = commentData.Slice(4, comment.Length); + + // Beginning of comment ff fe + markers[0] = JpegConstants.Markers.XFF; + markers[1] = JpegConstants.Markers.COM; + + // Write payload size + BinaryPrimitives.WriteInt16BigEndian(payloadSize, (short)(commentData.Length - 2)); + + Encoding.ASCII.GetBytes(comment.Span, payload); + + // Indicate begin of next comment in buffer + commentStart += totalComLength; + } + + this.outputStream.Write(commentBuffer, 0, commentBuffer.Length); + } + /// /// Writes the Define Huffman Table marker and tables. /// diff --git a/src/ImageSharp/Formats/Jpeg/JpegMetadata.cs b/src/ImageSharp/Formats/Jpeg/JpegMetadata.cs index 59fc2f9cb..61fe3b214 100644 --- a/src/ImageSharp/Formats/Jpeg/JpegMetadata.cs +++ b/src/ImageSharp/Formats/Jpeg/JpegMetadata.cs @@ -15,6 +15,7 @@ public class JpegMetadata : IDeepCloneable /// public JpegMetadata() { + this.Comments = new List>(); } /// @@ -25,6 +26,7 @@ public class JpegMetadata : IDeepCloneable { this.ColorType = other.ColorType; + this.Comments = other.Comments; this.LuminanceQuality = other.LuminanceQuality; this.ChrominanceQuality = other.ChrominanceQuality; } @@ -101,6 +103,11 @@ public class JpegMetadata : IDeepCloneable /// public bool? Progressive { get; internal set; } + /// + /// Gets the comments. + /// + public ICollection>? Comments { get; } + /// public IDeepCloneable DeepClone() => new JpegMetadata(this); } diff --git a/src/ImageSharp/Formats/Jpeg/MetadataExtensions.cs b/src/ImageSharp/Formats/Jpeg/MetadataExtensions.cs index 753dfdb60..53efc7d0c 100644 --- a/src/ImageSharp/Formats/Jpeg/MetadataExtensions.cs +++ b/src/ImageSharp/Formats/Jpeg/MetadataExtensions.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.Metadata; @@ -17,4 +18,24 @@ public static partial class MetadataExtensions /// The metadata this method extends. /// The . public static JpegMetadata GetJpegMetadata(this ImageMetadata metadata) => metadata.GetFormatMetadata(JpegFormat.Instance); + + /// + /// Saves the comment into + /// + /// The metadata this method extends. + /// The comment string. + public static void SaveComment(this JpegMetadata metadata, string comment) + { + ASCIIEncoding encoding = new(); + + byte[] bytes = encoding.GetBytes(comment); + metadata.Comments?.Add(encoding.GetChars(bytes)); + } + + /// + /// Gets the comments from + /// + /// The metadata this method extends. + /// The IEnumerable string of comments. + public static IEnumerable? GetComments(this JpegMetadata metadata) => metadata.Comments?.Select(x => x.ToString()); } diff --git a/tests/ImageSharp.Tests/Formats/Jpg/JpegDecoderTests.cs b/tests/ImageSharp.Tests/Formats/Jpg/JpegDecoderTests.cs index c8d93f6e9..b219e715f 100644 --- a/tests/ImageSharp.Tests/Formats/Jpg/JpegDecoderTests.cs +++ b/tests/ImageSharp.Tests/Formats/Jpg/JpegDecoderTests.cs @@ -364,4 +364,19 @@ public partial class JpegDecoderTests image.DebugSave(provider); image.CompareToOriginal(provider); } + + [Theory] + [WithFile(TestImages.Jpeg.Issues.Issue2067_CommentMarker, PixelTypes.Rgba32)] + public void JpegDecoder_DecodeMetadataComment(TestImageProvider provider) + where TPixel : unmanaged, IPixel + { + string expectedComment = "TEST COMMENT"; + using Image image = provider.GetImage(JpegDecoder.Instance); + JpegMetadata metadata = image.Metadata.GetJpegMetadata(); + + Assert.Equal(1, metadata.Comments?.Count); + Assert.Equal(expectedComment, metadata.GetComments()?.FirstOrDefault()); + image.DebugSave(provider); + image.CompareToOriginal(provider); + } } diff --git a/tests/ImageSharp.Tests/Formats/Jpg/JpegEncoderTests.Metadata.cs b/tests/ImageSharp.Tests/Formats/Jpg/JpegEncoderTests.Metadata.cs index 2b721b9b5..50f47a134 100644 --- a/tests/ImageSharp.Tests/Formats/Jpg/JpegEncoderTests.Metadata.cs +++ b/tests/ImageSharp.Tests/Formats/Jpg/JpegEncoderTests.Metadata.cs @@ -154,6 +154,50 @@ public partial class JpegEncoderTests } } + [Theory] + [WithFile(TestImages.Jpeg.Issues.Issue2067_CommentMarker, PixelTypes.Rgba32)] + public void Encode_PreservesComments(TestImageProvider provider) + where TPixel : unmanaged, IPixel + { + // arrange + using var input = provider.GetImage(JpegDecoder.Instance); + using var memStream = new MemoryStream(); + + // act + input.Save(memStream, JpegEncoder); + + // assert + memStream.Position = 0; + using var output = Image.Load(memStream); + JpegMetadata actual = output.Metadata.GetJpegMetadata(); + Assert.NotEmpty(actual.Comments); + Assert.Equal(1, actual.Comments.Count); + Assert.Equal("TEST COMMENT", actual.Comments.ElementAt(0).ToString()); + } + + [Fact] + public void Encode_SavesMultipleComments() + { + // arrange + using var input = new Image(1, 1); + JpegMetadata meta = input.Metadata.GetJpegMetadata(); + using var memStream = new MemoryStream(); + + // act + meta.SaveComment("First comment"); + meta.SaveComment("Second Comment"); + input.Save(memStream, JpegEncoder); + + // assert + memStream.Position = 0; + using var output = Image.Load(memStream); + JpegMetadata actual = output.Metadata.GetJpegMetadata(); + Assert.NotEmpty(actual.Comments); + Assert.Equal(2, actual.Comments.Count); + Assert.Equal(meta.Comments.ElementAt(0).ToString(), actual.Comments.ElementAt(0).ToString()); + Assert.Equal(meta.Comments.ElementAt(1).ToString(), actual.Comments.ElementAt(1).ToString()); + } + [Theory] [WithFile(TestImages.Jpeg.Baseline.Floorplan, PixelTypes.Rgb24, JpegEncodingColor.Luminance)] [WithFile(TestImages.Jpeg.Baseline.Jpeg444, PixelTypes.Rgb24, JpegEncodingColor.YCbCrRatio444)] diff --git a/tests/ImageSharp.Tests/Formats/Jpg/JpegMetadataTests.cs b/tests/ImageSharp.Tests/Formats/Jpg/JpegMetadataTests.cs index 05f22667d..901bb4619 100644 --- a/tests/ImageSharp.Tests/Formats/Jpg/JpegMetadataTests.cs +++ b/tests/ImageSharp.Tests/Formats/Jpg/JpegMetadataTests.cs @@ -1,6 +1,7 @@ // Copyright (c) Six Labors. // Licensed under the Six Labors Split License. +using System.Collections.ObjectModel; using SixLabors.ImageSharp.Formats.Jpeg; namespace SixLabors.ImageSharp.Tests.Formats.Jpg; @@ -55,6 +56,27 @@ public class JpegMetadataTests var meta = new JpegMetadata { LuminanceQuality = qualityLuma, ChrominanceQuality = qualityChroma }; - Assert.Equal(meta.Quality, qualityLuma); + Assert.Equal(meta.Quality, qualityLuma); + } + + [Fact] + public void Comment_EmptyComment() + { + var meta = new JpegMetadata(); + + Assert.True(Array.Empty>().SequenceEqual(meta.Comments)); + } + + [Fact] + public void Comment_OnlyComment() + { + string comment = "test comment"; + var expectedCollection = new Collection> { new(comment.ToCharArray()) }; + + var meta = new JpegMetadata(); + meta.Comments?.Add(comment.ToCharArray()); + + Assert.Equal(1, meta.Comments?.Count); + Assert.True(expectedCollection.FirstOrDefault().ToString() == meta.Comments?.FirstOrDefault().ToString()); } } diff --git a/tests/ImageSharp.Tests/TestImages.cs b/tests/ImageSharp.Tests/TestImages.cs index 8aa95d349..0dab4ff87 100644 --- a/tests/ImageSharp.Tests/TestImages.cs +++ b/tests/ImageSharp.Tests/TestImages.cs @@ -309,6 +309,7 @@ public static class TestImages public const string Issue2564 = "Jpg/issues/issue-2564.jpg"; public const string HangBadScan = "Jpg/issues/Hang_C438A851.jpg"; public const string Issue2517 = "Jpg/issues/issue2517-bad-d7.jpg"; + public const string Issue2067_CommentMarker = "Jpg/issues/issue-2067-comment.jpg"; public static class Fuzz { diff --git a/tests/Images/Input/Jpg/issues/issue-2067-comment.jpg b/tests/Images/Input/Jpg/issues/issue-2067-comment.jpg new file mode 100644 index 000000000..18dc6f2e3 --- /dev/null +++ b/tests/Images/Input/Jpg/issues/issue-2067-comment.jpg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d87b5429adeffcfac535aa8af2ec9801bf6c965a2e6751cfec4f8534195ba8f4 +size 21082