using System.IO.Compression; using System.Text; namespace Uwaa.PNG; /// /// Used to construct PNG images. Call to make a new builder. /// public class PngBuilder { const byte Deflate32KbWindow = 120; const byte ChecksumBits = 1; readonly byte[] rawData; readonly bool hasAlphaChannel; readonly int width; readonly int height; readonly int bytesPerPixel; bool hasTooManyColorsForPalette; readonly int backgroundColorInt; readonly Dictionary colorCounts; readonly List<(string keyword, byte[] data)> storedStrings = new List<(string keyword, byte[] data)>(); /// /// Create a builder for a PNG with the given width and size. /// public static PngBuilder Create(int width, int height, bool hasAlphaChannel) { int bpp = hasAlphaChannel ? 4 : 3; int length = (height * width * bpp) + height; return new PngBuilder(new byte[length], hasAlphaChannel, width, height, bpp); } /// /// Create a builder from a . /// public static PngBuilder FromPng(Png png) { var result = Create(png.Width, png.Height, png.HasAlphaChannel); for (int y = 0; y < png.Height; y++) for (int x = 0; x < png.Width; x++) result.SetPixel(png.GetPixel(x, y), x, y); return result; } /// /// Create a builder from the bytes of the specified PNG image. /// public static PngBuilder FromPngBytes(byte[] png) { var pngActual = Png.Open(png); return FromPng(pngActual); } /// /// Create a builder from the bytes in the BGRA32 pixel format. /// https://docs.microsoft.com/en-us/dotnet/api/system.windows.media.pixelformats.bgra32 /// /// The pixels in BGRA32 format. /// The width in pixels. /// The height in pixels. /// Whether to include an alpha channel in the output. public static PngBuilder FromBgra32Pixels(byte[] data, int width, int height, bool useAlphaChannel = true) { using var memoryStream = new MemoryStream(data); return FromBgra32Pixels(memoryStream, width, height, useAlphaChannel); } /// /// Create a builder from the bytes in the BGRA32 pixel format. /// https://docs.microsoft.com/en-us/dotnet/api/system.windows.media.pixelformats.bgra32 /// /// The pixels in BGRA32 format. /// The width in pixels. /// The height in pixels. /// Whether to include an alpha channel in the output. public static PngBuilder FromBgra32Pixels(Stream data, int width, int height, bool useAlphaChannel = true) { int bpp = useAlphaChannel ? 4 : 3; int length = (height * width * bpp) + height; PngBuilder builder = new PngBuilder(new byte[length], useAlphaChannel, width, height, bpp); byte[] buffer = new byte[4]; for (int y = 0; y < height; y++) { for (int x = 0; x < width; x++) { int read = data.Read(buffer, 0, buffer.Length); if (read != 4) { throw new InvalidOperationException($"Unexpected end of stream, expected to read 4 bytes at offset {data.Position - read} for (x: {x}, y: {y}), instead got {read}."); } if (useAlphaChannel) { builder.SetPixel(new Pixel(buffer[0], buffer[1], buffer[2], buffer[3]), x, y); } else { builder.SetPixel(buffer[0], buffer[1], buffer[2], x, y); } } } return builder; } PngBuilder(byte[] rawData, bool hasAlphaChannel, int width, int height, int bytesPerPixel) { this.rawData = rawData; this.hasAlphaChannel = hasAlphaChannel; this.width = width; this.height = height; this.bytesPerPixel = bytesPerPixel; backgroundColorInt = PixelToColorInt(0, 0, 0, hasAlphaChannel ? (byte)0 : byte.MaxValue); colorCounts = new Dictionary() { { backgroundColorInt, width * height} }; } /// /// Sets the RGB pixel value for the given column (x) and row (y). /// public void SetPixel(byte r, byte g, byte b, int x, int y) => SetPixel(new Pixel(r, g, b), x, y); /// /// Set the pixel value for the given column (x) and row (y). /// public void SetPixel(Pixel pixel, int x, int y) { if (!hasTooManyColorsForPalette) { int val = PixelToColorInt(pixel); if (val != backgroundColorInt) { if (!colorCounts.TryGetValue(val, out int value)) { colorCounts[val] = 1; } else { colorCounts[val] = value + 1; } colorCounts[backgroundColorInt]--; if (colorCounts[backgroundColorInt] == 0) { colorCounts.Remove(backgroundColorInt); } } if (colorCounts.Count > 256) { hasTooManyColorsForPalette = true; } } int start = (y * ((width * bytesPerPixel) + 1)) + 1 + (x * bytesPerPixel); rawData[start++] = pixel.R; rawData[start++] = pixel.G; rawData[start++] = pixel.B; if (hasAlphaChannel) { rawData[start] = pixel.A; } } /// /// Allows you to store arbitrary text data in the "iTXt" international textual data /// chunks of the generated PNG image. /// /// /// A keyword identifying the text data between 1-79 characters in length. /// Must not start with, end with or contain consecutive whitespace characters. /// Only characters in the range 32 - 126 and 161 - 255 are permitted. /// /// /// The text data to store. Encoded as UTF-8 that may not contain zero (0) bytes but can be zero-length. /// public PngBuilder StoreText(string keyword, string text) { if (keyword == null) throw new ArgumentNullException(nameof(keyword), "Keyword may not be null."); if (text == null) throw new ArgumentNullException(nameof(text), "Text may not be null."); if (keyword == string.Empty) throw new ArgumentException("Keyword may not be empty.", nameof(keyword)); if (keyword.Length > 79) throw new ArgumentException($"Keyword must be between 1 - 79 characters, provided keyword '{keyword}' has length of {keyword.Length} characters.", nameof(keyword)); for (int i = 0; i < keyword.Length; i++) { char c = keyword[i]; bool isValid = (c >= 32 && c <= 126) || (c >= 161 && c <= 255); if (!isValid) { throw new ArgumentException($"The keyword can only contain printable Latin 1 characters and spaces in the ranges 32 - 126 or 161 -255. The provided keyword '{keyword}' contained an invalid character ({c}) at index {i}.", nameof(keyword)); } // TODO: trailing, leading and consecutive whitespaces are also prohibited. } var bytes = Encoding.UTF8.GetBytes(text); for (int i = 0; i < bytes.Length; i++) { byte b = bytes[i]; if (b == 0) throw new ArgumentOutOfRangeException(nameof(text), $"The provided text contained a null (0) byte when converted to UTF-8. Null bytes are not permitted. Text was: '{text}'"); } storedStrings.Add((keyword, bytes)); return this; } /// /// Get the bytes of the PNG file for this builder. /// public byte[] Save(SaveOptions? options = null) { using var memoryStream = new MemoryStream(); Save(memoryStream, options); return memoryStream.ToArray(); } /// /// Write the PNG file bytes to the provided stream. /// public void Save(Stream outputStream, SaveOptions? options = null) { options = options ?? new SaveOptions(); byte[]? palette = null; int dataLength = rawData.Length; int bitDepth = 8; if (!hasTooManyColorsForPalette && !hasAlphaChannel) { var paletteColors = colorCounts.OrderByDescending(x => x.Value).Select(x => x.Key).ToList(); bitDepth = paletteColors.Count > 16 ? 8 : 4; int samplesPerByte = bitDepth == 8 ? 1 : 2; bool applyShift = samplesPerByte == 2; palette = new byte[3 * paletteColors.Count]; for (int i = 0; i < paletteColors.Count; i++) { (byte r, byte g, byte b, byte a) = ColorIntToPixel(paletteColors[i]); int startIndex = i * 3; palette[startIndex++] = r; palette[startIndex++] = g; palette[startIndex] = b; } int rawDataIndex = 0; for (int y = 0; y < height; y++) { // None filter - we don't use filtering for palette images. rawData[rawDataIndex++] = 0; for (int x = 0; x < width; x++) { int index = (y * width * bytesPerPixel) + y + 1 + (x * bytesPerPixel); byte r = rawData[index++]; byte g = rawData[index++]; byte b = rawData[index]; int colorInt = PixelToColorInt(r, g, b); byte value = (byte)paletteColors.IndexOf(colorInt); if (applyShift) { // apply mask and shift int withinByteIndex = x % 2; if (withinByteIndex == 1) { rawData[rawDataIndex] = (byte)(rawData[rawDataIndex] + value); rawDataIndex++; } else { rawData[rawDataIndex] = (byte)(value << 4); } } else { rawData[rawDataIndex++] = value; } } } dataLength = rawDataIndex; } else { AttemptCompressionOfRawData(rawData, options); } outputStream.Write(ImageHeader.ValidationHeader); PngStreamWriteHelper stream = new PngStreamWriteHelper(outputStream); stream.WriteChunkLength(13); stream.WriteChunkHeader(ImageHeader.HeaderBytes); StreamHelper.WriteBigEndianInt32(stream, width); StreamHelper.WriteBigEndianInt32(stream, height); stream.WriteByte((byte)bitDepth); var colorType = ColorType.ColorUsed; if (hasAlphaChannel) colorType |= ColorType.AlphaChannelUsed; if (palette != null) colorType |= ColorType.PaletteUsed; stream.WriteByte((byte)colorType); stream.WriteByte(0); stream.WriteByte(0); stream.WriteByte((byte)InterlaceMethod.None); stream.WriteCrc(); if (palette != null) { stream.WriteChunkLength(palette.Length); stream.WriteChunkHeader(Encoding.ASCII.GetBytes("PLTE")); stream.Write(palette, 0, palette.Length); stream.WriteCrc(); } byte[] imageData = Compress(rawData, dataLength, options); stream.WriteChunkLength(imageData.Length); stream.WriteChunkHeader(Encoding.ASCII.GetBytes("IDAT")); stream.Write(imageData, 0, imageData.Length); stream.WriteCrc(); foreach (var storedString in storedStrings) { byte[] keyword = Encoding.GetEncoding("iso-8859-1").GetBytes(storedString.keyword); int length = keyword.Length + 1 // Null separator + 1 // Compression flag + 1 // Compression method + 1 // Null separator + 1 // Null separator + storedString.data.Length; stream.WriteChunkLength(length); stream.WriteChunkHeader(Encoding.ASCII.GetBytes("iTXt")); stream.Write(keyword, 0, keyword.Length); stream.WriteByte(0); // Null separator stream.WriteByte(0); // Compression flag (0 for uncompressed) stream.WriteByte(0); // Compression method (0, ignored since flag is zero) stream.WriteByte(0); // Null separator stream.WriteByte(0); // Null separator stream.Write(storedString.data, 0, storedString.data.Length); stream.WriteCrc(); } stream.WriteChunkLength(0); stream.WriteChunkHeader(Encoding.ASCII.GetBytes("IEND")); stream.WriteCrc(); } static byte[] Compress(byte[] data, int dataLength, SaveOptions options) { const int headerLength = 2; const int checksumLength = 4; var compressionLevel = options?.AttemptCompression == true ? CompressionLevel.Optimal : CompressionLevel.Fastest; using (var compressStream = new MemoryStream()) using (var compressor = new DeflateStream(compressStream, compressionLevel, true)) { compressor.Write(data, 0, dataLength); compressor.Close(); compressStream.Seek(0, SeekOrigin.Begin); var result = new byte[headerLength + compressStream.Length + checksumLength]; // Write the ZLib header. result[0] = Deflate32KbWindow; result[1] = ChecksumBits; // Write the compressed data. int streamValue; int i = 0; while ((streamValue = compressStream.ReadByte()) != -1) { result[headerLength + i] = (byte)streamValue; i++; } // Write Checksum of raw data. int checksum = Adler32Checksum(data, dataLength); long offset = headerLength + compressStream.Length; result[offset++] = (byte)(checksum >> 24); result[offset++] = (byte)(checksum >> 16); result[offset++] = (byte)(checksum >> 8); result[offset] = (byte)(checksum >> 0); return result; } } /// /// Calculate the Adler-32 checksum for some data. /// /// /// Complies with the RFC 1950: ZLIB Compressed Data Format Specification. /// public static int Adler32Checksum(IEnumerable data, int length = -1) { // Both sums (s1 and s2) are done modulo 65521. const int AdlerModulus = 65521; // s1 is the sum of all bytes. int s1 = 1; // s2 is the sum of all s1 values. int s2 = 0; int count = 0; foreach (byte b in data) { if (length > 0 && count == length) break; s1 = (s1 + b) % AdlerModulus; s2 = (s1 + s2) % AdlerModulus; count++; } // The Adler-32 checksum is stored as s2*65536 + s1. return (s2 * 65536) + s1; } /// /// Attempt to improve compressability of the raw data by using adaptive filtering. /// void AttemptCompressionOfRawData(byte[] rawData, SaveOptions options) { if (!options.AttemptCompression) return; int bytesPerScanline = 1 + (bytesPerPixel * width); int scanlineCount = rawData.Length / bytesPerScanline; byte[] scanData = new byte[bytesPerScanline - 1]; for (int scanlineRowIndex = 0; scanlineRowIndex < scanlineCount; scanlineRowIndex++) { int sourceIndex = (scanlineRowIndex * bytesPerScanline) + 1; Array.Copy(rawData, sourceIndex, scanData, 0, bytesPerScanline - 1); //Incomplete: the original source had unfinished junk code here which did nothing } } static int PixelToColorInt(Pixel p) => PixelToColorInt(p.R, p.G, p.B, p.A); static int PixelToColorInt(byte r, byte g, byte b, byte a = 255) { return (a << 24) + (r << 16) + (g << 8) + b; } static (byte r, byte g, byte b, byte a) ColorIntToPixel(int i) => ((byte)(i >> 16), (byte)(i >> 8), (byte)i, (byte)(i >> 24)); /// /// Options for configuring generation of PNGs from a . /// public class SaveOptions { /// /// Whether the library should try to reduce the resulting image size. /// This process does not affect the original image data (it is lossless) but may /// result in longer save times. /// public bool AttemptCompression { get; set; } /// /// The number of parallel tasks allowed during compression. /// public int MaxDegreeOfParallelism { get; set; } = 1; } }