From 476637d39a729f5be54885e5ca7add466a62ffdc Mon Sep 17 00:00:00 2001 From: MPCoreDeveloper Date: Sun, 15 Nov 2026 12:58:03 +0100 Subject: [PATCH] Read the JP2 container and codestream header --- docs/adr/0005-image-filter-decoders.md | 20 +- .../Pdf/JpxDecoder.cs | 357 ++++++++++++++++++ .../Assets/jpx/jpx-5-3-expected.bin | Bin 0 -> 3072 bytes .../SharpCorePDF.Tests/Assets/jpx/jpx-5-3.j2k | Bin 0 -> 312 bytes .../SharpCorePDF.Tests/Assets/jpx/jpx-5-3.jp2 | Bin 0 -> 397 bytes .../Assets/jpx/jpx-9-7-expected.bin | Bin 0 -> 3072 bytes .../SharpCorePDF.Tests/Assets/jpx/jpx-9-7.jp2 | Bin 0 -> 488 bytes tests/SharpCorePDF.Tests/JpxDecoderTests.cs | 104 +++++ .../SharpCorePDF.Tests.csproj | 2 + 9 files changed, 482 insertions(+), 1 deletion(-) create mode 100644 src/SharpCorePDF.Infrastructure/Pdf/JpxDecoder.cs create mode 100644 tests/SharpCorePDF.Tests/Assets/jpx/jpx-5-3-expected.bin create mode 100644 tests/SharpCorePDF.Tests/Assets/jpx/jpx-5-3.j2k create mode 100644 tests/SharpCorePDF.Tests/Assets/jpx/jpx-5-3.jp2 create mode 100644 tests/SharpCorePDF.Tests/Assets/jpx/jpx-9-7-expected.bin create mode 100644 tests/SharpCorePDF.Tests/Assets/jpx/jpx-9-7.jp2 create mode 100644 tests/SharpCorePDF.Tests/JpxDecoderTests.cs 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 0000000000000000000000000000000000000000..3cbc78068ffafa362e6161481bb7b3d4dfa3d7c8 GIT binary patch literal 3072 zcmWO55rZQ=0|4NG7X}Un4h9wm76ujq3xS0|Ay5bu0);>!PzV$Pg+L)t2owT^Kp{{F z6aou@g}_2!Vc=lkVBlcjgTb$V;IXHws=E5Gsp_`+)m2Sj{RUMFs~1#txOyd3Ppdap z0bad}3d-t3RY+GWQ(;@JT}6Dg2^EdiHdQQFyHfF?+K-i(s>58#r8-vie|23q^{=*W zy83rtx1fH3b%*L#T=%4YqcvdlJFlUrKV*$mz0x%{_1f0N)f->aP;X<+QoYMHFZF(? z#aJJvTF&)xsa4fH^i_I=IJU9E&h@m-*jhvd}+^D5FRqe03ed@p4w(Z)NzU@H!3fmsFZ@2}d zeWxvC?FVm>Xjihus$J`rn08}Z>e{VuS!j2$<*D80R+RQIv~p~ZQ>*6ow6wbFn!5Yl zbWi=I>$<*s1ziujH`D>#y^{{o?t^uRcPr6h*{xMabhj}bwcXZr%y+xc@!0KCCvtZv zogBL3*r};I&7EGlbJhQ@`?l#{+Wx7(_I(ffH`oKzzvCW~{)6_2^()?E(XVAsRKL+Z zHT~B1%=J6p^U&{OFH(QVy)6B4=+)Stre4qedFf3B+8VqxpliWv2cG&H004q_1Rw?< z1Rx4l3}6n{0uTu{3Q!HU1~3bD4sZ|l0f-0=3CIkN1*id>MxZBfo`G4wrGhUt>>Bvm z!oGuVJ$&l#5JCh$Fhm5dD8vk|IV1vZB%}&%HDm_vEaVREJrn^RB9sXpGgJkh2B=4P zo}iiGWr4OrU5#EF)VJuZL!d|R06p~&gb+q6f-psEh6qO+fv7}Vg_uS=gSbU|hlEFm zfMi6+gj7bSg7kpSBQg`Z%*Zb2TH)6k_YHn)G3fAnk0HPx5I^-5#+cwW#f0Gv$5i00 z#7yCx#@yh&#lqo($1>n!#46!a#=7A1fX#?66SgzHF8EonugP0OKug{`0(sKxuSbbg`o#arK3krRiLLxb)x4?O`(^8 z+L2x->SlUdsIM5P*?Yra%RV}WdbR=#L$*eYV74Lbsc#u$ob3eTlI;}}njH+2mK_~a zo}B{Ik)0DWnOzFA1G|pQP3$%^zp#77-)jyV{?T&O@m0?;;A_YU;v38<;akd|`i^rh z_+E0M_(5}N_|bCZ_{nn}_&IWu_$6~&_;uiJ+-DIM@u=iS zEm0#^t;C&N_mTj)fs!J*#gY-ZqmnbZ=klo^q?Ae?wNyr)tkh1Py);2yqO?g~vvft? z2I)t6pJbTjW0A2^s8*{+;a06XMS8UXibAzTiea@Qic_^`N^o@$>Zu=GY=86zDC~4Cx)#oajB(g6RX- zQs|@9PyM8|Hu`L}ar)x54f-0jOZt|zFZw>{FzUyoy`&BYn(%{3So%`F+9&Apf~n8#@1WIkt;7V}lv zb!|ywH?5_e-S(CNyMvY^yT?`#d!SY_d*oILdy-Zwd)D@;U#zvxUcGg}-lFx%-m?wG zJ_Z{{`#IS(+popu%8}Y_8b@2V?Hudf4mb|o9yx*CfjCLskvYZPNjNRtSvjNK#ki+_ zby<^_O$Wa*0avdl zaj%6xOK+6FXm5?bTL08<-uvM1(TC(8*~j8P2cJg&HTgXI@5PrY&^m0Jz}m3u0`J2f z1OdYV1&PCv1VzJ%14 zHY{Iwwjy6;b}HX=_9owL4lX}@jv;@>{M5g4&Lw{jxs3T|%EO%hEP1R7ugktEqAiE6 z$i5swQLvm)(YTyRF|=G*vAkSGakAW0@w(hi3AQ|3iN1V>l4AKv<*9#{Qik#~mSHM? z<}xnjZ#C@eK{Ugm9b`8g`$2)>1P6_VGad{XE_ASLxbne?;U)*KhPxhu86I|sZus;= z3d2_%avHw#@YH{XVHk%$(=g7%-({GpQPkt18D%>jyHWMy35*(!XEYi-UdU+acx9vG z<4ufSj(0T%JwD7B?fB`&X7J>4GOqrYoHsn{IsaV!F#IsOh1nXr@m)B{zNfDTnDhP9;r0c^b;}XPBP) z-)WlW>EANV)hz4z*vzV(Pu;Bh`3z literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..2b2ce10ec5fb2b2264e35b3c2cfd083b79f7348c GIT binary patch literal 312 zcmezG|38pHp8*6Epcupk0R<=qu^AYc*%=uj=zkCc4^V)Sft7`Yf$@I~gRp~xherT1 z`k%s}%E0Jcl$uzQnxc?Yso-Cbn&%bZ>aI|hT2!2wpQm7?XR2rNe*yy+*zlb|b0#0Z z!or~sC*;o`;9!Yg{(yxg{`f6c`3wP;co2(?ftQJO@mppFZrhf_ubA7fu<CfL0EyLu_y<3?Gng%$xh{Ee{DPl{8*G=EL<`&r zKk$>;;U_ahVm?^x_JN;F4e`s5cWhr!)95gFjiw{}ff}U)HOdSi!TCS|hyV5eZvp@U Cy=3(O literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..3b283368ca3cd1ba4a2e6680f2ce7a743d970df8 GIT binary patch literal 397 zcmZQzVBpCLP*C9IYUg5LU=T?wsVvAUFj4@r8KAT-kj?;d#WFKeih#5N7&Ei818D{Z z{^b0eB1SM%fPsP00jN0nzyJS027R!Y0+0k@7*GH*Ko|m;*%=uj=zkCc4^WJeft7`Y zf$@I~gRp~xherT1`k%s}%E0Jcl$uzQnxc?Yso-Cbn&%bZ>aI|hT2!2wpQm7?XR2rN ze*y!@CkzZbf#ys;euafYA5O@hKfu8fzx)9UOZ@R$tnwKGEb$-~8v`#B>*BY}4BWOY zhhH(bUt!~A66Ch+I{b>2K}m`uwtwMQrUvHvyMgl$er|1k&H)mu2k{SnZf7uCICEX{ z;`jwW4>#B@Gl>?s6Mo<)v%^njh{Sxb*zE&9nHu7kAMe<{pr+Ab?ix);_5(Fa2WpfV PK!Wpu0uKM{|K9`vH^y=S literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..29cc9209edfbece0ba97af29fb0c5c2a3860a9c4 GIT binary patch literal 3072 zcmWN}p^G%T0RZq36(gP)@j(aP2OV)@#EFO#5hnsYXrKoTbfJN_(1;Zg3ol}!DP57m ziii~vDM68H75pg2oM8t^^Utz>2rtqL6Dn@pHf55M*s=E4BQ`K$tLsvC@ z^#xSFhSiU#`VFogNY&HokyU_KPojdddR7(E)o)GpJ6rwURe$i+A4B!0SpAf$Kj&&w zDn3-(vHE$cc60TYrTSY{|EjL*rv9O=o38%S*Da{O!n#BC1Fm~gKhheo`ia+2)X%a; zs(#TmHubBmiK|z>rlDTPnx%S^YhLQ@P>ZqNP4!>rdcV}FYVP|FP1Cf^m#%61<|}AA z*gT-7$IT;Y0BxRF19|f-8YG(+)nMJcnugeB@HmTvc*_K8O&2DVu)a>U* zEzO~7f2iC0{-tf(uKn7#9cUk5+oSdow}7-yw1upF<}DKKi)^uKUv*1NyRt2H?b^32 zw42!S)NXStO1m3cIkx+${rlV=mR46?Q+HpQ?!JHRx~}gYK-a_W5p@7}Po#siduAQt z-HYh3>|Rwzbhk1cwcXlv%y*m6@z`xsCvvwdogBLT*r};I%$;7kW7U7D`?l%7w*7s7 z==&b@kFW=*f5JT^{WI+m>tA?}MgJ;$qWYEYsp;3YXRhD)o`-%Ldy)EG?q%usL$AjE zF!g%wk4tYV@ar0UZ9vz8hYsBLM*si>PY6H^o(Vt{yfA<{col$1uu_0(ur`2MuyKHU zunj;&uuDK@urELj;4lI`f#VF!0!|hDv4&j(A6nRV@Ue&Y{Ru*d;4_AZz!wTJgRdMC z0ap@I1=kuf12-0O2e%%I0Cy3}1os)L0uKY!BRo#f%Ve~=}rs$O+!qG|~D$!aYrqRYAZqe2u;n6N28PPr=mC>OfJ)q-=%!E!evI{y_ z_@TyqgCAQAI{egQ2=Ft+_x%N9Oz_m^kg51isX=p&g58#8OUiQb|UAQxP@FQ`dCxY(5IHdjz0Gk0r~rJ3A&M#E4tNG7`n4mI=c5%1$u~7Cwk1(6nYw{9qDe&lm7_wKy2xcq7?)#cC#@R+NF4p8~)sK)bW>|W58b_Cy1{wr-ZL5zwaB)x!_yLh2lHSrQv(amE#A`b>PRy zP2#7_ZQ2q|_- zNGnRBf3OT z(j1hz??+>dH794RH|JnnG?!$2HrHapU~Z#{lX;s>TFiT8e_vbD*i~z3XV<-Dz;2-B z$ZoL}#O|n-%2jXzs| z-!I<#;IGk#;@<|JM*lwfJo}Humn!_J4(lecHf*}U`>+K;z_3F>;;<({(Qsfv z^KcZw$Z%4@>Tou}*>G{;zF$KKG2Bv!IlPsShVVXyJcW-rlqGyt@uxbnCT`luySVM6 z0C5MSL~)O!BJn_@X7R|Q5%DCWRq?E&Gx1`hck$}ueZR#RQ+&%YmH0lyJjRbHmN|Yd zaj4Rt>$GVSZ_~C*qE9=JBusmh6ix?{G)+gA3{NMKtW0N>oK6>$yiHe^f={=Q?)zIx zDW~_6@{m5pRHpPfr(sFos=TQ)Z}PUyqRYEJOOW?4E0hm7Ym$#N8mONI4*Jaxj(Ux6TWMB56C|C}tXk3n@ z7+OxOSYFPeI9V>LcwMfh1Y2&dL|@)QNwK`Aa^F8nDMR@j%P^IH&t+W7_iEVIgJ_0b zJIHR>_k#k%0S+1sM?4rZoakWLaOQ&(!$l5W4Ocw`Gu-SD-SFmz6o&UWGTga{gY4s64O;qK}|P3MKitGDY@z0PdQ8u?U*a;noUw&NaaN@Lean|b>$a5yl bU85OUVcoIV#Gv?qtQf-!h&))-{{Kw?Nx7Eb literal 0 HcmV?d00001 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 @@ + +