From 23438eada3d0f2c81e9870f9822c9c01f6e3a076 Mon Sep 17 00:00:00 2001 From: uwaa Date: Sun, 3 Nov 2024 21:34:26 +0000 Subject: [PATCH] initial commit --- .gitattributes | 2 + .gitignore | 4 + Adam7.cs | 95 ++++++++ ChunkHeader.cs | 58 +++++ ColorType.cs | 28 +++ Crc32.cs | 84 +++++++ Decoder.cs | 189 +++++++++++++++ FilterType.cs | 25 ++ ImageHeader.cs | 71 ++++++ InterlaceMethod.cs | 17 ++ PNG.csproj | 7 + Palette.cs | 42 ++++ Pixel.cs | 93 ++++++++ Png.cs | 80 +++++++ PngBuilder.cs | 514 ++++++++++++++++++++++++++++++++++++++++ PngOpener.cs | 158 ++++++++++++ PngStreamWriteHelper.cs | 57 +++++ RawPngData.cs | 120 ++++++++++ StreamHelper.cs | 26 ++ 19 files changed, 1670 insertions(+) create mode 100644 .gitattributes create mode 100644 .gitignore create mode 100644 Adam7.cs create mode 100644 ChunkHeader.cs create mode 100644 ColorType.cs create mode 100644 Crc32.cs create mode 100644 Decoder.cs create mode 100644 FilterType.cs create mode 100644 ImageHeader.cs create mode 100644 InterlaceMethod.cs create mode 100644 PNG.csproj create mode 100644 Palette.cs create mode 100644 Pixel.cs create mode 100644 Png.cs create mode 100644 PngBuilder.cs create mode 100644 PngOpener.cs create mode 100644 PngStreamWriteHelper.cs create mode 100644 RawPngData.cs create mode 100644 StreamHelper.cs 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