diff --git a/src/ImageSharp/Formats/Jxl/Fields/JxlBundle.cs b/src/ImageSharp/Formats/Jxl/Fields/JxlBundle.cs index 866e00f317..f7b8c921b1 100644 --- a/src/ImageSharp/Formats/Jxl/Fields/JxlBundle.cs +++ b/src/ImageSharp/Formats/Jxl/Fields/JxlBundle.cs @@ -135,7 +135,7 @@ internal static class JxlBundle public static bool WriteCodestreamHeaders(JxlCodecMetadata metadata, JxlBitWriter writer, JxlAuxiliaryOutput auxOut) { // Marker/signature - if (!writer.WithMaxBits(16, JxlLayerType.Header, auxOut, () => + if (!writer.WithMaxBits(16, () => { writer.Write(8, 0xFF); writer.Write(8, JxlShared.CodestreamMarker); diff --git a/src/ImageSharp/Formats/Jxl/IO/Metadata/JxlSizeHeader.cs b/src/ImageSharp/Formats/Jxl/IO/Metadata/JxlSizeHeader.cs index 5da30b8a0d..e40720a53c 100644 --- a/src/ImageSharp/Formats/Jxl/IO/Metadata/JxlSizeHeader.cs +++ b/src/ImageSharp/Formats/Jxl/IO/Metadata/JxlSizeHeader.cs @@ -2,6 +2,7 @@ // Licensed under the Six Labors Split License. using SixLabors.ImageSharp.Formats.Jxl.Fields; +using SixLabors.ImageSharp.Formats.Jxl.Processing.Primitives; namespace SixLabors.ImageSharp.Formats.Jxl.IO.Metadata; diff --git a/src/ImageSharp/Formats/Jxl/Processing/Encoder/JxlBitWriter.cs b/src/ImageSharp/Formats/Jxl/Processing/Encoder/JxlBitWriter.cs index e497da0a34..924dac7c92 100644 --- a/src/ImageSharp/Formats/Jxl/Processing/Encoder/JxlBitWriter.cs +++ b/src/ImageSharp/Formats/Jxl/Processing/Encoder/JxlBitWriter.cs @@ -57,6 +57,18 @@ internal sealed class JxlBitWriter(Stream stream) } } + public void ZeroPadToByte() + { + long remainderBits = JxlMath.RoundUpBitsToByteMultiple((int)this.BitsWritten) - this.BitsWritten; + + if (remainderBits == 0) + { + return; + } + + this.Write((int)remainderBits, 0); + } + public bool WithMaxBits(ulong maxBits, Func func) { bool previousIsLimited = this.isLimited; diff --git a/tests/ImageSharp.Tests/Formats/Jxl/IO/FieldsTests.cs b/tests/ImageSharp.Tests/Formats/Jxl/IO/FieldsTests.cs new file mode 100644 index 0000000000..b715b48a60 --- /dev/null +++ b/tests/ImageSharp.Tests/Formats/Jxl/IO/FieldsTests.cs @@ -0,0 +1,674 @@ +// Copyright (c) Six Labors. +// Licensed under the Six Labors Split License. + +using SixLabors.ImageSharp.Formats.Jxl.Fields; +using SixLabors.ImageSharp.Formats.Jxl.IO.FrameHeader; +using SixLabors.ImageSharp.Formats.Jxl.IO.Metadata; +using SixLabors.ImageSharp.Formats.Jxl.Processing; +using SixLabors.ImageSharp.Formats.Jxl.Processing.Decoder; +using SixLabors.ImageSharp.Formats.Jxl.Processing.Encoder; +using SixLabors.ImageSharp.Formats.Jxl.Processing.Encoder.AuxiliaryOutput; + +namespace SixLabors.ImageSharp.Tests.Formats.Jxl.IO; + +public class FieldsTests +{ + private sealed class OldBundle : IJxlFields + { + private uint oldSmall; + private float oldF; + private uint oldLarge; + private ulong extensions; + + public OldBundle() => JxlBundle.Init(this); + + public uint OldSmall + { + get => this.oldSmall; + set => this.oldSmall = value; + } + + public float OldF + { + get => this.oldF; + set => this.oldF = value; + } + + public uint OldLarge + { + get => this.oldLarge; + set => this.oldLarge = value; + } + + public ulong Extensions + { + get => this.extensions; + set => this.extensions = value; + } + + public bool Visit(JxlVisitor visitor) + { + if (!visitor.U32( + JxlFieldExpressions.Value(1), + JxlFieldExpressions.Bits(2), + JxlFieldExpressions.Bits(3), + JxlFieldExpressions.Bits(4), + 1, + ref this.oldSmall)) + { + return false; + } + + if (!visitor.F16(1.125f, ref this.oldF)) + { + return false; + } + + if (!visitor.U32( + JxlFieldExpressions.Bits(7), + JxlFieldExpressions.Bits(12), + JxlFieldExpressions.Bits(16), + JxlFieldExpressions.Bits(32), + 0, + ref this.oldLarge)) + { + return false; + } + + if (!visitor.BeginExtensions(ref this.extensions)) + { + return false; + } + + return visitor.EndExtensions(); + } + } + + private sealed class NewBundle : IJxlFields + { + private uint oldSmall; + private float oldF; + private uint oldLarge; + private ulong extensions; + private uint newSmall = 2; + private float newF = -2.0f; + private uint newLarge; + + public NewBundle() => JxlBundle.Init(this); + + public uint OldSmall + { + get => this.oldSmall; + set => this.oldSmall = value; + } + + public float OldF + { + get => this.oldF; + set => this.oldF = value; + } + + public uint OldLarge + { + get => this.oldLarge; + set => this.oldLarge = value; + } + + public ulong Extensions + { + get => this.extensions; + set => this.extensions = value; + } + + public uint NewSmall + { + get => this.newSmall; + set => this.newSmall = value; + } + + public float NewF + { + get => this.newF; + set => this.newF = value; + } + + public uint NewLarge + { + get => this.newLarge; + set => this.newLarge = value; + } + + public bool Visit(JxlVisitor visitor) + { + if (!visitor.U32( + JxlFieldExpressions.Value(1), + JxlFieldExpressions.Bits(2), + JxlFieldExpressions.Bits(3), + JxlFieldExpressions.Bits(4), + 1, + ref this.oldSmall)) + { + return false; + } + + if (!visitor.F16(1.125f, ref this.oldF)) + { + return false; + } + + if (!visitor.U32( + JxlFieldExpressions.Bits(7), + JxlFieldExpressions.Bits(12), + JxlFieldExpressions.Bits(16), + JxlFieldExpressions.Bits(32), + 0, + ref this.oldLarge)) + { + return false; + } + + if (!visitor.BeginExtensions(ref this.extensions)) + { + return false; + } + + if (visitor.Conditional((this.Extensions & 1) != 0)) + { + if (!visitor.U32( + JxlFieldExpressions.Value(2), + JxlFieldExpressions.Bits(2), + JxlFieldExpressions.Bits(3), + JxlFieldExpressions.Bits(4), + 2, + ref this.newSmall)) + { + return false; + } + + if (!visitor.F16(-2.0f, ref this.newF)) + { + return false; + } + } + + if (visitor.Conditional((this.Extensions & 2) != 0)) + { + if (!visitor.U32( + JxlFieldExpressions.Bits(9), + JxlFieldExpressions.Bits(12), + JxlFieldExpressions.Bits(16), + JxlFieldExpressions.Bits(32), + 0, + ref this.newLarge)) + { + return false; + } + } + + return visitor.EndExtensions(); + } + } + + private static void TestU32Coder(uint value, int expectedBitsWritten) + { + JxlU32Enc enc = new( + JxlFieldExpressions.Value(0), + JxlFieldExpressions.Bits(4), + JxlFieldExpressions.Value(0x7FFFFFFF), + JxlFieldExpressions.Bits(32)); + + using MemoryStream memoryStream = new(); + JxlBitWriter bitWriter = new(memoryStream); + + Assert.True(bitWriter.WithMaxBits( + (ulong)JxlMath.RoundUpBitsToByteMultiple(JxlU32Coder.MaxEncodedBits(enc)), + () => + { + int encodedBits = 0; + Assert.True(JxlU32Coder.CanEncode(in enc, value, ref encodedBits)); + Assert.Equal(expectedBitsWritten, encodedBits); + + Assert.True(JxlU32Coder.Write(in enc, value, bitWriter)); + Assert.Equal(expectedBitsWritten, bitWriter.BitsWritten); + + bitWriter.ZeroPadToByte(); + + return true; + })); + + memoryStream.Position = 0; + JxlBitReader reader = new(memoryStream); + uint decodedValue = JxlU32Coder.Read(in enc, reader); + + Assert.Equal(value, decodedValue); + } + + private static void TestU64Coder(ulong value, int expectedBitsWritten) + { + using MemoryStream memoryStream = new(); + JxlBitWriter bitWriter = new(memoryStream); + + Assert.True(bitWriter.WithMaxBits( + (ulong)JxlMath.RoundUpBitsToByteMultiple(JxlU64Coder.MaxEncodedBits()), + () => + { + int encodedBits = 0; + Assert.True(JxlU64Coder.CanEncode(value, ref encodedBits)); + Assert.Equal(expectedBitsWritten, encodedBits); + + Assert.True(JxlU64Coder.Write(value, bitWriter)); + Assert.Equal(expectedBitsWritten, bitWriter.BitsWritten); + + bitWriter.ZeroPadToByte(); + + return true; + })); + + memoryStream.Position = 0; + JxlBitReader reader = new(memoryStream); + ulong decodedValue = JxlU64Coder.Read(reader); + + Assert.Equal(value, decodedValue); + } + + private static void TestF16Coder(float value) + { + int maxEncodedBits = 0; + if (JxlF16Coder.CanEncode(value, ref maxEncodedBits)) + { + throw new InvalidOperationException("This F16 value cannot be encoded: " + value); + } + + Assert.Equal(JxlF16Coder.MaxEncodedBits(), maxEncodedBits); + + using MemoryStream memoryStream = new(); + JxlBitWriter bitWriter = new(memoryStream); + + Assert.True(bitWriter.WithMaxBits( + (ulong)JxlMath.RoundUpBitsToByteMultiple(maxEncodedBits), + () => + { + Assert.True(JxlF16Coder.Write(value, bitWriter)); + Assert.Equal(JxlF16Coder.MaxEncodedBits(), bitWriter.BitsWritten); + bitWriter.ZeroPadToByte(); + return true; + })); + + memoryStream.Position = 0; + JxlBitReader reader = new(memoryStream); + + float decodedValue = 0; + Assert.True(JxlF16Coder.Read(reader, ref decodedValue)); + Assert.Equal(value, decodedValue); + } + + [Fact] + public void U32CoderTest() + { + TestU32Coder(0, 2); + TestU32Coder(1, 6); + TestU32Coder(15, 6); + TestU32Coder(0x7FFFFFFF, 2); + TestU32Coder(128, 34); + TestU32Coder(0x7FFFFFFEu, 34); + TestU32Coder(0x80000000u, 34); + TestU32Coder(0xFFFFFFFFu, 34); + } + + [Fact] + public void U64CoderTest() + { + // Values that should take 2 bits (selector 00): 0 + TestU64Coder(0, 2); + + // Values that should take 6 bits (2 for selector, 4 for value): 1..16 + TestU64Coder(1, 6); + TestU64Coder(2, 6); + TestU64Coder(8, 6); + TestU64Coder(15, 6); + TestU64Coder(16, 6); + + // Values that should take 10 bits (2 for selector, 8 for value): 17..272 + TestU64Coder(17, 10); + TestU64Coder(18, 10); + TestU64Coder(100, 10); + TestU64Coder(271, 10); + TestU64Coder(272, 10); + + // Values that should take 15 bits (2 for selector, 12 for value, 1 for varint + // end): (0)..273..4095 + TestU64Coder(273, 15); + TestU64Coder(274, 15); + TestU64Coder(1000, 15); + TestU64Coder(4094, 15); + TestU64Coder(4095, 15); + + // Take 24 bits (of which 20 actual value): (0)..4096..1048575 + TestU64Coder(4096, 24); + TestU64Coder(4097, 24); + TestU64Coder(10000, 24); + TestU64Coder(1048574, 24); + TestU64Coder(1048575, 24); + + // Take 33 bits (of which 28 actual value): (0)..1048576..268435455 + TestU64Coder(1048576, 33); + TestU64Coder(1048577, 33); + TestU64Coder(10000000, 33); + TestU64Coder(268435454, 33); + TestU64Coder(268435455, 33); + + // Take 42 bits (of which 36 actual value): (0)..268435456..68719476735 + TestU64Coder(268435456uL, 42); + TestU64Coder(268435457uL, 42); + TestU64Coder(1000000000uL, 42); + TestU64Coder(68719476734uL, 42); + TestU64Coder(68719476735uL, 42); + + // Take 51 bits (of which 44 actual value): (0)..68719476736..17592186044415 + TestU64Coder(68719476736uL, 51); + TestU64Coder(68719476737uL, 51); + TestU64Coder(1000000000000uL, 51); + TestU64Coder(17592186044414uL, 51); + TestU64Coder(17592186044415uL, 51); + + // Take 60 bits (of which 52 actual value): + // (0)..17592186044416..4503599627370495 + TestU64Coder(17592186044416uL, 60); + TestU64Coder(17592186044417uL, 60); + TestU64Coder(100000000000000uL, 60); + TestU64Coder(4503599627370494uL, 60); + TestU64Coder(4503599627370495uL, 60); + + // Take 69 bits (of which 60 actual value): + // (0)..4503599627370496..1152921504606846975 + TestU64Coder(4503599627370496uL, 69); + TestU64Coder(4503599627370497uL, 69); + TestU64Coder(10000000000000000uL, 69); + TestU64Coder(1152921504606846974uL, 69); + TestU64Coder(1152921504606846975uL, 69); + + // Take 73 bits (of which 64 actual value): + // (0)..1152921504606846976..18446744073709551615 + TestU64Coder(1152921504606846976uL, 73); + TestU64Coder(1152921504606846977uL, 73); + TestU64Coder(10000000000000000000uL, 73); + TestU64Coder(18446744073709551614uL, 73); + TestU64Coder(18446744073709551615uL, 73); + } + + [Fact] + public void F16CoderTest() + { + ReadOnlySpan signs = [-1.0f, 1.0f]; + ReadOnlySpan magnitudes = [0.0f, 0.5f, 1.0f, 2.0f, 2.5f, 16.015625f, 1.0f / 4096, 1.0f / 16384, 65504.0f]; + + foreach (float sign in signs) + { + foreach (float magnitude in magnitudes) + { + TestF16Coder(sign * magnitude); + } + } + + // These values are out of range and cannot be represented + // by an F16 coder, so they're expected to throw. + Assert.Throws(() => TestF16Coder(65504.01f)); + Assert.Throws(() => TestF16Coder(-65505.0f)); + } + + [Fact] + public void TestRoundtripSize() + { + for (int i = 0; i < 8; i++) + { + JxlSizeHeader size = new(); + size.Set(123 + (77 * i), 7 + i); // It'll throw if it can't set + + int extensionBits = 999; + long totalBits = 999; + + Assert.True(JxlBundle.CanEncode(size, ref extensionBits, ref totalBits)); + Assert.Equal(0, extensionBits); + + using MemoryStream ms = new(); + JxlBitWriter writer = new(ms); + + Assert.True(JxlBundle.WriteSizeHeader(size, writer, JxlLayerType.Header, new JxlAuxiliaryOutput())); + Assert.Equal(totalBits, writer.BitsWritten); + + writer.ZeroPadToByte(); + + JxlSizeHeader size2 = new(); + + ms.Position = 0; + JxlBitReader reader = new(ms); + + Assert.True(JxlBundle.Read(reader, size2)); + Assert.Equal(totalBits, reader.TotalBitsConsumed); + + Assert.Equal(size.XSize, size2.XSize); + Assert.Equal(size.YSize, size2.YSize); + } + } + + [Fact] + public void TestCropRect() + { + JxlCodecMetadata metadata = new(); + + for (int i = -999; i < 19000; i++) + { + JxlFrameHeader f = new() + { + Metadata = metadata, + CustomSizeOrOrigin = true, + FrameOrigin = new Point(i), + FrameSize = new(1000 + i) + }; + + int extensionBits = 0; + long totalBits = 0; + + Assert.True(JxlBundle.CanEncode(f, ref extensionBits, ref totalBits)); + Assert.Equal(0, extensionBits); + Assert.True(totalBits >= 9); + } + } + + [Fact] + public void TestPreview() + { + for (int i = 1; i < 4360; i++) + { + JxlPreviewHeader p = new(); + p.Set(i, i); // Throws if it fails + + int extensionBits = 0; + long totalBits = 0; + + Assert.True(JxlBundle.CanEncode(p, ref extensionBits, ref totalBits)); + Assert.Equal(0, extensionBits); + Assert.True(totalBits >= 6); + } + } + + [Fact] + public void TestRoundtripFrame() + { + JxlCodecMetadata metadata = new(); + + JxlFrameHeader h = new() + { + Metadata = metadata, + Extensions = 0x800 + }; + + int extensionBits = 999; + long totalBits = 999; + + Assert.True(JxlBundle.CanEncode(h, ref extensionBits, ref totalBits)); + Assert.Equal(0, extensionBits); + + using MemoryStream ms = new(); + JxlBitWriter writer = new(ms); + + Assert.True(JxlBundle.WriteFrameHeader(h, writer, null)); + Assert.Equal(totalBits, writer.BitsWritten); + + writer.ZeroPadToByte(); + + JxlFrameHeader h2 = new() + { + Metadata = metadata + }; + + ms.Position = 0; + JxlBitReader reader = new(ms); + + Assert.True(JxlBundle.Read(reader, h2)); + Assert.Equal(totalBits, reader.TotalBitsConsumed); + + Assert.Equal(h.Extensions, h2.Extensions); + Assert.Equal(h.Flags, h2.Flags); + } + + [Fact] + public void TestOutOfRange() + { + JxlSizeHeader h = new(); + h.Set(unchecked((int)0xFFFFFFFF), unchecked((int)0xFFFFFFFF)); + + int extensionBits = 999; + long totalBits = 999; + + Assert.False(JxlBundle.CanEncode(h, ref extensionBits, ref totalBits)); + } + + [Fact] + public void TestNewDecoderOldData() + { + OldBundle oldBundle = new() + { + OldLarge = 123, + OldF = 3.75f, + Extensions = 0 + }; + + const int maxOutBytes = 999; + + using MemoryStream ms = new(); + JxlBitWriter writer = new(ms); + + int extensionBits = 12345; + long totalBits = 12345; + + Assert.True(JxlBundle.CanEncode(oldBundle, ref extensionBits, ref totalBits)); + Assert.True(totalBits <= maxOutBytes * JxlMath.BitsPerByte); + Assert.Equal(0, extensionBits); + + JxlAuxiliaryOutput auxOut = new(); + Assert.True( + JxlBundle.Write( + oldBundle, + writer, + JxlLayerType.Header, + auxOut)); + + Assert.True(writer.WithMaxBits( + (ulong)((maxOutBytes * JxlMath.BitsPerByte) - totalBits), + () => + { + writer.Write(20, 0xA55A); + writer.ZeroPadToByte(); + return true; + })); + + ms.Position = 0; + JxlBitReader reader = new(ms); + NewBundle newBundle = new(); + + Assert.True(JxlBundle.Read(reader, newBundle)); + + Assert.Equal( + reader.TotalBitsConsumed, + auxOut.Layers[(int)JxlLayerType.Header].TotalBits); + + Assert.Equal(0xA55A, (int)reader.ReadBits32(20)); + + // Old fields are the same in both. + Assert.Equal(oldBundle.Extensions, newBundle.Extensions); + Assert.Equal(oldBundle.OldSmall, newBundle.OldSmall); + Assert.Equal(oldBundle.OldF, newBundle.OldF); + Assert.Equal(oldBundle.OldLarge, newBundle.OldLarge); + + // New fields match their defaults. + Assert.Equal(2u, newBundle.NewSmall); + Assert.Equal(-2.0f, newBundle.NewF); + Assert.Equal(0u, newBundle.NewLarge); + } + + [Fact] + public void TestOldDecoderNewData() + { + NewBundle newBundle = new() + { + OldLarge = 123, + Extensions = 3, + NewF = 999.0f, + NewLarge = 456 + }; + + const int maxOutBytes = 999; + + using MemoryStream ms = new(); + JxlBitWriter writer = new(ms); + + int extensionBits = 12345; + long totalBits = 12345; + + Assert.True(JxlBundle.CanEncode( + newBundle, + ref extensionBits, + ref totalBits)); + + Assert.NotEqual(0, extensionBits); + + JxlAuxiliaryOutput auxOut = new(); + Assert.True(JxlBundle.Write( + newBundle, + writer, + JxlLayerType.Header, + auxOut)); + + int headerBits = auxOut.Layers[(int)JxlLayerType.Header].TotalBits; + + Assert.True(headerBits <= maxOutBytes * JxlMath.BitsPerByte); + + Assert.True(writer.WithMaxBits( + (ulong)((maxOutBytes * JxlMath.BitsPerByte) - headerBits), + () => + { + writer.Write(20, 0xA55A); + writer.ZeroPadToByte(); + return true; + })); + + ms.Position = 0; + JxlBitReader reader = new(ms); + OldBundle oldBundle = new(); + + Assert.True(JxlBundle.Read(reader, oldBundle)); + + Assert.Equal( + reader.TotalBitsConsumed, + auxOut.Layers[(int)JxlLayerType.Header].TotalBits); + + Assert.Equal(0xA55A, (int)reader.ReadBits32(20)); + + // Old fields are the same in both. + Assert.Equal(newBundle.Extensions, oldBundle.Extensions); + Assert.Equal(newBundle.OldSmall, oldBundle.OldSmall); + Assert.Equal(newBundle.OldF, oldBundle.OldF); + Assert.Equal(newBundle.OldLarge, oldBundle.OldLarge); + } +}