PNG/PngBuilder.cs

514 lines
17 KiB
C#
Raw Normal View History

2024-11-03 22:34:26 +01:00
using System.IO.Compression;
using System.Text;
namespace Uwaa.PNG;
/// <summary>
/// Used to construct PNG images. Call <see cref="Create"/> to make a new builder.
/// </summary>
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<int, int> colorCounts;
readonly List<(string keyword, byte[] data)> storedStrings = new List<(string keyword, byte[] data)>();
/// <summary>
/// Create a builder for a PNG with the given width and size.
/// </summary>
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);
}
/// <summary>
/// Create a builder from a <see cref="Png"/>.
/// </summary>
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;
}
/// <summary>
/// Create a builder from the bytes of the specified PNG image.
/// </summary>
public static PngBuilder FromPngBytes(byte[] png)
{
var pngActual = Png.Open(png);
return FromPng(pngActual);
}
/// <summary>
/// Create a builder from the bytes in the BGRA32 pixel format.
/// https://docs.microsoft.com/en-us/dotnet/api/system.windows.media.pixelformats.bgra32
/// </summary>
/// <param name="data">The pixels in BGRA32 format.</param>
/// <param name="width">The width in pixels.</param>
/// <param name="height">The height in pixels.</param>
/// <param name="useAlphaChannel">Whether to include an alpha channel in the output.</param>
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);
}
/// <summary>
/// Create a builder from the bytes in the BGRA32 pixel format.
/// https://docs.microsoft.com/en-us/dotnet/api/system.windows.media.pixelformats.bgra32
/// </summary>
/// <param name="data">The pixels in BGRA32 format.</param>
/// <param name="width">The width in pixels.</param>
/// <param name="height">The height in pixels.</param>
/// <param name="useAlphaChannel">Whether to include an alpha channel in the output.</param>
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<int, int>()
{
{ backgroundColorInt, width * height}
};
}
/// <summary>
/// Sets the RGB pixel value for the given column (x) and row (y).
/// </summary>
public void SetPixel(byte r, byte g, byte b, int x, int y) => SetPixel(new Pixel(r, g, b), x, y);
/// <summary>
/// Set the pixel value for the given column (x) and row (y).
/// </summary>
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;
}
}
/// <summary>
/// Allows you to store arbitrary text data in the "iTXt" international textual data
/// chunks of the generated PNG image.
/// </summary>
/// <param name="keyword">
/// 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.
/// </param>
/// <param name="text">
/// The text data to store. Encoded as UTF-8 that may not contain zero (0) bytes but can be zero-length.
/// </param>
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;
}
/// <summary>
/// Get the bytes of the PNG file for this builder.
/// </summary>
public byte[] Save(SaveOptions? options = null)
{
using var memoryStream = new MemoryStream();
Save(memoryStream, options);
return memoryStream.ToArray();
}
/// <summary>
/// Write the PNG file bytes to the provided stream.
/// </summary>
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;
}
}
/// <summary>
/// Calculate the Adler-32 checksum for some data.
/// </summary>
/// <remarks>
/// Complies with the RFC 1950: ZLIB Compressed Data Format Specification.
/// </remarks>
public static int Adler32Checksum(IEnumerable<byte> 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;
}
/// <summary>
/// Attempt to improve compressability of the raw data by using adaptive filtering.
/// </summary>
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));
/// <summary>
/// Options for configuring generation of PNGs from a <see cref="PngBuilder"/>.
/// </summary>
public class SaveOptions
{
/// <summary>
/// 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.
/// </summary>
public bool AttemptCompression { get; set; }
/// <summary>
/// The number of parallel tasks allowed during compression.
/// </summary>
public int MaxDegreeOfParallelism { get; set; } = 1;
}
}