Browse Source

Complete tests and fix issues

pull/2579/head
James Jackson-South 2 years ago
parent
commit
b31bd2337c
  1. 8
      src/ImageSharp/Formats/Cur/CurDecoderCore.cs
  2. 2
      src/ImageSharp/Formats/Cur/CurEncoder.cs
  3. 4
      src/ImageSharp/Formats/Cur/CurEncoderCore.cs
  4. 25
      src/ImageSharp/Formats/Cur/CurFrameMetadata.cs
  5. 8
      src/ImageSharp/Formats/Ico/IcoDecoderCore.cs
  6. 4
      src/ImageSharp/Formats/Ico/IcoEncoder.cs
  7. 4
      src/ImageSharp/Formats/Ico/IcoEncoderCore.cs
  8. 26
      src/ImageSharp/Formats/Ico/IcoFrameMetadata.cs
  9. 81
      src/ImageSharp/Formats/Icon/IconDecoderCore.cs
  10. 72
      src/ImageSharp/Formats/Icon/IconEncoderCore.cs
  11. 31
      src/ImageSharp/Formats/Png/PngDecoderCore.cs
  12. 7
      src/ImageSharp/Processing/Processors/Quantization/PaletteQuantizer.cs
  13. 19
      tests/ImageSharp.Tests/Formats/Icon/Cur/CurDecoderTests.cs
  14. 26
      tests/ImageSharp.Tests/Formats/Icon/Cur/CurEncoderTests.cs
  15. 139
      tests/ImageSharp.Tests/Formats/Icon/Ico/IcoDecoderTests.cs
  16. 26
      tests/ImageSharp.Tests/Formats/Icon/Ico/IcoEncoderTests.cs

8
src/ImageSharp/Formats/Cur/CurDecoderCore.cs

@ -14,11 +14,17 @@ internal sealed class CurDecoderCore : IconDecoderCore
{ {
} }
protected override void SetFrameMetadata(ImageFrameMetadata metadata, in IconDirEntry entry, IconFrameCompression compression, BmpBitsPerPixel bitsPerPixel) protected override void SetFrameMetadata(
ImageFrameMetadata metadata,
in IconDirEntry entry,
IconFrameCompression compression,
BmpBitsPerPixel bitsPerPixel,
ReadOnlyMemory<Color>? colorTable)
{ {
CurFrameMetadata curFrameMetadata = metadata.GetCurMetadata(); CurFrameMetadata curFrameMetadata = metadata.GetCurMetadata();
curFrameMetadata.FromIconDirEntry(entry); curFrameMetadata.FromIconDirEntry(entry);
curFrameMetadata.Compression = compression; curFrameMetadata.Compression = compression;
curFrameMetadata.BmpBitsPerPixel = bitsPerPixel; curFrameMetadata.BmpBitsPerPixel = bitsPerPixel;
curFrameMetadata.ColorTable = colorTable;
} }
} }

2
src/ImageSharp/Formats/Cur/CurEncoder.cs

@ -11,7 +11,7 @@ public sealed class CurEncoder : QuantizingImageEncoder
/// <inheritdoc/> /// <inheritdoc/>
protected override void Encode<TPixel>(Image<TPixel> image, Stream stream, CancellationToken cancellationToken) protected override void Encode<TPixel>(Image<TPixel> image, Stream stream, CancellationToken cancellationToken)
{ {
CurEncoderCore encoderCore = new(); CurEncoderCore encoderCore = new(this);
encoderCore.Encode(image, stream, cancellationToken); encoderCore.Encode(image, stream, cancellationToken);
} }
} }

4
src/ImageSharp/Formats/Cur/CurEncoderCore.cs

@ -7,8 +7,8 @@ namespace SixLabors.ImageSharp.Formats.Cur;
internal sealed class CurEncoderCore : IconEncoderCore internal sealed class CurEncoderCore : IconEncoderCore
{ {
public CurEncoderCore() public CurEncoderCore(QuantizingImageEncoder encoder)
: base(IconFileType.CUR) : base(encoder, IconFileType.CUR)
{ {
} }
} }

25
src/ImageSharp/Formats/Cur/CurFrameMetadata.cs

@ -3,6 +3,7 @@
using SixLabors.ImageSharp.Formats.Bmp; using SixLabors.ImageSharp.Formats.Bmp;
using SixLabors.ImageSharp.Formats.Icon; using SixLabors.ImageSharp.Formats.Icon;
using SixLabors.ImageSharp.PixelFormats;
namespace SixLabors.ImageSharp.Formats.Cur; namespace SixLabors.ImageSharp.Formats.Cur;
@ -18,14 +19,14 @@ public class CurFrameMetadata : IDeepCloneable<CurFrameMetadata>, IDeepCloneable
{ {
} }
private CurFrameMetadata(CurFrameMetadata metadata) private CurFrameMetadata(CurFrameMetadata other)
{ {
this.Compression = metadata.Compression; this.Compression = other.Compression;
this.HotspotX = metadata.HotspotX; this.HotspotX = other.HotspotX;
this.HotspotY = metadata.HotspotY; this.HotspotY = other.HotspotY;
this.EncodingWidth = metadata.EncodingWidth; this.EncodingWidth = other.EncodingWidth;
this.EncodingHeight = metadata.EncodingHeight; this.EncodingHeight = other.EncodingHeight;
this.BmpBitsPerPixel = metadata.BmpBitsPerPixel; this.BmpBitsPerPixel = other.BmpBitsPerPixel;
} }
/// <summary> /// <summary>
@ -45,13 +46,13 @@ public class CurFrameMetadata : IDeepCloneable<CurFrameMetadata>, IDeepCloneable
/// <summary> /// <summary>
/// Gets or sets the encoding width. <br /> /// Gets or sets the encoding width. <br />
/// Can be any number between 0 and 255. Value 0 means a frame height of 256 pixels. /// Can be any number between 0 and 255. Value 0 means a frame height of 256 pixels or greater.
/// </summary> /// </summary>
public byte EncodingWidth { get; set; } public byte EncodingWidth { get; set; }
/// <summary> /// <summary>
/// Gets or sets the encoding height. <br /> /// Gets or sets the encoding height. <br />
/// Can be any number between 0 and 255. Value 0 means a frame height of 256 pixels. /// Can be any number between 0 and 255. Value 0 means a frame height of 256 pixels or greater.
/// </summary> /// </summary>
public byte EncodingHeight { get; set; } public byte EncodingHeight { get; set; }
@ -61,6 +62,12 @@ public class CurFrameMetadata : IDeepCloneable<CurFrameMetadata>, IDeepCloneable
/// </summary> /// </summary>
public BmpBitsPerPixel BmpBitsPerPixel { get; set; } = BmpBitsPerPixel.Pixel32; public BmpBitsPerPixel BmpBitsPerPixel { get; set; } = BmpBitsPerPixel.Pixel32;
/// <summary>
/// Gets or sets the color table, if any.
/// The underlying pixel format is represented by <see cref="Bgr24"/>.
/// </summary>
public ReadOnlyMemory<Color>? ColorTable { get; set; }
/// <inheritdoc/> /// <inheritdoc/>
public CurFrameMetadata DeepClone() => new(this); public CurFrameMetadata DeepClone() => new(this);

8
src/ImageSharp/Formats/Ico/IcoDecoderCore.cs

@ -14,11 +14,17 @@ internal sealed class IcoDecoderCore : IconDecoderCore
{ {
} }
protected override void SetFrameMetadata(ImageFrameMetadata metadata, in IconDirEntry entry, IconFrameCompression compression, BmpBitsPerPixel bitsPerPixel) protected override void SetFrameMetadata(
ImageFrameMetadata metadata,
in IconDirEntry entry,
IconFrameCompression compression,
BmpBitsPerPixel bitsPerPixel,
ReadOnlyMemory<Color>? colorTable)
{ {
IcoFrameMetadata icoFrameMetadata = metadata.GetIcoMetadata(); IcoFrameMetadata icoFrameMetadata = metadata.GetIcoMetadata();
icoFrameMetadata.FromIconDirEntry(entry); icoFrameMetadata.FromIconDirEntry(entry);
icoFrameMetadata.Compression = compression; icoFrameMetadata.Compression = compression;
icoFrameMetadata.BmpBitsPerPixel = bitsPerPixel; icoFrameMetadata.BmpBitsPerPixel = bitsPerPixel;
icoFrameMetadata.ColorTable = colorTable;
} }
} }

4
src/ImageSharp/Formats/Ico/IcoEncoder.cs

@ -6,12 +6,12 @@ namespace SixLabors.ImageSharp.Formats.Ico;
/// <summary> /// <summary>
/// Image encoder for writing an image to a stream as a Windows Icon. /// Image encoder for writing an image to a stream as a Windows Icon.
/// </summary> /// </summary>
public sealed class IcoEncoder : ImageEncoder public sealed class IcoEncoder : QuantizingImageEncoder
{ {
/// <inheritdoc/> /// <inheritdoc/>
protected override void Encode<TPixel>(Image<TPixel> image, Stream stream, CancellationToken cancellationToken) protected override void Encode<TPixel>(Image<TPixel> image, Stream stream, CancellationToken cancellationToken)
{ {
IcoEncoderCore encoderCore = new(); IcoEncoderCore encoderCore = new(this);
encoderCore.Encode(image, stream, cancellationToken); encoderCore.Encode(image, stream, cancellationToken);
} }
} }

4
src/ImageSharp/Formats/Ico/IcoEncoderCore.cs

@ -7,8 +7,8 @@ namespace SixLabors.ImageSharp.Formats.Ico;
internal sealed class IcoEncoderCore : IconEncoderCore internal sealed class IcoEncoderCore : IconEncoderCore
{ {
public IcoEncoderCore() public IcoEncoderCore(QuantizingImageEncoder encoder)
: base(IconFileType.ICO) : base(encoder, IconFileType.ICO)
{ {
} }
} }

26
src/ImageSharp/Formats/Ico/IcoFrameMetadata.cs

@ -3,6 +3,7 @@
using SixLabors.ImageSharp.Formats.Bmp; using SixLabors.ImageSharp.Formats.Bmp;
using SixLabors.ImageSharp.Formats.Icon; using SixLabors.ImageSharp.Formats.Icon;
using SixLabors.ImageSharp.PixelFormats;
namespace SixLabors.ImageSharp.Formats.Ico; namespace SixLabors.ImageSharp.Formats.Ico;
@ -18,12 +19,17 @@ public class IcoFrameMetadata : IDeepCloneable<IcoFrameMetadata>, IDeepCloneable
{ {
} }
private IcoFrameMetadata(IcoFrameMetadata metadata) private IcoFrameMetadata(IcoFrameMetadata other)
{ {
this.Compression = metadata.Compression; this.Compression = other.Compression;
this.EncodingWidth = metadata.EncodingWidth; this.EncodingWidth = other.EncodingWidth;
this.EncodingHeight = metadata.EncodingHeight; this.EncodingHeight = other.EncodingHeight;
this.BmpBitsPerPixel = metadata.BmpBitsPerPixel; this.BmpBitsPerPixel = other.BmpBitsPerPixel;
if (other.ColorTable?.Length > 0)
{
this.ColorTable = other.ColorTable.Value.ToArray();
}
} }
/// <summary> /// <summary>
@ -33,13 +39,13 @@ public class IcoFrameMetadata : IDeepCloneable<IcoFrameMetadata>, IDeepCloneable
/// <summary> /// <summary>
/// Gets or sets the encoding width. <br /> /// Gets or sets the encoding width. <br />
/// Can be any number between 0 and 255. Value 0 means a frame height of 256 pixels. /// Can be any number between 0 and 255. Value 0 means a frame height of 256 pixels or greater.
/// </summary> /// </summary>
public byte EncodingWidth { get; set; } public byte EncodingWidth { get; set; }
/// <summary> /// <summary>
/// Gets or sets the encoding height. <br /> /// Gets or sets the encoding height. <br />
/// Can be any number between 0 and 255. Value 0 means a frame height of 256 pixels. /// Can be any number between 0 and 255. Value 0 means a frame height of 256 pixels or greater.
/// </summary> /// </summary>
public byte EncodingHeight { get; set; } public byte EncodingHeight { get; set; }
@ -49,6 +55,12 @@ public class IcoFrameMetadata : IDeepCloneable<IcoFrameMetadata>, IDeepCloneable
/// </summary> /// </summary>
public BmpBitsPerPixel BmpBitsPerPixel { get; set; } = BmpBitsPerPixel.Pixel32; public BmpBitsPerPixel BmpBitsPerPixel { get; set; } = BmpBitsPerPixel.Pixel32;
/// <summary>
/// Gets or sets the color table, if any.
/// The underlying pixel format is represented by <see cref="Bgr24"/>.
/// </summary>
public ReadOnlyMemory<Color>? ColorTable { get; set; }
/// <inheritdoc/> /// <inheritdoc/>
public IcoFrameMetadata DeepClone() => new(this); public IcoFrameMetadata DeepClone() => new(this);

81
src/ImageSharp/Formats/Icon/IconDecoderCore.cs

@ -31,10 +31,16 @@ internal abstract class IconDecoderCore : IImageDecoderInternals
Span<byte> flag = stackalloc byte[PngConstants.HeaderBytes.Length]; Span<byte> flag = stackalloc byte[PngConstants.HeaderBytes.Length];
List<(Image<TPixel> Image, IconFrameCompression Compression, int Index)> decodedEntries = new(this.entries.Length); List<(Image<TPixel> Image, IconFrameCompression Compression, int Index)> decodedEntries
= new((int)Math.Min(this.entries.Length, this.Options.MaxFrames));
for (int i = 0; i < this.entries.Length; i++) for (int i = 0; i < this.entries.Length; i++)
{ {
if (i == this.Options.MaxFrames)
{
break;
}
ref IconDirEntry entry = ref this.entries[i]; ref IconDirEntry entry = ref this.entries[i];
// If we hit the end of the stream we should break. // If we hit the end of the stream we should break.
@ -69,6 +75,7 @@ internal abstract class IconDecoderCore : IImageDecoderInternals
Image<TPixel> result = new(this.Options.Configuration, metadata, decodedEntries.Select(x => Image<TPixel> result = new(this.Options.Configuration, metadata, decodedEntries.Select(x =>
{ {
BmpBitsPerPixel bitsPerPixel = BmpBitsPerPixel.Pixel32; BmpBitsPerPixel bitsPerPixel = BmpBitsPerPixel.Pixel32;
ReadOnlyMemory<Color>? colorTable = null;
ImageFrame<TPixel> target = new(this.Options.Configuration, this.Dimensions); ImageFrame<TPixel> target = new(this.Options.Configuration, this.Dimensions);
ImageFrame<TPixel> source = x.Image.Frames.RootFrameUnsafe; ImageFrame<TPixel> source = x.Image.Frames.RootFrameUnsafe;
for (int y = 0; y < source.Height; y++) for (int y = 0; y < source.Height; y++)
@ -88,11 +95,22 @@ internal abstract class IconDecoderCore : IImageDecoderInternals
} }
else else
{ {
bmpMetadata = x.Image.Metadata.GetBmpMetadata(); BmpMetadata meta = x.Image.Metadata.GetBmpMetadata();
bitsPerPixel = bmpMetadata.BitsPerPixel; bitsPerPixel = meta.BitsPerPixel;
colorTable = meta.ColorTable;
if (x.Index == 0)
{
bmpMetadata = meta;
}
} }
this.SetFrameMetadata(target.Metadata, this.entries[x.Index], x.Compression, bitsPerPixel); this.SetFrameMetadata(
target.Metadata,
this.entries[x.Index],
x.Compression,
bitsPerPixel,
colorTable);
x.Image.Dispose(); x.Image.Dispose();
@ -122,11 +140,14 @@ internal abstract class IconDecoderCore : IImageDecoderInternals
Span<byte> flag = stackalloc byte[PngConstants.HeaderBytes.Length]; Span<byte> flag = stackalloc byte[PngConstants.HeaderBytes.Length];
ImageMetadata metadata = new(); ImageMetadata metadata = new();
ImageFrameMetadata[] frames = new ImageFrameMetadata[this.fileHeader.Count]; BmpMetadata? bmpMetadata = null;
PngMetadata? pngMetadata = null;
ImageFrameMetadata[] frames = new ImageFrameMetadata[Math.Min(this.fileHeader.Count, this.Options.MaxFrames)];
int bpp = 0; int bpp = 0;
for (int i = 0; i < frames.Length; i++) for (int i = 0; i < frames.Length; i++)
{ {
BmpBitsPerPixel bitsPerPixel = BmpBitsPerPixel.Pixel32; BmpBitsPerPixel bitsPerPixel = BmpBitsPerPixel.Pixel32;
ReadOnlyMemory<Color>? colorTable = null;
ref IconDirEntry entry = ref this.entries[i]; ref IconDirEntry entry = ref this.entries[i];
// If we hit the end of the stream we should break. // If we hit the end of the stream we should break.
@ -149,25 +170,65 @@ internal abstract class IconDecoderCore : IImageDecoderInternals
// Decode the frame into a temp image buffer. This is disposed after the frame is copied to the result. // Decode the frame into a temp image buffer. This is disposed after the frame is copied to the result.
ImageInfo temp = this.GetDecoder(isPng).Identify(stream, cancellationToken); ImageInfo temp = this.GetDecoder(isPng).Identify(stream, cancellationToken);
frames[i] = new(); ImageFrameMetadata frameMetadata = new();
if (!isPng)
if (isPng)
{
if (i == 0)
{
pngMetadata = temp.Metadata.GetPngMetadata();
}
frameMetadata.SetFormatMetadata(PngFormat.Instance, temp.FrameMetadataCollection[0].GetPngMetadata());
}
else
{ {
bitsPerPixel = temp.Metadata.GetBmpMetadata().BitsPerPixel; BmpMetadata meta = temp.Metadata.GetBmpMetadata();
bitsPerPixel = meta.BitsPerPixel;
colorTable = meta.ColorTable;
if (i == 0)
{
bmpMetadata = meta;
}
} }
bpp = Math.Max(bpp, (int)bitsPerPixel); bpp = Math.Max(bpp, (int)bitsPerPixel);
this.SetFrameMetadata(frames[i], this.entries[i], isPng ? IconFrameCompression.Png : IconFrameCompression.Bmp, bitsPerPixel); frames[i] = frameMetadata;
this.SetFrameMetadata(
frames[i],
this.entries[i],
isPng ? IconFrameCompression.Png : IconFrameCompression.Bmp,
bitsPerPixel,
colorTable);
// Since Windows Vista, the size of an image is determined from the BITMAPINFOHEADER structure or PNG image data // Since Windows Vista, the size of an image is determined from the BITMAPINFOHEADER structure or PNG image data
// which technically allows storing icons with larger than 256 pixels, but such larger sizes are not recommended by Microsoft. // which technically allows storing icons with larger than 256 pixels, but such larger sizes are not recommended by Microsoft.
this.Dimensions = new(Math.Max(this.Dimensions.Width, temp.Size.Width), Math.Max(this.Dimensions.Height, temp.Size.Height)); this.Dimensions = new(Math.Max(this.Dimensions.Width, temp.Size.Width), Math.Max(this.Dimensions.Height, temp.Size.Height));
} }
// Copy the format specific metadata to the image.
if (bmpMetadata != null)
{
metadata.SetFormatMetadata(BmpFormat.Instance, bmpMetadata);
}
if (pngMetadata != null)
{
metadata.SetFormatMetadata(PngFormat.Instance, pngMetadata);
}
return new(new(bpp), this.Dimensions, metadata, frames); return new(new(bpp), this.Dimensions, metadata, frames);
} }
protected abstract void SetFrameMetadata(ImageFrameMetadata metadata, in IconDirEntry entry, IconFrameCompression compression, BmpBitsPerPixel bitsPerPixel); protected abstract void SetFrameMetadata(
ImageFrameMetadata metadata,
in IconDirEntry entry,
IconFrameCompression compression,
BmpBitsPerPixel bitsPerPixel,
ReadOnlyMemory<Color>? colorTable);
[MemberNotNull(nameof(entries))] [MemberNotNull(nameof(entries))]
protected void ReadHeader(Stream stream) protected void ReadHeader(Stream stream)

72
src/ImageSharp/Formats/Icon/IconEncoderCore.cs

@ -13,12 +13,16 @@ namespace SixLabors.ImageSharp.Formats.Icon;
internal abstract class IconEncoderCore : IImageEncoderInternals internal abstract class IconEncoderCore : IImageEncoderInternals
{ {
private readonly QuantizingImageEncoder encoder;
private readonly IconFileType iconFileType; private readonly IconFileType iconFileType;
private IconDir fileHeader; private IconDir fileHeader;
private EncodingFrameMetadata[]? entries; private EncodingFrameMetadata[]? entries;
protected IconEncoderCore(IconFileType iconFileType) protected IconEncoderCore(QuantizingImageEncoder encoder, IconFileType iconFileType)
=> this.iconFileType = iconFileType; {
this.encoder = encoder;
this.iconFileType = iconFileType;
}
public void Encode<TPixel>( public void Encode<TPixel>(
Image<TPixel> image, Image<TPixel> image,
@ -71,14 +75,7 @@ internal abstract class IconEncoderCore : IImageEncoderInternals
{ {
IconFrameCompression.Bmp => new BmpEncoder() IconFrameCompression.Bmp => new BmpEncoder()
{ {
// We don't have access to the palette in the metadata so we need to quantize the image Quantizer = this.GetQuantizer(encodingMetadata),
// using a new one generated from the pixel data.
Quantizer = encodingMetadata.Entry.BitCount <= 8
? new WuQuantizer(new()
{
MaxColors = encodingMetadata.Entry.ColorCount
})
: null,
ProcessedAlphaMask = true, ProcessedAlphaMask = true,
UseDoubleHeight = true, UseDoubleHeight = true,
SkipFileHeader = true, SkipFileHeader = true,
@ -122,28 +119,65 @@ internal abstract class IconEncoderCore : IImageEncoderInternals
image.Frames.Select(i => image.Frames.Select(i =>
{ {
IcoFrameMetadata metadata = i.Metadata.GetIcoMetadata(); IcoFrameMetadata metadata = i.Metadata.GetIcoMetadata();
return new EncodingFrameMetadata(metadata.Compression, metadata.BmpBitsPerPixel, metadata.ToIconDirEntry()); return new EncodingFrameMetadata(metadata.Compression, metadata.BmpBitsPerPixel, metadata.ColorTable, metadata.ToIconDirEntry());
}).ToArray(), }).ToArray(),
IconFileType.CUR => IconFileType.CUR =>
image.Frames.Select(i => image.Frames.Select(i =>
{ {
CurFrameMetadata metadata = i.Metadata.GetCurMetadata(); CurFrameMetadata metadata = i.Metadata.GetCurMetadata();
return new EncodingFrameMetadata(metadata.Compression, metadata.BmpBitsPerPixel, metadata.ToIconDirEntry()); return new EncodingFrameMetadata(metadata.Compression, metadata.BmpBitsPerPixel, metadata.ColorTable, metadata.ToIconDirEntry());
}).ToArray(), }).ToArray(),
_ => throw new NotSupportedException(), _ => throw new NotSupportedException(),
}; };
} }
internal sealed class EncodingFrameMetadata( private IQuantizer? GetQuantizer(EncodingFrameMetadata metadata)
IconFrameCompression compression,
BmpBitsPerPixel bmpBitsPerPixel,
IconDirEntry iconDirEntry)
{ {
private IconDirEntry iconDirEntry = iconDirEntry; if (metadata.Entry.BitCount > 8)
{
return null;
}
if (this.encoder.Quantizer is not null)
{
return this.encoder.Quantizer;
}
if (metadata.ColorTable is null)
{
return new WuQuantizer(new()
{
MaxColors = metadata.Entry.ColorCount
});
}
// Don't dither if we have a palette. We want to preserve as much information as possible.
return new PaletteQuantizer(metadata.ColorTable.Value, new() { Dither = null });
}
internal sealed class EncodingFrameMetadata
{
private IconDirEntry iconDirEntry;
public EncodingFrameMetadata(
IconFrameCompression compression,
BmpBitsPerPixel bmpBitsPerPixel,
ReadOnlyMemory<Color>? colorTable,
IconDirEntry iconDirEntry)
{
this.Compression = compression;
this.BmpBitsPerPixel = compression == IconFrameCompression.Png
? BmpBitsPerPixel.Pixel32
: bmpBitsPerPixel;
this.ColorTable = colorTable;
this.iconDirEntry = iconDirEntry;
}
public IconFrameCompression Compression { get; }
public IconFrameCompression Compression { get; set; } = compression; public BmpBitsPerPixel BmpBitsPerPixel { get; }
public BmpBitsPerPixel BmpBitsPerPixel { get; set; } = bmpBitsPerPixel; public ReadOnlyMemory<Color>? ColorTable { get; set; }
public ref IconDirEntry Entry => ref this.iconDirEntry; public ref IconDirEntry Entry => ref this.iconDirEntry;
} }

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

@ -345,9 +345,10 @@ internal sealed class PngDecoderCore : IImageDecoderInternals
{ {
uint frameCount = 0; uint frameCount = 0;
ImageMetadata metadata = new(); ImageMetadata metadata = new();
List<ImageFrameMetadata> framesMetadata = [];
PngMetadata pngMetadata = metadata.GetPngMetadata(); PngMetadata pngMetadata = metadata.GetPngMetadata();
this.currentStream = stream; this.currentStream = stream;
FrameControl? lastFrameControl = null; FrameControl? currentFrameControl = null;
Span<byte> buffer = stackalloc byte[20]; Span<byte> buffer = stackalloc byte[20];
this.currentStream.Skip(8); this.currentStream.Skip(8);
@ -400,7 +401,8 @@ internal sealed class PngDecoderCore : IImageDecoderInternals
break; break;
} }
lastFrameControl = this.ReadFrameControlChunk(chunk.Data.GetSpan()); currentFrameControl = this.ReadFrameControlChunk(chunk.Data.GetSpan());
break; break;
case PngChunkType.FrameData: case PngChunkType.FrameData:
if (frameCount == this.maxFrames) if (frameCount == this.maxFrames)
@ -413,22 +415,35 @@ internal sealed class PngDecoderCore : IImageDecoderInternals
goto EOF; goto EOF;
} }
if (lastFrameControl is null) if (currentFrameControl is null)
{ {
PngThrowHelper.ThrowMissingFrameControl(); PngThrowHelper.ThrowMissingFrameControl();
} }
InitializeFrameMetadata(framesMetadata, currentFrameControl.Value);
// Skip sequence number // Skip sequence number
this.currentStream.Skip(4); this.currentStream.Skip(4);
this.SkipChunkDataAndCrc(chunk); this.SkipChunkDataAndCrc(chunk);
break; break;
case PngChunkType.Data: case PngChunkType.Data:
// Spec says tRNS must be before IDAT so safe to exit. // Spec says tRNS must be before IDAT so safe to exit.
if (this.colorMetadataOnly) if (this.colorMetadataOnly)
{ {
goto EOF; goto EOF;
} }
pngMetadata.AnimateRootFrame = currentFrameControl != null;
currentFrameControl ??= new((uint)this.header.Width, (uint)this.header.Height);
if (framesMetadata.Count == 0)
{
InitializeFrameMetadata(framesMetadata, currentFrameControl.Value);
// Both PLTE and tRNS chunks, if present, have been read at this point as per spec.
AssignColorPalette(this.palette, this.paletteAlpha, pngMetadata);
}
this.SkipChunkDataAndCrc(chunk); this.SkipChunkDataAndCrc(chunk);
break; break;
case PngChunkType.Palette: case PngChunkType.Palette:
@ -515,7 +530,7 @@ internal sealed class PngDecoderCore : IImageDecoderInternals
// Both PLTE and tRNS chunks, if present, have been read at this point as per spec. // Both PLTE and tRNS chunks, if present, have been read at this point as per spec.
AssignColorPalette(this.palette, this.paletteAlpha, pngMetadata); AssignColorPalette(this.palette, this.paletteAlpha, pngMetadata);
return new ImageInfo(new PixelTypeInfo(this.CalculateBitsPerPixel()), new(this.header.Width, this.header.Height), metadata); return new ImageInfo(new PixelTypeInfo(this.CalculateBitsPerPixel()), new(this.header.Width, this.header.Height), metadata, framesMetadata);
} }
finally finally
{ {
@ -680,6 +695,14 @@ internal sealed class PngDecoderCore : IImageDecoderInternals
this.scanline = this.configuration.MemoryAllocator.Allocate<byte>(this.bytesPerScanline, AllocationOptions.Clean); this.scanline = this.configuration.MemoryAllocator.Allocate<byte>(this.bytesPerScanline, AllocationOptions.Clean);
} }
private static void InitializeFrameMetadata(List<ImageFrameMetadata> imageFrameMetadata, FrameControl currentFrameControl)
{
ImageFrameMetadata meta = new();
PngFrameMetadata frameMetadata = meta.GetPngMetadata();
frameMetadata.FromChunk(currentFrameControl);
imageFrameMetadata.Add(meta);
}
/// <summary> /// <summary>
/// Calculates the correct number of bits per pixel for the given color type. /// Calculates the correct number of bits per pixel for the given color type.
/// </summary> /// </summary>

7
src/ImageSharp/Processing/Processors/Quantization/PaletteQuantizer.cs

@ -62,9 +62,10 @@ public class PaletteQuantizer : IQuantizer
{ {
Guard.NotNull(options, nameof(options)); Guard.NotNull(options, nameof(options));
// Always use the palette length over options since the palette cannot be reduced. // If the palette is larger than the max colors then we need to trim it down.
TPixel[] palette = new TPixel[this.colorPalette.Length]; // treat the buffer as FILO.
Color.ToPixel(this.colorPalette.Span, palette.AsSpan()); TPixel[] palette = new TPixel[Math.Min(options.MaxColors, this.colorPalette.Length)];
Color.ToPixel(this.colorPalette.Span[..palette.Length], palette.AsSpan());
return new PaletteQuantizer<TPixel>(configuration, options, palette, this.transparentIndex); return new PaletteQuantizer<TPixel>(configuration, options, palette, this.transparentIndex);
} }
} }

19
tests/ImageSharp.Tests/Formats/Icon/Cur/CurDecoderTests.cs

@ -1,7 +1,9 @@
// Copyright (c) Six Labors. // Copyright (c) Six Labors.
// Licensed under the Six Labors Split License. // Licensed under the Six Labors Split License.
using SixLabors.ImageSharp.Formats.Bmp;
using SixLabors.ImageSharp.Formats.Cur; using SixLabors.ImageSharp.Formats.Cur;
using SixLabors.ImageSharp.Formats.Icon;
using SixLabors.ImageSharp.PixelFormats; using SixLabors.ImageSharp.PixelFormats;
using static SixLabors.ImageSharp.Tests.TestImages.Cur; using static SixLabors.ImageSharp.Tests.TestImages.Cur;
@ -17,9 +19,11 @@ public class CurDecoderTests
{ {
using Image<Rgba32> image = provider.GetImage(CurDecoder.Instance); using Image<Rgba32> image = provider.GetImage(CurDecoder.Instance);
image.DebugSaveMultiFrame(provider, extension: "png"); CurFrameMetadata meta = image.Frames[0].Metadata.GetCurMetadata();
Assert.Equal(image.Width, meta.EncodingWidth);
// TODO: Assert metadata, frame count, etc Assert.Equal(image.Height, meta.EncodingHeight);
Assert.Equal(IconFrameCompression.Bmp, meta.Compression);
Assert.Equal(BmpBitsPerPixel.Pixel32, meta.BmpBitsPerPixel);
} }
[Theory] [Theory]
@ -28,9 +32,10 @@ public class CurDecoderTests
public void CurDecoder_Decode2(TestImageProvider<Rgba32> provider) public void CurDecoder_Decode2(TestImageProvider<Rgba32> provider)
{ {
using Image<Rgba32> image = provider.GetImage(CurDecoder.Instance); using Image<Rgba32> image = provider.GetImage(CurDecoder.Instance);
CurFrameMetadata meta = image.Frames[0].Metadata.GetCurMetadata();
image.DebugSaveMultiFrame(provider, extension: "png"); Assert.Equal(image.Width, meta.EncodingWidth);
Assert.Equal(image.Height, meta.EncodingHeight);
// TODO: Assert metadata, frame count, etc Assert.Equal(IconFrameCompression.Bmp, meta.Compression);
Assert.Equal(BmpBitsPerPixel.Pixel32, meta.BmpBitsPerPixel);
} }
} }

26
tests/ImageSharp.Tests/Formats/Icon/Cur/CurEncoderTests.cs

@ -3,6 +3,7 @@
using SixLabors.ImageSharp.Formats.Cur; using SixLabors.ImageSharp.Formats.Cur;
using SixLabors.ImageSharp.PixelFormats; using SixLabors.ImageSharp.PixelFormats;
using SixLabors.ImageSharp.Tests.TestUtilities.ImageComparison;
using static SixLabors.ImageSharp.Tests.TestImages.Cur; using static SixLabors.ImageSharp.Tests.TestImages.Cur;
namespace SixLabors.ImageSharp.Tests.Formats.Icon.Cur; namespace SixLabors.ImageSharp.Tests.Formats.Icon.Cur;
@ -10,23 +11,24 @@ namespace SixLabors.ImageSharp.Tests.Formats.Icon.Cur;
[Trait("Format", "Cur")] [Trait("Format", "Cur")]
public class CurEncoderTests public class CurEncoderTests
{ {
private static CurEncoder CurEncoder => new(); private static CurEncoder Encoder => new();
public static readonly TheoryData<string> Files = new()
{
{ WindowsMouse },
};
[Theory] [Theory]
[MemberData(nameof(Files))] [WithFile(CurReal, PixelTypes.Rgba32)]
public void Encode(string imagePath) [WithFile(WindowsMouse, PixelTypes.Rgba32)]
public void CanRoundTripEncoder<TPixel>(TestImageProvider<TPixel> provider)
where TPixel : unmanaged, IPixel<TPixel>
{ {
TestFile testFile = TestFile.Create(imagePath); using Image<TPixel> image = provider.GetImage(CurDecoder.Instance);
using Image<Rgba32> input = testFile.CreateRgba32Image();
using MemoryStream memStream = new(); using MemoryStream memStream = new();
input.Save(memStream, CurEncoder); image.DebugSaveMultiFrame(provider);
image.Save(memStream, Encoder);
memStream.Seek(0, SeekOrigin.Begin); memStream.Seek(0, SeekOrigin.Begin);
CurDecoder.Instance.Decode(new(), memStream);
using Image<TPixel> encoded = Image.Load<TPixel>(memStream);
encoded.DebugSaveMultiFrame(provider, appendPixelTypeToFileName: false);
encoded.CompareToOriginalMultiFrame(provider, ImageComparer.Exact, CurDecoder.Instance);
} }
} }

139
tests/ImageSharp.Tests/Formats/Icon/Ico/IcoDecoderTests.cs

@ -1,7 +1,9 @@
// Copyright (c) Six Labors. // Copyright (c) Six Labors.
// Licensed under the Six Labors Split License. // Licensed under the Six Labors Split License.
using SixLabors.ImageSharp.Formats.Bmp;
using SixLabors.ImageSharp.Formats.Ico; using SixLabors.ImageSharp.Formats.Ico;
using SixLabors.ImageSharp.Formats.Icon;
using SixLabors.ImageSharp.PixelFormats; using SixLabors.ImageSharp.PixelFormats;
using static SixLabors.ImageSharp.Tests.TestImages.Ico; using static SixLabors.ImageSharp.Tests.TestImages.Ico;
@ -17,9 +19,9 @@ public class IcoDecoderTests
{ {
using Image<Rgba32> image = provider.GetImage(IcoDecoder.Instance); using Image<Rgba32> image = provider.GetImage(IcoDecoder.Instance);
image.DebugSaveMultiFrame(provider, extension: "png"); image.DebugSaveMultiFrame(provider);
// TODO: Assert metadata, frame count, etc Assert.Equal(10, image.Frames.Count);
} }
[Theory] [Theory]
@ -45,9 +47,16 @@ public class IcoDecoderTests
{ {
using Image<Rgba32> image = provider.GetImage(IcoDecoder.Instance); using Image<Rgba32> image = provider.GetImage(IcoDecoder.Instance);
image.DebugSaveMultiFrame(provider, extension: "png"); image.DebugSave(provider);
// TODO: Assert metadata, frame count, etc IcoFrameMetadata meta = image.Frames.RootFrame.Metadata.GetIcoMetadata();
int expectedWidth = image.Width >= 256 ? 0 : image.Width;
int expectedHeight = image.Height >= 256 ? 0 : image.Height;
Assert.Equal(expectedWidth, meta.EncodingWidth);
Assert.Equal(expectedHeight, meta.EncodingHeight);
Assert.Equal(IconFrameCompression.Bmp, meta.Compression);
Assert.Equal(BmpBitsPerPixel.Pixel1, meta.BmpBitsPerPixel);
} }
[Theory] [Theory]
@ -74,9 +83,16 @@ public class IcoDecoderTests
{ {
using Image<Rgba32> image = provider.GetImage(IcoDecoder.Instance); using Image<Rgba32> image = provider.GetImage(IcoDecoder.Instance);
image.DebugSaveMultiFrame(provider, extension: "png"); image.DebugSave(provider);
IcoFrameMetadata meta = image.Frames.RootFrame.Metadata.GetIcoMetadata();
int expectedWidth = image.Width >= 256 ? 0 : image.Width;
int expectedHeight = image.Height >= 256 ? 0 : image.Height;
// TODO: Assert metadata, frame count, etc Assert.Equal(expectedWidth, meta.EncodingWidth);
Assert.Equal(expectedHeight, meta.EncodingHeight);
Assert.Equal(IconFrameCompression.Bmp, meta.Compression);
Assert.Equal(BmpBitsPerPixel.Pixel24, meta.BmpBitsPerPixel);
} }
[Theory] [Theory]
@ -103,9 +119,16 @@ public class IcoDecoderTests
{ {
using Image<Rgba32> image = provider.GetImage(IcoDecoder.Instance); using Image<Rgba32> image = provider.GetImage(IcoDecoder.Instance);
image.DebugSaveMultiFrame(provider, extension: "png"); image.DebugSave(provider);
// TODO: Assert metadata, frame count, etc IcoFrameMetadata meta = image.Frames.RootFrame.Metadata.GetIcoMetadata();
int expectedWidth = image.Width >= 256 ? 0 : image.Width;
int expectedHeight = image.Height >= 256 ? 0 : image.Height;
Assert.Equal(expectedWidth, meta.EncodingWidth);
Assert.Equal(expectedHeight, meta.EncodingHeight);
Assert.Equal(IconFrameCompression.Bmp, meta.Compression);
Assert.Equal(BmpBitsPerPixel.Pixel32, meta.BmpBitsPerPixel);
} }
[Theory] [Theory]
@ -131,9 +154,16 @@ public class IcoDecoderTests
{ {
using Image<Rgba32> image = provider.GetImage(IcoDecoder.Instance); using Image<Rgba32> image = provider.GetImage(IcoDecoder.Instance);
image.DebugSaveMultiFrame(provider, extension: "png"); image.DebugSave(provider);
IcoFrameMetadata meta = image.Frames.RootFrame.Metadata.GetIcoMetadata();
int expectedWidth = image.Width >= 256 ? 0 : image.Width;
int expectedHeight = image.Height >= 256 ? 0 : image.Height;
// TODO: Assert metadata, frame count, etc Assert.Equal(expectedWidth, meta.EncodingWidth);
Assert.Equal(expectedHeight, meta.EncodingHeight);
Assert.Equal(IconFrameCompression.Bmp, meta.Compression);
Assert.Equal(BmpBitsPerPixel.Pixel4, meta.BmpBitsPerPixel);
} }
[Theory] [Theory]
@ -151,7 +181,8 @@ public class IcoDecoderTests
[WithFile(Bpp8Size5x5, PixelTypes.Rgba32)] [WithFile(Bpp8Size5x5, PixelTypes.Rgba32)]
[WithFile(Bpp8Size6x6, PixelTypes.Rgba32)] [WithFile(Bpp8Size6x6, PixelTypes.Rgba32)]
[WithFile(Bpp8Size7x7, PixelTypes.Rgba32)] [WithFile(Bpp8Size7x7, PixelTypes.Rgba32)]
[WithFile(Bpp8Size8x8, PixelTypes.Rgba32)]
// [WithFile(Bpp8Size8x8, PixelTypes.Rgba32)] This is actually 24 bit.
[WithFile(Bpp8Size9x9, PixelTypes.Rgba32)] [WithFile(Bpp8Size9x9, PixelTypes.Rgba32)]
[WithFile(Bpp8TranspNotSquare, PixelTypes.Rgba32)] [WithFile(Bpp8TranspNotSquare, PixelTypes.Rgba32)]
[WithFile(Bpp8TranspPartial, PixelTypes.Rgba32)] [WithFile(Bpp8TranspPartial, PixelTypes.Rgba32)]
@ -159,9 +190,16 @@ public class IcoDecoderTests
{ {
using Image<Rgba32> image = provider.GetImage(IcoDecoder.Instance); using Image<Rgba32> image = provider.GetImage(IcoDecoder.Instance);
image.DebugSaveMultiFrame(provider, extension: "png"); image.DebugSave(provider);
IcoFrameMetadata meta = image.Frames.RootFrame.Metadata.GetIcoMetadata();
int expectedWidth = image.Width >= 256 ? 0 : image.Width;
int expectedHeight = image.Height >= 256 ? 0 : image.Height;
// TODO: Assert metadata, frame count, etc Assert.Equal(expectedWidth, meta.EncodingWidth);
Assert.Equal(expectedHeight, meta.EncodingHeight);
Assert.Equal(IconFrameCompression.Bmp, meta.Compression);
Assert.Equal(BmpBitsPerPixel.Pixel8, meta.BmpBitsPerPixel);
} }
[Theory] [Theory]
@ -174,10 +212,6 @@ public class IcoDecoderTests
=> Assert.Throws<NotSupportedException>(() => => Assert.Throws<NotSupportedException>(() =>
{ {
using Image<Rgba32> image = provider.GetImage(IcoDecoder.Instance); using Image<Rgba32> image = provider.GetImage(IcoDecoder.Instance);
image.DebugSaveMultiFrame(provider, extension: "png");
// TODO: Assert metadata, frame count, etc
}); });
[Theory] [Theory]
@ -186,9 +220,16 @@ public class IcoDecoderTests
{ {
using Image<Rgba32> image = provider.GetImage(IcoDecoder.Instance); using Image<Rgba32> image = provider.GetImage(IcoDecoder.Instance);
image.DebugSaveMultiFrame(provider, extension: "png"); image.DebugSave(provider);
IcoFrameMetadata meta = image.Frames.RootFrame.Metadata.GetIcoMetadata();
int expectedWidth = image.Width >= 256 ? 0 : image.Width;
int expectedHeight = image.Height >= 256 ? 0 : image.Height;
// TODO: Assert metadata, frame count, etc Assert.Equal(expectedWidth, meta.EncodingWidth);
Assert.Equal(expectedHeight, meta.EncodingHeight);
Assert.Equal(IconFrameCompression.Png, meta.Compression);
Assert.Equal(BmpBitsPerPixel.Pixel32, meta.BmpBitsPerPixel);
} }
[Theory] [Theory]
@ -199,9 +240,9 @@ public class IcoDecoderTests
{ {
using Image<Rgba32> image = provider.GetImage(IcoDecoder.Instance); using Image<Rgba32> image = provider.GetImage(IcoDecoder.Instance);
image.DebugSaveMultiFrame(provider, extension: "png"); Assert.True(image.Frames.Count > 1);
// TODO: Assert metadata, frame count, etc image.DebugSaveMultiFrame(provider);
} }
[Theory] [Theory]
@ -215,9 +256,46 @@ public class IcoDecoderTests
{ {
using Image<Rgba32> image = provider.GetImage(IcoDecoder.Instance); using Image<Rgba32> image = provider.GetImage(IcoDecoder.Instance);
image.DebugSaveMultiFrame(provider, extension: "png"); Assert.True(image.Frames.Count > 1);
for (int i = 0; i < image.Frames.Count; i++)
{
ImageFrame<Rgba32> frame = image.Frames[i];
IcoFrameMetadata meta = frame.Metadata.GetIcoMetadata();
Assert.Equal(BmpBitsPerPixel.Pixel32, meta.BmpBitsPerPixel);
}
image.DebugSaveMultiFrame(provider);
}
[Theory]
[WithFile(MultiSizeA, PixelTypes.Rgba32)]
[WithFile(MultiSizeB, PixelTypes.Rgba32)]
[WithFile(MultiSizeC, PixelTypes.Rgba32)]
[WithFile(MultiSizeD, PixelTypes.Rgba32)]
[WithFile(MultiSizeE, PixelTypes.Rgba32)]
[WithFile(MultiSizeF, PixelTypes.Rgba32)]
public void MultiSize_CanDecodeSingleFrame(TestImageProvider<Rgba32> provider)
{
using Image<Rgba32> image = provider.GetImage(IcoDecoder.Instance, new() { MaxFrames = 1 });
Assert.Single(image.Frames);
}
// TODO: Assert metadata, frame count, etc [Theory]
[InlineData(MultiSizeA)]
[InlineData(MultiSizeB)]
[InlineData(MultiSizeC)]
[InlineData(MultiSizeD)]
[InlineData(MultiSizeE)]
[InlineData(MultiSizeF)]
public void MultiSize_CanIdentifySingleFrame(string imagePath)
{
TestFile testFile = TestFile.Create(imagePath);
using MemoryStream stream = new(testFile.Bytes, false);
ImageInfo imageInfo = Image.Identify(new() { MaxFrames = 1 }, stream);
Assert.Single(imageInfo.FrameMetadataCollection);
} }
[Theory] [Theory]
@ -229,9 +307,9 @@ public class IcoDecoderTests
{ {
using Image<Rgba32> image = provider.GetImage(IcoDecoder.Instance); using Image<Rgba32> image = provider.GetImage(IcoDecoder.Instance);
image.DebugSaveMultiFrame(provider, extension: "png"); Assert.True(image.Frames.Count > 1);
// TODO: Assert metadata, frame count, etc image.DebugSaveMultiFrame(provider);
} }
[Theory] [Theory]
@ -240,8 +318,15 @@ public class IcoDecoderTests
{ {
using Image<Rgba32> image = provider.GetImage(IcoDecoder.Instance); using Image<Rgba32> image = provider.GetImage(IcoDecoder.Instance);
image.DebugSaveMultiFrame(provider, extension: "png"); image.DebugSave(provider);
IcoFrameMetadata meta = image.Frames.RootFrame.Metadata.GetIcoMetadata();
int expectedWidth = image.Width >= 256 ? 0 : image.Width;
int expectedHeight = image.Height >= 256 ? 0 : image.Height;
// TODO: Assert metadata, frame count, etc Assert.Equal(expectedWidth, meta.EncodingWidth);
Assert.Equal(expectedHeight, meta.EncodingHeight);
Assert.Equal(IconFrameCompression.Bmp, meta.Compression);
Assert.Equal(BmpBitsPerPixel.Pixel32, meta.BmpBitsPerPixel);
} }
} }

26
tests/ImageSharp.Tests/Formats/Icon/Ico/IcoEncoderTests.cs

@ -3,6 +3,7 @@
using SixLabors.ImageSharp.Formats.Ico; using SixLabors.ImageSharp.Formats.Ico;
using SixLabors.ImageSharp.PixelFormats; using SixLabors.ImageSharp.PixelFormats;
using SixLabors.ImageSharp.Tests.TestUtilities.ImageComparison;
using static SixLabors.ImageSharp.Tests.TestImages.Ico; using static SixLabors.ImageSharp.Tests.TestImages.Ico;
namespace SixLabors.ImageSharp.Tests.Formats.Icon.Ico; namespace SixLabors.ImageSharp.Tests.Formats.Icon.Ico;
@ -10,23 +11,24 @@ namespace SixLabors.ImageSharp.Tests.Formats.Icon.Ico;
[Trait("Format", "Icon")] [Trait("Format", "Icon")]
public class IcoEncoderTests public class IcoEncoderTests
{ {
private static IcoEncoder CurEncoder => new(); private static IcoEncoder Encoder => new();
public static readonly TheoryData<string> Files = new()
{
{ Flutter },
};
[Theory] [Theory]
[MemberData(nameof(Files))] [WithFile(Flutter, PixelTypes.Rgba32)]
public void Encode(string imagePath) public void CanRoundTripEncoder<TPixel>(TestImageProvider<TPixel> provider)
where TPixel : unmanaged, IPixel<TPixel>
{ {
TestFile testFile = TestFile.Create(imagePath); using Image<TPixel> image = provider.GetImage(IcoDecoder.Instance);
using Image<Rgba32> input = testFile.CreateRgba32Image();
using MemoryStream memStream = new(); using MemoryStream memStream = new();
input.Save(memStream, CurEncoder); image.DebugSaveMultiFrame(provider);
image.Save(memStream, Encoder);
memStream.Seek(0, SeekOrigin.Begin); memStream.Seek(0, SeekOrigin.Begin);
IcoDecoder.Instance.Decode(new(), memStream);
using Image<TPixel> encoded = Image.Load<TPixel>(memStream);
encoded.DebugSaveMultiFrame(provider, appendPixelTypeToFileName: false);
// Despite preservation of the palette. The process can still be lossy
encoded.CompareToOriginalMultiFrame(provider, ImageComparer.TolerantPercentage(.23f), IcoDecoder.Instance);
} }
} }

Loading…
Cancel
Save