mirror of https://github.com/SixLabors/ImageSharp
committed by
GitHub
36 changed files with 1032 additions and 145 deletions
@ -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
|
||||
|
|
||||
|
/// <summary>
|
||||
|
/// Gets the raw Data.
|
||||
|
/// </summary>
|
||||
|
public byte[] Data { get; } |
||||
|
|
||||
|
/// <summary>
|
||||
|
/// Reads the XMP metadata from the specified stream.
|
||||
|
/// </summary>
|
||||
|
/// <param name="stream">The stream to read from.</param>
|
||||
|
/// <returns>The XMP metadata</returns>
|
||||
|
/// <exception cref="ImageFormatException">Thrown if the XMP block is not properly terminated.</exception>
|
||||
|
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<byte[]>(); |
||||
|
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<byte> 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<byte> 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<byte> 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<byte> 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; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -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 |
||||
|
{ |
||||
|
/// <summary>
|
||||
|
/// Represents an XMP profile, providing access to the raw XML.
|
||||
|
/// See <seealso href="https://www.adobe.com/devnet/xmp.html"/> for the full specification.
|
||||
|
/// </summary>
|
||||
|
public sealed class XmpProfile : IDeepCloneable<XmpProfile> |
||||
|
{ |
||||
|
/// <summary>
|
||||
|
/// Initializes a new instance of the <see cref="XmpProfile"/> class.
|
||||
|
/// </summary>
|
||||
|
public XmpProfile() |
||||
|
: this((byte[])null) |
||||
|
{ |
||||
|
} |
||||
|
|
||||
|
/// <summary>
|
||||
|
/// Initializes a new instance of the <see cref="XmpProfile"/> class.
|
||||
|
/// </summary>
|
||||
|
/// <param name="data">The UTF8 encoded byte array to read the XMP profile from.</param>
|
||||
|
public XmpProfile(byte[] data) => this.Data = data; |
||||
|
|
||||
|
/// <summary>
|
||||
|
/// Initializes a new instance of the <see cref="XmpProfile"/> class
|
||||
|
/// by making a copy from another XMP profile.
|
||||
|
/// </summary>
|
||||
|
/// <param name="other">The other XMP profile, from which the clone should be made from.</param>
|
||||
|
private XmpProfile(XmpProfile other) |
||||
|
{ |
||||
|
Guard.NotNull(other, nameof(other)); |
||||
|
|
||||
|
this.Data = other.Data; |
||||
|
} |
||||
|
|
||||
|
/// <summary>
|
||||
|
/// Gets the XMP raw data byte array.
|
||||
|
/// </summary>
|
||||
|
internal byte[] Data { get; private set; } |
||||
|
|
||||
|
/// <summary>
|
||||
|
/// Gets the raw XML document containing the XMP profile.
|
||||
|
/// </summary>
|
||||
|
/// <returns>The <see cref="XDocument"/></returns>
|
||||
|
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); |
||||
|
} |
||||
|
|
||||
|
/// <summary>
|
||||
|
/// Convert the content of this <see cref="XmpProfile"/> into a byte array.
|
||||
|
/// </summary>
|
||||
|
/// <returns>The <see cref="T:byte[]"/></returns>
|
||||
|
public byte[] ToByteArray() |
||||
|
{ |
||||
|
byte[] result = new byte[this.Data.Length]; |
||||
|
this.Data.AsSpan().CopyTo(result); |
||||
|
return result; |
||||
|
} |
||||
|
|
||||
|
/// <inheritdoc/>
|
||||
|
public XmpProfile DeepClone() => new(this); |
||||
|
} |
||||
|
} |
||||
@ -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<TPixel>(TestImageProvider<TPixel> provider) |
||||
|
where TPixel : unmanaged, IPixel<TPixel> |
||||
|
{ |
||||
|
using (Image<TPixel> 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<TPixel>(TestImageProvider<TPixel> provider) |
||||
|
where TPixel : unmanaged, IPixel<TPixel> |
||||
|
{ |
||||
|
using (Image<TPixel> 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<TPixel>(TestImageProvider<TPixel> provider) |
||||
|
where TPixel : unmanaged, IPixel<TPixel> |
||||
|
{ |
||||
|
using (Image<TPixel> 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<TPixel>(TestImageProvider<TPixel> provider) |
||||
|
where TPixel : unmanaged, IPixel<TPixel> |
||||
|
{ |
||||
|
using (Image<TPixel> 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<TPixel>(TestImageProvider<TPixel> provider) |
||||
|
where TPixel : unmanaged, IPixel<TPixel> |
||||
|
{ |
||||
|
using (Image<TPixel> 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<Rgba32>(1, 1); |
||||
|
XmpProfile original = CreateMinimalXmlProfile(); |
||||
|
image.Metadata.XmpProfile = original; |
||||
|
var encoder = new GifEncoder(); |
||||
|
|
||||
|
// act
|
||||
|
using Image<Rgba32> 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<Rgba32>(1, 1); |
||||
|
XmpProfile original = CreateMinimalXmlProfile(); |
||||
|
image.Metadata.XmpProfile = original; |
||||
|
var encoder = new JpegEncoder(); |
||||
|
|
||||
|
// act
|
||||
|
using Image<Rgba32> 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<Rgba32>.File(TestImages.Jpeg.Baseline.ExtendedXmp); |
||||
|
using Image<Rgba32> image = await provider.GetImageAsync(JpegDecoder); |
||||
|
XmpProfile original = image.Metadata.XmpProfile; |
||||
|
var encoder = new JpegEncoder(); |
||||
|
|
||||
|
// act
|
||||
|
using Image<Rgba32> 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<Rgba32>(1, 1); |
||||
|
XmpProfile original = CreateMinimalXmlProfile(); |
||||
|
image.Metadata.XmpProfile = original; |
||||
|
var encoder = new PngEncoder(); |
||||
|
|
||||
|
// act
|
||||
|
using Image<Rgba32> 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<Rgba32>(1, 1); |
||||
|
XmpProfile original = CreateMinimalXmlProfile(); |
||||
|
image.Frames.RootFrame.Metadata.XmpProfile = original; |
||||
|
var encoder = new TiffEncoder(); |
||||
|
|
||||
|
// act
|
||||
|
using Image<Rgba32> 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<Rgba32>(1, 1); |
||||
|
XmpProfile original = CreateMinimalXmlProfile(); |
||||
|
image.Metadata.XmpProfile = original; |
||||
|
var encoder = new WebpEncoder(); |
||||
|
|
||||
|
// act
|
||||
|
using Image<Rgba32> 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 = $"<?xpacket begin='' id='{Guid.NewGuid()}'?><x:xmpmeta xmlns:x='adobe:ns:meta/'></x:xmpmeta><?xpacket end='w'?> "; |
||||
|
byte[] data = Encoding.UTF8.GetBytes(content); |
||||
|
var profile = new XmpProfile(data); |
||||
|
return profile; |
||||
|
} |
||||
|
|
||||
|
private static Image<Rgba32> WriteAndRead(Image<Rgba32> image, IImageEncoder encoder) |
||||
|
{ |
||||
|
using (var memStream = new MemoryStream()) |
||||
|
{ |
||||
|
image.Save(memStream, encoder); |
||||
|
image.Dispose(); |
||||
|
|
||||
|
memStream.Position = 0; |
||||
|
return Image.Load<Rgba32>(memStream); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,3 @@ |
|||||
|
version https://git-lfs.github.com/spec/v1 |
||||
|
oid sha256:800911efb6f0c796d61b5ea14fc67fe891aaae3c04a49cfd5b86e68958598436 |
||||
|
size 138810 |
||||
@ -0,0 +1,3 @@ |
|||||
|
version https://git-lfs.github.com/spec/v1 |
||||
|
oid sha256:000c67f210059b101570949e889846bb11d6bdc801bd641d7d26424ad9cd027f |
||||
|
size 623986 |
||||
@ -0,0 +1,3 @@ |
|||||
|
version https://git-lfs.github.com/spec/v1 |
||||
|
oid sha256:fb55607fd7de6a47d8dd242c1a7be9627c564821554db896ed46603d15963c06 |
||||
|
size 1025 |
||||
@ -0,0 +1,3 @@ |
|||||
|
version https://git-lfs.github.com/spec/v1 |
||||
|
oid sha256:755a63652695d7e190f375c9c0697cd37c9b601cd54405c704ec8efc200e67fc |
||||
|
size 474772 |
||||
Loading…
Reference in new issue