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