diff --git a/src/ImageSharp/Formats/Png/IPngEncoderOptions.cs b/src/ImageSharp/Formats/Png/IPngEncoderOptions.cs index 741ec825d4..66117371ed 100644 --- a/src/ImageSharp/Formats/Png/IPngEncoderOptions.cs +++ b/src/ImageSharp/Formats/Png/IPngEncoderOptions.cs @@ -57,5 +57,22 @@ internal interface IPngEncoderOptions /// Gets a value indicating whether this instance should write an Adam7 interlaced image. /// PngInterlaceMode? InterlaceMethod { get; } + + /// + /// Gets a value indicating whether the metadata should be ignored when the image is being encoded. + /// When set to true, all ancillary chunks will be skipped. + /// + bool IgnoreMetadata { get; } + + /// + /// Gets the chunk filter method. This allows to filter ancillary chunks. + /// + PngChunkFilter? ChunkFilter { get; } + + /// + /// Gets a value indicating whether fully transparent pixels that may contain R, G, B values which are not 0, + /// should be converted to transparent black, which can yield in better compression in some cases. + /// + PngTransparentColorMode TransparentColorMode { get; } } } diff --git a/src/ImageSharp/Formats/Png/PngChunkFilter.cs b/src/ImageSharp/Formats/Png/PngChunkFilter.cs new file mode 100644 index 0000000000..4e8b5ab96f --- /dev/null +++ b/src/ImageSharp/Formats/Png/PngChunkFilter.cs @@ -0,0 +1,44 @@ +// Copyright (c) Six Labors and contributors. +// Licensed under the GNU Affero General Public License, Version 3. + +using System; + +namespace SixLabors.ImageSharp.Formats.Png +{ + /// + /// Provides enumeration of available PNG optimization methods. + /// + [Flags] + public enum PngChunkFilter + { + /// + /// With the None filter, all chunks will be written. + /// + None = 0, + + /// + /// Excludes the physical dimension information chunk from encoding. + /// + ExcludePhysicalChunk = 1 << 0, + + /// + /// Excludes the gamma information chunk from encoding. + /// + ExcludeGammaChunk = 1 << 1, + + /// + /// Excludes the eXIf chunk from encoding. + /// + ExcludeExifChunk = 1 << 2, + + /// + /// Excludes the tTXt, iTXt or zTXt chunk from encoding. + /// + ExcludeTextChunks = 1 << 3, + + /// + /// All ancillary chunks will be excluded. + /// + ExcludeAll = ~None + } +} diff --git a/src/ImageSharp/Formats/Png/PngEncoder.cs b/src/ImageSharp/Formats/Png/PngEncoder.cs index bee0215506..9b1fc80e07 100644 --- a/src/ImageSharp/Formats/Png/PngEncoder.cs +++ b/src/ImageSharp/Formats/Png/PngEncoder.cs @@ -34,14 +34,21 @@ public sealed class PngEncoder : IImageEncoder, IPngEncoderOptions /// public IQuantizer Quantizer { get; set; } - /// - /// Gets or sets the transparency threshold. - /// + /// public byte Threshold { get; set; } = byte.MaxValue; /// public PngInterlaceMode? InterlaceMethod { get; set; } + /// + public PngChunkFilter? ChunkFilter { get; set; } + + /// + public bool IgnoreMetadata { get; set; } + + /// + public PngTransparentColorMode TransparentColorMode { get; set; } + /// /// Encodes the image to the specified stream from the . /// diff --git a/src/ImageSharp/Formats/Png/PngEncoderCore.cs b/src/ImageSharp/Formats/Png/PngEncoderCore.cs index c150388477..6c30550c2a 100644 --- a/src/ImageSharp/Formats/Png/PngEncoderCore.cs +++ b/src/ImageSharp/Formats/Png/PngEncoderCore.cs @@ -7,7 +7,7 @@ using System.IO; using System.Runtime.CompilerServices; using System.Runtime.InteropServices; -using SixLabors.ImageSharp.Advanced; + using SixLabors.ImageSharp.Formats.Png.Chunks; using SixLabors.ImageSharp.Formats.Png.Filters; using SixLabors.ImageSharp.Formats.Png.Zlib; @@ -141,10 +141,18 @@ public void Encode(Image image, Stream stream) this.height = image.Height; ImageMetadata metadata = image.Metadata; - PngMetadata pngMetadata = metadata.GetPngMetadata(); + + PngMetadata pngMetadata = metadata.GetFormatMetadata(PngFormat.Instance); PngEncoderOptionsHelpers.AdjustOptions(this.options, pngMetadata, out this.use16Bit, out this.bytesPerPixel); - IndexedImageFrame quantized = PngEncoderOptionsHelpers.CreateQuantizedFrame(this.options, image); - this.bitDepth = PngEncoderOptionsHelpers.CalculateBitDepth(this.options, image, quantized); + Image clonedImage = null; + bool clearTransparency = this.options.TransparentColorMode == PngTransparentColorMode.Clear; + if (clearTransparency) + { + clonedImage = image.Clone(); + ClearTransparentPixels(clonedImage); + } + + IndexedImageFrame quantized = this.CreateQuantizedImage(image, clonedImage); stream.Write(PngConstants.HeaderBytes); @@ -155,11 +163,13 @@ public void Encode(Image image, Stream stream) this.WritePhysicalChunk(stream, metadata); this.WriteExifChunk(stream, metadata); this.WriteTextChunks(stream, pngMetadata); - this.WriteDataChunks(image.Frames.RootFrame, quantized, stream); + this.WriteDataChunks(clearTransparency ? clonedImage : image, quantized, stream); this.WriteEndChunk(stream); + stream.Flush(); quantized?.Dispose(); + clonedImage?.Dispose(); } /// @@ -180,6 +190,55 @@ public void Dispose() this.filterBuffer = null; } + /// + /// Convert transparent pixels, to transparent black pixels, which can yield to better compression in some cases. + /// + /// The type of the pixel. + /// The cloned image where the transparent pixels will be changed. + private static void ClearTransparentPixels(Image image) + where TPixel : unmanaged, IPixel + { + Rgba32 rgba32 = default; + for (int y = 0; y < image.Height; y++) + { + Span span = image.GetPixelRowSpan(y); + for (int x = 0; x < image.Width; x++) + { + span[x].ToRgba32(ref rgba32); + + if (rgba32.A == 0) + { + span[x].FromRgba32(Color.Transparent); + } + } + } + } + + /// + /// Creates the quantized image and sets calculates and sets the bit depth. + /// + /// The type of the pixel. + /// The image to quantize. + /// Cloned image with transparent pixels are changed to black. + /// The quantized image. + private IndexedImageFrame CreateQuantizedImage(Image image, Image clonedImage) + where TPixel : unmanaged, IPixel + { + IndexedImageFrame quantized; + if (this.options.TransparentColorMode == PngTransparentColorMode.Clear) + { + quantized = PngEncoderOptionsHelpers.CreateQuantizedFrame(this.options, clonedImage); + this.bitDepth = PngEncoderOptionsHelpers.CalculateBitDepth(this.options, quantized); + } + else + { + quantized = PngEncoderOptionsHelpers.CreateQuantizedFrame(this.options, image); + this.bitDepth = PngEncoderOptionsHelpers.CalculateBitDepth(this.options, quantized); + } + + return quantized; + } + /// Collects a row of grayscale pixels. /// The pixel format. /// The image row span. @@ -602,6 +661,11 @@ private void WritePaletteChunk(Stream stream, IndexedImageFrame /// The image metadata. private void WritePhysicalChunk(Stream stream, ImageMetadata meta) { + if (((this.options.ChunkFilter ?? PngChunkFilter.None) & PngChunkFilter.ExcludePhysicalChunk) == PngChunkFilter.ExcludePhysicalChunk) + { + return; + } + PhysicalChunkData.FromMetadata(meta).WriteTo(this.chunkDataBuffer); this.WriteChunk(stream, PngChunkType.Physical, this.chunkDataBuffer, 0, PhysicalChunkData.Size); @@ -614,6 +678,11 @@ private void WritePhysicalChunk(Stream stream, ImageMetadata meta) /// The image metadata. private void WriteExifChunk(Stream stream, ImageMetadata meta) { + if (((this.options.ChunkFilter ?? PngChunkFilter.None) & PngChunkFilter.ExcludeExifChunk) == PngChunkFilter.ExcludeExifChunk) + { + return; + } + if (meta.ExifProfile is null || meta.ExifProfile.Values.Count == 0) { return; @@ -631,6 +700,11 @@ private void WriteExifChunk(Stream stream, ImageMetadata meta) /// The image metadata. private void WriteTextChunks(Stream stream, PngMetadata meta) { + if (((this.options.ChunkFilter ?? PngChunkFilter.None) & PngChunkFilter.ExcludeTextChunks) == PngChunkFilter.ExcludeTextChunks) + { + return; + } + const int MaxLatinCode = 255; for (int i = 0; i < meta.TextData.Count; i++) { @@ -723,6 +797,11 @@ private byte[] GetCompressedTextBytes(byte[] textBytes) /// The containing image data. private void WriteGammaChunk(Stream stream) { + if (((this.options.ChunkFilter ?? PngChunkFilter.None) & PngChunkFilter.ExcludeGammaChunk) == PngChunkFilter.ExcludeGammaChunk) + { + return; + } + if (this.options.Gamma > 0) { // 4-byte unsigned integer of gamma * 100,000. @@ -792,7 +871,7 @@ private void WriteTransparencyChunk(Stream stream, PngMetadata pngMetadata) /// The image. /// The quantized pixel data. Can be null. /// The stream. - private void WriteDataChunks(ImageFrame pixels, IndexedImageFrame quantized, Stream stream) + private void WriteDataChunks(Image pixels, IndexedImageFrame quantized, Stream stream) where TPixel : unmanaged, IPixel { byte[] buffer; @@ -890,8 +969,8 @@ private void AllocateExtBuffers() /// The pixels. /// The quantized pixels span. /// The deflate stream. - private void EncodePixels(ImageFrame pixels, IndexedImageFrame quantized, ZlibDeflateStream deflateStream) - where TPixel : unmanaged, IPixel + private void EncodePixels(Image pixels, IndexedImageFrame quantized, ZlibDeflateStream deflateStream) + where TPixel : unmanaged, IPixel { int bytesPerScanline = this.CalculateScanlineLength(this.width); int resultLength = bytesPerScanline + 1; @@ -914,7 +993,7 @@ private void EncodePixels(ImageFrame pixels, IndexedImageFrameThe type of the pixel. /// The pixels. /// The deflate stream. - private void EncodeAdam7Pixels(ImageFrame pixels, ZlibDeflateStream deflateStream) + private void EncodeAdam7Pixels(Image pixels, ZlibDeflateStream deflateStream) where TPixel : unmanaged, IPixel { int width = pixels.Width; diff --git a/src/ImageSharp/Formats/Png/PngEncoderOptions.cs b/src/ImageSharp/Formats/Png/PngEncoderOptions.cs index 5545f2c6e9..53e6ee30f8 100644 --- a/src/ImageSharp/Formats/Png/PngEncoderOptions.cs +++ b/src/ImageSharp/Formats/Png/PngEncoderOptions.cs @@ -29,6 +29,9 @@ public PngEncoderOptions(IPngEncoderOptions source) this.Quantizer = source.Quantizer; this.Threshold = source.Threshold; this.InterlaceMethod = source.InterlaceMethod; + this.ChunkFilter = source.ChunkFilter; + this.IgnoreMetadata = source.IgnoreMetadata; + this.TransparentColorMode = source.TransparentColorMode; } /// @@ -57,5 +60,14 @@ public PngEncoderOptions(IPngEncoderOptions source) /// public PngInterlaceMode? InterlaceMethod { get; set; } + + /// + public PngChunkFilter? ChunkFilter { get; set; } + + /// + public bool IgnoreMetadata { get; set; } + + /// + public PngTransparentColorMode TransparentColorMode { get; set; } } } diff --git a/src/ImageSharp/Formats/Png/PngEncoderOptionsHelpers.cs b/src/ImageSharp/Formats/Png/PngEncoderOptionsHelpers.cs index 33456eec51..d0f708e93f 100644 --- a/src/ImageSharp/Formats/Png/PngEncoderOptionsHelpers.cs +++ b/src/ImageSharp/Formats/Png/PngEncoderOptionsHelpers.cs @@ -40,6 +40,11 @@ public static void AdjustOptions( use16Bit = options.BitDepth == PngBitDepth.Bit16; bytesPerPixel = CalculateBytesPerPixel(options.ColorType, use16Bit); + if (options.IgnoreMetadata) + { + options.ChunkFilter = PngChunkFilter.ExcludeAll; + } + // Ensure we are not allowing impossible combinations. if (!PngConstants.ColorTypes.ContainsKey(options.ColorType.Value)) { @@ -89,11 +94,9 @@ public static IndexedImageFrame CreateQuantizedFrame( /// /// The type of the pixel. /// The options. - /// The image. /// The quantized frame. public static byte CalculateBitDepth( PngEncoderOptions options, - Image image, IndexedImageFrame quantizedFrame) where TPixel : unmanaged, IPixel { diff --git a/src/ImageSharp/Formats/Png/PngTransparentColorMode.cs b/src/ImageSharp/Formats/Png/PngTransparentColorMode.cs new file mode 100644 index 0000000000..63967c153f --- /dev/null +++ b/src/ImageSharp/Formats/Png/PngTransparentColorMode.cs @@ -0,0 +1,22 @@ +// Copyright (c) Six Labors and contributors. +// Licensed under the GNU Affero General Public License, Version 3. + +namespace SixLabors.ImageSharp.Formats.Png +{ + /// + /// Enum indicating how the transparency should be handled on encoding. + /// + public enum PngTransparentColorMode + { + /// + /// The transparency will be kept as is. + /// + Preserve = 0, + + /// + /// Converts fully transparent pixels that may contain R, G, B values which are not 0, + /// to transparent black, which can yield in better compression in some cases. + /// + Clear = 1, + } +} diff --git a/tests/ImageSharp.Tests/Formats/Png/PngEncoderTests.Chunks.cs b/tests/ImageSharp.Tests/Formats/Png/PngEncoderTests.Chunks.cs new file mode 100644 index 0000000000..fa63f73e06 --- /dev/null +++ b/tests/ImageSharp.Tests/Formats/Png/PngEncoderTests.Chunks.cs @@ -0,0 +1,328 @@ +// Copyright (c) Six Labors and contributors. +// Licensed under the GNU Affero General Public License, Version 3. + +using System; +using System.Buffers.Binary; +using System.Collections.Generic; +using System.ComponentModel; +using System.IO; +using SixLabors.ImageSharp.Formats.Png; +using SixLabors.ImageSharp.PixelFormats; +using Xunit; + +// ReSharper disable InconsistentNaming +namespace SixLabors.ImageSharp.Tests.Formats.Png +{ + public partial class PngEncoderTests + { + [Fact] + public void HeaderChunk_ComesFirst() + { + // arrange + var testFile = TestFile.Create(TestImages.Png.PngWithMetadata); + using Image input = testFile.CreateRgba32Image(); + using var memStream = new MemoryStream(); + + // act + input.Save(memStream, PngEncoder); + + // assert + memStream.Position = 0; + Span bytesSpan = memStream.ToArray().AsSpan(8); // Skip header. + BinaryPrimitives.ReadInt32BigEndian(bytesSpan.Slice(0, 4)); + var type = (PngChunkType)BinaryPrimitives.ReadInt32BigEndian(bytesSpan.Slice(4, 4)); + Assert.Equal(PngChunkType.Header, type); + } + + [Fact] + public void EndChunk_IsLast() + { + // arrange + var testFile = TestFile.Create(TestImages.Png.PngWithMetadata); + using Image input = testFile.CreateRgba32Image(); + using var memStream = new MemoryStream(); + + // act + input.Save(memStream, PngEncoder); + + // assert + memStream.Position = 0; + Span bytesSpan = memStream.ToArray().AsSpan(8); // Skip header. + bool endChunkFound = false; + while (bytesSpan.Length > 0) + { + int length = BinaryPrimitives.ReadInt32BigEndian(bytesSpan.Slice(0, 4)); + var type = (PngChunkType)BinaryPrimitives.ReadInt32BigEndian(bytesSpan.Slice(4, 4)); + Assert.False(endChunkFound); + if (type == PngChunkType.End) + { + endChunkFound = true; + } + + bytesSpan = bytesSpan.Slice(4 + 4 + length + 4); + } + } + + [Theory] + [InlineData(PngChunkType.Gamma)] + [InlineData(PngChunkType.Chroma)] + [InlineData(PngChunkType.EmbeddedColorProfile)] + [InlineData(PngChunkType.SignificantBits)] + [InlineData(PngChunkType.StandardRgbColourSpace)] + public void Chunk_ComesBeforePlteAndIDat(object chunkTypeObj) + { + // arrange + var chunkType = (PngChunkType)chunkTypeObj; + var testFile = TestFile.Create(TestImages.Png.PngWithMetadata); + using Image input = testFile.CreateRgba32Image(); + using var memStream = new MemoryStream(); + + // act + input.Save(memStream, PngEncoder); + + // assert + memStream.Position = 0; + Span bytesSpan = memStream.ToArray().AsSpan(8); // Skip header. + bool palFound = false; + bool dataFound = false; + while (bytesSpan.Length > 0) + { + int length = BinaryPrimitives.ReadInt32BigEndian(bytesSpan.Slice(0, 4)); + var type = (PngChunkType)BinaryPrimitives.ReadInt32BigEndian(bytesSpan.Slice(4, 4)); + if (chunkType == type) + { + Assert.False(palFound || dataFound, $"{chunkType} chunk should come before data and palette chunk"); + } + + switch (type) + { + case PngChunkType.Data: + dataFound = true; + break; + case PngChunkType.Palette: + palFound = true; + break; + } + + bytesSpan = bytesSpan.Slice(4 + 4 + length + 4); + } + } + + [Theory] + [InlineData(PngChunkType.Physical)] + [InlineData(PngChunkType.SuggestedPalette)] + public void Chunk_ComesBeforeIDat(object chunkTypeObj) + { + // arrange + var chunkType = (PngChunkType)chunkTypeObj; + var testFile = TestFile.Create(TestImages.Png.PngWithMetadata); + using Image input = testFile.CreateRgba32Image(); + using var memStream = new MemoryStream(); + + // act + input.Save(memStream, PngEncoder); + + // assert + memStream.Position = 0; + Span bytesSpan = memStream.ToArray().AsSpan(8); // Skip header. + bool dataFound = false; + while (bytesSpan.Length > 0) + { + int length = BinaryPrimitives.ReadInt32BigEndian(bytesSpan.Slice(0, 4)); + var type = (PngChunkType)BinaryPrimitives.ReadInt32BigEndian(bytesSpan.Slice(4, 4)); + if (chunkType == type) + { + Assert.False(dataFound, $"{chunkType} chunk should come before data chunk"); + } + + if (type == PngChunkType.Data) + { + dataFound = true; + } + + bytesSpan = bytesSpan.Slice(4 + 4 + length + 4); + } + } + + [Fact] + public void IgnoreMetadata_WillExcludeAllAncillaryChunks() + { + // arrange + var testFile = TestFile.Create(TestImages.Png.PngWithMetadata); + using Image input = testFile.CreateRgba32Image(); + using var memStream = new MemoryStream(); + var encoder = new PngEncoder() { IgnoreMetadata = true, TextCompressionThreshold = 8 }; + var expectedChunkTypes = new Dictionary() + { + { PngChunkType.Header, false }, + { PngChunkType.Palette, false }, + { PngChunkType.Data, false }, + { PngChunkType.End, false } + }; + var excludedChunkTypes = new List() + { + PngChunkType.Gamma, + PngChunkType.Exif, + PngChunkType.Physical, + PngChunkType.Text, + PngChunkType.InternationalText, + PngChunkType.CompressedText, + }; + + // act + input.Save(memStream, encoder); + + // assert + Assert.True(excludedChunkTypes.Count > 0); + memStream.Position = 0; + Span bytesSpan = memStream.ToArray().AsSpan(8); // Skip header. + while (bytesSpan.Length > 0) + { + int length = BinaryPrimitives.ReadInt32BigEndian(bytesSpan.Slice(0, 4)); + var chunkType = (PngChunkType)BinaryPrimitives.ReadInt32BigEndian(bytesSpan.Slice(4, 4)); + Assert.False(excludedChunkTypes.Contains(chunkType), $"{chunkType} chunk should have been excluded"); + if (expectedChunkTypes.ContainsKey(chunkType)) + { + expectedChunkTypes[chunkType] = true; + } + + bytesSpan = bytesSpan.Slice(4 + 4 + length + 4); + } + + // all expected chunk types should have been seen at least once. + foreach (PngChunkType chunkType in expectedChunkTypes.Keys) + { + Assert.True(expectedChunkTypes[chunkType], $"We expect {chunkType} chunk to be present at least once"); + } + } + + [Theory] + [InlineData(PngChunkFilter.ExcludeGammaChunk)] + [InlineData(PngChunkFilter.ExcludeExifChunk)] + [InlineData(PngChunkFilter.ExcludePhysicalChunk)] + [InlineData(PngChunkFilter.ExcludeTextChunks)] + [InlineData(PngChunkFilter.ExcludeAll)] + public void ExcludeFilter_Works(object filterObj) + { + // arrange + var chunkFilter = (PngChunkFilter)filterObj; + var testFile = TestFile.Create(TestImages.Png.PngWithMetadata); + using Image input = testFile.CreateRgba32Image(); + using var memStream = new MemoryStream(); + var encoder = new PngEncoder() { ChunkFilter = chunkFilter, TextCompressionThreshold = 8 }; + var expectedChunkTypes = new Dictionary() + { + { PngChunkType.Header, false }, + { PngChunkType.Gamma, false }, + { PngChunkType.Palette, false }, + { PngChunkType.InternationalText, false }, + { PngChunkType.Text, false }, + { PngChunkType.CompressedText, false }, + { PngChunkType.Exif, false }, + { PngChunkType.Physical, false }, + { PngChunkType.Data, false }, + { PngChunkType.End, false } + }; + var excludedChunkTypes = new List(); + switch (chunkFilter) + { + case PngChunkFilter.ExcludeGammaChunk: + excludedChunkTypes.Add(PngChunkType.Gamma); + expectedChunkTypes.Remove(PngChunkType.Gamma); + break; + case PngChunkFilter.ExcludeExifChunk: + excludedChunkTypes.Add(PngChunkType.Exif); + expectedChunkTypes.Remove(PngChunkType.Exif); + break; + case PngChunkFilter.ExcludePhysicalChunk: + excludedChunkTypes.Add(PngChunkType.Physical); + expectedChunkTypes.Remove(PngChunkType.Physical); + break; + case PngChunkFilter.ExcludeTextChunks: + excludedChunkTypes.Add(PngChunkType.Text); + excludedChunkTypes.Add(PngChunkType.InternationalText); + excludedChunkTypes.Add(PngChunkType.CompressedText); + expectedChunkTypes.Remove(PngChunkType.Text); + expectedChunkTypes.Remove(PngChunkType.InternationalText); + expectedChunkTypes.Remove(PngChunkType.CompressedText); + break; + case PngChunkFilter.ExcludeAll: + excludedChunkTypes.Add(PngChunkType.Gamma); + excludedChunkTypes.Add(PngChunkType.Exif); + excludedChunkTypes.Add(PngChunkType.Physical); + excludedChunkTypes.Add(PngChunkType.Text); + excludedChunkTypes.Add(PngChunkType.InternationalText); + excludedChunkTypes.Add(PngChunkType.CompressedText); + expectedChunkTypes.Remove(PngChunkType.Gamma); + expectedChunkTypes.Remove(PngChunkType.Exif); + expectedChunkTypes.Remove(PngChunkType.Physical); + expectedChunkTypes.Remove(PngChunkType.Text); + expectedChunkTypes.Remove(PngChunkType.InternationalText); + expectedChunkTypes.Remove(PngChunkType.CompressedText); + break; + } + + // act + input.Save(memStream, encoder); + + // assert + Assert.True(excludedChunkTypes.Count > 0); + memStream.Position = 0; + Span bytesSpan = memStream.ToArray().AsSpan(8); // Skip header. + while (bytesSpan.Length > 0) + { + int length = BinaryPrimitives.ReadInt32BigEndian(bytesSpan.Slice(0, 4)); + var chunkType = (PngChunkType)BinaryPrimitives.ReadInt32BigEndian(bytesSpan.Slice(4, 4)); + Assert.False(excludedChunkTypes.Contains(chunkType), $"{chunkType} chunk should have been excluded"); + if (expectedChunkTypes.ContainsKey(chunkType)) + { + expectedChunkTypes[chunkType] = true; + } + + bytesSpan = bytesSpan.Slice(4 + 4 + length + 4); + } + + // all expected chunk types should have been seen at least once. + foreach (PngChunkType chunkType in expectedChunkTypes.Keys) + { + Assert.True(expectedChunkTypes[chunkType], $"We expect {chunkType} chunk to be present at least once"); + } + } + + [Fact] + public void ExcludeFilter_WithNone_DoesNotExcludeChunks() + { + // arrange + var testFile = TestFile.Create(TestImages.Png.PngWithMetadata); + using Image input = testFile.CreateRgba32Image(); + using var memStream = new MemoryStream(); + var encoder = new PngEncoder() { ChunkFilter = PngChunkFilter.None, TextCompressionThreshold = 8 }; + var expectedChunkTypes = new List() + { + PngChunkType.Header, + PngChunkType.Gamma, + PngChunkType.Palette, + PngChunkType.InternationalText, + PngChunkType.Text, + PngChunkType.CompressedText, + PngChunkType.Exif, + PngChunkType.Physical, + PngChunkType.Data, + PngChunkType.End, + }; + + // act + input.Save(memStream, encoder); + memStream.Position = 0; + Span bytesSpan = memStream.ToArray().AsSpan(8); // Skip header. + while (bytesSpan.Length > 0) + { + int length = BinaryPrimitives.ReadInt32BigEndian(bytesSpan.Slice(0, 4)); + var chunkType = (PngChunkType)BinaryPrimitives.ReadInt32BigEndian(bytesSpan.Slice(4, 4)); + Assert.True(expectedChunkTypes.Contains(chunkType), $"{chunkType} chunk should have been present"); + + bytesSpan = bytesSpan.Slice(4 + 4 + length + 4); + } + } + } +} diff --git a/tests/ImageSharp.Tests/Formats/Png/PngEncoderTests.cs b/tests/ImageSharp.Tests/Formats/Png/PngEncoderTests.cs index 7828134a75..4f2490f9a6 100644 --- a/tests/ImageSharp.Tests/Formats/Png/PngEncoderTests.cs +++ b/tests/ImageSharp.Tests/Formats/Png/PngEncoderTests.cs @@ -2,8 +2,6 @@ // Licensed under the GNU Affero General Public License, Version 3. // ReSharper disable InconsistentNaming -using System; -using System.Buffers.Binary; using System.IO; using System.Linq; @@ -18,7 +16,7 @@ namespace SixLabors.ImageSharp.Tests.Formats.Png { - public class PngEncoderTests + public partial class PngEncoderTests { private static PngEncoder PngEncoder => new PngEncoder(); @@ -215,6 +213,40 @@ public void WorksWithAllBitDepths(TestImageProvider provider, Pn } } + [Theory] + [WithTestPatternImages(24, 24, PixelTypes.Rgba32, PngColorType.Rgb, PngBitDepth.Bit8)] + [WithTestPatternImages(24, 24, PixelTypes.Rgba64, PngColorType.Rgb, PngBitDepth.Bit16)] + [WithTestPatternImages(24, 24, PixelTypes.Rgba32, PngColorType.RgbWithAlpha, PngBitDepth.Bit8)] + [WithTestPatternImages(24, 24, PixelTypes.Rgba64, PngColorType.RgbWithAlpha, PngBitDepth.Bit16)] + [WithTestPatternImages(24, 24, PixelTypes.Rgba32, PngColorType.Palette, PngBitDepth.Bit1)] + [WithTestPatternImages(24, 24, PixelTypes.Rgba32, PngColorType.Palette, PngBitDepth.Bit2)] + [WithTestPatternImages(24, 24, PixelTypes.Rgba32, PngColorType.Palette, PngBitDepth.Bit4)] + [WithTestPatternImages(24, 24, PixelTypes.Rgba32, PngColorType.Palette, PngBitDepth.Bit8)] + [WithTestPatternImages(24, 24, PixelTypes.Rgb24, PngColorType.Grayscale, PngBitDepth.Bit1)] + [WithTestPatternImages(24, 24, PixelTypes.Rgb24, PngColorType.Grayscale, PngBitDepth.Bit2)] + [WithTestPatternImages(24, 24, PixelTypes.Rgb24, PngColorType.Grayscale, PngBitDepth.Bit4)] + [WithTestPatternImages(24, 24, PixelTypes.Rgb24, PngColorType.Grayscale, PngBitDepth.Bit8)] + [WithTestPatternImages(24, 24, PixelTypes.Rgb48, PngColorType.Grayscale, PngBitDepth.Bit16)] + [WithTestPatternImages(24, 24, PixelTypes.Rgba32, PngColorType.GrayscaleWithAlpha, PngBitDepth.Bit8)] + [WithTestPatternImages(24, 24, PixelTypes.Rgba64, PngColorType.GrayscaleWithAlpha, PngBitDepth.Bit16)] + public void WorksWithAllBitDepthsAndExcludeAllFilter(TestImageProvider provider, PngColorType pngColorType, PngBitDepth pngBitDepth) + where TPixel : unmanaged, IPixel + { + foreach (PngInterlaceMode interlaceMode in InterlaceMode) + { + TestPngEncoderCore( + provider, + pngColorType, + PngFilterMethod.Adaptive, + pngBitDepth, + interlaceMode, + appendPngColorType: true, + appendPixelType: true, + appendPngBitDepth: true, + optimizeMethod: PngChunkFilter.ExcludeAll); + } + } + [Theory] [WithBlankImages(1, 1, PixelTypes.A8, PngColorType.GrayscaleWithAlpha, PngBitDepth.Bit8)] [WithBlankImages(1, 1, PixelTypes.Argb32, PngColorType.RgbWithAlpha, PngBitDepth.Bit8)] @@ -358,6 +390,66 @@ public void Encode_PreserveBits(string imagePath, PngBitDepth pngBitDepth) } } + [Theory] + [InlineData(PngColorType.Palette)] + [InlineData(PngColorType.RgbWithAlpha)] + [InlineData(PngColorType.GrayscaleWithAlpha)] + public void Encode_WithPngTransparentColorBehaviorClear_Works(PngColorType colorType) + { + // arrange + var image = new Image(50, 50); + var encoder = new PngEncoder() + { + TransparentColorMode = PngTransparentColorMode.Clear, + ColorType = colorType + }; + Rgba32 rgba32 = Color.Blue; + for (int y = 0; y < image.Height; y++) + { + System.Span rowSpan = image.GetPixelRowSpan(y); + + // Half of the test image should be transparent. + if (y > 25) + { + rgba32.A = 0; + } + + for (int x = 0; x < image.Width; x++) + { + rowSpan[x].FromRgba32(rgba32); + } + } + + // act + using var memStream = new MemoryStream(); + image.Save(memStream, encoder); + + // assert + memStream.Position = 0; + using var actual = Image.Load(memStream); + Rgba32 expectedColor = Color.Blue; + if (colorType == PngColorType.Grayscale || colorType == PngColorType.GrayscaleWithAlpha) + { + var luminance = ImageMaths.Get8BitBT709Luminance(expectedColor.R, expectedColor.G, expectedColor.B); + expectedColor = new Rgba32(luminance, luminance, luminance); + } + + for (int y = 0; y < actual.Height; y++) + { + System.Span rowSpan = actual.GetPixelRowSpan(y); + + if (y > 25) + { + expectedColor = Color.Transparent; + } + + for (int x = 0; x < actual.Width; x++) + { + Assert.Equal(expectedColor, rowSpan[x]); + } + } + } + [Theory] [MemberData(nameof(PngTrnsFiles))] public void Encode_PreserveTrns(string imagePath, PngBitDepth pngBitDepth, PngColorType pngColorType) @@ -411,126 +503,6 @@ public void Encode_PreserveTrns(string imagePath, PngBitDepth pngBitDepth, PngCo } } - [Fact] - public void HeaderChunk_ComesFirst() - { - var testFile = TestFile.Create(TestImages.Png.PngWithMetadata); - using Image input = testFile.CreateRgba32Image(); - using var memStream = new MemoryStream(); - input.Save(memStream, PngEncoder); - memStream.Position = 0; - - // Skip header. - Span bytesSpan = memStream.ToArray().AsSpan(8); - BinaryPrimitives.ReadInt32BigEndian(bytesSpan.Slice(0, 4)); - var type = (PngChunkType)BinaryPrimitives.ReadInt32BigEndian(bytesSpan.Slice(4, 4)); - Assert.Equal(PngChunkType.Header, type); - } - - [Fact] - public void EndChunk_IsLast() - { - var testFile = TestFile.Create(TestImages.Png.PngWithMetadata); - using Image input = testFile.CreateRgba32Image(); - using var memStream = new MemoryStream(); - input.Save(memStream, PngEncoder); - memStream.Position = 0; - - // Skip header. - Span bytesSpan = memStream.ToArray().AsSpan(8); - - bool endChunkFound = false; - while (bytesSpan.Length > 0) - { - int length = BinaryPrimitives.ReadInt32BigEndian(bytesSpan.Slice(0, 4)); - var type = (PngChunkType)BinaryPrimitives.ReadInt32BigEndian(bytesSpan.Slice(4, 4)); - Assert.False(endChunkFound); - if (type == PngChunkType.End) - { - endChunkFound = true; - } - - bytesSpan = bytesSpan.Slice(4 + 4 + length + 4); - } - } - - [Theory] - [InlineData(PngChunkType.Gamma)] - [InlineData(PngChunkType.Chroma)] - [InlineData(PngChunkType.EmbeddedColorProfile)] - [InlineData(PngChunkType.SignificantBits)] - [InlineData(PngChunkType.StandardRgbColourSpace)] - public void Chunk_ComesBeforePlteAndIDat(object chunkTypeObj) - { - var chunkType = (PngChunkType)chunkTypeObj; - var testFile = TestFile.Create(TestImages.Png.PngWithMetadata); - using Image input = testFile.CreateRgba32Image(); - using var memStream = new MemoryStream(); - input.Save(memStream, PngEncoder); - memStream.Position = 0; - - // Skip header. - Span bytesSpan = memStream.ToArray().AsSpan(8); - - bool palFound = false; - bool dataFound = false; - while (bytesSpan.Length > 0) - { - int length = BinaryPrimitives.ReadInt32BigEndian(bytesSpan.Slice(0, 4)); - var type = (PngChunkType)BinaryPrimitives.ReadInt32BigEndian(bytesSpan.Slice(4, 4)); - if (chunkType == type) - { - Assert.False(palFound || dataFound, $"{chunkType} chunk should come before data and palette chunk"); - } - - switch (type) - { - case PngChunkType.Data: - dataFound = true; - break; - case PngChunkType.Palette: - palFound = true; - break; - } - - bytesSpan = bytesSpan.Slice(4 + 4 + length + 4); - } - } - - [Theory] - [InlineData(PngChunkType.Physical)] - [InlineData(PngChunkType.SuggestedPalette)] - public void Chunk_ComesBeforeIDat(object chunkTypeObj) - { - var chunkType = (PngChunkType)chunkTypeObj; - var testFile = TestFile.Create(TestImages.Png.PngWithMetadata); - using Image input = testFile.CreateRgba32Image(); - using var memStream = new MemoryStream(); - input.Save(memStream, PngEncoder); - memStream.Position = 0; - - // Skip header. - Span bytesSpan = memStream.ToArray().AsSpan(8); - - bool dataFound = false; - while (bytesSpan.Length > 0) - { - int length = BinaryPrimitives.ReadInt32BigEndian(bytesSpan.Slice(0, 4)); - var type = (PngChunkType)BinaryPrimitives.ReadInt32BigEndian(bytesSpan.Slice(4, 4)); - if (chunkType == type) - { - Assert.False(dataFound, $"{chunkType} chunk should come before data chunk"); - } - - if (type == PngChunkType.Data) - { - dataFound = true; - } - - bytesSpan = bytesSpan.Slice(4 + 4 + length + 4); - } - } - [Theory] [WithTestPatternImages(587, 821, PixelTypes.Rgba32)] [WithTestPatternImages(677, 683, PixelTypes.Rgba32)] @@ -564,8 +536,9 @@ private static void TestPngEncoderCore( bool appendPixelType = false, bool appendCompressionLevel = false, bool appendPaletteSize = false, - bool appendPngBitDepth = false) - where TPixel : unmanaged, IPixel + bool appendPngBitDepth = false, + PngChunkFilter optimizeMethod = PngChunkFilter.None) + where TPixel : unmanaged, IPixel { using (Image image = provider.GetImage()) { @@ -576,7 +549,8 @@ private static void TestPngEncoderCore( CompressionLevel = compressionLevel, BitDepth = bitDepth, Quantizer = new WuQuantizer(new QuantizerOptions { MaxColors = paletteSize }), - InterlaceMethod = interlaceMode + InterlaceMethod = interlaceMode, + ChunkFilter = optimizeMethod, }; string pngColorTypeInfo = appendPngColorType ? pngColorType.ToString() : string.Empty;