From 2ba70b8a80ce821820f4c54caef8276148944692 Mon Sep 17 00:00:00 2001
From: Glitched Polygons GmbH
<37942667+GlitchedPolygons@users.noreply.github.com>
Date: Thu, 7 May 2026 15:15:05 +0000
Subject: [PATCH 1/5] Added csharp/ThumbHash.cs
Works with .NET 10+
---
csharp/ThumbHash.cs | 568 ++++++++++++++++++++++++++++++++++++++++++++
1 file changed, 568 insertions(+)
create mode 100644 csharp/ThumbHash.cs
diff --git a/csharp/ThumbHash.cs b/csharp/ThumbHash.cs
new file mode 100644
index 0000000..9022358
--- /dev/null
+++ b/csharp/ThumbHash.cs
@@ -0,0 +1,568 @@
+using System.Text;
+using System.Buffers;
+using System.Buffers.Binary;
+
+///
+/// ThumbHash .NET implementation based on https://github.com/evanw/thumbhash
+///
+public static class ThumbHash
+{
+ ///
+ /// Encodes an RGBA image to a ThumbHash.
+ ///
+ /// The width of the input image. Must be ≤100px.
+ /// The height of the input image. Must be ≤100px.
+ /// The pixels in the input image, row-by-row. Must have w*h*4 elements.
+ /// RGB should not be premultiplied by A.
+ /// ThumbHash bytes.
+ /// Thrown if or is greater than 100.
+ public static byte[] RgbaToThumbHash(int w, int h, ReadOnlySpan rgba)
+ {
+ // Encoding an image larger than 100x100 is slow with no benefit.
+ if (w > 100 || h > 100)
+ {
+ throw new ArgumentException($"{w}x{h} is larger than the maximum allowed input resolution of 100x100.");
+ }
+
+ float averageR = 0;
+ float averageG = 0;
+ float averageB = 0;
+ float averageA = 0;
+
+ int resolution = w * h;
+
+ for (int i = 0, j = 0; i < resolution; ++i, j += 4)
+ {
+ float alpha = (rgba[j + 3] & 255) / 255.0f;
+
+ averageR += alpha / 255.0f * (rgba[j] & 255);
+ averageG += alpha / 255.0f * (rgba[j + 1] & 255);
+ averageB += alpha / 255.0f * (rgba[j + 2] & 255);
+ averageA += alpha;
+ }
+
+ if (averageA > 0)
+ {
+ averageR /= averageA;
+ averageG /= averageA;
+ averageB /= averageA;
+ }
+
+ bool hasAlpha = averageA < resolution;
+
+ int luminanceLimit = hasAlpha ? 5 : 7; // Use fewer luminance bits if there's an alpha.
+
+ int lx = (int)Math.Max(1, Math.Round(luminanceLimit * w / (float)Math.Max(w, h), MidpointRounding.AwayFromZero));
+ int ly = (int)Math.Max(1, Math.Round(luminanceLimit * h / (float)Math.Max(w, h), MidpointRounding.AwayFromZero));
+
+ float[] lpqaBuffer = ArrayPool.Shared.Rent(resolution * 4);
+
+ try
+ {
+ Span l = lpqaBuffer.AsSpan(resolution * 0, resolution); // luminance
+ Span p = lpqaBuffer.AsSpan(resolution * 1, resolution); // yellow - blue
+ Span q = lpqaBuffer.AsSpan(resolution * 2, resolution); // red - green
+ Span a = lpqaBuffer.AsSpan(resolution * 3, resolution); // alpha
+
+ // Convert the image from RGBA to LPQA (composite atop the average color).
+ for (int i = 0, j = 0; i < resolution; ++i, j += 4)
+ {
+ float alpha = (rgba[j + 3] & 255) / 255.0f;
+ float r = averageR * (1.0f - alpha) + alpha / 255.0f * (rgba[j] & 255);
+ float g = averageG * (1.0f - alpha) + alpha / 255.0f * (rgba[j + 1] & 255);
+ float b = averageB * (1.0f - alpha) + alpha / 255.0f * (rgba[j + 2] & 255);
+ l[i] = (r + g + b) / 3.0f;
+ p[i] = (r + g) / 2.0f - b;
+ q[i] = r - g;
+ a[i] = alpha;
+ }
+
+ // Encode using the DCT into DC (constant) and normalized AC (varying) terms.
+ Channel channelL = new Channel(Math.Max(3, lx), Math.Max(3, ly)).Encode(w, h, l);
+ Channel channelP = new Channel(3, 3).Encode(w, h, p);
+ Channel channelQ = new Channel(3, 3).Encode(w, h, q);
+ Channel? channelA = hasAlpha ? new Channel(5, 5).Encode(w, h, a) : null;
+
+ bool isLandscape = w > h;
+
+ int header24 =
+ (int)Math.Round(63.0f * channelL.DC, MidpointRounding.AwayFromZero)
+ |
+ ((int)Math.Round(31.5f + 31.5f * channelP.DC, MidpointRounding.AwayFromZero) << 6)
+ |
+ ((int)Math.Round(31.5f + 31.5f * channelQ.DC, MidpointRounding.AwayFromZero) << 12)
+ |
+ ((int)Math.Round(31.0f * channelL.Scale, MidpointRounding.AwayFromZero) << 18)
+ |
+ (hasAlpha ? 1 << 23 : 0);
+
+ int header16 =
+ (isLandscape ? ly : lx)
+ |
+ ((int)Math.Round(63.0f * channelP.Scale, MidpointRounding.AwayFromZero) << 3)
+ |
+ ((int)Math.Round(63.0f * channelQ.Scale, MidpointRounding.AwayFromZero) << 9)
+ |
+ (isLandscape ? 1 << 15 : 0);
+
+ int acStart = hasAlpha ? 6 : 5;
+
+ int acCount = channelL.AC.Length + channelP.AC.Length + channelQ.AC.Length + (hasAlpha ? channelA!.AC.Length : 0);
+
+ byte[] hash = new byte[acStart + (acCount + 1) / 2];
+
+ hash[0] = (byte)header24;
+ hash[1] = (byte)(header24 >> 8);
+ hash[2] = (byte)(header24 >> 16);
+ hash[3] = (byte)header16;
+ hash[4] = (byte)(header16 >> 8);
+
+ if (hasAlpha)
+ {
+ hash[5] = (byte)((int)Math.Round(15.0f * channelA!.DC) | ((int)Math.Round(15.0f * channelA.Scale, MidpointRounding.AwayFromZero) << 4));
+ }
+
+ int acIndex = 0;
+
+ acIndex = channelL.WriteTo(hash, acStart, acIndex);
+ acIndex = channelP.WriteTo(hash, acStart, acIndex);
+ acIndex = channelQ.WriteTo(hash, acStart, acIndex);
+
+ if (hasAlpha)
+ {
+ channelA!.WriteTo(hash, acStart, acIndex);
+ }
+
+ return hash;
+ }
+ finally
+ {
+ ArrayPool.Shared.Return(lpqaBuffer);
+ }
+ }
+
+ ///
+ /// Decodes a ThumbHash into a "data:" URL string, ready to be used as an src attribute value in an <img> element.
+ ///
+ /// ThumbHash bytes to convert into a data URL.
+ /// Data URL, ready to be used as an src attribute value in an <img> element. E.g. data:image/png;base64,iVBORw0KGgoAAAAN [etc...]
+ public static string ThumbHashToDataUrl(ReadOnlySpan hash)
+ {
+ (int w, int h, byte[] rgba) = ThumbHashToRgba(hash);
+
+ return RgbaToDataUrl(w, h, rgba);
+ }
+
+ ///
+ /// Decodes a ThumbHash to an RGBA image. RGB is not to be premultiplied by A.
+ ///
+ /// The bytes of the ThumbHash.
+ /// The width, height, and pixels (rgba) of the rendered placeholder image.
+ public static(int, int, byte[]) ThumbHashToRgba(ReadOnlySpan hash)
+ {
+ // Read the constants.
+ int header24 = (hash[0] & 255) | ((hash[1] & 255) << 8) | ((hash[2] & 255) << 16);
+ int header16 = (hash[3] & 255) | ((hash[4] & 255) << 8);
+ float l_dc = (header24 & 63) / 63.0f;
+ float p_dc = ((header24 >> 6) & 63) / 31.5f - 1.0f;
+ float q_dc = ((header24 >> 12) & 63) / 31.5f - 1.0f;
+ float l_scale = ((header24 >> 18) & 31) / 31.0f;
+ bool hasAlpha = (header24 >> 23) != 0;
+ float p_scale = ((header16 >> 3) & 63) / 63.0f;
+ float q_scale = ((header16 >> 9) & 63) / 63.0f;
+ bool isLandscape = (header16 >> 15) != 0;
+ int lx = Math.Max(3, isLandscape ? hasAlpha ? 5 : 7 : header16 & 7);
+ int ly = Math.Max(3, isLandscape ? header16 & 7 : hasAlpha ? 5 : 7);
+ float a_dc = hasAlpha ? (hash[5] & 15) / 15.0f : 1.0f;
+ float a_scale = ((hash[5] >> 4) & 15) / 15.0f;
+
+ // Read the varying factors (boost saturation by 1.25x to compensate for quantization).
+ int ac_start = hasAlpha ? 6 : 5;
+ int ac_index = 0;
+
+ Channel l_channel = new Channel(lx, ly);
+ Channel p_channel = new Channel(3, 3);
+ Channel q_channel = new Channel(3, 3);
+ Channel? a_channel = null;
+
+ ac_index = l_channel.Decode(hash, ac_start, ac_index, l_scale);
+ ac_index = p_channel.Decode(hash, ac_start, ac_index, p_scale * 1.25f);
+ ac_index = q_channel.Decode(hash, ac_start, ac_index, q_scale * 1.25f);
+
+ if (hasAlpha)
+ {
+ a_channel = new Channel(5, 5);
+ a_channel.Decode(hash, ac_start, ac_index, a_scale);
+ }
+
+ float[] l_ac = l_channel.AC;
+ float[] p_ac = p_channel.AC;
+ float[] q_ac = q_channel.AC;
+ float[]? a_ac = hasAlpha ? a_channel!.AC : null;
+
+ // Decode using the DCT into RGB.
+ float ratio = ThumbHashToApproximateAspectRatio(hash);
+
+ int w = (int)Math.Round(ratio > 1.0f ? 32.0f : 32.0f * ratio, MidpointRounding.AwayFromZero);
+ int h = (int)Math.Round(ratio > 1.0f ? 32.0f / ratio : 32.0f, MidpointRounding.AwayFromZero);
+
+ byte[] rgba = new byte[w * h * 4];
+
+ int cx_stop = Math.Max(lx, hasAlpha ? 5 : 3);
+ int cy_stop = Math.Max(ly, hasAlpha ? 5 : 3);
+
+ Span fx = stackalloc float[cx_stop];
+ Span fy = stackalloc float[cy_stop];
+
+ for (int y = 0, i = 0; y < h; ++y)
+ {
+ for (int x = 0; x < w; ++x, i += 4)
+ {
+ float l = l_dc, p = p_dc, q = q_dc, a = a_dc;
+
+ // Precompute the coefficients
+ for (int cx = 0; cx < cx_stop; ++cx)
+ {
+ fx[cx] = (float)Math.Cos(Math.PI / w * (x + 0.5f) * cx);
+ }
+
+ for (int cy = 0; cy < cy_stop; ++cy)
+ {
+ fy[cy] = (float)Math.Cos(Math.PI / h * (y + 0.5f) * cy);
+ }
+
+ // Decode L
+ for (int cy = 0, j = 0; cy < ly; ++cy)
+ {
+ float fy2 = fy[cy] * 2.0f;
+ for (int cx = cy > 0 ? 0 : 1; cx * ly < lx * (ly - cy); ++cx, ++j)
+ {
+ l += l_ac[j] * fx[cx] * fy2;
+ }
+ }
+
+ // Decode P and Q.
+ for (int cy = 0, j = 0; cy < 3; ++cy)
+ {
+ float fy2 = fy[cy] * 2.0f;
+
+ for (int cx = cy > 0 ? 0 : 1; cx < 3 - cy; ++cx, ++j)
+ {
+ float f = fx[cx] * fy2;
+ p += p_ac[j] * f;
+ q += q_ac[j] * f;
+ }
+ }
+
+ // Decode A.
+ if (hasAlpha)
+ {
+ for (int cy = 0, j = 0; cy < 5; ++cy)
+ {
+ float fy2 = fy[cy] * 2.0f;
+ for (int cx = cy > 0 ? 0 : 1; cx < 5 - cy; ++cx, ++j)
+ {
+ a += a_ac![j] * fx[cx] * fy2;
+ }
+ }
+ }
+
+ // Convert to RGB.
+ float b = l - 2.0f / 3.0f * p;
+ float r = (3.0f * l - b + q) / 2.0f;
+ float g = r - q;
+
+ rgba[i] = (byte)Math.Max(0, Math.Round(255.0f * Math.Min(1, r), MidpointRounding.AwayFromZero));
+ rgba[i + 1] = (byte)Math.Max(0, Math.Round(255.0f * Math.Min(1, g), MidpointRounding.AwayFromZero));
+ rgba[i + 2] = (byte)Math.Max(0, Math.Round(255.0f * Math.Min(1, b), MidpointRounding.AwayFromZero));
+ rgba[i + 3] = (byte)Math.Max(0, Math.Round(255.0f * Math.Min(1, a), MidpointRounding.AwayFromZero));
+ }
+ }
+
+ return (w, h, rgba);
+ }
+
+ ///
+ /// Extracts the average color from a ThumbHash. RGB is not to be premultiplied by A.
+ ///
+ /// ThumbHash bytes.
+ /// A tuple containing the RGBA values for the average color. Each value ranges from 0 to 1.
+ public static(float, float, float, float) ThumbHashToAverageRgba(ReadOnlySpan hash)
+ {
+ int header = (hash[0] & 255) | ((hash[1] & 255) << 8) | ((hash[2] & 255) << 16);
+ float l = (header & 63) / 63.0f;
+ float p = ((header >> 6) & 63) / 31.5f - 1.0f;
+ float q = ((header >> 12) & 63) / 31.5f - 1.0f;
+ bool hasAlpha = header >> 23 != 0;
+ float a = hasAlpha ? (hash[5] & 15) / 15.0f : 1.0f;
+ float b = l - 2.0f / 3.0f * p;
+ float r = (3.0f * l - b + q) / 2.0f;
+ float g = r - q;
+
+ return
+ (
+ Math.Max(0.0f, Math.Min(1.0f, r)),
+ Math.Max(0.0f, Math.Min(1.0f, g)),
+ Math.Max(0.0f, Math.Min(1.0f, b)),
+ a
+ );
+ }
+
+ ///
+ /// Extracts the approximate aspect ratio of the original image.
+ ///
+ /// The bytes of the ThumbHash.
+ /// The approximate aspect ratio (width / height).
+ public static float ThumbHashToApproximateAspectRatio(ReadOnlySpan hash)
+ {
+ byte header = hash[3];
+ bool hasAlpha = (hash[2] & 0x80) != 0;
+ bool isLandscape = (hash[4] & 0x80) != 0;
+
+ int lx = isLandscape ? hasAlpha ? 5 : 7 : header & 7;
+ int ly = isLandscape ? header & 7 : hasAlpha ? 5 : 7;
+
+ return lx / (float)ly;
+ }
+
+ ///
+ /// Converts an image of x RGBA pixels into a "data:" URL string, ready to be used in an <img> tag as the value for the src attribute.
+ ///
+ /// The width of the input image. Must be ≤100px.
+ /// The height of the input image. Must be ≤100px.
+ /// The pixels in the input image, row-by-row. Must have w*h*4 elements.
+ /// Data URL, ready to be used as an src attribute value in an <img> element. E.g. data:image/png;base64,iVBORw0KGgoAAAAN [etc...]
+ public static string RgbaToDataUrl(int w, int h, ReadOnlySpan rgba)
+ {
+ int rowDataLength = w * 4;
+ int rowWithFilter = rowDataLength + 1;
+ int idatBodyLength = 6 + h * (5 + rowWithFilter);
+ int totalSize = 57 + idatBodyLength;
+
+ byte[] buffer = ArrayPool.Shared.Rent(totalSize);
+
+ Span span = buffer.AsSpan(0, totalSize);
+
+ void WriteCrc(ReadOnlySpan data, Span crcTarget)
+ {
+ uint c = 0xFFFFFFFF;
+
+ foreach (byte b in data)
+ {
+ c ^= b;
+ c = (c >> 4) ^ CRC_TABLE[c & 15];
+ c = (c >> 4) ^ CRC_TABLE[c & 15];
+ }
+
+ BinaryPrimitives.WriteUInt32BigEndian(crcTarget, ~c);
+ }
+
+ try
+ {
+ SIGNATURE.CopyTo(span);
+
+ BinaryPrimitives.WriteInt32BigEndian(span[8..], 13);
+
+ "IHDR"u8.CopyTo(span[12..]);
+
+ BinaryPrimitives.WriteInt32BigEndian(span[16..], w);
+ BinaryPrimitives.WriteInt32BigEndian(span[20..], h);
+
+ span[24] = 8;
+ span[25] = 6;
+ span.Slice(26, 3).Clear();
+
+ WriteCrc(span.Slice(12, 17), span.Slice(29, 4));
+
+ BinaryPrimitives.WriteInt32BigEndian(span[33..], idatBodyLength);
+
+ "IDAT"u8.CopyTo(span[37..]);
+
+ int p = 41;
+
+ span[p++] = 0x78;
+ span[p++] = 0x01;
+
+ int a = 1, b = 0;
+ int rgbaIndex = 0;
+
+ for (int y = 0; y < h; ++y)
+ {
+ span[p++] = (byte)(y + 1 < h ? 0 : 1);
+
+ BinaryPrimitives.WriteUInt16LittleEndian(span[p..], (ushort)rowWithFilter);
+ BinaryPrimitives.WriteUInt16LittleEndian(span[(p + 2)..], (ushort)~rowWithFilter);
+
+ p += 4;
+
+ span[p++] = 0;
+
+ b = (b + a) % 65521;
+
+ for (int x = 0; x < rowDataLength; ++x)
+ {
+ byte u = rgba[rgbaIndex++];
+ span[p++] = u;
+ a = (a + u) % 65521;
+ b = (b + a) % 65521;
+ }
+ }
+
+ BinaryPrimitives.WriteInt16BigEndian(span[p..], (short)b);
+ BinaryPrimitives.WriteInt16BigEndian(span[(p + 2)..], (short)a);
+ p += 4;
+
+ WriteCrc(span.Slice(37, idatBodyLength + 4), span.Slice(p, 4));
+ p += 4;
+
+ BinaryPrimitives.WriteInt32BigEndian(span[p..], 0);
+ "IEND"u8.CopyTo(span[(p + 4)..]);
+
+ BinaryPrimitives.WriteUInt32BigEndian(span[(p + 8)..], 0xAE426082);
+
+ StringBuilder sb = new(32 + (int)Math.Ceiling(span.Length / 3.0) * 4);
+
+ sb.Append("data:image/png;base64,");
+ sb.Append(Convert.ToBase64String(span));
+
+ return sb.ToString();
+ }
+ finally
+ {
+ ArrayPool.Shared.Return(buffer);
+ }
+ }
+
+ private class Channel
+ {
+ private readonly int nx;
+ private readonly int ny;
+
+ public float Scale { get; private set; }
+ public float DC { get; private set; }
+ public float[] AC { get; }
+
+ public Channel(int nx, int ny)
+ {
+ this.nx = nx;
+ this.ny = ny;
+ int n = 0;
+
+ for (int cy = 0; cy < ny; ++cy)
+ {
+ for (int cx = cy > 0 ? 0 : 1; cx * ny < nx * (ny - cy); ++cx)
+ {
+ ++n;
+ }
+ }
+
+ AC = new float[n];
+ }
+
+ public Channel Encode(int w, int h, ReadOnlySpan channel)
+ {
+ int n = 0;
+ float[] fx = new float[w];
+
+ for (int cy = 0; cy < ny; ++cy)
+ {
+ for (int cx = 0; cx * ny < nx * (ny - cy); ++cx)
+ {
+ float f = 0;
+
+ for (int x = 0; x < w; ++x)
+ {
+ fx[x] = (float)Math.Cos(Math.PI / w * cx * (x + 0.5f));
+ }
+
+ for (int y = 0; y < h; ++y)
+ {
+ float fy = (float)Math.Cos(Math.PI / h * cy * (y + 0.5f));
+
+ for (int x = 0; x < w; ++x)
+ {
+ f += channel[x + y * w] * fx[x] * fy;
+ }
+ }
+
+ f /= w * h;
+
+ if (cx > 0 || cy > 0)
+ {
+ AC[n++] = f;
+ Scale = Math.Max(Scale, Math.Abs(f));
+ }
+ else
+ {
+ DC = f;
+ }
+ }
+ }
+
+ if (Scale > 0)
+ {
+ for (int i = 0; i < AC.Length; ++i)
+ {
+ AC[i] = 0.5f + 0.5f / Scale * AC[i];
+ }
+ }
+
+ return this;
+ }
+
+ public int Decode(ReadOnlySpan hash, int start, int index, float scale)
+ {
+ for (int i = 0; i < AC.Length; ++i)
+ {
+ int data = hash[start + (index >> 1)] >> ((index & 1) << 2);
+ AC[i] = ((data & 15) / 7.5f - 1.0f) * scale;
+ ++index;
+ }
+
+ return index;
+ }
+
+ public int WriteTo(Span hash, int start, int index)
+ {
+ foreach (float v in AC)
+ {
+ hash[start + (index >> 1)] |= (byte)((int)Math.Round(15.0f * v, MidpointRounding.AwayFromZero) << ((index & 1) << 2));
+ ++index;
+ }
+
+ return index;
+ }
+ }
+
+ private static readonly uint[] CRC_TABLE =
+ [
+ 0,
+ 498536548,
+ 997073096,
+ 651767980,
+ 1994146192,
+ 1802195444,
+ 1303535960,
+ 1342533948,
+ 3988292384,
+ 4027552580,
+ 3604390888,
+ 3412177804,
+ 2607071920,
+ 2262029012,
+ 2685067896,
+ 3183342108
+ ];
+
+ private static readonly byte[] SIGNATURE =
+ [
+ 137,
+ 80,
+ 78,
+ 71,
+ 13,
+ 10,
+ 26,
+ 10
+ ];
+}
From da9b8cded6c5b32ae48ff4baa6212d06c09f9972 Mon Sep 17 00:00:00 2001
From: Raphael Beck
Date: Sat, 9 May 2026 00:05:13 +0200
Subject: [PATCH 2/5] Added more memory-friendly overload that makes use of
Span.
---
csharp/ThumbHash.cs | 95 ++++++++++++++++++++++++++++-----------------
1 file changed, 60 insertions(+), 35 deletions(-)
diff --git a/csharp/ThumbHash.cs b/csharp/ThumbHash.cs
index 9022358..856865f 100644
--- a/csharp/ThumbHash.cs
+++ b/csharp/ThumbHash.cs
@@ -16,8 +16,32 @@ public static class ThumbHash
/// RGB should not be premultiplied by A.
/// ThumbHash bytes.
/// Thrown if or is greater than 100.
- public static byte[] RgbaToThumbHash(int w, int h, ReadOnlySpan rgba)
+ public static Span RgbaToThumbHash(int w, int h, ReadOnlySpan rgba)
{
+ byte[] thumbHashBuffer = new byte[32];
+
+ int n = RgbaToThumbHash(w, h, rgba, thumbHashBuffer);
+
+ return thumbHashBuffer.AsSpan(0, n);
+ }
+
+ ///
+ /// Encodes an RGBA image to a ThumbHash.
+ ///
+ /// The width of the input image. Must be ≤100px.
+ /// The height of the input image. Must be ≤100px.
+ /// The pixels in the input image, row-by-row. Must have w*h*4 elements.
+ /// Output byte buffer of at least 32B in size into which the ThumbHash should be written.
+ /// RGB should not be premultiplied by A.
+ /// Number of ThumbHash bytes written into .
+ /// Thrown if or is greater than 100 or the buffer size is insufficient (must be at least 32 bytes).
+ public static int RgbaToThumbHash(int w, int h, ReadOnlySpan rgba, Span output)
+ {
+ if (output.Length < 32)
+ {
+ throw new ArgumentException("Output buffer is too small. Please allocate at least 32 bytes of storage!", nameof(output));
+ }
+
// Encoding an image larger than 100x100 is slow with no benefit.
if (w > 100 || h > 100)
{
@@ -86,54 +110,54 @@ public static byte[] RgbaToThumbHash(int w, int h, ReadOnlySpan rgba)
bool isLandscape = w > h;
int header24 =
- (int)Math.Round(63.0f * channelL.DC, MidpointRounding.AwayFromZero)
- |
- ((int)Math.Round(31.5f + 31.5f * channelP.DC, MidpointRounding.AwayFromZero) << 6)
- |
- ((int)Math.Round(31.5f + 31.5f * channelQ.DC, MidpointRounding.AwayFromZero) << 12)
- |
- ((int)Math.Round(31.0f * channelL.Scale, MidpointRounding.AwayFromZero) << 18)
- |
- (hasAlpha ? 1 << 23 : 0);
+ (int)Math.Round(63.0f * channelL.DC, MidpointRounding.AwayFromZero)
+ |
+ ((int)Math.Round(31.5f + 31.5f * channelP.DC, MidpointRounding.AwayFromZero) << 6)
+ |
+ ((int)Math.Round(31.5f + 31.5f * channelQ.DC, MidpointRounding.AwayFromZero) << 12)
+ |
+ ((int)Math.Round(31.0f * channelL.Scale, MidpointRounding.AwayFromZero) << 18)
+ |
+ (hasAlpha ? 1 << 23 : 0);
int header16 =
- (isLandscape ? ly : lx)
- |
- ((int)Math.Round(63.0f * channelP.Scale, MidpointRounding.AwayFromZero) << 3)
- |
- ((int)Math.Round(63.0f * channelQ.Scale, MidpointRounding.AwayFromZero) << 9)
- |
- (isLandscape ? 1 << 15 : 0);
+ (isLandscape ? ly : lx)
+ |
+ ((int)Math.Round(63.0f * channelP.Scale, MidpointRounding.AwayFromZero) << 3)
+ |
+ ((int)Math.Round(63.0f * channelQ.Scale, MidpointRounding.AwayFromZero) << 9)
+ |
+ (isLandscape ? 1 << 15 : 0);
int acStart = hasAlpha ? 6 : 5;
int acCount = channelL.AC.Length + channelP.AC.Length + channelQ.AC.Length + (hasAlpha ? channelA!.AC.Length : 0);
- byte[] hash = new byte[acStart + (acCount + 1) / 2];
+ int outputSize = acStart + (acCount + 1) / 2;
- hash[0] = (byte)header24;
- hash[1] = (byte)(header24 >> 8);
- hash[2] = (byte)(header24 >> 16);
- hash[3] = (byte)header16;
- hash[4] = (byte)(header16 >> 8);
+ output[0] = (byte)header24;
+ output[1] = (byte)(header24 >> 8);
+ output[2] = (byte)(header24 >> 16);
+ output[3] = (byte)header16;
+ output[4] = (byte)(header16 >> 8);
if (hasAlpha)
{
- hash[5] = (byte)((int)Math.Round(15.0f * channelA!.DC) | ((int)Math.Round(15.0f * channelA.Scale, MidpointRounding.AwayFromZero) << 4));
+ output[5] = (byte)((int)Math.Round(15.0f * channelA!.DC) | ((int)Math.Round(15.0f * channelA.Scale, MidpointRounding.AwayFromZero) << 4));
}
int acIndex = 0;
- acIndex = channelL.WriteTo(hash, acStart, acIndex);
- acIndex = channelP.WriteTo(hash, acStart, acIndex);
- acIndex = channelQ.WriteTo(hash, acStart, acIndex);
+ acIndex = channelL.WriteTo(output, acStart, acIndex);
+ acIndex = channelP.WriteTo(output, acStart, acIndex);
+ acIndex = channelQ.WriteTo(output, acStart, acIndex);
if (hasAlpha)
{
- channelA!.WriteTo(hash, acStart, acIndex);
+ channelA!.WriteTo(output, acStart, acIndex);
}
- return hash;
+ return outputSize;
}
finally
{
@@ -142,10 +166,10 @@ public static byte[] RgbaToThumbHash(int w, int h, ReadOnlySpan rgba)
}
///
- /// Decodes a ThumbHash into a "data:" URL string, ready to be used as an src attribute value in an <img> element.
+ /// Decodes a ThumbHash into a "data:" URL string, ready to be used as an src attribute value in an <img> element.
///
/// ThumbHash bytes to convert into a data URL.
- /// Data URL, ready to be used as an src attribute value in an <img> element. E.g. data:image/png;base64,iVBORw0KGgoAAAAN [etc...]
+ /// Data URL, ready to be used as an src attribute value in an <img> element. E.g. data:image/png;base64,iVBORw0KGgoAAAAN [etc...].
public static string ThumbHashToDataUrl(ReadOnlySpan hash)
{
(int w, int h, byte[] rgba) = ThumbHashToRgba(hash);
@@ -302,9 +326,9 @@ public static(float, float, float, float) ThumbHashToAverageRgba(ReadOnlySpan hash)
/// The width of the input image. Must be ≤100px.
/// The height of the input image. Must be ≤100px.
/// The pixels in the input image, row-by-row. Must have w*h*4 elements.
- /// Data URL, ready to be used as an src attribute value in an <img> element. E.g. data:image/png;base64,iVBORw0KGgoAAAAN [etc...]
+ /// Data URL, ready to be used as an src attribute value in an <img> element. E.g. data:image/png;base64,iVBORw0KGgoAAAAN [etc...].
public static string RgbaToDataUrl(int w, int h, ReadOnlySpan rgba)
{
int rowDataLength = w * 4;
@@ -446,6 +470,7 @@ public Channel(int nx, int ny)
{
this.nx = nx;
this.ny = ny;
+
int n = 0;
for (int cy = 0; cy < ny; ++cy)
From 76858373c5673a2b003f2702a0c56456c45466ff Mon Sep 17 00:00:00 2001
From: Glitched Polygons GmbH
<37942667+GlitchedPolygons@users.noreply.github.com>
Date: Sun, 10 May 2026 20:26:17 +0200
Subject: [PATCH 3/5] Update ThumbHash.cs
---
csharp/ThumbHash.cs | 69 +++++++++++++++++++++++++--------------------
1 file changed, 38 insertions(+), 31 deletions(-)
diff --git a/csharp/ThumbHash.cs b/csharp/ThumbHash.cs
index 856865f..44f96a8 100644
--- a/csharp/ThumbHash.cs
+++ b/csharp/ThumbHash.cs
@@ -5,7 +5,7 @@
///
/// ThumbHash .NET implementation based on https://github.com/evanw/thumbhash
///
-public static class ThumbHash
+public static class ThumbHashConvert
{
///
/// Encodes an RGBA image to a ThumbHash.
@@ -16,11 +16,11 @@ public static class ThumbHash
/// RGB should not be premultiplied by A.
/// ThumbHash bytes.
/// Thrown if or is greater than 100.
- public static Span RgbaToThumbHash(int w, int h, ReadOnlySpan rgba)
+ public static Span FromRgba(int w, int h, ReadOnlySpan rgba)
{
byte[] thumbHashBuffer = new byte[32];
- int n = RgbaToThumbHash(w, h, rgba, thumbHashBuffer);
+ int n = FromRgba(w, h, rgba, thumbHashBuffer);
return thumbHashBuffer.AsSpan(0, n);
}
@@ -35,7 +35,7 @@ public static Span RgbaToThumbHash(int w, int h, ReadOnlySpan rgba)
/// RGB should not be premultiplied by A.
/// Number of ThumbHash bytes written into .
/// Thrown if or is greater than 100 or the buffer size is insufficient (must be at least 32 bytes).
- public static int RgbaToThumbHash(int w, int h, ReadOnlySpan rgba, Span output)
+ public static int FromRgba(int w, int h, ReadOnlySpan rgba, Span output)
{
if (output.Length < 32)
{
@@ -110,24 +110,24 @@ public static int RgbaToThumbHash(int w, int h, ReadOnlySpan rgba, Span h;
int header24 =
- (int)Math.Round(63.0f * channelL.DC, MidpointRounding.AwayFromZero)
- |
- ((int)Math.Round(31.5f + 31.5f * channelP.DC, MidpointRounding.AwayFromZero) << 6)
- |
- ((int)Math.Round(31.5f + 31.5f * channelQ.DC, MidpointRounding.AwayFromZero) << 12)
- |
- ((int)Math.Round(31.0f * channelL.Scale, MidpointRounding.AwayFromZero) << 18)
- |
- (hasAlpha ? 1 << 23 : 0);
+ (int)Math.Round(63.0f * channelL.DC, MidpointRounding.AwayFromZero)
+ |
+ ((int)Math.Round(31.5f + 31.5f * channelP.DC, MidpointRounding.AwayFromZero) << 6)
+ |
+ ((int)Math.Round(31.5f + 31.5f * channelQ.DC, MidpointRounding.AwayFromZero) << 12)
+ |
+ ((int)Math.Round(31.0f * channelL.Scale, MidpointRounding.AwayFromZero) << 18)
+ |
+ (hasAlpha ? 1 << 23 : 0);
int header16 =
- (isLandscape ? ly : lx)
- |
- ((int)Math.Round(63.0f * channelP.Scale, MidpointRounding.AwayFromZero) << 3)
- |
- ((int)Math.Round(63.0f * channelQ.Scale, MidpointRounding.AwayFromZero) << 9)
- |
- (isLandscape ? 1 << 15 : 0);
+ (isLandscape ? ly : lx)
+ |
+ ((int)Math.Round(63.0f * channelP.Scale, MidpointRounding.AwayFromZero) << 3)
+ |
+ ((int)Math.Round(63.0f * channelQ.Scale, MidpointRounding.AwayFromZero) << 9)
+ |
+ (isLandscape ? 1 << 15 : 0);
int acStart = hasAlpha ? 6 : 5;
@@ -166,13 +166,14 @@ public static int RgbaToThumbHash(int w, int h, ReadOnlySpan rgba, Span
- /// Decodes a ThumbHash into a "data:" URL string, ready to be used as an src attribute value in an <img> element.
+ /// Decodes a ThumbHash into a "data:" URL string, ready to be used as an src attribute value in an <img> element.
///
/// ThumbHash bytes to convert into a data URL.
/// Data URL, ready to be used as an src attribute value in an <img> element. E.g. data:image/png;base64,iVBORw0KGgoAAAAN [etc...].
- public static string ThumbHashToDataUrl(ReadOnlySpan hash)
+ /// Thrown if the passed is empty.
+ public static string ToDataUrl(ReadOnlySpan hash)
{
- (int w, int h, byte[] rgba) = ThumbHashToRgba(hash);
+ (int w, int h, byte[] rgba) = ToRgba(hash);
return RgbaToDataUrl(w, h, rgba);
}
@@ -182,8 +183,14 @@ public static string ThumbHashToDataUrl(ReadOnlySpan hash)
///
/// The bytes of the ThumbHash.
/// The width, height, and pixels (rgba) of the rendered placeholder image.
- public static(int, int, byte[]) ThumbHashToRgba(ReadOnlySpan hash)
+ /// Thrown if the passed is empty.
+ public static(int, int, byte[]) ToRgba(ReadOnlySpan hash)
{
+ if (hash.IsEmpty)
+ {
+ throw new ArgumentException("The passed ThumbHash is empty.", nameof(hash));
+ }
+
// Read the constants.
int header24 = (hash[0] & 255) | ((hash[1] & 255) << 8) | ((hash[2] & 255) << 16);
int header16 = (hash[3] & 255) | ((hash[4] & 255) << 8);
@@ -225,7 +232,7 @@ public static(int, int, byte[]) ThumbHashToRgba(ReadOnlySpan hash)
float[]? a_ac = hasAlpha ? a_channel!.AC : null;
// Decode using the DCT into RGB.
- float ratio = ThumbHashToApproximateAspectRatio(hash);
+ float ratio = ToApproximateAspectRatio(hash);
int w = (int)Math.Round(ratio > 1.0f ? 32.0f : 32.0f * ratio, MidpointRounding.AwayFromZero);
int h = (int)Math.Round(ratio > 1.0f ? 32.0f / ratio : 32.0f, MidpointRounding.AwayFromZero);
@@ -311,7 +318,7 @@ public static(int, int, byte[]) ThumbHashToRgba(ReadOnlySpan hash)
///
/// ThumbHash bytes.
/// A tuple containing the RGBA values for the average color. Each value ranges from 0 to 1.
- public static(float, float, float, float) ThumbHashToAverageRgba(ReadOnlySpan hash)
+ public static(float, float, float, float) ToAverageRgba(ReadOnlySpan hash)
{
int header = (hash[0] & 255) | ((hash[1] & 255) << 8) | ((hash[2] & 255) << 16);
float l = (header & 63) / 63.0f;
@@ -326,9 +333,9 @@ public static(float, float, float, float) ThumbHashToAverageRgba(ReadOnlySpan
/// The bytes of the ThumbHash.
/// The approximate aspect ratio (width / height).
- public static float ThumbHashToApproximateAspectRatio(ReadOnlySpan hash)
+ public static float ToApproximateAspectRatio(ReadOnlySpan hash)
{
byte header = hash[3];
bool hasAlpha = (hash[2] & 0x80) != 0;
@@ -354,7 +361,7 @@ public static float ThumbHashToApproximateAspectRatio(ReadOnlySpan hash)
///
/// The width of the input image. Must be ≤100px.
/// The height of the input image. Must be ≤100px.
- /// The pixels in the input image, row-by-row. Must have w*h*4 elements.
+ /// The pixels in the input image, row-by-row. Must have w*h*4 elements.
/// Data URL, ready to be used as an src attribute value in an <img> element. E.g. data:image/png;base64,iVBORw0KGgoAAAAN [etc...].
public static string RgbaToDataUrl(int w, int h, ReadOnlySpan rgba)
{
From 318cb08dbb2a00233e3c0c9a445eeb99aea30835 Mon Sep 17 00:00:00 2001
From: Glitched Polygons GmbH
<37942667+GlitchedPolygons@users.noreply.github.com>
Date: Sun, 10 May 2026 23:43:02 +0000
Subject: [PATCH 4/5] Update ThumbHash.cs
---
csharp/ThumbHash.cs | 209 +++++++++++++++++++++++++++++++++-----------
1 file changed, 156 insertions(+), 53 deletions(-)
diff --git a/csharp/ThumbHash.cs b/csharp/ThumbHash.cs
index 44f96a8..c638481 100644
--- a/csharp/ThumbHash.cs
+++ b/csharp/ThumbHash.cs
@@ -1,6 +1,7 @@
using System.Text;
using System.Buffers;
using System.Buffers.Binary;
+using System.IO.Compression;
///
/// ThumbHash .NET implementation based on https://github.com/evanw/thumbhash
@@ -190,7 +191,7 @@ public static(int, int, byte[]) ToRgba(ReadOnlySpan hash)
{
throw new ArgumentException("The passed ThumbHash is empty.", nameof(hash));
}
-
+
// Read the constants.
int header24 = (hash[0] & 255) | ((hash[1] & 255) << 8) | ((hash[2] & 255) << 16);
int header16 = (hash[3] & 255) | ((hash[4] & 255) << 8);
@@ -357,24 +358,21 @@ public static float ToApproximateAspectRatio(ReadOnlySpan hash)
}
///
- /// Converts an image of x RGBA pixels into a "data:" URL string, ready to be used in an <img> tag as the value for the src attribute.
+ /// Converts an image of x into PNG-encoded RGBA pixels.
///
/// The width of the input image. Must be ≤100px.
/// The height of the input image. Must be ≤100px.
/// The pixels in the input image, row-by-row. Must have w*h*4 elements.
- /// Data URL, ready to be used as an src attribute value in an <img> element. E.g. data:image/png;base64,iVBORw0KGgoAAAAN [etc...].
- public static string RgbaToDataUrl(int w, int h, ReadOnlySpan rgba)
+ /// Whether the output PNG should be losslessly compressed or just raw, uncompressed, PNG-encoded RGBA pixels.
+ /// PNG bytes.
+ public static byte[] RgbaToPngBytes(int w, int h, ReadOnlySpan rgba, bool compress = true)
{
int rowDataLength = w * 4;
int rowWithFilter = rowDataLength + 1;
int idatBodyLength = 6 + h * (5 + rowWithFilter);
int totalSize = 57 + idatBodyLength;
- byte[] buffer = ArrayPool.Shared.Rent(totalSize);
-
- Span span = buffer.AsSpan(0, totalSize);
-
- void WriteCrc(ReadOnlySpan data, Span crcTarget)
+ void WriteCrc(Span crcTarget, ReadOnlySpan data, ReadOnlySpan additionalData)
{
uint c = 0xFFFFFFFF;
@@ -385,83 +383,188 @@ void WriteCrc(ReadOnlySpan data, Span crcTarget)
c = (c >> 4) ^ CRC_TABLE[c & 15];
}
+ foreach (byte b in additionalData)
+ {
+ c ^= b;
+ c = (c >> 4) ^ CRC_TABLE[c & 15];
+ c = (c >> 4) ^ CRC_TABLE[c & 15];
+ }
+
BinaryPrimitives.WriteUInt32BigEndian(crcTarget, ~c);
}
- try
+ if (compress)
{
- SIGNATURE.CopyTo(span);
+ using MemoryStream pngStream = new(totalSize);
- BinaryPrimitives.WriteInt32BigEndian(span[8..], 13);
+ pngStream.Write(SIGNATURE);
- "IHDR"u8.CopyTo(span[12..]);
+ Span ihdr = stackalloc byte[13];
- BinaryPrimitives.WriteInt32BigEndian(span[16..], w);
- BinaryPrimitives.WriteInt32BigEndian(span[20..], h);
+ BinaryPrimitives.WriteInt32BigEndian(ihdr[0..], w);
+ BinaryPrimitives.WriteInt32BigEndian(ihdr[4..], h);
- span[24] = 8;
- span[25] = 6;
- span.Slice(26, 3).Clear();
+ ihdr[08] = 8; // Bit depth
+ ihdr[09] = 6; // Color type (RGBA)
+ ihdr[10] = 0; // Compression
+ ihdr[11] = 0; // Filter
+ ihdr[12] = 0; // Interlace
- WriteCrc(span.Slice(12, 17), span.Slice(29, 4));
+ void WriteChunk(Stream outputStream, ReadOnlySpan type, ReadOnlySpan data)
+ {
+ Span lengthBuffer = stackalloc byte[4];
+ Span crcHash = stackalloc byte[4];
- BinaryPrimitives.WriteInt32BigEndian(span[33..], idatBodyLength);
+ BinaryPrimitives.WriteInt32BigEndian(lengthBuffer, data.Length);
- "IDAT"u8.CopyTo(span[37..]);
+ outputStream.Write(lengthBuffer);
+ outputStream.Write(type);
+ outputStream.Write(data);
- int p = 41;
+ WriteCrc(crcHash, type, data);
- span[p++] = 0x78;
- span[p++] = 0x01;
+ outputStream.Write(crcHash);
+ }
- int a = 1, b = 0;
- int rgbaIndex = 0;
+ WriteChunk(pngStream, "IHDR"u8, ihdr);
- for (int y = 0; y < h; ++y)
- {
- span[p++] = (byte)(y + 1 < h ? 0 : 1);
+ long lengthPosition = pngStream.Position;
- BinaryPrimitives.WriteUInt16LittleEndian(span[p..], (ushort)rowWithFilter);
- BinaryPrimitives.WriteUInt16LittleEndian(span[(p + 2)..], (ushort)~rowWithFilter);
+ pngStream.Write(stackalloc byte[4]);
- p += 4;
+ long typePosition = pngStream.Position;
- span[p++] = 0;
+ pngStream.Write("IDAT"u8);
- b = (b + a) % 65521;
+ long dataStart = pngStream.Position;
- for (int x = 0; x < rowDataLength; ++x)
+ // PNG requires a filter byte (0 for "None") at the start of every scanline.
+ using (ZLibStream zlib = new(pngStream, CompressionLevel.Optimal, true))
+ {
+ for (int y = 0; y < h; ++y)
{
- byte u = rgba[rgbaIndex++];
- span[p++] = u;
- a = (a + u) % 65521;
- b = (b + a) % 65521;
+ zlib.WriteByte(0); // Above mentioned required filter type: None.
+
+ zlib.Write(rgba.Slice(y * w * 4, w * 4));
}
}
- BinaryPrimitives.WriteInt16BigEndian(span[p..], (short)b);
- BinaryPrimitives.WriteInt16BigEndian(span[(p + 2)..], (short)a);
- p += 4;
+ long dataEnd = pngStream.Position;
- WriteCrc(span.Slice(37, idatBodyLength + 4), span.Slice(p, 4));
- p += 4;
+ long compressedSize = dataEnd - dataStart;
+
+ if (typePosition >= int.MaxValue || compressedSize >= int.MaxValue)
+ {
+ throw new OutOfMemoryException();
+ }
+
+ pngStream.Seek(lengthPosition, SeekOrigin.Begin);
+
+ BinaryPrimitives.WriteInt32BigEndian(ihdr[0..4], (int)compressedSize);
+
+ pngStream.Write(ihdr[0..4]);
+
+ pngStream.Seek(typePosition, SeekOrigin.Begin);
- BinaryPrimitives.WriteInt32BigEndian(span[p..], 0);
- "IEND"u8.CopyTo(span[(p + 4)..]);
+ Span crcHash = stackalloc byte[4];
- BinaryPrimitives.WriteUInt32BigEndian(span[(p + 8)..], 0xAE426082);
+ WriteCrc(crcHash, pngStream.GetBuffer().AsSpan((int)typePosition, (int)(dataEnd - typePosition)), []);
- StringBuilder sb = new(32 + (int)Math.Ceiling(span.Length / 3.0) * 4);
+ pngStream.Seek(dataEnd, SeekOrigin.Begin);
- sb.Append("data:image/png;base64,");
- sb.Append(Convert.ToBase64String(span));
+ pngStream.Write(crcHash);
- return sb.ToString();
+ WriteChunk(pngStream, "IEND"u8, ReadOnlySpan.Empty);
+
+ return pngStream.ToArray();
}
- finally
+
+ byte[] pngBytes = new byte[totalSize];
+
+ Span pngBytesSpan = pngBytes;
+
+ SIGNATURE.CopyTo(pngBytesSpan);
+
+ BinaryPrimitives.WriteInt32BigEndian(pngBytesSpan[8..], 13);
+
+ "IHDR"u8.CopyTo(pngBytesSpan[12..]);
+
+ BinaryPrimitives.WriteInt32BigEndian(pngBytesSpan[16..], w);
+ BinaryPrimitives.WriteInt32BigEndian(pngBytesSpan[20..], h);
+
+ pngBytesSpan[24] = 8;
+ pngBytesSpan[25] = 6;
+ pngBytesSpan.Slice(26, 3).Clear();
+
+ WriteCrc(pngBytesSpan.Slice(29, 4), pngBytesSpan.Slice(12, 17), []);
+
+ BinaryPrimitives.WriteInt32BigEndian(pngBytesSpan[33..], idatBodyLength);
+
+ "IDAT"u8.CopyTo(pngBytesSpan[37..]);
+
+ int p = 41;
+
+ pngBytesSpan[p++] = 0x78;
+ pngBytesSpan[p++] = 0x01;
+
+ int a = 1, b = 0;
+ int rgbaIndex = 0;
+
+ for (int y = 0; y < h; ++y)
{
- ArrayPool.Shared.Return(buffer);
+ pngBytesSpan[p++] = (byte)(y + 1 < h ? 0 : 1);
+
+ BinaryPrimitives.WriteUInt16LittleEndian(pngBytesSpan[p..], (ushort)rowWithFilter);
+ BinaryPrimitives.WriteUInt16LittleEndian(pngBytesSpan[(p + 2)..], (ushort)~rowWithFilter);
+
+ p += 4;
+
+ pngBytesSpan[p++] = 0;
+
+ b = (b + a) % 65521;
+
+ for (int x = 0; x < rowDataLength; ++x)
+ {
+ byte u = rgba[rgbaIndex++];
+ pngBytesSpan[p++] = u;
+ a = (a + u) % 65521;
+ b = (b + a) % 65521;
+ }
}
+
+ BinaryPrimitives.WriteInt16BigEndian(pngBytesSpan[p..], (short)b);
+ BinaryPrimitives.WriteInt16BigEndian(pngBytesSpan[(p + 2)..], (short)a);
+ p += 4;
+
+ WriteCrc(pngBytesSpan.Slice(p, 4), pngBytesSpan.Slice(37, idatBodyLength + 4), []);
+ p += 4;
+
+ BinaryPrimitives.WriteInt32BigEndian(pngBytesSpan[p..], 0);
+ "IEND"u8.CopyTo(pngBytesSpan[(p + 4)..]);
+
+ BinaryPrimitives.WriteUInt32BigEndian(pngBytesSpan[(p + 8)..], 0xAE426082);
+
+ return pngBytes;
+ }
+
+ ///
+ /// Converts an image of x RGBA pixels into a "data:" URL string, ready to be used in an <img> tag as the value for the src attribute.
+ ///
+ /// The width of the input image. Must be ≤100px.
+ /// The height of the input image. Must be ≤100px.
+ /// The pixels in the input image, row-by-row. Must have w*h*4 elements.
+ /// Whether the output data URL should be losslessly compressed PNG or just raw, uncompressed, PNG-encoded RGBA pixels.
+ /// Data URL, ready to be used as an src attribute value in an <img> element. E.g. data:image/png;base64,iVBORw0KGgoAAAAN [etc...].
+ public static string RgbaToDataUrl(int w, int h, ReadOnlySpan rgba, bool compress = true)
+ {
+ Span pngBytes = RgbaToPngBytes(w, h, rgba, compress);
+
+ StringBuilder sb = new(32 + (int)Math.Ceiling(pngBytes.Length / 3.0) * 4);
+
+ sb.Append("data:image/png;base64,");
+ sb.Append(Convert.ToBase64String(pngBytes));
+
+ return sb.ToString();
}
private class Channel
From e9a71498144c06be02e6afcedd268d2e9740b3ce Mon Sep 17 00:00:00 2001
From: Glitched Polygons GmbH
<37942667+GlitchedPolygons@users.noreply.github.com>
Date: Mon, 11 May 2026 00:21:17 +0000
Subject: [PATCH 5/5] Update ThumbHash.cs
---
csharp/ThumbHash.cs | 5 +++--
1 file changed, 3 insertions(+), 2 deletions(-)
diff --git a/csharp/ThumbHash.cs b/csharp/ThumbHash.cs
index c638481..05eae8e 100644
--- a/csharp/ThumbHash.cs
+++ b/csharp/ThumbHash.cs
@@ -170,13 +170,14 @@ public static int FromRgba(int w, int h, ReadOnlySpan rgba, Span out
/// Decodes a ThumbHash into a "data:" URL string, ready to be used as an src attribute value in an <img> element.
///
/// ThumbHash bytes to convert into a data URL.
+ /// Whether the output PNG should be losslessly compressed or just raw, uncompressed, PNG-encoded RGBA pixels.
/// Data URL, ready to be used as an src attribute value in an <img> element. E.g. data:image/png;base64,iVBORw0KGgoAAAAN [etc...].
/// Thrown if the passed is empty.
- public static string ToDataUrl(ReadOnlySpan hash)
+ public static string ToDataUrl(ReadOnlySpan hash, bool compress = true)
{
(int w, int h, byte[] rgba) = ToRgba(hash);
- return RgbaToDataUrl(w, h, rgba);
+ return RgbaToDataUrl(w, h, rgba, compress);
}
///