Browse Source

Use IptcProfile in PNG

pull/3048/head
James Jackson-South 4 months ago
parent
commit
ff02d0c1f0
  1. 42
      src/ImageSharp/Formats/Png/PngConstants.cs
  2. 218
      src/ImageSharp/Formats/Png/PngDecoderCore.cs
  3. 159
      src/ImageSharp/Formats/Png/PngEncoderCore.cs
  4. 4
      src/ImageSharp/Metadata/Profiles/IPTC/IptcRecordNumber.cs
  5. 36
      src/ImageSharp/Metadata/Profiles/IPTC/IptcValue.cs
  6. 50
      tests/ImageSharp.Tests/Formats/Png/PngMetadataTests.cs
  7. 1
      tests/ImageSharp.Tests/TestImages.cs
  8. 3
      tests/Images/Input/Png/iptc-profile.png

42
src/ImageSharp/Formats/Png/PngConstants.cs

@ -62,6 +62,21 @@ internal static class PngConstants
/// </summary>
public const int MinTextKeywordLength = 1;
/// <summary>
/// Specifies the keyword used to identify the Exif raw profile in image metadata.
/// </summary>
public const string ExifRawProfileKeyword = "Raw profile type exif";
/// <summary>
/// Specifies the profile keyword used to identify raw IPTC metadata within image files.
/// </summary>
public const string IptcRawProfileKeyword = "Raw profile type iptc";
/// <summary>
/// The IPTC resource id in Photoshop IRB. 0x0404 (big endian).
/// </summary>
public const ushort AdobeIptcResourceId = 0x0404;
/// <summary>
/// Gets the header bytes identifying a Png.
/// </summary>
@ -100,4 +115,31 @@ internal static class PngConstants
(byte)'m',
(byte)'p'
];
/// <summary>
/// Gets the ASCII bytes for the "Photoshop 3.0" identifier used in some PNG metadata payloads.
/// This value is null-terminated.
/// </summary>
public static ReadOnlySpan<byte> 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
];
/// <summary>
/// Gets the ASCII bytes for the "8BIM" signature used in Photoshop resources.
/// </summary>
public static ReadOnlySpan<byte> EightBim => [(byte)'8', (byte)'B', (byte)'I', (byte)'M'];
}

218
src/ImageSharp/Formats/Png/PngDecoderCore.cs

@ -21,6 +21,7 @@ using SixLabors.ImageSharp.Metadata;
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 @@ internal sealed class PngDecoderCore : ImageDecoderCore
/// object unmodified.</returns>
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 @@ internal sealed class PngDecoderCore : ImageDecoderCore
return true;
}
/// <summary>
/// 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.
/// </summary>
/// <param name="metadata">The <see cref="ImageMetadata"/> to store the decoded iptc tags into.</param>
/// <param name="data">The contents of the "Raw profile type iptc" text chunk.</param>
private static bool TryReadLegacyIptcTextChunk(ImageMetadata metadata, string data)
{
// Preserve first IPTC found.
if (metadata.IptcProfile != null)
{
return true;
}
ReadOnlySpan<char> dataSpan = data.AsSpan().TrimStart();
// Must start with the "iptc" identifier (case-insensitive).
// Common real-world format (ImageMagick/ExifTool) is:
// "IPTC profile\n <len>\n<hex...>"
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<char> 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;
}
/// <summary>
/// Attempts to extract IPTC metadata from a Photoshop Image Resource Block (IRB) contained within the specified
/// data buffer.
/// </summary>
/// <remarks>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.</remarks>
/// <param name="data">A read-only span of bytes containing the Photoshop IRB data to search for embedded IPTC metadata.</param>
/// <param name="iptcBytes">When this method returns, contains the extracted IPTC metadata as a byte array if found; otherwise, an undefined
/// value.</param>
/// <returns><see langword="true"/> if IPTC metadata is successfully extracted from the IRB data; otherwise, <see langword="false"/>.</returns>
private static bool TryExtractIptcFromPhotoshopIrb(ReadOnlySpan<byte> data, out byte[] iptcBytes)
{
iptcBytes = default!;
ReadOnlySpan<byte> 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<byte> 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<byte> 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;
}
/// <summary>
/// Reads the color profile chunk. The data is stored similar to the zTXt chunk.
/// </summary>

159
src/ImageSharp/Formats/Png/PngEncoderCore.cs

@ -8,6 +8,7 @@ using System.IO.Hashing;
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 @@ internal sealed class PngEncoderCore : IDisposable
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 @@ internal sealed class PngEncoderCore : IDisposable
this.WriteChunk(stream, PngChunkType.InternationalText, payload);
}
/// <summary>
/// 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.
/// </summary>
/// <param name="stream">The <see cref="Stream"/> containing image data.</param>
/// <param name="meta">The image metadata.</param>
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<byte> irb = this.BuildPhotoshopIrbForIptc(iptcData);
Span<byte> irbSpan = irb.GetSpan();
// Build "raw profile" textual wrapper:
// "IPTC profile\n<decimal length>\n<hex bytes...>\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<byte> payload = this.memoryAllocator.Allocate<byte>(payloadLength);
Span<byte> 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);
}
/// <summary>
/// Builds a Photoshop Image Resource Block (IRB) containing the specified IPTC-IIM data.
/// </summary>
/// <remarks>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.</remarks>
/// <param name="iptcIim">
/// 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.
/// </param>
/// <returns>
/// A byte array representing the Photoshop IRB with the embedded IPTC-IIM data, formatted according to the
/// Photoshop specification.
/// </returns>
private IMemoryOwner<byte> BuildPhotoshopIrbForIptc(ReadOnlySpan<byte> 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<byte> bufferOwner = this.memoryAllocator.Allocate<byte>(4 + 2 + 2 + 4 + iptcIim.Length + pad);
Span<byte> 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;
}
/// <summary>
/// Builds a formatted text representation of a binary profile, including a header, the payload length, and the
/// payload as hexadecimal text.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
/// <param name="header">The header text to include at the beginning of the profile. This is written as the first line of the output.</param>
/// <param name="payload">The binary payload to encode as hexadecimal text. The payload is split into lines of 64 bytes each.</param>
/// <returns>
/// A string containing the header, the payload length, and the hexadecimal representation of the payload, each on
/// separate lines.
/// </returns>
private static string BuildRawProfileText(string header, ReadOnlySpan<byte> 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<byte> 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]);
}
}
/// <summary>
/// Writes the CICP profile chunk
/// </summary>

4
src/ImageSharp/Metadata/Profiles/IPTC/IptcRecordNumber.cs

@ -9,12 +9,12 @@ namespace SixLabors.ImageSharp.Metadata.Profiles.IPTC;
internal enum IptcRecordNumber : byte
{
/// <summary>
/// A Envelope Record.
/// An Envelope Record.
/// </summary>
Envelope = 0x01,
/// <summary>
/// A Application Record.
/// An Application Record.
/// </summary>
Application = 0x02
}

36
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;
/// <summary>
/// Represents a single value of the IPTC profile.
/// </summary>
[DebuggerDisplay("{Tag} = {ToString(),nq} ({GetType().Name,nq})")]
[DebuggerDisplay("{Tag} = {DebuggerDisplayValue(),nq} ({GetType().Name,nq})")]
public sealed class IptcValue : IDeepCloneable<IptcValue>
{
private byte[] data = [];
@ -213,4 +214,37 @@ public sealed class IptcValue : IDeepCloneable<IptcValue>
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);
}
}

50
tests/ImageSharp.Tests/Formats/Png/PngMetadataTests.cs

@ -6,6 +6,7 @@ using SixLabors.ImageSharp.Formats.Png;
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 class PngMetadataTests
ColorType = PngColorType.GrayscaleWithAlpha,
InterlaceMethod = PngInterlaceMode.Adam7,
Gamma = 2,
TextData = new List<PngTextData> { 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 class PngMetadataTests
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<TPixel>(TestImageProvider<TPixel> provider)
where TPixel : unmanaged, IPixel<TPixel>
{
using Image<TPixel> 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<TPixel>(TestImageProvider<TPixel> provider)
where TPixel : unmanaged, IPixel<TPixel>
{
using Image<TPixel> 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<Rgba32> decoded = PngDecoder.Instance.Decode<Rgba32>(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<TPixel>(TestImageProvider<TPixel> provider)
@ -337,7 +370,6 @@ public class PngMetadataTests
Assert.Equal(42, (int)exif.GetValue(ExifTag.ImageNumber).Value);
}
[Theory]
[InlineData(PixelColorType.Binary, PngColorType.Palette)]
[InlineData(PixelColorType.Indexed, PngColorType.Palette)]

1
tests/ImageSharp.Tests/TestImages.cs

@ -62,6 +62,7 @@ public static class TestImages
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

3
tests/Images/Input/Png/iptc-profile.png

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:ae7f5d11145762b6544b3e289fc6c3bcb13a5f4cd8511b02280da683bec4c96e
size 448011
Loading…
Cancel
Save