diff --git a/readme.md b/readme.md
index 976f5ba..5894838 100644
--- a/readme.md
+++ b/readme.md
@@ -10,8 +10,6 @@ Extends [Verify](https://github.com/VerifyTests/Verify) to allow verification of
**See [Milestones](../../milestones?state=closed) for release notes.**
Converts pdf documents to png for verification.
-This library uses [SixLabors ImageSharp](https://github.com/SixLabors/ImageSharp) for png generation. For commercial application support visit [SixLabors/Pricing](https://sixlabors.com/pricing/).
-
## Sponsors
@@ -46,7 +44,7 @@ public static void Initialize()
VerifyDocNet.Initialize();
VerifyImageMagick.RegisterComparers(
threshold: 0.13,
- ImageMagick.ErrorMetric.PerceptualHash);
+ ErrorMetric.PerceptualHash);
}
```
snippet source | anchor
diff --git a/src/Directory.Build.props b/src/Directory.Build.props
index 82bf827..a003242 100644
--- a/src/Directory.Build.props
+++ b/src/Directory.Build.props
@@ -2,7 +2,7 @@
CS1591;CS0649;NU1608;NU1109
- 3.1.7
+ 3.2.0
1.0.0
preview
false
@@ -12,6 +12,5 @@
true
true
true
- $(MSBuildThisFileDirectory)sixlabors.lic
\ No newline at end of file
diff --git a/src/Directory.Packages.props b/src/Directory.Packages.props
index 6d80da9..44da04b 100644
--- a/src/Directory.Packages.props
+++ b/src/Directory.Packages.props
@@ -10,7 +10,6 @@
-
diff --git a/src/Tests/GlobalUsings.cs b/src/Tests/GlobalUsings.cs
new file mode 100644
index 0000000..ddfffa8
--- /dev/null
+++ b/src/Tests/GlobalUsings.cs
@@ -0,0 +1,3 @@
+global using System.Buffers.Binary;
+global using System.IO.Compression;
+global using ImageMagick;
diff --git a/src/Tests/ModuleInitializer.cs b/src/Tests/ModuleInitializer.cs
index 005059d..9b938da 100644
--- a/src/Tests/ModuleInitializer.cs
+++ b/src/Tests/ModuleInitializer.cs
@@ -8,7 +8,7 @@ public static void Initialize()
VerifyDocNet.Initialize();
VerifyImageMagick.RegisterComparers(
threshold: 0.13,
- ImageMagick.ErrorMetric.PerceptualHash);
+ ErrorMetric.PerceptualHash);
}
#endregion
@@ -16,4 +16,4 @@ public static void Initialize()
[ModuleInitializer]
public static void InitializeOther() =>
VerifyDiffPlex.Initialize();
-}
\ No newline at end of file
+}
diff --git a/src/Tests/PngEncoderTests.cs b/src/Tests/PngEncoderTests.cs
new file mode 100644
index 0000000..2173323
--- /dev/null
+++ b/src/Tests/PngEncoderTests.cs
@@ -0,0 +1,238 @@
+[TestFixture]
+public class PngEncoderTests
+{
+ [Test]
+ public void StartsWithPngSignature()
+ {
+ var png = Encode(Gradient(1, 1), 1, 1);
+
+ ReadOnlySpan signature = [137, 80, 78, 71, 13, 10, 26, 10];
+ Assert.That(png.AsSpan(0, 8).SequenceEqual(signature), Is.True);
+ }
+
+ [TestCase(1, 1)]
+ [TestCase(2, 3)]
+ [TestCase(7, 5)]
+ [TestCase(64, 48)]
+ public void HeaderDescribesDimensionsAndRgbaFormat(int width, int height)
+ {
+ var decoded = Decode(Encode(Gradient(width, height), width, height));
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(decoded.Width, Is.EqualTo(width));
+ Assert.That(decoded.Height, Is.EqualTo(height));
+ Assert.That(decoded.BitDepth, Is.EqualTo(8));
+ Assert.That(decoded.ColorType, Is.EqualTo(6)); // truecolor with alpha
+ Assert.That(decoded.Compression, Is.EqualTo(0)); // deflate
+ Assert.That(decoded.Filter, Is.EqualTo(0)); // adaptive
+ Assert.That(decoded.Interlace, Is.EqualTo(0)); // none
+ });
+ }
+
+ [Test]
+ public void ChunkOrderIsIhdrThenIdatThenIend()
+ {
+ var types = ReadChunks(Encode(Gradient(4, 4), 4, 4))
+ .Select(_ => _.type)
+ .ToList();
+
+ Assert.That(types, Is.EqualTo(["IHDR", "IDAT", "IEND"]));
+ }
+
+ [Test]
+ public void EndChunkIsEmpty()
+ {
+ var end = ReadChunks(Encode(Gradient(4, 4), 4, 4))
+ .Single(_ => _.type == "IEND");
+
+ Assert.That(end.data, Is.Empty);
+ }
+
+ [Test]
+ public void EveryChunkHasValidCrc()
+ {
+ foreach (var chunk in ReadChunks(Encode(Gradient(10, 6), 10, 6)))
+ {
+ Assert.That(chunk.crcValid, Is.True, $"Invalid CRC for chunk {chunk.type}");
+ }
+ }
+
+ [Test]
+ public void EveryScanlineUsesNoneFilter()
+ {
+ const int width = 5;
+ const int height = 4;
+ var decoded = Decode(Encode(Gradient(width, height), width, height));
+
+ for (var y = 0; y < height; y++)
+ {
+ Assert.That(decoded.FilterByte(y), Is.EqualTo(0), $"Row {y} should use the None filter");
+ }
+ }
+
+ [Test]
+ public void RoundTripsPixelsConvertingBgraToRgba()
+ {
+ const int width = 3;
+ const int height = 2;
+ var bgra = Gradient(width, height);
+ var decoded = Decode(Encode(bgra, width, height));
+
+ for (var y = 0; y < height; y++)
+ for (var x = 0; x < width; x++)
+ {
+ var source = (y * width + x) * 4;
+ var b = bgra[source];
+ var g = bgra[source + 1];
+ var r = bgra[source + 2];
+ var a = bgra[source + 3];
+
+ Assert.That(decoded.Pixel(x, y), Is.EqualTo((r, g, b, a)), $"Pixel ({x},{y})");
+ }
+ }
+
+ [Test]
+ public void PreservesAlphaChannel()
+ {
+ // a single fully transparent blue pixel (B=255, G=0, R=0, A=0)
+ var decoded = Decode(Encode([255, 0, 0, 0], 1, 1));
+
+ Assert.That(decoded.Pixel(0, 0), Is.EqualTo(((byte)0, (byte)0, (byte)255, (byte)0)));
+ }
+
+ [Test]
+ public void OutputIsDeterministic()
+ {
+ var bgra = Gradient(20, 12);
+
+ Assert.That(Encode(bgra, 20, 12), Is.EqualTo(Encode(bgra, 20, 12)));
+ }
+
+ [Test]
+ public void ProducesPngDecodableByImageMagick()
+ {
+ const int width = 8;
+ const int height = 6;
+ var bgra = Gradient(width, height);
+
+ using var image = new MagickImage(Encode(bgra, width, height));
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(image.Format, Is.EqualTo(MagickFormat.Png));
+ Assert.That((int)image.Width, Is.EqualTo(width));
+ Assert.That((int)image.Height, Is.EqualTo(height));
+ });
+ }
+
+ static byte[] Encode(byte[] bgra, int width, int height)
+ {
+ using var stream = new MemoryStream();
+ PngEncoder.WriteBgraAsPng(bgra, width, height, stream);
+ return stream.ToArray();
+ }
+
+ // Deterministic pattern that varies every channel, including alpha.
+ static byte[] Gradient(int width, int height)
+ {
+ var bytes = new byte[width * height * 4];
+ var i = 0;
+ for (var y = 0; y < height; y++)
+ for (var x = 0; x < width; x++)
+ {
+ bytes[i++] = (byte)(x * 7 + y); // B
+ bytes[i++] = (byte)(x + y * 5); // G
+ bytes[i++] = (byte)(x * 3 + y * 2); // R
+ bytes[i++] = (byte)(255 - ((x + y) & 0xFF)); // A
+ }
+
+ return bytes;
+ }
+
+ static List<(string type, byte[] data, bool crcValid)> ReadChunks(byte[] png)
+ {
+ var chunks = new List<(string, byte[], bool)>();
+ var pos = 8; // skip the signature
+ while (pos < png.Length)
+ {
+ var length = BinaryPrimitives.ReadInt32BigEndian(png.AsSpan(pos, 4));
+ var type = Encoding.ASCII.GetString(png, pos + 4, 4);
+ var data = png.AsSpan(pos + 8, length).ToArray();
+ var storedCrc = BinaryPrimitives.ReadUInt32BigEndian(png.AsSpan(pos + 8 + length, 4));
+ // CRC covers the chunk type plus its data.
+ var crcValid = storedCrc == Crc32(png.AsSpan(pos + 4, 4 + length));
+ chunks.Add((type, data, crcValid));
+ pos += 12 + length;
+ }
+
+ return chunks;
+ }
+
+ static DecodedPng Decode(byte[] png)
+ {
+ var chunks = ReadChunks(png);
+ var header = chunks.Single(_ => _.type == "IHDR").data;
+
+ using var compressed = new MemoryStream();
+ foreach (var chunk in chunks.Where(_ => _.type == "IDAT"))
+ {
+ compressed.Write(chunk.data);
+ }
+
+ compressed.Position = 0;
+ using var zlib = new ZLibStream(compressed, CompressionMode.Decompress);
+ using var scanlines = new MemoryStream();
+ zlib.CopyTo(scanlines);
+
+ return new()
+ {
+ Width = BinaryPrimitives.ReadInt32BigEndian(header.AsSpan(0, 4)),
+ Height = BinaryPrimitives.ReadInt32BigEndian(header.AsSpan(4, 4)),
+ BitDepth = header[8],
+ ColorType = header[9],
+ Compression = header[10],
+ Filter = header[11],
+ Interlace = header[12],
+ Scanlines = scanlines.ToArray()
+ };
+ }
+
+ // Bitwise CRC-32, independent of the table-based implementation under test.
+ static uint Crc32(ReadOnlySpan data)
+ {
+ var crc = 0xFFFFFFFFu;
+ foreach (var b in data)
+ {
+ crc ^= b;
+ for (var i = 0; i < 8; i++)
+ {
+ crc = (crc & 1) != 0 ? (crc >> 1) ^ 0xEDB88320 : crc >> 1;
+ }
+ }
+
+ return crc ^ 0xFFFFFFFFu;
+ }
+
+ class DecodedPng
+ {
+ public int Width;
+ public int Height;
+ public byte BitDepth;
+ public byte ColorType;
+ public byte Compression;
+ public byte Filter;
+ public byte Interlace;
+ public byte[] Scanlines = [];
+
+ int Stride => Width * 4 + 1;
+
+ public byte FilterByte(int y) => Scanlines[y * Stride];
+
+ public (byte r, byte g, byte b, byte a) Pixel(int x, int y)
+ {
+ var offset = y * Stride + 1 + x * 4;
+ return (Scanlines[offset], Scanlines[offset + 1], Scanlines[offset + 2], Scanlines[offset + 3]);
+ }
+ }
+}
diff --git a/src/Tests/Samples.VerifyFirstPage.verified.png b/src/Tests/Samples.VerifyFirstPage.verified.png
index 6218598..634eec1 100644
Binary files a/src/Tests/Samples.VerifyFirstPage.verified.png and b/src/Tests/Samples.VerifyFirstPage.verified.png differ
diff --git a/src/Tests/Samples.VerifyPageDimensions#00.verified.png b/src/Tests/Samples.VerifyPageDimensions#00.verified.png
index a257533..92801c6 100644
Binary files a/src/Tests/Samples.VerifyPageDimensions#00.verified.png and b/src/Tests/Samples.VerifyPageDimensions#00.verified.png differ
diff --git a/src/Tests/Samples.VerifyPageDimensions#01.verified.png b/src/Tests/Samples.VerifyPageDimensions#01.verified.png
index 2855e94..97e8fc4 100644
Binary files a/src/Tests/Samples.VerifyPageDimensions#01.verified.png and b/src/Tests/Samples.VerifyPageDimensions#01.verified.png differ
diff --git a/src/Tests/Samples.VerifyPdf#00.verified.png b/src/Tests/Samples.VerifyPdf#00.verified.png
index 6218598..634eec1 100644
Binary files a/src/Tests/Samples.VerifyPdf#00.verified.png and b/src/Tests/Samples.VerifyPdf#00.verified.png differ
diff --git a/src/Tests/Samples.VerifyPdf#01.verified.png b/src/Tests/Samples.VerifyPdf#01.verified.png
index be7a522..c4ce0e7 100644
Binary files a/src/Tests/Samples.VerifyPdf#01.verified.png and b/src/Tests/Samples.VerifyPdf#01.verified.png differ
diff --git a/src/Tests/Samples.VerifyPdfStream#00.verified.png b/src/Tests/Samples.VerifyPdfStream#00.verified.png
index 6218598..634eec1 100644
Binary files a/src/Tests/Samples.VerifyPdfStream#00.verified.png and b/src/Tests/Samples.VerifyPdfStream#00.verified.png differ
diff --git a/src/Tests/Samples.VerifyPdfStream#01.verified.png b/src/Tests/Samples.VerifyPdfStream#01.verified.png
index be7a522..c4ce0e7 100644
Binary files a/src/Tests/Samples.VerifyPdfStream#01.verified.png and b/src/Tests/Samples.VerifyPdfStream#01.verified.png differ
diff --git a/src/Tests/Samples.VerifyPreserveTransparency#00.verified.png b/src/Tests/Samples.VerifyPreserveTransparency#00.verified.png
index 703e5e8..6abb4ca 100644
Binary files a/src/Tests/Samples.VerifyPreserveTransparency#00.verified.png and b/src/Tests/Samples.VerifyPreserveTransparency#00.verified.png differ
diff --git a/src/Tests/Samples.VerifyPreserveTransparency#01.verified.png b/src/Tests/Samples.VerifyPreserveTransparency#01.verified.png
index 265e4a5..b33d884 100644
Binary files a/src/Tests/Samples.VerifyPreserveTransparency#01.verified.png and b/src/Tests/Samples.VerifyPreserveTransparency#01.verified.png differ
diff --git a/src/Tests/Samples.VerifySecondPage.verified.png b/src/Tests/Samples.VerifySecondPage.verified.png
index be7a522..c4ce0e7 100644
Binary files a/src/Tests/Samples.VerifySecondPage.verified.png and b/src/Tests/Samples.VerifySecondPage.verified.png differ
diff --git a/src/Verify.DocNet/GlobalUsings.cs b/src/Verify.DocNet/GlobalUsings.cs
index 38360fb..c64bf4e 100644
--- a/src/Verify.DocNet/GlobalUsings.cs
+++ b/src/Verify.DocNet/GlobalUsings.cs
@@ -1,6 +1,6 @@
-global using Docnet.Core;
+global using System.Buffers.Binary;
+global using System.IO.Compression;
+global using Docnet.Core;
global using Docnet.Core.Converters;
global using Docnet.Core.Models;
global using Docnet.Core.Readers;
-global using SixLabors.ImageSharp;
-global using SixLabors.ImageSharp.PixelFormats;
\ No newline at end of file
diff --git a/src/Verify.DocNet/PngEncoder.cs b/src/Verify.DocNet/PngEncoder.cs
new file mode 100644
index 0000000..1b51f66
--- /dev/null
+++ b/src/Verify.DocNet/PngEncoder.cs
@@ -0,0 +1,116 @@
+static class PngEncoder
+{
+ static ReadOnlySpan Signature => [137, 80, 78, 71, 13, 10, 26, 10];
+
+ ///
+ /// Encodes raw BGRA32 pixel data as a PNG (8-bit RGBA, no interlacing) and writes it to .
+ ///
+ public static void WriteBgraAsPng(byte[] bgra, int width, int height, Stream stream)
+ {
+ stream.Write(Signature);
+
+ Span header = stackalloc byte[13];
+ BinaryPrimitives.WriteInt32BigEndian(header[..4], width);
+ BinaryPrimitives.WriteInt32BigEndian(header[4..8], height);
+ header[8] = 8; // bit depth
+ header[9] = 6; // color type: truecolor with alpha (RGBA)
+ header[10] = 0; // compression: deflate
+ header[11] = 0; // filter: adaptive
+ header[12] = 0; // interlace: none
+ WriteChunk(stream, "IHDR"u8, header);
+
+ WriteChunk(stream, "IDAT"u8, Compress(BuildScanlines(bgra, width, height)));
+
+ WriteChunk(stream, "IEND"u8, []);
+ }
+
+ // Each scanline is prefixed with a filter-type byte (0 = None) and pixels are converted from BGRA to RGBA.
+ static byte[] BuildScanlines(byte[] bgra, int width, int height)
+ {
+ var stride = width * 4;
+ var scanlines = new byte[height * (stride + 1)];
+ var source = 0;
+ var target = 0;
+ for (var y = 0; y < height; y++)
+ {
+ scanlines[target++] = 0; // filter: None
+ for (var x = 0; x < width; x++)
+ {
+ var b = bgra[source++];
+ var g = bgra[source++];
+ var r = bgra[source++];
+ var a = bgra[source++];
+ scanlines[target++] = r;
+ scanlines[target++] = g;
+ scanlines[target++] = b;
+ scanlines[target++] = a;
+ }
+ }
+
+ return scanlines;
+ }
+
+ static byte[] Compress(byte[] data)
+ {
+ using var output = new MemoryStream();
+ using (var zlib = new ZLibStream(output, CompressionLevel.Optimal, leaveOpen: true))
+ {
+ zlib.Write(data);
+ }
+
+ return output.ToArray();
+ }
+
+ static void WriteChunk(Stream stream, ReadOnlySpan type, ReadOnlySpan data)
+ {
+ Span length = stackalloc byte[4];
+ BinaryPrimitives.WriteInt32BigEndian(length, data.Length);
+ stream.Write(length);
+ stream.Write(type);
+ stream.Write(data);
+
+ Span crc = stackalloc byte[4];
+ BinaryPrimitives.WriteUInt32BigEndian(crc, Crc32.Compute(type, data));
+ stream.Write(crc);
+ }
+
+ static class Crc32
+ {
+ static uint[] table = BuildTable();
+
+ static uint[] BuildTable()
+ {
+ var table = new uint[256];
+ for (var n = 0; n < 256; n++)
+ {
+ var c = (uint)n;
+ for (var k = 0; k < 8; k++)
+ {
+ c = (c & 1) != 0 ? 0xEDB88320 ^ (c >> 1) : c >> 1;
+ }
+
+ table[n] = c;
+ }
+
+ return table;
+ }
+
+ public static uint Compute(ReadOnlySpan type, ReadOnlySpan data)
+ {
+ var crc = 0xFFFFFFFFu;
+ crc = Update(crc, type);
+ crc = Update(crc, data);
+ return crc ^ 0xFFFFFFFFu;
+ }
+
+ static uint Update(uint crc, ReadOnlySpan data)
+ {
+ foreach (var b in data)
+ {
+ crc = table[(crc ^ b) & 0xFF] ^ (crc >> 8);
+ }
+
+ return crc;
+ }
+ }
+}
diff --git a/src/Verify.DocNet/Verify.DocNet.csproj b/src/Verify.DocNet/Verify.DocNet.csproj
index 4fdf1ab..7995b6b 100644
--- a/src/Verify.DocNet/Verify.DocNet.csproj
+++ b/src/Verify.DocNet/Verify.DocNet.csproj
@@ -2,11 +2,13 @@
net6.0
+
+
+
-
-
\ No newline at end of file
+
diff --git a/src/Verify.DocNet/VerifyDocNet_Pdf.cs b/src/Verify.DocNet/VerifyDocNet_Pdf.cs
index ca53f3f..cf19256 100644
--- a/src/Verify.DocNet/VerifyDocNet_Pdf.cs
+++ b/src/Verify.DocNet/VerifyDocNet_Pdf.cs
@@ -47,10 +47,8 @@ static IEnumerable GetStreams(string? name, IDocReader document, IReadOn
var width = reader.GetPageWidth();
var height = reader.GetPageHeight();
- var image = Image.LoadPixelData(rawBytes, width, height);
-
var stream = new MemoryStream();
- image.SaveAsPng(stream);
+ PngEncoder.WriteBgraAsPng(rawBytes, width, height, stream);
yield return new("png", stream, name);
}
}
diff --git a/src/sixlabors.lic b/src/sixlabors.lic
deleted file mode 100644
index 26c704d..0000000
--- a/src/sixlabors.lic
+++ /dev/null
@@ -1 +0,0 @@
-Id=ctm_01k4a0m3dxnya9epg35rqqxs29;Kind=Community;ExpiryDateUtc=2026-12-03T09:33:00.1566670Z;Key=M+2oIq6h6sAag2H476SNAgeI8LUHBYIhPnTHNzADw2WBuJ9+KYC8ouKV6kgbg78CuxMC2FbgnNUcHvvIT/s8Qg==
\ No newline at end of file