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 @@
+
+