commit 23438eada3d0f2c81e9870f9822c9c01f6e3a076 Author: uwaa Date: Sun Nov 3 21:34:26 2024 +0000 initial commit diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..65dad72 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +*.cs eol=crlf +*.txt eol=lf \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..44e1bbc --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +.vs +.vscode +bin +obj \ No newline at end of file diff --git a/Adam7.cs b/Adam7.cs new file mode 100644 index 0000000..8685bc2 --- /dev/null +++ b/Adam7.cs @@ -0,0 +1,95 @@ +namespace Uwaa.PNG; + +internal static class Adam7 +{ + /// + /// For a given pass number (1 indexed) the scanline indexes of the lines included in that pass in the 8x8 grid. + /// + static readonly IReadOnlyDictionary PassToScanlineGridIndex = new Dictionary + { + { 1, [ 0 ] }, + { 2, [ 0 ] }, + { 3, [ 4 ] }, + { 4, [ 0, 4 ] }, + { 5, [ 2, 6 ] }, + { 6, [ 0, 2, 4, 6 ] }, + { 7, [ 1, 3, 5, 7 ] } + }; + + static readonly IReadOnlyDictionary PassToScanlineColumnIndex = new Dictionary + { + { 1, [ 0 ] }, + { 2, [ 4 ] }, + { 3, [ 0, 4 ] }, + { 4, [ 2, 6 ] }, + { 5, [ 0, 2, 4, 6 ] }, + { 6, [ 1, 3, 5, 7 ] }, + { 7, [ 0, 1, 2, 3, 4, 5, 6, 7 ] } + }; + + /* + * To go from raw image data to interlaced: + * + * An 8x8 grid is repeated over the image. There are 7 passes and the indexes in this grid correspond to the + * pass number including that pixel. Each row in the grid corresponds to a scanline. + * + * 1 6 4 6 2 6 4 6 - Scanline 0: pass 1 has pixel 0, 8, 16, etc. pass 2 has pixel 4, 12, 20, etc. + * 7 7 7 7 7 7 7 7 + * 5 6 5 6 5 6 5 6 + * 7 7 7 7 7 7 7 7 + * 3 6 4 6 3 6 4 6 + * 7 7 7 7 7 7 7 7 + * 5 6 5 6 5 6 5 6 + * 7 7 7 7 7 7 7 7 + * + * + * + */ + + public static int GetNumberOfScanlinesInPass(ImageHeader header, int pass) + { + int[] indices = PassToScanlineGridIndex[pass + 1]; + + int mod = header.Height % 8; + + if (mod == 0) //fits exactly + return indices.Length * (header.Height / 8); + + int additionalLines = 0; + for (int i = 0; i < indices.Length; i++) + if (indices[i] < mod) + additionalLines++; + + return (indices.Length * (header.Height / 8)) + additionalLines; + } + + public static int GetPixelsPerScanlineInPass(ImageHeader header, int pass) + { + int[] indices = PassToScanlineColumnIndex[pass + 1]; + + int mod = header.Width % 8; + + if (mod == 0) //fits exactly + return indices.Length * (header.Width / 8); + + int additionalColumns = 0; + for (int i = 0; i < indices.Length; i++) + if (indices[i] < mod) + additionalColumns++; + + return (indices.Length * (header.Width / 8)) + additionalColumns; + } + + public static (int x, int y) GetPixelIndexForScanlineInPass(int pass, int scanlineIndex, int indexInScanline) + { + int[] columnIndices = PassToScanlineColumnIndex[pass + 1]; + int[] rows = PassToScanlineGridIndex[pass + 1]; + + int actualRow = scanlineIndex % rows.Length; + int actualCol = indexInScanline % columnIndices.Length; + int precedingRows = 8 * (scanlineIndex / rows.Length); + int precedingCols = 8 * (indexInScanline / columnIndices.Length); + + return (precedingCols + columnIndices[actualCol], precedingRows + rows[actualRow]); + } +} \ No newline at end of file diff --git a/ChunkHeader.cs b/ChunkHeader.cs new file mode 100644 index 0000000..7b1002b --- /dev/null +++ b/ChunkHeader.cs @@ -0,0 +1,58 @@ +namespace Uwaa.PNG; + +/// +/// The header for a data chunk in a PNG file. +/// +public readonly struct ChunkHeader +{ + /// + /// The position/start of the chunk header within the stream. + /// + public long Position { get; } + + /// + /// The length of the chunk in bytes. + /// + public int Length { get; } + + /// + /// The name of the chunk, uppercase first letter means the chunk is critical (vs. ancillary). + /// + public string Name { get; } + + /// + /// Whether the chunk is critical (must be read by all readers) or ancillary (may be ignored). + /// + public bool IsCritical => char.IsUpper(Name[0]); + + /// + /// A public chunk is one that is defined in the International Standard or is registered in the list of public chunk types maintained by the Registration Authority. + /// Applications can also define private (unregistered) chunk types for their own purposes. + /// + public bool IsPublic => char.IsUpper(Name[1]); + + /// + /// Whether the (if unrecognized) chunk is safe to copy. + /// + public bool IsSafeToCopy => char.IsUpper(Name[3]); + + /// + /// Create a new . + /// + public ChunkHeader(long position, int length, string name) + { + if (length < 0) + { + throw new ArgumentException($"Length less than zero ({length}) encountered when reading chunk at position {position}."); + } + + Position = position; + Length = length; + Name = name; + } + + public override string ToString() + { + return $"{Name} at {Position} (length: {Length})."; + } +} \ No newline at end of file diff --git a/ColorType.cs b/ColorType.cs new file mode 100644 index 0000000..15d9a90 --- /dev/null +++ b/ColorType.cs @@ -0,0 +1,28 @@ +namespace Uwaa.PNG; + +/// +/// Describes the interpretation of the image data. +/// +[Flags] +public enum ColorType : byte +{ + /// + /// Grayscale. + /// + None = 0, + + /// + /// Colors are stored in a palette rather than directly in the data. + /// + PaletteUsed = 1, + + /// + /// The image uses color. + /// + ColorUsed = 2, + + /// + /// The image has an alpha channel. + /// + AlphaChannelUsed = 4 +} \ No newline at end of file diff --git a/Crc32.cs b/Crc32.cs new file mode 100644 index 0000000..e3a7599 --- /dev/null +++ b/Crc32.cs @@ -0,0 +1,84 @@ +namespace Uwaa.PNG; + +/// +/// 32-bit Cyclic Redundancy Code used by the PNG for checking the data is intact. +/// +public static class Crc32 +{ + const uint Polynomial = 0xEDB88320; + + static readonly uint[] Lookup; + + static Crc32() + { + Lookup = new uint[256]; + for (uint i = 0; i < 256; i++) + { + var value = i; + for (var j = 0; j < 8; ++j) + { + if ((value & 1) != 0) + { + value = (value >> 1) ^ Polynomial; + } + else + { + value >>= 1; + } + } + + Lookup[i] = value; + } + } + + /// + /// Calculate the CRC32 for data. + /// + public static uint Calculate(byte[] data) + { + var crc32 = uint.MaxValue; + for (var i = 0; i < data.Length; i++) + { + var index = (crc32 ^ data[i]) & 0xFF; + crc32 = (crc32 >> 8) ^ Lookup[index]; + } + + return crc32 ^ uint.MaxValue; + } + + /// + /// Calculate the CRC32 for data. + /// + public static uint Calculate(List data) + { + var crc32 = uint.MaxValue; + for (var i = 0; i < data.Count; i++) + { + var index = (crc32 ^ data[i]) & 0xFF; + crc32 = (crc32 >> 8) ^ Lookup[index]; + } + + return crc32 ^ uint.MaxValue; + } + + /// + /// Calculate the combined CRC32 for data. + /// + public static uint Calculate(byte[] data, byte[] data2) + { + var crc32 = uint.MaxValue; + for (var i = 0; i < data.Length; i++) + { + var index = (crc32 ^ data[i]) & 0xFF; + crc32 = (crc32 >> 8) ^ Lookup[index]; + } + + for (var i = 0; i < data2.Length; i++) + { + var index = (crc32 ^ data2[i]) & 0xFF; + crc32 = (crc32 >> 8) ^ Lookup[index]; + } + + return crc32 ^ uint.MaxValue; + } +} diff --git a/Decoder.cs b/Decoder.cs new file mode 100644 index 0000000..de7d902 --- /dev/null +++ b/Decoder.cs @@ -0,0 +1,189 @@ +namespace Uwaa.PNG; + +internal static class Decoder +{ + public static (byte bytesPerPixel, byte samplesPerPixel) GetBytesAndSamplesPerPixel(ImageHeader header) + { + var bitDepthCorrected = (header.BitDepth + 7) / 8; + var samplesPerPixel = SamplesPerPixel(header); + return ((byte)(samplesPerPixel * bitDepthCorrected), samplesPerPixel); + } + + public static byte[] Decode(byte[] decompressedData, ImageHeader header, byte bytesPerPixel, byte samplesPerPixel) + { + switch (header.InterlaceMethod) + { + case InterlaceMethod.None: + { + var bytesPerScanline = BytesPerScanline(header, samplesPerPixel); + + var currentRowStartByteAbsolute = 1; + for (var rowIndex = 0; rowIndex < header.Height; rowIndex++) + { + var filterType = (FilterType)decompressedData[currentRowStartByteAbsolute - 1]; + + var previousRowStartByteAbsolute = rowIndex + (bytesPerScanline * (rowIndex - 1)); + + var end = currentRowStartByteAbsolute + bytesPerScanline; + for (var currentByteAbsolute = currentRowStartByteAbsolute; currentByteAbsolute < end; currentByteAbsolute++) + { + ReverseFilter(decompressedData, filterType, previousRowStartByteAbsolute, currentRowStartByteAbsolute, currentByteAbsolute, currentByteAbsolute - currentRowStartByteAbsolute, bytesPerPixel); + } + + currentRowStartByteAbsolute += bytesPerScanline + 1; + } + + return decompressedData; + } + case InterlaceMethod.Adam7: + { + int byteHack = bytesPerPixel == 1 ? 1 : 0; // TODO: Further investigation required. + int pixelsPerRow = (header.Width * bytesPerPixel) + byteHack; // Add an extra byte per line. + byte[] newBytes = new byte[header.Height * pixelsPerRow]; + int i = 0; + int previousStartRowByteAbsolute = -1; + // 7 passes + for (int pass = 0; pass < 7; pass++) + { + int numberOfScanlines = Adam7.GetNumberOfScanlinesInPass(header, pass); + int numberOfPixelsPerScanline = Adam7.GetPixelsPerScanlineInPass(header, pass); + + if (numberOfScanlines <= 0 || numberOfPixelsPerScanline <= 0) + { + continue; + } + + for (int scanlineIndex = 0; scanlineIndex < numberOfScanlines; scanlineIndex++) + { + FilterType filterType = (FilterType)decompressedData[i++]; + int rowStartByte = i; + + for (int j = 0; j < numberOfPixelsPerScanline; j++) + { + (int x, int y) pixelIndex = Adam7.GetPixelIndexForScanlineInPass(pass, scanlineIndex, j); + for (int k = 0; k < bytesPerPixel; k++) + { + int byteLineNumber = (j * bytesPerPixel) + k; + ReverseFilter(decompressedData, filterType, previousStartRowByteAbsolute, rowStartByte, i, byteLineNumber, bytesPerPixel); + i++; + } + + int start = byteHack + (pixelsPerRow * pixelIndex.y) + (pixelIndex.x * bytesPerPixel); + Array.ConstrainedCopy(decompressedData, rowStartByte + (j * bytesPerPixel), newBytes, start, bytesPerPixel); + } + + previousStartRowByteAbsolute = rowStartByte; + } + } + + return newBytes; + } + default: + throw new ArgumentOutOfRangeException($"Invalid interlace method: {header.InterlaceMethod}."); + } + } + + static byte SamplesPerPixel(ImageHeader header) + { + return header.ColorType switch + { + ColorType.None => 1, + ColorType.PaletteUsed or ColorType.PaletteUsed | ColorType.ColorUsed => 1, + ColorType.ColorUsed => 3, + ColorType.AlphaChannelUsed => 2, + ColorType.ColorUsed | ColorType.AlphaChannelUsed => 4, + _ => 0, + }; + } + + static int BytesPerScanline(ImageHeader header, byte samplesPerPixel) + { + int width = header.Width; + + return header.BitDepth switch + { + 1 => (width + 7) / 8, + 2 => (width + 3) / 4, + 4 => (width + 1) / 2, + 8 or 16 => width * samplesPerPixel * (header.BitDepth / 8), + _ => 0, + }; + } + + static void ReverseFilter(byte[] data, FilterType type, int previousRowStartByteAbsolute, int rowStartByteAbsolute, int byteAbsolute, int rowByteIndex, int bytesPerPixel) + { + byte GetLeftByteValue() + { + int leftIndex = rowByteIndex - bytesPerPixel; + byte leftValue = leftIndex >= 0 ? data[rowStartByteAbsolute + leftIndex] : (byte)0; + return leftValue; + } + + byte GetAboveByteValue() + { + int upIndex = previousRowStartByteAbsolute + rowByteIndex; + return upIndex >= 0 ? data[upIndex] : (byte)0; + } + + byte GetAboveLeftByteValue() + { + int index = previousRowStartByteAbsolute + rowByteIndex - bytesPerPixel; + return index < previousRowStartByteAbsolute || previousRowStartByteAbsolute < 0 ? (byte)0 : data[index]; + } + + // Moved out of the switch for performance. + if (type == FilterType.Up) + { + int above = previousRowStartByteAbsolute + rowByteIndex; + if (above < 0) + return; + + data[byteAbsolute] += data[above]; + return; + } + + if (type == FilterType.Sub) + { + int leftIndex = rowByteIndex - bytesPerPixel; + if (leftIndex < 0) + return; + + data[byteAbsolute] += data[rowStartByteAbsolute + leftIndex]; + return; + } + + switch (type) + { + case FilterType.None: + return; + case FilterType.Average: + data[byteAbsolute] += (byte)((GetLeftByteValue() + GetAboveByteValue()) / 2); + break; + case FilterType.Paeth: + byte a = GetLeftByteValue(); + byte b = GetAboveByteValue(); + byte c = GetAboveLeftByteValue(); + data[byteAbsolute] += GetPaethValue(a, b, c); + break; + default: + throw new ArgumentOutOfRangeException(nameof(type), type, null); + } + } + + /// + /// Computes a simple linear function of the three neighboring pixels (left, above, upper left), + /// then chooses as predictor the neighboring pixel closest to the computed value. + /// + static byte GetPaethValue(byte a, byte b, byte c) + { + int p = a + b - c; + int pa = Math.Abs(p - a); + int pb = Math.Abs(p - b); + int pc = Math.Abs(p - c); + + if (pa <= pb && pa <= pc) + return a; + else + return pb <= pc ? b : c; + } +} diff --git a/FilterType.cs b/FilterType.cs new file mode 100644 index 0000000..9b6f586 --- /dev/null +++ b/FilterType.cs @@ -0,0 +1,25 @@ +namespace Uwaa.PNG; + +internal enum FilterType +{ + /// + /// The raw byte is unaltered. + /// + None = 0, + /// + /// The byte to the left. + /// + Sub = 1, + /// + /// The byte above. + /// + Up = 2, + /// + /// The mean of bytes left and above, rounded down. + /// + Average = 3, + /// + /// Byte to the left, above or top-left based on Paeth's algorithm. + /// + Paeth = 4 +} \ No newline at end of file diff --git a/ImageHeader.cs b/ImageHeader.cs new file mode 100644 index 0000000..6f42e24 --- /dev/null +++ b/ImageHeader.cs @@ -0,0 +1,71 @@ +namespace Uwaa.PNG; + +/// +/// The high level information about the image. +/// +public readonly struct ImageHeader +{ + internal static readonly byte[] HeaderBytes = { 73, 72, 68, 82 }; + + internal static readonly byte[] ValidationHeader = { 137, 80, 78, 71, 13, 10, 26, 10 }; + + static readonly Dictionary> PermittedBitDepths = new Dictionary> + { + {ColorType.None, new HashSet {1, 2, 4, 8, 16}}, + {ColorType.ColorUsed, new HashSet {8, 16}}, + {ColorType.PaletteUsed | ColorType.ColorUsed, new HashSet {1, 2, 4, 8}}, + {ColorType.AlphaChannelUsed, new HashSet {8, 16}}, + {ColorType.AlphaChannelUsed | ColorType.ColorUsed, new HashSet {8, 16}}, + }; + + /// + /// The width of the image in pixels. + /// + public int Width { get; } + + /// + /// The height of the image in pixels. + /// + public int Height { get; } + + /// + /// The bit depth of the image. + /// + public byte BitDepth { get; } + + /// + /// The color type of the image. + /// + public ColorType ColorType { get; } + + /// + /// The interlace method used by the image.. + /// + public InterlaceMethod InterlaceMethod { get; } + + /// + /// Create a new . + /// + public ImageHeader(int width, int height, byte bitDepth, ColorType colorType, InterlaceMethod interlaceMethod) + { + if (width == 0) + throw new ArgumentOutOfRangeException(nameof(width), "Invalid width (0) for image."); + + if (height == 0) + throw new ArgumentOutOfRangeException(nameof(height), "Invalid height (0) for image."); + + if (!PermittedBitDepths.TryGetValue(colorType, out var permitted) || !permitted.Contains(bitDepth)) + throw new ArgumentException($"The bit depth {bitDepth} is not permitted for color type {colorType}."); + + Width = width; + Height = height; + BitDepth = bitDepth; + ColorType = colorType; + InterlaceMethod = interlaceMethod; + } + + public override string ToString() + { + return $"w: {Width}, h: {Height}, bitDepth: {BitDepth}, colorType: {ColorType}, interlace: {InterlaceMethod}."; + } +} \ No newline at end of file diff --git a/InterlaceMethod.cs b/InterlaceMethod.cs new file mode 100644 index 0000000..65dc6f3 --- /dev/null +++ b/InterlaceMethod.cs @@ -0,0 +1,17 @@ +namespace Uwaa.PNG; + +/// +/// Indicates the transmission order of the image data. +/// +public enum InterlaceMethod : byte +{ + /// + /// No interlace. + /// + None = 0, + + /// + /// Adam7 interlace. + /// + Adam7 = 1 +} \ No newline at end of file diff --git a/PNG.csproj b/PNG.csproj new file mode 100644 index 0000000..d364a52 --- /dev/null +++ b/PNG.csproj @@ -0,0 +1,7 @@ + + + net8.0 + enable + enable + + diff --git a/Palette.cs b/Palette.cs new file mode 100644 index 0000000..48ba57c --- /dev/null +++ b/Palette.cs @@ -0,0 +1,42 @@ +using System.Runtime.CompilerServices; + +namespace Uwaa.PNG; + +internal class Palette +{ + public bool HasAlphaValues { get; private set; } + + public byte[] Data { get; } + + /// + /// Creates a palette object. Input palette data length from PLTE chunk must be a multiple of 3. + /// + public Palette(byte[] data) + { + Data = new byte[data.Length * 4 / 3]; + var dataIndex = 0; + for (var i = 0; i < data.Length; i += 3) + { + Data[dataIndex++] = data[i]; + Data[dataIndex++] = data[i + 1]; + Data[dataIndex++] = data[i + 2]; + Data[dataIndex++] = 255; + } + } + + /// + /// Adds transparency values from tRNS chunk. + /// + public void SetAlphaValues(byte[] bytes) + { + HasAlphaValues = true; + + for (var i = 0; i < bytes.Length; i++) + Data[(i * 4) + 3] = bytes[i]; + } + + public Pixel GetPixel(int index) + { + return Unsafe.As(ref Data[index * 4]); + } +} \ No newline at end of file diff --git a/Pixel.cs b/Pixel.cs new file mode 100644 index 0000000..3ccd6c3 --- /dev/null +++ b/Pixel.cs @@ -0,0 +1,93 @@ +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; + +namespace Uwaa.PNG; + +/// +/// A 32-bit RGBA pixel in a image. +/// +[StructLayout(LayoutKind.Sequential)] +public readonly struct Pixel : IEquatable +{ + /// + /// The red value for the pixel. + /// + public byte R { get; } + + /// + /// The green value for the pixel. + /// + public byte G { get; } + + /// + /// The blue value for the pixel. + /// + public byte B { get; } + + /// + /// The alpha transparency value for the pixel. + /// + public byte A { get; } + + /// + /// Create a new . + /// + /// The red value for the pixel. + /// The green value for the pixel. + /// The blue value for the pixel. + /// The alpha transparency value for the pixel. + public Pixel(byte r, byte g, byte b, byte a) + { + R = r; + G = g; + B = b; + A = a; + } + + /// + /// Create a new which is fully opaque. + /// + /// The red value for the pixel. + /// The green value for the pixel. + /// The blue value for the pixel. + public Pixel(byte r, byte g, byte b) + { + R = r; + G = g; + B = b; + A = 255; + } + + /// + /// Create a new grayscale . + /// + /// The grayscale value. + public Pixel(byte grayscale) + { + R = grayscale; + G = grayscale; + B = grayscale; + A = 255; + } + + public override string ToString() => $"({R}, {G}, {B}, {A})"; + + public override bool Equals(object? obj) => obj is Pixel pixel && Equals(pixel); + + /// + /// Whether the pixel values are equal. + /// + /// The other pixel. + /// if all pixel values are equal otherwise . + public bool Equals(Pixel other) + { + Pixel this_ = this; + return Unsafe.As(ref this_) == Unsafe.As(ref other); + } + + public override int GetHashCode() => HashCode.Combine(R, G, B, A); + + public static bool operator ==(Pixel left, Pixel right) => left.Equals(right); + + public static bool operator !=(Pixel left, Pixel right) => !(left == right); +} \ No newline at end of file diff --git a/Png.cs b/Png.cs new file mode 100644 index 0000000..f37fe41 --- /dev/null +++ b/Png.cs @@ -0,0 +1,80 @@ +namespace Uwaa.PNG; + +/// +/// A PNG image. Call to open from file or bytes. +/// +public class Png +{ + readonly RawPngData data; + readonly bool hasTransparencyChunk; + + /// + /// The header data from the PNG image. + /// + public ImageHeader Header { get; } + + /// + /// The width of the image in pixels. + /// + public int Width => Header.Width; + + /// + /// The height of the image in pixels. + /// + public int Height => Header.Height; + + /// + /// Whether the image has an alpha (transparency) layer. + /// + public bool HasAlphaChannel => (Header.ColorType & ColorType.AlphaChannelUsed) != 0 || hasTransparencyChunk; + + internal Png(ImageHeader header, RawPngData data, bool hasTransparencyChunk) + { + Header = header; + this.data = data ?? throw new ArgumentNullException(nameof(data)); + this.hasTransparencyChunk = hasTransparencyChunk; + } + + /// + /// Get the pixel at the given column and row (x, y). + /// + /// + /// Pixel values are generated on demand from the underlying data to prevent holding many items in memory at once, so consumers + /// should cache values if they're going to be looped over many time. + /// + /// The x coordinate (column). + /// The y coordinate (row). + /// The pixel at the coordinate. + public Pixel GetPixel(int x, int y) => data.GetPixel(x, y); + + /// + /// Read the PNG image from the stream. + /// + /// The stream containing PNG data to be read. + /// The data from the stream. + public static Png Open(Stream stream) + => PngOpener.Open(stream); + + /// + /// Read the PNG image from the bytes. + /// + /// The bytes of the PNG data to be read. + /// The data from the bytes. + public static Png Open(byte[] bytes) + { + using var memoryStream = new MemoryStream(bytes); + return PngOpener.Open(memoryStream); + } + + /// + /// Read the PNG from the file path. + /// + /// The path to the PNG file to open. + /// This will open the file to obtain a so will lock the file during reading. + /// The data from the file. + public static Png Open(string path) + { + using var fileStream = File.OpenRead(path); + return Open(fileStream); + } +} diff --git a/PngBuilder.cs b/PngBuilder.cs new file mode 100644 index 0000000..946d248 --- /dev/null +++ b/PngBuilder.cs @@ -0,0 +1,514 @@ +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; + } +} \ No newline at end of file diff --git a/PngOpener.cs b/PngOpener.cs new file mode 100644 index 0000000..1b18d51 --- /dev/null +++ b/PngOpener.cs @@ -0,0 +1,158 @@ +using System.Buffers.Binary; +using System.IO.Compression; +using System.Text; + +namespace Uwaa.PNG; + +internal static class PngOpener +{ + public static Png Open(Stream stream) + { + ArgumentNullException.ThrowIfNull(stream, nameof(stream)); + + if (!stream.CanRead) + throw new ArgumentException($"The provided stream of type {stream.GetType().FullName} was not readable."); + + if (!HasValidHeader(stream)) + throw new ArgumentException($"The provided stream did not start with the PNG header."); + + Span crc = stackalloc byte[4]; + ImageHeader imageHeader = ReadImageHeader(stream, crc); + + bool hasEncounteredImageEnd = false; + + Palette? palette = null; + + using MemoryStream output = new MemoryStream(); + using MemoryStream memoryStream = new MemoryStream(); + + while (TryReadChunkHeader(stream, out var header)) + { + if (hasEncounteredImageEnd) + break; + + byte[] bytes = new byte[header.Length]; + int read = stream.Read(bytes, 0, bytes.Length); + if (read != bytes.Length) + throw new InvalidOperationException($"Did not read {header.Length} bytes for the {header} header, only found: {read}."); + + if (header.IsCritical) + { + switch (header.Name) + { + case "PLTE": + if (header.Length % 3 != 0) + throw new InvalidOperationException($"Palette data must be multiple of 3, got {header.Length}."); + + // Ignore palette data unless the header.ColorType indicates that the image is paletted. + if (imageHeader.ColorType.HasFlag(ColorType.PaletteUsed)) + palette = new Palette(bytes); + + break; + + case "IDAT": + memoryStream.Write(bytes, 0, bytes.Length); + break; + + case "IEND": + hasEncounteredImageEnd = true; + break; + + default: + throw new NotSupportedException($"Encountered critical header {header} which was not recognised."); + } + } + else + { + switch (header.Name) + { + case "tRNS": + // Add transparency to palette, if the PLTE chunk has been read. + palette?.SetAlphaValues(bytes); + break; + } + } + + read = stream.Read(crc); + if (read != 4) + throw new InvalidOperationException($"Did not read 4 bytes for the CRC, only found: {read}."); + + int result = (int)Crc32.Calculate(Encoding.ASCII.GetBytes(header.Name), bytes); + int crcActual = (crc[0] << 24) + (crc[1] << 16) + (crc[2] << 8) + crc[3]; + + if (result != crcActual) + throw new InvalidOperationException($"CRC calculated {result} did not match file {crcActual} for chunk: {header.Name}."); + } + + memoryStream.Flush(); + memoryStream.Seek(2, SeekOrigin.Begin); + + using (DeflateStream deflateStream = new DeflateStream(memoryStream, CompressionMode.Decompress)) + { + deflateStream.CopyTo(output); + deflateStream.Close(); + } + + byte[] bytesOut = output.ToArray(); + + (byte bytesPerPixel, byte samplesPerPixel) = Decoder.GetBytesAndSamplesPerPixel(imageHeader); + + bytesOut = Decoder.Decode(bytesOut, imageHeader, bytesPerPixel, samplesPerPixel); + + return new Png(imageHeader, new RawPngData(bytesOut, bytesPerPixel, palette, imageHeader), palette?.HasAlphaValues ?? false); + } + + static bool HasValidHeader(Stream stream) + { + for (int i = 0; i < ImageHeader.ValidationHeader.Length; i++) + if (stream.ReadByte() != ImageHeader.ValidationHeader[i]) + return false; + + return true; + } + + static bool TryReadChunkHeader(Stream stream, out ChunkHeader chunkHeader) + { + chunkHeader = default; + + long position = stream.Position; + if (!StreamHelper.TryReadHeaderBytes(stream, out var headerBytes)) + return false; + + int length = BinaryPrimitives.ReadInt32BigEndian(headerBytes); + string name = Encoding.ASCII.GetString(headerBytes, 4, 4); + chunkHeader = new ChunkHeader(position, length, name); + + return true; + } + + static ImageHeader ReadImageHeader(Stream stream, Span crc) + { + if (!TryReadChunkHeader(stream, out var header)) + throw new ArgumentException("The provided stream did not contain a single chunk."); + + if (header.Name != "IHDR") + throw new ArgumentException($"The first chunk was not the IHDR chunk: {header}."); + + if (header.Length != 13) + throw new ArgumentException($"The first chunk did not have a length of 13 bytes: {header}."); + + byte[] ihdrBytes = new byte[13]; + int read = stream.Read(ihdrBytes, 0, ihdrBytes.Length); + + if (read != 13) + throw new InvalidOperationException($"Did not read 13 bytes for the IHDR, only found: {read}."); + + read = stream.Read(crc); + if (read != 4) + throw new InvalidOperationException($"Did not read 4 bytes for the CRC, only found: {read}."); + + int width = BinaryPrimitives.ReadInt32BigEndian(ihdrBytes); + int height = BinaryPrimitives.ReadInt32BigEndian(ihdrBytes[4..]); + byte bitDepth = ihdrBytes[8]; + byte colorType = ihdrBytes[9]; + byte interlaceMethod = ihdrBytes[12]; + + return new ImageHeader(width, height, bitDepth, (ColorType)colorType, (InterlaceMethod)interlaceMethod); + } +} diff --git a/PngStreamWriteHelper.cs b/PngStreamWriteHelper.cs new file mode 100644 index 0000000..95f9aa1 --- /dev/null +++ b/PngStreamWriteHelper.cs @@ -0,0 +1,57 @@ +namespace Uwaa.PNG; + +internal class PngStreamWriteHelper : Stream +{ + readonly Stream inner; + readonly List written = new List(); + + public override bool CanRead => inner.CanRead; + + public override bool CanSeek => inner.CanSeek; + + public override bool CanWrite => inner.CanWrite; + + public override long Length => inner.Length; + + public override long Position + { + get => inner.Position; + set => inner.Position = value; + } + + public PngStreamWriteHelper(Stream inner) + { + this.inner = inner ?? throw new ArgumentNullException(nameof(inner)); + } + + public override void Flush() => inner.Flush(); + + public void WriteChunkHeader(byte[] header) + { + written.Clear(); + Write(header, 0, header.Length); + } + + public void WriteChunkLength(int length) + { + StreamHelper.WriteBigEndianInt32(inner, length); + } + + public override int Read(byte[] buffer, int offset, int count) => inner.Read(buffer, offset, count); + + public override long Seek(long offset, SeekOrigin origin) => inner.Seek(offset, origin); + + public override void SetLength(long value) => inner.SetLength(value); + + public override void Write(byte[] buffer, int offset, int count) + { + written.AddRange(buffer.Skip(offset).Take(count)); + inner.Write(buffer, offset, count); + } + + public void WriteCrc() + { + var result = (int)Crc32.Calculate(written); + StreamHelper.WriteBigEndianInt32(inner, result); + } +} \ No newline at end of file diff --git a/RawPngData.cs b/RawPngData.cs new file mode 100644 index 0000000..0769cf0 --- /dev/null +++ b/RawPngData.cs @@ -0,0 +1,120 @@ +namespace Uwaa.PNG; + +/// +/// Provides convenience methods for indexing into a raw byte array to extract pixel values. +/// +internal class RawPngData +{ + readonly byte[] data; + readonly int bytesPerPixel; + readonly int width; + readonly Palette? palette; + readonly ColorType colorType; + readonly int rowOffset; + readonly int bitDepth; + + /// + /// Create a new . + /// + /// The decoded pixel data as bytes. + /// The number of bytes in each pixel. + /// The palette for the image. + /// The image header. + public RawPngData(byte[] data, int bytesPerPixel, Palette? palette, ImageHeader imageHeader) + { + if (width < 0) + { + throw new ArgumentOutOfRangeException($"Width must be greater than or equal to 0, got {width}."); + } + + this.data = data ?? throw new ArgumentNullException(nameof(data)); + this.bytesPerPixel = bytesPerPixel; + this.palette = palette; + + width = imageHeader.Width; + colorType = imageHeader.ColorType; + rowOffset = imageHeader.InterlaceMethod == InterlaceMethod.Adam7 ? 0 : 1; + bitDepth = imageHeader.BitDepth; + } + + public Pixel GetPixel(int x, int y) + { + if (palette != null) + { + int pixelsPerByte = 8 / bitDepth; + int bytesInRow = 1 + (width / pixelsPerByte); + int byteIndexInRow = x / pixelsPerByte; + int paletteIndex = 1 + (y * bytesInRow) + byteIndexInRow; + + byte b = data[paletteIndex]; + + if (bitDepth == 8) + return palette.GetPixel(b); + + int withinByteIndex = x % pixelsPerByte; + int rightShift = 8 - ((withinByteIndex + 1) * bitDepth); + int indexActual = (b >> rightShift) & ((1 << bitDepth) - 1); + + return palette.GetPixel(indexActual); + } + + int rowStartPixel = rowOffset + (rowOffset * y) + (bytesPerPixel * width * y); + int pixelStartIndex = rowStartPixel + (bytesPerPixel * x); + byte first = data[pixelStartIndex]; + + switch (bytesPerPixel) + { + case 1: + return new Pixel(first); + + case 2: + switch (colorType) + { + case ColorType.None: + { + byte second = data[pixelStartIndex + 1]; + byte value = ToSingleByte(first, second); + return new Pixel(value, value, value, 255); + + } + default: + return new Pixel(first, first, first, data[pixelStartIndex + 1]); + } + + case 3: + return new Pixel(first, data[pixelStartIndex + 1], data[pixelStartIndex + 2], 255); + + case 4: + switch (colorType) + { + case ColorType.None | ColorType.AlphaChannelUsed: + { + byte second = data[pixelStartIndex + 1]; + byte firstAlpha = data[pixelStartIndex + 2]; + byte secondAlpha = data[pixelStartIndex + 3]; + byte gray = ToSingleByte(first, second); + byte alpha = ToSingleByte(firstAlpha, secondAlpha); + return new Pixel(gray, gray, gray, alpha); + } + default: + return new Pixel(first, data[pixelStartIndex + 1], data[pixelStartIndex + 2], data[pixelStartIndex + 3]); + } + + case 6: + return new Pixel(first, data[pixelStartIndex + 2], data[pixelStartIndex + 4], 255); + + case 8: + return new Pixel(first, data[pixelStartIndex + 2], data[pixelStartIndex + 4], data[pixelStartIndex + 6]); + + default: + throw new InvalidOperationException($"Unreconized number of bytes per pixel: {bytesPerPixel}."); + } + } + + static byte ToSingleByte(byte first, byte second) + { + int us = (first << 8) + second; + byte result = (byte)Math.Round(255 * us / (double)ushort.MaxValue); + return result; + } +} \ No newline at end of file diff --git a/StreamHelper.cs b/StreamHelper.cs new file mode 100644 index 0000000..650387d --- /dev/null +++ b/StreamHelper.cs @@ -0,0 +1,26 @@ +using System.Buffers.Binary; + +namespace Uwaa.PNG; + +internal static class StreamHelper +{ + public static int ReadBigEndianInt32(Stream stream) + { + Span buffer = stackalloc byte[4]; + stream.Read(buffer); + return BinaryPrimitives.ReadInt32BigEndian(buffer); + } + + public static void WriteBigEndianInt32(Stream stream, int value) + { + Span buffer = stackalloc byte[4]; + BinaryPrimitives.WriteInt32BigEndian(buffer, value); + stream.Write(buffer); + } + + public static bool TryReadHeaderBytes(Stream stream, out byte[] bytes) + { + bytes = new byte[8]; + return stream.Read(bytes, 0, 8) == 8; + } +} \ No newline at end of file