diff --git a/src/ImageSharp/Formats/Png/PngConstants.cs b/src/ImageSharp/Formats/Png/PngConstants.cs index 17d13e86df..c5d8879853 100644 --- a/src/ImageSharp/Formats/Png/PngConstants.cs +++ b/src/ImageSharp/Formats/Png/PngConstants.cs @@ -62,6 +62,21 @@ internal static class PngConstants /// public const int MinTextKeywordLength = 1; + /// + /// Specifies the keyword used to identify the Exif raw profile in image metadata. + /// + public const string ExifRawProfileKeyword = "Raw profile type exif"; + + /// + /// Specifies the profile keyword used to identify raw IPTC metadata within image files. + /// + public const string IptcRawProfileKeyword = "Raw profile type iptc"; + + /// + /// The IPTC resource id in Photoshop IRB. 0x0404 (big endian). + /// + public const ushort AdobeIptcResourceId = 0x0404; + /// /// Gets the header bytes identifying a Png. /// @@ -100,4 +115,31 @@ internal static class PngConstants (byte)'m', (byte)'p' ]; + + /// + /// Gets the ASCII bytes for the "Photoshop 3.0" identifier used in some PNG metadata payloads. + /// This value is null-terminated. + /// + public static ReadOnlySpan AdobePhotoshop30 => + [ + (byte)'P', + (byte)'h', + (byte)'o', + (byte)'t', + (byte)'o', + (byte)'s', + (byte)'h', + (byte)'o', + (byte)'p', + (byte)' ', + (byte)'3', + (byte)'.', + (byte)'0', + 0 + ]; + + /// + /// Gets the ASCII bytes for the "8BIM" signature used in Photoshop resources. + /// + public static ReadOnlySpan EightBim => [(byte)'8', (byte)'B', (byte)'I', (byte)'M']; } diff --git a/src/ImageSharp/Formats/Png/PngDecoderCore.cs b/src/ImageSharp/Formats/Png/PngDecoderCore.cs index bff4d30ee5..271474a7e5 100644 --- a/src/ImageSharp/Formats/Png/PngDecoderCore.cs +++ b/src/ImageSharp/Formats/Png/PngDecoderCore.cs @@ -21,6 +21,7 @@ using SixLabors.ImageSharp.Metadata.Profiles.Cicp; 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; @@ -1440,14 +1441,19 @@ private void ReadCompressedTextChunk(ImageMetadata baseMetadata, PngMetadata met /// object unmodified. private static bool TryReadTextChunkMetadata(ImageMetadata baseMetadata, string chunkName, string chunkText) { - if (chunkName.Equals("Raw profile type exif", StringComparison.OrdinalIgnoreCase) && + if (chunkName.Equals(PngConstants.ExifRawProfileKeyword, StringComparison.OrdinalIgnoreCase) && TryReadLegacyExifTextChunk(baseMetadata, chunkText)) { // Successfully parsed legacy exif data from text return true; } - // TODO: "Raw profile type iptc", potentially others? + if (chunkName.Equals(PngConstants.IptcRawProfileKeyword, StringComparison.OrdinalIgnoreCase) && + TryReadLegacyIptcTextChunk(baseMetadata, chunkText)) + { + // Successfully parsed legacy iptc data from text + return true; + } // No special chunk data identified return false; @@ -1571,6 +1577,214 @@ private static bool TryReadLegacyExifTextChunk(ImageMetadata metadata, string da return true; } + /// + /// Reads iptc data encoded into a text chunk with the name "Raw profile type iptc". + /// This convention is used by ImageMagick/exiftool/exiv2/digiKam and stores a byte-count + /// followed by hex-encoded bytes. + /// + /// The to store the decoded iptc tags into. + /// The contents of the "Raw profile type iptc" text chunk. + private static bool TryReadLegacyIptcTextChunk(ImageMetadata metadata, string data) + { + // Preserve first IPTC found. + if (metadata.IptcProfile != null) + { + return true; + } + + ReadOnlySpan dataSpan = data.AsSpan().TrimStart(); + + // Must start with the "iptc" identifier (case-insensitive). + // Common real-world format (ImageMagick/ExifTool) is: + // "IPTC profile\n \n" + if (dataSpan.Length < 4 || !StringEqualsInsensitive(dataSpan[..4], "iptc".AsSpan())) + { + return false; + } + + // Skip the remainder of the first line ("IPTC profile", etc). + int firstLineEnd = dataSpan.IndexOf('\n'); + if (firstLineEnd < 0) + { + return false; + } + + dataSpan = dataSpan[(firstLineEnd + 1)..].TrimStart(); + + // Next line contains the decimal byte length (often indented). + int dataLengthEnd = dataSpan.IndexOf('\n'); + if (dataLengthEnd < 0) + { + return false; + } + + int dataLength; + try + { + dataLength = ParseInt32(dataSpan[..dataLengthEnd]); + } + catch + { + return false; + } + + if (dataLength <= 0) + { + return false; + } + + // Skip to the hex-encoded data. + dataSpan = dataSpan[(dataLengthEnd + 1)..].Trim(); + + byte[] iptcBlob = new byte[dataLength]; + + try + { + int written = 0; + + for (; written < dataLength;) + { + ReadOnlySpan lineSpan = dataSpan; + + int newlineIndex = dataSpan.IndexOf('\n'); + if (newlineIndex != -1) + { + lineSpan = dataSpan[..newlineIndex]; + } + + // Important: handle CRLF and any incidental whitespace. + lineSpan = lineSpan.Trim(); // removes ' ', '\t', '\r', '\n', etc. + + if (!lineSpan.IsEmpty) + { + written += HexConverter.HexStringToBytes(lineSpan, iptcBlob.AsSpan()[written..]); + } + + if (newlineIndex == -1) + { + break; + } + + dataSpan = dataSpan[(newlineIndex + 1)..]; + } + + if (written != dataLength) + { + return false; + } + } + catch + { + return false; + } + + // Prefer IRB extraction if this is Photoshop-style data (8BIM resource blocks). + byte[] iptcPayload = TryExtractIptcFromPhotoshopIrb(iptcBlob, out byte[] extracted) + ? extracted + : iptcBlob; + + metadata.IptcProfile = new IptcProfile(iptcPayload); + return true; + } + + /// + /// Attempts to extract IPTC metadata from a Photoshop Image Resource Block (IRB) contained within the specified + /// data buffer. + /// + /// This method scans the provided data for a Photoshop IRB block containing IPTC metadata and + /// extracts it if present. The method does not validate the contents of the IPTC data beyond locating the + /// appropriate resource block. + /// A read-only span of bytes containing the Photoshop IRB data to search for embedded IPTC metadata. + /// When this method returns, contains the extracted IPTC metadata as a byte array if found; otherwise, an undefined + /// value. + /// if IPTC metadata is successfully extracted from the IRB data; otherwise, . + private static bool TryExtractIptcFromPhotoshopIrb(ReadOnlySpan data, out byte[] iptcBytes) + { + iptcBytes = default!; + + ReadOnlySpan adobePhotoshop30 = PngConstants.AdobePhotoshop30; + + // Some writers include the "Photoshop 3.0\0" header, some store just IRB blocks. + if (data.Length >= adobePhotoshop30.Length && data[..adobePhotoshop30.Length].SequenceEqual(adobePhotoshop30)) + { + data = data[adobePhotoshop30.Length..]; + } + + ReadOnlySpan eightBim = PngConstants.EightBim; + ushort adobeIptcResourceId = PngConstants.AdobeIptcResourceId; + while (data.Length >= 12) + { + if (!data[..4].SequenceEqual(eightBim)) + { + return false; + } + + data = data[4..]; + + // Resource ID (2 bytes, big endian) + if (data.Length < 2) + { + return false; + } + + ushort resourceId = (ushort)((data[0] << 8) | data[1]); + data = data[2..]; + + // Pascal string name (1-byte length, then bytes), padded to even. + if (data.Length < 1) + { + return false; + } + + int nameLen = data[0]; + int nameFieldLen = 1 + nameLen; + if ((nameFieldLen & 1) != 0) + { + nameFieldLen++; // pad to even + } + + if (data.Length < nameFieldLen + 4) + { + return false; + } + + data = data[nameFieldLen..]; + + // Resource data size (4 bytes, big endian) + int size = (data[0] << 24) | (data[1] << 16) | (data[2] << 8) | data[3]; + data = data[4..]; + + if (size < 0 || data.Length < size) + { + return false; + } + + ReadOnlySpan payload = data[..size]; + + // Data is padded to even. + int advance = size; + if ((advance & 1) != 0) + { + advance++; + } + + if (resourceId == adobeIptcResourceId) + { + iptcBytes = payload.ToArray(); + return true; + } + + if (data.Length < advance) + { + return false; + } + + data = data[advance..]; + } + + return false; + } + /// /// Reads the color profile chunk. The data is stored similar to the zTXt chunk. /// diff --git a/src/ImageSharp/Formats/Png/PngEncoderCore.cs b/src/ImageSharp/Formats/Png/PngEncoderCore.cs index 2b01affea2..2bb97221cc 100644 --- a/src/ImageSharp/Formats/Png/PngEncoderCore.cs +++ b/src/ImageSharp/Formats/Png/PngEncoderCore.cs @@ -8,6 +8,7 @@ using System.Runtime.CompilerServices; using System.Runtime.InteropServices; using System.Runtime.Intrinsics; +using System.Text; using SixLabors.ImageSharp.Common.Helpers; using SixLabors.ImageSharp.Compression.Zlib; using SixLabors.ImageSharp.Formats.Png.Chunks; @@ -217,6 +218,7 @@ public void Encode(Image image, Stream stream, CancellationToken this.WritePhysicalChunk(stream, metadata); this.WriteExifChunk(stream, metadata); this.WriteXmpChunk(stream, metadata); + this.WriteIptcChunk(stream, metadata); this.WriteTextChunks(stream, pngMetadata); if (image.Frames.Count > 1) @@ -889,6 +891,163 @@ private void WriteXmpChunk(Stream stream, ImageMetadata meta) this.WriteChunk(stream, PngChunkType.InternationalText, payload); } + /// + /// Writes the IPTC metadata from the specified image metadata to the provided stream as a compressed zTXt chunk in + /// PNG format, if IPTC data is present. + /// + /// The containing image data. + /// The image metadata. + private void WriteIptcChunk(Stream stream, ImageMetadata meta) + { + if ((this.chunkFilter & PngChunkFilter.ExcludeTextChunks) == PngChunkFilter.ExcludeTextChunks) + { + return; + } + + if (meta.IptcProfile is null || !meta.IptcProfile.Values.Any()) + { + return; + } + + meta.IptcProfile.UpdateData(); + + byte[]? iptcData = meta.IptcProfile.Data; + if (iptcData?.Length is 0 or null) + { + return; + } + + // For interoperability, wrap raw IPTC (IIM) in a Photoshop IRB (8BIM, resource 0x0404), + // since "Raw profile type iptc" commonly stores IRB payloads. + using IMemoryOwner irb = this.BuildPhotoshopIrbForIptc(iptcData); + + Span irbSpan = irb.GetSpan(); + + // Build "raw profile" textual wrapper: + // "IPTC profile\n\n\n" + string rawProfileText = BuildRawProfileText("IPTC profile", irbSpan); + + byte[] compressedData = this.GetZlibCompressedBytes(PngConstants.Encoding.GetBytes(rawProfileText)); + + // zTXt layout: keyword (latin-1) + 0 + compression-method(0) + compressed-data + const string iptcRawProfileKeyword = PngConstants.IptcRawProfileKeyword; + int payloadLength = iptcRawProfileKeyword.Length + compressedData.Length + 2; + + using IMemoryOwner payload = this.memoryAllocator.Allocate(payloadLength); + Span outputBytes = payload.GetSpan(); + + PngConstants.Encoding.GetBytes(iptcRawProfileKeyword).CopyTo(outputBytes); + int bytesWritten = iptcRawProfileKeyword.Length; + outputBytes[bytesWritten++] = 0; // Null separator + outputBytes[bytesWritten++] = 0; // Compression method: deflate + compressedData.CopyTo(outputBytes[bytesWritten..]); + + this.WriteChunk(stream, PngChunkType.CompressedText, outputBytes); + } + + /// + /// Builds a Photoshop Image Resource Block (IRB) containing the specified IPTC-IIM data. + /// + /// The returned IRB uses resource ID 0x0404 and an empty Pascal string for the name, as required + /// for IPTC-NAA record embedding in Photoshop files. The data is padded to ensure even length, as specified by the + /// IRB format. + /// + /// The IPTC-IIM data to embed in the IRB, provided as a read-only span of bytes. The data is included as-is in the + /// resulting block. + /// + /// + /// A byte array representing the Photoshop IRB with the embedded IPTC-IIM data, formatted according to the + /// Photoshop specification. + /// + private IMemoryOwner BuildPhotoshopIrbForIptc(ReadOnlySpan iptcIim) + { + // IRB block: + // 4 bytes: "8BIM" + // 2 bytes: resource id 0x0404 (big endian) + // 2 bytes: pascal name (len=0) + pad to even => 0x00 0x00 + // 4 bytes: data size (big endian) + // n bytes: IPTC-IIM data + // pad to even + int pad = (iptcIim.Length & 1) != 0 ? 1 : 0; + IMemoryOwner bufferOwner = this.memoryAllocator.Allocate(4 + 2 + 2 + 4 + iptcIim.Length + pad); + Span buffer = bufferOwner.GetSpan(); + + int bytesWritten = 0; + PngConstants.EightBim.CopyTo(buffer); + bytesWritten += 4; + + buffer[bytesWritten++] = 0x04; + buffer[bytesWritten++] = 0x04; + + buffer[bytesWritten++] = 0x00; // Pascal name length + buffer[bytesWritten++] = 0x00; // pad to even + + int size = iptcIim.Length; + buffer[bytesWritten++] = (byte)((size >> 24) & 0xFF); + buffer[bytesWritten++] = (byte)((size >> 16) & 0xFF); + buffer[bytesWritten++] = (byte)((size >> 8) & 0xFF); + buffer[bytesWritten++] = (byte)(size & 0xFF); + + iptcIim.CopyTo(buffer[bytesWritten..]); + + // Final pad byte already zero-initialized if needed + return bufferOwner; + } + + /// + /// Builds a formatted text representation of a binary profile, including a header, the payload length, and the + /// payload as hexadecimal text. + /// + /// + /// The hexadecimal payload is formatted with 64 bytes per line to improve readability. The + /// output consists of the header line, a line with the payload length, and one or more lines of hexadecimal + /// text. + /// + /// The header text to include at the beginning of the profile. This is written as the first line of the output. + /// The binary payload to encode as hexadecimal text. The payload is split into lines of 64 bytes each. + /// + /// A string containing the header, the payload length, and the hexadecimal representation of the payload, each on + /// separate lines. + /// + private static string BuildRawProfileText(string header, ReadOnlySpan payload) + { + // Hex text can be multi-line + // Use 64 bytes per line (128 hex chars) to keep the chunk readable. + const int bytesPerLine = 64; + + int hexChars = payload.Length * 2; + int lineCount = (payload.Length + (bytesPerLine - 1)) / bytesPerLine; + int newlineCount = 2 + lineCount; // header line + length line + hex lines + int capacity = header.Length + 32 + hexChars + newlineCount; + + StringBuilder sb = new(capacity); + sb.Append(header).Append('\n'); + sb.Append(payload.Length).Append('\n'); + + int i = 0; + while (i < payload.Length) + { + int take = Math.Min(bytesPerLine, payload.Length - i); + AppendHex(sb, payload.Slice(i, take)); + sb.Append('\n'); + i += take; + } + + return sb.ToString(); + } + + private static void AppendHex(StringBuilder sb, ReadOnlySpan data) + { + const string hex = "0123456789ABCDEF"; + + for (int i = 0; i < data.Length; i++) + { + byte b = data[i]; + _ = sb.Append(hex[b >> 4]); + _ = sb.Append(hex[b & 0x0F]); + } + } + /// /// Writes the CICP profile chunk /// diff --git a/src/ImageSharp/Metadata/Profiles/IPTC/IptcRecordNumber.cs b/src/ImageSharp/Metadata/Profiles/IPTC/IptcRecordNumber.cs index 2d5fe6a09a..bbbeb83e09 100644 --- a/src/ImageSharp/Metadata/Profiles/IPTC/IptcRecordNumber.cs +++ b/src/ImageSharp/Metadata/Profiles/IPTC/IptcRecordNumber.cs @@ -9,12 +9,12 @@ namespace SixLabors.ImageSharp.Metadata.Profiles.IPTC; internal enum IptcRecordNumber : byte { /// - /// A Envelope Record. + /// An Envelope Record. /// Envelope = 0x01, /// - /// A Application Record. + /// An Application Record. /// Application = 0x02 } diff --git a/src/ImageSharp/Metadata/Profiles/IPTC/IptcValue.cs b/src/ImageSharp/Metadata/Profiles/IPTC/IptcValue.cs index 7735810b3f..65daf59368 100644 --- a/src/ImageSharp/Metadata/Profiles/IPTC/IptcValue.cs +++ b/src/ImageSharp/Metadata/Profiles/IPTC/IptcValue.cs @@ -2,6 +2,7 @@ // Licensed under the Six Labors Split License. using System.Diagnostics; +using System.Globalization; using System.Text; namespace SixLabors.ImageSharp.Metadata.Profiles.Iptc; @@ -9,7 +10,7 @@ namespace SixLabors.ImageSharp.Metadata.Profiles.Iptc; /// /// Represents a single value of the IPTC profile. /// -[DebuggerDisplay("{Tag} = {ToString(),nq} ({GetType().Name,nq})")] +[DebuggerDisplay("{Tag} = {DebuggerDisplayValue(),nq} ({GetType().Name,nq})")] public sealed class IptcValue : IDeepCloneable { private byte[] data = []; @@ -213,4 +214,37 @@ public string ToString(Encoding encoding) return encoding.GetString(this.data); } + + private string DebuggerDisplayValue() + { + // IPTC RecordVersion (2:00) is a 2-byte binary value, commonly 0x0004. + // Showing it as UTF-8 produces control characters like "\0\u0004". + if (this.Tag == IptcTag.RecordVersion && this.data.Length == 2) + { + int version = (this.data[0] << 8) | this.data[1]; + return version.ToString(CultureInfo.InvariantCulture); + } + + // Prefer readable text if it looks like it, otherwise show hex. + // (Avoid surprising debugger output for binary payloads.) + bool printable = true; + for (int i = 0; i < this.data.Length; i++) + { + byte b = this.data[i]; + + // If any byte is an ASCII control character, treat this value as binary. + if (b is < 0x20 or 0x7F) + { + printable = false; + break; + } + } + + if (printable) + { + return this.Value; + } + + return Convert.ToHexString(this.data); + } } diff --git a/tests/ImageSharp.Tests/Formats/Png/PngMetadataTests.cs b/tests/ImageSharp.Tests/Formats/Png/PngMetadataTests.cs index a0c552a221..42048426ee 100644 --- a/tests/ImageSharp.Tests/Formats/Png/PngMetadataTests.cs +++ b/tests/ImageSharp.Tests/Formats/Png/PngMetadataTests.cs @@ -6,6 +6,7 @@ using SixLabors.ImageSharp.Formats.Png.Chunks; using SixLabors.ImageSharp.Metadata; using SixLabors.ImageSharp.Metadata.Profiles.Exif; +using SixLabors.ImageSharp.Metadata.Profiles.Iptc; using SixLabors.ImageSharp.PixelFormats; using SixLabors.ImageSharp.Tests.TestUtilities; @@ -31,16 +32,16 @@ public void CloneIsDeep() ColorType = PngColorType.GrayscaleWithAlpha, InterlaceMethod = PngInterlaceMode.Adam7, Gamma = 2, - TextData = new List { new("name", "value", "foo", "bar") }, + TextData = [new("name", "value", "foo", "bar")], RepeatCount = 123, AnimateRootFrame = false }; - PngMetadata clone = (PngMetadata)meta.DeepClone(); + PngMetadata clone = meta.DeepClone(); - Assert.True(meta.BitDepth == clone.BitDepth); - Assert.True(meta.ColorType == clone.ColorType); - Assert.True(meta.InterlaceMethod == clone.InterlaceMethod); + Assert.Equal(meta.BitDepth, clone.BitDepth); + Assert.Equal(meta.ColorType, clone.ColorType); + Assert.Equal(meta.InterlaceMethod, clone.InterlaceMethod); Assert.True(meta.Gamma.Equals(clone.Gamma)); Assert.False(meta.TextData.Equals(clone.TextData)); Assert.True(meta.TextData.SequenceEqual(clone.TextData)); @@ -53,15 +54,47 @@ public void CloneIsDeep() clone.Gamma = 1; clone.RepeatCount = 321; - Assert.False(meta.BitDepth == clone.BitDepth); - Assert.False(meta.ColorType == clone.ColorType); - Assert.False(meta.InterlaceMethod == clone.InterlaceMethod); + Assert.NotEqual(meta.BitDepth, clone.BitDepth); + Assert.NotEqual(meta.ColorType, clone.ColorType); + Assert.NotEqual(meta.InterlaceMethod, clone.InterlaceMethod); Assert.False(meta.Gamma.Equals(clone.Gamma)); Assert.False(meta.TextData.Equals(clone.TextData)); Assert.True(meta.TextData.SequenceEqual(clone.TextData)); Assert.False(meta.RepeatCount == clone.RepeatCount); } + [Theory] + [WithFile(TestImages.Png.IptcMetadata, PixelTypes.Rgba32)] + public void Decoder_CanReadIptcProfile(TestImageProvider provider) + where TPixel : unmanaged, IPixel + { + using Image image = provider.GetImage(PngDecoder.Instance); + Assert.NotNull(image.Metadata.IptcProfile); + Assert.Equal("test1, test2", image.Metadata.IptcProfile.GetValues(IptcTag.Keywords)[0].Value); + Assert.Equal("\0\u0004", image.Metadata.IptcProfile.GetValues(IptcTag.RecordVersion)[0].Value); + } + + [Theory] + [WithFile(TestImages.Png.IptcMetadata, PixelTypes.Rgba32)] + public void Encoder_CanWriteIptcProfile(TestImageProvider provider) + where TPixel : unmanaged, IPixel + { + using Image image = provider.GetImage(PngDecoder.Instance); + Assert.NotNull(image.Metadata.IptcProfile); + Assert.Equal("test1, test2", image.Metadata.IptcProfile.GetValues(IptcTag.Keywords)[0].Value); + Assert.Equal("\0\u0004", image.Metadata.IptcProfile.GetValues(IptcTag.RecordVersion)[0].Value); + + using MemoryStream memoryStream = new(); + image.Save(memoryStream, new PngEncoder()); + + memoryStream.Position = 0; + + using Image decoded = PngDecoder.Instance.Decode(DecoderOptions.Default, memoryStream); + Assert.NotNull(decoded.Metadata.IptcProfile); + Assert.Equal("test1, test2", decoded.Metadata.IptcProfile.GetValues(IptcTag.Keywords)[0].Value); + Assert.Equal("\0\u0004", decoded.Metadata.IptcProfile.GetValues(IptcTag.RecordVersion)[0].Value); + } + [Theory] [WithFile(TestImages.Png.PngWithMetadata, PixelTypes.Rgba32)] public void Decoder_CanReadTextData(TestImageProvider provider) @@ -337,7 +370,6 @@ public void Identify_ReadsLegacyExifData(string imagePath) Assert.Equal(42, (int)exif.GetValue(ExifTag.ImageNumber).Value); } - [Theory] [InlineData(PixelColorType.Binary, PngColorType.Palette)] [InlineData(PixelColorType.Indexed, PngColorType.Palette)] diff --git a/tests/ImageSharp.Tests/TestImages.cs b/tests/ImageSharp.Tests/TestImages.cs index f6cd776e47..6b4a866669 100644 --- a/tests/ImageSharp.Tests/TestImages.cs +++ b/tests/ImageSharp.Tests/TestImages.cs @@ -62,6 +62,7 @@ public static class Png public const string TestPattern31x31HalfTransparent = "Png/testpattern31x31-halftransparent.png"; public const string XmpColorPalette = "Png/xmp-colorpalette.png"; public const string AdamHeadsHlg = "Png/adamHeadsHLG.png"; + public const string IptcMetadata = "Png/iptc-profile.png"; // Animated // https://philip.html5.org/tests/apng/tests.html diff --git a/tests/Images/Input/Png/iptc-profile.png b/tests/Images/Input/Png/iptc-profile.png new file mode 100644 index 0000000000..fa4199a0c8 --- /dev/null +++ b/tests/Images/Input/Png/iptc-profile.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ae7f5d11145762b6544b3e289fc6c3bcb13a5f4cd8511b02280da683bec4c96e +size 448011