/* ========================================================================
* Copyright (c) 2005-2026 The OPC Foundation, Inc. All rights reserved.
*
* OPC Foundation MIT License 1.00
*
* Permission is hereby granted, free of charge, to any person
* obtaining a copy of this software and associated documentation
* files (the "Software"), to deal in the Software without
* restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the
* Software is furnished to do so, subject to the following
* conditions:
*
* The above copyright notice and this permission notice shall be
* included in all copies or substantial portions of the Software.
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
* OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
* NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
* HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
* WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
* OTHER DEALINGS IN THE SOFTWARE.
*
* The complete license agreement can be found here:
* http://opcfoundation.org/License/MIT/1.00/
* ======================================================================*/
using System;
using System.IO;
using System.IO.Compression;
using NUnit.Framework;
using Opc.Ua.Vision.OpenUsd.Encoding;
namespace Opc.Ua.Vision.Tests
{
///
/// Pins the byte-level contract of
/// - PNG signature, IHDR / IDAT / IEND chunk layout, and round-trip
/// through the standard decoder so any PNG
/// reader (including .NET's own Image) can consume the output.
/// The encoder is used for every simulated-camera frame the Vision
/// server publishes, so a regression that produces invalid PNG bytes
/// would silently break every client.
///
[TestFixture]
public sealed class PngEncoderTests
{
[Test]
public void EncodeRgba8OutputStartsWithPngSignature()
{
byte[] rgba = MakeGradient(4, 4);
byte[] png = PngEncoder.EncodeRgba8(4, 4, rgba);
Assert.Multiple(() =>
{
Assert.That(png.Length, Is.GreaterThan(8));
Assert.That(png[0], Is.EqualTo(0x89));
Assert.That(png[1], Is.EqualTo((byte)'P'));
Assert.That(png[2], Is.EqualTo((byte)'N'));
Assert.That(png[3], Is.EqualTo((byte)'G'));
Assert.That(png[4], Is.EqualTo(0x0D));
Assert.That(png[5], Is.EqualTo(0x0A));
Assert.That(png[6], Is.EqualTo(0x1A));
Assert.That(png[7], Is.EqualTo(0x0A));
});
}
[Test]
public void EncodeRgba8ProducesIhdrIdatIendInThatOrder()
{
byte[] png = PngEncoder.EncodeRgba8(4, 4, MakeGradient(4, 4));
int idxIhdr = FindChunk(png, "IHDR");
int idxIdat = FindChunk(png, "IDAT");
int idxIend = FindChunk(png, "IEND");
Assert.Multiple(() =>
{
Assert.That(idxIhdr, Is.EqualTo(8),
"IHDR must be the first chunk after the PNG signature.");
Assert.That(idxIdat, Is.GreaterThan(idxIhdr));
Assert.That(idxIend, Is.GreaterThan(idxIdat));
});
}
[Test]
public void IhdrCarriesWidthHeightBitDepth8ColorType6()
{
byte[] png = PngEncoder.EncodeRgba8(7, 5, MakeGradient(7, 5));
int idx = FindChunk(png, "IHDR");
int dataOffset = idx + 8;
uint w = ReadUInt32BE(png, dataOffset);
uint h = ReadUInt32BE(png, dataOffset + 4);
byte bitDepth = png[dataOffset + 8];
byte colorType = png[dataOffset + 9];
byte compression = png[dataOffset + 10];
byte filter = png[dataOffset + 11];
byte interlace = png[dataOffset + 12];
Assert.Multiple(() =>
{
Assert.That(w, Is.EqualTo(7u));
Assert.That(h, Is.EqualTo(5u));
Assert.That(bitDepth, Is.EqualTo(8));
Assert.That(colorType, Is.EqualTo(6),
"PngEncoder is RGBA-only: colour type 6 is truecolour with alpha.");
Assert.That(compression, Is.EqualTo(0));
Assert.That(filter, Is.EqualTo(0));
Assert.That(interlace, Is.EqualTo(0));
});
}
[Test]
public void IdatDecompressesBackToFilterZeroPrefixedRowsWithOriginalPixels()
{
const int W = 3;
const int H = 2;
byte[] rgba = MakeGradient(W, H);
byte[] png = PngEncoder.EncodeRgba8(W, H, rgba);
byte[] filtered = DecompressIdat(png);
Assert.That(filtered.Length, Is.EqualTo(H * ((W * 4) + 1)),
"Decompressed IDAT must be H rows of (1 filter byte + W*4 pixel bytes).");
for (int y = 0; y < H; y++)
{
int rowStart = y * ((W * 4) + 1);
Assert.That(filtered[rowStart], Is.EqualTo(0),
"PngEncoder emits filter type 0 (None) on every row.");
for (int x = 0; x < W * 4; x++)
{
int srcIdx = (y * W * 4) + x;
int dstIdx = rowStart + 1 + x;
Assert.That(filtered[dstIdx], Is.EqualTo(rgba[srcIdx]),
$"Pixel byte round-trip mismatch at y={y}, x={x}.");
}
}
}
[Test]
public void EncodeRgba8ThrowsArgumentOutOfRangeForNonPositiveWidth()
{
Assert.That(
() => PngEncoder.EncodeRgba8(0, 1, new byte[4]),
Throws.TypeOf());
Assert.That(
() => PngEncoder.EncodeRgba8(-1, 1, new byte[4]),
Throws.TypeOf());
}
[Test]
public void EncodeRgba8ThrowsArgumentOutOfRangeForNonPositiveHeight()
{
Assert.That(
() => PngEncoder.EncodeRgba8(1, 0, new byte[4]),
Throws.TypeOf());
Assert.That(
() => PngEncoder.EncodeRgba8(1, -1, new byte[4]),
Throws.TypeOf());
}
[Test]
public void EncodeRgba8ThrowsArgumentExceptionForMismatchedBufferLength()
{
Assert.That(
() => PngEncoder.EncodeRgba8(4, 4, new byte[10]),
Throws.TypeOf());
}
private static int FindChunk(byte[] png, string fourcc)
{
if (fourcc.Length != 4)
{
throw new ArgumentException("Chunk type must be exactly 4 ASCII bytes.", nameof(fourcc));
}
for (int i = 8; i + 8 <= png.Length;)
{
uint length = ReadUInt32BE(png, i);
if (length > (uint)int.MaxValue)
{
return -1;
}
if (png[i + 4] == (byte)fourcc[0] &&
png[i + 5] == (byte)fourcc[1] &&
png[i + 6] == (byte)fourcc[2] &&
png[i + 7] == (byte)fourcc[3])
{
return i;
}
i += 4 + 4 + (int)length + 4;
}
return -1;
}
private static byte[] DecompressIdat(byte[] png)
{
int idx = FindChunk(png, "IDAT");
Assert.That(idx, Is.GreaterThan(0), "Encoder must emit at least one IDAT chunk.");
uint len = ReadUInt32BE(png, idx);
byte[] payload = new byte[len];
Array.Copy(png, idx + 8, payload, 0, (int)len);
using var input = new MemoryStream(payload);
using var zlib = new ZLibStream(input, CompressionMode.Decompress);
using var output = new MemoryStream();
zlib.CopyTo(output);
return output.ToArray();
}
private static uint ReadUInt32BE(byte[] buf, int offset)
{
return ((uint)buf[offset] << 24) |
((uint)buf[offset + 1] << 16) |
((uint)buf[offset + 2] << 8) |
buf[offset + 3];
}
private static byte[] MakeGradient(int width, int height)
{
byte[] rgba = new byte[width * height * 4];
for (int y = 0; y < height; y++)
{
for (int x = 0; x < width; x++)
{
int i = ((y * width) + x) * 4;
rgba[i] = (byte)(x * 17);
rgba[i + 1] = (byte)(y * 23);
rgba[i + 2] = (byte)((x + y) * 5);
rgba[i + 3] = 0xFF;
}
}
return rgba;
}
}
}