initial commit
This commit is contained in:
commit
23438eada3
19 changed files with 1670 additions and 0 deletions
2
.gitattributes
vendored
Normal file
2
.gitattributes
vendored
Normal file
|
@ -0,0 +1,2 @@
|
|||
*.cs eol=crlf
|
||||
*.txt eol=lf
|
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
|
@ -0,0 +1,4 @@
|
|||
.vs
|
||||
.vscode
|
||||
bin
|
||||
obj
|
95
Adam7.cs
Normal file
95
Adam7.cs
Normal file
|
@ -0,0 +1,95 @@
|
|||
namespace Uwaa.PNG;
|
||||
|
||||
internal static class Adam7
|
||||
{
|
||||
/// <summary>
|
||||
/// For a given pass number (1 indexed) the scanline indexes of the lines included in that pass in the 8x8 grid.
|
||||
/// </summary>
|
||||
static readonly IReadOnlyDictionary<int, int[]> PassToScanlineGridIndex = new Dictionary<int, int[]>
|
||||
{
|
||||
{ 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<int, int[]> PassToScanlineColumnIndex = new Dictionary<int, int[]>
|
||||
{
|
||||
{ 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]);
|
||||
}
|
||||
}
|
58
ChunkHeader.cs
Normal file
58
ChunkHeader.cs
Normal file
|
@ -0,0 +1,58 @@
|
|||
namespace Uwaa.PNG;
|
||||
|
||||
/// <summary>
|
||||
/// The header for a data chunk in a PNG file.
|
||||
/// </summary>
|
||||
public readonly struct ChunkHeader
|
||||
{
|
||||
/// <summary>
|
||||
/// The position/start of the chunk header within the stream.
|
||||
/// </summary>
|
||||
public long Position { get; }
|
||||
|
||||
/// <summary>
|
||||
/// The length of the chunk in bytes.
|
||||
/// </summary>
|
||||
public int Length { get; }
|
||||
|
||||
/// <summary>
|
||||
/// The name of the chunk, uppercase first letter means the chunk is critical (vs. ancillary).
|
||||
/// </summary>
|
||||
public string Name { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether the chunk is critical (must be read by all readers) or ancillary (may be ignored).
|
||||
/// </summary>
|
||||
public bool IsCritical => char.IsUpper(Name[0]);
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public bool IsPublic => char.IsUpper(Name[1]);
|
||||
|
||||
/// <summary>
|
||||
/// Whether the (if unrecognized) chunk is safe to copy.
|
||||
/// </summary>
|
||||
public bool IsSafeToCopy => char.IsUpper(Name[3]);
|
||||
|
||||
/// <summary>
|
||||
/// Create a new <see cref="ChunkHeader"/>.
|
||||
/// </summary>
|
||||
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}).";
|
||||
}
|
||||
}
|
28
ColorType.cs
Normal file
28
ColorType.cs
Normal file
|
@ -0,0 +1,28 @@
|
|||
namespace Uwaa.PNG;
|
||||
|
||||
/// <summary>
|
||||
/// Describes the interpretation of the image data.
|
||||
/// </summary>
|
||||
[Flags]
|
||||
public enum ColorType : byte
|
||||
{
|
||||
/// <summary>
|
||||
/// Grayscale.
|
||||
/// </summary>
|
||||
None = 0,
|
||||
|
||||
/// <summary>
|
||||
/// Colors are stored in a palette rather than directly in the data.
|
||||
/// </summary>
|
||||
PaletteUsed = 1,
|
||||
|
||||
/// <summary>
|
||||
/// The image uses color.
|
||||
/// </summary>
|
||||
ColorUsed = 2,
|
||||
|
||||
/// <summary>
|
||||
/// The image has an alpha channel.
|
||||
/// </summary>
|
||||
AlphaChannelUsed = 4
|
||||
}
|
84
Crc32.cs
Normal file
84
Crc32.cs
Normal file
|
@ -0,0 +1,84 @@
|
|||
namespace Uwaa.PNG;
|
||||
|
||||
/// <summary>
|
||||
/// 32-bit Cyclic Redundancy Code used by the PNG for checking the data is intact.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Calculate the CRC32 for data.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Calculate the CRC32 for data.
|
||||
/// </summary>
|
||||
public static uint Calculate(List<byte> 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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Calculate the combined CRC32 for data.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
}
|
189
Decoder.cs
Normal file
189
Decoder.cs
Normal file
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
}
|
25
FilterType.cs
Normal file
25
FilterType.cs
Normal file
|
@ -0,0 +1,25 @@
|
|||
namespace Uwaa.PNG;
|
||||
|
||||
internal enum FilterType
|
||||
{
|
||||
/// <summary>
|
||||
/// The raw byte is unaltered.
|
||||
/// </summary>
|
||||
None = 0,
|
||||
/// <summary>
|
||||
/// The byte to the left.
|
||||
/// </summary>
|
||||
Sub = 1,
|
||||
/// <summary>
|
||||
/// The byte above.
|
||||
/// </summary>
|
||||
Up = 2,
|
||||
/// <summary>
|
||||
/// The mean of bytes left and above, rounded down.
|
||||
/// </summary>
|
||||
Average = 3,
|
||||
/// <summary>
|
||||
/// Byte to the left, above or top-left based on Paeth's algorithm.
|
||||
/// </summary>
|
||||
Paeth = 4
|
||||
}
|
71
ImageHeader.cs
Normal file
71
ImageHeader.cs
Normal file
|
@ -0,0 +1,71 @@
|
|||
namespace Uwaa.PNG;
|
||||
|
||||
/// <summary>
|
||||
/// The high level information about the image.
|
||||
/// </summary>
|
||||
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<ColorType, HashSet<byte>> PermittedBitDepths = new Dictionary<ColorType, HashSet<byte>>
|
||||
{
|
||||
{ColorType.None, new HashSet<byte> {1, 2, 4, 8, 16}},
|
||||
{ColorType.ColorUsed, new HashSet<byte> {8, 16}},
|
||||
{ColorType.PaletteUsed | ColorType.ColorUsed, new HashSet<byte> {1, 2, 4, 8}},
|
||||
{ColorType.AlphaChannelUsed, new HashSet<byte> {8, 16}},
|
||||
{ColorType.AlphaChannelUsed | ColorType.ColorUsed, new HashSet<byte> {8, 16}},
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// The width of the image in pixels.
|
||||
/// </summary>
|
||||
public int Width { get; }
|
||||
|
||||
/// <summary>
|
||||
/// The height of the image in pixels.
|
||||
/// </summary>
|
||||
public int Height { get; }
|
||||
|
||||
/// <summary>
|
||||
/// The bit depth of the image.
|
||||
/// </summary>
|
||||
public byte BitDepth { get; }
|
||||
|
||||
/// <summary>
|
||||
/// The color type of the image.
|
||||
/// </summary>
|
||||
public ColorType ColorType { get; }
|
||||
|
||||
/// <summary>
|
||||
/// The interlace method used by the image..
|
||||
/// </summary>
|
||||
public InterlaceMethod InterlaceMethod { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Create a new <see cref="ImageHeader"/>.
|
||||
/// </summary>
|
||||
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}.";
|
||||
}
|
||||
}
|
17
InterlaceMethod.cs
Normal file
17
InterlaceMethod.cs
Normal file
|
@ -0,0 +1,17 @@
|
|||
namespace Uwaa.PNG;
|
||||
|
||||
/// <summary>
|
||||
/// Indicates the transmission order of the image data.
|
||||
/// </summary>
|
||||
public enum InterlaceMethod : byte
|
||||
{
|
||||
/// <summary>
|
||||
/// No interlace.
|
||||
/// </summary>
|
||||
None = 0,
|
||||
|
||||
/// <summary>
|
||||
/// Adam7 interlace.
|
||||
/// </summary>
|
||||
Adam7 = 1
|
||||
}
|
7
PNG.csproj
Normal file
7
PNG.csproj
Normal file
|
@ -0,0 +1,7 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
</Project>
|
42
Palette.cs
Normal file
42
Palette.cs
Normal file
|
@ -0,0 +1,42 @@
|
|||
using System.Runtime.CompilerServices;
|
||||
|
||||
namespace Uwaa.PNG;
|
||||
|
||||
internal class Palette
|
||||
{
|
||||
public bool HasAlphaValues { get; private set; }
|
||||
|
||||
public byte[] Data { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Creates a palette object. Input palette data length from PLTE chunk must be a multiple of 3.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds transparency values from tRNS chunk.
|
||||
/// </summary>
|
||||
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<byte, Pixel>(ref Data[index * 4]);
|
||||
}
|
||||
}
|
93
Pixel.cs
Normal file
93
Pixel.cs
Normal file
|
@ -0,0 +1,93 @@
|
|||
using System.Runtime.CompilerServices;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace Uwaa.PNG;
|
||||
|
||||
/// <summary>
|
||||
/// A 32-bit RGBA pixel in a <see cref="Png"/> image.
|
||||
/// </summary>
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
public readonly struct Pixel : IEquatable<Pixel>
|
||||
{
|
||||
/// <summary>
|
||||
/// The red value for the pixel.
|
||||
/// </summary>
|
||||
public byte R { get; }
|
||||
|
||||
/// <summary>
|
||||
/// The green value for the pixel.
|
||||
/// </summary>
|
||||
public byte G { get; }
|
||||
|
||||
/// <summary>
|
||||
/// The blue value for the pixel.
|
||||
/// </summary>
|
||||
public byte B { get; }
|
||||
|
||||
/// <summary>
|
||||
/// The alpha transparency value for the pixel.
|
||||
/// </summary>
|
||||
public byte A { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Create a new <see cref="Pixel"/>.
|
||||
/// </summary>
|
||||
/// <param name="r">The red value for the pixel.</param>
|
||||
/// <param name="g">The green value for the pixel.</param>
|
||||
/// <param name="b">The blue value for the pixel.</param>
|
||||
/// <param name="a">The alpha transparency value for the pixel.</param>
|
||||
public Pixel(byte r, byte g, byte b, byte a)
|
||||
{
|
||||
R = r;
|
||||
G = g;
|
||||
B = b;
|
||||
A = a;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Create a new <see cref="Pixel"/> which is fully opaque.
|
||||
/// </summary>
|
||||
/// <param name="r">The red value for the pixel.</param>
|
||||
/// <param name="g">The green value for the pixel.</param>
|
||||
/// <param name="b">The blue value for the pixel.</param>
|
||||
public Pixel(byte r, byte g, byte b)
|
||||
{
|
||||
R = r;
|
||||
G = g;
|
||||
B = b;
|
||||
A = 255;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Create a new grayscale <see cref="Pixel"/>.
|
||||
/// </summary>
|
||||
/// <param name="grayscale">The grayscale value.</param>
|
||||
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);
|
||||
|
||||
/// <summary>
|
||||
/// Whether the pixel values are equal.
|
||||
/// </summary>
|
||||
/// <param name="other">The other pixel.</param>
|
||||
/// <returns><see langword="true"/> if all pixel values are equal otherwise <see langword="false"/>.</returns>
|
||||
public bool Equals(Pixel other)
|
||||
{
|
||||
Pixel this_ = this;
|
||||
return Unsafe.As<Pixel, uint>(ref this_) == Unsafe.As<Pixel, uint>(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);
|
||||
}
|
80
Png.cs
Normal file
80
Png.cs
Normal file
|
@ -0,0 +1,80 @@
|
|||
namespace Uwaa.PNG;
|
||||
|
||||
/// <summary>
|
||||
/// A PNG image. Call <see cref="Open(byte[])"/> to open from file or bytes.
|
||||
/// </summary>
|
||||
public class Png
|
||||
{
|
||||
readonly RawPngData data;
|
||||
readonly bool hasTransparencyChunk;
|
||||
|
||||
/// <summary>
|
||||
/// The header data from the PNG image.
|
||||
/// </summary>
|
||||
public ImageHeader Header { get; }
|
||||
|
||||
/// <summary>
|
||||
/// The width of the image in pixels.
|
||||
/// </summary>
|
||||
public int Width => Header.Width;
|
||||
|
||||
/// <summary>
|
||||
/// The height of the image in pixels.
|
||||
/// </summary>
|
||||
public int Height => Header.Height;
|
||||
|
||||
/// <summary>
|
||||
/// Whether the image has an alpha (transparency) layer.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get the pixel at the given column and row (x, y).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
/// <param name="x">The x coordinate (column).</param>
|
||||
/// <param name="y">The y coordinate (row).</param>
|
||||
/// <returns>The pixel at the coordinate.</returns>
|
||||
public Pixel GetPixel(int x, int y) => data.GetPixel(x, y);
|
||||
|
||||
/// <summary>
|
||||
/// Read the PNG image from the stream.
|
||||
/// </summary>
|
||||
/// <param name="stream">The stream containing PNG data to be read.</param>
|
||||
/// <returns>The <see cref="Png"/> data from the stream.</returns>
|
||||
public static Png Open(Stream stream)
|
||||
=> PngOpener.Open(stream);
|
||||
|
||||
/// <summary>
|
||||
/// Read the PNG image from the bytes.
|
||||
/// </summary>
|
||||
/// <param name="bytes">The bytes of the PNG data to be read.</param>
|
||||
/// <returns>The <see cref="Png"/> data from the bytes.</returns>
|
||||
public static Png Open(byte[] bytes)
|
||||
{
|
||||
using var memoryStream = new MemoryStream(bytes);
|
||||
return PngOpener.Open(memoryStream);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Read the PNG from the file path.
|
||||
/// </summary>
|
||||
/// <param name="path">The path to the PNG file to open.</param>
|
||||
/// <remarks>This will open the file to obtain a <see cref="FileStream"/> so will lock the file during reading.</remarks>
|
||||
/// <returns>The <see cref="Png"/> data from the file.</returns>
|
||||
public static Png Open(string path)
|
||||
{
|
||||
using var fileStream = File.OpenRead(path);
|
||||
return Open(fileStream);
|
||||
}
|
||||
}
|
514
PngBuilder.cs
Normal file
514
PngBuilder.cs
Normal file
|
@ -0,0 +1,514 @@
|
|||
using System.IO.Compression;
|
||||
using System.Text;
|
||||
|
||||
namespace Uwaa.PNG;
|
||||
|
||||
/// <summary>
|
||||
/// Used to construct PNG images. Call <see cref="Create"/> to make a new builder.
|
||||
/// </summary>
|
||||
public class PngBuilder
|
||||
{
|
||||
const byte Deflate32KbWindow = 120;
|
||||
const byte ChecksumBits = 1;
|
||||
|
||||
readonly byte[] rawData;
|
||||
readonly bool hasAlphaChannel;
|
||||
readonly int width;
|
||||
readonly int height;
|
||||
readonly int bytesPerPixel;
|
||||
|
||||
bool hasTooManyColorsForPalette;
|
||||
|
||||
readonly int backgroundColorInt;
|
||||
readonly Dictionary<int, int> colorCounts;
|
||||
|
||||
readonly List<(string keyword, byte[] data)> storedStrings = new List<(string keyword, byte[] data)>();
|
||||
|
||||
/// <summary>
|
||||
/// Create a builder for a PNG with the given width and size.
|
||||
/// </summary>
|
||||
public static PngBuilder Create(int width, int height, bool hasAlphaChannel)
|
||||
{
|
||||
int bpp = hasAlphaChannel ? 4 : 3;
|
||||
|
||||
int length = (height * width * bpp) + height;
|
||||
|
||||
return new PngBuilder(new byte[length], hasAlphaChannel, width, height, bpp);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Create a builder from a <see cref="Png"/>.
|
||||
/// </summary>
|
||||
public static PngBuilder FromPng(Png png)
|
||||
{
|
||||
var result = Create(png.Width, png.Height, png.HasAlphaChannel);
|
||||
|
||||
for (int y = 0; y < png.Height; y++)
|
||||
for (int x = 0; x < png.Width; x++)
|
||||
result.SetPixel(png.GetPixel(x, y), x, y);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Create a builder from the bytes of the specified PNG image.
|
||||
/// </summary>
|
||||
public static PngBuilder FromPngBytes(byte[] png)
|
||||
{
|
||||
var pngActual = Png.Open(png);
|
||||
return FromPng(pngActual);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Create a builder from the bytes in the BGRA32 pixel format.
|
||||
/// https://docs.microsoft.com/en-us/dotnet/api/system.windows.media.pixelformats.bgra32
|
||||
/// </summary>
|
||||
/// <param name="data">The pixels in BGRA32 format.</param>
|
||||
/// <param name="width">The width in pixels.</param>
|
||||
/// <param name="height">The height in pixels.</param>
|
||||
/// <param name="useAlphaChannel">Whether to include an alpha channel in the output.</param>
|
||||
public static PngBuilder FromBgra32Pixels(byte[] data, int width, int height, bool useAlphaChannel = true)
|
||||
{
|
||||
using var memoryStream = new MemoryStream(data);
|
||||
return FromBgra32Pixels(memoryStream, width, height, useAlphaChannel);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Create a builder from the bytes in the BGRA32 pixel format.
|
||||
/// https://docs.microsoft.com/en-us/dotnet/api/system.windows.media.pixelformats.bgra32
|
||||
/// </summary>
|
||||
/// <param name="data">The pixels in BGRA32 format.</param>
|
||||
/// <param name="width">The width in pixels.</param>
|
||||
/// <param name="height">The height in pixels.</param>
|
||||
/// <param name="useAlphaChannel">Whether to include an alpha channel in the output.</param>
|
||||
public static PngBuilder FromBgra32Pixels(Stream data, int width, int height, bool useAlphaChannel = true)
|
||||
{
|
||||
int bpp = useAlphaChannel ? 4 : 3;
|
||||
int length = (height * width * bpp) + height;
|
||||
PngBuilder builder = new PngBuilder(new byte[length], useAlphaChannel, width, height, bpp);
|
||||
byte[] buffer = new byte[4];
|
||||
|
||||
for (int y = 0; y < height; y++)
|
||||
{
|
||||
for (int x = 0; x < width; x++)
|
||||
{
|
||||
int read = data.Read(buffer, 0, buffer.Length);
|
||||
|
||||
if (read != 4)
|
||||
{
|
||||
throw new InvalidOperationException($"Unexpected end of stream, expected to read 4 bytes at offset {data.Position - read} for (x: {x}, y: {y}), instead got {read}.");
|
||||
}
|
||||
|
||||
if (useAlphaChannel)
|
||||
{
|
||||
builder.SetPixel(new Pixel(buffer[0], buffer[1], buffer[2], buffer[3]), x, y);
|
||||
}
|
||||
else
|
||||
{
|
||||
builder.SetPixel(buffer[0], buffer[1], buffer[2], x, y);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return builder;
|
||||
}
|
||||
|
||||
PngBuilder(byte[] rawData, bool hasAlphaChannel, int width, int height, int bytesPerPixel)
|
||||
{
|
||||
this.rawData = rawData;
|
||||
this.hasAlphaChannel = hasAlphaChannel;
|
||||
this.width = width;
|
||||
this.height = height;
|
||||
this.bytesPerPixel = bytesPerPixel;
|
||||
|
||||
backgroundColorInt = PixelToColorInt(0, 0, 0, hasAlphaChannel ? (byte)0 : byte.MaxValue);
|
||||
|
||||
colorCounts = new Dictionary<int, int>()
|
||||
{
|
||||
{ backgroundColorInt, width * height}
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets the RGB pixel value for the given column (x) and row (y).
|
||||
/// </summary>
|
||||
public void SetPixel(byte r, byte g, byte b, int x, int y) => SetPixel(new Pixel(r, g, b), x, y);
|
||||
|
||||
/// <summary>
|
||||
/// Set the pixel value for the given column (x) and row (y).
|
||||
/// </summary>
|
||||
public void SetPixel(Pixel pixel, int x, int y)
|
||||
{
|
||||
if (!hasTooManyColorsForPalette)
|
||||
{
|
||||
int val = PixelToColorInt(pixel);
|
||||
if (val != backgroundColorInt)
|
||||
{
|
||||
if (!colorCounts.TryGetValue(val, out int value))
|
||||
{
|
||||
colorCounts[val] = 1;
|
||||
}
|
||||
else
|
||||
{
|
||||
colorCounts[val] = value + 1;
|
||||
}
|
||||
|
||||
colorCounts[backgroundColorInt]--;
|
||||
if (colorCounts[backgroundColorInt] == 0)
|
||||
{
|
||||
colorCounts.Remove(backgroundColorInt);
|
||||
}
|
||||
}
|
||||
|
||||
if (colorCounts.Count > 256)
|
||||
{
|
||||
hasTooManyColorsForPalette = true;
|
||||
}
|
||||
}
|
||||
|
||||
int start = (y * ((width * bytesPerPixel) + 1)) + 1 + (x * bytesPerPixel);
|
||||
|
||||
rawData[start++] = pixel.R;
|
||||
rawData[start++] = pixel.G;
|
||||
rawData[start++] = pixel.B;
|
||||
|
||||
if (hasAlphaChannel)
|
||||
{
|
||||
rawData[start] = pixel.A;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Allows you to store arbitrary text data in the "iTXt" international textual data
|
||||
/// chunks of the generated PNG image.
|
||||
/// </summary>
|
||||
/// <param name="keyword">
|
||||
/// A keyword identifying the text data between 1-79 characters in length.
|
||||
/// Must not start with, end with or contain consecutive whitespace characters.
|
||||
/// Only characters in the range 32 - 126 and 161 - 255 are permitted.
|
||||
/// </param>
|
||||
/// <param name="text">
|
||||
/// The text data to store. Encoded as UTF-8 that may not contain zero (0) bytes but can be zero-length.
|
||||
/// </param>
|
||||
public PngBuilder StoreText(string keyword, string text)
|
||||
{
|
||||
if (keyword == null)
|
||||
throw new ArgumentNullException(nameof(keyword), "Keyword may not be null.");
|
||||
|
||||
if (text == null)
|
||||
throw new ArgumentNullException(nameof(text), "Text may not be null.");
|
||||
|
||||
if (keyword == string.Empty)
|
||||
throw new ArgumentException("Keyword may not be empty.", nameof(keyword));
|
||||
|
||||
if (keyword.Length > 79)
|
||||
throw new ArgumentException($"Keyword must be between 1 - 79 characters, provided keyword '{keyword}' has length of {keyword.Length} characters.", nameof(keyword));
|
||||
|
||||
for (int i = 0; i < keyword.Length; i++)
|
||||
{
|
||||
char c = keyword[i];
|
||||
bool isValid = (c >= 32 && c <= 126) || (c >= 161 && c <= 255);
|
||||
if (!isValid)
|
||||
{
|
||||
throw new ArgumentException($"The keyword can only contain printable Latin 1 characters and spaces in the ranges 32 - 126 or 161 -255. The provided keyword '{keyword}' contained an invalid character ({c}) at index {i}.", nameof(keyword));
|
||||
}
|
||||
|
||||
// TODO: trailing, leading and consecutive whitespaces are also prohibited.
|
||||
}
|
||||
|
||||
var bytes = Encoding.UTF8.GetBytes(text);
|
||||
|
||||
for (int i = 0; i < bytes.Length; i++)
|
||||
{
|
||||
byte b = bytes[i];
|
||||
if (b == 0)
|
||||
throw new ArgumentOutOfRangeException(nameof(text), $"The provided text contained a null (0) byte when converted to UTF-8. Null bytes are not permitted. Text was: '{text}'");
|
||||
}
|
||||
|
||||
storedStrings.Add((keyword, bytes));
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get the bytes of the PNG file for this builder.
|
||||
/// </summary>
|
||||
public byte[] Save(SaveOptions? options = null)
|
||||
{
|
||||
using var memoryStream = new MemoryStream();
|
||||
Save(memoryStream, options);
|
||||
return memoryStream.ToArray();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Write the PNG file bytes to the provided stream.
|
||||
/// </summary>
|
||||
public void Save(Stream outputStream, SaveOptions? options = null)
|
||||
{
|
||||
options = options ?? new SaveOptions();
|
||||
|
||||
byte[]? palette = null;
|
||||
int dataLength = rawData.Length;
|
||||
int bitDepth = 8;
|
||||
|
||||
if (!hasTooManyColorsForPalette && !hasAlphaChannel)
|
||||
{
|
||||
var paletteColors = colorCounts.OrderByDescending(x => x.Value).Select(x => x.Key).ToList();
|
||||
bitDepth = paletteColors.Count > 16 ? 8 : 4;
|
||||
int samplesPerByte = bitDepth == 8 ? 1 : 2;
|
||||
bool applyShift = samplesPerByte == 2;
|
||||
|
||||
palette = new byte[3 * paletteColors.Count];
|
||||
|
||||
for (int i = 0; i < paletteColors.Count; i++)
|
||||
{
|
||||
(byte r, byte g, byte b, byte a) = ColorIntToPixel(paletteColors[i]);
|
||||
int startIndex = i * 3;
|
||||
palette[startIndex++] = r;
|
||||
palette[startIndex++] = g;
|
||||
palette[startIndex] = b;
|
||||
}
|
||||
|
||||
int rawDataIndex = 0;
|
||||
|
||||
for (int y = 0; y < height; y++)
|
||||
{
|
||||
// None filter - we don't use filtering for palette images.
|
||||
rawData[rawDataIndex++] = 0;
|
||||
|
||||
for (int x = 0; x < width; x++)
|
||||
{
|
||||
int index = (y * width * bytesPerPixel) + y + 1 + (x * bytesPerPixel);
|
||||
|
||||
byte r = rawData[index++];
|
||||
byte g = rawData[index++];
|
||||
byte b = rawData[index];
|
||||
|
||||
int colorInt = PixelToColorInt(r, g, b);
|
||||
|
||||
byte value = (byte)paletteColors.IndexOf(colorInt);
|
||||
|
||||
if (applyShift)
|
||||
{
|
||||
// apply mask and shift
|
||||
int withinByteIndex = x % 2;
|
||||
|
||||
if (withinByteIndex == 1)
|
||||
{
|
||||
rawData[rawDataIndex] = (byte)(rawData[rawDataIndex] + value);
|
||||
rawDataIndex++;
|
||||
}
|
||||
else
|
||||
{
|
||||
rawData[rawDataIndex] = (byte)(value << 4);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
rawData[rawDataIndex++] = value;
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
dataLength = rawDataIndex;
|
||||
}
|
||||
else
|
||||
{
|
||||
AttemptCompressionOfRawData(rawData, options);
|
||||
}
|
||||
|
||||
outputStream.Write(ImageHeader.ValidationHeader);
|
||||
|
||||
PngStreamWriteHelper stream = new PngStreamWriteHelper(outputStream);
|
||||
|
||||
stream.WriteChunkLength(13);
|
||||
stream.WriteChunkHeader(ImageHeader.HeaderBytes);
|
||||
|
||||
StreamHelper.WriteBigEndianInt32(stream, width);
|
||||
StreamHelper.WriteBigEndianInt32(stream, height);
|
||||
stream.WriteByte((byte)bitDepth);
|
||||
|
||||
var colorType = ColorType.ColorUsed;
|
||||
if (hasAlphaChannel)
|
||||
colorType |= ColorType.AlphaChannelUsed;
|
||||
|
||||
if (palette != null)
|
||||
colorType |= ColorType.PaletteUsed;
|
||||
|
||||
stream.WriteByte((byte)colorType);
|
||||
stream.WriteByte(0);
|
||||
stream.WriteByte(0);
|
||||
stream.WriteByte((byte)InterlaceMethod.None);
|
||||
|
||||
stream.WriteCrc();
|
||||
|
||||
if (palette != null)
|
||||
{
|
||||
stream.WriteChunkLength(palette.Length);
|
||||
stream.WriteChunkHeader(Encoding.ASCII.GetBytes("PLTE"));
|
||||
stream.Write(palette, 0, palette.Length);
|
||||
stream.WriteCrc();
|
||||
}
|
||||
|
||||
byte[] imageData = Compress(rawData, dataLength, options);
|
||||
stream.WriteChunkLength(imageData.Length);
|
||||
stream.WriteChunkHeader(Encoding.ASCII.GetBytes("IDAT"));
|
||||
stream.Write(imageData, 0, imageData.Length);
|
||||
stream.WriteCrc();
|
||||
|
||||
foreach (var storedString in storedStrings)
|
||||
{
|
||||
byte[] keyword = Encoding.GetEncoding("iso-8859-1").GetBytes(storedString.keyword);
|
||||
int length = keyword.Length
|
||||
+ 1 // Null separator
|
||||
+ 1 // Compression flag
|
||||
+ 1 // Compression method
|
||||
+ 1 // Null separator
|
||||
+ 1 // Null separator
|
||||
+ storedString.data.Length;
|
||||
|
||||
stream.WriteChunkLength(length);
|
||||
stream.WriteChunkHeader(Encoding.ASCII.GetBytes("iTXt"));
|
||||
stream.Write(keyword, 0, keyword.Length);
|
||||
|
||||
stream.WriteByte(0); // Null separator
|
||||
stream.WriteByte(0); // Compression flag (0 for uncompressed)
|
||||
stream.WriteByte(0); // Compression method (0, ignored since flag is zero)
|
||||
stream.WriteByte(0); // Null separator
|
||||
stream.WriteByte(0); // Null separator
|
||||
|
||||
stream.Write(storedString.data, 0, storedString.data.Length);
|
||||
stream.WriteCrc();
|
||||
}
|
||||
|
||||
stream.WriteChunkLength(0);
|
||||
stream.WriteChunkHeader(Encoding.ASCII.GetBytes("IEND"));
|
||||
stream.WriteCrc();
|
||||
}
|
||||
|
||||
static byte[] Compress(byte[] data, int dataLength, SaveOptions options)
|
||||
{
|
||||
const int headerLength = 2;
|
||||
const int checksumLength = 4;
|
||||
|
||||
var compressionLevel = options?.AttemptCompression == true ? CompressionLevel.Optimal : CompressionLevel.Fastest;
|
||||
|
||||
using (var compressStream = new MemoryStream())
|
||||
using (var compressor = new DeflateStream(compressStream, compressionLevel, true))
|
||||
{
|
||||
compressor.Write(data, 0, dataLength);
|
||||
compressor.Close();
|
||||
|
||||
compressStream.Seek(0, SeekOrigin.Begin);
|
||||
|
||||
var result = new byte[headerLength + compressStream.Length + checksumLength];
|
||||
|
||||
// Write the ZLib header.
|
||||
result[0] = Deflate32KbWindow;
|
||||
result[1] = ChecksumBits;
|
||||
|
||||
// Write the compressed data.
|
||||
int streamValue;
|
||||
int i = 0;
|
||||
while ((streamValue = compressStream.ReadByte()) != -1)
|
||||
{
|
||||
result[headerLength + i] = (byte)streamValue;
|
||||
i++;
|
||||
}
|
||||
|
||||
// Write Checksum of raw data.
|
||||
int checksum = Adler32Checksum(data, dataLength);
|
||||
|
||||
long offset = headerLength + compressStream.Length;
|
||||
|
||||
result[offset++] = (byte)(checksum >> 24);
|
||||
result[offset++] = (byte)(checksum >> 16);
|
||||
result[offset++] = (byte)(checksum >> 8);
|
||||
result[offset] = (byte)(checksum >> 0);
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Calculate the Adler-32 checksum for some data.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Complies with the RFC 1950: ZLIB Compressed Data Format Specification.
|
||||
/// </remarks>
|
||||
public static int Adler32Checksum(IEnumerable<byte> data, int length = -1)
|
||||
{
|
||||
// Both sums (s1 and s2) are done modulo 65521.
|
||||
const int AdlerModulus = 65521;
|
||||
|
||||
// s1 is the sum of all bytes.
|
||||
int s1 = 1;
|
||||
|
||||
// s2 is the sum of all s1 values.
|
||||
int s2 = 0;
|
||||
|
||||
int count = 0;
|
||||
foreach (byte b in data)
|
||||
{
|
||||
if (length > 0 && count == length)
|
||||
break;
|
||||
|
||||
s1 = (s1 + b) % AdlerModulus;
|
||||
s2 = (s1 + s2) % AdlerModulus;
|
||||
count++;
|
||||
}
|
||||
|
||||
// The Adler-32 checksum is stored as s2*65536 + s1.
|
||||
return (s2 * 65536) + s1;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Attempt to improve compressability of the raw data by using adaptive filtering.
|
||||
/// </summary>
|
||||
void AttemptCompressionOfRawData(byte[] rawData, SaveOptions options)
|
||||
{
|
||||
if (!options.AttemptCompression)
|
||||
return;
|
||||
|
||||
int bytesPerScanline = 1 + (bytesPerPixel * width);
|
||||
int scanlineCount = rawData.Length / bytesPerScanline;
|
||||
|
||||
byte[] scanData = new byte[bytesPerScanline - 1];
|
||||
|
||||
for (int scanlineRowIndex = 0; scanlineRowIndex < scanlineCount; scanlineRowIndex++)
|
||||
{
|
||||
int sourceIndex = (scanlineRowIndex * bytesPerScanline) + 1;
|
||||
|
||||
Array.Copy(rawData, sourceIndex, scanData, 0, bytesPerScanline - 1);
|
||||
|
||||
//Incomplete: the original source had unfinished junk code here which did nothing
|
||||
}
|
||||
}
|
||||
|
||||
static int PixelToColorInt(Pixel p) => PixelToColorInt(p.R, p.G, p.B, p.A);
|
||||
static int PixelToColorInt(byte r, byte g, byte b, byte a = 255)
|
||||
{
|
||||
return (a << 24) + (r << 16) + (g << 8) + b;
|
||||
}
|
||||
|
||||
static (byte r, byte g, byte b, byte a) ColorIntToPixel(int i) => ((byte)(i >> 16), (byte)(i >> 8), (byte)i, (byte)(i >> 24));
|
||||
|
||||
/// <summary>
|
||||
/// Options for configuring generation of PNGs from a <see cref="PngBuilder"/>.
|
||||
/// </summary>
|
||||
public class SaveOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether the library should try to reduce the resulting image size.
|
||||
/// This process does not affect the original image data (it is lossless) but may
|
||||
/// result in longer save times.
|
||||
/// </summary>
|
||||
public bool AttemptCompression { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The number of parallel tasks allowed during compression.
|
||||
/// </summary>
|
||||
public int MaxDegreeOfParallelism { get; set; } = 1;
|
||||
}
|
||||
}
|
158
PngOpener.cs
Normal file
158
PngOpener.cs
Normal file
|
@ -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<byte> 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<byte> 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);
|
||||
}
|
||||
}
|
57
PngStreamWriteHelper.cs
Normal file
57
PngStreamWriteHelper.cs
Normal file
|
@ -0,0 +1,57 @@
|
|||
namespace Uwaa.PNG;
|
||||
|
||||
internal class PngStreamWriteHelper : Stream
|
||||
{
|
||||
readonly Stream inner;
|
||||
readonly List<byte> written = new List<byte>();
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
120
RawPngData.cs
Normal file
120
RawPngData.cs
Normal file
|
@ -0,0 +1,120 @@
|
|||
namespace Uwaa.PNG;
|
||||
|
||||
/// <summary>
|
||||
/// Provides convenience methods for indexing into a raw byte array to extract pixel values.
|
||||
/// </summary>
|
||||
internal class RawPngData
|
||||
{
|
||||
readonly byte[] data;
|
||||
readonly int bytesPerPixel;
|
||||
readonly int width;
|
||||
readonly Palette? palette;
|
||||
readonly ColorType colorType;
|
||||
readonly int rowOffset;
|
||||
readonly int bitDepth;
|
||||
|
||||
/// <summary>
|
||||
/// Create a new <see cref="RawPngData"/>.
|
||||
/// </summary>
|
||||
/// <param name="data">The decoded pixel data as bytes.</param>
|
||||
/// <param name="bytesPerPixel">The number of bytes in each pixel.</param>
|
||||
/// <param name="palette">The palette for the image.</param>
|
||||
/// <param name="imageHeader">The image header.</param>
|
||||
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;
|
||||
}
|
||||
}
|
26
StreamHelper.cs
Normal file
26
StreamHelper.cs
Normal file
|
@ -0,0 +1,26 @@
|
|||
using System.Buffers.Binary;
|
||||
|
||||
namespace Uwaa.PNG;
|
||||
|
||||
internal static class StreamHelper
|
||||
{
|
||||
public static int ReadBigEndianInt32(Stream stream)
|
||||
{
|
||||
Span<byte> buffer = stackalloc byte[4];
|
||||
stream.Read(buffer);
|
||||
return BinaryPrimitives.ReadInt32BigEndian(buffer);
|
||||
}
|
||||
|
||||
public static void WriteBigEndianInt32(Stream stream, int value)
|
||||
{
|
||||
Span<byte> 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;
|
||||
}
|
||||
}
|
Loading…
Reference in a new issue