diff --git a/docs/adr/0005-image-filter-decoders.md b/docs/adr/0005-image-filter-decoders.md index 4894d73..9302299 100644 --- a/docs/adr/0005-image-filter-decoders.md +++ b/docs/adr/0005-image-filter-decoders.md @@ -152,6 +152,24 @@ negative) — the inverse of the magnitude-field convention an AC first scan use decodes every scan to the end and leaves a handful of coefficients one or two steps out, which is why the acceptance is a pixel comparison and not "it decoded". +## The JPX slice — stage 1 (2026-11-15) + +JPEG 2000 is the largest decoder this ADR scopes, so it lands in stages. The first stage is the container +and the main header: a JP2's boxes — the signature, and the `jp2c` box that holds the codestream — or a raw +J2K's SOC marker, then SIZ (the image and tile sizes and the components with their depth, sign and +subsampling) and COD (the progression, the layer count, the colour transform, the decomposition levels and +whether the transform is the reversible 5/3 or the irreversible 9/7). `JpxDecoder.TryReadHeader` states +those values and refuses a container without a codestream or a main header without SIZ or COD. + +The fixtures are OpenJPEG's, through Pillow: `jpx-5-3.jp2` and `jpx-9-7.jp2` for the two transforms and +`jpx-5-3.j2k` for the raw form, each 32 x 32 with three 8-bit components, five decomposition levels and no +colour transform. The expected bytes are Pillow's decode of the same files, kept for the pixel stage. + +What is **not** done is the part that makes pixels: the packet headers and the tag trees, the EBCOT +bit-plane passes with the MQ arithmetic decoder, the inverse 5/3 and 9/7 wavelets, and the reversible or +irreversible colour transform. That is the next stage, and the status table says so rather than claiming +the filter. + ## Status of the slices | Slice | State | @@ -159,5 +177,5 @@ acceptance is a pixel comparison and not "it decoded". | `RunLengthDecode` | **Implemented** (2026-11-14) | | `CCITTFaxDecode` | **Implemented** (2026-11-15) | | `DCTDecode` | **Baseline (SOF0/SOF1) and progressive (SOF2) implemented, fancy upsampling** (2026-11-15); `JPXDecode` next | -| `JPXDecode` | Planned | +| `JPXDecode` | **In progress** — the JP2 container and the codestream header are read (2026-11-15); the packets and the inverse wavelet are next | | `JBIG2Decode` | Planned | diff --git a/src/SharpCorePDF.Infrastructure/Pdf/JpxDecoder.cs b/src/SharpCorePDF.Infrastructure/Pdf/JpxDecoder.cs new file mode 100644 index 0000000..89b2ac9 --- /dev/null +++ b/src/SharpCorePDF.Infrastructure/Pdf/JpxDecoder.cs @@ -0,0 +1,357 @@ +namespace SharpCorePDF.Infrastructure.Pdf; + +/// +/// Reads the container and the codestream header of a JPEG 2000 image: a JP2 file or a raw J2K +/// codestream. +/// +/// +/// +/// The decoder is staged, per ADR-005. This half states the geometry, the components and the transform the +/// codestream uses — the values the coefficient decoder needs — and refuses a container or a main header +/// that does not carry them. The packets, the EBCOT bit-planes and the inverse wavelet transform are the +/// next half. +/// +/// +internal static class JpxDecoder +{ + private const int SignatureLength = 12; + + /// The geometry, the components and the transform a JPEG 2000 main header states. + internal sealed class Header + { + public int Width { get; init; } + + public int Height { get; init; } + + public int TileWidth { get; init; } + + public int TileHeight { get; init; } + + public Component[] Components { get; init; } = []; + + public int ProgressionOrder { get; init; } + + public int Layers { get; init; } + + public int ColourTransform { get; init; } + + public int DecompositionLevels { get; init; } + + public bool IsReversible { get; init; } + } + + /// One component's depth, sign and subsampling, as SIZ states them. + internal sealed class Component + { + public int BitDepth { get; init; } + + public bool Signed { get; init; } + + public int HorizontalSubsampling { get; init; } + + public int VerticalSubsampling { get; init; } + } + + /// Finds the codestream in a JP2 box or a raw J2K file and reads its main header. + public static bool TryReadHeader(ReadOnlySpan data, out Header? header) + { + header = null; + var offset = FindCodestream(data); + + return offset >= 0 && ParseMainHeader(data[offset..], out header); + } + + private static int FindCodestream(ReadOnlySpan data) + { + if (data.Length >= 2 && data[0] == 0xFF && data[1] == 0x4F) + { + return 0; + } + + if (data.Length < SignatureLength + || data[0] != 0 + || data[1] != 0 + || data[2] != 0 + || data[3] != SignatureLength + || data[4] != (byte)'j' + || data[5] != (byte)'P' + || data[6] != 0x20 + || data[7] != 0x20 + || data[8] != 0x0D + || data[9] != 0x0A + || data[10] != 0x87 + || data[11] != 0x0A) + { + return -1; + } + + var offset = SignatureLength; + + while (offset + 8 <= data.Length) + { + var length = ReadUInt32(data, offset); + var type = data.Slice(offset + 4, 4); + var payload = offset + 8; + + if (length == 1) + { + if (offset + 16 > data.Length) + { + return -1; + } + + var extended = ReadUInt64(data, offset + 8); + + if (extended < 16 || extended > (ulong)(data.Length - offset)) + { + return -1; + } + + length = (uint)extended; + payload = offset + 16; + } + else if (length == 0) + { + length = (uint)(data.Length - offset); + } + + if (length < 8 || length > data.Length - offset) + { + return -1; + } + + if (type[0] == (byte)'j' && type[1] == (byte)'p' && type[2] == (byte)'2' && type[3] == (byte)'c') + { + return payload; + } + + offset += (int)length; + } + + return -1; + } + + private static bool ParseMainHeader(ReadOnlySpan data, out Header? header) + { + header = null; + + if (data.Length < 4 || data[0] != 0xFF || data[1] != 0x4F) + { + return false; + } + + var offset = 2; + var size = default(Size); + var coding = default(Coding); + var haveSize = false; + var haveCoding = false; + + while (offset + 1 < data.Length) + { + if (data[offset] != 0xFF) + { + offset++; + continue; + } + + var marker = data[offset + 1]; + offset += 2; + + if (marker is 0x90 or 0x93 or 0xD9) + { + break; + } + + if (marker == 0x4F || marker is >= 0xD0 and <= 0xD7) + { + continue; + } + + if (offset + 2 > data.Length) + { + return false; + } + + var length = ReadUInt16(data, offset); + + if (length < 2 || offset + length > data.Length) + { + return false; + } + + var segment = data.Slice(offset + 2, length - 2); + + if (marker == 0x51) + { + if (!ParseSize(segment, out size)) + { + return false; + } + + haveSize = true; + } + else if (marker == 0x52) + { + if (!ParseCoding(segment, out coding)) + { + return false; + } + + haveCoding = true; + } + + offset += length; + } + + if (!haveSize || !haveCoding) + { + return false; + } + + header = new Header + { + Width = size.Width, + Height = size.Height, + TileWidth = size.TileWidth, + TileHeight = size.TileHeight, + Components = size.Components, + ProgressionOrder = coding.ProgressionOrder, + Layers = coding.Layers, + ColourTransform = coding.ColourTransform, + DecompositionLevels = coding.DecompositionLevels, + IsReversible = coding.IsReversible, + }; + + return true; + } + + private static bool ParseSize(ReadOnlySpan segment, out Size size) + { + size = default; + + if (segment.Length < 36) + { + return false; + } + + var xsiz = ReadUInt32(segment, 2); + var ysiz = ReadUInt32(segment, 6); + var xosiz = ReadUInt32(segment, 10); + var yosiz = ReadUInt32(segment, 14); + var xtsiz = ReadUInt32(segment, 18); + var ytsiz = ReadUInt32(segment, 22); + var csiz = ReadUInt16(segment, 34); + + if (xsiz <= xosiz + || ysiz <= yosiz + || xtsiz == 0 + || ytsiz == 0 + || csiz is < 1 or > 16384 + || segment.Length < 36 + (csiz * 3)) + { + return false; + } + + var components = new Component[csiz]; + + for (var index = 0; index < csiz; index++) + { + var depth = segment[36 + (index * 3)]; + var horizontal = segment[37 + (index * 3)]; + var vertical = segment[38 + (index * 3)]; + + if ((depth & 0x7F) + 1 > 38 || horizontal == 0 || vertical == 0) + { + return false; + } + + components[index] = new Component + { + BitDepth = (depth & 0x7F) + 1, + Signed = (depth & 0x80) != 0, + HorizontalSubsampling = horizontal, + VerticalSubsampling = vertical, + }; + } + + size = new Size + { + Width = (int)(xsiz - xosiz), + Height = (int)(ysiz - yosiz), + TileWidth = (int)xtsiz, + TileHeight = (int)ytsiz, + Components = components, + }; + + return true; + } + + private static bool ParseCoding(ReadOnlySpan segment, out Coding coding) + { + coding = default; + + if (segment.Length < 10) + { + return false; + } + + var transform = segment[9]; + + coding = new Coding + { + ProgressionOrder = segment[1], + Layers = ReadUInt16(segment, 2), + ColourTransform = segment[4], + DecompositionLevels = segment[5], + IsReversible = transform == 1, + }; + + return coding.Layers > 0 + && coding.ProgressionOrder <= 4 + && coding.ColourTransform <= 1 + && coding.DecompositionLevels <= 32 + && segment[6] is >= 2 and <= 10 + && segment[7] is >= 2 and <= 10 + && segment[6] + segment[7] <= 12 + && transform <= 1; + } + + private static int ReadUInt16(ReadOnlySpan data, int offset) => + (data[offset] << 8) | data[offset + 1]; + + private static uint ReadUInt32(ReadOnlySpan data, int offset) => + ((uint)data[offset] << 24) + | ((uint)data[offset + 1] << 16) + | ((uint)data[offset + 2] << 8) + | data[offset + 3]; + + private static ulong ReadUInt64(ReadOnlySpan data, int offset) => + ((ulong)ReadUInt32(data, offset) << 32) | ReadUInt32(data, offset + 4); + + private struct Size + { + public int Width { get; set; } + + public int Height { get; set; } + + public int TileWidth { get; set; } + + public int TileHeight { get; set; } + + public Component[] Components { get; set; } + } + + private struct Coding + { + public int ProgressionOrder { get; set; } + + public int Layers { get; set; } + + public int ColourTransform { get; set; } + + public int DecompositionLevels { get; set; } + + public bool IsReversible { get; set; } + } +} + diff --git a/tests/SharpCorePDF.Tests/Assets/jpx/jpx-5-3-expected.bin b/tests/SharpCorePDF.Tests/Assets/jpx/jpx-5-3-expected.bin new file mode 100644 index 0000000..3cbc780 Binary files /dev/null and b/tests/SharpCorePDF.Tests/Assets/jpx/jpx-5-3-expected.bin differ diff --git a/tests/SharpCorePDF.Tests/Assets/jpx/jpx-5-3.j2k b/tests/SharpCorePDF.Tests/Assets/jpx/jpx-5-3.j2k new file mode 100644 index 0000000..2b2ce10 Binary files /dev/null and b/tests/SharpCorePDF.Tests/Assets/jpx/jpx-5-3.j2k differ diff --git a/tests/SharpCorePDF.Tests/Assets/jpx/jpx-5-3.jp2 b/tests/SharpCorePDF.Tests/Assets/jpx/jpx-5-3.jp2 new file mode 100644 index 0000000..3b28336 Binary files /dev/null and b/tests/SharpCorePDF.Tests/Assets/jpx/jpx-5-3.jp2 differ diff --git a/tests/SharpCorePDF.Tests/Assets/jpx/jpx-9-7-expected.bin b/tests/SharpCorePDF.Tests/Assets/jpx/jpx-9-7-expected.bin new file mode 100644 index 0000000..29cc920 Binary files /dev/null and b/tests/SharpCorePDF.Tests/Assets/jpx/jpx-9-7-expected.bin differ diff --git a/tests/SharpCorePDF.Tests/Assets/jpx/jpx-9-7.jp2 b/tests/SharpCorePDF.Tests/Assets/jpx/jpx-9-7.jp2 new file mode 100644 index 0000000..499455f Binary files /dev/null and b/tests/SharpCorePDF.Tests/Assets/jpx/jpx-9-7.jp2 differ diff --git a/tests/SharpCorePDF.Tests/JpxDecoderTests.cs b/tests/SharpCorePDF.Tests/JpxDecoderTests.cs new file mode 100644 index 0000000..2320c31 --- /dev/null +++ b/tests/SharpCorePDF.Tests/JpxDecoderTests.cs @@ -0,0 +1,104 @@ +using SharpCorePDF.Infrastructure.Pdf; + +namespace SharpCorePDF.Tests; + +/// +/// Tests of the JPEG 2000 header reader against a JP2 and a raw codestream OpenJPEG wrote: the geometry, +/// the components and the transform the reader states must be the ones the codestream carries. +/// +public sealed class JpxDecoderTests +{ + private static readonly byte[] Reversible = File.ReadAllBytes(Fixture("jpx-5-3.jp2")); + private static readonly byte[] Irreversible = File.ReadAllBytes(Fixture("jpx-9-7.jp2")); + private static readonly byte[] Raw = File.ReadAllBytes(Fixture("jpx-5-3.j2k")); + + private static string Fixture(string name) => Path.Combine(TestAssets.Folder, "jpx", name); + + /// A JP2 built with the reversible 5/3 filter states its size, its components and its transform. + [Fact] + public void Reads_the_header_of_a_reversible_jp2() + { + Assert.True(JpxDecoder.TryReadHeader(Reversible, out var header)); + + Assert.NotNull(header); + Assert.Equal(32, header!.Width); + Assert.Equal(32, header.Height); + Assert.Equal(32, header.TileWidth); + Assert.Equal(32, header.TileHeight); + Assert.Equal(3, header.Components.Length); + Assert.All(header.Components, component => + { + Assert.Equal(8, component.BitDepth); + Assert.False(component.Signed); + Assert.Equal(1, component.HorizontalSubsampling); + Assert.Equal(1, component.VerticalSubsampling); + }); + Assert.Equal(0, header.ProgressionOrder); + Assert.Equal(1, header.Layers); + Assert.Equal(0, header.ColourTransform); + Assert.Equal(5, header.DecompositionLevels); + Assert.True(header.IsReversible); + } + + /// The same container with the irreversible 9/7 filter states the other transform. + [Fact] + public void Reads_the_header_of_an_irreversible_jp2() + { + Assert.True(JpxDecoder.TryReadHeader(Irreversible, out var header)); + + Assert.NotNull(header); + Assert.Equal(32, header!.Width); + Assert.Equal(3, header.Components.Length); + Assert.False(header.IsReversible); + } + + /// A raw J2K codestream has no boxes; its SOC marker is the start. + [Fact] + public void Reads_the_header_of_a_raw_codestream() + { + Assert.True(JpxDecoder.TryReadHeader(Raw, out var header)); + + Assert.NotNull(header); + Assert.Equal(32, header!.Width); + Assert.Equal(32, header.Height); + Assert.Equal(3, header.Components.Length); + Assert.True(header.IsReversible); + } + + /// A stream that is neither a JP2 nor a raw codestream is refused. + [Fact] + public void Refuses_a_stream_that_is_neither_jp2_nor_j2k() => + Assert.False(JpxDecoder.TryReadHeader(new byte[] { 0, 0, 0, 0 }, out _)); + + /// A JP2 whose signature is right and whose codestream is missing is refused. + [Fact] + public void Refuses_a_jp2_without_a_codestream() + { + var data = new byte[20]; + data[3] = 12; + data[4] = (byte)'j'; + data[5] = (byte)'P'; + data[6] = 0x20; + data[7] = 0x20; + data[8] = 0x0D; + data[9] = 0x0A; + data[10] = 0x87; + data[11] = 0x0A; + data[15] = 8; + data[16] = (byte)'f'; + data[17] = (byte)'t'; + data[18] = (byte)'y'; + data[19] = (byte)'p'; + + Assert.False(JpxDecoder.TryReadHeader(data, out _)); + } + + /// A codestream that stops before its coding style is refused rather than half read. + [Fact] + public void Refuses_a_codestream_without_its_coding_style() + { + var truncated = Raw.AsSpan(0, 40).ToArray(); + + Assert.False(JpxDecoder.TryReadHeader(truncated, out _)); + } +} diff --git a/tests/SharpCorePDF.Tests/SharpCorePDF.Tests.csproj b/tests/SharpCorePDF.Tests/SharpCorePDF.Tests.csproj index c1cae59..d24d94f 100644 --- a/tests/SharpCorePDF.Tests/SharpCorePDF.Tests.csproj +++ b/tests/SharpCorePDF.Tests/SharpCorePDF.Tests.csproj @@ -47,6 +47,8 @@ + +