diff --git a/src/ImageSharp/Formats/Gif/GifConstants.cs b/src/ImageSharp/Formats/Gif/GifConstants.cs index 24fd8a9365..1179b67b1e 100644 --- a/src/ImageSharp/Formats/Gif/GifConstants.cs +++ b/src/ImageSharp/Formats/Gif/GifConstants.cs @@ -121,5 +121,16 @@ internal static class GifConstants (byte)'P', (byte)'E', (byte)'2', (byte)'.', (byte)'0' }; + + /// + /// Gets the ASCII encoded application identification bytes. + /// + internal static ReadOnlySpan XmpApplicationIdentificationBytes => new[] + { + (byte)'X', (byte)'M', (byte)'P', + (byte)' ', (byte)'D', (byte)'a', + (byte)'t', (byte)'a', + (byte)'X', (byte)'M', (byte)'P' + }; } } diff --git a/src/ImageSharp/Formats/Gif/GifDecoderCore.cs b/src/ImageSharp/Formats/Gif/GifDecoderCore.cs index 3e33a6e379..b6348803a4 100644 --- a/src/ImageSharp/Formats/Gif/GifDecoderCore.cs +++ b/src/ImageSharp/Formats/Gif/GifDecoderCore.cs @@ -11,6 +11,7 @@ using SixLabors.ImageSharp.IO; using SixLabors.ImageSharp.Memory; using SixLabors.ImageSharp.Metadata; +using SixLabors.ImageSharp.Metadata.Profiles.Xmp; using SixLabors.ImageSharp.PixelFormats; namespace SixLabors.ImageSharp.Formats.Gif @@ -250,7 +251,7 @@ private void ReadLogicalScreenDescriptor() } /// - /// Reads the application extension block parsing any animation information + /// Reads the application extension block parsing any animation or XMP information /// if present. /// private void ReadApplicationExtension() @@ -258,25 +259,37 @@ private void ReadApplicationExtension() int appLength = this.stream.ReadByte(); // If the length is 11 then it's a valid extension and most likely - // a NETSCAPE or ANIMEXTS extension. We want the loop count from this. + // a NETSCAPE, XMP or ANIMEXTS extension. We want the loop count from this. if (appLength == GifConstants.ApplicationBlockSize) { - this.stream.Skip(appLength); - int subBlockSize = this.stream.ReadByte(); + this.stream.Read(this.buffer, 0, GifConstants.ApplicationBlockSize); + bool isXmp = this.buffer.AsSpan().StartsWith(GifConstants.XmpApplicationIdentificationBytes); - // TODO: There's also a NETSCAPE buffer extension. - // http://www.vurdalakov.net/misc/gif/netscape-buffering-application-extension - if (subBlockSize == GifConstants.NetscapeLoopingSubBlockSize) + if (isXmp) { - this.stream.Read(this.buffer, 0, GifConstants.NetscapeLoopingSubBlockSize); - this.gifMetadata.RepeatCount = GifNetscapeLoopingApplicationExtension.Parse(this.buffer.AsSpan(1)).RepeatCount; - this.stream.Skip(1); // Skip the terminator. + var extension = GifXmpApplicationExtension.Read(this.stream); + this.metadata.XmpProfile = new XmpProfile(extension.Data); return; } + else + { + int subBlockSize = this.stream.ReadByte(); + + // TODO: There's also a NETSCAPE buffer extension. + // http://www.vurdalakov.net/misc/gif/netscape-buffering-application-extension + if (subBlockSize == GifConstants.NetscapeLoopingSubBlockSize) + { + this.stream.Read(this.buffer, 0, GifConstants.NetscapeLoopingSubBlockSize); + this.gifMetadata.RepeatCount = GifNetscapeLoopingApplicationExtension.Parse(this.buffer.AsSpan(1)).RepeatCount; + this.stream.Skip(1); // Skip the terminator. + return; + } + + // Could be something else not supported yet. + // Skip the subblock and terminator. + this.SkipBlock(subBlockSize); + } - // Could be XMP or something else not supported yet. - // Skip the subblock and terminator. - this.SkipBlock(subBlockSize); return; } diff --git a/src/ImageSharp/Formats/Gif/GifEncoderCore.cs b/src/ImageSharp/Formats/Gif/GifEncoderCore.cs index 05ea14e9ce..a21b050a81 100644 --- a/src/ImageSharp/Formats/Gif/GifEncoderCore.cs +++ b/src/ImageSharp/Formats/Gif/GifEncoderCore.cs @@ -10,6 +10,7 @@ using SixLabors.ImageSharp.Advanced; using SixLabors.ImageSharp.Memory; using SixLabors.ImageSharp.Metadata; +using SixLabors.ImageSharp.Metadata.Profiles.Xmp; using SixLabors.ImageSharp.PixelFormats; using SixLabors.ImageSharp.Processing.Processors.Quantization; @@ -121,11 +122,8 @@ public void Encode(Image image, Stream stream, CancellationToken // Write the comments. this.WriteComments(gifMetadata, stream); - // Write application extension to allow additional frames. - if (image.Frames.Count > 1) - { - this.WriteApplicationExtension(stream, gifMetadata.RepeatCount); - } + // Write application extensions. + this.WriteApplicationExtensions(stream, image.Frames.Count, gifMetadata.RepeatCount, metadata.XmpProfile); if (useGlobalTable) { @@ -326,15 +324,24 @@ private void WriteLogicalScreenDescriptor( /// Writes the application extension to the stream. /// /// The stream to write to. + /// The frame count fo this image. /// The animated image repeat count. - private void WriteApplicationExtension(Stream stream, ushort repeatCount) + /// The XMP metadata profile. Null if profile is not to be written. + private void WriteApplicationExtensions(Stream stream, int frameCount, ushort repeatCount, XmpProfile xmpProfile) { - // Application Extension Header - if (repeatCount != 1) + // Application Extension: Loop repeat count. + if (frameCount > 1 && repeatCount != 1) { var loopingExtension = new GifNetscapeLoopingApplicationExtension(repeatCount); this.WriteExtension(loopingExtension, stream); } + + // Application Extension: XMP Profile. + if (xmpProfile != null) + { + var xmpExtension = new GifXmpApplicationExtension(xmpProfile.Data); + this.WriteExtension(xmpExtension, stream); + } } /// @@ -420,14 +427,28 @@ private void WriteGraphicalControlExtension(GifFrameMetadata metadata, int trans private void WriteExtension(TGifExtension extension, Stream stream) where TGifExtension : struct, IGifExtension { - this.buffer[0] = GifConstants.ExtensionIntroducer; - this.buffer[1] = extension.Label; + IMemoryOwner owner = null; + Span buffer; + int extensionSize = extension.ContentLength; + if (extensionSize > this.buffer.Length - 3) + { + owner = this.memoryAllocator.Allocate(extensionSize + 3); + buffer = owner.GetSpan(); + } + else + { + buffer = this.buffer; + } + + buffer[0] = GifConstants.ExtensionIntroducer; + buffer[1] = extension.Label; - int extensionSize = extension.WriteTo(this.buffer.AsSpan(2)); + extension.WriteTo(buffer.Slice(2)); - this.buffer[extensionSize + 2] = GifConstants.Terminator; + buffer[extensionSize + 2] = GifConstants.Terminator; - stream.Write(this.buffer, 0, extensionSize + 3); + stream.Write(buffer, 0, extensionSize + 3); + owner?.Dispose(); } /// diff --git a/src/ImageSharp/Formats/Gif/Sections/GifGraphicControlExtension.cs b/src/ImageSharp/Formats/Gif/Sections/GifGraphicControlExtension.cs index ee5a43d805..801849c9b8 100644 --- a/src/ImageSharp/Formats/Gif/Sections/GifGraphicControlExtension.cs +++ b/src/ImageSharp/Formats/Gif/Sections/GifGraphicControlExtension.cs @@ -1,4 +1,4 @@ -// Copyright (c) Six Labors. +// Copyright (c) Six Labors. // Licensed under the Apache License, Version 2.0. using System; @@ -63,6 +63,8 @@ public GifGraphicControlExtension( byte IGifExtension.Label => GifConstants.GraphicControlLabel; + int IGifExtension.ContentLength => 5; + public int WriteTo(Span buffer) { ref GifGraphicControlExtension dest = ref Unsafe.As(ref MemoryMarshal.GetReference(buffer)); diff --git a/src/ImageSharp/Formats/Gif/Sections/GifNetscapeLoopingApplicationExtension.cs b/src/ImageSharp/Formats/Gif/Sections/GifNetscapeLoopingApplicationExtension.cs index 26faa8925e..2c7bed6115 100644 --- a/src/ImageSharp/Formats/Gif/Sections/GifNetscapeLoopingApplicationExtension.cs +++ b/src/ImageSharp/Formats/Gif/Sections/GifNetscapeLoopingApplicationExtension.cs @@ -12,6 +12,8 @@ namespace SixLabors.ImageSharp.Formats.Gif public byte Label => GifConstants.ApplicationExtensionLabel; + public int ContentLength => 16; + /// /// Gets the repeat count. /// 0 means loop indefinitely. Count is set as play n + 1 times. diff --git a/src/ImageSharp/Formats/Gif/Sections/GifXmpApplicationExtension.cs b/src/ImageSharp/Formats/Gif/Sections/GifXmpApplicationExtension.cs new file mode 100644 index 0000000000..236508fe99 --- /dev/null +++ b/src/ImageSharp/Formats/Gif/Sections/GifXmpApplicationExtension.cs @@ -0,0 +1,97 @@ +// Copyright (c) Six Labors. +// Licensed under the Apache License, Version 2.0. + +using System; +using System.Collections.Generic; +using System.IO; +using SixLabors.ImageSharp.Metadata.Profiles.Xmp; + +namespace SixLabors.ImageSharp.Formats.Gif +{ + internal readonly struct GifXmpApplicationExtension : IGifExtension + { + public GifXmpApplicationExtension(byte[] data) => this.Data = data; + + public byte Label => GifConstants.ApplicationExtensionLabel; + + public int ContentLength => this.Data.Length + 269; // 12 + Data Length + 1 + 256 + + /// + /// Gets the raw Data. + /// + public byte[] Data { get; } + + /// + /// Reads the XMP metadata from the specified stream. + /// + /// The stream to read from. + /// The XMP metadata + /// Thrown if the XMP block is not properly terminated. + public static GifXmpApplicationExtension Read(Stream stream) + { + // Read data in blocks, until an \0 character is encountered. + // We overshoot, indicated by the terminatorIndex variable. + const int bufferSize = 256; + var list = new List(); + int terminationIndex = -1; + while (terminationIndex < 0) + { + byte[] temp = new byte[bufferSize]; + int bytesRead = stream.Read(temp); + list.Add(temp); + terminationIndex = Array.IndexOf(temp, (byte)1); + } + + // Pack all the blocks (except magic trailer) into one single array again. + int dataSize = ((list.Count - 1) * bufferSize) + terminationIndex; + byte[] buffer = new byte[dataSize]; + Span bufferSpan = buffer; + int pos = 0; + for (int j = 0; j < list.Count - 1; j++) + { + list[j].CopyTo(bufferSpan.Slice(pos)); + pos += bufferSize; + } + + // Last one only needs the portion until terminationIndex copied over. + Span lastBytes = list[list.Count - 1]; + lastBytes.Slice(0, terminationIndex).CopyTo(bufferSpan.Slice(pos)); + + // Skip the remainder of the magic trailer. + stream.Skip(258 - (bufferSize - terminationIndex)); + return new GifXmpApplicationExtension(buffer); + } + + public int WriteTo(Span buffer) + { + int totalSize = this.ContentLength; + if (buffer.Length < totalSize) + { + throw new InsufficientMemoryException("Unable to write XMP metadata to GIF image"); + } + + int bytesWritten = 0; + buffer[bytesWritten++] = GifConstants.ApplicationBlockSize; + + // Write "XMP DataXMP" + ReadOnlySpan idBytes = GifConstants.XmpApplicationIdentificationBytes; + idBytes.CopyTo(buffer.Slice(bytesWritten)); + bytesWritten += idBytes.Length; + + // XMP Data itself + this.Data.CopyTo(buffer.Slice(bytesWritten)); + bytesWritten += this.Data.Length; + + // Write the Magic Trailer + buffer[bytesWritten++] = 0x01; + for (byte i = 255; i > 0; i--) + { + buffer[bytesWritten++] = i; + } + + buffer[bytesWritten++] = 0x00; + + return totalSize; + } + } +} diff --git a/src/ImageSharp/Formats/Gif/Sections/IGifExtension.cs b/src/ImageSharp/Formats/Gif/Sections/IGifExtension.cs index 5a15a6dfa9..d2783fc48d 100644 --- a/src/ImageSharp/Formats/Gif/Sections/IGifExtension.cs +++ b/src/ImageSharp/Formats/Gif/Sections/IGifExtension.cs @@ -1,4 +1,4 @@ -// Copyright (c) Six Labors. +// Copyright (c) Six Labors. // Licensed under the Apache License, Version 2.0. using System; @@ -15,6 +15,11 @@ public interface IGifExtension /// byte Label { get; } + /// + /// Gets the length of the contents of this extension. + /// + int ContentLength { get; } + /// /// Writes the extension data to the buffer. /// diff --git a/src/ImageSharp/Formats/Jpeg/Components/Decoder/ProfileResolver.cs b/src/ImageSharp/Formats/Jpeg/Components/Decoder/ProfileResolver.cs index e1e0e160cd..b41c949b26 100644 --- a/src/ImageSharp/Formats/Jpeg/Components/Decoder/ProfileResolver.cs +++ b/src/ImageSharp/Formats/Jpeg/Components/Decoder/ProfileResolver.cs @@ -60,6 +60,18 @@ internal static class ProfileResolver (byte)'E', (byte)'x', (byte)'i', (byte)'f', (byte)'\0', (byte)'\0' }; + /// + /// Gets the XMP specific markers. + /// + public static ReadOnlySpan XmpMarker => new[] + { + (byte)'h', (byte)'t', (byte)'t', (byte)'p', (byte)':', (byte)'/', (byte)'/', + (byte)'n', (byte)'s', (byte)'.', (byte)'a', (byte)'d', (byte)'o', (byte)'b', + (byte)'e', (byte)'.', (byte)'c', (byte)'o', (byte)'m', (byte)'/', (byte)'x', + (byte)'a', (byte)'p', (byte)'/', (byte)'1', (byte)'.', (byte)'0', (byte)'/', + (byte)0 + }; + /// /// Gets the Adobe specific markers . /// diff --git a/src/ImageSharp/Formats/Jpeg/JpegDecoderCore.cs b/src/ImageSharp/Formats/Jpeg/JpegDecoderCore.cs index 14a7d49489..6c529f0c1f 100644 --- a/src/ImageSharp/Formats/Jpeg/JpegDecoderCore.cs +++ b/src/ImageSharp/Formats/Jpeg/JpegDecoderCore.cs @@ -17,6 +17,7 @@ using SixLabors.ImageSharp.Metadata.Profiles.Exif; using SixLabors.ImageSharp.Metadata.Profiles.Icc; using SixLabors.ImageSharp.Metadata.Profiles.Iptc; +using SixLabors.ImageSharp.Metadata.Profiles.Xmp; using SixLabors.ImageSharp.PixelFormats; namespace SixLabors.ImageSharp.Formats.Jpeg @@ -46,7 +47,7 @@ internal sealed class JpegDecoderCore : IRawJpegData, IImageDecoderInternals /// /// Whether the image has an EXIF marker. /// - private bool isExif; + private bool hasExif; /// /// Contains exif data. @@ -56,7 +57,7 @@ internal sealed class JpegDecoderCore : IRawJpegData, IImageDecoderInternals /// /// Whether the image has an ICC marker. /// - private bool isIcc; + private bool hasIcc; /// /// Contains ICC data. @@ -66,13 +67,23 @@ internal sealed class JpegDecoderCore : IRawJpegData, IImageDecoderInternals /// /// Whether the image has a IPTC data. /// - private bool isIptc; + private bool hasIptc; /// /// Contains IPTC data. /// private byte[] iptcData; + /// + /// Whether the image has a XMP data. + /// + private bool hasXmp; + + /// + /// Contains XMP data. + /// + private byte[] xmpData; + /// /// Contains information about the JFIF marker. /// @@ -183,6 +194,7 @@ public Image Decode(BufferedReadStream stream, CancellationToken this.InitExifProfile(); this.InitIccProfile(); this.InitIptcProfile(); + this.InitXmpProfile(); this.InitDerivedMetadataProperties(); return new Image( @@ -198,6 +210,7 @@ public IImageInfo Identify(BufferedReadStream stream, CancellationToken cancella this.InitExifProfile(); this.InitIccProfile(); this.InitIptcProfile(); + this.InitXmpProfile(); this.InitDerivedMetadataProperties(); Size pixelSize = this.Frame.PixelSize; @@ -539,7 +552,7 @@ private JpegColorType DeduceJpegColorType() /// private void InitExifProfile() { - if (this.isExif) + if (this.hasExif) { this.Metadata.ExifProfile = new ExifProfile(this.exifData); } @@ -550,7 +563,7 @@ private void InitExifProfile() /// private void InitIccProfile() { - if (this.isIcc) + if (this.hasIcc) { var profile = new IccProfile(this.iccData); if (profile.CheckIsValid()) @@ -565,13 +578,25 @@ private void InitIccProfile() /// private void InitIptcProfile() { - if (this.isIptc) + if (this.hasIptc) { var profile = new IptcProfile(this.iptcData); this.Metadata.IptcProfile = profile; } } + /// + /// Initializes the XMP profile. + /// + private void InitXmpProfile() + { + if (this.hasXmp) + { + var profile = new XmpProfile(this.xmpData); + this.Metadata.XmpProfile = profile; + } + } + /// /// Assigns derived metadata properties to , eg. horizontal and vertical resolution if it has a JFIF header. /// @@ -583,7 +608,7 @@ private void InitDerivedMetadataProperties() this.Metadata.VerticalResolution = this.jFif.YDensity; this.Metadata.ResolutionUnits = this.jFif.DensityUnits; } - else if (this.isExif) + else if (this.hasExif) { double horizontalValue = this.GetExifResolutionValue(ExifTag.XResolution); double verticalValue = this.GetExifResolutionValue(ExifTag.YResolution); @@ -656,8 +681,9 @@ private void ProcessApplicationHeaderMarker(BufferedReadStream stream, int remai /// The remaining bytes in the segment block. private void ProcessApp1Marker(BufferedReadStream stream, int remaining) { - const int Exif00 = 6; - if (remaining < Exif00 || this.IgnoreMetadata) + const int ExifMarkerLength = 6; + const int XmpMarkerLength = 29; + if (remaining < ExifMarkerLength || this.IgnoreMetadata) { // Skip the application header length stream.Skip(remaining); @@ -669,23 +695,55 @@ private void ProcessApp1Marker(BufferedReadStream stream, int remaining) JpegThrowHelper.ThrowInvalidImageContentException("Bad App1 Marker length."); } - byte[] profile = new byte[remaining]; - stream.Read(profile, 0, remaining); + // XMP marker is the longest, so read at least that many bytes into temp. + stream.Read(this.temp, 0, ExifMarkerLength); - if (ProfileResolver.IsProfile(profile, ProfileResolver.ExifMarker)) + if (ProfileResolver.IsProfile(this.temp, ProfileResolver.ExifMarker)) { - this.isExif = true; + remaining -= ExifMarkerLength; + this.hasExif = true; + byte[] profile = new byte[remaining]; + stream.Read(profile, 0, remaining); + if (this.exifData is null) { - // The first 6 bytes (Exif00) will be skipped, because this is Jpeg specific - this.exifData = profile.AsSpan(Exif00).ToArray(); + this.exifData = profile; } else { // If the EXIF information exceeds 64K, it will be split over multiple APP1 markers - this.ExtendProfile(ref this.exifData, profile.AsSpan(Exif00).ToArray()); + this.ExtendProfile(ref this.exifData, profile); } + + remaining = 0; } + + if (ProfileResolver.IsProfile(this.temp, ProfileResolver.XmpMarker.Slice(0, ExifMarkerLength))) + { + stream.Read(this.temp, 0, XmpMarkerLength - ExifMarkerLength); + remaining -= XmpMarkerLength; + if (ProfileResolver.IsProfile(this.temp, ProfileResolver.XmpMarker.Slice(ExifMarkerLength))) + { + this.hasXmp = true; + byte[] profile = new byte[remaining]; + stream.Read(profile, 0, remaining); + + if (this.xmpData is null) + { + this.xmpData = profile; + } + else + { + // If the XMP information exceeds 64K, it will be split over multiple APP1 markers + this.ExtendProfile(ref this.xmpData, profile); + } + + remaining = 0; + } + } + + // Skip over any remaining bytes of this header. + stream.Skip(remaining); } /// @@ -709,7 +767,7 @@ private void ProcessApp2Marker(BufferedReadStream stream, int remaining) if (ProfileResolver.IsProfile(identifier, ProfileResolver.IccMarker)) { - this.isIcc = true; + this.hasIcc = true; byte[] profile = new byte[remaining]; stream.Read(profile, 0, remaining); @@ -768,7 +826,7 @@ private void ProcessApp13Marker(BufferedReadStream stream, int remaining) int dataStartIdx = 2 + resourceBlockNameLength + 4; if (resourceDataSize > 0 && blockDataSpan.Length >= dataStartIdx + resourceDataSize) { - this.isIptc = true; + this.hasIptc = true; this.iptcData = blockDataSpan.Slice(dataStartIdx, resourceDataSize).ToArray(); break; } diff --git a/src/ImageSharp/Formats/Jpeg/JpegEncoderCore.cs b/src/ImageSharp/Formats/Jpeg/JpegEncoderCore.cs index abe59516fa..a3cff8f31d 100644 --- a/src/ImageSharp/Formats/Jpeg/JpegEncoderCore.cs +++ b/src/ImageSharp/Formats/Jpeg/JpegEncoderCore.cs @@ -14,6 +14,7 @@ using SixLabors.ImageSharp.Metadata.Profiles.Exif; using SixLabors.ImageSharp.Metadata.Profiles.Icc; using SixLabors.ImageSharp.Metadata.Profiles.Iptc; +using SixLabors.ImageSharp.Metadata.Profiles.Xmp; using SixLabors.ImageSharp.PixelFormats; namespace SixLabors.ImageSharp.Formats.Jpeg @@ -109,7 +110,7 @@ public void Encode(Image image, Stream stream, CancellationToken this.WriteJfifApplicationHeader(metadata); } - // Write Exif, ICC and IPTC profiles + // Write Exif, XMP, ICC and IPTC profiles this.WriteProfiles(metadata); if (this.colorType == JpegColorType.Rgb) @@ -466,6 +467,54 @@ private void WriteIptcProfile(IptcProfile iptcProfile) this.outputStream.Write(data, 0, data.Length); } + /// + /// Writes the XMP metadata. + /// + /// The XMP metadata to write. + /// + /// Thrown if the XMP profile size exceeds the limit of 65533 bytes. + /// + private void WriteXmpProfile(XmpProfile xmpProfile) + { + if (xmpProfile is null) + { + return; + } + + const int XmpOverheadLength = 29; + const int Max = 65533; + const int MaxData = Max - XmpOverheadLength; + + byte[] data = xmpProfile.Data; + + if (data is null || data.Length == 0) + { + return; + } + + int dataLength = data.Length; + int offset = 0; + + while (dataLength > 0) + { + int length = dataLength; // Number of bytes to write. + + if (length > MaxData) + { + length = MaxData; + } + + dataLength -= length; + + int app1Length = 2 + ProfileResolver.XmpMarker.Length + length; + this.WriteApp1Header(app1Length); + this.outputStream.Write(ProfileResolver.XmpMarker); + this.outputStream.Write(data, offset, length); + + offset += length; + } + } + /// /// Writes the App1 header. /// @@ -579,8 +628,14 @@ private void WriteProfiles(ImageMetadata metadata) return; } + // For compatibility, place the profiles in the following order: + // - APP1 EXIF + // - APP1 XMP + // - APP2 ICC + // - APP13 IPTC metadata.SyncProfiles(); this.WriteExifProfile(metadata.ExifProfile); + this.WriteXmpProfile(metadata.XmpProfile); this.WriteIccProfile(metadata.IccProfile); this.WriteIptcProfile(metadata.IptcProfile); } diff --git a/src/ImageSharp/Formats/Png/PngConstants.cs b/src/ImageSharp/Formats/Png/PngConstants.cs index b4ef28083e..fcc8fd992c 100644 --- a/src/ImageSharp/Formats/Png/PngConstants.cs +++ b/src/ImageSharp/Formats/Png/PngConstants.cs @@ -78,5 +78,29 @@ internal static class PngConstants 0x1A, // EOF 0x0A // LF }; + + /// + /// Gets the keyword of the XMP metadata, encoded in an iTXT chunk. + /// + public static ReadOnlySpan XmpKeyword => new byte[] + { + (byte)'X', + (byte)'M', + (byte)'L', + (byte)':', + (byte)'c', + (byte)'o', + (byte)'m', + (byte)'.', + (byte)'a', + (byte)'d', + (byte)'o', + (byte)'b', + (byte)'e', + (byte)'.', + (byte)'x', + (byte)'m', + (byte)'p' + }; } } diff --git a/src/ImageSharp/Formats/Png/PngDecoderCore.cs b/src/ImageSharp/Formats/Png/PngDecoderCore.cs index c9f0ce3755..f5fc86ee4d 100644 --- a/src/ImageSharp/Formats/Png/PngDecoderCore.cs +++ b/src/ImageSharp/Formats/Png/PngDecoderCore.cs @@ -19,6 +19,7 @@ using SixLabors.ImageSharp.Memory; using SixLabors.ImageSharp.Metadata; using SixLabors.ImageSharp.Metadata.Profiles.Exif; +using SixLabors.ImageSharp.Metadata.Profiles.Xmp; using SixLabors.ImageSharp.PixelFormats; namespace SixLabors.ImageSharp.Formats.Png @@ -194,7 +195,7 @@ public Image Decode(BufferedReadStream stream, CancellationToken this.ReadCompressedTextChunk(metadata, pngMetadata, chunk.Data.GetSpan()); break; case PngChunkType.InternationalText: - this.ReadInternationalTextChunk(pngMetadata, chunk.Data.GetSpan()); + this.ReadInternationalTextChunk(metadata, chunk.Data.GetSpan()); break; case PngChunkType.Exif: if (!this.ignoreMetadata) @@ -316,7 +317,7 @@ public IImageInfo Identify(BufferedReadStream stream, CancellationToken cancella break; } - this.ReadInternationalTextChunk(pngMetadata, chunk.Data.GetSpan()); + this.ReadInternationalTextChunk(metadata, chunk.Data.GetSpan()); break; case PngChunkType.Exif: if (this.colorMetadataOnly) @@ -1224,13 +1225,14 @@ private void MergeOrSetExifProfile(ImageMetadata metadata, ExifProfile newProfil /// /// The metadata to decode to. /// The containing the data. - private void ReadInternationalTextChunk(PngMetadata metadata, ReadOnlySpan data) + private void ReadInternationalTextChunk(ImageMetadata metadata, ReadOnlySpan data) { if (this.ignoreMetadata) { return; } + PngMetadata pngMetadata = metadata.GetPngMetadata(); int zeroIndexKeyword = data.IndexOf((byte)0); if (zeroIndexKeyword < PngConstants.MinTextKeywordLength || zeroIndexKeyword > PngConstants.MaxTextKeywordLength) { @@ -1276,13 +1278,18 @@ private void ReadInternationalTextChunk(PngMetadata metadata, ReadOnlySpan if (this.TryUncompressTextData(compressedData, PngConstants.TranslatedEncoding, out string uncompressed)) { - metadata.TextData.Add(new PngTextData(keyword, uncompressed, language, translatedKeyword)); + pngMetadata.TextData.Add(new PngTextData(keyword, uncompressed, language, translatedKeyword)); } } + else if (this.IsXmpTextData(keywordBytes)) + { + XmpProfile xmpProfile = new XmpProfile(data.Slice(dataStartIdx).ToArray()); + metadata.XmpProfile = xmpProfile; + } else { string value = PngConstants.TranslatedEncoding.GetString(data.Slice(dataStartIdx)); - metadata.TextData.Add(new PngTextData(keyword, value, language, translatedKeyword)); + pngMetadata.TextData.Add(new PngTextData(keyword, value, language, translatedKeyword)); } } @@ -1550,6 +1557,8 @@ private bool TryReadTextKeyword(ReadOnlySpan keywordBytes, out string name return true; } + private bool IsXmpTextData(ReadOnlySpan keywordBytes) => keywordBytes.SequenceEqual(PngConstants.XmpKeyword); + private void SwapScanlineBuffers() { IMemoryOwner temp = this.previousScanline; diff --git a/src/ImageSharp/Formats/Png/PngEncoderCore.cs b/src/ImageSharp/Formats/Png/PngEncoderCore.cs index 5e067aba57..c443c0fcf1 100644 --- a/src/ImageSharp/Formats/Png/PngEncoderCore.cs +++ b/src/ImageSharp/Formats/Png/PngEncoderCore.cs @@ -138,6 +138,7 @@ public void Encode(Image image, Stream stream, CancellationToken this.WriteTransparencyChunk(stream, pngMetadata); this.WritePhysicalChunk(stream, metadata); this.WriteExifChunk(stream, metadata); + this.WriteXmpChunk(stream, metadata); this.WriteTextChunks(stream, pngMetadata); this.WriteDataChunks(clearTransparency ? clonedImage : image, quantized, stream); this.WriteEndChunk(stream); @@ -654,6 +655,51 @@ private void WriteExifChunk(Stream stream, ImageMetadata meta) this.WriteChunk(stream, PngChunkType.Exif, meta.ExifProfile.ToByteArray()); } + /// + /// Writes an iTXT chunk, containing the XMP metdata to the stream, if such profile is present in the metadata. + /// + /// The containing image data. + /// The image metadata. + private void WriteXmpChunk(Stream stream, ImageMetadata meta) + { + const int iTxtHeaderSize = 5; + if (((this.options.ChunkFilter ?? PngChunkFilter.None) & PngChunkFilter.ExcludeTextChunks) == PngChunkFilter.ExcludeTextChunks) + { + return; + } + + if (meta.XmpProfile is null) + { + return; + } + + var xmpData = meta.XmpProfile.Data; + + if (xmpData.Length == 0) + { + return; + } + + int payloadLength = xmpData.Length + PngConstants.XmpKeyword.Length + iTxtHeaderSize; + using (IMemoryOwner owner = this.memoryAllocator.Allocate(payloadLength)) + { + Span payload = owner.GetSpan(); + PngConstants.XmpKeyword.CopyTo(payload); + int bytesWritten = PngConstants.XmpKeyword.Length; + + // Write the iTxt header (all zeros in this case) + payload[bytesWritten++] = 0; + payload[bytesWritten++] = 0; + payload[bytesWritten++] = 0; + payload[bytesWritten++] = 0; + payload[bytesWritten++] = 0; + + // And the XMP data itself + xmpData.CopyTo(payload.Slice(bytesWritten)); + this.WriteChunk(stream, PngChunkType.InternationalText, payload); + } + } + /// /// Writes a text chunk to the stream. Can be either a tTXt, iTXt or zTXt chunk, /// depending whether the text contains any latin characters or should be compressed. @@ -693,21 +739,33 @@ private void WriteTextChunks(Stream stream, PngMetadata meta) byte[] translatedKeyword = PngConstants.TranslatedEncoding.GetBytes(textData.TranslatedKeyword); byte[] languageTag = PngConstants.LanguageEncoding.GetBytes(textData.LanguageTag); - Span outputBytes = new byte[keywordBytes.Length + textBytes.Length + - translatedKeyword.Length + languageTag.Length + 5]; - keywordBytes.CopyTo(outputBytes); - if (textData.Value.Length > this.options.TextCompressionThreshold) + int payloadLength = keywordBytes.Length + textBytes.Length + translatedKeyword.Length + languageTag.Length + 5; + using (IMemoryOwner owner = this.memoryAllocator.Allocate(payloadLength)) { - // Indicate that the text is compressed. - outputBytes[keywordBytes.Length + 1] = 1; - } + Span outputBytes = owner.GetSpan(); + keywordBytes.CopyTo(outputBytes); + int bytesWritten = keywordBytes.Length; + outputBytes[bytesWritten++] = 0; + if (textData.Value.Length > this.options.TextCompressionThreshold) + { + // Indicate that the text is compressed. + outputBytes[bytesWritten++] = 1; + } + else + { + outputBytes[bytesWritten++] = 0; + } - int keywordStart = keywordBytes.Length + 3; - languageTag.CopyTo(outputBytes.Slice(keywordStart)); - int translatedKeywordStart = keywordStart + languageTag.Length + 1; - translatedKeyword.CopyTo(outputBytes.Slice(translatedKeywordStart)); - textBytes.CopyTo(outputBytes.Slice(translatedKeywordStart + translatedKeyword.Length + 1)); - this.WriteChunk(stream, PngChunkType.InternationalText, outputBytes.ToArray()); + outputBytes[bytesWritten++] = 0; + languageTag.CopyTo(outputBytes.Slice(bytesWritten)); + bytesWritten += languageTag.Length; + outputBytes[bytesWritten++] = 0; + translatedKeyword.CopyTo(outputBytes.Slice(bytesWritten)); + bytesWritten += translatedKeyword.Length; + outputBytes[bytesWritten++] = 0; + textBytes.CopyTo(outputBytes.Slice(bytesWritten)); + this.WriteChunk(stream, PngChunkType.InternationalText, outputBytes); + } } else { @@ -716,19 +774,32 @@ private void WriteTextChunks(Stream stream, PngMetadata meta) // Write zTXt chunk. byte[] compressedData = this.GetCompressedTextBytes(PngConstants.Encoding.GetBytes(textData.Value)); - Span outputBytes = new byte[textData.Keyword.Length + compressedData.Length + 2]; - PngConstants.Encoding.GetBytes(textData.Keyword).CopyTo(outputBytes); - compressedData.CopyTo(outputBytes.Slice(textData.Keyword.Length + 2)); - this.WriteChunk(stream, PngChunkType.CompressedText, outputBytes.ToArray()); + int payloadLength = textData.Keyword.Length + compressedData.Length + 2; + using (IMemoryOwner owner = this.memoryAllocator.Allocate(payloadLength)) + { + Span outputBytes = owner.GetSpan(); + PngConstants.Encoding.GetBytes(textData.Keyword).CopyTo(outputBytes); + int bytesWritten = textData.Keyword.Length; + outputBytes[bytesWritten++] = 0; + outputBytes[bytesWritten++] = 0; + compressedData.CopyTo(outputBytes.Slice(bytesWritten)); + this.WriteChunk(stream, PngChunkType.CompressedText, outputBytes.ToArray()); + } } else { // Write tEXt chunk. - Span outputBytes = new byte[textData.Keyword.Length + textData.Value.Length + 1]; - PngConstants.Encoding.GetBytes(textData.Keyword).CopyTo(outputBytes); - PngConstants.Encoding.GetBytes(textData.Value) - .CopyTo(outputBytes.Slice(textData.Keyword.Length + 1)); - this.WriteChunk(stream, PngChunkType.Text, outputBytes.ToArray()); + int payloadLength = textData.Keyword.Length + textData.Value.Length + 1; + using (IMemoryOwner owner = this.memoryAllocator.Allocate(payloadLength)) + { + Span outputBytes = owner.GetSpan(); + PngConstants.Encoding.GetBytes(textData.Keyword).CopyTo(outputBytes); + int bytesWritten = textData.Keyword.Length; + outputBytes[bytesWritten++] = 0; + PngConstants.Encoding.GetBytes(textData.Value) + .CopyTo(outputBytes.Slice(bytesWritten)); + this.WriteChunk(stream, PngChunkType.Text, outputBytes.ToArray()); + } } } } diff --git a/src/ImageSharp/Formats/Tiff/TiffDecoderCore.cs b/src/ImageSharp/Formats/Tiff/TiffDecoderCore.cs index 177a93d247..05c5358f59 100644 --- a/src/ImageSharp/Formats/Tiff/TiffDecoderCore.cs +++ b/src/ImageSharp/Formats/Tiff/TiffDecoderCore.cs @@ -13,6 +13,7 @@ using SixLabors.ImageSharp.Memory; using SixLabors.ImageSharp.Metadata; using SixLabors.ImageSharp.Metadata.Profiles.Exif; +using SixLabors.ImageSharp.Metadata.Profiles.Xmp; using SixLabors.ImageSharp.PixelFormats; namespace SixLabors.ImageSharp.Formats.Tiff @@ -204,9 +205,11 @@ public IImageInfo Identify(BufferedReadStream stream, CancellationToken cancella private ImageFrame DecodeFrame(ExifProfile tags, CancellationToken cancellationToken) where TPixel : unmanaged, IPixel { - ImageFrameMetadata imageFrameMetaData = this.ignoreMetadata ? - new ImageFrameMetadata() : - new ImageFrameMetadata { ExifProfile = tags, XmpProfile = tags.GetValue(ExifTag.XMP)?.Value }; + var imageFrameMetaData = new ImageFrameMetadata(); + if (!this.ignoreMetadata) + { + imageFrameMetaData.ExifProfile = tags; + } TiffFrameMetadata tiffFrameMetaData = imageFrameMetaData.GetTiffMetadata(); TiffFrameMetadata.Parse(tiffFrameMetaData, tags); diff --git a/src/ImageSharp/Formats/Tiff/TiffDecoderMetadataCreator.cs b/src/ImageSharp/Formats/Tiff/TiffDecoderMetadataCreator.cs index 4c4023acee..ddbfbcb48a 100644 --- a/src/ImageSharp/Formats/Tiff/TiffDecoderMetadataCreator.cs +++ b/src/ImageSharp/Formats/Tiff/TiffDecoderMetadataCreator.cs @@ -9,6 +9,7 @@ using SixLabors.ImageSharp.Metadata.Profiles.Exif; using SixLabors.ImageSharp.Metadata.Profiles.Icc; using SixLabors.ImageSharp.Metadata.Profiles.Iptc; +using SixLabors.ImageSharp.Metadata.Profiles.Xmp; using SixLabors.ImageSharp.PixelFormats; namespace SixLabors.ImageSharp.Formats.Tiff @@ -39,6 +40,12 @@ public static ImageMetadata Create(List> frames, bool frameMetaData.IptcProfile = new IptcProfile(iptcBytes); } + IExifValue xmpProfileBytes = frameMetaData.ExifProfile.GetValue(ExifTag.XMP); + if (xmpProfileBytes != null) + { + frameMetaData.XmpProfile = new XmpProfile(xmpProfileBytes.Value); + } + IExifValue iccProfileBytes = frameMetaData.ExifProfile.GetValue(ExifTag.IccProfile); if (iccProfileBytes != null) { diff --git a/src/ImageSharp/Formats/Tiff/TiffEncoderEntriesCollector.cs b/src/ImageSharp/Formats/Tiff/TiffEncoderEntriesCollector.cs index 55dd7d3973..e54d029ab5 100644 --- a/src/ImageSharp/Formats/Tiff/TiffEncoderEntriesCollector.cs +++ b/src/ImageSharp/Formats/Tiff/TiffEncoderEntriesCollector.cs @@ -6,6 +6,7 @@ using SixLabors.ImageSharp.Formats.Tiff.Constants; using SixLabors.ImageSharp.Metadata; using SixLabors.ImageSharp.Metadata.Profiles.Exif; +using SixLabors.ImageSharp.Metadata.Profiles.Xmp; namespace SixLabors.ImageSharp.Formats.Tiff { @@ -57,9 +58,9 @@ public void Process(Image image) { ImageFrame rootFrame = image.Frames.RootFrame; ExifProfile rootFrameExifProfile = rootFrame.Metadata.ExifProfile ?? new ExifProfile(); - byte[] foorFrameXmpBytes = rootFrame.Metadata.XmpProfile; + XmpProfile rootFrameXmpProfile = rootFrame.Metadata.XmpProfile; - this.ProcessProfiles(image.Metadata, rootFrameExifProfile, foorFrameXmpBytes); + this.ProcessProfiles(image.Metadata, rootFrameExifProfile, rootFrameXmpProfile); this.ProcessMetadata(rootFrameExifProfile); if (!this.Collector.Entries.Exists(t => t.Tag == ExifTag.Software)) @@ -149,7 +150,7 @@ private void ProcessMetadata(ExifProfile exifProfile) } } - private void ProcessProfiles(ImageMetadata imageMetadata, ExifProfile exifProfile, byte[] xmpProfile) + private void ProcessProfiles(ImageMetadata imageMetadata, ExifProfile exifProfile, XmpProfile xmpProfile) { if (exifProfile != null && exifProfile.Parts != ExifParts.None) { @@ -203,7 +204,7 @@ private void ProcessProfiles(ImageMetadata imageMetadata, ExifProfile exifProfil { var xmp = new ExifByteArray(ExifTagValue.XMP, ExifDataType.Byte) { - Value = xmpProfile + Value = xmpProfile.Data }; this.Collector.Add(xmp); diff --git a/src/ImageSharp/Formats/Webp/BitWriter/BitWriterBase.cs b/src/ImageSharp/Formats/Webp/BitWriter/BitWriterBase.cs index 9208881360..ac039be797 100644 --- a/src/ImageSharp/Formats/Webp/BitWriter/BitWriterBase.cs +++ b/src/ImageSharp/Formats/Webp/BitWriter/BitWriterBase.cs @@ -5,6 +5,7 @@ using System.Buffers.Binary; using System.IO; using SixLabors.ImageSharp.Metadata.Profiles.Exif; +using SixLabors.ImageSharp.Metadata.Profiles.Xmp; namespace SixLabors.ImageSharp.Formats.Webp.BitWriter { @@ -90,34 +91,35 @@ protected void WriteRiffHeader(Stream stream, uint riffSize) } /// - /// Calculates the exif chunk size. + /// Calculates the chunk size of EXIF or XMP metadata. /// - /// The exif profile bytes. + /// The metadata profile bytes. /// The exif chunk size in bytes. - protected uint ExifChunkSize(byte[] exifBytes) + protected uint MetadataChunkSize(byte[] metadataBytes) { - uint exifSize = (uint)exifBytes.Length; - uint exifChunkSize = WebpConstants.ChunkHeaderSize + exifSize + (exifSize & 1); + uint metaSize = (uint)metadataBytes.Length; + uint metaChunkSize = WebpConstants.ChunkHeaderSize + metaSize + (metaSize & 1); - return exifChunkSize; + return metaChunkSize; } /// - /// Writes the Exif profile to the stream. + /// Writes a metadata profile (EXIF or XMP) to the stream. /// /// The stream to write to. - /// The exif profile bytes. - protected void WriteExifProfile(Stream stream, byte[] exifBytes) + /// The metadata profile's bytes. + /// The chuck type to write. + protected void WriteMetadataProfile(Stream stream, byte[] metadataBytes, WebpChunkType chunkType) { - DebugGuard.NotNull(exifBytes, nameof(exifBytes)); + DebugGuard.NotNull(metadataBytes, nameof(metadataBytes)); - uint size = (uint)exifBytes.Length; + uint size = (uint)metadataBytes.Length; Span buf = this.scratchBuffer.AsSpan(0, 4); - BinaryPrimitives.WriteUInt32BigEndian(buf, (uint)WebpChunkType.Exif); + BinaryPrimitives.WriteUInt32BigEndian(buf, (uint)chunkType); stream.Write(buf); BinaryPrimitives.WriteUInt32LittleEndian(buf, size); stream.Write(buf); - stream.Write(exifBytes); + stream.Write(metadataBytes); // Add padding byte if needed. if ((size & 1) == 1) @@ -131,10 +133,11 @@ protected void WriteExifProfile(Stream stream, byte[] exifBytes) /// /// The stream to write to. /// A exif profile or null, if it does not exist. + /// A XMP profile or null, if it does not exist. /// The width of the image. /// The height of the image. /// Flag indicating, if a alpha channel is present. - protected void WriteVp8XHeader(Stream stream, ExifProfile exifProfile, uint width, uint height, bool hasAlpha) + protected void WriteVp8XHeader(Stream stream, ExifProfile exifProfile, XmpProfile xmpProfile, uint width, uint height, bool hasAlpha) { if (width > MaxDimension || height > MaxDimension) { @@ -154,6 +157,12 @@ protected void WriteVp8XHeader(Stream stream, ExifProfile exifProfile, uint widt flags |= 8; } + if (xmpProfile != null) + { + // Set xmp bit. + flags |= 4; + } + if (hasAlpha) { // Set alpha bit. diff --git a/src/ImageSharp/Formats/Webp/BitWriter/Vp8BitWriter.cs b/src/ImageSharp/Formats/Webp/BitWriter/Vp8BitWriter.cs index 3b2f943db5..4e91bedb0b 100644 --- a/src/ImageSharp/Formats/Webp/BitWriter/Vp8BitWriter.cs +++ b/src/ImageSharp/Formats/Webp/BitWriter/Vp8BitWriter.cs @@ -6,6 +6,7 @@ using System.IO; using SixLabors.ImageSharp.Formats.Webp.Lossy; using SixLabors.ImageSharp.Metadata.Profiles.Exif; +using SixLabors.ImageSharp.Metadata.Profiles.Xmp; namespace SixLabors.ImageSharp.Formats.Webp.BitWriter { @@ -404,20 +405,30 @@ private void Flush() /// /// The stream to write to. /// The exif profile. + /// The XMP profile. /// The width of the image. /// The height of the image. /// Flag indicating, if a alpha channel is present. - public void WriteEncodedImageToStream(Stream stream, ExifProfile exifProfile, uint width, uint height, bool hasAlpha) + public void WriteEncodedImageToStream(Stream stream, ExifProfile exifProfile, XmpProfile xmpProfile, uint width, uint height, bool hasAlpha) { bool isVp8X = false; byte[] exifBytes = null; + byte[] xmpBytes = null; uint riffSize = 0; if (exifProfile != null) { isVp8X = true; riffSize += ExtendedFileChunkSize; exifBytes = exifProfile.ToByteArray(); - riffSize += this.ExifChunkSize(exifBytes); + riffSize += this.MetadataChunkSize(exifBytes); + } + + if (xmpProfile != null) + { + isVp8X = true; + riffSize += ExtendedFileChunkSize; + xmpBytes = xmpProfile.Data; + riffSize += this.MetadataChunkSize(xmpBytes); } this.Finish(); @@ -440,7 +451,7 @@ public void WriteEncodedImageToStream(Stream stream, ExifProfile exifProfile, ui riffSize += WebpConstants.TagSize + WebpConstants.ChunkHeaderSize + vp8Size; // Emit headers and partition #0 - this.WriteWebpHeaders(stream, size0, vp8Size, riffSize, isVp8X, width, height, exifProfile, hasAlpha); + this.WriteWebpHeaders(stream, size0, vp8Size, riffSize, isVp8X, width, height, exifProfile, xmpProfile, hasAlpha); bitWriterPartZero.WriteToStream(stream); // Write the encoded image to the stream. @@ -452,7 +463,12 @@ public void WriteEncodedImageToStream(Stream stream, ExifProfile exifProfile, ui if (exifProfile != null) { - this.WriteExifProfile(stream, exifBytes); + this.WriteMetadataProfile(stream, exifBytes, WebpChunkType.Exif); + } + + if (xmpProfile != null) + { + this.WriteMetadataProfile(stream, xmpBytes, WebpChunkType.Xmp); } } @@ -623,14 +639,14 @@ private void CodeIntraModes(Vp8BitWriter bitWriter) while (it.Next()); } - private void WriteWebpHeaders(Stream stream, uint size0, uint vp8Size, uint riffSize, bool isVp8X, uint width, uint height, ExifProfile exifProfile, bool hasAlpha) + private void WriteWebpHeaders(Stream stream, uint size0, uint vp8Size, uint riffSize, bool isVp8X, uint width, uint height, ExifProfile exifProfile, XmpProfile xmpProfile, bool hasAlpha) { this.WriteRiffHeader(stream, riffSize); // Write VP8X, header if necessary. if (isVp8X) { - this.WriteVp8XHeader(stream, exifProfile, width, height, hasAlpha); + this.WriteVp8XHeader(stream, exifProfile, xmpProfile, width, height, hasAlpha); } this.WriteVp8Header(stream, vp8Size); diff --git a/src/ImageSharp/Formats/Webp/BitWriter/Vp8LBitWriter.cs b/src/ImageSharp/Formats/Webp/BitWriter/Vp8LBitWriter.cs index b83865aa36..d41224f908 100644 --- a/src/ImageSharp/Formats/Webp/BitWriter/Vp8LBitWriter.cs +++ b/src/ImageSharp/Formats/Webp/BitWriter/Vp8LBitWriter.cs @@ -6,6 +6,7 @@ using System.IO; using SixLabors.ImageSharp.Formats.Webp.Lossless; using SixLabors.ImageSharp.Metadata.Profiles.Exif; +using SixLabors.ImageSharp.Metadata.Profiles.Xmp; namespace SixLabors.ImageSharp.Formats.Webp.BitWriter { @@ -132,20 +133,30 @@ public override void Finish() /// /// The stream to write to. /// The exif profile. + /// The XMP profile. /// The width of the image. /// The height of the image. /// Flag indicating, if a alpha channel is present. - public void WriteEncodedImageToStream(Stream stream, ExifProfile exifProfile, uint width, uint height, bool hasAlpha) + public void WriteEncodedImageToStream(Stream stream, ExifProfile exifProfile, XmpProfile xmpProfile, uint width, uint height, bool hasAlpha) { bool isVp8X = false; byte[] exifBytes = null; + byte[] xmpBytes = null; uint riffSize = 0; if (exifProfile != null) { isVp8X = true; riffSize += ExtendedFileChunkSize; exifBytes = exifProfile.ToByteArray(); - riffSize += this.ExifChunkSize(exifBytes); + riffSize += this.MetadataChunkSize(exifBytes); + } + + if (xmpProfile != null) + { + isVp8X = true; + riffSize += ExtendedFileChunkSize; + xmpBytes = xmpProfile.Data; + riffSize += this.MetadataChunkSize(xmpBytes); } this.Finish(); @@ -160,7 +171,7 @@ public void WriteEncodedImageToStream(Stream stream, ExifProfile exifProfile, ui // Write VP8X, header if necessary. if (isVp8X) { - this.WriteVp8XHeader(stream, exifProfile, width, height, hasAlpha); + this.WriteVp8XHeader(stream, exifProfile, xmpProfile, width, height, hasAlpha); } // Write magic bytes indicating its a lossless webp. @@ -180,7 +191,12 @@ public void WriteEncodedImageToStream(Stream stream, ExifProfile exifProfile, ui if (exifProfile != null) { - this.WriteExifProfile(stream, exifBytes); + this.WriteMetadataProfile(stream, exifBytes, WebpChunkType.Exif); + } + + if (xmpProfile != null) + { + this.WriteMetadataProfile(stream, xmpBytes, WebpChunkType.Xmp); } } diff --git a/src/ImageSharp/Formats/Webp/Lossless/Vp8LEncoder.cs b/src/ImageSharp/Formats/Webp/Lossless/Vp8LEncoder.cs index 8566566f60..e9dce913a3 100644 --- a/src/ImageSharp/Formats/Webp/Lossless/Vp8LEncoder.cs +++ b/src/ImageSharp/Formats/Webp/Lossless/Vp8LEncoder.cs @@ -10,6 +10,7 @@ using System.Runtime.InteropServices; using SixLabors.ImageSharp.Formats.Webp.BitWriter; using SixLabors.ImageSharp.Memory; +using SixLabors.ImageSharp.Metadata; using SixLabors.ImageSharp.PixelFormats; namespace SixLabors.ImageSharp.Formats.Webp.Lossless @@ -252,7 +253,9 @@ public void Encode(Image image, Stream stream) this.EncodeStream(image); // Write bytes from the bitwriter buffer to the stream. - this.bitWriter.WriteEncodedImageToStream(stream, image.Metadata.ExifProfile, (uint)width, (uint)height, hasAlpha); + ImageMetadata metadata = image.Metadata; + metadata.SyncProfiles(); + this.bitWriter.WriteEncodedImageToStream(stream, metadata.ExifProfile, metadata.XmpProfile, (uint)width, (uint)height, hasAlpha); } /// diff --git a/src/ImageSharp/Formats/Webp/Lossy/Vp8Encoder.cs b/src/ImageSharp/Formats/Webp/Lossy/Vp8Encoder.cs index 37e09d0802..0222320502 100644 --- a/src/ImageSharp/Formats/Webp/Lossy/Vp8Encoder.cs +++ b/src/ImageSharp/Formats/Webp/Lossy/Vp8Encoder.cs @@ -7,6 +7,7 @@ using System.Runtime.CompilerServices; using SixLabors.ImageSharp.Formats.Webp.BitWriter; using SixLabors.ImageSharp.Memory; +using SixLabors.ImageSharp.Metadata; using SixLabors.ImageSharp.PixelFormats; namespace SixLabors.ImageSharp.Formats.Webp.Lossy @@ -355,8 +356,9 @@ public void Encode(Image image, Stream stream) this.AdjustFilterStrength(); // Write bytes from the bitwriter buffer to the stream. - image.Metadata.SyncProfiles(); - this.bitWriter.WriteEncodedImageToStream(stream, image.Metadata.ExifProfile, (uint)width, (uint)height, hasAlpha); + ImageMetadata metadata = image.Metadata; + metadata.SyncProfiles(); + this.bitWriter.WriteEncodedImageToStream(stream, metadata.ExifProfile, metadata.XmpProfile, (uint)width, (uint)height, hasAlpha); } /// diff --git a/src/ImageSharp/Formats/Webp/WebpDecoderCore.cs b/src/ImageSharp/Formats/Webp/WebpDecoderCore.cs index 09071406c5..9d18e5d821 100644 --- a/src/ImageSharp/Formats/Webp/WebpDecoderCore.cs +++ b/src/ImageSharp/Formats/Webp/WebpDecoderCore.cs @@ -13,6 +13,7 @@ using SixLabors.ImageSharp.Metadata; using SixLabors.ImageSharp.Metadata.Profiles.Exif; using SixLabors.ImageSharp.Metadata.Profiles.Icc; +using SixLabors.ImageSharp.Metadata.Profiles.Xmp; using SixLabors.ImageSharp.PixelFormats; namespace SixLabors.ImageSharp.Formats.Webp @@ -177,6 +178,7 @@ private WebpImageInfo ReadVp8Info() /// Reads an the extended webp file header. An extended file header consists of: /// - A 'VP8X' chunk with information about features used in the file. /// - An optional 'ICCP' chunk with color profile. + /// - An optional 'XMP' chunk with metadata. /// - An optional 'ANIM' chunk with animation control data. /// - An optional 'ALPH' chunk with alpha channel data. /// After the image header, image data will follow. After that optional image metadata chunks (EXIF and XMP) can follow. @@ -228,12 +230,27 @@ private WebpImageInfo ReadVp8XHeader() this.buffer[3] = 0; uint height = (uint)BinaryPrimitives.ReadInt32LittleEndian(this.buffer) + 1; - // Optional chunks ICCP, ALPH and ANIM can follow here. - WebpChunkType chunkType = this.ReadChunkType(); - while (IsOptionalVp8XChunk(chunkType)) + // Read all the chunks in the order they occur. + var info = new WebpImageInfo(); + while (this.currentStream.Position < this.currentStream.Length) { - this.ParseOptionalExtendedChunks(chunkType, features); - chunkType = this.ReadChunkType(); + WebpChunkType chunkType = this.ReadChunkType(); + if (chunkType == WebpChunkType.Vp8) + { + info = this.ReadVp8Header(features); + } + else if (chunkType == WebpChunkType.Vp8L) + { + info = this.ReadVp8LHeader(features); + } + else if (IsOptionalVp8XChunk(chunkType)) + { + this.ParseOptionalExtendedChunks(chunkType, features); + } + else + { + WebpThrowHelper.ThrowImageFormatException("Unexpected chunk followed VP8X header"); + } } if (features.Animation) @@ -242,17 +259,7 @@ private WebpImageInfo ReadVp8XHeader() return new WebpImageInfo() { Width = width, Height = height, Features = features }; } - switch (chunkType) - { - case WebpChunkType.Vp8: - return this.ReadVp8Header(features); - case WebpChunkType.Vp8L: - return this.ReadVp8LHeader(features); - } - - WebpThrowHelper.ThrowImageFormatException("Unexpected chunk followed VP8X header"); - - return new WebpImageInfo(); + return info; } /// @@ -413,7 +420,7 @@ private WebpImageInfo ReadVp8LHeader(WebpFeatures features = null) } /// - /// Parses optional VP8X chunks, which can be ICCP, ANIM or ALPH chunks. + /// Parses optional VP8X chunks, which can be ICCP, XMP, ANIM or ALPH chunks. /// /// The chunk type. /// The webp image features. @@ -440,6 +447,38 @@ private void ParseOptionalExtendedChunks(WebpChunkType chunkType, WebpFeatures f break; + case WebpChunkType.Exif: + uint exifChunkSize = this.ReadChunkSize(); + if (this.IgnoreMetadata) + { + this.currentStream.Skip((int)exifChunkSize); + } + else + { + byte[] exifData = new byte[exifChunkSize]; + this.currentStream.Read(exifData, 0, (int)exifChunkSize); + var profile = new ExifProfile(exifData); + this.Metadata.ExifProfile = profile; + } + + break; + + case WebpChunkType.Xmp: + uint xmpChunkSize = this.ReadChunkSize(); + if (this.IgnoreMetadata) + { + this.currentStream.Skip((int)xmpChunkSize); + } + else + { + byte[] xmpData = new byte[xmpChunkSize]; + this.currentStream.Read(xmpData, 0, (int)xmpChunkSize); + var profile = new XmpProfile(xmpData); + this.Metadata.XmpProfile = profile; + } + + break; + case WebpChunkType.Animation: // TODO: Decoding animation is not implemented yet. break; @@ -451,6 +490,9 @@ private void ParseOptionalExtendedChunks(WebpChunkType chunkType, WebpFeatures f features.AlphaData = this.memoryAllocator.Allocate(alphaDataSize); this.currentStream.Read(features.AlphaData.Memory.Span, 0, alphaDataSize); break; + default: + WebpThrowHelper.ThrowImageFormatException("Unexpected chunk followed VP8X header"); + break; } } @@ -530,7 +572,9 @@ private uint ReadChunkSize() { WebpChunkType.Alpha => true, WebpChunkType.Animation => true, + WebpChunkType.Exif => true, WebpChunkType.Iccp => true, + WebpChunkType.Xmp => true, _ => false }; } diff --git a/src/ImageSharp/IO/BufferedReadStream.cs b/src/ImageSharp/IO/BufferedReadStream.cs index acba3eff0a..4ab7f312b2 100644 --- a/src/ImageSharp/IO/BufferedReadStream.cs +++ b/src/ImageSharp/IO/BufferedReadStream.cs @@ -87,7 +87,6 @@ public override long Position set { Guard.MustBeGreaterThanOrEqualTo(value, 0, nameof(this.Position)); - Guard.MustBeLessThanOrEqualTo(value, this.Length, nameof(this.Position)); // Only reset readBufferIndex if we are out of bounds of our working buffer // otherwise we should simply move the value by the diff. diff --git a/src/ImageSharp/Metadata/ImageFrameMetadata.cs b/src/ImageSharp/Metadata/ImageFrameMetadata.cs index 1819fd2bc5..1cad4ebe86 100644 --- a/src/ImageSharp/Metadata/ImageFrameMetadata.cs +++ b/src/ImageSharp/Metadata/ImageFrameMetadata.cs @@ -1,12 +1,12 @@ // Copyright (c) Six Labors. // Licensed under the Apache License, Version 2.0. -using System; using System.Collections.Generic; using SixLabors.ImageSharp.Formats; using SixLabors.ImageSharp.Metadata.Profiles.Exif; using SixLabors.ImageSharp.Metadata.Profiles.Icc; using SixLabors.ImageSharp.Metadata.Profiles.Iptc; +using SixLabors.ImageSharp.Metadata.Profiles.Xmp; namespace SixLabors.ImageSharp.Metadata { @@ -43,8 +43,7 @@ internal ImageFrameMetadata(ImageFrameMetadata other) this.ExifProfile = other.ExifProfile?.DeepClone(); this.IccProfile = other.IccProfile?.DeepClone(); this.IptcProfile = other.IptcProfile?.DeepClone(); - this.XmpProfile = other.XmpProfile != null ? new byte[other.XmpProfile.Length] : null; - other.XmpProfile?.AsSpan().CopyTo(this.XmpProfile.AsSpan()); + this.XmpProfile = other.XmpProfile?.DeepClone(); } /// @@ -55,7 +54,7 @@ internal ImageFrameMetadata(ImageFrameMetadata other) /// /// Gets or sets the XMP profile. /// - internal byte[] XmpProfile { get; set; } + public XmpProfile XmpProfile { get; set; } /// /// Gets or sets the list of ICC profiles. diff --git a/src/ImageSharp/Metadata/ImageMetadata.cs b/src/ImageSharp/Metadata/ImageMetadata.cs index 425fd9b47f..b7ab23c2ba 100644 --- a/src/ImageSharp/Metadata/ImageMetadata.cs +++ b/src/ImageSharp/Metadata/ImageMetadata.cs @@ -6,6 +6,7 @@ using SixLabors.ImageSharp.Metadata.Profiles.Exif; using SixLabors.ImageSharp.Metadata.Profiles.Icc; using SixLabors.ImageSharp.Metadata.Profiles.Iptc; +using SixLabors.ImageSharp.Metadata.Profiles.Xmp; namespace SixLabors.ImageSharp.Metadata { @@ -119,13 +120,18 @@ public double VerticalResolution /// public ExifProfile ExifProfile { get; set; } + /// + /// Gets or sets the XMP profile. + /// + public XmpProfile XmpProfile { get; set; } + /// /// Gets or sets the list of ICC profiles. /// public IccProfile IccProfile { get; set; } /// - /// Gets or sets the iptc profile. + /// Gets or sets the IPTC profile. /// public IptcProfile IptcProfile { get; set; } diff --git a/src/ImageSharp/Metadata/Profiles/XMP/XmpProfile.cs b/src/ImageSharp/Metadata/Profiles/XMP/XmpProfile.cs new file mode 100644 index 0000000000..8fba243ce2 --- /dev/null +++ b/src/ImageSharp/Metadata/Profiles/XMP/XmpProfile.cs @@ -0,0 +1,89 @@ +// Copyright (c) Six Labors. +// Licensed under the Apache License, Version 2.0. + +using System; +using System.IO; +using System.Text; +using System.Xml.Linq; + +namespace SixLabors.ImageSharp.Metadata.Profiles.Xmp +{ + /// + /// Represents an XMP profile, providing access to the raw XML. + /// See for the full specification. + /// + public sealed class XmpProfile : IDeepCloneable + { + /// + /// Initializes a new instance of the class. + /// + public XmpProfile() + : this((byte[])null) + { + } + + /// + /// Initializes a new instance of the class. + /// + /// The UTF8 encoded byte array to read the XMP profile from. + public XmpProfile(byte[] data) => this.Data = data; + + /// + /// Initializes a new instance of the class + /// by making a copy from another XMP profile. + /// + /// The other XMP profile, from which the clone should be made from. + private XmpProfile(XmpProfile other) + { + Guard.NotNull(other, nameof(other)); + + this.Data = other.Data; + } + + /// + /// Gets the XMP raw data byte array. + /// + internal byte[] Data { get; private set; } + + /// + /// Gets the raw XML document containing the XMP profile. + /// + /// The + public XDocument GetDocument() + { + byte[] byteArray = this.Data; + if (byteArray is null) + { + return null; + } + + // Strip leading whitespace, as the XmlReader doesn't like them. + int count = byteArray.Length; + for (int i = count - 1; i > 0; i--) + { + if (byteArray[i] is 0 or 0x0f) + { + count--; + } + } + + using var stream = new MemoryStream(byteArray, 0, count); + using var reader = new StreamReader(stream, Encoding.UTF8); + return XDocument.Load(reader); + } + + /// + /// Convert the content of this into a byte array. + /// + /// The + public byte[] ToByteArray() + { + byte[] result = new byte[this.Data.Length]; + this.Data.AsSpan().CopyTo(result); + return result; + } + + /// + public XmpProfile DeepClone() => new(this); + } +} diff --git a/tests/ImageSharp.Tests/Formats/Tiff/TiffMetadataTests.cs b/tests/ImageSharp.Tests/Formats/Tiff/TiffMetadataTests.cs index 7715ac3a38..6a47a95771 100644 --- a/tests/ImageSharp.Tests/Formats/Tiff/TiffMetadataTests.cs +++ b/tests/ImageSharp.Tests/Formats/Tiff/TiffMetadataTests.cs @@ -10,6 +10,7 @@ using SixLabors.ImageSharp.Metadata; using SixLabors.ImageSharp.Metadata.Profiles.Exif; using SixLabors.ImageSharp.Metadata.Profiles.Iptc; +using SixLabors.ImageSharp.Metadata.Profiles.Xmp; using SixLabors.ImageSharp.PixelFormats; using Xunit; @@ -132,7 +133,7 @@ public void MetadataProfiles(TestImageProvider provider, bool ig { Assert.NotNull(rootFrameMetaData.XmpProfile); Assert.NotNull(rootFrameMetaData.ExifProfile); - Assert.Equal(2599, rootFrameMetaData.XmpProfile.Length); + Assert.Equal(2599, rootFrameMetaData.XmpProfile.Data.Length); Assert.Equal(26, rootFrameMetaData.ExifProfile.Values.Count); } } @@ -163,7 +164,7 @@ public void BaselineTags(TestImageProvider provider) Assert.Equal(32, rootFrame.Width); Assert.Equal(32, rootFrame.Height); Assert.NotNull(rootFrame.Metadata.XmpProfile); - Assert.Equal(2599, rootFrame.Metadata.XmpProfile.Length); + Assert.Equal(2599, rootFrame.Metadata.XmpProfile.Data.Length); ExifProfile exifProfile = rootFrame.Metadata.ExifProfile; TiffFrameMetadata tiffFrameMetadata = rootFrame.Metadata.GetTiffMetadata(); @@ -251,7 +252,7 @@ public void Encode_PreservesMetadata(TestImageProvider provider) ImageMetadata inputMetaData = image.Metadata; ImageFrame rootFrameInput = image.Frames.RootFrame; TiffFrameMetadata frameMetaInput = rootFrameInput.Metadata.GetTiffMetadata(); - byte[] xmpProfileInput = rootFrameInput.Metadata.XmpProfile; + XmpProfile xmpProfileInput = rootFrameInput.Metadata.XmpProfile; ExifProfile exifProfileInput = rootFrameInput.Metadata.ExifProfile; Assert.Equal(TiffCompression.Lzw, frameMetaInput.Compression); @@ -270,7 +271,7 @@ public void Encode_PreservesMetadata(TestImageProvider provider) ImageFrame rootFrameEncodedImage = encodedImage.Frames.RootFrame; TiffFrameMetadata tiffMetaDataEncodedRootFrame = rootFrameEncodedImage.Metadata.GetTiffMetadata(); ExifProfile encodedImageExifProfile = rootFrameEncodedImage.Metadata.ExifProfile; - byte[] encodedImageXmpProfile = rootFrameEncodedImage.Metadata.XmpProfile; + XmpProfile encodedImageXmpProfile = rootFrameEncodedImage.Metadata.XmpProfile; Assert.Equal(TiffBitsPerPixel.Bit4, tiffMetaDataEncodedRootFrame.BitsPerPixel); Assert.Equal(TiffCompression.Lzw, tiffMetaDataEncodedRootFrame.Compression); @@ -288,7 +289,9 @@ public void Encode_PreservesMetadata(TestImageProvider provider) Assert.Equal(exifProfileInput.GetValue(ExifTag.XResolution).Value.ToDouble(), encodedImageExifProfile.GetValue(ExifTag.XResolution).Value.ToDouble()); Assert.Equal(exifProfileInput.GetValue(ExifTag.YResolution).Value.ToDouble(), encodedImageExifProfile.GetValue(ExifTag.YResolution).Value.ToDouble()); - Assert.Equal(xmpProfileInput, encodedImageXmpProfile); + Assert.NotNull(xmpProfileInput); + Assert.NotNull(encodedImageXmpProfile); + Assert.Equal(xmpProfileInput.Data, encodedImageXmpProfile.Data); Assert.Equal("IrfanView", exifProfileInput.GetValue(ExifTag.Software).Value); Assert.Equal("This is Название", exifProfileInput.GetValue(ExifTag.ImageDescription).Value); diff --git a/tests/ImageSharp.Tests/Formats/WebP/WebpMetaDataTests.cs b/tests/ImageSharp.Tests/Formats/WebP/WebpMetaDataTests.cs index a051de1c01..7fba86b4fe 100644 --- a/tests/ImageSharp.Tests/Formats/WebP/WebpMetaDataTests.cs +++ b/tests/ImageSharp.Tests/Formats/WebP/WebpMetaDataTests.cs @@ -63,6 +63,26 @@ public void IgnoreMetadata_ControlsWhetherIccpIsParsed(TestImageProvider } } + [Theory] + [WithFile(TestImages.Webp.Lossy.WithXmp, PixelTypes.Rgba32, false)] + [WithFile(TestImages.Webp.Lossy.WithXmp, PixelTypes.Rgba32, true)] + public async void IgnoreMetadata_ControlsWhetherXmpIsParsed(TestImageProvider provider, bool ignoreMetadata) + where TPixel : unmanaged, IPixel + { + var decoder = new WebpDecoder { IgnoreMetadata = ignoreMetadata }; + + using Image image = await provider.GetImageAsync(decoder); + if (ignoreMetadata) + { + Assert.Null(image.Metadata.XmpProfile); + } + else + { + Assert.NotNull(image.Metadata.XmpProfile); + Assert.NotEmpty(image.Metadata.XmpProfile.Data); + } + } + [Theory] [InlineData(WebpFileFormatType.Lossy)] [InlineData(WebpFileFormatType.Lossless)] diff --git a/tests/ImageSharp.Tests/IO/BufferedReadStreamTests.cs b/tests/ImageSharp.Tests/IO/BufferedReadStreamTests.cs index 8e73218647..f968b16f00 100644 --- a/tests/ImageSharp.Tests/IO/BufferedReadStreamTests.cs +++ b/tests/ImageSharp.Tests/IO/BufferedReadStreamTests.cs @@ -314,7 +314,7 @@ public void BufferedStreamReadsCanReadAllAsSingleByteFromOrigin(int bufferSize) [Theory] [MemberData(nameof(BufferSizes))] - public void BufferedStreamThrowsOnBadPosition(int bufferSize) + public void BufferedStreamThrowsOnNegativePosition(int bufferSize) { this.configuration.StreamProcessingBufferSize = bufferSize; using (MemoryStream stream = this.CreateTestStream(bufferSize)) @@ -322,15 +322,14 @@ public void BufferedStreamThrowsOnBadPosition(int bufferSize) using (var reader = new BufferedReadStream(this.configuration, stream)) { Assert.Throws(() => reader.Position = -stream.Length); - Assert.Throws(() => reader.Position = stream.Length + 1); } } } - [Fact] - public void BufferedStreamCanSetPositionToEnd() + [Theory] + [MemberData(nameof(BufferSizes))] + public void BufferedStreamCanSetPositionToEnd(int bufferSize) { - var bufferSize = 8; this.configuration.StreamProcessingBufferSize = bufferSize; using (MemoryStream stream = this.CreateTestStream(bufferSize * 2)) { @@ -341,6 +340,21 @@ public void BufferedStreamCanSetPositionToEnd() } } + [Theory] + [MemberData(nameof(BufferSizes))] + public void BufferedStreamCanSetPositionPastTheEnd(int bufferSize) + { + this.configuration.StreamProcessingBufferSize = bufferSize; + using (MemoryStream stream = this.CreateTestStream(bufferSize * 2)) + { + using (var reader = new BufferedReadStream(this.configuration, stream)) + { + reader.Position = reader.Length + 1; + Assert.Equal(stream.Length + 1, stream.Position); + } + } + } + private MemoryStream CreateTestStream(int length) { var buffer = new byte[length]; diff --git a/tests/ImageSharp.Tests/Metadata/ImageFrameMetadataTests.cs b/tests/ImageSharp.Tests/Metadata/ImageFrameMetadataTests.cs index f1a90d43e7..dd8ae3d5ac 100644 --- a/tests/ImageSharp.Tests/Metadata/ImageFrameMetadataTests.cs +++ b/tests/ImageSharp.Tests/Metadata/ImageFrameMetadataTests.cs @@ -1,10 +1,10 @@ // Copyright (c) Six Labors. // Licensed under the Apache License, Version 2.0. -using System.Linq; using SixLabors.ImageSharp.Formats.Gif; using SixLabors.ImageSharp.Metadata; using SixLabors.ImageSharp.Metadata.Profiles.Icc; +using SixLabors.ImageSharp.Metadata.Profiles.Xmp; using Xunit; using ExifProfile = SixLabors.ImageSharp.Metadata.Profiles.Exif.ExifProfile; using ExifTag = SixLabors.ImageSharp.Metadata.Profiles.Exif.ExifTag; @@ -41,10 +41,10 @@ public void ConstructorImageFrameMetadata() public void CloneIsDeep() { // arrange - byte[] xmpProfile = { 1, 2, 3 }; var exifProfile = new ExifProfile(); exifProfile.SetValue(ExifTag.Software, "UnitTest"); exifProfile.SetValue(ExifTag.Artist, "UnitTest"); + var xmpProfile = new XmpProfile(new byte[0]); var iccProfile = new IccProfile() { Header = new IccProfileHeader() @@ -72,8 +72,8 @@ public void CloneIsDeep() Assert.NotNull(clone.IptcProfile); Assert.False(metaData.ExifProfile.Equals(clone.ExifProfile)); Assert.True(metaData.ExifProfile.Values.Count == clone.ExifProfile.Values.Count); - Assert.False(metaData.XmpProfile.Equals(clone.XmpProfile)); - Assert.True(metaData.XmpProfile.SequenceEqual(clone.XmpProfile)); + Assert.False(ReferenceEquals(metaData.XmpProfile, clone.XmpProfile)); + Assert.True(metaData.XmpProfile.Data.Equals(clone.XmpProfile.Data)); Assert.False(metaData.GetGifMetadata().Equals(clone.GetGifMetadata())); Assert.False(metaData.IccProfile.Equals(clone.IccProfile)); Assert.False(metaData.IptcProfile.Equals(clone.IptcProfile)); diff --git a/tests/ImageSharp.Tests/Metadata/Profiles/XMP/XmpProfileTests.cs b/tests/ImageSharp.Tests/Metadata/Profiles/XMP/XmpProfileTests.cs new file mode 100644 index 0000000000..81dad699a1 --- /dev/null +++ b/tests/ImageSharp.Tests/Metadata/Profiles/XMP/XmpProfileTests.cs @@ -0,0 +1,260 @@ +// Copyright (c) Six Labors. +// Licensed under the Apache License, Version 2.0. + +using System; +using System.IO; +using System.Text; +using System.Xml.Linq; +using SixLabors.ImageSharp.Formats; +using SixLabors.ImageSharp.Formats.Gif; +using SixLabors.ImageSharp.Formats.Jpeg; +using SixLabors.ImageSharp.Formats.Png; +using SixLabors.ImageSharp.Formats.Tiff; +using SixLabors.ImageSharp.Formats.Webp; +using SixLabors.ImageSharp.Metadata.Profiles.Xmp; +using SixLabors.ImageSharp.PixelFormats; +using Xunit; + +namespace SixLabors.ImageSharp.Tests.Metadata.Profiles.Xmp +{ + public class XmpProfileTests + { + private static GifDecoder GifDecoder => new() { IgnoreMetadata = false }; + + private static JpegDecoder JpegDecoder => new() { IgnoreMetadata = false }; + + private static PngDecoder PngDecoder => new() { IgnoreMetadata = false }; + + private static TiffDecoder TiffDecoder => new() { IgnoreMetadata = false }; + + private static WebpDecoder WebpDecoder => new() { IgnoreMetadata = false }; + + [Theory] + [WithFile(TestImages.Gif.Receipt, PixelTypes.Rgba32)] + public async void ReadXmpMetadata_FromGif_Works(TestImageProvider provider) + where TPixel : unmanaged, IPixel + { + using (Image image = await provider.GetImageAsync(GifDecoder)) + { + XmpProfile actual = image.Metadata.XmpProfile ?? image.Frames.RootFrame.Metadata.XmpProfile; + XmpProfileContainsExpectedValues(actual); + } + } + + [Theory] + [WithFile(TestImages.Jpeg.Baseline.Lake, PixelTypes.Rgba32)] + [WithFile(TestImages.Jpeg.Baseline.Metadata, PixelTypes.Rgba32)] + [WithFile(TestImages.Jpeg.Baseline.ExtendedXmp, PixelTypes.Rgba32)] + public async void ReadXmpMetadata_FromJpg_Works(TestImageProvider provider) + where TPixel : unmanaged, IPixel + { + using (Image image = await provider.GetImageAsync(JpegDecoder)) + { + XmpProfile actual = image.Metadata.XmpProfile ?? image.Frames.RootFrame.Metadata.XmpProfile; + XmpProfileContainsExpectedValues(actual); + } + } + + [Theory] + [WithFile(TestImages.Png.XmpColorPalette, PixelTypes.Rgba32)] + public async void ReadXmpMetadata_FromPng_Works(TestImageProvider provider) + where TPixel : unmanaged, IPixel + { + using (Image image = await provider.GetImageAsync(PngDecoder)) + { + XmpProfile actual = image.Metadata.XmpProfile ?? image.Frames.RootFrame.Metadata.XmpProfile; + XmpProfileContainsExpectedValues(actual); + } + } + + [Theory] + [WithFile(TestImages.Tiff.SampleMetadata, PixelTypes.Rgba32)] + public async void ReadXmpMetadata_FromTiff_Works(TestImageProvider provider) + where TPixel : unmanaged, IPixel + { + using (Image image = await provider.GetImageAsync(TiffDecoder)) + { + XmpProfile actual = image.Metadata.XmpProfile ?? image.Frames.RootFrame.Metadata.XmpProfile; + XmpProfileContainsExpectedValues(actual); + } + } + + [Theory] + [WithFile(TestImages.Webp.Lossy.WithXmp, PixelTypes.Rgba32)] + public async void ReadXmpMetadata_FromWebp_Works(TestImageProvider provider) + where TPixel : unmanaged, IPixel + { + using (Image image = await provider.GetImageAsync(WebpDecoder)) + { + XmpProfile actual = image.Metadata.XmpProfile ?? image.Frames.RootFrame.Metadata.XmpProfile; + XmpProfileContainsExpectedValues(actual); + } + } + + [Fact] + public void XmpProfile_ToFromByteArray_ReturnsClone() + { + // arrange + XmpProfile profile = CreateMinimalXmlProfile(); + byte[] original = profile.ToByteArray(); + + // act + byte[] actual = profile.ToByteArray(); + + // assert + Assert.False(ReferenceEquals(original, actual)); + } + + [Fact] + public void XmpProfile_CloneIsDeep() + { + // arrange + XmpProfile profile = CreateMinimalXmlProfile(); + byte[] original = profile.ToByteArray(); + + // act + XmpProfile clone = profile.DeepClone(); + byte[] actual = clone.ToByteArray(); + + // assert + Assert.False(ReferenceEquals(original, actual)); + } + + [Fact] + public void WritingGif_PreservesXmpProfile() + { + // arrange + var image = new Image(1, 1); + XmpProfile original = CreateMinimalXmlProfile(); + image.Metadata.XmpProfile = original; + var encoder = new GifEncoder(); + + // act + using Image reloadedImage = WriteAndRead(image, encoder); + + // assert + XmpProfile actual = reloadedImage.Metadata.XmpProfile ?? reloadedImage.Frames.RootFrame.Metadata.XmpProfile; + XmpProfileContainsExpectedValues(actual); + Assert.Equal(original.Data, actual.Data); + } + + [Fact] + public void WritingJpeg_PreservesXmpProfile() + { + // arrange + var image = new Image(1, 1); + XmpProfile original = CreateMinimalXmlProfile(); + image.Metadata.XmpProfile = original; + var encoder = new JpegEncoder(); + + // act + using Image reloadedImage = WriteAndRead(image, encoder); + + // assert + XmpProfile actual = reloadedImage.Metadata.XmpProfile ?? reloadedImage.Frames.RootFrame.Metadata.XmpProfile; + XmpProfileContainsExpectedValues(actual); + Assert.Equal(original.Data, actual.Data); + } + + [Fact] + public async void WritingJpeg_PreservesExtendedXmpProfile() + { + // arrange + var provider = TestImageProvider.File(TestImages.Jpeg.Baseline.ExtendedXmp); + using Image image = await provider.GetImageAsync(JpegDecoder); + XmpProfile original = image.Metadata.XmpProfile; + var encoder = new JpegEncoder(); + + // act + using Image reloadedImage = WriteAndRead(image, encoder); + + // assert + XmpProfile actual = reloadedImage.Metadata.XmpProfile ?? reloadedImage.Frames.RootFrame.Metadata.XmpProfile; + XmpProfileContainsExpectedValues(actual); + Assert.Equal(original.Data, actual.Data); + } + + [Fact] + public void WritingPng_PreservesXmpProfile() + { + // arrange + var image = new Image(1, 1); + XmpProfile original = CreateMinimalXmlProfile(); + image.Metadata.XmpProfile = original; + var encoder = new PngEncoder(); + + // act + using Image reloadedImage = WriteAndRead(image, encoder); + + // assert + XmpProfile actual = reloadedImage.Metadata.XmpProfile ?? reloadedImage.Frames.RootFrame.Metadata.XmpProfile; + XmpProfileContainsExpectedValues(actual); + Assert.Equal(original.Data, actual.Data); + } + + [Fact] + public void WritingTiff_PreservesXmpProfile() + { + // arrange + var image = new Image(1, 1); + XmpProfile original = CreateMinimalXmlProfile(); + image.Frames.RootFrame.Metadata.XmpProfile = original; + var encoder = new TiffEncoder(); + + // act + using Image reloadedImage = WriteAndRead(image, encoder); + + // assert + XmpProfile actual = reloadedImage.Metadata.XmpProfile ?? reloadedImage.Frames.RootFrame.Metadata.XmpProfile; + XmpProfileContainsExpectedValues(actual); + Assert.Equal(original.Data, actual.Data); + } + + [Fact] + public void WritingWebp_PreservesXmpProfile() + { + // arrange + var image = new Image(1, 1); + XmpProfile original = CreateMinimalXmlProfile(); + image.Metadata.XmpProfile = original; + var encoder = new WebpEncoder(); + + // act + using Image reloadedImage = WriteAndRead(image, encoder); + + // assert + XmpProfile actual = reloadedImage.Metadata.XmpProfile ?? reloadedImage.Frames.RootFrame.Metadata.XmpProfile; + XmpProfileContainsExpectedValues(actual); + Assert.Equal(original.Data, actual.Data); + } + + private static void XmpProfileContainsExpectedValues(XmpProfile xmp) + { + Assert.NotNull(xmp); + XDocument document = xmp.GetDocument(); + Assert.NotNull(document); + Assert.Equal("xmpmeta", document.Root.Name.LocalName); + Assert.Equal("adobe:ns:meta/", document.Root.Name.NamespaceName); + } + + private static XmpProfile CreateMinimalXmlProfile() + { + string content = $" "; + byte[] data = Encoding.UTF8.GetBytes(content); + var profile = new XmpProfile(data); + return profile; + } + + private static Image WriteAndRead(Image image, IImageEncoder encoder) + { + using (var memStream = new MemoryStream()) + { + image.Save(memStream, encoder); + image.Dispose(); + + memStream.Position = 0; + return Image.Load(memStream); + } + } + } +} diff --git a/tests/ImageSharp.Tests/TestImages.cs b/tests/ImageSharp.Tests/TestImages.cs index deed0b2404..3fb07e12d0 100644 --- a/tests/ImageSharp.Tests/TestImages.cs +++ b/tests/ImageSharp.Tests/TestImages.cs @@ -61,6 +61,7 @@ public static class Png public const string David = "Png/david.png"; public const string TestPattern31x31 = "Png/testpattern31x31.png"; public const string TestPattern31x31HalfTransparent = "Png/testpattern31x31-halftransparent.png"; + public const string XmpColorPalette = "Png/xmp-colorpalette.png"; // Filtered test images from http://www.schaik.com/pngsuite/pngsuite_fil_png.html public const string Filter0 = "Png/filter0.png"; @@ -218,6 +219,8 @@ public static class Bad public const string ArithmeticCodingProgressive = "Jpg/progressive/arithmetic_progressive.jpg"; public const string Lossless = "Jpg/baseline/lossless.jpg"; public const string Winter444_Interleaved = "Jpg/baseline/winter444_interleaved.jpg"; + public const string Metadata = "Jpg/baseline/Metadata-test-file.jpg"; + public const string ExtendedXmp = "Jpg/baseline/extended-xmp.jpg"; public static readonly string[] All = { @@ -625,6 +628,7 @@ public static class Lossy public const string Earth = "Webp/earth_lossy.webp"; public const string WithExif = "Webp/exif_lossy.webp"; public const string WithIccp = "Webp/lossy_with_iccp.webp"; + public const string WithXmp = "Webp/xmp_lossy.webp"; public const string BikeSmall = "Webp/bike_lossless_small.webp"; // Lossy images without macroblock filtering. diff --git a/tests/Images/Input/Jpg/baseline/Metadata-test-file.jpg b/tests/Images/Input/Jpg/baseline/Metadata-test-file.jpg new file mode 100644 index 0000000000..160d7ebf81 --- /dev/null +++ b/tests/Images/Input/Jpg/baseline/Metadata-test-file.jpg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:800911efb6f0c796d61b5ea14fc67fe891aaae3c04a49cfd5b86e68958598436 +size 138810 diff --git a/tests/Images/Input/Jpg/baseline/extended-xmp.jpg b/tests/Images/Input/Jpg/baseline/extended-xmp.jpg new file mode 100644 index 0000000000..6fc84b95eb --- /dev/null +++ b/tests/Images/Input/Jpg/baseline/extended-xmp.jpg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:000c67f210059b101570949e889846bb11d6bdc801bd641d7d26424ad9cd027f +size 623986 diff --git a/tests/Images/Input/Png/xmp-colorpalette.png b/tests/Images/Input/Png/xmp-colorpalette.png new file mode 100644 index 0000000000..375879413b --- /dev/null +++ b/tests/Images/Input/Png/xmp-colorpalette.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:fb55607fd7de6a47d8dd242c1a7be9627c564821554db896ed46603d15963c06 +size 1025 diff --git a/tests/Images/Input/Webp/xmp_lossy.webp b/tests/Images/Input/Webp/xmp_lossy.webp new file mode 100644 index 0000000000..4e92f280c3 --- /dev/null +++ b/tests/Images/Input/Webp/xmp_lossy.webp @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:755a63652695d7e190f375c9c0697cd37c9b601cd54405c704ec8efc200e67fc +size 474772